From 1516c6018d1dae32715f950cda4225ff41c8a626 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 2 Sep 2026 14:53:45 +0800 Subject: [PATCH 01/79] docs: propose logging each request's image projection --- ...-02-log-image-request-projection.i18n.yaml | 6 ++ ...2026-09-02-log-image-request-projection.md | 62 +++++++++++++++++++ ...6-09-02-log-image-request-projection.zh.md | 62 +++++++++++++++++++ 3 files changed, 130 insertions(+) create mode 100644 .agents/notes/proposed/architecture/2026-09-02-log-image-request-projection.i18n.yaml create mode 100644 .agents/notes/proposed/architecture/2026-09-02-log-image-request-projection.md create mode 100644 .agents/notes/proposed/architecture/2026-09-02-log-image-request-projection.zh.md diff --git a/.agents/notes/proposed/architecture/2026-09-02-log-image-request-projection.i18n.yaml b/.agents/notes/proposed/architecture/2026-09-02-log-image-request-projection.i18n.yaml new file mode 100644 index 0000000000..32d160182b --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-09-02-log-image-request-projection.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-09-02-log-image-request-projection.md +2026-09-02-log-image-request-projection.md: d1acc5007875f1f0178498d9e6d934e1d5c1843d +2026-09-02-log-image-request-projection.zh.md: 041f63570453325970cd29407f09d17068512f3d diff --git a/.agents/notes/proposed/architecture/2026-09-02-log-image-request-projection.md b/.agents/notes/proposed/architecture/2026-09-02-log-image-request-projection.md new file mode 100644 index 0000000000..d1acc50078 --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-09-02-log-image-request-projection.md @@ -0,0 +1,62 @@ +# Agent Note: Record each request's image projection in the session log + +Status: proposed + +English | [中文](2026-09-02-log-image-request-projection.zh.md) + +## Problem + +The repository invariant says model-visible ⟺ logged: anything that reaches a model request must be reconstructable from the session log. Request-size image offload currently breaks the reconstruction half of that invariant for historical requests. + +Offload itself is deliberately transient: the durable surface keeps every original image reference, and each request replaces an oldest-first prefix of image occurrences with placeholder text at serialization time, per the [unified image request pipeline](../../implemented/feature/2026-08-20-unified-image-request-pipeline.md). The projection of a *future* request is a function of durable history plus live route configuration, and that half is healthy. + +Reconstructing what a *past* request actually contained is not. Four decision inputs never enter the log: + +1. **The representation actually dispatched.** A Files resolution failure rebuilds the request inline under the tighter `maxInlineRequestImageBytes` budget and its own removal quantum, offloading more images than file mode would, per the [Files inline fallback](../../implemented/bug-fix/2026-08-21-deepseek-files-inline-fallback.md). Which path ran is a per-request network outcome with no trace in the log. +2. **Exact derived request-version bytes.** The second projection stage uses the encoded byte length of each derived request version, which depends on the encoder pipeline, not on any logged fact; the log holds only the normalized attachment's byte count. +3. **Execution-world access paths.** `offloadedImageText` and `requestImageHandleText` embed the read-only path resolved for the current tool execution world into model-visible text. Resume or fork in another environment reconstructs different text than the request carried. +4. **Route budgets and quanta.** `maxRequestFilesBytes`, `maxImagesPerRequest`, both removal quanta, the inline watermark, and the per-model pixel/byte policy live only in composed connection configuration. The `request/header` snapshot records call config, system prompt, and tools; a configuration change silently changes every reprojection of old history. + +Provider usage anchors token totals only and cannot recover the image set. The token meter's [route-priced estimate](../../implemented/feature/2026-08-24-route-priced-image-request-pressure.md) reproduces just the first projection stage and documents that the fallback budget is not reproduced and that access paths resolve at pricing time. So today no consumer — debugging, accounting reconciliation, or replay tooling — can pair a logged assistant response with the exact model-visible input that produced it. + +## Proposal + +Keep offload a transient projection and record its outcome: append one log-only event describing the image projection of each dispatched request whose outcome the log records. + +**Event.** A new `SessionEventMap` member (working name `request/images`) carrying `{ turn, step }` plus the projection facts, appended within its step beside `request/header`. Like `request/header` it is log-only: derived message history and the durable surface are unchanged, and future requests still reproject from durable history and live configuration. + +**Payload.** The representation dispatched (`file`, `inline`, or `text-only`) and, per image occurrence in request order: a retained occurrence's request-version width, height, and encoded byte count together with its model-visible handle text, or an offloaded occurrence's model-visible placeholder text. Substitution text is recorded verbatim so reconstruction does not depend on the stability of the text-building functions or on any execution environment. The vocabulary stays in `dsh-llm` terms so the pi-ai and DeepSeek adapters share one schema. + +**Reporting channel.** The adapter that serialized the request owns these facts and reports them with the generation outcome, the same way `usage` travels to `assistant/message` today; the loop appends the event. The exact `LlmAdapter` result field is implementation detail for the follow-up PR. + +**Attempt rule.** One event per dispatched request whose outcome enters the log. A pre-dispatch re-serialization (the file-to-inline fallback) records only the projection actually dispatched; the once-permitted stale-file retry records the retried dispatch, whose model-visible image set is unchanged because the fallback reuses the already derived request versions. Whether dispatches that end in a terminal provider failure also record is an open review question; the default is yes, so a failed request's content is as reconstructable as a successful one's. + +**Format impact.** The event carries `ignorable: true` under the [session log version mechanism](../../implemented/architecture/2026-08-10-session-log-version-mechanism.md): a build that does not know the type still derives the surface correctly, and `SESSION_FORMAT_VERSION` stays unchanged because no structural format changes. + +**Token accounting.** Unchanged. Provider usage remains the anchor for completed requests and the route-priced projection remains a synchronous estimate; the recorded event additionally enables exact post-hoc reconciliation of any historical request. + +**Out of scope.** Copying budgets into `request/header` is unnecessary once outcomes are recorded and would change header equality semantics. Permanent image eviction — durably replacing old image occurrences on the message surface — remains a separate future decision requiring its own surface-replacement event; this event never changes the surface. + +## Alternatives considered + +**Pure derivation, with budgets logged into `request/header`.** Fixes only input 4. The dispatched representation and the exact derived byte lengths are runtime results no amount of logged configuration makes derivable, and access paths would still leak the environment into model-visible text. Making pure derivation true would require deleting the inline fallback and the exact second projection stage, trading away request deliverability for reconstructability. + +**Log the full projected request body per request.** Complete but redundant: everything except the projection outcome is already derivable, and repeating the whole history per request grows the log quadratically. The outcome facts are the only new information. + +**Permanently evict offloaded images from the durable surface.** Different semantics, not a cheaper encoding of the same one: it changes every future request, forfeits the property that images return when budgets rise or routes change, and conflates request-level omission with surface mutation — exactly what the issue requires keeping separate. + +**Accept approximate reconstruction.** Leaves a model-visible divergence with zero trace: two replays of one log can disagree with what the model actually saw, and the fallback path makes the disagreement unbounded. This narrows the invariant instead of honoring it. + +## Acceptance criteria + +- From the log alone, every recorded model outcome in an image-bearing session pairs with the exact ordered image set — identity, request dimensions, encoded bytes — and the verbatim substitution texts its request carried, across file-mode requests, inline fallback, configuration changes, resume, fork, retry, and compaction. +- Replaying a log containing the event derives an unchanged message surface, and a build without the event type still accepts the log. +- Unit tests pin the event's append timing, attempt rule, and payload; keyless recorded-session snapshots cover offload in file mode and through the inline fallback; recovery tests cover resume and fork; both SDK expected outputs update if their projections surface the event. +- Review of this note files the follow-up implementation work items. + +## Risks + +- The payload vocabulary must stay provider-neutral; a schema that encodes DeepSeek transport concepts will strain the next adapter. +- Verbatim substitution text grows the log for every image-bearing request; the strings are short and per-occurrence, but image-dense sessions pay it on every step. +- Recording only the dispatched attempt hides earlier rejected dispatches; if those ever matter, the attempt rule needs revisiting rather than silent extension. +- The adapter-to-loop reporting channel is new surface on generation results; without a stated scope it invites other transient request facts into the log. This proposal covers image projection facts only. diff --git a/.agents/notes/proposed/architecture/2026-09-02-log-image-request-projection.zh.md b/.agents/notes/proposed/architecture/2026-09-02-log-image-request-projection.zh.md new file mode 100644 index 0000000000..041f635704 --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-09-02-log-image-request-projection.zh.md @@ -0,0 +1,62 @@ +# Agent Note: 在 session log 中记录每次请求的图片投影 + +Status: proposed + +[English](2026-09-02-log-image-request-projection.md) | 中文 + +## 问题 + +仓库不变量要求模型可见 ⟺ 已记录:凡是进入模型请求的内容都必须能从 session log 重建。请求级图片 offload 目前在历史请求这一半上破坏了该不变量。 + +offload 本身被刻意设计为临时投影:持久表层保留全部原始图片引用,每次请求在序列化时把最老的一段图片出现位置替换为占位文本,见[统一图片请求管线](../../implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md)。未来请求的投影是持久历史加当前路由配置的函数,这一半是健康的。 + +重建某次过去请求实际包含的内容则做不到。有四个决定输入从不进入日志: + +1. **实际发出的表示方式。** Files 解析失败会用更紧的 `maxInlineRequestImageBytes` 预算和独立的删除量子以内联方式重建请求,比 file 模式 offload 掉更多图片,见 [Files 内联回退](../../implemented/bug-fix/2026-08-21-deepseek-files-inline-fallback.zh.md)。走了哪条路是当次网络结果,日志里没有任何痕迹。 +2. **派生请求版本的精确字节数。** 第二轮投影使用每个派生请求版本的编码字节长度,它取决于编码管线而非任何已记录事实,日志里只有归一化附件的字节数。 +3. **执行世界访问路径。** `offloadedImageText` 和 `requestImageHandleText` 会把为当前工具执行世界解析出的只读路径写进模型可见文本。在另一个环境 resume 或 fork 时,重建出的文本和请求当时携带的不同。 +4. **路由预算与量子。** `maxRequestFilesBytes`、`maxImagesPerRequest`、两个删除量子、内联水位以及按模型的像素和字节策略只存在于组合出的连接配置中。`request/header` 快照记录调用配置、系统提示词和工具,配置一变,对旧历史的每次重投影都会悄悄改变。 + +Provider usage 只锚定 token 总量,恢复不了图片集合。token meter 的[按路由定价估计](../../implemented/feature/2026-08-24-route-priced-image-request-pressure.zh.md)只复现第一轮投影,并自己写明不复现回退预算、访问路径在定价时才解析。所以今天没有任何消费方(调试、计费对账或重放工具)能把一条已记录的助手响应与产生它的精确模型可见输入配对。 + +## 提案 + +保持 offload 为临时投影,同时记录它的结果:为每个结果进入日志的已发出请求追加一条 log-only 事件,描述该请求的图片投影。 + +**事件。** 新增一个 `SessionEventMap` 成员(工作名 `request/images`),携带 `{ turn, step }` 和投影事实,在所属 step 内与 `request/header` 一样追加。它和 `request/header` 同为 log-only:派生消息历史与持久表层不变,未来请求仍从持久历史和当前配置重投影。 + +**载荷。** 发出的表示方式(`file`、`inline` 或 `text-only`),以及按请求顺序的每个图片出现位置:保留的记请求版本宽、高、编码字节数和它的模型可见句柄文本,被 offload 的记它的模型可见占位文本。替换文本按原样记录,重建因此不依赖文本构造函数的稳定性,也不依赖任何执行环境。词汇保持在 `dsh-llm` 层面,让 pi-ai 和 DeepSeek 两个 adapter 共用一个 schema。 + +**回报通道。** 序列化请求的 adapter 拥有这些事实,随生成结果一起回报,方式与今天 `usage` 传到 `assistant/message` 相同,由循环追加事件。具体落在 `LlmAdapter` 结果的哪个字段是后续实现 PR 的细节。 + +**attempt 规则。** 结果进入日志的每次已发出请求记一条事件。发出前的重新序列化(file 到内联的回退)只记录实际发出的那份投影;被允许一次的过期 fileId 重试记录重试后的那次发出,因回退复用已派生的请求版本,其模型可见图片集合不变。以终局 provider 失败收场的发出是否也记录留作评审的开放问题,默认记录,让失败请求的内容和成功请求同样可重建。 + +**格式影响。** 事件按[session log 版本机制](../../implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md)携带 `ignorable: true`:不认识该类型的构建仍能正确派生表层,且没有结构性格式变化,`SESSION_FORMAT_VERSION` 保持不变。 + +**token 记账。** 不变。provider usage 仍是已完成请求的锚点,按路由定价的投影仍是同步估计;记录的事件额外让任何历史请求可以事后精确对账。 + +**范围之外。** 把预算复制进 `request/header` 在结果被记录后已无必要,且会改变 header 相等性语义。永久淘汰图片(在消息表层上持久替换旧图片出现位置)仍是需要独立表层替换事件的未来决定,本事件永不改变表层。 + +## 考虑过的替代方案 + +**纯派生,并把预算记入 `request/header`。** 只修复输入 4。发出的表示方式和精确派生字节数是运行时结果,记录再多配置也推不出来,访问路径也仍会把环境泄漏进模型可见文本。要让纯派生成立就得删掉内联回退和精确的第二轮投影,用请求的可发出性换可重建性。 + +**每次请求记录完整投影后的请求体。** 完整但冗余:除投影结果外一切都已可派生,每次请求重复整段历史让日志平方级增长。结果事实才是唯一的新信息。 + +**从持久表层永久淘汰被 offload 的图片。** 这是不同的语义,不是同一语义的更省编码:它改变每个未来请求,放弃预算上调或路由切换后图片回归的性质,并把请求级省略与表层修改混为一谈,恰是 issue 要求分开的两件事。 + +**接受近似重建。** 留下一处零痕迹的模型可见分歧:对同一份日志的两次重放可以和模型实际所见不一致,回退路径让这种不一致没有上界。这是收窄不变量,不是履行它。 + +## 验收标准 + +- 仅凭日志,含图会话中每条已记录的模型结果都能与其请求携带的精确有序图片集合(身份、请求尺寸、编码字节数)及逐字替换文本配对,覆盖 file 模式请求、内联回退、配置变化、resume、fork、retry 和 compaction。 +- 重放含该事件的日志派生出不变的消息表层,不认识该事件类型的构建仍接受这份日志。 +- 单元测试钉住事件的追加时机、attempt 规则和载荷;keyless 录制会话快照覆盖 file 模式与内联回退下的 offload;恢复测试覆盖 resume 和 fork;若 SDK 投影呈现该事件,两个 SDK 的期望输出同步更新。 +- 本 note 的评审建立后续实现工作项。 + +## 风险 + +- 载荷词汇必须保持 provider 中立,编入 DeepSeek 传输概念的 schema 会让下一个 adapter 难以复用。 +- 逐字替换文本让每个含图请求的日志变大;字符串短且按出现位置计,但图片密集的会话每个 step 都要付这笔开销。 +- 只记录实际发出的那次 attempt 会隐藏更早被拒绝的发出;若这些将来变得重要,应重新审视 attempt 规则而不是悄悄扩展。 +- adapter 到循环的回报通道是生成结果上的新表面,不声明范围就会诱使其他临时请求事实进入日志。本提案只覆盖图片投影事实。 From c1b1cd2984f673f2946168f4319d050a9dcd195e Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 3 Sep 2026 14:40:24 +0800 Subject: [PATCH 02/79] docs: propose a durable image offload watermark --- ...6-09-02-image-offload-watermark.i18n.yaml} | 6 +- .../2026-09-02-image-offload-watermark.md | 60 ++++++++++++++++++ .../2026-09-02-image-offload-watermark.zh.md | 60 ++++++++++++++++++ ...2026-09-02-log-image-request-projection.md | 62 ------------------- ...6-09-02-log-image-request-projection.zh.md | 62 ------------------- 5 files changed, 123 insertions(+), 127 deletions(-) rename .agents/notes/proposed/architecture/{2026-09-02-log-image-request-projection.i18n.yaml => 2026-09-02-image-offload-watermark.i18n.yaml} (56%) create mode 100644 .agents/notes/proposed/architecture/2026-09-02-image-offload-watermark.md create mode 100644 .agents/notes/proposed/architecture/2026-09-02-image-offload-watermark.zh.md delete mode 100644 .agents/notes/proposed/architecture/2026-09-02-log-image-request-projection.md delete mode 100644 .agents/notes/proposed/architecture/2026-09-02-log-image-request-projection.zh.md diff --git a/.agents/notes/proposed/architecture/2026-09-02-log-image-request-projection.i18n.yaml b/.agents/notes/proposed/architecture/2026-09-02-image-offload-watermark.i18n.yaml similarity index 56% rename from .agents/notes/proposed/architecture/2026-09-02-log-image-request-projection.i18n.yaml rename to .agents/notes/proposed/architecture/2026-09-02-image-offload-watermark.i18n.yaml index 32d160182b..caa127ec4e 100644 --- a/.agents/notes/proposed/architecture/2026-09-02-log-image-request-projection.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-09-02-image-offload-watermark.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-09-02-log-image-request-projection.md -2026-09-02-log-image-request-projection.md: d1acc5007875f1f0178498d9e6d934e1d5c1843d -2026-09-02-log-image-request-projection.zh.md: 041f63570453325970cd29407f09d17068512f3d +# pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-09-02-image-offload-watermark.md +2026-09-02-image-offload-watermark.md: 61864bb8fd81af0ac0e3668b0c0ebc872ac1c031 +2026-09-02-image-offload-watermark.zh.md: 7fb9503ad8de8e02f358ada598bbb54f2a493445 diff --git a/.agents/notes/proposed/architecture/2026-09-02-image-offload-watermark.md b/.agents/notes/proposed/architecture/2026-09-02-image-offload-watermark.md new file mode 100644 index 0000000000..61864bb8fd --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-09-02-image-offload-watermark.md @@ -0,0 +1,60 @@ +# Agent Note: Durable image offload watermark + +Status: proposed + +English | [中文](2026-09-02-image-offload-watermark.zh.md) + +## Problem + +Request-size image offload is recomputed from scratch on every request. Each route collects every image occurrence on the derived surface, oldest first, and once the accumulated bytes exceed its budget it rounds the excess up to a whole removal quantum and replaces that many oldest occurrences with placeholder text, per the [unified image request pipeline](../../implemented/feature/2026-08-20-unified-image-request-pipeline.md). Under the DeepSeek defaults of a 128 MiB budget and a 64 MiB quantum, 129 one-mebibyte images offload the oldest 65, and that prefix holds until history passes 192 MiB. Nothing remembers where the previous request stopped; the prefix is stable only because the arithmetic over an append-only history repeats itself. + +That stability fails wherever the arithmetic inputs move. The [Files inline fallback](../../implemented/bug-fix/2026-08-21-deepseek-files-inline-fallback.md) rebuilds a request under a 20 MiB inline budget with a 10 MiB quantum, offloading far more images for that one request, and the next request in file mode brings them back. The pi-ai route uses a quantum of one byte, so its prefix moves on almost every request. Compaction lowers the total and returns previously offloaded images. A route switch moves every step boundary. Each move changes the model-visible prefix and invalidates the provider cache prefix. + +The same recomputation breaks the repository invariant that model-visible input is reconstructable from the session log. Which representation was dispatched, the exact derived request-version byte lengths, and the route budgets and quanta are runtime or configuration facts that never enter the log; `request/header` records call config, system prompt, and tools only. Provider usage anchors token totals and cannot recover the image set, and the [route-priced estimate](../../implemented/feature/2026-08-24-route-priced-image-request-pressure.md) documents that it does not reproduce the fallback budget. No consumer can pair a logged assistant response with the image set its request carried. + +## Proposal + +Make the offload point a durable session fact: an image offload watermark recorded as a surface-changing session event that only advances. + +**Event.** A new required-on-read `SessionEventMap` member (working name `image/offload`) carrying `{ turn, step }` and the watermark position. It is a surface event in the same sense as the compaction events: derived history marks every image occurrence positioned before the watermark as offloaded, and every route projects a marked occurrence as its `offloadedImageText` placeholder. Occurrences at or after the watermark are retained. Adapters keep their existing placeholder rendering; the only change is that the offloaded set comes from the surface rather than from per-request arithmetic. + +**Only advances.** A route whose budget is exceeded advances the watermark; a route with headroom leaves it alone. The watermark never retreats when a budget grows, a route changes, or compaction lowers the total, so the model-visible prefix and the provider cache prefix move only forward. Advancement keeps the existing count and byte quanta, so one advance still jumps to the next removal boundary rather than to the minimum that fits. + +**Position by occurrence.** The watermark names the first retained image occurrence by the sequence number of the event that carries it and the block path inside that event's content, including nested tool-result content. It does not use attachment ids, which are content digests that repeat when one image is attached twice, and it does not count occurrences, which compaction pruning would shift. + +**Appended before dispatch.** Like `request/header`, the event is appended inside its step before the request is sent, so the projection of every dispatched request is fully determined by the log at dispatch time. When the file-to-inline fallback needs a further advance, the adapter reports the requirement before the inline dispatch and the loop appends a second advance in the same step; the fallback still never sends an unlogged projection. + +**Never ignorable.** The event changes the derived surface, so a build that does not know the type must refuse the log rather than replay it with images the model never saw. `SESSION_FORMAT_VERSION` stays unchanged because the log structure does not change. + +**Decision owned by the loop.** `LlmRuntime` computes the watermark from budgets the route declares — representation, byte and count budgets, quanta — and appends the event; adapters declare budgets and report fallback requirements but do not decide the offloaded set. This makes the route-capability metadata seam that #2644 deferred a prerequisite of the implementation. + +**Consequences for other consumers.** The token meter prices the exact offloaded set instead of reproducing first-stage arithmetic. Resume, fork, and replay reproduce the surface from the log. Compaction that prunes messages below the watermark leaves it valid because the position names a retained occurrence. Text-only routes keep their separate whole-history substitution and do not touch the watermark. + +**Out of scope.** The execution-world access path embedded in placeholder and handle text is still resolved at serialization time; that gap exists for retained images too and belongs to a separate decision about recording the execution-world mapping. Recording per-request projection outcomes as a log-only event is unnecessary once the surface owns the offloaded set. + +## Alternatives considered + +**Keep recomputing the offload point per request (status quo).** Stable only while the arithmetic inputs hold still; the inline fallback, the pi-ai quantum, compaction, and route switches all move the prefix, and no consumer can reconstruct a historical request's image set. This is the state the issue asks to resolve. + +**Record each request's projection outcome as a log-only event.** Restores reconstructability but not stability: the recorded outcome is not a decision input, so every oscillation above still happens and the log merely documents it. It also creates two sources of truth for the offloaded set, the surface arithmetic and the record, which can disagree. + +**Log the full projected request body.** Everything except the offload decision is already derivable; repeating the history per request grows the log quadratically to record one position. + +**Identify the watermark by attachment id or by occurrence count.** Attachment ids repeat for duplicate attachments, so the position is ambiguous; counts shift when compaction prunes earlier occurrences. A sequence number plus block path is unambiguous and survives pruning. + +**Let each adapter append the event.** The adapter owns the budgets but not the session surface; a surface event appended below the loop bypasses the loop's ownership of derived history and would let two adapters define the surface differently. + +## Acceptance criteria + +- The watermark advances under file-mode budget pressure, inline fallback, and a route switch to a smaller budget, and never retreats after compaction, a budget increase, or a switch back. +- From the log alone, the offloaded set of every dispatched request is determined, including the fallback's second advance, across resume, fork, retry, and compaction. +- Replay, resume, and fork derive an identical surface, and a build without the event type refuses the log. +- Keyless recorded-session snapshots cover file-mode offload, inline-fallback advance, and compaction below the watermark; unit tests pin the position encoding, monotonicity, and pre-dispatch append timing; both SDK expected outputs update for the new surface event. +- The token meter's image pricing consumes the surface's offloaded set and the first-stage arithmetic reproduction is removed. + +## Risks + +- Offloaded images never return automatically when budgets grow; recovery is the read-only path in the placeholder, which the model must act on deliberately. +- The loop-side decision requires routes to declare budgets through a capability metadata seam that does not exist yet; the implementation cannot land without it. +- The fallback's pre-dispatch second advance adds an adapter-to-loop channel; without a stated scope it invites other transient request facts into surface events. +- The execution-world path in placeholder text remains unlogged; this proposal narrows the reconstruction gap to that one input rather than closing it. diff --git a/.agents/notes/proposed/architecture/2026-09-02-image-offload-watermark.zh.md b/.agents/notes/proposed/architecture/2026-09-02-image-offload-watermark.zh.md new file mode 100644 index 0000000000..7fb9503ad8 --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-09-02-image-offload-watermark.zh.md @@ -0,0 +1,60 @@ +# Agent Note: 持久的图片 offload 水位 + +Status: proposed + +[English](2026-09-02-image-offload-watermark.md) | 中文 + +## 问题 + +请求级图片 offload 在每次请求时都从头重算。每条路由按最老优先的顺序收集派生表层上的全部图片出现位置,累计字节一旦超过预算,就把超出部分向上取整到整个删除量子,把这么多最老的出现位置替换为占位文本,见[统一图片请求管线](../../implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md)。在 DeepSeek 默认的 128 MiB 预算和 64 MiB 量子下,129 张 1 MiB 的图片会省略最老的 65 张,这个前缀维持到历史超过 192 MiB。没有任何东西记住上一次请求停在哪里,前缀稳定只是因为对 append-only 历史做同样的算术会得到同样的结果。 + +算术的输入一动,这种稳定就失效。[Files 内联回退](../../implemented/bug-fix/2026-08-21-deepseek-files-inline-fallback.zh.md)会用 20 MiB 内联预算和 10 MiB 量子重建请求,那一次省略多得多的图片,下一次 file 模式的请求又把它们带回来。pi-ai 路由的量子是一个字节,前缀几乎每次请求都会移动。compaction 降低总量,让先前省略的图片回归。切换路由会移动每个台阶边界。每一次移动都改变模型可见前缀,并让 provider 的缓存前缀失效。 + +同样的重算也破坏了仓库不变量:模型可见输入必须能从 session log 重建。实际发出的表示方式、派生请求版本的精确字节长度、路由预算和量子都是运行时或配置事实,从不进入日志,`request/header` 只记录调用配置、系统提示词和工具。provider usage 只锚定 token 总量,恢复不了图片集合,[按路由定价的估计](../../implemented/feature/2026-08-24-route-priced-image-request-pressure.zh.md)也写明它不复现回退预算。没有任何消费方能把一条已记录的助手响应和它的请求携带的图片集合配对。 + +## 提案 + +把 offload 位置变成持久的会话事实:以一条只会前进的、改变表层的会话事件记录图片 offload 水位。 + +**事件。** 新增一个读取时必须识别的 `SessionEventMap` 成员(工作名 `image/offload`),携带 `{ turn, step }` 和水位位置。它与 compaction 事件同属表层事件:派生历史把位于水位之前的每个图片出现位置标记为已省略,每条路由把被标记的出现位置投影为它的 `offloadedImageText` 占位文本。位于水位及之后的出现位置保留。adapter 保持现有的占位渲染,唯一的变化是省略集合来自表层而不是每次请求的算术。 + +**只前进。** 预算被超过的路由推进水位,有余量的路由不动它。预算变大、路由切换或 compaction 降低总量时水位永不回退,所以模型可见前缀和 provider 缓存前缀只向前移动。推进保留现有的张数和字节量子,一次推进仍然跳到下一个删除边界,而不是刚好够用的最小值。 + +**按出现位置定位。** 水位以承载事件的序号加该事件内容中的块路径(含嵌套的工具结果内容)指向第一个保留的图片出现位置。它不用附件 id,因为附件 id 是内容摘要,同一张图附加两次就会重复;也不按出现次数计数,因为 compaction 剪枝会让计数漂移。 + +**发送前追加。** 与 `request/header` 一样,事件在所属 step 内、请求发出之前追加,因此每个已发出请求的投影在发出时刻已完全由日志决定。file 到内联的回退需要进一步推进时,adapter 在内联发送之前上报需求,循环在同一 step 内追加第二次推进,回退仍然从不发出未记录的投影。 + +**永不可忽略。** 该事件改变派生表层,不认识该类型的构建必须拒绝这份日志,而不是带着模型从未见过的图片重放它。日志结构没有变化,`SESSION_FORMAT_VERSION` 保持不变。 + +**决定权在循环。** `LlmRuntime` 根据路由声明的预算(表示方式、字节和张数预算、量子)计算水位并追加事件;adapter 声明预算、上报回退需求,但不决定省略集合。这让 #2644 推迟的路由能力元数据 seam 成为实现的前置条件。 + +**对其他消费方的影响。** token meter 直接为精确的省略集合定价,不再复现第一轮算术。resume、fork 和重放从日志复现表层。剪掉水位之下消息的 compaction 不影响水位有效性,因为位置指向的是一个保留的出现位置。纯文本路由保留各自的全历史替换,不碰水位。 + +**范围之外。** 占位和句柄文本中嵌入的执行世界访问路径仍在序列化时解析,这个缺口对保留的图片同样存在,属于另一个关于记录执行世界映射的决定。表层拥有省略集合之后,再用 log-only 事件记录每次请求的投影结果已无必要。 + +## 考虑过的替代方案 + +**继续每次请求重算 offload 位置(现状)。** 只在算术输入不动时稳定,内联回退、pi-ai 量子、compaction 和路由切换都会移动前缀,且没有消费方能重建历史请求的图片集合。这正是 issue 要解决的状态。 + +**用 log-only 事件记录每次请求的投影结果。** 恢复了可重建性但没有稳定性:记录的结果不是决策输入,上述每一种抖动照旧发生,日志只是把它记下来。它还为省略集合制造了两个事实来源,表层算术和记录,两者可能不一致。 + +**记录完整的投影后请求体。** 除 offload 决定外一切都已可派生,为记录一个位置而每次请求重复整段历史会让日志平方级增长。 + +**用附件 id 或出现次数标识水位。** 附件 id 在重复附加时重复,位置因此含糊;compaction 剪掉更早的出现位置后计数会漂移。事件序号加块路径没有歧义,也不受剪枝影响。 + +**让各个 adapter 自己追加事件。** adapter 拥有预算,但不拥有会话表层;在循环之下追加表层事件绕过了循环对派生历史的所有权,也会让两个 adapter 对表层做出不同定义。 + +## 验收标准 + +- 水位在 file 模式预算压力、内联回退和切换到更小预算的路由时推进,在 compaction、预算增大或切换回来之后永不回退。 +- 仅凭日志即可确定每个已发出请求的省略集合,包括回退的第二次推进,覆盖 resume、fork、retry 和 compaction。 +- 重放、resume 和 fork 派生出完全相同的表层,不认识该事件类型的构建拒绝这份日志。 +- keyless 录制会话快照覆盖 file 模式省略、内联回退推进和水位之下的 compaction;单元测试钉住位置编码、单调性和发送前的追加时机;两个 SDK 的期望输出为新的表层事件同步更新。 +- token meter 的图片定价消费表层的省略集合,第一轮算术的复现被移除。 + +## 风险 + +- 预算变大时被省略的图片不会自动回归,恢复手段是占位文本中的只读路径,模型必须主动使用它。 +- 循环侧决定要求路由通过一个尚不存在的能力元数据 seam 声明预算,没有它实现无法落地。 +- 回退的发送前第二次推进新增了一条 adapter 到循环的通道,不声明范围就会诱使其他临时请求事实进入表层事件。 +- 占位文本中的执行世界路径仍未记录,本提案把重建缺口收窄到这一个输入,而不是完全关闭。 diff --git a/.agents/notes/proposed/architecture/2026-09-02-log-image-request-projection.md b/.agents/notes/proposed/architecture/2026-09-02-log-image-request-projection.md deleted file mode 100644 index d1acc50078..0000000000 --- a/.agents/notes/proposed/architecture/2026-09-02-log-image-request-projection.md +++ /dev/null @@ -1,62 +0,0 @@ -# Agent Note: Record each request's image projection in the session log - -Status: proposed - -English | [中文](2026-09-02-log-image-request-projection.zh.md) - -## Problem - -The repository invariant says model-visible ⟺ logged: anything that reaches a model request must be reconstructable from the session log. Request-size image offload currently breaks the reconstruction half of that invariant for historical requests. - -Offload itself is deliberately transient: the durable surface keeps every original image reference, and each request replaces an oldest-first prefix of image occurrences with placeholder text at serialization time, per the [unified image request pipeline](../../implemented/feature/2026-08-20-unified-image-request-pipeline.md). The projection of a *future* request is a function of durable history plus live route configuration, and that half is healthy. - -Reconstructing what a *past* request actually contained is not. Four decision inputs never enter the log: - -1. **The representation actually dispatched.** A Files resolution failure rebuilds the request inline under the tighter `maxInlineRequestImageBytes` budget and its own removal quantum, offloading more images than file mode would, per the [Files inline fallback](../../implemented/bug-fix/2026-08-21-deepseek-files-inline-fallback.md). Which path ran is a per-request network outcome with no trace in the log. -2. **Exact derived request-version bytes.** The second projection stage uses the encoded byte length of each derived request version, which depends on the encoder pipeline, not on any logged fact; the log holds only the normalized attachment's byte count. -3. **Execution-world access paths.** `offloadedImageText` and `requestImageHandleText` embed the read-only path resolved for the current tool execution world into model-visible text. Resume or fork in another environment reconstructs different text than the request carried. -4. **Route budgets and quanta.** `maxRequestFilesBytes`, `maxImagesPerRequest`, both removal quanta, the inline watermark, and the per-model pixel/byte policy live only in composed connection configuration. The `request/header` snapshot records call config, system prompt, and tools; a configuration change silently changes every reprojection of old history. - -Provider usage anchors token totals only and cannot recover the image set. The token meter's [route-priced estimate](../../implemented/feature/2026-08-24-route-priced-image-request-pressure.md) reproduces just the first projection stage and documents that the fallback budget is not reproduced and that access paths resolve at pricing time. So today no consumer — debugging, accounting reconciliation, or replay tooling — can pair a logged assistant response with the exact model-visible input that produced it. - -## Proposal - -Keep offload a transient projection and record its outcome: append one log-only event describing the image projection of each dispatched request whose outcome the log records. - -**Event.** A new `SessionEventMap` member (working name `request/images`) carrying `{ turn, step }` plus the projection facts, appended within its step beside `request/header`. Like `request/header` it is log-only: derived message history and the durable surface are unchanged, and future requests still reproject from durable history and live configuration. - -**Payload.** The representation dispatched (`file`, `inline`, or `text-only`) and, per image occurrence in request order: a retained occurrence's request-version width, height, and encoded byte count together with its model-visible handle text, or an offloaded occurrence's model-visible placeholder text. Substitution text is recorded verbatim so reconstruction does not depend on the stability of the text-building functions or on any execution environment. The vocabulary stays in `dsh-llm` terms so the pi-ai and DeepSeek adapters share one schema. - -**Reporting channel.** The adapter that serialized the request owns these facts and reports them with the generation outcome, the same way `usage` travels to `assistant/message` today; the loop appends the event. The exact `LlmAdapter` result field is implementation detail for the follow-up PR. - -**Attempt rule.** One event per dispatched request whose outcome enters the log. A pre-dispatch re-serialization (the file-to-inline fallback) records only the projection actually dispatched; the once-permitted stale-file retry records the retried dispatch, whose model-visible image set is unchanged because the fallback reuses the already derived request versions. Whether dispatches that end in a terminal provider failure also record is an open review question; the default is yes, so a failed request's content is as reconstructable as a successful one's. - -**Format impact.** The event carries `ignorable: true` under the [session log version mechanism](../../implemented/architecture/2026-08-10-session-log-version-mechanism.md): a build that does not know the type still derives the surface correctly, and `SESSION_FORMAT_VERSION` stays unchanged because no structural format changes. - -**Token accounting.** Unchanged. Provider usage remains the anchor for completed requests and the route-priced projection remains a synchronous estimate; the recorded event additionally enables exact post-hoc reconciliation of any historical request. - -**Out of scope.** Copying budgets into `request/header` is unnecessary once outcomes are recorded and would change header equality semantics. Permanent image eviction — durably replacing old image occurrences on the message surface — remains a separate future decision requiring its own surface-replacement event; this event never changes the surface. - -## Alternatives considered - -**Pure derivation, with budgets logged into `request/header`.** Fixes only input 4. The dispatched representation and the exact derived byte lengths are runtime results no amount of logged configuration makes derivable, and access paths would still leak the environment into model-visible text. Making pure derivation true would require deleting the inline fallback and the exact second projection stage, trading away request deliverability for reconstructability. - -**Log the full projected request body per request.** Complete but redundant: everything except the projection outcome is already derivable, and repeating the whole history per request grows the log quadratically. The outcome facts are the only new information. - -**Permanently evict offloaded images from the durable surface.** Different semantics, not a cheaper encoding of the same one: it changes every future request, forfeits the property that images return when budgets rise or routes change, and conflates request-level omission with surface mutation — exactly what the issue requires keeping separate. - -**Accept approximate reconstruction.** Leaves a model-visible divergence with zero trace: two replays of one log can disagree with what the model actually saw, and the fallback path makes the disagreement unbounded. This narrows the invariant instead of honoring it. - -## Acceptance criteria - -- From the log alone, every recorded model outcome in an image-bearing session pairs with the exact ordered image set — identity, request dimensions, encoded bytes — and the verbatim substitution texts its request carried, across file-mode requests, inline fallback, configuration changes, resume, fork, retry, and compaction. -- Replaying a log containing the event derives an unchanged message surface, and a build without the event type still accepts the log. -- Unit tests pin the event's append timing, attempt rule, and payload; keyless recorded-session snapshots cover offload in file mode and through the inline fallback; recovery tests cover resume and fork; both SDK expected outputs update if their projections surface the event. -- Review of this note files the follow-up implementation work items. - -## Risks - -- The payload vocabulary must stay provider-neutral; a schema that encodes DeepSeek transport concepts will strain the next adapter. -- Verbatim substitution text grows the log for every image-bearing request; the strings are short and per-occurrence, but image-dense sessions pay it on every step. -- Recording only the dispatched attempt hides earlier rejected dispatches; if those ever matter, the attempt rule needs revisiting rather than silent extension. -- The adapter-to-loop reporting channel is new surface on generation results; without a stated scope it invites other transient request facts into the log. This proposal covers image projection facts only. diff --git a/.agents/notes/proposed/architecture/2026-09-02-log-image-request-projection.zh.md b/.agents/notes/proposed/architecture/2026-09-02-log-image-request-projection.zh.md deleted file mode 100644 index 041f635704..0000000000 --- a/.agents/notes/proposed/architecture/2026-09-02-log-image-request-projection.zh.md +++ /dev/null @@ -1,62 +0,0 @@ -# Agent Note: 在 session log 中记录每次请求的图片投影 - -Status: proposed - -[English](2026-09-02-log-image-request-projection.md) | 中文 - -## 问题 - -仓库不变量要求模型可见 ⟺ 已记录:凡是进入模型请求的内容都必须能从 session log 重建。请求级图片 offload 目前在历史请求这一半上破坏了该不变量。 - -offload 本身被刻意设计为临时投影:持久表层保留全部原始图片引用,每次请求在序列化时把最老的一段图片出现位置替换为占位文本,见[统一图片请求管线](../../implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md)。未来请求的投影是持久历史加当前路由配置的函数,这一半是健康的。 - -重建某次过去请求实际包含的内容则做不到。有四个决定输入从不进入日志: - -1. **实际发出的表示方式。** Files 解析失败会用更紧的 `maxInlineRequestImageBytes` 预算和独立的删除量子以内联方式重建请求,比 file 模式 offload 掉更多图片,见 [Files 内联回退](../../implemented/bug-fix/2026-08-21-deepseek-files-inline-fallback.zh.md)。走了哪条路是当次网络结果,日志里没有任何痕迹。 -2. **派生请求版本的精确字节数。** 第二轮投影使用每个派生请求版本的编码字节长度,它取决于编码管线而非任何已记录事实,日志里只有归一化附件的字节数。 -3. **执行世界访问路径。** `offloadedImageText` 和 `requestImageHandleText` 会把为当前工具执行世界解析出的只读路径写进模型可见文本。在另一个环境 resume 或 fork 时,重建出的文本和请求当时携带的不同。 -4. **路由预算与量子。** `maxRequestFilesBytes`、`maxImagesPerRequest`、两个删除量子、内联水位以及按模型的像素和字节策略只存在于组合出的连接配置中。`request/header` 快照记录调用配置、系统提示词和工具,配置一变,对旧历史的每次重投影都会悄悄改变。 - -Provider usage 只锚定 token 总量,恢复不了图片集合。token meter 的[按路由定价估计](../../implemented/feature/2026-08-24-route-priced-image-request-pressure.zh.md)只复现第一轮投影,并自己写明不复现回退预算、访问路径在定价时才解析。所以今天没有任何消费方(调试、计费对账或重放工具)能把一条已记录的助手响应与产生它的精确模型可见输入配对。 - -## 提案 - -保持 offload 为临时投影,同时记录它的结果:为每个结果进入日志的已发出请求追加一条 log-only 事件,描述该请求的图片投影。 - -**事件。** 新增一个 `SessionEventMap` 成员(工作名 `request/images`),携带 `{ turn, step }` 和投影事实,在所属 step 内与 `request/header` 一样追加。它和 `request/header` 同为 log-only:派生消息历史与持久表层不变,未来请求仍从持久历史和当前配置重投影。 - -**载荷。** 发出的表示方式(`file`、`inline` 或 `text-only`),以及按请求顺序的每个图片出现位置:保留的记请求版本宽、高、编码字节数和它的模型可见句柄文本,被 offload 的记它的模型可见占位文本。替换文本按原样记录,重建因此不依赖文本构造函数的稳定性,也不依赖任何执行环境。词汇保持在 `dsh-llm` 层面,让 pi-ai 和 DeepSeek 两个 adapter 共用一个 schema。 - -**回报通道。** 序列化请求的 adapter 拥有这些事实,随生成结果一起回报,方式与今天 `usage` 传到 `assistant/message` 相同,由循环追加事件。具体落在 `LlmAdapter` 结果的哪个字段是后续实现 PR 的细节。 - -**attempt 规则。** 结果进入日志的每次已发出请求记一条事件。发出前的重新序列化(file 到内联的回退)只记录实际发出的那份投影;被允许一次的过期 fileId 重试记录重试后的那次发出,因回退复用已派生的请求版本,其模型可见图片集合不变。以终局 provider 失败收场的发出是否也记录留作评审的开放问题,默认记录,让失败请求的内容和成功请求同样可重建。 - -**格式影响。** 事件按[session log 版本机制](../../implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md)携带 `ignorable: true`:不认识该类型的构建仍能正确派生表层,且没有结构性格式变化,`SESSION_FORMAT_VERSION` 保持不变。 - -**token 记账。** 不变。provider usage 仍是已完成请求的锚点,按路由定价的投影仍是同步估计;记录的事件额外让任何历史请求可以事后精确对账。 - -**范围之外。** 把预算复制进 `request/header` 在结果被记录后已无必要,且会改变 header 相等性语义。永久淘汰图片(在消息表层上持久替换旧图片出现位置)仍是需要独立表层替换事件的未来决定,本事件永不改变表层。 - -## 考虑过的替代方案 - -**纯派生,并把预算记入 `request/header`。** 只修复输入 4。发出的表示方式和精确派生字节数是运行时结果,记录再多配置也推不出来,访问路径也仍会把环境泄漏进模型可见文本。要让纯派生成立就得删掉内联回退和精确的第二轮投影,用请求的可发出性换可重建性。 - -**每次请求记录完整投影后的请求体。** 完整但冗余:除投影结果外一切都已可派生,每次请求重复整段历史让日志平方级增长。结果事实才是唯一的新信息。 - -**从持久表层永久淘汰被 offload 的图片。** 这是不同的语义,不是同一语义的更省编码:它改变每个未来请求,放弃预算上调或路由切换后图片回归的性质,并把请求级省略与表层修改混为一谈,恰是 issue 要求分开的两件事。 - -**接受近似重建。** 留下一处零痕迹的模型可见分歧:对同一份日志的两次重放可以和模型实际所见不一致,回退路径让这种不一致没有上界。这是收窄不变量,不是履行它。 - -## 验收标准 - -- 仅凭日志,含图会话中每条已记录的模型结果都能与其请求携带的精确有序图片集合(身份、请求尺寸、编码字节数)及逐字替换文本配对,覆盖 file 模式请求、内联回退、配置变化、resume、fork、retry 和 compaction。 -- 重放含该事件的日志派生出不变的消息表层,不认识该事件类型的构建仍接受这份日志。 -- 单元测试钉住事件的追加时机、attempt 规则和载荷;keyless 录制会话快照覆盖 file 模式与内联回退下的 offload;恢复测试覆盖 resume 和 fork;若 SDK 投影呈现该事件,两个 SDK 的期望输出同步更新。 -- 本 note 的评审建立后续实现工作项。 - -## 风险 - -- 载荷词汇必须保持 provider 中立,编入 DeepSeek 传输概念的 schema 会让下一个 adapter 难以复用。 -- 逐字替换文本让每个含图请求的日志变大;字符串短且按出现位置计,但图片密集的会话每个 step 都要付这笔开销。 -- 只记录实际发出的那次 attempt 会隐藏更早被拒绝的发出;若这些将来变得重要,应重新审视 attempt 规则而不是悄悄扩展。 -- adapter 到循环的回报通道是生成结果上的新表面,不声明范围就会诱使其他临时请求事实进入日志。本提案只覆盖图片投影事实。 From 345b5cdc6f920034a8a7bf50a644a6a99330e8ca Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 3 Sep 2026 16:29:51 +0800 Subject: [PATCH 03/79] feat(session, llm): durable image offload watermark Record the image offload point as the core image/offload session event so the derived surface, every adapter, and the token meter share one offloaded set. The agent loop plans the advance from the route's declared request-image budget before dispatch; an adapter whose exact accounting still overflows fails with IMAGE_OFFLOAD_REQUIRED and the loop advances by the named count. --- ...26-09-02-image-offload-watermark.i18n.yaml | 6 + .../2026-09-02-image-offload-watermark.md | 53 ++++ .../2026-09-02-image-offload-watermark.zh.md | 53 ++++ ...26-09-02-image-offload-watermark.i18n.yaml | 6 - .../2026-09-02-image-offload-watermark.md | 60 ---- .../2026-09-02-image-offload-watermark.zh.md | 60 ---- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 7 +- docs/architecture.zh.md | 7 +- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 10 +- docs/event-producer-consumer.zh.md | 10 +- docs/persistence-catalog.i18n.yaml | 4 +- docs/persistence-catalog.md | 48 +++- docs/persistence-catalog.zh.md | 22 ++ docs/subsystems/core.i18n.yaml | 4 +- docs/subsystems/core.md | 2 +- docs/subsystems/core.zh.md | 2 +- docs/subsystems/llm-streaming.i18n.yaml | 4 +- docs/subsystems/llm-streaming.md | 18 +- docs/subsystems/llm-streaming.zh.md | 18 +- docs/subsystems/session.i18n.yaml | 4 +- docs/subsystems/session.md | 42 ++- docs/subsystems/session.zh.md | 42 ++- packages/core/agent-loop/README.i18n.yaml | 4 +- packages/core/agent-loop/README.md | 2 +- packages/core/agent-loop/README.zh.md | 2 +- packages/core/agent-loop/src/agent.ts | 66 ++++- .../agent-loop/tests/image-offload.spec.ts | 161 +++++++++++ .../core/agent-loop/tests/mock-adapter.ts | 4 +- packages/core/session/README.i18n.yaml | 4 +- packages/core/session/README.md | 5 +- packages/core/session/README.zh.md | 5 +- packages/core/session/src/image-offload.ts | 98 +++++++ packages/core/session/src/index.ts | 47 ++- packages/core/session/src/invariant.ts | 3 +- .../core/session/src/known-event-types.ts | 1 + packages/core/session/src/types.ts | 25 ++ .../core/session/tests/image-offload.spec.ts | 161 +++++++++++ packages/llm/llm-deepseek/README.i18n.yaml | 4 +- packages/llm/llm-deepseek/README.md | 4 +- packages/llm/llm-deepseek/README.zh.md | 4 +- packages/llm/llm-deepseek/src/adapter.ts | 41 +-- .../llm/llm-deepseek/src/request-pricing.ts | 65 ++--- packages/llm/llm-deepseek/src/serialize.ts | 62 ++-- .../llm/llm-deepseek/tests/adapter.spec.ts | 48 +++- .../llm-deepseek/tests/dynamic-config.spec.ts | 22 +- .../tests/request-pricing.spec.ts | 39 ++- .../llm/llm-deepseek/tests/serialize.spec.ts | 30 +- packages/llm/llm-pi-ai/README.i18n.yaml | 4 +- packages/llm/llm-pi-ai/README.md | 6 +- packages/llm/llm-pi-ai/README.zh.md | 6 +- packages/llm/llm-pi-ai/src/adapter.ts | 9 + packages/llm/llm-pi-ai/src/context.ts | 58 ++-- packages/llm/llm-pi-ai/tests/context.spec.ts | 54 ++-- packages/llm/llm/README.i18n.yaml | 4 +- packages/llm/llm/README.md | 6 +- packages/llm/llm/README.zh.md | 6 +- packages/llm/llm/src/adapter-failure.ts | 5 +- packages/llm/llm/src/content.ts | 270 ++++++++++++------ packages/llm/llm/src/error.ts | 9 + packages/llm/llm/src/index.ts | 42 +++ packages/llm/llm/src/types.ts | 44 ++- packages/llm/llm/tests/content.spec.ts | 220 +++++++------- packages/llm/llm/tests/service.spec.ts | 30 ++ packages/llm/token-meter/README.i18n.yaml | 4 +- packages/llm/token-meter/README.md | 2 +- packages/llm/token-meter/README.zh.md | 2 +- packages/llm/token-meter/src/index.ts | 17 +- packages/llm/token-meter/src/route-pricing.ts | 18 +- packages/llm/token-meter/src/surface-fold.ts | 31 +- .../token-meter/tests/route-pricing.spec.ts | 26 ++ .../test-support/llm-replay/README.i18n.yaml | 4 +- packages/test-support/llm-replay/README.md | 2 +- packages/test-support/llm-replay/README.zh.md | 2 +- packages/test-support/llm-replay/src/index.ts | 33 ++- .../llm-replay/tests/llm-replay.spec.ts | 41 ++- scripts/type-equiv.manifest.json | 5 + snapshots/acp/acp.snapshot.ts | 5 + .../acp/image-offload/cordis.snapshot.yml | 56 ++++ snapshots/acp/image-offload/cordis.yml | 33 +++ snapshots/acp/image-offload/input.json | 86 ++++++ snapshots/acp/image-offload/session.jsonl | 33 +++ snapshots/acp/image-offload/snapshot.yml | 16 ++ .../acp/image-offload/stdout.expected.jsonl | 8 + .../image-offload/system-prompt.expected.md | 1 + 86 files changed, 1963 insertions(+), 632 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.md create mode 100644 .agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.zh.md delete mode 100644 .agents/notes/proposed/architecture/2026-09-02-image-offload-watermark.i18n.yaml delete mode 100644 .agents/notes/proposed/architecture/2026-09-02-image-offload-watermark.md delete mode 100644 .agents/notes/proposed/architecture/2026-09-02-image-offload-watermark.zh.md create mode 100644 packages/core/agent-loop/tests/image-offload.spec.ts create mode 100644 packages/core/session/src/image-offload.ts create mode 100644 packages/core/session/tests/image-offload.spec.ts create mode 100644 snapshots/acp/image-offload/cordis.snapshot.yml create mode 100644 snapshots/acp/image-offload/cordis.yml create mode 100644 snapshots/acp/image-offload/input.json create mode 100644 snapshots/acp/image-offload/session.jsonl create mode 100644 snapshots/acp/image-offload/snapshot.yml create mode 100644 snapshots/acp/image-offload/stdout.expected.jsonl create mode 120000 snapshots/acp/image-offload/system-prompt.expected.md diff --git a/.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.i18n.yaml new file mode 100644 index 0000000000..3b3fe9cd5c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.md +2026-09-02-image-offload-watermark.md: b525554a31100a19f2ff27df178dc7c5b1b8d392 +2026-09-02-image-offload-watermark.zh.md: abe44a9c18d619ca877a4adfe33bef7017913338 diff --git a/.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.md b/.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.md new file mode 100644 index 0000000000..b525554a31 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.md @@ -0,0 +1,53 @@ +# Agent Note: Durable image offload watermark + +Status: implemented + +English | [中文](2026-09-02-image-offload-watermark.zh.md) + +## Problem + +Request-size image offload was recomputed from scratch on every request. Each route collected every image occurrence on the derived surface, oldest first, and once the accumulated bytes exceeded its budget it rounded the excess up to a whole removal quantum and replaced that many oldest occurrences with placeholder text, per the [unified image request pipeline](../feature/2026-08-20-unified-image-request-pipeline.md). Nothing remembered where the previous request stopped; the prefix was stable only because the arithmetic over an append-only history repeated itself. + +That stability failed wherever the arithmetic inputs moved. The [Files inline fallback](../bug-fix/2026-08-21-deepseek-files-inline-fallback.md) rebuilt a request under a 20 MiB inline budget with a 10 MiB quantum, offloading far more images for that one request, and the next request in file mode brought them back. The pi-ai route used a quantum of one byte, so its prefix moved on almost every request. Compaction lowered the total and returned previously offloaded images. A route switch moved every step boundary. Each move changed the model-visible prefix and invalidated the provider cache prefix. + +The same recomputation broke the repository invariant that model-visible input is reconstructable from the session log. Which representation was dispatched, the exact derived request-version byte lengths, and the route budgets and quanta were runtime or configuration facts that never entered the log; `request/header` records call config, system prompt, and tools only. Provider usage anchors token totals and cannot recover the image set, and the [route-priced estimate](../feature/2026-08-24-route-priced-image-request-pressure.md) documented that it did not reproduce the fallback budget. No consumer could pair a logged assistant response with the image set its request carried. + +## Decision + +The offload point is a durable session fact: the core `image/offload` event records an image offload watermark that only advances, and the derived surface, every route, and the token meter read the offloaded set from it. + +**Event.** `image/offload` carries `{ turn, step, watermark }`, where `watermark` is an `ImageOccurrencePosition`: the seq of the event carrying the last offloaded occurrence and its block path inside that event's content (the top-level block index, then the index inside a tool-result block). Positions order by seq, then by path, so a newer event always lies after an older one regardless of surface replacements. `Session.deriveMessages()` marks every occurrence positioned at or before the watermark `offloaded: true` on the `ImageBlock`; the marked copies are frozen and the durable event content is untouched. `Session.append` and seeding reject a watermark that is malformed, names an event outside the log, or does not advance strictly past the previous one; `session.imageOffloadWatermark()` folds the latest. The event is required-on-read because it changes the derived surface; `SESSION_FORMAT_VERSION` is unchanged because the log structure is not. + +**Only advances.** The watermark never retreats when a budget grows, a route changes, or compaction lowers the total, so the model-visible prefix and the provider cache prefix move only forward. An occurrence below the watermark that compaction later shadows leaves the watermark valid, because the comparison is positional. + +**Decision owned by the loop, budgets declared by routes.** An image-capable route declares an `LlmImageRequestBudget` (`representation`, `maxBytes`, `maxImages`, both quanta, and the request-version byte target) as `imageRequest` on its `LlmResolvedModelInfo`; `LlmRuntime` validates it and exposes it on `PreparedLlmCall`. After `request/header`, `buildRequest` collects the retained occurrences from the surface in log order, plans the advance with the pure `planImageOffload()` (represented bytes are the normalized count clamped to the version target and base64-expanded for inline routes, removed in whole quanta), appends the event, and only then derives the request messages. The DeepSeek adapter declares its file-mode budget; the pi-ai adapter declares its base64 bound; the replay adapter declares an optional `imageRequestMaxBytes` for keyless scenarios. + +**Adapters project, never decide.** Serialization renders every `offloaded` block as `offloadedImageText` with the currently resolved access path and prepares only retained occurrences. When the retained occurrences' exact request-version bytes still exceed the route budget, in file mode, under the inline fallback's tighter budget, or under the pi-ai bound, the adapter fails the attempt with `IMAGE_OFFLOAD_REQUIRED` and `LlmFailure.offloadImages` naming how many more oldest occurrences must be offloaded, computed with `offloadedImagePrefixCount()`. The loop advances the watermark by that count and rebuilds the request before `agent/request-error` runs; when nothing remains to offload the failure reaches ordinary recovery. + +**Token accounting.** `priceImages` receives surface `ImageBlock`s and prices an `offloaded` one as its placeholder text; the DeepSeek and replay pricing no longer reproduce any offload arithmetic. The meter folds `image/offload` into its replay state, prices the current surface under the current watermark and each usage anchor under the watermark its request was derived with. Provider usage remains the anchor for completed requests. + +**Other consumers.** Compaction summarization and every other `ctx.llm.stream` caller derive from the same surface, so they read the watermark and never advance it. Resume, fork, and replay reproduce the surface from the log. Text-only routes keep their separate whole-history substitution. + +## Alternatives considered + +**Keep recomputing the offload point per request.** Stable only while the arithmetic inputs held still; the inline fallback, the pi-ai quantum, compaction, and route switches all moved the prefix, and no consumer could reconstruct a historical request's image set. + +**Record each request's projection outcome as a log-only event.** Restores reconstructability but not stability: the recorded outcome is not a decision input, so every oscillation still happens and the log merely documents it, with two sources of truth for the offloaded set that can disagree. + +**Log the full projected request body.** Everything except the offload decision is already derivable; repeating the history per request grows the log quadratically to record one position. + +**Identify the watermark by attachment id or by occurrence count.** Attachment ids repeat for duplicate attachments, so the position is ambiguous; counts shift when compaction prunes earlier occurrences. A seq plus block path is unambiguous and survives pruning. + +**Let each adapter append the event.** The adapter owns the budgets but not the session surface; a surface fact appended below the loop bypasses the loop's ownership of derived history and lets two adapters define the surface differently. Adapters report the count they need instead. + +**Keep a transient extra offload for the inline fallback and exact-byte overflow.** Would have sent an unlogged projection in exactly the cases the invariant exists for; the failure-and-advance path costs one serialization attempt and keeps every dispatched request derivable from the log. + +## Consequences + +Offloaded images never return automatically when budgets grow, a larger route is selected, or compaction lowers the total; recovery is the read-only path in the placeholder, which the model uses deliberately. A Files outage or a temporary switch to a small-budget route advances the watermark permanently; both are accepted for the same reason. + +Every dispatched request's image set is determined by the log alone, across file mode, inline fallback, resume, fork, retry, and compaction, and the provider cache prefix no longer oscillates. The execution-world access path embedded in placeholder and handle text is still resolved at serialization time; that gap exists for retained images too and belongs to a separate decision about recording the execution-world mapping. + +## Testing + +`packages/llm/llm/tests/content.spec.ts` pins the image walk, position ordering, represented bytes, marking, projection, and the watermark planner, including the 129-to-64 MiB quantum example. `packages/core/session/tests/image-offload.spec.ts` pins append and seed validation, strict advance, nested marking, frozen copies, cache rebuild, and scratch-replay equality. `packages/core/agent-loop/tests/image-offload.spec.ts` pins the pre-dispatch advance, the non-retreating watermark, the `IMAGE_OFFLOAD_REQUIRED` advance-and-rebuild path, and the exhausted case. Adapter specs pin placeholder projection, prepared-only-retained reads, and the exact-byte failure with its count; `route-pricing.spec.ts` pins watermark pricing; the replay adapter spec pins `imageRequestMaxBytes`. The `image-offload` ACP snapshot replays an authored six-frame session through the shipped profile under a replay route whose base64 budget admits three frames, and pins the `image/offload` watermark appended after the first `request/header` and the unchanged second turn. diff --git a/.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.zh.md b/.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.zh.md new file mode 100644 index 0000000000..abe44a9c18 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.zh.md @@ -0,0 +1,53 @@ +# Agent Note: 持久的图片 offload 水位 + +Status: implemented + +[English](2026-09-02-image-offload-watermark.md) | 中文 + +## 问题 + +请求级图片 offload 过去在每次请求时都从头重算。每条路由按最老优先的顺序收集派生表层上的全部图片出现位置,累计字节一旦超过预算,就把超出部分向上取整到整个删除量子,把这么多最老的出现位置替换为占位文本,见[统一图片请求管线](../feature/2026-08-20-unified-image-request-pipeline.zh.md)。没有任何东西记住上一次请求停在哪里,前缀稳定只是因为对 append-only 历史做同样的算术会得到同样的结果。 + +算术的输入一动,这种稳定就失效。[Files 内联回退](../bug-fix/2026-08-21-deepseek-files-inline-fallback.zh.md)会用 20 MiB 内联预算和 10 MiB 量子重建请求,那一次省略多得多的图片,下一次 file 模式的请求又把它们带回来。pi-ai 路由的量子是一个字节,前缀几乎每次请求都会移动。compaction 降低总量,让先前省略的图片回归。切换路由会移动每个台阶边界。每一次移动都改变模型可见前缀,并让 provider 的缓存前缀失效。 + +同样的重算也破坏了仓库不变量:模型可见输入必须能从 session log 重建。实际发出的表示方式、派生请求版本的精确字节长度、路由预算和量子都是运行时或配置事实,从不进入日志,`request/header` 只记录调用配置、系统提示词和工具。provider usage 只锚定 token 总量,恢复不了图片集合,[按路由定价的估计](../feature/2026-08-24-route-priced-image-request-pressure.zh.md)也写明它不复现回退预算。没有任何消费方能把一条已记录的助手响应和它的请求携带的图片集合配对。 + +## 决定 + +offload 位置是持久的会话事实:核心事件 `image/offload` 记录一条只会前进的图片 offload 水位,派生表层、每条路由和 token meter 都从它读取省略集合。 + +**事件。** `image/offload` 携带 `{ turn, step, watermark }`,其中 `watermark` 是一个 `ImageOccurrencePosition`:承载最后一个被省略出现位置的事件序号,以及它在该事件内容中的块路径(顶层块下标,再是工具结果块内的下标)。位置先按序号再按路径排序,所以无论表层如何替换,较新的事件总在较老的之后。`Session.deriveMessages()` 把位于水位及之前的每个出现位置在 `ImageBlock` 上标为 `offloaded: true`;标记后的副本被冻结,持久事件内容不受影响。`Session.append` 与 seed 拒绝畸形、指向日志之外事件或没有严格越过前一条的水位;`session.imageOffloadWatermark()` 折叠最新值。该事件改变派生表层,因此读取时必须识别;日志结构没有变化,`SESSION_FORMAT_VERSION` 保持不变。 + +**只前进。** 预算变大、路由切换或 compaction 降低总量时水位永不回退,所以模型可见前缀和 provider 缓存前缀只向前移动。水位之下的出现位置后来被 compaction 遮蔽也不影响水位有效性,因为比较是按位置进行的。 + +**决定权在循环,预算由路由声明。** 支持图片的路由在其 `LlmResolvedModelInfo` 上以 `imageRequest` 声明一个 `LlmImageRequestBudget`(`representation`、`maxBytes`、`maxImages`、两个量子与请求版本字节目标);`LlmRuntime` 校验它并通过 `PreparedLlmCall` 暴露。`request/header` 之后,`buildRequest` 按日志顺序从表层收集保留的出现位置,用纯函数 `planImageOffload()` 规划推进(表示字节是归一化字节数按版本目标截断后的值,内联路由再按 base64 展开,按整量子删除),追加事件,然后才派生请求消息。DeepSeek adapter 声明其 file 模式预算,pi-ai adapter 声明其 base64 上限,replay adapter 为 keyless 场景声明可选的 `imageRequestMaxBytes`。 + +**adapter 只投影,不决定。** 序列化把每个 `offloaded` 块渲染为带当前已解析访问路径的 `offloadedImageText`,只准备保留的出现位置。当保留的出现位置按精确请求版本字节仍超过路由预算,无论是 file 模式、内联回退更紧的预算还是 pi-ai 上限,adapter 都以 `IMAGE_OFFLOAD_REQUIRED` 让本次尝试失败,并在 `LlmFailure.offloadImages` 中用 `offloadedImagePrefixCount()` 算出还需省略多少最老的出现位置。循环按该数量推进水位并在 `agent/request-error` 运行前重建请求;没有可省略的出现位置时,失败进入普通恢复路径。 + +**token 记账。** `priceImages` 接收表层的 `ImageBlock`,把 `offloaded` 的按占位文本定价;DeepSeek 和 replay 的定价不再复现任何 offload 算术。meter 把 `image/offload` 折进其重放状态,按当前水位为当前表层定价,按每个 usage 锚点的请求派生时的水位为该锚点定价。已完成请求仍以 provider usage 为锚点。 + +**其他消费方。** compaction 摘要和其他所有 `ctx.llm.stream` 调用方都从同一表层派生,因此只读取水位、从不推进。resume、fork 和重放从日志复现表层。纯文本路由保留各自的全历史替换。 + +## 考虑过的替代方案 + +**继续每次请求重算 offload 位置。** 只在算术输入不动时稳定,内联回退、pi-ai 量子、compaction 和路由切换都会移动前缀,且没有消费方能重建历史请求的图片集合。 + +**用 log-only 事件记录每次请求的投影结果。** 恢复了可重建性但没有稳定性:记录的结果不是决策输入,每一种抖动照旧发生,日志只是把它记下来,且省略集合有两个可能不一致的事实来源。 + +**记录完整的投影后请求体。** 除 offload 决定外一切都已可派生,为记录一个位置而每次请求重复整段历史会让日志平方级增长。 + +**用附件 id 或出现次数标识水位。** 附件 id 在重复附加时重复,位置因此含糊;compaction 剪掉更早的出现位置后计数会漂移。序号加块路径没有歧义,也不受剪枝影响。 + +**让各个 adapter 自己追加事件。** adapter 拥有预算,但不拥有会话表层;在循环之下追加表层事实绕过了循环对派生历史的所有权,也会让两个 adapter 对表层做出不同定义。adapter 改为上报它需要的数量。 + +**为内联回退和精确字节溢出保留临时的额外省略。** 恰好会在不变量所针对的场景发送未记录的投影;失败再推进的路径只多花一次序列化尝试,且让每个已发出请求都可由日志派生。 + +## 后果 + +预算变大、选中更大的路由或 compaction 降低总量时,被省略的图片不会自动回归;恢复手段是占位文本中的只读路径,模型需要时主动使用。一次 Files 故障或临时切到小预算路由会永久推进水位,两者出于同一理由被接受。 + +每个已发出请求的图片集合仅由日志决定,覆盖 file 模式、内联回退、resume、fork、retry 和 compaction,provider 缓存前缀不再抖动。占位和句柄文本中嵌入的执行世界访问路径仍在序列化时解析,这个缺口对保留的图片同样存在,属于另一个关于记录执行世界映射的决定。 + +## 测试 + +`packages/llm/llm/tests/content.spec.ts` 钉住图片遍历、位置排序、表示字节、标记、投影和水位规划器,包括 129 到 64 MiB 的量子示例。`packages/core/session/tests/image-offload.spec.ts` 钉住追加与 seed 校验、严格推进、嵌套标记、冻结副本、缓存重建和从头重放的一致性。`packages/core/agent-loop/tests/image-offload.spec.ts` 钉住发送前推进、不回退的水位、`IMAGE_OFFLOAD_REQUIRED` 的推进并重建路径以及耗尽的情况。adapter 测试钉住占位投影、只读取保留图片以及带数量的精确字节失败;`route-pricing.spec.ts` 钉住水位定价;replay adapter 测试钉住 `imageRequestMaxBytes`。`image-offload` ACP 快照通过发布的 profile、在一条 base64 预算只容纳三帧的 replay 路由下重放一段人工编写的六帧会话,钉住第一次 `request/header` 之后追加的 `image/offload` 水位和保持不变的第二轮。 diff --git a/.agents/notes/proposed/architecture/2026-09-02-image-offload-watermark.i18n.yaml b/.agents/notes/proposed/architecture/2026-09-02-image-offload-watermark.i18n.yaml deleted file mode 100644 index caa127ec4e..0000000000 --- a/.agents/notes/proposed/architecture/2026-09-02-image-offload-watermark.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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/proposed/architecture/2026-09-02-image-offload-watermark.md -2026-09-02-image-offload-watermark.md: 61864bb8fd81af0ac0e3668b0c0ebc872ac1c031 -2026-09-02-image-offload-watermark.zh.md: 7fb9503ad8de8e02f358ada598bbb54f2a493445 diff --git a/.agents/notes/proposed/architecture/2026-09-02-image-offload-watermark.md b/.agents/notes/proposed/architecture/2026-09-02-image-offload-watermark.md deleted file mode 100644 index 61864bb8fd..0000000000 --- a/.agents/notes/proposed/architecture/2026-09-02-image-offload-watermark.md +++ /dev/null @@ -1,60 +0,0 @@ -# Agent Note: Durable image offload watermark - -Status: proposed - -English | [中文](2026-09-02-image-offload-watermark.zh.md) - -## Problem - -Request-size image offload is recomputed from scratch on every request. Each route collects every image occurrence on the derived surface, oldest first, and once the accumulated bytes exceed its budget it rounds the excess up to a whole removal quantum and replaces that many oldest occurrences with placeholder text, per the [unified image request pipeline](../../implemented/feature/2026-08-20-unified-image-request-pipeline.md). Under the DeepSeek defaults of a 128 MiB budget and a 64 MiB quantum, 129 one-mebibyte images offload the oldest 65, and that prefix holds until history passes 192 MiB. Nothing remembers where the previous request stopped; the prefix is stable only because the arithmetic over an append-only history repeats itself. - -That stability fails wherever the arithmetic inputs move. The [Files inline fallback](../../implemented/bug-fix/2026-08-21-deepseek-files-inline-fallback.md) rebuilds a request under a 20 MiB inline budget with a 10 MiB quantum, offloading far more images for that one request, and the next request in file mode brings them back. The pi-ai route uses a quantum of one byte, so its prefix moves on almost every request. Compaction lowers the total and returns previously offloaded images. A route switch moves every step boundary. Each move changes the model-visible prefix and invalidates the provider cache prefix. - -The same recomputation breaks the repository invariant that model-visible input is reconstructable from the session log. Which representation was dispatched, the exact derived request-version byte lengths, and the route budgets and quanta are runtime or configuration facts that never enter the log; `request/header` records call config, system prompt, and tools only. Provider usage anchors token totals and cannot recover the image set, and the [route-priced estimate](../../implemented/feature/2026-08-24-route-priced-image-request-pressure.md) documents that it does not reproduce the fallback budget. No consumer can pair a logged assistant response with the image set its request carried. - -## Proposal - -Make the offload point a durable session fact: an image offload watermark recorded as a surface-changing session event that only advances. - -**Event.** A new required-on-read `SessionEventMap` member (working name `image/offload`) carrying `{ turn, step }` and the watermark position. It is a surface event in the same sense as the compaction events: derived history marks every image occurrence positioned before the watermark as offloaded, and every route projects a marked occurrence as its `offloadedImageText` placeholder. Occurrences at or after the watermark are retained. Adapters keep their existing placeholder rendering; the only change is that the offloaded set comes from the surface rather than from per-request arithmetic. - -**Only advances.** A route whose budget is exceeded advances the watermark; a route with headroom leaves it alone. The watermark never retreats when a budget grows, a route changes, or compaction lowers the total, so the model-visible prefix and the provider cache prefix move only forward. Advancement keeps the existing count and byte quanta, so one advance still jumps to the next removal boundary rather than to the minimum that fits. - -**Position by occurrence.** The watermark names the first retained image occurrence by the sequence number of the event that carries it and the block path inside that event's content, including nested tool-result content. It does not use attachment ids, which are content digests that repeat when one image is attached twice, and it does not count occurrences, which compaction pruning would shift. - -**Appended before dispatch.** Like `request/header`, the event is appended inside its step before the request is sent, so the projection of every dispatched request is fully determined by the log at dispatch time. When the file-to-inline fallback needs a further advance, the adapter reports the requirement before the inline dispatch and the loop appends a second advance in the same step; the fallback still never sends an unlogged projection. - -**Never ignorable.** The event changes the derived surface, so a build that does not know the type must refuse the log rather than replay it with images the model never saw. `SESSION_FORMAT_VERSION` stays unchanged because the log structure does not change. - -**Decision owned by the loop.** `LlmRuntime` computes the watermark from budgets the route declares — representation, byte and count budgets, quanta — and appends the event; adapters declare budgets and report fallback requirements but do not decide the offloaded set. This makes the route-capability metadata seam that #2644 deferred a prerequisite of the implementation. - -**Consequences for other consumers.** The token meter prices the exact offloaded set instead of reproducing first-stage arithmetic. Resume, fork, and replay reproduce the surface from the log. Compaction that prunes messages below the watermark leaves it valid because the position names a retained occurrence. Text-only routes keep their separate whole-history substitution and do not touch the watermark. - -**Out of scope.** The execution-world access path embedded in placeholder and handle text is still resolved at serialization time; that gap exists for retained images too and belongs to a separate decision about recording the execution-world mapping. Recording per-request projection outcomes as a log-only event is unnecessary once the surface owns the offloaded set. - -## Alternatives considered - -**Keep recomputing the offload point per request (status quo).** Stable only while the arithmetic inputs hold still; the inline fallback, the pi-ai quantum, compaction, and route switches all move the prefix, and no consumer can reconstruct a historical request's image set. This is the state the issue asks to resolve. - -**Record each request's projection outcome as a log-only event.** Restores reconstructability but not stability: the recorded outcome is not a decision input, so every oscillation above still happens and the log merely documents it. It also creates two sources of truth for the offloaded set, the surface arithmetic and the record, which can disagree. - -**Log the full projected request body.** Everything except the offload decision is already derivable; repeating the history per request grows the log quadratically to record one position. - -**Identify the watermark by attachment id or by occurrence count.** Attachment ids repeat for duplicate attachments, so the position is ambiguous; counts shift when compaction prunes earlier occurrences. A sequence number plus block path is unambiguous and survives pruning. - -**Let each adapter append the event.** The adapter owns the budgets but not the session surface; a surface event appended below the loop bypasses the loop's ownership of derived history and would let two adapters define the surface differently. - -## Acceptance criteria - -- The watermark advances under file-mode budget pressure, inline fallback, and a route switch to a smaller budget, and never retreats after compaction, a budget increase, or a switch back. -- From the log alone, the offloaded set of every dispatched request is determined, including the fallback's second advance, across resume, fork, retry, and compaction. -- Replay, resume, and fork derive an identical surface, and a build without the event type refuses the log. -- Keyless recorded-session snapshots cover file-mode offload, inline-fallback advance, and compaction below the watermark; unit tests pin the position encoding, monotonicity, and pre-dispatch append timing; both SDK expected outputs update for the new surface event. -- The token meter's image pricing consumes the surface's offloaded set and the first-stage arithmetic reproduction is removed. - -## Risks - -- Offloaded images never return automatically when budgets grow; recovery is the read-only path in the placeholder, which the model must act on deliberately. -- The loop-side decision requires routes to declare budgets through a capability metadata seam that does not exist yet; the implementation cannot land without it. -- The fallback's pre-dispatch second advance adds an adapter-to-loop channel; without a stated scope it invites other transient request facts into surface events. -- The execution-world path in placeholder text remains unlogged; this proposal narrows the reconstruction gap to that one input rather than closing it. diff --git a/.agents/notes/proposed/architecture/2026-09-02-image-offload-watermark.zh.md b/.agents/notes/proposed/architecture/2026-09-02-image-offload-watermark.zh.md deleted file mode 100644 index 7fb9503ad8..0000000000 --- a/.agents/notes/proposed/architecture/2026-09-02-image-offload-watermark.zh.md +++ /dev/null @@ -1,60 +0,0 @@ -# Agent Note: 持久的图片 offload 水位 - -Status: proposed - -[English](2026-09-02-image-offload-watermark.md) | 中文 - -## 问题 - -请求级图片 offload 在每次请求时都从头重算。每条路由按最老优先的顺序收集派生表层上的全部图片出现位置,累计字节一旦超过预算,就把超出部分向上取整到整个删除量子,把这么多最老的出现位置替换为占位文本,见[统一图片请求管线](../../implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md)。在 DeepSeek 默认的 128 MiB 预算和 64 MiB 量子下,129 张 1 MiB 的图片会省略最老的 65 张,这个前缀维持到历史超过 192 MiB。没有任何东西记住上一次请求停在哪里,前缀稳定只是因为对 append-only 历史做同样的算术会得到同样的结果。 - -算术的输入一动,这种稳定就失效。[Files 内联回退](../../implemented/bug-fix/2026-08-21-deepseek-files-inline-fallback.zh.md)会用 20 MiB 内联预算和 10 MiB 量子重建请求,那一次省略多得多的图片,下一次 file 模式的请求又把它们带回来。pi-ai 路由的量子是一个字节,前缀几乎每次请求都会移动。compaction 降低总量,让先前省略的图片回归。切换路由会移动每个台阶边界。每一次移动都改变模型可见前缀,并让 provider 的缓存前缀失效。 - -同样的重算也破坏了仓库不变量:模型可见输入必须能从 session log 重建。实际发出的表示方式、派生请求版本的精确字节长度、路由预算和量子都是运行时或配置事实,从不进入日志,`request/header` 只记录调用配置、系统提示词和工具。provider usage 只锚定 token 总量,恢复不了图片集合,[按路由定价的估计](../../implemented/feature/2026-08-24-route-priced-image-request-pressure.zh.md)也写明它不复现回退预算。没有任何消费方能把一条已记录的助手响应和它的请求携带的图片集合配对。 - -## 提案 - -把 offload 位置变成持久的会话事实:以一条只会前进的、改变表层的会话事件记录图片 offload 水位。 - -**事件。** 新增一个读取时必须识别的 `SessionEventMap` 成员(工作名 `image/offload`),携带 `{ turn, step }` 和水位位置。它与 compaction 事件同属表层事件:派生历史把位于水位之前的每个图片出现位置标记为已省略,每条路由把被标记的出现位置投影为它的 `offloadedImageText` 占位文本。位于水位及之后的出现位置保留。adapter 保持现有的占位渲染,唯一的变化是省略集合来自表层而不是每次请求的算术。 - -**只前进。** 预算被超过的路由推进水位,有余量的路由不动它。预算变大、路由切换或 compaction 降低总量时水位永不回退,所以模型可见前缀和 provider 缓存前缀只向前移动。推进保留现有的张数和字节量子,一次推进仍然跳到下一个删除边界,而不是刚好够用的最小值。 - -**按出现位置定位。** 水位以承载事件的序号加该事件内容中的块路径(含嵌套的工具结果内容)指向第一个保留的图片出现位置。它不用附件 id,因为附件 id 是内容摘要,同一张图附加两次就会重复;也不按出现次数计数,因为 compaction 剪枝会让计数漂移。 - -**发送前追加。** 与 `request/header` 一样,事件在所属 step 内、请求发出之前追加,因此每个已发出请求的投影在发出时刻已完全由日志决定。file 到内联的回退需要进一步推进时,adapter 在内联发送之前上报需求,循环在同一 step 内追加第二次推进,回退仍然从不发出未记录的投影。 - -**永不可忽略。** 该事件改变派生表层,不认识该类型的构建必须拒绝这份日志,而不是带着模型从未见过的图片重放它。日志结构没有变化,`SESSION_FORMAT_VERSION` 保持不变。 - -**决定权在循环。** `LlmRuntime` 根据路由声明的预算(表示方式、字节和张数预算、量子)计算水位并追加事件;adapter 声明预算、上报回退需求,但不决定省略集合。这让 #2644 推迟的路由能力元数据 seam 成为实现的前置条件。 - -**对其他消费方的影响。** token meter 直接为精确的省略集合定价,不再复现第一轮算术。resume、fork 和重放从日志复现表层。剪掉水位之下消息的 compaction 不影响水位有效性,因为位置指向的是一个保留的出现位置。纯文本路由保留各自的全历史替换,不碰水位。 - -**范围之外。** 占位和句柄文本中嵌入的执行世界访问路径仍在序列化时解析,这个缺口对保留的图片同样存在,属于另一个关于记录执行世界映射的决定。表层拥有省略集合之后,再用 log-only 事件记录每次请求的投影结果已无必要。 - -## 考虑过的替代方案 - -**继续每次请求重算 offload 位置(现状)。** 只在算术输入不动时稳定,内联回退、pi-ai 量子、compaction 和路由切换都会移动前缀,且没有消费方能重建历史请求的图片集合。这正是 issue 要解决的状态。 - -**用 log-only 事件记录每次请求的投影结果。** 恢复了可重建性但没有稳定性:记录的结果不是决策输入,上述每一种抖动照旧发生,日志只是把它记下来。它还为省略集合制造了两个事实来源,表层算术和记录,两者可能不一致。 - -**记录完整的投影后请求体。** 除 offload 决定外一切都已可派生,为记录一个位置而每次请求重复整段历史会让日志平方级增长。 - -**用附件 id 或出现次数标识水位。** 附件 id 在重复附加时重复,位置因此含糊;compaction 剪掉更早的出现位置后计数会漂移。事件序号加块路径没有歧义,也不受剪枝影响。 - -**让各个 adapter 自己追加事件。** adapter 拥有预算,但不拥有会话表层;在循环之下追加表层事件绕过了循环对派生历史的所有权,也会让两个 adapter 对表层做出不同定义。 - -## 验收标准 - -- 水位在 file 模式预算压力、内联回退和切换到更小预算的路由时推进,在 compaction、预算增大或切换回来之后永不回退。 -- 仅凭日志即可确定每个已发出请求的省略集合,包括回退的第二次推进,覆盖 resume、fork、retry 和 compaction。 -- 重放、resume 和 fork 派生出完全相同的表层,不认识该事件类型的构建拒绝这份日志。 -- keyless 录制会话快照覆盖 file 模式省略、内联回退推进和水位之下的 compaction;单元测试钉住位置编码、单调性和发送前的追加时机;两个 SDK 的期望输出为新的表层事件同步更新。 -- token meter 的图片定价消费表层的省略集合,第一轮算术的复现被移除。 - -## 风险 - -- 预算变大时被省略的图片不会自动回归,恢复手段是占位文本中的只读路径,模型必须主动使用它。 -- 循环侧决定要求路由通过一个尚不存在的能力元数据 seam 声明预算,没有它实现无法落地。 -- 回退的发送前第二次推进新增了一条 adapter 到循环的通道,不声明范围就会诱使其他临时请求事实进入表层事件。 -- 占位文本中的执行世界路径仍未记录,本提案把重建缺口收窄到这一个输入,而不是完全关闭。 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 14c3c8ad07..095077a20d 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: 902fd7b53fe7493127da68f9a8f381a33b8edc18 -architecture.zh.md: 2890b32b5db1ac30f6eafef47b019a03c6acedd4 +architecture.md: 3ba5a0889bf2f592eec818046d92f713d21c01c7 +architecture.zh.md: a4ab51dc0eb9e1b23f3fa03d3713c545531a66eb diff --git a/docs/architecture.md b/docs/architecture.md index 902fd7b53f..3ba5a0889b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -83,8 +83,9 @@ turn/start reject, or a first enter rewritten empty -> close the turn with no step step/start append entered messages as user/message + agent/request -> request/header? -> image/offload? derive model history from the log - agent/request -> llm/stream -> assistant/chunk* -> assistant/message + llm/stream -> assistant/chunk* -> assistant/message tool/call* -> tools/pre-execute -> tools/execute -> tools/post-execute -> tool/result* step/end tools owe another request, or next-step input arrived -> claim -> next step @@ -92,12 +93,14 @@ turn/start turn/end ``` -`turn/*`, `step/*`, `user/message`, `assistant/*`, and `tool/*` are durable session events; the rest are live extension points across three domains. `agent/pre-step`, `agent/request`, `llm/stream`, and the three `tools/*` events are waterfalls, whose listeners must call `next()` to delegate; `agent/turn-stopping` is serial and has no `next()`. +`turn/*`, `step/*`, `user/message`, `assistant/*`, `tool/*`, `request/header`, and `image/offload` are durable session events; the rest are live extension points across three domains. `agent/pre-step`, `agent/request`, `llm/stream`, and the three `tools/*` events are waterfalls, whose listeners must call `next()` to delegate; `agent/turn-stopping` is serial and has no `next()`. Input reaches the driver through one inbox. Some messages wake it immediately; injected context waits in the inbox until another message does. `agent/pre-step` decides what the model sees. Listeners may rewrite the claimed messages or reject them outright; a rejected or empty first claim still closes a durable turn that spent no step, so the log records the attempt. An enter decision may also set `startsRequestSeries` to begin a distinct model-message series: the loop then logs a fresh `request/header` (reason `series`, or `change` carrying `startsSeries: true` when the envelope changed too). A listener that rebuilds a downstream enter decision must spread it (`{ ...decision, messages }`) so the declaration survives. Each step reads the prompt sections and tool schemas that plugins registered. +`image/offload` records the durable image offload watermark. After `request/header`, when the prepared route declares a request-image budget that the retained image occurrences exceed, the loop appends the advance and only then derives the request, so the history the model receives is exactly the log's projection. An adapter whose exact byte accounting still overflows fails the attempt with `IMAGE_OFFLOAD_REQUIRED` naming the additional oldest occurrences; the loop advances by that count and rebuilds the request before any `agent/request-error` listener runs. The watermark never retreats ([decision](../.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.md)). + Details: the [sequence diagram](agent-lifecycle.md), the [tool pipeline](tool-execution-pipeline.md), and [cancellation and error recovery](subsystems/core.md#the-agent-handle). ## Session log diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 2890b32b5d..a4ab51dc0e 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -87,8 +87,9 @@ turn/start reject, or a first enter rewritten empty -> close the turn with no step step/start append entered messages as user/message + agent/request -> request/header? -> image/offload? derive model history from the log - agent/request -> llm/stream -> assistant/chunk* -> assistant/message + llm/stream -> assistant/chunk* -> assistant/message tool/call* -> tools/pre-execute -> tools/execute -> tools/post-execute -> tool/result* step/end tools owe another request, or next-step input arrived -> claim -> next step @@ -96,12 +97,14 @@ turn/start turn/end ``` -`turn/*`、`step/*`、`user/message`、`assistant/*` 和 `tool/*` 是持久会话事件;其余是分属三个事件域的实时扩展点。`agent/pre-step`、`agent/request`、`llm/stream` 和三个 `tools/*` 事件是 waterfall(瀑布式事件),其监听器必须调用 `next()` 才能委托下去;`agent/turn-stopping` 是 serial 事件,没有 `next()`。 +`turn/*`、`step/*`、`user/message`、`assistant/*`、`tool/*`、`request/header` 和 `image/offload` 是持久会话事件;其余是分属三个事件域的实时扩展点。`agent/pre-step`、`agent/request`、`llm/stream` 和三个 `tools/*` 事件是 waterfall(瀑布式事件),其监听器必须调用 `next()` 才能委托下去;`agent/turn-stopping` 是 serial 事件,没有 `next()`。 输入通过同一个 inbox 到达驱动器。有些消息会立即唤醒它;注入的上下文会留在 inbox 中,直到另一条消息将其唤醒。 `agent/pre-step` 决定模型看到什么。监听器可以改写已领取的消息,也可以直接拒绝它们;首次领取被拒绝或被改写为空时,仍会关闭一个不含步骤的持久轮次,因此日志会记录这次尝试。enter 决策还可以设置 `startsRequestSeries` 来开启独立的模型消息序列:loop 会随之记录一个新的 `request/header`(原因为 `series`,或在封装同时变化时为携带 `startsSeries: true` 的 `change`)。重建下游 enter 决策的监听器必须展开它(`{ ...decision, messages }`),该声明才能存活。每个步骤读取插件注册的提示词片段和工具 schema。 +`image/offload` 记录持久的图片 offload 水位。`request/header` 之后,若已准备的路由声明了请求图片预算且保留的图片出现位置超过该预算,循环先追加推进再派生请求,因此模型收到的历史正是日志的投影。若 adapter 按精确字节计量后仍超预算,会以 `IMAGE_OFFLOAD_REQUIRED` 让本次尝试失败并说明还需省略多少最老的出现位置;循环按该数量推进并在任何 `agent/request-error` 监听器运行之前重建请求。水位永不回退([决定](../.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.zh.md))。 + 详情见[时序图](agent-lifecycle.zh.md)、[工具流水线](tool-execution-pipeline.zh.md)和[取消与错误恢复](subsystems/core.zh.md#the-agent-handle)。 ## 会话日志 diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 571af289d6..ffd7a7c7aa 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/event-producer-consumer.md -event-producer-consumer.md: d04a37ee71756619b36de192011a0053e5ceccb8 -event-producer-consumer.zh.md: 226c368517d989cba2eeac515ee2c544151e8005 +event-producer-consumer.md: 29a80986e2cd1c04f323e6c5ed286b5a5f764e6c +event-producer-consumer.zh.md: 6e7f1f194e863bf0e723fba43873c181fd077dfe diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index d04a37ee71..29a80986e2 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -43,12 +43,12 @@ 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:67`](../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: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) | | `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:52`](../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:62`](../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:74`](../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), [`headless`](../packages/bundle/headless), [`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:83`](../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) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../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:64`](../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:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `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), [`headless`](../packages/bundle/headless), [`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:85`](../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) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:298`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 226c368517..6e7f1f194e 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -45,12 +45,12 @@ | `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:67`](../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: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) | | `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:52`](../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:62`](../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:74`](../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), [`headless`](../packages/bundle/headless), [`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:83`](../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) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../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:64`](../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:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `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), [`headless`](../packages/bundle/headless), [`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:85`](../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) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:298`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index b6e2f8ac7f..21f3177593 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/persistence-catalog.md -persistence-catalog.md: 1c0c6919987c691b82c4639aff0f779c95dca83c -persistence-catalog.zh.md: 4cc8ba5b7fc76708a80285013ebcbb03fe3e8e4a +persistence-catalog.md: ea6c0dbe5be9b4d4332c8de91d19b3f3f90599ef +persistence-catalog.zh.md: 36da7743c1d404d128ebe36e49303f51291a1b2e diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 1c0c691998..ea6c0dbe5b 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -90,7 +90,7 @@ export type SessionEvent = { }[T] ``` -Sources: [`packages/core/session/src/types.ts:368`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:375`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:404`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:436`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:393`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:400`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:429`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:461`](../packages/core/session/src/types.ts) ## Events @@ -215,7 +215,7 @@ Source: [`packages/interaction/user-approval/src/index.ts:33`](../packages/inter Types: [StreamChunk](subsystems/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:291`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:305`](../packages/core/session/src/types.ts) @@ -237,7 +237,7 @@ Source: [`packages/core/session/src/types.ts:291`](../packages/core/session/src/ Types: [TokenUsage](subsystems/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:302`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:316`](../packages/core/session/src/types.ts) ### `command/*` @@ -474,6 +474,28 @@ Source: [`packages/hooks/hook-protocol/src/types.ts:19`](../packages/hooks/hook- Source: [`packages/hooks/hook-protocol/src/types.ts:31`](../packages/hooks/hook-protocol/src/types.ts) +### `image/*` + + + +#### `image/offload` — log-only + +```ts persistence-catalog +/** + * Advances the durable image offload watermark before a request in step + * `step` of turn `turn` is dispatched. Every image occurrence positioned at + * or before `watermark` derives with `offloaded: true`, so each route sends + * its placeholder text instead of the image; occurrences after it stay + * retained. The watermark only advances: each event names a position + * strictly after the previous one, and no later budget, route change, or + * compaction moves it back. It is a log-only event that changes the derived + * surface, so a build that does not know the type refuses the log. + */ +'image/offload': { turn: number; step: number; watermark: ImageOccurrencePosition } +``` + +Source: [`packages/core/session/src/types.ts:366`](../packages/core/session/src/types.ts) + ### `llm/*` @@ -563,7 +585,7 @@ Source: [`packages/plan/plan-mode/src/index.ts:46`](../packages/plan/plan-mode/s 'request/context': RequestContext ``` -Source: [`packages/core/session/src/types.ts:341`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:355`](../packages/core/session/src/types.ts) @@ -582,7 +604,7 @@ Source: [`packages/core/session/src/types.ts:341`](../packages/core/session/src/ } ``` -Source: [`packages/core/session/src/types.ts:331`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:345`](../packages/core/session/src/types.ts) ### `sandbox/*` @@ -657,7 +679,7 @@ Source: [`packages/schedule/schedule/src/types.ts:219`](../packages/schedule/sch 'session/end-seed': Record ``` -Source: [`packages/core/session/src/types.ts:364`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:389`](../packages/core/session/src/types.ts) @@ -717,7 +739,7 @@ Source: [`packages/session/session-log-deepseek/src/types.ts:57`](../packages/se 'step/end': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:281`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:295`](../packages/core/session/src/types.ts) @@ -728,7 +750,7 @@ Source: [`packages/core/session/src/types.ts:281`](../packages/core/session/src/ 'step/start': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:279`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:293`](../packages/core/session/src/types.ts) ### `subagent/*` @@ -859,7 +881,7 @@ Source: [`packages/todo/tool-todo/src/types.ts:31`](../packages/todo/tool-todo/s Types: [ToolCallId](subsystems/core.md) -Source: [`packages/core/session/src/types.ts:308`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:322`](../packages/core/session/src/types.ts) @@ -934,7 +956,7 @@ Source: [`packages/core/tools/src/types.ts:40`](../packages/core/tools/src/types } ``` -Source: [`packages/core/session/src/types.ts:320`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:334`](../packages/core/session/src/types.ts) ### `tool-workflow/*` @@ -1014,7 +1036,7 @@ Source: [`packages/workflow/tool-workflow/src/types.ts:47`](../packages/workflow Types: [TurnEndReason](subsystems/session.md) -Source: [`packages/core/session/src/types.ts:277`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:291`](../packages/core/session/src/types.ts) @@ -1030,7 +1052,7 @@ Source: [`packages/core/session/src/types.ts:277`](../packages/core/session/src/ 'turn/start': { turn: number } ``` -Source: [`packages/core/session/src/types.ts:268`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:282`](../packages/core/session/src/types.ts) ### `user/*` @@ -1049,7 +1071,7 @@ Source: [`packages/core/session/src/types.ts:268`](../packages/core/session/src/ 'user/message': UserMessage ``` -Source: [`packages/core/session/src/types.ts:289`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:303`](../packages/core/session/src/types.ts) ### `web/*` diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 4cc8ba5b7f..36da7743c1 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -476,6 +476,28 @@ export type SessionEvent = { 来源:[`packages/hooks/hook-protocol/src/types.ts:31`](../packages/hooks/hook-protocol/src/types.ts) +### `image/*` + + + +#### `image/offload` — log-only + +```ts persistence-catalog +/** + * Advances the durable image offload watermark before a request in step + * `step` of turn `turn` is dispatched. Every image occurrence positioned at + * or before `watermark` derives with `offloaded: true`, so each route sends + * its placeholder text instead of the image; occurrences after it stay + * retained. The watermark only advances: each event names a position + * strictly after the previous one, and no later budget, route change, or + * compaction moves it back. It is a log-only event that changes the derived + * surface, so a build that does not know the type refuses the log. + */ +'image/offload': { turn: number; step: number; watermark: ImageOccurrencePosition } +``` + +来源:[`packages/core/session/src/types.ts:366`](../packages/core/session/src/types.ts) + ### `llm/*` diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index 3dac641914..08cd54dffe 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/core.md -core.md: 170268351454643544d5ab20e3d4b6a8fd8c6c69 -core.zh.md: 048f7ed5f2136bb583c0308635e5cddde2b96785 +core.md: b193b306188e54d4e32985ae2274cfdbc96ea58c +core.zh.md: 3de620f9bbf48b6affd5797718130baca1de757c diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index 1702683514..b193b30618 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -252,7 +252,7 @@ type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact' A `Session` is an **append-only log** of typed `SessionEvent`s — the single source of truth. The LLM message history is *derived* from the log (`deriveMessages()`), not stored separately. Every entry carries a monotonic `seq`, a `time`, and a `type`-discriminated `data` payload; surface variants may also list cited earlier events in `sourceEventSeqs` and carry a `surfaceOp`. -The `SessionEvent` envelope's exact conditional fields, the twelve core event variants (`turn/start`, `turn/end`, `step/start`, `step/end`, `user/message`, `assistant/chunk`, `assistant/message`, `tool/call`, `tool/result`, `request/header`, `request/context`, `session/end-seed`), the `deriveMessages()` projection rules, the `TurnEndReason` reasons, and the execution-enclosure and standalone-event rules are on **[session.md](session.md)**. How the log is made durable — the `SessionPersistence` interface, JSONL provider, `session/flush` checkpoint, crash recovery, and `SessionHeader` — is on **[persistence.md](persistence.md)**. +The `SessionEvent` envelope's exact conditional fields, the thirteen core event variants (`turn/start`, `turn/end`, `step/start`, `step/end`, `user/message`, `assistant/chunk`, `assistant/message`, `tool/call`, `tool/result`, `request/header`, `request/context`, `image/offload`, `session/end-seed`), the `deriveMessages()` projection rules, the `TurnEndReason` reasons, and the execution-enclosure and standalone-event rules are on **[session.md](session.md)**. How the log is made durable — the `SessionPersistence` interface, JSONL provider, `session/flush` checkpoint, crash recovery, and `SessionHeader` — is on **[persistence.md](persistence.md)**. ## `ToolDefinition` diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index 048f7ed5f2..3de620f9bb 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -260,7 +260,7 @@ type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact' `Session` 是一份类型化 `SessionEvent` 的**仅追加日志**——唯一的真源。LLM 消息历史从日志*派生*(`deriveMessages()`),而非单独存储。每个条目携带单调的 `seq`、`time` 与按 `type` 判别的 `data` payload;surface 变体还可以在 `sourceEventSeqs` 中列出被引用的较早事件,并携带 `surfaceOp`。 -`SessionEvent` 信封的确切条件字段、十二种核心事件变体(`turn/start`、`turn/end`、`step/start`、`step/end`、`user/message`、`assistant/chunk`、`assistant/message`、`tool/call`、`tool/result`、`request/header`、`request/context`、`session/end-seed`)、`deriveMessages()` 投影规则、`TurnEndReason` 原因以及执行封闭和独立事件规则都在 **[session.md](session.zh.md)** 中。日志如何持久化——`SessionPersistence` 接口、JSONL provider、`session/flush` 检查点、崩溃恢复与 `SessionHeader`——则在 **[persistence.md](persistence.zh.md)** 中。 +`SessionEvent` 信封的确切条件字段、十三种核心事件变体(`turn/start`、`turn/end`、`step/start`、`step/end`、`user/message`、`assistant/chunk`、`assistant/message`、`tool/call`、`tool/result`、`request/header`、`request/context`、`image/offload`、`session/end-seed`)、`deriveMessages()` 投影规则、`TurnEndReason` 原因以及执行封闭和独立事件规则都在 **[session.md](session.zh.md)** 中。日志如何持久化——`SessionPersistence` 接口、JSONL provider、`session/flush` 检查点、崩溃恢复与 `SessionHeader`——则在 **[persistence.md](persistence.zh.md)** 中。 ## `ToolDefinition` diff --git a/docs/subsystems/llm-streaming.i18n.yaml b/docs/subsystems/llm-streaming.i18n.yaml index ea90fbb43d..e6032cee1e 100644 --- a/docs/subsystems/llm-streaming.i18n.yaml +++ b/docs/subsystems/llm-streaming.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/llm-streaming.md -llm-streaming.md: 6867ae292d77474bcedc1466ae0ce6b1fc1c92d3 -llm-streaming.zh.md: b75e24f2e9010b4fb08c035f14bc4e91dc3971ef +llm-streaming.md: 86b80be929d0821445b3ad86294e918c89f79b20 +llm-streaming.zh.md: 76e636e1c20824b7b9fdd90c0a7128464df3db3f diff --git a/docs/subsystems/llm-streaming.md b/docs/subsystems/llm-streaming.md index 6867ae292d..86b80be929 100644 --- a/docs/subsystems/llm-streaming.md +++ b/docs/subsystems/llm-streaming.md @@ -233,12 +233,19 @@ interface LlmFailure { readonly providerRetryAfterMs?: number /** Opaque provider-issued request identifier for diagnostics. */ readonly requestId?: ProviderRequestId + /** + * With code `IMAGE_OFFLOAD_REQUIRED`: how many more of the oldest retained + * image occurrences the route needs offloaded before the same request fits + * its exact byte accounting. The agent loop advances the durable watermark + * by this count and rebuilds the request. + */ + readonly offloadImages?: number } ``` ## Request-image pricing -An adapter whose provider charges visual tokens for request images declares per-route pricing by overriding `LlmAdapter.imageRequestPricing`, and `ctx.llm.imageRequestPricing(provider, model)` resolves it synchronously for consumers. The token meter resolves the routed model's pricing on every measurement so compaction pressure, retention, and range selection price image history as the routed request actually sends it; the DeepSeek adapter reproduces its own request projection (per-model pixel budget, oldest-first offload) and prices retained images with the published v4 vision accounting, while provider usage remains the authoritative anchor for completed requests. +An adapter whose provider charges visual tokens for request images declares per-route pricing by overriding `LlmAdapter.imageRequestPricing`, and `ctx.llm.imageRequestPricing(provider, model)` resolves it synchronously for consumers. The token meter resolves the routed model's pricing on every measurement so compaction pressure, retention, and range selection price image history as the routed request actually sends it; the DeepSeek adapter prices each retained occurrence at its per-model pixel-budget projection with the published v4 vision accounting and each occurrence the session's `image/offload` watermark marks offloaded as its placeholder text, while provider usage remains the authoritative anchor for completed requests. ```ts type-equiv /** @@ -267,10 +274,11 @@ interface LlmImageRequestPrice { interface LlmImageRequestPricing { /** * Price every image occurrence of one request projection. - * @param images - durable image references in request order, one entry per occurrence. + * @param images - surface image blocks in request order, one entry per occurrence; an `offloaded` block + * is priced as its placeholder text. * @returns one price per occurrence, aligned by index with `images`. */ - priceImages(images: readonly ImageAttachmentRef[]): readonly LlmImageRequestPrice[] + priceImages(images: readonly ImageBlock[]): readonly LlmImageRequestPrice[] } ``` @@ -551,6 +559,8 @@ interface LlmResolvedModelInfo extends LlmModelInfo { defaultMaxTokens?: number /** Adapter-owned selectable reasoning levels when exposed. */ reasoning?: LlmModelReasoningInfo + /** Request-image budget the route enforces; absent for routes that never offload. */ + imageRequest?: LlmImageRequestBudget } ``` @@ -739,6 +749,8 @@ interface PreparedLlmCall { readonly context?: LlmModelContext /** Exact model modalities captured with the adapter dispatch generation. */ readonly inputModalities?: readonly ModelModality[] + /** Detached request-image budget the route enforces, when it declares one. */ + readonly imageRequest?: LlmImageRequestBudget /** Config fields materialized by the captured adapter rather than proposed by the caller. */ readonly adapterDefaults: LlmCallConfigAdapterDefaults /** diff --git a/docs/subsystems/llm-streaming.zh.md b/docs/subsystems/llm-streaming.zh.md index b75e24f2e9..76e636e1c2 100644 --- a/docs/subsystems/llm-streaming.zh.md +++ b/docs/subsystems/llm-streaming.zh.md @@ -235,12 +235,19 @@ interface LlmFailure { readonly providerRetryAfterMs?: number /** Opaque provider-issued request identifier for diagnostics. */ readonly requestId?: ProviderRequestId + /** + * With code `IMAGE_OFFLOAD_REQUIRED`: how many more of the oldest retained + * image occurrences the route needs offloaded before the same request fits + * its exact byte accounting. The agent loop advances the durable watermark + * by this count and rebuilds the request. + */ + readonly offloadImages?: number } ``` ## 请求图片定价 -提供方对请求图片收取视觉 token 的适配器通过覆写 `LlmAdapter.imageRequestPricing` 声明按路由的定价,消费方经 `ctx.llm.imageRequestPricing(provider, model)` 同步解析。token 计量服务在每次计量时解析路由模型的定价,使 compaction 的压力、保留与选段都按路由请求实际发送的形式为图片历史计价;DeepSeek 适配器复现自身的请求投影(按模型的像素预算、最旧优先 offload),并用官方公布的 v4 视觉计量为保留图片定价,已完成请求仍以 provider usage 为权威锚点。 +提供方对请求图片收取视觉 token 的适配器通过覆写 `LlmAdapter.imageRequestPricing` 声明按路由的定价,消费方经 `ctx.llm.imageRequestPricing(provider, model)` 同步解析。token 计量服务在每次计量时解析路由模型的定价,使 compaction 的压力、保留与选段都按路由请求实际发送的形式为图片历史计价;DeepSeek 适配器按模型像素预算的投影用官方公布的 v4 视觉计量为每个保留的出现位置定价,并把会话 `image/offload` 水位标记为已省略的出现位置按其占位文本定价,已完成请求仍以 provider usage 为权威锚点。 ```ts type-equiv /** @@ -269,10 +276,11 @@ interface LlmImageRequestPrice { interface LlmImageRequestPricing { /** * Price every image occurrence of one request projection. - * @param images - durable image references in request order, one entry per occurrence. + * @param images - surface image blocks in request order, one entry per occurrence; an `offloaded` block + * is priced as its placeholder text. * @returns one price per occurrence, aligned by index with `images`. */ - priceImages(images: readonly ImageAttachmentRef[]): readonly LlmImageRequestPrice[] + priceImages(images: readonly ImageBlock[]): readonly LlmImageRequestPrice[] } ``` @@ -557,6 +565,8 @@ interface LlmResolvedModelInfo extends LlmModelInfo { defaultMaxTokens?: number /** Adapter-owned selectable reasoning levels when exposed. */ reasoning?: LlmModelReasoningInfo + /** Request-image budget the route enforces; absent for routes that never offload. */ + imageRequest?: LlmImageRequestBudget } ``` @@ -745,6 +755,8 @@ interface PreparedLlmCall { readonly context?: LlmModelContext /** Exact model modalities captured with the adapter dispatch generation. */ readonly inputModalities?: readonly ModelModality[] + /** Detached request-image budget the route enforces, when it declares one. */ + readonly imageRequest?: LlmImageRequestBudget /** Config fields materialized by the captured adapter rather than proposed by the caller. */ readonly adapterDefaults: LlmCallConfigAdapterDefaults /** diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index f9653cecea..27cedc0f34 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session.md -session.md: 48a80ccb92af774db2f5bedea8e2e00043932d31 -session.zh.md: df5cfd631a15bfe015c766842956cc0c344c2ccd +session.md: 2eaec19f5133003ee49bd2821ce71af9c2112974 +session.zh.md: 3574ebd7fd3cba48cf2fd43f8952545de8f636fb diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index 48a80ccb92..2eaec19f51 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -105,6 +105,17 @@ interface SessionEventMap { * changes. It does not participate in request reconstruction or header equality. */ 'request/context': RequestContext + /** + * Advances the durable image offload watermark before a request in step + * `step` of turn `turn` is dispatched. Every image occurrence positioned at + * or before `watermark` derives with `offloaded: true`, so each route sends + * its placeholder text instead of the image; occurrences after it stay + * retained. The watermark only advances: each event names a position + * strictly after the previous one, and no later budget, route change, or + * compaction moves it back. It is a log-only event that changes the derived + * surface, so a build that does not know the type refuses the log. + */ + 'image/offload': { turn: number; step: number; watermark: ImageOccurrencePosition } /** * Marks the end of a constructor seed. Events before it have smaller seq * values and came from the seed (resume, fork, or replay); this lifecycle @@ -175,6 +186,26 @@ interface RequestContext { } ``` +### The image offload event: `image/offload` + +The agent loop appends `image/offload` inside a step, after `request/header` and before deriving the request, when the prepared route's request-image budget is exceeded by the surface's retained image occurrences, or when an adapter fails an attempt with `IMAGE_OFFLOAD_REQUIRED`. Its `watermark` names the last offloaded occurrence; `deriveMessages()` marks every occurrence positioned at or before it `offloaded: true`, and each route renders those marks as placeholder text. `Session.append` and seeding reject a watermark that is malformed, names an event outside the log, or does not advance strictly past the previous one, and `session.imageOffloadWatermark()` folds the latest. Like `request/header`, it is not a `SurfaceEventType`; unlike it, it changes the derived surface, so it stays required-on-read ([decision](../../.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.md)). + +```ts type-equiv +/** + * Durable position of one image occurrence on the model-visible surface: the + * seq of the event carrying it and the block path inside that event's + * content (the top-level block index, followed by the index inside a + * tool-result block). Positions order by seq, then by path, so a newer + * event always lies after an older one regardless of surface replacements. + */ +interface ImageOccurrencePosition { + /** Seq of the `user/message` or `tool/result` event carrying the occurrence. */ + seq: SessionSeq + /** Block index path inside that event's message content. */ + path: number[] +} +``` + ## `SessionEvent` — one log entry A proper discriminated union over `type` (not independent `type`/`data` unions), so `switch (event.type)` narrows `event.data` without casts. `seq` is the monotonic position in the log (`seq = log.length`); `time` is epoch ms. @@ -531,6 +562,13 @@ declare class Session { * @returns the latest immutable route metadata. */ requestContext(): RequestContext | undefined; + /** + * The durable image offload watermark in force: every image occurrence + * positioned at or before it derives as offloaded. Undefined until the + * first `image/offload` event. + * @returns the frozen latest watermark, or undefined when nothing is offloaded. + */ + imageOffloadWatermark(): ImageOccurrencePosition | undefined; /** * Derive the LLM message history by walking the ordered sequences of * message-producing events maintained by `surfaceOp` markers. The @@ -542,7 +580,9 @@ declare class Session { * * CACHED: each surface node is projected exactly once, when first seen — a * call costs O(new nodes), and a surface rewrite (a `replace`; - * {@link SessionSurface.replaceGeneration}) rebuilds. The returned array is + * {@link SessionSurface.replaceGeneration}) or an `image/offload` advance + * rebuilds, and image occurrences at or before the watermark derive with + * `offloaded: true` ({@link markImageOffload}). The returned array is * a fresh snapshot per call (later appends never grow an array a caller * already holds); the `Message` objects in it are SHARED and **deep-frozen**. * Their content reuses the already frozen durable event data, so the cache diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index df5cfd631a..3574ebd7fd 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -105,6 +105,17 @@ interface SessionEventMap { * changes. It does not participate in request reconstruction or header equality. */ 'request/context': RequestContext + /** + * Advances the durable image offload watermark before a request in step + * `step` of turn `turn` is dispatched. Every image occurrence positioned at + * or before `watermark` derives with `offloaded: true`, so each route sends + * its placeholder text instead of the image; occurrences after it stay + * retained. The watermark only advances: each event names a position + * strictly after the previous one, and no later budget, route change, or + * compaction moves it back. It is a log-only event that changes the derived + * surface, so a build that does not know the type refuses the log. + */ + 'image/offload': { turn: number; step: number; watermark: ImageOccurrencePosition } /** * Marks the end of a constructor seed. Events before it have smaller seq * values and came from the seed (resume, fork, or replay); this lifecycle @@ -175,6 +186,26 @@ interface RequestContext { } ``` +### 图片 offload 事件:`image/offload` + +当已准备路由的请求图片预算被表层上保留的图片出现位置超过,或 adapter 以 `IMAGE_OFFLOAD_REQUIRED` 让一次尝试失败时,agent loop 会在 step 内、`request/header` 之后、派生请求之前追加 `image/offload`。其 `watermark` 指向最后一个被省略的出现位置;`deriveMessages()` 把位于它及之前的每个出现位置标为 `offloaded: true`,每条路由把这些标记渲染为占位文本。`Session.append` 与 seed 拒绝畸形、指向日志之外事件或没有严格越过前一条的水位,`session.imageOffloadWatermark()` 折叠最新值。它和 `request/header` 一样不是 `SurfaceEventType`;与之不同的是它改变派生表层,因此读取时必须识别([决定](../../.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.zh.md))。 + +```ts type-equiv +/** + * Durable position of one image occurrence on the model-visible surface: the + * seq of the event carrying it and the block path inside that event's + * content (the top-level block index, followed by the index inside a + * tool-result block). Positions order by seq, then by path, so a newer + * event always lies after an older one regardless of surface replacements. + */ +interface ImageOccurrencePosition { + /** Seq of the `user/message` or `tool/result` event carrying the occurrence. */ + seq: SessionSeq + /** Block index path inside that event's message content. */ + path: number[] +} +``` + ## `SessionEvent`:一条日志条目 基于 `type` 的真正可辨识联合(而非独立的 `type`/`data` 联合),因此 `switch (event.type)` 能直接收窄 `event.data`,无需类型断言。`seq` 是日志中的单调递增位置(`seq = log.length`);`time` 为 epoch 毫秒。 @@ -533,6 +564,13 @@ declare class Session { * @returns the latest immutable route metadata. */ requestContext(): RequestContext | undefined; + /** + * The durable image offload watermark in force: every image occurrence + * positioned at or before it derives as offloaded. Undefined until the + * first `image/offload` event. + * @returns the frozen latest watermark, or undefined when nothing is offloaded. + */ + imageOffloadWatermark(): ImageOccurrencePosition | undefined; /** * Derive the LLM message history by walking the ordered sequences of * message-producing events maintained by `surfaceOp` markers. The @@ -544,7 +582,9 @@ declare class Session { * * CACHED: each surface node is projected exactly once, when first seen — a * call costs O(new nodes), and a surface rewrite (a `replace`; - * {@link SessionSurface.replaceGeneration}) rebuilds. The returned array is + * {@link SessionSurface.replaceGeneration}) or an `image/offload` advance + * rebuilds, and image occurrences at or before the watermark derive with + * `offloaded: true` ({@link markImageOffload}). The returned array is * a fresh snapshot per call (later appends never grow an array a caller * already holds); the `Message` objects in it are SHARED and **deep-frozen**. * Their content reuses the already frozen durable event data, so the cache diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml index 7541d9a310..3696f14548 100644 --- a/packages/core/agent-loop/README.i18n.yaml +++ b/packages/core/agent-loop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/agent-loop/README.md -README.md: 5e4ab0a741f0b7dae821e52f1a9b0a1faa90ceed -README.zh.md: ef47f897978c8254242075a559f56faa2dceca19 +README.md: 340e05be64f5358fb2b90fe4007ebb29e0d712cc +README.zh.md: a7fe9d9cde9c2c555fa022ecfdab4ff72dd67690 diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 5e4ab0a741..340e05be64 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -88,7 +88,7 @@ The package is the one concrete implementation of the public `Agent` contract. I ### Request headers and adapter defaults -After `agent/request`, `ctx.llm.prepareCall()` validates adapter-owned fields and resolves reasoning-effort and output-token defaults under the active turn signal. The loop retains that exact adapter through resolution, `request/header` logging, and dispatch. It writes a full header for the first request, a changed envelope, an explicit message-series start, a request after surface replacement, and resume; unchanged steps, retries, and ordinary later turns in the same series inherit the latest header. Before the next waterfall, the loop removes adapter-default fields so the current route resolves them again, while explicit settings persist. An unhandled route still fails with `NO_ADAPTER`. +After `agent/request`, `ctx.llm.prepareCall()` validates adapter-owned fields and resolves reasoning-effort and output-token defaults under the active turn signal. The loop retains that exact adapter through resolution, `request/header` logging, and dispatch. It writes a full header for the first request, a changed envelope, an explicit message-series start, a request after surface replacement, and resume; unchanged steps, retries, and ordinary later turns in the same series inherit the latest header. Before the next waterfall, the loop removes adapter-default fields so the current route resolves them again, while explicit settings persist. An unhandled route still fails with `NO_ADAPTER`. When the prepared route declares a request-image budget (`PreparedLlmCall.imageRequest`) that the surface's retained image occurrences exceed, the loop appends an `image/offload` advance before deriving the request; an adapter whose exact accounting still overflows fails the attempt with `IMAGE_OFFLOAD_REQUIRED`, and the loop advances the watermark by the named count and rebuilds the request before `agent/request-error` runs. ### Source map diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md index ef47f89797..a7fe9d9cde 100644 --- a/packages/core/agent-loop/README.zh.md +++ b/packages/core/agent-loop/README.zh.md @@ -88,7 +88,7 @@ const handle = await ctx.agents.create({ ### 请求 header 与适配器默认值 -`agent/request` 返回后,`ctx.llm.prepareCall()` 会在活跃轮次信号下校验适配器持有的字段,并解析推理强度和输出 token 默认值。循环会在解析、`request/header` 记录与分派期间保留同一个适配器。循环会为首次请求、变化的 envelope、显式消息序列起点、表层替换后的请求及恢复写入完整 header;同一序列内内容未变的步骤、重试与普通后续轮次继承最新 header。下一次 waterfall 前,循环移除适配器默认字段,使当前路由重新解析它们;显式设置则保留。未处理的路由仍以 `NO_ADAPTER` 失败。 +`agent/request` 返回后,`ctx.llm.prepareCall()` 会在活跃轮次信号下校验适配器持有的字段,并解析推理强度和输出 token 默认值。循环会在解析、`request/header` 记录与分派期间保留同一个适配器。循环会为首次请求、变化的 envelope、显式消息序列起点、表层替换后的请求及恢复写入完整 header;同一序列内内容未变的步骤、重试与普通后续轮次继承最新 header。下一次 waterfall 前,循环移除适配器默认字段,使当前路由重新解析它们;显式设置则保留。未处理的路由仍以 `NO_ADAPTER` 失败。当已准备的路由声明了请求图片预算(`PreparedLlmCall.imageRequest`)且表层上保留的图片出现位置超过该预算时,循环会在派生请求前追加一条 `image/offload` 推进;adapter 按精确计量仍超预算时以 `IMAGE_OFFLOAD_REQUIRED` 让本次尝试失败,循环按其说明的数量推进水位并在 `agent/request-error` 运行前重建请求。 ### 源码地图 diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 1b8448e693..0192f9e0bf 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -16,19 +16,22 @@ import type { RequestErrorAction, } from '@deepseek-ai/dsh-agent' import { Inbox, agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent' -import type { GenerateOptions, LlmCallConfig, Message, PreparedLlmCall } from '@deepseek-ai/dsh-llm' +import type { GenerateOptions, LlmCallConfig, LlmImageRequestBudget, PreparedLlmCall, RetainedImageOccurrence } from '@deepseek-ai/dsh-llm' import { BlockAssembler, + IMAGE_OFFLOAD_REQUIRED_CODE, LlmError, createAssistantMessage, errorChain, markAgentLoopRequest, + planImageOffload, + visitImageBlocks, } from '@deepseek-ai/dsh-llm' import { deepFreeze } from '@deepseek-ai/dsh-util-values' import type { Scope } from '@deepseek-ai/dsh-scope' import { createScope } from '@deepseek-ai/dsh-scope' -import type { EpochHeader, RequestContext, Session, SessionId, SessionSeq, TurnEndReason, UserMessage } from '@deepseek-ai/dsh-session' -import { canonicalHeader, headerEquals } from '@deepseek-ai/dsh-session' +import type { EpochHeader, ImageOccurrencePosition, RequestContext, Session, SessionId, SessionSeq, TurnEndReason, UserMessage } from '@deepseek-ai/dsh-session' +import { canonicalHeader, compareImagePositions, headerEquals } from '@deepseek-ai/dsh-session' import { joinContextSections, renderContextSections, renderPrompt } from '@deepseek-ai/dsh-system-prompt' import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-session-projection' @@ -352,7 +355,6 @@ export class ReactLoopAgent implements Agent { step, assembly.tools, system, - this.session.deriveMessages(), startsRequestSeries, surfaceGeneration, signal, @@ -388,6 +390,12 @@ export class ReactLoopAgent implements Agent { throw error } const finish = assembler.finish + if (finish.kind === 'error' + && finish.failure.code === IMAGE_OFFLOAD_REQUIRED_CODE + && finish.failure.offloadImages !== undefined + && this.advanceImageOffload(turn, step, finish.failure.offloadImages)) { + continue + } if (finish.kind === 'error' || finish.kind === 'aborted') { const action = await this.dispatch.waterfall( 'agent/request-error', { @@ -446,7 +454,6 @@ export class ReactLoopAgent implements Agent { step: number, tools: GenerateOptions['tools'] & object, system: string, - boundaryMessages: Message[], startsRequestSeries: boolean, surfaceGeneration: number, signal: AbortSignal, @@ -530,11 +537,14 @@ export class ReactLoopAgent implements Agent { || previousContext.contextWindow !== requestContext.contextWindow) { session.append('request/context', requestContext) } + if (preparedCall?.imageRequest !== undefined) { + this.planImageOffload(turn, step, preparedCall.imageRequest) + } signal.throwIfAborted() const request = markAgentLoopRequest(deepFreeze({ ...header.config, - messages: boundaryMessages, + messages: session.deriveMessages(), ...header.system !== undefined ? { system: header.system } : {}, ...header.tools !== undefined ? { tools: header.tools } : {}, sessionId: this.session.id, @@ -542,4 +552,48 @@ export class ReactLoopAgent implements Agent { })) return { request, ...preparedCall === undefined ? {} : { preparedCall } } } + + /** + * Every image occurrence the surface still sends, in log order (oldest + * first), with the durable position an `image/offload` advance would name. + */ + private retainedImageOccurrences(): RetainedImageOccurrence[] { + const { session } = this + const watermark = session.imageOffloadWatermark() + const retained: RetainedImageOccurrence[] = [] + for (const seq of session.surface.nodes) { + // oxlint-disable-next-line typescript/no-non-null-assertion -- surface nodes index the durable log + const message = session.deriveEventMessage(session.eventAt(seq)!) + if (message === null) continue + visitImageBlocks(message.content, (block, path) => { + const position = { seq, path: [...path] } + if (watermark !== undefined && compareImagePositions(position, watermark) <= 0) return + retained.push({ position, bytes: block.attachment.bytes }) + }) + } + return retained.sort((a, b) => compareImagePositions(a.position, b.position)) + } + + /** + * Advance the durable watermark when the route budget is exceeded by the + * retained occurrences, so the request derived next carries the offloaded + * set the log records. + */ + private planImageOffload(turn: number, step: number, budget: LlmImageRequestBudget): void { + const watermark = planImageOffload(this.retainedImageOccurrences(), budget) + if (watermark !== undefined) this.session.append('image/offload', { turn, step, watermark }) + } + + /** + * Advance the watermark past the `count` oldest retained occurrences an + * adapter's exact accounting still cannot send. + * @returns whether any occurrence remained to offload, so the step can rebuild the request. + */ + private advanceImageOffload(turn: number, step: number, count: number): boolean { + const retained = this.retainedImageOccurrences() + const last = retained[Math.min(count, retained.length) - 1] + if (last === undefined) return false + this.session.append('image/offload', { turn, step, watermark: last.position }) + return true + } } diff --git a/packages/core/agent-loop/tests/image-offload.spec.ts b/packages/core/agent-loop/tests/image-offload.spec.ts new file mode 100644 index 0000000000..916120fbf3 --- /dev/null +++ b/packages/core/agent-loop/tests/image-offload.spec.ts @@ -0,0 +1,161 @@ +/** + * Durable image offload in the loop: the route budget advances the + * `image/offload` watermark before dispatch, the watermark never retreats, + * and an adapter's `IMAGE_OFFLOAD_REQUIRED` failure advances it by the named + * count and rebuilds the request. + */ + +import { describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import LlmRuntime, { createAssistantMessage, createUserMessage, IMAGE_OFFLOAD_REQUIRED_CODE, LlmError, ToolCallId } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, GenerateOptions, LlmImageRequestBudget } from '@deepseek-ai/dsh-llm' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import type { Session } from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRuntime from '@deepseek-ai/dsh-tools' +import { MockAdapter, textResponse } from './mock-adapter.ts' + +async function harness(adapter: MockAdapter): Promise { + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRuntime) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [] }) + ctx.llm.registerAdapter(['mock'], adapter) + return ctx +} + +function image(name: string, bytes: number): Extract { + return { + type: 'image', + attachment: { attachmentId: `sha256:${'a'.repeat(64)}` as never, name, mediaType: 'image/png', bytes, width: 1, height: 1 }, + } +} + +function offloadedNames(options: GenerateOptions): string[] { + const names: string[] = [] + for (const message of options.messages) { + for (const block of message.content) { + if (block.type === 'image' && block.offloaded === true) names.push(block.attachment.name ?? '') + if (block.type === 'tool-result') { + for (const inner of block.content) { + if (inner.type === 'image' && inner.offloaded === true) names.push(inner.attachment.name ?? '') + } + } + } + } + return names +} + +function offloadEvents(session: Session): { turn: number; step: number; seq: number; path: number[] }[] { + return session.snapshotEvents() + .filter(event => event.type === 'image/offload') + .map(event => ({ + turn: event.data.turn, + step: event.data.step, + seq: Number(event.data.watermark.seq), + path: event.data.watermark.path, + })) +} + +const budget: LlmImageRequestBudget = { representation: 'raw', maxBytes: 10, byteQuantum: 4 } + +describe('image/offload in the agent loop', () => { + it('advances the watermark before dispatch and never retreats when the budget has headroom again', async () => { + const adapter = new MockAdapter([textResponse('one'), textResponse('two')], undefined, undefined, budget) + const ctx = await harness(adapter) + const agent = await ctx.agentLoop.create(SessionId('offload-advance'), { provider: 'mock', model: 'mock' }) + + agent.followup(createUserMessage({ + content: [image('a', 4), { type: 'tool-result', toolCallId: ToolCallId('shot'), content: [image('b', 4)] }, image('c', 4)], + source: { kind: 'user' }, + })) + await agent.whenIdle() + + // 12 raw bytes exceed the 10-byte bound by 2, rounded up to one 4-byte quantum: 'a' alone + // removes 4 bytes but the quantum rule crosses into 'b'. + expect(offloadedNames(adapter.requests[0]!)).toEqual(['a', 'b']) + const events = offloadEvents(agent.session) + expect(events).toHaveLength(1) + expect(events[0]).toMatchObject({ turn: 1, step: 1, path: [1, 0] }) + const dispatchOrder = agent.session.snapshotEvents().map(event => event.type) + expect(dispatchOrder.indexOf('image/offload')).toBeGreaterThan(dispatchOrder.indexOf('request/header')) + expect(dispatchOrder.indexOf('image/offload')).toBeLessThan(dispatchOrder.indexOf('assistant/chunk')) + + // An empty-content assistant node derives no message and carries no occurrence. + agent.session.append('assistant/message', { + turn: 1, + step: 1, + message: createAssistantMessage({ content: [], source: { provider: 'mock', model: 'mock' } }), + }, { surfaceOp: 'append' }) + // The next turn adds no bytes beyond the bound, so the watermark holds, and the first + // request's offloaded set is still what the second request sends. + agent.followup(createUserMessage({ content: [{ type: 'text', text: 'again' }], source: { kind: 'user' } })) + await agent.whenIdle() + expect(offloadedNames(adapter.requests[1]!)).toEqual(['a', 'b']) + expect(offloadEvents(agent.session)).toHaveLength(1) + }) + + it('leaves a route without a declared budget alone', async () => { + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(adapter) + const agent = await ctx.agentLoop.create(SessionId('offload-none'), { provider: 'mock', model: 'mock' }) + agent.followup(createUserMessage({ content: [image('a', 400)], source: { kind: 'user' } })) + await agent.whenIdle() + expect(offloadedNames(adapter.requests[0]!)).toEqual([]) + expect(offloadEvents(agent.session)).toHaveLength(0) + }) + + it('advances by the count an IMAGE_OFFLOAD_REQUIRED failure names and rebuilds the request', async () => { + const adapter = new MockAdapter([ + () => { + throw new LlmError('inline budget exceeded', IMAGE_OFFLOAD_REQUIRED_CODE, { offloadImages: 2 }) + }, + textResponse('sent'), + ], undefined, undefined, { representation: 'raw', maxBytes: 100 }) + const ctx = await harness(adapter) + const agent = await ctx.agentLoop.create(SessionId('offload-required'), { provider: 'mock', model: 'mock' }) + const recoveries: string[] = [] + ctx.on('agent/request-error', async ({ failure }) => { + recoveries.push(failure.code) + }) + + agent.followup(createUserMessage({ + content: [image('a', 1), image('b', 1), image('c', 1)], source: { kind: 'user' }, + })) + await agent.whenIdle() + + expect(adapter.requests).toHaveLength(2) + expect(offloadedNames(adapter.requests[0]!)).toEqual([]) + expect(offloadedNames(adapter.requests[1]!)).toEqual(['a', 'b']) + expect(offloadEvents(agent.session)).toHaveLength(1) + expect(offloadEvents(agent.session)[0]).toMatchObject({ turn: 1, step: 1, path: [1] }) + expect(recoveries).toEqual([]) + expect(agent.session.snapshotEvents().filter(event => event.type === 'assistant/message')).toHaveLength(1) + }) + + it('surfaces IMAGE_OFFLOAD_REQUIRED as an ordinary failure once nothing remains to offload', async () => { + const adapter = new MockAdapter([ + () => { + throw new LlmError('still too large', IMAGE_OFFLOAD_REQUIRED_CODE, { offloadImages: 1 }) + }, + ]) + const ctx = await harness(adapter) + const agent = await ctx.agentLoop.create(SessionId('offload-exhausted'), { provider: 'mock', model: 'mock' }) + const recoveries: string[] = [] + ctx.on('agent/request-error', async ({ failure }) => { + recoveries.push(failure.code) + }) + agent.followup(createUserMessage({ content: [{ type: 'text', text: 'no images' }], source: { kind: 'user' } })) + await agent.whenIdle() + expect(recoveries).toEqual([IMAGE_OFFLOAD_REQUIRED_CODE]) + expect(offloadEvents(agent.session)).toHaveLength(0) + expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'error' } } }) + }) +}) diff --git a/packages/core/agent-loop/tests/mock-adapter.ts b/packages/core/agent-loop/tests/mock-adapter.ts index 7543fd21c1..09b2ab5bc5 100644 --- a/packages/core/agent-loop/tests/mock-adapter.ts +++ b/packages/core/agent-loop/tests/mock-adapter.ts @@ -1,4 +1,4 @@ -import type { GenerateOptions, LlmModelReasoningInfo, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm' +import type { GenerateOptions, LlmImageRequestBudget, LlmModelReasoningInfo, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm' import { ToolCallId, LlmAdapter } from '@deepseek-ai/dsh-llm' /** Helpers to write scripted responses tersely. */ @@ -76,6 +76,7 @@ export class MockAdapter extends LlmAdapter { private script: (StreamChunk[] | ((options: GenerateOptions) => StreamChunk[]) | 'hang' | 'hang-slow' | HangAfter)[], private readonly reasoning?: LlmModelReasoningInfo, private readonly defaultMaxTokens?: number, + private readonly imageRequest?: LlmImageRequestBudget, ) { super() } @@ -90,6 +91,7 @@ export class MockAdapter extends LlmAdapter { name: model, ...this.reasoning === undefined ? {} : { reasoning: this.reasoning }, ...this.defaultMaxTokens === undefined ? {} : { defaultMaxTokens: this.defaultMaxTokens }, + ...this.imageRequest === undefined ? {} : { inputModalities: ['text', 'image'], imageRequest: this.imageRequest }, }) } diff --git a/packages/core/session/README.i18n.yaml b/packages/core/session/README.i18n.yaml index 71c2828f9c..c5064e0d9b 100644 --- a/packages/core/session/README.i18n.yaml +++ b/packages/core/session/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/session/README.md -README.md: f5cf910854203021a619cc786dfd13705927ffc1 -README.zh.md: 385d7fd63a6e4dec9c23c9d38a352942d7dbc7f9 +README.md: 810bd9a0620d1491d9e8aeebf656a9ab1a47fc9f +README.zh.md: 9fd60641c59db28baea14fae523ce3e96bb5b91e diff --git a/packages/core/session/README.md b/packages/core/session/README.md index f5cf910854..810bd9a062 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -83,6 +83,8 @@ The package is built on event sourcing: a `Session` is an append-only log of typ `request/header` stores a full canonical snapshot of the non-history request envelope with reason `initial`, `resume`, `change`, or `series`. An explicit message-series start or a surface replacement writes a `series` snapshot when the envelope is unchanged; a simultaneous change uses `startsSeries: true`. Same-series steps, retries, and ordinary later turns inherit the latest snapshot. `adapterDefaults` distinguishes values resolved by the adapter from explicit settings, and `foldRequestHeader()` selects the latest snapshot. This self-contained record supports partial-window rendering and exact reconstruction at the cost of growth per message series; the [reconstructable-requests Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md) owns the detail. +`image/offload` records the durable image offload watermark the agent loop advances before dispatch when a route's request-image budget is exceeded: an `ImageOccurrencePosition` naming the last offloaded occurrence by event seq and block path. The derivation marks every occurrence at or before it `offloaded: true`, so each route sends its placeholder text instead of the image; the position only advances, and `Session.append` and seeding reject a malformed or non-advancing watermark. Because the event changes the derived surface it is required-on-read: a build without the type refuses the log rather than replaying images the model never saw ([decision](../../../.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.md)). + ### Source map | File | Role | @@ -91,6 +93,7 @@ The package is built on event sourcing: a `Session` is an append-only log of typ | [`src/types.ts`](src/types.ts) | `SessionEventMap`, `SessionEvent`, `UserMessage`, `SessionHeader`, `TurnEndReasonMap` | | [`src/surface.ts`](src/surface.ts) | Ordered surface projection, replacement validation, `deriveEventMessage` | | [`src/request-header.ts`](src/request-header.ts) | `request/header` folding and reconstruction | +| [`src/image-offload.ts`](src/image-offload.ts) | `image/offload` watermark validation, folding, and surface marking | | [`dsh-util-values`](../../util/values/README.md) | Shared lossless JSON validation and detached snapshots | | [`src/chunk-rows.ts`](src/chunk-rows.ts) | Shared compact-row storage codec for persistence backends | | [`src/repair.ts`](src/repair.ts) | Cold repair of crash-orphaned logs | @@ -106,7 +109,7 @@ Every append uses the shared iterative `snapshotJsonValue()` pass, which reads, ### The request header -The loop logs a full canonical `request/header` snapshot (call config, adapter defaults, rendered system prompt, assembled tool schemas) at each loop-instance boundary and on change; `foldRequestHeader(events)` reconstructs it by selecting the latest snapshot, making every conversation request a pure function of the log. Route metadata (`request/context`) is separate logged state appended only when the provider, model, or capacity differs. +The loop logs a full canonical `request/header` snapshot (call config, adapter defaults, rendered system prompt, assembled tool schemas) at each loop-instance boundary and on change; `foldRequestHeader(events)` reconstructs it by selecting the latest snapshot, making every conversation request a pure function of the log. Route metadata (`request/context`) is separate logged state appended only when the provider, model, or capacity differs. The image offload watermark (`image/offload`) is durable surface state: every image occurrence positioned at or before it derives with `offloaded: true`, each event must advance strictly past the previous one, and `session.imageOffloadWatermark()` folds the latest position. diff --git a/packages/core/session/README.zh.md b/packages/core/session/README.zh.md index 385d7fd63a..9fd60641c5 100644 --- a/packages/core/session/README.zh.md +++ b/packages/core/session/README.zh.md @@ -83,6 +83,8 @@ session.deriveMessages() // the derived model history `request/header` 存储非历史请求 envelope 的完整规范快照,原因为 `initial`、`resume`、`change` 或 `series`。显式消息序列起点或表层替换会在 envelope 不变时写入 `series` 快照;同时发生变化时使用 `startsSeries: true`。同一序列内的步骤、重试与普通后续轮次继承最新快照。`adapterDefaults` 区分由适配器解析的值与显式设置,`foldRequestHeader()` 选择最新快照。这种自包含记录以每个消息序列增加存储为代价,支持局部窗口渲染与精确重建;细节由[可重建请求 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md)负责。 +`image/offload` 记录 agent loop 在路由请求图片预算被超过时于分派前推进的持久图片 offload 水位:一个以事件序号加块路径指向最后一个被省略出现位置的 `ImageOccurrencePosition`。派生把位于水位及之前的每个出现位置标为 `offloaded: true`,于是每条路由发送其占位文本而不是图片;位置只会推进,`Session.append` 与 seed 都拒绝畸形或不推进的水位。该事件改变派生表层,因此读取时必须识别:不认识该类型的构建拒绝这份日志,而不是带着模型从未见过的图片重放([决定](../../../.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.zh.md))。 + ### 源码地图 | 文件 | 职责 | @@ -91,6 +93,7 @@ session.deriveMessages() // the derived model history | [`src/types.ts`](src/types.ts) | `SessionEventMap`、`SessionEvent`、`UserMessage`、`SessionHeader`、`TurnEndReasonMap` | | [`src/surface.ts`](src/surface.ts) | 有序 surface 投影、替换校验、`deriveEventMessage` | | [`src/request-header.ts`](src/request-header.ts) | `request/header` 折叠与重建 | +| [`src/image-offload.ts`](src/image-offload.ts) | `image/offload` 水位校验、折叠与表层标记 | | [`dsh-util-values`](../../util/values/README.zh.md) | 共享无损 JSON 校验与分离式快照 | | [`src/chunk-rows.ts`](src/chunk-rows.ts) | 供持久化后端使用的共享紧凑行存储编解码器 | | [`src/repair.ts`](src/repair.ts) | 崩溃遗留日志的冷修复 | @@ -106,7 +109,7 @@ session.deriveMessages() // the derived model history ### 请求头 -循环在每个循环实例边界及变更时记录完整规范 `request/header` 快照(调用配置、适配器默认值、渲染后的系统提示词、组装后的工具 schema);`foldRequestHeader(events)` 通过选择最新快照来重建它,使每个对话请求都成为日志的纯函数。路由元数据(`request/context`)是独立的已记录状态,仅在提供方、模型或容量变化时追加。 +循环在每个循环实例边界及变更时记录完整规范 `request/header` 快照(调用配置、适配器默认值、渲染后的系统提示词、组装后的工具 schema);`foldRequestHeader(events)` 通过选择最新快照来重建它,使每个对话请求都成为日志的纯函数。路由元数据(`request/context`)是独立的已记录状态,仅在提供方、模型或容量变化时追加。图片 offload 水位(`image/offload`)是持久的表层状态:位于水位及之前的每个图片出现位置派生时带 `offloaded: true`,每条事件必须严格越过前一条,`session.imageOffloadWatermark()` 折叠最新位置。 diff --git a/packages/core/session/src/image-offload.ts b/packages/core/session/src/image-offload.ts new file mode 100644 index 0000000000..d4b1dd759f --- /dev/null +++ b/packages/core/session/src/image-offload.ts @@ -0,0 +1,98 @@ +/** + * Durable image offload watermark: the `image/offload` fold, its append-time + * validation, and the per-node projection that marks image occurrences at or + * before the watermark as offloaded. + * + * @module @deepseek-ai/dsh-session/image-offload + */ + +import { compareImageBlockPaths, markOffloadedImages } from '@deepseek-ai/dsh-llm' +import type { Message } from '@deepseek-ai/dsh-llm' +import type { ImageOccurrencePosition, SessionEvent, SessionSeq } from './types.ts' + +/** + * Compare two image occurrence positions in log order: by event seq, then by + * block path inside the event's content. + * @param a - first position. + * @param b - second position. + * @returns negative, zero, or positive as `a` lies before, at, or after `b`. + */ +export function compareImagePositions(a: ImageOccurrencePosition, b: ImageOccurrencePosition): number { + return a.seq === b.seq ? compareImageBlockPaths(a.path, b.path) : a.seq - b.seq +} + +/** + * Select the watermark in force after a run of events: the last + * `image/offload` event's position, or the prior watermark when the run + * carries none. + * @param events - events in seq order. + * @param prior - watermark folded from the events before `events`. + * @returns the latest watermark, or undefined when no event has set one. + */ +export function foldImageOffloadWatermark( + events: readonly SessionEvent[], + prior?: ImageOccurrencePosition, +): ImageOccurrencePosition | undefined { + let watermark = prior + for (const event of events) { + if (event.type === 'image/offload') watermark = event.data.watermark + } + return watermark +} + +/** + * Validate one `image/offload` payload at the append or seed boundary: the + * position must name an event already in the log, carry a block path of + * non-negative integers, and lie strictly after the watermark in force. + * @param data - the event payload as materialized JSON. + * @param logLength - number of events already accepted. + * @param prior - watermark in force before this event. + * @param location - append or seed location named by the diagnostic. + * @throws when the payload is malformed or does not advance the watermark. + */ +export function assertImageOffloadAdvance( + data: unknown, + logLength: number, + prior: ImageOccurrencePosition | undefined, + location: string, +): void { + const record = data !== null && typeof data === 'object' && !Array.isArray(data) + ? data as Record + : undefined + const watermark = record?.['watermark'] + const position = watermark !== null && typeof watermark === 'object' && !Array.isArray(watermark) + ? watermark as Record + : undefined + const seq = position?.['seq'] + const path = position?.['path'] + if (typeof seq !== 'number' || !Number.isSafeInteger(seq) || seq < 0 || seq >= logLength + || !Array.isArray(path) || path.length === 0 + || path.some(index => typeof index !== 'number' || !Number.isSafeInteger(index) || index < 0)) { + throw new Error(`${location} names an invalid image offload watermark`) + } + if (prior !== undefined + && compareImagePositions({ seq: seq as SessionSeq, path: path as number[] }, prior) <= 0) { + throw new Error(`${location} does not advance the image offload watermark`) + } +} + +/** + * Project one derived surface message under the watermark: every image + * occurrence positioned at or before it carries `offloaded: true`. + * @param message - the node's derived message. + * @param seq - the node's event seq. + * @param watermark - watermark in force, or undefined when nothing is offloaded. + * @returns the same message when nothing changes, otherwise a shallow copy with marked content. + */ +export function markImageOffload( + message: Message, + seq: SessionSeq, + watermark: ImageOccurrencePosition | undefined, +): Message { + if (watermark === undefined || seq > watermark.seq) return message + const content = markOffloadedImages( + message.content, + path => seq < watermark.seq || compareImageBlockPaths(path, watermark.path) <= 0, + ) + return content === message.content ? message : { ...message, content: content as Message['content'] } +} diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 59e198db44..2f9131bb48 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -15,10 +15,11 @@ import type { Scoped } from '@deepseek-ai/dsh-scope' import type { Message } from '@deepseek-ai/dsh-llm' import { SESSION_FORMAT_VERSION, SessionLogOffset, SessionSeq } from './types.ts' import type { TypertLookup } from '@deepseek-ai/dsh-typert-protocol' -import type { CreateSessionOptions, EpochHeader, PrepareSessionOptions, RequestContext, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SessionId, SurfaceIntent, SurfaceEventType } from './types.ts' +import type { CreateSessionOptions, EpochHeader, ImageOccurrencePosition, PrepareSessionOptions, RequestContext, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SessionId, SurfaceIntent, SurfaceEventType } from './types.ts' import { deriveEventMessage, SurfaceManager } from './surface.ts' import type { SessionSurface } from './surface.ts' import { foldRequestHeader } from './request-header.ts' +import { assertImageOffloadAdvance, markImageOffload } from './image-offload.ts' export * from './types.ts' export { SessionPreparation } from './preparation.ts' @@ -30,6 +31,7 @@ export type { ChunkRow, StorageRecord } from './chunk-rows.ts' export type { SessionSurface, SurfaceFoldReplacement, SurfaceFoldResult } from './surface.ts' export { deriveEventMessage, foldSurface, isAppendSurfaceEvent, isReplacementSurfaceEvent, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts' export { canonicalHeader, foldRequestHeader, headerEquals } from './request-header.ts' +export { assertImageOffloadAdvance, compareImagePositions, foldImageOffloadWatermark, markImageOffload } from './image-offload.ts' export { KNOWN_SESSION_EVENT_TYPES } from './known-event-types.ts' declare module '@deepseek-ai/cordis' { @@ -424,6 +426,8 @@ const attachments = new WeakMap() */ export class Session { private log: SessionEvent[] = [] + /** Watermark in force after the last accepted `image/offload` event. */ + private imageOffloadFold: ImageOccurrencePosition | undefined /** Single incremental owner of surface acceptance and projection state. */ private readonly surfaceManager = new SurfaceManager(this.log) @@ -538,6 +542,9 @@ export class Session { } assertSessionEventEnvelope(snapshot, index) assertSupportedRequestHeader(snapshot.type, snapshot.data, `seed event at index ${index}`) + if (snapshot.type === 'image/offload') { + assertImageOffloadAdvance(snapshot.data, this.log.length, this.imageOffloadFold, `seed event at index ${index}`) + } if (snapshot.seq !== index) { throw new Error(`seed event at index ${index} has seq ${snapshot.seq} (expected ${index}); seed must be contiguous from 0`) } @@ -550,6 +557,7 @@ export class Session { throw new Error(`invalid seed event at index ${index}: ${error instanceof Error ? error.message : 'invalid surface metadata'}`) } this.log.push(mode === 'restore' ? freezeRestoredObject(snapshot) : deepFreeze(snapshot)) + this.foldImageOffload(snapshot) } } this.firstLiveSeq = SessionLogOffset(this.log.length) @@ -680,6 +688,9 @@ export class Session { throw new Error(`session event "${type}" carries non-JSON-serializable data`) } assertSupportedRequestHeader(type, dataSnapshot, `session event "${type}"`) + if (type === 'image/offload') { + assertImageOffloadAdvance(dataSnapshot, this.log.length, this.imageOffloadFold, `session event "${type}"`) + } const surfaceMetadataSnapshot = snapshotJsonValue(surfaceMetadata) if (surfaceMetadataSnapshot === undefined) { throw new Error(`session event "${type}" carries non-JSON-serializable surface metadata`) @@ -705,6 +716,7 @@ export class Session { callbacks = collectSessionCallbacks(entry.emitCtx, [entry.carrier, 'session/event', ...callbackArgs]) } this.log.push(event as SessionEvent) + this.foldImageOffload(event as SessionEvent) this.eventsSnapshot = undefined if (callbacks !== undefined && entry !== undefined) { invokeContainedSessionObservers(entry.emitCtx, 'session/event', entry.id, callbackArgs, callbacks) @@ -762,12 +774,29 @@ export class Session { return this.contextFold } + /** Record an accepted `image/offload` event as the watermark in force. */ + private foldImageOffload(event: SessionEvent): void { + if (event.type === 'image/offload') this.imageOffloadFold = event.data.watermark + } + + /** + * The durable image offload watermark in force: every image occurrence + * positioned at or before it derives as offloaded. Undefined until the + * first `image/offload` event. + * @returns the frozen latest watermark, or undefined when nothing is offloaded. + */ + imageOffloadWatermark(): ImageOccurrencePosition | undefined { + return this.imageOffloadFold + } + /** The derived-message cache: frozen projections, extended per unseen node. */ private derived: Message[] = [] /** Surface position (nodes projected) the cache has reached. */ private derivedNodes = 0 /** {@link SurfaceManager.replaceGeneration} the cache was built under. */ private derivedGeneration = 0 + /** Watermark the cache was built under; an advance rebuilds every node. */ + private derivedWatermark: ImageOccurrencePosition | undefined /** * Derive the LLM message history by walking the ordered sequences of @@ -780,7 +809,9 @@ export class Session { * * CACHED: each surface node is projected exactly once, when first seen — a * call costs O(new nodes), and a surface rewrite (a `replace`; - * {@link SessionSurface.replaceGeneration}) rebuilds. The returned array is + * {@link SessionSurface.replaceGeneration}) or an `image/offload` advance + * rebuilds, and image occurrences at or before the watermark derive with + * `offloaded: true` ({@link markImageOffload}). The returned array is * a fresh snapshot per call (later appends never grow an array a caller * already holds); the `Message` objects in it are SHARED and **deep-frozen**. * Their content reuses the already frozen durable event data, so the cache @@ -791,10 +822,12 @@ export class Session { const surface = this.surface const nodes = surface.nodes const generation = surface.replaceGeneration - if (generation !== this.derivedGeneration) { + const watermark = this.imageOffloadFold + if (generation !== this.derivedGeneration || watermark !== this.derivedWatermark) { this.derived = [] this.derivedNodes = 0 this.derivedGeneration = generation + this.derivedWatermark = watermark } for (const seq of nodes.slice(this.derivedNodes)) { // Surface sequences are built from this.log — seq is always a valid @@ -804,12 +837,18 @@ export class Session { // A surface node is one of the five message-producing types, but an // empty-content assistant/message (a max-tokens step that hosts only // usage) derives to null and must not enter the transcript. - if (msg) this.derived.push(msg) + if (msg) this.derived.push(this.markImageOffload(msg, seq, watermark)) } this.derivedNodes = nodes.length return [...this.derived] } + /** Apply the watermark to one derived node, freezing any marked copy like the durable original. */ + private markImageOffload(message: Message, seq: SessionSeq, watermark: ImageOccurrencePosition | undefined): Message { + const marked = markImageOffload(message, seq, watermark) + return marked === message ? message : deepFreeze(marked) + } + /** * Instance face of the pure per-node `deriveEventMessage` export from * `surface.ts`. diff --git a/packages/core/session/src/invariant.ts b/packages/core/session/src/invariant.ts index e479835d3f..699bc4e618 100644 --- a/packages/core/session/src/invariant.ts +++ b/packages/core/session/src/invariant.ts @@ -148,7 +148,8 @@ function validateEvent( // Unconstrained: an unbalanced seed legally puts it inside an open turn. break case 'request/header': - case 'request/context': { + case 'request/context': + case 'image/offload': { if (trace.openTurn === null) { fail(`${event.type} appended outside any open turn (core execution events must be turn-enclosed)`) } diff --git a/packages/core/session/src/known-event-types.ts b/packages/core/session/src/known-event-types.ts index 774e1a55cb..d060de2c14 100644 --- a/packages/core/session/src/known-event-types.ts +++ b/packages/core/session/src/known-event-types.ts @@ -37,6 +37,7 @@ export const KNOWN_SESSION_EVENT_TYPES: ReadonlySet = new Set([ 'goal/change', 'hook/invoked', 'hook/result', + 'image/offload', 'llm/retry', 'llm/retry-started', 'model/selection', diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index c27cd4d98e..dc17c77da3 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -242,6 +242,20 @@ export interface RequestContext { contextWindow?: number } +/** + * Durable position of one image occurrence on the model-visible surface: the + * seq of the event carrying it and the block path inside that event's + * content (the top-level block index, followed by the index inside a + * tool-result block). Positions order by seq, then by path, so a newer + * event always lies after an older one regardless of surface replacements. + */ +export interface ImageOccurrencePosition { + /** Seq of the `user/message` or `tool/result` event carrying the occurrence. */ + seq: SessionSeq + /** Block index path inside that event's message content. */ + path: number[] +} + /** * Why a `request/header` snapshot was appended: `'initial'` — the log's first * header (a new conversation); `'resume'` — a loop instance's first request @@ -339,6 +353,17 @@ export interface SessionEventMap { * changes. It does not participate in request reconstruction or header equality. */ 'request/context': RequestContext + /** + * Advances the durable image offload watermark before a request in step + * `step` of turn `turn` is dispatched. Every image occurrence positioned at + * or before `watermark` derives with `offloaded: true`, so each route sends + * its placeholder text instead of the image; occurrences after it stay + * retained. The watermark only advances: each event names a position + * strictly after the previous one, and no later budget, route change, or + * compaction moves it back. It is a log-only event that changes the derived + * surface, so a build that does not know the type refuses the log. + */ + 'image/offload': { turn: number; step: number; watermark: ImageOccurrencePosition } /** * Marks the end of a constructor seed. Events before it have smaller seq * values and came from the seed (resume, fork, or replay); this lifecycle diff --git a/packages/core/session/tests/image-offload.spec.ts b/packages/core/session/tests/image-offload.spec.ts new file mode 100644 index 0000000000..4e633d1c39 --- /dev/null +++ b/packages/core/session/tests/image-offload.spec.ts @@ -0,0 +1,161 @@ +/** + * Durable image offload watermark: append/seed validation, monotonic + * advance, derived-surface marking, cache rebuild, and replay equality. + */ + +import { describe, expect, it } from 'vitest' +import { createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import { compareImagePositions, foldImageOffloadWatermark, Session, SessionId, SessionSeq } from '@deepseek-ai/dsh-session' + +function image(name: string): Extract { + return { + type: 'image', + attachment: { + attachmentId: `sha256:${'a'.repeat(64)}` as never, + name, + mediaType: 'image/png', + bytes: 3, + width: 1, + height: 1, + }, + } +} + +function toolResult(name: string): ContentBlock { + return { type: 'tool-result', toolCallId: ToolCallId(name), content: [{ type: 'text', text: 'shot' }, image(name)] } +} + +function seeded(): Session { + const session = Session.create(SessionId('offload')) + session.append('turn/start', { turn: 1 }) + session.append('step/start', { turn: 1, step: 1 }) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'no images here' }], source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + session.append('user/message', createUserMessage({ + content: [image('first'), image('second')], source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + session.append('user/message', createUserMessage({ + content: [toolResult('third'), image('fourth')], source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + return session +} + +function offloadedNames(session: Session): string[] { + const names: string[] = [] + for (const message of session.deriveMessages()) { + for (const block of message.content) { + if (block.type === 'image' && block.offloaded === true) names.push(block.attachment.name ?? '') + if (block.type === 'tool-result') { + for (const inner of block.content) { + if (inner.type === 'image' && inner.offloaded === true) names.push(inner.attachment.name ?? '') + } + } + } + } + return names +} + +describe('image/offload append validation', () => { + it('rejects a malformed or out-of-range watermark', () => { + const session = seeded() + expect(() => session.append('image/offload', { turn: 1, step: 1, watermark: { seq: SessionSeq(99), path: [0] } })) + .toThrow('names an invalid image offload watermark') + expect(() => session.append('image/offload', { turn: 1, step: 1, watermark: { seq: SessionSeq(3), path: [] } })) + .toThrow('names an invalid image offload watermark') + expect(() => session.append('image/offload', { turn: 1, step: 1, watermark: { seq: SessionSeq(3), path: [-1] } })) + .toThrow('names an invalid image offload watermark') + expect(() => session.append('image/offload', { turn: 1, step: 1, watermark: null as never })) + .toThrow('names an invalid image offload watermark') + expect(() => session.append('image/offload', 7 as never)) + .toThrow('names an invalid image offload watermark') + expect(session.imageOffloadWatermark()).toBeUndefined() + }) + + it('accepts only strictly advancing positions', () => { + const session = seeded() + session.append('image/offload', { turn: 1, step: 1, watermark: { seq: SessionSeq(3), path: [1] } }) + expect(session.imageOffloadWatermark()).toEqual({ seq: 3, path: [1] }) + expect(() => session.append('image/offload', { turn: 1, step: 1, watermark: { seq: SessionSeq(3), path: [1] } })) + .toThrow('does not advance the image offload watermark') + expect(() => session.append('image/offload', { turn: 1, step: 1, watermark: { seq: SessionSeq(3), path: [0] } })) + .toThrow('does not advance the image offload watermark') + session.append('image/offload', { turn: 1, step: 1, watermark: { seq: SessionSeq(4), path: [0, 1] } }) + expect(session.imageOffloadWatermark()).toEqual({ seq: 4, path: [0, 1] }) + expect(Object.isFrozen(session.imageOffloadWatermark())).toBe(true) + }) + + it('validates a seed with the same rules and folds its watermark', () => { + const session = seeded() + session.append('image/offload', { turn: 1, step: 1, watermark: { seq: SessionSeq(3), path: [0] } }) + const replayed = Session.create(SessionId('offload-replay'), session.snapshotEvents()) + expect(replayed.imageOffloadWatermark()).toEqual({ seq: 3, path: [0] }) + expect(replayed.deriveMessages()).toEqual(session.deriveMessages()) + + const events = session.snapshotEvents().map(event => ( + event.type === 'image/offload' + ? { ...event, data: { ...event.data, watermark: { seq: 50, path: [0] } } } + : event + )) + expect(() => Session.create(SessionId('offload-bad-seed'), events as never)) + .toThrow('seed event at index 5 names an invalid image offload watermark') + }) +}) + +describe('image/offload surface derivation', () => { + it('marks occurrences at or before the watermark, including nested tool results', () => { + const session = seeded() + expect(offloadedNames(session)).toEqual([]) + session.append('image/offload', { turn: 1, step: 1, watermark: { seq: SessionSeq(3), path: [0] } }) + expect(offloadedNames(session)).toEqual(['first']) + session.append('image/offload', { turn: 1, step: 1, watermark: { seq: SessionSeq(4), path: [0, 1] } }) + expect(offloadedNames(session)).toEqual(['first', 'second', 'third']) + session.append('image/offload', { turn: 1, step: 1, watermark: { seq: SessionSeq(4), path: [1] } }) + expect(offloadedNames(session)).toEqual(['first', 'second', 'third', 'fourth']) + }) + + it('freezes marked copies, keeps the durable event content untouched, and matches a scratch replay', () => { + const session = seeded() + const before = session.deriveMessages() + session.append('image/offload', { turn: 1, step: 1, watermark: { seq: SessionSeq(3), path: [0] } }) + const after = session.deriveMessages() + expect(after[0]).toBe(before[0]) + expect(after[1]).not.toBe(before[1]) + expect(Object.isFrozen(after[1])).toBe(true) + expect(Object.isFrozen(after[1]!.content[0])).toBe(true) + expect(after[2]).toBe(before[2]) + expect(before[1]!.content[0]).toEqual(image('first')) + expect(session.eventAt(SessionSeq(3))!.data).toMatchObject({ content: [image('first'), image('second')] }) + const scratch = Session.create(SessionId('offload-scratch'), session.snapshotEvents()) + expect(scratch.deriveMessages()).toEqual(after) + }) + + it('leaves later appended nodes retained without rebuilding the cache', () => { + const session = seeded() + session.append('image/offload', { turn: 1, step: 1, watermark: { seq: SessionSeq(4), path: [1] } }) + const marked = session.deriveMessages() + session.append('user/message', createUserMessage({ + content: [image('fifth')], source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + const grown = session.deriveMessages() + expect(grown[0]).toBe(marked[0]) + expect(grown.at(-1)!.content[0]).toEqual(image('fifth')) + expect(offloadedNames(session)).toEqual(['first', 'second', 'third', 'fourth']) + }) +}) + +describe('image offload helpers', () => { + it('orders positions by seq then path and folds the latest watermark', () => { + expect(compareImagePositions({ seq: SessionSeq(1), path: [5] }, { seq: SessionSeq(3), path: [0] })).toBeLessThan(0) + expect(compareImagePositions({ seq: SessionSeq(4), path: [1] }, { seq: SessionSeq(3), path: [0, 3] })).toBeGreaterThan(0) + const session = seeded() + expect(foldImageOffloadWatermark(session.snapshotEvents())).toBeUndefined() + session.append('image/offload', { turn: 1, step: 1, watermark: { seq: SessionSeq(3), path: [0] } }) + const prior = foldImageOffloadWatermark(session.snapshotEvents()) + expect(prior).toEqual({ seq: 3, path: [0] }) + session.append('image/offload', { turn: 1, step: 1, watermark: { seq: SessionSeq(4), path: [1] } }) + expect(foldImageOffloadWatermark(session.snapshotEvents().slice(6), prior)).toEqual({ seq: 4, path: [1] }) + expect(foldImageOffloadWatermark([], prior)).toEqual(prior) + }) +}) diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index 3943c96eca..6e63647686 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-deepseek/README.md -README.md: 6c69083909c71631dde04b453b399cc6bf687110 -README.zh.md: 9b6d35864dee20320e3f16bed82d8eecb4f39ec1 +README.md: da64841f610a9099360eee878e7fff8b35ed29bd +README.zh.md: 6863a991b6d86460e673f39f83980675d07e0a18 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 6c69083909..da64841f61 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -58,7 +58,7 @@ A request selects the route with `provider: deepseek-official`; the model id pas | `defaultContextWindow` | `1,000,000` | Capacity fallback for models without an exact value | | `models` | V4 Flash + V4 Pro + V4 Flash Vision Exp | Advisory catalog shown by discovery consumers | | `streamIdleTimeoutMs` | `300,000` | Maximum provider idle time per outstanding stream read | -| `maxRequestFilesBytes` | `128 MiB` | High watermark for retained request-image bytes before oldest-first offload | +| `maxRequestFilesBytes` | `128 MiB` | File-mode request-image byte budget declared to the agent loop's `image/offload` watermark | | `maxInlineRequestImageBytes` | `20 MiB` | Independent base64 fallback high watermark | | `maxImagesPerRequest` | `600` | High watermark for retained request-image count | | `imageOffloadByteQuantum` | `64 MiB` | Files-mode oldest-prefix removal quantum | @@ -156,7 +156,7 @@ The selected DeepSeek model receives the harness system prompt, message history, #### Token effect -Provider tokenization governs exact text and image-token input. The adapter declares per-route `imageRequestPricing`: it reproduces oldest-first image offload from durable byte lengths and prices each retained image at its projected dimensions with the published v4 vision accounting (14px patch grid, 3:1 downsampling, 384-token cap, worst-case alignment pad). This lets the token meter price image pressure before a request; reported usage remains authoritative. Reasoning passback carries every reasoned turn's chain of thought into later requests, while dropping over-budget images avoids paying those tokens again. Cache-read usage is reported when available. `totalTokens` is the exact `prompt_tokens + completion_tokens` aggregate and is omitted if a supplied `total_tokens` disagrees. +Provider tokenization governs exact text and image-token input. The adapter declares per-route `imageRequestPricing`: it prices each occurrence the session's `image/offload` watermark marks offloaded as its placeholder text and each retained image at its projected dimensions with the published v4 vision accounting (14px patch grid, 3:1 downsampling, 384-token cap, worst-case alignment pad). This lets the token meter price image pressure before a request; reported usage remains authoritative. Reasoning passback carries every reasoned turn's chain of thought into later requests, while offloaded images stop costing visual tokens. Each image-capable catalog route also declares its file-mode budget (`maxRequestFilesBytes`, `maxImagesPerRequest`, both quanta, and the request-version byte target) as `imageRequest` on its resolved model info, so the agent loop advances the watermark from logged facts; a request whose retained occurrences still exceed the file-mode or inline-fallback budget at their exact request-version bytes fails with `IMAGE_OFFLOAD_REQUIRED` naming the additional oldest occurrences to offload. Cache-read usage is reported when available. `totalTokens` is the exact `prompt_tokens + completion_tokens` aggregate and is omitted if a supplied `total_tokens` disagrees. #### KV Cache effect diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index 9b6d35864d..6863a991b6 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -58,7 +58,7 @@ kind: "package-reference" | `defaultContextWindow` | `1,000,000` | 无精确值模型的容量回退 | | `models` | V4 Flash + V4 Pro + V4 Flash Vision Exp | 供发现消费方查看的建议性目录 | | `streamIdleTimeoutMs` | `300,000` | 单次流读取未完成的最大提供方空闲时间 | -| `maxRequestFilesBytes` | `128 MiB` | 按最旧优先卸载前保留的请求图片字节高水位 | +| `maxRequestFilesBytes` | `128 MiB` | 向 agent loop 的 `image/offload` 水位声明的 file 模式请求图片字节预算 | | `maxInlineRequestImageBytes` | `20 MiB` | 独立的 base64 回退高水位 | | `maxImagesPerRequest` | `600` | 保留请求图片数量的高水位 | | `imageOffloadByteQuantum` | `64 MiB` | Files 模式最旧前缀移除量子 | @@ -156,7 +156,7 @@ Files 模式通过 `maxRequestFilesBytes` 与 `maxImagesPerRequest` 限制保留 #### Token 影响 -提供方分词决定精确的文本与图片 token 输入。适配器声明按路由的 `imageRequestPricing`:它根据持久字节长度复现最旧优先的图片 offload,并按投影后的尺寸使用官方公布的 v4 视觉计量(14px patch 网格、3:1 降采样、单图 384 token 上限、最坏对齐 pad)为每张保留图片计价。这使 token 计量服务可以在请求发出前为图片压力定价;上报的 usage 仍是权威值。推理回传会把每个推理轮次的思维链带进后续请求,而丢弃超预算图片会避免再次为它们付费。可用时报告缓存读取用量。`totalTokens` 是精确的 `prompt_tokens + completion_tokens` 汇总值;提供方给出的 `total_tokens` 不一致时省略该值。 +提供方分词决定精确的文本与图片 token 输入。适配器声明按路由的 `imageRequestPricing`:把会话 `image/offload` 水位标记为已省略的每个出现位置按其占位文本计价,并按投影后的尺寸使用官方公布的 v4 视觉计量(14px patch 网格、3:1 降采样、单图 384 token 上限、最坏对齐 pad)为每张保留图片计价。这使 token 计量服务可以在请求发出前为图片压力定价;上报的 usage 仍是权威值。推理回传会把每个推理轮次的思维链带进后续请求,而已省略的图片不再消耗视觉 token。每条支持图片的目录路由还会把 file 模式预算(`maxRequestFilesBytes`、`maxImagesPerRequest`、两个量子与请求版本字节目标)作为 `imageRequest` 声明在其解析后的模型信息上,供 agent loop 仅凭已记录事实推进水位;保留的出现位置按精确请求版本字节仍超过 file 模式或内联回退预算的请求,以 `IMAGE_OFFLOAD_REQUIRED` 失败并说明还需省略多少最老的出现位置。可用时报告缓存读取用量。`totalTokens` 是精确的 `prompt_tokens + completion_tokens` 汇总值;提供方给出的 `total_tokens` 不一致时省略该值。 #### KV Cache 影响 diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index e211655633..8181e2113d 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -8,7 +8,7 @@ * @module dsh-llm-deepseek/adapter */ -import { attributionHeaders, contentHasImage, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, LlmAdapter, LlmError, offloadedImageText, offloadRequestImagesWithPolicy, ProviderRequestId, QUOTA_EXCEEDED_CODE, ReasoningEffortId } from '@deepseek-ai/dsh-llm' +import { attributionHeaders, contentHasImage, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, LlmAdapter, LlmError, ProviderRequestId, QUOTA_EXCEEDED_CODE, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, @@ -19,8 +19,7 @@ import type { LlmResolvedModelInfo, ModelModality, ResolvedRetryPolicy, - StreamChunk, -} from '@deepseek-ai/dsh-llm' + StreamChunk, LlmImageRequestBudget } from '@deepseek-ai/dsh-llm' import type { AttachmentId, AttachmentStore, @@ -205,8 +204,23 @@ function collectImageRefs( refs: Map, ): void { for (const block of content) { - if (block.type === 'image') refs.set(block.attachment.attachmentId, block.attachment) - else if (block.type === 'tool-result') collectImageRefs(block.content, refs) + if (block.type === 'image') { + if (block.offloaded !== true) refs.set(block.attachment.attachmentId, block.attachment) + } else if (block.type === 'tool-result') { + collectImageRefs(block.content, refs) + } + } +} + +/** The file-mode request-image budget one image-capable catalog route declares to the agent loop. */ +function imageRequestBudget(connection: DeepSeekConnectionOptions, model: DeepSeekCatalogModel): LlmImageRequestBudget { + return { + representation: 'raw', + maxBytes: connection.maxRequestFilesBytes, + maxImages: connection.maxImagesPerRequest, + byteQuantum: connection.imageOffloadByteQuantum, + countQuantum: connection.imageOffloadCountQuantum, + versionMaxBytes: resolveRequestImagePolicy(model).maxBytes, } } @@ -407,6 +421,9 @@ export class DeepSeekAdapter extends LlmAdapter { : modelInfo(provider, configured), context: { contextWindow }, defaultMaxTokens: configured?.maxTokens ?? connection.maxTokens, + ...configured?.inputModalities?.includes('image') === true + ? { imageRequest: imageRequestBudget(connection, configured) } + : {}, ...connection.defaults.thinking === 'disabled' ? { reasoning: { @@ -544,21 +561,13 @@ export class DeepSeekAdapter extends LlmAdapter { const fileConnection = { baseURL: connection.baseURL, apiKey } const model = connection.models.find(entry => entry.id === options.model) - const policy = model === undefined ? undefined : resolveRequestImagePolicy(model) const resolveImageAccess = attachments === undefined ? undefined : (ref: ImageAttachmentRef): ImageAttachmentAccess | undefined => this.config.resolveImageAccess?.(attachments, ref) const imageAccessOptions = resolveImageAccess === undefined ? {} : { resolveImageAccess } - const requestMessages = policy === undefined ? options.messages : offloadRequestImagesWithPolicy(options.messages, { - representation: 'raw', - maxBytes: connection.maxRequestFilesBytes, - maxImages: connection.maxImagesPerRequest, - byteQuantum: connection.imageOffloadByteQuantum, - countQuantum: connection.imageOffloadCountQuantum, - byteLength: ref => Math.min(ref.bytes, policy.maxBytes), - placeholder: ref => offloadedImageText(ref, resolveImageAccess?.(ref)), - }) - const requestOptions = requestMessages === options.messages ? options : { ...options, messages: [...requestMessages] } + // The offloaded set is a durable surface fact carried by each image block; + // serialization projects it and only retained occurrences are prepared. + const requestOptions = options const requestImages = attachments === undefined || model === undefined ? new Map() : await prepareRequestImages(requestOptions, attachments, model, signal) diff --git a/packages/llm/llm-deepseek/src/request-pricing.ts b/packages/llm/llm-deepseek/src/request-pricing.ts index 71dd0ac291..f7504a011e 100644 --- a/packages/llm/llm-deepseek/src/request-pricing.ts +++ b/packages/llm/llm-deepseek/src/request-pricing.ts @@ -1,18 +1,18 @@ /** - * Provider-side request-image pricing for DeepSeek routes: reproduces the - * adapter's deterministic request projection (per-model pixel budget, - * oldest-first offload under the raw-byte and count budgets) and prices every - * retained image with the published v4 vision-token accounting. Consumed - * synchronously by the token meter through `LlmAdapter.imageRequestPricing`; - * provider usage remains the authoritative anchor for completed requests. + * Provider-side request-image pricing for DeepSeek routes: prices every + * retained surface occurrence at its per-model pixel-budget projection with + * the published v4 vision-token accounting, and every occurrence the surface + * marks offloaded as its placeholder text. Consumed synchronously by the + * token meter through `LlmAdapter.imageRequestPricing`; provider usage + * remains the authoritative anchor for completed requests. * * @module dsh-llm-deepseek/request-pricing */ -import { offloadedImageText, offloadedImagePrefixCount, requestImageHandleText, textOnlyImageText } from '@deepseek-ai/dsh-llm' -import type { ImageAttachmentAccessResolver, LlmImageRequestPrice, LlmImageRequestPricing } from '@deepseek-ai/dsh-llm' +import { offloadedImageText, requestImageHandleText, textOnlyImageText } from '@deepseek-ai/dsh-llm' +import type { ImageAttachmentAccessResolver, ImageBlock, LlmImageRequestPrice, LlmImageRequestPricing } from '@deepseek-ai/dsh-llm' import { requestImageDimensions } from '@deepseek-ai/dsh-attachment' -import type { ImageAttachmentRef, ImageRequestPolicy } from '@deepseek-ai/dsh-attachment' +import type { ImageRequestPolicy } from '@deepseek-ai/dsh-attachment' import { deepSeekImageTokens } from './image-tokens.ts' import type { DeepSeekCatalogModel, DeepSeekConnectionOptions } from './adapter.ts' @@ -50,21 +50,19 @@ export function resolveRequestImagePolicy(model: DeepSeekCatalogModel): ImageReq * reproducing the `projectImagesForTextModel` substitution `LlmRuntime` * applies before dispatching to a route without the `image` modality. */ -function textOnlyPrice(ref: ImageAttachmentRef): LlmImageRequestPrice { - return { visualTokens: 0, text: textOnlyImageText(ref) } +function textOnlyPrice(block: ImageBlock): LlmImageRequestPrice { + return { visualTokens: 0, text: textOnlyImageText(block.attachment) } } /** * Build the request-image pricing for one DeepSeek route from a validated * connection snapshot. Uncatalogued and text-only models price every * occurrence as its deterministic text substitution; image-capable models - * reproduce the adapter's first-stage oldest-first offload from durable byte - * lengths and price retained images by their projected request dimensions, - * with each occurrence's handle or placeholder text built through the same - * access resolution the serializer uses. The base64 fallback's tighter inline - * budget is not reproduced, so a fallback request can only cost less than - * this estimate; access paths resolve at pricing time, so a path that changes - * before the request only shifts the text price by its own length. + * price an offloaded occurrence as its placeholder text and a retained one by + * its projected request dimensions, with each occurrence's handle or + * placeholder text built through the same access resolution the serializer + * uses. Access paths resolve at pricing time, so a path that changes before + * the request only shifts the text price by its own length. * @param connection - validated connection facts of the pricing resolution. * @param model - exact model id named by the request header. * @param resolveAccess - current execution-world access resolution shared with request serialization. @@ -81,26 +79,15 @@ export function deepSeekImageRequestPricing( } const policy = resolveRequestImagePolicy(catalogModel) return { - priceImages: (images) => { - const offloaded = offloadedImagePrefixCount( - images.map(ref => Math.min(ref.bytes, policy.maxBytes)), - { - maxBytes: connection.maxRequestFilesBytes, - maxImages: connection.maxImagesPerRequest, - byteQuantum: connection.imageOffloadByteQuantum, - countQuantum: connection.imageOffloadCountQuantum, - }, - ) - return images.map((ref, index) => { - if (index < offloaded) { - return { visualTokens: 0, text: offloadedImageText(ref, resolveAccess?.(ref)) } - } - const dimensions = requestImageDimensions(ref.width, ref.height, policy.maxPixels) - return { - visualTokens: deepSeekImageTokens(dimensions.width, dimensions.height), - text: requestImageHandleText(ref, dimensions, resolveAccess?.(ref)), - } - }) - }, + priceImages: images => images.map(({ attachment: ref, offloaded }) => { + if (offloaded === true) { + return { visualTokens: 0, text: offloadedImageText(ref, resolveAccess?.(ref)) } + } + const dimensions = requestImageDimensions(ref.width, ref.height, policy.maxPixels) + return { + visualTokens: deepSeekImageTokens(dimensions.width, dimensions.height), + text: requestImageHandleText(ref, dimensions, resolveAccess?.(ref)), + } + }), } } diff --git a/packages/llm/llm-deepseek/src/serialize.ts b/packages/llm/llm-deepseek/src/serialize.ts index 1749991eca..597ed8ef03 100644 --- a/packages/llm/llm-deepseek/src/serialize.ts +++ b/packages/llm/llm-deepseek/src/serialize.ts @@ -6,7 +6,7 @@ * @module dsh-llm-deepseek/serialize */ -import { contentHasImage, LlmError, offloadedImageText, offloadRequestImagesWithPolicy, requestImageHandleText } from '@deepseek-ai/dsh-llm' +import { contentHasImage, IMAGE_OFFLOAD_REQUIRED_CODE, LlmError, offloadedImageText, offloadedImagePrefixCount, projectOffloadedImages, representedImageBytes, requestImageHandleText, visitImageBlocks } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, ImageAttachmentAccessResolver, Message } from '@deepseek-ai/dsh-llm' import type { ImageAttachmentRef, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' import type { @@ -278,7 +278,7 @@ export function serializeMessages(messages: Message[]): WireMessage[] { * Serialize image-capable history after resolving durable attachments. * Consecutive tool results keep string `tool` messages and share one following * user message containing their images. - * @param messages - transient request history after request-size offloading. + * @param messages - request history whose offloaded occurrences are already placeholder text. * @param images - prepared request versions, one provider representation, and its budget. * @returns ordered DeepSeek wire messages. */ @@ -391,10 +391,44 @@ export function serializeRequest( return requestWithMessages(options, messages, defaults) } +/** + * Reject a request whose retained occurrences, at their exact request-version + * byte lengths under this representation, still exceed the route budget. The + * failure names how many more of the oldest retained occurrences the agent + * loop must offload; the surface, not this serializer, owns the offloaded set. + */ +function assertRetainedImagesFit(messages: readonly Message[], images: ImageSerializationOptions): void { + const representation = images.representation.kind === 'file' ? 'raw' : 'base64' + const lengths: number[] = [] + for (const message of messages) { + visitImageBlocks(message.content, (block) => { + if (block.offloaded === true) return + const version = images.requestImages.get(block.attachment.attachmentId) + if (version === undefined) { + throw new LlmError(`DeepSeek request image ${block.attachment.attachmentId} was not prepared.`, 'INVALID_REQUEST') + } + lengths.push(representedImageBytes(version.bytes, { representation })) + }) + } + const offloadImages = offloadedImagePrefixCount(lengths, { + maxBytes: images.maxRequestImageBytes, + ...images.maxImagesPerRequest === undefined ? {} : { maxImages: images.maxImagesPerRequest }, + ...images.byteQuantum === undefined ? {} : { byteQuantum: images.byteQuantum }, + ...images.countQuantum === undefined ? {} : { countQuantum: images.countQuantum }, + }) + if (offloadImages > 0) { + throw new LlmError( + `DeepSeek ${representation} request images exceed the route budget; ${offloadImages} more oldest occurrence(s) must be offloaded.`, + IMAGE_OFFLOAD_REQUIRED_CODE, + { offloadImages }, + ) + } +} + /** * Build one image-capable request while keeping durable bytes out of session - * messages. Oversized oldest images become per-image text after their - * exact request-version byte lengths are known and before provider serialization. + * messages. Offloaded occurrences become per-image text; retained occurrences + * must fit the route budget at their exact request-version byte lengths. * @param options - harness request containing image-capable user content. * @param images - request versions, optional current access resolver, and request bounds. * @param defaults - adapter-level thinking defaults. @@ -406,21 +440,11 @@ export async function serializeRequestWithImages( defaults: RequestDefaults = {}, ): Promise { assertSupportedImageRoles(options.messages) - const requestMessages = offloadRequestImagesWithPolicy(options.messages, { - representation: images.representation.kind === 'file' ? 'raw' : 'base64', - byteLength: (ref) => { - const version = images.requestImages.get(ref.attachmentId) - if (version === undefined) { - throw new LlmError(`DeepSeek request image ${ref.attachmentId} was not prepared.`, 'INVALID_REQUEST') - } - return version.bytes - }, - maxBytes: images.maxRequestImageBytes, - ...images.maxImagesPerRequest === undefined ? {} : { maxImages: images.maxImagesPerRequest }, - ...images.byteQuantum === undefined ? {} : { byteQuantum: images.byteQuantum }, - ...images.countQuantum === undefined ? {} : { countQuantum: images.countQuantum }, - placeholder: ref => offloadedImageText(ref, images.resolveImageAccess?.(ref)), - }) + assertRetainedImagesFit(options.messages, images) + const requestMessages = projectOffloadedImages( + options.messages, + ref => offloadedImageText(ref, images.resolveImageAccess?.(ref)), + ) const messages: WireMessage[] = [] if (options.system !== undefined) { messages.push({ role: 'system', content: options.system }) diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 39dd9e25b5..3d8cb10a44 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -163,10 +163,10 @@ describe('request image policy', () => { const adapter = adapterOf({ models: [{ id: 'vision', inputModalities: ['text', 'image'] }], }) - const priced = adapter.imageRequestPricing('deepseek-official', 'vision')?.priceImages([imageRef]) + const priced = adapter.imageRequestPricing('deepseek-official', 'vision')?.priceImages([{ type: 'image', attachment: imageRef }]) expect(priced).toHaveLength(1) expect(priced?.[0]!.visualTokens).toBeGreaterThan(0) - const textOnly = adapter.imageRequestPricing('deepseek-official', 'unlisted')?.priceImages([imageRef]) + const textOnly = adapter.imageRequestPricing('deepseek-official', 'unlisted')?.priceImages([{ type: 'image', attachment: imageRef }]) expect(textOnly?.[0]!.visualTokens).toBe(0) }) @@ -182,7 +182,7 @@ describe('request image policy', () => { : undefined), prepareExtensions: noExtensions, }) - const priced = adapter.imageRequestPricing('deepseek-official', 'vision')?.priceImages([imageRef]) + const priced = adapter.imageRequestPricing('deepseek-official', 'vision')?.priceImages([{ type: 'image', attachment: imageRef }]) expect(priced?.[0]?.text).toContain('/world/img.png') }) }) @@ -441,7 +441,7 @@ describe('DeepSeekAdapter against a mock server', () => { expect(files.ensureUploaded).toHaveBeenCalledTimes(1) }) - it('reduces base64 fallback history from the configured high watermark to its half-size quantum', async () => { + it('fails a base64 fallback whose retained images exceed the inline bound with the count to offload', async () => { const server = await mockServer([{ kind: 'sse', events: textEvents }]) const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))).store const files = fileStoreOf(() => Promise.reject(new LlmError('Files unavailable', 'SERVER'))) @@ -452,20 +452,16 @@ describe('DeepSeekAdapter against a mock server', () => { models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], }, attachments, files.store) - await drain(adapter.stream({ + await expect(drain(adapter.stream({ provider: 'deepseek-official', model: 'deepseek-v4-flash-vision-exp', messages: [createUserMessage({ content: Array.from({ length: 21 }, () => ({ type: 'image' as const, attachment: imageRef })), source: { kind: 'plugin', plugin: 'test' }, })], - })) - - const body = JSON.stringify(server.requests[0]) - expect(body.match(/image omitted to fit request image limits/g)).toHaveLength(11) - expect(body.match(/"type":"image_url"/g)).toHaveLength(10) + }))).rejects.toMatchObject({ code: 'IMAGE_OFFLOAD_REQUIRED', message: expect.stringContaining('11 more') as string }) + expect(server.requests).toHaveLength(0) }) - it('discards partially resolved file ids and falls back with every retained image inline', async () => { const server = await mockServer([{ kind: 'sse', events: textEvents }]) const secondRef = { ...imageRef, attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`) } @@ -592,7 +588,7 @@ describe('DeepSeekAdapter against a mock server', () => { expect(JSON.stringify(server.requests[0])).not.toContain('image_url') }) - it('does not prepare an old image removed by request offload', async () => { + it('does not prepare a surface-offloaded image and sends its placeholder instead', async () => { const server = await mockServer([{ kind: 'sse', events: textEvents }]) const old = { ...imageRef, attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`), bytes: 3 } const recent = { ...imageRef, attachmentId: AttachmentId(`sha256:${'d'.repeat(64)}`), bytes: 3 } @@ -603,8 +599,6 @@ describe('DeepSeekAdapter against a mock server', () => { const adapter = adapterOf({ baseURL: server.url, models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], - maxRequestFilesBytes: 4, - imageOffloadByteQuantum: 2, }, attachmentMocks.store) await drain(adapter.stream({ @@ -612,7 +606,7 @@ describe('DeepSeekAdapter against a mock server', () => { model: 'deepseek-v4-flash-vision-exp', messages: [createUserMessage({ content: [ - { type: 'image', attachment: old }, + { type: 'image', attachment: old, offloaded: true }, { type: 'image', attachment: recent }, ], source: { kind: 'plugin', plugin: 'test' }, @@ -635,6 +629,30 @@ describe('DeepSeekAdapter against a mock server', () => { }) }) + it('declares the file-mode request-image budget only for image-capable catalog routes', async () => { + const adapter = adapterOf({ + maxRequestFilesBytes: 4096, + maxImagesPerRequest: 40, + imageOffloadByteQuantum: 1024, + imageOffloadCountQuantum: 20, + models: [ + { id: 'vision', inputModalities: ['text', 'image'], imageMaxBytes: 2048 }, + { id: 'text-only' }, + ], + }) + await expect(adapter.resolveModel('deepseek-official', 'vision')).resolves.toMatchObject({ + imageRequest: { + representation: 'raw', + maxBytes: 4096, + maxImages: 40, + byteQuantum: 1024, + countQuantum: 20, + versionMaxBytes: 2048, + }, + }) + const textOnly = await adapter.resolveModel('deepseek-official', 'text-only') + expect(textOnly.imageRequest).toBeUndefined() + }) it('projects nested tool-result images with route-owned request budgets', async () => { const server = await mockServer([ { kind: 'sse', events: textEvents }, diff --git a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts index d8b27a2407..2c76c6fb5f 100644 --- a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts @@ -219,16 +219,34 @@ describe('request-level dynamic configuration', () => { await assemble(ctx, { model: 'deepseek-v4-flash-vision-exp', messages }) await ctx.settings.update(NS, { maxRequestFilesBytes: 4, imageOffloadByteQuantum: 2 }) - await assemble(ctx, { model: 'deepseek-v4-flash-vision-exp', messages }) + // The tightened budget reaches the agent loop through the resolved route metadata... + await expect(ctx.llm.resolveModelInfo('deepseek-official', 'deepseek-v4-flash-vision-exp')) + .resolves.toMatchObject({ imageRequest: { representation: 'raw', maxBytes: 4, byteQuantum: 2 } }) + // ...and a request whose retained exact bytes still exceed it names the occurrences to offload. + const rejected = await assemble(ctx, { model: 'deepseek-v4-flash-vision-exp', messages }) + expect(rejected.finish).toMatchObject({ + kind: 'error', + failure: { code: 'IMAGE_OFFLOAD_REQUIRED', offloadImages: 1 }, + }) + await assemble(ctx, { + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [ + { type: 'image', attachment: IMAGE_REF, offloaded: true }, + { type: 'image', attachment: IMAGE_REF }, + ], + source: { kind: 'plugin', plugin: 'test' }, + })], + }) const first = (server.requests[0] as { messages: Array<{ content: unknown }> }).messages[0]?.content const second = (server.requests[1] as { messages: Array<{ content: unknown }> }).messages[0]?.content + expect(server.requests).toHaveLength(2) expect(JSON.stringify(first).match(/"type":"file"/g)).toHaveLength(2) expect(JSON.stringify(second)).toContain('[image omitted to fit request image limits') expect(JSON.stringify(second)).toContain(MODEL_IMAGE_PATH) expect(JSON.stringify(second).match(/"type":"file"/g)).toHaveLength(1) }) - it('re-registers the route in place when the captured retry policy changes, without an empty-registry window', async () => { const dir = await home() const { ctx } = await boot(dir, { baseURL: 'http://127.0.0.1:1' }) diff --git a/packages/llm/llm-deepseek/tests/request-pricing.spec.ts b/packages/llm/llm-deepseek/tests/request-pricing.spec.ts index a9d81c574a..ae05290547 100644 --- a/packages/llm/llm-deepseek/tests/request-pricing.spec.ts +++ b/packages/llm/llm-deepseek/tests/request-pricing.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { offloadedImageText, requestImageHandleText, textOnlyImageText } from '@deepseek-ai/dsh-llm' +import type { ImageBlock } from '@deepseek-ai/dsh-llm' import { AttachmentId } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import { deepSeekImageRequestPricing } from '../src/request-pricing.ts' @@ -22,6 +23,10 @@ function ref(name: string, width: number, height: number, bytes = 1024): ImageAt } } +function block(attachment: ImageAttachmentRef, offloaded?: true): ImageBlock { + return { type: 'image', attachment, ...offloaded === undefined ? {} : { offloaded } } +} + function connection(config: Omit = {}): ReturnType { return resolveAdapterOptions(Object.assign({ models: [VISION_MODEL] }, config)) } @@ -29,20 +34,20 @@ function connection(config: Omit = {}): ReturnType { it('prices an uncatalogued model as its text-only substitution', () => { const image = ref('photo', 1920, 1080) - const prices = deepSeekImageRequestPricing(connection(), 'unlisted').priceImages([image]) + const prices = deepSeekImageRequestPricing(connection(), 'unlisted').priceImages([block(image)]) expect(prices).toEqual([{ visualTokens: 0, text: textOnlyImageText(image) }]) }) it('prices a catalogued text-only model as its text-only substitution', () => { const image = ref('photo', 1920, 1080) const options = resolveAdapterOptions({ models: [{ id: 'text-only' }] }) - const prices = deepSeekImageRequestPricing(options, 'text-only').priceImages([image]) + const prices = deepSeekImageRequestPricing(options, 'text-only').priceImages([block(image)]) expect(prices).toEqual([{ visualTokens: 0, text: textOnlyImageText(image) }]) }) it('prices a retained image by its projected request dimensions plus its handle text', () => { const image = ref('photo', 1920, 1080) - const prices = deepSeekImageRequestPricing(connection(), 'vision').priceImages([image]) + const prices = deepSeekImageRequestPricing(connection(), 'vision').priceImages([block(image)]) expect(prices).toEqual([{ visualTokens: 369, text: requestImageHandleText(image, { width: 1066, height: 600 }), @@ -54,7 +59,7 @@ describe('DeepSeek request-image pricing', () => { const options = resolveAdapterOptions({ models: [{ ...VISION_MODEL, imagePixelBudget: 'low' as const }], }) - const prices = deepSeekImageRequestPricing(options, 'vision').priceImages([image]) + const prices = deepSeekImageRequestPricing(options, 'vision').priceImages([block(image)]) expect(prices[0]!.visualTokens).toBe(201) }) @@ -62,10 +67,10 @@ describe('DeepSeek request-image pricing', () => { const access = { readonlyPath: '/world/attachments/photo.png' } const images = [ref('first', 800, 800), ref('second', 800, 800)] const prices = deepSeekImageRequestPricing( - connection({ maxImagesPerRequest: 1, imageOffloadCountQuantum: 1 }), + connection(), 'vision', () => access, - ).priceImages(images) + ).priceImages([block(images[0]!, true), block(images[1]!)]) expect(prices[0]).toEqual({ visualTokens: 0, text: offloadedImageText(images[0]!, access) }) expect(prices[1]).toEqual({ visualTokens: 349, @@ -74,12 +79,12 @@ describe('DeepSeek request-image pricing', () => { expect(prices[1]?.text).toContain('/world/attachments/photo.png') }) - it('prices count-offloaded oldest occurrences as their placeholder text', () => { + it('prices surface-offloaded occurrences as placeholder text and every retained one at its visual price', () => { const images = [ref('first', 800, 800), ref('second', 800, 800), ref('third', 800, 800)] const prices = deepSeekImageRequestPricing( - connection({ maxImagesPerRequest: 2, imageOffloadCountQuantum: 1 }), + connection({ maxImagesPerRequest: 1, imageOffloadCountQuantum: 1 }), 'vision', - ).priceImages(images) + ).priceImages([block(images[0]!, true), block(images[1]!), block(images[2]!)]) expect(prices).toEqual([ { visualTokens: 0, text: offloadedImageText(images[0]!) }, { visualTokens: 349, text: requestImageHandleText(images[1]!, { width: 800, height: 800 }) }, @@ -87,20 +92,12 @@ describe('DeepSeek request-image pricing', () => { ]) }) - it('caps each occurrence at the per-image byte target before the byte budget', () => { - // Each 5 MiB source counts as the 1 MiB request target, so a 2 MiB budget - // with a one-byte quantum removes exactly the oldest occurrence. - const oversized = 5 * 1024 * 1024 - const images = [ - ref('first', 800, 800, oversized), - ref('second', 800, 800, oversized), - ref('third', 800, 800, oversized), - ] + it('prices a retained oversized occurrence at its visual price: the surface, not the budget, decides offload', () => { + const oversized = ref('first', 800, 800, 5 * 1024 * 1024) const prices = deepSeekImageRequestPricing( connection({ maxRequestFilesBytes: 2 * 1024 * 1024, imageOffloadByteQuantum: 1 }), 'vision', - ).priceImages(images) - expect(prices.map(price => price.visualTokens)).toEqual([0, 349, 349]) - expect(prices[0]!.text).toBe(offloadedImageText(images[0]!)) + ).priceImages([block(oversized)]) + expect(prices.map(price => price.visualTokens)).toEqual([349]) }) }) diff --git a/packages/llm/llm-deepseek/tests/serialize.spec.ts b/packages/llm/llm-deepseek/tests/serialize.spec.ts index 97b4b62127..2fc65799c9 100644 --- a/packages/llm/llm-deepseek/tests/serialize.spec.ts +++ b/packages/llm/llm-deepseek/tests/serialize.spec.ts @@ -589,7 +589,7 @@ describe('image serialization', () => { ]) }) - it('offloads oldest images before reads and keeps the newest image', async () => { + it('projects surface-offloaded occurrences to placeholders without resolving them', async () => { const resolveFileId = fileResolver() const png = imageRef('image/png', 3) const jpeg = imageRef('image/jpeg', 3) @@ -601,7 +601,7 @@ describe('image serialization', () => { model: 'deepseek-v4-flash-vision-exp', messages: [createUserMessage({ content: [ - { type: 'image', attachment: png }, + { type: 'image', attachment: png, offloaded: true }, { type: 'image', attachment: jpeg }, ], source: { kind: 'plugin', plugin: 'test' }, @@ -622,22 +622,38 @@ describe('image serialization', () => { expect(resolveFileId).toHaveBeenCalledTimes(1) expect(resolveFileId.mock.calls[0]?.[0]).toMatchObject({ attachment: { mediaType: 'image/jpeg' } }) }) - - it('drops base64 history from a 20-unit high watermark to a 10-unit low watermark', async () => { + it('rejects retained inline images beyond the bound with the quantized count to offload', async () => { const ref = imageRef('image/png', 3) - const wire = await serializeRequestWithImages(request({ + // 21 retained 3-byte images encode to 84 base64 bytes against an 80-byte bound with a + // 40-byte quantum: the removal crosses the quantum at the eleventh oldest occurrence. + await expect(serializeRequestWithImages(request({ model: 'deepseek-v4-flash-vision-exp', messages: [createUserMessage({ content: Array.from({ length: 21 }, () => ({ type: 'image' as const, attachment: ref })), source: { kind: 'plugin', plugin: 'test' }, })], - }), inlineImageOptions([ref], 80, 40)) + }), inlineImageOptions([ref], 80, 40))).rejects.toMatchObject({ + code: 'IMAGE_OFFLOAD_REQUIRED', + failure: { offloadImages: 11 }, + }) + }) + it('counts only retained occurrences against the bound', async () => { + const ref = imageRef('image/png', 3) + const wire = await serializeRequestWithImages(request({ + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [ + ...Array.from({ length: 11 }, () => ({ type: 'image' as const, attachment: ref, offloaded: true as const })), + ...Array.from({ length: 10 }, () => ({ type: 'image' as const, attachment: ref })), + ], + source: { kind: 'plugin', plugin: 'test' }, + })], + }), inlineImageOptions([ref], 80, 40)) const content = wire.messages[0]?.content expect(JSON.stringify(content).match(/image omitted to fit request image limits/g)).toHaveLength(11) expect(JSON.stringify(content).match(/"type":"image_url"/g)).toHaveLength(10) }) - it('rejects an unprepared image while computing exact request bytes', async () => { const ref = imageRef() await expect(serializeRequestWithImages(request({ diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 656439bf24..f19ecce85d 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-pi-ai/README.md -README.md: c0aacbe3e0b727cd7b6b163814dc96859b954b9c -README.zh.md: 7b8cef7db2ba5cebe3e42345b308716ae0099c03 +README.md: da413435eb997f87c70f54ed74f3098c0baa437e +README.zh.md: 5fb07af85243e33105980ab1f47a34cd6407b195 diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index c0aacbe3e0..da413435eb 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -83,7 +83,7 @@ Each profile may set a `retryPolicy`; omission uses normal mode with five retrie | `defaultMaxTokens` | `32,768` | Output-cap fallback for undescribed models | | `requestImagePixelBudget` | `4,194,304` | Total-pixel budget for each deterministic request image | | `requestImageMaxBytes` | `1 MiB` | Encoded-byte target for each request image before base64 expansion | -| `maxRequestImageBytes` | `20 MiB` | Aggregate base64 image-payload bound with oldest-first offload | +| `maxRequestImageBytes` | `20 MiB` | Aggregate base64 image-payload bound declared to the agent loop's `image/offload` watermark | | `retryPolicy` | normal, 5 retries | Provider-owned retry policy executed by `dsh-llm-retry` | The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-llm-pi-ai) is the exhaustive source for every accepted field and its JSDoc. @@ -174,7 +174,7 @@ Read these pages when the package-level contract is not enough. They move from t #### What the model sees -The selected catalog model receives `GenerateOptions.system`, history, tools, and sampling fields supported by pi-ai's common streaming API. Each retained image is preceded by text naming its complete attachment id and actual request dimensions. When the current execution filesystem maps the attachment provider's host object, the text also carries a read-only normalized-object path and warns that normalization or request projection may have resized or re-encoded the upload. When accumulated base64 image payload exceeds the route's `maxRequestImageBytes`, each offloaded image keeps its own identity and currently resolved access in replacement text. Offloaded normalized attachments are not read or transformed. Provider-native replay metadata is restored only when the adapter validates it for the historical content. +The selected catalog model receives `GenerateOptions.system`, history, tools, and sampling fields supported by pi-ai's common streaming API. Each retained image is preceded by text naming its complete attachment id and actual request dimensions. When the current execution filesystem maps the attachment provider's host object, the text also carries a read-only normalized-object path and warns that normalization or request projection may have resized or re-encoded the upload. Each occurrence the session's `image/offload` watermark marks offloaded keeps its own identity and currently resolved access in replacement text, and its normalized attachment is not read or transformed. When the retained occurrences' exact base64 payload still exceeds the route's `maxRequestImageBytes`, the call fails with `IMAGE_OFFLOAD_REQUIRED` so the agent loop advances the watermark and rebuilds the request. Provider-native replay metadata is restored only when the adapter validates it for the historical content. #### Token effect @@ -182,7 +182,7 @@ Provider tokenization governs exact input. Retained images add the stable attach #### KV Cache effect -Conversion preserves logical request order, while image handles and offload placeholders add model-visible text. A changed execution-world path rewrites a historical handle and can prevent reuse from that image even when attachment identity and request bytes stay stable. Changing adapter instance, provider, model, or another upstream token has the same suffix effect. Crossing the image bound replaces an earlier image with placeholder text, so reuse ends at that message until the offloaded prefix stabilizes. +Conversion preserves logical request order, while image handles and offload placeholders add model-visible text. A changed execution-world path rewrites a historical handle and can prevent reuse from that image even when attachment identity and request bytes stay stable. Changing adapter instance, provider, model, or another upstream token has the same suffix effect. An `image/offload` advance replaces an earlier image with placeholder text, so reuse ends at that message; the watermark never retreats, so the prefix stays stable afterwards. ### Provider response diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index 7b8cef7db2..5fb07af852 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -83,7 +83,7 @@ kind: "package-reference" | `defaultMaxTokens` | `32,768` | 未描述模型的输出上限回退 | | `requestImagePixelBudget` | `4,194,304` | 每张确定性请求图片的总像素预算 | | `requestImageMaxBytes` | `1 MiB` | 每张请求图片在 base64 扩展前的编码字节目标 | -| `maxRequestImageBytes` | `20 MiB` | 带最旧优先卸载的 base64 图片载荷总上限 | +| `maxRequestImageBytes` | `20 MiB` | 向 agent loop 的 `image/offload` 水位声明的 base64 图片载荷总上限 | | `retryPolicy` | normal,5 次重试 | 由 `dsh-llm-retry` 执行的提供方自有重试策略 | 生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-llm-pi-ai)是每个受支持字段及其 JSDoc 的穷尽式真源。 @@ -174,7 +174,7 @@ pi-ai 不提供的路由需要 `api`、`baseURL` 与非空 `models` 列表;无 #### 模型看到什么 -所选目录模型会收到 `GenerateOptions.system`、历史、工具与 pi-ai 通用流式 API 支持的采样字段。每张保留图片前都会有文本,注明其完整附件 id 与实际请求尺寸。当前执行文件系统可以映射附件提供方的宿主对象时,该文本还会携带只读规范化对象路径,并警告规范化或请求投影可能缩放或重新编码上传内容。当累计 base64 图片载荷超过路由的 `maxRequestImageBytes` 时,每张卸载图片都会在替换文本中保留自己的身份与当前已解析访问方式。卸载的规范化附件不会读取或变换。提供方原生回放元数据只在适配器针对历史内容校验通过后恢复。 +所选目录模型会收到 `GenerateOptions.system`、历史、工具与 pi-ai 通用流式 API 支持的采样字段。每张保留图片前都会有文本,注明其完整附件 id 与实际请求尺寸。当前执行文件系统可以映射附件提供方的宿主对象时,该文本还会携带只读规范化对象路径,并警告规范化或请求投影可能缩放或重新编码上传内容。会话 `image/offload` 水位标记为已卸载的每个出现位置都会在替换文本中保留自己的身份与当前已解析访问方式,其规范化附件不会读取或变换。当保留的出现位置按精确 base64 载荷仍超过路由的 `maxRequestImageBytes` 时,调用以 `IMAGE_OFFLOAD_REQUIRED` 失败,由 agent loop 推进水位并重建请求。提供方原生回放元数据只在适配器针对历史内容校验通过后恢复。 #### Token 影响 @@ -182,7 +182,7 @@ pi-ai 不提供的路由需要 `api`、`baseURL` 与非空 `models` 列表;无 #### KV Cache 影响 -转换保持逻辑请求顺序,图片句柄与卸载占位符则会添加模型可见文本。即使附件身份与请求字节保持稳定,执行世界路径变化也会改写历史句柄,并可能从该图片起阻止复用。更换适配器实例、提供方、模型或其他上游 token 具有相同的后缀影响。越过图片上限会把较早图片替换为占位文本,因此复用在该消息处结束,直到被卸载前缀稳定。 +转换保持逻辑请求顺序,图片句柄与卸载占位符则会添加模型可见文本。即使附件身份与请求字节保持稳定,执行世界路径变化也会改写历史句柄,并可能从该图片起阻止复用。更换适配器实例、提供方、模型或其他上游 token 具有相同的后缀影响。一次 `image/offload` 推进会把较早图片替换为占位文本,因此复用在该消息处结束;水位永不回退,此后前缀保持稳定。 ### 提供方响应 diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index e20b8e0072..2c2842d2c3 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -306,6 +306,15 @@ export class PiAiAdapter extends LlmAdapter { inputModalities: [...resolvedModel.input], context: { contextWindow: resolvedModel.contextWindow }, ...configuredMaxTokens === undefined ? {} : { defaultMaxTokens: configuredMaxTokens }, + ...resolvedModel.input.includes('image') + ? { + imageRequest: { + representation: 'base64' as const, + maxBytes: profile.maxRequestImageBytes, + versionMaxBytes: profile.requestImageMaxBytes, + }, + } + : {}, ...reasoningInfo(resolvedModel, defaultLevel), } } diff --git a/packages/llm/llm-pi-ai/src/context.ts b/packages/llm/llm-pi-ai/src/context.ts index 338e71a6ad..6a9607b2dc 100644 --- a/packages/llm/llm-pi-ai/src/context.ts +++ b/packages/llm/llm-pi-ai/src/context.ts @@ -5,7 +5,7 @@ */ import { brandString } from '@deepseek-ai/dsh-brand' -import { contentHasImage, LlmError, offloadedImageText, offloadRequestImagesWithPolicy, requestImageHandleText } from '@deepseek-ai/dsh-llm' +import { contentHasImage, IMAGE_OFFLOAD_REQUIRED_CODE, LlmError, offloadedImageText, offloadedImagePrefixCount, projectOffloadedImages, representedImageBytes, requestImageHandleText, visitImageBlocks } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, ImageAttachmentAccessResolver, Message, ToolCallId } from '@deepseek-ai/dsh-llm' import type { AttachmentId, @@ -94,8 +94,11 @@ function collectImageRefs( refs: Map, ): void { for (const block of blocks) { - if (block.type === 'image') refs.set(block.attachment.attachmentId, block.attachment) - else if (block.type === 'tool-result') collectImageRefs(block.content, refs) + if (block.type === 'image') { + if (block.offloaded !== true) refs.set(block.attachment.attachmentId, block.attachment) + } else if (block.type === 'tool-result') { + collectImageRefs(block.content, refs) + } } } @@ -192,7 +195,7 @@ export interface PiImageRequestContext { attachments: AttachmentStore /** Resolve current tool access separately from deterministic request-image versions. */ resolveImageAccess: ImageAttachmentAccessResolver - /** Request-level bound on base64-encoded image payload; omission leaves every image in place. */ + /** Request-level bound on the base64-encoded payload of retained images; omission leaves the bound unchecked. */ maxRequestImageBytes?: number /** Route pixel and raw encoded-byte budgets. */ requestImagePolicy?: ImageRequestPolicy @@ -213,10 +216,11 @@ export function toPiContext( ): PiContext /** * Convert harness history to a pi-ai Context while resolving durable images. - * Tool result names are recovered from preceding assistant tool calls. When - * the accumulated base64 image payload exceeds `maxRequestImageBytes`, the - * oldest images are replaced by text placeholders until the request fits, so - * an image-heavy session keeps clearing gateway request-size caps. + * Tool result names are recovered from preceding assistant tool calls. Image + * occurrences the surface marks offloaded become text placeholders; when the + * retained occurrences' exact base64 payload still exceeds + * `maxRequestImageBytes`, the call fails with `IMAGE_OFFLOAD_REQUIRED` naming + * how many more oldest occurrences the agent loop must offload. * @param options - the harness request; `options.system` maps to pi-ai's single `systemPrompt` slot. * @param images - attachment provider, current path resolver, and request limits. * @param onReplayDegrade - forwarded to {@link toPiAssistant} for each assistant message. @@ -248,21 +252,29 @@ async function toPiContextWithImages( maxBytes: DEFAULT_REQUEST_IMAGE_MAX_BYTES, } assertSupportedImageRoles(options.messages) - const requestMessages = offloadRequestImagesWithPolicy(options.messages, { - representation: 'base64', - ...maxRequestImageBytes === undefined ? {} : { maxBytes: maxRequestImageBytes }, - byteQuantum: 1, - byteLength: ref => Math.min(ref.bytes, requestImagePolicy.maxBytes), - placeholder: ref => offloadedImageText(ref, resolveImageAccess(ref)), - }) - const requestImages = await prepareRequestImages(requestMessages, attachments, requestImagePolicy, options.signal) - const exactMessages = offloadRequestImagesWithPolicy(requestMessages, { - representation: 'base64', - ...maxRequestImageBytes === undefined ? {} : { maxBytes: maxRequestImageBytes }, - byteQuantum: 1, - byteLength: ref => (requestImages.get(ref.attachmentId) as RequestImageAttachment).bytes, - placeholder: ref => offloadedImageText(ref, resolveImageAccess(ref)), - }) + const requestImages = await prepareRequestImages(options.messages, attachments, requestImagePolicy, options.signal) + if (maxRequestImageBytes !== undefined) { + const lengths: number[] = [] + for (const message of options.messages) { + visitImageBlocks(message.content, (block) => { + if (block.offloaded === true) return + const version = requestImages.get(block.attachment.attachmentId) as RequestImageAttachment + lengths.push(representedImageBytes(version.bytes, { representation: 'base64' })) + }) + } + const offloadImages = offloadedImagePrefixCount(lengths, { maxBytes: maxRequestImageBytes }) + if (offloadImages > 0) { + throw new LlmError( + `pi-ai request images exceed the ${maxRequestImageBytes}-byte base64 bound; ${offloadImages} more oldest occurrence(s) must be offloaded.`, + IMAGE_OFFLOAD_REQUIRED_CODE, + { offloadImages }, + ) + } + } + const exactMessages = projectOffloadedImages( + options.messages, + ref => offloadedImageText(ref, resolveImageAccess(ref)), + ) const toolNames = new Map() const messages: PiMessage[] = [] diff --git a/packages/llm/llm-pi-ai/tests/context.spec.ts b/packages/llm/llm-pi-ai/tests/context.spec.ts index 902a288caa..cd6ed0935b 100644 --- a/packages/llm/llm-pi-ai/tests/context.spec.ts +++ b/packages/llm/llm-pi-ai/tests/context.spec.ts @@ -257,20 +257,20 @@ describe('pi-ai request context conversion', () => { }) }) - it('replaces the oldest images with placeholders once the request payload bound is exceeded', async () => { + it('projects surface-offloaded occurrences to placeholders and prepares only retained ones', async () => { const readImageRequest = vi.fn((value: ImageAttachmentRef) => ( Promise.resolve(requestImage(value, Uint8Array.of(1, 2, 3))) )) const store = projectionStore(readImageRequest) const sized: ImageAttachmentRef = { ...ref, bytes: 3 } const callId = ToolCallId('shot-call') - // Three 3-byte images cost 4 base64 characters each (12 total); a bound of - // 8 forces exactly the oldest one out, including one nested in a tool result. + // The nested occurrence is offloaded on the surface; the two retained 3-byte images cost + // 4 base64 characters each and fit the 8-byte bound exactly. const context = await toPiContext(request([ user([{ type: 'tool-result', toolCallId: callId, - content: [{ type: 'image', attachment: sized }], + content: [{ type: 'image', attachment: sized, offloaded: true }], }]), user([{ type: 'image', attachment: sized }, { type: 'text', text: 'newer' }]), user([{ type: 'image', attachment: sized }]), @@ -305,8 +305,7 @@ describe('pi-ai request context conversion', () => { ]) expect(readImageRequest).toHaveBeenCalledTimes(1) }) - - it('does not prepare an old image removed by the conservative request projection', async () => { + it('does not prepare a surface-offloaded image', async () => { const old = { ...ref, attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`), bytes: 3 } const recent = { ...ref, attachmentId: AttachmentId(`sha256:${'d'.repeat(64)}`), bytes: 3 } const readImageRequest = vi.fn((value: ImageAttachmentRef) => { @@ -315,7 +314,7 @@ describe('pi-ai request context conversion', () => { }) const context = await toPiContext(request([user([ - { type: 'image', attachment: old }, + { type: 'image', attachment: old, offloaded: true }, { type: 'image', attachment: recent }, ])]), imageContext(projectionStore(readImageRequest), { maxRequestImageBytes: 4 })) @@ -330,16 +329,26 @@ describe('pi-ai request context conversion', () => { expect(readImageRequest).toHaveBeenCalledTimes(1) expect(readImageRequest.mock.calls[0]?.[0]).toEqual(recent) }) - - it('uses independently resolved access when exact encoded bytes require offload', async () => { + it('fails with the count to offload when exact encoded bytes exceed the bound', async () => { const sized: ImageAttachmentRef = { ...ref, bytes: 3 } - const access = { readonlyPath: '/tmp/dsh-normalized-image' } const readImageRequest = vi.fn((value: ImageAttachmentRef) => Promise.resolve({ ...requestImage(value, Uint8Array.of(1, 2, 3, 4)), })) - const context = await toPiContext(request([ + await expect(toPiContext(request([ user([{ type: 'image', attachment: sized }]), + ]), imageContext(projectionStore(readImageRequest), { maxRequestImageBytes: 4 }))) + .rejects.toMatchObject({ code: 'IMAGE_OFFLOAD_REQUIRED', failure: { offloadImages: 1 } }) + expect(readImageRequest).toHaveBeenCalledTimes(1) + }) + + it('renders an offloaded occurrence with independently resolved access', async () => { + const sized: ImageAttachmentRef = { ...ref, bytes: 3 } + const access = { readonlyPath: '/tmp/dsh-normalized-image' } + const readImageRequest = vi.fn() + + const context = await toPiContext(request([ + user([{ type: 'image', attachment: sized, offloaded: true }]), ]), imageContext(projectionStore(readImageRequest), { maxRequestImageBytes: 4, resolveImageAccess: () => access, @@ -350,10 +359,9 @@ describe('pi-ai request context conversion', () => { content: offloadedImageText(sized, access), timestamp: 0, }]) - expect(readImageRequest).toHaveBeenCalledTimes(1) + expect(readImageRequest).not.toHaveBeenCalled() }) - - it('keeps every image at exactly the payload bound and drops all of them when even the newest cannot fit', async () => { + it('keeps every image at exactly the payload bound and rejects a single image that cannot fit', async () => { const sized: ImageAttachmentRef = { ...ref, bytes: 3 } const exact = await toPiContext(request([ user([{ type: 'image', attachment: sized }]), @@ -376,17 +384,12 @@ describe('pi-ai request context conversion', () => { Promise.resolve(requestImage(value, new Uint8Array(300))) )) const store = projectionStore(readImageRequest) - const oversized = await toPiContext(request([ + await expect(toPiContext(request([ user([{ type: 'image', attachment: { ...ref, bytes: 300 } }]), - ]), imageContext(store, { maxRequestImageBytes: 8 })) - // All-text content collapses to the string form; the placeholder still reaches the model. - expect(oversized.messages).toEqual([ - { role: 'user', content: offloadedImageText({ ...ref, bytes: 300 }), timestamp: 0 }, - ]) - expect(readImageRequest).not.toHaveBeenCalled() + ]), imageContext(store, { maxRequestImageBytes: 8 }))) + .rejects.toMatchObject({ code: 'IMAGE_OFFLOAD_REQUIRED', failure: { offloadImages: 1 } }) }) - - it('offloads repeated image-block occurrences by position rather than shared object identity', async () => { + it('projects repeated image-block occurrences by their own surface mark', async () => { const sized: ImageAttachmentRef = { ...ref, bytes: 3 } const shared: ContentBlock = { type: 'image', attachment: sized } const readImageRequest = vi.fn((value: ImageAttachmentRef) => ( @@ -394,11 +397,11 @@ describe('pi-ai request context conversion', () => { )) const store = projectionStore(readImageRequest) const aliased = await toPiContext( - request([user([shared, shared])]), + request([user([{ ...shared, offloaded: true }, shared])]), imageContext(store, { maxRequestImageBytes: 4 }), ) const replayed = await toPiContext(request([user([ - { type: 'image', attachment: { ...sized } }, + { type: 'image', attachment: { ...sized }, offloaded: true }, { type: 'image', attachment: { ...sized } }, ])]), imageContext(store, { maxRequestImageBytes: 4 })) @@ -415,7 +418,6 @@ describe('pi-ai request context conversion', () => { expect(replayed.messages).toEqual(expected) expect(readImageRequest).toHaveBeenCalledTimes(2) }) - it('keeps empty text-only users while separating result-only messages', () => { const callId = ToolCallId('unknown-call') expect(toPiContext(request([ diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml index e5a5b22f6a..6b4eafc91a 100644 --- a/packages/llm/llm/README.i18n.yaml +++ b/packages/llm/llm/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm/README.md -README.md: 6baa020a9503f32a71231d30e78ec7a3d8862f2b -README.zh.md: f985736da44a76b063f16915a53cb19979fe1962 +README.md: 749bd226b80cbcb232f8b2a7264a67d2bfa8815e +README.zh.md: 501790afd6665e21683b39650b6c35241740437f diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index 6baa020a95..749bd226b8 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -93,13 +93,13 @@ The service is built on one separation: **the logical contract is provider-neutr | [`src/call-config.ts`](src/call-config.ts) | Call-config validation, adapter-default materialization, and request freezing | | [`src/retry-policy.ts`](src/retry-policy.ts) | Provider-owned retry policy resolution (normal and always modes) | | [`src/error.ts`](src/error.ts) | `HarnessError`/`LlmError` taxonomy and provider-neutral failure codes | -| [`src/content.ts`](src/content.ts) | Shared image-content helpers, including request-image offloading | +| [`src/content.ts`](src/content.ts) | Shared image-content helpers: the image walk, offload watermark planning, and offloaded-image projection | | [`src/api-key.ts`](src/api-key.ts) | Credential format check shared by every adapter | | [`src/adapter-failure.ts`](src/adapter-failure.ts) | Failure normalization into terminal finish chunks | ### Main flow -A request is validated against its exact model's capability — context window, output default, reasoning efforts, and input modalities — and any adapter-configured defaults are materialized, then the whole request is deep-frozen. `prepareCall()` binds those facts, detached context, and retry policy to the exact adapter generation that performs terminal dispatch, so HMR or dynamic settings cannot combine one generation's image capability with another generation's endpoint. An image-capable adapter projects durable references into route-specific request versions; `resolveImageAttachmentAccess()` separately maps an attachment provider's optional host object into the current tool execution world without changing the request image or its `variantId`. A text-only route receives deterministic per-image placeholders, including nested tool-result images, without rewriting append-only session history. `offloadRequestImagesWithPolicy()` removes oldest images deterministically by raw or base64 size and count or byte quanta; the pure `offloadedImagePrefixCount()` exposes that decision so route-owned request pricing can reproduce it without building the projection. Adapters that charge visual tokens declare per-route `imageRequestPricing`, which `ctx.llm.imageRequestPricing(provider, model)` resolves synchronously for the token meter. Dispatch goes through the `llm/stream` waterfall, then chunks return as token-level deltas and every adapter outcome reaches the consumer as one terminal `finish` chunk. +A request is validated against its exact model's capability — context window, output default, reasoning efforts, and input modalities — and any adapter-configured defaults are materialized, then the whole request is deep-frozen. `prepareCall()` binds those facts, detached context, and retry policy to the exact adapter generation that performs terminal dispatch, so HMR or dynamic settings cannot combine one generation's image capability with another generation's endpoint. An image-capable adapter projects durable references into route-specific request versions; `resolveImageAttachmentAccess()` separately maps an attachment provider's optional host object into the current tool execution world without changing the request image or its `variantId`. A text-only route receives deterministic per-image placeholders, including nested tool-result images, without rewriting append-only session history. An image-capable route declares a `LlmImageRequestBudget` on its resolved model info; the agent loop plans the session's durable `image/offload` watermark from it with the pure `planImageOffload()`, surface derivation marks occurrences at or before the watermark `offloaded: true`, and `projectOffloadedImages()` renders those marks as route-owned placeholder text. An adapter whose exact byte accounting still exceeds its budget fails with `IMAGE_OFFLOAD_REQUIRED` naming the additional occurrences (`offloadedImagePrefixCount()`), never with an unlogged projection. Adapters that charge visual tokens declare per-route `imageRequestPricing`, which `ctx.llm.imageRequestPricing(provider, model)` resolves synchronously for the token meter. Dispatch goes through the `llm/stream` waterfall, then chunks return as token-level deltas and every adapter outcome reaches the consumer as one terminal `finish` chunk. ### Invariants @@ -136,7 +136,7 @@ None, as the LLM service adds no content; adapters choose when to add the shared #### KV Cache effect -Reasoning-effort materialization preserves the assembled request prefix. Image identity and request-preview text are deterministic, while an optional execution-world path is resolved for each request; a changed path or image-offload boundary can prevent reuse from that image. +Reasoning-effort materialization preserves the assembled request prefix. Image identity and request-preview text are deterministic, while an optional execution-world path is resolved for each request; a changed path or an `image/offload` advance can prevent reuse from that image. ## Known Limitations and Deferred Work diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md index f985736da4..501790afd6 100644 --- a/packages/llm/llm/README.zh.md +++ b/packages/llm/llm/README.zh.md @@ -93,13 +93,13 @@ for await (const chunk of ctx.llm.stream({ | [`src/call-config.ts`](src/call-config.ts) | 调用配置校验、适配器默认值填入与请求冻结 | | [`src/retry-policy.ts`](src/retry-policy.ts) | 提供方自有重试策略解析(normal 与 always 模式) | | [`src/error.ts`](src/error.ts) | `HarnessError`/`LlmError` 分类体系与提供方无关失败 code | -| [`src/content.ts`](src/content.ts) | 共享图片内容辅助函数,包括请求图片卸载 | +| [`src/content.ts`](src/content.ts) | 共享图片内容辅助函数:图片遍历、卸载水位规划与已卸载图片投影 | | [`src/api-key.ts`](src/api-key.ts) | 每个适配器共享的凭据格式校验 | | [`src/adapter-failure.ts`](src/adapter-failure.ts) | 把失败归一化为终止 finish 分片 | ### 主流程 -请求会对照其精确模型的能力——上下文窗口、输出默认值、推理强度与输入模态——校验,填入任何适配器配置的默认值,然后整个请求被深度冻结。`prepareCall()` 把这些事实、分离的上下文与重试策略绑定到执行最终分发的精确适配器代次,因此 HMR 或动态设置无法把一个代次的图片能力与另一代次的端点混用。支持图片的适配器把持久引用投影为路由专用请求版本;`resolveImageAttachmentAccess()` 会单独把附件提供方的可选宿主对象映射进当前工具执行世界,而不改变请求图片或其 `variantId`。纯文本路由接收确定性的逐图片占位符,包括嵌套工具结果图片,而不会改写仅追加会话历史。`offloadRequestImagesWithPolicy()` 按原始字节或 base64 大小以及图片数或字节步长,确定性地从最旧图片开始移除;纯函数 `offloadedImagePrefixCount()` 公开同一决策,使路由所属的请求定价无需构建投影即可复现它。对视觉 token 收费的适配器声明按路由的 `imageRequestPricing`,`ctx.llm.imageRequestPricing(provider, model)` 为 token meter 同步解析它。分发经过 `llm/stream` waterfall,随后分片以 token 级增量返回,每个适配器结果都以唯一一个终止 `finish` 分片到达消费方。 +请求会对照其精确模型的能力——上下文窗口、输出默认值、推理强度与输入模态——校验,填入任何适配器配置的默认值,然后整个请求被深度冻结。`prepareCall()` 把这些事实、分离的上下文与重试策略绑定到执行最终分发的精确适配器代次,因此 HMR 或动态设置无法把一个代次的图片能力与另一代次的端点混用。支持图片的适配器把持久引用投影为路由专用请求版本;`resolveImageAttachmentAccess()` 会单独把附件提供方的可选宿主对象映射进当前工具执行世界,而不改变请求图片或其 `variantId`。纯文本路由接收确定性的逐图片占位符,包括嵌套工具结果图片,而不会改写仅追加会话历史。支持图片的路由在其解析后的模型信息上声明 `LlmImageRequestBudget`;agent loop 用纯函数 `planImageOffload()` 据此规划会话的持久 `image/offload` 水位,表层派生把位于水位及之前的出现位置标为 `offloaded: true`,`projectOffloadedImages()` 把这些标记渲染为路由所属的占位文本。adapter 按精确字节计量仍超预算时,以 `IMAGE_OFFLOAD_REQUIRED` 失败并说明还需省略的出现位置数(`offloadedImagePrefixCount()`),绝不发送未记录的投影。对视觉 token 收费的适配器声明按路由的 `imageRequestPricing`,`ctx.llm.imageRequestPricing(provider, model)` 为 token meter 同步解析它。分发经过 `llm/stream` waterfall,随后分片以 token 级增量返回,每个适配器结果都以唯一一个终止 `finish` 分片到达消费方。 ### 不变式 @@ -136,7 +136,7 @@ for await (const chunk of ctx.llm.stream({ #### KV Cache 影响 -推理强度的具体化会保留已组装请求前缀。图片身份与请求预览文本是确定性的,可选执行世界路径则按请求解析;路径变化或图片卸载边界变化可能从该图片起阻止复用。 +推理强度的具体化会保留已组装请求前缀。图片身份与请求预览文本是确定性的,可选执行世界路径则按请求解析;路径变化或一次 `image/offload` 推进可能从该图片起阻止复用。 ## 已知限制与延期工作 diff --git a/packages/llm/llm/src/adapter-failure.ts b/packages/llm/llm/src/adapter-failure.ts index d11ee2e52b..98dba446d1 100644 --- a/packages/llm/llm/src/adapter-failure.ts +++ b/packages/llm/llm/src/adapter-failure.ts @@ -69,18 +69,21 @@ function failureSnapshot(value: unknown): LlmFailure | undefined { const status = candidate.status const providerRetryAfterMs = candidate.providerRetryAfterMs const requestId = candidate.requestId + const offloadImages = candidate.offloadImages if (typeof message !== 'string' || message.length === 0 || typeof code !== 'string' || code.length === 0 || (status !== undefined && (!Number.isInteger(status) || status < 100 || status > 599)) || (providerRetryAfterMs !== undefined && (!Number.isFinite(providerRetryAfterMs) || providerRetryAfterMs <= 0)) - || (requestId !== undefined && (typeof requestId !== 'string' || requestId.length === 0))) return undefined + || (requestId !== undefined && (typeof requestId !== 'string' || requestId.length === 0)) + || (offloadImages !== undefined && (!Number.isSafeInteger(offloadImages) || offloadImages <= 0))) return undefined return Object.freeze({ message, code, ...status === undefined ? {} : { status }, ...providerRetryAfterMs === undefined ? {} : { providerRetryAfterMs }, ...requestId === undefined ? {} : { requestId }, + ...offloadImages === undefined ? {} : { offloadImages }, }) } catch (_sdkFailureGetter) { return undefined diff --git a/packages/llm/llm/src/content.ts b/packages/llm/llm/src/content.ts index 5880f82c98..a5e70738d8 100644 --- a/packages/llm/llm/src/content.ts +++ b/packages/llm/llm/src/content.ts @@ -1,6 +1,6 @@ /** Content-block structure helpers. @module @deepseek-ai/dsh-llm/content */ -import type { ContentBlock } from './types.ts' +import type { ContentBlock, ImageBlock, LlmImageRequestBudget } from './types.ts' import type { Message } from './message.ts' import type { AttachmentStore, ImageAttachmentRef, ImageMediaType, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' import { assertNever } from '@deepseek-ai/dsh-util-values' @@ -131,58 +131,117 @@ function base64Length(bytes: number): number { return Math.ceil(bytes / 3) * 4 } -/** Byte accounting and quantized removal policy for one request representation. */ -export interface RequestImageOffloadPolicy { - /** Image count accepted by the route; omission leaves count unbounded. */ - maxImages?: number - /** Accumulated image bytes accepted by the route; omission leaves bytes unbounded. */ - maxBytes?: number - /** Number of excess images removed as one deterministic step. */ - countQuantum?: number - /** Number of excess bytes removed as one deterministic step. */ - byteQuantum?: number - /** Whether byte accounting uses raw file bytes or inline base64 length. */ - representation: 'raw' | 'base64' - /** Resolve the encoded request-version length; omission uses normalized attachment bytes. */ - byteLength?: (ref: ImageAttachmentRef) => number - /** Build the model-visible replacement for each omitted attachment. */ - placeholder: (ref: ImageAttachmentRef) => string +/** + * Block index path of one image occurrence inside message content: the + * top-level block index alone, or that index followed by the position inside + * a tool-result block's content. Paths compare lexicographically in message + * order. + */ +export type ImageBlockPath = readonly number[] + +/** + * Compare two block paths in message order. + * @param a - first path. + * @param b - second path. + * @returns negative, zero, or positive as `a` sorts before, at, or after `b`. + */ +export function compareImageBlockPaths(a: ImageBlockPath, b: ImageBlockPath): number { + const length = Math.min(a.length, b.length) + for (let index = 0; index < length; index += 1) { + // oxlint-disable-next-line typescript/no-non-null-assertion -- index is below both lengths + const delta = a[index]! - b[index]! + if (delta !== 0) return delta + } + return a.length - b.length } -/** Collect represented image lengths in request and nested-block order. */ -function collectImageLengths( - blocks: readonly ContentBlock[], - lengths: number[], - policy: RequestImageOffloadPolicy, +/** + * Visit every image occurrence of typed content in message order, including + * nested tool-result content, with the block path that identifies it. This is + * the one image walk shared by surface derivation, watermark planning, and + * pricing, so no consumer can diverge on nesting depth or occurrence order. + * @param content - typed model content blocks. + * @param visit - called once per occurrence with the block and its path. + */ +export function visitImageBlocks( + content: readonly ContentBlock[], + visit: (block: ImageBlock, path: ImageBlockPath) => void, ): void { - for (const block of blocks) { + for (const [index, block] of content.entries()) { if (block.type === 'image') { - const bytes = policy.byteLength === undefined - ? block.attachment.bytes - : policy.byteLength(block.attachment) - lengths.push(policy.representation === 'base64' ? base64Length(bytes) : bytes) + visit(block, [index]) } else if (block.type === 'tool-result') { - collectImageLengths(block.content, lengths, policy) + for (const [nested, inner] of block.content.entries()) { + if (inner.type === 'image') visit(inner, [index, nested]) + } } } } -/** Replace the first `remaining.count` image occurrences without mutating durable messages. */ -function replaceOldestImages( +/** + * Represented byte length of one image occurrence under a route budget: the + * normalized byte count clamped to the route's request-version target, then + * base64-expanded for an inline representation. + * @param bytes - normalized attachment byte count. + * @param budget - route representation and request-version target. + * @returns the byte length the route's request accounting charges. + */ +export function representedImageBytes( + bytes: number, + budget: Pick, +): number { + const clamped = budget.versionMaxBytes === undefined ? bytes : Math.min(bytes, budget.versionMaxBytes) + return budget.representation === 'base64' ? base64Length(clamped) : clamped +} + +/** + * Mark image occurrences as offloaded without mutating durable content. + * @param content - typed model content blocks. + * @param offloaded - whether the occurrence at `path` lies at or before the watermark. + * @returns the original array when nothing changes, otherwise a copy whose marked blocks carry `offloaded: true`. + */ +export function markOffloadedImages( + content: readonly ContentBlock[], + offloaded: (path: ImageBlockPath) => boolean, +): readonly ContentBlock[] { + let next: ContentBlock[] | undefined + for (const [index, block] of content.entries()) { + if (block.type === 'image') { + if (block.offloaded !== true && offloaded([index])) { + next ??= content.slice(0, index) + next.push({ ...block, offloaded: true }) + continue + } + } else if (block.type === 'tool-result') { + const inner = markOffloadedImages( + block.content, + path => offloaded([index, ...path]), + ) + if (inner !== block.content) { + next ??= content.slice(0, index) + next.push({ ...block, content: inner as ContentBlock[] }) + continue + } + } + next?.push(block) + } + return next ?? content +} + +/** Replace every offloaded occurrence, including nested tool results, with its placeholder. */ +function replaceOffloadedImages( blocks: readonly ContentBlock[], - remaining: { count: number }, placeholder: (ref: ImageAttachmentRef) => string, ): ContentBlock[] { let next: ContentBlock[] | undefined for (const [index, block] of blocks.entries()) { - if (block.type === 'image' && remaining.count > 0) { - remaining.count -= 1 + if (block.type === 'image' && block.offloaded === true) { next ??= blocks.slice(0, index) next.push({ type: 'text', text: placeholder(block.attachment) }) continue } if (block.type === 'tool-result') { - const content = replaceOldestImages(block.content, remaining, placeholder) + const content = replaceOffloadedImages(block.content, placeholder) if (content !== block.content) { next ??= blocks.slice(0, index) next.push({ ...block, content }) @@ -194,6 +253,90 @@ function replaceOldestImages( return next ?? blocks as ContentBlock[] } +/** + * Project the surface's offloaded occurrences into deterministic text for one + * request. The offloaded set is a durable surface fact, so every route sends + * the same set; only the placeholder text is route-owned. + * @param messages - derived request history. + * @param placeholder - build the model-visible replacement for one offloaded attachment. + * @returns the original list when nothing is offloaded, otherwise shallow message copies with placeholders. + */ +export function projectOffloadedImages( + messages: readonly Message[], + placeholder: (ref: ImageAttachmentRef) => string, +): readonly Message[] { + return messages.map((message) => { + const content = replaceOffloadedImages(message.content, placeholder) + return content === message.content ? message : { ...message, content } + }) +} + +/** + * Number of oldest retained image occurrences one route budget removes, in + * whole count and byte quanta, once the budget is exceeded. The result depends + * only on the represented lengths, so the agent loop plans the durable + * watermark from logged facts and an adapter names the same count when its + * exact accounting still overflows. + * @param lengths - represented byte length of every retained occurrence, oldest first. + * @param budget - count/byte budgets and removal quanta; unbounded when absent. + * @returns how many leading occurrences to offload. + */ +export function offloadedImagePrefixCount( + lengths: readonly number[], + budget: Pick, +): number { + const total = lengths.reduce((sum, bytes) => sum + bytes, 0) + const excessCount = budget.maxImages === undefined ? 0 : Math.max(0, lengths.length - budget.maxImages) + const excessBytes = budget.maxBytes === undefined ? 0 : Math.max(0, total - budget.maxBytes) + if (excessCount === 0 && excessBytes === 0) return 0 + const countQuantum = budget.countQuantum ?? 1 + const byteQuantum = budget.byteQuantum ?? 1 + const removeCount = excessCount === 0 ? 0 : Math.ceil(excessCount / countQuantum) * countQuantum + const removeBytes = excessBytes === 0 ? 0 : Math.ceil(excessBytes / byteQuantum) * byteQuantum + let count = 0 + let removedBytes = 0 + for (const imageBytes of lengths) { + const byteTargetMet = removeBytes === 0 + || (byteQuantum === 1 ? removedBytes >= removeBytes : removedBytes > removeBytes) + if (count >= removeCount && byteTargetMet) break + removedBytes += imageBytes + count += 1 + } + return count +} + +/** One retained image occurrence the watermark planner may offload. */ +export interface RetainedImageOccurrence { + /** Durable position of the occurrence, used as the watermark when it becomes the last offloaded one. */ + position: Position + /** Normalized attachment byte count. */ + bytes: number +} + +/** + * Plan the next durable watermark for one route budget: with the retained + * occurrences oldest first, the budget removes a whole-quantum prefix and the + * last removed occurrence's position becomes the watermark. Under a 128 MiB + * bound with a 64 MiB quantum, 129 retained one-megabyte images offload the + * oldest 65 so 64 MiB remain, and the watermark then holds until the retained + * total again exceeds the bound. + * @param retained - retained occurrences in log order, oldest first. + * @param budget - route representation, budgets, and removal quanta. + * @returns the position of the last occurrence to offload, or undefined when the budget holds. + */ +export function planImageOffload( + retained: readonly RetainedImageOccurrence[], + budget: LlmImageRequestBudget, +): Position | undefined { + const count = offloadedImagePrefixCount( + retained.map(occurrence => representedImageBytes(occurrence.bytes, budget)), + budget, + ) + if (count === 0) return undefined + // oxlint-disable-next-line typescript/no-non-null-assertion -- the prefix count never exceeds the retained length + return retained[count - 1]!.position +} + /** Replace every image occurrence, including nested tool results, for a text-only model. */ function replaceImagesForTextModel(blocks: readonly ContentBlock[]): ContentBlock[] { let next: ContentBlock[] | undefined @@ -228,62 +371,3 @@ export function projectImagesForTextModel(messages: readonly Message[]): readonl return content === message.content ? message : { ...message, content } }) } - -/** - * Number of oldest image occurrences one request projection removes, in whole - * count and byte quanta, once a route budget is exceeded. The result depends - * only on the represented lengths, so provider request pricing reproduces the - * exact serialization decision without building the projected messages. - * @param lengths - represented byte length of every occurrence, in request order. - * @param policy - count/byte budgets and removal quanta; unbounded when absent. - * @returns how many leading occurrences the projection replaces with placeholders. - */ -export function offloadedImagePrefixCount( - lengths: readonly number[], - policy: Pick, -): number { - const total = lengths.reduce((sum, bytes) => sum + bytes, 0) - const excessCount = policy.maxImages === undefined ? 0 : Math.max(0, lengths.length - policy.maxImages) - const excessBytes = policy.maxBytes === undefined ? 0 : Math.max(0, total - policy.maxBytes) - if (excessCount === 0 && excessBytes === 0) return 0 - const countQuantum = policy.countQuantum ?? 1 - const byteQuantum = policy.byteQuantum ?? 1 - const removeCount = excessCount === 0 ? 0 : Math.ceil(excessCount / countQuantum) * countQuantum - const removeBytes = excessBytes === 0 ? 0 : Math.ceil(excessBytes / byteQuantum) * byteQuantum - let count = 0 - let removedBytes = 0 - for (const imageBytes of lengths) { - const byteTargetMet = removeBytes === 0 - || (byteQuantum === 1 ? removedBytes >= removeBytes : removedBytes > removeBytes) - if (count >= removeCount && byteTargetMet) break - removedBytes += imageBytes - count += 1 - } - return count -} - -/** - * Return a deterministic transient projection whose oldest images are replaced - * in whole count and byte quanta after a route budget is exceeded. The target - * depends only on complete durable history: at 129 one-megabyte images under - * a 128 MiB bound with a 64 MiB quantum, the oldest 65 images are removed so - * 64 MiB remain; that removed prefix stays fixed until total history exceeds - * 192 MiB. - * @param messages - complete request history, oldest first. - * @param policy - route representation, budgets, and removal quanta. - * @returns original messages below both bounds, otherwise shallow copies with deterministic placeholders. - */ -export function offloadRequestImagesWithPolicy( - messages: readonly Message[], - policy: RequestImageOffloadPolicy, -): readonly Message[] { - const lengths: number[] = [] - for (const message of messages) collectImageLengths(message.content, lengths, policy) - const count = offloadedImagePrefixCount(lengths, policy) - if (count === 0) return messages - const remaining = { count } - return messages.map((message) => { - const content = replaceOldestImages(message.content, remaining, policy.placeholder) - return content === message.content ? message : { ...message, content } - }) -} diff --git a/packages/llm/llm/src/error.ts b/packages/llm/llm/src/error.ts index 4b3e25c75f..afd3a8b324 100644 --- a/packages/llm/llm/src/error.ts +++ b/packages/llm/llm/src/error.ts @@ -161,3 +161,12 @@ export function errorChain(value: unknown): string { export function isHarnessError(value: unknown): value is HarnessError { return value instanceof HarnessError } + +/** + * Canonical code for a request an image-capable route cannot send until the + * agent loop advances the session's durable `image/offload` watermark. The + * failure's `offloadImages` names how many more of the oldest retained + * occurrences must be offloaded; the loop appends the advance and rebuilds the + * request, so the model never receives an unlogged projection. + */ +export const IMAGE_OFFLOAD_REQUIRED_CODE = 'IMAGE_OFFLOAD_REQUIRED' diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index cdecc4c96e..ed2d2dcbae 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -15,6 +15,7 @@ import type { LlmDiscoveredModel, LlmFailure, LlmImageRequestPricing, + LlmImageRequestBudget, LlmModelContext, LlmModelDiscoveryRequest, LlmModelInfo, @@ -77,6 +78,8 @@ export interface LlmErrorOptions extends ErrorOptions { providerRetryAfterMs?: number /** Non-empty opaque provider request id. */ requestId?: ProviderRequestId + /** Positive count of additional oldest retained image occurrences to offload; only with `IMAGE_OFFLOAD_REQUIRED`. */ + offloadImages?: number } /** @@ -107,6 +110,10 @@ export class LlmError extends HarnessError { && (typeof options.requestId !== 'string' || options.requestId.length === 0)) { throw new Error('LlmError requestId must be a non-empty string') } + if (options?.offloadImages !== undefined + && (!Number.isSafeInteger(options.offloadImages) || options.offloadImages <= 0)) { + throw new Error('LlmError offloadImages must be a positive safe integer') + } super(message, code, options) this.name = 'LlmError' this.failure = Object.freeze({ @@ -115,6 +122,7 @@ export class LlmError extends HarnessError { ...options?.status === undefined ? {} : { status: options.status }, ...options?.providerRetryAfterMs === undefined ? {} : { providerRetryAfterMs: options.providerRetryAfterMs }, ...options?.requestId === undefined ? {} : { requestId: options.requestId }, + ...options?.offloadImages === undefined ? {} : { offloadImages: options.offloadImages }, }) } } @@ -164,6 +172,8 @@ export interface PreparedLlmCall { readonly context?: LlmModelContext /** Exact model modalities captured with the adapter dispatch generation. */ readonly inputModalities?: readonly ModelModality[] + /** Detached request-image budget the route enforces, when it declares one. */ + readonly imageRequest?: LlmImageRequestBudget /** Config fields materialized by the captured adapter rather than proposed by the caller. */ readonly adapterDefaults: LlmCallConfigAdapterDefaults /** @@ -665,6 +675,33 @@ export class LlmRuntime extends TypertRemoteService { return modalities === undefined ? undefined : [...modalities] } + /** Validate and detach one adapter-declared request-image budget. */ + private detachedImageRequest( + budget: LlmImageRequestBudget | undefined, + provider: string, + model: string, + ): LlmImageRequestBudget | undefined { + if (budget === undefined) return undefined + const positive = (value: number | undefined): boolean => value === undefined + || (Number.isSafeInteger(value) && value > 0) + if (!positive(budget.maxBytes) || !positive(budget.maxImages) + || !positive(budget.byteQuantum) || !positive(budget.countQuantum) + || !positive(budget.versionMaxBytes)) { + throw new LlmError( + `adapter returned invalid request-image budget for provider "${provider}" model "${model}"`, + 'INVALID_MODEL_IMAGE_BUDGET', + ) + } + return { + representation: budget.representation, + ...budget.maxBytes === undefined ? {} : { maxBytes: budget.maxBytes }, + ...budget.maxImages === undefined ? {} : { maxImages: budget.maxImages }, + ...budget.byteQuantum === undefined ? {} : { byteQuantum: budget.byteQuantum }, + ...budget.countQuantum === undefined ? {} : { countQuantum: budget.countQuantum }, + ...budget.versionMaxBytes === undefined ? {} : { versionMaxBytes: budget.versionMaxBytes }, + } + } + /** * Discover models advertised by one registered provider. Catalog membership * is advisory and never changes routing or request validation. @@ -765,6 +802,7 @@ export class LlmRuntime extends TypertRemoteService { 'INVALID_MODEL_MAX_TOKENS', ) } + const imageRequest = this.detachedImageRequest(resolved.imageRequest, provider, model) const info: LlmResolvedModelInfo = { provider, id: model, @@ -773,6 +811,7 @@ export class LlmRuntime extends TypertRemoteService { ...inputModalities === undefined ? {} : { inputModalities }, ...context === undefined ? {} : { context: { contextWindow: context.contextWindow } }, ...defaultMaxTokens === undefined ? {} : { defaultMaxTokens }, + ...imageRequest === undefined ? {} : { imageRequest }, } const reasoning = resolved.reasoning if (reasoning === undefined) return info @@ -913,6 +952,9 @@ export class LlmRuntime extends TypertRemoteService { ...modelInfo.inputModalities === undefined ? {} : { inputModalities: Object.freeze([...modelInfo.inputModalities]) }, + ...modelInfo.imageRequest === undefined + ? {} + : { imageRequest: deepFreeze(structuredClone(modelInfo.imageRequest)) }, stream: (options: GenerateOptions): AsyncIterable => { if (dispatched) { throw new LlmError('a prepared LLM call can only be dispatched once', 'INVALID_PREPARED_CALL') diff --git a/packages/llm/llm/src/types.ts b/packages/llm/llm/src/types.ts index 4303538892..1053115d82 100644 --- a/packages/llm/llm/src/types.ts +++ b/packages/llm/llm/src/types.ts @@ -48,6 +48,13 @@ export interface LlmFailure { readonly providerRetryAfterMs?: number /** Opaque provider-issued request identifier for diagnostics. */ readonly requestId?: ProviderRequestId + /** + * With code `IMAGE_OFFLOAD_REQUIRED`: how many more of the oldest retained + * image occurrences the route needs offloaded before the same request fits + * its exact byte accounting. The agent loop advances the durable watermark + * by this count and rebuilds the request. + */ + readonly offloadImages?: number } /** Plain text visible to the end user. */ @@ -72,6 +79,12 @@ export interface ImageBlock { type: 'image' /** Immutable bytes and intrinsic display metadata owned by the attachment service. */ attachment: ImageAttachmentRef + /** + * Set by surface derivation when the occurrence lies at or before the + * session's durable `image/offload` watermark: every route sends its + * placeholder text instead of the image. Never stored in a logged message. + */ + offloaded?: true } /** A tool invocation requested by the model. */ @@ -172,10 +185,11 @@ export interface LlmImageRequestPrice { export interface LlmImageRequestPricing { /** * Price every image occurrence of one request projection. - * @param images - durable image references in request order, one entry per occurrence. + * @param images - surface image blocks in request order, one entry per occurrence; an `offloaded` block + * is priced as its placeholder text. * @returns one price per occurrence, aligned by index with `images`. */ - priceImages(images: readonly ImageAttachmentRef[]): readonly LlmImageRequestPrice[] + priceImages(images: readonly ImageBlock[]): readonly LlmImageRequestPrice[] } /** Display metadata for one registered provider route. */ @@ -301,6 +315,30 @@ export interface LlmModelContext { contextWindow: number } +/** + * Request-image budget one exact image-capable route declares, so the agent + * loop can advance the session's durable `image/offload` watermark before + * dispatch from logged facts alone. Byte accounting clamps each occurrence's + * normalized byte count to {@link versionMaxBytes} and, for the `base64` + * representation, expands it to its encoded length. A route whose exact + * request accounting still exceeds the budget fails the request with + * `IMAGE_OFFLOAD_REQUIRED` naming the additional occurrences to offload. + */ +export interface LlmImageRequestBudget { + /** Whether the route accounts raw file bytes or inline base64 length. */ + representation: 'raw' | 'base64' + /** Accumulated represented image bytes the route accepts; absent leaves bytes unbounded. */ + maxBytes?: number + /** Image occurrences the route accepts; absent leaves the count unbounded. */ + maxImages?: number + /** Represented bytes removed as one deterministic advance step; absent removes the minimum. */ + byteQuantum?: number + /** Occurrences removed as one deterministic advance step; absent removes the minimum. */ + countQuantum?: number + /** Encoded-byte target of the route's derived request version; absent accounts normalized bytes. */ + versionMaxBytes?: number +} + /** Display metadata for one adapter-owned reasoning effort. */ export interface LlmReasoningEffortInfo { /** Opaque stable value accepted by {@link GenerateOptions.reasoningEffort}. */ @@ -330,6 +368,8 @@ export interface LlmResolvedModelInfo extends LlmModelInfo { defaultMaxTokens?: number /** Adapter-owned selectable reasoning levels when exposed. */ reasoning?: LlmModelReasoningInfo + /** Request-image budget the route enforces; absent for routes that never offload. */ + imageRequest?: LlmImageRequestBudget } /** diff --git a/packages/llm/llm/tests/content.spec.ts b/packages/llm/llm/tests/content.spec.ts index 748e20f412..812c60c8ce 100644 --- a/packages/llm/llm/tests/content.spec.ts +++ b/packages/llm/llm/tests/content.spec.ts @@ -3,30 +3,26 @@ import { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment' import type { AttachmentStore, ImageMediaType } from '@deepseek-ai/dsh-attachment' import { ToolCallId, + compareImageBlockPaths, createUserMessage, + markOffloadedImages, offloadedImageText, offloadedImagePrefixCount, - offloadRequestImagesWithPolicy, + planImageOffload, projectImagesForTextModel, + projectOffloadedImages, + representedImageBytes, resolveImageAttachmentAccess, requestImageHandleText, + visitImageBlocks, } from '../src/index.ts' -import type { ContentBlock, Message } from '../src/index.ts' +import type { ContentBlock, ImageBlockPath } from '../src/index.ts' const source = { kind: 'plugin' as const, plugin: 'test' } const OMITTED = '[omitted]' -function offloadBase64(messages: readonly Message[], maxBytes: number | undefined): readonly Message[] { - return offloadRequestImagesWithPolicy(messages, { - representation: 'base64', - ...maxBytes === undefined ? {} : { maxBytes }, - byteQuantum: 1, - placeholder: () => OMITTED, - }) -} - -function image(bytes: number): Extract { +function image(bytes: number, offloaded?: true): Extract { return { type: 'image', attachment: { @@ -36,78 +32,109 @@ function image(bytes: number): Extract { width: 1, height: 1, }, + ...offloaded === undefined ? {} : { offloaded }, } } -describe('base64 request-image offload', () => { - it('preserves every image when no payload bound is configured', () => { - const messages = [createUserMessage({ content: [image(300)], source })] - expect(offloadBase64(messages, undefined)).toBe(messages) +describe('visitImageBlocks', () => { + it('visits top-level and nested tool-result occurrences with their block paths', () => { + const seen: ImageBlockPath[] = [] + visitImageBlocks([ + { type: 'text', text: 'before' }, + image(1), + { type: 'tool-result', toolCallId: ToolCallId('shot'), content: [{ type: 'text', text: 'x' }, image(2)] }, + image(3), + ], (block, path) => seen.push([...path, block.attachment.bytes])) + expect(seen).toEqual([[1, 1], [2, 1, 2], [3, 3]]) + }) +}) + +describe('compareImageBlockPaths', () => { + it('orders paths lexicographically in message order', () => { + expect(compareImageBlockPaths([1], [2])).toBeLessThan(0) + expect(compareImageBlockPaths([2, 0], [2])).toBeGreaterThan(0) + expect(compareImageBlockPaths([2, 1], [2, 1])).toBe(0) + expect(compareImageBlockPaths([2, 1], [2, 3])).toBeLessThan(0) + }) +}) + +describe('representedImageBytes', () => { + it('clamps to the request-version target and expands inline representations', () => { + expect(representedImageBytes(100, { representation: 'raw' })).toBe(100) + expect(representedImageBytes(100, { representation: 'raw', versionMaxBytes: 60 })).toBe(60) + expect(representedImageBytes(3, { representation: 'base64' })).toBe(4) + expect(representedImageBytes(4, { representation: 'base64', versionMaxBytes: 60 })).toBe(8) + }) +}) + +describe('markOffloadedImages', () => { + it('returns the original content when nothing lies at or before the watermark', () => { + const content: ContentBlock[] = [ + { type: 'text', text: 'before' }, + image(1), + { type: 'tool-result', toolCallId: ToolCallId('shot'), content: [image(2)] }, + ] + expect(markOffloadedImages(content, () => false)).toBe(content) }) - it('preserves the original request when its base64 payload fits exactly', () => { - const messages = [createUserMessage({ content: [image(3), image(3)], source })] - expect(offloadBase64(messages, 8)).toBe(messages) - }) - - it('keeps five 3 MiB images at 20 MiB and offloads the oldest after one more raw byte', () => { - const rawImageBytes = 3 * 1024 * 1024 - const maxRequestImageBytes = 20 * 1024 * 1024 - const exact = [createUserMessage({ - content: Array.from({ length: 5 }, () => image(rawImageBytes)), - source, - })] - expect(offloadBase64(exact, maxRequestImageBytes)).toBe(exact) - - const over = [createUserMessage({ - content: [image(rawImageBytes + 1), ...Array.from({ length: 4 }, () => image(rawImageBytes))], - source, - })] - expect(offloadBase64(over, maxRequestImageBytes)[0]?.content).toEqual([ - { type: 'text', text: OMITTED }, - ...Array.from({ length: 4 }, () => image(rawImageBytes)), + it('marks matching top-level and nested occurrences without mutating durable content', () => { + const nested = image(2) + const content: ContentBlock[] = [ + image(1), + { type: 'text', text: 'between' }, + { type: 'tool-result', toolCallId: ToolCallId('shot'), content: [nested, image(3)] }, + image(4), + ] + const marked = markOffloadedImages(content, path => compareImageBlockPaths(path, [2, 0]) <= 0) + expect(marked).toEqual([ + image(1, true), + { type: 'text', text: 'between' }, + { type: 'tool-result', toolCallId: ToolCallId('shot'), content: [image(2, true), image(3)] }, + image(4), ]) + expect(content[0]).toEqual(image(1)) + expect(nested).toEqual(image(2)) }) - it('replaces the oldest nested occurrences without mutating durable messages', () => { - const shared = image(3) + it('leaves an already marked occurrence untouched', () => { + const content = [image(1, true)] + expect(markOffloadedImages(content, () => true)).toBe(content) + }) +}) + +describe('projectOffloadedImages', () => { + it('keeps messages without offloaded occurrences by identity', () => { + const messages = [createUserMessage({ content: [image(300)], source })] + const projected = projectOffloadedImages(messages, () => OMITTED) + expect(projected[0]).toBe(messages[0]) + }) + + it('replaces marked top-level and nested occurrences with route placeholders', () => { const messages = [ createUserMessage({ - content: [{ - type: 'tool-result', - toolCallId: ToolCallId('shot'), - content: [shared], - }], + content: [{ type: 'tool-result', toolCallId: ToolCallId('shot'), content: [image(3, true)] }], source, }), - createUserMessage({ content: [shared, image(3)], source }), + createUserMessage({ content: [image(3, true), image(3)], source }), ] - - const fitted = offloadBase64(messages, 8) - expect(fitted).not.toBe(messages) - expect(fitted[0]?.content).toEqual([{ + const projected = projectOffloadedImages(messages, ref => `${OMITTED}:${ref.bytes}`) + expect(projected[0]?.content).toEqual([{ type: 'tool-result', toolCallId: ToolCallId('shot'), - content: [{ type: 'text', text: OMITTED }], + content: [{ type: 'text', text: `${OMITTED}:3` }], }]) - expect(fitted[1]?.content).toEqual([shared, image(3)]) - expect(messages[0]?.content[0]).toMatchObject({ type: 'tool-result', content: [shared] }) + expect(projected[1]?.content).toEqual([{ type: 'text', text: `${OMITTED}:3` }, image(3)]) + expect(messages[1]?.content[0]).toEqual(image(3, true)) }) - it('replaces a single image that cannot fit', () => { - const messages = [createUserMessage({ content: [image(300)], source })] - expect(offloadBase64(messages, 8)[0]?.content) - .toEqual([{ type: 'text', text: OMITTED }]) - }) - - it('keeps unchanged nested content while replacing a later image', () => { + it('keeps unchanged nested content while replacing a later occurrence', () => { const nested = { type: 'tool-result' as const, toolCallId: ToolCallId('text-only'), content: [{ type: 'text' as const, text: 'kept' }], } - const messages = [createUserMessage({ content: [nested, image(3)], source })] - expect(offloadBase64(messages, 1)[0]?.content).toEqual([ + const messages = [createUserMessage({ content: [nested, image(3, true)], source })] + expect(projectOffloadedImages(messages, () => OMITTED)[0]?.content).toEqual([ nested, { type: 'text', text: OMITTED }, ]) @@ -127,67 +154,26 @@ describe('offloadedImagePrefixCount', () => { }) }) -describe('offloadRequestImagesWithPolicy', () => { - it('drops 129 MiB to 64 MiB and keeps the removed prefix stable through 192 MiB', () => { - const mib = 1024 * 1024 - const project = (count: number) => offloadRequestImagesWithPolicy([ - createUserMessage({ content: Array.from({ length: count }, () => image(mib)), source }), - ], { - representation: 'raw', - maxBytes: 128 * mib, - byteQuantum: 64 * mib, - placeholder: () => OMITTED, - })[0]?.content +describe('planImageOffload', () => { + const mib = 1024 * 1024 + const retained = (count: number) => Array.from({ length: count }, (_, index) => ({ position: index, bytes: mib })) - expect(project(128)?.filter(block => block.type === 'image')).toHaveLength(128) - expect(project(129)?.filter(block => block.type === 'text')).toHaveLength(65) - expect(project(192)?.filter(block => block.type === 'text')).toHaveLength(65) - expect(project(193)?.filter(block => block.type === 'text')).toHaveLength(129) + it('drops 129 retained MiB to 64 MiB and holds until the retained total exceeds the bound again', () => { + const budget = { representation: 'raw' as const, maxBytes: 128 * mib, byteQuantum: 64 * mib } + expect(planImageOffload(retained(128), budget)).toBeUndefined() + expect(planImageOffload(retained(129), budget)).toBe(64) + expect(planImageOffload(retained(64), budget)).toBeUndefined() }) - it('rounds a count excess up to a 20-image removal step', () => { - const projected = offloadRequestImagesWithPolicy([ - createUserMessage({ content: Array.from({ length: 601 }, () => image(1)), source }), - ], { - representation: 'raw', - maxImages: 600, - countQuantum: 20, - placeholder: () => OMITTED, - }) - expect(projected[0]?.content.filter(block => block.type === 'text')).toHaveLength(20) - expect(projected[0]?.content.filter(block => block.type === 'image')).toHaveLength(581) + it('rounds a count excess up to a 20-image advance', () => { + const budget = { representation: 'raw' as const, maxImages: 600, countQuantum: 20 } + expect(planImageOffload(retained(601), budget)).toBe(19) }) - it('uses route-owned request byte lengths when supplied', () => { - const messages = [createUserMessage({ content: [image(100), image(100)], source })] - const projected = offloadRequestImagesWithPolicy(messages, { - representation: 'raw', - maxBytes: 3, - byteLength: () => 2, - placeholder: () => OMITTED, - }) - expect(projected[0]?.content).toEqual([ - { type: 'text', text: OMITTED }, - image(100), - ]) - }) - - it('builds a distinct placeholder from each omitted attachment', () => { - const first = image(3) - const second = image(3) - first.attachment = { ...first.attachment, name: 'first.png' } - second.attachment = { ...second.attachment, name: 'second.png' } - const projected = offloadRequestImagesWithPolicy([ - createUserMessage({ content: [first, second], source }), - ], { - representation: 'raw', - maxBytes: 3, - placeholder: ref => `omitted:${ref.name}`, - }) - expect(projected[0]?.content).toEqual([ - { type: 'text', text: 'omitted:first.png' }, - second, - ]) + it('accounts inline representations by base64 length after the version clamp', () => { + const budget = { representation: 'base64' as const, maxBytes: 8, versionMaxBytes: 3 } + expect(planImageOffload([{ position: 'a', bytes: 3 }, { position: 'b', bytes: 3 }], budget)).toBeUndefined() + expect(planImageOffload([{ position: 'a', bytes: 300 }, { position: 'b', bytes: 3 }, { position: 'c', bytes: 3 }], budget)).toBe('a') }) }) diff --git a/packages/llm/llm/tests/service.spec.ts b/packages/llm/llm/tests/service.spec.ts index 47309daea1..04554c3845 100644 --- a/packages/llm/llm/tests/service.spec.ts +++ b/packages/llm/llm/tests/service.spec.ts @@ -1338,3 +1338,33 @@ describe('LlmRuntime', () => { expect(ctx.llm.listProviders()).toEqual([]) }) }) + +describe('request-image budget metadata', () => { + it('detaches a valid budget onto the prepared call and rejects a non-positive field', async () => { + class BudgetAdapter extends LlmAdapter { + constructor(private readonly budget: Record) { + super() + } + + override resolveModel(provider: string, model: string) { + return Promise.resolve({ provider, id: model, name: model, imageRequest: this.budget as never }) + } + + async * stream(): AsyncIterable { + throw new Error('unused') + } + } + const ctx = new Context() + const llm = new LlmRuntime(ctx) + const full = { representation: 'raw', maxBytes: 8, maxImages: 3, byteQuantum: 4, countQuantum: 2, versionMaxBytes: 5 } + llm.registerAdapter(['good'], new BudgetAdapter(full)) + llm.registerAdapter(['bare'], new BudgetAdapter({ representation: 'base64' })) + llm.registerAdapter(['bad'], new BudgetAdapter({ representation: 'base64', maxImages: 0 })) + const prepared = await llm.prepareCall({ provider: 'good', model: 'm' }) + expect(prepared.imageRequest).toEqual(full) + expect((await llm.prepareCall({ provider: 'bare', model: 'm' })).imageRequest).toEqual({ representation: 'base64' }) + expect(Object.isFrozen(prepared.imageRequest)).toBe(true) + await expect(llm.prepareCall({ provider: 'bad', model: 'm' })).rejects.toMatchObject({ code: 'INVALID_MODEL_IMAGE_BUDGET' }) + expect(() => new LlmError('x', 'y', { offloadImages: 0 })).toThrow('offloadImages must be a positive safe integer') + }) +}) diff --git a/packages/llm/token-meter/README.i18n.yaml b/packages/llm/token-meter/README.i18n.yaml index bb885f0bfc..f6ee2bb333 100644 --- a/packages/llm/token-meter/README.i18n.yaml +++ b/packages/llm/token-meter/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/token-meter/README.md -README.md: f2a4f7f7c70de4a08fd824e6e8001622ddb456b5 -README.zh.md: 3b163198279d4b254cd78638ebcad479d063630f +README.md: 6871dc5097e2f1c493dfd4c883af4a0ceaa5f39a +README.zh.md: cab6eb0c283d0797efd73409d30f5cf32ebef287 diff --git a/packages/llm/token-meter/README.md b/packages/llm/token-meter/README.md index f2a4f7f7c7..6871dc5097 100644 --- a/packages/llm/token-meter/README.md +++ b/packages/llm/token-meter/README.md @@ -40,7 +40,7 @@ const { totalTokens, surfaceTokens, nodes } = ctx.tokenMeter.measure(session) const price = ctx.tokenMeter.estimateMessage(message) ``` -Each measurement resolves the effective envelope's provider/model through the optional `llm` service. Image occurrences use the routed request's visual-token price plus model-visible text when the adapter declares pricing; other routes keep the fixed heuristic. Each node also carries route-independent `heuristicTokens` for replacement shadow prices. Provider usage is reused only when the latest successful call's canonical request envelope matches the measured envelope and its total is no lower than that call's full route-priced anchor; otherwise the complete current envelope and surface are estimated. Surface changes stay signed relative to a matching anchor repriced under the same route, including negative deltas after shrinking replacements. +Each measurement resolves the effective envelope's provider/model through the optional `llm` service. Image occurrences use the routed request's visual-token price plus model-visible text when the adapter declares pricing, and occurrences at or before the session's durable `image/offload` watermark are priced as the route's placeholder text exactly as the derived surface sends them; other routes keep the fixed heuristic. Each node also carries route-independent `heuristicTokens` for replacement shadow prices. Provider usage is reused only when the latest successful call's canonical request envelope matches the measured envelope and its total is no lower than that call's full route-priced anchor; otherwise the complete current envelope and surface are estimated. Surface changes stay signed relative to a matching anchor repriced under the same route, including negative deltas after shrinking replacements. ### Session projections diff --git a/packages/llm/token-meter/README.zh.md b/packages/llm/token-meter/README.zh.md index 3b16319827..cab6eb0c28 100644 --- a/packages/llm/token-meter/README.zh.md +++ b/packages/llm/token-meter/README.zh.md @@ -40,7 +40,7 @@ const { totalTokens, surfaceTokens, nodes } = ctx.tokenMeter.measure(session) const price = ctx.tokenMeter.estimateMessage(message) ``` -每次测量都会通过可选的 `llm` 服务解析生效 envelope 的提供方/模型。适配器声明图片定价时,图片出现处使用路由请求的视觉 token 价格加模型可见文本;其他路由保持固定启发式规则。每个节点还携带与路由无关的 `heuristicTokens`,供替换影子价使用。只有当最新成功调用的规范请求 envelope 与已测量 envelope 匹配、且其总量不低于该调用完整路由定价锚点时,才复用提供方用量;否则会对完整当前 envelope 与表面做估算。表面变更保持相对于按同一路由重新定价的匹配锚点的带符号值,包括缩减替换后的负 delta。 +每次测量都会通过可选的 `llm` 服务解析生效 envelope 的提供方/模型。适配器声明图片定价时,图片出现处使用路由请求的视觉 token 价格加模型可见文本,位于会话持久 `image/offload` 水位及之前的出现位置则按路由的占位文本计价,与派生表层实际发送的一致;其他路由保持固定启发式规则。每个节点还携带与路由无关的 `heuristicTokens`,供替换影子价使用。只有当最新成功调用的规范请求 envelope 与已测量 envelope 匹配、且其总量不低于该调用完整路由定价锚点时,才复用提供方用量;否则会对完整当前 envelope 与表面做估算。表面变更保持相对于按同一路由重新定价的匹配锚点的带符号值,包括缩减替换后的负 delta。 ### 会话投影 diff --git a/packages/llm/token-meter/src/index.ts b/packages/llm/token-meter/src/index.ts index 5e045223e3..fb00fb1f68 100644 --- a/packages/llm/token-meter/src/index.ts +++ b/packages/llm/token-meter/src/index.ts @@ -10,6 +10,7 @@ import { BlockAssembler } from '@deepseek-ai/dsh-llm' import type { LlmImageRequestPricing, Message, TokenUsage } from '@deepseek-ai/dsh-llm' import { deepFreeze } from '@deepseek-ai/dsh-util-values' import type { + ImageOccurrencePosition, EpochHeader, Session, SessionEvent, @@ -47,6 +48,8 @@ interface MeasurementAnchor { readonly header: EpochHeader | undefined /** Surface snapshot the anchored request was derived from. */ readonly nodes: readonly MeterSurfaceNode[] + /** Offload watermark the anchored request was derived under. */ + readonly watermark: ImageOccurrencePosition | undefined /** Fixed-heuristic price of the call's provider output. */ readonly assistantTokens: number /** Provider usage of the call, when it reported one under a known header. */ @@ -57,6 +60,8 @@ interface ReplayState { consumedEvents: SessionLogOffsetType header: EpochHeader | undefined surface: MeterSurfaceNode[] + /** Durable `image/offload` watermark in force after the consumed events. */ + watermark: ImageOccurrencePosition | undefined stepStart: { turn: number; step: number; nodes: readonly MeterSurfaceNode[] } | undefined anchor: MeasurementAnchor | undefined } @@ -142,7 +147,7 @@ export class TokenMeter extends Service { ? state.header : canonicalHeader(requestHeader) const pricing = this._routeImagePricing(header) - const surface = priceSurface(state.surface, pricing) + const surface = priceSurface(state.surface, pricing, state.watermark) const anchor = state.anchor let baseline: TokenMeasurementBaseline @@ -151,7 +156,7 @@ export class TokenMeter extends Service { // Matching headers share one route, so the anchored snapshot reprices // under the same pricing as the current surface and the signed delta // compares like with like. - const anchorSurfaceTokens = priceSurface(anchor.nodes, pricing).surfaceTokens + const anchorSurfaceTokens = priceSurface(anchor.nodes, pricing, anchor.watermark).surfaceTokens + anchor.assistantTokens const estimatedAnchorTokens = estimateHeader(header) + anchorSurfaceTokens const usage = anchor.usage @@ -207,6 +212,7 @@ export class TokenMeter extends Service { consumedEvents: SessionLogOffset(0), header: undefined, surface: [], + watermark: undefined, stepStart: undefined, anchor: undefined, } @@ -229,6 +235,7 @@ export class TokenMeter extends Service { */ private _foldEvent(session: Session, state: ReplayState, event: SessionEvent): void { let nextHeader = state.header + let nextWatermark = state.watermark let nextStepStart = state.stepStart let nextAnchor = state.anchor @@ -236,6 +243,9 @@ export class TokenMeter extends Service { case 'request/header': nextHeader = canonicalHeader(event.data.header) break + case 'image/offload': + nextWatermark = event.data.watermark + break case 'step/start': if (state.stepStart !== undefined) { throw new Error( @@ -275,6 +285,7 @@ export class TokenMeter extends Service { nextAnchor = { header: nextHeader, nodes: stepStart.nodes, + watermark: nextWatermark, assistantTokens: this._estimateProviderAssistant(session, event, eventTokens), usage: event.data.usage, } @@ -282,6 +293,7 @@ export class TokenMeter extends Service { nextAnchor = { header: nextHeader, nodes: stepStart.nodes, + watermark: nextWatermark, assistantTokens: eventTokens, usage: undefined, } @@ -289,6 +301,7 @@ export class TokenMeter extends Service { } state.header = nextHeader + state.watermark = nextWatermark state.stepStart = nextStepStart if (plan !== undefined) { commitSurfaceTokens(state.surface, plan) diff --git a/packages/llm/token-meter/src/route-pricing.ts b/packages/llm/token-meter/src/route-pricing.ts index 402db18ac0..f5a0b6c8a0 100644 --- a/packages/llm/token-meter/src/route-pricing.ts +++ b/packages/llm/token-meter/src/route-pricing.ts @@ -8,7 +8,9 @@ * @module @deepseek-ai/dsh-token-meter/route-pricing */ -import type { LlmImageRequestPricing } from '@deepseek-ai/dsh-llm' +import type { ImageBlock, LlmImageRequestPricing } from '@deepseek-ai/dsh-llm' +import { compareImagePositions } from '@deepseek-ai/dsh-session' +import type { ImageOccurrencePosition } from '@deepseek-ai/dsh-session' import { estimateContent } from './estimate.ts' import type { MeterSurfaceNode } from './surface-fold.ts' import type { TokenSurfaceNode } from './types.ts' @@ -22,9 +24,12 @@ export interface PricedSurface { } /** - * Price one ordered surface under a route's request-image pricing. + * Price one ordered surface under a route's request-image pricing. Every + * occurrence positioned at or before `watermark` is priced as the route's + * offloaded placeholder, exactly as the derived surface sends it. * @param nodes - the fold's current or snapshotted surface, in model-visible order. * @param pricing - the routed model's image pricing, or undefined to keep the fixed heuristic. + * @param watermark - the durable `image/offload` watermark in force for this surface, if any. * @returns detached public nodes and their route-priced total. * @throws when the pricing answers a different occurrence count than it was * asked — misalignment would silently misprice nodes, so it must fail loud. @@ -32,8 +37,15 @@ export interface PricedSurface { export function priceSurface( nodes: readonly MeterSurfaceNode[], pricing: LlmImageRequestPricing | undefined, + watermark?: ImageOccurrencePosition, ): PricedSurface { - const images = pricing === undefined ? [] : nodes.flatMap(node => node.images) + const images: ImageBlock[] = pricing === undefined + ? [] + : nodes.flatMap(node => node.images.map(({ attachment, path }) => ( + watermark !== undefined && compareImagePositions({ seq: node.seq, path: [...path] }, watermark) <= 0 + ? { type: 'image' as const, attachment, offloaded: true as const } + : { type: 'image' as const, attachment } + ))) if (pricing === undefined || images.length === 0) { let surfaceTokens = 0 const publicNodes = nodes.map((node) => { diff --git a/packages/llm/token-meter/src/surface-fold.ts b/packages/llm/token-meter/src/surface-fold.ts index ae293a7f57..72989ef269 100644 --- a/packages/llm/token-meter/src/surface-fold.ts +++ b/packages/llm/token-meter/src/surface-fold.ts @@ -18,10 +18,19 @@ import { deriveEventMessage } from '@deepseek-ai/dsh-session' import type { SessionSeq, SurfaceEvent } from '@deepseek-ai/dsh-session' -import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm' +import { visitImageBlocks } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, ImageBlockPath, Message } from '@deepseek-ai/dsh-llm' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import { estimateMessage, estimateStructuralBlock } from './estimate.ts' +/** One durable image occurrence of a surface node, positioned for the offload watermark. */ +export interface MeterImageOccurrence { + /** Durable normalized attachment reference of the occurrence. */ + readonly attachment: ImageAttachmentRef + /** Block path of the occurrence inside the node's message content. */ + readonly path: ImageBlockPath +} + /** One priced surface node with the image occurrences route pricing replaces. */ export interface MeterSurfaceNode { /** Durable sequence number of the surface event. */ @@ -31,7 +40,7 @@ export interface MeterSurfaceNode { /** Fixed-heuristic price with every image occurrence's structural price removed. */ readonly imageFreeTokens: number /** Durable image occurrences in message order; empty for image-free nodes. */ - readonly images: readonly ImageAttachmentRef[] + readonly images: readonly MeterImageOccurrence[] } /** One validated surface transition that has not mutated the priced surface yet. */ @@ -46,17 +55,13 @@ export interface SurfaceTokenPlan { readonly target: 'append' | { readonly startIdx: number; readonly endIdx: number } } -/** Collect image occurrences recursively and total their structural prices. */ -function collectImages(blocks: readonly ContentBlock[], images: ImageAttachmentRef[]): number { +/** Collect image occurrences with their block paths and total their structural prices. */ +function collectImages(blocks: readonly ContentBlock[], images: MeterImageOccurrence[]): number { let structuralTokens = 0 - for (const block of blocks) { - if (block.type === 'image') { - images.push(block.attachment) - structuralTokens += estimateStructuralBlock(block) - } else if (block.type === 'tool-result') { - structuralTokens += collectImages(block.content, images) - } - } + visitImageBlocks(blocks, (block, path) => { + images.push({ attachment: block.attachment, path }) + structuralTokens += estimateStructuralBlock(block) + }) return structuralTokens } @@ -64,7 +69,7 @@ function collectImages(blocks: readonly ContentBlock[], images: ImageAttachmentR function analyzeNode(seq: SessionSeq, message: Message | null): MeterSurfaceNode { if (message === null) return { seq, heuristicTokens: 0, imageFreeTokens: 0, images: [] } const heuristicTokens = estimateMessage(message) - const images: ImageAttachmentRef[] = [] + const images: MeterImageOccurrence[] = [] const imageStructuralTokens = collectImages(message.content, images) return { seq, diff --git a/packages/llm/token-meter/tests/route-pricing.spec.ts b/packages/llm/token-meter/tests/route-pricing.spec.ts index 37ef87ce04..b3cbf954bb 100644 --- a/packages/llm/token-meter/tests/route-pricing.spec.ts +++ b/packages/llm/token-meter/tests/route-pricing.spec.ts @@ -165,6 +165,32 @@ describe('route-aware image pricing', () => { expect(unknownRoute.nodes[0]!.tokens).toBe(estimateMessage(message)) }) + it('prices occurrences at or before the image/offload watermark as the route placeholder', async () => { + const placeholder = '[offloaded]' + const watermarkPricing: LlmImageRequestPricing = { + priceImages: images => images.map(block => (block.offloaded === true + ? { visualTokens: 0, text: placeholder } + : { visualTokens: VISUAL_TOKENS, text: HANDLE_TEXT })), + } + const { meter, session } = await harness(() => watermarkPricing) + session.append('turn/start', { turn: 1 }) + const older = imageMessage('older') + const newer = imageMessage('newer') + const olderSeq = session.append('user/message', older, { surfaceOp: 'append' }).seq + session.append('user/message', newer, { surfaceOp: 'append' }) + session.append('request/header', { header: header('vision'), reason: 'initial' }) + const before = meter.measure(session) + expect(before.nodes.map(node => node.tokens)).toEqual([routedMessageTokens(older), routedMessageTokens(newer)]) + + session.append('step/start', { turn: 1, step: 1 }) + session.append('image/offload', { turn: 1, step: 1, watermark: { seq: olderSeq, path: [1] } }) + const after = meter.measure(session) + const imageFree = estimateMessage({ ...older, content: older.content.filter(block => block.type !== 'image') }) + expect(after.nodes[0]!.tokens).toBe(imageFree + estimateContent([{ type: 'text', text: placeholder }])) + expect(after.nodes[1]!.tokens).toBe(routedMessageTokens(newer)) + expect(after.totalTokens).toBeLessThan(before.totalTokens) + }) + it('fails loud when a route answers a mismatched occurrence count', async () => { const broken: LlmImageRequestPricing = { priceImages: () => [] } const { meter, session } = await harness(() => broken) diff --git a/packages/test-support/llm-replay/README.i18n.yaml b/packages/test-support/llm-replay/README.i18n.yaml index 410a2bba4a..1c15391bbd 100644 --- a/packages/test-support/llm-replay/README.i18n.yaml +++ b/packages/test-support/llm-replay/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/test-support/llm-replay/README.md -README.md: c0643bf6823e96cf3663f8299d0f5645d7974011 -README.zh.md: 1c2fe3393691b4988678497aa5948957f678c80b +README.md: edb05c3356aa4f438a158cbddcad7913adc85bf3 +README.zh.md: 7688093b598509fb35cb6987b340eb3891361f57 diff --git a/packages/test-support/llm-replay/README.md b/packages/test-support/llm-replay/README.md index c0643bf682..edb05c3356 100644 --- a/packages/test-support/llm-replay/README.md +++ b/packages/test-support/llm-replay/README.md @@ -58,7 +58,7 @@ With `providers` configured, the plugin registers a replay-only adapter whose ca | `file` | `$DSH_SNAPSHOT_FILE` | Path to the primary (parent) `session.jsonl` fixture; required (config or env) | | `overrideFile` | `$DSH_SNAPSHOT_OVERRIDE` | Optional `ReplayOverrideDoc` sidecar for the primary session | | `childFiles` | `$DSH_SNAPSHOT_CHILD_FILES` | Recorded subagent child-session logs for a nested scenario | -| `providers` | — | Optional replay-only provider and model catalog; a model may declare `contextWindow`, text/image modalities, and positive `imageRequestTokens` when image-capable; invalid values fail at load and routes never perform provider I/O | +| `providers` | — | Optional replay-only provider and model catalog; a model may declare `contextWindow`, text/image modalities, and, when image-capable, positive `imageRequestTokens` and a positive `imageRequestMaxBytes` base64 budget that drives the agent loop's `image/offload` watermark; invalid values fail at load and routes never perform provider I/O | | `paceMs` | — (burst) | Optional per-chunk delay in ms for genuinely incremental delivery | The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-llm-replay) is the exhaustive source for every accepted field and its JSDoc. diff --git a/packages/test-support/llm-replay/README.zh.md b/packages/test-support/llm-replay/README.zh.md index 1c2fe33936..7688093b59 100644 --- a/packages/test-support/llm-replay/README.zh.md +++ b/packages/test-support/llm-replay/README.zh.md @@ -58,7 +58,7 @@ kind: "package-reference" | `file` | `$DSH_SNAPSHOT_FILE` | 主(父)`session.jsonl` fixture 的路径;必需(配置或 env) | | `overrideFile` | `$DSH_SNAPSHOT_OVERRIDE` | 主会话的可选 `ReplayOverrideDoc` 伴随文件 | | `childFiles` | `$DSH_SNAPSHOT_CHILD_FILES` | 嵌套场景中已记录的 subagent 子会话日志 | -| `providers` | 无 | 可选的仅回放提供方与模型目录;模型可声明 `contextWindow`、文本/图片模态,以及图片模型使用的正整数 `imageRequestTokens`;非法值会在加载时失败,路由绝不执行提供方 I/O | +| `providers` | 无 | 可选的仅回放提供方与模型目录;模型可声明 `contextWindow`、文本/图片模态,以及图片模型使用的正整数 `imageRequestTokens` 和驱动 agent loop `image/offload` 水位的正整数 base64 预算 `imageRequestMaxBytes`;非法值会在加载时失败,路由绝不执行提供方 I/O | | `paceMs` | 无(突发) | 可选的每分片延迟(毫秒),用于真正的增量投递 | 生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-llm-replay)是每个受支持字段及其 JSDoc 的穷尽式真源。 diff --git a/packages/test-support/llm-replay/src/index.ts b/packages/test-support/llm-replay/src/index.ts index 7be893cc95..454c25bf70 100644 --- a/packages/test-support/llm-replay/src/index.ts +++ b/packages/test-support/llm-replay/src/index.ts @@ -27,7 +27,7 @@ import type { StreamChunk, TokenUsage, } from '@deepseek-ai/dsh-llm' -import { LlmAdapter, LlmError, ReasoningEffortId, requestImageHandleText, resolveRetryPolicy } from '@deepseek-ai/dsh-llm' +import { LlmAdapter, LlmError, ReasoningEffortId, offloadedImageText, requestImageHandleText, resolveRetryPolicy } from '@deepseek-ai/dsh-llm' import { assertNever } from '@deepseek-ai/dsh-util-values' const PACKED_CHUNK_ROW_TYPES = new Set(['text-chunks', 'reasoning-chunks', 'tool-call-chunks']) @@ -73,6 +73,13 @@ export interface ReplayModelConfig { * no image pricing. */ imageRequestTokens?: number + /** + * Optional accumulated base64 image-byte bound the replay route declares, + * so the agent loop advances the durable `image/offload` watermark in + * keyless scenarios exactly as a live image-capable route would. Requires + * {@link inputModalities} to include `image`. Absent declares no budget. + */ + imageRequestMaxBytes?: number /** Optional reasoning-effort ids the replay route accepts, in display order. */ reasoningEfforts?: string[] /** @@ -683,10 +690,9 @@ class ReplayAdapter extends LlmAdapter { const visualTokens = configured?.models?.find(candidate => candidate.id === model)?.imageRequestTokens if (visualTokens === undefined) return undefined return { - priceImages: images => images.map(ref => ({ - visualTokens, - text: requestImageHandleText(ref, { width: ref.width, height: ref.height }), - })), + priceImages: images => images.map(({ attachment: ref, offloaded }) => (offloaded === true + ? { visualTokens: 0, text: offloadedImageText(ref) } + : { visualTokens, text: requestImageHandleText(ref, { width: ref.width, height: ref.height }) })), } } @@ -722,6 +728,9 @@ class ReplayAdapter extends LlmAdapter { ...configuredModel?.defaultMaxTokens === undefined ? {} : { defaultMaxTokens: configuredModel.defaultMaxTokens }, + ...configuredModel?.imageRequestMaxBytes === undefined + ? {} + : { imageRequest: { representation: 'base64' as const, maxBytes: configuredModel.imageRequestMaxBytes } }, ...configuredModel?.reasoningEfforts === undefined ? {} : { @@ -966,6 +975,20 @@ function validateConfiguredModels(providers: ReplayProviderConfig[] | undefined) + 'requires inputModalities to include "image"', ) } + const imageRequestMaxBytes: unknown = model.imageRequestMaxBytes + if (imageRequestMaxBytes !== undefined + && (!Number.isSafeInteger(imageRequestMaxBytes) || (imageRequestMaxBytes as number) <= 0)) { + throw new Error( + `llm-replay: provider "${provider.id}" model "${model.id}" imageRequestMaxBytes ` + + 'must be a positive safe integer', + ) + } + if (imageRequestMaxBytes !== undefined && model.inputModalities?.includes('image') !== true) { + throw new Error( + `llm-replay: provider "${provider.id}" model "${model.id}" imageRequestMaxBytes ` + + 'requires inputModalities to include "image"', + ) + } } } } diff --git a/packages/test-support/llm-replay/tests/llm-replay.spec.ts b/packages/test-support/llm-replay/tests/llm-replay.spec.ts index 8f2a6dd059..781a523590 100644 --- a/packages/test-support/llm-replay/tests/llm-replay.spec.ts +++ b/packages/test-support/llm-replay/tests/llm-replay.spec.ts @@ -1380,12 +1380,47 @@ describe('apply (the plugin entry)', () => { width: 640, height: 480, } as never - const priced = pricing?.priceImages([ref, ref]) - expect(priced?.map(price => price.visualTokens)).toEqual([384, 384]) - expect(priced?.every(price => price.text.includes('640x480px'))).toBe(true) + const priced = pricing?.priceImages([ + { type: 'image', attachment: ref }, + { type: 'image', attachment: ref, offloaded: true }, + ]) + expect(priced?.map(price => price.visualTokens)).toEqual([384, 0]) + expect(priced?.[0]?.text).toContain('640x480px') + expect(priced?.[1]?.text).toContain('image omitted to fit request image limits') expect(ctx.llm.imageRequestPricing('deepseek', 'plain')).toBeUndefined() }) + it('declares a base64 request-image budget only for models that configure it', async () => { + writeFileSync(file, sessionJsonl(TEXT_CHUNKS.map((c, i) => chunkEvent(SessionSeq(i + 1), 1, 1, c))), 'utf8') + const ctx = new Context() + await ctx.plugin(LlmRuntime) + installLlmReplay(ctx, { + file, + providers: [{ + id: 'deepseek', + models: [ + { id: 'vision', inputModalities: ['text', 'image'], imageRequestMaxBytes: 4096 }, + { id: 'plain' }, + ], + }], + }) + await expect(ctx.llm.resolveModelInfo('deepseek', 'vision')).resolves.toMatchObject({ + imageRequest: { representation: 'base64', maxBytes: 4096 }, + }) + expect((await ctx.llm.resolveModelInfo('deepseek', 'plain')).imageRequest).toBeUndefined() + }) + + it.each([ + [{ id: 'm', inputModalities: ['text', 'image'], imageRequestMaxBytes: 0 }, 'must be a positive safe integer'], + [{ id: 'm', imageRequestMaxBytes: 4096 }, 'requires inputModalities to include "image"'], + ])('rejects an invalid imageRequestMaxBytes during load: %j', (model, reason) => { + const ctx = new Context() + const providers = [{ id: 'm', models: [model] }] as unknown as NonNullable + expect(() => { apply(ctx, { file, providers }) }).toThrow( + `llm-replay: provider "m" model "m" imageRequestMaxBytes ${reason}`, + ) + }) + it('rejects imageRequestTokens on a model without the image modality during load', () => { const ctx = new Context() const providers = [{ id: 'm', models: [{ id: 'm', imageRequestTokens: 384 }] }] as unknown as diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index a2ad93dd52..91a1fbf81d 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -469,6 +469,11 @@ "symbol": "RequestContext", "source": "packages/core/session/src/types.ts" }, + { + "doc": "docs/subsystems/session.md", + "symbol": "ImageOccurrencePosition", + "source": "packages/core/session/src/types.ts" + }, { "doc": "docs/subsystems/session.md", "symbol": "SessionSeq", diff --git a/snapshots/acp/acp.snapshot.ts b/snapshots/acp/acp.snapshot.ts index 4d7ecf263a..38c0011bdc 100644 --- a/snapshots/acp/acp.snapshot.ts +++ b/snapshots/acp/acp.snapshot.ts @@ -40,6 +40,11 @@ const controllerCases: readonly { hasModelTurn: true, configPath: join(corpusDir, 'image-compaction', 'cordis.yml'), }, + { + name: 'image-offload', + hasModelTurn: true, + configPath: join(corpusDir, 'image-offload', 'cordis.yml'), + }, ] as const function localScenarioSource(source: string | undefined): string | undefined { diff --git a/snapshots/acp/image-offload/cordis.snapshot.yml b/snapshots/acp/image-offload/cordis.snapshot.yml new file mode 100644 index 0000000000..5dba33cea6 --- /dev/null +++ b/snapshots/acp/image-offload/cordis.snapshot.yml @@ -0,0 +1,56 @@ +# Keyless replay for the image-offload scenario. This profile patch swaps the +# adapter and re-pins the recorded vision model. The replay catalog declares +# image input and a base64 request-image budget that six 69-byte frames +# (92 base64 bytes each) exceed, so the agent loop appends an `image/offload` +# watermark before the first dispatch and the second turn sends placeholders. +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + +- id: acp + name: '@deepseek-ai/dsh-acp' + config: + provider: deepseek-official + model: deepseek-v4-flash-vision-exp + +- id: session-persistence-jsonl + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + compression: none + +- id: agent-instructions + name: '@deepseek-ai/dsh-agent-instructions' + config: + maxBytes: 65536 + +- id: system-prompt + name: '@deepseek-ai/dsh-system-prompt' + config: + persona: | + You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + + Verify your work by running the code or tests. Keep answers brief and factual. + +- insert: + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek-official + name: DeepSeek + models: + - id: deepseek-v4-flash + inputModalities: [text] + - id: deepseek-v4-pro + inputModalities: [text] + - id: deepseek-v4-flash-vision-exp + inputModalities: [text, image] + # A wide context window keeps automatic compaction out of the + # scenario; the base64 budget admits three of the six frames. + contextWindow: 200000 + imageRequestTokens: 384 + imageRequestMaxBytes: 300 + +- id: attachment-local + name: '@deepseek-ai/dsh-attachment-local' diff --git a/snapshots/acp/image-offload/cordis.yml b/snapshots/acp/image-offload/cordis.yml new file mode 100644 index 0000000000..30d4f6e1df --- /dev/null +++ b/snapshots/acp/image-offload/cordis.yml @@ -0,0 +1,33 @@ +# Image-offload overlay: the image scenario with the shipped vision model and +# the durable attachment store the read_image tool commits through. The store +# resolves its root from $DSH_HOME, which the snapshot harness scopes per run, +# so the patch itself carries no attachment path. No compaction budget is +# tightened: the route's request-image budget alone drives the `image/offload` +# watermark this scenario pins. +- id: acp + name: '@deepseek-ai/dsh-acp' + config: + provider: deepseek-official + model: deepseek-v4-flash-vision-exp + +- id: session-persistence-jsonl + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + compression: none + +- id: agent-instructions + name: '@deepseek-ai/dsh-agent-instructions' + config: + maxBytes: 65536 + +- id: system-prompt + name: '@deepseek-ai/dsh-system-prompt' + config: + persona: | + You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + + Verify your work by running the code or tests. Keep answers brief and factual. + +- id: attachment-local + name: '@deepseek-ai/dsh-attachment-local' diff --git a/snapshots/acp/image-offload/input.json b/snapshots/acp/image-offload/input.json new file mode 100644 index 0000000000..a6d653c5c2 --- /dev/null +++ b/snapshots/acp/image-offload/input.json @@ -0,0 +1,86 @@ +{ + "steps": [ + { + "op": "initialize" + }, + { + "op": "newSession" + }, + { + "op": "promptContent", + "content": [ + { + "type": "text", + "text": "Here are six reference screenshots of the dashboard: " + }, + { + "type": "image", + "data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC", + "mimeType": "image/png" + }, + { + "type": "text", + "text": " (frame 1) " + }, + { + "type": "image", + "data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC", + "mimeType": "image/png" + }, + { + "type": "text", + "text": " (frame 2) " + }, + { + "type": "image", + "data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC", + "mimeType": "image/png" + }, + { + "type": "text", + "text": " (frame 3) " + }, + { + "type": "image", + "data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC", + "mimeType": "image/png" + }, + { + "type": "text", + "text": " (frame 4) " + }, + { + "type": "image", + "data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC", + "mimeType": "image/png" + }, + { + "type": "text", + "text": " (frame 5) " + }, + { + "type": "image", + "data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC", + "mimeType": "image/png" + }, + { + "type": "text", + "text": " (frame 6) " + }, + { + "type": "text", + "text": "Acknowledge receipt briefly; we will discuss them next." + } + ] + }, + { + "op": "promptContent", + "content": [ + { + "type": "text", + "text": "Now reply with exactly the single word DONE." + } + ] + } + ] +} diff --git a/snapshots/acp/image-offload/session.jsonl b/snapshots/acp/image-offload/session.jsonl new file mode 100644 index 0000000000..0e5a91ee34 --- /dev/null +++ b/snapshots/acp/image-offload/session.jsonl @@ -0,0 +1,33 @@ +{"type":"session","version":0,"id":"{{session:1}}","createdAt":1783952000000,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"permission/preset","data":{"preset":"danger-full-access"}} +{"type":"sandbox/mode","data":{"mode":"danger-full-access"}} +{"type":"approval/policy","data":{"policy":"never"}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Here are six reference screenshots of the dashboard: "},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"text","text":" (frame 1) "},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"text","text":" (frame 2) "},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"text","text":" (frame 3) "},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"text","text":" (frame 4) "},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"text","text":" (frame 5) "},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"text","text":" (frame 6) Acknowledge receipt briefly; we will discuss them next."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]}} +{"type":"turn/start","data":{"turn":1}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","data":{"turn":1,"step":1}} +{"type":"user/message","data":{"content":[{"type":"text","text":"Here are six reference screenshots of the dashboard: "},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"text","text":" (frame 1) "},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"text","text":" (frame 2) "},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"text","text":" (frame 3) "},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"text","text":" (frame 4) "},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"text","text":" (frame 5) "},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"text","text":" (frame 6) Acknowledge receipt briefly; we will discuss them next."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{message:2}}"},"surfaceOp":"append"} +{"type":"session/title","data":{"title":"Here are six reference screenshots","messageSeqs":[7],"source":{"kind":"fallback"}}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp","contextWindow":200000}} +{"type":"image/offload","data":{"turn":1,"step":1,"watermark":{"seq":7,"path":[5]}}} +{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Received six dashboard frames; three of them are already offloaded."}}}} +{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"Received six dashboard frames; three of them are already offloaded."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:3}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[[13,16]],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":1}} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Now reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:4}}"}]}} +{"type":"turn/start","data":{"turn":2}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","data":{"turn":2,"step":1}} +{"type":"user/message","data":{"content":[{"type":"text","text":"Now reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:4}}"},"surfaceOp":"append"} +{"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:5}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[[25,28]],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":2,"step":1}} +{"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/snapshots/acp/image-offload/snapshot.yml b/snapshots/acp/image-offload/snapshot.yml new file mode 100644 index 0000000000..b591cbcfc8 --- /dev/null +++ b/snapshots/acp/image-offload/snapshot.yml @@ -0,0 +1,16 @@ +version: 1 +scenario: image-offload +profile: acp +composition: image-offload +recording: authored +header: + class: image-offload + pin: true + systemPromptSource: session/read-image + toolSchemasSource: escalation-approved +permission: danger-full-access +input: + attachments: + - id: sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640 + mediaType: image/png + data: iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElFTkSuQmCC diff --git a/snapshots/acp/image-offload/stdout.expected.jsonl b/snapshots/acp/image-offload/stdout.expected.jsonl new file mode 100644 index 0000000000..5fce11218a --- /dev/null +++ b/snapshots/acp/image-offload/stdout.expected.jsonl @@ -0,0 +1,8 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"mcpCapabilities":{"http":true},"promptCapabilities":{"image":true,"audio":false,"embeddedContext":false},"sessionCapabilities":{"close":{},"list":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash-vision-exp\"]","options":[{"group":"deepseek-official","name":"DeepSeek","options":[{"value":"[\"deepseek-official\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek-official\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"},{"value":"[\"deepseek-official\",\"deepseek-v4-flash-vision-exp\"]","name":"deepseek-v4-flash-vision-exp"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"Received six dashboard frames; three of them are already offloaded."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"usage_update","used":"{{usedTokens}}","size":200000}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"DONE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"usage_update","used":"{{usedTokens}}","size":200000}}} +{"jsonrpc":"2.0","id":4,"result":{"stopReason":"end_turn"}} diff --git a/snapshots/acp/image-offload/system-prompt.expected.md b/snapshots/acp/image-offload/system-prompt.expected.md new file mode 120000 index 0000000000..7d5e489c58 --- /dev/null +++ b/snapshots/acp/image-offload/system-prompt.expected.md @@ -0,0 +1 @@ +../../session/read-image/system-prompt.expected.md \ No newline at end of file From 5e277c211169ef59b692765a95049604ed1b4287 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 7 Sep 2026 12:51:09 +0800 Subject: [PATCH 04/79] test(session): close image offload coverage gaps --- .../agent-loop/tests/image-offload.spec.ts | 2 +- packages/core/session/src/image-offload.ts | 27 +++++++++---------- packages/core/session/src/index.ts | 11 +++----- .../core/session/tests/image-offload.spec.ts | 16 +++++++++-- 4 files changed, 32 insertions(+), 24 deletions(-) diff --git a/packages/core/agent-loop/tests/image-offload.spec.ts b/packages/core/agent-loop/tests/image-offload.spec.ts index 967b1159d4..035074f72c 100644 --- a/packages/core/agent-loop/tests/image-offload.spec.ts +++ b/packages/core/agent-loop/tests/image-offload.spec.ts @@ -144,7 +144,7 @@ describe('image/offload in the agent loop', () => { it('interprets an adapter count in request order after a surface replacement', async () => { const adapter = new MockAdapter([ () => { - throw new LlmError('inline budget exceeded', IMAGE_OFFLOAD_REQUIRED_CODE, { offloadImages: 1 }) + throw new LlmError('inline budget exceeded', IMAGE_OFFLOAD_REQUIRED_CODE, { offloadImages: 2 }) }, textResponse('sent'), ]) diff --git a/packages/core/session/src/image-offload.ts b/packages/core/session/src/image-offload.ts index 82325e9f51..4f0ac93541 100644 --- a/packages/core/session/src/image-offload.ts +++ b/packages/core/session/src/image-offload.ts @@ -21,17 +21,16 @@ function compareImageBlockPaths(a: ImageBlockPath, b: ImageBlockPath): number { return a.length - b.length } -/** Return whether a nested block path identifies an image occurrence. */ -function pathIdentifiesImage(content: readonly ContentBlock[], path: ImageBlockPath): boolean { - let blocks = content - for (const [depth, index] of path.entries()) { - const block = blocks[index] - if (block === undefined) return false - if (depth === path.length - 1) return block.type === 'image' - if (block.type !== 'tool-result') return false - blocks = block.content - } - return false +/** Return whether a non-empty nested block path identifies an image occurrence. */ +function pathIdentifiesImage( + content: readonly ContentBlock[], + [index, ...rest]: readonly [number, ...number[]], +): boolean { + const block = content[index] + if (block === undefined) return false + if (rest.length === 0) return block.type === 'image' + if (block.type !== 'tool-result') return false + return pathIdentifiesImage(block.content, rest as [number, ...number[]]) } /** Mark matching image occurrences while preserving unchanged durable content by identity. */ @@ -128,11 +127,11 @@ export function assertImageOffloadAdvance( && compareImagePositions(positionValue, prior) <= 0) { throw new Error(`${location} does not advance the image offload watermark`) } - const event = log[seq] - const message = event === undefined ? null : deriveEventMessage(event) + // oxlint-disable-next-line typescript/no-non-null-assertion -- accepted logs are contiguous and seq is range-checked + const message = deriveEventMessage(log[seq]!) if (!surfaceNodes.includes(positionValue.seq) || message === null - || !pathIdentifiesImage(message.content, positionValue.path)) { + || !pathIdentifiesImage(message.content, positionValue.path as [number, ...number[]])) { throw new Error(`${location} watermark does not identify an image on the current surface`) } } diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 249a62cd02..313575dcbb 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -366,13 +366,10 @@ function assertMessageEventShape(event: Record, subject: string /** Reject the request-only image marker from durable message content at every nesting depth. */ function assertNoLoggedImageOffloadMarker(type: string, data: unknown, subject: string): void { if (type !== 'user/message' && type !== 'assistant/message' && type !== 'tool/result') return - const dataRecord = typeof data === 'object' && data !== null - ? data as Record - : undefined - const message = type === 'user/message' ? dataRecord : dataRecord?.['message'] - if (typeof message !== 'object' || message === null) return - const content = (message as Record)['content'] - if (!Array.isArray(content)) return + // Both callers supply a message envelope with array content: append by type and seed by validation. + const dataRecord = data as Record + const message = (type === 'user/message' ? dataRecord : dataRecord['message']) as Record + const content = message['content'] as unknown[] const visit = (blocks: readonly unknown[]): void => { for (const value of blocks) { if (typeof value !== 'object' || value === null || Array.isArray(value)) continue diff --git a/packages/core/session/tests/image-offload.spec.ts b/packages/core/session/tests/image-offload.spec.ts index f3fca716eb..4508e1c581 100644 --- a/packages/core/session/tests/image-offload.spec.ts +++ b/packages/core/session/tests/image-offload.spec.ts @@ -6,7 +6,7 @@ import { describe, expect, it } from 'vitest' import { createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm' import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import { compareImagePositions, foldImageOffloadWatermark, Session, SessionId, SessionSeq } from '@deepseek-ai/dsh-session' +import { compareImagePositions, foldImageOffloadWatermark, markImageOffload, Session, SessionId, SessionSeq } from '@deepseek-ai/dsh-session' function image(name: string): Extract { return { @@ -78,6 +78,8 @@ describe('image/offload append validation', () => { .toThrow('watermark does not identify an image on the current surface') expect(() => session.append('image/offload', { turn: 1, step: 1, watermark: { seq: SessionSeq(3), path: [9] } })) .toThrow('watermark does not identify an image on the current surface') + expect(() => session.append('image/offload', { turn: 1, step: 1, watermark: { seq: SessionSeq(3), path: [0, 0] } })) + .toThrow('watermark does not identify an image on the current surface') session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'replacement' }], source: { kind: 'user' }, }), { @@ -99,7 +101,7 @@ describe('image/offload append validation', () => { const event = events[3]! const marked = { ...event, - data: { ...event.data, content: [{ ...image('restored-marked'), offloaded: true }] }, + data: { ...event.data, content: [null, { ...image('restored-marked'), offloaded: true }] }, } events[3] = marked as never expect(() => Session.create(SessionId('offload-marked-seed'), events)) @@ -201,6 +203,16 @@ describe('image/offload surface derivation', () => { }) describe('image offload helpers', () => { + it('preserves a nested block when only an earlier sibling is offloaded', () => { + const message = createUserMessage({ + content: [image('first'), toolResult('later')], + source: { kind: 'user' }, + }) + const projected = markImageOffload(message, SessionSeq(3), { seq: SessionSeq(3), path: [0] }) + expect(projected.content[0]).toEqual({ ...image('first'), offloaded: true }) + expect(projected.content[1]).toBe(message.content[1]) + }) + it('orders positions by seq then path and folds the latest watermark', () => { expect(compareImagePositions({ seq: SessionSeq(1), path: [5] }, { seq: SessionSeq(3), path: [0] })).toBeLessThan(0) expect(compareImagePositions({ seq: SessionSeq(4), path: [1] }, { seq: SessionSeq(3), path: [0, 3] })).toBeGreaterThan(0) From fdbeeddc367de3387b45eeb61b504ed20d9f673c Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 7 Sep 2026 13:01:19 +0800 Subject: [PATCH 05/79] fix(session): tolerate contentless synthetic messages --- packages/core/session/src/index.ts | 4 ++-- packages/core/session/tests/image-offload.spec.ts | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 313575dcbb..2150d5ceaa 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -366,10 +366,10 @@ function assertMessageEventShape(event: Record, subject: string /** Reject the request-only image marker from durable message content at every nesting depth. */ function assertNoLoggedImageOffloadMarker(type: string, data: unknown, subject: string): void { if (type !== 'user/message' && type !== 'assistant/message' && type !== 'tool/result') return - // Both callers supply a message envelope with array content: append by type and seed by validation. const dataRecord = data as Record const message = (type === 'user/message' ? dataRecord : dataRecord['message']) as Record - const content = message['content'] as unknown[] + const content = message['content'] + if (!Array.isArray(content)) return const visit = (blocks: readonly unknown[]): void => { for (const value of blocks) { if (typeof value !== 'object' || value === null || Array.isArray(value)) continue diff --git a/packages/core/session/tests/image-offload.spec.ts b/packages/core/session/tests/image-offload.spec.ts index 4508e1c581..287c9e3ce3 100644 --- a/packages/core/session/tests/image-offload.spec.ts +++ b/packages/core/session/tests/image-offload.spec.ts @@ -92,6 +92,11 @@ describe('image/offload append validation', () => { it('rejects request-only offload markers from appended and restored messages', () => { const session = seeded() + expect(() => session.append('assistant/message', { + turn: 1, + step: 1, + message: {}, + } as never, { surfaceOp: 'append' })).not.toThrow() expect(() => session.append('user/message', createUserMessage({ content: [{ ...image('marked'), offloaded: true }], source: { kind: 'user' }, }), { surfaceOp: 'append' })) From 74f380806f6b0d14c8706e794ac4c5d7f355f659 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 7 Sep 2026 14:13:01 +0800 Subject: [PATCH 06/79] test(snapshot): follow persona prefix config --- snapshots/acp/image-offload/cordis.snapshot.yml | 2 +- snapshots/acp/image-offload/cordis.yml | 2 +- snapshots/sdk/inline-image-prompt/cordis.snapshot.yml | 2 +- snapshots/sdk/inline-image-prompt/cordis.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/snapshots/acp/image-offload/cordis.snapshot.yml b/snapshots/acp/image-offload/cordis.snapshot.yml index 5dba33cea6..fbbaf80b53 100644 --- a/snapshots/acp/image-offload/cordis.snapshot.yml +++ b/snapshots/acp/image-offload/cordis.snapshot.yml @@ -27,7 +27,7 @@ - id: system-prompt name: '@deepseek-ai/dsh-system-prompt' config: - persona: | + personaPrefix: | You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. Verify your work by running the code or tests. Keep answers brief and factual. diff --git a/snapshots/acp/image-offload/cordis.yml b/snapshots/acp/image-offload/cordis.yml index 30d4f6e1df..25f63462fb 100644 --- a/snapshots/acp/image-offload/cordis.yml +++ b/snapshots/acp/image-offload/cordis.yml @@ -24,7 +24,7 @@ - id: system-prompt name: '@deepseek-ai/dsh-system-prompt' config: - persona: | + personaPrefix: | You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. Verify your work by running the code or tests. Keep answers brief and factual. diff --git a/snapshots/sdk/inline-image-prompt/cordis.snapshot.yml b/snapshots/sdk/inline-image-prompt/cordis.snapshot.yml index 0e3aa19480..1545f9755c 100644 --- a/snapshots/sdk/inline-image-prompt/cordis.snapshot.yml +++ b/snapshots/sdk/inline-image-prompt/cordis.snapshot.yml @@ -13,7 +13,7 @@ - id: system-prompt name: '@deepseek-ai/dsh-system-prompt' config: - persona: | + personaPrefix: | You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. Verify your work by running the code or tests. Keep answers brief and factual. diff --git a/snapshots/sdk/inline-image-prompt/cordis.yml b/snapshots/sdk/inline-image-prompt/cordis.yml index b2b30505e7..cddbe6e689 100644 --- a/snapshots/sdk/inline-image-prompt/cordis.yml +++ b/snapshots/sdk/inline-image-prompt/cordis.yml @@ -9,7 +9,7 @@ - id: system-prompt name: '@deepseek-ai/dsh-system-prompt' config: - persona: | + personaPrefix: | You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. Verify your work by running the code or tests. Keep answers brief and factual. From 8c7b023d6ce919544ac53aad562970849e8c1fc4 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 7 Sep 2026 15:58:20 +0800 Subject: [PATCH 07/79] refactor(llm): move image offload planning into a plugin and trim the change surface Keep the agent loop unchanged: the new @deepseek-ai/dsh-llm-image-offload plugin plans the image/offload watermark on agent/pre-step from the routed model's budget and advances it on agent/request-error when an adapter fails with IMAGE_OFFLOAD_REQUIRED, returning the retry action the loop already honours. Drop PreparedLlmCall.imageRequest and its validation, the planner wrapper, the replay adapter's imageRequestMaxBytes option, and the duplicate ACP snapshot scenario; share the exact-byte overflow count between the DeepSeek and pi-ai adapters through requiredImageOffload(). The SDK image snapshot replays an authored IMAGE_OFFLOAD_REQUIRED attempt instead of a replay-only budget. --- ...26-09-02-image-offload-watermark.i18n.yaml | 4 +- .../2026-09-02-image-offload-watermark.md | 10 +- .../2026-09-02-image-offload-watermark.zh.md | 10 +- apps/cli/composition.md | 3 + docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 7 +- docs/architecture.zh.md | 7 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 22 +- docs/config-catalog.zh.md | 22 +- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 6 +- docs/event-producer-consumer.zh.md | 6 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 5 + docs/module-graph.zh.md | 5 + docs/subsystems/llm-streaming.i18n.yaml | 4 +- docs/subsystems/llm-streaming.md | 6 +- docs/subsystems/llm-streaming.zh.md | 6 +- docs/subsystems/session.i18n.yaml | 4 +- docs/subsystems/session.md | 2 +- docs/subsystems/session.zh.md | 2 +- packages/bundle/base/cordis.patch.yml | 3 + packages/bundle/base/package.json | 1 + packages/bundle/sdk-minimal/cordis.patch.yml | 3 + packages/bundle/sdk-minimal/package.json | 1 + .../sdk-minimal/tests/sdk-minimal.spec.ts | 1 + packages/core/agent-loop/README.i18n.yaml | 4 +- packages/core/agent-loop/README.md | 2 +- packages/core/agent-loop/README.zh.md | 2 +- packages/core/agent-loop/src/agent.ts | 95 +------- .../agent-loop/tests/image-offload.spec.ts | 192 --------------- .../core/agent-loop/tests/mock-adapter.ts | 4 +- packages/core/session/README.i18n.yaml | 4 +- packages/core/session/README.md | 2 +- packages/core/session/README.zh.md | 2 +- .../extensions/tool-cordis/src/api-catalog.ts | 2 +- packages/llm/README.i18n.yaml | 4 +- packages/llm/README.md | 3 +- packages/llm/README.zh.md | 3 +- packages/llm/llm-deepseek/README.i18n.yaml | 4 +- packages/llm/llm-deepseek/README.md | 4 +- packages/llm/llm-deepseek/README.zh.md | 4 +- packages/llm/llm-deepseek/src/serialize.ts | 22 +- .../llm/llm-image-offload/README.i18n.yaml | 6 + packages/llm/llm-image-offload/README.md | 106 +++++++++ packages/llm/llm-image-offload/README.zh.md | 106 +++++++++ packages/llm/llm-image-offload/package.json | 46 ++++ packages/llm/llm-image-offload/src/index.ts | 112 +++++++++ .../tests/image-offload.spec.ts | 222 ++++++++++++++++++ packages/llm/llm-image-offload/tsconfig.json | 30 +++ .../llm/llm-image-offload/tsdown.config.ts | 13 + packages/llm/llm-pi-ai/README.i18n.yaml | 4 +- packages/llm/llm-pi-ai/README.md | 4 +- packages/llm/llm-pi-ai/README.zh.md | 4 +- packages/llm/llm-pi-ai/src/context.ts | 18 +- packages/llm/llm/README.i18n.yaml | 4 +- packages/llm/llm/README.md | 2 +- packages/llm/llm/README.zh.md | 2 +- packages/llm/llm/src/content.ts | 52 ++-- packages/llm/llm/src/error.ts | 8 +- packages/llm/llm/src/index.ts | 39 +-- packages/llm/llm/src/types.ts | 11 +- packages/llm/llm/tests/content.spec.ts | 27 +-- packages/llm/llm/tests/service.spec.ts | 30 --- .../test-support/llm-replay/README.i18n.yaml | 4 +- packages/test-support/llm-replay/README.md | 2 +- packages/test-support/llm-replay/README.zh.md | 2 +- packages/test-support/llm-replay/src/index.ts | 24 -- .../llm-replay/tests/llm-replay.spec.ts | 31 --- pnpm-lock.yaml | 37 +++ python/sdk-runtime/package.json | 1 + snapshots/acp/acp.snapshot.ts | 5 - .../acp/image-offload/cordis.snapshot.yml | 56 ----- snapshots/acp/image-offload/cordis.yml | 33 --- snapshots/acp/image-offload/input.json | 86 ------- snapshots/acp/image-offload/session.v2.jsonl | 25 -- snapshots/acp/image-offload/snapshot.yml | 16 -- .../acp/image-offload/stdout.expected.jsonl | 8 - .../inline-image-prompt/cordis.snapshot.yml | 40 ---- snapshots/sdk/inline-image-prompt/cordis.yml | 18 -- .../sdk/inline-image-prompt/session.v2.jsonl | 3 +- .../sdk/inline-image-prompt/snapshot.yml | 5 +- tsconfig.base.json | 1 + tsconfig.host.json | 1 + 85 files changed, 873 insertions(+), 880 deletions(-) delete mode 100644 packages/core/agent-loop/tests/image-offload.spec.ts create mode 100644 packages/llm/llm-image-offload/README.i18n.yaml create mode 100644 packages/llm/llm-image-offload/README.md create mode 100644 packages/llm/llm-image-offload/README.zh.md create mode 100644 packages/llm/llm-image-offload/package.json create mode 100644 packages/llm/llm-image-offload/src/index.ts create mode 100644 packages/llm/llm-image-offload/tests/image-offload.spec.ts create mode 100644 packages/llm/llm-image-offload/tsconfig.json create mode 100644 packages/llm/llm-image-offload/tsdown.config.ts delete mode 100644 snapshots/acp/image-offload/cordis.snapshot.yml delete mode 100644 snapshots/acp/image-offload/cordis.yml delete mode 100644 snapshots/acp/image-offload/input.json delete mode 100644 snapshots/acp/image-offload/session.v2.jsonl delete mode 100644 snapshots/acp/image-offload/snapshot.yml delete mode 100644 snapshots/acp/image-offload/stdout.expected.jsonl delete mode 100644 snapshots/sdk/inline-image-prompt/cordis.snapshot.yml delete mode 100644 snapshots/sdk/inline-image-prompt/cordis.yml diff --git a/.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.i18n.yaml index 890fa3cb8e..0d62c2c804 100644 --- a/.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.md -2026-09-02-image-offload-watermark.md: 618446d823b1c7393f6088f780f67aa2d8f5f284 -2026-09-02-image-offload-watermark.zh.md: 26bbe8b21293ae1e7a60eb14c0ea410b13b49b94 +2026-09-02-image-offload-watermark.md: 2b682cd3f3de19c47c0c38f986dd430f86a1ca51 +2026-09-02-image-offload-watermark.zh.md: 6a07f8f37b33fa08898adba46c7a8ca9d8bf432b diff --git a/.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.md b/.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.md index 618446d823..2b682cd3f3 100644 --- a/.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.md +++ b/.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.md @@ -20,9 +20,9 @@ The offload point is a durable session fact: the core `image/offload` event reco **Only advances.** The watermark never retreats when a budget grows, a route changes, or compaction lowers the total, so the model-visible prefix and the provider cache prefix move only forward. An occurrence below the watermark that compaction later shadows leaves the watermark valid, because the comparison is positional. -**Decision owned by the loop, budgets declared by routes.** An image-capable route declares an `LlmImageRequestBudget` (`representation`, `maxBytes`, `maxImages`, both quanta, and the request-version byte target) as `imageRequest` on its `LlmResolvedModelInfo`; `LlmRuntime` validates it and exposes it on `PreparedLlmCall`. After `request/header`, `buildRequest` collects retained occurrences in model request order and plans the removal prefix with the pure `planImageOffload()` (represented bytes are the normalized count clamped to the version target and base64-expanded for inline routes, removed in whole quanta). A surface replacement can put a newer event before older events; the loop converts the request prefix to the greatest durable position in that prefix, which may omit additional occurrences but always satisfies the requested removal. It appends the event and only then derives the request messages. The DeepSeek adapter declares its file-mode budget; the pi-ai adapter declares its base64 bound; the replay adapter declares an optional `imageRequestMaxBytes` for keyless scenarios. +**Decision owned by one plugin, budgets declared by routes.** An image-capable route declares an `LlmImageRequestBudget` (`representation`, `maxBytes`, `maxImages`, both quanta, and the request-version byte target) as `imageRequest` on its `LlmResolvedModelInfo`. The `dsh-llm-image-offload` plugin listens on `agent/pre-step`: once downstream listeners have entered the step, it resolves the budget of the route the latest `request/header` names through `ctx.llm.resolveModelInfo()`, collects retained occurrences in model request order, and plans the removal prefix with the pure `offloadedImagePrefixCount()` (represented bytes are the normalized count clamped to the version target and base64-expanded for inline routes, removed in whole quanta). A surface replacement can put a newer event before older events; the plugin converts the request prefix to the greatest durable position in that prefix, which may omit additional occurrences but always satisfies the requested removal. The event lands before `step/start`, so the loop's ordinary derivation sends the offloaded set. Before the first header, and for occurrences the entering step itself appends, no plan runs; the adapter failure below covers them. The DeepSeek adapter declares its file-mode budget and the pi-ai adapter its base64 bound; the agent loop is unchanged. -**Adapters project, never decide.** Serialization renders every `offloaded` block as `offloadedImageText` with the currently resolved access path and prepares only retained occurrences. When the retained occurrences' exact request-version bytes still exceed the route budget, in file mode, under the inline fallback's tighter budget, or under the pi-ai bound, the adapter fails the attempt with `IMAGE_OFFLOAD_REQUIRED` and `LlmFailure.offloadImages` naming how many more oldest occurrences must be offloaded, computed with `offloadedImagePrefixCount()`. The loop advances the watermark by that count and rebuilds the request before `agent/request-error` runs; when nothing remains to offload the failure reaches ordinary recovery. +**Adapters project, never decide.** Serialization renders every `offloaded` block as `offloadedImageText` with the currently resolved access path and prepares only retained occurrences. When the retained occurrences' exact request-version bytes still exceed the route budget, in file mode, under the inline fallback's tighter budget, or under the pi-ai bound, the adapter fails the attempt with `IMAGE_OFFLOAD_REQUIRED` and `LlmFailure.offloadImages` naming how many more oldest occurrences must be offloaded, computed with the shared `requiredImageOffload()`. The plugin's `agent/request-error` listener advances the watermark by that count and returns the `retry` action, so the loop re-runs the step over the derived surface; when nothing remains to offload the listener delegates and the failure reaches ordinary recovery. **Token accounting.** `priceImages` receives surface `ImageBlock`s and prices an `offloaded` one as its placeholder text; the DeepSeek and replay pricing no longer reproduce any offload arithmetic. The meter folds `image/offload` into its replay state, prices the current surface under the current watermark and each usage anchor under the watermark its request was derived with. Provider usage remains the anchor for completed requests. @@ -38,7 +38,9 @@ The offload point is a durable session fact: the core `image/offload` event reco **Identify the watermark by attachment id or by occurrence count.** Attachment ids repeat for duplicate attachments, so the position is ambiguous; counts shift when compaction prunes earlier occurrences. A seq plus block path is unambiguous and survives pruning. -**Let each adapter append the event.** The adapter owns the budgets but not the session surface; a surface fact appended below the loop bypasses the loop's ownership of derived history and lets two adapters define the surface differently. Adapters report the count they need instead. +**Let each adapter append the event.** The adapter owns the budgets but not the session surface; a surface fact appended below the loop bypasses the session's derived history and lets two adapters define the surface differently. Adapters report the count they need instead. + +**Plan and advance inside the agent loop.** The loop knows the exact prepared route before deriving each request, so planning there never spends a failed attempt. It also puts a route-specific policy into the one component every profile shares, changes the documented step order, and bypasses the `agent/request-error` waterfall that context-overflow compaction and retry already use for the same recover-and-retry pattern. A plugin on the existing `agent/pre-step` and `agent/request-error` extension points costs one failed attempt when a step's own messages or an unknown route push the retained set past the budget, and keeps the loop unchanged. **Keep a transient extra offload for the inline fallback and exact-byte overflow.** Would have sent an unlogged projection in exactly the cases the invariant exists for; the failure-and-advance path costs one serialization attempt and keeps every dispatched request derivable from the log. @@ -50,4 +52,4 @@ Every dispatched request's image set is determined by the log alone, across file ## Testing -`packages/llm/llm/tests/content.spec.ts` pins arbitrary-depth image traversal, represented bytes, projection, and the watermark planner, including the 129-to-64 MiB quantum example. `packages/core/session/tests/image-offload.spec.ts` pins append and seed validation, exact current-surface image paths, rejection of persisted derived markers, strict advance, arbitrary-depth marking, frozen copies, cache rebuild, and scratch-replay equality. `packages/core/agent-loop/tests/image-offload.spec.ts` pins the pre-dispatch advance, the non-retreating watermark, request-order counts after a surface replacement, the `IMAGE_OFFLOAD_REQUIRED` advance-and-rebuild path, and the exhausted case. Compaction tests pin watermark application to direct summarization input. Adapter specs pin placeholder projection, prepared-only-retained reads, and the exact-byte failure with its count; `route-pricing.spec.ts` pins watermark pricing; the replay adapter spec pins `imageRequestMaxBytes`. The `image-offload` ACP snapshot and `inline-image-prompt` TypeScript SDK snapshot replay six-frame sessions through shipped profiles under replay routes with bounded base64 image requests. The Python SDK notification test pins lossless forwarding of the new event and its nested watermark. +`packages/llm/llm/tests/content.spec.ts` pins arbitrary-depth image traversal, represented bytes, projection, and the prefix count, including the 129-to-64 MiB quantum example. `packages/core/session/tests/image-offload.spec.ts` pins append and seed validation, exact current-surface image paths, rejection of persisted derived markers, strict advance, arbitrary-depth marking, frozen copies, cache rebuild, and scratch-replay equality. `packages/llm/llm-image-offload/tests/image-offload.spec.ts` pins the pre-step advance, the non-retreating watermark, request-order counts after a surface replacement, the `IMAGE_OFFLOAD_REQUIRED` advance-and-retry path, and the exhausted case that delegates downstream. Compaction tests pin watermark application to direct summarization input. Adapter specs pin placeholder projection, prepared-only-retained reads, and the exact-byte failure with its count; `route-pricing.spec.ts` pins watermark pricing. The `inline-image-prompt` TypeScript SDK snapshot replays an authored `IMAGE_OFFLOAD_REQUIRED` attempt through the shipped profile and pins the advance and the retried request. The Python SDK notification test pins lossless forwarding of the new event and its nested watermark. diff --git a/.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.zh.md b/.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.zh.md index 26bbe8b212..6a07f8f37b 100644 --- a/.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.zh.md @@ -20,9 +20,9 @@ offload 位置是持久的会话事实:核心事件 `image/offload` 记录一 **只前进。** 预算变大、路由切换或 compaction 降低总量时水位永不回退,所以模型可见前缀和 provider 缓存前缀只向前移动。水位之下的出现位置后来被 compaction 遮蔽也不影响水位有效性,因为比较是按位置进行的。 -**决定权在循环,预算由路由声明。** 支持图片的路由在其 `LlmResolvedModelInfo` 上以 `imageRequest` 声明一个 `LlmImageRequestBudget`(`representation`、`maxBytes`、`maxImages`、两个量子与请求版本字节目标);`LlmRuntime` 校验它并通过 `PreparedLlmCall` 暴露。`request/header` 之后,`buildRequest` 按模型请求顺序收集保留的出现位置,用纯函数 `planImageOffload()` 规划删除前缀(表示字节是归一化字节数按版本目标截断后的值,内联路由再按 base64 展开,按整量子删除)。表层替换可能把较新的事件放到较老事件之前;循环把请求前缀换算成其中最大的持久位置,因此可能额外省略一些出现位置,但一定满足要求的删除量。循环追加事件,然后才派生请求消息。DeepSeek adapter 声明其 file 模式预算,pi-ai adapter 声明其 base64 上限,replay adapter 为 keyless 场景声明可选的 `imageRequestMaxBytes`。 +**决定权在一个插件,预算由路由声明。** 支持图片的路由在其 `LlmResolvedModelInfo` 上以 `imageRequest` 声明一个 `LlmImageRequestBudget`(`representation`、`maxBytes`、`maxImages`、两个量子与请求版本字节目标)。`dsh-llm-image-offload` 插件监听 `agent/pre-step`:下游监听器决定进入 step 后,它通过 `ctx.llm.resolveModelInfo()` 解析最新 `request/header` 所指路由的预算,按模型请求顺序收集保留的出现位置,用纯函数 `offloadedImagePrefixCount()` 规划删除前缀(表示字节是归一化字节数按版本目标截断后的值,内联路由再按 base64 展开,按整量子删除)。表层替换可能把较新的事件放到较老事件之前;插件把请求前缀换算成其中最大的持久位置,因此可能额外省略一些出现位置,但一定满足要求的删除量。事件落在 `step/start` 之前,循环的常规派生因此发送已省略的集合。第一个 header 之前,以及进入的 step 自己追加的出现位置,不做规划,由下面的 adapter 失败路径覆盖。DeepSeek adapter 声明其 file 模式预算,pi-ai adapter 声明其 base64 上限;agent loop 不变。 -**adapter 只投影,不决定。** 序列化把每个 `offloaded` 块渲染为带当前已解析访问路径的 `offloadedImageText`,只准备保留的出现位置。当保留的出现位置按精确请求版本字节仍超过路由预算,无论是 file 模式、内联回退更紧的预算还是 pi-ai 上限,adapter 都以 `IMAGE_OFFLOAD_REQUIRED` 让本次尝试失败,并在 `LlmFailure.offloadImages` 中用 `offloadedImagePrefixCount()` 算出还需省略多少最老的出现位置。循环按该数量推进水位并在 `agent/request-error` 运行前重建请求;没有可省略的出现位置时,失败进入普通恢复路径。 +**adapter 只投影,不决定。** 序列化把每个 `offloaded` 块渲染为带当前已解析访问路径的 `offloadedImageText`,只准备保留的出现位置。当保留的出现位置按精确请求版本字节仍超过路由预算,无论是 file 模式、内联回退更紧的预算还是 pi-ai 上限,adapter 都以 `IMAGE_OFFLOAD_REQUIRED` 让本次尝试失败,并在 `LlmFailure.offloadImages` 中用共享的 `requiredImageOffload()` 算出还需省略多少最老的出现位置。插件的 `agent/request-error` 监听器按该数量推进水位并返回 `retry` 动作,循环据此在派生表层上重跑该 step;没有可省略的出现位置时,监听器向下游委托,失败进入普通恢复路径。 **token 记账。** `priceImages` 接收表层的 `ImageBlock`,把 `offloaded` 的按占位文本定价;DeepSeek 和 replay 的定价不再复现任何 offload 算术。meter 把 `image/offload` 折进其重放状态,按当前水位为当前表层定价,按每个 usage 锚点的请求派生时的水位为该锚点定价。已完成请求仍以 provider usage 为锚点。 @@ -38,7 +38,9 @@ offload 位置是持久的会话事实:核心事件 `image/offload` 记录一 **用附件 id 或出现次数标识水位。** 附件 id 在重复附加时重复,位置因此含糊;compaction 剪掉更早的出现位置后计数会漂移。序号加块路径没有歧义,也不受剪枝影响。 -**让各个 adapter 自己追加事件。** adapter 拥有预算,但不拥有会话表层;在循环之下追加表层事实绕过了循环对派生历史的所有权,也会让两个 adapter 对表层做出不同定义。adapter 改为上报它需要的数量。 +**让各个 adapter 自己追加事件。** adapter 拥有预算,但不拥有会话表层;在循环之下追加表层事实绕过了会话的派生历史,也会让两个 adapter 对表层做出不同定义。adapter 改为上报它需要的数量。 + +**在 agent loop 内部规划并推进。** 循环在派生每个请求之前就知道精确的已准备路由,在那里规划永远不会多花一次失败的尝试。但这会把一条路由专属的策略放进所有 profile 共用的那个组件,改变已记录的 step 顺序,还绕过了上下文溢出 compaction 和重试已经在用的同一套 `agent/request-error` waterfall。放在现有 `agent/pre-step` 与 `agent/request-error` 扩展点上的插件,只在 step 自己的消息或未知路由把保留集合推过预算时多花一次失败的尝试,循环保持不变。 **为内联回退和精确字节溢出保留临时的额外省略。** 恰好会在不变量所针对的场景发送未记录的投影;失败再推进的路径只多花一次序列化尝试,且让每个已发出请求都可由日志派生。 @@ -50,4 +52,4 @@ offload 位置是持久的会话事实:核心事件 `image/offload` 记录一 ## 测试 -`packages/llm/llm/tests/content.spec.ts` 钉住任意深度的图片遍历、表示字节、投影和水位规划器,包括 129 到 64 MiB 的量子示例。`packages/core/session/tests/image-offload.spec.ts` 钉住追加与 seed 校验、当前表层图片路径、拒绝持久化派生标记、严格推进、任意深度标记、冻结副本、缓存重建和从头重放的一致性。`packages/core/agent-loop/tests/image-offload.spec.ts` 钉住发送前推进、不回退的水位、表层替换后的请求顺序计数、`IMAGE_OFFLOAD_REQUIRED` 的推进并重建路径以及耗尽的情况。compaction 测试钉住直接摘要输入中的水位应用。adapter 测试钉住占位投影、只读取保留图片以及带数量的精确字节失败;`route-pricing.spec.ts` 钉住水位定价;replay adapter 测试钉住 `imageRequestMaxBytes`。`image-offload` ACP 快照与 `inline-image-prompt` TypeScript SDK 快照通过发布的 profile,在带 base64 图片请求上限的 replay 路由下重放六帧会话。Python SDK 通知测试钉住新事件及其嵌套水位的无损转发。 +`packages/llm/llm/tests/content.spec.ts` 钉住任意深度的图片遍历、表示字节、投影和前缀计数,包括 129 到 64 MiB 的量子示例。`packages/core/session/tests/image-offload.spec.ts` 钉住追加与 seed 校验、当前表层图片路径、拒绝持久化派生标记、严格推进、任意深度标记、冻结副本、缓存重建和从头重放的一致性。`packages/llm/llm-image-offload/tests/image-offload.spec.ts` 钉住 step 前推进、不回退的水位、表层替换后的请求顺序计数、`IMAGE_OFFLOAD_REQUIRED` 的推进并重试路径以及向下游委托的耗尽情况。compaction 测试钉住直接摘要输入中的水位应用。adapter 测试钉住占位投影、只读取保留图片以及带数量的精确字节失败;`route-pricing.spec.ts` 钉住水位定价。`inline-image-prompt` TypeScript SDK 快照通过发布的 profile 重放一次手工编写的 `IMAGE_OFFLOAD_REQUIRED` 尝试,钉住推进和重试后的请求。Python SDK 通知测试钉住新事件及其嵌套水位的无损转发。 diff --git a/apps/cli/composition.md b/apps/cli/composition.md index 406edb4557..2f838adc76 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -42,6 +42,8 @@ flowchart LR cfg --> plugin_dsh_base_jobs plugin_dsh_base_llm_retry["llm-retry
@deepseek-ai/dsh-llm-retry"] cfg --> plugin_dsh_base_llm_retry + plugin_dsh_base_llm_image_offload["llm-image-offload
@deepseek-ai/dsh-llm-image-offload"] + cfg --> plugin_dsh_base_llm_image_offload plugin_dsh_base_settings["settings
@deepseek-ai/dsh-settings-file"] cfg --> plugin_dsh_base_settings plugin_dsh_base_credentials["credentials
@deepseek-ai/dsh-credentials-local"] @@ -197,6 +199,7 @@ flowchart LR | `agent-default-model` | `@deepseek-ai/dsh-agent-default-model` | | `jobs` | `@deepseek-ai/dsh-jobs-local` | | `llm-retry` | `@deepseek-ai/dsh-llm-retry` | +| `llm-image-offload` | `@deepseek-ai/dsh-llm-image-offload` | | `settings` | `@deepseek-ai/dsh-settings-file` | | `credentials` | `@deepseek-ai/dsh-credentials-local` | | `llm-pi-ai` | `@deepseek-ai/dsh-llm-pi-ai` | diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 252d376aae..12a7a1d617 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: 3da1757596649d294485fab3ece176668df501e1 -architecture.zh.md: b6a876453124124708b53c7d67c4d1581b355413 +architecture.md: fcb9c1e59b60dc059ab66b64ac26acd3c9157c96 +architecture.zh.md: d7a0a3833ebf9397837967065249d7fe1650d707 diff --git a/docs/architecture.md b/docs/architecture.md index 3da1757596..fcb9c1e59b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -83,9 +83,8 @@ turn/start reject, or a first enter rewritten empty -> close the turn with no step step/start append entered messages as user/message - agent/request -> request/header? -> image/offload? derive model history from the log - llm/stream -> agent/assistant-stream start + agent/request -> llm/stream -> agent/assistant-stream start agent/assistant-stream chunk* assistant/message | assistant/attempt -> agent/assistant-stream end tool/call* -> tools/pre-execute -> tools/execute -> tools/post-execute -> tool/result* @@ -95,14 +94,12 @@ turn/start turn/end ``` -`turn/*`, `step/*`, `user/message`, `assistant/message`, `assistant/attempt`, `tool/*`, `request/header`, `request/context`, and `image/offload` are durable session events; the rest are live extension points across three domains. `agent/assistant-stream` publishes process-local start, transient chunk, and end frames. The loop commits the complete compact stream as one message or log-only attempt before a committed end frame, and the Web Session-follow adapter is the live event's only remote consumer. `agent/pre-step`, `agent/request`, `llm/stream`, and the three `tools/*` events are waterfalls, whose listeners must call `next()` to delegate; `agent/turn-stopping` is serial and has no `next()`. +`turn/*`, `step/*`, `user/message`, `assistant/message`, `assistant/attempt`, and `tool/*` are durable session events; the rest are live extension points across three domains. `agent/assistant-stream` publishes process-local start, transient chunk, and end frames. The loop commits the complete compact stream as one message or log-only attempt before a committed end frame, and the Web Session-follow adapter is the live event's only remote consumer. `agent/pre-step`, `agent/request`, `llm/stream`, and the three `tools/*` events are waterfalls, whose listeners must call `next()` to delegate; `agent/turn-stopping` is serial and has no `next()`. Input reaches the driver through one inbox. Some messages wake it immediately; injected context waits in the inbox until another message does. `agent/pre-step` decides what the model sees. Listeners may rewrite the claimed messages or reject them outright; a rejected or empty first claim still closes a durable turn that spent no step, so the log records the attempt. An enter decision may also set `startsRequestSeries` to begin a distinct model-message series: the loop then logs a fresh `request/header` (reason `series`, or `change` carrying `startsSeries: true` when the envelope changed too). A listener that rebuilds a downstream enter decision must spread it (`{ ...decision, messages }`) so the declaration survives. Each step reads the prompt sections and tool schemas that plugins registered. -`image/offload` records the durable image offload watermark. After `request/header`, when the prepared route declares a request-image budget that the retained image occurrences exceed, the loop appends the advance and only then derives the request, so the history the model receives is exactly the log's projection. An adapter whose exact byte accounting still overflows fails the attempt with `IMAGE_OFFLOAD_REQUIRED` naming the additional oldest occurrences; the loop advances by that count and rebuilds the request before any `agent/request-error` listener runs. The watermark never retreats ([decision](../.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.md)). - The loop sends immutable requests while keeping cancellation live. It reuses message-freeze provenance only for identities it has fully frozen; [agent-loop](../packages/core/agent-loop/README.md) owns the request construction rules. Details: the [sequence diagram](agent-lifecycle.md), the [tool pipeline](tool-execution-pipeline.md), and [cancellation and error recovery](subsystems/core.md#the-agent-handle). diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index b6a8764531..d7a0a3833e 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -87,9 +87,8 @@ turn/start reject, or a first enter rewritten empty -> close the turn with no step step/start append entered messages as user/message - agent/request -> request/header? -> image/offload? derive model history from the log - llm/stream -> agent/assistant-stream start + agent/request -> llm/stream -> agent/assistant-stream start agent/assistant-stream chunk* assistant/message | assistant/attempt -> agent/assistant-stream end tool/call* -> tools/pre-execute -> tools/execute -> tools/post-execute -> tool/result* @@ -99,14 +98,12 @@ turn/start turn/end ``` -`turn/*`、`step/*`、`user/message`、`assistant/message`、`assistant/attempt`、`tool/*`、`request/header`、`request/context` 和 `image/offload` 是持久会话事件;其余是分属三个事件域的实时扩展点。`agent/assistant-stream` 发布进程本地 start、瞬态 chunk 与 end frame。loop 会在 committed end frame 前把完整紧凑 stream 提交为一个 message 或仅日志 attempt;Web Session-follow adapter 是该 live event 唯一的远程消费方。`agent/pre-step`、`agent/request`、`llm/stream` 和三个 `tools/*` 事件是 waterfall(瀑布式事件),其监听器必须调用 `next()` 才能委托下去;`agent/turn-stopping` 是 serial 事件,没有 `next()`。 +`turn/*`、`step/*`、`user/message`、`assistant/message`、`assistant/attempt` 和 `tool/*` 是持久会话事件;其余是分属三个事件域的实时扩展点。`agent/assistant-stream` 发布进程本地 start、瞬态 chunk 与 end frame。loop 会在 committed end frame 前把完整紧凑 stream 提交为一个 message 或仅日志 attempt;Web Session-follow adapter 是该 live event 唯一的远程消费方。`agent/pre-step`、`agent/request`、`llm/stream` 和三个 `tools/*` 事件是 waterfall(瀑布式事件),其监听器必须调用 `next()` 才能委托下去;`agent/turn-stopping` 是 serial 事件,没有 `next()`。 输入通过同一个 inbox 到达驱动器。有些消息会立即唤醒它;注入的上下文会留在 inbox 中,直到另一条消息将其唤醒。 `agent/pre-step` 决定模型看到什么。监听器可以改写已领取的消息,也可以直接拒绝它们;首次领取被拒绝或被改写为空时,仍会关闭一个不含步骤的持久轮次,因此日志会记录这次尝试。enter 决策还可以设置 `startsRequestSeries` 来开启独立的模型消息序列:loop 会随之记录一个新的 `request/header`(原因为 `series`,或在封装同时变化时为携带 `startsSeries: true` 的 `change`)。重建下游 enter 决策的监听器必须展开它(`{ ...decision, messages }`),该声明才能存活。每个步骤读取插件注册的提示词片段和工具 schema。 -`image/offload` 记录持久的图片 offload 水位。`request/header` 之后,若已准备的路由声明了请求图片预算且保留的图片出现位置超过该预算,循环先追加推进再派生请求,因此模型收到的历史正是日志的投影。若 adapter 按精确字节计量后仍超预算,会以 `IMAGE_OFFLOAD_REQUIRED` 让本次尝试失败并说明还需省略多少最老的出现位置;循环按该数量推进并在任何 `agent/request-error` 监听器运行之前重建请求。水位永不回退([决定](../.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.zh.md))。 - 循环发送不可变请求,同时保留实时取消能力。只有已由该循环完整冻结的消息对象身份才能复用冻结证明;[agent-loop](../packages/core/agent-loop/README.zh.md)拥有请求构造规则。 详情见[时序图](agent-lifecycle.zh.md)、[工具流水线](tool-execution-pipeline.zh.md)和[取消与错误恢复](subsystems/core.zh.md#the-agent-handle)。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 52834c575b..c2e4a176c5 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 317e09ba8393545d46111c35ab502dccf3bb3efc -config-catalog.zh.md: 70ea00906cbd8ec0cea091d765ff0a0aad914cff +config-catalog.md: d31ccd412ec108b54de5162590ed0951e8d36739 +config-catalog.zh.md: 649a510b7feedf0708c109d9ed838745306528e5 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 317e09ba83..d31ccd412e 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1031,6 +1031,19 @@ Depends on: [`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicy Source: [`packages/llm/llm-deepseek/src/index.ts:125`](../packages/llm/llm-deepseek/src/index.ts) + + +## `@deepseek-ai/dsh-llm-image-offload` + +Requires: `llm` · `agents` + +```ts config-catalog +/** The plugin has no configuration; image-capable routes declare their budgets. */ +export type Config = Readonly> +``` + +Source: [`packages/llm/llm-image-offload/src/index.ts:25`](../packages/llm/llm-image-offload/src/index.ts) + ## `@deepseek-ai/dsh-llm-pi-ai` @@ -1369,13 +1382,6 @@ export interface ReplayModelConfig { * no image pricing. */ imageRequestTokens?: number - /** - * Optional accumulated base64 image-byte bound the replay route declares, - * so the agent loop advances the durable `image/offload` watermark in - * keyless scenarios exactly as a live image-capable route would. Requires - * {@link inputModalities} to include `image`. Absent declares no budget. - */ - imageRequestMaxBytes?: number /** Optional reasoning-effort ids the replay route accepts, in display order. */ reasoningEfforts?: string[] /** @@ -1388,7 +1394,7 @@ export interface ReplayModelConfig { Depends on: [`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) -Source: [`packages/test-support/llm-replay/src/index.ts:1287`](../packages/test-support/llm-replay/src/index.ts) +Source: [`packages/test-support/llm-replay/src/index.ts:1277`](../packages/test-support/llm-replay/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 70ea00906c..649a510b7f 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1033,6 +1033,19 @@ export interface DeepSeekCatalogModel { 来源:[`packages/llm/llm-deepseek/src/index.ts:125`](../packages/llm/llm-deepseek/src/index.ts) + + +## `@deepseek-ai/dsh-llm-image-offload` + +需要:`llm` · `agents` + +```ts config-catalog +/** The plugin has no configuration; image-capable routes declare their budgets. */ +export type Config = Readonly> +``` + +来源:[`packages/llm/llm-image-offload/src/index.ts:25`](../packages/llm/llm-image-offload/src/index.ts) + ## `@deepseek-ai/dsh-llm-pi-ai` @@ -1371,13 +1384,6 @@ export interface ReplayModelConfig { * no image pricing. */ imageRequestTokens?: number - /** - * Optional accumulated base64 image-byte bound the replay route declares, - * so the agent loop advances the durable `image/offload` watermark in - * keyless scenarios exactly as a live image-capable route would. Requires - * {@link inputModalities} to include `image`. Absent declares no budget. - */ - imageRequestMaxBytes?: number /** Optional reasoning-effort ids the replay route accepts, in display order. */ reasoningEfforts?: string[] /** @@ -1390,7 +1396,7 @@ export interface ReplayModelConfig { 依赖:[`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) -来源:[`packages/test-support/llm-replay/src/index.ts:1287`](../packages/test-support/llm-replay/src/index.ts) +来源:[`packages/test-support/llm-replay/src/index.ts:1277`](../packages/test-support/llm-replay/src/index.ts) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 8639f1189d..36ac229284 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/event-producer-consumer.md -event-producer-consumer.md: 117644fcdce3d83bb5d360d006138c0ec89342e1 -event-producer-consumer.zh.md: 3182e2526a66f0996a331dfc8e2506a60e10e43e +event-producer-consumer.md: bba59e8716a77e12957d0e509d98c775e798f95a +event-producer-consumer.zh.md: 8f08cc98d05d2a96b81e49b6fd67b308c0bfc5dc diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 117644fcdc..bba59e8716 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -16,9 +16,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:242`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | | `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:250`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | | `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:231`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | -| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:276`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:276`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`llm-image-offload`](../packages/llm/llm-image-offload), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) | | `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:289`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:305`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:305`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-image-offload`](../packages/llm/llm-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `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) | @@ -45,7 +45,7 @@ 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:72`](../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:52`](../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:62`](../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) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 3182e2526a..8f08cc98d0 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -18,9 +18,9 @@ | `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:242`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | | `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:250`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | | `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:231`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | -| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:276`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:276`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`llm-image-offload`](../packages/llm/llm-image-offload), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) | | `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:289`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:305`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:305`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-image-offload`](../packages/llm/llm-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `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) | @@ -47,7 +47,7 @@ | `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:72`](../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:52`](../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:62`](../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) | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 937312106a..1e8f4bbe0a 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: f39edaf985ca8f5882384625246be2faf295ad11 -module-graph.zh.md: 27d40a0d43a559465d989f743e0b8d28b4b11909 +module-graph.md: fb2153f925c497fdd01872c45a1923b166770d74 +module-graph.zh.md: 24cf1a9a5fb7c34c2c8803513ac559e0ad95fd93 diff --git a/docs/module-graph.md b/docs/module-graph.md index f39edaf985..fb2153f925 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -27,6 +27,7 @@ flowchart TD pkg_deepseek_llm_api_extensions["deepseek-llm-api-extensions"] pkg_llm["llm"] pkg_llm_deepseek["llm-deepseek"] + pkg_llm_image_offload["llm-image-offload"] pkg_llm_pi_ai["llm-pi-ai"] pkg_llm_retry["llm-retry"] pkg_plugin_package_inventory_deepseek["plugin-package-inventory-deepseek"] @@ -504,6 +505,9 @@ flowchart TD pkg_llm_deepseek --> pkg_llm pkg_llm_deepseek --> pkg_settings pkg_llm_deepseek --> pkg_timeout + pkg_llm_image_offload --> pkg_agent + pkg_llm_image_offload --> pkg_llm + pkg_llm_image_offload --> pkg_session pkg_llm_pi_ai --> pkg_attachment pkg_llm_pi_ai --> pkg_authorization pkg_llm_pi_ai --> pkg_credentials @@ -1299,6 +1303,7 @@ flowchart TD | [`shell`](../packages/shell/shell) | `shell` | [`sandbox`](../packages/sandbox/sandbox), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess) | | [`workspace`](../packages/workspace/workspace) | `workspace` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage`](../packages/storage/storage), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) | | [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`atomic-write`](../packages/util/atomic-write), [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | +| [`llm-image-offload`](../packages/llm/llm-image-offload) | `llm` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`authorization`](../packages/credentials/authorization), [`credentials`](../packages/credentials/credentials), [`fs`](../packages/fs/fs), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`timeout`](../packages/util/timeout) | | [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 27d40a0d43..24cf1a9a5f 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -29,6 +29,7 @@ flowchart TD pkg_deepseek_llm_api_extensions["deepseek-llm-api-extensions"] pkg_llm["llm"] pkg_llm_deepseek["llm-deepseek"] + pkg_llm_image_offload["llm-image-offload"] pkg_llm_pi_ai["llm-pi-ai"] pkg_llm_retry["llm-retry"] pkg_plugin_package_inventory_deepseek["plugin-package-inventory-deepseek"] @@ -506,6 +507,9 @@ flowchart TD pkg_llm_deepseek --> pkg_llm pkg_llm_deepseek --> pkg_settings pkg_llm_deepseek --> pkg_timeout + pkg_llm_image_offload --> pkg_agent + pkg_llm_image_offload --> pkg_llm + pkg_llm_image_offload --> pkg_session pkg_llm_pi_ai --> pkg_attachment pkg_llm_pi_ai --> pkg_authorization pkg_llm_pi_ai --> pkg_credentials @@ -1301,6 +1305,7 @@ flowchart TD | [`shell`](../packages/shell/shell) | `shell` | [`sandbox`](../packages/sandbox/sandbox), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess) | | [`workspace`](../packages/workspace/workspace) | `workspace` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage`](../packages/storage/storage), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) | | [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`atomic-write`](../packages/util/atomic-write), [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | +| [`llm-image-offload`](../packages/llm/llm-image-offload) | `llm` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`authorization`](../packages/credentials/authorization), [`credentials`](../packages/credentials/credentials), [`fs`](../packages/fs/fs), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`timeout`](../packages/util/timeout) | | [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings) | diff --git a/docs/subsystems/llm-streaming.i18n.yaml b/docs/subsystems/llm-streaming.i18n.yaml index 1c73eb910d..eb8ec388bb 100644 --- a/docs/subsystems/llm-streaming.i18n.yaml +++ b/docs/subsystems/llm-streaming.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/llm-streaming.md -llm-streaming.md: 370bec72da353d7d26c6459978e126e1ebbde480 -llm-streaming.zh.md: 16242bc61551042a2263f23d40235656ce1ba767 +llm-streaming.md: b0414499e9cfc2fb98bcceed1142aafdb0c59dbf +llm-streaming.zh.md: 6c509e18a8095c8b420e94c4a0410523dd958486 diff --git a/docs/subsystems/llm-streaming.md b/docs/subsystems/llm-streaming.md index 370bec72da..b0414499e9 100644 --- a/docs/subsystems/llm-streaming.md +++ b/docs/subsystems/llm-streaming.md @@ -247,8 +247,8 @@ interface LlmFailure { /** * With code `IMAGE_OFFLOAD_REQUIRED`: how many more of the oldest retained * image occurrences the route needs offloaded before the same request fits - * its exact byte accounting. The agent loop advances the durable watermark - * by this count and rebuilds the request. + * its exact byte accounting. The `dsh-llm-image-offload` plugin advances the + * durable watermark by this count and retries the step. */ readonly offloadImages?: number } @@ -760,8 +760,6 @@ interface PreparedLlmCall { readonly context?: LlmModelContext /** Exact model modalities captured with the adapter dispatch generation. */ readonly inputModalities?: readonly ModelModality[] - /** Detached request-image budget the route enforces, when it declares one. */ - readonly imageRequest?: LlmImageRequestBudget /** Config fields materialized by the captured adapter rather than proposed by the caller. */ readonly adapterDefaults: LlmCallConfigAdapterDefaults /** diff --git a/docs/subsystems/llm-streaming.zh.md b/docs/subsystems/llm-streaming.zh.md index 16242bc615..6c509e18a8 100644 --- a/docs/subsystems/llm-streaming.zh.md +++ b/docs/subsystems/llm-streaming.zh.md @@ -249,8 +249,8 @@ interface LlmFailure { /** * With code `IMAGE_OFFLOAD_REQUIRED`: how many more of the oldest retained * image occurrences the route needs offloaded before the same request fits - * its exact byte accounting. The agent loop advances the durable watermark - * by this count and rebuilds the request. + * its exact byte accounting. The `dsh-llm-image-offload` plugin advances the + * durable watermark by this count and retries the step. */ readonly offloadImages?: number } @@ -766,8 +766,6 @@ interface PreparedLlmCall { readonly context?: LlmModelContext /** Exact model modalities captured with the adapter dispatch generation. */ readonly inputModalities?: readonly ModelModality[] - /** Detached request-image budget the route enforces, when it declares one. */ - readonly imageRequest?: LlmImageRequestBudget /** Config fields materialized by the captured adapter rather than proposed by the caller. */ readonly adapterDefaults: LlmCallConfigAdapterDefaults /** diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index 20aa12ff71..85d0e1a9a0 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session.md -session.md: 61ebf5506ecb3fc4feafc0c6e62f1c454a382eba -session.zh.md: 42dd44a1a350898e82a012ba2312663dc1eea05c +session.md: acebb773edfdef905d9d1488d8d19cf2773b39bf +session.zh.md: b8de950392e8643618de16b41a4f850ee47e2501 diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index 61ebf5506e..acebb773ed 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -200,7 +200,7 @@ interface RequestContext { ### The image offload event: `image/offload` -The agent loop appends `image/offload` inside a step, after `request/header` and before deriving the request, when the prepared route's request-image budget is exceeded by the surface's retained image occurrences, or when an adapter fails an attempt with `IMAGE_OFFLOAD_REQUIRED`. Its `watermark` names the last offloaded occurrence; `deriveMessages()` and the Session instance's `deriveEventMessage()` mark every occurrence positioned at or before it `offloaded: true`, and each route renders those marks as placeholder text. `Session.append` and seeding require the watermark to identify an image on the current surface, reject malformed and non-advancing positions, and reject request-only `offloaded` markers in durable messages. `session.imageOffloadWatermark()` folds the latest frozen position. Like `request/header`, it is not a `SurfaceEventType`; unlike it, it changes the derived surface, so it stays required-on-read ([decision](../../.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.md)). +The `dsh-llm-image-offload` plugin appends `image/offload` inside an open turn: before a step enters, when the surface's retained image occurrences exceed the request-image budget of the route the latest `request/header` names, and on the `agent/request-error` waterfall when an adapter fails an attempt with `IMAGE_OFFLOAD_REQUIRED`, before the step retries. Its `watermark` names the last offloaded occurrence; `deriveMessages()` and the Session instance's `deriveEventMessage()` mark every occurrence positioned at or before it `offloaded: true`, and each route renders those marks as placeholder text. `Session.append` and seeding require the watermark to identify an image on the current surface, reject malformed and non-advancing positions, and reject request-only `offloaded` markers in durable messages. `session.imageOffloadWatermark()` folds the latest frozen position. Like `request/header`, it is not a `SurfaceEventType`; unlike it, it changes the derived surface, so it stays required-on-read ([decision](../../.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.md)). ```ts type-equiv /** diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index 42dd44a1a3..b8de950392 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -200,7 +200,7 @@ interface RequestContext { ### 图片 offload 事件:`image/offload` -当已准备路由的请求图片预算被表层上保留的图片出现位置超过,或 adapter 以 `IMAGE_OFFLOAD_REQUIRED` 让一次尝试失败时,agent loop 会在 step 内、`request/header` 之后、派生请求之前追加 `image/offload`。其 `watermark` 指向最后一个被省略的出现位置;`deriveMessages()` 与 Session 实例的 `deriveEventMessage()` 把位于它及之前的每个出现位置标为 `offloaded: true`,每条路由把这些标记渲染为占位文本。`Session.append` 与 seed 要求水位指向当前表层的图片,拒绝畸形和没有严格前进的位置,并拒绝持久消息中的请求专用 `offloaded` 标记。`session.imageOffloadWatermark()` 折叠最新的冻结位置。它和 `request/header` 一样不是 `SurfaceEventType`;与之不同的是它改变派生表层,因此读取时必须识别([决定](../../.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.zh.md))。 +`dsh-llm-image-offload` 插件在打开的轮次内追加 `image/offload`:step 进入前,表层上保留的图片出现位置超过最新 `request/header` 所指路由的请求图片预算时追加一次;adapter 以 `IMAGE_OFFLOAD_REQUIRED` 让一次尝试失败时,在 `agent/request-error` waterfall 上、step 重试前再追加一次。其 `watermark` 指向最后一个被省略的出现位置;`deriveMessages()` 与 Session 实例的 `deriveEventMessage()` 把位于它及之前的每个出现位置标为 `offloaded: true`,每条路由把这些标记渲染为占位文本。`Session.append` 与 seed 要求水位指向当前表层的图片,拒绝畸形和没有严格前进的位置,并拒绝持久消息中的请求专用 `offloaded` 标记。`session.imageOffloadWatermark()` 折叠最新的冻结位置。它和 `request/header` 一样不是 `SurfaceEventType`;与之不同的是它改变派生表层,因此读取时必须识别([决定](../../.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.zh.md))。 ```ts type-equiv /** diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index 5c50261d0d..e7a8b9b889 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -84,6 +84,9 @@ - id: llm-retry name: '@deepseek-ai/dsh-llm-retry' + - id: llm-image-offload + name: '@deepseek-ai/dsh-llm-image-offload' + # User-settings document (`$DSH_HOME/settings.yaml`, hot-reloaded): a # `llm-deepseek:` or `llm-pi-ai:` section there overrides the adapter entries # below without a restart, and is what the web Models page writes. diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index 61131898c4..736450a62d 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -60,6 +60,7 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-llm-pi-ai": "workspace:^", + "@deepseek-ai/dsh-llm-image-offload": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-permission-presets": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", diff --git a/packages/bundle/sdk-minimal/cordis.patch.yml b/packages/bundle/sdk-minimal/cordis.patch.yml index f19486e7d0..ce07d6e2a6 100644 --- a/packages/bundle/sdk-minimal/cordis.patch.yml +++ b/packages/bundle/sdk-minimal/cordis.patch.yml @@ -104,6 +104,9 @@ - id: llm-retry name: '@deepseek-ai/dsh-llm-retry' + - id: llm-image-offload + name: '@deepseek-ai/dsh-llm-image-offload' + - id: jobs name: '@deepseek-ai/dsh-jobs-local' diff --git a/packages/bundle/sdk-minimal/package.json b/packages/bundle/sdk-minimal/package.json index b0dbc597d2..64db1d1c00 100644 --- a/packages/bundle/sdk-minimal/package.json +++ b/packages/bundle/sdk-minimal/package.json @@ -43,6 +43,7 @@ "@deepseek-ai/dsh-jobs-local": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", + "@deepseek-ai/dsh-llm-image-offload": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-plugin-package-inventory-deepseek": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", diff --git a/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts b/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts index d74cba198b..bb7bb65563 100644 --- a/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts +++ b/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts @@ -48,6 +48,7 @@ describe('dsh-sdk-minimal bundle', () => { ['tools', '@deepseek-ai/dsh-tools'], ['agent', '@deepseek-ai/dsh-agent'], ['llm-retry', '@deepseek-ai/dsh-llm-retry'], + ['llm-image-offload', '@deepseek-ai/dsh-llm-image-offload'], ['jobs', '@deepseek-ai/dsh-jobs-local'], ['invariants', '@deepseek-ai/dsh-invariants'], ['session-invariant', '@deepseek-ai/dsh-session/invariant'], diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml index 572044a0ea..b1cc6893b2 100644 --- a/packages/core/agent-loop/README.i18n.yaml +++ b/packages/core/agent-loop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/agent-loop/README.md -README.md: 5c6bc70af3317e085e6bfb40c27a421179b795ab -README.zh.md: 1bf62a68ee2eb24e9fec5f907f7db9e021b3d352 +README.md: 55966a4b0eed0c1cc3484314e809de79341072de +README.zh.md: 1d27bf3743f54d8fe66a58e75565fc85d94ae1ed diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 5c6bc70af3..55966a4b0e 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -88,7 +88,7 @@ The package is the one concrete implementation of the public `Agent` contract. I ### Request headers and adapter defaults -After `agent/request`, `ctx.llm.prepareCall()` validates adapter-owned fields and resolves reasoning-effort and output-token defaults under the active turn signal. The loop retains that exact adapter through resolution, `request/header` logging, and dispatch. It writes a full header for the first request, a changed envelope, an explicit message-series start, a request after surface replacement, and resume; unchanged steps, retries, and ordinary later turns in the same series inherit the latest header. Before the next waterfall, the loop removes adapter-default fields so the current route resolves them again, while explicit settings persist. An unhandled route still fails with `NO_ADAPTER`. When the prepared route declares a request-image budget (`PreparedLlmCall.imageRequest`) that the surface's retained image occurrences exceed, the loop appends an `image/offload` advance before deriving the request; an adapter whose exact accounting still overflows fails the attempt with `IMAGE_OFFLOAD_REQUIRED`, and the loop advances the watermark by the named count and rebuilds the request before `agent/request-error` runs. +After `agent/request`, `ctx.llm.prepareCall()` validates adapter-owned fields and resolves reasoning-effort and output-token defaults under the active turn signal. The loop retains that exact adapter through resolution, `request/header` logging, and dispatch. It writes a full header for the first request, a changed envelope, an explicit message-series start, a request after surface replacement, and resume; unchanged steps, retries, and ordinary later turns in the same series inherit the latest header. Before the next waterfall, the loop removes adapter-default fields so the current route resolves them again, while explicit settings persist. An unhandled route still fails with `NO_ADAPTER`. The loop deep-freezes each derived message identity on its first request and reuses that proof only within the same agent. Restored messages keep their identity; request construction does not freeze their containing event wrappers. Each request freezes its local canonical header, fresh message array, and envelope while leaving the cancellation signal live. The [request-freeze decision](../../../.agents/notes/implemented/simplification/2026-09-06-agent-request-freeze-provenance.md) explains ownership and measurement. diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md index 1bf62a68ee..1d27bf3743 100644 --- a/packages/core/agent-loop/README.zh.md +++ b/packages/core/agent-loop/README.zh.md @@ -88,7 +88,7 @@ const handle = await ctx.agents.create({ ### 请求 header 与适配器默认值 -`agent/request` 返回后,`ctx.llm.prepareCall()` 会在活跃轮次信号下校验适配器持有的字段,并解析推理强度和输出 token 默认值。循环会在解析、`request/header` 记录与分派期间保留同一个适配器。循环会为首次请求、变化的 envelope、显式消息序列起点、表层替换后的请求及恢复写入完整 header;同一序列内内容未变的步骤、重试与普通后续轮次继承最新 header。下一次 waterfall 前,循环移除适配器默认字段,使当前路由重新解析它们;显式设置则保留。未处理的路由仍以 `NO_ADAPTER` 失败。当已准备的路由声明了请求图片预算(`PreparedLlmCall.imageRequest`)且表层上保留的图片出现位置超过该预算时,循环会在派生请求前追加一条 `image/offload` 推进;adapter 按精确计量仍超预算时以 `IMAGE_OFFLOAD_REQUIRED` 让本次尝试失败,循环按其说明的数量推进水位并在 `agent/request-error` 运行前重建请求。 +`agent/request` 返回后,`ctx.llm.prepareCall()` 会在活跃轮次信号下校验适配器持有的字段,并解析推理强度和输出 token 默认值。循环会在解析、`request/header` 记录与分派期间保留同一个适配器。循环会为首次请求、变化的 envelope、显式消息序列起点、表层替换后的请求及恢复写入完整 header;同一序列内内容未变的步骤、重试与普通后续轮次继承最新 header。下一次 waterfall 前,循环移除适配器默认字段,使当前路由重新解析它们;显式设置则保留。未处理的路由仍以 `NO_ADAPTER` 失败。 循环在每个派生消息对象首次进入请求时执行深冻结,并且仅在同一 agent 内复用该证明。恢复的消息保留对象身份;构造请求不会冻结包含消息的事件包装对象。每个请求都会冻结本地规范化 header、新消息数组和请求封装,同时保留取消信号的可变性。[请求冻结决策](../../../.agents/notes/implemented/simplification/2026-09-06-agent-request-freeze-provenance.zh.md)解释了所有权与测量依据。 diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 2a40ffb18b..634a1a52de 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -16,21 +16,18 @@ import type { RequestErrorAction, } from '@deepseek-ai/dsh-agent' import { Inbox, agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent' -import type { GenerateOptions, LlmCallConfig, LlmImageRequestBudget, Message, PreparedLlmCall, RetainedImageOccurrence } from '@deepseek-ai/dsh-llm' +import type { GenerateOptions, LlmCallConfig, Message, PreparedLlmCall } from '@deepseek-ai/dsh-llm' import { - IMAGE_OFFLOAD_REQUIRED_CODE, LlmError, createAssistantMessage, errorChain, markAgentLoopRequest, - planImageOffload, - visitImageBlocks, } from '@deepseek-ai/dsh-llm' import { deepFreeze } from '@deepseek-ai/dsh-util-values' import type { Scope } from '@deepseek-ai/dsh-scope' import { createScope } from '@deepseek-ai/dsh-scope' -import type { EpochHeader, ImageOccurrencePosition, RequestContext, Session, SessionId, TurnEndReason, UserMessage } from '@deepseek-ai/dsh-session' -import { canonicalHeader, compareImagePositions, headerEquals } from '@deepseek-ai/dsh-session' +import type { EpochHeader, RequestContext, Session, SessionId, TurnEndReason, UserMessage } from '@deepseek-ai/dsh-session' +import { canonicalHeader, headerEquals } from '@deepseek-ai/dsh-session' import { joinContextSections, renderContextSections, renderPrompt } from '@deepseek-ai/dsh-system-prompt' import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-session-projection' @@ -360,6 +357,7 @@ export class ReactLoopAgent implements Agent { step, assembly.tools, system, + this.session.deriveMessages(), startsRequestSeries, surfaceGeneration, signal, @@ -428,22 +426,11 @@ export class ReactLoopAgent implements Agent { } try { const finish = live.finish - if (finish.kind === 'error' - && finish.failure.code === IMAGE_OFFLOAD_REQUIRED_CODE - && finish.failure.offloadImages !== undefined) { + if (finish.kind === 'error' || finish.kind === 'aborted') { live.settle( 'assistant/attempt', () => this.session.append('assistant/attempt', { turn, step, stream: live.stream }).seq, ) - if (this.advanceImageOffload(turn, step, finish.failure.offloadImages)) continue - } - if (finish.kind === 'error' || finish.kind === 'aborted') { - if (!live.ended) { - live.settle( - 'assistant/attempt', - () => this.session.append('assistant/attempt', { turn, step, stream: live.stream }).seq, - ) - } const action = await this.dispatch.waterfall( 'agent/request-error', { turn, @@ -506,6 +493,7 @@ export class ReactLoopAgent implements Agent { step: number, tools: GenerateOptions['tools'] & object, system: string, + boundaryMessages: Message[], startsRequestSeries: boolean, surfaceGeneration: number, signal: AbortSignal, @@ -589,23 +577,19 @@ export class ReactLoopAgent implements Agent { || previousContext.contextWindow !== requestContext.contextWindow) { session.append('request/context', requestContext) } - if (preparedCall?.imageRequest !== undefined) { - this.planImageOffload(turn, step, preparedCall.imageRequest) - } signal.throwIfAborted() // canonicalHeader is shallow; append logs a detached snapshot, not these local values. deepFreeze(header) - const messages = session.deriveMessages() - for (const message of messages) { + for (const message of boundaryMessages) { if (this.frozenMessages.has(message)) continue deepFreeze(message) this.frozenMessages.add(message) } - Object.freeze(messages) + Object.freeze(boundaryMessages) const request = markAgentLoopRequest(Object.freeze({ ...header.config, - messages, + messages: boundaryMessages, ...header.system !== undefined ? { system: header.system } : {}, ...header.tools !== undefined ? { tools: header.tools } : {}, sessionId: this.session.id, @@ -613,65 +597,4 @@ export class ReactLoopAgent implements Agent { })) return { request, ...preparedCall === undefined ? {} : { preparedCall } } } - - /** - * Every image occurrence the surface still sends, in request order, with - * the durable position an `image/offload` advance would name. - */ - private retainedImageOccurrences(): RetainedImageOccurrence[] { - const { session } = this - const watermark = session.imageOffloadWatermark() - const retained: RetainedImageOccurrence[] = [] - for (const seq of session.surface.nodes) { - // oxlint-disable-next-line typescript/no-non-null-assertion -- surface nodes index the durable log - const message = session.deriveEventMessage(session.eventAt(seq)!) - if (message === null) continue - visitImageBlocks(message.content, (block, path) => { - const position = { seq, path: [...path] } - if (watermark !== undefined && compareImagePositions(position, watermark) <= 0) return - retained.push({ position, bytes: block.attachment.bytes }) - }) - } - return retained - } - - /** Select the durable watermark that removes at least one request-order prefix. */ - private watermarkForPrefix( - retained: readonly RetainedImageOccurrence[], - count: number, - ): ImageOccurrencePosition | undefined { - let watermark: ImageOccurrencePosition | undefined - for (const occurrence of retained.slice(0, count)) { - if (watermark === undefined || compareImagePositions(occurrence.position, watermark) > 0) { - watermark = occurrence.position - } - } - return watermark - } - - /** - * Advance the durable watermark when the route budget is exceeded by the - * retained occurrences, so the request derived next carries the offloaded - * set the log records. - */ - private planImageOffload(turn: number, step: number, budget: LlmImageRequestBudget): void { - const retained = this.retainedImageOccurrences() - const lastPlanned = planImageOffload(retained, budget) - const prefixCount = retained.findIndex(occurrence => occurrence.position === lastPlanned) + 1 - const watermark = this.watermarkForPrefix(retained, prefixCount) - if (watermark !== undefined) this.session.append('image/offload', { turn, step, watermark }) - } - - /** - * Advance the watermark past the `count` oldest retained occurrences an - * adapter's exact accounting still cannot send. - * @returns whether any occurrence remained to offload, so the step can rebuild the request. - */ - private advanceImageOffload(turn: number, step: number, count: number): boolean { - const retained = this.retainedImageOccurrences() - const watermark = this.watermarkForPrefix(retained, Math.min(count, retained.length)) - if (watermark === undefined) return false - this.session.append('image/offload', { turn, step, watermark }) - return true - } } diff --git a/packages/core/agent-loop/tests/image-offload.spec.ts b/packages/core/agent-loop/tests/image-offload.spec.ts deleted file mode 100644 index 035074f72c..0000000000 --- a/packages/core/agent-loop/tests/image-offload.spec.ts +++ /dev/null @@ -1,192 +0,0 @@ -/** - * Durable image offload in the loop: the route budget advances the - * `image/offload` watermark before dispatch, the watermark never retreats, - * and an adapter's `IMAGE_OFFLOAD_REQUIRED` failure advances it by the named - * count and rebuilds the request. - */ - -import { describe, expect, it } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import AgentRegistry from '@deepseek-ai/dsh-agent' -import AgentLoop from '@deepseek-ai/dsh-agent-loop' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' -import LlmRuntime, { createAssistantMessage, createUserMessage, IMAGE_OFFLOAD_REQUIRED_CODE, LlmError, ToolCallId } from '@deepseek-ai/dsh-llm' -import type { ContentBlock, GenerateOptions, LlmImageRequestBudget } from '@deepseek-ai/dsh-llm' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' -import type { Session } from '@deepseek-ai/dsh-session' -import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRuntime from '@deepseek-ai/dsh-tools' -import { MockAdapter, textResponse } from './mock-adapter.ts' - -async function harness(adapter: MockAdapter): Promise { - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await ctx.plugin(SessionStore) - await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRuntime) - await ctx.plugin(AgentRegistry) - await ctx.plugin(AgentLoop, { agents: [] }) - ctx.llm.registerAdapter(['mock'], adapter) - return ctx -} - -function image(name: string, bytes: number): Extract { - return { - type: 'image', - attachment: { attachmentId: `sha256:${'a'.repeat(64)}` as never, name, mediaType: 'image/png', bytes, width: 1, height: 1 }, - } -} - -function offloadedNames(options: GenerateOptions): string[] { - const names: string[] = [] - for (const message of options.messages) { - for (const block of message.content) { - if (block.type === 'image' && block.offloaded === true) names.push(block.attachment.name ?? '') - if (block.type === 'tool-result') { - for (const inner of block.content) { - if (inner.type === 'image' && inner.offloaded === true) names.push(inner.attachment.name ?? '') - } - } - } - } - return names -} - -function offloadEvents(session: Session): { turn: number; step: number; seq: number; path: number[] }[] { - return session.snapshotEvents() - .filter(event => event.type === 'image/offload') - .map(event => ({ - turn: event.data.turn, - step: event.data.step, - seq: Number(event.data.watermark.seq), - path: event.data.watermark.path, - })) -} - -const budget: LlmImageRequestBudget = { representation: 'raw', maxBytes: 10, byteQuantum: 4 } - -describe('image/offload in the agent loop', () => { - it('advances the watermark before dispatch and never retreats when the budget has headroom again', async () => { - const adapter = new MockAdapter([textResponse('one'), textResponse('two')], undefined, undefined, budget) - const ctx = await harness(adapter) - const agent = await ctx.agentLoop.create(SessionId('offload-advance'), { provider: 'mock', model: 'mock' }) - - agent.followup(createUserMessage({ - content: [image('a', 4), { type: 'tool-result', toolCallId: ToolCallId('shot'), content: [image('b', 4)] }, image('c', 4)], - source: { kind: 'user' }, - })) - await agent.whenIdle() - - // 12 raw bytes exceed the 10-byte bound by 2, rounded up to one 4-byte quantum: 'a' alone - // removes 4 bytes but the quantum rule crosses into 'b'. - expect(offloadedNames(adapter.requests[0]!)).toEqual(['a', 'b']) - const events = offloadEvents(agent.session) - expect(events).toHaveLength(1) - expect(events[0]).toMatchObject({ turn: 1, step: 1, path: [1, 0] }) - const dispatchOrder = agent.session.snapshotEvents().map(event => event.type) - expect(dispatchOrder.indexOf('image/offload')).toBeGreaterThan(dispatchOrder.indexOf('request/header')) - expect(dispatchOrder.indexOf('image/offload')).toBeLessThan(dispatchOrder.indexOf('assistant/message')) - - // An empty-content assistant node derives no message and carries no occurrence. - agent.session.append('assistant/message', { - turn: 1, - step: 1, - message: createAssistantMessage({ content: [], source: { provider: 'mock', model: 'mock' } }), - stream: [], - }, { surfaceOp: 'append' }) - // The next turn adds no bytes beyond the bound, so the watermark holds, and the first - // request's offloaded set is still what the second request sends. - agent.followup(createUserMessage({ content: [{ type: 'text', text: 'again' }], source: { kind: 'user' } })) - await agent.whenIdle() - expect(offloadedNames(adapter.requests[1]!)).toEqual(['a', 'b']) - expect(offloadEvents(agent.session)).toHaveLength(1) - }) - - it('leaves a route without a declared budget alone', async () => { - const adapter = new MockAdapter([textResponse('ok')]) - const ctx = await harness(adapter) - const agent = await ctx.agentLoop.create(SessionId('offload-none'), { provider: 'mock', model: 'mock' }) - agent.followup(createUserMessage({ content: [image('a', 400)], source: { kind: 'user' } })) - await agent.whenIdle() - expect(offloadedNames(adapter.requests[0]!)).toEqual([]) - expect(offloadEvents(agent.session)).toHaveLength(0) - }) - - it('advances by the count an IMAGE_OFFLOAD_REQUIRED failure names and rebuilds the request', async () => { - const adapter = new MockAdapter([ - () => { - throw new LlmError('inline budget exceeded', IMAGE_OFFLOAD_REQUIRED_CODE, { offloadImages: 2 }) - }, - textResponse('sent'), - ], undefined, undefined, { representation: 'raw', maxBytes: 100 }) - const ctx = await harness(adapter) - const agent = await ctx.agentLoop.create(SessionId('offload-required'), { provider: 'mock', model: 'mock' }) - const recoveries: string[] = [] - ctx.on('agent/request-error', async ({ failure }) => { - recoveries.push(failure.code) - }) - - agent.followup(createUserMessage({ - content: [image('a', 1), image('b', 1), image('c', 1)], source: { kind: 'user' }, - })) - await agent.whenIdle() - - expect(adapter.requests).toHaveLength(2) - expect(offloadedNames(adapter.requests[0]!)).toEqual([]) - expect(offloadedNames(adapter.requests[1]!)).toEqual(['a', 'b']) - expect(offloadEvents(agent.session)).toHaveLength(1) - expect(offloadEvents(agent.session)[0]).toMatchObject({ turn: 1, step: 1, path: [1] }) - expect(recoveries).toEqual([]) - expect(agent.session.snapshotEvents().filter(event => event.type === 'assistant/message')).toHaveLength(1) - }) - - it('interprets an adapter count in request order after a surface replacement', async () => { - const adapter = new MockAdapter([ - () => { - throw new LlmError('inline budget exceeded', IMAGE_OFFLOAD_REQUIRED_CODE, { offloadImages: 2 }) - }, - textResponse('sent'), - ]) - const ctx = await harness(adapter) - const agent = await ctx.agentLoop.create(SessionId('offload-reordered'), { provider: 'mock', model: 'mock' }) - const first = agent.session.append('user/message', createUserMessage({ - content: [image('first', 1)], source: { kind: 'user' }, - }), { surfaceOp: 'append' }) - agent.session.append('user/message', createUserMessage({ - content: [image('second', 1)], source: { kind: 'user' }, - }), { surfaceOp: 'append' }) - agent.session.append('user/message', createUserMessage({ - content: [image('replacement', 1)], source: { kind: 'user' }, - }), { - surfaceOp: { op: 'replace', start: first.seq, end: first.seq }, - sourceEventSeqs: [first.seq], - }) - - agent.followup(createUserMessage({ content: [{ type: 'text', text: 'send' }], source: { kind: 'user' } })) - await agent.whenIdle() - - expect(adapter.requests).toHaveLength(2) - expect(offloadedNames(adapter.requests[1]!)).toEqual(['replacement', 'second']) - expect(offloadEvents(agent.session).at(-1)).toMatchObject({ seq: 2, path: [0] }) - }) - - it('surfaces IMAGE_OFFLOAD_REQUIRED as an ordinary failure once nothing remains to offload', async () => { - const adapter = new MockAdapter([ - () => { - throw new LlmError('still too large', IMAGE_OFFLOAD_REQUIRED_CODE, { offloadImages: 1 }) - }, - ]) - const ctx = await harness(adapter) - const agent = await ctx.agentLoop.create(SessionId('offload-exhausted'), { provider: 'mock', model: 'mock' }) - const recoveries: string[] = [] - ctx.on('agent/request-error', async ({ failure }) => { - recoveries.push(failure.code) - }) - agent.followup(createUserMessage({ content: [{ type: 'text', text: 'no images' }], source: { kind: 'user' } })) - await agent.whenIdle() - expect(recoveries).toEqual([IMAGE_OFFLOAD_REQUIRED_CODE]) - expect(offloadEvents(agent.session)).toHaveLength(0) - expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'error' } } }) - }) -}) diff --git a/packages/core/agent-loop/tests/mock-adapter.ts b/packages/core/agent-loop/tests/mock-adapter.ts index 09b2ab5bc5..7543fd21c1 100644 --- a/packages/core/agent-loop/tests/mock-adapter.ts +++ b/packages/core/agent-loop/tests/mock-adapter.ts @@ -1,4 +1,4 @@ -import type { GenerateOptions, LlmImageRequestBudget, LlmModelReasoningInfo, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm' +import type { GenerateOptions, LlmModelReasoningInfo, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm' import { ToolCallId, LlmAdapter } from '@deepseek-ai/dsh-llm' /** Helpers to write scripted responses tersely. */ @@ -76,7 +76,6 @@ export class MockAdapter extends LlmAdapter { private script: (StreamChunk[] | ((options: GenerateOptions) => StreamChunk[]) | 'hang' | 'hang-slow' | HangAfter)[], private readonly reasoning?: LlmModelReasoningInfo, private readonly defaultMaxTokens?: number, - private readonly imageRequest?: LlmImageRequestBudget, ) { super() } @@ -91,7 +90,6 @@ export class MockAdapter extends LlmAdapter { name: model, ...this.reasoning === undefined ? {} : { reasoning: this.reasoning }, ...this.defaultMaxTokens === undefined ? {} : { defaultMaxTokens: this.defaultMaxTokens }, - ...this.imageRequest === undefined ? {} : { inputModalities: ['text', 'image'], imageRequest: this.imageRequest }, }) } diff --git a/packages/core/session/README.i18n.yaml b/packages/core/session/README.i18n.yaml index 8e73302907..64def90aca 100644 --- a/packages/core/session/README.i18n.yaml +++ b/packages/core/session/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/session/README.md -README.md: 0b1a5fbad92d9fb940f5b677f585ab7f424e3ff6 -README.zh.md: ebb2ea1289cb3a39a0a29216c963940c196a529e +README.md: 75aa851751e48e58829834f08118f5cd5685fcf2 +README.zh.md: 31e6a08024872bd5c1afde7200f1607e96da52ea diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 0b1a5fbad9..75aa851751 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -83,7 +83,7 @@ The package is built on event sourcing: a `Session` is an append-only log of typ `request/header` stores a full canonical snapshot of the non-history request envelope with reason `initial`, `resume`, `change`, or `series`. An explicit message-series start or a surface replacement writes a `series` snapshot when the envelope is unchanged; a simultaneous change uses `startsSeries: true`. Same-series steps, retries, and ordinary later turns inherit the latest snapshot. `adapterDefaults` distinguishes values resolved by the adapter from explicit settings, and `foldRequestHeader()` selects the latest snapshot. This self-contained record supports partial-window rendering and exact reconstruction at the cost of growth per message series; the [reconstructable-requests Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md) owns the detail. -`image/offload` records the durable image offload watermark the agent loop advances before dispatch when a route's request-image budget is exceeded: an `ImageOccurrencePosition` naming the last offloaded occurrence by event seq and its complete nested block path. The derivation marks every occurrence at or before it `offloaded: true`, so each route sends its placeholder text instead of the image. The position only advances; `Session.append` and seeding require it to identify a current surface image and reject request-only `offloaded` markers in durable messages. Because the event changes the derived surface it is required-on-read: a build without the type refuses the log rather than replaying images the model never saw ([decision](../../../.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.md)). +`image/offload` records the durable image offload watermark the `dsh-llm-image-offload` plugin advances when a route's request-image budget is exceeded: an `ImageOccurrencePosition` naming the last offloaded occurrence by event seq and its complete nested block path. The derivation marks every occurrence at or before it `offloaded: true`, so each route sends its placeholder text instead of the image. The position only advances; `Session.append` and seeding require it to identify a current surface image and reject request-only `offloaded` markers in durable messages. Because the event changes the derived surface it is required-on-read: a build without the type refuses the log rather than replaying images the model never saw ([decision](../../../.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.md)). ### Source map diff --git a/packages/core/session/README.zh.md b/packages/core/session/README.zh.md index ebb2ea1289..31e6a08024 100644 --- a/packages/core/session/README.zh.md +++ b/packages/core/session/README.zh.md @@ -83,7 +83,7 @@ session.deriveMessages() // the derived model history `request/header` 存储非历史请求 envelope 的完整规范快照,原因为 `initial`、`resume`、`change` 或 `series`。显式消息序列起点或表层替换会在 envelope 不变时写入 `series` 快照;同时发生变化时使用 `startsSeries: true`。同一序列内的步骤、重试与普通后续轮次继承最新快照。`adapterDefaults` 区分由适配器解析的值与显式设置,`foldRequestHeader()` 选择最新快照。这种自包含记录以每个消息序列增加存储为代价,支持局部窗口渲染与精确重建;细节由[可重建请求 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md)负责。 -`image/offload` 记录 agent loop 在路由请求图片预算被超过时于分派前推进的持久图片 offload 水位:一个以事件序号加完整嵌套块路径指向最后一个被省略出现位置的 `ImageOccurrencePosition`。派生把位于水位及之前的每个出现位置标为 `offloaded: true`,于是每条路由发送其占位文本而不是图片。位置只会推进;`Session.append` 与 seed 要求它指向当前表层图片,并拒绝持久消息中的请求专用 `offloaded` 标记。该事件改变派生表层,因此读取时必须识别:不认识该类型的构建拒绝这份日志,而不是带着模型从未见过的图片重放([决定](../../../.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.zh.md))。 +`image/offload` 记录 `dsh-llm-image-offload` 插件在路由请求图片预算被超过时推进的持久图片 offload 水位:一个以事件序号加完整嵌套块路径指向最后一个被省略出现位置的 `ImageOccurrencePosition`。派生把位于水位及之前的每个出现位置标为 `offloaded: true`,于是每条路由发送其占位文本而不是图片。位置只会推进;`Session.append` 与 seed 要求它指向当前表层图片,并拒绝持久消息中的请求专用 `offloaded` 标记。该事件改变派生表层,因此读取时必须识别:不认识该类型的构建拒绝这份日志,而不是带着模型从未见过的图片重放([决定](../../../.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.zh.md))。 ### 源码地图 diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index b74a1d8f7e..dd04ccad1a 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -4679,7 +4679,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'PreparedLlmCall', - declaration: 'export interface PreparedLlmCall {\n readonly config: LlmCallConfig;\n readonly retryPolicy: ResolvedRetryPolicy;\n readonly context?: LlmModelContext;\n readonly inputModalities?: readonly ModelModality[];\n readonly imageRequest?: LlmImageRequestBudget;\n readonly adapterDefaults: LlmCallConfigAdapterDefaults;\n stream(options: GenerateOptions): AsyncIterable;\n}', + declaration: 'export interface PreparedLlmCall {\n readonly config: LlmCallConfig;\n readonly retryPolicy: ResolvedRetryPolicy;\n readonly context?: LlmModelContext;\n readonly inputModalities?: readonly ModelModality[];\n readonly adapterDefaults: LlmCallConfigAdapterDefaults;\n stream(options: GenerateOptions): AsyncIterable;\n}', }, { name: 'PreparedReferencedMessage', diff --git a/packages/llm/README.i18n.yaml b/packages/llm/README.i18n.yaml index 25e569fd0f..a286ecf235 100644 --- a/packages/llm/README.i18n.yaml +++ b/packages/llm/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/README.md -README.md: 9f7e8f444345bb60677a57679ad559f1e06be83f -README.zh.md: 5030a71a256af0d59dcf8d8e4875c0b9790e7a55 +README.md: 31f6ecc6b84e23169a539627befef030f023d0ef +README.zh.md: 6d1d69441ac425d03f13a83fb655733a69b3a228 diff --git a/packages/llm/README.md b/packages/llm/README.md index 9f7e8f4443..31f6ecc6b8 100644 --- a/packages/llm/README.md +++ b/packages/llm/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -The llm group provides the harness's model-call capability: one provider-neutral service through which any composition streams requests to a model provider, plus adapters, provider-specific request metadata, retry execution, and measurement. The core `llm` package defines the message, content-block, and stream-chunk vocabulary every plugin and the session log use; provider adapters translate a provider's wire format into that vocabulary; DeepSeek request-extension plugins contribute lifecycle-owned metadata outside model input; `llm-retry` re-runs failed requests at durable agent-step boundaries; and `token-meter` measures request and context pressure from the durable log. This page maps the group; each package README owns its per-package contract. +The llm group provides the harness's model-call capability: one provider-neutral service through which any composition streams requests to a model provider, plus adapters, provider-specific request metadata, retry execution, and measurement. The core `llm` package defines the message, content-block, and stream-chunk vocabulary every plugin and the session log use; provider adapters translate a provider's wire format into that vocabulary; DeepSeek request-extension plugins contribute lifecycle-owned metadata outside model input; `llm-retry` re-runs failed requests at durable agent-step boundaries; `llm-image-offload` advances the durable `image/offload` watermark that keeps request images within each route's budget; and `token-meter` measures request and context pressure from the durable log. This page maps the group; each package README owns its per-package contract. ## Table of Contents @@ -30,6 +30,7 @@ The llm group provides the harness's model-call capability: one provider-neutral | [`deepseek-llm-api-extensions/`](deepseek-llm-api-extensions/README.md) | Registers lifecycle-owned top-level fields on official DeepSeek requests | `ctx.deepseekLlmApiExtensions` | | [`plugin-package-inventory-deepseek/`](plugin-package-inventory-deepseek/README.md) | Contributes the active Loader package inventory to official DeepSeek requests | contributes `dsh_plugin_packages` | | [`llm-retry/`](llm-retry/README.md) | Retries failed model requests under each provider's policy at durable agent-step boundaries | listens to `agent/request-error` | +| [`llm-image-offload/`](llm-image-offload/README.md) | Advances the session's durable `image/offload` watermark from route budgets and adapter failures | listens to `agent/pre-step`, `agent/request-error` | | [`token-meter/`](token-meter/README.md) | Measures request and context pressure from the durable session log with a fixed heuristic | `ctx.tokenMeter` | ----- diff --git a/packages/llm/README.zh.md b/packages/llm/README.zh.md index 5030a71a25..6d1d69441a 100644 --- a/packages/llm/README.zh.md +++ b/packages/llm/README.zh.md @@ -9,7 +9,7 @@ kind: "package-group" ## 概述 -llm 组提供 harness 的模型调用能力:一个提供方无关的服务,任何组合都可以通过它向模型提供方发起流式请求,外加适配器、提供方专用请求元数据、重试执行与计量。核心 `llm` 包定义所有插件与会话日志使用的消息、内容块与流式分片词汇;提供方适配器把某个提供方的协议格式翻译为该词汇;DeepSeek 请求扩展插件在模型输入之外贡献具有生命周期归属的元数据;`llm-retry` 在持久 agent 步骤边界上重跑失败的请求;`token-meter` 从持久日志测量请求与上下文压力。本页是组的映射;每个包 README 负责各自的包级约定。 +llm 组提供 harness 的模型调用能力:一个提供方无关的服务,任何组合都可以通过它向模型提供方发起流式请求,外加适配器、提供方专用请求元数据、重试执行与计量。核心 `llm` 包定义所有插件与会话日志使用的消息、内容块与流式分片词汇;提供方适配器把某个提供方的协议格式翻译为该词汇;DeepSeek 请求扩展插件在模型输入之外贡献具有生命周期归属的元数据;`llm-retry` 在持久 agent 步骤边界上重跑失败的请求;`llm-image-offload` 推进持久 `image/offload` 水位,使请求图片不超过各路由预算;`token-meter` 从持久日志测量请求与上下文压力。本页是组的映射;每个包 README 负责各自的包级约定。 ## 目录 @@ -30,6 +30,7 @@ llm 组提供 harness 的模型调用能力:一个提供方无关的服务, | [`deepseek-llm-api-extensions/`](deepseek-llm-api-extensions/README.zh.md) | 在官方 DeepSeek 请求上注册具有生命周期归属的顶层字段 | `ctx.deepseekLlmApiExtensions` | | [`plugin-package-inventory-deepseek/`](plugin-package-inventory-deepseek/README.zh.md) | 为官方 DeepSeek 请求贡献活跃 Loader 包清单 | 贡献 `dsh_plugin_packages` | | [`llm-retry/`](llm-retry/README.zh.md) | 在持久 agent 步骤边界上按各提供方策略重试失败的模型请求 | 监听 `agent/request-error` | +| [`llm-image-offload/`](llm-image-offload/README.zh.md) | 依据路由预算与适配器失败推进会话的持久 `image/offload` 水位 | 监听 `agent/pre-step`、`agent/request-error` | | [`token-meter/`](token-meter/README.zh.md) | 用固定启发式规则从持久会话日志测量请求与上下文压力 | `ctx.tokenMeter` | ----- diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index 6e63647686..516a61c747 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-deepseek/README.md -README.md: da64841f610a9099360eee878e7fff8b35ed29bd -README.zh.md: 6863a991b6d86460e673f39f83980675d07e0a18 +README.md: 25b79b13a0a1597e510d71c68032bc02a01f9451 +README.zh.md: 2f2dcd8444745e93724e56ce94c6337ef0b9d444 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index da64841f61..25b79b13a0 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -58,7 +58,7 @@ A request selects the route with `provider: deepseek-official`; the model id pas | `defaultContextWindow` | `1,000,000` | Capacity fallback for models without an exact value | | `models` | V4 Flash + V4 Pro + V4 Flash Vision Exp | Advisory catalog shown by discovery consumers | | `streamIdleTimeoutMs` | `300,000` | Maximum provider idle time per outstanding stream read | -| `maxRequestFilesBytes` | `128 MiB` | File-mode request-image byte budget declared to the agent loop's `image/offload` watermark | +| `maxRequestFilesBytes` | `128 MiB` | File-mode request-image byte budget declared to the `dsh-llm-image-offload` plugin's `image/offload` watermark planning | | `maxInlineRequestImageBytes` | `20 MiB` | Independent base64 fallback high watermark | | `maxImagesPerRequest` | `600` | High watermark for retained request-image count | | `imageOffloadByteQuantum` | `64 MiB` | Files-mode oldest-prefix removal quantum | @@ -156,7 +156,7 @@ The selected DeepSeek model receives the harness system prompt, message history, #### Token effect -Provider tokenization governs exact text and image-token input. The adapter declares per-route `imageRequestPricing`: it prices each occurrence the session's `image/offload` watermark marks offloaded as its placeholder text and each retained image at its projected dimensions with the published v4 vision accounting (14px patch grid, 3:1 downsampling, 384-token cap, worst-case alignment pad). This lets the token meter price image pressure before a request; reported usage remains authoritative. Reasoning passback carries every reasoned turn's chain of thought into later requests, while offloaded images stop costing visual tokens. Each image-capable catalog route also declares its file-mode budget (`maxRequestFilesBytes`, `maxImagesPerRequest`, both quanta, and the request-version byte target) as `imageRequest` on its resolved model info, so the agent loop advances the watermark from logged facts; a request whose retained occurrences still exceed the file-mode or inline-fallback budget at their exact request-version bytes fails with `IMAGE_OFFLOAD_REQUIRED` naming the additional oldest occurrences to offload. Cache-read usage is reported when available. `totalTokens` is the exact `prompt_tokens + completion_tokens` aggregate and is omitted if a supplied `total_tokens` disagrees. +Provider tokenization governs exact text and image-token input. The adapter declares per-route `imageRequestPricing`: it prices each occurrence the session's `image/offload` watermark marks offloaded as its placeholder text and each retained image at its projected dimensions with the published v4 vision accounting (14px patch grid, 3:1 downsampling, 384-token cap, worst-case alignment pad). This lets the token meter price image pressure before a request; reported usage remains authoritative. Reasoning passback carries every reasoned turn's chain of thought into later requests, while offloaded images stop costing visual tokens. Each image-capable catalog route also declares its file-mode budget (`maxRequestFilesBytes`, `maxImagesPerRequest`, both quanta, and the request-version byte target) as `imageRequest` on its resolved model info, so the `dsh-llm-image-offload` plugin advances the watermark from logged facts; a request whose retained occurrences still exceed the file-mode or inline-fallback budget at their exact request-version bytes fails with `IMAGE_OFFLOAD_REQUIRED` naming the additional oldest occurrences to offload. Cache-read usage is reported when available. `totalTokens` is the exact `prompt_tokens + completion_tokens` aggregate and is omitted if a supplied `total_tokens` disagrees. #### KV Cache effect diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index 6863a991b6..2f2dcd8444 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -58,7 +58,7 @@ kind: "package-reference" | `defaultContextWindow` | `1,000,000` | 无精确值模型的容量回退 | | `models` | V4 Flash + V4 Pro + V4 Flash Vision Exp | 供发现消费方查看的建议性目录 | | `streamIdleTimeoutMs` | `300,000` | 单次流读取未完成的最大提供方空闲时间 | -| `maxRequestFilesBytes` | `128 MiB` | 向 agent loop 的 `image/offload` 水位声明的 file 模式请求图片字节预算 | +| `maxRequestFilesBytes` | `128 MiB` | 向 `dsh-llm-image-offload` 插件的 `image/offload` 水位规划声明的 file 模式请求图片字节预算 | | `maxInlineRequestImageBytes` | `20 MiB` | 独立的 base64 回退高水位 | | `maxImagesPerRequest` | `600` | 保留请求图片数量的高水位 | | `imageOffloadByteQuantum` | `64 MiB` | Files 模式最旧前缀移除量子 | @@ -156,7 +156,7 @@ Files 模式通过 `maxRequestFilesBytes` 与 `maxImagesPerRequest` 限制保留 #### Token 影响 -提供方分词决定精确的文本与图片 token 输入。适配器声明按路由的 `imageRequestPricing`:把会话 `image/offload` 水位标记为已省略的每个出现位置按其占位文本计价,并按投影后的尺寸使用官方公布的 v4 视觉计量(14px patch 网格、3:1 降采样、单图 384 token 上限、最坏对齐 pad)为每张保留图片计价。这使 token 计量服务可以在请求发出前为图片压力定价;上报的 usage 仍是权威值。推理回传会把每个推理轮次的思维链带进后续请求,而已省略的图片不再消耗视觉 token。每条支持图片的目录路由还会把 file 模式预算(`maxRequestFilesBytes`、`maxImagesPerRequest`、两个量子与请求版本字节目标)作为 `imageRequest` 声明在其解析后的模型信息上,供 agent loop 仅凭已记录事实推进水位;保留的出现位置按精确请求版本字节仍超过 file 模式或内联回退预算的请求,以 `IMAGE_OFFLOAD_REQUIRED` 失败并说明还需省略多少最老的出现位置。可用时报告缓存读取用量。`totalTokens` 是精确的 `prompt_tokens + completion_tokens` 汇总值;提供方给出的 `total_tokens` 不一致时省略该值。 +提供方分词决定精确的文本与图片 token 输入。适配器声明按路由的 `imageRequestPricing`:把会话 `image/offload` 水位标记为已省略的每个出现位置按其占位文本计价,并按投影后的尺寸使用官方公布的 v4 视觉计量(14px patch 网格、3:1 降采样、单图 384 token 上限、最坏对齐 pad)为每张保留图片计价。这使 token 计量服务可以在请求发出前为图片压力定价;上报的 usage 仍是权威值。推理回传会把每个推理轮次的思维链带进后续请求,而已省略的图片不再消耗视觉 token。每条支持图片的目录路由还会把 file 模式预算(`maxRequestFilesBytes`、`maxImagesPerRequest`、两个量子与请求版本字节目标)作为 `imageRequest` 声明在其解析后的模型信息上,供 `dsh-llm-image-offload` 插件仅凭已记录事实推进水位;保留的出现位置按精确请求版本字节仍超过 file 模式或内联回退预算的请求,以 `IMAGE_OFFLOAD_REQUIRED` 失败并说明还需省略多少最老的出现位置。可用时报告缓存读取用量。`totalTokens` 是精确的 `prompt_tokens + completion_tokens` 汇总值;提供方给出的 `total_tokens` 不一致时省略该值。 #### KV Cache 影响 diff --git a/packages/llm/llm-deepseek/src/serialize.ts b/packages/llm/llm-deepseek/src/serialize.ts index 597ed8ef03..056d6d0e6e 100644 --- a/packages/llm/llm-deepseek/src/serialize.ts +++ b/packages/llm/llm-deepseek/src/serialize.ts @@ -6,7 +6,7 @@ * @module dsh-llm-deepseek/serialize */ -import { contentHasImage, IMAGE_OFFLOAD_REQUIRED_CODE, LlmError, offloadedImageText, offloadedImagePrefixCount, projectOffloadedImages, representedImageBytes, requestImageHandleText, visitImageBlocks } from '@deepseek-ai/dsh-llm' +import { contentHasImage, IMAGE_OFFLOAD_REQUIRED_CODE, LlmError, offloadedImageText, projectOffloadedImages, requestImageHandleText, requiredImageOffload } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, ImageAttachmentAccessResolver, Message } from '@deepseek-ai/dsh-llm' import type { ImageAttachmentRef, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' import type { @@ -399,22 +399,18 @@ export function serializeRequest( */ function assertRetainedImagesFit(messages: readonly Message[], images: ImageSerializationOptions): void { const representation = images.representation.kind === 'file' ? 'raw' : 'base64' - const lengths: number[] = [] - for (const message of messages) { - visitImageBlocks(message.content, (block) => { - if (block.offloaded === true) return - const version = images.requestImages.get(block.attachment.attachmentId) - if (version === undefined) { - throw new LlmError(`DeepSeek request image ${block.attachment.attachmentId} was not prepared.`, 'INVALID_REQUEST') - } - lengths.push(representedImageBytes(version.bytes, { representation })) - }) - } - const offloadImages = offloadedImagePrefixCount(lengths, { + const offloadImages = requiredImageOffload(messages, { + representation, maxBytes: images.maxRequestImageBytes, ...images.maxImagesPerRequest === undefined ? {} : { maxImages: images.maxImagesPerRequest }, ...images.byteQuantum === undefined ? {} : { byteQuantum: images.byteQuantum }, ...images.countQuantum === undefined ? {} : { countQuantum: images.countQuantum }, + }, (block) => { + const version = images.requestImages.get(block.attachment.attachmentId) + if (version === undefined) { + throw new LlmError(`DeepSeek request image ${block.attachment.attachmentId} was not prepared.`, 'INVALID_REQUEST') + } + return version.bytes }) if (offloadImages > 0) { throw new LlmError( diff --git a/packages/llm/llm-image-offload/README.i18n.yaml b/packages/llm/llm-image-offload/README.i18n.yaml new file mode 100644 index 0000000000..7bd1f8a72c --- /dev/null +++ b/packages/llm/llm-image-offload/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/llm/llm-image-offload/README.md +README.md: 6f00392b9d5e3462845980a23332f9bd64bbf673 +README.zh.md: 24ecdd57a9096072721cb73affb7c8f587619179 diff --git a/packages/llm/llm-image-offload/README.md b/packages/llm/llm-image-offload/README.md new file mode 100644 index 0000000000..6f00392b9d --- /dev/null +++ b/packages/llm/llm-image-offload/README.md @@ -0,0 +1,106 @@ +--- +description: "The image-offload plugin for users and maintainers keeping request images within each route's budget through the session's durable image/offload watermark." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-llm-image-offload + +English | [中文](README.zh.md) + +## Summary + +`@deepseek-ai/dsh-llm-image-offload` keeps request images within each route's budget by advancing the session's durable `image/offload` watermark. Before a step enters, it plans the watermark from the request-image budget the routed model declares and the image occurrences the surface still sends; when an adapter fails an attempt with `IMAGE_OFFLOAD_REQUIRED`, it advances the watermark by the count the failure names and retries the step. The watermark only advances, so the images the model sees and the provider cache prefix move only forward, and every dispatched request's image set is determined by the session log alone. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +Mount this plugin in every composition that runs the agent loop with an image-capable route. Without it, an image-capable adapter whose budget is exceeded fails the step with `IMAGE_OFFLOAD_REQUIRED` and no recovery advances the watermark. The plugin has no configuration: the DeepSeek adapter declares its file-mode budget and the pi-ai adapter its base64 bound as `imageRequest` on their resolved model info. + +### Minimal configuration + +```yaml +- name: '@deepseek-ai/dsh-llm-image-offload' +``` + +### What you can observe + +Each advance is one durable `image/offload` event carrying the turn, the step, and the watermark: the seq of the event carrying the last offloaded occurrence and the block path inside its content. A pre-step advance lands after `turn/start` and before `step/start`; a failure-driven advance lands after the `assistant/attempt` it answers and before the retried request. `session.imageOffloadWatermark()` reads the watermark in force, and every derived image block at or before it carries `offloaded: true`, which each route renders as placeholder text naming the image and its read-only path. + +### Failures and recovery + +The pre-step plan reads the route the latest `request/header` names and covers the surface as it stands before the step's own messages are appended; it skips before the first header, for a route without an adapter, and for a route that declares no budget. Occurrences the plan cannot see, and occurrences whose exact request bytes exceed the budget only at serialization, reach the model through one failed attempt: the adapter fails with `IMAGE_OFFLOAD_REQUIRED` and `offloadImages`, the plugin advances by that count on the `agent/request-error` waterfall and returns the `retry` action. When nothing remains to offload the plugin delegates, so the failure reaches downstream recovery and otherwise ends the turn. + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +The plugin is one function plugin with two listeners. On `agent/pre-step` it awaits the downstream decision, and for an `enter` decision resolves the routed budget through `ctx.llm.resolveModelInfo()`, walks the surface nodes through `Session.deriveEventMessage()` collecting every retained occurrence in request order with its durable position, and plans the removal prefix with the pure `offloadedImagePrefixCount()` over the occurrences' represented bytes. On `agent/request-error` it handles only `IMAGE_OFFLOAD_REQUIRED` failures carrying `offloadImages`. Both paths append one `image/offload` event naming the greatest durable position in the request-order prefix, which a surface replacement can place before older events; the advance may offload additional occurrences but always covers the prefix. Adapters never append the event: the session surface has one owner of its offloaded set. + +No invariant companion is published: `Session.append` and seeding already reject a malformed, non-advancing, or off-surface watermark, so no independent observation can diverge from the event the plugin appends. + +
+ +----- + + +## Further Exploration + +- [Durable image offload watermark](../../../.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.md) — the decision this plugin implements and the alternatives it replaced. +- [dsh-session](../../core/session/README.md) — the `image/offload` event, its validation, and the marked derivation. +- [dsh-llm](../llm/README.md) — `LlmImageRequestBudget`, `IMAGE_OFFLOAD_REQUIRED`, and the shared image walk. +- [llm-deepseek adapter](../llm-deepseek/README.md) — the file-mode budget and inline fallback that report offload counts. +- [llm-pi-ai adapter](../llm-pi-ai/README.md) — the base64 bound that reports offload counts. + +----- + + +## Model Experience + +### Request-image offload + +#### What the model sees + +Every image occurrence at or before the watermark reaches the model as the route's placeholder text (`offloadedImageText`) naming the attachment and its read-only path instead of the image; occurrences after it stay images. The set never shrinks on its own, so the model can rely on an offloaded image staying offloaded and read it back through the path when it needs the content again. + +#### Token effect + +An offloaded occurrence costs its placeholder text instead of visual tokens. The token meter prices the current surface under the current watermark and each usage anchor under the watermark its request was derived with. + +#### KV Cache effect + +An advance replaces earlier images with placeholder text, so provider cache reuse ends at the first replaced message for that request. Because the watermark never retreats, the prefix stays stable afterwards. + +## Known Limitations and Deferred Work + + + +- **Offloaded images never return automatically** — a larger budget, a larger route, or compaction lowering the total leaves the watermark in place; recovery is the read-only path in the placeholder. +- **The pre-step plan is one step behind the entering messages** — images a user message or the step's own tool results add reach the model through one failed attempt when they push the retained set past the budget. +- **A temporary small budget advances permanently** — a Files outage that forces the inline fallback, or a temporary switch to a small-budget route, offloads images that a later route would have sent. + + +### Dev Note + +
+Working context for maintainers — click to expand + +This Dev Note is non-authoritative working context: notes for maintainers and open questions. Shipped behavior and accepted rationale live in the sections above, the package code, and the linked Agent Notes. + +- The pre-step plan walks every surface node on each step of a route with a budget; nothing caches the retained set. Revisit only with profiling evidence from long image-heavy sessions. + +
diff --git a/packages/llm/llm-image-offload/README.zh.md b/packages/llm/llm-image-offload/README.zh.md new file mode 100644 index 0000000000..24ecdd57a9 --- /dev/null +++ b/packages/llm/llm-image-offload/README.zh.md @@ -0,0 +1,106 @@ +--- +description: "面向用户与维护者的图片省略插件说明:通过会话的持久 image/offload 水位,把请求图片控制在各路由预算之内。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-llm-image-offload + +[English](README.md) | 中文 + +## 概述 + +`@deepseek-ai/dsh-llm-image-offload` 通过推进会话的持久 `image/offload` 水位,把请求图片控制在各路由的预算之内。step 进入前,它根据路由模型声明的请求图片预算和表层仍在发送的图片出现位置规划水位;adapter 以 `IMAGE_OFFLOAD_REQUIRED` 让一次尝试失败时,它按失败中给出的数量推进水位并重试该 step。水位只会前进,因此模型看到的图片和 provider 缓存前缀只向前移动,每个已发出请求的图片集合都仅由会话日志决定。 + +## 目录 + +- [使用本包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +凡是运行 agent loop 且带有支持图片路由的组合,都应挂载本插件。没有它,支持图片的 adapter 在预算超出时会以 `IMAGE_OFFLOAD_REQUIRED` 让 step 失败,且没有任何恢复路径会推进水位。本插件没有配置:DeepSeek adapter 在其解析后的模型信息上以 `imageRequest` 声明 file 模式预算,pi-ai adapter 声明其 base64 上限。 + +### 最小配置 + +```yaml +- name: '@deepseek-ai/dsh-llm-image-offload' +``` + +### 你可以观察到什么 + +每次推进都是一个持久的 `image/offload` 事件,携带轮次、step 和水位:承载最后一个被省略出现位置的事件序号,以及它在内容中的块路径。step 前的推进落在 `turn/start` 之后、`step/start` 之前;由失败驱动的推进落在它所回应的 `assistant/attempt` 之后、重试请求之前。`session.imageOffloadWatermark()` 读取当前生效的水位,位于水位及之前的每个派生图片块都带有 `offloaded: true`,每条路由把它渲染为注明图片及其只读路径的占位文本。 + +### 失败与恢复 + +step 前的规划读取最新 `request/header` 所指的路由,覆盖的是 step 自己的消息追加之前的表层;第一个 header 之前、路由没有 adapter、路由未声明预算时都会跳过。规划看不到的出现位置,以及只有在序列化时才发现精确请求字节超出预算的出现位置,会经过一次失败的尝试到达模型:adapter 以 `IMAGE_OFFLOAD_REQUIRED` 和 `offloadImages` 失败,插件在 `agent/request-error` waterfall 上按该数量推进并返回 `retry` 动作。没有可省略的出现位置时插件向下游委托,失败因此进入下游恢复,否则结束该轮次。 + +----- + + +## 理解实现 + +
+实现内部——点击展开 + +本插件是带两个监听器的函数插件。在 `agent/pre-step` 上它先等待下游决定,对 `enter` 决定通过 `ctx.llm.resolveModelInfo()` 解析路由预算,经 `Session.deriveEventMessage()` 遍历表层节点,按请求顺序收集每个保留的出现位置及其持久位置,再用纯函数 `offloadedImagePrefixCount()` 对出现位置的表示字节规划删除前缀。在 `agent/request-error` 上它只处理带 `offloadImages` 的 `IMAGE_OFFLOAD_REQUIRED` 失败。两条路径都追加一个 `image/offload` 事件,指向请求序前缀中最大的持久位置;表层替换可能把该位置放在较老事件之前,推进因此可能额外省略一些出现位置,但一定覆盖该前缀。adapter 从不追加该事件:会话表层的已省略集合只有一个所有者。 + +本包不发布 invariant 伴生插件:`Session.append` 与 seed 已经拒绝畸形、未前进或不在表层上的水位,没有任何独立观察能与插件追加的事件产生分歧。 + +
+ +----- + + +## 进一步探索 + +- [持久图片 offload 水位](../../../.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.zh.md)——本插件实现的决定及其替代的方案。 +- [dsh-session](../../core/session/README.zh.md)——`image/offload` 事件、它的校验与带标记的派生。 +- [dsh-llm](../llm/README.zh.md)——`LlmImageRequestBudget`、`IMAGE_OFFLOAD_REQUIRED` 与共享的图片遍历。 +- [llm-deepseek 适配器](../llm-deepseek/README.zh.md)——上报省略数量的 file 模式预算与内联回退。 +- [llm-pi-ai 适配器](../llm-pi-ai/README.zh.md)——上报省略数量的 base64 上限。 + +----- + + +## 模型体验 + +### 请求图片省略 + +#### 模型看到什么 + +位于水位及之前的每个图片出现位置,以路由的占位文本(`offloadedImageText`)到达模型,文本注明附件及其只读路径而不是图片本身;水位之后的出现位置仍是图片。该集合不会自行缩小,模型可以确信被省略的图片会一直被省略,再需要内容时通过路径读回。 + +#### Token 影响 + +被省略的出现位置只花费占位文本,不再花费视觉 token。token meter 按当前水位为当前表层定价,按每个 usage 锚点的请求派生时的水位为该锚点定价。 + +#### KV Cache 影响 + +一次推进把较早的图片替换为占位文本,该请求的 provider 缓存复用因此止于第一条被替换的消息。水位永不回退,之后的前缀保持稳定。 + +## 已知限制与延期工作 + + + +- **被省略的图片不会自动回归**——更大的预算、更大的路由或 compaction 降低总量都不会移动水位;恢复手段是占位文本中的只读路径。 +- **step 前的规划比进入的消息晚一步**——用户消息或 step 自己的工具结果新增的图片把保留集合推过预算时,会经过一次失败的尝试才到达模型。 +- **临时的小预算会永久推进**——迫使内联回退的 Files 故障,或临时切到小预算路由,会省略后来的路由本可以发送的图片。 + + +### 开发备注 + +
+维护者工作背景——点击展开 + +本开发备注是非权威的工作背景:给维护者的备注和未决问题。已交付的行为与被接受的理由见上文各节、包代码和链接的 Agent Note。 + +- 对声明了预算的路由,step 前的规划每一步都遍历全部表层节点,没有缓存保留集合。只有在图片密集的长会话中拿到 profiling 证据后再考虑优化。 + +
diff --git a/packages/llm/llm-image-offload/package.json b/packages/llm/llm-image-offload/package.json new file mode 100644 index 0000000000..861cfa9bed --- /dev/null +++ b/packages/llm/llm-image-offload/package.json @@ -0,0 +1,46 @@ +{ + "name": "@deepseek-ai/dsh-llm-image-offload", + "description": "Durable request-image offload for the DeepSeek Harness agent loop", + "version": "0.1.3-alpha.1", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/llm/llm-image-offload" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts" + ], + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + }, + "dependencies": { + "@deepseek-ai/schemastery": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + } +} diff --git a/packages/llm/llm-image-offload/src/index.ts b/packages/llm/llm-image-offload/src/index.ts new file mode 100644 index 0000000000..1b2a17ce4d --- /dev/null +++ b/packages/llm/llm-image-offload/src/index.ts @@ -0,0 +1,112 @@ +/** + * Durable request-image offload on the agent loop's step extension points. + * Before a step enters, the plugin plans the session's `image/offload` + * watermark from the request-image budget of the route the latest + * `request/header` names; when an adapter fails an attempt with + * `IMAGE_OFFLOAD_REQUIRED`, it advances the watermark by the named count and + * retries the step, so every dispatched request carries the offloaded set the + * log records. + * + * @module @deepseek-ai/dsh-llm-image-offload + */ + +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' +import type { PreStepDecision, RequestErrorAction } from '@deepseek-ai/dsh-agent' +import { IMAGE_OFFLOAD_REQUIRED_CODE, LlmError, offloadedImagePrefixCount, representedImageBytes, visitImageBlocks } from '@deepseek-ai/dsh-llm' +import type { LlmImageRequestBudget } from '@deepseek-ai/dsh-llm' +import { compareImagePositions } from '@deepseek-ai/dsh-session' +import type { ImageOccurrencePosition, Session } from '@deepseek-ai/dsh-session' + +export const name = 'llm-image-offload' +export const inject = ['llm', 'agents'] + +/** The plugin has no configuration; image-capable routes declare their budgets. */ +export type Config = Readonly> + +/** Runtime schema for {@link Config}. */ +export const Config = z.object({}) as unknown as z + +/** One image occurrence the surface still sends. */ +interface RetainedOccurrence { + /** Durable position an advance names when this occurrence becomes the last offloaded one. */ + readonly position: ImageOccurrencePosition + /** Normalized attachment byte count. */ + readonly bytes: number +} + +/** Every retained image occurrence in model request order. */ +function retainedOccurrences(session: Session): RetainedOccurrence[] { + const watermark = session.imageOffloadWatermark() + const retained: RetainedOccurrence[] = [] + for (const seq of session.surface.nodes) { + // oxlint-disable-next-line typescript/no-non-null-assertion -- surface nodes index the durable log + const message = session.deriveEventMessage(session.eventAt(seq)!) + if (message === null) continue + visitImageBlocks(message.content, (block, path) => { + const position = { seq, path: [...path] } + if (watermark !== undefined && compareImagePositions(position, watermark) <= 0) return + retained.push({ position, bytes: block.attachment.bytes }) + }) + } + return retained +} + +/** + * Advance the watermark past the `count` oldest retained occurrences. A + * surface replacement can place a newer event before older ones in request + * order, so the advance names the greatest durable position in that prefix, + * which may offload additional occurrences but always covers the prefix. + * @returns whether any occurrence remained to offload. + */ +function advance(session: Session, turn: number, step: number, count: number): boolean { + let watermark: ImageOccurrencePosition | undefined + for (const occurrence of retainedOccurrences(session).slice(0, count)) { + if (watermark === undefined || compareImagePositions(occurrence.position, watermark) > 0) { + watermark = occurrence.position + } + } + if (watermark === undefined) return false + session.append('image/offload', { turn, step, watermark }) + return true +} + +/** Request-image budget of the route the latest request header names; undefined before the first header or for a route without one. */ +async function routedBudget(ctx: Context, session: Session, signal: AbortSignal): Promise { + const config = session.requestHeader()?.config + if (config === undefined) return undefined + try { + return (await ctx.llm.resolveModelInfo(config.provider, config.model, signal)).imageRequest + } catch (error: unknown) { + // Middleware may serve a route without an adapter; the loop dispatches it the same way. + if (error instanceof LlmError && error.code === 'NO_ADAPTER') return undefined + throw error + } +} + +/** + * Mount the offload listeners. + * @param ctx - the plugin context. + * @param _config - no configuration. + */ +export function apply(ctx: Context, _config: Config = {}): void { + ctx.on('agent/pre-step', async ({ agent, turn, step, signal }, next): Promise => { + const decision = await next() + if (decision.kind !== 'enter') return decision + const budget = await routedBudget(ctx, agent.session, signal) + if (budget === undefined || signal.aborted) return decision + const retained = retainedOccurrences(agent.session) + const count = offloadedImagePrefixCount( + retained.map(occurrence => representedImageBytes(occurrence.bytes, budget)), + budget, + ) + if (count > 0) advance(agent.session, turn, step, count) + return decision + }) + + ctx.on('agent/request-error', ({ agent, turn, step, failure }, next): Promise => { + if (failure.code !== IMAGE_OFFLOAD_REQUIRED_CODE || failure.offloadImages === undefined) return next() + if (!advance(agent.session, turn, step, failure.offloadImages)) return next() + return Promise.resolve({ kind: 'retry' }) + }) +} diff --git a/packages/llm/llm-image-offload/tests/image-offload.spec.ts b/packages/llm/llm-image-offload/tests/image-offload.spec.ts new file mode 100644 index 0000000000..ae02766d28 --- /dev/null +++ b/packages/llm/llm-image-offload/tests/image-offload.spec.ts @@ -0,0 +1,222 @@ +/** + * Durable image offload through the plugin: the routed budget advances the + * `image/offload` watermark before a step, the watermark never retreats, and + * an adapter's `IMAGE_OFFLOAD_REQUIRED` failure advances it by the named count + * and retries the step. + */ + +import { describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import { createUserMessage, IMAGE_OFFLOAD_REQUIRED_CODE, LlmAdapter, LlmError, ToolCallId } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, GenerateOptions, LlmImageRequestBudget, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import type { Session } from '@deepseek-ai/dsh-session' +import * as offload from '../src/index.ts' + +type ScriptEntry = StreamChunk[] | (() => never) + +/** Replies one scripted stream per request and declares an optional request-image budget. */ +class ScriptedAdapter extends LlmAdapter { + readonly requests: GenerateOptions[] = [] + + constructor(private readonly script: ScriptEntry[], private readonly imageRequest?: LlmImageRequestBudget) { + super() + } + + override resolveModel(provider: string, model: string): Promise { + return Promise.resolve({ + provider, + id: model, + name: model, + ...this.imageRequest === undefined ? {} : { inputModalities: ['text', 'image'], imageRequest: this.imageRequest }, + }) + } + + async * stream(options: GenerateOptions): AsyncIterable { + this.requests.push(options) + const entry = this.script.shift() + if (entry === undefined) throw new Error('script exhausted') + if (typeof entry === 'function') entry() + else yield * entry + } +} + +function textResponse(text: string): StreamChunk[] { + return [ + { type: 'block-start', index: 0, blockType: 'text' }, + { type: 'block-end', index: 0, block: { type: 'text', text } }, + { type: 'finish', reason: { kind: 'stop' } }, + ] +} + +function offloadRequired(offloadImages: number): () => never { + return () => { + throw new LlmError('request images exceed the route budget', IMAGE_OFFLOAD_REQUIRED_CODE, { offloadImages }) + } +} + +async function harness(adapter: ScriptedAdapter): Promise { + const ctx = new Context() + await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(Object.assign((inner: Context) => { offload.apply(inner, {}) }, { inject: offload.inject })) + await ctx.plugin(AgentLoop, { agents: [] }) + ctx.llm.registerAdapter(['mock'], adapter) + return ctx +} + +function image(name: string, bytes: number): Extract { + return { + type: 'image', + attachment: { attachmentId: `sha256:${'a'.repeat(64)}` as never, name, mediaType: 'image/png', bytes, width: 1, height: 1 }, + } +} + +function text(value: string): ReturnType { + return createUserMessage({ content: [{ type: 'text', text: value }], source: { kind: 'user' } }) +} + +function offloadedNames(options: GenerateOptions): string[] { + const names: string[] = [] + const visit = (blocks: readonly ContentBlock[]): void => { + for (const block of blocks) { + if (block.type === 'image' && block.offloaded === true) names.push(block.attachment.name ?? '') + if (block.type === 'tool-result') visit(block.content) + } + } + for (const message of options.messages) visit(message.content) + return names +} + +function offloadEvents(session: Session): { turn: number; step: number; seq: number; path: number[] }[] { + return session.snapshotEvents() + .filter(event => event.type === 'image/offload') + .map(event => ({ + turn: event.data.turn, + step: event.data.step, + seq: Number(event.data.watermark.seq), + path: event.data.watermark.path, + })) +} + +const budget: LlmImageRequestBudget = { representation: 'raw', maxBytes: 10, byteQuantum: 4 } + +describe('llm-image-offload', () => { + it('advances the watermark before a step from the routed budget and never retreats', async () => { + const adapter = new ScriptedAdapter([textResponse('one'), textResponse('two'), textResponse('three')], budget) + const ctx = await harness(adapter) + const agent = await ctx.agentLoop.create(SessionId('offload-advance'), { provider: 'mock', model: 'mock' }) + + // The first turn logs the request header that names the route. + agent.followup(text('hello')) + await agent.whenIdle() + expect(offloadEvents(agent.session)).toHaveLength(0) + + // 12 raw bytes exceed the 10-byte bound by 2, rounded up to one 4-byte quantum: 'a' + // alone removes 4 bytes but the quantum rule crosses into 'b'. + agent.session.append('user/message', createUserMessage({ + content: [image('a', 4), { type: 'tool-result', toolCallId: ToolCallId('shot'), content: [image('b', 4)] }, image('c', 4)], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + agent.followup(text('again')) + await agent.whenIdle() + + expect(offloadedNames(adapter.requests[1]!)).toEqual(['a', 'b']) + const events = offloadEvents(agent.session) + expect(events).toHaveLength(1) + expect(events[0]).toMatchObject({ turn: 2, step: 1, path: [1, 0] }) + const order = agent.session.snapshotEvents().map(event => event.type) + expect(order.lastIndexOf('turn/start')).toBeLessThan(order.indexOf('image/offload')) + expect(order.indexOf('image/offload')).toBeLessThan(order.lastIndexOf('step/start')) + + // The next turn adds no bytes beyond the bound, so the watermark holds and the + // second request's offloaded set is still what the third request sends. + agent.followup(text('once more')) + await agent.whenIdle() + expect(offloadedNames(adapter.requests[2]!)).toEqual(['a', 'b']) + expect(offloadEvents(agent.session)).toHaveLength(1) + }) + + it('leaves a route without a declared budget alone', async () => { + const adapter = new ScriptedAdapter([textResponse('ok'), textResponse('ok')]) + const ctx = await harness(adapter) + const agent = await ctx.agentLoop.create(SessionId('offload-none'), { provider: 'mock', model: 'mock' }) + agent.followup(text('hello')) + await agent.whenIdle() + agent.session.append('user/message', createUserMessage({ content: [image('a', 400)], source: { kind: 'user' } }), { surfaceOp: 'append' }) + agent.followup(text('again')) + await agent.whenIdle() + expect(offloadedNames(adapter.requests[1]!)).toEqual([]) + expect(offloadEvents(agent.session)).toHaveLength(0) + }) + + it('advances by the count an IMAGE_OFFLOAD_REQUIRED failure names and retries the step', async () => { + const adapter = new ScriptedAdapter([offloadRequired(2), textResponse('sent')], { representation: 'raw', maxBytes: 100 }) + const ctx = await harness(adapter) + const agent = await ctx.agentLoop.create(SessionId('offload-required'), { provider: 'mock', model: 'mock' }) + const delegated: string[] = [] + ctx.on('agent/request-error', ({ failure }, next) => { + delegated.push(failure.code) + return next() + }) + + agent.followup(createUserMessage({ + content: [image('a', 1), image('b', 1), image('c', 1)], source: { kind: 'user' }, + })) + await agent.whenIdle() + + expect(adapter.requests).toHaveLength(2) + expect(offloadedNames(adapter.requests[0]!)).toEqual([]) + expect(offloadedNames(adapter.requests[1]!)).toEqual(['a', 'b']) + expect(offloadEvents(agent.session)).toEqual([{ turn: 1, step: 1, seq: expect.any(Number) as never, path: [1] }]) + expect(delegated).toEqual([]) + const types = agent.session.snapshotEvents().map(event => event.type) + expect(types.filter(type => type === 'assistant/attempt')).toHaveLength(1) + expect(types.filter(type => type === 'assistant/message')).toHaveLength(1) + expect(types.indexOf('assistant/attempt')).toBeLessThan(types.indexOf('image/offload')) + }) + + it('interprets an adapter count in request order after a surface replacement', async () => { + const adapter = new ScriptedAdapter([offloadRequired(2), textResponse('sent')]) + const ctx = await harness(adapter) + const agent = await ctx.agentLoop.create(SessionId('offload-reordered'), { provider: 'mock', model: 'mock' }) + const first = agent.session.append('user/message', createUserMessage({ + content: [image('first', 1)], source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + agent.session.append('user/message', createUserMessage({ + content: [image('second', 1)], source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + agent.session.append('user/message', createUserMessage({ + content: [image('replacement', 1)], source: { kind: 'user' }, + }), { + surfaceOp: { op: 'replace', start: first.seq, end: first.seq }, + sourceEventSeqs: [first.seq], + }) + + agent.followup(text('send')) + await agent.whenIdle() + + expect(adapter.requests).toHaveLength(2) + expect(offloadedNames(adapter.requests[1]!)).toEqual(['replacement', 'second']) + expect(offloadEvents(agent.session).at(-1)).toMatchObject({ seq: 2, path: [0] }) + }) + + it('delegates IMAGE_OFFLOAD_REQUIRED once nothing remains to offload', async () => { + const adapter = new ScriptedAdapter([offloadRequired(1)]) + const ctx = await harness(adapter) + const agent = await ctx.agentLoop.create(SessionId('offload-exhausted'), { provider: 'mock', model: 'mock' }) + const delegated: string[] = [] + ctx.on('agent/request-error', ({ failure }, next) => { + delegated.push(failure.code) + return next() + }) + agent.followup(text('no images')) + await agent.whenIdle() + expect(delegated).toEqual([IMAGE_OFFLOAD_REQUIRED_CODE]) + expect(offloadEvents(agent.session)).toHaveLength(0) + expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'error' } } }) + }) +}) diff --git a/packages/llm/llm-image-offload/tsconfig.json b/packages/llm/llm-image-offload/tsconfig.json new file mode 100644 index 0000000000..aa2185055d --- /dev/null +++ b/packages/llm/llm-image-offload/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/session" + }, + { + "path": "../../core/agent" + } + ] +} diff --git a/packages/llm/llm-image-offload/tsdown.config.ts b/packages/llm/llm-image-offload/tsdown.config.ts new file mode 100644 index 0000000000..8600a71b0a --- /dev/null +++ b/packages/llm/llm-image-offload/tsdown.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'tsdown' + +/** Bundle the package root from its emitted declarations build. */ +export default defineConfig({ + entry: ['lib/types/index.js'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, +}) diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 65147ab045..bdc0af81d3 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-pi-ai/README.md -README.md: cc5a9ab745d7f18d8450730c01d6158df102fa62 -README.zh.md: f13f162966bb2446155a65a8d0225a5f19f02924 +README.md: 08a5d9a220af2ec3a0e929926c74cc6e6c9b7499 +README.zh.md: c6eb57bf02538b07edb571a1b6c69029f18cbf24 diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index cc5a9ab745..08a5d9a220 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -83,7 +83,7 @@ Each profile may set a `retryPolicy`; omission uses normal mode with five retrie | `defaultMaxTokens` | `32,768` | Output-cap fallback for undescribed models | | `requestImagePixelBudget` | `4,194,304` | Total-pixel budget for each deterministic request image | | `requestImageMaxBytes` | `1 MiB` | Encoded-byte target for each request image before base64 expansion | -| `maxRequestImageBytes` | `20 MiB` | Aggregate base64 image-payload bound declared to the agent loop's `image/offload` watermark | +| `maxRequestImageBytes` | `20 MiB` | Aggregate base64 image-payload bound declared to the `dsh-llm-image-offload` plugin's `image/offload` watermark planning | | `retryPolicy` | normal, 5 retries | Provider-owned retry policy executed by `dsh-llm-retry` | The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-llm-pi-ai) is the exhaustive source for every accepted field and its JSDoc. @@ -176,7 +176,7 @@ Read these pages when the package-level contract is not enough. They move from t #### What the model sees -The selected catalog model receives `GenerateOptions.system`, history, tools, and sampling fields supported by pi-ai's common streaming API. Each retained image is preceded by text naming its complete attachment id and actual request dimensions. When the current execution filesystem maps the attachment provider's host object, the text also carries a read-only normalized-object path and warns that normalization or request projection may have resized or re-encoded the upload. Each occurrence the session's `image/offload` watermark marks offloaded keeps its own identity and currently resolved access in replacement text, and its normalized attachment is not read or transformed. When the retained occurrences' exact base64 payload still exceeds the route's `maxRequestImageBytes`, the call fails with `IMAGE_OFFLOAD_REQUIRED` so the agent loop advances the watermark and rebuilds the request. Provider-native replay metadata is restored only when the adapter validates it for the historical content. +The selected catalog model receives `GenerateOptions.system`, history, tools, and sampling fields supported by pi-ai's common streaming API. Each retained image is preceded by text naming its complete attachment id and actual request dimensions. When the current execution filesystem maps the attachment provider's host object, the text also carries a read-only normalized-object path and warns that normalization or request projection may have resized or re-encoded the upload. Each occurrence the session's `image/offload` watermark marks offloaded keeps its own identity and currently resolved access in replacement text, and its normalized attachment is not read or transformed. When the retained occurrences' exact base64 payload still exceeds the route's `maxRequestImageBytes`, the call fails with `IMAGE_OFFLOAD_REQUIRED` so the `dsh-llm-image-offload` plugin advances the watermark and retries the step. Provider-native replay metadata is restored only when the adapter validates it for the historical content. #### Token effect diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index f13f162966..c6eb57bf02 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -83,7 +83,7 @@ kind: "package-reference" | `defaultMaxTokens` | `32,768` | 未描述模型的输出上限回退 | | `requestImagePixelBudget` | `4,194,304` | 每张确定性请求图片的总像素预算 | | `requestImageMaxBytes` | `1 MiB` | 每张请求图片在 base64 扩展前的编码字节目标 | -| `maxRequestImageBytes` | `20 MiB` | 向 agent loop 的 `image/offload` 水位声明的 base64 图片载荷总上限 | +| `maxRequestImageBytes` | `20 MiB` | 向 `dsh-llm-image-offload` 插件的 `image/offload` 水位规划声明的 base64 图片载荷总上限 | | `retryPolicy` | normal,5 次重试 | 由 `dsh-llm-retry` 执行的提供方自有重试策略 | 生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-llm-pi-ai)是每个受支持字段及其 JSDoc 的穷尽式真源。 @@ -176,7 +176,7 @@ pi-ai 不提供的路由需要 `api`、`baseURL` 与非空 `models` 列表;无 #### 模型看到什么 -所选目录模型会收到 `GenerateOptions.system`、历史、工具与 pi-ai 通用流式 API 支持的采样字段。每张保留图片前都会有文本,注明其完整附件 id 与实际请求尺寸。当前执行文件系统可以映射附件提供方的宿主对象时,该文本还会携带只读规范化对象路径,并警告规范化或请求投影可能缩放或重新编码上传内容。会话 `image/offload` 水位标记为已卸载的每个出现位置都会在替换文本中保留自己的身份与当前已解析访问方式,其规范化附件不会读取或变换。当保留的出现位置按精确 base64 载荷仍超过路由的 `maxRequestImageBytes` 时,调用以 `IMAGE_OFFLOAD_REQUIRED` 失败,由 agent loop 推进水位并重建请求。提供方原生回放元数据只在适配器针对历史内容校验通过后恢复。 +所选目录模型会收到 `GenerateOptions.system`、历史、工具与 pi-ai 通用流式 API 支持的采样字段。每张保留图片前都会有文本,注明其完整附件 id 与实际请求尺寸。当前执行文件系统可以映射附件提供方的宿主对象时,该文本还会携带只读规范化对象路径,并警告规范化或请求投影可能缩放或重新编码上传内容。会话 `image/offload` 水位标记为已卸载的每个出现位置都会在替换文本中保留自己的身份与当前已解析访问方式,其规范化附件不会读取或变换。当保留的出现位置按精确 base64 载荷仍超过路由的 `maxRequestImageBytes` 时,调用以 `IMAGE_OFFLOAD_REQUIRED` 失败,由 `dsh-llm-image-offload` 插件推进水位并重试 step。提供方原生回放元数据只在适配器针对历史内容校验通过后恢复。 #### Token 影响 diff --git a/packages/llm/llm-pi-ai/src/context.ts b/packages/llm/llm-pi-ai/src/context.ts index 6a9607b2dc..78c6d6504f 100644 --- a/packages/llm/llm-pi-ai/src/context.ts +++ b/packages/llm/llm-pi-ai/src/context.ts @@ -5,7 +5,7 @@ */ import { brandString } from '@deepseek-ai/dsh-brand' -import { contentHasImage, IMAGE_OFFLOAD_REQUIRED_CODE, LlmError, offloadedImageText, offloadedImagePrefixCount, projectOffloadedImages, representedImageBytes, requestImageHandleText, visitImageBlocks } from '@deepseek-ai/dsh-llm' +import { contentHasImage, IMAGE_OFFLOAD_REQUIRED_CODE, LlmError, offloadedImageText, projectOffloadedImages, requestImageHandleText, requiredImageOffload } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, ImageAttachmentAccessResolver, Message, ToolCallId } from '@deepseek-ai/dsh-llm' import type { AttachmentId, @@ -220,7 +220,7 @@ export function toPiContext( * occurrences the surface marks offloaded become text placeholders; when the * retained occurrences' exact base64 payload still exceeds * `maxRequestImageBytes`, the call fails with `IMAGE_OFFLOAD_REQUIRED` naming - * how many more oldest occurrences the agent loop must offload. + * how many more oldest occurrences must be offloaded. * @param options - the harness request; `options.system` maps to pi-ai's single `systemPrompt` slot. * @param images - attachment provider, current path resolver, and request limits. * @param onReplayDegrade - forwarded to {@link toPiAssistant} for each assistant message. @@ -254,15 +254,11 @@ async function toPiContextWithImages( assertSupportedImageRoles(options.messages) const requestImages = await prepareRequestImages(options.messages, attachments, requestImagePolicy, options.signal) if (maxRequestImageBytes !== undefined) { - const lengths: number[] = [] - for (const message of options.messages) { - visitImageBlocks(message.content, (block) => { - if (block.offloaded === true) return - const version = requestImages.get(block.attachment.attachmentId) as RequestImageAttachment - lengths.push(representedImageBytes(version.bytes, { representation: 'base64' })) - }) - } - const offloadImages = offloadedImagePrefixCount(lengths, { maxBytes: maxRequestImageBytes }) + const offloadImages = requiredImageOffload( + options.messages, + { representation: 'base64', maxBytes: maxRequestImageBytes }, + block => (requestImages.get(block.attachment.attachmentId) as RequestImageAttachment).bytes, + ) if (offloadImages > 0) { throw new LlmError( `pi-ai request images exceed the ${maxRequestImageBytes}-byte base64 bound; ${offloadImages} more oldest occurrence(s) must be offloaded.`, diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml index dc3ba2ed6f..089c30be04 100644 --- a/packages/llm/llm/README.i18n.yaml +++ b/packages/llm/llm/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm/README.md -README.md: 0cb0e5dfae841d17823c06dc514df27db24d84bf -README.zh.md: 6109783e65f4a71eb955c85d904f1ebad779aca1 +README.md: dc5fb35d5bcb9a5a129ff4b9db9e595a5342f19c +README.zh.md: 20fa7102eea142720153feb01deb0185205fda28 diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index 0cb0e5dfae..dc5fb35d5b 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -101,7 +101,7 @@ The service is built on one separation: **the logical contract is provider-neutr ### Main flow -A request is validated against its exact model's capability — context window, output default, reasoning efforts, and input modalities — and any adapter-configured defaults are materialized, then the whole request is deep-frozen. `prepareCall()` binds those facts, detached context, and retry policy to the exact adapter generation that performs terminal dispatch, so HMR or dynamic settings cannot combine one generation's image capability with another generation's endpoint. An image-capable adapter projects durable references into route-specific request versions; `resolveImageAttachmentAccess()` separately maps an attachment provider's optional host object into the current tool execution world without changing the request image or its `variantId`. A text-only route receives deterministic per-image placeholders, including nested tool-result images, without rewriting append-only session history. Durable `FileBlock` references never reach any adapter: request assembly replaces each one, nested tool-result occurrences included, with deterministic handle text naming the file and its saved read-only path, resolved through the mounted attachment and filesystem providers. `ctx.llm.fileRequestText(ref)` exposes that exact synchronous projection to request measurement. An image-capable route declares a `LlmImageRequestBudget` on its resolved model info; the agent loop plans the session's durable `image/offload` watermark from it with the pure `planImageOffload()`, surface derivation marks occurrences at or before the watermark `offloaded: true`, and `projectOffloadedImages()` renders those marks as route-owned placeholder text. An adapter whose exact byte accounting still exceeds its budget fails with `IMAGE_OFFLOAD_REQUIRED` naming the additional occurrences (`offloadedImagePrefixCount()`), never with an unlogged projection. Adapters that charge visual tokens declare per-route `imageRequestPricing`, which `ctx.llm.imageRequestPricing(provider, model)` resolves synchronously for the token meter. Dispatch goes through the `llm/stream` waterfall, then chunks return as token-level deltas and every adapter outcome reaches the consumer as one terminal `finish` chunk. +A request is validated against its exact model's capability — context window, output default, reasoning efforts, and input modalities — and any adapter-configured defaults are materialized, then the whole request is deep-frozen. `prepareCall()` binds those facts, detached context, and retry policy to the exact adapter generation that performs terminal dispatch, so HMR or dynamic settings cannot combine one generation's image capability with another generation's endpoint. An image-capable adapter projects durable references into route-specific request versions; `resolveImageAttachmentAccess()` separately maps an attachment provider's optional host object into the current tool execution world without changing the request image or its `variantId`. A text-only route receives deterministic per-image placeholders, including nested tool-result images, without rewriting append-only session history. Durable `FileBlock` references never reach any adapter: request assembly replaces each one, nested tool-result occurrences included, with deterministic handle text naming the file and its saved read-only path, resolved through the mounted attachment and filesystem providers. `ctx.llm.fileRequestText(ref)` exposes that exact synchronous projection to request measurement. An image-capable route declares a `LlmImageRequestBudget` on its resolved model info; the `dsh-llm-image-offload` plugin plans the session's durable `image/offload` watermark from it with the pure `offloadedImagePrefixCount()`, surface derivation marks occurrences at or before the watermark `offloaded: true`, and `projectOffloadedImages()` renders those marks as route-owned placeholder text. An adapter whose exact byte accounting still exceeds its budget fails with `IMAGE_OFFLOAD_REQUIRED` naming the additional occurrences (`requiredImageOffload()`), never with an unlogged projection. Adapters that charge visual tokens declare per-route `imageRequestPricing`, which `ctx.llm.imageRequestPricing(provider, model)` resolves synchronously for the token meter. Dispatch goes through the `llm/stream` waterfall, then chunks return as token-level deltas and every adapter outcome reaches the consumer as one terminal `finish` chunk. ### Invariants diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md index 6109783e65..20fa7102ee 100644 --- a/packages/llm/llm/README.zh.md +++ b/packages/llm/llm/README.zh.md @@ -101,7 +101,7 @@ for await (const chunk of ctx.llm.stream({ ### 主流程 -请求会对照其精确模型的能力——上下文窗口、输出默认值、推理强度与输入模态——校验,填入任何适配器配置的默认值,然后整个请求被深度冻结。`prepareCall()` 把这些事实、分离的上下文与重试策略绑定到执行最终分发的精确适配器代次,因此 HMR 或动态设置无法把一个代次的图片能力与另一代次的端点混用。支持图片的适配器把持久引用投影为路由专用请求版本;`resolveImageAttachmentAccess()` 会单独把附件提供方的可选宿主对象映射进当前工具执行世界,而不改变请求图片或其 `variantId`。纯文本路由接收确定性的逐图片占位符,包括嵌套工具结果图片,而不会改写仅追加会话历史。持久 `FileBlock` 引用永远不会到达任何适配器:请求组装把每个引用(包括嵌套工具结果中的出现)替换为确定性 handle 文本,指出文件与其只读保存路径,路径经由挂载的附件与文件系统提供方解析。`ctx.llm.fileRequestText(ref)` 向请求计量公开相同的同步投影。支持图片的路由在其解析后的模型信息上声明 `LlmImageRequestBudget`;agent loop 用纯函数 `planImageOffload()` 据此规划会话的持久 `image/offload` 水位,表层派生把位于水位及之前的出现位置标为 `offloaded: true`,`projectOffloadedImages()` 把这些标记渲染为路由所属的占位文本。adapter 按精确字节计量仍超预算时,以 `IMAGE_OFFLOAD_REQUIRED` 失败并说明还需省略的出现位置数(`offloadedImagePrefixCount()`),绝不发送未记录的投影。对视觉 token 收费的适配器声明按路由的 `imageRequestPricing`,`ctx.llm.imageRequestPricing(provider, model)` 为 token meter 同步解析它。分发经过 `llm/stream` waterfall,随后分片以 token 级增量返回,每个适配器结果都以唯一一个终止 `finish` 分片到达消费方。 +请求会对照其精确模型的能力——上下文窗口、输出默认值、推理强度与输入模态——校验,填入任何适配器配置的默认值,然后整个请求被深度冻结。`prepareCall()` 把这些事实、分离的上下文与重试策略绑定到执行最终分发的精确适配器代次,因此 HMR 或动态设置无法把一个代次的图片能力与另一代次的端点混用。支持图片的适配器把持久引用投影为路由专用请求版本;`resolveImageAttachmentAccess()` 会单独把附件提供方的可选宿主对象映射进当前工具执行世界,而不改变请求图片或其 `variantId`。纯文本路由接收确定性的逐图片占位符,包括嵌套工具结果图片,而不会改写仅追加会话历史。持久 `FileBlock` 引用永远不会到达任何适配器:请求组装把每个引用(包括嵌套工具结果中的出现)替换为确定性 handle 文本,指出文件与其只读保存路径,路径经由挂载的附件与文件系统提供方解析。`ctx.llm.fileRequestText(ref)` 向请求计量公开相同的同步投影。支持图片的路由在其解析后的模型信息上声明 `LlmImageRequestBudget`;`dsh-llm-image-offload` 插件用纯函数 `offloadedImagePrefixCount()` 据此规划会话的持久 `image/offload` 水位,表层派生把位于水位及之前的出现位置标为 `offloaded: true`,`projectOffloadedImages()` 把这些标记渲染为路由所属的占位文本。adapter 按精确字节计量仍超预算时,以 `IMAGE_OFFLOAD_REQUIRED` 失败并说明还需省略的出现位置数(`requiredImageOffload()`),绝不发送未记录的投影。对视觉 token 收费的适配器声明按路由的 `imageRequestPricing`,`ctx.llm.imageRequestPricing(provider, model)` 为 token meter 同步解析它。分发经过 `llm/stream` waterfall,随后分片以 token 级增量返回,每个适配器结果都以唯一一个终止 `finish` 分片到达消费方。 ### 不变式 diff --git a/packages/llm/llm/src/content.ts b/packages/llm/llm/src/content.ts index 7e50b1761a..fcd16472c9 100644 --- a/packages/llm/llm/src/content.ts +++ b/packages/llm/llm/src/content.ts @@ -300,9 +300,9 @@ export function projectOffloadedImages( /** * Number of oldest retained image occurrences one route budget removes, in * whole count and byte quanta, once the budget is exceeded. The result depends - * only on the represented lengths, so the agent loop plans the durable - * watermark from logged facts and an adapter names the same count when its - * exact accounting still overflows. + * only on the represented lengths, so the image-offload plugin plans the + * durable watermark from logged facts and an adapter names the same count + * when its exact accounting still overflows. * @param lengths - represented byte length of every retained occurrence, oldest first. * @param budget - count/byte budgets and removal quanta; unbounded when absent. * @returns how many leading occurrences to offload. @@ -331,36 +331,28 @@ export function offloadedImagePrefixCount( return count } -/** One retained image occurrence the watermark planner may offload. */ -export interface RetainedImageOccurrence { - /** Durable position of the occurrence, used as the watermark when it becomes the last offloaded one. */ - position: Position - /** Normalized attachment byte count. */ - bytes: number -} - /** - * Plan the next durable watermark for one route budget: with the retained - * occurrences in request order, the budget removes a whole-quantum prefix and the - * last removed occurrence's position becomes the watermark. Under a 128 MiB - * bound with a 64 MiB quantum, 129 retained one-megabyte images offload the - * oldest 65 so 64 MiB remain, and the watermark then holds until the retained - * total again exceeds the bound. - * @param retained - retained occurrences in request order. + * Number of oldest retained occurrences a route must still offload before a + * derived request fits its budget at the exact byte length the route sends; + * zero when the request fits. A route fails with `IMAGE_OFFLOAD_REQUIRED` + * carrying this count instead of offloading on its own. + * @param messages - derived request history carrying the surface's `offloaded` marks. * @param budget - route representation, budgets, and removal quanta. - * @returns the position of the last occurrence to offload, or undefined when the budget holds. + * @param versionBytes - exact request-version byte length of one retained occurrence. + * @returns how many more leading retained occurrences to offload. */ -export function planImageOffload( - retained: readonly RetainedImageOccurrence[], - budget: LlmImageRequestBudget, -): Position | undefined { - const count = offloadedImagePrefixCount( - retained.map(occurrence => representedImageBytes(occurrence.bytes, budget)), - budget, - ) - if (count === 0) return undefined - // oxlint-disable-next-line typescript/no-non-null-assertion -- the prefix count never exceeds the retained length - return retained[count - 1]!.position +export function requiredImageOffload( + messages: readonly Message[], + budget: Pick, + versionBytes: (block: ImageBlock) => number, +): number { + const lengths: number[] = [] + for (const message of messages) { + visitImageBlocks(message.content, (block) => { + if (block.offloaded !== true) lengths.push(representedImageBytes(versionBytes(block), budget)) + }) + } + return offloadedImagePrefixCount(lengths, budget) } /** Replace every image occurrence, including nested tool results, for a text-only model. */ diff --git a/packages/llm/llm/src/error.ts b/packages/llm/llm/src/error.ts index afd3a8b324..232af84fb4 100644 --- a/packages/llm/llm/src/error.ts +++ b/packages/llm/llm/src/error.ts @@ -164,9 +164,9 @@ export function isHarnessError(value: unknown): value is HarnessError { /** * Canonical code for a request an image-capable route cannot send until the - * agent loop advances the session's durable `image/offload` watermark. The - * failure's `offloadImages` names how many more of the oldest retained - * occurrences must be offloaded; the loop appends the advance and rebuilds the - * request, so the model never receives an unlogged projection. + * session's durable `image/offload` watermark advances. The failure's + * `offloadImages` names how many more of the oldest retained occurrences must + * be offloaded; the `dsh-llm-image-offload` plugin appends the advance and + * retries the step, so the model never receives an unlogged projection. */ export const IMAGE_OFFLOAD_REQUIRED_CODE = 'IMAGE_OFFLOAD_REQUIRED' diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 95ab7d9ec4..d80e16e354 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -15,7 +15,6 @@ import type { LlmDiscoveredModel, LlmFailure, LlmImageRequestPricing, - LlmImageRequestBudget, LlmModelContext, LlmModelDiscoveryRequest, LlmModelInfo, @@ -114,10 +113,6 @@ export class LlmError extends HarnessError { && (typeof options.requestId !== 'string' || options.requestId.length === 0)) { throw new Error('LlmError requestId must be a non-empty string') } - if (options?.offloadImages !== undefined - && (!Number.isSafeInteger(options.offloadImages) || options.offloadImages <= 0)) { - throw new Error('LlmError offloadImages must be a positive safe integer') - } super(message, code, options) this.name = 'LlmError' this.failure = Object.freeze({ @@ -176,8 +171,6 @@ export interface PreparedLlmCall { readonly context?: LlmModelContext /** Exact model modalities captured with the adapter dispatch generation. */ readonly inputModalities?: readonly ModelModality[] - /** Detached request-image budget the route enforces, when it declares one. */ - readonly imageRequest?: LlmImageRequestBudget /** Config fields materialized by the captured adapter rather than proposed by the caller. */ readonly adapterDefaults: LlmCallConfigAdapterDefaults /** @@ -689,33 +682,6 @@ export class LlmRuntime extends TypertRemoteService { return modalities === undefined ? undefined : [...modalities] } - /** Validate and detach one adapter-declared request-image budget. */ - private detachedImageRequest( - budget: LlmImageRequestBudget | undefined, - provider: string, - model: string, - ): LlmImageRequestBudget | undefined { - if (budget === undefined) return undefined - const positive = (value: number | undefined): boolean => value === undefined - || (Number.isSafeInteger(value) && value > 0) - if (!positive(budget.maxBytes) || !positive(budget.maxImages) - || !positive(budget.byteQuantum) || !positive(budget.countQuantum) - || !positive(budget.versionMaxBytes)) { - throw new LlmError( - `adapter returned invalid request-image budget for provider "${provider}" model "${model}"`, - 'INVALID_MODEL_IMAGE_BUDGET', - ) - } - return { - representation: budget.representation, - ...budget.maxBytes === undefined ? {} : { maxBytes: budget.maxBytes }, - ...budget.maxImages === undefined ? {} : { maxImages: budget.maxImages }, - ...budget.byteQuantum === undefined ? {} : { byteQuantum: budget.byteQuantum }, - ...budget.countQuantum === undefined ? {} : { countQuantum: budget.countQuantum }, - ...budget.versionMaxBytes === undefined ? {} : { versionMaxBytes: budget.versionMaxBytes }, - } - } - /** * Discover models advertised by one registered provider. Catalog membership * is advisory and never changes routing or request validation. @@ -816,7 +782,7 @@ export class LlmRuntime extends TypertRemoteService { 'INVALID_MODEL_MAX_TOKENS', ) } - const imageRequest = this.detachedImageRequest(resolved.imageRequest, provider, model) + const imageRequest = resolved.imageRequest === undefined ? undefined : { ...resolved.imageRequest } const info: LlmResolvedModelInfo = { provider, id: model, @@ -966,9 +932,6 @@ export class LlmRuntime extends TypertRemoteService { ...modelInfo.inputModalities === undefined ? {} : { inputModalities: Object.freeze([...modelInfo.inputModalities]) }, - ...modelInfo.imageRequest === undefined - ? {} - : { imageRequest: deepFreeze(structuredClone(modelInfo.imageRequest)) }, stream: (options: GenerateOptions): AsyncIterable => { if (dispatched) { throw new LlmError('a prepared LLM call can only be dispatched once', 'INVALID_PREPARED_CALL') diff --git a/packages/llm/llm/src/types.ts b/packages/llm/llm/src/types.ts index 62c9a78038..829074a7fb 100644 --- a/packages/llm/llm/src/types.ts +++ b/packages/llm/llm/src/types.ts @@ -51,8 +51,8 @@ export interface LlmFailure { /** * With code `IMAGE_OFFLOAD_REQUIRED`: how many more of the oldest retained * image occurrences the route needs offloaded before the same request fits - * its exact byte accounting. The agent loop advances the durable watermark - * by this count and rebuilds the request. + * its exact byte accounting. The `dsh-llm-image-offload` plugin advances the + * durable watermark by this count and retries the step. */ readonly offloadImages?: number } @@ -330,9 +330,10 @@ export interface LlmModelContext { } /** - * Request-image budget one exact image-capable route declares, so the agent - * loop can advance the session's durable `image/offload` watermark before - * dispatch from logged facts alone. Byte accounting clamps each occurrence's + * Request-image budget one exact image-capable route declares, so the + * `dsh-llm-image-offload` plugin can advance the session's durable + * `image/offload` watermark before a step from logged facts alone. Byte + * accounting clamps each occurrence's * normalized byte count to {@link versionMaxBytes} and, for the `base64` * representation, expands it to its encoded length. A route whose exact * request accounting still exceeds the budget fails the request with diff --git a/packages/llm/llm/tests/content.spec.ts b/packages/llm/llm/tests/content.spec.ts index 1291c9252d..792caeff9e 100644 --- a/packages/llm/llm/tests/content.spec.ts +++ b/packages/llm/llm/tests/content.spec.ts @@ -9,7 +9,6 @@ import { projectFilesToText, offloadedImageText, offloadedImagePrefixCount, - planImageOffload, projectImagesForTextModel, projectOffloadedImages, representedImageBytes, @@ -120,29 +119,9 @@ describe('offloadedImagePrefixCount', () => { expect(offloadedImagePrefixCount([...lengths, 4], { maxImages: 4, countQuantum: 2 })).toBe(2) // One excess byte removes a whole byte quantum, crossing the second image. expect(offloadedImagePrefixCount([...lengths, 1], { maxBytes: 16, byteQuantum: 5 })).toBe(2) - }) -}) - -describe('planImageOffload', () => { - const mib = 1024 * 1024 - const retained = (count: number) => Array.from({ length: count }, (_, index) => ({ position: index, bytes: mib })) - - it('drops 129 retained MiB to 64 MiB and holds until the retained total exceeds the bound again', () => { - const budget = { representation: 'raw' as const, maxBytes: 128 * mib, byteQuantum: 64 * mib } - expect(planImageOffload(retained(128), budget)).toBeUndefined() - expect(planImageOffload(retained(129), budget)).toBe(64) - expect(planImageOffload(retained(64), budget)).toBeUndefined() - }) - - it('rounds a count excess up to a 20-image advance', () => { - const budget = { representation: 'raw' as const, maxImages: 600, countQuantum: 20 } - expect(planImageOffload(retained(601), budget)).toBe(19) - }) - - it('accounts inline representations by base64 length after the version clamp', () => { - const budget = { representation: 'base64' as const, maxBytes: 8, versionMaxBytes: 3 } - expect(planImageOffload([{ position: 'a', bytes: 3 }, { position: 'b', bytes: 3 }], budget)).toBeUndefined() - expect(planImageOffload([{ position: 'a', bytes: 300 }, { position: 'b', bytes: 3 }, { position: 'c', bytes: 3 }], budget)).toBe('a') + // 129 one-mebibyte images under a 128 MiB bound with a 64 MiB quantum offload the oldest 65. + const mib = 1024 * 1024 + expect(offloadedImagePrefixCount(Array.from({ length: 129 }, () => mib), { maxBytes: 128 * mib, byteQuantum: 64 * mib })).toBe(65) }) }) diff --git a/packages/llm/llm/tests/service.spec.ts b/packages/llm/llm/tests/service.spec.ts index 7af4323f4a..2bfcd0f486 100644 --- a/packages/llm/llm/tests/service.spec.ts +++ b/packages/llm/llm/tests/service.spec.ts @@ -1405,33 +1405,3 @@ describe('LlmRuntime', () => { expect(ctx.llm.listProviders()).toEqual([]) }) }) - -describe('request-image budget metadata', () => { - it('detaches a valid budget onto the prepared call and rejects a non-positive field', async () => { - class BudgetAdapter extends LlmAdapter { - constructor(private readonly budget: Record) { - super() - } - - override resolveModel(provider: string, model: string) { - return Promise.resolve({ provider, id: model, name: model, imageRequest: this.budget as never }) - } - - async * stream(): AsyncIterable { - throw new Error('unused') - } - } - const ctx = new Context() - const llm = new LlmRuntime(ctx) - const full = { representation: 'raw', maxBytes: 8, maxImages: 3, byteQuantum: 4, countQuantum: 2, versionMaxBytes: 5 } - llm.registerAdapter(['good'], new BudgetAdapter(full)) - llm.registerAdapter(['bare'], new BudgetAdapter({ representation: 'base64' })) - llm.registerAdapter(['bad'], new BudgetAdapter({ representation: 'base64', maxImages: 0 })) - const prepared = await llm.prepareCall({ provider: 'good', model: 'm' }) - expect(prepared.imageRequest).toEqual(full) - expect((await llm.prepareCall({ provider: 'bare', model: 'm' })).imageRequest).toEqual({ representation: 'base64' }) - expect(Object.isFrozen(prepared.imageRequest)).toBe(true) - await expect(llm.prepareCall({ provider: 'bad', model: 'm' })).rejects.toMatchObject({ code: 'INVALID_MODEL_IMAGE_BUDGET' }) - expect(() => new LlmError('x', 'y', { offloadImages: 0 })).toThrow('offloadImages must be a positive safe integer') - }) -}) diff --git a/packages/test-support/llm-replay/README.i18n.yaml b/packages/test-support/llm-replay/README.i18n.yaml index 5b80259ec2..2d22379198 100644 --- a/packages/test-support/llm-replay/README.i18n.yaml +++ b/packages/test-support/llm-replay/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/test-support/llm-replay/README.md -README.md: 77ee0bb6ca6f2f849cad1a7d907be3cb91318ad3 -README.zh.md: c8fd4b18f50ed86b909c41380d83975f8b1c9718 +README.md: e8a257c2ee9f95f1e7d20197e6db67b2b4c7b45c +README.zh.md: 5d085959982bcaa090a893089c0295426141bed2 diff --git a/packages/test-support/llm-replay/README.md b/packages/test-support/llm-replay/README.md index 77ee0bb6ca..e8a257c2ee 100644 --- a/packages/test-support/llm-replay/README.md +++ b/packages/test-support/llm-replay/README.md @@ -58,7 +58,7 @@ With `providers` configured, the plugin registers a replay-only adapter whose ca | `file` | `$DSH_SNAPSHOT_FILE` | Path to the selected primary fixture: `session.jsonl` for v0 or `session.vN.jsonl` for a positive generation; required (config or env) | | `overrideFile` | `$DSH_SNAPSHOT_OVERRIDE` | Optional `ReplayOverrideDoc` sidecar for the primary session | | `childFiles` | `$DSH_SNAPSHOT_CHILD_FILES` | Recorded subagent child-session logs for a nested scenario | -| `providers` | — | Optional replay-only provider and model catalog; a model may declare `contextWindow`, text/image modalities, and, when image-capable, positive `imageRequestTokens` and a positive `imageRequestMaxBytes` base64 budget that drives the agent loop's `image/offload` watermark; invalid values fail at load and routes never perform provider I/O | +| `providers` | — | Optional replay-only provider and model catalog; a model may declare `contextWindow`, text/image modalities, and positive `imageRequestTokens` when image-capable; invalid values fail at load and routes never perform provider I/O | | `paceMs` | — (burst) | Optional per-chunk delay in ms for genuinely incremental delivery | The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-llm-replay) is the exhaustive source for every accepted field and its JSDoc. diff --git a/packages/test-support/llm-replay/README.zh.md b/packages/test-support/llm-replay/README.zh.md index c8fd4b18f5..5d08595998 100644 --- a/packages/test-support/llm-replay/README.zh.md +++ b/packages/test-support/llm-replay/README.zh.md @@ -58,7 +58,7 @@ kind: "package-reference" | `file` | `$DSH_SNAPSHOT_FILE` | 选定 primary fixture 路径:v0 为 `session.jsonl`,正 generation 为 `session.vN.jsonl`;必需(config 或 env) | | `overrideFile` | `$DSH_SNAPSHOT_OVERRIDE` | 主会话的可选 `ReplayOverrideDoc` 伴随文件 | | `childFiles` | `$DSH_SNAPSHOT_CHILD_FILES` | 嵌套场景中已记录的 subagent 子会话日志 | -| `providers` | 无 | 可选的仅回放提供方与模型目录;模型可声明 `contextWindow`、文本/图片模态,以及图片模型使用的正整数 `imageRequestTokens` 和驱动 agent loop `image/offload` 水位的正整数 base64 预算 `imageRequestMaxBytes`;非法值会在加载时失败,路由绝不执行提供方 I/O | +| `providers` | 无 | 可选的仅回放提供方与模型目录;模型可声明 `contextWindow`、文本/图片模态,以及图片模型使用的正整数 `imageRequestTokens`;非法值会在加载时失败,路由绝不执行提供方 I/O | | `paceMs` | 无(突发) | 可选的每分片延迟(毫秒),用于真正的增量投递 | 生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-llm-replay)是每个受支持字段及其 JSDoc 的穷尽式真源。 diff --git a/packages/test-support/llm-replay/src/index.ts b/packages/test-support/llm-replay/src/index.ts index 1b71e9077c..01ba0e0c4e 100644 --- a/packages/test-support/llm-replay/src/index.ts +++ b/packages/test-support/llm-replay/src/index.ts @@ -93,13 +93,6 @@ export interface ReplayModelConfig { * no image pricing. */ imageRequestTokens?: number - /** - * Optional accumulated base64 image-byte bound the replay route declares, - * so the agent loop advances the durable `image/offload` watermark in - * keyless scenarios exactly as a live image-capable route would. Requires - * {@link inputModalities} to include `image`. Absent declares no budget. - */ - imageRequestMaxBytes?: number /** Optional reasoning-effort ids the replay route accepts, in display order. */ reasoningEfforts?: string[] /** @@ -1082,9 +1075,6 @@ class ReplayAdapter extends LlmAdapter { ...configuredModel?.defaultMaxTokens === undefined ? {} : { defaultMaxTokens: configuredModel.defaultMaxTokens }, - ...configuredModel?.imageRequestMaxBytes === undefined - ? {} - : { imageRequest: { representation: 'base64' as const, maxBytes: configuredModel.imageRequestMaxBytes } }, ...configuredModel?.reasoningEfforts === undefined ? {} : { @@ -1329,20 +1319,6 @@ function validateConfiguredModels(providers: ReplayProviderConfig[] | undefined) + 'requires inputModalities to include "image"', ) } - const imageRequestMaxBytes: unknown = model.imageRequestMaxBytes - if (imageRequestMaxBytes !== undefined - && (!Number.isSafeInteger(imageRequestMaxBytes) || (imageRequestMaxBytes as number) <= 0)) { - throw new Error( - `llm-replay: provider "${provider.id}" model "${model.id}" imageRequestMaxBytes ` - + 'must be a positive safe integer', - ) - } - if (imageRequestMaxBytes !== undefined && model.inputModalities?.includes('image') !== true) { - throw new Error( - `llm-replay: provider "${provider.id}" model "${model.id}" imageRequestMaxBytes ` - + 'requires inputModalities to include "image"', - ) - } } } } diff --git a/packages/test-support/llm-replay/tests/llm-replay.spec.ts b/packages/test-support/llm-replay/tests/llm-replay.spec.ts index 4cacddbdf0..5dcd64f908 100644 --- a/packages/test-support/llm-replay/tests/llm-replay.spec.ts +++ b/packages/test-support/llm-replay/tests/llm-replay.spec.ts @@ -2237,37 +2237,6 @@ describe('apply (the plugin entry)', () => { expect(ctx.llm.imageRequestPricing('deepseek', 'plain')).toBeUndefined() }) - it('declares a base64 request-image budget only for models that configure it', async () => { - writeFileSync(file, replaySessionJsonl([TEXT_CHUNKS]), 'utf8') - const ctx = new Context() - await ctx.plugin(LlmRuntime) - installLlmReplay(ctx, { - file, - providers: [{ - id: 'deepseek', - models: [ - { id: 'vision', inputModalities: ['text', 'image'], imageRequestMaxBytes: 4096 }, - { id: 'plain' }, - ], - }], - }) - await expect(ctx.llm.resolveModelInfo('deepseek', 'vision')).resolves.toMatchObject({ - imageRequest: { representation: 'base64', maxBytes: 4096 }, - }) - expect((await ctx.llm.resolveModelInfo('deepseek', 'plain')).imageRequest).toBeUndefined() - }) - - it.each([ - [{ id: 'm', inputModalities: ['text', 'image'], imageRequestMaxBytes: 0 }, 'must be a positive safe integer'], - [{ id: 'm', imageRequestMaxBytes: 4096 }, 'requires inputModalities to include "image"'], - ])('rejects an invalid imageRequestMaxBytes during load: %j', (model, reason) => { - const ctx = new Context() - const providers = [{ id: 'm', models: [model] }] as unknown as NonNullable - expect(() => { apply(ctx, { file, providers }) }).toThrow( - `llm-replay: provider "m" model "m" imageRequestMaxBytes ${reason}`, - ) - }) - it('rejects imageRequestTokens on a model without the image modality during load', () => { const ctx = new Context() const providers = [{ id: 'm', models: [{ id: 'm', imageRequestTokens: 384 }] }] as unknown as diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dadcc170fe..c4a36acc8f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1206,6 +1206,9 @@ importers: '@deepseek-ai/dsh-llm-deepseek': specifier: workspace:^ version: link:../../llm/llm-deepseek + '@deepseek-ai/dsh-llm-image-offload': + specifier: workspace:^ + version: link:../../llm/llm-image-offload '@deepseek-ai/dsh-llm-pi-ai': specifier: workspace:^ version: link:../../llm/llm-pi-ai @@ -1476,6 +1479,9 @@ importers: '@deepseek-ai/dsh-llm-deepseek': specifier: workspace:^ version: link:../../llm/llm-deepseek + '@deepseek-ai/dsh-llm-image-offload': + specifier: workspace:^ + version: link:../../llm/llm-image-offload '@deepseek-ai/dsh-llm-retry': specifier: workspace:^ version: link:../../llm/llm-retry @@ -6357,6 +6363,34 @@ importers: specifier: workspace:^ version: link:../../util/timeout + packages/llm/llm-image-offload: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../test-support/agent-loop-testkit + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection + packages/llm/llm-pi-ai: dependencies: '@deepseek-ai/dsh-brand': @@ -10189,6 +10223,9 @@ importers: '@deepseek-ai/dsh-llm-deepseek': specifier: workspace:^ version: link:../../packages/llm/llm-deepseek + '@deepseek-ai/dsh-llm-image-offload': + specifier: workspace:^ + version: link:../../packages/llm/llm-image-offload '@deepseek-ai/dsh-llm-pi-ai': specifier: workspace:^ version: link:../../packages/llm/llm-pi-ai diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index 97c1a6bc68..15837853fc 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -53,6 +53,7 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-llm-pi-ai": "workspace:^", + "@deepseek-ai/dsh-llm-image-offload": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-mcp-client": "workspace:^", "@deepseek-ai/dsh-output-retention": "workspace:^", diff --git a/snapshots/acp/acp.snapshot.ts b/snapshots/acp/acp.snapshot.ts index c602b80504..91ed36c539 100644 --- a/snapshots/acp/acp.snapshot.ts +++ b/snapshots/acp/acp.snapshot.ts @@ -40,11 +40,6 @@ const controllerCases: readonly { hasModelTurn: true, configPath: join(corpusDir, 'image-compaction', 'cordis.yml'), }, - { - name: 'image-offload', - hasModelTurn: true, - configPath: join(corpusDir, 'image-offload', 'cordis.yml'), - }, ] as const function localScenarioSource(source: string | undefined): string | undefined { diff --git a/snapshots/acp/image-offload/cordis.snapshot.yml b/snapshots/acp/image-offload/cordis.snapshot.yml deleted file mode 100644 index fbbaf80b53..0000000000 --- a/snapshots/acp/image-offload/cordis.snapshot.yml +++ /dev/null @@ -1,56 +0,0 @@ -# Keyless replay for the image-offload scenario. This profile patch swaps the -# adapter and re-pins the recorded vision model. The replay catalog declares -# image input and a base64 request-image budget that six 69-byte frames -# (92 base64 bytes each) exceed, so the agent loop appends an `image/offload` -# watermark before the first dispatch and the second turn sends placeholders. -- id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' - disabled: true - -- id: acp - name: '@deepseek-ai/dsh-acp' - config: - provider: deepseek-official - model: deepseek-v4-flash-vision-exp - -- id: session-persistence-jsonl - name: '@deepseek-ai/dsh-session-persistence-jsonl' - config: - root: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' - compression: none - -- id: agent-instructions - name: '@deepseek-ai/dsh-agent-instructions' - config: - maxBytes: 65536 - -- id: system-prompt - name: '@deepseek-ai/dsh-system-prompt' - config: - personaPrefix: | - You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. - - Verify your work by running the code or tests. Keep answers brief and factual. - -- insert: - - id: llm-replay - name: '@deepseek-ai/dsh-llm-replay' - config: - providers: - - id: deepseek-official - name: DeepSeek - models: - - id: deepseek-v4-flash - inputModalities: [text] - - id: deepseek-v4-pro - inputModalities: [text] - - id: deepseek-v4-flash-vision-exp - inputModalities: [text, image] - # A wide context window keeps automatic compaction out of the - # scenario; the base64 budget admits three of the six frames. - contextWindow: 200000 - imageRequestTokens: 384 - imageRequestMaxBytes: 300 - -- id: attachment-local - name: '@deepseek-ai/dsh-attachment-local' diff --git a/snapshots/acp/image-offload/cordis.yml b/snapshots/acp/image-offload/cordis.yml deleted file mode 100644 index 25f63462fb..0000000000 --- a/snapshots/acp/image-offload/cordis.yml +++ /dev/null @@ -1,33 +0,0 @@ -# Image-offload overlay: the image scenario with the shipped vision model and -# the durable attachment store the read_image tool commits through. The store -# resolves its root from $DSH_HOME, which the snapshot harness scopes per run, -# so the patch itself carries no attachment path. No compaction budget is -# tightened: the route's request-image budget alone drives the `image/offload` -# watermark this scenario pins. -- id: acp - name: '@deepseek-ai/dsh-acp' - config: - provider: deepseek-official - model: deepseek-v4-flash-vision-exp - -- id: session-persistence-jsonl - name: '@deepseek-ai/dsh-session-persistence-jsonl' - config: - root: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' - compression: none - -- id: agent-instructions - name: '@deepseek-ai/dsh-agent-instructions' - config: - maxBytes: 65536 - -- id: system-prompt - name: '@deepseek-ai/dsh-system-prompt' - config: - personaPrefix: | - You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. - - Verify your work by running the code or tests. Keep answers brief and factual. - -- id: attachment-local - name: '@deepseek-ai/dsh-attachment-local' diff --git a/snapshots/acp/image-offload/input.json b/snapshots/acp/image-offload/input.json deleted file mode 100644 index a6d653c5c2..0000000000 --- a/snapshots/acp/image-offload/input.json +++ /dev/null @@ -1,86 +0,0 @@ -{ - "steps": [ - { - "op": "initialize" - }, - { - "op": "newSession" - }, - { - "op": "promptContent", - "content": [ - { - "type": "text", - "text": "Here are six reference screenshots of the dashboard: " - }, - { - "type": "image", - "data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC", - "mimeType": "image/png" - }, - { - "type": "text", - "text": " (frame 1) " - }, - { - "type": "image", - "data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC", - "mimeType": "image/png" - }, - { - "type": "text", - "text": " (frame 2) " - }, - { - "type": "image", - "data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC", - "mimeType": "image/png" - }, - { - "type": "text", - "text": " (frame 3) " - }, - { - "type": "image", - "data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC", - "mimeType": "image/png" - }, - { - "type": "text", - "text": " (frame 4) " - }, - { - "type": "image", - "data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC", - "mimeType": "image/png" - }, - { - "type": "text", - "text": " (frame 5) " - }, - { - "type": "image", - "data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC", - "mimeType": "image/png" - }, - { - "type": "text", - "text": " (frame 6) " - }, - { - "type": "text", - "text": "Acknowledge receipt briefly; we will discuss them next." - } - ] - }, - { - "op": "promptContent", - "content": [ - { - "type": "text", - "text": "Now reply with exactly the single word DONE." - } - ] - } - ] -} diff --git a/snapshots/acp/image-offload/session.v2.jsonl b/snapshots/acp/image-offload/session.v2.jsonl deleted file mode 100644 index c02eca29cb..0000000000 --- a/snapshots/acp/image-offload/session.v2.jsonl +++ /dev/null @@ -1,25 +0,0 @@ -{"type":"session","version":2,"id":"{{session:1}}","createdAt":1783952000000,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0} -{"type":"permission/preset","data":{"preset":"danger-full-access"}} -{"type":"sandbox/mode","data":{"mode":"danger-full-access"}} -{"type":"approval/policy","data":{"policy":"never"}} -{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Here are six reference screenshots of the dashboard: "},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"text","text":" (frame 1) "},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"text","text":" (frame 2) "},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"text","text":" (frame 3) "},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"text","text":" (frame 4) "},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"text","text":" (frame 5) "},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"text","text":" (frame 6) Acknowledge receipt briefly; we will discuss them next."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]}} -{"type":"turn/start","data":{"turn":1}} -{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"step/start","data":{"turn":1,"step":1}} -{"type":"user/message","data":{"content":[{"type":"text","text":"Here are six reference screenshots of the dashboard: "},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"text","text":" (frame 1) "},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"text","text":" (frame 2) "},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"text","text":" (frame 3) "},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"text","text":" (frame 4) "},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"text","text":" (frame 5) "},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"text","text":" (frame 6) Acknowledge receipt briefly; we will discuss them next."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{message:2}}"},"surfaceOp":"append"} -{"type":"session/title","data":{"title":"Here are six reference screenshots","messageSeqs":[7],"source":{"kind":"fallback"}}} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp","contextWindow":200000}} -{"type":"image/offload","data":{"turn":1,"step":1,"watermark":{"seq":7,"path":[5]}}} -{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"Received six dashboard frames; three of them are already offloaded."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:3}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Received six dashboard frames; three of them are already offloaded."}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} -{"type":"step/end","data":{"turn":1,"step":1}} -{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} -{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Now reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:4}}"}]}} -{"type":"turn/start","data":{"turn":2}} -{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"step/start","data":{"turn":2,"step":1}} -{"type":"user/message","data":{"content":[{"type":"text","text":"Now reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:4}}"},"surfaceOp":"append"} -{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:5}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} -{"type":"step/end","data":{"turn":2,"step":1}} -{"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/snapshots/acp/image-offload/snapshot.yml b/snapshots/acp/image-offload/snapshot.yml deleted file mode 100644 index 06616f2cef..0000000000 --- a/snapshots/acp/image-offload/snapshot.yml +++ /dev/null @@ -1,16 +0,0 @@ -version: 1 -scenario: image-offload -profile: acp -composition: image-offload -recording: authored -header: - class: image-offload - pin: true - systemPromptSource: image-compaction - toolSchemasSource: escalation-approved -permission: danger-full-access -input: - attachments: - - id: sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640 - mediaType: image/png - data: iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElFTkSuQmCC diff --git a/snapshots/acp/image-offload/stdout.expected.jsonl b/snapshots/acp/image-offload/stdout.expected.jsonl deleted file mode 100644 index 5fce11218a..0000000000 --- a/snapshots/acp/image-offload/stdout.expected.jsonl +++ /dev/null @@ -1,8 +0,0 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"mcpCapabilities":{"http":true},"promptCapabilities":{"image":true,"audio":false,"embeddedContext":false},"sessionCapabilities":{"close":{},"list":{},"resume":{}}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash-vision-exp\"]","options":[{"group":"deepseek-official","name":"DeepSeek","options":[{"value":"[\"deepseek-official\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek-official\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"},{"value":"[\"deepseek-official\",\"deepseek-v4-flash-vision-exp\"]","name":"deepseek-v4-flash-vision-exp"}]}]}]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"Received six dashboard frames; three of them are already offloaded."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"usage_update","used":"{{usedTokens}}","size":200000}}} -{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"DONE"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"usage_update","used":"{{usedTokens}}","size":200000}}} -{"jsonrpc":"2.0","id":4,"result":{"stopReason":"end_turn"}} diff --git a/snapshots/sdk/inline-image-prompt/cordis.snapshot.yml b/snapshots/sdk/inline-image-prompt/cordis.snapshot.yml deleted file mode 100644 index 1545f9755c..0000000000 --- a/snapshots/sdk/inline-image-prompt/cordis.snapshot.yml +++ /dev/null @@ -1,40 +0,0 @@ -# Keyless SDK replay with a request-image budget that six 69-byte images -# exceed after base64 expansion. -- id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' - disabled: true - -- id: agent-default-model - name: '@deepseek-ai/dsh-agent-default-model' - config: - provider: deepseek-official - model: deepseek-v4-flash-vision-exp - -- id: system-prompt - name: '@deepseek-ai/dsh-system-prompt' - config: - personaPrefix: | - You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. - - Verify your work by running the code or tests. Keep answers brief and factual. - -- insert: - - id: llm-replay - name: '@deepseek-ai/dsh-llm-replay' - config: - providers: - - id: deepseek-official - name: DeepSeek - models: - - id: deepseek-v4-flash - inputModalities: [text] - - id: deepseek-v4-pro - inputModalities: [text] - - id: deepseek-v4-flash-vision-exp - inputModalities: [text, image] - contextWindow: 200000 - imageRequestTokens: 384 - imageRequestMaxBytes: 300 - -- id: attachment-local - name: '@deepseek-ai/dsh-attachment-local' diff --git a/snapshots/sdk/inline-image-prompt/cordis.yml b/snapshots/sdk/inline-image-prompt/cordis.yml deleted file mode 100644 index cddbe6e689..0000000000 --- a/snapshots/sdk/inline-image-prompt/cordis.yml +++ /dev/null @@ -1,18 +0,0 @@ -# SDK image-offload overlay: selects the shipped vision route and installs the -# durable attachment store used by SDK image prompt blocks. -- id: agent-default-model - name: '@deepseek-ai/dsh-agent-default-model' - config: - provider: deepseek-official - model: deepseek-v4-flash-vision-exp - -- id: system-prompt - name: '@deepseek-ai/dsh-system-prompt' - config: - personaPrefix: | - You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. - - Verify your work by running the code or tests. Keep answers brief and factual. - -- id: attachment-local - name: '@deepseek-ai/dsh-attachment-local' diff --git a/snapshots/sdk/inline-image-prompt/session.v2.jsonl b/snapshots/sdk/inline-image-prompt/session.v2.jsonl index 71bcd1b56b..612b5a2983 100644 --- a/snapshots/sdk/inline-image-prompt/session.v2.jsonl +++ b/snapshots/sdk/inline-image-prompt/session.v2.jsonl @@ -10,7 +10,8 @@ {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{message:2}}"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Inspect these images, then reply","messageSeqs":[7],"source":{"kind":"fallback"}}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp","contextWindow":200000}} +{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"}} +{"type":"assistant/attempt","data":{"turn":1,"step":1,"stream":[{"type":"chunk","time":1788752482896,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"DeepSeek base64 request images exceed the route budget; 3 more oldest occurrence(s) must be offloaded.","code":"IMAGE_OFFLOAD_REQUIRED","offloadImages":3}}}}]}} {"type":"image/offload","data":{"turn":1,"step":1,"watermark":{"seq":7,"path":[3]}}} {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:3}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1788752482896,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"chunk","time":1788752482896,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1788752482896,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1788752482896,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} diff --git a/snapshots/sdk/inline-image-prompt/snapshot.yml b/snapshots/sdk/inline-image-prompt/snapshot.yml index c76ca9777b..bf4930032e 100644 --- a/snapshots/sdk/inline-image-prompt/snapshot.yml +++ b/snapshots/sdk/inline-image-prompt/snapshot.yml @@ -1,13 +1,10 @@ version: 1 scenario: inline-image-prompt profile: sdk -composition: image-offload-sdk +composition: image recording: authored header: class: image - pin: true - systemPromptSource: session/read-image - toolSchemasSource: session/text-turn input: attachments: - id: sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640 diff --git a/tsconfig.base.json b/tsconfig.base.json index 64a17b2c50..8e2d5adf79 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -297,6 +297,7 @@ "@deepseek-ai/dsh-llm": ["./packages/llm/llm/src"], "@deepseek-ai/dsh-llm/invariant": ["./packages/llm/llm/src/invariant.ts"], "@deepseek-ai/dsh-llm-deepseek": ["./packages/llm/llm-deepseek/src"], + "@deepseek-ai/dsh-llm-image-offload": ["./packages/llm/llm-image-offload/src"], "@deepseek-ai/dsh-llm-mock-server": ["./packages/test-support/llm-mock-server/src"], "@deepseek-ai/dsh-llm-pi-ai": ["./packages/llm/llm-pi-ai/src"], "@deepseek-ai/dsh-llm-replay": ["./packages/test-support/llm-replay/src"], diff --git a/tsconfig.host.json b/tsconfig.host.json index a6817dce02..bf1ba85522 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -225,6 +225,7 @@ { "path": "./packages/context/agent-instructions" }, { "path": "./packages/core/agent-loop" }, { "path": "./packages/llm/llm-retry" }, + { "path": "./packages/llm/llm-image-offload" }, { "path": "./packages/subprocess/subprocess" }, { "path": "./packages/subprocess/subprocess-local" }, { "path": "./packages/subprocess/win32-process" }, From fcb976d3dc639beea216d487f70cb1af5de01090 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 7 Sep 2026 17:03:23 +0800 Subject: [PATCH 08/79] refactor(llm): recover IMAGE_OFFLOAD_REQUIRED in llm-retry instead of a new plugin package Remove the llm-image-offload package and every registration it needed. The recovery branch lives in dsh-llm-retry, which already sits on the agent/request-error waterfall: an IMAGE_OFFLOAD_REQUIRED failure advances the image/offload watermark by the count the adapter names and retries the step outside the provider retry budget. Drop pre-dispatch planning with it: routes no longer declare a request-image budget on their model info, and the byte checks that stay in the DeepSeek and pi-ai adapters are provisional until the provider reports uncacheable images itself. --- ...26-09-02-image-offload-watermark.i18n.yaml | 4 +- .../2026-09-02-image-offload-watermark.md | 10 +- .../2026-09-02-image-offload-watermark.zh.md | 10 +- apps/cli/composition.md | 3 - docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 15 +-- docs/config-catalog.zh.md | 15 +-- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 4 +- docs/event-producer-consumer.zh.md | 4 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 5 - docs/module-graph.zh.md | 5 - docs/subsystems/llm-streaming.i18n.yaml | 4 +- docs/subsystems/llm-streaming.md | 6 +- docs/subsystems/llm-streaming.zh.md | 6 +- docs/subsystems/session.i18n.yaml | 4 +- docs/subsystems/session.md | 2 +- docs/subsystems/session.zh.md | 2 +- packages/bundle/base/cordis.patch.yml | 3 - packages/bundle/base/package.json | 1 - packages/bundle/sdk-minimal/cordis.patch.yml | 3 - packages/bundle/sdk-minimal/package.json | 1 - .../sdk-minimal/tests/sdk-minimal.spec.ts | 1 - packages/core/session/README.i18n.yaml | 4 +- packages/core/session/README.md | 2 +- packages/core/session/README.zh.md | 2 +- .../extensions/tool-cordis/src/api-catalog.ts | 6 +- packages/llm/README.i18n.yaml | 4 +- packages/llm/README.md | 3 +- packages/llm/README.zh.md | 3 +- packages/llm/llm-deepseek/README.i18n.yaml | 4 +- packages/llm/llm-deepseek/README.md | 4 +- packages/llm/llm-deepseek/README.zh.md | 4 +- packages/llm/llm-deepseek/src/adapter.ts | 18 +-- .../llm/llm-deepseek/tests/adapter.spec.ts | 24 ---- .../llm-deepseek/tests/dynamic-config.spec.ts | 5 +- .../llm/llm-image-offload/README.i18n.yaml | 6 - packages/llm/llm-image-offload/README.md | 106 ----------------- packages/llm/llm-image-offload/README.zh.md | 106 ----------------- packages/llm/llm-image-offload/package.json | 46 ------- packages/llm/llm-image-offload/src/index.ts | 112 ------------------ packages/llm/llm-image-offload/tsconfig.json | 30 ----- .../llm/llm-image-offload/tsdown.config.ts | 13 -- packages/llm/llm-pi-ai/README.i18n.yaml | 4 +- packages/llm/llm-pi-ai/README.md | 4 +- packages/llm/llm-pi-ai/README.zh.md | 4 +- packages/llm/llm-pi-ai/src/adapter.ts | 9 -- packages/llm/llm-retry/README.i18n.yaml | 4 +- packages/llm/llm-retry/README.md | 9 +- packages/llm/llm-retry/README.zh.md | 9 +- packages/llm/llm-retry/src/image-offload.ts | 44 +++++++ packages/llm/llm-retry/src/index.ts | 7 ++ .../tests/image-offload.spec.ts | 110 ++++------------- packages/llm/llm/README.i18n.yaml | 4 +- packages/llm/llm/README.md | 2 +- packages/llm/llm/README.zh.md | 2 +- packages/llm/llm/src/content.ts | 17 ++- packages/llm/llm/src/error.ts | 4 +- packages/llm/llm/src/index.ts | 2 - packages/llm/llm/src/types.ts | 19 +-- packages/llm/llm/tests/content.spec.ts | 5 +- pnpm-lock.yaml | 37 ------ python/sdk-runtime/package.json | 1 - tsconfig.base.json | 1 - tsconfig.host.json | 1 - 66 files changed, 166 insertions(+), 755 deletions(-) delete mode 100644 packages/llm/llm-image-offload/README.i18n.yaml delete mode 100644 packages/llm/llm-image-offload/README.md delete mode 100644 packages/llm/llm-image-offload/README.zh.md delete mode 100644 packages/llm/llm-image-offload/package.json delete mode 100644 packages/llm/llm-image-offload/src/index.ts delete mode 100644 packages/llm/llm-image-offload/tsconfig.json delete mode 100644 packages/llm/llm-image-offload/tsdown.config.ts create mode 100644 packages/llm/llm-retry/src/image-offload.ts rename packages/llm/{llm-image-offload => llm-retry}/tests/image-offload.spec.ts (51%) diff --git a/.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.i18n.yaml index 0d62c2c804..941b5ec3ed 100644 --- a/.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.md -2026-09-02-image-offload-watermark.md: 2b682cd3f3de19c47c0c38f986dd430f86a1ca51 -2026-09-02-image-offload-watermark.zh.md: 6a07f8f37b33fa08898adba46c7a8ca9d8bf432b +2026-09-02-image-offload-watermark.md: f178ea96ae982d89b575795a93e7321fed7afad6 +2026-09-02-image-offload-watermark.zh.md: cdd92011ce2ee3b80c5ea3641988cec1d2e1a80c diff --git a/.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.md b/.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.md index 2b682cd3f3..f178ea96ae 100644 --- a/.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.md +++ b/.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.md @@ -20,9 +20,9 @@ The offload point is a durable session fact: the core `image/offload` event reco **Only advances.** The watermark never retreats when a budget grows, a route changes, or compaction lowers the total, so the model-visible prefix and the provider cache prefix move only forward. An occurrence below the watermark that compaction later shadows leaves the watermark valid, because the comparison is positional. -**Decision owned by one plugin, budgets declared by routes.** An image-capable route declares an `LlmImageRequestBudget` (`representation`, `maxBytes`, `maxImages`, both quanta, and the request-version byte target) as `imageRequest` on its `LlmResolvedModelInfo`. The `dsh-llm-image-offload` plugin listens on `agent/pre-step`: once downstream listeners have entered the step, it resolves the budget of the route the latest `request/header` names through `ctx.llm.resolveModelInfo()`, collects retained occurrences in model request order, and plans the removal prefix with the pure `offloadedImagePrefixCount()` (represented bytes are the normalized count clamped to the version target and base64-expanded for inline routes, removed in whole quanta). A surface replacement can put a newer event before older events; the plugin converts the request prefix to the greatest durable position in that prefix, which may omit additional occurrences but always satisfies the requested removal. The event lands before `step/start`, so the loop's ordinary derivation sends the offloaded set. Before the first header, and for occurrences the entering step itself appends, no plan runs; the adapter failure below covers them. The DeepSeek adapter declares its file-mode budget and the pi-ai adapter its base64 bound; the agent loop is unchanged. +**Decision owned by `dsh-llm-retry`, budgets enforced by routes.** An image-capable route enforces an `LlmImageRequestBudget` (`representation`, `maxBytes`, `maxImages`, both quanta) over its retained occurrences' exact request-version bytes and reports the overflow as a failure; nothing plans an offload before dispatch. `dsh-llm-retry`, the recovery executor already on the `agent/request-error` waterfall, handles `IMAGE_OFFLOAD_REQUIRED` before any provider policy: it walks the surface in model request order, converts the reported count to the greatest durable position in that request-order prefix (a surface replacement can put a newer event before older ones, so the advance may offload additional occurrences but always covers the prefix), appends `image/offload`, and returns the `retry` action without spending the provider retry budget or logging `llm/retry`. The loop re-runs the step over the derived surface; the agent loop is unchanged. -**Adapters project, never decide.** Serialization renders every `offloaded` block as `offloadedImageText` with the currently resolved access path and prepares only retained occurrences. When the retained occurrences' exact request-version bytes still exceed the route budget, in file mode, under the inline fallback's tighter budget, or under the pi-ai bound, the adapter fails the attempt with `IMAGE_OFFLOAD_REQUIRED` and `LlmFailure.offloadImages` naming how many more oldest occurrences must be offloaded, computed with the shared `requiredImageOffload()`. The plugin's `agent/request-error` listener advances the watermark by that count and returns the `retry` action, so the loop re-runs the step over the derived surface; when nothing remains to offload the listener delegates and the failure reaches ordinary recovery. +**Adapters project, never decide.** Serialization renders every `offloaded` block as `offloadedImageText` with the currently resolved access path and prepares only retained occurrences. When the retained occurrences' exact request-version bytes still exceed the route budget, in file mode, under the inline fallback's tighter budget, or under the pi-ai bound, the adapter fails the attempt with `IMAGE_OFFLOAD_REQUIRED` and `LlmFailure.offloadImages` naming how many more oldest occurrences must be offloaded, computed with the shared `requiredImageOffload()`. When nothing remains to offload, `dsh-llm-retry` delegates and the failure reaches ordinary recovery. **Token accounting.** `priceImages` receives surface `ImageBlock`s and prices an `offloaded` one as its placeholder text; the DeepSeek and replay pricing no longer reproduce any offload arithmetic. The meter folds `image/offload` into its replay state, prices the current surface under the current watermark and each usage anchor under the watermark its request was derived with. Provider usage remains the anchor for completed requests. @@ -40,16 +40,16 @@ The offload point is a durable session fact: the core `image/offload` event reco **Let each adapter append the event.** The adapter owns the budgets but not the session surface; a surface fact appended below the loop bypasses the session's derived history and lets two adapters define the surface differently. Adapters report the count they need instead. -**Plan and advance inside the agent loop.** The loop knows the exact prepared route before deriving each request, so planning there never spends a failed attempt. It also puts a route-specific policy into the one component every profile shares, changes the documented step order, and bypasses the `agent/request-error` waterfall that context-overflow compaction and retry already use for the same recover-and-retry pattern. A plugin on the existing `agent/pre-step` and `agent/request-error` extension points costs one failed attempt when a step's own messages or an unknown route push the retained set past the budget, and keeps the loop unchanged. +**Plan the offload before dispatch, in the loop or in a plugin.** The loop knows the exact prepared route before deriving each request, so planning there never spends a failed attempt; but it puts a route-specific policy into the one component every profile shares, changes the documented step order, and bypasses the `agent/request-error` waterfall that context-overflow compaction and retry already use for the same repair-and-retry pattern. A pre-step plugin keeps the loop unchanged but cannot see the step's own messages or the first request's route, so the failure path stays necessary, and the plan needs every route to declare its budget on its model info. Handling only the failure costs one attempt per quantum crossing (64 MiB in DeepSeek file mode, 20 MiB on pi-ai) and matches the planned direction: routes will stop checking sizes locally, send every image, and the provider will report what it cannot cache, which is exactly a failure naming an offload point. **Keep a transient extra offload for the inline fallback and exact-byte overflow.** Would have sent an unlogged projection in exactly the cases the invariant exists for; the failure-and-advance path costs one serialization attempt and keeps every dispatched request derivable from the log. ## Consequences -Offloaded images never return automatically when budgets grow, a larger route is selected, or compaction lowers the total; recovery is the read-only path in the placeholder, which the model uses deliberately. A Files outage or a temporary switch to a small-budget route advances the watermark permanently; both are accepted for the same reason. +Offloaded images never return automatically when budgets grow, a larger route is selected, or compaction lowers the total; recovery is the read-only path in the placeholder, which the model uses deliberately. A Files outage or a temporary switch to a small-budget route advances the watermark permanently; both are accepted for the same reason. The route-local byte checks are provisional: once the provider reports uncacheable images itself, `requiredImageOffload()` and the route budgets go away while the event, the derivation, and the recovery branch stay as they are. Every dispatched request's image set is determined by the log alone, across file mode, inline fallback, resume, fork, retry, and compaction, and the provider cache prefix no longer oscillates. The execution-world access path embedded in placeholder and handle text is still resolved at serialization time; that gap exists for retained images too and belongs to a separate decision about recording the execution-world mapping. ## Testing -`packages/llm/llm/tests/content.spec.ts` pins arbitrary-depth image traversal, represented bytes, projection, and the prefix count, including the 129-to-64 MiB quantum example. `packages/core/session/tests/image-offload.spec.ts` pins append and seed validation, exact current-surface image paths, rejection of persisted derived markers, strict advance, arbitrary-depth marking, frozen copies, cache rebuild, and scratch-replay equality. `packages/llm/llm-image-offload/tests/image-offload.spec.ts` pins the pre-step advance, the non-retreating watermark, request-order counts after a surface replacement, the `IMAGE_OFFLOAD_REQUIRED` advance-and-retry path, and the exhausted case that delegates downstream. Compaction tests pin watermark application to direct summarization input. Adapter specs pin placeholder projection, prepared-only-retained reads, and the exact-byte failure with its count; `route-pricing.spec.ts` pins watermark pricing. The `inline-image-prompt` TypeScript SDK snapshot replays an authored `IMAGE_OFFLOAD_REQUIRED` attempt through the shipped profile and pins the advance and the retried request. The Python SDK notification test pins lossless forwarding of the new event and its nested watermark. +`packages/llm/llm/tests/content.spec.ts` pins arbitrary-depth image traversal, represented bytes, projection, and the prefix count, including the 129-to-64 MiB quantum example. `packages/core/session/tests/image-offload.spec.ts` pins append and seed validation, exact current-surface image paths, rejection of persisted derived markers, strict advance, arbitrary-depth marking, frozen copies, cache rebuild, and scratch-replay equality. `packages/llm/llm-retry/tests/image-offload.spec.ts` pins the `IMAGE_OFFLOAD_REQUIRED` advance-and-retry path without a retry event, request-order counts after a surface replacement, and the exhausted case that delegates downstream. Compaction tests pin watermark application to direct summarization input. Adapter specs pin placeholder projection, prepared-only-retained reads, and the exact-byte failure with its count; `route-pricing.spec.ts` pins watermark pricing. The `inline-image-prompt` TypeScript SDK snapshot replays an authored `IMAGE_OFFLOAD_REQUIRED` attempt through the shipped profile and pins the advance and the retried request. The Python SDK notification test pins lossless forwarding of the new event and its nested watermark. diff --git a/.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.zh.md b/.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.zh.md index 6a07f8f37b..cdd92011ce 100644 --- a/.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.zh.md @@ -20,9 +20,9 @@ offload 位置是持久的会话事实:核心事件 `image/offload` 记录一 **只前进。** 预算变大、路由切换或 compaction 降低总量时水位永不回退,所以模型可见前缀和 provider 缓存前缀只向前移动。水位之下的出现位置后来被 compaction 遮蔽也不影响水位有效性,因为比较是按位置进行的。 -**决定权在一个插件,预算由路由声明。** 支持图片的路由在其 `LlmResolvedModelInfo` 上以 `imageRequest` 声明一个 `LlmImageRequestBudget`(`representation`、`maxBytes`、`maxImages`、两个量子与请求版本字节目标)。`dsh-llm-image-offload` 插件监听 `agent/pre-step`:下游监听器决定进入 step 后,它通过 `ctx.llm.resolveModelInfo()` 解析最新 `request/header` 所指路由的预算,按模型请求顺序收集保留的出现位置,用纯函数 `offloadedImagePrefixCount()` 规划删除前缀(表示字节是归一化字节数按版本目标截断后的值,内联路由再按 base64 展开,按整量子删除)。表层替换可能把较新的事件放到较老事件之前;插件把请求前缀换算成其中最大的持久位置,因此可能额外省略一些出现位置,但一定满足要求的删除量。事件落在 `step/start` 之前,循环的常规派生因此发送已省略的集合。第一个 header 之前,以及进入的 step 自己追加的出现位置,不做规划,由下面的 adapter 失败路径覆盖。DeepSeek adapter 声明其 file 模式预算,pi-ai adapter 声明其 base64 上限;agent loop 不变。 +**决定权在 `dsh-llm-retry`,预算由路由执行。** 支持图片的路由按保留的出现位置的精确请求版本字节执行一个 `LlmImageRequestBudget`(`representation`、`maxBytes`、`maxImages` 与两个量子),超出时以失败上报;发送前没有任何规划。`dsh-llm-retry` 已经是 `agent/request-error` waterfall 上的恢复执行器,它在任何提供方策略之前处理 `IMAGE_OFFLOAD_REQUIRED`:按模型请求顺序遍历表层,把上报的数量换算成该请求序前缀中最大的持久位置(表层替换可能把较新的事件放到较老事件之前,推进因此可能额外省略一些出现位置,但一定覆盖该前缀),追加 `image/offload`,返回 `retry` 动作,不占提供方重试预算,也不记录 `llm/retry`。循环在派生表层上重跑该 step;agent loop 不变。 -**adapter 只投影,不决定。** 序列化把每个 `offloaded` 块渲染为带当前已解析访问路径的 `offloadedImageText`,只准备保留的出现位置。当保留的出现位置按精确请求版本字节仍超过路由预算,无论是 file 模式、内联回退更紧的预算还是 pi-ai 上限,adapter 都以 `IMAGE_OFFLOAD_REQUIRED` 让本次尝试失败,并在 `LlmFailure.offloadImages` 中用共享的 `requiredImageOffload()` 算出还需省略多少最老的出现位置。插件的 `agent/request-error` 监听器按该数量推进水位并返回 `retry` 动作,循环据此在派生表层上重跑该 step;没有可省略的出现位置时,监听器向下游委托,失败进入普通恢复路径。 +**adapter 只投影,不决定。** 序列化把每个 `offloaded` 块渲染为带当前已解析访问路径的 `offloadedImageText`,只准备保留的出现位置。当保留的出现位置按精确请求版本字节仍超过路由预算,无论是 file 模式、内联回退更紧的预算还是 pi-ai 上限,adapter 都以 `IMAGE_OFFLOAD_REQUIRED` 让本次尝试失败,并在 `LlmFailure.offloadImages` 中用共享的 `requiredImageOffload()` 算出还需省略多少最老的出现位置。没有可省略的出现位置时,`dsh-llm-retry` 向下游委托,失败进入普通恢复路径。 **token 记账。** `priceImages` 接收表层的 `ImageBlock`,把 `offloaded` 的按占位文本定价;DeepSeek 和 replay 的定价不再复现任何 offload 算术。meter 把 `image/offload` 折进其重放状态,按当前水位为当前表层定价,按每个 usage 锚点的请求派生时的水位为该锚点定价。已完成请求仍以 provider usage 为锚点。 @@ -40,16 +40,16 @@ offload 位置是持久的会话事实:核心事件 `image/offload` 记录一 **让各个 adapter 自己追加事件。** adapter 拥有预算,但不拥有会话表层;在循环之下追加表层事实绕过了会话的派生历史,也会让两个 adapter 对表层做出不同定义。adapter 改为上报它需要的数量。 -**在 agent loop 内部规划并推进。** 循环在派生每个请求之前就知道精确的已准备路由,在那里规划永远不会多花一次失败的尝试。但这会把一条路由专属的策略放进所有 profile 共用的那个组件,改变已记录的 step 顺序,还绕过了上下文溢出 compaction 和重试已经在用的同一套 `agent/request-error` waterfall。放在现有 `agent/pre-step` 与 `agent/request-error` 扩展点上的插件,只在 step 自己的消息或未知路由把保留集合推过预算时多花一次失败的尝试,循环保持不变。 +**在发送前规划省略,无论放在循环里还是插件里。** 循环在派生每个请求之前就知道精确的已准备路由,在那里规划永远不会多花一次失败的尝试;但这会把一条路由专属的策略放进所有 profile 共用的那个组件,改变已记录的 step 顺序,还绕过了上下文溢出 compaction 和重试已经在用的同一套 `agent/request-error` waterfall。pre-step 插件不改循环,但看不到 step 自己的消息和第一个请求的路由,失败路径仍然必需,而且规划要求每条路由在模型信息上声明预算。只处理失败的代价是每越过一个量子多一次尝试(DeepSeek file 模式 64 MiB,pi-ai 20 MiB),并且和既定方向一致:路由将不再本地检查大小,全部发送,由 provider 报告无法缓存的部分,那正是一个指明省略点的失败。 **为内联回退和精确字节溢出保留临时的额外省略。** 恰好会在不变量所针对的场景发送未记录的投影;失败再推进的路径只多花一次序列化尝试,且让每个已发出请求都可由日志派生。 ## 后果 -预算变大、选中更大的路由或 compaction 降低总量时,被省略的图片不会自动回归;恢复手段是占位文本中的只读路径,模型需要时主动使用。一次 Files 故障或临时切到小预算路由会永久推进水位,两者出于同一理由被接受。 +预算变大、选中更大的路由或 compaction 降低总量时,被省略的图片不会自动回归;恢复手段是占位文本中的只读路径,模型需要时主动使用。一次 Files 故障或临时切到小预算路由会永久推进水位,两者出于同一理由被接受。路由本地的字节检查是过渡实现:provider 自己报告无法缓存的图片之后,`requiredImageOffload()` 和路由预算会被删掉,事件、派生和恢复分支保持不变。 每个已发出请求的图片集合仅由日志决定,覆盖 file 模式、内联回退、resume、fork、retry 和 compaction,provider 缓存前缀不再抖动。占位和句柄文本中嵌入的执行世界访问路径仍在序列化时解析,这个缺口对保留的图片同样存在,属于另一个关于记录执行世界映射的决定。 ## 测试 -`packages/llm/llm/tests/content.spec.ts` 钉住任意深度的图片遍历、表示字节、投影和前缀计数,包括 129 到 64 MiB 的量子示例。`packages/core/session/tests/image-offload.spec.ts` 钉住追加与 seed 校验、当前表层图片路径、拒绝持久化派生标记、严格推进、任意深度标记、冻结副本、缓存重建和从头重放的一致性。`packages/llm/llm-image-offload/tests/image-offload.spec.ts` 钉住 step 前推进、不回退的水位、表层替换后的请求顺序计数、`IMAGE_OFFLOAD_REQUIRED` 的推进并重试路径以及向下游委托的耗尽情况。compaction 测试钉住直接摘要输入中的水位应用。adapter 测试钉住占位投影、只读取保留图片以及带数量的精确字节失败;`route-pricing.spec.ts` 钉住水位定价。`inline-image-prompt` TypeScript SDK 快照通过发布的 profile 重放一次手工编写的 `IMAGE_OFFLOAD_REQUIRED` 尝试,钉住推进和重试后的请求。Python SDK 通知测试钉住新事件及其嵌套水位的无损转发。 +`packages/llm/llm/tests/content.spec.ts` 钉住任意深度的图片遍历、表示字节、投影和前缀计数,包括 129 到 64 MiB 的量子示例。`packages/core/session/tests/image-offload.spec.ts` 钉住追加与 seed 校验、当前表层图片路径、拒绝持久化派生标记、严格推进、任意深度标记、冻结副本、缓存重建和从头重放的一致性。`packages/llm/llm-retry/tests/image-offload.spec.ts` 钉住 `IMAGE_OFFLOAD_REQUIRED` 推进并重试且不追加重试事件的路径、表层替换后的请求顺序计数,以及向下游委托的耗尽情况。compaction 测试钉住直接摘要输入中的水位应用。adapter 测试钉住占位投影、只读取保留图片以及带数量的精确字节失败;`route-pricing.spec.ts` 钉住水位定价。`inline-image-prompt` TypeScript SDK 快照通过发布的 profile 重放一次手工编写的 `IMAGE_OFFLOAD_REQUIRED` 尝试,钉住推进和重试后的请求。Python SDK 通知测试钉住新事件及其嵌套水位的无损转发。 diff --git a/apps/cli/composition.md b/apps/cli/composition.md index 2f838adc76..406edb4557 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -42,8 +42,6 @@ flowchart LR cfg --> plugin_dsh_base_jobs plugin_dsh_base_llm_retry["llm-retry
@deepseek-ai/dsh-llm-retry"] cfg --> plugin_dsh_base_llm_retry - plugin_dsh_base_llm_image_offload["llm-image-offload
@deepseek-ai/dsh-llm-image-offload"] - cfg --> plugin_dsh_base_llm_image_offload plugin_dsh_base_settings["settings
@deepseek-ai/dsh-settings-file"] cfg --> plugin_dsh_base_settings plugin_dsh_base_credentials["credentials
@deepseek-ai/dsh-credentials-local"] @@ -199,7 +197,6 @@ flowchart LR | `agent-default-model` | `@deepseek-ai/dsh-agent-default-model` | | `jobs` | `@deepseek-ai/dsh-jobs-local` | | `llm-retry` | `@deepseek-ai/dsh-llm-retry` | -| `llm-image-offload` | `@deepseek-ai/dsh-llm-image-offload` | | `settings` | `@deepseek-ai/dsh-settings-file` | | `credentials` | `@deepseek-ai/dsh-credentials-local` | | `llm-pi-ai` | `@deepseek-ai/dsh-llm-pi-ai` | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index c2e4a176c5..6a50dc41cf 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: d31ccd412ec108b54de5162590ed0951e8d36739 -config-catalog.zh.md: 649a510b7feedf0708c109d9ed838745306528e5 +config-catalog.md: fd30c7391f96221544f1d8b608923e51dd84b623 +config-catalog.zh.md: 6ec95d0e76d687e82f9cfb2fa29ab13e20bcd7db diff --git a/docs/config-catalog.md b/docs/config-catalog.md index d31ccd412e..fd30c7391f 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1031,19 +1031,6 @@ Depends on: [`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicy Source: [`packages/llm/llm-deepseek/src/index.ts:125`](../packages/llm/llm-deepseek/src/index.ts) - - -## `@deepseek-ai/dsh-llm-image-offload` - -Requires: `llm` · `agents` - -```ts config-catalog -/** The plugin has no configuration; image-capable routes declare their budgets. */ -export type Config = Readonly> -``` - -Source: [`packages/llm/llm-image-offload/src/index.ts:25`](../packages/llm/llm-image-offload/src/index.ts) - ## `@deepseek-ai/dsh-llm-pi-ai` @@ -1407,7 +1394,7 @@ Requires: `agents` · `sessionProjections` export type Config = Readonly> ``` -Source: [`packages/llm/llm-retry/src/index.ts:25`](../packages/llm/llm-retry/src/index.ts) +Source: [`packages/llm/llm-retry/src/index.ts:27`](../packages/llm/llm-retry/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 649a510b7f..6ec95d0e76 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1033,19 +1033,6 @@ export interface DeepSeekCatalogModel { 来源:[`packages/llm/llm-deepseek/src/index.ts:125`](../packages/llm/llm-deepseek/src/index.ts) - - -## `@deepseek-ai/dsh-llm-image-offload` - -需要:`llm` · `agents` - -```ts config-catalog -/** The plugin has no configuration; image-capable routes declare their budgets. */ -export type Config = Readonly> -``` - -来源:[`packages/llm/llm-image-offload/src/index.ts:25`](../packages/llm/llm-image-offload/src/index.ts) - ## `@deepseek-ai/dsh-llm-pi-ai` @@ -1409,7 +1396,7 @@ export interface ReplayModelConfig { export type Config = Readonly> ``` -来源:[`packages/llm/llm-retry/src/index.ts:25`](../packages/llm/llm-retry/src/index.ts) +来源:[`packages/llm/llm-retry/src/index.ts:27`](../packages/llm/llm-retry/src/index.ts) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 45aa601073..2ff3b1bb3f 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/event-producer-consumer.md -event-producer-consumer.md: 4911452782d5bb26d214e86c71cf24f27bac0fd4 -event-producer-consumer.zh.md: 8f08cc98d05d2a96b81e49b6fd67b308c0bfc5dc +event-producer-consumer.md: 1f8bb7772f3941e69f09425e4ed54e1f424a2e99 +event-producer-consumer.zh.md: 31e01a2f6082e59bf21541660c6b28d77222f04d diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 4911452782..1f8bb7772f 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -16,9 +16,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:242`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | | `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:250`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | | `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:231`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | -| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:276`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`llm-image-offload`](../packages/llm/llm-image-offload), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:276`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) | | `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:289`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:305`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-image-offload`](../packages/llm/llm-image-offload), [`llm-retry`](../packages/llm/llm-retry) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:305`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) | | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts: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) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 8f08cc98d0..31e01a2f60 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -18,9 +18,9 @@ | `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:242`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | | `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:250`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | | `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:231`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | -| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:276`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`llm-image-offload`](../packages/llm/llm-image-offload), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:276`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) | | `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:289`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:305`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-image-offload`](../packages/llm/llm-image-offload), [`llm-retry`](../packages/llm/llm-retry) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:305`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) | | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts: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) | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index f94724e3a8..779484e185 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: 550b29c672089d024162c0b0a1ad3efd8007ef0a -module-graph.zh.md: c4f8ff30c93b726657b7e5d1193e0ec9ded4993e +module-graph.md: 9da7242a5c6fca227175892eaaf65ccf45c61316 +module-graph.zh.md: 58f0e7eda73e8094072390041e683f1cf7e5c140 diff --git a/docs/module-graph.md b/docs/module-graph.md index 550b29c672..9da7242a5c 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -27,7 +27,6 @@ flowchart TD pkg_deepseek_llm_api_extensions["deepseek-llm-api-extensions"] pkg_llm["llm"] pkg_llm_deepseek["llm-deepseek"] - pkg_llm_image_offload["llm-image-offload"] pkg_llm_pi_ai["llm-pi-ai"] pkg_llm_retry["llm-retry"] pkg_plugin_package_inventory_deepseek["plugin-package-inventory-deepseek"] @@ -505,9 +504,6 @@ flowchart TD pkg_llm_deepseek --> pkg_llm pkg_llm_deepseek --> pkg_settings pkg_llm_deepseek --> pkg_timeout - pkg_llm_image_offload --> pkg_agent - pkg_llm_image_offload --> pkg_llm - pkg_llm_image_offload --> pkg_session pkg_llm_pi_ai --> pkg_attachment pkg_llm_pi_ai --> pkg_authorization pkg_llm_pi_ai --> pkg_credentials @@ -1304,7 +1300,6 @@ flowchart TD | [`shell`](../packages/shell/shell) | `shell` | [`sandbox`](../packages/sandbox/sandbox), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess) | | [`workspace`](../packages/workspace/workspace) | `workspace` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage`](../packages/storage/storage), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) | | [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`atomic-write`](../packages/util/atomic-write), [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | -| [`llm-image-offload`](../packages/llm/llm-image-offload) | `llm` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`authorization`](../packages/credentials/authorization), [`credentials`](../packages/credentials/credentials), [`fs`](../packages/fs/fs), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`timeout`](../packages/util/timeout) | | [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index c4f8ff30c9..58f0e7eda7 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -29,7 +29,6 @@ flowchart TD pkg_deepseek_llm_api_extensions["deepseek-llm-api-extensions"] pkg_llm["llm"] pkg_llm_deepseek["llm-deepseek"] - pkg_llm_image_offload["llm-image-offload"] pkg_llm_pi_ai["llm-pi-ai"] pkg_llm_retry["llm-retry"] pkg_plugin_package_inventory_deepseek["plugin-package-inventory-deepseek"] @@ -507,9 +506,6 @@ flowchart TD pkg_llm_deepseek --> pkg_llm pkg_llm_deepseek --> pkg_settings pkg_llm_deepseek --> pkg_timeout - pkg_llm_image_offload --> pkg_agent - pkg_llm_image_offload --> pkg_llm - pkg_llm_image_offload --> pkg_session pkg_llm_pi_ai --> pkg_attachment pkg_llm_pi_ai --> pkg_authorization pkg_llm_pi_ai --> pkg_credentials @@ -1306,7 +1302,6 @@ flowchart TD | [`shell`](../packages/shell/shell) | `shell` | [`sandbox`](../packages/sandbox/sandbox), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess) | | [`workspace`](../packages/workspace/workspace) | `workspace` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage`](../packages/storage/storage), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) | | [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`atomic-write`](../packages/util/atomic-write), [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | -| [`llm-image-offload`](../packages/llm/llm-image-offload) | `llm` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`authorization`](../packages/credentials/authorization), [`credentials`](../packages/credentials/credentials), [`fs`](../packages/fs/fs), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`timeout`](../packages/util/timeout) | | [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings) | diff --git a/docs/subsystems/llm-streaming.i18n.yaml b/docs/subsystems/llm-streaming.i18n.yaml index eb8ec388bb..d06acdc2c0 100644 --- a/docs/subsystems/llm-streaming.i18n.yaml +++ b/docs/subsystems/llm-streaming.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/llm-streaming.md -llm-streaming.md: b0414499e9cfc2fb98bcceed1142aafdb0c59dbf -llm-streaming.zh.md: 6c509e18a8095c8b420e94c4a0410523dd958486 +llm-streaming.md: f2c8c09779e3e8e808dc62f9861230b7ac69cb87 +llm-streaming.zh.md: ddf7ae1dd5a514b58178521c526b0de1d6c165e6 diff --git a/docs/subsystems/llm-streaming.md b/docs/subsystems/llm-streaming.md index b0414499e9..f2c8c09779 100644 --- a/docs/subsystems/llm-streaming.md +++ b/docs/subsystems/llm-streaming.md @@ -247,8 +247,8 @@ interface LlmFailure { /** * With code `IMAGE_OFFLOAD_REQUIRED`: how many more of the oldest retained * image occurrences the route needs offloaded before the same request fits - * its exact byte accounting. The `dsh-llm-image-offload` plugin advances the - * durable watermark by this count and retries the step. + * its exact byte accounting. `dsh-llm-retry` advances the session's durable + * `image/offload` watermark by this count and retries the step. */ readonly offloadImages?: number } @@ -570,8 +570,6 @@ interface LlmResolvedModelInfo extends LlmModelInfo { defaultMaxTokens?: number /** Adapter-owned selectable reasoning levels when exposed. */ reasoning?: LlmModelReasoningInfo - /** Request-image budget the route enforces; absent for routes that never offload. */ - imageRequest?: LlmImageRequestBudget } ``` diff --git a/docs/subsystems/llm-streaming.zh.md b/docs/subsystems/llm-streaming.zh.md index 6c509e18a8..ddf7ae1dd5 100644 --- a/docs/subsystems/llm-streaming.zh.md +++ b/docs/subsystems/llm-streaming.zh.md @@ -249,8 +249,8 @@ interface LlmFailure { /** * With code `IMAGE_OFFLOAD_REQUIRED`: how many more of the oldest retained * image occurrences the route needs offloaded before the same request fits - * its exact byte accounting. The `dsh-llm-image-offload` plugin advances the - * durable watermark by this count and retries the step. + * its exact byte accounting. `dsh-llm-retry` advances the session's durable + * `image/offload` watermark by this count and retries the step. */ readonly offloadImages?: number } @@ -576,8 +576,6 @@ interface LlmResolvedModelInfo extends LlmModelInfo { defaultMaxTokens?: number /** Adapter-owned selectable reasoning levels when exposed. */ reasoning?: LlmModelReasoningInfo - /** Request-image budget the route enforces; absent for routes that never offload. */ - imageRequest?: LlmImageRequestBudget } ``` diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index 85d0e1a9a0..ce026ac40d 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session.md -session.md: acebb773edfdef905d9d1488d8d19cf2773b39bf -session.zh.md: b8de950392e8643618de16b41a4f850ee47e2501 +session.md: 68beb5f1fb77f723c59adfc583380773e41be1ae +session.zh.md: 7134819ee4572ba65e89b3f35be837c7a139fe99 diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index acebb773ed..68beb5f1fb 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -200,7 +200,7 @@ interface RequestContext { ### The image offload event: `image/offload` -The `dsh-llm-image-offload` plugin appends `image/offload` inside an open turn: before a step enters, when the surface's retained image occurrences exceed the request-image budget of the route the latest `request/header` names, and on the `agent/request-error` waterfall when an adapter fails an attempt with `IMAGE_OFFLOAD_REQUIRED`, before the step retries. Its `watermark` names the last offloaded occurrence; `deriveMessages()` and the Session instance's `deriveEventMessage()` mark every occurrence positioned at or before it `offloaded: true`, and each route renders those marks as placeholder text. `Session.append` and seeding require the watermark to identify an image on the current surface, reject malformed and non-advancing positions, and reject request-only `offloaded` markers in durable messages. `session.imageOffloadWatermark()` folds the latest frozen position. Like `request/header`, it is not a `SurfaceEventType`; unlike it, it changes the derived surface, so it stays required-on-read ([decision](../../.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.md)). +`dsh-llm-retry` appends `image/offload` on the `agent/request-error` waterfall when an adapter fails an attempt with `IMAGE_OFFLOAD_REQUIRED`, before the step retries. Its `watermark` names the last offloaded occurrence; `deriveMessages()` and the Session instance's `deriveEventMessage()` mark every occurrence positioned at or before it `offloaded: true`, and each route renders those marks as placeholder text. `Session.append` and seeding require the watermark to identify an image on the current surface, reject malformed and non-advancing positions, and reject request-only `offloaded` markers in durable messages. `session.imageOffloadWatermark()` folds the latest frozen position. Like `request/header`, it is not a `SurfaceEventType`; unlike it, it changes the derived surface, so it stays required-on-read ([decision](../../.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.md)). ```ts type-equiv /** diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index b8de950392..7134819ee4 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -200,7 +200,7 @@ interface RequestContext { ### 图片 offload 事件:`image/offload` -`dsh-llm-image-offload` 插件在打开的轮次内追加 `image/offload`:step 进入前,表层上保留的图片出现位置超过最新 `request/header` 所指路由的请求图片预算时追加一次;adapter 以 `IMAGE_OFFLOAD_REQUIRED` 让一次尝试失败时,在 `agent/request-error` waterfall 上、step 重试前再追加一次。其 `watermark` 指向最后一个被省略的出现位置;`deriveMessages()` 与 Session 实例的 `deriveEventMessage()` 把位于它及之前的每个出现位置标为 `offloaded: true`,每条路由把这些标记渲染为占位文本。`Session.append` 与 seed 要求水位指向当前表层的图片,拒绝畸形和没有严格前进的位置,并拒绝持久消息中的请求专用 `offloaded` 标记。`session.imageOffloadWatermark()` 折叠最新的冻结位置。它和 `request/header` 一样不是 `SurfaceEventType`;与之不同的是它改变派生表层,因此读取时必须识别([决定](../../.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.zh.md))。 +adapter 以 `IMAGE_OFFLOAD_REQUIRED` 让一次尝试失败时,`dsh-llm-retry` 在 `agent/request-error` waterfall 上、step 重试前追加 `image/offload`。其 `watermark` 指向最后一个被省略的出现位置;`deriveMessages()` 与 Session 实例的 `deriveEventMessage()` 把位于它及之前的每个出现位置标为 `offloaded: true`,每条路由把这些标记渲染为占位文本。`Session.append` 与 seed 要求水位指向当前表层的图片,拒绝畸形和没有严格前进的位置,并拒绝持久消息中的请求专用 `offloaded` 标记。`session.imageOffloadWatermark()` 折叠最新的冻结位置。它和 `request/header` 一样不是 `SurfaceEventType`;与之不同的是它改变派生表层,因此读取时必须识别([决定](../../.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.zh.md))。 ```ts type-equiv /** diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index e7a8b9b889..5c50261d0d 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -84,9 +84,6 @@ - id: llm-retry name: '@deepseek-ai/dsh-llm-retry' - - id: llm-image-offload - name: '@deepseek-ai/dsh-llm-image-offload' - # User-settings document (`$DSH_HOME/settings.yaml`, hot-reloaded): a # `llm-deepseek:` or `llm-pi-ai:` section there overrides the adapter entries # below without a restart, and is what the web Models page writes. diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index 736450a62d..61131898c4 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -60,7 +60,6 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-llm-pi-ai": "workspace:^", - "@deepseek-ai/dsh-llm-image-offload": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-permission-presets": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", diff --git a/packages/bundle/sdk-minimal/cordis.patch.yml b/packages/bundle/sdk-minimal/cordis.patch.yml index ce07d6e2a6..f19486e7d0 100644 --- a/packages/bundle/sdk-minimal/cordis.patch.yml +++ b/packages/bundle/sdk-minimal/cordis.patch.yml @@ -104,9 +104,6 @@ - id: llm-retry name: '@deepseek-ai/dsh-llm-retry' - - id: llm-image-offload - name: '@deepseek-ai/dsh-llm-image-offload' - - id: jobs name: '@deepseek-ai/dsh-jobs-local' diff --git a/packages/bundle/sdk-minimal/package.json b/packages/bundle/sdk-minimal/package.json index 64db1d1c00..b0dbc597d2 100644 --- a/packages/bundle/sdk-minimal/package.json +++ b/packages/bundle/sdk-minimal/package.json @@ -43,7 +43,6 @@ "@deepseek-ai/dsh-jobs-local": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", - "@deepseek-ai/dsh-llm-image-offload": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-plugin-package-inventory-deepseek": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", diff --git a/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts b/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts index bb7bb65563..d74cba198b 100644 --- a/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts +++ b/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts @@ -48,7 +48,6 @@ describe('dsh-sdk-minimal bundle', () => { ['tools', '@deepseek-ai/dsh-tools'], ['agent', '@deepseek-ai/dsh-agent'], ['llm-retry', '@deepseek-ai/dsh-llm-retry'], - ['llm-image-offload', '@deepseek-ai/dsh-llm-image-offload'], ['jobs', '@deepseek-ai/dsh-jobs-local'], ['invariants', '@deepseek-ai/dsh-invariants'], ['session-invariant', '@deepseek-ai/dsh-session/invariant'], diff --git a/packages/core/session/README.i18n.yaml b/packages/core/session/README.i18n.yaml index 64def90aca..aae135a310 100644 --- a/packages/core/session/README.i18n.yaml +++ b/packages/core/session/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/session/README.md -README.md: 75aa851751e48e58829834f08118f5cd5685fcf2 -README.zh.md: 31e6a08024872bd5c1afde7200f1607e96da52ea +README.md: 1c58fafe55b28916b94b917caaefbbad18a2dc86 +README.zh.md: ba2f84c13c82cd97cf3fee53e84a845b46569c07 diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 75aa851751..1c58fafe55 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -83,7 +83,7 @@ The package is built on event sourcing: a `Session` is an append-only log of typ `request/header` stores a full canonical snapshot of the non-history request envelope with reason `initial`, `resume`, `change`, or `series`. An explicit message-series start or a surface replacement writes a `series` snapshot when the envelope is unchanged; a simultaneous change uses `startsSeries: true`. Same-series steps, retries, and ordinary later turns inherit the latest snapshot. `adapterDefaults` distinguishes values resolved by the adapter from explicit settings, and `foldRequestHeader()` selects the latest snapshot. This self-contained record supports partial-window rendering and exact reconstruction at the cost of growth per message series; the [reconstructable-requests Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md) owns the detail. -`image/offload` records the durable image offload watermark the `dsh-llm-image-offload` plugin advances when a route's request-image budget is exceeded: an `ImageOccurrencePosition` naming the last offloaded occurrence by event seq and its complete nested block path. The derivation marks every occurrence at or before it `offloaded: true`, so each route sends its placeholder text instead of the image. The position only advances; `Session.append` and seeding require it to identify a current surface image and reject request-only `offloaded` markers in durable messages. Because the event changes the derived surface it is required-on-read: a build without the type refuses the log rather than replaying images the model never saw ([decision](../../../.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.md)). +`image/offload` records the durable image offload watermark `dsh-llm-retry` advances when an adapter reports that a route's request-image budget is exceeded: an `ImageOccurrencePosition` naming the last offloaded occurrence by event seq and its complete nested block path. The derivation marks every occurrence at or before it `offloaded: true`, so each route sends its placeholder text instead of the image. The position only advances; `Session.append` and seeding require it to identify a current surface image and reject request-only `offloaded` markers in durable messages. Because the event changes the derived surface it is required-on-read: a build without the type refuses the log rather than replaying images the model never saw ([decision](../../../.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.md)). ### Source map diff --git a/packages/core/session/README.zh.md b/packages/core/session/README.zh.md index 31e6a08024..ba2f84c13c 100644 --- a/packages/core/session/README.zh.md +++ b/packages/core/session/README.zh.md @@ -83,7 +83,7 @@ session.deriveMessages() // the derived model history `request/header` 存储非历史请求 envelope 的完整规范快照,原因为 `initial`、`resume`、`change` 或 `series`。显式消息序列起点或表层替换会在 envelope 不变时写入 `series` 快照;同时发生变化时使用 `startsSeries: true`。同一序列内的步骤、重试与普通后续轮次继承最新快照。`adapterDefaults` 区分由适配器解析的值与显式设置,`foldRequestHeader()` 选择最新快照。这种自包含记录以每个消息序列增加存储为代价,支持局部窗口渲染与精确重建;细节由[可重建请求 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md)负责。 -`image/offload` 记录 `dsh-llm-image-offload` 插件在路由请求图片预算被超过时推进的持久图片 offload 水位:一个以事件序号加完整嵌套块路径指向最后一个被省略出现位置的 `ImageOccurrencePosition`。派生把位于水位及之前的每个出现位置标为 `offloaded: true`,于是每条路由发送其占位文本而不是图片。位置只会推进;`Session.append` 与 seed 要求它指向当前表层图片,并拒绝持久消息中的请求专用 `offloaded` 标记。该事件改变派生表层,因此读取时必须识别:不认识该类型的构建拒绝这份日志,而不是带着模型从未见过的图片重放([决定](../../../.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.zh.md))。 +`image/offload` 记录 `dsh-llm-retry` 在 adapter 报告路由请求图片预算被超过时推进的持久图片 offload 水位:一个以事件序号加完整嵌套块路径指向最后一个被省略出现位置的 `ImageOccurrencePosition`。派生把位于水位及之前的每个出现位置标为 `offloaded: true`,于是每条路由发送其占位文本而不是图片。位置只会推进;`Session.append` 与 seed 要求它指向当前表层图片,并拒绝持久消息中的请求专用 `offloaded` 标记。该事件改变派生表层,因此读取时必须识别:不认识该类型的构建拒绝这份日志,而不是带着模型从未见过的图片重放([决定](../../../.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.zh.md))。 ### 源码地图 diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 42966918ed..8afea303f8 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -4429,10 +4429,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'LlmFailure', declaration: 'export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n readonly offloadImages?: number;\n}', }, - { - name: 'LlmImageRequestBudget', - declaration: 'export interface LlmImageRequestBudget {\n representation: \'raw\' | \'base64\';\n maxBytes?: number;\n maxImages?: number;\n byteQuantum?: number;\n countQuantum?: number;\n versionMaxBytes?: number;\n}', - }, { name: 'LlmImageRequestPrice', declaration: 'export interface LlmImageRequestPrice {\n visualTokens: number;\n text: string;\n}', @@ -4467,7 +4463,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'LlmResolvedModelInfo', - declaration: 'export interface LlmResolvedModelInfo extends LlmModelInfo {\n context?: LlmModelContext;\n defaultMaxTokens?: number;\n reasoning?: LlmModelReasoningInfo;\n imageRequest?: LlmImageRequestBudget;\n}', + declaration: 'export interface LlmResolvedModelInfo extends LlmModelInfo {\n context?: LlmModelContext;\n defaultMaxTokens?: number;\n reasoning?: LlmModelReasoningInfo;\n}', }, { name: 'LlmRuntime', diff --git a/packages/llm/README.i18n.yaml b/packages/llm/README.i18n.yaml index a286ecf235..25e569fd0f 100644 --- a/packages/llm/README.i18n.yaml +++ b/packages/llm/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/README.md -README.md: 31f6ecc6b84e23169a539627befef030f023d0ef -README.zh.md: 6d1d69441ac425d03f13a83fb655733a69b3a228 +README.md: 9f7e8f444345bb60677a57679ad559f1e06be83f +README.zh.md: 5030a71a256af0d59dcf8d8e4875c0b9790e7a55 diff --git a/packages/llm/README.md b/packages/llm/README.md index 31f6ecc6b8..9f7e8f4443 100644 --- a/packages/llm/README.md +++ b/packages/llm/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -The llm group provides the harness's model-call capability: one provider-neutral service through which any composition streams requests to a model provider, plus adapters, provider-specific request metadata, retry execution, and measurement. The core `llm` package defines the message, content-block, and stream-chunk vocabulary every plugin and the session log use; provider adapters translate a provider's wire format into that vocabulary; DeepSeek request-extension plugins contribute lifecycle-owned metadata outside model input; `llm-retry` re-runs failed requests at durable agent-step boundaries; `llm-image-offload` advances the durable `image/offload` watermark that keeps request images within each route's budget; and `token-meter` measures request and context pressure from the durable log. This page maps the group; each package README owns its per-package contract. +The llm group provides the harness's model-call capability: one provider-neutral service through which any composition streams requests to a model provider, plus adapters, provider-specific request metadata, retry execution, and measurement. The core `llm` package defines the message, content-block, and stream-chunk vocabulary every plugin and the session log use; provider adapters translate a provider's wire format into that vocabulary; DeepSeek request-extension plugins contribute lifecycle-owned metadata outside model input; `llm-retry` re-runs failed requests at durable agent-step boundaries; and `token-meter` measures request and context pressure from the durable log. This page maps the group; each package README owns its per-package contract. ## Table of Contents @@ -30,7 +30,6 @@ The llm group provides the harness's model-call capability: one provider-neutral | [`deepseek-llm-api-extensions/`](deepseek-llm-api-extensions/README.md) | Registers lifecycle-owned top-level fields on official DeepSeek requests | `ctx.deepseekLlmApiExtensions` | | [`plugin-package-inventory-deepseek/`](plugin-package-inventory-deepseek/README.md) | Contributes the active Loader package inventory to official DeepSeek requests | contributes `dsh_plugin_packages` | | [`llm-retry/`](llm-retry/README.md) | Retries failed model requests under each provider's policy at durable agent-step boundaries | listens to `agent/request-error` | -| [`llm-image-offload/`](llm-image-offload/README.md) | Advances the session's durable `image/offload` watermark from route budgets and adapter failures | listens to `agent/pre-step`, `agent/request-error` | | [`token-meter/`](token-meter/README.md) | Measures request and context pressure from the durable session log with a fixed heuristic | `ctx.tokenMeter` | ----- diff --git a/packages/llm/README.zh.md b/packages/llm/README.zh.md index 6d1d69441a..5030a71a25 100644 --- a/packages/llm/README.zh.md +++ b/packages/llm/README.zh.md @@ -9,7 +9,7 @@ kind: "package-group" ## 概述 -llm 组提供 harness 的模型调用能力:一个提供方无关的服务,任何组合都可以通过它向模型提供方发起流式请求,外加适配器、提供方专用请求元数据、重试执行与计量。核心 `llm` 包定义所有插件与会话日志使用的消息、内容块与流式分片词汇;提供方适配器把某个提供方的协议格式翻译为该词汇;DeepSeek 请求扩展插件在模型输入之外贡献具有生命周期归属的元数据;`llm-retry` 在持久 agent 步骤边界上重跑失败的请求;`llm-image-offload` 推进持久 `image/offload` 水位,使请求图片不超过各路由预算;`token-meter` 从持久日志测量请求与上下文压力。本页是组的映射;每个包 README 负责各自的包级约定。 +llm 组提供 harness 的模型调用能力:一个提供方无关的服务,任何组合都可以通过它向模型提供方发起流式请求,外加适配器、提供方专用请求元数据、重试执行与计量。核心 `llm` 包定义所有插件与会话日志使用的消息、内容块与流式分片词汇;提供方适配器把某个提供方的协议格式翻译为该词汇;DeepSeek 请求扩展插件在模型输入之外贡献具有生命周期归属的元数据;`llm-retry` 在持久 agent 步骤边界上重跑失败的请求;`token-meter` 从持久日志测量请求与上下文压力。本页是组的映射;每个包 README 负责各自的包级约定。 ## 目录 @@ -30,7 +30,6 @@ llm 组提供 harness 的模型调用能力:一个提供方无关的服务, | [`deepseek-llm-api-extensions/`](deepseek-llm-api-extensions/README.zh.md) | 在官方 DeepSeek 请求上注册具有生命周期归属的顶层字段 | `ctx.deepseekLlmApiExtensions` | | [`plugin-package-inventory-deepseek/`](plugin-package-inventory-deepseek/README.zh.md) | 为官方 DeepSeek 请求贡献活跃 Loader 包清单 | 贡献 `dsh_plugin_packages` | | [`llm-retry/`](llm-retry/README.zh.md) | 在持久 agent 步骤边界上按各提供方策略重试失败的模型请求 | 监听 `agent/request-error` | -| [`llm-image-offload/`](llm-image-offload/README.zh.md) | 依据路由预算与适配器失败推进会话的持久 `image/offload` 水位 | 监听 `agent/pre-step`、`agent/request-error` | | [`token-meter/`](token-meter/README.zh.md) | 用固定启发式规则从持久会话日志测量请求与上下文压力 | `ctx.tokenMeter` | ----- diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index 516a61c747..df870c0d50 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-deepseek/README.md -README.md: 25b79b13a0a1597e510d71c68032bc02a01f9451 -README.zh.md: 2f2dcd8444745e93724e56ce94c6337ef0b9d444 +README.md: ad9b89a35fb6bd53579d573aa8d88ee12dc80a77 +README.zh.md: dce5b3f7332173f4ef9a82d1dd6845b0597d009d diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 25b79b13a0..ad9b89a35f 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -58,7 +58,7 @@ A request selects the route with `provider: deepseek-official`; the model id pas | `defaultContextWindow` | `1,000,000` | Capacity fallback for models without an exact value | | `models` | V4 Flash + V4 Pro + V4 Flash Vision Exp | Advisory catalog shown by discovery consumers | | `streamIdleTimeoutMs` | `300,000` | Maximum provider idle time per outstanding stream read | -| `maxRequestFilesBytes` | `128 MiB` | File-mode request-image byte budget declared to the `dsh-llm-image-offload` plugin's `image/offload` watermark planning | +| `maxRequestFilesBytes` | `128 MiB` | File-mode request-image byte budget; a request whose retained images exceed it fails with `IMAGE_OFFLOAD_REQUIRED` | | `maxInlineRequestImageBytes` | `20 MiB` | Independent base64 fallback high watermark | | `maxImagesPerRequest` | `600` | High watermark for retained request-image count | | `imageOffloadByteQuantum` | `64 MiB` | Files-mode oldest-prefix removal quantum | @@ -156,7 +156,7 @@ The selected DeepSeek model receives the harness system prompt, message history, #### Token effect -Provider tokenization governs exact text and image-token input. The adapter declares per-route `imageRequestPricing`: it prices each occurrence the session's `image/offload` watermark marks offloaded as its placeholder text and each retained image at its projected dimensions with the published v4 vision accounting (14px patch grid, 3:1 downsampling, 384-token cap, worst-case alignment pad). This lets the token meter price image pressure before a request; reported usage remains authoritative. Reasoning passback carries every reasoned turn's chain of thought into later requests, while offloaded images stop costing visual tokens. Each image-capable catalog route also declares its file-mode budget (`maxRequestFilesBytes`, `maxImagesPerRequest`, both quanta, and the request-version byte target) as `imageRequest` on its resolved model info, so the `dsh-llm-image-offload` plugin advances the watermark from logged facts; a request whose retained occurrences still exceed the file-mode or inline-fallback budget at their exact request-version bytes fails with `IMAGE_OFFLOAD_REQUIRED` naming the additional oldest occurrences to offload. Cache-read usage is reported when available. `totalTokens` is the exact `prompt_tokens + completion_tokens` aggregate and is omitted if a supplied `total_tokens` disagrees. +Provider tokenization governs exact text and image-token input. The adapter declares per-route `imageRequestPricing`: it prices each occurrence the session's `image/offload` watermark marks offloaded as its placeholder text and each retained image at its projected dimensions with the published v4 vision accounting (14px patch grid, 3:1 downsampling, 384-token cap, worst-case alignment pad). This lets the token meter price image pressure before a request; reported usage remains authoritative. Reasoning passback carries every reasoned turn's chain of thought into later requests, while offloaded images stop costing visual tokens. A request whose retained occurrences exceed the file-mode or inline-fallback budget (`maxRequestFilesBytes`, `maxImagesPerRequest`, both quanta) at their exact request-version bytes fails with `IMAGE_OFFLOAD_REQUIRED` naming the additional oldest occurrences to offload, and `dsh-llm-retry` advances the watermark and retries. Cache-read usage is reported when available. `totalTokens` is the exact `prompt_tokens + completion_tokens` aggregate and is omitted if a supplied `total_tokens` disagrees. #### KV Cache effect diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index 2f2dcd8444..dce5b3f733 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -58,7 +58,7 @@ kind: "package-reference" | `defaultContextWindow` | `1,000,000` | 无精确值模型的容量回退 | | `models` | V4 Flash + V4 Pro + V4 Flash Vision Exp | 供发现消费方查看的建议性目录 | | `streamIdleTimeoutMs` | `300,000` | 单次流读取未完成的最大提供方空闲时间 | -| `maxRequestFilesBytes` | `128 MiB` | 向 `dsh-llm-image-offload` 插件的 `image/offload` 水位规划声明的 file 模式请求图片字节预算 | +| `maxRequestFilesBytes` | `128 MiB` | file 模式请求图片字节预算,保留图片超过时请求以 `IMAGE_OFFLOAD_REQUIRED` 失败 | | `maxInlineRequestImageBytes` | `20 MiB` | 独立的 base64 回退高水位 | | `maxImagesPerRequest` | `600` | 保留请求图片数量的高水位 | | `imageOffloadByteQuantum` | `64 MiB` | Files 模式最旧前缀移除量子 | @@ -156,7 +156,7 @@ Files 模式通过 `maxRequestFilesBytes` 与 `maxImagesPerRequest` 限制保留 #### Token 影响 -提供方分词决定精确的文本与图片 token 输入。适配器声明按路由的 `imageRequestPricing`:把会话 `image/offload` 水位标记为已省略的每个出现位置按其占位文本计价,并按投影后的尺寸使用官方公布的 v4 视觉计量(14px patch 网格、3:1 降采样、单图 384 token 上限、最坏对齐 pad)为每张保留图片计价。这使 token 计量服务可以在请求发出前为图片压力定价;上报的 usage 仍是权威值。推理回传会把每个推理轮次的思维链带进后续请求,而已省略的图片不再消耗视觉 token。每条支持图片的目录路由还会把 file 模式预算(`maxRequestFilesBytes`、`maxImagesPerRequest`、两个量子与请求版本字节目标)作为 `imageRequest` 声明在其解析后的模型信息上,供 `dsh-llm-image-offload` 插件仅凭已记录事实推进水位;保留的出现位置按精确请求版本字节仍超过 file 模式或内联回退预算的请求,以 `IMAGE_OFFLOAD_REQUIRED` 失败并说明还需省略多少最老的出现位置。可用时报告缓存读取用量。`totalTokens` 是精确的 `prompt_tokens + completion_tokens` 汇总值;提供方给出的 `total_tokens` 不一致时省略该值。 +提供方分词决定精确的文本与图片 token 输入。适配器声明按路由的 `imageRequestPricing`:把会话 `image/offload` 水位标记为已省略的每个出现位置按其占位文本计价,并按投影后的尺寸使用官方公布的 v4 视觉计量(14px patch 网格、3:1 降采样、单图 384 token 上限、最坏对齐 pad)为每张保留图片计价。这使 token 计量服务可以在请求发出前为图片压力定价;上报的 usage 仍是权威值。推理回传会把每个推理轮次的思维链带进后续请求,而已省略的图片不再消耗视觉 token。保留的出现位置按精确请求版本字节超过 file 模式或内联回退预算(`maxRequestFilesBytes`、`maxImagesPerRequest` 与两个量子)的请求,以 `IMAGE_OFFLOAD_REQUIRED` 失败并说明还需省略多少最老的出现位置,由 `dsh-llm-retry` 推进水位并重试。可用时报告缓存读取用量。`totalTokens` 是精确的 `prompt_tokens + completion_tokens` 汇总值;提供方给出的 `total_tokens` 不一致时省略该值。 #### KV Cache 影响 diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 8181e2113d..9844950413 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -19,7 +19,8 @@ import type { LlmResolvedModelInfo, ModelModality, ResolvedRetryPolicy, - StreamChunk, LlmImageRequestBudget } from '@deepseek-ai/dsh-llm' + StreamChunk, +} from '@deepseek-ai/dsh-llm' import type { AttachmentId, AttachmentStore, @@ -212,18 +213,6 @@ function collectImageRefs( } } -/** The file-mode request-image budget one image-capable catalog route declares to the agent loop. */ -function imageRequestBudget(connection: DeepSeekConnectionOptions, model: DeepSeekCatalogModel): LlmImageRequestBudget { - return { - representation: 'raw', - maxBytes: connection.maxRequestFilesBytes, - maxImages: connection.maxImagesPerRequest, - byteQuantum: connection.imageOffloadByteQuantum, - countQuantum: connection.imageOffloadCountQuantum, - versionMaxBytes: resolveRequestImagePolicy(model).maxBytes, - } -} - async function prepareRequestImages( options: GenerateOptions, attachments: AttachmentStore, @@ -421,9 +410,6 @@ export class DeepSeekAdapter extends LlmAdapter { : modelInfo(provider, configured), context: { contextWindow }, defaultMaxTokens: configured?.maxTokens ?? connection.maxTokens, - ...configured?.inputModalities?.includes('image') === true - ? { imageRequest: imageRequestBudget(connection, configured) } - : {}, ...connection.defaults.thinking === 'disabled' ? { reasoning: { diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 3d8cb10a44..c51e25e272 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -629,30 +629,6 @@ describe('DeepSeekAdapter against a mock server', () => { }) }) - it('declares the file-mode request-image budget only for image-capable catalog routes', async () => { - const adapter = adapterOf({ - maxRequestFilesBytes: 4096, - maxImagesPerRequest: 40, - imageOffloadByteQuantum: 1024, - imageOffloadCountQuantum: 20, - models: [ - { id: 'vision', inputModalities: ['text', 'image'], imageMaxBytes: 2048 }, - { id: 'text-only' }, - ], - }) - await expect(adapter.resolveModel('deepseek-official', 'vision')).resolves.toMatchObject({ - imageRequest: { - representation: 'raw', - maxBytes: 4096, - maxImages: 40, - byteQuantum: 1024, - countQuantum: 20, - versionMaxBytes: 2048, - }, - }) - const textOnly = await adapter.resolveModel('deepseek-official', 'text-only') - expect(textOnly.imageRequest).toBeUndefined() - }) it('projects nested tool-result images with route-owned request budgets', async () => { const server = await mockServer([ { kind: 'sse', events: textEvents }, diff --git a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts index 2c76c6fb5f..dad3ac76b3 100644 --- a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts @@ -219,10 +219,7 @@ describe('request-level dynamic configuration', () => { await assemble(ctx, { model: 'deepseek-v4-flash-vision-exp', messages }) await ctx.settings.update(NS, { maxRequestFilesBytes: 4, imageOffloadByteQuantum: 2 }) - // The tightened budget reaches the agent loop through the resolved route metadata... - await expect(ctx.llm.resolveModelInfo('deepseek-official', 'deepseek-v4-flash-vision-exp')) - .resolves.toMatchObject({ imageRequest: { representation: 'raw', maxBytes: 4, byteQuantum: 2 } }) - // ...and a request whose retained exact bytes still exceed it names the occurrences to offload. + // A request whose retained exact bytes exceed the tightened budget names the occurrences to offload. const rejected = await assemble(ctx, { model: 'deepseek-v4-flash-vision-exp', messages }) expect(rejected.finish).toMatchObject({ kind: 'error', diff --git a/packages/llm/llm-image-offload/README.i18n.yaml b/packages/llm/llm-image-offload/README.i18n.yaml deleted file mode 100644 index 7bd1f8a72c..0000000000 --- a/packages/llm/llm-image-offload/README.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write packages/llm/llm-image-offload/README.md -README.md: 6f00392b9d5e3462845980a23332f9bd64bbf673 -README.zh.md: 24ecdd57a9096072721cb73affb7c8f587619179 diff --git a/packages/llm/llm-image-offload/README.md b/packages/llm/llm-image-offload/README.md deleted file mode 100644 index 6f00392b9d..0000000000 --- a/packages/llm/llm-image-offload/README.md +++ /dev/null @@ -1,106 +0,0 @@ ---- -description: "The image-offload plugin for users and maintainers keeping request images within each route's budget through the session's durable image/offload watermark." -kind: "package-reference" ---- - -# @deepseek-ai/dsh-llm-image-offload - -English | [中文](README.zh.md) - -## Summary - -`@deepseek-ai/dsh-llm-image-offload` keeps request images within each route's budget by advancing the session's durable `image/offload` watermark. Before a step enters, it plans the watermark from the request-image budget the routed model declares and the image occurrences the surface still sends; when an adapter fails an attempt with `IMAGE_OFFLOAD_REQUIRED`, it advances the watermark by the count the failure names and retries the step. The watermark only advances, so the images the model sees and the provider cache prefix move only forward, and every dispatched request's image set is determined by the session log alone. - -## Table of Contents - -- [Use this package](#use-this-package) -- [Understand the implementation](#understand-the-implementation) -- [Further Exploration](#further-exploration) -- [Model Experience](#model-experience) -- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) -- [Dev Note](#dev-note) - ------ - - -## Use this package - -Mount this plugin in every composition that runs the agent loop with an image-capable route. Without it, an image-capable adapter whose budget is exceeded fails the step with `IMAGE_OFFLOAD_REQUIRED` and no recovery advances the watermark. The plugin has no configuration: the DeepSeek adapter declares its file-mode budget and the pi-ai adapter its base64 bound as `imageRequest` on their resolved model info. - -### Minimal configuration - -```yaml -- name: '@deepseek-ai/dsh-llm-image-offload' -``` - -### What you can observe - -Each advance is one durable `image/offload` event carrying the turn, the step, and the watermark: the seq of the event carrying the last offloaded occurrence and the block path inside its content. A pre-step advance lands after `turn/start` and before `step/start`; a failure-driven advance lands after the `assistant/attempt` it answers and before the retried request. `session.imageOffloadWatermark()` reads the watermark in force, and every derived image block at or before it carries `offloaded: true`, which each route renders as placeholder text naming the image and its read-only path. - -### Failures and recovery - -The pre-step plan reads the route the latest `request/header` names and covers the surface as it stands before the step's own messages are appended; it skips before the first header, for a route without an adapter, and for a route that declares no budget. Occurrences the plan cannot see, and occurrences whose exact request bytes exceed the budget only at serialization, reach the model through one failed attempt: the adapter fails with `IMAGE_OFFLOAD_REQUIRED` and `offloadImages`, the plugin advances by that count on the `agent/request-error` waterfall and returns the `retry` action. When nothing remains to offload the plugin delegates, so the failure reaches downstream recovery and otherwise ends the turn. - ------ - - -## Understand the implementation - -
-Implementation internals — click to expand - -The plugin is one function plugin with two listeners. On `agent/pre-step` it awaits the downstream decision, and for an `enter` decision resolves the routed budget through `ctx.llm.resolveModelInfo()`, walks the surface nodes through `Session.deriveEventMessage()` collecting every retained occurrence in request order with its durable position, and plans the removal prefix with the pure `offloadedImagePrefixCount()` over the occurrences' represented bytes. On `agent/request-error` it handles only `IMAGE_OFFLOAD_REQUIRED` failures carrying `offloadImages`. Both paths append one `image/offload` event naming the greatest durable position in the request-order prefix, which a surface replacement can place before older events; the advance may offload additional occurrences but always covers the prefix. Adapters never append the event: the session surface has one owner of its offloaded set. - -No invariant companion is published: `Session.append` and seeding already reject a malformed, non-advancing, or off-surface watermark, so no independent observation can diverge from the event the plugin appends. - -
- ------ - - -## Further Exploration - -- [Durable image offload watermark](../../../.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.md) — the decision this plugin implements and the alternatives it replaced. -- [dsh-session](../../core/session/README.md) — the `image/offload` event, its validation, and the marked derivation. -- [dsh-llm](../llm/README.md) — `LlmImageRequestBudget`, `IMAGE_OFFLOAD_REQUIRED`, and the shared image walk. -- [llm-deepseek adapter](../llm-deepseek/README.md) — the file-mode budget and inline fallback that report offload counts. -- [llm-pi-ai adapter](../llm-pi-ai/README.md) — the base64 bound that reports offload counts. - ------ - - -## Model Experience - -### Request-image offload - -#### What the model sees - -Every image occurrence at or before the watermark reaches the model as the route's placeholder text (`offloadedImageText`) naming the attachment and its read-only path instead of the image; occurrences after it stay images. The set never shrinks on its own, so the model can rely on an offloaded image staying offloaded and read it back through the path when it needs the content again. - -#### Token effect - -An offloaded occurrence costs its placeholder text instead of visual tokens. The token meter prices the current surface under the current watermark and each usage anchor under the watermark its request was derived with. - -#### KV Cache effect - -An advance replaces earlier images with placeholder text, so provider cache reuse ends at the first replaced message for that request. Because the watermark never retreats, the prefix stays stable afterwards. - -## Known Limitations and Deferred Work - - - -- **Offloaded images never return automatically** — a larger budget, a larger route, or compaction lowering the total leaves the watermark in place; recovery is the read-only path in the placeholder. -- **The pre-step plan is one step behind the entering messages** — images a user message or the step's own tool results add reach the model through one failed attempt when they push the retained set past the budget. -- **A temporary small budget advances permanently** — a Files outage that forces the inline fallback, or a temporary switch to a small-budget route, offloads images that a later route would have sent. - - -### Dev Note - -
-Working context for maintainers — click to expand - -This Dev Note is non-authoritative working context: notes for maintainers and open questions. Shipped behavior and accepted rationale live in the sections above, the package code, and the linked Agent Notes. - -- The pre-step plan walks every surface node on each step of a route with a budget; nothing caches the retained set. Revisit only with profiling evidence from long image-heavy sessions. - -
diff --git a/packages/llm/llm-image-offload/README.zh.md b/packages/llm/llm-image-offload/README.zh.md deleted file mode 100644 index 24ecdd57a9..0000000000 --- a/packages/llm/llm-image-offload/README.zh.md +++ /dev/null @@ -1,106 +0,0 @@ ---- -description: "面向用户与维护者的图片省略插件说明:通过会话的持久 image/offload 水位,把请求图片控制在各路由预算之内。" -kind: "package-reference" ---- - -# @deepseek-ai/dsh-llm-image-offload - -[English](README.md) | 中文 - -## 概述 - -`@deepseek-ai/dsh-llm-image-offload` 通过推进会话的持久 `image/offload` 水位,把请求图片控制在各路由的预算之内。step 进入前,它根据路由模型声明的请求图片预算和表层仍在发送的图片出现位置规划水位;adapter 以 `IMAGE_OFFLOAD_REQUIRED` 让一次尝试失败时,它按失败中给出的数量推进水位并重试该 step。水位只会前进,因此模型看到的图片和 provider 缓存前缀只向前移动,每个已发出请求的图片集合都仅由会话日志决定。 - -## 目录 - -- [使用本包](#use-this-package) -- [理解实现](#understand-the-implementation) -- [进一步探索](#further-exploration) -- [模型体验](#model-experience) -- [已知限制与延期工作](#known-limitations-and-deferred-work) -- [开发备注](#dev-note) - ------ - - -## 使用本包 - -凡是运行 agent loop 且带有支持图片路由的组合,都应挂载本插件。没有它,支持图片的 adapter 在预算超出时会以 `IMAGE_OFFLOAD_REQUIRED` 让 step 失败,且没有任何恢复路径会推进水位。本插件没有配置:DeepSeek adapter 在其解析后的模型信息上以 `imageRequest` 声明 file 模式预算,pi-ai adapter 声明其 base64 上限。 - -### 最小配置 - -```yaml -- name: '@deepseek-ai/dsh-llm-image-offload' -``` - -### 你可以观察到什么 - -每次推进都是一个持久的 `image/offload` 事件,携带轮次、step 和水位:承载最后一个被省略出现位置的事件序号,以及它在内容中的块路径。step 前的推进落在 `turn/start` 之后、`step/start` 之前;由失败驱动的推进落在它所回应的 `assistant/attempt` 之后、重试请求之前。`session.imageOffloadWatermark()` 读取当前生效的水位,位于水位及之前的每个派生图片块都带有 `offloaded: true`,每条路由把它渲染为注明图片及其只读路径的占位文本。 - -### 失败与恢复 - -step 前的规划读取最新 `request/header` 所指的路由,覆盖的是 step 自己的消息追加之前的表层;第一个 header 之前、路由没有 adapter、路由未声明预算时都会跳过。规划看不到的出现位置,以及只有在序列化时才发现精确请求字节超出预算的出现位置,会经过一次失败的尝试到达模型:adapter 以 `IMAGE_OFFLOAD_REQUIRED` 和 `offloadImages` 失败,插件在 `agent/request-error` waterfall 上按该数量推进并返回 `retry` 动作。没有可省略的出现位置时插件向下游委托,失败因此进入下游恢复,否则结束该轮次。 - ------ - - -## 理解实现 - -
-实现内部——点击展开 - -本插件是带两个监听器的函数插件。在 `agent/pre-step` 上它先等待下游决定,对 `enter` 决定通过 `ctx.llm.resolveModelInfo()` 解析路由预算,经 `Session.deriveEventMessage()` 遍历表层节点,按请求顺序收集每个保留的出现位置及其持久位置,再用纯函数 `offloadedImagePrefixCount()` 对出现位置的表示字节规划删除前缀。在 `agent/request-error` 上它只处理带 `offloadImages` 的 `IMAGE_OFFLOAD_REQUIRED` 失败。两条路径都追加一个 `image/offload` 事件,指向请求序前缀中最大的持久位置;表层替换可能把该位置放在较老事件之前,推进因此可能额外省略一些出现位置,但一定覆盖该前缀。adapter 从不追加该事件:会话表层的已省略集合只有一个所有者。 - -本包不发布 invariant 伴生插件:`Session.append` 与 seed 已经拒绝畸形、未前进或不在表层上的水位,没有任何独立观察能与插件追加的事件产生分歧。 - -
- ------ - - -## 进一步探索 - -- [持久图片 offload 水位](../../../.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.zh.md)——本插件实现的决定及其替代的方案。 -- [dsh-session](../../core/session/README.zh.md)——`image/offload` 事件、它的校验与带标记的派生。 -- [dsh-llm](../llm/README.zh.md)——`LlmImageRequestBudget`、`IMAGE_OFFLOAD_REQUIRED` 与共享的图片遍历。 -- [llm-deepseek 适配器](../llm-deepseek/README.zh.md)——上报省略数量的 file 模式预算与内联回退。 -- [llm-pi-ai 适配器](../llm-pi-ai/README.zh.md)——上报省略数量的 base64 上限。 - ------ - - -## 模型体验 - -### 请求图片省略 - -#### 模型看到什么 - -位于水位及之前的每个图片出现位置,以路由的占位文本(`offloadedImageText`)到达模型,文本注明附件及其只读路径而不是图片本身;水位之后的出现位置仍是图片。该集合不会自行缩小,模型可以确信被省略的图片会一直被省略,再需要内容时通过路径读回。 - -#### Token 影响 - -被省略的出现位置只花费占位文本,不再花费视觉 token。token meter 按当前水位为当前表层定价,按每个 usage 锚点的请求派生时的水位为该锚点定价。 - -#### KV Cache 影响 - -一次推进把较早的图片替换为占位文本,该请求的 provider 缓存复用因此止于第一条被替换的消息。水位永不回退,之后的前缀保持稳定。 - -## 已知限制与延期工作 - - - -- **被省略的图片不会自动回归**——更大的预算、更大的路由或 compaction 降低总量都不会移动水位;恢复手段是占位文本中的只读路径。 -- **step 前的规划比进入的消息晚一步**——用户消息或 step 自己的工具结果新增的图片把保留集合推过预算时,会经过一次失败的尝试才到达模型。 -- **临时的小预算会永久推进**——迫使内联回退的 Files 故障,或临时切到小预算路由,会省略后来的路由本可以发送的图片。 - - -### 开发备注 - -
-维护者工作背景——点击展开 - -本开发备注是非权威的工作背景:给维护者的备注和未决问题。已交付的行为与被接受的理由见上文各节、包代码和链接的 Agent Note。 - -- 对声明了预算的路由,step 前的规划每一步都遍历全部表层节点,没有缓存保留集合。只有在图片密集的长会话中拿到 profiling 证据后再考虑优化。 - -
diff --git a/packages/llm/llm-image-offload/package.json b/packages/llm/llm-image-offload/package.json deleted file mode 100644 index 861cfa9bed..0000000000 --- a/packages/llm/llm-image-offload/package.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "name": "@deepseek-ai/dsh-llm-image-offload", - "description": "Durable request-image offload for the DeepSeek Harness agent loop", - "version": "0.1.3-alpha.1", - "publishConfig": { - "access": "public" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", - "directory": "packages/llm/llm-image-offload" - }, - "type": "module", - "main": "lib/index.js", - "types": "lib/types/index.d.ts", - "exports": { - ".": { - "types": "./lib/types/index.d.ts", - "default": "./lib/index.js" - }, - "./package.json": "./package.json" - }, - "files": [ - "lib/index.js", - "lib/types/**/*.d.ts" - ], - "license": "MIT", - "peerDependencies": { - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" - }, - "dependencies": { - "@deepseek-ai/schemastery": "workspace:^" - }, - "devDependencies": { - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-agent-loop": "workspace:^", - "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" - } -} diff --git a/packages/llm/llm-image-offload/src/index.ts b/packages/llm/llm-image-offload/src/index.ts deleted file mode 100644 index 1b2a17ce4d..0000000000 --- a/packages/llm/llm-image-offload/src/index.ts +++ /dev/null @@ -1,112 +0,0 @@ -/** - * Durable request-image offload on the agent loop's step extension points. - * Before a step enters, the plugin plans the session's `image/offload` - * watermark from the request-image budget of the route the latest - * `request/header` names; when an adapter fails an attempt with - * `IMAGE_OFFLOAD_REQUIRED`, it advances the watermark by the named count and - * retries the step, so every dispatched request carries the offloaded set the - * log records. - * - * @module @deepseek-ai/dsh-llm-image-offload - */ - -import type { Context } from '@deepseek-ai/cordis' -import z from '@deepseek-ai/schemastery' -import type { PreStepDecision, RequestErrorAction } from '@deepseek-ai/dsh-agent' -import { IMAGE_OFFLOAD_REQUIRED_CODE, LlmError, offloadedImagePrefixCount, representedImageBytes, visitImageBlocks } from '@deepseek-ai/dsh-llm' -import type { LlmImageRequestBudget } from '@deepseek-ai/dsh-llm' -import { compareImagePositions } from '@deepseek-ai/dsh-session' -import type { ImageOccurrencePosition, Session } from '@deepseek-ai/dsh-session' - -export const name = 'llm-image-offload' -export const inject = ['llm', 'agents'] - -/** The plugin has no configuration; image-capable routes declare their budgets. */ -export type Config = Readonly> - -/** Runtime schema for {@link Config}. */ -export const Config = z.object({}) as unknown as z - -/** One image occurrence the surface still sends. */ -interface RetainedOccurrence { - /** Durable position an advance names when this occurrence becomes the last offloaded one. */ - readonly position: ImageOccurrencePosition - /** Normalized attachment byte count. */ - readonly bytes: number -} - -/** Every retained image occurrence in model request order. */ -function retainedOccurrences(session: Session): RetainedOccurrence[] { - const watermark = session.imageOffloadWatermark() - const retained: RetainedOccurrence[] = [] - for (const seq of session.surface.nodes) { - // oxlint-disable-next-line typescript/no-non-null-assertion -- surface nodes index the durable log - const message = session.deriveEventMessage(session.eventAt(seq)!) - if (message === null) continue - visitImageBlocks(message.content, (block, path) => { - const position = { seq, path: [...path] } - if (watermark !== undefined && compareImagePositions(position, watermark) <= 0) return - retained.push({ position, bytes: block.attachment.bytes }) - }) - } - return retained -} - -/** - * Advance the watermark past the `count` oldest retained occurrences. A - * surface replacement can place a newer event before older ones in request - * order, so the advance names the greatest durable position in that prefix, - * which may offload additional occurrences but always covers the prefix. - * @returns whether any occurrence remained to offload. - */ -function advance(session: Session, turn: number, step: number, count: number): boolean { - let watermark: ImageOccurrencePosition | undefined - for (const occurrence of retainedOccurrences(session).slice(0, count)) { - if (watermark === undefined || compareImagePositions(occurrence.position, watermark) > 0) { - watermark = occurrence.position - } - } - if (watermark === undefined) return false - session.append('image/offload', { turn, step, watermark }) - return true -} - -/** Request-image budget of the route the latest request header names; undefined before the first header or for a route without one. */ -async function routedBudget(ctx: Context, session: Session, signal: AbortSignal): Promise { - const config = session.requestHeader()?.config - if (config === undefined) return undefined - try { - return (await ctx.llm.resolveModelInfo(config.provider, config.model, signal)).imageRequest - } catch (error: unknown) { - // Middleware may serve a route without an adapter; the loop dispatches it the same way. - if (error instanceof LlmError && error.code === 'NO_ADAPTER') return undefined - throw error - } -} - -/** - * Mount the offload listeners. - * @param ctx - the plugin context. - * @param _config - no configuration. - */ -export function apply(ctx: Context, _config: Config = {}): void { - ctx.on('agent/pre-step', async ({ agent, turn, step, signal }, next): Promise => { - const decision = await next() - if (decision.kind !== 'enter') return decision - const budget = await routedBudget(ctx, agent.session, signal) - if (budget === undefined || signal.aborted) return decision - const retained = retainedOccurrences(agent.session) - const count = offloadedImagePrefixCount( - retained.map(occurrence => representedImageBytes(occurrence.bytes, budget)), - budget, - ) - if (count > 0) advance(agent.session, turn, step, count) - return decision - }) - - ctx.on('agent/request-error', ({ agent, turn, step, failure }, next): Promise => { - if (failure.code !== IMAGE_OFFLOAD_REQUIRED_CODE || failure.offloadImages === undefined) return next() - if (!advance(agent.session, turn, step, failure.offloadImages)) return next() - return Promise.resolve({ kind: 'retry' }) - }) -} diff --git a/packages/llm/llm-image-offload/tsconfig.json b/packages/llm/llm-image-offload/tsconfig.json deleted file mode 100644 index aa2185055d..0000000000 --- a/packages/llm/llm-image-offload/tsconfig.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types" - }, - "include": [ - "src" - ], - "references": [ - { - "path": "../../../vendor/cosmokit" - }, - { - "path": "../../../vendor/cordis" - }, - { - "path": "../../../vendor/schemastery" - }, - { - "path": "../../llm/llm" - }, - { - "path": "../../core/session" - }, - { - "path": "../../core/agent" - } - ] -} diff --git a/packages/llm/llm-image-offload/tsdown.config.ts b/packages/llm/llm-image-offload/tsdown.config.ts deleted file mode 100644 index 8600a71b0a..0000000000 --- a/packages/llm/llm-image-offload/tsdown.config.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { defineConfig } from 'tsdown' - -/** Bundle the package root from its emitted declarations build. */ -export default defineConfig({ - entry: ['lib/types/index.js'], - outDir: 'lib', - format: ['esm'], - platform: 'node', - target: 'es2024', - fixedExtension: false, - dts: false, - clean: false, -}) diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index bdc0af81d3..e926a274ee 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-pi-ai/README.md -README.md: 08a5d9a220af2ec3a0e929926c74cc6e6c9b7499 -README.zh.md: c6eb57bf02538b07edb571a1b6c69029f18cbf24 +README.md: 56d68aa62fbb8497d4f6e34ded79d1ad27f41985 +README.zh.md: ff6734e57a4e2e725b4d45fee5b24aa01221cb21 diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 08a5d9a220..56d68aa62f 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -83,7 +83,7 @@ Each profile may set a `retryPolicy`; omission uses normal mode with five retrie | `defaultMaxTokens` | `32,768` | Output-cap fallback for undescribed models | | `requestImagePixelBudget` | `4,194,304` | Total-pixel budget for each deterministic request image | | `requestImageMaxBytes` | `1 MiB` | Encoded-byte target for each request image before base64 expansion | -| `maxRequestImageBytes` | `20 MiB` | Aggregate base64 image-payload bound declared to the `dsh-llm-image-offload` plugin's `image/offload` watermark planning | +| `maxRequestImageBytes` | `20 MiB` | Aggregate base64 image-payload bound; a request whose retained images exceed it fails with `IMAGE_OFFLOAD_REQUIRED` | | `retryPolicy` | normal, 5 retries | Provider-owned retry policy executed by `dsh-llm-retry` | The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-llm-pi-ai) is the exhaustive source for every accepted field and its JSDoc. @@ -176,7 +176,7 @@ Read these pages when the package-level contract is not enough. They move from t #### What the model sees -The selected catalog model receives `GenerateOptions.system`, history, tools, and sampling fields supported by pi-ai's common streaming API. Each retained image is preceded by text naming its complete attachment id and actual request dimensions. When the current execution filesystem maps the attachment provider's host object, the text also carries a read-only normalized-object path and warns that normalization or request projection may have resized or re-encoded the upload. Each occurrence the session's `image/offload` watermark marks offloaded keeps its own identity and currently resolved access in replacement text, and its normalized attachment is not read or transformed. When the retained occurrences' exact base64 payload still exceeds the route's `maxRequestImageBytes`, the call fails with `IMAGE_OFFLOAD_REQUIRED` so the `dsh-llm-image-offload` plugin advances the watermark and retries the step. Provider-native replay metadata is restored only when the adapter validates it for the historical content. +The selected catalog model receives `GenerateOptions.system`, history, tools, and sampling fields supported by pi-ai's common streaming API. Each retained image is preceded by text naming its complete attachment id and actual request dimensions. When the current execution filesystem maps the attachment provider's host object, the text also carries a read-only normalized-object path and warns that normalization or request projection may have resized or re-encoded the upload. Each occurrence the session's `image/offload` watermark marks offloaded keeps its own identity and currently resolved access in replacement text, and its normalized attachment is not read or transformed. When the retained occurrences' exact base64 payload still exceeds the route's `maxRequestImageBytes`, the call fails with `IMAGE_OFFLOAD_REQUIRED` so `dsh-llm-retry` advances the watermark and retries the step. Provider-native replay metadata is restored only when the adapter validates it for the historical content. #### Token effect diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index c6eb57bf02..ff6734e57a 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -83,7 +83,7 @@ kind: "package-reference" | `defaultMaxTokens` | `32,768` | 未描述模型的输出上限回退 | | `requestImagePixelBudget` | `4,194,304` | 每张确定性请求图片的总像素预算 | | `requestImageMaxBytes` | `1 MiB` | 每张请求图片在 base64 扩展前的编码字节目标 | -| `maxRequestImageBytes` | `20 MiB` | 向 `dsh-llm-image-offload` 插件的 `image/offload` 水位规划声明的 base64 图片载荷总上限 | +| `maxRequestImageBytes` | `20 MiB` | base64 图片载荷总上限,保留图片超过时请求以 `IMAGE_OFFLOAD_REQUIRED` 失败 | | `retryPolicy` | normal,5 次重试 | 由 `dsh-llm-retry` 执行的提供方自有重试策略 | 生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-llm-pi-ai)是每个受支持字段及其 JSDoc 的穷尽式真源。 @@ -176,7 +176,7 @@ pi-ai 不提供的路由需要 `api`、`baseURL` 与非空 `models` 列表;无 #### 模型看到什么 -所选目录模型会收到 `GenerateOptions.system`、历史、工具与 pi-ai 通用流式 API 支持的采样字段。每张保留图片前都会有文本,注明其完整附件 id 与实际请求尺寸。当前执行文件系统可以映射附件提供方的宿主对象时,该文本还会携带只读规范化对象路径,并警告规范化或请求投影可能缩放或重新编码上传内容。会话 `image/offload` 水位标记为已卸载的每个出现位置都会在替换文本中保留自己的身份与当前已解析访问方式,其规范化附件不会读取或变换。当保留的出现位置按精确 base64 载荷仍超过路由的 `maxRequestImageBytes` 时,调用以 `IMAGE_OFFLOAD_REQUIRED` 失败,由 `dsh-llm-image-offload` 插件推进水位并重试 step。提供方原生回放元数据只在适配器针对历史内容校验通过后恢复。 +所选目录模型会收到 `GenerateOptions.system`、历史、工具与 pi-ai 通用流式 API 支持的采样字段。每张保留图片前都会有文本,注明其完整附件 id 与实际请求尺寸。当前执行文件系统可以映射附件提供方的宿主对象时,该文本还会携带只读规范化对象路径,并警告规范化或请求投影可能缩放或重新编码上传内容。会话 `image/offload` 水位标记为已卸载的每个出现位置都会在替换文本中保留自己的身份与当前已解析访问方式,其规范化附件不会读取或变换。当保留的出现位置按精确 base64 载荷仍超过路由的 `maxRequestImageBytes` 时,调用以 `IMAGE_OFFLOAD_REQUIRED` 失败,由 `dsh-llm-retry` 推进水位并重试 step。提供方原生回放元数据只在适配器针对历史内容校验通过后恢复。 #### Token 影响 diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index da2cb1bd99..41a2171752 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -306,15 +306,6 @@ export class PiAiAdapter extends LlmAdapter { inputModalities: [...resolvedModel.input], context: { contextWindow: resolvedModel.contextWindow }, ...configuredMaxTokens === undefined ? {} : { defaultMaxTokens: configuredMaxTokens }, - ...resolvedModel.input.includes('image') - ? { - imageRequest: { - representation: 'base64' as const, - maxBytes: profile.maxRequestImageBytes, - versionMaxBytes: profile.requestImageMaxBytes, - }, - } - : {}, ...reasoningInfo(resolvedModel, defaultLevel), } } diff --git a/packages/llm/llm-retry/README.i18n.yaml b/packages/llm/llm-retry/README.i18n.yaml index 295b07d56e..aa54d6047a 100644 --- a/packages/llm/llm-retry/README.i18n.yaml +++ b/packages/llm/llm-retry/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-retry/README.md -README.md: 0ea815130212a165582540e43aad7d59fad32d2c -README.zh.md: 43a0187c68d272e2764d34db35fa26e51a9d3a83 +README.md: e5c97f602b4db1450ae1e2566e34b40fc6c385fd +README.zh.md: 2c3c310c9cc86e439f57de8c2f3068cc26176312 diff --git a/packages/llm/llm-retry/README.md b/packages/llm/llm-retry/README.md index 0ea8151302..e5c97f602b 100644 --- a/packages/llm/llm-retry/README.md +++ b/packages/llm/llm-retry/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`@deepseek-ai/dsh-llm-retry` is the retry executor for failed model requests: it applies each provider's resolved retry policy at the agent loop's open-step `agent/request-error` extension point, so every retry re-runs the same step inside the same open turn over the same durable history. It does not wrap the streaming call itself — every adapter call remains one provider attempt, and direct `ctx.llm.stream()` consumers stay single-attempt. Retry scheduling is durable: the plugin appends `llm/retry` events to the session log before waiting, and cancellation during backoff leaves the log consistent. Normal mode retries a bounded set of failure codes up to `maxRetries` with exponential backoff; always mode asks downstream recovery first, then retries every failure without an attempt limit. +`@deepseek-ai/dsh-llm-retry` is the retry executor for failed model requests: it applies each provider's resolved retry policy at the agent loop's open-step `agent/request-error` extension point, so every retry re-runs the same step inside the same open turn over the same durable history. It does not wrap the streaming call itself — every adapter call remains one provider attempt, and direct `ctx.llm.stream()` consumers stay single-attempt. Retry scheduling is durable: the plugin appends `llm/retry` events to the session log before waiting, and cancellation during backoff leaves the log consistent. Normal mode retries a bounded set of failure codes up to `maxRetries` with exponential backoff; always mode asks downstream recovery first, then retries every failure without an attempt limit. It also owns one durable repair: an adapter's `IMAGE_OFFLOAD_REQUIRED` failure advances the session's `image/offload` watermark by the count the failure names and retries the step, outside any provider retry budget. ## Table of Contents @@ -55,7 +55,7 @@ Each scheduled retry is durable before its wait: the plugin appends a non-surfac ### Failures and recovery -A failure before any final adapter is selected has no provider policy and delegates downstream unchanged. In normal mode, a failure code outside the eligible set, or an exhausted budget, delegates; in always mode, an over-cap provider delay uses the configured local backoff so the policy cannot terminate on that instruction. Nothing here is model-visible: no retry event, delay, provider error, or failed partial output reaches the model or derived messages. +A failure before any final adapter is selected has no provider policy and delegates downstream unchanged. In normal mode, a failure code outside the eligible set, or an exhausted budget, delegates; in always mode, an over-cap provider delay uses the configured local backoff so the policy cannot terminate on that instruction. Nothing here is model-visible: no retry event, delay, provider error, or failed partial output reaches the model or derived messages. An `IMAGE_OFFLOAD_REQUIRED` failure carrying `offloadImages` is handled before any policy: the plugin appends one `image/offload` event naming the greatest durable position among that many oldest retained image occurrences in request order and returns `retry`; when nothing remains to offload it delegates. That retry spends no retry budget and logs no `llm/retry` event, and the retried request sends placeholder text for the offloaded images ([decision](../../../.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.md)). ----- @@ -77,12 +77,13 @@ The executor is built on one rule: **durable before wait, open-step boundaries.* |---|---| | [`src/index.ts`](src/index.ts) | The function plugin: waterfall listener, policy lookup, backoff, durable event appends | | [`src/history.ts`](src/history.ts) | Durable retry-history lookup from the session log | +| [`src/image-offload.ts`](src/image-offload.ts) | The `image/offload` advance for `IMAGE_OFFLOAD_REQUIRED` failures | | [`src/types.ts`](src/types.ts) | Browser-safe `llm/retry` and `llm/retry-started` event payload types | | [`src/brand.ts`](src/brand.ts) | The `RetryId` brand shared by the event payloads | ### Recovery flow -A failed step arrives on the waterfall with its provider and resolved policy. Always mode settles downstream recovery first and honors a downstream `retry` decision; normal mode first checks that the failure code is eligible and the budget is not exhausted. The plugin computes the delay — provider `Retry-After` when valid and within bounds, otherwise local bounded exponential backoff with symmetric jitter — appends the `llm/retry` event, waits on a cancellable timer, appends `llm/retry-started`, and returns `{ kind: 'retry' }`. The loop then re-runs the failed step inside the same open turn over the same durable history. +A failed step arrives on the waterfall with its provider and resolved policy. An `IMAGE_OFFLOAD_REQUIRED` failure naming `offloadImages` is repaired at once: the watermark advances and the step retries with no delay. Always mode settles downstream recovery first and honors a downstream `retry` decision; normal mode first checks that the failure code is eligible and the budget is not exhausted. The plugin computes the delay — provider `Retry-After` when valid and within bounds, otherwise local bounded exponential backoff with symmetric jitter — appends the `llm/retry` event, waits on a cancellable timer, appends `llm/retry-started`, and returns `{ kind: 'retry' }`. The loop then re-runs the failed step inside the same open turn over the same durable history. ### Waterfall composition @@ -112,7 +113,7 @@ Read these pages when the package-level contract is not enough. They move from t #### What the model sees -No retry event, delay, provider error, or failed partial output is model-visible. The retried step reconstructs the same explicit provider/model request from durable surface history unless a downstream recovery policy deliberately changes that surface; failed chunks never enter derived messages. +No retry event, delay, provider error, or failed partial output is model-visible. The retried step reconstructs the same explicit provider/model request from durable surface history unless a downstream recovery policy deliberately changes that surface; failed chunks never enter derived messages. An `IMAGE_OFFLOAD_REQUIRED` repair is the one exception: the retried request replaces the offloaded images with placeholder text naming each image and its read-only path. #### Token effect diff --git a/packages/llm/llm-retry/README.zh.md b/packages/llm/llm-retry/README.zh.md index 43a0187c68..2c3c310c9c 100644 --- a/packages/llm/llm-retry/README.zh.md +++ b/packages/llm/llm-retry/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`@deepseek-ai/dsh-llm-retry` 是失败模型请求的重试执行器:它在 agent loop 的打开步骤 `agent/request-error` 扩展点上应用各提供方解析后的重试策略,因此每次重试都会在同一个打开的轮次内重跑同一个步骤(基于同一份持久历史)。它不包装流式调用本身——每次适配器调用仍是一次提供方尝试,直接 `ctx.llm.stream()` 消费方仍是单次尝试。重试调度是持久的:插件在等待之前就把 `llm/retry` 事件追加进会话日志,退避期间取消会让日志保持一致。normal mode 以指数退避重试一组有界的失败 code,最多 `maxRetries` 次;always mode 先询问下游恢复,然后无尝试上限地重试每个失败。 +`@deepseek-ai/dsh-llm-retry` 是失败模型请求的重试执行器:它在 agent loop 的打开步骤 `agent/request-error` 扩展点上应用各提供方解析后的重试策略,因此每次重试都会在同一个打开的轮次内重跑同一个步骤(基于同一份持久历史)。它不包装流式调用本身——每次适配器调用仍是一次提供方尝试,直接 `ctx.llm.stream()` 消费方仍是单次尝试。重试调度是持久的:插件在等待之前就把 `llm/retry` 事件追加进会话日志,退避期间取消会让日志保持一致。normal mode 以指数退避重试一组有界的失败 code,最多 `maxRetries` 次;always mode 先询问下游恢复,然后无尝试上限地重试每个失败。它还负责一种持久修复:adapter 以 `IMAGE_OFFLOAD_REQUIRED` 失败时,按失败给出的数量推进会话的 `image/offload` 水位并重试该步骤,不占任何提供方重试预算。 ## 目录 @@ -55,7 +55,7 @@ kind: "package-reference" ### 失败与恢复 -在任何最终适配器被选中之前发生的失败没有提供方策略,原样委派下游。normal mode 中,不在合格集合内的失败 code 或已耗尽的预算会委派;always mode 中,超上限的提供方延迟使用配置的本地退避,因此策略不会因该指令终止。这里没有任何模型可见内容:重试事件、延迟、提供方错误或失败的部分输出都不会到达模型或派生消息。 +在任何最终适配器被选中之前发生的失败没有提供方策略,原样委派下游。normal mode 中,不在合格集合内的失败 code 或已耗尽的预算会委派;always mode 中,超上限的提供方延迟使用配置的本地退避,因此策略不会因该指令终止。这里没有任何模型可见内容:重试事件、延迟、提供方错误或失败的部分输出都不会到达模型或派生消息。带 `offloadImages` 的 `IMAGE_OFFLOAD_REQUIRED` 失败在任何策略之前处理:插件追加一个 `image/offload` 事件,指向按请求顺序最老的那些保留图片中最大的持久位置,然后返回 `retry`;没有可省略的图片时委派下游。这次重试不占重试预算,也不追加 `llm/retry` 事件,重试的请求为被省略的图片发送占位文本([决定](../../../.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.zh.md))。 ----- @@ -77,12 +77,13 @@ kind: "package-reference" |---|---| | [`src/index.ts`](src/index.ts) | 函数插件:waterfall 监听器、策略查找、退避、持久事件追加 | | [`src/history.ts`](src/history.ts) | 从会话日志查找持久重试历史 | +| [`src/image-offload.ts`](src/image-offload.ts) | 针对 `IMAGE_OFFLOAD_REQUIRED` 失败的 `image/offload` 推进 | | [`src/types.ts`](src/types.ts) | 浏览器安全的 `llm/retry` 与 `llm/retry-started` 事件载荷类型 | | [`src/brand.ts`](src/brand.ts) | 事件载荷共享的 `RetryId` 品牌 | ### 恢复流程 -失败步骤连同其提供方与解析后的策略一起到达 waterfall。always mode 先结算下游恢复,并遵循下游的 `retry` 决定;normal mode 先检查失败 code 是否合格、预算是否未耗尽。插件计算延迟——有效且在边界内的提供方 `Retry-After`,否则带对称抖动的本地有界指数退避——追加 `llm/retry` 事件,在可取消定时器上等待,追加 `llm/retry-started`,然后返回 `{ kind: 'retry' }`。loop 随后在同一个打开的轮次内重跑失败步骤(仍基于同一份持久历史)。 +失败步骤连同其提供方与解析后的策略一起到达 waterfall。带 `offloadImages` 的 `IMAGE_OFFLOAD_REQUIRED` 失败会立即修复:推进水位,步骤无延迟重试。always mode 先结算下游恢复,并遵循下游的 `retry` 决定;normal mode 先检查失败 code 是否合格、预算是否未耗尽。插件计算延迟——有效且在边界内的提供方 `Retry-After`,否则带对称抖动的本地有界指数退避——追加 `llm/retry` 事件,在可取消定时器上等待,追加 `llm/retry-started`,然后返回 `{ kind: 'retry' }`。loop 随后在同一个打开的轮次内重跑失败步骤(仍基于同一份持久历史)。 ### Waterfall 组合 @@ -112,7 +113,7 @@ kind: "package-reference" #### 模型看到什么 -重试事件、延迟、提供方错误或失败的部分输出都不会对模型可见。除非下游恢复策略刻意改变表面,重试步骤从持久表面历史重建同一显式提供方/模型请求;失败分片绝不进入派生消息。 +重试事件、延迟、提供方错误或失败的部分输出都不会对模型可见。除非下游恢复策略刻意改变表面,重试步骤从持久表面历史重建同一显式提供方/模型请求;失败分片绝不进入派生消息。`IMAGE_OFFLOAD_REQUIRED` 修复是唯一的例外:重试的请求把被省略的图片替换为注明图片及其只读路径的占位文本。 #### Token 影响 diff --git a/packages/llm/llm-retry/src/image-offload.ts b/packages/llm/llm-retry/src/image-offload.ts new file mode 100644 index 0000000000..ffa59dc6b9 --- /dev/null +++ b/packages/llm/llm-retry/src/image-offload.ts @@ -0,0 +1,44 @@ +/** + * Durable image offload recovery: advance the session's `image/offload` + * watermark past the oldest retained image occurrences an adapter's + * `IMAGE_OFFLOAD_REQUIRED` failure names, so the retried step derives the + * offloaded set the log records. + * + * @module @deepseek-ai/dsh-llm-retry/image-offload + */ + +import { visitImageBlocks } from '@deepseek-ai/dsh-llm' +import { compareImagePositions } from '@deepseek-ai/dsh-session' +import type { ImageOccurrencePosition, Session } from '@deepseek-ai/dsh-session' + +/** + * Append one `image/offload` advance covering the `count` oldest image + * occurrences the surface still sends, in model request order. A surface + * replacement can place a newer event before older ones, so the advance + * names the greatest durable position in that prefix; it may offload + * additional occurrences but always covers the prefix. + * @param session - the session whose derived surface the retried request reads. + * @param turn - the open turn. + * @param step - the step being retried. + * @param count - how many more oldest retained occurrences the adapter needs offloaded. + * @returns whether any occurrence remained to offload. + */ +export function advanceImageOffload(session: Session, turn: number, step: number, count: number): boolean { + let remaining = count + let watermark: ImageOccurrencePosition | undefined + for (const seq of session.surface.nodes) { + if (remaining === 0) break + // oxlint-disable-next-line typescript/no-non-null-assertion -- surface nodes index the durable log + const message = session.deriveEventMessage(session.eventAt(seq)!) + if (message === null) continue + visitImageBlocks(message.content, (block, path) => { + if (remaining === 0 || block.offloaded === true) return + remaining -= 1 + const position = { seq, path: [...path] } + if (watermark === undefined || compareImagePositions(position, watermark) > 0) watermark = position + }) + } + if (watermark === undefined) return false + session.append('image/offload', { turn, step, watermark }) + return true +} diff --git a/packages/llm/llm-retry/src/index.ts b/packages/llm/llm-retry/src/index.ts index 89ffc14c40..9bc7b2acb0 100644 --- a/packages/llm/llm-retry/src/index.ts +++ b/packages/llm/llm-retry/src/index.ts @@ -10,9 +10,11 @@ import type { Context, Events } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { z as zod } from 'zod' import type { Agent, RequestErrorAction } from '@deepseek-ai/dsh-agent' +import { IMAGE_OFFLOAD_REQUIRED_CODE } from '@deepseek-ai/dsh-llm' import type { LlmFailure, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-session-projection' import { RetryId } from './brand.ts' +import { advanceImageOffload } from './image-offload.ts' import type { LlmRetryEventData } from './types.ts' export type { LlmRetryEventData, LlmRetryStartedEventData } from './types.ts' @@ -195,6 +197,11 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna { agent, turn, step, provider, failure, retryPolicy: policy, signal }: Parameters[0], next: () => Promise, ): Promise { + // A durable surface repair, not a provider retry: it spends no retry budget and logs no retry event. + if (failure.code === IMAGE_OFFLOAD_REQUIRED_CODE && failure.offloadImages !== undefined + && advanceImageOffload(agent.session, turn, step, failure.offloadImages)) { + return { kind: 'retry' } + } if (policy === undefined) return next() if (policy.mode === 'always') { if (signal.aborted || lifetime.signal.aborted) return diff --git a/packages/llm/llm-image-offload/tests/image-offload.spec.ts b/packages/llm/llm-retry/tests/image-offload.spec.ts similarity index 51% rename from packages/llm/llm-image-offload/tests/image-offload.spec.ts rename to packages/llm/llm-retry/tests/image-offload.spec.ts index ae02766d28..f0633422f9 100644 --- a/packages/llm/llm-image-offload/tests/image-offload.spec.ts +++ b/packages/llm/llm-retry/tests/image-offload.spec.ts @@ -1,8 +1,7 @@ /** - * Durable image offload through the plugin: the routed budget advances the - * `image/offload` watermark before a step, the watermark never retreats, and - * an adapter's `IMAGE_OFFLOAD_REQUIRED` failure advances it by the named count - * and retries the step. + * Image offload recovery: an adapter's `IMAGE_OFFLOAD_REQUIRED` failure + * advances the durable watermark by the named count and retries the step + * without spending the provider retry budget. */ import { describe, expect, it } from 'vitest' @@ -11,30 +10,21 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { createUserMessage, IMAGE_OFFLOAD_REQUIRED_CODE, LlmAdapter, LlmError, ToolCallId } from '@deepseek-ai/dsh-llm' -import type { ContentBlock, GenerateOptions, LlmImageRequestBudget, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import type { Session } from '@deepseek-ai/dsh-session' -import * as offload from '../src/index.ts' +import * as retry from '../src/index.ts' type ScriptEntry = StreamChunk[] | (() => never) -/** Replies one scripted stream per request and declares an optional request-image budget. */ +/** Replies one scripted stream per request and declares no retry policy. */ class ScriptedAdapter extends LlmAdapter { readonly requests: GenerateOptions[] = [] - constructor(private readonly script: ScriptEntry[], private readonly imageRequest?: LlmImageRequestBudget) { + constructor(private readonly script: ScriptEntry[]) { super() } - override resolveModel(provider: string, model: string): Promise { - return Promise.resolve({ - provider, - id: model, - name: model, - ...this.imageRequest === undefined ? {} : { inputModalities: ['text', 'image'], imageRequest: this.imageRequest }, - }) - } - async * stream(options: GenerateOptions): AsyncIterable { this.requests.push(options) const entry = this.script.shift() @@ -62,23 +52,19 @@ async function harness(adapter: ScriptedAdapter): Promise { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(Object.assign((inner: Context) => { offload.apply(inner, {}) }, { inject: offload.inject })) + await ctx.plugin(Object.assign((inner: Context) => { retry.apply(inner, {}) }, { inject: retry.inject })) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) return ctx } -function image(name: string, bytes: number): Extract { +function image(name: string): Extract { return { type: 'image', - attachment: { attachmentId: `sha256:${'a'.repeat(64)}` as never, name, mediaType: 'image/png', bytes, width: 1, height: 1 }, + attachment: { attachmentId: `sha256:${'a'.repeat(64)}` as never, name, mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, } } -function text(value: string): ReturnType { - return createUserMessage({ content: [{ type: 'text', text: value }], source: { kind: 'user' } }) -} - function offloadedNames(options: GenerateOptions): string[] { const names: string[] = [] const visit = (blocks: readonly ContentBlock[]): void => { @@ -102,59 +88,9 @@ function offloadEvents(session: Session): { turn: number; step: number; seq: num })) } -const budget: LlmImageRequestBudget = { representation: 'raw', maxBytes: 10, byteQuantum: 4 } - -describe('llm-image-offload', () => { - it('advances the watermark before a step from the routed budget and never retreats', async () => { - const adapter = new ScriptedAdapter([textResponse('one'), textResponse('two'), textResponse('three')], budget) - const ctx = await harness(adapter) - const agent = await ctx.agentLoop.create(SessionId('offload-advance'), { provider: 'mock', model: 'mock' }) - - // The first turn logs the request header that names the route. - agent.followup(text('hello')) - await agent.whenIdle() - expect(offloadEvents(agent.session)).toHaveLength(0) - - // 12 raw bytes exceed the 10-byte bound by 2, rounded up to one 4-byte quantum: 'a' - // alone removes 4 bytes but the quantum rule crosses into 'b'. - agent.session.append('user/message', createUserMessage({ - content: [image('a', 4), { type: 'tool-result', toolCallId: ToolCallId('shot'), content: [image('b', 4)] }, image('c', 4)], - source: { kind: 'user' }, - }), { surfaceOp: 'append' }) - agent.followup(text('again')) - await agent.whenIdle() - - expect(offloadedNames(adapter.requests[1]!)).toEqual(['a', 'b']) - const events = offloadEvents(agent.session) - expect(events).toHaveLength(1) - expect(events[0]).toMatchObject({ turn: 2, step: 1, path: [1, 0] }) - const order = agent.session.snapshotEvents().map(event => event.type) - expect(order.lastIndexOf('turn/start')).toBeLessThan(order.indexOf('image/offload')) - expect(order.indexOf('image/offload')).toBeLessThan(order.lastIndexOf('step/start')) - - // The next turn adds no bytes beyond the bound, so the watermark holds and the - // second request's offloaded set is still what the third request sends. - agent.followup(text('once more')) - await agent.whenIdle() - expect(offloadedNames(adapter.requests[2]!)).toEqual(['a', 'b']) - expect(offloadEvents(agent.session)).toHaveLength(1) - }) - - it('leaves a route without a declared budget alone', async () => { - const adapter = new ScriptedAdapter([textResponse('ok'), textResponse('ok')]) - const ctx = await harness(adapter) - const agent = await ctx.agentLoop.create(SessionId('offload-none'), { provider: 'mock', model: 'mock' }) - agent.followup(text('hello')) - await agent.whenIdle() - agent.session.append('user/message', createUserMessage({ content: [image('a', 400)], source: { kind: 'user' } }), { surfaceOp: 'append' }) - agent.followup(text('again')) - await agent.whenIdle() - expect(offloadedNames(adapter.requests[1]!)).toEqual([]) - expect(offloadEvents(agent.session)).toHaveLength(0) - }) - - it('advances by the count an IMAGE_OFFLOAD_REQUIRED failure names and retries the step', async () => { - const adapter = new ScriptedAdapter([offloadRequired(2), textResponse('sent')], { representation: 'raw', maxBytes: 100 }) +describe('image offload recovery', () => { + it('advances the watermark by the named count and retries without a retry event', async () => { + const adapter = new ScriptedAdapter([offloadRequired(2), textResponse('sent')]) const ctx = await harness(adapter) const agent = await ctx.agentLoop.create(SessionId('offload-required'), { provider: 'mock', model: 'mock' }) const delegated: string[] = [] @@ -164,39 +100,41 @@ describe('llm-image-offload', () => { }) agent.followup(createUserMessage({ - content: [image('a', 1), image('b', 1), image('c', 1)], source: { kind: 'user' }, + content: [image('a'), { type: 'tool-result', toolCallId: ToolCallId('shot'), content: [image('b')] }, image('c')], + source: { kind: 'user' }, })) await agent.whenIdle() expect(adapter.requests).toHaveLength(2) expect(offloadedNames(adapter.requests[0]!)).toEqual([]) expect(offloadedNames(adapter.requests[1]!)).toEqual(['a', 'b']) - expect(offloadEvents(agent.session)).toEqual([{ turn: 1, step: 1, seq: expect.any(Number) as never, path: [1] }]) + expect(offloadEvents(agent.session)).toMatchObject([{ turn: 1, step: 1, path: [1, 0] }]) expect(delegated).toEqual([]) const types = agent.session.snapshotEvents().map(event => event.type) + expect(types.filter(type => type === 'llm/retry')).toHaveLength(0) expect(types.filter(type => type === 'assistant/attempt')).toHaveLength(1) - expect(types.filter(type => type === 'assistant/message')).toHaveLength(1) expect(types.indexOf('assistant/attempt')).toBeLessThan(types.indexOf('image/offload')) + expect(types.indexOf('image/offload')).toBeLessThan(types.indexOf('assistant/message')) }) - it('interprets an adapter count in request order after a surface replacement', async () => { + it('counts the adapter prefix in request order after a surface replacement', async () => { const adapter = new ScriptedAdapter([offloadRequired(2), textResponse('sent')]) const ctx = await harness(adapter) const agent = await ctx.agentLoop.create(SessionId('offload-reordered'), { provider: 'mock', model: 'mock' }) const first = agent.session.append('user/message', createUserMessage({ - content: [image('first', 1)], source: { kind: 'user' }, + content: [image('first')], source: { kind: 'user' }, }), { surfaceOp: 'append' }) agent.session.append('user/message', createUserMessage({ - content: [image('second', 1)], source: { kind: 'user' }, + content: [image('second')], source: { kind: 'user' }, }), { surfaceOp: 'append' }) agent.session.append('user/message', createUserMessage({ - content: [image('replacement', 1)], source: { kind: 'user' }, + content: [image('replacement')], source: { kind: 'user' }, }), { surfaceOp: { op: 'replace', start: first.seq, end: first.seq }, sourceEventSeqs: [first.seq], }) - agent.followup(text('send')) + agent.followup(createUserMessage({ content: [{ type: 'text', text: 'send' }], source: { kind: 'user' } })) await agent.whenIdle() expect(adapter.requests).toHaveLength(2) @@ -213,7 +151,7 @@ describe('llm-image-offload', () => { delegated.push(failure.code) return next() }) - agent.followup(text('no images')) + agent.followup(createUserMessage({ content: [{ type: 'text', text: 'no images' }], source: { kind: 'user' } })) await agent.whenIdle() expect(delegated).toEqual([IMAGE_OFFLOAD_REQUIRED_CODE]) expect(offloadEvents(agent.session)).toHaveLength(0) diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml index 089c30be04..e4c929a014 100644 --- a/packages/llm/llm/README.i18n.yaml +++ b/packages/llm/llm/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm/README.md -README.md: dc5fb35d5bcb9a5a129ff4b9db9e595a5342f19c -README.zh.md: 20fa7102eea142720153feb01deb0185205fda28 +README.md: 0fec0b06c9e9d7725e9efe6b98e27ccae1cb1b65 +README.zh.md: 4b1d9738769f3568c66d2bad5a124f0087eaced3 diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index dc5fb35d5b..0fec0b06c9 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -101,7 +101,7 @@ The service is built on one separation: **the logical contract is provider-neutr ### Main flow -A request is validated against its exact model's capability — context window, output default, reasoning efforts, and input modalities — and any adapter-configured defaults are materialized, then the whole request is deep-frozen. `prepareCall()` binds those facts, detached context, and retry policy to the exact adapter generation that performs terminal dispatch, so HMR or dynamic settings cannot combine one generation's image capability with another generation's endpoint. An image-capable adapter projects durable references into route-specific request versions; `resolveImageAttachmentAccess()` separately maps an attachment provider's optional host object into the current tool execution world without changing the request image or its `variantId`. A text-only route receives deterministic per-image placeholders, including nested tool-result images, without rewriting append-only session history. Durable `FileBlock` references never reach any adapter: request assembly replaces each one, nested tool-result occurrences included, with deterministic handle text naming the file and its saved read-only path, resolved through the mounted attachment and filesystem providers. `ctx.llm.fileRequestText(ref)` exposes that exact synchronous projection to request measurement. An image-capable route declares a `LlmImageRequestBudget` on its resolved model info; the `dsh-llm-image-offload` plugin plans the session's durable `image/offload` watermark from it with the pure `offloadedImagePrefixCount()`, surface derivation marks occurrences at or before the watermark `offloaded: true`, and `projectOffloadedImages()` renders those marks as route-owned placeholder text. An adapter whose exact byte accounting still exceeds its budget fails with `IMAGE_OFFLOAD_REQUIRED` naming the additional occurrences (`requiredImageOffload()`), never with an unlogged projection. Adapters that charge visual tokens declare per-route `imageRequestPricing`, which `ctx.llm.imageRequestPricing(provider, model)` resolves synchronously for the token meter. Dispatch goes through the `llm/stream` waterfall, then chunks return as token-level deltas and every adapter outcome reaches the consumer as one terminal `finish` chunk. +A request is validated against its exact model's capability — context window, output default, reasoning efforts, and input modalities — and any adapter-configured defaults are materialized, then the whole request is deep-frozen. `prepareCall()` binds those facts, detached context, and retry policy to the exact adapter generation that performs terminal dispatch, so HMR or dynamic settings cannot combine one generation's image capability with another generation's endpoint. An image-capable adapter projects durable references into route-specific request versions; `resolveImageAttachmentAccess()` separately maps an attachment provider's optional host object into the current tool execution world without changing the request image or its `variantId`. A text-only route receives deterministic per-image placeholders, including nested tool-result images, without rewriting append-only session history. Durable `FileBlock` references never reach any adapter: request assembly replaces each one, nested tool-result occurrences included, with deterministic handle text naming the file and its saved read-only path, resolved through the mounted attachment and filesystem providers. `ctx.llm.fileRequestText(ref)` exposes that exact synchronous projection to request measurement. Surface derivation marks occurrences at or before the session's durable `image/offload` watermark `offloaded: true`, and `projectOffloadedImages()` renders those marks as route-owned placeholder text. An image-capable route whose retained occurrences exceed its `LlmImageRequestBudget` at their exact bytes fails with `IMAGE_OFFLOAD_REQUIRED` naming the additional oldest occurrences (`requiredImageOffload()`), never with an unlogged projection; `dsh-llm-retry` advances the watermark and retries. Adapters that charge visual tokens declare per-route `imageRequestPricing`, which `ctx.llm.imageRequestPricing(provider, model)` resolves synchronously for the token meter. Dispatch goes through the `llm/stream` waterfall, then chunks return as token-level deltas and every adapter outcome reaches the consumer as one terminal `finish` chunk. ### Invariants diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md index 20fa7102ee..4b1d973876 100644 --- a/packages/llm/llm/README.zh.md +++ b/packages/llm/llm/README.zh.md @@ -101,7 +101,7 @@ for await (const chunk of ctx.llm.stream({ ### 主流程 -请求会对照其精确模型的能力——上下文窗口、输出默认值、推理强度与输入模态——校验,填入任何适配器配置的默认值,然后整个请求被深度冻结。`prepareCall()` 把这些事实、分离的上下文与重试策略绑定到执行最终分发的精确适配器代次,因此 HMR 或动态设置无法把一个代次的图片能力与另一代次的端点混用。支持图片的适配器把持久引用投影为路由专用请求版本;`resolveImageAttachmentAccess()` 会单独把附件提供方的可选宿主对象映射进当前工具执行世界,而不改变请求图片或其 `variantId`。纯文本路由接收确定性的逐图片占位符,包括嵌套工具结果图片,而不会改写仅追加会话历史。持久 `FileBlock` 引用永远不会到达任何适配器:请求组装把每个引用(包括嵌套工具结果中的出现)替换为确定性 handle 文本,指出文件与其只读保存路径,路径经由挂载的附件与文件系统提供方解析。`ctx.llm.fileRequestText(ref)` 向请求计量公开相同的同步投影。支持图片的路由在其解析后的模型信息上声明 `LlmImageRequestBudget`;`dsh-llm-image-offload` 插件用纯函数 `offloadedImagePrefixCount()` 据此规划会话的持久 `image/offload` 水位,表层派生把位于水位及之前的出现位置标为 `offloaded: true`,`projectOffloadedImages()` 把这些标记渲染为路由所属的占位文本。adapter 按精确字节计量仍超预算时,以 `IMAGE_OFFLOAD_REQUIRED` 失败并说明还需省略的出现位置数(`requiredImageOffload()`),绝不发送未记录的投影。对视觉 token 收费的适配器声明按路由的 `imageRequestPricing`,`ctx.llm.imageRequestPricing(provider, model)` 为 token meter 同步解析它。分发经过 `llm/stream` waterfall,随后分片以 token 级增量返回,每个适配器结果都以唯一一个终止 `finish` 分片到达消费方。 +请求会对照其精确模型的能力——上下文窗口、输出默认值、推理强度与输入模态——校验,填入任何适配器配置的默认值,然后整个请求被深度冻结。`prepareCall()` 把这些事实、分离的上下文与重试策略绑定到执行最终分发的精确适配器代次,因此 HMR 或动态设置无法把一个代次的图片能力与另一代次的端点混用。支持图片的适配器把持久引用投影为路由专用请求版本;`resolveImageAttachmentAccess()` 会单独把附件提供方的可选宿主对象映射进当前工具执行世界,而不改变请求图片或其 `variantId`。纯文本路由接收确定性的逐图片占位符,包括嵌套工具结果图片,而不会改写仅追加会话历史。持久 `FileBlock` 引用永远不会到达任何适配器:请求组装把每个引用(包括嵌套工具结果中的出现)替换为确定性 handle 文本,指出文件与其只读保存路径,路径经由挂载的附件与文件系统提供方解析。`ctx.llm.fileRequestText(ref)` 向请求计量公开相同的同步投影。表层派生把位于会话持久 `image/offload` 水位及之前的出现位置标为 `offloaded: true`,`projectOffloadedImages()` 把这些标记渲染为路由所属的占位文本。支持图片的路由在保留的出现位置按精确字节超过其 `LlmImageRequestBudget` 时,以 `IMAGE_OFFLOAD_REQUIRED` 失败并说明还需省略多少最老的出现位置(`requiredImageOffload()`),绝不发送未记录的投影;`dsh-llm-retry` 推进水位并重试。对视觉 token 收费的适配器声明按路由的 `imageRequestPricing`,`ctx.llm.imageRequestPricing(provider, model)` 为 token meter 同步解析它。分发经过 `llm/stream` waterfall,随后分片以 token 级增量返回,每个适配器结果都以唯一一个终止 `finish` 分片到达消费方。 ### 不变式 diff --git a/packages/llm/llm/src/content.ts b/packages/llm/llm/src/content.ts index fcd16472c9..f3ba195c10 100644 --- a/packages/llm/llm/src/content.ts +++ b/packages/llm/llm/src/content.ts @@ -240,18 +240,16 @@ export function visitImageBlocks( /** * Represented byte length of one image occurrence under a route budget: the - * normalized byte count clamped to the route's request-version target, then - * base64-expanded for an inline representation. - * @param bytes - normalized attachment byte count. - * @param budget - route representation and request-version target. + * request-version byte count, base64-expanded for an inline representation. + * @param bytes - request-version byte count. + * @param budget - route representation. * @returns the byte length the route's request accounting charges. */ export function representedImageBytes( bytes: number, - budget: Pick, + budget: Pick, ): number { - const clamped = budget.versionMaxBytes === undefined ? bytes : Math.min(bytes, budget.versionMaxBytes) - return budget.representation === 'base64' ? base64Length(clamped) : clamped + return budget.representation === 'base64' ? base64Length(bytes) : bytes } /** Replace every offloaded occurrence, including nested tool results, with its placeholder. */ @@ -300,9 +298,8 @@ export function projectOffloadedImages( /** * Number of oldest retained image occurrences one route budget removes, in * whole count and byte quanta, once the budget is exceeded. The result depends - * only on the represented lengths, so the image-offload plugin plans the - * durable watermark from logged facts and an adapter names the same count - * when its exact accounting still overflows. + * only on the represented lengths, so every route names the count the same + * way. * @param lengths - represented byte length of every retained occurrence, oldest first. * @param budget - count/byte budgets and removal quanta; unbounded when absent. * @returns how many leading occurrences to offload. diff --git a/packages/llm/llm/src/error.ts b/packages/llm/llm/src/error.ts index 232af84fb4..e031fcf970 100644 --- a/packages/llm/llm/src/error.ts +++ b/packages/llm/llm/src/error.ts @@ -166,7 +166,7 @@ export function isHarnessError(value: unknown): value is HarnessError { * Canonical code for a request an image-capable route cannot send until the * session's durable `image/offload` watermark advances. The failure's * `offloadImages` names how many more of the oldest retained occurrences must - * be offloaded; the `dsh-llm-image-offload` plugin appends the advance and - * retries the step, so the model never receives an unlogged projection. + * be offloaded; `dsh-llm-retry` appends the advance and retries the step, so + * the model never receives an unlogged projection. */ export const IMAGE_OFFLOAD_REQUIRED_CODE = 'IMAGE_OFFLOAD_REQUIRED' diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index d80e16e354..3d345d7a0d 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -782,7 +782,6 @@ export class LlmRuntime extends TypertRemoteService { 'INVALID_MODEL_MAX_TOKENS', ) } - const imageRequest = resolved.imageRequest === undefined ? undefined : { ...resolved.imageRequest } const info: LlmResolvedModelInfo = { provider, id: model, @@ -791,7 +790,6 @@ export class LlmRuntime extends TypertRemoteService { ...inputModalities === undefined ? {} : { inputModalities }, ...context === undefined ? {} : { context: { contextWindow: context.contextWindow } }, ...defaultMaxTokens === undefined ? {} : { defaultMaxTokens }, - ...imageRequest === undefined ? {} : { imageRequest }, } const reasoning = resolved.reasoning if (reasoning === undefined) return info diff --git a/packages/llm/llm/src/types.ts b/packages/llm/llm/src/types.ts index 829074a7fb..fd8c86f8bc 100644 --- a/packages/llm/llm/src/types.ts +++ b/packages/llm/llm/src/types.ts @@ -51,8 +51,8 @@ export interface LlmFailure { /** * With code `IMAGE_OFFLOAD_REQUIRED`: how many more of the oldest retained * image occurrences the route needs offloaded before the same request fits - * its exact byte accounting. The `dsh-llm-image-offload` plugin advances the - * durable watermark by this count and retries the step. + * its exact byte accounting. `dsh-llm-retry` advances the session's durable + * `image/offload` watermark by this count and retries the step. */ readonly offloadImages?: number } @@ -330,13 +330,10 @@ export interface LlmModelContext { } /** - * Request-image budget one exact image-capable route declares, so the - * `dsh-llm-image-offload` plugin can advance the session's durable - * `image/offload` watermark before a step from logged facts alone. Byte - * accounting clamps each occurrence's - * normalized byte count to {@link versionMaxBytes} and, for the `base64` - * representation, expands it to its encoded length. A route whose exact - * request accounting still exceeds the budget fails the request with + * Request-image budget one exact image-capable route enforces over the + * retained occurrences' exact request-version bytes; for the `base64` + * representation each byte count expands to its encoded length. A route whose + * retained occurrences exceed the budget fails the request with * `IMAGE_OFFLOAD_REQUIRED` naming the additional occurrences to offload. */ export interface LlmImageRequestBudget { @@ -350,8 +347,6 @@ export interface LlmImageRequestBudget { byteQuantum?: number /** Occurrences removed as one deterministic advance step; absent removes the minimum. */ countQuantum?: number - /** Encoded-byte target of the route's derived request version; absent accounts normalized bytes. */ - versionMaxBytes?: number } /** Display metadata for one adapter-owned reasoning effort. */ @@ -383,8 +378,6 @@ export interface LlmResolvedModelInfo extends LlmModelInfo { defaultMaxTokens?: number /** Adapter-owned selectable reasoning levels when exposed. */ reasoning?: LlmModelReasoningInfo - /** Request-image budget the route enforces; absent for routes that never offload. */ - imageRequest?: LlmImageRequestBudget } /** diff --git a/packages/llm/llm/tests/content.spec.ts b/packages/llm/llm/tests/content.spec.ts index 792caeff9e..76c87382fc 100644 --- a/packages/llm/llm/tests/content.spec.ts +++ b/packages/llm/llm/tests/content.spec.ts @@ -62,11 +62,10 @@ describe('visitImageBlocks', () => { }) describe('representedImageBytes', () => { - it('clamps to the request-version target and expands inline representations', () => { + it('expands inline representations to their encoded length', () => { expect(representedImageBytes(100, { representation: 'raw' })).toBe(100) - expect(representedImageBytes(100, { representation: 'raw', versionMaxBytes: 60 })).toBe(60) expect(representedImageBytes(3, { representation: 'base64' })).toBe(4) - expect(representedImageBytes(4, { representation: 'base64', versionMaxBytes: 60 })).toBe(8) + expect(representedImageBytes(4, { representation: 'base64' })).toBe(8) }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0f965fc6db..9a631af3c8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1209,9 +1209,6 @@ importers: '@deepseek-ai/dsh-llm-deepseek': specifier: workspace:^ version: link:../../llm/llm-deepseek - '@deepseek-ai/dsh-llm-image-offload': - specifier: workspace:^ - version: link:../../llm/llm-image-offload '@deepseek-ai/dsh-llm-pi-ai': specifier: workspace:^ version: link:../../llm/llm-pi-ai @@ -1482,9 +1479,6 @@ importers: '@deepseek-ai/dsh-llm-deepseek': specifier: workspace:^ version: link:../../llm/llm-deepseek - '@deepseek-ai/dsh-llm-image-offload': - specifier: workspace:^ - version: link:../../llm/llm-image-offload '@deepseek-ai/dsh-llm-retry': specifier: workspace:^ version: link:../../llm/llm-retry @@ -6366,34 +6360,6 @@ importers: specifier: workspace:^ version: link:../../util/timeout - packages/llm/llm-image-offload: - dependencies: - '@deepseek-ai/schemastery': - specifier: link:../../../vendor/schemastery - version: link:../../../vendor/schemastery - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-agent-loop': - specifier: workspace:^ - version: link:../../core/agent-loop - '@deepseek-ai/dsh-agent-loop-testkit': - specifier: workspace:^ - version: link:../../test-support/agent-loop-testkit - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../llm - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session - '@deepseek-ai/dsh-session-projection': - specifier: workspace:^ - version: link:../../session/session-projection - packages/llm/llm-pi-ai: dependencies: '@deepseek-ai/dsh-brand': @@ -10226,9 +10192,6 @@ importers: '@deepseek-ai/dsh-llm-deepseek': specifier: workspace:^ version: link:../../packages/llm/llm-deepseek - '@deepseek-ai/dsh-llm-image-offload': - specifier: workspace:^ - version: link:../../packages/llm/llm-image-offload '@deepseek-ai/dsh-llm-pi-ai': specifier: workspace:^ version: link:../../packages/llm/llm-pi-ai diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index 15837853fc..97c1a6bc68 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -53,7 +53,6 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-llm-pi-ai": "workspace:^", - "@deepseek-ai/dsh-llm-image-offload": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-mcp-client": "workspace:^", "@deepseek-ai/dsh-output-retention": "workspace:^", diff --git a/tsconfig.base.json b/tsconfig.base.json index 8e2d5adf79..64a17b2c50 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -297,7 +297,6 @@ "@deepseek-ai/dsh-llm": ["./packages/llm/llm/src"], "@deepseek-ai/dsh-llm/invariant": ["./packages/llm/llm/src/invariant.ts"], "@deepseek-ai/dsh-llm-deepseek": ["./packages/llm/llm-deepseek/src"], - "@deepseek-ai/dsh-llm-image-offload": ["./packages/llm/llm-image-offload/src"], "@deepseek-ai/dsh-llm-mock-server": ["./packages/test-support/llm-mock-server/src"], "@deepseek-ai/dsh-llm-pi-ai": ["./packages/llm/llm-pi-ai/src"], "@deepseek-ai/dsh-llm-replay": ["./packages/test-support/llm-replay/src"], diff --git a/tsconfig.host.json b/tsconfig.host.json index bf1ba85522..a6817dce02 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -225,7 +225,6 @@ { "path": "./packages/context/agent-instructions" }, { "path": "./packages/core/agent-loop" }, { "path": "./packages/llm/llm-retry" }, - { "path": "./packages/llm/llm-image-offload" }, { "path": "./packages/subprocess/subprocess" }, { "path": "./packages/subprocess/subprocess-local" }, { "path": "./packages/subprocess/win32-process" }, From ab93bfd90c8570b3f3544bc304853e1a34859ca2 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 7 Sep 2026 17:35:28 +0800 Subject: [PATCH 09/79] refactor(session): fold the image offload helpers into surface.ts and index.ts The watermark marking and position comparison sit next to deriveEventMessage in surface.ts; the append/seed validation sits with the other event validators in index.ts and the fold is inline. Only compareImagePositions stays exported. --- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 8 +- docs/event-producer-consumer.zh.md | 8 +- packages/core/session/README.i18n.yaml | 4 +- packages/core/session/README.md | 3 +- packages/core/session/README.zh.md | 3 +- packages/core/session/src/image-offload.ts | 158 ------------------ packages/core/session/src/index.ts | 64 ++++++- packages/core/session/src/surface.ts | 73 +++++++- .../core/session/tests/image-offload.spec.ts | 21 ++- 10 files changed, 155 insertions(+), 191 deletions(-) delete mode 100644 packages/core/session/src/image-offload.ts diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 2ff3b1bb3f..598d2c1187 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/event-producer-consumer.md -event-producer-consumer.md: 1f8bb7772f3941e69f09425e4ed54e1f424a2e99 -event-producer-consumer.zh.md: 31e01a2f6082e59bf21541660c6b28d77222f04d +event-producer-consumer.md: 7cecb1f362c311cf9b7b617466c1eaa06721a05e +event-producer-consumer.zh.md: 57af4848a79065cc4ca65d6f56f4d543ec979441 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 1f8bb7772f..7cecb1f362 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -47,10 +47,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `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: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:52`](../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:62`](../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:74`](../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:83`](../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) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:50`](../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:60`](../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:72`](../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:81`](../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) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:298`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 31e01a2f60..57af4848a7 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -49,10 +49,10 @@ | `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: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:52`](../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:62`](../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:74`](../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:83`](../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) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:50`](../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:60`](../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:72`](../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:81`](../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) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:298`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | diff --git a/packages/core/session/README.i18n.yaml b/packages/core/session/README.i18n.yaml index aae135a310..c079753a48 100644 --- a/packages/core/session/README.i18n.yaml +++ b/packages/core/session/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/session/README.md -README.md: 1c58fafe55b28916b94b917caaefbbad18a2dc86 -README.zh.md: ba2f84c13c82cd97cf3fee53e84a845b46569c07 +README.md: 0d519ec60e6aeed466736d69c3bd39337d0f01f4 +README.zh.md: 8aa5db753b96e980faac6cfdc14983ffca42571c diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 1c58fafe55..0d519ec60e 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -91,9 +91,8 @@ The package is built on event sourcing: a `Session` is an append-only log of typ |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry: `SessionStore` service, store lifecycle, `fork`, `flush` | | [`src/types.ts`](src/types.ts) | `SessionEventMap`, `SessionEvent`, `UserMessage`, `SessionHeader`, `TurnEndReasonMap` | -| [`src/surface.ts`](src/surface.ts) | Ordered surface projection, replacement validation, `deriveEventMessage` | +| [`src/surface.ts`](src/surface.ts) | Ordered surface projection, replacement validation, `deriveEventMessage`, `image/offload` marking | | [`src/request-header.ts`](src/request-header.ts) | `request/header` folding and reconstruction | -| [`src/image-offload.ts`](src/image-offload.ts) | `image/offload` watermark validation, folding, and surface marking | | [`dsh-util-values`](../../util/values/README.md) | Shared lossless JSON validation and detached snapshots | | [`src/repair.ts`](src/repair.ts) | Cold repair of crash-orphaned logs | | [`src/invariant.ts`](src/invariant.ts) | Invariant companion: seq, turn/step enclosure, tool call/result pairing | diff --git a/packages/core/session/README.zh.md b/packages/core/session/README.zh.md index ba2f84c13c..8aa5db753b 100644 --- a/packages/core/session/README.zh.md +++ b/packages/core/session/README.zh.md @@ -91,9 +91,8 @@ session.deriveMessages() // the derived model history |---|---| | [`src/index.ts`](src/index.ts) | 插件入口:`SessionStore` 服务、存储生命周期、`fork`、`flush` | | [`src/types.ts`](src/types.ts) | `SessionEventMap`、`SessionEvent`、`UserMessage`、`SessionHeader`、`TurnEndReasonMap` | -| [`src/surface.ts`](src/surface.ts) | 有序 surface 投影、替换校验、`deriveEventMessage` | +| [`src/surface.ts`](src/surface.ts) | 有序 surface 投影、替换校验、`deriveEventMessage`、`image/offload` 标记 | | [`src/request-header.ts`](src/request-header.ts) | `request/header` 折叠与重建 | -| [`src/image-offload.ts`](src/image-offload.ts) | `image/offload` 水位校验、折叠与表层标记 | | [`dsh-util-values`](../../util/values/README.zh.md) | 共享无损 JSON 校验与分离式快照 | | [`src/repair.ts`](src/repair.ts) | 崩溃遗留日志的冷修复 | | [`src/invariant.ts`](src/invariant.ts) | 不变式配套:序号、轮次/步骤闭合、工具调用/结果配对 | diff --git a/packages/core/session/src/image-offload.ts b/packages/core/session/src/image-offload.ts deleted file mode 100644 index 4f0ac93541..0000000000 --- a/packages/core/session/src/image-offload.ts +++ /dev/null @@ -1,158 +0,0 @@ -/** - * Durable image offload watermark: the `image/offload` fold, its append-time - * validation, and the per-node projection that marks image occurrences at or - * before the watermark as offloaded. - * - * @module @deepseek-ai/dsh-session/image-offload - */ - -import type { ContentBlock, ImageBlockPath, Message } from '@deepseek-ai/dsh-llm' -import { deriveEventMessage } from './surface.ts' -import type { ImageOccurrencePosition, SessionEvent, SessionSeq } from './types.ts' - -/** Compare two nested block paths in message order. */ -function compareImageBlockPaths(a: ImageBlockPath, b: ImageBlockPath): number { - const length = Math.min(a.length, b.length) - for (let index = 0; index < length; index += 1) { - // oxlint-disable-next-line typescript/no-non-null-assertion -- index is below both lengths - const delta = a[index]! - b[index]! - if (delta !== 0) return delta - } - return a.length - b.length -} - -/** Return whether a non-empty nested block path identifies an image occurrence. */ -function pathIdentifiesImage( - content: readonly ContentBlock[], - [index, ...rest]: readonly [number, ...number[]], -): boolean { - const block = content[index] - if (block === undefined) return false - if (rest.length === 0) return block.type === 'image' - if (block.type !== 'tool-result') return false - return pathIdentifiesImage(block.content, rest as [number, ...number[]]) -} - -/** Mark matching image occurrences while preserving unchanged durable content by identity. */ -function markOffloadedImages( - content: readonly ContentBlock[], - offloaded: (path: ImageBlockPath) => boolean, - prefix: ImageBlockPath = [], -): readonly ContentBlock[] { - let next: ContentBlock[] | undefined - for (const [index, block] of content.entries()) { - const path = [...prefix, index] - if (block.type === 'image' && block.offloaded !== true && offloaded(path)) { - next ??= content.slice(0, index) - next.push({ ...block, offloaded: true }) - continue - } - if (block.type === 'tool-result') { - const inner = markOffloadedImages(block.content, offloaded, path) - if (inner !== block.content) { - next ??= content.slice(0, index) - next.push({ ...block, content: inner as ContentBlock[] }) - continue - } - } - next?.push(block) - } - return next ?? content -} - -/** - * Compare two image occurrence positions in log order: by event seq, then by - * block path inside the event's content. - * @param a - first position. - * @param b - second position. - * @returns negative, zero, or positive as `a` lies before, at, or after `b`. - */ -export function compareImagePositions(a: ImageOccurrencePosition, b: ImageOccurrencePosition): number { - return a.seq === b.seq ? compareImageBlockPaths(a.path, b.path) : a.seq - b.seq -} - -/** - * Select the watermark in force after a run of events: the last - * `image/offload` event's position, or the prior watermark when the run - * carries none. - * @param events - events in seq order. - * @param prior - watermark folded from the events before `events`. - * @returns the latest watermark, or undefined when no event has set one. - */ -export function foldImageOffloadWatermark( - events: readonly SessionEvent[], - prior?: ImageOccurrencePosition, -): ImageOccurrencePosition | undefined { - let watermark = prior - for (const event of events) { - if (event.type === 'image/offload') watermark = event.data.watermark - } - return watermark -} - -/** - * Validate one `image/offload` payload at the append or seed boundary: the - * position must name an image occurrence on the current surface and lie - * strictly after the watermark in force. - * @param data - the event payload as materialized JSON. - * @param log - events already accepted. - * @param surfaceNodes - current model-visible surface event sequences. - * @param prior - watermark in force before this event. - * @param location - append or seed location named by the diagnostic. - * @throws when the payload is malformed or does not advance the watermark. - */ -export function assertImageOffloadAdvance( - data: unknown, - log: readonly SessionEvent[], - surfaceNodes: readonly SessionSeq[], - prior: ImageOccurrencePosition | undefined, - location: string, -): void { - const record = data !== null && typeof data === 'object' && !Array.isArray(data) - ? data as Record - : undefined - const watermark = record?.['watermark'] - const position = watermark !== null && typeof watermark === 'object' && !Array.isArray(watermark) - ? watermark as Record - : undefined - const seq = position?.['seq'] - const path = position?.['path'] - if (typeof seq !== 'number' || !Number.isSafeInteger(seq) || seq < 0 || seq >= log.length - || !Array.isArray(path) || path.length === 0 - || path.some(index => typeof index !== 'number' || !Number.isSafeInteger(index) || index < 0)) { - throw new Error(`${location} names an invalid image offload watermark`) - } - const positionValue = { seq: seq as SessionSeq, path: path as number[] } - if (prior !== undefined - && compareImagePositions(positionValue, prior) <= 0) { - throw new Error(`${location} does not advance the image offload watermark`) - } - // oxlint-disable-next-line typescript/no-non-null-assertion -- accepted logs are contiguous and seq is range-checked - const message = deriveEventMessage(log[seq]!) - if (!surfaceNodes.includes(positionValue.seq) - || message === null - || !pathIdentifiesImage(message.content, positionValue.path as [number, ...number[]])) { - throw new Error(`${location} watermark does not identify an image on the current surface`) - } -} - -/** - * Project one derived surface message under the watermark: every image - * occurrence positioned at or before it carries `offloaded: true`. - * @param message - the node's derived message. - * @param seq - the node's event seq. - * @param watermark - watermark in force, or undefined when nothing is offloaded. - * @returns the same message when nothing changes, otherwise a shallow copy with marked content. - */ -export function markImageOffload( - message: Message, - seq: SessionSeq, - watermark: ImageOccurrencePosition | undefined, -): Message { - if (watermark === undefined || seq > watermark.seq) return message - const content = markOffloadedImages( - message.content, - path => seq < watermark.seq || compareImageBlockPaths(path, watermark.path) <= 0, - ) - return content === message.content ? message : { ...message, content: content as Message['content'] } -} diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 2150d5ceaa..0c1178948a 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -12,14 +12,13 @@ import { brandString } from '@deepseek-ai/dsh-brand' import { assertNever, deepFreeze, snapshotJsonValue } from '@deepseek-ai/dsh-util-values' import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope' -import type { Message } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm' import { SESSION_FORMAT_VERSION, SessionLogOffset, SessionSeq } from './types.ts' import type { TypertLookup } from '@deepseek-ai/dsh-typert-protocol' import type { CreateSessionOptions, EpochHeader, ImageOccurrencePosition, PrepareSessionOptions, RequestContext, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SessionId, SessionSeedEventState, SurfaceIntent, SurfaceEventType } from './types.ts' -import { deriveEventMessage, SurfaceManager } from './surface.ts' +import { compareImagePositions, deriveEventMessage, markImageOffload, SurfaceManager } from './surface.ts' import type { SessionSurface } from './surface.ts' import { foldRequestHeader } from './request-header.ts' -import { assertImageOffloadAdvance, foldImageOffloadWatermark, markImageOffload } from './image-offload.ts' export * from './types.ts' export { SessionPreparation } from './preparation.ts' @@ -27,9 +26,8 @@ export type { SessionPreparationOptions } from './preparation.ts' export type { AssistantMessage, ToolResultMessage, UserMessage } from '@deepseek-ai/dsh-llm' export { interruptedTurnClosers, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from './repair.ts' export type { SessionSurface, SurfaceFoldReplacement, SurfaceFoldResult } from './surface.ts' -export { deriveEventMessage, foldSurface, isAppendSurfaceEvent, isReplacementSurfaceEvent, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts' +export { compareImagePositions, deriveEventMessage, foldSurface, isAppendSurfaceEvent, isReplacementSurfaceEvent, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts' export { canonicalHeader, foldRequestHeader, headerEquals } from './request-header.ts' -export { assertImageOffloadAdvance, compareImagePositions, foldImageOffloadWatermark, markImageOffload } from './image-offload.ts' export { KNOWN_SESSION_EVENT_TYPES } from './known-event-types.ts' declare module '@deepseek-ai/cordis' { @@ -383,6 +381,58 @@ function assertNoLoggedImageOffloadMarker(type: string, data: unknown, subject: visit(content) } +/** Return whether a non-empty nested block path identifies an image occurrence. */ +function pathIdentifiesImage( + content: readonly ContentBlock[], + [index, ...rest]: readonly [number, ...number[]], +): boolean { + const block = content[index] + if (block === undefined) return false + if (rest.length === 0) return block.type === 'image' + if (block.type !== 'tool-result') return false + return pathIdentifiesImage(block.content, rest as [number, ...number[]]) +} + +/** + * Validate one `image/offload` payload at the append or seed boundary: the + * position must name an image occurrence on the current surface and lie + * strictly after the watermark in force. + */ +function assertImageOffloadAdvance( + data: unknown, + log: readonly SessionEvent[], + surfaceNodes: readonly SessionSeq[], + prior: ImageOccurrencePosition | undefined, + location: string, +): void { + const record = data !== null && typeof data === 'object' && !Array.isArray(data) + ? data as Record + : undefined + const watermark = record?.['watermark'] + const position = watermark !== null && typeof watermark === 'object' && !Array.isArray(watermark) + ? watermark as Record + : undefined + const seq = position?.['seq'] + const path = position?.['path'] + if (typeof seq !== 'number' || !Number.isSafeInteger(seq) || seq < 0 || seq >= log.length + || !Array.isArray(path) || path.length === 0 + || path.some(index => typeof index !== 'number' || !Number.isSafeInteger(index) || index < 0)) { + throw new Error(`${location} names an invalid image offload watermark`) + } + const positionValue = { seq: seq as SessionSeq, path: path as number[] } + if (prior !== undefined + && compareImagePositions(positionValue, prior) <= 0) { + throw new Error(`${location} does not advance the image offload watermark`) + } + // oxlint-disable-next-line typescript/no-non-null-assertion -- accepted logs are contiguous and seq is range-checked + const message = deriveEventMessage(log[seq]!) + if (!surfaceNodes.includes(positionValue.seq) + || message === null + || !pathIdentifiesImage(message.content, positionValue.path as [number, ...number[]])) { + throw new Error(`${location} watermark does not identify an image on the current surface`) + } +} + /** Whether an unknown value carries the current provider/model pair. */ function hasProviderModel(value: unknown): boolean { if (typeof value !== 'object' || value === null) return false @@ -808,8 +858,8 @@ export class Session { /** Record an accepted `image/offload` event as the watermark in force. */ private foldImageOffload(event: SessionEvent): void { - const watermark = foldImageOffloadWatermark([event], this.imageOffloadFold) - if (watermark === this.imageOffloadFold || watermark === undefined) return + if (event.type !== 'image/offload') return + const { watermark } = event.data this.imageOffloadFold = deepFreeze({ seq: watermark.seq, path: [...watermark.path] }) } diff --git a/packages/core/session/src/surface.ts b/packages/core/session/src/surface.ts index 1cec97e3cd..1593a94f27 100644 --- a/packages/core/session/src/surface.ts +++ b/packages/core/session/src/surface.ts @@ -8,9 +8,10 @@ * @module @deepseek-ai/dsh-session/surface */ -import type { Message } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, ImageBlockPath, Message } from '@deepseek-ai/dsh-llm' import { SessionLogOffset, SessionSeq } from './types.ts' import type { + ImageOccurrencePosition, SessionEvent, SessionSeqCursor, SurfaceEvent, @@ -120,6 +121,76 @@ export function deriveEventMessage(event: SessionEvent): Message | null { } } +/** Compare two nested block paths in message order. */ +function compareImageBlockPaths(a: ImageBlockPath, b: ImageBlockPath): number { + const length = Math.min(a.length, b.length) + for (let index = 0; index < length; index += 1) { + // oxlint-disable-next-line typescript/no-non-null-assertion -- index is below both lengths + const delta = a[index]! - b[index]! + if (delta !== 0) return delta + } + return a.length - b.length +} + +/** + * Compare two image occurrence positions in log order: by event seq, then by + * block path inside the event's content. + * @param a - first position. + * @param b - second position. + * @returns negative, zero, or positive as `a` lies before, at, or after `b`. + */ +export function compareImagePositions(a: ImageOccurrencePosition, b: ImageOccurrencePosition): number { + return a.seq === b.seq ? compareImageBlockPaths(a.path, b.path) : a.seq - b.seq +} + +/** Mark matching image occurrences while preserving unchanged durable content by identity. */ +function markOffloadedImages( + content: readonly ContentBlock[], + offloaded: (path: ImageBlockPath) => boolean, + prefix: ImageBlockPath = [], +): readonly ContentBlock[] { + let next: ContentBlock[] | undefined + for (const [index, block] of content.entries()) { + const path = [...prefix, index] + if (block.type === 'image' && block.offloaded !== true && offloaded(path)) { + next ??= content.slice(0, index) + next.push({ ...block, offloaded: true }) + continue + } + if (block.type === 'tool-result') { + const inner = markOffloadedImages(block.content, offloaded, path) + if (inner !== block.content) { + next ??= content.slice(0, index) + next.push({ ...block, content: inner as ContentBlock[] }) + continue + } + } + next?.push(block) + } + return next ?? content +} + +/** + * Project one derived surface message under the `image/offload` watermark: + * every image occurrence positioned at or before it carries `offloaded: true`. + * @param message - the node's derived message. + * @param seq - the node's event seq. + * @param watermark - watermark in force, or undefined when nothing is offloaded. + * @returns the same message when nothing changes, otherwise a shallow copy with marked content. + */ +export function markImageOffload( + message: Message, + seq: SessionSeq, + watermark: ImageOccurrencePosition | undefined, +): Message { + if (watermark === undefined || seq > watermark.seq) return message + const content = markOffloadedImages( + message.content, + path => seq < watermark.seq || compareImageBlockPaths(path, watermark.path) <= 0, + ) + return content === message.content ? message : { ...message, content: content as Message['content'] } +} + /** One replacement operation observed while folding a session surface. */ export interface SurfaceFoldReplacement { /** Seq of the event that replaced the prior surface range. */ diff --git a/packages/core/session/tests/image-offload.spec.ts b/packages/core/session/tests/image-offload.spec.ts index 287c9e3ce3..f377cb3ec3 100644 --- a/packages/core/session/tests/image-offload.spec.ts +++ b/packages/core/session/tests/image-offload.spec.ts @@ -6,7 +6,7 @@ import { describe, expect, it } from 'vitest' import { createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm' import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import { compareImagePositions, foldImageOffloadWatermark, markImageOffload, Session, SessionId, SessionSeq } from '@deepseek-ai/dsh-session' +import { compareImagePositions, Session, SessionId, SessionSeq } from '@deepseek-ai/dsh-session' function image(name: string): Extract { return { @@ -208,26 +208,29 @@ describe('image/offload surface derivation', () => { }) describe('image offload helpers', () => { - it('preserves a nested block when only an earlier sibling is offloaded', () => { + it('preserves a nested block by identity when only an earlier sibling is offloaded', () => { + const session = Session.create(SessionId('offload-sibling')) + session.append('turn/start', { turn: 1 }) const message = createUserMessage({ content: [image('first'), toolResult('later')], source: { kind: 'user' }, }) - const projected = markImageOffload(message, SessionSeq(3), { seq: SessionSeq(3), path: [0] }) + const seq = session.append('user/message', message, { surfaceOp: 'append' }).seq + const before = session.deriveMessages()[0]! + session.append('image/offload', { turn: 1, step: 1, watermark: { seq, path: [0] } }) + const projected = session.deriveMessages()[0]! expect(projected.content[0]).toEqual({ ...image('first'), offloaded: true }) - expect(projected.content[1]).toBe(message.content[1]) + expect(projected.content[1]).toBe(before.content[1]) }) it('orders positions by seq then path and folds the latest watermark', () => { expect(compareImagePositions({ seq: SessionSeq(1), path: [5] }, { seq: SessionSeq(3), path: [0] })).toBeLessThan(0) expect(compareImagePositions({ seq: SessionSeq(4), path: [1] }, { seq: SessionSeq(3), path: [0, 3] })).toBeGreaterThan(0) const session = seeded() - expect(foldImageOffloadWatermark(session.snapshotEvents())).toBeUndefined() + expect(session.imageOffloadWatermark()).toBeUndefined() session.append('image/offload', { turn: 1, step: 1, watermark: { seq: SessionSeq(3), path: [0] } }) - const prior = foldImageOffloadWatermark(session.snapshotEvents()) - expect(prior).toEqual({ seq: 3, path: [0] }) + expect(session.imageOffloadWatermark()).toEqual({ seq: 3, path: [0] }) session.append('image/offload', { turn: 1, step: 1, watermark: { seq: SessionSeq(4), path: [1] } }) - expect(foldImageOffloadWatermark(session.snapshotEvents().slice(6), prior)).toEqual({ seq: 4, path: [1] }) - expect(foldImageOffloadWatermark([], prior)).toEqual(prior) + expect(session.imageOffloadWatermark()).toEqual({ seq: 4, path: [1] }) }) }) From 523ae08a2dba1f5b531516389ce34f01fe434849 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 7 Sep 2026 20:18:56 +0800 Subject: [PATCH 10/79] refactor(session, llm): cut the image offload surface to its callers Drop Session.imageOffloadWatermark(), which no production code read once recovery moved to llm-retry; shorten the append and seed validators; keep offloadedImagePrefixCount and the represented-bytes arithmetic private to requiredImageOffload, their only caller. --- ...26-09-02-image-offload-watermark.i18n.yaml | 4 +- .../2026-09-02-image-offload-watermark.md | 2 +- .../2026-09-02-image-offload-watermark.zh.md | 2 +- docs/subsystems/session.i18n.yaml | 4 +- docs/subsystems/session.md | 13 +-- docs/subsystems/session.zh.md | 13 +-- packages/core/session/README.i18n.yaml | 4 +- packages/core/session/README.md | 2 +- packages/core/session/README.zh.md | 2 +- packages/core/session/src/index.ts | 81 ++++++------------- packages/core/session/src/types.ts | 4 +- .../core/session/tests/image-offload.spec.ts | 20 +++-- .../extensions/tool-cordis/src/api-catalog.ts | 2 +- .../llm/llm-retry/tests/image-offload.spec.ts | 12 ++- packages/llm/llm/src/content.ts | 20 +---- packages/llm/llm/tests/content.spec.ts | 38 +++++---- 16 files changed, 86 insertions(+), 137 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.i18n.yaml index 941b5ec3ed..2f0427cb1e 100644 --- a/.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.md -2026-09-02-image-offload-watermark.md: f178ea96ae982d89b575795a93e7321fed7afad6 -2026-09-02-image-offload-watermark.zh.md: cdd92011ce2ee3b80c5ea3641988cec1d2e1a80c +2026-09-02-image-offload-watermark.md: 1197fd06950aa2caa54e124fdbdfddeaff07d250 +2026-09-02-image-offload-watermark.zh.md: 548e4a0f13a5f3a60a5636b0cfe00b6210b695f5 diff --git a/.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.md b/.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.md index f178ea96ae..1197fd0695 100644 --- a/.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.md +++ b/.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.md @@ -16,7 +16,7 @@ The same recomputation broke the repository invariant that model-visible input i The offload point is a durable session fact: the core `image/offload` event records an image offload watermark that only advances, and the derived surface, every route, and the token meter read the offloaded set from it. -**Event.** `image/offload` carries `{ turn, step, watermark }`, where `watermark` is an `ImageOccurrencePosition`: the seq of the event carrying the last offloaded occurrence and its complete nested block path inside that event's content. Positions order by seq, then by path, so a newer event always lies after an older one regardless of surface replacements. `Session.deriveMessages()` and `Session.deriveEventMessage()` mark every occurrence positioned at or before the watermark `offloaded: true` on the `ImageBlock`; the marked copies are frozen and the durable event content is untouched. `Session.append` and seeding reject a malformed or non-advancing watermark and require its path to identify an image on the current surface. They also reject a logged message containing the request-only `offloaded` marker. `session.imageOffloadWatermark()` folds the latest frozen position. The event is required-on-read because it changes the derived surface; adding it does not change the log envelope structure. +**Event.** `image/offload` carries `{ turn, step, watermark }`, where `watermark` is an `ImageOccurrencePosition`: the seq of the event carrying the last offloaded occurrence and its complete nested block path inside that event's content. Positions order by seq, then by path, so a newer event always lies after an older one regardless of surface replacements. `Session.deriveMessages()` and `Session.deriveEventMessage()` mark every occurrence positioned at or before the watermark `offloaded: true` on the `ImageBlock`; the marked copies are frozen and the durable event content is untouched. `Session.append` and seeding reject a malformed or non-advancing watermark and require its path to identify an image on the current surface. They also reject a logged message containing the request-only `offloaded` marker. The event is required-on-read because it changes the derived surface; adding it does not change the log envelope structure. **Only advances.** The watermark never retreats when a budget grows, a route changes, or compaction lowers the total, so the model-visible prefix and the provider cache prefix move only forward. An occurrence below the watermark that compaction later shadows leaves the watermark valid, because the comparison is positional. diff --git a/.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.zh.md b/.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.zh.md index cdd92011ce..548e4a0f13 100644 --- a/.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.zh.md @@ -16,7 +16,7 @@ Status: implemented offload 位置是持久的会话事实:核心事件 `image/offload` 记录一条只会前进的图片 offload 水位,派生表层、每条路由和 token meter 都从它读取省略集合。 -**事件。** `image/offload` 携带 `{ turn, step, watermark }`,其中 `watermark` 是一个 `ImageOccurrencePosition`:承载最后一个被省略出现位置的事件序号,以及它在该事件内容中的完整嵌套块路径。位置先按序号再按路径排序,所以无论表层如何替换,较新的事件总在较老的之后。`Session.deriveMessages()` 与 `Session.deriveEventMessage()` 把位于水位及之前的每个出现位置在 `ImageBlock` 上标为 `offloaded: true`;标记后的副本被冻结,持久事件内容不受影响。`Session.append` 与 seed 拒绝畸形或没有严格前进的水位,并要求路径指向当前表层中的图片。它们还会拒绝包含请求专用 `offloaded` 标记的持久消息。`session.imageOffloadWatermark()` 折叠最新的冻结位置。该事件改变派生表层,因此读取时必须识别;增加该事件不改变日志信封结构。 +**事件。** `image/offload` 携带 `{ turn, step, watermark }`,其中 `watermark` 是一个 `ImageOccurrencePosition`:承载最后一个被省略出现位置的事件序号,以及它在该事件内容中的完整嵌套块路径。位置先按序号再按路径排序,所以无论表层如何替换,较新的事件总在较老的之后。`Session.deriveMessages()` 与 `Session.deriveEventMessage()` 把位于水位及之前的每个出现位置在 `ImageBlock` 上标为 `offloaded: true`;标记后的副本被冻结,持久事件内容不受影响。`Session.append` 与 seed 拒绝畸形或没有严格前进的水位,并要求路径指向当前表层中的图片。它们还会拒绝包含请求专用 `offloaded` 标记的持久消息。该事件改变派生表层,因此读取时必须识别;增加该事件不改变日志信封结构。 **只前进。** 预算变大、路由切换或 compaction 降低总量时水位永不回退,所以模型可见前缀和 provider 缓存前缀只向前移动。水位之下的出现位置后来被 compaction 遮蔽也不影响水位有效性,因为比较是按位置进行的。 diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index ce026ac40d..6477c348d7 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session.md -session.md: 68beb5f1fb77f723c59adfc583380773e41be1ae -session.zh.md: 7134819ee4572ba65e89b3f35be837c7a139fe99 +session.md: df91b16dcd2125c33ce92d62079f7afc84cc47e5 +session.zh.md: f107b1f4d0eac7c49206b7018f44d0653091a6af diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index 68beb5f1fb..df91b16dcd 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -200,15 +200,15 @@ interface RequestContext { ### The image offload event: `image/offload` -`dsh-llm-retry` appends `image/offload` on the `agent/request-error` waterfall when an adapter fails an attempt with `IMAGE_OFFLOAD_REQUIRED`, before the step retries. Its `watermark` names the last offloaded occurrence; `deriveMessages()` and the Session instance's `deriveEventMessage()` mark every occurrence positioned at or before it `offloaded: true`, and each route renders those marks as placeholder text. `Session.append` and seeding require the watermark to identify an image on the current surface, reject malformed and non-advancing positions, and reject request-only `offloaded` markers in durable messages. `session.imageOffloadWatermark()` folds the latest frozen position. Like `request/header`, it is not a `SurfaceEventType`; unlike it, it changes the derived surface, so it stays required-on-read ([decision](../../.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.md)). +`dsh-llm-retry` appends `image/offload` on the `agent/request-error` waterfall when an adapter fails an attempt with `IMAGE_OFFLOAD_REQUIRED`, before the step retries. Its `watermark` names the last offloaded occurrence; `deriveMessages()` and the Session instance's `deriveEventMessage()` mark every occurrence positioned at or before it `offloaded: true`, and each route renders those marks as placeholder text. `Session.append` and seeding require the watermark to identify an image on the current surface, reject malformed and non-advancing positions, and reject request-only `offloaded` markers in durable messages. Like `request/header`, it is not a `SurfaceEventType`; unlike it, it changes the derived surface, so it stays required-on-read ([decision](../../.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.md)). ```ts type-equiv /** * Durable position of one image occurrence on the model-visible surface: the * seq of the event carrying it and the block path inside that event's * content. Every nested tool-result contributes another path index. Positions - * order by seq, then by path, so a newer - * event always lies after an older one regardless of surface replacements. + * order by seq, then by path, so a newer event always lies after an older one + * regardless of surface replacements. */ interface ImageOccurrencePosition { /** Seq of the surface message event carrying the occurrence. */ @@ -577,13 +577,6 @@ declare class Session { * @returns the latest immutable route metadata. */ requestContext(): RequestContext | undefined; - /** - * The durable image offload watermark in force: every image occurrence - * positioned at or before it derives as offloaded. Undefined until the - * first `image/offload` event. - * @returns the frozen latest watermark, or undefined when nothing is offloaded. - */ - imageOffloadWatermark(): ImageOccurrencePosition | undefined; /** * Derive the LLM message history by walking the ordered sequences of * message-producing events maintained by `surfaceOp` markers. The diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index 7134819ee4..f107b1f4d0 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -200,15 +200,15 @@ interface RequestContext { ### 图片 offload 事件:`image/offload` -adapter 以 `IMAGE_OFFLOAD_REQUIRED` 让一次尝试失败时,`dsh-llm-retry` 在 `agent/request-error` waterfall 上、step 重试前追加 `image/offload`。其 `watermark` 指向最后一个被省略的出现位置;`deriveMessages()` 与 Session 实例的 `deriveEventMessage()` 把位于它及之前的每个出现位置标为 `offloaded: true`,每条路由把这些标记渲染为占位文本。`Session.append` 与 seed 要求水位指向当前表层的图片,拒绝畸形和没有严格前进的位置,并拒绝持久消息中的请求专用 `offloaded` 标记。`session.imageOffloadWatermark()` 折叠最新的冻结位置。它和 `request/header` 一样不是 `SurfaceEventType`;与之不同的是它改变派生表层,因此读取时必须识别([决定](../../.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.zh.md))。 +adapter 以 `IMAGE_OFFLOAD_REQUIRED` 让一次尝试失败时,`dsh-llm-retry` 在 `agent/request-error` waterfall 上、step 重试前追加 `image/offload`。其 `watermark` 指向最后一个被省略的出现位置;`deriveMessages()` 与 Session 实例的 `deriveEventMessage()` 把位于它及之前的每个出现位置标为 `offloaded: true`,每条路由把这些标记渲染为占位文本。`Session.append` 与 seed 要求水位指向当前表层的图片,拒绝畸形和没有严格前进的位置,并拒绝持久消息中的请求专用 `offloaded` 标记。它和 `request/header` 一样不是 `SurfaceEventType`;与之不同的是它改变派生表层,因此读取时必须识别([决定](../../.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.zh.md))。 ```ts type-equiv /** * Durable position of one image occurrence on the model-visible surface: the * seq of the event carrying it and the block path inside that event's * content. Every nested tool-result contributes another path index. Positions - * order by seq, then by path, so a newer - * event always lies after an older one regardless of surface replacements. + * order by seq, then by path, so a newer event always lies after an older one + * regardless of surface replacements. */ interface ImageOccurrencePosition { /** Seq of the surface message event carrying the occurrence. */ @@ -579,13 +579,6 @@ declare class Session { * @returns the latest immutable route metadata. */ requestContext(): RequestContext | undefined; - /** - * The durable image offload watermark in force: every image occurrence - * positioned at or before it derives as offloaded. Undefined until the - * first `image/offload` event. - * @returns the frozen latest watermark, or undefined when nothing is offloaded. - */ - imageOffloadWatermark(): ImageOccurrencePosition | undefined; /** * Derive the LLM message history by walking the ordered sequences of * message-producing events maintained by `surfaceOp` markers. The diff --git a/packages/core/session/README.i18n.yaml b/packages/core/session/README.i18n.yaml index c079753a48..376d694b90 100644 --- a/packages/core/session/README.i18n.yaml +++ b/packages/core/session/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/session/README.md -README.md: 0d519ec60e6aeed466736d69c3bd39337d0f01f4 -README.zh.md: 8aa5db753b96e980faac6cfdc14983ffca42571c +README.md: 584c4921811e08f7d5d5450f5d2f3211e137df6f +README.zh.md: ea82ccebab44f571a481d85cdcf18648c5caa9a7 diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 0d519ec60e..584c492181 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -107,7 +107,7 @@ Every append uses the shared iterative `snapshotJsonValue()` pass, which reads, ### The request header -The loop logs a full canonical `request/header` snapshot (call config, adapter defaults, rendered system prompt, assembled tool schemas) at each loop-instance boundary and on change; `foldRequestHeader(events)` reconstructs it by selecting the latest snapshot, making every conversation request a pure function of the log. Route metadata (`request/context`) is separate logged state appended only when the provider, model, or capacity differs. The image offload watermark (`image/offload`) is durable surface state: every image occurrence positioned at or before it derives with `offloaded: true`, each event must advance strictly past the previous one, and `session.imageOffloadWatermark()` folds the latest position. +The loop logs a full canonical `request/header` snapshot (call config, adapter defaults, rendered system prompt, assembled tool schemas) at each loop-instance boundary and on change; `foldRequestHeader(events)` reconstructs it by selecting the latest snapshot, making every conversation request a pure function of the log. Route metadata (`request/context`) is separate logged state appended only when the provider, model, or capacity differs. The image offload watermark (`image/offload`) is durable surface state: every image occurrence positioned at or before it derives with `offloaded: true`, each event must advance strictly past the previous one. diff --git a/packages/core/session/README.zh.md b/packages/core/session/README.zh.md index 8aa5db753b..ea82ccebab 100644 --- a/packages/core/session/README.zh.md +++ b/packages/core/session/README.zh.md @@ -107,7 +107,7 @@ session.deriveMessages() // the derived model history ### 请求头 -循环在每个循环实例边界及变更时记录完整规范 `request/header` 快照(调用配置、适配器默认值、渲染后的系统提示词、组装后的工具 schema);`foldRequestHeader(events)` 通过选择最新快照来重建它,使每个对话请求都成为日志的纯函数。路由元数据(`request/context`)是独立的已记录状态,仅在提供方、模型或容量变化时追加。图片 offload 水位(`image/offload`)是持久的表层状态:位于水位及之前的每个图片出现位置派生时带 `offloaded: true`,每条事件必须严格越过前一条,`session.imageOffloadWatermark()` 折叠最新位置。 +循环在每个循环实例边界及变更时记录完整规范 `request/header` 快照(调用配置、适配器默认值、渲染后的系统提示词、组装后的工具 schema);`foldRequestHeader(events)` 通过选择最新快照来重建它,使每个对话请求都成为日志的纯函数。路由元数据(`request/context`)是独立的已记录状态,仅在提供方、模型或容量变化时追加。图片 offload 水位(`image/offload`)是持久的表层状态:位于水位及之前的每个图片出现位置派生时带 `offloaded: true`,每条事件必须严格越过前一条。 diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 0c1178948a..2c16d89569 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -364,33 +364,25 @@ function assertMessageEventShape(event: Record, subject: string /** Reject the request-only image marker from durable message content at every nesting depth. */ function assertNoLoggedImageOffloadMarker(type: string, data: unknown, subject: string): void { if (type !== 'user/message' && type !== 'assistant/message' && type !== 'tool/result') return - const dataRecord = data as Record - const message = (type === 'user/message' ? dataRecord : dataRecord['message']) as Record - const content = message['content'] - if (!Array.isArray(content)) return - const visit = (blocks: readonly unknown[]): void => { - for (const value of blocks) { - if (typeof value !== 'object' || value === null || Array.isArray(value)) continue - const block = value as Record - if (block['type'] === 'image' && Object.hasOwn(block, 'offloaded')) { + const message = (type === 'user/message' ? data : (data as { message: unknown }).message) as { content?: unknown } + const visit = (blocks: unknown): void => { + if (!Array.isArray(blocks)) return + for (const block of blocks as ({ type?: unknown; content?: unknown } | null)[]) { + if (block?.type === 'image' && 'offloaded' in block) { throw new Error(`${subject} stores the request-only image offload marker`) } - if (block['type'] === 'tool-result' && Array.isArray(block['content'])) visit(block['content']) + if (block?.type === 'tool-result') visit(block.content) } } - visit(content) + visit(message.content) } -/** Return whether a non-empty nested block path identifies an image occurrence. */ -function pathIdentifiesImage( - content: readonly ContentBlock[], - [index, ...rest]: readonly [number, ...number[]], -): boolean { - const block = content[index] +/** Return whether a block path identifies an image occurrence. */ +function pathIdentifiesImage(content: readonly ContentBlock[], [index, ...rest]: readonly number[]): boolean { + const block = index === undefined ? undefined : content[index] if (block === undefined) return false if (rest.length === 0) return block.type === 'image' - if (block.type !== 'tool-result') return false - return pathIdentifiesImage(block.content, rest as [number, ...number[]]) + return block.type === 'tool-result' && pathIdentifiesImage(block.content, rest) } /** @@ -405,30 +397,18 @@ function assertImageOffloadAdvance( prior: ImageOccurrencePosition | undefined, location: string, ): void { - const record = data !== null && typeof data === 'object' && !Array.isArray(data) - ? data as Record - : undefined - const watermark = record?.['watermark'] - const position = watermark !== null && typeof watermark === 'object' && !Array.isArray(watermark) - ? watermark as Record - : undefined - const seq = position?.['seq'] - const path = position?.['path'] - if (typeof seq !== 'number' || !Number.isSafeInteger(seq) || seq < 0 || seq >= log.length - || !Array.isArray(path) || path.length === 0 - || path.some(index => typeof index !== 'number' || !Number.isSafeInteger(index) || index < 0)) { + const watermark = (data as { watermark?: { seq?: unknown; path?: unknown } } | null)?.watermark + if (typeof watermark?.seq !== 'number' || !Array.isArray(watermark.path) + || !watermark.path.every(index => Number.isInteger(index) && index >= 0)) { throw new Error(`${location} names an invalid image offload watermark`) } - const positionValue = { seq: seq as SessionSeq, path: path as number[] } - if (prior !== undefined - && compareImagePositions(positionValue, prior) <= 0) { + const position = { seq: watermark.seq as SessionSeq, path: watermark.path as number[] } + if (prior !== undefined && compareImagePositions(position, prior) <= 0) { throw new Error(`${location} does not advance the image offload watermark`) } - // oxlint-disable-next-line typescript/no-non-null-assertion -- accepted logs are contiguous and seq is range-checked - const message = deriveEventMessage(log[seq]!) - if (!surfaceNodes.includes(positionValue.seq) - || message === null - || !pathIdentifiesImage(message.content, positionValue.path as [number, ...number[]])) { + // oxlint-disable-next-line typescript/no-non-null-assertion -- surface nodes index the accepted log + const message = surfaceNodes.includes(position.seq) ? deriveEventMessage(log[position.seq]!) : null + if (message === null || !pathIdentifiesImage(message.content, position.path)) { throw new Error(`${location} watermark does not identify an image on the current surface`) } } @@ -863,16 +843,6 @@ export class Session { this.imageOffloadFold = deepFreeze({ seq: watermark.seq, path: [...watermark.path] }) } - /** - * The durable image offload watermark in force: every image occurrence - * positioned at or before it derives as offloaded. Undefined until the - * first `image/offload` event. - * @returns the frozen latest watermark, or undefined when nothing is offloaded. - */ - imageOffloadWatermark(): ImageOccurrencePosition | undefined { - return this.imageOffloadFold - } - /** The derived-message cache: frozen projections, extended per unseen node. */ private derived: Message[] = [] /** Surface position (nodes projected) the cache has reached. */ @@ -927,12 +897,6 @@ export class Session { return [...this.derived] } - /** Apply the watermark to one derived node, freezing any marked copy like the durable original. */ - private markImageOffload(message: Message, seq: SessionSeq, watermark: ImageOccurrencePosition | undefined): Message { - const marked = markImageOffload(message, seq, watermark) - return marked === message ? message : deepFreeze(marked) - } - /** * Derive one event under the session's current image offload watermark. * Direct model consumers use this instance method so their per-event @@ -942,9 +906,10 @@ export class Session { */ deriveEventMessage(event: SessionEvent): Message | null { const message = deriveEventMessage(event) - return message === null - ? null - : this.markImageOffload(message, event.seq, this.imageOffloadFold) + if (message === null) return null + // A marked copy is frozen like the durable original it derives from. + const marked = markImageOffload(message, event.seq, this.imageOffloadFold) + return marked === message ? message : deepFreeze(marked) } } diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index e11be97085..9808f23f64 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -251,8 +251,8 @@ export interface RequestContext { * Durable position of one image occurrence on the model-visible surface: the * seq of the event carrying it and the block path inside that event's * content. Every nested tool-result contributes another path index. Positions - * order by seq, then by path, so a newer - * event always lies after an older one regardless of surface replacements. + * order by seq, then by path, so a newer event always lies after an older one + * regardless of surface replacements. */ export interface ImageOccurrencePosition { /** Seq of the surface message event carrying the occurrence. */ diff --git a/packages/core/session/tests/image-offload.spec.ts b/packages/core/session/tests/image-offload.spec.ts index f377cb3ec3..946d64cf76 100644 --- a/packages/core/session/tests/image-offload.spec.ts +++ b/packages/core/session/tests/image-offload.spec.ts @@ -60,16 +60,16 @@ describe('image/offload append validation', () => { it('rejects a malformed or out-of-range watermark', () => { const session = seeded() expect(() => session.append('image/offload', { turn: 1, step: 1, watermark: { seq: SessionSeq(99), path: [0] } })) - .toThrow('names an invalid image offload watermark') + .toThrow('watermark does not identify an image on the current surface') expect(() => session.append('image/offload', { turn: 1, step: 1, watermark: { seq: SessionSeq(3), path: [] } })) - .toThrow('names an invalid image offload watermark') + .toThrow('watermark does not identify an image on the current surface') expect(() => session.append('image/offload', { turn: 1, step: 1, watermark: { seq: SessionSeq(3), path: [-1] } })) .toThrow('names an invalid image offload watermark') expect(() => session.append('image/offload', { turn: 1, step: 1, watermark: null as never })) .toThrow('names an invalid image offload watermark') expect(() => session.append('image/offload', 7 as never)) .toThrow('names an invalid image offload watermark') - expect(session.imageOffloadWatermark()).toBeUndefined() + expect(offloadedNames(session)).toEqual([]) }) it('requires the watermark path to identify a current surface image', () => { @@ -116,21 +116,20 @@ describe('image/offload append validation', () => { it('accepts only strictly advancing positions', () => { const session = seeded() session.append('image/offload', { turn: 1, step: 1, watermark: { seq: SessionSeq(3), path: [1] } }) - expect(session.imageOffloadWatermark()).toEqual({ seq: 3, path: [1] }) + expect(offloadedNames(session)).toEqual(['first', 'second']) expect(() => session.append('image/offload', { turn: 1, step: 1, watermark: { seq: SessionSeq(3), path: [1] } })) .toThrow('does not advance the image offload watermark') expect(() => session.append('image/offload', { turn: 1, step: 1, watermark: { seq: SessionSeq(3), path: [0] } })) .toThrow('does not advance the image offload watermark') session.append('image/offload', { turn: 1, step: 1, watermark: { seq: SessionSeq(4), path: [0, 1] } }) - expect(session.imageOffloadWatermark()).toEqual({ seq: 4, path: [0, 1] }) - expect(Object.isFrozen(session.imageOffloadWatermark())).toBe(true) + expect(offloadedNames(session)).toEqual(['first', 'second', 'third']) }) it('validates a seed with the same rules and folds its watermark', () => { const session = seeded() session.append('image/offload', { turn: 1, step: 1, watermark: { seq: SessionSeq(3), path: [0] } }) const replayed = Session.create(SessionId('offload-replay'), session.snapshotEvents()) - expect(replayed.imageOffloadWatermark()).toEqual({ seq: 3, path: [0] }) + expect(offloadedNames(replayed)).toEqual(['first']) expect(replayed.deriveMessages()).toEqual(session.deriveMessages()) const events = session.snapshotEvents().map(event => ( @@ -139,7 +138,7 @@ describe('image/offload append validation', () => { : event )) expect(() => Session.create(SessionId('offload-bad-seed'), events as never)) - .toThrow('seed event at index 5 names an invalid image offload watermark') + .toThrow('seed event at index 5 watermark does not identify an image on the current surface') }) }) @@ -227,10 +226,9 @@ describe('image offload helpers', () => { expect(compareImagePositions({ seq: SessionSeq(1), path: [5] }, { seq: SessionSeq(3), path: [0] })).toBeLessThan(0) expect(compareImagePositions({ seq: SessionSeq(4), path: [1] }, { seq: SessionSeq(3), path: [0, 3] })).toBeGreaterThan(0) const session = seeded() - expect(session.imageOffloadWatermark()).toBeUndefined() session.append('image/offload', { turn: 1, step: 1, watermark: { seq: SessionSeq(3), path: [0] } }) - expect(session.imageOffloadWatermark()).toEqual({ seq: 3, path: [0] }) + expect(offloadedNames(session)).toEqual(['first']) session.append('image/offload', { turn: 1, step: 1, watermark: { seq: SessionSeq(4), path: [1] } }) - expect(session.imageOffloadWatermark()).toEqual({ seq: 4, path: [1] }) + expect(offloadedNames(session)).toEqual(['first', 'second', 'third', 'fourth']) }) }) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 8afea303f8..582524e18b 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -4943,7 +4943,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'Session', - declaration: 'export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n readonly inheritedEventCount: SessionLogOffset;\n get id(): SessionId;\n readonly firstLiveSeq: SessionLogOffset;\n static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader, inheritedEventCount?: SessionLogOffset): Session;\n static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader, inheritedEventCount: SessionLogOffset, eventState: SessionSeedEventState): Session;\n eventAt(seq: SessionSeq): SessionEvent | undefined;\n snapshotEvents(fromSeq: SessionLogOffset = SessionLogOffset(0), toSeqExclusive: SessionLogOffset = this.seq): readonly SessionEvent[];\n ownEvents(): readonly SessionEvent[];\n isOwnSeq(seq: SessionSeq): boolean;\n get seq(): SessionLogOffset;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n imageOffloadWatermark(): ImageOccurrencePosition | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n}', + declaration: 'export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n readonly inheritedEventCount: SessionLogOffset;\n get id(): SessionId;\n readonly firstLiveSeq: SessionLogOffset;\n static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader, inheritedEventCount?: SessionLogOffset): Session;\n static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader, inheritedEventCount: SessionLogOffset, eventState: SessionSeedEventState): Session;\n eventAt(seq: SessionSeq): SessionEvent | undefined;\n snapshotEvents(fromSeq: SessionLogOffset = SessionLogOffset(0), toSeqExclusive: SessionLogOffset = this.seq): readonly SessionEvent[];\n ownEvents(): readonly SessionEvent[];\n isOwnSeq(seq: SessionSeq): boolean;\n get seq(): SessionLogOffset;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n}', }, { name: 'SessionAccess', diff --git a/packages/llm/llm-retry/tests/image-offload.spec.ts b/packages/llm/llm-retry/tests/image-offload.spec.ts index f0633422f9..ac4da6b487 100644 --- a/packages/llm/llm-retry/tests/image-offload.spec.ts +++ b/packages/llm/llm-retry/tests/image-offload.spec.ts @@ -9,7 +9,7 @@ import { Context } from '@deepseek-ai/cordis' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' -import { createUserMessage, IMAGE_OFFLOAD_REQUIRED_CODE, LlmAdapter, LlmError, ToolCallId } from '@deepseek-ai/dsh-llm' +import { createAssistantMessage, createUserMessage, IMAGE_OFFLOAD_REQUIRED_CODE, LlmAdapter, LlmError, ToolCallId } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import type { Session } from '@deepseek-ai/dsh-session' @@ -127,6 +127,13 @@ describe('image offload recovery', () => { agent.session.append('user/message', createUserMessage({ content: [image('second')], source: { kind: 'user' }, }), { surfaceOp: 'append' }) + // An empty-content assistant node derives no message and carries no occurrence. + agent.session.append('assistant/message', { + turn: 0, + step: 0, + message: createAssistantMessage({ content: [], source: { provider: 'mock', model: 'mock' } }), + stream: [], + }, { surfaceOp: 'append' }) agent.session.append('user/message', createUserMessage({ content: [image('replacement')], source: { kind: 'user' }, }), { @@ -139,7 +146,8 @@ describe('image offload recovery', () => { expect(adapter.requests).toHaveLength(2) expect(offloadedNames(adapter.requests[1]!)).toEqual(['replacement', 'second']) - expect(offloadEvents(agent.session).at(-1)).toMatchObject({ seq: 2, path: [0] }) + // The replacement node carries the newest seq, so it is the greatest position in the prefix. + expect(offloadEvents(agent.session).at(-1)).toMatchObject({ seq: 3, path: [0] }) }) it('delegates IMAGE_OFFLOAD_REQUIRED once nothing remains to offload', async () => { diff --git a/packages/llm/llm/src/content.ts b/packages/llm/llm/src/content.ts index f3ba195c10..f0a513b45f 100644 --- a/packages/llm/llm/src/content.ts +++ b/packages/llm/llm/src/content.ts @@ -238,20 +238,6 @@ export function visitImageBlocks( visitImageBlocksAt(content, [], visit) } -/** - * Represented byte length of one image occurrence under a route budget: the - * request-version byte count, base64-expanded for an inline representation. - * @param bytes - request-version byte count. - * @param budget - route representation. - * @returns the byte length the route's request accounting charges. - */ -export function representedImageBytes( - bytes: number, - budget: Pick, -): number { - return budget.representation === 'base64' ? base64Length(bytes) : bytes -} - /** Replace every offloaded occurrence, including nested tool results, with its placeholder. */ function replaceOffloadedImages( blocks: readonly ContentBlock[], @@ -304,7 +290,7 @@ export function projectOffloadedImages( * @param budget - count/byte budgets and removal quanta; unbounded when absent. * @returns how many leading occurrences to offload. */ -export function offloadedImagePrefixCount( +function offloadedImagePrefixCount( lengths: readonly number[], budget: Pick, ): number { @@ -346,7 +332,9 @@ export function requiredImageOffload( const lengths: number[] = [] for (const message of messages) { visitImageBlocks(message.content, (block) => { - if (block.offloaded !== true) lengths.push(representedImageBytes(versionBytes(block), budget)) + if (block.offloaded === true) return + const bytes = versionBytes(block) + lengths.push(budget.representation === 'base64' ? base64Length(bytes) : bytes) }) } return offloadedImagePrefixCount(lengths, budget) diff --git a/packages/llm/llm/tests/content.spec.ts b/packages/llm/llm/tests/content.spec.ts index 76c87382fc..9d2c3a1ff7 100644 --- a/packages/llm/llm/tests/content.spec.ts +++ b/packages/llm/llm/tests/content.spec.ts @@ -8,10 +8,9 @@ import { fileHandleText, projectFilesToText, offloadedImageText, - offloadedImagePrefixCount, projectImagesForTextModel, projectOffloadedImages, - representedImageBytes, + requiredImageOffload, resolveImageAttachmentAccess, requestImageHandleText, visitImageBlocks, @@ -61,14 +60,6 @@ describe('visitImageBlocks', () => { }) }) -describe('representedImageBytes', () => { - it('expands inline representations to their encoded length', () => { - expect(representedImageBytes(100, { representation: 'raw' })).toBe(100) - expect(representedImageBytes(3, { representation: 'base64' })).toBe(4) - expect(representedImageBytes(4, { representation: 'base64' })).toBe(8) - }) -}) - describe('projectOffloadedImages', () => { it('keeps messages without offloaded occurrences by identity', () => { const messages = [createUserMessage({ content: [image(300)], source })] @@ -108,19 +99,32 @@ describe('projectOffloadedImages', () => { }) }) -describe('offloadedImagePrefixCount', () => { +describe('requiredImageOffload', () => { + const request = (lengths: number[], offloaded: number[] = []) => [createUserMessage({ + content: lengths.map((bytes, index) => image(bytes, offloaded.includes(index) ? true : undefined)), + source, + })] + const bytesOf = (block: Extract): number => block.attachment.bytes + const raw = { representation: 'raw' as const } + it('removes nothing under unbounded budgets and whole quanta past them', () => { const lengths = [4, 4, 4, 4] - expect(offloadedImagePrefixCount(lengths, {})).toBe(0) - expect(offloadedImagePrefixCount(lengths, { maxBytes: 16 })).toBe(0) - expect(offloadedImagePrefixCount(lengths, { maxImages: 4 })).toBe(0) + expect(requiredImageOffload(request(lengths), raw, bytesOf)).toBe(0) + expect(requiredImageOffload(request(lengths), { ...raw, maxBytes: 16 }, bytesOf)).toBe(0) + expect(requiredImageOffload(request(lengths), { ...raw, maxImages: 4 }, bytesOf)).toBe(0) // One excess image rounds up to the whole count quantum. - expect(offloadedImagePrefixCount([...lengths, 4], { maxImages: 4, countQuantum: 2 })).toBe(2) + expect(requiredImageOffload(request([...lengths, 4]), { ...raw, maxImages: 4, countQuantum: 2 }, bytesOf)).toBe(2) // One excess byte removes a whole byte quantum, crossing the second image. - expect(offloadedImagePrefixCount([...lengths, 1], { maxBytes: 16, byteQuantum: 5 })).toBe(2) + expect(requiredImageOffload(request([...lengths, 1]), { ...raw, maxBytes: 16, byteQuantum: 5 }, bytesOf)).toBe(2) // 129 one-mebibyte images under a 128 MiB bound with a 64 MiB quantum offload the oldest 65. const mib = 1024 * 1024 - expect(offloadedImagePrefixCount(Array.from({ length: 129 }, () => mib), { maxBytes: 128 * mib, byteQuantum: 64 * mib })).toBe(65) + const budget = { ...raw, maxBytes: 128 * mib, byteQuantum: 64 * mib } + expect(requiredImageOffload(request(Array.from({ length: 129 }, () => mib)), budget, bytesOf)).toBe(65) + }) + + it('skips offloaded occurrences and accounts inline bytes by their base64 length', () => { + expect(requiredImageOffload(request([3, 3, 3]), { representation: 'base64', maxBytes: 8 }, bytesOf)).toBe(1) + expect(requiredImageOffload(request([3, 3, 3], [0]), { representation: 'base64', maxBytes: 8 }, bytesOf)).toBe(0) }) }) From e44eb8906534e0a1f573fe717d54b343f6ee4c8e Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 7 Sep 2026 20:19:30 +0800 Subject: [PATCH 11/79] test(llm-retry): cover the surface node that derives no message --- packages/llm/llm-retry/tests/image-offload.spec.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/llm/llm-retry/tests/image-offload.spec.ts b/packages/llm/llm-retry/tests/image-offload.spec.ts index ac4da6b487..c03fcb39ed 100644 --- a/packages/llm/llm-retry/tests/image-offload.spec.ts +++ b/packages/llm/llm-retry/tests/image-offload.spec.ts @@ -121,12 +121,6 @@ describe('image offload recovery', () => { const adapter = new ScriptedAdapter([offloadRequired(2), textResponse('sent')]) const ctx = await harness(adapter) const agent = await ctx.agentLoop.create(SessionId('offload-reordered'), { provider: 'mock', model: 'mock' }) - const first = agent.session.append('user/message', createUserMessage({ - content: [image('first')], source: { kind: 'user' }, - }), { surfaceOp: 'append' }) - agent.session.append('user/message', createUserMessage({ - content: [image('second')], source: { kind: 'user' }, - }), { surfaceOp: 'append' }) // An empty-content assistant node derives no message and carries no occurrence. agent.session.append('assistant/message', { turn: 0, @@ -134,6 +128,12 @@ describe('image offload recovery', () => { message: createAssistantMessage({ content: [], source: { provider: 'mock', model: 'mock' } }), stream: [], }, { surfaceOp: 'append' }) + const first = agent.session.append('user/message', createUserMessage({ + content: [image('first')], source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + agent.session.append('user/message', createUserMessage({ + content: [image('second')], source: { kind: 'user' }, + }), { surfaceOp: 'append' }) agent.session.append('user/message', createUserMessage({ content: [image('replacement')], source: { kind: 'user' }, }), { From 52386ec6fbb381333c65862c6170bcfb6a5e345b Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 8 Sep 2026 10:46:02 +0800 Subject: [PATCH 12/79] test(llm-retry): follow agent loop testkit dependencies --- packages/llm/llm-retry/tests/image-offload.spec.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/llm/llm-retry/tests/image-offload.spec.ts b/packages/llm/llm-retry/tests/image-offload.spec.ts index c03fcb39ed..41f08d2b96 100644 --- a/packages/llm/llm-retry/tests/image-offload.spec.ts +++ b/packages/llm/llm-retry/tests/image-offload.spec.ts @@ -8,7 +8,6 @@ import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { createAssistantMessage, createUserMessage, IMAGE_OFFLOAD_REQUIRED_CODE, LlmAdapter, LlmError, ToolCallId } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' @@ -51,7 +50,6 @@ function offloadRequired(offloadImages: number): () => never { async function harness(adapter: ScriptedAdapter): Promise { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(Object.assign((inner: Context) => { retry.apply(inner, {}) }, { inject: retry.inject })) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) From 6329c53a3737c3db12a362b10322699415e0d016 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 8 Sep 2026 12:00:35 +0800 Subject: [PATCH 13/79] refactor(llm-retry, session): offload images by surface replacement instead of a watermark event Drop the image/offload event and every session change with it. An adapter's IMAGE_OFFLOAD_REQUIRED failure now makes dsh-llm-retry replace the surface nodes carrying the named oldest images with copies whose blocks are marked offloaded, through the same surfaceOp: replace compaction uses, then retry the step. ImageBlock.offloaded becomes a durable field the routes project and the token meter prices; the session core, its event map, derivation, and validation return to master. --- ...026-09-02-durable-image-offload.i18n.yaml} | 6 +- .../2026-09-02-durable-image-offload.md | 55 ++++ .../2026-09-02-durable-image-offload.zh.md | 55 ++++ .../2026-09-02-image-offload-watermark.md | 55 ---- .../2026-09-02-image-offload-watermark.zh.md | 55 ---- docs/persistence-catalog.i18n.yaml | 4 +- docs/persistence-catalog.md | 48 +--- docs/persistence-catalog.zh.md | 22 -- docs/subsystems/core.i18n.yaml | 4 +- docs/subsystems/core.md | 2 +- docs/subsystems/core.zh.md | 2 +- docs/subsystems/llm-streaming.i18n.yaml | 4 +- docs/subsystems/llm-streaming.md | 5 +- docs/subsystems/llm-streaming.zh.md | 5 +- docs/subsystems/session.i18n.yaml | 4 +- docs/subsystems/session.md | 42 +--- docs/subsystems/session.zh.md | 42 +--- .../tests/compaction-basic.spec.ts | 40 --- packages/core/session/README.i18n.yaml | 4 +- packages/core/session/README.md | 6 +- packages/core/session/README.zh.md | 6 +- packages/core/session/src/index.ts | 101 +------- packages/core/session/src/invariant.ts | 3 +- .../core/session/src/known-event-types.ts | 1 - packages/core/session/src/surface.ts | 73 +----- packages/core/session/src/types.ts | 25 -- .../core/session/tests/image-offload.spec.ts | 234 ------------------ .../extensions/tool-cordis/src/api-catalog.ts | 6 +- packages/llm/llm-deepseek/README.i18n.yaml | 4 +- packages/llm/llm-deepseek/README.md | 2 +- packages/llm/llm-deepseek/README.zh.md | 2 +- packages/llm/llm-pi-ai/README.i18n.yaml | 4 +- packages/llm/llm-pi-ai/README.md | 4 +- packages/llm/llm-pi-ai/README.zh.md | 4 +- packages/llm/llm-retry/README.i18n.yaml | 4 +- packages/llm/llm-retry/README.md | 8 +- packages/llm/llm-retry/README.zh.md | 8 +- packages/llm/llm-retry/src/image-offload.ts | 97 +++++--- packages/llm/llm-retry/src/index.ts | 4 +- .../llm/llm-retry/tests/image-offload.spec.ts | 82 ++++-- packages/llm/llm/README.i18n.yaml | 4 +- packages/llm/llm/README.md | 6 +- packages/llm/llm/README.zh.md | 6 +- packages/llm/llm/src/content.ts | 35 +-- packages/llm/llm/src/error.ts | 10 +- packages/llm/llm/src/types.ts | 11 +- packages/llm/llm/tests/content.spec.ts | 10 +- packages/llm/token-meter/README.i18n.yaml | 4 +- packages/llm/token-meter/README.md | 2 +- packages/llm/token-meter/README.zh.md | 2 +- packages/llm/token-meter/src/index.ts | 17 +- packages/llm/token-meter/src/route-pricing.ts | 20 +- packages/llm/token-meter/src/surface-fold.ts | 55 ++-- .../token-meter/tests/route-pricing.spec.ts | 12 +- python/sdk/tests/test_client.py | 15 -- scripts/type-equiv.manifest.json | 5 - .../sdk/inline-image-prompt/session.v2.jsonl | 7 +- 57 files changed, 395 insertions(+), 958 deletions(-) rename .agents/notes/implemented/architecture/{2026-09-02-image-offload-watermark.i18n.yaml => 2026-09-02-durable-image-offload.i18n.yaml} (57%) create mode 100644 .agents/notes/implemented/architecture/2026-09-02-durable-image-offload.md create mode 100644 .agents/notes/implemented/architecture/2026-09-02-durable-image-offload.zh.md delete mode 100644 .agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.md delete mode 100644 .agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.zh.md delete mode 100644 packages/core/session/tests/image-offload.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-02-durable-image-offload.i18n.yaml similarity index 57% rename from .agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.i18n.yaml rename to .agents/notes/implemented/architecture/2026-09-02-durable-image-offload.i18n.yaml index 2f0427cb1e..a2c691105e 100644 --- a/.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-02-durable-image-offload.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.md -2026-09-02-image-offload-watermark.md: 1197fd06950aa2caa54e124fdbdfddeaff07d250 -2026-09-02-image-offload-watermark.zh.md: 548e4a0f13a5f3a60a5636b0cfe00b6210b695f5 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-02-durable-image-offload.md +2026-09-02-durable-image-offload.md: db77e4743f1f81c7425b3dd63d32b36f161eafec +2026-09-02-durable-image-offload.zh.md: 9b0a424aff24acea130980a61095929b834a3145 diff --git a/.agents/notes/implemented/architecture/2026-09-02-durable-image-offload.md b/.agents/notes/implemented/architecture/2026-09-02-durable-image-offload.md new file mode 100644 index 0000000000..db77e4743f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-02-durable-image-offload.md @@ -0,0 +1,55 @@ +# Agent Note: Durable image offload by surface replacement + +Status: implemented + +English | [中文](2026-09-02-durable-image-offload.zh.md) + +## Problem + +Request-size image offload was recomputed from scratch on every request. Each route collected every image occurrence on the derived surface, oldest first, and once the accumulated bytes exceeded its budget it rounded the excess up to a whole removal quantum and replaced that many oldest occurrences with placeholder text, per the [unified image request pipeline](../feature/2026-08-20-unified-image-request-pipeline.md). Nothing remembered where the previous request stopped; the prefix was stable only because the arithmetic over an append-only history repeated itself. + +That stability failed wherever the arithmetic inputs moved. The [Files inline fallback](../../archived/bug-fix/2026-08-21-deepseek-files-inline-fallback.md) rebuilt a request under a 20 MiB inline budget with a 10 MiB quantum, offloading far more images for that one request, and the next request in file mode brought them back. The pi-ai route used a quantum of one byte, so its prefix moved on almost every request. Compaction lowered the total and returned previously offloaded images. A route switch moved every step boundary. Each move changed the model-visible prefix and invalidated the provider cache prefix. + +The same recomputation broke the repository invariant that model-visible input is reconstructable from the session log. Which representation was dispatched, the exact derived request-version byte lengths, and the route budgets and quanta were runtime or configuration facts that never entered the log; `request/header` records call config, system prompt, and tools only. Provider usage anchors token totals and cannot recover the image set, and the [route-priced estimate](../../archived/feature/2026-08-24-route-priced-image-request-pressure.md) documented that it did not reproduce the fallback budget. No consumer could pair a logged assistant response with the image set its request carried. + +## Decision + +The offload of an image occurrence is a durable surface fact recorded the way compaction records its reductions: a `surfaceOp: replace` node. + +**Marked copies on the surface.** `ImageBlock` gains `offloaded?: true`. An offloaded occurrence lives in a replacement event of the same type as the node that carried it (`user/message` or `tool/result`) whose content is a copy of the original with those blocks marked; `sourceEventSeqs` points at the replaced node. The session core, its event map, its derivation, and its validation are unchanged. `deriveMessages()` sends the marked block; serialization renders every marked block as `offloadedImageText` with the currently resolved access path and prepares only retained occurrences. + +**Only advances.** A replacement never reverts. When a budget grows, a route changes, or compaction lowers the total, the marked copies stay, so the model-visible prefix and the provider cache prefix move only forward. + +**Adapters project, never decide.** An image-capable route enforces an `LlmImageRequestBudget` (`representation`, `maxBytes`, `maxImages`, both quanta) over the retained occurrences' exact request-version bytes. When they still exceed the budget, in file mode, under the inline fallback's tighter budget, or under the pi-ai bound, the adapter fails the attempt with `IMAGE_OFFLOAD_REQUIRED` and `LlmFailure.offloadImages` naming how many more oldest occurrences must be offloaded, computed with the shared `requiredImageOffload()`. Nothing plans an offload before dispatch. + +**Recovery owned by `dsh-llm-retry`.** The retry executor already sits on the `agent/request-error` waterfall. Before any provider policy it handles `IMAGE_OFFLOAD_REQUIRED`: it walks the surface in model request order, marks the first `offloadImages` retained occurrences, appends one replacement per node that carried any of them, and returns the `retry` action without spending the provider retry budget or logging `llm/retry`. Assistant nodes carry model output, not input images, and are skipped. When nothing remains to offload it delegates and the failure reaches ordinary recovery. The loop re-runs the step over the replaced surface and logs the fresh `request/header` every surface replacement gets; the agent loop is unchanged. + +**Token accounting.** `priceImages` receives the surface's `ImageBlock`s and prices a marked one as its placeholder text; the DeepSeek and replay pricing no longer reproduce any offload arithmetic. The meter needs no new state: a replacement reprices the node like any other replacement. + +**Other consumers.** Compaction reconstructs each selected event through `deriveEventMessage()` and sees the marks. Resume, fork, and replay reproduce the surface from the log. Text-only routes keep their separate whole-history substitution. + +## Alternatives considered + +**Keep recomputing the offload point per request.** Stable only while the arithmetic inputs held still; the inline fallback, the pi-ai quantum, compaction, and route switches all moved the prefix, and no consumer could reconstruct a historical request's image set. + +**Record each request's projection outcome as a log-only event.** Restores reconstructability but not stability: the recorded outcome is not a decision input, so every oscillation still happens and the log merely documents it, with two sources of truth for the offloaded set that can disagree. + +**Log the full projected request body.** Everything except the offload decision is already derivable; repeating the history per request grows the log quadratically to record one position. + +**A log-only `image/offload` watermark event applied by session derivation.** An earlier revision recorded the offload point as a position (event seq plus block path) and had `Session.deriveMessages()` mark every occurrence at or before it. That gave the session a second mechanism for changing model-visible history next to `surfaceOp: replace`, with its own validation, fold, cache invalidation, and required-on-read event, and placed the logic in the core instead of on the plugin that decides. Issue #3041 asks that a permanent eviction use a surface-changing event rather than a request-projection event; because the offload never reverts, it is one. + +**Let each adapter append the replacement.** The adapter owns the budgets but not the session surface; a surface change appended below the loop lets two adapters define the surface differently. Adapters report the count they need instead. + +**Plan the offload before dispatch, in the loop or in a plugin.** The loop knows the exact prepared route before deriving each request, so planning there never spends a failed attempt; but it puts a route-specific policy into the one component every profile shares, changes the documented step order, and bypasses the `agent/request-error` waterfall that context-overflow compaction and retry already use for the same repair-and-retry pattern. A pre-step plugin keeps the loop unchanged but cannot see the step's own messages or the first request's route, so the failure path stays necessary, and the plan needs every route to declare its budget on its model info. Handling only the failure costs one attempt per quantum crossing (64 MiB in DeepSeek file mode, 20 MiB on pi-ai) and matches the planned direction: routes will stop checking sizes locally, send every image, and the provider will report what it cannot cache, which is exactly a failure naming an offload point. + +**Keep a transient extra offload for the inline fallback and exact-byte overflow.** Would have sent an unlogged projection in exactly the cases the invariant exists for; the failure-and-advance path costs one serialization attempt and keeps every dispatched request derivable from the log. + +## Consequences + +Offloaded images never return automatically when budgets grow, a larger route is selected, or compaction lowers the total; recovery is the read-only path in the placeholder, which the model uses deliberately. A Files outage or a temporary switch to a small-budget route offloads permanently; both are accepted for the same reason. Each offload copies the carrying node's message into the log; images are references, so the copy is the node's text plus block metadata. + +Every dispatched request's image set is determined by the log alone, across file mode, inline fallback, resume, fork, retry, and compaction, and the provider cache prefix no longer oscillates. The execution-world access path embedded in placeholder and handle text is still resolved at serialization time; that gap exists for retained images too and belongs to a separate decision about recording the execution-world mapping. The route-local byte checks are provisional: once the provider reports uncacheable images itself, `requiredImageOffload()` and the route budgets go away while the marked copies and the recovery branch stay as they are. + +## Testing + +`packages/llm/llm/tests/content.spec.ts` pins arbitrary-depth image traversal, the offload count including the 129-to-64 MiB quantum example, and placeholder projection. `packages/llm/llm-retry/tests/image-offload.spec.ts` pins the `IMAGE_OFFLOAD_REQUIRED` replace-and-retry path without a retry event, the untouched original node, request-order counts after an earlier surface replacement, and the exhausted case that delegates downstream. Adapter specs pin placeholder projection, prepared-only-retained reads, and the exact-byte failure with its count; `route-pricing.spec.ts` pins placeholder pricing for a marked replacement. The `inline-image-prompt` TypeScript SDK snapshot replays an authored `IMAGE_OFFLOAD_REQUIRED` attempt through the shipped profile and pins the replacement and the retried request. diff --git a/.agents/notes/implemented/architecture/2026-09-02-durable-image-offload.zh.md b/.agents/notes/implemented/architecture/2026-09-02-durable-image-offload.zh.md new file mode 100644 index 0000000000..9b0a424aff --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-02-durable-image-offload.zh.md @@ -0,0 +1,55 @@ +# Agent Note: 以表层替换实现持久的图片 offload + +Status: implemented + +[English](2026-09-02-durable-image-offload.md) | 中文 + +## 问题 + +请求级图片 offload 过去在每次请求时都从头重算。每条路由按最老优先的顺序收集派生表层上的全部图片出现位置,累计字节一旦超过预算,就把超出部分向上取整到整个删除量子,把这么多最老的出现位置替换为占位文本,见[统一图片请求管线](../feature/2026-08-20-unified-image-request-pipeline.zh.md)。没有任何东西记住上一次请求停在哪里,前缀稳定只是因为对 append-only 历史做同样的算术会得到同样的结果。 + +算术的输入一动,这种稳定就失效。[Files 内联回退](../../archived/bug-fix/2026-08-21-deepseek-files-inline-fallback.md)会用 20 MiB 内联预算和 10 MiB 量子重建请求,那一次省略多得多的图片,下一次 file 模式的请求又把它们带回来。pi-ai 路由的量子是一个字节,前缀几乎每次请求都会移动。compaction 降低总量,让先前省略的图片回归。切换路由会移动每个台阶边界。每一次移动都改变模型可见前缀,并让 provider 的缓存前缀失效。 + +同样的重算也破坏了仓库不变量:模型可见输入必须能从 session log 重建。实际发出的表示方式、派生请求版本的精确字节长度、路由预算和量子都是运行时或配置事实,从不进入日志,`request/header` 只记录调用配置、系统提示词和工具。provider usage 只锚定 token 总量,恢复不了图片集合,[按路由定价的估计](../../archived/feature/2026-08-24-route-priced-image-request-pressure.md)也写明它不复现回退预算。没有任何消费方能把一条已记录的助手响应和它的请求携带的图片集合配对。 + +## 决定 + +一个图片出现位置的省略是一条持久的表层事实,记录方式和 compaction 记录它的缩减一样:一个 `surfaceOp: replace` 节点。 + +**表层上带标记的副本。** `ImageBlock` 新增 `offloaded?: true`。被省略的出现位置存在于一条与原承载节点同类型的替换事件里(`user/message` 或 `tool/result`),内容是原消息的副本,只把那些块打上标记;`sourceEventSeqs` 指向被替换的节点。session 核心、它的事件表、派生和校验都不变。`deriveMessages()` 原样发送带标记的块;序列化把每个带标记的块渲染为带当前已解析访问路径的 `offloadedImageText`,只准备保留的出现位置。 + +**只前进。** 替换永不回退。预算变大、路由切换或 compaction 降低总量时,带标记的副本留在原地,所以模型可见前缀和 provider 缓存前缀只向前移动。 + +**adapter 只投影,不决定。** 支持图片的路由按保留的出现位置的精确请求版本字节执行一个 `LlmImageRequestBudget`(`representation`、`maxBytes`、`maxImages` 与两个量子)。当它们仍超过预算,无论是 file 模式、内联回退更紧的预算还是 pi-ai 上限,adapter 都以 `IMAGE_OFFLOAD_REQUIRED` 让本次尝试失败,并在 `LlmFailure.offloadImages` 中用共享的 `requiredImageOffload()` 算出还需省略多少最老的出现位置。发送前没有任何规划。 + +**恢复归 `dsh-llm-retry`。** 重试执行器本来就在 `agent/request-error` waterfall 上。它在任何提供方策略之前处理 `IMAGE_OFFLOAD_REQUIRED`:按模型请求顺序遍历表层,给前 `offloadImages` 个保留的出现位置打标记,为每个承载了其中任一位置的节点追加一条替换,然后返回 `retry` 动作,不占提供方重试预算,也不记录 `llm/retry`。assistant 节点承载的是模型输出而不是输入图片,直接跳过。没有可省略的出现位置时向下游委托,失败进入普通恢复路径。循环在替换后的表层上重跑该 step,并像每次表层替换后一样记录新的 `request/header`;agent loop 不变。 + +**token 记账。** `priceImages` 接收表层的 `ImageBlock`,把带标记的按占位文本定价;DeepSeek 和 replay 的定价不再复现任何 offload 算术。meter 不需要新状态:一次替换和其他替换一样重新为该节点定价。 + +**其他消费方。** compaction 通过 `deriveEventMessage()` 重建每个选中事件,看得到标记。resume、fork 和重放从日志复现表层。纯文本路由保留各自的全历史替换。 + +## 考虑过的替代方案 + +**继续每次请求重算 offload 位置。** 只在算术输入不动时稳定,内联回退、pi-ai 量子、compaction 和路由切换都会移动前缀,且没有消费方能重建历史请求的图片集合。 + +**用 log-only 事件记录每次请求的投影结果。** 恢复了可重建性但没有稳定性:记录的结果不是决策输入,每一种抖动照旧发生,日志只是把它记下来,且省略集合有两个可能不一致的事实来源。 + +**记录完整的投影后请求体。** 除 offload 决定外一切都已可派生,为记录一个位置而每次请求重复整段历史会让日志平方级增长。 + +**由 session 派生应用的 log-only `image/offload` 水位事件。** 更早的一版把 offload 点记成一个位置(事件序号加块路径),由 `Session.deriveMessages()` 给位于它及之前的每个出现位置打标记。这让 session 在 `surfaceOp: replace` 之外多出第二种改变模型可见历史的机制,带着自己的校验、折叠、缓存失效和读取时必须识别的事件,并且把逻辑放进了核心而不是做决定的插件。issue #3041 要求永久淘汰使用改变表层的事件而不是请求投影事件;省略永不回退,它就是永久淘汰。 + +**让各个 adapter 自己追加替换。** adapter 拥有预算,但不拥有会话表层;在循环之下追加表层变更会让两个 adapter 对表层做出不同定义。adapter 改为上报它需要的数量。 + +**在发送前规划省略,无论放在循环里还是插件里。** 循环在派生每个请求之前就知道精确的已准备路由,在那里规划永远不会多花一次失败的尝试;但这会把一条路由专属的策略放进所有 profile 共用的那个组件,改变已记录的 step 顺序,还绕过了上下文溢出 compaction 和重试已经在用的同一套 `agent/request-error` waterfall。pre-step 插件不改循环,但看不到 step 自己的消息和第一个请求的路由,失败路径仍然必需,而且规划要求每条路由在模型信息上声明预算。只处理失败的代价是每越过一个量子多一次尝试(DeepSeek file 模式 64 MiB,pi-ai 20 MiB),并且和既定方向一致:路由将不再本地检查大小,全部发送,由 provider 报告无法缓存的部分,那正是一个指明省略点的失败。 + +**为内联回退和精确字节溢出保留临时的额外省略。** 恰好会在不变量所针对的场景发送未记录的投影;失败再推进的路径只多花一次序列化尝试,且让每个已发出请求都可由日志派生。 + +## 后果 + +预算变大、选中更大的路由或 compaction 降低总量时,被省略的图片不会自动回归;恢复手段是占位文本中的只读路径,模型需要时主动使用。一次 Files 故障或临时切到小预算路由会永久省略,两者出于同一理由被接受。每次省略会把承载节点的消息复制一份进日志;图片只是引用,复制的是该节点的文本和块元数据。 + +每个已发出请求的图片集合都仅由日志决定,覆盖 file 模式、内联回退、resume、fork、重试和 compaction,provider 缓存前缀不再来回变化。占位文本和句柄文本里嵌入的执行世界访问路径仍在序列化时解析;这个缺口对保留的图片同样存在,属于另一个关于记录执行世界映射的决定。路由本地的字节检查是过渡实现:provider 自己报告无法缓存的图片之后,`requiredImageOffload()` 和路由预算会被删掉,带标记的副本和恢复分支保持不变。 + +## 测试 + +`packages/llm/llm/tests/content.spec.ts` 钉住任意深度的图片遍历、包括 129 到 64 MiB 量子示例在内的省略计数,以及占位投影。`packages/llm/llm-retry/tests/image-offload.spec.ts` 钉住 `IMAGE_OFFLOAD_REQUIRED` 替换并重试且不追加重试事件的路径、原节点保持不变、更早一次表层替换之后的请求顺序计数,以及向下游委托的耗尽情况。adapter 测试钉住占位投影、只读取保留图片以及带数量的精确字节失败;`route-pricing.spec.ts` 钉住带标记替换的占位定价。`inline-image-prompt` TypeScript SDK 快照通过发布的 profile 重放一次手工编写的 `IMAGE_OFFLOAD_REQUIRED` 尝试,钉住替换和重试后的请求。 diff --git a/.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.md b/.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.md deleted file mode 100644 index 1197fd0695..0000000000 --- a/.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.md +++ /dev/null @@ -1,55 +0,0 @@ -# Agent Note: Durable image offload watermark - -Status: implemented - -English | [中文](2026-09-02-image-offload-watermark.zh.md) - -## Problem - -Request-size image offload was recomputed from scratch on every request. Each route collected every image occurrence on the derived surface, oldest first, and once the accumulated bytes exceeded its budget it rounded the excess up to a whole removal quantum and replaced that many oldest occurrences with placeholder text, per the [unified image request pipeline](../feature/2026-08-20-unified-image-request-pipeline.md). Nothing remembered where the previous request stopped; the prefix was stable only because the arithmetic over an append-only history repeated itself. - -That stability failed wherever the arithmetic inputs moved. The [Files inline fallback](../../archived/bug-fix/2026-08-21-deepseek-files-inline-fallback.md) rebuilt a request under a 20 MiB inline budget with a 10 MiB quantum, offloading far more images for that one request, and the next request in file mode brought them back. The pi-ai route used a quantum of one byte, so its prefix moved on almost every request. Compaction lowered the total and returned previously offloaded images. A route switch moved every step boundary. Each move changed the model-visible prefix and invalidated the provider cache prefix. - -The same recomputation broke the repository invariant that model-visible input is reconstructable from the session log. Which representation was dispatched, the exact derived request-version byte lengths, and the route budgets and quanta were runtime or configuration facts that never entered the log; `request/header` records call config, system prompt, and tools only. Provider usage anchors token totals and cannot recover the image set, and the [route-priced estimate](../../archived/feature/2026-08-24-route-priced-image-request-pressure.md) documented that it did not reproduce the fallback budget. No consumer could pair a logged assistant response with the image set its request carried. - -## Decision - -The offload point is a durable session fact: the core `image/offload` event records an image offload watermark that only advances, and the derived surface, every route, and the token meter read the offloaded set from it. - -**Event.** `image/offload` carries `{ turn, step, watermark }`, where `watermark` is an `ImageOccurrencePosition`: the seq of the event carrying the last offloaded occurrence and its complete nested block path inside that event's content. Positions order by seq, then by path, so a newer event always lies after an older one regardless of surface replacements. `Session.deriveMessages()` and `Session.deriveEventMessage()` mark every occurrence positioned at or before the watermark `offloaded: true` on the `ImageBlock`; the marked copies are frozen and the durable event content is untouched. `Session.append` and seeding reject a malformed or non-advancing watermark and require its path to identify an image on the current surface. They also reject a logged message containing the request-only `offloaded` marker. The event is required-on-read because it changes the derived surface; adding it does not change the log envelope structure. - -**Only advances.** The watermark never retreats when a budget grows, a route changes, or compaction lowers the total, so the model-visible prefix and the provider cache prefix move only forward. An occurrence below the watermark that compaction later shadows leaves the watermark valid, because the comparison is positional. - -**Decision owned by `dsh-llm-retry`, budgets enforced by routes.** An image-capable route enforces an `LlmImageRequestBudget` (`representation`, `maxBytes`, `maxImages`, both quanta) over its retained occurrences' exact request-version bytes and reports the overflow as a failure; nothing plans an offload before dispatch. `dsh-llm-retry`, the recovery executor already on the `agent/request-error` waterfall, handles `IMAGE_OFFLOAD_REQUIRED` before any provider policy: it walks the surface in model request order, converts the reported count to the greatest durable position in that request-order prefix (a surface replacement can put a newer event before older ones, so the advance may offload additional occurrences but always covers the prefix), appends `image/offload`, and returns the `retry` action without spending the provider retry budget or logging `llm/retry`. The loop re-runs the step over the derived surface; the agent loop is unchanged. - -**Adapters project, never decide.** Serialization renders every `offloaded` block as `offloadedImageText` with the currently resolved access path and prepares only retained occurrences. When the retained occurrences' exact request-version bytes still exceed the route budget, in file mode, under the inline fallback's tighter budget, or under the pi-ai bound, the adapter fails the attempt with `IMAGE_OFFLOAD_REQUIRED` and `LlmFailure.offloadImages` naming how many more oldest occurrences must be offloaded, computed with the shared `requiredImageOffload()`. When nothing remains to offload, `dsh-llm-retry` delegates and the failure reaches ordinary recovery. - -**Token accounting.** `priceImages` receives surface `ImageBlock`s and prices an `offloaded` one as its placeholder text; the DeepSeek and replay pricing no longer reproduce any offload arithmetic. The meter folds `image/offload` into its replay state, prices the current surface under the current watermark and each usage anchor under the watermark its request was derived with. Provider usage remains the anchor for completed requests. - -**Other consumers.** Compaction reconstructs each selected event through `Session.deriveEventMessage()`, which applies the same current watermark as `deriveMessages()`, before its direct `ctx.llm.stream` call. Resume, fork, and replay reproduce the surface from the log. Text-only routes keep their separate whole-history substitution. - -## Alternatives considered - -**Keep recomputing the offload point per request.** Stable only while the arithmetic inputs held still; the inline fallback, the pi-ai quantum, compaction, and route switches all moved the prefix, and no consumer could reconstruct a historical request's image set. - -**Record each request's projection outcome as a log-only event.** Restores reconstructability but not stability: the recorded outcome is not a decision input, so every oscillation still happens and the log merely documents it, with two sources of truth for the offloaded set that can disagree. - -**Log the full projected request body.** Everything except the offload decision is already derivable; repeating the history per request grows the log quadratically to record one position. - -**Identify the watermark by attachment id or by occurrence count.** Attachment ids repeat for duplicate attachments, so the position is ambiguous; counts shift when compaction prunes earlier occurrences. A seq plus block path is unambiguous and survives pruning. - -**Let each adapter append the event.** The adapter owns the budgets but not the session surface; a surface fact appended below the loop bypasses the session's derived history and lets two adapters define the surface differently. Adapters report the count they need instead. - -**Plan the offload before dispatch, in the loop or in a plugin.** The loop knows the exact prepared route before deriving each request, so planning there never spends a failed attempt; but it puts a route-specific policy into the one component every profile shares, changes the documented step order, and bypasses the `agent/request-error` waterfall that context-overflow compaction and retry already use for the same repair-and-retry pattern. A pre-step plugin keeps the loop unchanged but cannot see the step's own messages or the first request's route, so the failure path stays necessary, and the plan needs every route to declare its budget on its model info. Handling only the failure costs one attempt per quantum crossing (64 MiB in DeepSeek file mode, 20 MiB on pi-ai) and matches the planned direction: routes will stop checking sizes locally, send every image, and the provider will report what it cannot cache, which is exactly a failure naming an offload point. - -**Keep a transient extra offload for the inline fallback and exact-byte overflow.** Would have sent an unlogged projection in exactly the cases the invariant exists for; the failure-and-advance path costs one serialization attempt and keeps every dispatched request derivable from the log. - -## Consequences - -Offloaded images never return automatically when budgets grow, a larger route is selected, or compaction lowers the total; recovery is the read-only path in the placeholder, which the model uses deliberately. A Files outage or a temporary switch to a small-budget route advances the watermark permanently; both are accepted for the same reason. The route-local byte checks are provisional: once the provider reports uncacheable images itself, `requiredImageOffload()` and the route budgets go away while the event, the derivation, and the recovery branch stay as they are. - -Every dispatched request's image set is determined by the log alone, across file mode, inline fallback, resume, fork, retry, and compaction, and the provider cache prefix no longer oscillates. The execution-world access path embedded in placeholder and handle text is still resolved at serialization time; that gap exists for retained images too and belongs to a separate decision about recording the execution-world mapping. - -## Testing - -`packages/llm/llm/tests/content.spec.ts` pins arbitrary-depth image traversal, represented bytes, projection, and the prefix count, including the 129-to-64 MiB quantum example. `packages/core/session/tests/image-offload.spec.ts` pins append and seed validation, exact current-surface image paths, rejection of persisted derived markers, strict advance, arbitrary-depth marking, frozen copies, cache rebuild, and scratch-replay equality. `packages/llm/llm-retry/tests/image-offload.spec.ts` pins the `IMAGE_OFFLOAD_REQUIRED` advance-and-retry path without a retry event, request-order counts after a surface replacement, and the exhausted case that delegates downstream. Compaction tests pin watermark application to direct summarization input. Adapter specs pin placeholder projection, prepared-only-retained reads, and the exact-byte failure with its count; `route-pricing.spec.ts` pins watermark pricing. The `inline-image-prompt` TypeScript SDK snapshot replays an authored `IMAGE_OFFLOAD_REQUIRED` attempt through the shipped profile and pins the advance and the retried request. The Python SDK notification test pins lossless forwarding of the new event and its nested watermark. diff --git a/.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.zh.md b/.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.zh.md deleted file mode 100644 index 548e4a0f13..0000000000 --- a/.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.zh.md +++ /dev/null @@ -1,55 +0,0 @@ -# Agent Note: 持久的图片 offload 水位 - -Status: implemented - -[English](2026-09-02-image-offload-watermark.md) | 中文 - -## 问题 - -请求级图片 offload 过去在每次请求时都从头重算。每条路由按最老优先的顺序收集派生表层上的全部图片出现位置,累计字节一旦超过预算,就把超出部分向上取整到整个删除量子,把这么多最老的出现位置替换为占位文本,见[统一图片请求管线](../feature/2026-08-20-unified-image-request-pipeline.zh.md)。没有任何东西记住上一次请求停在哪里,前缀稳定只是因为对 append-only 历史做同样的算术会得到同样的结果。 - -算术的输入一动,这种稳定就失效。[Files 内联回退](../../archived/bug-fix/2026-08-21-deepseek-files-inline-fallback.md)会用 20 MiB 内联预算和 10 MiB 量子重建请求,那一次省略多得多的图片,下一次 file 模式的请求又把它们带回来。pi-ai 路由的量子是一个字节,前缀几乎每次请求都会移动。compaction 降低总量,让先前省略的图片回归。切换路由会移动每个台阶边界。每一次移动都改变模型可见前缀,并让 provider 的缓存前缀失效。 - -同样的重算也破坏了仓库不变量:模型可见输入必须能从 session log 重建。实际发出的表示方式、派生请求版本的精确字节长度、路由预算和量子都是运行时或配置事实,从不进入日志,`request/header` 只记录调用配置、系统提示词和工具。provider usage 只锚定 token 总量,恢复不了图片集合,[按路由定价的估计](../../archived/feature/2026-08-24-route-priced-image-request-pressure.md)也写明它不复现回退预算。没有任何消费方能把一条已记录的助手响应和它的请求携带的图片集合配对。 - -## 决定 - -offload 位置是持久的会话事实:核心事件 `image/offload` 记录一条只会前进的图片 offload 水位,派生表层、每条路由和 token meter 都从它读取省略集合。 - -**事件。** `image/offload` 携带 `{ turn, step, watermark }`,其中 `watermark` 是一个 `ImageOccurrencePosition`:承载最后一个被省略出现位置的事件序号,以及它在该事件内容中的完整嵌套块路径。位置先按序号再按路径排序,所以无论表层如何替换,较新的事件总在较老的之后。`Session.deriveMessages()` 与 `Session.deriveEventMessage()` 把位于水位及之前的每个出现位置在 `ImageBlock` 上标为 `offloaded: true`;标记后的副本被冻结,持久事件内容不受影响。`Session.append` 与 seed 拒绝畸形或没有严格前进的水位,并要求路径指向当前表层中的图片。它们还会拒绝包含请求专用 `offloaded` 标记的持久消息。该事件改变派生表层,因此读取时必须识别;增加该事件不改变日志信封结构。 - -**只前进。** 预算变大、路由切换或 compaction 降低总量时水位永不回退,所以模型可见前缀和 provider 缓存前缀只向前移动。水位之下的出现位置后来被 compaction 遮蔽也不影响水位有效性,因为比较是按位置进行的。 - -**决定权在 `dsh-llm-retry`,预算由路由执行。** 支持图片的路由按保留的出现位置的精确请求版本字节执行一个 `LlmImageRequestBudget`(`representation`、`maxBytes`、`maxImages` 与两个量子),超出时以失败上报;发送前没有任何规划。`dsh-llm-retry` 已经是 `agent/request-error` waterfall 上的恢复执行器,它在任何提供方策略之前处理 `IMAGE_OFFLOAD_REQUIRED`:按模型请求顺序遍历表层,把上报的数量换算成该请求序前缀中最大的持久位置(表层替换可能把较新的事件放到较老事件之前,推进因此可能额外省略一些出现位置,但一定覆盖该前缀),追加 `image/offload`,返回 `retry` 动作,不占提供方重试预算,也不记录 `llm/retry`。循环在派生表层上重跑该 step;agent loop 不变。 - -**adapter 只投影,不决定。** 序列化把每个 `offloaded` 块渲染为带当前已解析访问路径的 `offloadedImageText`,只准备保留的出现位置。当保留的出现位置按精确请求版本字节仍超过路由预算,无论是 file 模式、内联回退更紧的预算还是 pi-ai 上限,adapter 都以 `IMAGE_OFFLOAD_REQUIRED` 让本次尝试失败,并在 `LlmFailure.offloadImages` 中用共享的 `requiredImageOffload()` 算出还需省略多少最老的出现位置。没有可省略的出现位置时,`dsh-llm-retry` 向下游委托,失败进入普通恢复路径。 - -**token 记账。** `priceImages` 接收表层的 `ImageBlock`,把 `offloaded` 的按占位文本定价;DeepSeek 和 replay 的定价不再复现任何 offload 算术。meter 把 `image/offload` 折进其重放状态,按当前水位为当前表层定价,按每个 usage 锚点的请求派生时的水位为该锚点定价。已完成请求仍以 provider usage 为锚点。 - -**其他消费方。** compaction 在直接调用 `ctx.llm.stream` 之前,通过 `Session.deriveEventMessage()` 重建每个选中事件,因此会应用与 `deriveMessages()` 相同的当前水位。resume、fork 和重放从日志复现表层。纯文本路由保留各自的全历史替换。 - -## 考虑过的替代方案 - -**继续每次请求重算 offload 位置。** 只在算术输入不动时稳定,内联回退、pi-ai 量子、compaction 和路由切换都会移动前缀,且没有消费方能重建历史请求的图片集合。 - -**用 log-only 事件记录每次请求的投影结果。** 恢复了可重建性但没有稳定性:记录的结果不是决策输入,每一种抖动照旧发生,日志只是把它记下来,且省略集合有两个可能不一致的事实来源。 - -**记录完整的投影后请求体。** 除 offload 决定外一切都已可派生,为记录一个位置而每次请求重复整段历史会让日志平方级增长。 - -**用附件 id 或出现次数标识水位。** 附件 id 在重复附加时重复,位置因此含糊;compaction 剪掉更早的出现位置后计数会漂移。序号加块路径没有歧义,也不受剪枝影响。 - -**让各个 adapter 自己追加事件。** adapter 拥有预算,但不拥有会话表层;在循环之下追加表层事实绕过了会话的派生历史,也会让两个 adapter 对表层做出不同定义。adapter 改为上报它需要的数量。 - -**在发送前规划省略,无论放在循环里还是插件里。** 循环在派生每个请求之前就知道精确的已准备路由,在那里规划永远不会多花一次失败的尝试;但这会把一条路由专属的策略放进所有 profile 共用的那个组件,改变已记录的 step 顺序,还绕过了上下文溢出 compaction 和重试已经在用的同一套 `agent/request-error` waterfall。pre-step 插件不改循环,但看不到 step 自己的消息和第一个请求的路由,失败路径仍然必需,而且规划要求每条路由在模型信息上声明预算。只处理失败的代价是每越过一个量子多一次尝试(DeepSeek file 模式 64 MiB,pi-ai 20 MiB),并且和既定方向一致:路由将不再本地检查大小,全部发送,由 provider 报告无法缓存的部分,那正是一个指明省略点的失败。 - -**为内联回退和精确字节溢出保留临时的额外省略。** 恰好会在不变量所针对的场景发送未记录的投影;失败再推进的路径只多花一次序列化尝试,且让每个已发出请求都可由日志派生。 - -## 后果 - -预算变大、选中更大的路由或 compaction 降低总量时,被省略的图片不会自动回归;恢复手段是占位文本中的只读路径,模型需要时主动使用。一次 Files 故障或临时切到小预算路由会永久推进水位,两者出于同一理由被接受。路由本地的字节检查是过渡实现:provider 自己报告无法缓存的图片之后,`requiredImageOffload()` 和路由预算会被删掉,事件、派生和恢复分支保持不变。 - -每个已发出请求的图片集合仅由日志决定,覆盖 file 模式、内联回退、resume、fork、retry 和 compaction,provider 缓存前缀不再抖动。占位和句柄文本中嵌入的执行世界访问路径仍在序列化时解析,这个缺口对保留的图片同样存在,属于另一个关于记录执行世界映射的决定。 - -## 测试 - -`packages/llm/llm/tests/content.spec.ts` 钉住任意深度的图片遍历、表示字节、投影和前缀计数,包括 129 到 64 MiB 的量子示例。`packages/core/session/tests/image-offload.spec.ts` 钉住追加与 seed 校验、当前表层图片路径、拒绝持久化派生标记、严格推进、任意深度标记、冻结副本、缓存重建和从头重放的一致性。`packages/llm/llm-retry/tests/image-offload.spec.ts` 钉住 `IMAGE_OFFLOAD_REQUIRED` 推进并重试且不追加重试事件的路径、表层替换后的请求顺序计数,以及向下游委托的耗尽情况。compaction 测试钉住直接摘要输入中的水位应用。adapter 测试钉住占位投影、只读取保留图片以及带数量的精确字节失败;`route-pricing.spec.ts` 钉住水位定价。`inline-image-prompt` TypeScript SDK 快照通过发布的 profile 重放一次手工编写的 `IMAGE_OFFLOAD_REQUIRED` 尝试,钉住推进和重试后的请求。Python SDK 通知测试钉住新事件及其嵌套水位的无损转发。 diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index fab47e0319..6775871bcd 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/persistence-catalog.md -persistence-catalog.md: f03164165bb23acf87948ae36960b00e0cf84559 -persistence-catalog.zh.md: ba2e6aa7ce2953b04019a34ac7365c9c77ad52e6 +persistence-catalog.md: 8d6f66dc949b6d41530eb387534689dc66f1e99f +persistence-catalog.zh.md: 06d07ceab984ad20ee6637227d54fafbbb58bdd4 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index f03164165b..8d6f66dc94 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -88,7 +88,7 @@ export type SessionEvent = { }[T] ``` -Sources: [`packages/core/session/src/types.ts:410`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:418`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:447`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:478`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:385`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:393`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:422`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:453`](../packages/core/session/src/types.ts) ## Events @@ -215,7 +215,7 @@ Source: [`packages/interaction/user-approval/src/index.ts:33`](../packages/inter 'assistant/attempt': { turn: number; step: number; stream: AssistantStreamRecord[] } ``` -Source: [`packages/core/session/src/types.ts:333`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:319`](../packages/core/session/src/types.ts) @@ -245,7 +245,7 @@ Source: [`packages/core/session/src/types.ts:333`](../packages/core/session/src/ Types: [TokenUsage](subsystems/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:319`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:305`](../packages/core/session/src/types.ts) ### `command/*` @@ -504,28 +504,6 @@ Source: [`packages/hooks/hook-protocol/src/types.ts:19`](../packages/hooks/hook- Source: [`packages/hooks/hook-protocol/src/types.ts:31`](../packages/hooks/hook-protocol/src/types.ts) -### `image/*` - - - -#### `image/offload` — log-only - -```ts persistence-catalog -/** - * Advances the durable image offload watermark before a request in step - * `step` of turn `turn` is dispatched. Every image occurrence positioned at - * or before `watermark` derives with `offloaded: true`, so each route sends - * its placeholder text instead of the image; occurrences after it stay - * retained. The watermark only advances: each event names a position - * strictly after the previous one, and no later budget, route change, or - * compaction moves it back. It is a log-only event that changes the derived - * surface, so a build that does not know the type refuses the log. - */ -'image/offload': { turn: number; step: number; watermark: ImageOccurrencePosition } -``` - -Source: [`packages/core/session/src/types.ts:383`](../packages/core/session/src/types.ts) - ### `llm/*` @@ -615,7 +593,7 @@ Source: [`packages/plan/plan-mode/src/index.ts:46`](../packages/plan/plan-mode/s 'request/context': RequestContext ``` -Source: [`packages/core/session/src/types.ts:372`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:358`](../packages/core/session/src/types.ts) @@ -634,7 +612,7 @@ Source: [`packages/core/session/src/types.ts:372`](../packages/core/session/src/ } ``` -Source: [`packages/core/session/src/types.ts:362`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:348`](../packages/core/session/src/types.ts) ### `sandbox/*` @@ -709,7 +687,7 @@ Source: [`packages/schedule/schedule/src/types.ts:219`](../packages/schedule/sch 'session/end-seed': { inherited?: true } ``` -Source: [`packages/core/session/src/types.ts:406`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:381`](../packages/core/session/src/types.ts) @@ -771,7 +749,7 @@ Source: [`packages/session/session-log-deepseek/src/types.ts:59`](../packages/se 'step/end': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:300`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:286`](../packages/core/session/src/types.ts) @@ -782,7 +760,7 @@ Source: [`packages/core/session/src/types.ts:300`](../packages/core/session/src/ 'step/start': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:298`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:284`](../packages/core/session/src/types.ts) ### `subagent/*` @@ -913,7 +891,7 @@ Source: [`packages/todo/tool-todo/src/types.ts:31`](../packages/todo/tool-todo/s Types: [ToolCallId](subsystems/core.md) -Source: [`packages/core/session/src/types.ts:339`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:325`](../packages/core/session/src/types.ts) @@ -988,7 +966,7 @@ Source: [`packages/core/tools/src/types.ts:40`](../packages/core/tools/src/types } ``` -Source: [`packages/core/session/src/types.ts:351`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:337`](../packages/core/session/src/types.ts) ### `tool-workflow/*` @@ -1068,7 +1046,7 @@ Source: [`packages/workflow/tool-workflow/src/types.ts:47`](../packages/workflow Types: [TurnEndReason](subsystems/session.md) -Source: [`packages/core/session/src/types.ts:296`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:282`](../packages/core/session/src/types.ts) @@ -1084,7 +1062,7 @@ Source: [`packages/core/session/src/types.ts:296`](../packages/core/session/src/ 'turn/start': { turn: number } ``` -Source: [`packages/core/session/src/types.ts:287`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:273`](../packages/core/session/src/types.ts) ### `user/*` @@ -1103,7 +1081,7 @@ Source: [`packages/core/session/src/types.ts:287`](../packages/core/session/src/ 'user/message': UserMessage ``` -Source: [`packages/core/session/src/types.ts:308`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:294`](../packages/core/session/src/types.ts) ### `web/*` diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index ba2e6aa7ce..06d07ceab9 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -506,28 +506,6 @@ export type SessionEvent = { 来源:[`packages/hooks/hook-protocol/src/types.ts:31`](../packages/hooks/hook-protocol/src/types.ts) -### `image/*` - - - -#### `image/offload` — log-only - -```ts persistence-catalog -/** - * Advances the durable image offload watermark before a request in step - * `step` of turn `turn` is dispatched. Every image occurrence positioned at - * or before `watermark` derives with `offloaded: true`, so each route sends - * its placeholder text instead of the image; occurrences after it stay - * retained. The watermark only advances: each event names a position - * strictly after the previous one, and no later budget, route change, or - * compaction moves it back. It is a log-only event that changes the derived - * surface, so a build that does not know the type refuses the log. - */ -'image/offload': { turn: number; step: number; watermark: ImageOccurrencePosition } -``` - -来源:[`packages/core/session/src/types.ts:366`](../packages/core/session/src/types.ts) - ### `llm/*` diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index d92af9b9b1..25c3042d2f 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/core.md -core.md: 0f64cd32eb74ccd17e74312d11ed4bb236098f7a -core.zh.md: 245b6c0f08f9902adcf56d9221406692dd87c346 +core.md: 4af3a22478f324655dea1b132f0c8a8528f4b883 +core.zh.md: 7359e5aaa75e9f750d44f1386acab388665e1e1e diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index 0f64cd32eb..4af3a22478 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -347,7 +347,7 @@ type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact' A `Session` is an **append-only log** of typed `SessionEvent`s — the single source of truth. The LLM message history is *derived* from the log (`deriveMessages()`), not stored separately. Every entry carries a monotonic `seq`, a `time`, and a `type`-discriminated `data` payload; surface variants may also list cited earlier events in `sourceEventSeqs` and carry a `surfaceOp`. -The `SessionEvent` envelope's exact conditional fields, the thirteen core event variants (`turn/start`, `turn/end`, `step/start`, `step/end`, `user/message`, `assistant/message`, `assistant/attempt`, `tool/call`, `tool/result`, `request/header`, `request/context`, `image/offload`, `session/end-seed`), the `deriveMessages()` projection rules, the `TurnEndReason` reasons, and the execution-enclosure and standalone-event rules are on **[session.md](session.md)**. How the log is made durable — the `SessionPersistence` interface, JSONL provider, `session/flush` checkpoint, crash recovery, and `SessionHeader` — is on **[persistence.md](persistence.md)**. +The `SessionEvent` envelope's exact conditional fields, the twelve core event variants (`turn/start`, `turn/end`, `step/start`, `step/end`, `user/message`, `assistant/message`, `assistant/attempt`, `tool/call`, `tool/result`, `request/header`, `request/context`, `session/end-seed`), the `deriveMessages()` projection rules, the `TurnEndReason` reasons, and the execution-enclosure and standalone-event rules are on **[session.md](session.md)**. How the log is made durable — the `SessionPersistence` interface, JSONL provider, `session/flush` checkpoint, crash recovery, and `SessionHeader` — is on **[persistence.md](persistence.md)**. ## `ToolDefinition` diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index 245b6c0f08..7359e5aaa7 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -355,7 +355,7 @@ type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact' `Session` 是一份类型化 `SessionEvent` 的**仅追加日志**——唯一的真源。LLM 消息历史从日志*派生*(`deriveMessages()`),而非单独存储。每个条目携带单调的 `seq`、`time` 与按 `type` 判别的 `data` payload;surface 变体还可以在 `sourceEventSeqs` 中列出被引用的较早事件,并携带 `surfaceOp`。 -`SessionEvent` 信封的确切条件字段、十三种核心事件变体(`turn/start`、`turn/end`、`step/start`、`step/end`、`user/message`、`assistant/message`、`assistant/attempt`、`tool/call`、`tool/result`、`request/header`、`request/context`、`image/offload`、`session/end-seed`)、`deriveMessages()` 投影规则、`TurnEndReason` 原因以及执行封闭和独立事件规则都在 **[session.md](session.zh.md)** 中。日志如何持久化——`SessionPersistence` 接口、JSONL provider、`session/flush` 检查点、崩溃恢复与 `SessionHeader`——则在 **[persistence.md](persistence.zh.md)** 中。 +`SessionEvent` 信封的确切条件字段、十二种核心事件变体(`turn/start`、`turn/end`、`step/start`、`step/end`、`user/message`、`assistant/message`、`assistant/attempt`、`tool/call`、`tool/result`、`request/header`、`request/context`、`session/end-seed`)、`deriveMessages()` 投影规则、`TurnEndReason` 原因以及执行封闭和独立事件规则都在 **[session.md](session.zh.md)** 中。日志如何持久化——`SessionPersistence` 接口、JSONL provider、`session/flush` 检查点、崩溃恢复与 `SessionHeader`——则在 **[persistence.md](persistence.zh.md)** 中。 ## `ToolDefinition` diff --git a/docs/subsystems/llm-streaming.i18n.yaml b/docs/subsystems/llm-streaming.i18n.yaml index d06acdc2c0..4f7cf608c3 100644 --- a/docs/subsystems/llm-streaming.i18n.yaml +++ b/docs/subsystems/llm-streaming.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/llm-streaming.md -llm-streaming.md: f2c8c09779e3e8e808dc62f9861230b7ac69cb87 -llm-streaming.zh.md: ddf7ae1dd5a514b58178521c526b0de1d6c165e6 +llm-streaming.md: 7eb1610febca36973f2892f2e15ad8af52a62b44 +llm-streaming.zh.md: b1cab08a1193ab8f70fcf570dcc57a573b5c96cd diff --git a/docs/subsystems/llm-streaming.md b/docs/subsystems/llm-streaming.md index f2c8c09779..7eb1610feb 100644 --- a/docs/subsystems/llm-streaming.md +++ b/docs/subsystems/llm-streaming.md @@ -247,8 +247,9 @@ interface LlmFailure { /** * With code `IMAGE_OFFLOAD_REQUIRED`: how many more of the oldest retained * image occurrences the route needs offloaded before the same request fits - * its exact byte accounting. `dsh-llm-retry` advances the session's durable - * `image/offload` watermark by this count and retries the step. + * its exact byte accounting. `dsh-llm-retry` replaces the surface nodes + * carrying that many oldest retained occurrences with copies marked + * `offloaded` and retries the step. */ readonly offloadImages?: number } diff --git a/docs/subsystems/llm-streaming.zh.md b/docs/subsystems/llm-streaming.zh.md index ddf7ae1dd5..b1cab08a11 100644 --- a/docs/subsystems/llm-streaming.zh.md +++ b/docs/subsystems/llm-streaming.zh.md @@ -249,8 +249,9 @@ interface LlmFailure { /** * With code `IMAGE_OFFLOAD_REQUIRED`: how many more of the oldest retained * image occurrences the route needs offloaded before the same request fits - * its exact byte accounting. `dsh-llm-retry` advances the session's durable - * `image/offload` watermark by this count and retries the step. + * its exact byte accounting. `dsh-llm-retry` replaces the surface nodes + * carrying that many oldest retained occurrences with copies marked + * `offloaded` and retries the step. */ readonly offloadImages?: number } diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index 6477c348d7..592b0599b3 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session.md -session.md: df91b16dcd2125c33ce92d62079f7afc84cc47e5 -session.zh.md: f107b1f4d0eac7c49206b7018f44d0653091a6af +session.md: 90051ca8668173daab4a003cf574f5ec64073223 +session.zh.md: 5de78f22697f71ec8c15824d36b4b93f31fc6a94 diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index df91b16dcd..90051ca866 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -117,17 +117,6 @@ interface SessionEventMap { * changes. It does not participate in request reconstruction or header equality. */ 'request/context': RequestContext - /** - * Advances the durable image offload watermark before a request in step - * `step` of turn `turn` is dispatched. Every image occurrence positioned at - * or before `watermark` derives with `offloaded: true`, so each route sends - * its placeholder text instead of the image; occurrences after it stay - * retained. The watermark only advances: each event names a position - * strictly after the previous one, and no later budget, route change, or - * compaction moves it back. It is a log-only event that changes the derived - * surface, so a build that does not know the type refuses the log. - */ - 'image/offload': { turn: number; step: number; watermark: ImageOccurrencePosition } /** * Marks the end of a constructor seed. Events before it have smaller seq * values and came from the seed (resume, fork, or replay); this lifecycle @@ -198,26 +187,6 @@ interface RequestContext { } ``` -### The image offload event: `image/offload` - -`dsh-llm-retry` appends `image/offload` on the `agent/request-error` waterfall when an adapter fails an attempt with `IMAGE_OFFLOAD_REQUIRED`, before the step retries. Its `watermark` names the last offloaded occurrence; `deriveMessages()` and the Session instance's `deriveEventMessage()` mark every occurrence positioned at or before it `offloaded: true`, and each route renders those marks as placeholder text. `Session.append` and seeding require the watermark to identify an image on the current surface, reject malformed and non-advancing positions, and reject request-only `offloaded` markers in durable messages. Like `request/header`, it is not a `SurfaceEventType`; unlike it, it changes the derived surface, so it stays required-on-read ([decision](../../.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.md)). - -```ts type-equiv -/** - * Durable position of one image occurrence on the model-visible surface: the - * seq of the event carrying it and the block path inside that event's - * content. Every nested tool-result contributes another path index. Positions - * order by seq, then by path, so a newer event always lies after an older one - * regardless of surface replacements. - */ -interface ImageOccurrencePosition { - /** Seq of the surface message event carrying the occurrence. */ - seq: SessionSeq - /** Block index path inside that event's message content. */ - path: number[] -} -``` - ## `SessionEvent` — one log entry A proper discriminated union over `type` (not independent `type`/`data` unions), so `switch (event.type)` narrows `event.data` without casts. `seq` is the monotonic position in the log (`seq = log.length`); `time` is epoch ms. @@ -588,9 +557,7 @@ declare class Session { * * CACHED: each surface node is projected exactly once, when first seen — a * call costs O(new nodes), and a surface rewrite (a `replace`; - * {@link SessionSurface.replaceGeneration}) or an `image/offload` advance - * rebuilds, and image occurrences at or before the watermark derive with - * `offloaded: true` ({@link markImageOffload}). The returned array is + * {@link SessionSurface.replaceGeneration}) rebuilds. The returned array is * a fresh snapshot per call (later appends never grow an array a caller * already holds); the `Message` objects in it are SHARED and **deep-frozen**. * Their content reuses the already frozen durable event data, so the cache @@ -599,9 +566,8 @@ declare class Session { */ deriveMessages(): Message[]; /** - * Derive one event under the session's current image offload watermark. - * Direct model consumers use this instance method so their per-event - * reconstruction matches {@link deriveMessages}. + * Instance face of the pure per-node `deriveEventMessage` export from + * `surface.ts`. * @param event - the event to project. * @returns the derived message, or null when the event produces none. */ @@ -611,7 +577,7 @@ declare class Session { ## Derived history: `deriveMessages()` and `deriveEventMessage()` -`Session.deriveMessages()` projects the event log into the `Message[]` the model sees — cached (each surface node projected once, when first seen; a surface rewrite or image watermark advance rebuilds) and frozen (a fresh array per call over shared, deep-frozen messages, so mutating logged history through a projection is unrepresentable). The pure `deriveEventMessage(event)` export applies the message-type rules without session state. The Session instance method of the same name additionally applies the current image watermark and is the per-event API for direct model consumers such as compaction. The projection rules: +`Session.deriveMessages()` projects the event log into the `Message[]` the model sees — cached (each surface node projected once, when first seen; a surface rewrite rebuilds) and frozen (a fresh array per call over shared, deep-frozen messages, so mutating logged history through a projection is unrepresentable). `deriveEventMessage(event)` is the per-node pure function the fold applies — public so external reconstructors and the dev invariant project a log prefix with exactly the same rules and cannot disagree with the cache. The projection rules: - `user/message` → a user message carrying exact `content`; an optional envelope remains log-only display metadata. - `assistant/message` → an assistant message with the provider and model that produced it plus optional adapter-private replay state. Its embedded compact stream is replay, usage, and UI evidence rather than a second message. An **empty-content** `assistant/message` is also skipped — a max-tokens step cut off with no content still records an `assistant/message` to hold its stream, usage, provider, and model, but a content-less assistant turn must not enter the provider transcript. diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index f107b1f4d0..5de78f2269 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -117,17 +117,6 @@ interface SessionEventMap { * changes. It does not participate in request reconstruction or header equality. */ 'request/context': RequestContext - /** - * Advances the durable image offload watermark before a request in step - * `step` of turn `turn` is dispatched. Every image occurrence positioned at - * or before `watermark` derives with `offloaded: true`, so each route sends - * its placeholder text instead of the image; occurrences after it stay - * retained. The watermark only advances: each event names a position - * strictly after the previous one, and no later budget, route change, or - * compaction moves it back. It is a log-only event that changes the derived - * surface, so a build that does not know the type refuses the log. - */ - 'image/offload': { turn: number; step: number; watermark: ImageOccurrencePosition } /** * Marks the end of a constructor seed. Events before it have smaller seq * values and came from the seed (resume, fork, or replay); this lifecycle @@ -198,26 +187,6 @@ interface RequestContext { } ``` -### 图片 offload 事件:`image/offload` - -adapter 以 `IMAGE_OFFLOAD_REQUIRED` 让一次尝试失败时,`dsh-llm-retry` 在 `agent/request-error` waterfall 上、step 重试前追加 `image/offload`。其 `watermark` 指向最后一个被省略的出现位置;`deriveMessages()` 与 Session 实例的 `deriveEventMessage()` 把位于它及之前的每个出现位置标为 `offloaded: true`,每条路由把这些标记渲染为占位文本。`Session.append` 与 seed 要求水位指向当前表层的图片,拒绝畸形和没有严格前进的位置,并拒绝持久消息中的请求专用 `offloaded` 标记。它和 `request/header` 一样不是 `SurfaceEventType`;与之不同的是它改变派生表层,因此读取时必须识别([决定](../../.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.zh.md))。 - -```ts type-equiv -/** - * Durable position of one image occurrence on the model-visible surface: the - * seq of the event carrying it and the block path inside that event's - * content. Every nested tool-result contributes another path index. Positions - * order by seq, then by path, so a newer event always lies after an older one - * regardless of surface replacements. - */ -interface ImageOccurrencePosition { - /** Seq of the surface message event carrying the occurrence. */ - seq: SessionSeq - /** Block index path inside that event's message content. */ - path: number[] -} -``` - ## `SessionEvent`:一条日志条目 基于 `type` 的真正可辨识联合(而非独立的 `type`/`data` 联合),因此 `switch (event.type)` 能直接收窄 `event.data`,无需类型断言。`seq` 是日志中的单调递增位置(`seq = log.length`);`time` 为 epoch 毫秒。 @@ -590,9 +559,7 @@ declare class Session { * * CACHED: each surface node is projected exactly once, when first seen — a * call costs O(new nodes), and a surface rewrite (a `replace`; - * {@link SessionSurface.replaceGeneration}) or an `image/offload` advance - * rebuilds, and image occurrences at or before the watermark derive with - * `offloaded: true` ({@link markImageOffload}). The returned array is + * {@link SessionSurface.replaceGeneration}) rebuilds. The returned array is * a fresh snapshot per call (later appends never grow an array a caller * already holds); the `Message` objects in it are SHARED and **deep-frozen**. * Their content reuses the already frozen durable event data, so the cache @@ -601,9 +568,8 @@ declare class Session { */ deriveMessages(): Message[]; /** - * Derive one event under the session's current image offload watermark. - * Direct model consumers use this instance method so their per-event - * reconstruction matches {@link deriveMessages}. + * Instance face of the pure per-node `deriveEventMessage` export from + * `surface.ts`. * @param event - the event to project. * @returns the derived message, or null when the event produces none. */ @@ -613,7 +579,7 @@ declare class Session { ## 派生历史:`deriveMessages()` 与 `deriveEventMessage()` -`Session.deriveMessages()` 将事件日志投影为模型看到的 `Message[]`。它是缓存的(每个 surface 节点在首次出现时投影一次;surface 重写或图片水位推进触发重建)且冻结的(每次调用返回一个新数组,引用共享的深冻结消息,因此通过投影修改已记录的历史在类型上不可表达)。纯函数导出 `deriveEventMessage(event)` 只应用消息类型规则。同名 Session 实例方法还会应用当前图片水位,是 compaction 等直接模型消费方使用的逐事件接口。投影规则: +`Session.deriveMessages()` 将事件日志投影为模型看到的 `Message[]`。它是缓存的(每个 surface 节点在首次出现时投影一次;surface 重写触发重建)且冻结的(每次调用返回一个新数组,引用共享的深冻结消息,因此通过投影修改已记录的历史在类型上不可表达)。`deriveEventMessage(event)` 是折叠所应用的逐节点纯函数,公开暴露以便外部重建器和开发不变式检查能以完全相同的规则投影日志前缀,不会与缓存产生分歧。投影规则: - `user/message` → 一条携带确切 `content` 的 user 消息;可选 envelope 仅作为日志中的展示元数据保留。 - `assistant/message` → 一条 assistant 消息,包含生成它的提供方和模型,以及可选的适配器私有回放状态。其嵌入式紧凑 stream 是回放、usage 与 UI 证据,而不是第二条 message。**内容为空的** `assistant/message` 也会跳过:因 max-tokens 而截断且无内容的步骤仍会记录一条 `assistant/message` 来保存 stream、usage、提供方和模型,但无内容的 assistant 轮次不得进入提供方 transcript(文本记录)。 diff --git a/packages/compaction/compaction-basic/tests/compaction-basic.spec.ts b/packages/compaction/compaction-basic/tests/compaction-basic.spec.ts index 28aebda515..76ddb5f021 100644 --- a/packages/compaction/compaction-basic/tests/compaction-basic.spec.ts +++ b/packages/compaction/compaction-basic/tests/compaction-basic.spec.ts @@ -908,46 +908,6 @@ describe('compaction region transaction', () => { expect(summarizedText(input)).toContain('fixture user 1') }) - it('applies the durable image watermark to the replayed summarization prefix', async () => { - const compact = service() - const session = conversation(3) - const replaced = session.surface.nodes[0]! - const replacement = session.append('user/message', createUserMessage({ - content: [ - { type: 'text', text: 'large image context '.repeat(100) }, - { - type: 'image', - attachment: { - attachmentId: AttachmentId(`sha256:${'d'.repeat(64)}`), - mediaType: 'image/png', - bytes: 1, - width: 1, - height: 1, - }, - }, - ], - source: { kind: 'user' }, - }), { - surfaceOp: { op: 'replace', start: replaced, end: replaced }, - sourceEventSeqs: [replaced], - }) - session.append('image/offload', { - turn: 4, - step: 1, - watermark: { seq: replacement.seq, path: [1] }, - }) - - await compact.compactRegion(replacement.seq, replacement.seq, agent(session, MODEL), SIGNAL) - - expect(compact.calls[0]!.input.messages[0]!.content[1]).toMatchObject({ - type: 'image', - offloaded: true, - }) - const durable = session.eventAt(replacement.seq)! - expect(durable.type === 'user/message' ? durable.data.content[1] : undefined) - .not.toHaveProperty('offloaded') - }) - it.each([ ['start missing', 9_001, undefined, /start seq 9001 not found/], ['end missing', undefined, 9_002, /end seq 9002 not found/], diff --git a/packages/core/session/README.i18n.yaml b/packages/core/session/README.i18n.yaml index 376d694b90..3d5d742b46 100644 --- a/packages/core/session/README.i18n.yaml +++ b/packages/core/session/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/session/README.md -README.md: 584c4921811e08f7d5d5450f5d2f3211e137df6f -README.zh.md: ea82ccebab44f571a481d85cdcf18648c5caa9a7 +README.md: e810299678c93fd0b5c740dcd2485df746eee0e0 +README.zh.md: 9d8250053a6b11fd9ac71839bd6517cb58c3d90a diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 584c492181..e810299678 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -83,15 +83,13 @@ The package is built on event sourcing: a `Session` is an append-only log of typ `request/header` stores a full canonical snapshot of the non-history request envelope with reason `initial`, `resume`, `change`, or `series`. An explicit message-series start or a surface replacement writes a `series` snapshot when the envelope is unchanged; a simultaneous change uses `startsSeries: true`. Same-series steps, retries, and ordinary later turns inherit the latest snapshot. `adapterDefaults` distinguishes values resolved by the adapter from explicit settings, and `foldRequestHeader()` selects the latest snapshot. This self-contained record supports partial-window rendering and exact reconstruction at the cost of growth per message series; the [reconstructable-requests Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md) owns the detail. -`image/offload` records the durable image offload watermark `dsh-llm-retry` advances when an adapter reports that a route's request-image budget is exceeded: an `ImageOccurrencePosition` naming the last offloaded occurrence by event seq and its complete nested block path. The derivation marks every occurrence at or before it `offloaded: true`, so each route sends its placeholder text instead of the image. The position only advances; `Session.append` and seeding require it to identify a current surface image and reject request-only `offloaded` markers in durable messages. Because the event changes the derived surface it is required-on-read: a build without the type refuses the log rather than replaying images the model never saw ([decision](../../../.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.md)). - ### Source map | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry: `SessionStore` service, store lifecycle, `fork`, `flush` | | [`src/types.ts`](src/types.ts) | `SessionEventMap`, `SessionEvent`, `UserMessage`, `SessionHeader`, `TurnEndReasonMap` | -| [`src/surface.ts`](src/surface.ts) | Ordered surface projection, replacement validation, `deriveEventMessage`, `image/offload` marking | +| [`src/surface.ts`](src/surface.ts) | Ordered surface projection, replacement validation, `deriveEventMessage` | | [`src/request-header.ts`](src/request-header.ts) | `request/header` folding and reconstruction | | [`dsh-util-values`](../../util/values/README.md) | Shared lossless JSON validation and detached snapshots | | [`src/repair.ts`](src/repair.ts) | Cold repair of crash-orphaned logs | @@ -107,7 +105,7 @@ Every append uses the shared iterative `snapshotJsonValue()` pass, which reads, ### The request header -The loop logs a full canonical `request/header` snapshot (call config, adapter defaults, rendered system prompt, assembled tool schemas) at each loop-instance boundary and on change; `foldRequestHeader(events)` reconstructs it by selecting the latest snapshot, making every conversation request a pure function of the log. Route metadata (`request/context`) is separate logged state appended only when the provider, model, or capacity differs. The image offload watermark (`image/offload`) is durable surface state: every image occurrence positioned at or before it derives with `offloaded: true`, each event must advance strictly past the previous one. +The loop logs a full canonical `request/header` snapshot (call config, adapter defaults, rendered system prompt, assembled tool schemas) at each loop-instance boundary and on change; `foldRequestHeader(events)` reconstructs it by selecting the latest snapshot, making every conversation request a pure function of the log. Route metadata (`request/context`) is separate logged state appended only when the provider, model, or capacity differs. diff --git a/packages/core/session/README.zh.md b/packages/core/session/README.zh.md index ea82ccebab..9d8250053a 100644 --- a/packages/core/session/README.zh.md +++ b/packages/core/session/README.zh.md @@ -83,15 +83,13 @@ session.deriveMessages() // the derived model history `request/header` 存储非历史请求 envelope 的完整规范快照,原因为 `initial`、`resume`、`change` 或 `series`。显式消息序列起点或表层替换会在 envelope 不变时写入 `series` 快照;同时发生变化时使用 `startsSeries: true`。同一序列内的步骤、重试与普通后续轮次继承最新快照。`adapterDefaults` 区分由适配器解析的值与显式设置,`foldRequestHeader()` 选择最新快照。这种自包含记录以每个消息序列增加存储为代价,支持局部窗口渲染与精确重建;细节由[可重建请求 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md)负责。 -`image/offload` 记录 `dsh-llm-retry` 在 adapter 报告路由请求图片预算被超过时推进的持久图片 offload 水位:一个以事件序号加完整嵌套块路径指向最后一个被省略出现位置的 `ImageOccurrencePosition`。派生把位于水位及之前的每个出现位置标为 `offloaded: true`,于是每条路由发送其占位文本而不是图片。位置只会推进;`Session.append` 与 seed 要求它指向当前表层图片,并拒绝持久消息中的请求专用 `offloaded` 标记。该事件改变派生表层,因此读取时必须识别:不认识该类型的构建拒绝这份日志,而不是带着模型从未见过的图片重放([决定](../../../.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.zh.md))。 - ### 源码地图 | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | 插件入口:`SessionStore` 服务、存储生命周期、`fork`、`flush` | | [`src/types.ts`](src/types.ts) | `SessionEventMap`、`SessionEvent`、`UserMessage`、`SessionHeader`、`TurnEndReasonMap` | -| [`src/surface.ts`](src/surface.ts) | 有序 surface 投影、替换校验、`deriveEventMessage`、`image/offload` 标记 | +| [`src/surface.ts`](src/surface.ts) | 有序 surface 投影、替换校验、`deriveEventMessage` | | [`src/request-header.ts`](src/request-header.ts) | `request/header` 折叠与重建 | | [`dsh-util-values`](../../util/values/README.zh.md) | 共享无损 JSON 校验与分离式快照 | | [`src/repair.ts`](src/repair.ts) | 崩溃遗留日志的冷修复 | @@ -107,7 +105,7 @@ session.deriveMessages() // the derived model history ### 请求头 -循环在每个循环实例边界及变更时记录完整规范 `request/header` 快照(调用配置、适配器默认值、渲染后的系统提示词、组装后的工具 schema);`foldRequestHeader(events)` 通过选择最新快照来重建它,使每个对话请求都成为日志的纯函数。路由元数据(`request/context`)是独立的已记录状态,仅在提供方、模型或容量变化时追加。图片 offload 水位(`image/offload`)是持久的表层状态:位于水位及之前的每个图片出现位置派生时带 `offloaded: true`,每条事件必须严格越过前一条。 +循环在每个循环实例边界及变更时记录完整规范 `request/header` 快照(调用配置、适配器默认值、渲染后的系统提示词、组装后的工具 schema);`foldRequestHeader(events)` 通过选择最新快照来重建它,使每个对话请求都成为日志的纯函数。路由元数据(`request/context`)是独立的已记录状态,仅在提供方、模型或容量变化时追加。 diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 2c16d89569..14e889ad87 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -12,11 +12,11 @@ import { brandString } from '@deepseek-ai/dsh-brand' import { assertNever, deepFreeze, snapshotJsonValue } from '@deepseek-ai/dsh-util-values' import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope' -import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm' +import type { Message } from '@deepseek-ai/dsh-llm' import { SESSION_FORMAT_VERSION, SessionLogOffset, SessionSeq } from './types.ts' import type { TypertLookup } from '@deepseek-ai/dsh-typert-protocol' -import type { CreateSessionOptions, EpochHeader, ImageOccurrencePosition, PrepareSessionOptions, RequestContext, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SessionId, SessionSeedEventState, SurfaceIntent, SurfaceEventType } from './types.ts' -import { compareImagePositions, deriveEventMessage, markImageOffload, SurfaceManager } from './surface.ts' +import type { CreateSessionOptions, EpochHeader, PrepareSessionOptions, RequestContext, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SessionId, SessionSeedEventState, SurfaceIntent, SurfaceEventType } from './types.ts' +import { deriveEventMessage, SurfaceManager } from './surface.ts' import type { SessionSurface } from './surface.ts' import { foldRequestHeader } from './request-header.ts' @@ -26,7 +26,7 @@ export type { SessionPreparationOptions } from './preparation.ts' export type { AssistantMessage, ToolResultMessage, UserMessage } from '@deepseek-ai/dsh-llm' export { interruptedTurnClosers, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from './repair.ts' export type { SessionSurface, SurfaceFoldReplacement, SurfaceFoldResult } from './surface.ts' -export { compareImagePositions, deriveEventMessage, foldSurface, isAppendSurfaceEvent, isReplacementSurfaceEvent, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts' +export { deriveEventMessage, foldSurface, isAppendSurfaceEvent, isReplacementSurfaceEvent, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts' export { canonicalHeader, foldRequestHeader, headerEquals } from './request-header.ts' export { KNOWN_SESSION_EVENT_TYPES } from './known-event-types.ts' @@ -335,7 +335,6 @@ function assertMessageEventShape(event: Record, subject: string if (!Array.isArray(messageRecord['content'])) { throw new Error(`${subject} message has invalid content`) } - assertNoLoggedImageOffloadMarker(type, data, subject) const sourceRecord = source as Record if (type === 'assistant/message') { if (sourceRecord['kind'] !== 'model' || !hasProviderModel(sourceRecord)) { @@ -361,58 +360,6 @@ function assertMessageEventShape(event: Record, subject: string } } -/** Reject the request-only image marker from durable message content at every nesting depth. */ -function assertNoLoggedImageOffloadMarker(type: string, data: unknown, subject: string): void { - if (type !== 'user/message' && type !== 'assistant/message' && type !== 'tool/result') return - const message = (type === 'user/message' ? data : (data as { message: unknown }).message) as { content?: unknown } - const visit = (blocks: unknown): void => { - if (!Array.isArray(blocks)) return - for (const block of blocks as ({ type?: unknown; content?: unknown } | null)[]) { - if (block?.type === 'image' && 'offloaded' in block) { - throw new Error(`${subject} stores the request-only image offload marker`) - } - if (block?.type === 'tool-result') visit(block.content) - } - } - visit(message.content) -} - -/** Return whether a block path identifies an image occurrence. */ -function pathIdentifiesImage(content: readonly ContentBlock[], [index, ...rest]: readonly number[]): boolean { - const block = index === undefined ? undefined : content[index] - if (block === undefined) return false - if (rest.length === 0) return block.type === 'image' - return block.type === 'tool-result' && pathIdentifiesImage(block.content, rest) -} - -/** - * Validate one `image/offload` payload at the append or seed boundary: the - * position must name an image occurrence on the current surface and lie - * strictly after the watermark in force. - */ -function assertImageOffloadAdvance( - data: unknown, - log: readonly SessionEvent[], - surfaceNodes: readonly SessionSeq[], - prior: ImageOccurrencePosition | undefined, - location: string, -): void { - const watermark = (data as { watermark?: { seq?: unknown; path?: unknown } } | null)?.watermark - if (typeof watermark?.seq !== 'number' || !Array.isArray(watermark.path) - || !watermark.path.every(index => Number.isInteger(index) && index >= 0)) { - throw new Error(`${location} names an invalid image offload watermark`) - } - const position = { seq: watermark.seq as SessionSeq, path: watermark.path as number[] } - if (prior !== undefined && compareImagePositions(position, prior) <= 0) { - throw new Error(`${location} does not advance the image offload watermark`) - } - // oxlint-disable-next-line typescript/no-non-null-assertion -- surface nodes index the accepted log - const message = surfaceNodes.includes(position.seq) ? deriveEventMessage(log[position.seq]!) : null - if (message === null || !pathIdentifiesImage(message.content, position.path)) { - throw new Error(`${location} watermark does not identify an image on the current surface`) - } -} - /** Whether an unknown value carries the current provider/model pair. */ function hasProviderModel(value: unknown): boolean { if (typeof value !== 'object' || value === null) return false @@ -474,8 +421,6 @@ const attachments = new WeakMap() */ export class Session { private log: SessionEvent[] = [] - /** Watermark in force after the last accepted `image/offload` event. */ - private imageOffloadFold: ImageOccurrencePosition | undefined /** Single incremental owner of surface acceptance and projection state. */ private readonly surfaceManager = new SurfaceManager(this.log) @@ -598,9 +543,6 @@ export class Session { throw new Error(`seed event at index ${index} is not losslessly JSON-serializable`) } assertSessionEventEnvelope(snapshot, index) - if (snapshot.type === 'image/offload') { - assertImageOffloadAdvance(snapshot.data, this.log, this.surface.nodes, this.imageOffloadFold, `seed event at index ${index}`) - } if (snapshot.seq !== index) { throw new Error(`seed event at index ${index} has seq ${snapshot.seq} (expected ${index}); seed must be contiguous from 0`) } @@ -613,8 +555,6 @@ export class Session { throw new Error(`invalid seed event at index ${index}: ${error instanceof Error ? error.message : 'invalid surface metadata'}`) } this.log.push(mode === 'snapshot' ? deepFreeze(snapshot) : snapshot) - // oxlint-disable-next-line typescript/no-non-null-assertion -- the event was just appended - this.foldImageOffload(this.log.at(-1)!) } } this.firstLiveSeq = SessionLogOffset(this.log.length) @@ -749,10 +689,6 @@ export class Session { if (dataSnapshot === undefined) { throw new Error(`session event "${type}" carries non-JSON-serializable data`) } - assertNoLoggedImageOffloadMarker(type, dataSnapshot, `session event "${type}"`) - if (type === 'image/offload') { - assertImageOffloadAdvance(dataSnapshot, this.log, this.surface.nodes, this.imageOffloadFold, `session event "${type}"`) - } const surfaceMetadataSnapshot = snapshotJsonValue(surfaceMetadata) if (surfaceMetadataSnapshot === undefined) { throw new Error(`session event "${type}" carries non-JSON-serializable surface metadata`) @@ -778,7 +714,6 @@ export class Session { callbacks = collectSessionCallbacks(entry.emitCtx, [entry.carrier, 'session/event', ...callbackArgs]) } this.log.push(event as SessionEvent) - this.foldImageOffload(event as SessionEvent) this.eventsSnapshot = undefined if (callbacks !== undefined && entry !== undefined) { invokeContainedSessionObservers(entry.emitCtx, 'session/event', entry.id, callbackArgs, callbacks) @@ -836,21 +771,12 @@ export class Session { return this.contextFold } - /** Record an accepted `image/offload` event as the watermark in force. */ - private foldImageOffload(event: SessionEvent): void { - if (event.type !== 'image/offload') return - const { watermark } = event.data - this.imageOffloadFold = deepFreeze({ seq: watermark.seq, path: [...watermark.path] }) - } - /** The derived-message cache: frozen projections, extended per unseen node. */ private derived: Message[] = [] /** Surface position (nodes projected) the cache has reached. */ private derivedNodes = 0 /** {@link SurfaceManager.replaceGeneration} the cache was built under. */ private derivedGeneration = 0 - /** Watermark the cache was built under; an advance rebuilds every node. */ - private derivedWatermark: ImageOccurrencePosition | undefined /** * Derive the LLM message history by walking the ordered sequences of @@ -863,9 +789,7 @@ export class Session { * * CACHED: each surface node is projected exactly once, when first seen — a * call costs O(new nodes), and a surface rewrite (a `replace`; - * {@link SessionSurface.replaceGeneration}) or an `image/offload` advance - * rebuilds, and image occurrences at or before the watermark derive with - * `offloaded: true` ({@link markImageOffload}). The returned array is + * {@link SessionSurface.replaceGeneration}) rebuilds. The returned array is * a fresh snapshot per call (later appends never grow an array a caller * already holds); the `Message` objects in it are SHARED and **deep-frozen**. * Their content reuses the already frozen durable event data, so the cache @@ -876,12 +800,10 @@ export class Session { const surface = this.surface const nodes = surface.nodes const generation = surface.replaceGeneration - const watermark = this.imageOffloadFold - if (generation !== this.derivedGeneration || watermark !== this.derivedWatermark) { + if (generation !== this.derivedGeneration) { this.derived = [] this.derivedNodes = 0 this.derivedGeneration = generation - this.derivedWatermark = watermark } for (const seq of nodes.slice(this.derivedNodes)) { // Surface sequences are built from this.log — seq is always a valid @@ -898,18 +820,13 @@ export class Session { } /** - * Derive one event under the session's current image offload watermark. - * Direct model consumers use this instance method so their per-event - * reconstruction matches {@link deriveMessages}. + * Instance face of the pure per-node `deriveEventMessage` export from + * `surface.ts`. * @param event - the event to project. * @returns the derived message, or null when the event produces none. */ deriveEventMessage(event: SessionEvent): Message | null { - const message = deriveEventMessage(event) - if (message === null) return null - // A marked copy is frozen like the durable original it derives from. - const marked = markImageOffload(message, event.seq, this.imageOffloadFold) - return marked === message ? message : deepFreeze(marked) + return deriveEventMessage(event) } } diff --git a/packages/core/session/src/invariant.ts b/packages/core/session/src/invariant.ts index aad11350db..3242691324 100644 --- a/packages/core/session/src/invariant.ts +++ b/packages/core/session/src/invariant.ts @@ -148,8 +148,7 @@ function validateEvent( // Unconstrained: an unbalanced seed legally puts it inside an open turn. break case 'request/header': - case 'request/context': - case 'image/offload': { + case 'request/context': { if (trace.openTurn === null) { fail(`${event.type} appended outside any open turn (core execution events must be turn-enclosed)`) } diff --git a/packages/core/session/src/known-event-types.ts b/packages/core/session/src/known-event-types.ts index 749ab47fc3..3bc31ec32d 100644 --- a/packages/core/session/src/known-event-types.ts +++ b/packages/core/session/src/known-event-types.ts @@ -39,7 +39,6 @@ export const KNOWN_SESSION_EVENT_TYPES: ReadonlySet = new Set([ 'goal/change', 'hook/invoked', 'hook/result', - 'image/offload', 'llm/retry', 'llm/retry-started', 'model/selection', diff --git a/packages/core/session/src/surface.ts b/packages/core/session/src/surface.ts index 1593a94f27..1cec97e3cd 100644 --- a/packages/core/session/src/surface.ts +++ b/packages/core/session/src/surface.ts @@ -8,10 +8,9 @@ * @module @deepseek-ai/dsh-session/surface */ -import type { ContentBlock, ImageBlockPath, Message } from '@deepseek-ai/dsh-llm' +import type { Message } from '@deepseek-ai/dsh-llm' import { SessionLogOffset, SessionSeq } from './types.ts' import type { - ImageOccurrencePosition, SessionEvent, SessionSeqCursor, SurfaceEvent, @@ -121,76 +120,6 @@ export function deriveEventMessage(event: SessionEvent): Message | null { } } -/** Compare two nested block paths in message order. */ -function compareImageBlockPaths(a: ImageBlockPath, b: ImageBlockPath): number { - const length = Math.min(a.length, b.length) - for (let index = 0; index < length; index += 1) { - // oxlint-disable-next-line typescript/no-non-null-assertion -- index is below both lengths - const delta = a[index]! - b[index]! - if (delta !== 0) return delta - } - return a.length - b.length -} - -/** - * Compare two image occurrence positions in log order: by event seq, then by - * block path inside the event's content. - * @param a - first position. - * @param b - second position. - * @returns negative, zero, or positive as `a` lies before, at, or after `b`. - */ -export function compareImagePositions(a: ImageOccurrencePosition, b: ImageOccurrencePosition): number { - return a.seq === b.seq ? compareImageBlockPaths(a.path, b.path) : a.seq - b.seq -} - -/** Mark matching image occurrences while preserving unchanged durable content by identity. */ -function markOffloadedImages( - content: readonly ContentBlock[], - offloaded: (path: ImageBlockPath) => boolean, - prefix: ImageBlockPath = [], -): readonly ContentBlock[] { - let next: ContentBlock[] | undefined - for (const [index, block] of content.entries()) { - const path = [...prefix, index] - if (block.type === 'image' && block.offloaded !== true && offloaded(path)) { - next ??= content.slice(0, index) - next.push({ ...block, offloaded: true }) - continue - } - if (block.type === 'tool-result') { - const inner = markOffloadedImages(block.content, offloaded, path) - if (inner !== block.content) { - next ??= content.slice(0, index) - next.push({ ...block, content: inner as ContentBlock[] }) - continue - } - } - next?.push(block) - } - return next ?? content -} - -/** - * Project one derived surface message under the `image/offload` watermark: - * every image occurrence positioned at or before it carries `offloaded: true`. - * @param message - the node's derived message. - * @param seq - the node's event seq. - * @param watermark - watermark in force, or undefined when nothing is offloaded. - * @returns the same message when nothing changes, otherwise a shallow copy with marked content. - */ -export function markImageOffload( - message: Message, - seq: SessionSeq, - watermark: ImageOccurrencePosition | undefined, -): Message { - if (watermark === undefined || seq > watermark.seq) return message - const content = markOffloadedImages( - message.content, - path => seq < watermark.seq || compareImageBlockPaths(path, watermark.path) <= 0, - ) - return content === message.content ? message : { ...message, content: content as Message['content'] } -} - /** One replacement operation observed while folding a session surface. */ export interface SurfaceFoldReplacement { /** Seq of the event that replaced the prior surface range. */ diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 9808f23f64..3007650a85 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -247,20 +247,6 @@ export interface RequestContext { contextWindow?: number } -/** - * Durable position of one image occurrence on the model-visible surface: the - * seq of the event carrying it and the block path inside that event's - * content. Every nested tool-result contributes another path index. Positions - * order by seq, then by path, so a newer event always lies after an older one - * regardless of surface replacements. - */ -export interface ImageOccurrencePosition { - /** Seq of the surface message event carrying the occurrence. */ - seq: SessionSeq - /** Block index path inside that event's message content. */ - path: number[] -} - /** * Why a `request/header` snapshot was appended: `'initial'` — the log's first * header (a new conversation); `'resume'` — a loop instance's first request @@ -370,17 +356,6 @@ export interface SessionEventMap { * changes. It does not participate in request reconstruction or header equality. */ 'request/context': RequestContext - /** - * Advances the durable image offload watermark before a request in step - * `step` of turn `turn` is dispatched. Every image occurrence positioned at - * or before `watermark` derives with `offloaded: true`, so each route sends - * its placeholder text instead of the image; occurrences after it stay - * retained. The watermark only advances: each event names a position - * strictly after the previous one, and no later budget, route change, or - * compaction moves it back. It is a log-only event that changes the derived - * surface, so a build that does not know the type refuses the log. - */ - 'image/offload': { turn: number; step: number; watermark: ImageOccurrencePosition } /** * Marks the end of a constructor seed. Events before it have smaller seq * values and came from the seed (resume, fork, or replay); this lifecycle diff --git a/packages/core/session/tests/image-offload.spec.ts b/packages/core/session/tests/image-offload.spec.ts deleted file mode 100644 index 946d64cf76..0000000000 --- a/packages/core/session/tests/image-offload.spec.ts +++ /dev/null @@ -1,234 +0,0 @@ -/** - * Durable image offload watermark: append/seed validation, monotonic - * advance, derived-surface marking, cache rebuild, and replay equality. - */ - -import { describe, expect, it } from 'vitest' -import { createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm' -import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import { compareImagePositions, Session, SessionId, SessionSeq } from '@deepseek-ai/dsh-session' - -function image(name: string): Extract { - return { - type: 'image', - attachment: { - attachmentId: `sha256:${'a'.repeat(64)}` as never, - name, - mediaType: 'image/png', - bytes: 3, - width: 1, - height: 1, - }, - } -} - -function toolResult(name: string): ContentBlock { - return { type: 'tool-result', toolCallId: ToolCallId(name), content: [{ type: 'text', text: 'shot' }, image(name)] } -} - -function seeded(): Session { - const session = Session.create(SessionId('offload')) - session.append('turn/start', { turn: 1 }) - session.append('step/start', { turn: 1, step: 1 }) - session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'no images here' }], source: { kind: 'user' }, - }), { surfaceOp: 'append' }) - session.append('user/message', createUserMessage({ - content: [image('first'), image('second')], source: { kind: 'user' }, - }), { surfaceOp: 'append' }) - session.append('user/message', createUserMessage({ - content: [toolResult('third'), image('fourth')], source: { kind: 'user' }, - }), { surfaceOp: 'append' }) - return session -} - -function offloadedNames(session: Session): string[] { - const names: string[] = [] - const visit = (content: readonly ContentBlock[]): void => { - for (const block of content) { - if (block.type === 'image' && block.offloaded === true) names.push(block.attachment.name ?? '') - if (block.type === 'tool-result') visit(block.content) - } - } - for (const message of session.deriveMessages()) { - visit(message.content) - } - return names -} - -describe('image/offload append validation', () => { - it('rejects a malformed or out-of-range watermark', () => { - const session = seeded() - expect(() => session.append('image/offload', { turn: 1, step: 1, watermark: { seq: SessionSeq(99), path: [0] } })) - .toThrow('watermark does not identify an image on the current surface') - expect(() => session.append('image/offload', { turn: 1, step: 1, watermark: { seq: SessionSeq(3), path: [] } })) - .toThrow('watermark does not identify an image on the current surface') - expect(() => session.append('image/offload', { turn: 1, step: 1, watermark: { seq: SessionSeq(3), path: [-1] } })) - .toThrow('names an invalid image offload watermark') - expect(() => session.append('image/offload', { turn: 1, step: 1, watermark: null as never })) - .toThrow('names an invalid image offload watermark') - expect(() => session.append('image/offload', 7 as never)) - .toThrow('names an invalid image offload watermark') - expect(offloadedNames(session)).toEqual([]) - }) - - it('requires the watermark path to identify a current surface image', () => { - const session = seeded() - expect(() => session.append('image/offload', { turn: 1, step: 1, watermark: { seq: SessionSeq(2), path: [0] } })) - .toThrow('watermark does not identify an image on the current surface') - expect(() => session.append('image/offload', { turn: 1, step: 1, watermark: { seq: SessionSeq(3), path: [9] } })) - .toThrow('watermark does not identify an image on the current surface') - expect(() => session.append('image/offload', { turn: 1, step: 1, watermark: { seq: SessionSeq(3), path: [0, 0] } })) - .toThrow('watermark does not identify an image on the current surface') - session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'replacement' }], source: { kind: 'user' }, - }), { - surfaceOp: { op: 'replace', start: SessionSeq(3), end: SessionSeq(3) }, - sourceEventSeqs: [SessionSeq(3)], - }) - expect(() => session.append('image/offload', { turn: 1, step: 1, watermark: { seq: SessionSeq(3), path: [0] } })) - .toThrow('watermark does not identify an image on the current surface') - }) - - it('rejects request-only offload markers from appended and restored messages', () => { - const session = seeded() - expect(() => session.append('assistant/message', { - turn: 1, - step: 1, - message: {}, - } as never, { surfaceOp: 'append' })).not.toThrow() - expect(() => session.append('user/message', createUserMessage({ - content: [{ ...image('marked'), offloaded: true }], source: { kind: 'user' }, - }), { surfaceOp: 'append' })) - .toThrow('stores the request-only image offload marker') - - const events = [...session.snapshotEvents()] - const event = events[3]! - const marked = { - ...event, - data: { ...event.data, content: [null, { ...image('restored-marked'), offloaded: true }] }, - } - events[3] = marked as never - expect(() => Session.create(SessionId('offload-marked-seed'), events)) - .toThrow('seed user/message at index 3 stores the request-only image offload marker') - }) - - it('accepts only strictly advancing positions', () => { - const session = seeded() - session.append('image/offload', { turn: 1, step: 1, watermark: { seq: SessionSeq(3), path: [1] } }) - expect(offloadedNames(session)).toEqual(['first', 'second']) - expect(() => session.append('image/offload', { turn: 1, step: 1, watermark: { seq: SessionSeq(3), path: [1] } })) - .toThrow('does not advance the image offload watermark') - expect(() => session.append('image/offload', { turn: 1, step: 1, watermark: { seq: SessionSeq(3), path: [0] } })) - .toThrow('does not advance the image offload watermark') - session.append('image/offload', { turn: 1, step: 1, watermark: { seq: SessionSeq(4), path: [0, 1] } }) - expect(offloadedNames(session)).toEqual(['first', 'second', 'third']) - }) - - it('validates a seed with the same rules and folds its watermark', () => { - const session = seeded() - session.append('image/offload', { turn: 1, step: 1, watermark: { seq: SessionSeq(3), path: [0] } }) - const replayed = Session.create(SessionId('offload-replay'), session.snapshotEvents()) - expect(offloadedNames(replayed)).toEqual(['first']) - expect(replayed.deriveMessages()).toEqual(session.deriveMessages()) - - const events = session.snapshotEvents().map(event => ( - event.type === 'image/offload' - ? { ...event, data: { ...event.data, watermark: { seq: 50, path: [0] } } } - : event - )) - expect(() => Session.create(SessionId('offload-bad-seed'), events as never)) - .toThrow('seed event at index 5 watermark does not identify an image on the current surface') - }) -}) - -describe('image/offload surface derivation', () => { - it('marks occurrences at or before the watermark, including nested tool results', () => { - const session = seeded() - expect(offloadedNames(session)).toEqual([]) - session.append('image/offload', { turn: 1, step: 1, watermark: { seq: SessionSeq(3), path: [0] } }) - expect(offloadedNames(session)).toEqual(['first']) - session.append('image/offload', { turn: 1, step: 1, watermark: { seq: SessionSeq(4), path: [0, 1] } }) - expect(offloadedNames(session)).toEqual(['first', 'second', 'third']) - session.append('image/offload', { turn: 1, step: 1, watermark: { seq: SessionSeq(4), path: [1] } }) - expect(offloadedNames(session)).toEqual(['first', 'second', 'third', 'fourth']) - }) - - it('marks images below two nested tool-result levels', () => { - const session = seeded() - const deep = session.append('user/message', createUserMessage({ - content: [{ - type: 'tool-result', - toolCallId: ToolCallId('outer'), - content: [{ - type: 'tool-result', - toolCallId: ToolCallId('middle'), - content: [{ type: 'tool-result', toolCallId: ToolCallId('inner'), content: [image('deep')] }], - }], - }], - source: { kind: 'user' }, - }), { surfaceOp: 'append' }) - session.append('image/offload', { - turn: 1, - step: 1, - watermark: { seq: deep.seq, path: [0, 0, 0, 0] }, - }) - expect(offloadedNames(session)).toContain('deep') - }) - - it('freezes marked copies, keeps the durable event content untouched, and matches a scratch replay', () => { - const session = seeded() - const before = session.deriveMessages() - session.append('image/offload', { turn: 1, step: 1, watermark: { seq: SessionSeq(3), path: [0] } }) - const after = session.deriveMessages() - expect(after[0]).toBe(before[0]) - expect(after[1]).not.toBe(before[1]) - expect(Object.isFrozen(after[1])).toBe(true) - expect(Object.isFrozen(after[1]!.content[0])).toBe(true) - expect(after[2]).toBe(before[2]) - expect(before[1]!.content[0]).toEqual(image('first')) - expect(session.eventAt(SessionSeq(3))!.data).toMatchObject({ content: [image('first'), image('second')] }) - const scratch = Session.create(SessionId('offload-scratch'), session.snapshotEvents()) - expect(scratch.deriveMessages()).toEqual(after) - }) - - it('leaves later appended nodes retained without rebuilding the cache', () => { - const session = seeded() - session.append('image/offload', { turn: 1, step: 1, watermark: { seq: SessionSeq(4), path: [1] } }) - const marked = session.deriveMessages() - session.append('user/message', createUserMessage({ - content: [image('fifth')], source: { kind: 'user' }, - }), { surfaceOp: 'append' }) - const grown = session.deriveMessages() - expect(grown[0]).toBe(marked[0]) - expect(grown.at(-1)!.content[0]).toEqual(image('fifth')) - expect(offloadedNames(session)).toEqual(['first', 'second', 'third', 'fourth']) - }) -}) - -describe('image offload helpers', () => { - it('preserves a nested block by identity when only an earlier sibling is offloaded', () => { - const session = Session.create(SessionId('offload-sibling')) - session.append('turn/start', { turn: 1 }) - const message = createUserMessage({ - content: [image('first'), toolResult('later')], - source: { kind: 'user' }, - }) - const seq = session.append('user/message', message, { surfaceOp: 'append' }).seq - const before = session.deriveMessages()[0]! - session.append('image/offload', { turn: 1, step: 1, watermark: { seq, path: [0] } }) - const projected = session.deriveMessages()[0]! - expect(projected.content[0]).toEqual({ ...image('first'), offloaded: true }) - expect(projected.content[1]).toBe(before.content[1]) - }) - - it('orders positions by seq then path and folds the latest watermark', () => { - expect(compareImagePositions({ seq: SessionSeq(1), path: [5] }, { seq: SessionSeq(3), path: [0] })).toBeLessThan(0) - expect(compareImagePositions({ seq: SessionSeq(4), path: [1] }, { seq: SessionSeq(3), path: [0, 3] })).toBeGreaterThan(0) - const session = seeded() - session.append('image/offload', { turn: 1, step: 1, watermark: { seq: SessionSeq(3), path: [0] } }) - expect(offloadedNames(session)).toEqual(['first']) - session.append('image/offload', { turn: 1, step: 1, watermark: { seq: SessionSeq(4), path: [1] } }) - expect(offloadedNames(session)).toEqual(['first', 'second', 'third', 'fourth']) - }) -}) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 70e3415c99..211686b403 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -4311,10 +4311,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ImageMediaType', declaration: 'export type ImageMediaType = \'image/png\' | \'image/jpeg\' | \'image/webp\' | \'image/gif\';', }, - { - name: 'ImageOccurrencePosition', - declaration: 'export interface ImageOccurrencePosition {\n seq: SessionSeq;\n path: number[];\n}', - }, { name: 'ImageRequestPolicy', declaration: 'export interface ImageRequestPolicy {\n maxPixels: number;\n maxBytes: number;\n}', @@ -5061,7 +5057,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionEventMap', - declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': UserMessage;\n \'assistant/message\': {\n turn: number;\n step: number;\n message: AssistantMessage;\n stream: AssistantStreamRecord[];\n usage?: TokenUsage;\n interrupted?: true;\n };\n \'assistant/attempt\': {\n turn: number;\n step: number;\n stream: AssistantStreamRecord[];\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: ToolCallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n \'request/header\': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n startsSeries?: true;\n };\n \'request/context\': RequestContext;\n \'image/offload\': {\n turn: number;\n step: number;\n watermark: ImageOccurrencePosition;\n };\n \'session/end-seed\': {\n inherited?: true;\n };\n}', + declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': UserMessage;\n \'assistant/message\': {\n turn: number;\n step: number;\n message: AssistantMessage;\n stream: AssistantStreamRecord[];\n usage?: TokenUsage;\n interrupted?: true;\n };\n \'assistant/attempt\': {\n turn: number;\n step: number;\n stream: AssistantStreamRecord[];\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: ToolCallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n \'request/header\': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n startsSeries?: true;\n };\n \'request/context\': RequestContext;\n \'session/end-seed\': {\n inherited?: true;\n };\n}', }, { name: 'SessionEventMetadataFilter', diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index df870c0d50..7a002de1b1 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-deepseek/README.md -README.md: ad9b89a35fb6bd53579d573aa8d88ee12dc80a77 -README.zh.md: dce5b3f7332173f4ef9a82d1dd6845b0597d009d +README.md: 476dfa93129caa867aacd9d7120de58a7ac6700c +README.zh.md: c6fc5c0ced2da5afd78bfc101f68e7b3c70c107a diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index ad9b89a35f..476dfa9312 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -156,7 +156,7 @@ The selected DeepSeek model receives the harness system prompt, message history, #### Token effect -Provider tokenization governs exact text and image-token input. The adapter declares per-route `imageRequestPricing`: it prices each occurrence the session's `image/offload` watermark marks offloaded as its placeholder text and each retained image at its projected dimensions with the published v4 vision accounting (14px patch grid, 3:1 downsampling, 384-token cap, worst-case alignment pad). This lets the token meter price image pressure before a request; reported usage remains authoritative. Reasoning passback carries every reasoned turn's chain of thought into later requests, while offloaded images stop costing visual tokens. A request whose retained occurrences exceed the file-mode or inline-fallback budget (`maxRequestFilesBytes`, `maxImagesPerRequest`, both quanta) at their exact request-version bytes fails with `IMAGE_OFFLOAD_REQUIRED` naming the additional oldest occurrences to offload, and `dsh-llm-retry` advances the watermark and retries. Cache-read usage is reported when available. `totalTokens` is the exact `prompt_tokens + completion_tokens` aggregate and is omitted if a supplied `total_tokens` disagrees. +Provider tokenization governs exact text and image-token input. The adapter declares per-route `imageRequestPricing`: it prices each occurrence a surface replacement marks offloaded as its placeholder text and each retained image at its projected dimensions with the published v4 vision accounting (14px patch grid, 3:1 downsampling, 384-token cap, worst-case alignment pad). This lets the token meter price image pressure before a request; reported usage remains authoritative. Reasoning passback carries every reasoned turn's chain of thought into later requests, while offloaded images stop costing visual tokens. A request whose retained occurrences exceed the file-mode or inline-fallback budget (`maxRequestFilesBytes`, `maxImagesPerRequest`, both quanta) at their exact request-version bytes fails with `IMAGE_OFFLOAD_REQUIRED` naming the additional oldest occurrences to offload, and `dsh-llm-retry` replaces the carrying surface nodes with marked copies and retries. Cache-read usage is reported when available. `totalTokens` is the exact `prompt_tokens + completion_tokens` aggregate and is omitted if a supplied `total_tokens` disagrees. #### KV Cache effect diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index dce5b3f733..c6fc5c0ced 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -156,7 +156,7 @@ Files 模式通过 `maxRequestFilesBytes` 与 `maxImagesPerRequest` 限制保留 #### Token 影响 -提供方分词决定精确的文本与图片 token 输入。适配器声明按路由的 `imageRequestPricing`:把会话 `image/offload` 水位标记为已省略的每个出现位置按其占位文本计价,并按投影后的尺寸使用官方公布的 v4 视觉计量(14px patch 网格、3:1 降采样、单图 384 token 上限、最坏对齐 pad)为每张保留图片计价。这使 token 计量服务可以在请求发出前为图片压力定价;上报的 usage 仍是权威值。推理回传会把每个推理轮次的思维链带进后续请求,而已省略的图片不再消耗视觉 token。保留的出现位置按精确请求版本字节超过 file 模式或内联回退预算(`maxRequestFilesBytes`、`maxImagesPerRequest` 与两个量子)的请求,以 `IMAGE_OFFLOAD_REQUIRED` 失败并说明还需省略多少最老的出现位置,由 `dsh-llm-retry` 推进水位并重试。可用时报告缓存读取用量。`totalTokens` 是精确的 `prompt_tokens + completion_tokens` 汇总值;提供方给出的 `total_tokens` 不一致时省略该值。 +提供方分词决定精确的文本与图片 token 输入。适配器声明按路由的 `imageRequestPricing`:把表层替换标记为已省略的每个出现位置按其占位文本计价,并按投影后的尺寸使用官方公布的 v4 视觉计量(14px patch 网格、3:1 降采样、单图 384 token 上限、最坏对齐 pad)为每张保留图片计价。这使 token 计量服务可以在请求发出前为图片压力定价;上报的 usage 仍是权威值。推理回传会把每个推理轮次的思维链带进后续请求,而已省略的图片不再消耗视觉 token。保留的出现位置按精确请求版本字节超过 file 模式或内联回退预算(`maxRequestFilesBytes`、`maxImagesPerRequest` 与两个量子)的请求,以 `IMAGE_OFFLOAD_REQUIRED` 失败并说明还需省略多少最老的出现位置,由 `dsh-llm-retry` 用带标记的副本替换承载节点并重试。可用时报告缓存读取用量。`totalTokens` 是精确的 `prompt_tokens + completion_tokens` 汇总值;提供方给出的 `total_tokens` 不一致时省略该值。 #### KV Cache 影响 diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index e926a274ee..75b1476aeb 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-pi-ai/README.md -README.md: 56d68aa62fbb8497d4f6e34ded79d1ad27f41985 -README.zh.md: ff6734e57a4e2e725b4d45fee5b24aa01221cb21 +README.md: 326028b8c2f64dec2a49241752e2285308031de8 +README.zh.md: 502390b50de611f434e561f0567e5fc319fe7550 diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 56d68aa62f..326028b8c2 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -176,7 +176,7 @@ Read these pages when the package-level contract is not enough. They move from t #### What the model sees -The selected catalog model receives `GenerateOptions.system`, history, tools, and sampling fields supported by pi-ai's common streaming API. Each retained image is preceded by text naming its complete attachment id and actual request dimensions. When the current execution filesystem maps the attachment provider's host object, the text also carries a read-only normalized-object path and warns that normalization or request projection may have resized or re-encoded the upload. Each occurrence the session's `image/offload` watermark marks offloaded keeps its own identity and currently resolved access in replacement text, and its normalized attachment is not read or transformed. When the retained occurrences' exact base64 payload still exceeds the route's `maxRequestImageBytes`, the call fails with `IMAGE_OFFLOAD_REQUIRED` so `dsh-llm-retry` advances the watermark and retries the step. Provider-native replay metadata is restored only when the adapter validates it for the historical content. +The selected catalog model receives `GenerateOptions.system`, history, tools, and sampling fields supported by pi-ai's common streaming API. Each retained image is preceded by text naming its complete attachment id and actual request dimensions. When the current execution filesystem maps the attachment provider's host object, the text also carries a read-only normalized-object path and warns that normalization or request projection may have resized or re-encoded the upload. Each occurrence a surface replacement marks offloaded keeps its own identity and currently resolved access in replacement text, and its normalized attachment is not read or transformed. When the retained occurrences' exact base64 payload still exceeds the route's `maxRequestImageBytes`, the call fails with `IMAGE_OFFLOAD_REQUIRED` so `dsh-llm-retry` replaces the carrying surface nodes with marked copies and retries the step. Provider-native replay metadata is restored only when the adapter validates it for the historical content. #### Token effect @@ -184,7 +184,7 @@ Provider tokenization governs exact input. Retained images add the stable attach #### KV Cache effect -Conversion preserves logical request order, while image handles and offload placeholders add model-visible text. A changed execution-world path rewrites a historical handle and can prevent reuse from that image even when attachment identity and request bytes stay stable. Changing adapter instance, provider, model, or another upstream token has the same suffix effect. An `image/offload` advance replaces an earlier image with placeholder text, so reuse ends at that message; the watermark never retreats, so the prefix stays stable afterwards. +Conversion preserves logical request order, while image handles and offload placeholders add model-visible text. A changed execution-world path rewrites a historical handle and can prevent reuse from that image even when attachment identity and request bytes stay stable. Changing adapter instance, provider, model, or another upstream token has the same suffix effect. An offload replacement turns an earlier image into placeholder text, so reuse ends at that message; the replacement never reverts, so the prefix stays stable afterwards. ### Provider response diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index ff6734e57a..502390b50d 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -176,7 +176,7 @@ pi-ai 不提供的路由需要 `api`、`baseURL` 与非空 `models` 列表;无 #### 模型看到什么 -所选目录模型会收到 `GenerateOptions.system`、历史、工具与 pi-ai 通用流式 API 支持的采样字段。每张保留图片前都会有文本,注明其完整附件 id 与实际请求尺寸。当前执行文件系统可以映射附件提供方的宿主对象时,该文本还会携带只读规范化对象路径,并警告规范化或请求投影可能缩放或重新编码上传内容。会话 `image/offload` 水位标记为已卸载的每个出现位置都会在替换文本中保留自己的身份与当前已解析访问方式,其规范化附件不会读取或变换。当保留的出现位置按精确 base64 载荷仍超过路由的 `maxRequestImageBytes` 时,调用以 `IMAGE_OFFLOAD_REQUIRED` 失败,由 `dsh-llm-retry` 推进水位并重试 step。提供方原生回放元数据只在适配器针对历史内容校验通过后恢复。 +所选目录模型会收到 `GenerateOptions.system`、历史、工具与 pi-ai 通用流式 API 支持的采样字段。每张保留图片前都会有文本,注明其完整附件 id 与实际请求尺寸。当前执行文件系统可以映射附件提供方的宿主对象时,该文本还会携带只读规范化对象路径,并警告规范化或请求投影可能缩放或重新编码上传内容。表层替换标记为已卸载的每个出现位置都会在替换文本中保留自己的身份与当前已解析访问方式,其规范化附件不会读取或变换。当保留的出现位置按精确 base64 载荷仍超过路由的 `maxRequestImageBytes` 时,调用以 `IMAGE_OFFLOAD_REQUIRED` 失败,由 `dsh-llm-retry` 用带标记的副本替换承载节点并重试 step。提供方原生回放元数据只在适配器针对历史内容校验通过后恢复。 #### Token 影响 @@ -184,7 +184,7 @@ pi-ai 不提供的路由需要 `api`、`baseURL` 与非空 `models` 列表;无 #### KV Cache 影响 -转换保持逻辑请求顺序,图片句柄与卸载占位符则会添加模型可见文本。即使附件身份与请求字节保持稳定,执行世界路径变化也会改写历史句柄,并可能从该图片起阻止复用。更换适配器实例、提供方、模型或其他上游 token 具有相同的后缀影响。一次 `image/offload` 推进会把较早图片替换为占位文本,因此复用在该消息处结束;水位永不回退,此后前缀保持稳定。 +转换保持逻辑请求顺序,图片句柄与卸载占位符则会添加模型可见文本。即使附件身份与请求字节保持稳定,执行世界路径变化也会改写历史句柄,并可能从该图片起阻止复用。更换适配器实例、提供方、模型或其他上游 token 具有相同的后缀影响。一次省略替换会把较早图片换成占位文本,因此复用在该消息处结束;替换永不回退,此后前缀保持稳定。 ### 提供方响应 diff --git a/packages/llm/llm-retry/README.i18n.yaml b/packages/llm/llm-retry/README.i18n.yaml index aa54d6047a..9084498243 100644 --- a/packages/llm/llm-retry/README.i18n.yaml +++ b/packages/llm/llm-retry/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-retry/README.md -README.md: e5c97f602b4db1450ae1e2566e34b40fc6c385fd -README.zh.md: 2c3c310c9cc86e439f57de8c2f3068cc26176312 +README.md: 52740300adb9e791fb6bcbcd1f4845e684f10667 +README.zh.md: 0855be01fa06e9f2b8194480dcfbb8faf2e54451 diff --git a/packages/llm/llm-retry/README.md b/packages/llm/llm-retry/README.md index e5c97f602b..52740300ad 100644 --- a/packages/llm/llm-retry/README.md +++ b/packages/llm/llm-retry/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`@deepseek-ai/dsh-llm-retry` is the retry executor for failed model requests: it applies each provider's resolved retry policy at the agent loop's open-step `agent/request-error` extension point, so every retry re-runs the same step inside the same open turn over the same durable history. It does not wrap the streaming call itself — every adapter call remains one provider attempt, and direct `ctx.llm.stream()` consumers stay single-attempt. Retry scheduling is durable: the plugin appends `llm/retry` events to the session log before waiting, and cancellation during backoff leaves the log consistent. Normal mode retries a bounded set of failure codes up to `maxRetries` with exponential backoff; always mode asks downstream recovery first, then retries every failure without an attempt limit. It also owns one durable repair: an adapter's `IMAGE_OFFLOAD_REQUIRED` failure advances the session's `image/offload` watermark by the count the failure names and retries the step, outside any provider retry budget. +`@deepseek-ai/dsh-llm-retry` is the retry executor for failed model requests: it applies each provider's resolved retry policy at the agent loop's open-step `agent/request-error` extension point, so every retry re-runs the same step inside the same open turn over the same durable history. It does not wrap the streaming call itself — every adapter call remains one provider attempt, and direct `ctx.llm.stream()` consumers stay single-attempt. Retry scheduling is durable: the plugin appends `llm/retry` events to the session log before waiting, and cancellation during backoff leaves the log consistent. Normal mode retries a bounded set of failure codes up to `maxRetries` with exponential backoff; always mode asks downstream recovery first, then retries every failure without an attempt limit. It also owns one durable repair: an adapter's `IMAGE_OFFLOAD_REQUIRED` failure replaces the surface nodes carrying the count of oldest images the failure names with copies marked `offloaded`, then retries the step outside any provider retry budget. ## Table of Contents @@ -55,7 +55,7 @@ Each scheduled retry is durable before its wait: the plugin appends a non-surfac ### Failures and recovery -A failure before any final adapter is selected has no provider policy and delegates downstream unchanged. In normal mode, a failure code outside the eligible set, or an exhausted budget, delegates; in always mode, an over-cap provider delay uses the configured local backoff so the policy cannot terminate on that instruction. Nothing here is model-visible: no retry event, delay, provider error, or failed partial output reaches the model or derived messages. An `IMAGE_OFFLOAD_REQUIRED` failure carrying `offloadImages` is handled before any policy: the plugin appends one `image/offload` event naming the greatest durable position among that many oldest retained image occurrences in request order and returns `retry`; when nothing remains to offload it delegates. That retry spends no retry budget and logs no `llm/retry` event, and the retried request sends placeholder text for the offloaded images ([decision](../../../.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.md)). +A failure before any final adapter is selected has no provider policy and delegates downstream unchanged. In normal mode, a failure code outside the eligible set, or an exhausted budget, delegates; in always mode, an over-cap provider delay uses the configured local backoff so the policy cannot terminate on that instruction. Nothing here is model-visible: no retry event, delay, provider error, or failed partial output reaches the model or derived messages. An `IMAGE_OFFLOAD_REQUIRED` failure carrying `offloadImages` is handled before any policy: the plugin walks the surface in request order, replaces each node carrying one of that many oldest retained image occurrences with a copy whose occurrences are marked `offloaded` (a `surfaceOp: replace` like compaction's), and returns `retry`; when nothing remains to offload it delegates. That retry spends no retry budget and logs no `llm/retry` event, and the retried request sends placeholder text for the marked images ([decision](../../../.agents/notes/implemented/architecture/2026-09-02-durable-image-offload.md)). ----- @@ -77,13 +77,13 @@ The executor is built on one rule: **durable before wait, open-step boundaries.* |---|---| | [`src/index.ts`](src/index.ts) | The function plugin: waterfall listener, policy lookup, backoff, durable event appends | | [`src/history.ts`](src/history.ts) | Durable retry-history lookup from the session log | -| [`src/image-offload.ts`](src/image-offload.ts) | The `image/offload` advance for `IMAGE_OFFLOAD_REQUIRED` failures | +| [`src/image-offload.ts`](src/image-offload.ts) | The surface replacements that offload images for `IMAGE_OFFLOAD_REQUIRED` failures | | [`src/types.ts`](src/types.ts) | Browser-safe `llm/retry` and `llm/retry-started` event payload types | | [`src/brand.ts`](src/brand.ts) | The `RetryId` brand shared by the event payloads | ### Recovery flow -A failed step arrives on the waterfall with its provider and resolved policy. An `IMAGE_OFFLOAD_REQUIRED` failure naming `offloadImages` is repaired at once: the watermark advances and the step retries with no delay. Always mode settles downstream recovery first and honors a downstream `retry` decision; normal mode first checks that the failure code is eligible and the budget is not exhausted. The plugin computes the delay — provider `Retry-After` when valid and within bounds, otherwise local bounded exponential backoff with symmetric jitter — appends the `llm/retry` event, waits on a cancellable timer, appends `llm/retry-started`, and returns `{ kind: 'retry' }`. The loop then re-runs the failed step inside the same open turn over the same durable history. +A failed step arrives on the waterfall with its provider and resolved policy. An `IMAGE_OFFLOAD_REQUIRED` failure naming `offloadImages` is repaired at once: the carrying surface nodes are replaced and the step retries with no delay. Always mode settles downstream recovery first and honors a downstream `retry` decision; normal mode first checks that the failure code is eligible and the budget is not exhausted. The plugin computes the delay — provider `Retry-After` when valid and within bounds, otherwise local bounded exponential backoff with symmetric jitter — appends the `llm/retry` event, waits on a cancellable timer, appends `llm/retry-started`, and returns `{ kind: 'retry' }`. The loop then re-runs the failed step inside the same open turn over the same durable history. ### Waterfall composition diff --git a/packages/llm/llm-retry/README.zh.md b/packages/llm/llm-retry/README.zh.md index 2c3c310c9c..0855be01fa 100644 --- a/packages/llm/llm-retry/README.zh.md +++ b/packages/llm/llm-retry/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`@deepseek-ai/dsh-llm-retry` 是失败模型请求的重试执行器:它在 agent loop 的打开步骤 `agent/request-error` 扩展点上应用各提供方解析后的重试策略,因此每次重试都会在同一个打开的轮次内重跑同一个步骤(基于同一份持久历史)。它不包装流式调用本身——每次适配器调用仍是一次提供方尝试,直接 `ctx.llm.stream()` 消费方仍是单次尝试。重试调度是持久的:插件在等待之前就把 `llm/retry` 事件追加进会话日志,退避期间取消会让日志保持一致。normal mode 以指数退避重试一组有界的失败 code,最多 `maxRetries` 次;always mode 先询问下游恢复,然后无尝试上限地重试每个失败。它还负责一种持久修复:adapter 以 `IMAGE_OFFLOAD_REQUIRED` 失败时,按失败给出的数量推进会话的 `image/offload` 水位并重试该步骤,不占任何提供方重试预算。 +`@deepseek-ai/dsh-llm-retry` 是失败模型请求的重试执行器:它在 agent loop 的打开步骤 `agent/request-error` 扩展点上应用各提供方解析后的重试策略,因此每次重试都会在同一个打开的轮次内重跑同一个步骤(基于同一份持久历史)。它不包装流式调用本身——每次适配器调用仍是一次提供方尝试,直接 `ctx.llm.stream()` 消费方仍是单次尝试。重试调度是持久的:插件在等待之前就把 `llm/retry` 事件追加进会话日志,退避期间取消会让日志保持一致。normal mode 以指数退避重试一组有界的失败 code,最多 `maxRetries` 次;always mode 先询问下游恢复,然后无尝试上限地重试每个失败。它还负责一种持久修复:adapter 以 `IMAGE_OFFLOAD_REQUIRED` 失败时,把承载失败所报数量的最老图片的表层节点替换为标了 `offloaded` 的副本,再重试该步骤,不占任何提供方重试预算。 ## 目录 @@ -55,7 +55,7 @@ kind: "package-reference" ### 失败与恢复 -在任何最终适配器被选中之前发生的失败没有提供方策略,原样委派下游。normal mode 中,不在合格集合内的失败 code 或已耗尽的预算会委派;always mode 中,超上限的提供方延迟使用配置的本地退避,因此策略不会因该指令终止。这里没有任何模型可见内容:重试事件、延迟、提供方错误或失败的部分输出都不会到达模型或派生消息。带 `offloadImages` 的 `IMAGE_OFFLOAD_REQUIRED` 失败在任何策略之前处理:插件追加一个 `image/offload` 事件,指向按请求顺序最老的那些保留图片中最大的持久位置,然后返回 `retry`;没有可省略的图片时委派下游。这次重试不占重试预算,也不追加 `llm/retry` 事件,重试的请求为被省略的图片发送占位文本([决定](../../../.agents/notes/implemented/architecture/2026-09-02-image-offload-watermark.zh.md))。 +在任何最终适配器被选中之前发生的失败没有提供方策略,原样委派下游。normal mode 中,不在合格集合内的失败 code 或已耗尽的预算会委派;always mode 中,超上限的提供方延迟使用配置的本地退避,因此策略不会因该指令终止。这里没有任何模型可见内容:重试事件、延迟、提供方错误或失败的部分输出都不会到达模型或派生消息。带 `offloadImages` 的 `IMAGE_OFFLOAD_REQUIRED` 失败在任何策略之前处理:插件按请求顺序遍历表层,把承载那些最老保留图片的每个节点替换为标了 `offloaded` 的副本(和 compaction 一样的 `surfaceOp: replace`),然后返回 `retry`;没有可省略的图片时委派下游。这次重试不占重试预算,也不追加 `llm/retry` 事件,重试的请求为被标记的图片发送占位文本([决定](../../../.agents/notes/implemented/architecture/2026-09-02-durable-image-offload.zh.md))。 ----- @@ -77,13 +77,13 @@ kind: "package-reference" |---|---| | [`src/index.ts`](src/index.ts) | 函数插件:waterfall 监听器、策略查找、退避、持久事件追加 | | [`src/history.ts`](src/history.ts) | 从会话日志查找持久重试历史 | -| [`src/image-offload.ts`](src/image-offload.ts) | 针对 `IMAGE_OFFLOAD_REQUIRED` 失败的 `image/offload` 推进 | +| [`src/image-offload.ts`](src/image-offload.ts) | 为 `IMAGE_OFFLOAD_REQUIRED` 失败省略图片的表层替换 | | [`src/types.ts`](src/types.ts) | 浏览器安全的 `llm/retry` 与 `llm/retry-started` 事件载荷类型 | | [`src/brand.ts`](src/brand.ts) | 事件载荷共享的 `RetryId` 品牌 | ### 恢复流程 -失败步骤连同其提供方与解析后的策略一起到达 waterfall。带 `offloadImages` 的 `IMAGE_OFFLOAD_REQUIRED` 失败会立即修复:推进水位,步骤无延迟重试。always mode 先结算下游恢复,并遵循下游的 `retry` 决定;normal mode 先检查失败 code 是否合格、预算是否未耗尽。插件计算延迟——有效且在边界内的提供方 `Retry-After`,否则带对称抖动的本地有界指数退避——追加 `llm/retry` 事件,在可取消定时器上等待,追加 `llm/retry-started`,然后返回 `{ kind: 'retry' }`。loop 随后在同一个打开的轮次内重跑失败步骤(仍基于同一份持久历史)。 +失败步骤连同其提供方与解析后的策略一起到达 waterfall。带 `offloadImages` 的 `IMAGE_OFFLOAD_REQUIRED` 失败会立即修复:替换承载节点,步骤无延迟重试。always mode 先结算下游恢复,并遵循下游的 `retry` 决定;normal mode 先检查失败 code 是否合格、预算是否未耗尽。插件计算延迟——有效且在边界内的提供方 `Retry-After`,否则带对称抖动的本地有界指数退避——追加 `llm/retry` 事件,在可取消定时器上等待,追加 `llm/retry-started`,然后返回 `{ kind: 'retry' }`。loop 随后在同一个打开的轮次内重跑失败步骤(仍基于同一份持久历史)。 ### Waterfall 组合 diff --git a/packages/llm/llm-retry/src/image-offload.ts b/packages/llm/llm-retry/src/image-offload.ts index ffa59dc6b9..2888bfc03c 100644 --- a/packages/llm/llm-retry/src/image-offload.ts +++ b/packages/llm/llm-retry/src/image-offload.ts @@ -1,44 +1,81 @@ /** - * Durable image offload recovery: advance the session's `image/offload` - * watermark past the oldest retained image occurrences an adapter's - * `IMAGE_OFFLOAD_REQUIRED` failure names, so the retried step derives the - * offloaded set the log records. + * Image offload recovery: replace the surface nodes carrying the oldest + * retained image occurrences with copies that mark them `offloaded`, so the + * retried request sends placeholder text for exactly the occurrences an + * adapter's `IMAGE_OFFLOAD_REQUIRED` failure named. The replacement is a + * durable surface fact: it never reverts, and every later request and + * measurement reads it from the log. * * @module @deepseek-ai/dsh-llm-retry/image-offload */ -import { visitImageBlocks } from '@deepseek-ai/dsh-llm' -import { compareImagePositions } from '@deepseek-ai/dsh-session' -import type { ImageOccurrencePosition, Session } from '@deepseek-ai/dsh-session' +import type { ContentBlock, ToolResultMessage } from '@deepseek-ai/dsh-llm' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' + +/** Mark the first `remaining.count` retained occurrences, keeping unchanged blocks by identity. */ +function markOldestImages(blocks: readonly ContentBlock[], remaining: { count: number }): readonly ContentBlock[] { + let next: ContentBlock[] | undefined + for (const [index, block] of blocks.entries()) { + if (remaining.count > 0 && block.type === 'image' && block.offloaded !== true) { + remaining.count -= 1 + next ??= blocks.slice(0, index) + next.push({ ...block, offloaded: true }) + continue + } + if (remaining.count > 0 && block.type === 'tool-result') { + const content = markOldestImages(block.content, remaining) + if (content !== block.content) { + next ??= blocks.slice(0, index) + next.push({ ...block, content: content as ContentBlock[] }) + continue + } + } + next?.push(block) + } + return next ?? blocks +} + +/** The surface node types that carry model input images. */ +type ImageNode = SessionEvent<'user/message'> | SessionEvent<'tool/result'> + +/** Append the marked copy of one surface node in place of the original. */ +function replaceNode(session: Session, event: ImageNode, content: readonly ContentBlock[]): void { + const options = { + surfaceOp: { op: 'replace' as const, start: event.seq, end: event.seq }, + sourceEventSeqs: [event.seq], + } + if (event.type === 'user/message') { + session.append('user/message', { ...event.data, content: content as ContentBlock[] }, options) + } else { + session.append('tool/result', { + ...event.data, + message: { ...event.data.message, content: content as ToolResultMessage['content'] }, + }, options) + } +} /** - * Append one `image/offload` advance covering the `count` oldest image - * occurrences the surface still sends, in model request order. A surface - * replacement can place a newer event before older ones, so the advance - * names the greatest durable position in that prefix; it may offload - * additional occurrences but always covers the prefix. + * Offload the `count` oldest retained image occurrences, in model request + * order, by replacing each surface node that carries one with a copy whose + * occurrences are marked `offloaded`. Assistant nodes carry model output, not + * input images, and are skipped. * @param session - the session whose derived surface the retried request reads. - * @param turn - the open turn. - * @param step - the step being retried. * @param count - how many more oldest retained occurrences the adapter needs offloaded. * @returns whether any occurrence remained to offload. */ -export function advanceImageOffload(session: Session, turn: number, step: number, count: number): boolean { - let remaining = count - let watermark: ImageOccurrencePosition | undefined - for (const seq of session.surface.nodes) { - if (remaining === 0) break +export function offloadOldestImages(session: Session, count: number): boolean { + const remaining = { count } + let replaced = false + for (const seq of [...session.surface.nodes]) { + if (remaining.count === 0) break // oxlint-disable-next-line typescript/no-non-null-assertion -- surface nodes index the durable log - const message = session.deriveEventMessage(session.eventAt(seq)!) - if (message === null) continue - visitImageBlocks(message.content, (block, path) => { - if (remaining === 0 || block.offloaded === true) return - remaining -= 1 - const position = { seq, path: [...path] } - if (watermark === undefined || compareImagePositions(position, watermark) > 0) watermark = position - }) + const event = session.eventAt(seq)! + if (event.type !== 'user/message' && event.type !== 'tool/result') continue + const original = event.type === 'user/message' ? event.data.content : event.data.message.content + const content = markOldestImages(original, remaining) + if (content === original) continue + replaceNode(session, event, content) + replaced = true } - if (watermark === undefined) return false - session.append('image/offload', { turn, step, watermark }) - return true + return replaced } diff --git a/packages/llm/llm-retry/src/index.ts b/packages/llm/llm-retry/src/index.ts index 9bc7b2acb0..0971ff6263 100644 --- a/packages/llm/llm-retry/src/index.ts +++ b/packages/llm/llm-retry/src/index.ts @@ -14,7 +14,7 @@ import { IMAGE_OFFLOAD_REQUIRED_CODE } from '@deepseek-ai/dsh-llm' import type { LlmFailure, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-session-projection' import { RetryId } from './brand.ts' -import { advanceImageOffload } from './image-offload.ts' +import { offloadOldestImages } from './image-offload.ts' import type { LlmRetryEventData } from './types.ts' export type { LlmRetryEventData, LlmRetryStartedEventData } from './types.ts' @@ -199,7 +199,7 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna ): Promise { // A durable surface repair, not a provider retry: it spends no retry budget and logs no retry event. if (failure.code === IMAGE_OFFLOAD_REQUIRED_CODE && failure.offloadImages !== undefined - && advanceImageOffload(agent.session, turn, step, failure.offloadImages)) { + && offloadOldestImages(agent.session, failure.offloadImages)) { return { kind: 'retry' } } if (policy === undefined) return next() diff --git a/packages/llm/llm-retry/tests/image-offload.spec.ts b/packages/llm/llm-retry/tests/image-offload.spec.ts index 41f08d2b96..8a5be7f21e 100644 --- a/packages/llm/llm-retry/tests/image-offload.spec.ts +++ b/packages/llm/llm-retry/tests/image-offload.spec.ts @@ -1,16 +1,16 @@ /** * Image offload recovery: an adapter's `IMAGE_OFFLOAD_REQUIRED` failure - * advances the durable watermark by the named count and retries the step - * without spending the provider retry budget. + * replaces the surface nodes carrying the named count of oldest images with + * marked copies and retries the step without spending the provider retry budget. */ import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' -import { createAssistantMessage, createUserMessage, IMAGE_OFFLOAD_REQUIRED_CODE, LlmAdapter, LlmError, ToolCallId } from '@deepseek-ai/dsh-llm' +import { createAssistantMessage, createToolResultMessage, createUserMessage, IMAGE_OFFLOAD_REQUIRED_CODE, LlmAdapter, LlmError, ToolCallId } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' -import { SessionId } from '@deepseek-ai/dsh-session' +import { isReplacementSurfaceEvent, SessionId, SessionSeq } from '@deepseek-ai/dsh-session' import type { Session } from '@deepseek-ai/dsh-session' import * as retry from '../src/index.ts' @@ -75,15 +75,11 @@ function offloadedNames(options: GenerateOptions): string[] { return names } -function offloadEvents(session: Session): { turn: number; step: number; seq: number; path: number[] }[] { +/** Surface replacements appended by the recovery, as `[original seq, replacement seq]` pairs. */ +function replacements(session: Session): [number, number][] { return session.snapshotEvents() - .filter(event => event.type === 'image/offload') - .map(event => ({ - turn: event.data.turn, - step: event.data.step, - seq: Number(event.data.watermark.seq), - path: event.data.watermark.path, - })) + .filter(isReplacementSurfaceEvent) + .map(event => [Number(event.sourceEventSeqs?.[0]), Number(event.seq)]) } describe('image offload recovery', () => { @@ -106,13 +102,20 @@ describe('image offload recovery', () => { expect(adapter.requests).toHaveLength(2) expect(offloadedNames(adapter.requests[0]!)).toEqual([]) expect(offloadedNames(adapter.requests[1]!)).toEqual(['a', 'b']) - expect(offloadEvents(agent.session)).toMatchObject([{ turn: 1, step: 1, path: [1, 0] }]) expect(delegated).toEqual([]) - const types = agent.session.snapshotEvents().map(event => event.type) + const events = agent.session.snapshotEvents() + const types = events.map(event => event.type) expect(types.filter(type => type === 'llm/retry')).toHaveLength(0) expect(types.filter(type => type === 'assistant/attempt')).toHaveLength(1) - expect(types.indexOf('assistant/attempt')).toBeLessThan(types.indexOf('image/offload')) - expect(types.indexOf('image/offload')).toBeLessThan(types.indexOf('assistant/message')) + // One node carried both occurrences, so one replacement lands between the failed attempt and the retry. + expect(replacements(agent.session)).toHaveLength(1) + const [original, replacement] = replacements(agent.session)[0]! + expect(events[original]).toMatchObject({ type: 'user/message', surfaceOp: 'append' }) + expect(types.indexOf('assistant/attempt')).toBeLessThan(replacement) + expect(replacement).toBeLessThan(types.indexOf('assistant/message')) + // The original event keeps its content; only the replacement carries the marks. + const durable = events[original]! + expect(durable.type === 'user/message' ? durable.data.content[0] : undefined).not.toHaveProperty('offloaded') }) it('counts the adapter prefix in request order after a surface replacement', async () => { @@ -144,8 +147,49 @@ describe('image offload recovery', () => { expect(adapter.requests).toHaveLength(2) expect(offloadedNames(adapter.requests[1]!)).toEqual(['replacement', 'second']) - // The replacement node carries the newest seq, so it is the greatest position in the prefix. - expect(offloadEvents(agent.session).at(-1)).toMatchObject({ seq: 3, path: [0] }) + // Both nodes carried one occurrence, so the recovery replaced the replacement node and 'second'. + expect(replacements(agent.session).slice(1).map(([original]) => original)).toEqual([3, 2]) + }) + + it('replaces a tool result node and leaves blocks after the count untouched', async () => { + const adapter = new ScriptedAdapter([offloadRequired(1), textResponse('sent')]) + const ctx = await harness(adapter) + const agent = await ctx.agentLoop.create(SessionId('offload-tool-result'), { provider: 'mock', model: 'mock' }) + const callId = ToolCallId('shot') + agent.session.append('turn/start', { turn: 0 }) + agent.session.append('assistant/message', { + turn: 0, + step: 1, + message: createAssistantMessage({ + content: [{ type: 'tool-call', id: callId, name: 'read_image', arguments: '{}' }], + source: { provider: 'mock', model: 'mock' }, + }), + stream: [], + }, { surfaceOp: 'append' }) + agent.session.append('tool/call', { turn: 0, step: 1, callId, name: 'read_image', arguments: '{}' }) + const result = agent.session.append('tool/result', { + turn: 0, + step: 1, + message: createToolResultMessage({ + callId, + content: [ + { type: 'tool-result', toolCallId: ToolCallId('empty'), content: [{ type: 'text', text: 'no image' }] }, + image('first'), + { type: 'tool-result', toolCallId: ToolCallId('inner'), content: [image('second')] }, + ], + isError: false, + }), + }, { surfaceOp: 'append' }) + agent.session.append('turn/end', { turn: 0, reason: { kind: 'completed' } }) + + agent.followup(createUserMessage({ content: [{ type: 'text', text: 'send' }], source: { kind: 'user' } })) + await agent.whenIdle() + + expect(offloadedNames(adapter.requests[1]!)).toEqual(['first']) + const [replacement] = replacements(agent.session) + expect(replacement).toEqual([Number(result.seq), expect.any(Number) as never]) + const replaced = agent.session.eventAt(SessionSeq(replacement![1]))! + expect(replaced.type === 'tool/result' ? replaced.data.message.source.callId : undefined).toBe(callId) }) it('delegates IMAGE_OFFLOAD_REQUIRED once nothing remains to offload', async () => { @@ -160,7 +204,7 @@ describe('image offload recovery', () => { agent.followup(createUserMessage({ content: [{ type: 'text', text: 'no images' }], source: { kind: 'user' } })) await agent.whenIdle() expect(delegated).toEqual([IMAGE_OFFLOAD_REQUIRED_CODE]) - expect(offloadEvents(agent.session)).toHaveLength(0) + expect(replacements(agent.session)).toHaveLength(0) expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'error' } } }) }) }) diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml index e4c929a014..5c59f00429 100644 --- a/packages/llm/llm/README.i18n.yaml +++ b/packages/llm/llm/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm/README.md -README.md: 0fec0b06c9e9d7725e9efe6b98e27ccae1cb1b65 -README.zh.md: 4b1d9738769f3568c66d2bad5a124f0087eaced3 +README.md: 398c6e17d5b5da631a48d510844b2156a8f0579d +README.zh.md: 83293a94d5e5560ef1f5efeee4d4c3db038e9be4 diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index 0fec0b06c9..398c6e17d5 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -95,13 +95,13 @@ The service is built on one separation: **the logical contract is provider-neutr | [`src/call-config.ts`](src/call-config.ts) | Call-config validation, adapter-default materialization, and request freezing | | [`src/retry-policy.ts`](src/retry-policy.ts) | Provider-owned retry policy resolution (normal and always modes) | | [`src/error.ts`](src/error.ts) | `HarnessError`/`LlmError` taxonomy and provider-neutral failure codes | -| [`src/content.ts`](src/content.ts) | Shared image-content helpers: the image walk, offload watermark planning, and offloaded-image projection | +| [`src/content.ts`](src/content.ts) | Shared image-content helpers: the image walk, offload accounting, and offloaded-image projection | | [`src/api-key.ts`](src/api-key.ts) | Credential format check shared by every adapter | | [`src/adapter-failure.ts`](src/adapter-failure.ts) | Failure normalization into terminal finish chunks | ### Main flow -A request is validated against its exact model's capability — context window, output default, reasoning efforts, and input modalities — and any adapter-configured defaults are materialized, then the whole request is deep-frozen. `prepareCall()` binds those facts, detached context, and retry policy to the exact adapter generation that performs terminal dispatch, so HMR or dynamic settings cannot combine one generation's image capability with another generation's endpoint. An image-capable adapter projects durable references into route-specific request versions; `resolveImageAttachmentAccess()` separately maps an attachment provider's optional host object into the current tool execution world without changing the request image or its `variantId`. A text-only route receives deterministic per-image placeholders, including nested tool-result images, without rewriting append-only session history. Durable `FileBlock` references never reach any adapter: request assembly replaces each one, nested tool-result occurrences included, with deterministic handle text naming the file and its saved read-only path, resolved through the mounted attachment and filesystem providers. `ctx.llm.fileRequestText(ref)` exposes that exact synchronous projection to request measurement. Surface derivation marks occurrences at or before the session's durable `image/offload` watermark `offloaded: true`, and `projectOffloadedImages()` renders those marks as route-owned placeholder text. An image-capable route whose retained occurrences exceed its `LlmImageRequestBudget` at their exact bytes fails with `IMAGE_OFFLOAD_REQUIRED` naming the additional oldest occurrences (`requiredImageOffload()`), never with an unlogged projection; `dsh-llm-retry` advances the watermark and retries. Adapters that charge visual tokens declare per-route `imageRequestPricing`, which `ctx.llm.imageRequestPricing(provider, model)` resolves synchronously for the token meter. Dispatch goes through the `llm/stream` waterfall, then chunks return as token-level deltas and every adapter outcome reaches the consumer as one terminal `finish` chunk. +A request is validated against its exact model's capability — context window, output default, reasoning efforts, and input modalities — and any adapter-configured defaults are materialized, then the whole request is deep-frozen. `prepareCall()` binds those facts, detached context, and retry policy to the exact adapter generation that performs terminal dispatch, so HMR or dynamic settings cannot combine one generation's image capability with another generation's endpoint. An image-capable adapter projects durable references into route-specific request versions; `resolveImageAttachmentAccess()` separately maps an attachment provider's optional host object into the current tool execution world without changing the request image or its `variantId`. A text-only route receives deterministic per-image placeholders, including nested tool-result images, without rewriting append-only session history. Durable `FileBlock` references never reach any adapter: request assembly replaces each one, nested tool-result occurrences included, with deterministic handle text naming the file and its saved read-only path, resolved through the mounted attachment and filesystem providers. `ctx.llm.fileRequestText(ref)` exposes that exact synchronous projection to request measurement. An image occurrence a surface replacement marked `offloaded: true` reaches every route as placeholder text through `projectOffloadedImages()`. An image-capable route whose retained occurrences exceed its `LlmImageRequestBudget` at their exact bytes fails with `IMAGE_OFFLOAD_REQUIRED` naming the additional oldest occurrences (`requiredImageOffload()`), never with an unlogged projection; `dsh-llm-retry` replaces the carrying surface nodes with marked copies and retries. Adapters that charge visual tokens declare per-route `imageRequestPricing`, which `ctx.llm.imageRequestPricing(provider, model)` resolves synchronously for the token meter. Dispatch goes through the `llm/stream` waterfall, then chunks return as token-level deltas and every adapter outcome reaches the consumer as one terminal `finish` chunk. ### Invariants @@ -139,7 +139,7 @@ None, as the LLM service adds no content; adapters choose when to add the shared #### KV Cache effect -Reasoning-effort materialization preserves the assembled request prefix. Image identity and request-preview text are deterministic, while an optional execution-world path is resolved for each request; a changed path or an `image/offload` advance can prevent reuse from that image. +Reasoning-effort materialization preserves the assembled request prefix. Image identity and request-preview text are deterministic, while an optional execution-world path is resolved for each request; a changed path or an offload replacement can prevent reuse from that image. ## Known Limitations and Deferred Work diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md index 4b1d973876..83293a94d5 100644 --- a/packages/llm/llm/README.zh.md +++ b/packages/llm/llm/README.zh.md @@ -95,13 +95,13 @@ for await (const chunk of ctx.llm.stream({ | [`src/call-config.ts`](src/call-config.ts) | 调用配置校验、适配器默认值填入与请求冻结 | | [`src/retry-policy.ts`](src/retry-policy.ts) | 提供方自有重试策略解析(normal 与 always 模式) | | [`src/error.ts`](src/error.ts) | `HarnessError`/`LlmError` 分类体系与提供方无关失败 code | -| [`src/content.ts`](src/content.ts) | 共享图片内容辅助函数:图片遍历、卸载水位规划与已卸载图片投影 | +| [`src/content.ts`](src/content.ts) | 共享图片内容辅助函数:图片遍历、省略计数与已省略图片投影 | | [`src/api-key.ts`](src/api-key.ts) | 每个适配器共享的凭据格式校验 | | [`src/adapter-failure.ts`](src/adapter-failure.ts) | 把失败归一化为终止 finish 分片 | ### 主流程 -请求会对照其精确模型的能力——上下文窗口、输出默认值、推理强度与输入模态——校验,填入任何适配器配置的默认值,然后整个请求被深度冻结。`prepareCall()` 把这些事实、分离的上下文与重试策略绑定到执行最终分发的精确适配器代次,因此 HMR 或动态设置无法把一个代次的图片能力与另一代次的端点混用。支持图片的适配器把持久引用投影为路由专用请求版本;`resolveImageAttachmentAccess()` 会单独把附件提供方的可选宿主对象映射进当前工具执行世界,而不改变请求图片或其 `variantId`。纯文本路由接收确定性的逐图片占位符,包括嵌套工具结果图片,而不会改写仅追加会话历史。持久 `FileBlock` 引用永远不会到达任何适配器:请求组装把每个引用(包括嵌套工具结果中的出现)替换为确定性 handle 文本,指出文件与其只读保存路径,路径经由挂载的附件与文件系统提供方解析。`ctx.llm.fileRequestText(ref)` 向请求计量公开相同的同步投影。表层派生把位于会话持久 `image/offload` 水位及之前的出现位置标为 `offloaded: true`,`projectOffloadedImages()` 把这些标记渲染为路由所属的占位文本。支持图片的路由在保留的出现位置按精确字节超过其 `LlmImageRequestBudget` 时,以 `IMAGE_OFFLOAD_REQUIRED` 失败并说明还需省略多少最老的出现位置(`requiredImageOffload()`),绝不发送未记录的投影;`dsh-llm-retry` 推进水位并重试。对视觉 token 收费的适配器声明按路由的 `imageRequestPricing`,`ctx.llm.imageRequestPricing(provider, model)` 为 token meter 同步解析它。分发经过 `llm/stream` waterfall,随后分片以 token 级增量返回,每个适配器结果都以唯一一个终止 `finish` 分片到达消费方。 +请求会对照其精确模型的能力——上下文窗口、输出默认值、推理强度与输入模态——校验,填入任何适配器配置的默认值,然后整个请求被深度冻结。`prepareCall()` 把这些事实、分离的上下文与重试策略绑定到执行最终分发的精确适配器代次,因此 HMR 或动态设置无法把一个代次的图片能力与另一代次的端点混用。支持图片的适配器把持久引用投影为路由专用请求版本;`resolveImageAttachmentAccess()` 会单独把附件提供方的可选宿主对象映射进当前工具执行世界,而不改变请求图片或其 `variantId`。纯文本路由接收确定性的逐图片占位符,包括嵌套工具结果图片,而不会改写仅追加会话历史。持久 `FileBlock` 引用永远不会到达任何适配器:请求组装把每个引用(包括嵌套工具结果中的出现)替换为确定性 handle 文本,指出文件与其只读保存路径,路径经由挂载的附件与文件系统提供方解析。`ctx.llm.fileRequestText(ref)` 向请求计量公开相同的同步投影。表层替换标为 `offloaded: true` 的图片出现位置,经 `projectOffloadedImages()` 以占位文本到达每条路由。支持图片的路由在保留的出现位置按精确字节超过其 `LlmImageRequestBudget` 时,以 `IMAGE_OFFLOAD_REQUIRED` 失败并说明还需省略多少最老的出现位置(`requiredImageOffload()`),绝不发送未记录的投影;`dsh-llm-retry` 用带标记的副本替换承载这些图片的表层节点并重试。对视觉 token 收费的适配器声明按路由的 `imageRequestPricing`,`ctx.llm.imageRequestPricing(provider, model)` 为 token meter 同步解析它。分发经过 `llm/stream` waterfall,随后分片以 token 级增量返回,每个适配器结果都以唯一一个终止 `finish` 分片到达消费方。 ### 不变式 @@ -139,7 +139,7 @@ for await (const chunk of ctx.llm.stream({ #### KV Cache 影响 -推理强度的具体化会保留已组装请求前缀。图片身份与请求预览文本是确定性的,可选执行世界路径则按请求解析;路径变化或一次 `image/offload` 推进可能从该图片起阻止复用。 +推理强度的具体化会保留已组装请求前缀。图片身份与请求预览文本是确定性的,可选执行世界路径则按请求解析;路径变化或一次省略替换可能从该图片起阻止复用。 ## 已知限制与延期工作 diff --git a/packages/llm/llm/src/content.ts b/packages/llm/llm/src/content.ts index f0a513b45f..33ea56d7b5 100644 --- a/packages/llm/llm/src/content.ts +++ b/packages/llm/llm/src/content.ts @@ -205,37 +205,18 @@ function base64Length(bytes: number): number { return Math.ceil(bytes / 3) * 4 } -/** - * Block index path of one image occurrence inside message content. Each - * nested tool-result contributes another index. Paths compare - * lexicographically in message order. - */ -export type ImageBlockPath = readonly number[] - -function visitImageBlocksAt( - content: readonly ContentBlock[], - prefix: ImageBlockPath, - visit: (block: ImageBlock, path: ImageBlockPath) => void, -): void { - for (const [index, block] of content.entries()) { - const path = [...prefix, index] - if (block.type === 'image') visit(block, path) - else if (block.type === 'tool-result') visitImageBlocksAt(block.content, path, visit) - } -} - /** * Visit every image occurrence of typed content in message order, including - * nested tool-result content, with the block path that identifies it. Budget - * planning, adapter validation, and pricing share this request-content walk. + * nested tool-result content. Budget accounting, adapter validation, and + * pricing share this request-content walk. * @param content - typed model content blocks. - * @param visit - called once per occurrence with the block and its path. + * @param visit - called once per occurrence. */ -export function visitImageBlocks( - content: readonly ContentBlock[], - visit: (block: ImageBlock, path: ImageBlockPath) => void, -): void { - visitImageBlocksAt(content, [], visit) +export function visitImageBlocks(content: readonly ContentBlock[], visit: (block: ImageBlock) => void): void { + for (const block of content) { + if (block.type === 'image') visit(block) + else if (block.type === 'tool-result') visitImageBlocks(block.content, visit) + } } /** Replace every offloaded occurrence, including nested tool results, with its placeholder. */ diff --git a/packages/llm/llm/src/error.ts b/packages/llm/llm/src/error.ts index e031fcf970..6c5f7ad5b4 100644 --- a/packages/llm/llm/src/error.ts +++ b/packages/llm/llm/src/error.ts @@ -163,10 +163,10 @@ export function isHarnessError(value: unknown): value is HarnessError { } /** - * Canonical code for a request an image-capable route cannot send until the - * session's durable `image/offload` watermark advances. The failure's - * `offloadImages` names how many more of the oldest retained occurrences must - * be offloaded; `dsh-llm-retry` appends the advance and retries the step, so - * the model never receives an unlogged projection. + * Canonical code for a request an image-capable route cannot send until more + * of its images are offloaded. The failure's `offloadImages` names how many + * more of the oldest retained occurrences must be offloaded; `dsh-llm-retry` + * replaces the surface nodes carrying them with copies marked `offloaded` and + * retries the step, so the model never receives an unlogged projection. */ export const IMAGE_OFFLOAD_REQUIRED_CODE = 'IMAGE_OFFLOAD_REQUIRED' diff --git a/packages/llm/llm/src/types.ts b/packages/llm/llm/src/types.ts index fd8c86f8bc..b0df82c35b 100644 --- a/packages/llm/llm/src/types.ts +++ b/packages/llm/llm/src/types.ts @@ -51,8 +51,9 @@ export interface LlmFailure { /** * With code `IMAGE_OFFLOAD_REQUIRED`: how many more of the oldest retained * image occurrences the route needs offloaded before the same request fits - * its exact byte accounting. `dsh-llm-retry` advances the session's durable - * `image/offload` watermark by this count and retries the step. + * its exact byte accounting. `dsh-llm-retry` replaces the surface nodes + * carrying that many oldest retained occurrences with copies marked + * `offloaded` and retries the step. */ readonly offloadImages?: number } @@ -80,9 +81,9 @@ export interface ImageBlock { /** Immutable bytes and intrinsic display metadata owned by the attachment service. */ attachment: ImageAttachmentRef /** - * Set by surface derivation when the occurrence lies at or before the - * session's durable `image/offload` watermark: every route sends its - * placeholder text instead of the image. Never stored in a logged message. + * Set on a surface replacement once the occurrence is offloaded from + * requests: every route sends its placeholder text, which names the image + * and its read-only path, instead of the image. */ offloaded?: true } diff --git a/packages/llm/llm/tests/content.spec.ts b/packages/llm/llm/tests/content.spec.ts index 9d2c3a1ff7..cf22656a99 100644 --- a/packages/llm/llm/tests/content.spec.ts +++ b/packages/llm/llm/tests/content.spec.ts @@ -15,7 +15,7 @@ import { requestImageHandleText, visitImageBlocks, } from '../src/index.ts' -import type { ContentBlock, ImageBlockPath } from '../src/index.ts' +import type { ContentBlock } from '../src/index.ts' const source = { kind: 'plugin' as const, plugin: 'test' } @@ -36,8 +36,8 @@ function image(bytes: number, offloaded?: true): Extract { - it('visits image occurrences at every tool-result depth with their block paths', () => { - const seen: ImageBlockPath[] = [] + it('visits image occurrences at every tool-result depth in message order', () => { + const seen: number[] = [] visitImageBlocks([ { type: 'text', text: 'before' }, image(1), @@ -55,8 +55,8 @@ describe('visitImageBlocks', () => { ], }, image(3), - ], (block, path) => seen.push([...path, block.attachment.bytes])) - expect(seen).toEqual([[1, 1], [2, 1, 2], [2, 2, 0, 0, 4], [3, 3]]) + ], block => seen.push(block.attachment.bytes)) + expect(seen).toEqual([1, 2, 4, 3]) }) }) diff --git a/packages/llm/token-meter/README.i18n.yaml b/packages/llm/token-meter/README.i18n.yaml index a1cfd47b79..9b88f82260 100644 --- a/packages/llm/token-meter/README.i18n.yaml +++ b/packages/llm/token-meter/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/token-meter/README.md -README.md: 9bd737a1ffc9fe34cc1fa9819bd1676da996cb49 -README.zh.md: 0588ce576677e2fd6b7be1fe2a76154053c1a30d +README.md: 85108bf138f3148898121935f78021fa597a23f5 +README.zh.md: 73707d6198022dc2d7d960f5e67978da426527f2 diff --git a/packages/llm/token-meter/README.md b/packages/llm/token-meter/README.md index 9bd737a1ff..85108bf138 100644 --- a/packages/llm/token-meter/README.md +++ b/packages/llm/token-meter/README.md @@ -40,7 +40,7 @@ const { totalTokens, surfaceTokens, nodes } = ctx.tokenMeter.measure(session) const price = ctx.tokenMeter.estimateMessage(message) ``` -Each measurement resolves the effective envelope's provider/model through the optional `llm` service. Image occurrences use the routed request's visual-token price plus model-visible text when the adapter declares pricing, and occurrences at or before the session's durable `image/offload` watermark are priced as the route's placeholder text exactly as the derived surface sends them; other routes keep the fixed heuristic. File occurrences use the exact route-independent handle text that the same `llm` service resolves for adapter dispatch, including its current execution-world path or explicit no-path message. Each node also carries route-independent `heuristicTokens` for replacement shadow prices. Provider usage is reused only when the latest successful call's canonical request envelope matches the measured envelope and its total is no lower than that call's full route-priced anchor; otherwise the complete current envelope and surface are estimated. Surface changes stay signed relative to a matching anchor repriced under the same route, including negative deltas after shrinking replacements. +Each measurement resolves the effective envelope's provider/model through the optional `llm` service. Image occurrences use the routed request's visual-token price plus model-visible text when the adapter declares pricing, and occurrences a surface replacement marked `offloaded` are priced as the route's placeholder text exactly as the surface sends them; other routes keep the fixed heuristic. File occurrences use the exact route-independent handle text that the same `llm` service resolves for adapter dispatch, including its current execution-world path or explicit no-path message. Each node also carries route-independent `heuristicTokens` for replacement shadow prices. Provider usage is reused only when the latest successful call's canonical request envelope matches the measured envelope and its total is no lower than that call's full route-priced anchor; otherwise the complete current envelope and surface are estimated. Surface changes stay signed relative to a matching anchor repriced under the same route, including negative deltas after shrinking replacements. ### Session projections diff --git a/packages/llm/token-meter/README.zh.md b/packages/llm/token-meter/README.zh.md index 0588ce5766..73707d6198 100644 --- a/packages/llm/token-meter/README.zh.md +++ b/packages/llm/token-meter/README.zh.md @@ -40,7 +40,7 @@ const { totalTokens, surfaceTokens, nodes } = ctx.tokenMeter.measure(session) const price = ctx.tokenMeter.estimateMessage(message) ``` -每次测量都会通过可选的 `llm` 服务解析生效 envelope 的提供方/模型。适配器声明图片定价时,图片出现处使用路由请求的视觉 token 价格加模型可见文本,位于会话持久 `image/offload` 水位及之前的出现位置则按路由的占位文本计价,与派生表层实际发送的一致;其他路由保持固定启发式规则。文件出现处使用同一个 `llm` 服务为适配器分发解析的确切、与路由无关的 handle 文本,其中包含当前执行世界路径或明确的无路径说明。每个节点还携带与路由无关的 `heuristicTokens`,供替换影子价使用。只有当最新成功调用的规范请求 envelope 与已测量 envelope 匹配、且其总量不低于该调用完整路由定价锚点时,才复用提供方用量;否则会对完整当前 envelope 与表面做估算。表面变更保持相对于按同一路由重新定价的匹配锚点的带符号值,包括缩减替换后的负 delta。 +每次测量都会通过可选的 `llm` 服务解析生效 envelope 的提供方/模型。适配器声明图片定价时,图片出现处使用路由请求的视觉 token 价格加模型可见文本,表层替换标为已省略的出现位置则按路由的占位文本计价,与表层实际发送的一致;其他路由保持固定启发式规则。文件出现处使用同一个 `llm` 服务为适配器分发解析的确切、与路由无关的 handle 文本,其中包含当前执行世界路径或明确的无路径说明。每个节点还携带与路由无关的 `heuristicTokens`,供替换影子价使用。只有当最新成功调用的规范请求 envelope 与已测量 envelope 匹配、且其总量不低于该调用完整路由定价锚点时,才复用提供方用量;否则会对完整当前 envelope 与表面做估算。表面变更保持相对于按同一路由重新定价的匹配锚点的带符号值,包括缩减替换后的负 delta。 ### 会话投影 diff --git a/packages/llm/token-meter/src/index.ts b/packages/llm/token-meter/src/index.ts index b7ea85076b..e28476d61a 100644 --- a/packages/llm/token-meter/src/index.ts +++ b/packages/llm/token-meter/src/index.ts @@ -10,7 +10,6 @@ import { assembleAssistantStream } from '@deepseek-ai/dsh-llm' import type { LlmImageRequestPricing, LlmRuntime, Message, TokenUsage } from '@deepseek-ai/dsh-llm' import { deepFreeze } from '@deepseek-ai/dsh-util-values' import type { - ImageOccurrencePosition, EpochHeader, Session, SessionEvent, @@ -53,8 +52,6 @@ interface MeasurementAnchor { readonly header: EpochHeader | undefined /** Surface snapshot the anchored request was derived from. */ readonly nodes: readonly MeterSurfaceNode[] - /** Offload watermark the anchored request was derived under. */ - readonly watermark: ImageOccurrencePosition | undefined /** Fixed-heuristic price of the call's provider output. */ readonly assistantTokens: number /** Provider usage of the call, when it reported one under a known header. */ @@ -65,8 +62,6 @@ interface ReplayState { consumedEvents: SessionLogOffsetType header: EpochHeader | undefined surface: MeterSurfaceNode[] - /** Durable `image/offload` watermark in force after the consumed events. */ - watermark: ImageOccurrencePosition | undefined stepStart: { turn: number; step: number; nodes: readonly MeterSurfaceNode[] } | undefined anchor: MeasurementAnchor | undefined } @@ -153,7 +148,7 @@ export class TokenMeter extends Service { : canonicalHeader(requestHeader) const pricing = this._routeImagePricing(header) const fileText = this._fileRequestText() - const surface = priceSurface(state.surface, pricing, fileText, state.watermark) + const surface = priceSurface(state.surface, pricing, fileText) const anchor = state.anchor let baseline: TokenMeasurementBaseline @@ -162,7 +157,7 @@ export class TokenMeter extends Service { // Matching headers share one route, so the anchored snapshot reprices // under the same pricing as the current surface and the signed delta // compares like with like. - const anchorSurfaceTokens = priceSurface(anchor.nodes, pricing, fileText, anchor.watermark).surfaceTokens + const anchorSurfaceTokens = priceSurface(anchor.nodes, pricing, fileText).surfaceTokens + anchor.assistantTokens const estimatedAnchorTokens = estimateHeader(header) + anchorSurfaceTokens const usage = anchor.usage @@ -226,7 +221,6 @@ export class TokenMeter extends Service { consumedEvents: SessionLogOffset(0), header: undefined, surface: [], - watermark: undefined, stepStart: undefined, anchor: undefined, } @@ -249,7 +243,6 @@ export class TokenMeter extends Service { */ private _foldEvent(state: ReplayState, event: SessionEvent): void { let nextHeader = state.header - let nextWatermark = state.watermark let nextStepStart = state.stepStart let nextAnchor = state.anchor @@ -257,9 +250,6 @@ export class TokenMeter extends Service { case 'request/header': nextHeader = canonicalHeader(event.data.header) break - case 'image/offload': - nextWatermark = event.data.watermark - break case 'step/start': if (state.stepStart !== undefined) { throw new Error( @@ -299,7 +289,6 @@ export class TokenMeter extends Service { nextAnchor = { header: nextHeader, nodes: stepStart.nodes, - watermark: nextWatermark, assistantTokens: this._estimateProviderAssistant(event), usage: event.data.usage, } @@ -307,7 +296,6 @@ export class TokenMeter extends Service { nextAnchor = { header: nextHeader, nodes: stepStart.nodes, - watermark: nextWatermark, assistantTokens: eventTokens, usage: undefined, } @@ -315,7 +303,6 @@ export class TokenMeter extends Service { } state.header = nextHeader - state.watermark = nextWatermark state.stepStart = nextStepStart if (plan !== undefined) { commitSurfaceTokens(state.surface, plan) diff --git a/packages/llm/token-meter/src/route-pricing.ts b/packages/llm/token-meter/src/route-pricing.ts index 7ca6156067..03a1c519c3 100644 --- a/packages/llm/token-meter/src/route-pricing.ts +++ b/packages/llm/token-meter/src/route-pricing.ts @@ -5,9 +5,7 @@ * @module @deepseek-ai/dsh-token-meter/route-pricing */ -import type { ContentBlock, ImageBlock, LlmImageRequestPricing } from '@deepseek-ai/dsh-llm' -import { compareImagePositions } from '@deepseek-ai/dsh-session' -import type { ImageOccurrencePosition } from '@deepseek-ai/dsh-session' +import type { ContentBlock, LlmImageRequestPricing } from '@deepseek-ai/dsh-llm' import { estimateContent } from './estimate.ts' import type { MeterSurfaceNode } from './surface-fold.ts' import type { TokenSurfaceNode } from './types.ts' @@ -23,13 +21,10 @@ export interface PricedSurface { } /** - * Price one ordered surface under its model-request attachment projection. Every - * occurrence positioned at or before `watermark` is priced as the route's - * offloaded placeholder, exactly as the derived surface sends it. + * Price one ordered surface under its model-request attachment projection. * @param nodes - the fold's current or snapshotted surface, in model-visible order. * @param pricing - the routed model's image pricing, or undefined to keep the fixed heuristic. * @param fileText - exact file handle projection used by the mounted LLM service. - * @param watermark - the durable `image/offload` watermark in force for this surface, if any. * @returns detached public nodes and their route-priced total. * @throws when the pricing answers a different occurrence count than it was * asked — misalignment would silently misprice nodes, so it must fail loud. @@ -37,16 +32,9 @@ export interface PricedSurface { export function priceSurface( nodes: readonly MeterSurfaceNode[], pricing: LlmImageRequestPricing | undefined, - fileText: ((ref: FileAttachmentRef) => string) | undefined, - watermark?: ImageOccurrencePosition, + fileText?: (ref: FileAttachmentRef) => string, ): PricedSurface { - const images: ImageBlock[] = pricing === undefined - ? [] - : nodes.flatMap(node => node.images.map(({ attachment, path }) => ( - watermark !== undefined && compareImagePositions({ seq: node.seq, path: [...path] }, watermark) <= 0 - ? { type: 'image' as const, attachment, offloaded: true as const } - : { type: 'image' as const, attachment } - ))) + const images = pricing === undefined ? [] : nodes.flatMap(node => node.images) const hasFiles = fileText !== undefined && nodes.some(node => node.files.length > 0) if ((pricing === undefined || images.length === 0) && !hasFiles) { let surfaceTokens = 0 diff --git a/packages/llm/token-meter/src/surface-fold.ts b/packages/llm/token-meter/src/surface-fold.ts index 6ce0b8fcf2..af51e66183 100644 --- a/packages/llm/token-meter/src/surface-fold.ts +++ b/packages/llm/token-meter/src/surface-fold.ts @@ -18,19 +18,9 @@ import { deriveEventMessage } from '@deepseek-ai/dsh-session' import type { SessionSeq, SurfaceEvent } from '@deepseek-ai/dsh-session' -import { visitImageBlocks } from '@deepseek-ai/dsh-llm' -import type { ContentBlock, ImageBlockPath, Message } from '@deepseek-ai/dsh-llm' -import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { ContentBlock, ImageBlock, Message } from '@deepseek-ai/dsh-llm' import { estimateMessage, estimateStructuralBlock } from './estimate.ts' -/** One durable image occurrence of a surface node, positioned for the offload watermark. */ -export interface MeterImageOccurrence { - /** Durable normalized attachment reference of the occurrence. */ - readonly attachment: ImageAttachmentRef - /** Block path of the occurrence inside the node's message content. */ - readonly path: ImageBlockPath -} - type FileAttachmentRef = Extract['attachment'] /** One priced surface node with the image occurrences route pricing replaces. */ @@ -43,8 +33,8 @@ export interface MeterSurfaceNode { readonly imageStructuralTokens: number /** Structural JSON price replaced when request assembly projects files to text. */ readonly fileStructuralTokens: number - /** Durable image occurrences in message order; empty for image-free nodes. */ - readonly images: readonly MeterImageOccurrence[] + /** Durable image blocks in message order, `offloaded` marks included; empty for image-free nodes. */ + readonly images: readonly ImageBlock[] /** Durable file occurrences in message order; empty for file-free nodes. */ readonly files: readonly FileAttachmentRef[] } @@ -61,28 +51,28 @@ export interface SurfaceTokenPlan { readonly target: 'append' | { readonly startIdx: number; readonly endIdx: number } } -/** Collect image occurrences with their block paths and total their structural prices. */ -function collectImages(blocks: readonly ContentBlock[], images: MeterImageOccurrence[]): number { - let structuralTokens = 0 - visitImageBlocks(blocks, (block, path) => { - images.push({ attachment: block.attachment, path }) - structuralTokens += estimateStructuralBlock(block) - }) - return structuralTokens -} - -/** Collect file occurrences recursively and total their structural prices. */ -function collectFiles(blocks: readonly ContentBlock[], files: FileAttachmentRef[]): number { +/** Collect projected attachment occurrences and their structural prices. */ +function collectProjectedAttachments( + blocks: readonly ContentBlock[], + images: ImageBlock[], + files: FileAttachmentRef[], +): { readonly imageTokens: number; readonly fileTokens: number } { + let imageTokens = 0 let fileTokens = 0 for (const block of blocks) { - if (block.type === 'file') { + if (block.type === 'image') { + images.push(block) + imageTokens += estimateStructuralBlock(block) + } else if (block.type === 'file') { files.push(block.attachment) fileTokens += estimateStructuralBlock(block) } else if (block.type === 'tool-result') { - fileTokens += collectFiles(block.content, files) + const nested = collectProjectedAttachments(block.content, images, files) + imageTokens += nested.imageTokens + fileTokens += nested.fileTokens } } - return fileTokens + return { imageTokens, fileTokens } } /** Build one priced node from a surface event's derived message. */ @@ -98,15 +88,14 @@ function analyzeNode(seq: SessionSeq, message: Message | null): MeterSurfaceNode } } const heuristicTokens = estimateMessage(message) - const images: MeterImageOccurrence[] = [] - const imageStructuralTokens = collectImages(message.content, images) + const images: ImageBlock[] = [] const files: FileAttachmentRef[] = [] - const fileStructuralTokens = collectFiles(message.content, files) + const structural = collectProjectedAttachments(message.content, images, files) return { seq, heuristicTokens, - imageStructuralTokens, - fileStructuralTokens, + imageStructuralTokens: structural.imageTokens, + fileStructuralTokens: structural.fileTokens, images, files, } diff --git a/packages/llm/token-meter/tests/route-pricing.spec.ts b/packages/llm/token-meter/tests/route-pricing.spec.ts index b80df6b16a..4499970b2a 100644 --- a/packages/llm/token-meter/tests/route-pricing.spec.ts +++ b/packages/llm/token-meter/tests/route-pricing.spec.ts @@ -198,14 +198,14 @@ describe('request projection pricing', () => { expect(unknownRoute.nodes[0]!.tokens).toBe(estimateMessage(message)) }) - it('prices occurrences at or before the image/offload watermark as the route placeholder', async () => { + it('prices an occurrence a surface replacement marked offloaded as the route placeholder', async () => { const placeholder = '[offloaded]' - const watermarkPricing: LlmImageRequestPricing = { + const markedPricing: LlmImageRequestPricing = { priceImages: images => images.map(block => (block.offloaded === true ? { visualTokens: 0, text: placeholder } : { visualTokens: VISUAL_TOKENS, text: HANDLE_TEXT })), } - const { meter, session } = await harness(() => watermarkPricing) + const { meter, session } = await harness(() => markedPricing) session.append('turn/start', { turn: 1 }) const older = imageMessage('older') const newer = imageMessage('newer') @@ -215,8 +215,10 @@ describe('request projection pricing', () => { const before = meter.measure(session) expect(before.nodes.map(node => node.tokens)).toEqual([routedMessageTokens(older), routedMessageTokens(newer)]) - session.append('step/start', { turn: 1, step: 1 }) - session.append('image/offload', { turn: 1, step: 1, watermark: { seq: olderSeq, path: [1] } }) + session.append('user/message', { + ...older, + content: older.content.map(block => (block.type === 'image' ? { ...block, offloaded: true as const } : block)), + }, { surfaceOp: { op: 'replace', start: olderSeq, end: olderSeq }, sourceEventSeqs: [olderSeq] }) const after = meter.measure(session) const imageFree = estimateMessage({ ...older, content: older.content.filter(block => block.type !== 'image') }) expect(after.nodes[0]!.tokens).toBe(imageFree + estimateContent([{ type: 'text', text: placeholder }])) diff --git a/python/sdk/tests/test_client.py b/python/sdk/tests/test_client.py index 122c92cbcc..0af0db29ad 100644 --- a/python/sdk/tests/test_client.py +++ b/python/sdk/tests/test_client.py @@ -43,17 +43,6 @@ for line in sys.stdin: print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": params["sessionId"], "event": {"type": "agent/inbox/spliced", "data": {"target": "next-turn", "start": 0, "inserted": [{"id": "message-1"}]}}}}), flush=True) print(json.dumps({"jsonrpc": "2.0", "method": "session.status", "params": {"sessionId": params["sessionId"], "status": "running"}}), flush=True) print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"messageId": "message-1"}}), flush=True) - print(json.dumps({ - "jsonrpc": "2.0", - "method": "session.event", - "params": { - "sessionId": params["sessionId"], - "event": { - "type": "image/offload", - "data": {"turn": 1, "step": 1, "watermark": {"seq": 7, "path": [3]}}, - }, - }, - }), flush=True) print(json.dumps({ "jsonrpc": "2.0", "method": "session.event", @@ -120,10 +109,6 @@ for line in sys.stdin: assert result.final_response == "hello from runtime" assert result.finish_reason == "max-tokens" - assert result.events[1] == { - "type": "image/offload", - "data": {"turn": 1, "step": 1, "watermark": {"seq": 7, "path": [3]}}, - } assert result.events[-1]["type"] == "turn/end" dumped_env = json.loads(env_dump.read_text()) assert dumped_env["DEEPSEEK_API_KEY"] == "env-key" diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 164c8395a3..c843a901ed 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -474,11 +474,6 @@ "symbol": "RequestContext", "source": "packages/core/session/src/types.ts" }, - { - "doc": "docs/subsystems/session.md", - "symbol": "ImageOccurrencePosition", - "source": "packages/core/session/src/types.ts" - }, { "doc": "docs/subsystems/session.md", "symbol": "SessionSeq", diff --git a/snapshots/sdk/inline-image-prompt/session.v2.jsonl b/snapshots/sdk/inline-image-prompt/session.v2.jsonl index 612b5a2983..a4af1cdade 100644 --- a/snapshots/sdk/inline-image-prompt/session.v2.jsonl +++ b/snapshots/sdk/inline-image-prompt/session.v2.jsonl @@ -11,8 +11,9 @@ {"type":"session/title","data":{"title":"Inspect these images, then reply","messageSeqs":[7],"source":{"kind":"fallback"}}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"}} -{"type":"assistant/attempt","data":{"turn":1,"step":1,"stream":[{"type":"chunk","time":1788752482896,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"DeepSeek base64 request images exceed the route budget; 3 more oldest occurrence(s) must be offloaded.","code":"IMAGE_OFFLOAD_REQUIRED","offloadImages":3}}}}]}} -{"type":"image/offload","data":{"turn":1,"step":1,"watermark":{"seq":7,"path":[3]}}} -{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:3}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1788752482896,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"chunk","time":1788752482896,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1788752482896,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1788752482896,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} +{"type":"assistant/attempt","data":{"turn":1,"step":1,"stream":[{"type":"chunk","time":1788839885455,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"DeepSeek base64 request images exceed the route budget; 3 more oldest occurrence(s) must be offloaded.","code":"IMAGE_OFFLOAD_REQUIRED","offloadImages":3}}}}]}} +{"type":"user/message","data":{"content":[{"type":"text","text":"Inspect these images, then reply with exactly "},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69},"offloaded":true},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69},"offloaded":true},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69},"offloaded":true},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"text","text":"the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"sourceEventSeqs":[7],"surfaceOp":{"op":"replace","start":7,"end":7}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"system":"{{system}}","tools":"{{tools}}"},"reason":"series"}} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:3}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1788839885461,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"chunk","time":1788839885461,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1788839885461,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1788839885461,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} From 6b673aedf5b938697888327479fc756d101261f4 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 8 Sep 2026 14:08:47 +0800 Subject: [PATCH 14/79] docs(llm): drop the last watermark wording from llm-streaming and the retry test title --- docs/subsystems/llm-streaming.i18n.yaml | 4 ++-- docs/subsystems/llm-streaming.md | 2 +- docs/subsystems/llm-streaming.zh.md | 2 +- packages/llm/llm-retry/tests/image-offload.spec.ts | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/subsystems/llm-streaming.i18n.yaml b/docs/subsystems/llm-streaming.i18n.yaml index 4f7cf608c3..a9a32f6001 100644 --- a/docs/subsystems/llm-streaming.i18n.yaml +++ b/docs/subsystems/llm-streaming.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/llm-streaming.md -llm-streaming.md: 7eb1610febca36973f2892f2e15ad8af52a62b44 -llm-streaming.zh.md: b1cab08a1193ab8f70fcf570dcc57a573b5c96cd +llm-streaming.md: b556bf14c545f29ab3551e40dffeb354f3f0cb1d +llm-streaming.zh.md: a6c2dfa284be6be0e8e501391b98d499dca844ac diff --git a/docs/subsystems/llm-streaming.md b/docs/subsystems/llm-streaming.md index 7eb1610feb..b556bf14c5 100644 --- a/docs/subsystems/llm-streaming.md +++ b/docs/subsystems/llm-streaming.md @@ -257,7 +257,7 @@ interface LlmFailure { ## Request-image pricing -An adapter whose provider charges visual tokens for request images declares per-route pricing by overriding `LlmAdapter.imageRequestPricing`, and `ctx.llm.imageRequestPricing(provider, model)` resolves it synchronously for consumers. The token meter resolves the routed model's pricing on every measurement so compaction pressure, retention, and range selection price image history as the routed request actually sends it; the DeepSeek adapter prices each retained occurrence at its per-model pixel-budget projection with the published v4 vision accounting and each occurrence the session's `image/offload` watermark marks offloaded as its placeholder text, while provider usage remains the authoritative anchor for completed requests. +An adapter whose provider charges visual tokens for request images declares per-route pricing by overriding `LlmAdapter.imageRequestPricing`, and `ctx.llm.imageRequestPricing(provider, model)` resolves it synchronously for consumers. The token meter resolves the routed model's pricing on every measurement so compaction pressure, retention, and range selection price image history as the routed request actually sends it; the DeepSeek adapter prices each retained occurrence at its per-model pixel-budget projection with the published v4 vision accounting and each occurrence a surface replacement marks offloaded as its placeholder text, while provider usage remains the authoritative anchor for completed requests. ```ts type-equiv /** diff --git a/docs/subsystems/llm-streaming.zh.md b/docs/subsystems/llm-streaming.zh.md index b1cab08a11..a6c2dfa284 100644 --- a/docs/subsystems/llm-streaming.zh.md +++ b/docs/subsystems/llm-streaming.zh.md @@ -259,7 +259,7 @@ interface LlmFailure { ## 请求图片定价 -提供方对请求图片收取视觉 token 的适配器通过覆写 `LlmAdapter.imageRequestPricing` 声明按路由的定价,消费方经 `ctx.llm.imageRequestPricing(provider, model)` 同步解析。token 计量服务在每次计量时解析路由模型的定价,使 compaction 的压力、保留与选段都按路由请求实际发送的形式为图片历史计价;DeepSeek 适配器按模型像素预算的投影用官方公布的 v4 视觉计量为每个保留的出现位置定价,并把会话 `image/offload` 水位标记为已省略的出现位置按其占位文本定价,已完成请求仍以 provider usage 为权威锚点。 +提供方对请求图片收取视觉 token 的适配器通过覆写 `LlmAdapter.imageRequestPricing` 声明按路由的定价,消费方经 `ctx.llm.imageRequestPricing(provider, model)` 同步解析。token 计量服务在每次计量时解析路由模型的定价,使 compaction 的压力、保留与选段都按路由请求实际发送的形式为图片历史计价;DeepSeek 适配器按模型像素预算的投影用官方公布的 v4 视觉计量为每个保留的出现位置定价,并把表层替换标记为已省略的出现位置按其占位文本定价,已完成请求仍以 provider usage 为权威锚点。 ```ts type-equiv /** diff --git a/packages/llm/llm-retry/tests/image-offload.spec.ts b/packages/llm/llm-retry/tests/image-offload.spec.ts index 8a5be7f21e..6b7c540ed1 100644 --- a/packages/llm/llm-retry/tests/image-offload.spec.ts +++ b/packages/llm/llm-retry/tests/image-offload.spec.ts @@ -83,7 +83,7 @@ function replacements(session: Session): [number, number][] { } describe('image offload recovery', () => { - it('advances the watermark by the named count and retries without a retry event', async () => { + it('replaces the node carrying the named count of images and retries without a retry event', async () => { const adapter = new ScriptedAdapter([offloadRequired(2), textResponse('sent')]) const ctx = await harness(adapter) const agent = await ctx.agentLoop.create(SessionId('offload-required'), { provider: 'mock', model: 'mock' }) From 4779f054b6c8b6e0a503106d4164fc11cfbbaa60 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 8 Sep 2026 15:10:16 +0800 Subject: [PATCH 15/79] refactor(compaction): own image offload as a compaction executor Move the IMAGE_OFFLOAD_REQUIRED recovery out of llm-retry into the new @deepseek-ai/dsh-compaction-image-offload package, a sibling of the tool-result pruner: image offload is compaction in another capacity dimension. Each replaced node is priced through the seam's compaction/prune shadow-price protocol before its marked copy lands. llm-retry returns to master; the base bundle mounts the executor. --- ...2026-09-02-durable-image-offload.i18n.yaml | 4 +- .../2026-09-02-durable-image-offload.md | 6 +- .../2026-09-02-durable-image-offload.zh.md | 6 +- apps/cli/composition.md | 3 + docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 15 ++- docs/config-catalog.zh.md | 15 ++- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 2 +- docs/event-producer-consumer.zh.md | 2 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 7 ++ docs/module-graph.zh.md | 7 ++ docs/subsystems/llm-streaming.i18n.yaml | 4 +- docs/subsystems/llm-streaming.md | 6 +- docs/subsystems/llm-streaming.zh.md | 6 +- packages/bundle/base/cordis.patch.yml | 5 + packages/bundle/base/package.json | 1 + packages/compaction/README.i18n.yaml | 4 +- packages/compaction/README.md | 3 +- packages/compaction/README.zh.md | 3 +- .../compaction-image-offload/README.i18n.yaml | 6 + .../compaction-image-offload/README.md | 106 ++++++++++++++++++ .../compaction-image-offload/README.zh.md | 106 ++++++++++++++++++ .../compaction-image-offload/package.json | 50 +++++++++ .../src/image-offload.ts | 45 +++++--- .../compaction-image-offload/src/index.ts | 40 +++++++ .../tests/image-offload.spec.ts | 44 +++++++- .../compaction-image-offload/tsconfig.json | 20 ++++ packages/llm/llm-deepseek/README.i18n.yaml | 4 +- packages/llm/llm-deepseek/README.md | 2 +- packages/llm/llm-deepseek/README.zh.md | 2 +- packages/llm/llm-pi-ai/README.i18n.yaml | 4 +- packages/llm/llm-pi-ai/README.md | 2 +- packages/llm/llm-pi-ai/README.zh.md | 2 +- packages/llm/llm-retry/README.i18n.yaml | 4 +- packages/llm/llm-retry/README.md | 9 +- packages/llm/llm-retry/README.zh.md | 9 +- packages/llm/llm-retry/src/index.ts | 7 -- packages/llm/llm/README.i18n.yaml | 4 +- packages/llm/llm/README.md | 2 +- packages/llm/llm/README.zh.md | 2 +- packages/llm/llm/src/error.ts | 7 +- packages/llm/llm/src/types.ts | 6 +- pnpm-lock.yaml | 50 ++++++++- python/sdk-runtime/package.json | 1 + .../sdk/inline-image-prompt/session.v2.jsonl | 5 +- tsconfig.base.json | 1 + tsconfig.host.json | 1 + 49 files changed, 560 insertions(+), 92 deletions(-) create mode 100644 packages/compaction/compaction-image-offload/README.i18n.yaml create mode 100644 packages/compaction/compaction-image-offload/README.md create mode 100644 packages/compaction/compaction-image-offload/README.zh.md create mode 100644 packages/compaction/compaction-image-offload/package.json rename packages/{llm/llm-retry => compaction/compaction-image-offload}/src/image-offload.ts (59%) create mode 100644 packages/compaction/compaction-image-offload/src/index.ts rename packages/{llm/llm-retry => compaction/compaction-image-offload}/tests/image-offload.spec.ts (82%) create mode 100644 packages/compaction/compaction-image-offload/tsconfig.json diff --git a/.agents/notes/implemented/architecture/2026-09-02-durable-image-offload.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-02-durable-image-offload.i18n.yaml index a2c691105e..b998cd4005 100644 --- a/.agents/notes/implemented/architecture/2026-09-02-durable-image-offload.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-02-durable-image-offload.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-02-durable-image-offload.md -2026-09-02-durable-image-offload.md: db77e4743f1f81c7425b3dd63d32b36f161eafec -2026-09-02-durable-image-offload.zh.md: 9b0a424aff24acea130980a61095929b834a3145 +2026-09-02-durable-image-offload.md: d4cc791deec64567b6920c878cb39439fb51c24d +2026-09-02-durable-image-offload.zh.md: b31a4747db98328ea445d5891d1c813631aeaa94 diff --git a/.agents/notes/implemented/architecture/2026-09-02-durable-image-offload.md b/.agents/notes/implemented/architecture/2026-09-02-durable-image-offload.md index db77e4743f..d4cc791dee 100644 --- a/.agents/notes/implemented/architecture/2026-09-02-durable-image-offload.md +++ b/.agents/notes/implemented/architecture/2026-09-02-durable-image-offload.md @@ -22,9 +22,9 @@ The offload of an image occurrence is a durable surface fact recorded the way co **Adapters project, never decide.** An image-capable route enforces an `LlmImageRequestBudget` (`representation`, `maxBytes`, `maxImages`, both quanta) over the retained occurrences' exact request-version bytes. When they still exceed the budget, in file mode, under the inline fallback's tighter budget, or under the pi-ai bound, the adapter fails the attempt with `IMAGE_OFFLOAD_REQUIRED` and `LlmFailure.offloadImages` naming how many more oldest occurrences must be offloaded, computed with the shared `requiredImageOffload()`. Nothing plans an offload before dispatch. -**Recovery owned by `dsh-llm-retry`.** The retry executor already sits on the `agent/request-error` waterfall. Before any provider policy it handles `IMAGE_OFFLOAD_REQUIRED`: it walks the surface in model request order, marks the first `offloadImages` retained occurrences, appends one replacement per node that carried any of them, and returns the `retry` action without spending the provider retry budget or logging `llm/retry`. Assistant nodes carry model output, not input images, and are skipped. When nothing remains to offload it delegates and the failure reaches ordinary recovery. The loop re-runs the step over the replaced surface and logs the fresh `request/header` every surface replacement gets; the agent loop is unchanged. +**Recovery owned by `dsh-compaction-image-offload`.** Image offload is compaction in another capacity dimension: the provider rejects a request, durable history is reduced, the step retries. The executor is a sibling of `compaction-tool-result-pruner` in the compaction group and listens on the `agent/request-error` waterfall. On `IMAGE_OFFLOAD_REQUIRED` it walks the surface in model request order, marks the first `offloadImages` retained occurrences, and for each node that carried one appends the seam's `compaction/prune` shadow price followed by the marked copy, then returns the `retry` action without spending the provider retry budget or logging `llm/retry`. Assistant nodes carry model output, not input images, and are skipped. When nothing remains to offload it delegates and the failure reaches ordinary recovery. The loop re-runs the step over the replaced surface and logs the fresh `request/header` every surface replacement gets; the agent loop is unchanged. -**Token accounting.** `priceImages` receives the surface's `ImageBlock`s and prices a marked one as its placeholder text; the DeepSeek and replay pricing no longer reproduce any offload arithmetic. The meter needs no new state: a replacement reprices the node like any other replacement. +**Token accounting.** `priceImages` receives the surface's `ImageBlock`s and prices a marked one as its placeholder text; the DeepSeek and replay pricing no longer reproduce any offload arithmetic. The meter needs no new state: the `compaction/prune` event and the replacement reprice the node like a tool-result prune. **Other consumers.** Compaction reconstructs each selected event through `deriveEventMessage()` and sees the marks. Resume, fork, and replay reproduce the surface from the log. Text-only routes keep their separate whole-history substitution. @@ -52,4 +52,4 @@ Every dispatched request's image set is determined by the log alone, across file ## Testing -`packages/llm/llm/tests/content.spec.ts` pins arbitrary-depth image traversal, the offload count including the 129-to-64 MiB quantum example, and placeholder projection. `packages/llm/llm-retry/tests/image-offload.spec.ts` pins the `IMAGE_OFFLOAD_REQUIRED` replace-and-retry path without a retry event, the untouched original node, request-order counts after an earlier surface replacement, and the exhausted case that delegates downstream. Adapter specs pin placeholder projection, prepared-only-retained reads, and the exact-byte failure with its count; `route-pricing.spec.ts` pins placeholder pricing for a marked replacement. The `inline-image-prompt` TypeScript SDK snapshot replays an authored `IMAGE_OFFLOAD_REQUIRED` attempt through the shipped profile and pins the replacement and the retried request. +`packages/llm/llm/tests/content.spec.ts` pins arbitrary-depth image traversal, the offload count including the 129-to-64 MiB quantum example, and placeholder projection. `packages/compaction/compaction-image-offload/tests/image-offload.spec.ts` pins the `IMAGE_OFFLOAD_REQUIRED` replace-and-retry path with its `compaction/prune` shadow prices and without a retry event, the untouched original node, request-order counts after an earlier surface replacement, and the exhausted case that delegates downstream. Adapter specs pin placeholder projection, prepared-only-retained reads, and the exact-byte failure with its count; `route-pricing.spec.ts` pins placeholder pricing for a marked replacement. The `inline-image-prompt` TypeScript SDK snapshot replays an authored `IMAGE_OFFLOAD_REQUIRED` attempt through the shipped profile and pins the replacement and the retried request. diff --git a/.agents/notes/implemented/architecture/2026-09-02-durable-image-offload.zh.md b/.agents/notes/implemented/architecture/2026-09-02-durable-image-offload.zh.md index 9b0a424aff..b31a4747db 100644 --- a/.agents/notes/implemented/architecture/2026-09-02-durable-image-offload.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-02-durable-image-offload.zh.md @@ -22,9 +22,9 @@ Status: implemented **adapter 只投影,不决定。** 支持图片的路由按保留的出现位置的精确请求版本字节执行一个 `LlmImageRequestBudget`(`representation`、`maxBytes`、`maxImages` 与两个量子)。当它们仍超过预算,无论是 file 模式、内联回退更紧的预算还是 pi-ai 上限,adapter 都以 `IMAGE_OFFLOAD_REQUIRED` 让本次尝试失败,并在 `LlmFailure.offloadImages` 中用共享的 `requiredImageOffload()` 算出还需省略多少最老的出现位置。发送前没有任何规划。 -**恢复归 `dsh-llm-retry`。** 重试执行器本来就在 `agent/request-error` waterfall 上。它在任何提供方策略之前处理 `IMAGE_OFFLOAD_REQUIRED`:按模型请求顺序遍历表层,给前 `offloadImages` 个保留的出现位置打标记,为每个承载了其中任一位置的节点追加一条替换,然后返回 `retry` 动作,不占提供方重试预算,也不记录 `llm/retry`。assistant 节点承载的是模型输出而不是输入图片,直接跳过。没有可省略的出现位置时向下游委托,失败进入普通恢复路径。循环在替换后的表层上重跑该 step,并像每次表层替换后一样记录新的 `request/header`;agent loop 不变。 +**恢复归 `dsh-compaction-image-offload`。** 图片省略是 compaction 在另一个容量维度上的实例:provider 拒绝请求,持久历史被缩减,step 重试。执行器是 compaction 组里 `compaction-tool-result-pruner` 的兄弟包,监听 `agent/request-error` waterfall。收到 `IMAGE_OFFLOAD_REQUIRED` 时,它按模型请求顺序遍历表层,给前 `offloadImages` 个保留的出现位置打标记,为每个承载了其中任一位置的节点先追加 seam 的 `compaction/prune` 影子价格,再追加带标记的副本,然后返回 `retry` 动作,不占提供方重试预算,也不记录 `llm/retry`。assistant 节点承载的是模型输出而不是输入图片,直接跳过。没有可省略的出现位置时向下游委托,失败进入普通恢复路径。循环在替换后的表层上重跑该 step,并像每次表层替换后一样记录新的 `request/header`;agent loop 不变。 -**token 记账。** `priceImages` 接收表层的 `ImageBlock`,把带标记的按占位文本定价;DeepSeek 和 replay 的定价不再复现任何 offload 算术。meter 不需要新状态:一次替换和其他替换一样重新为该节点定价。 +**token 记账。** `priceImages` 接收表层的 `ImageBlock`,把带标记的按占位文本定价;DeepSeek 和 replay 的定价不再复现任何 offload 算术。meter 不需要新状态:`compaction/prune` 事件加替换节点,和工具结果剪枝一样重新为该节点定价。 **其他消费方。** compaction 通过 `deriveEventMessage()` 重建每个选中事件,看得到标记。resume、fork 和重放从日志复现表层。纯文本路由保留各自的全历史替换。 @@ -52,4 +52,4 @@ Status: implemented ## 测试 -`packages/llm/llm/tests/content.spec.ts` 钉住任意深度的图片遍历、包括 129 到 64 MiB 量子示例在内的省略计数,以及占位投影。`packages/llm/llm-retry/tests/image-offload.spec.ts` 钉住 `IMAGE_OFFLOAD_REQUIRED` 替换并重试且不追加重试事件的路径、原节点保持不变、更早一次表层替换之后的请求顺序计数,以及向下游委托的耗尽情况。adapter 测试钉住占位投影、只读取保留图片以及带数量的精确字节失败;`route-pricing.spec.ts` 钉住带标记替换的占位定价。`inline-image-prompt` TypeScript SDK 快照通过发布的 profile 重放一次手工编写的 `IMAGE_OFFLOAD_REQUIRED` 尝试,钉住替换和重试后的请求。 +`packages/llm/llm/tests/content.spec.ts` 钉住任意深度的图片遍历、包括 129 到 64 MiB 量子示例在内的省略计数,以及占位投影。`packages/compaction/compaction-image-offload/tests/image-offload.spec.ts` 钉住 `IMAGE_OFFLOAD_REQUIRED` 替换并重试、带 `compaction/prune` 影子价格且不追加重试事件的路径、原节点保持不变、更早一次表层替换之后的请求顺序计数,以及向下游委托的耗尽情况。adapter 测试钉住占位投影、只读取保留图片以及带数量的精确字节失败;`route-pricing.spec.ts` 钉住带标记替换的占位定价。`inline-image-prompt` TypeScript SDK 快照通过发布的 profile 重放一次手工编写的 `IMAGE_OFFLOAD_REQUIRED` 尝试,钉住替换和重试后的请求。 diff --git a/apps/cli/composition.md b/apps/cli/composition.md index 406edb4557..9832d32dc7 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -150,6 +150,8 @@ flowchart LR cfg --> plugin_dsh_base_session_checkpoint_policy plugin_dsh_base_tool_result_pruner["tool-result-pruner
@deepseek-ai/dsh-compaction-tool-result-pruner"] cfg --> plugin_dsh_base_tool_result_pruner + plugin_dsh_base_image_offload["image-offload
@deepseek-ai/dsh-compaction-image-offload"] + cfg --> plugin_dsh_base_image_offload plugin_dsh_base_tool_todo["tool-todo
@deepseek-ai/dsh-tool-todo"] cfg --> plugin_dsh_base_tool_todo plugin_dsh_base_tool_goal["tool-goal
@deepseek-ai/dsh-tool-goal"] @@ -251,6 +253,7 @@ flowchart LR | `spill-policy` | `@deepseek-ai/dsh-spill-policy` | | `session-checkpoint-policy` | `@deepseek-ai/dsh-session-checkpoint-policy` | | `tool-result-pruner` | `@deepseek-ai/dsh-compaction-tool-result-pruner` | +| `image-offload` | `@deepseek-ai/dsh-compaction-image-offload` | | `tool-todo` | `@deepseek-ai/dsh-tool-todo` | | `tool-goal` | `@deepseek-ai/dsh-tool-goal` | | `tool-ralph` | `@deepseek-ai/dsh-tool-ralph` | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index b53e03caf6..823bd247a5 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 826100d4c7847d05fda64e0a884ad508516ca1db -config-catalog.zh.md: d72bf693e36a16d1e67ce9f11527175ec3aecf60 +config-catalog.md: 834b11f8c75a284bbe611583324d1acc6cde23dd +config-catalog.zh.md: d490d312ea380b2a0703f2f97357a46f6d45eb2e diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 826100d4c7..834b11f8c7 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -482,6 +482,19 @@ export interface ModelCompactPolicyConfig extends CompactionPolicyConfig { Source: [`packages/compaction/compaction-basic/src/types.ts:38`](../packages/compaction/compaction-basic/src/types.ts) + + +## `@deepseek-ai/dsh-compaction-image-offload` + +Requires: `agents` · `tokenMeter` + +```ts config-catalog +/** The executor has no configuration; image-capable routes own their budgets. */ +export type Config = Readonly> +``` + +Source: [`packages/compaction/compaction-image-offload/src/index.ts:23`](../packages/compaction/compaction-image-offload/src/index.ts) + ## `@deepseek-ai/dsh-compaction-tool-result-pruner` @@ -1451,7 +1464,7 @@ Requires: `agents` · `sessionProjections` export type Config = Readonly> ``` -Source: [`packages/llm/llm-retry/src/index.ts:27`](../packages/llm/llm-retry/src/index.ts) +Source: [`packages/llm/llm-retry/src/index.ts:25`](../packages/llm/llm-retry/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index d72bf693e3..d490d312ea 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -484,6 +484,19 @@ export interface ModelCompactPolicyConfig extends CompactionPolicyConfig { 来源:[`packages/compaction/compaction-basic/src/types.ts:38`](../packages/compaction/compaction-basic/src/types.ts) + + +## `@deepseek-ai/dsh-compaction-image-offload` + +需要:`agents` · `tokenMeter` + +```ts config-catalog +/** The executor has no configuration; image-capable routes own their budgets. */ +export type Config = Readonly> +``` + +来源:[`packages/compaction/compaction-image-offload/src/index.ts:23`](../packages/compaction/compaction-image-offload/src/index.ts) + ## `@deepseek-ai/dsh-compaction-tool-result-pruner` @@ -1453,7 +1466,7 @@ export interface ReplayModelConfig { export type Config = Readonly> ``` -来源:[`packages/llm/llm-retry/src/index.ts:27`](../packages/llm/llm-retry/src/index.ts) +来源:[`packages/llm/llm-retry/src/index.ts:25`](../packages/llm/llm-retry/src/index.ts) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index dcb4c44e45..6d58cd486f 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/event-producer-consumer.md -event-producer-consumer.md: 99811d1dac8b51e96544eea95b1d67379cc15f14 -event-producer-consumer.zh.md: 68ea7460eeb2192b6d8d5195fec1254c457e7c16 +event-producer-consumer.md: da13ad19a662de1292945ede102fa26637e75fd0 +event-producer-consumer.zh.md: cf4551bcb50887c0df4d2679e3ddfcf71093e9b3 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 99811d1dac..da13ad19a6 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -18,7 +18,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | | `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:330`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) | | `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:343`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:359`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:359`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:316`](../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:277`](../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:387`](../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) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 68ea7460ee..cf4551bcb5 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -20,7 +20,7 @@ | `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | | `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:330`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) | | `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:343`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:359`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:359`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:316`](../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:277`](../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:387`](../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) | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 7789f3c308..4b04aeba4f 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: ecb72f02bc7a27a75318e8abf56c20f9915831ea -module-graph.zh.md: 5c94c866d10e81948dd69089210cd4b08ded7531 +module-graph.md: 02660e82972c86bf3e7ea4a2a4e42f419ce42728 +module-graph.zh.md: 69533d8c4853cf28e72a353986987edb707b3066 diff --git a/docs/module-graph.md b/docs/module-graph.md index ecb72f02bc..02660e8297 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -193,6 +193,7 @@ flowchart TD pkg_command_compact["command-compact"] pkg_compaction["compaction"] pkg_compaction_basic["compaction-basic"] + pkg_compaction_image_offload["compaction-image-offload"] pkg_compaction_tool_result_pruner["compaction-tool-result-pruner"] end subgraph group_context["packages/context"] @@ -914,6 +915,11 @@ flowchart TD pkg_api_settings_controller --> pkg_typert_protocol pkg_web_app --> pkg_shell_env pkg_web_app --> pkg_system_prompt + pkg_compaction_image_offload --> pkg_agent + pkg_compaction_image_offload --> pkg_compaction + pkg_compaction_image_offload --> pkg_llm + pkg_compaction_image_offload --> pkg_session + pkg_compaction_image_offload --> pkg_token_meter pkg_compaction_tool_result_pruner --> pkg_compaction pkg_compaction_tool_result_pruner --> pkg_llm pkg_compaction_tool_result_pruner --> pkg_session @@ -1399,6 +1405,7 @@ flowchart TD | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) | | [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`credentials`](../packages/credentials/credentials), [`native-command`](../packages/util/native-command), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) | +| [`compaction-image-offload`](../packages/compaction/compaction-image-offload) | `compaction` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 5c94c866d1..69533d8c48 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -195,6 +195,7 @@ flowchart TD pkg_command_compact["command-compact"] pkg_compaction["compaction"] pkg_compaction_basic["compaction-basic"] + pkg_compaction_image_offload["compaction-image-offload"] pkg_compaction_tool_result_pruner["compaction-tool-result-pruner"] end subgraph group_context["packages/context"] @@ -916,6 +917,11 @@ flowchart TD pkg_api_settings_controller --> pkg_typert_protocol pkg_web_app --> pkg_shell_env pkg_web_app --> pkg_system_prompt + pkg_compaction_image_offload --> pkg_agent + pkg_compaction_image_offload --> pkg_compaction + pkg_compaction_image_offload --> pkg_llm + pkg_compaction_image_offload --> pkg_session + pkg_compaction_image_offload --> pkg_token_meter pkg_compaction_tool_result_pruner --> pkg_compaction pkg_compaction_tool_result_pruner --> pkg_llm pkg_compaction_tool_result_pruner --> pkg_session @@ -1401,6 +1407,7 @@ flowchart TD | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) | | [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`credentials`](../packages/credentials/credentials), [`native-command`](../packages/util/native-command), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) | +| [`compaction-image-offload`](../packages/compaction/compaction-image-offload) | `compaction` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) | diff --git a/docs/subsystems/llm-streaming.i18n.yaml b/docs/subsystems/llm-streaming.i18n.yaml index a9a32f6001..ecadadf08b 100644 --- a/docs/subsystems/llm-streaming.i18n.yaml +++ b/docs/subsystems/llm-streaming.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/llm-streaming.md -llm-streaming.md: b556bf14c545f29ab3551e40dffeb354f3f0cb1d -llm-streaming.zh.md: a6c2dfa284be6be0e8e501391b98d499dca844ac +llm-streaming.md: 96d614e3db068c440f246622fbf1512123654a70 +llm-streaming.zh.md: a5bc55dc9472ea986b5c0de07eebaa2028b91454 diff --git a/docs/subsystems/llm-streaming.md b/docs/subsystems/llm-streaming.md index b556bf14c5..96d614e3db 100644 --- a/docs/subsystems/llm-streaming.md +++ b/docs/subsystems/llm-streaming.md @@ -247,9 +247,9 @@ interface LlmFailure { /** * With code `IMAGE_OFFLOAD_REQUIRED`: how many more of the oldest retained * image occurrences the route needs offloaded before the same request fits - * its exact byte accounting. `dsh-llm-retry` replaces the surface nodes - * carrying that many oldest retained occurrences with copies marked - * `offloaded` and retries the step. + * its exact byte accounting. `dsh-compaction-image-offload` replaces the + * surface nodes carrying that many oldest retained occurrences with copies + * marked `offloaded` and retries the step. */ readonly offloadImages?: number } diff --git a/docs/subsystems/llm-streaming.zh.md b/docs/subsystems/llm-streaming.zh.md index a6c2dfa284..a5bc55dc94 100644 --- a/docs/subsystems/llm-streaming.zh.md +++ b/docs/subsystems/llm-streaming.zh.md @@ -249,9 +249,9 @@ interface LlmFailure { /** * With code `IMAGE_OFFLOAD_REQUIRED`: how many more of the oldest retained * image occurrences the route needs offloaded before the same request fits - * its exact byte accounting. `dsh-llm-retry` replaces the surface nodes - * carrying that many oldest retained occurrences with copies marked - * `offloaded` and retries the step. + * its exact byte accounting. `dsh-compaction-image-offload` replaces the + * surface nodes carrying that many oldest retained occurrences with copies + * marked `offloaded` and retries the step. */ readonly offloadImages?: number } diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index 5c50261d0d..d85405b67e 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -398,6 +398,11 @@ headChars: 4096 tailChars: 1024 + # When an image-capable route rejects a request as over its image budget, + # replace the oldest images with placeholders on the surface and retry. + - id: image-offload + name: '@deepseek-ai/dsh-compaction-image-offload' + - id: tool-todo name: '@deepseek-ai/dsh-tool-todo' config: diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index d9faf89f9e..1d0fde8368 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -47,6 +47,7 @@ "@deepseek-ai/dsh-command-goal": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-compaction-basic": "workspace:^", + "@deepseek-ai/dsh-compaction-image-offload": "workspace:^", "@deepseek-ai/dsh-compaction-tool-result-pruner": "workspace:^", "@deepseek-ai/dsh-credentials-local": "workspace:^", "@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:^", diff --git a/packages/compaction/README.i18n.yaml b/packages/compaction/README.i18n.yaml index e1e9725272..1b6b263835 100644 --- a/packages/compaction/README.i18n.yaml +++ b/packages/compaction/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/compaction/README.md -README.md: dbab0fa400026f965e1fcbe8af88e0873bb560e4 -README.zh.md: 5584c180d63321aac7fa23b4f87157520b7a3b47 +README.md: 46ab8fab72d55b4926c9fe582187e025ce7a67d0 +README.zh.md: ca1ccab9f980cd4e4ba7632f5dd148e9d02cbdd4 diff --git a/packages/compaction/README.md b/packages/compaction/README.md index dbab0fa400..46ab8fab72 100644 --- a/packages/compaction/README.md +++ b/packages/compaction/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -The `compaction/` group keeps long agent conversations working near the model's context limit: older history is condensed into a summary automatically as token pressure builds, on demand with `/compact`, and oversized tool outputs can be trimmed first so there is less to condense. The shipped `dsh` base enables the feature by default — mount the packages explicitly to tune when and how condensation happens. The token measurement that decides when to condense lives in a separate LLM-family service. +The `compaction/` group keeps long agent conversations working near the model's context limit: older history is condensed into a summary automatically as token pressure builds, on demand with `/compact`, oversized tool outputs can be trimmed first so there is less to condense, and images an image-capable route can no longer send are replaced with placeholders. The shipped `dsh` base enables the feature by default — mount the packages explicitly to tune when and how condensation happens. The token measurement that decides when to condense lives in a separate LLM-family service. ## Table of Contents @@ -29,6 +29,7 @@ Each package below provides one piece of the feature; open a package page for ho | [`compaction/`](compaction/README.md) | The shared condensation contract: the operations and summary format every backend and trigger use | `ctx.compaction` | | [`compaction-basic/`](compaction-basic/README.md) | Automatic condensation of older history into a summary as token pressure builds | registers `ctx.compaction` | | [`compaction-tool-result-pruner/`](compaction-tool-result-pruner/README.md) | Trims oversized tool outputs so less history needs condensing | `ctx.toolResultPruner` | +| [`compaction-image-offload/`](compaction-image-offload/README.md) | Replaces over-budget request images with placeholders when an image-capable route rejects a request | listens to `agent/request-error` | | [`command-compact/`](command-compact/README.md) | The `/compact` command to condense history on demand | registers on `ctx.commands` | ----- diff --git a/packages/compaction/README.zh.md b/packages/compaction/README.zh.md index 5584c180d6..ca1ccab9f9 100644 --- a/packages/compaction/README.zh.md +++ b/packages/compaction/README.zh.md @@ -9,7 +9,7 @@ kind: "package-group" ## 概述 -`compaction/` 组让长时 agent 会话在接近模型上下文上限时仍能正常工作:token 压力上升时自动把较早历史压缩为摘要,随时可用 `/compact` 按需压缩,超大工具输出也可以先被修剪,从而减少需要压缩的内容。随附 `dsh` 基础配置默认启用该功能——显式挂载各包即可调整压缩发生的时机与方式。决定何时压缩的 token 测量属于独立的 LLM(大语言模型)家族服务。 +`compaction/` 组让长时 agent 会话在接近模型上下文上限时仍能正常工作:token 压力上升时自动把较早历史压缩为摘要,随时可用 `/compact` 按需压缩,超大工具输出也可以先被修剪,从而减少需要压缩的内容,支持图片的路由发不出的图片则被替换为占位文本。随附 `dsh` 基础配置默认启用该功能——显式挂载各包即可调整压缩发生的时机与方式。决定何时压缩的 token 测量属于独立的 LLM(大语言模型)家族服务。 ## 目录 @@ -29,6 +29,7 @@ kind: "package-group" | [`compaction/`](compaction/README.zh.md) | 共享的压缩约定:所有后端与触发器使用的操作与摘要格式 | `ctx.compaction` | | [`compaction-basic/`](compaction-basic/README.zh.md) | 随 token 压力上升自动把较早历史压缩为摘要 | 注册 `ctx.compaction` | | [`compaction-tool-result-pruner/`](compaction-tool-result-pruner/README.zh.md) | 修剪超大工具输出,减少需要压缩的历史 | `ctx.toolResultPruner` | +| [`compaction-image-offload/`](compaction-image-offload/README.zh.md) | 支持图片的路由拒绝请求时,把超出预算的请求图片替换为占位文本 | 监听 `agent/request-error` | | [`command-compact/`](command-compact/README.zh.md) | 按需压缩历史的 `/compact` 命令 | 注册到 `ctx.commands` | ----- diff --git a/packages/compaction/compaction-image-offload/README.i18n.yaml b/packages/compaction/compaction-image-offload/README.i18n.yaml new file mode 100644 index 0000000000..ae6745c158 --- /dev/null +++ b/packages/compaction/compaction-image-offload/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/compaction/compaction-image-offload/README.md +README.md: 7a912557b1d77aeaf7066a3ba07c0332670cf192 +README.zh.md: b5bb0d5f38e28b87a97374dd8201b220dd936b0c diff --git a/packages/compaction/compaction-image-offload/README.md b/packages/compaction/compaction-image-offload/README.md new file mode 100644 index 0000000000..7a912557b1 --- /dev/null +++ b/packages/compaction/compaction-image-offload/README.md @@ -0,0 +1,106 @@ +--- +description: "The image offload executor for deployments composing compaction: what happens when an image-capable route rejects a request as over its image budget." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-compaction-image-offload + +English | [中文](README.zh.md) + +## Summary + +`dsh-compaction-image-offload` keeps image-heavy conversations sendable. When an image-capable route fails a request with `IMAGE_OFFLOAD_REQUIRED`, naming how many of the oldest retained images no longer fit its budget, the plugin replaces each surface node carrying one of those images with a copy whose image blocks are marked `offloaded`, prices the replaced node through the compaction seam's `compaction/prune` protocol, and retries the step. Every route then sends placeholder text naming the image and its read-only path instead of the image. Like the other compaction executors, the replacement is a durable surface fact: it never reverts, so the images the model sees and the provider cache prefix move only forward. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +Mount this plugin in every composition that runs the agent loop with an image-capable route and the token meter. The shipped `dsh` base does. Without it, an `IMAGE_OFFLOAD_REQUIRED` failure reaches ordinary recovery and ends the turn as an error. The plugin has no configuration: the DeepSeek adapter enforces its file-mode and inline-fallback budgets, the pi-ai adapter its base64 bound, and each reports the count it needs offloaded. + +### Minimal configuration + +```yaml +- name: '@deepseek-ai/dsh-compaction-image-offload' +``` + +### What you can observe + +Each offload appends, per replaced node, one `compaction/prune` event carrying the node's heuristic token price and then the replacement node itself: a `user/message` or `tool/result` copy of the original with the affected image blocks marked `offloaded: true`, a `surfaceOp` of `replace`, and `sourceEventSeqs` naming the original. The original event stays in the log untouched. The retried request follows; the loop logs a fresh `request/header` after the surface change, as it does after any compaction. + +### Failures and recovery + +The plugin acts only on `IMAGE_OFFLOAD_REQUIRED` failures that carry `offloadImages`. It walks the surface in model request order, skips assistant nodes and images already marked, and marks that many occurrences. When nothing remains to offload it delegates on the `agent/request-error` waterfall, so downstream recovery or the ordinary turn error applies. The retry spends no provider retry budget and logs no `llm/retry` event. + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +The plugin is one function plugin with one `agent/request-error` listener. `offloadOldestImages()` walks `session.surface.nodes`, marks the first `offloadImages` retained occurrences across `user/message` and `tool/result` nodes, nested tool results included, and for each changed node appends the `compaction/prune` shadow price followed by the marked copy under `surfaceOp: { op: 'replace' }`. It reuses the compaction seam's events and the session's replacement mechanism; no session or agent-loop change exists for it. + +No runtime invariant companion is published: Session validates each replacement's surface metadata, and the `compaction/prune` protocol is owned by the compaction seam's companion. + +
+ +----- + + +## Further Exploration + +- [Durable image offload by surface replacement](../../../.agents/notes/implemented/architecture/2026-09-02-durable-image-offload.md) — the decision this executor implements and the alternatives it replaced. +- [compaction seam](../compaction/README.md) — the `compaction/prune` shadow-price protocol. +- [compaction-tool-result-pruner](../compaction-tool-result-pruner/README.md) — the sibling executor that trims tool outputs by the same replacement mechanism. +- [dsh-llm](../../llm/llm/README.md) — `ImageBlock.offloaded`, `IMAGE_OFFLOAD_REQUIRED`, and the placeholder projection. +- [llm-deepseek adapter](../../llm/llm-deepseek/README.md) and [llm-pi-ai adapter](../../llm/llm-pi-ai/README.md) — the route budgets that report offload counts. + +----- + + +## Model Experience + +### Offloaded request images + +#### What the model sees + +Every image occurrence a replacement marked reaches the model as the route's placeholder text (`offloadedImageText`) naming the attachment and its read-only path instead of the image; unmarked occurrences stay images. The set never shrinks on its own, so the model can rely on an offloaded image staying offloaded and read it back through the path when it needs the content again. + +#### Token effect + +An offloaded occurrence costs its placeholder text instead of visual tokens. The token meter prices the replaced node from the marks on the surface. + +#### KV Cache effect + +A replacement turns earlier images into placeholder text, so provider cache reuse ends at the first replaced message for that request. Because the replacement never reverts, the prefix stays stable afterwards. + +## Known Limitations and Deferred Work + + + +- **Offloaded images never return automatically** — a larger budget, a larger route, or compaction lowering the total leaves the marks in place; recovery is the read-only path in the placeholder. +- **Every offload costs one failed attempt** — nothing plans an offload before dispatch; the route's rejection is the signal, which matches the planned move to provider-reported uncacheable images. +- **A temporary small budget offloads permanently** — a Files outage that forces the inline fallback, or a temporary switch to a small-budget route, offloads images a later route would have sent. + + +### Dev Note + +
+Working context for maintainers — click to expand + +This Dev Note is non-authoritative working context: notes for maintainers and open questions. Shipped behavior and accepted rationale live in the sections above, the package code, and the linked Agent Notes. + +- The route-local byte checks that produce `IMAGE_OFFLOAD_REQUIRED` are provisional: once the provider reports uncacheable images itself, the adapters map that report to a count and this executor stays as it is. + +
diff --git a/packages/compaction/compaction-image-offload/README.zh.md b/packages/compaction/compaction-image-offload/README.zh.md new file mode 100644 index 0000000000..b5bb0d5f38 --- /dev/null +++ b/packages/compaction/compaction-image-offload/README.zh.md @@ -0,0 +1,106 @@ +--- +description: "面向组合 compaction 的部署的图片省略执行器说明:支持图片的路由以请求超出图片预算拒绝时会发生什么。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-compaction-image-offload + +[English](README.md) | 中文 + +## 概述 + +`dsh-compaction-image-offload` 让图片密集的会话保持可发送。支持图片的路由以 `IMAGE_OFFLOAD_REQUIRED` 让请求失败并说明最老的几张保留图片已经装不进预算时,本插件把承载这些图片的每个表层节点替换为图片块标了 `offloaded` 的副本,通过 compaction seam 的 `compaction/prune` 协议为被替换节点计价,然后重试该 step。此后每条路由都以注明图片及其只读路径的占位文本代替图片发送。和其他 compaction 执行器一样,替换是持久的表层事实,永不回退,因此模型看到的图片和 provider 缓存前缀只向前移动。 + +## 目录 + +- [使用本包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +凡是运行 agent loop、带有支持图片的路由并挂了 token meter 的组合,都应挂载本插件,随附的 `dsh` 基础配置已经挂载。没有它,`IMAGE_OFFLOAD_REQUIRED` 失败会进入普通恢复并以错误结束该轮次。本插件没有配置:DeepSeek adapter 执行其 file 模式和内联回退预算,pi-ai adapter 执行其 base64 上限,各自上报需要省略的数量。 + +### 最小可用组合 + +```yaml +- name: '@deepseek-ai/dsh-compaction-image-offload' +``` + +### 你可以观察到什么 + +每次省略会为每个被替换节点追加一条携带该节点启发式 token 价格的 `compaction/prune` 事件,紧接着追加替换节点本身:原消息的 `user/message` 或 `tool/result` 副本,受影响的图片块标了 `offloaded: true`,`surfaceOp` 为 `replace`,`sourceEventSeqs` 指向原节点。原事件原封不动留在日志里。随后是重试的请求;表层变化后 loop 会像任何一次 compaction 之后一样记录新的 `request/header`。 + +### 失败与恢复 + +本插件只处理带 `offloadImages` 的 `IMAGE_OFFLOAD_REQUIRED` 失败。它按模型请求顺序遍历表层,跳过 assistant 节点和已经标记的图片,给指定数量的出现位置打标记。没有可省略的图片时在 `agent/request-error` waterfall 上委派下游,由下游恢复或普通的轮次错误处理。这次重试不占提供方重试预算,也不追加 `llm/retry` 事件。 + +----- + + +## 理解实现 + +
+实现内部——点击展开 + +本插件是只有一个 `agent/request-error` 监听器的函数插件。`offloadOldestImages()` 遍历 `session.surface.nodes`,在 `user/message` 和 `tool/result` 节点(含嵌套工具结果)中给前 `offloadImages` 个保留的出现位置打标记,对每个有变化的节点先追加 `compaction/prune` 影子价格,再以 `surfaceOp: { op: 'replace' }` 追加带标记的副本。它复用 compaction seam 的事件和 session 的替换机制,没有为它改动 session 或 agent loop。 + +本包不发布运行时 invariant 伴生插件:Session 校验每次替换的表层元数据,`compaction/prune` 协议由 compaction seam 的伴生插件负责。 + +
+ +----- + + +## 进一步探索 + +- [以表层替换实现持久的图片 offload](../../../.agents/notes/implemented/architecture/2026-09-02-durable-image-offload.zh.md)——本执行器实现的决定及其替代的方案。 +- [compaction seam](../compaction/README.zh.md)——`compaction/prune` 影子定价协议。 +- [compaction-tool-result-pruner](../compaction-tool-result-pruner/README.zh.md)——用同一替换机制修剪工具输出的兄弟执行器。 +- [dsh-llm](../../llm/llm/README.zh.md)——`ImageBlock.offloaded`、`IMAGE_OFFLOAD_REQUIRED` 与占位投影。 +- [llm-deepseek 适配器](../../llm/llm-deepseek/README.zh.md)与 [llm-pi-ai 适配器](../../llm/llm-pi-ai/README.zh.md)——上报省略数量的路由预算。 + +----- + + +## 模型体验 + +### 已省略的请求图片 + +#### 模型看到的内容 + +替换标记过的每个图片出现位置,以路由的占位文本(`offloadedImageText`)到达模型,文本注明附件及其只读路径而不是图片本身;未标记的出现位置仍是图片。该集合不会自行缩小,模型可以确信被省略的图片会一直被省略,再需要内容时通过路径读回。 + +#### Token 影响 + +被省略的出现位置只花费占位文本,不再花费视觉 token。token meter 按表层上的标记为被替换节点定价。 + +#### KV Cache 影响 + +一次替换把较早的图片换成占位文本,该请求的 provider 缓存复用因此止于第一条被替换的消息。替换永不回退,之后的前缀保持稳定。 + +## 已知限制与延期工作 + + + +- **被省略的图片不会自动回归**——更大的预算、更大的路由或 compaction 降低总量都不会移除标记;恢复手段是占位文本中的只读路径。 +- **每次省略都要先失败一次**——发送前没有任何规划,路由的拒绝就是信号,这和计划中改由 provider 报告无法缓存图片的方向一致。 +- **临时的小预算会永久省略**——迫使内联回退的 Files 故障,或临时切到小预算路由,会省略后来的路由本可以发送的图片。 + + +### 开发备注 + +
+维护者工作背景——点击展开 + +本开发备注是非权威的工作背景:给维护者的备注和未决问题。已交付的行为与被接受的理由见上文各节、包代码和链接的 Agent Note。 + +- 产生 `IMAGE_OFFLOAD_REQUIRED` 的路由本地字节检查是过渡实现:provider 自己报告无法缓存的图片之后,adapter 把该报告换算成数量,本执行器保持不变。 + +
diff --git a/packages/compaction/compaction-image-offload/package.json b/packages/compaction/compaction-image-offload/package.json new file mode 100644 index 0000000000..d54166cbbd --- /dev/null +++ b/packages/compaction/compaction-image-offload/package.json @@ -0,0 +1,50 @@ +{ + "name": "@deepseek-ai/dsh-compaction-image-offload", + "description": "Durable image offload for image-capable routes: replace over-budget request images with placeholders and retry", + "version": "0.1.3-alpha.2", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/compaction/compaction-image-offload" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts" + ], + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-compaction": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-token-meter": "workspace:^" + }, + "dependencies": { + "@deepseek-ai/schemastery": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", + "@deepseek-ai/dsh-compaction": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", + "@deepseek-ai/dsh-token-meter": "workspace:^" + } +} diff --git a/packages/llm/llm-retry/src/image-offload.ts b/packages/compaction/compaction-image-offload/src/image-offload.ts similarity index 59% rename from packages/llm/llm-retry/src/image-offload.ts rename to packages/compaction/compaction-image-offload/src/image-offload.ts index 2888bfc03c..dd0bc2cda0 100644 --- a/packages/llm/llm-retry/src/image-offload.ts +++ b/packages/compaction/compaction-image-offload/src/image-offload.ts @@ -1,16 +1,18 @@ /** - * Image offload recovery: replace the surface nodes carrying the oldest - * retained image occurrences with copies that mark them `offloaded`, so the - * retried request sends placeholder text for exactly the occurrences an - * adapter's `IMAGE_OFFLOAD_REQUIRED` failure named. The replacement is a - * durable surface fact: it never reverts, and every later request and - * measurement reads it from the log. + * The image offload replacement: mark the oldest retained image occurrences + * on the surface `offloaded` by replacing each carrying node with a marked + * copy, priced through the shared `compaction/prune` shadow-price protocol. + * A replacement never reverts, so every later request and measurement reads + * the offloaded set from the log. * - * @module @deepseek-ai/dsh-llm-retry/image-offload + * @module @deepseek-ai/dsh-compaction-image-offload/image-offload */ -import type { ContentBlock, ToolResultMessage } from '@deepseek-ai/dsh-llm' +import type { Context } from '@deepseek-ai/cordis' +import type { ContentBlock, Message, ToolResultMessage } from '@deepseek-ai/dsh-llm' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-compaction' +import type {} from '@deepseek-ai/dsh-token-meter' /** Mark the first `remaining.count` retained occurrences, keeping unchanged blocks by identity. */ function markOldestImages(blocks: readonly ContentBlock[], remaining: { count: number }): readonly ContentBlock[] { @@ -38,8 +40,22 @@ function markOldestImages(blocks: readonly ContentBlock[], remaining: { count: n /** The surface node types that carry model input images. */ type ImageNode = SessionEvent<'user/message'> | SessionEvent<'tool/result'> -/** Append the marked copy of one surface node in place of the original. */ -function replaceNode(session: Session, event: ImageNode, content: readonly ContentBlock[]): void { +/** The message one image node derives. */ +function messageOf(event: ImageNode): Message { + return event.type === 'user/message' ? event.data : event.data.message +} + +/** + * Append the shadow price of one node and then its marked copy in place of + * the original, following the `compaction/prune` protocol so pure consumers + * subtract the replaced node's heuristic price without per-node state. + */ +function replaceNode(ctx: Context, session: Session, event: ImageNode, content: readonly ContentBlock[]): void { + session.append('compaction/prune', { + shadowedRange: { start: event.seq, end: event.seq }, + shadowedSeqs: [event.seq], + shadowedTokenCount: ctx.tokenMeter.estimateMessage(messageOf(event)), + }) const options = { surfaceOp: { op: 'replace' as const, start: event.seq, end: event.seq }, sourceEventSeqs: [event.seq], @@ -59,11 +75,12 @@ function replaceNode(session: Session, event: ImageNode, content: readonly Conte * order, by replacing each surface node that carries one with a copy whose * occurrences are marked `offloaded`. Assistant nodes carry model output, not * input images, and are skipped. - * @param session - the session whose derived surface the retried request reads. + * @param ctx - the plugin context, for the token meter's heuristic price. + * @param session - the session whose surface the retried request reads. * @param count - how many more oldest retained occurrences the adapter needs offloaded. * @returns whether any occurrence remained to offload. */ -export function offloadOldestImages(session: Session, count: number): boolean { +export function offloadOldestImages(ctx: Context, session: Session, count: number): boolean { const remaining = { count } let replaced = false for (const seq of [...session.surface.nodes]) { @@ -71,10 +88,10 @@ export function offloadOldestImages(session: Session, count: number): boolean { // oxlint-disable-next-line typescript/no-non-null-assertion -- surface nodes index the durable log const event = session.eventAt(seq)! if (event.type !== 'user/message' && event.type !== 'tool/result') continue - const original = event.type === 'user/message' ? event.data.content : event.data.message.content + const original = messageOf(event).content const content = markOldestImages(original, remaining) if (content === original) continue - replaceNode(session, event, content) + replaceNode(ctx, session, event, content) replaced = true } return replaced diff --git a/packages/compaction/compaction-image-offload/src/index.ts b/packages/compaction/compaction-image-offload/src/index.ts new file mode 100644 index 0000000000..ee55cee4fc --- /dev/null +++ b/packages/compaction/compaction-image-offload/src/index.ts @@ -0,0 +1,40 @@ +/** + * Image offload executor for the compaction seam. When an image-capable route + * fails a request with `IMAGE_OFFLOAD_REQUIRED`, the plugin replaces the + * surface nodes carrying the named count of oldest retained image occurrences + * with copies marked `offloaded`, prices each replaced node through the + * shared `compaction/prune` protocol, and retries the step on the + * `agent/request-error` waterfall. Every route then sends placeholder text + * for those occurrences, and the replacement never reverts. + * + * @module @deepseek-ai/dsh-compaction-image-offload + */ + +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' +import type { RequestErrorAction } from '@deepseek-ai/dsh-agent' +import { IMAGE_OFFLOAD_REQUIRED_CODE } from '@deepseek-ai/dsh-llm' +import { offloadOldestImages } from './image-offload.ts' + +export const name = 'compaction-image-offload' +export const inject = ['agents', 'tokenMeter'] + +/** The executor has no configuration; image-capable routes own their budgets. */ +export type Config = Readonly> + +/** Runtime schema for {@link Config}. */ +export const Config = z.object({}) as unknown as z + +/** + * Mount the recovery listener. + * @param ctx - the plugin context. + * @param _config - no configuration. + */ +export function apply(ctx: Context, _config: Config = {}): void { + ctx.on('agent/request-error', ({ agent, failure }, next): Promise => { + if (failure.code !== IMAGE_OFFLOAD_REQUIRED_CODE || failure.offloadImages === undefined) return next() + // A durable surface repair, not a provider retry: it spends no retry budget and logs no retry event. + if (!offloadOldestImages(ctx, agent.session, failure.offloadImages)) return next() + return Promise.resolve({ kind: 'retry' }) + }) +} diff --git a/packages/llm/llm-retry/tests/image-offload.spec.ts b/packages/compaction/compaction-image-offload/tests/image-offload.spec.ts similarity index 82% rename from packages/llm/llm-retry/tests/image-offload.spec.ts rename to packages/compaction/compaction-image-offload/tests/image-offload.spec.ts index 6b7c540ed1..ee4245107d 100644 --- a/packages/llm/llm-retry/tests/image-offload.spec.ts +++ b/packages/compaction/compaction-image-offload/tests/image-offload.spec.ts @@ -1,7 +1,8 @@ /** * Image offload recovery: an adapter's `IMAGE_OFFLOAD_REQUIRED` failure * replaces the surface nodes carrying the named count of oldest images with - * marked copies and retries the step without spending the provider retry budget. + * marked copies, each priced by a preceding `compaction/prune` event, and + * retries the step without a retry event. */ import { describe, expect, it } from 'vitest' @@ -12,7 +13,8 @@ import { createAssistantMessage, createToolResultMessage, createUserMessage, IMA import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { isReplacementSurfaceEvent, SessionId, SessionSeq } from '@deepseek-ai/dsh-session' import type { Session } from '@deepseek-ai/dsh-session' -import * as retry from '../src/index.ts' +import TokenMeter from '@deepseek-ai/dsh-token-meter' +import * as offload from '../src/index.ts' type ScriptEntry = StreamChunk[] | (() => never) @@ -50,7 +52,8 @@ function offloadRequired(offloadImages: number): () => never { async function harness(adapter: ScriptedAdapter): Promise { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(Object.assign((inner: Context) => { retry.apply(inner, {}) }, { inject: retry.inject })) + await ctx.plugin(TokenMeter) + await ctx.plugin(Object.assign((inner: Context) => { offload.apply(inner, {}) }, { inject: offload.inject })) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) return ctx @@ -82,7 +85,20 @@ function replacements(session: Session): [number, number][] { .map(event => [Number(event.sourceEventSeqs?.[0]), Number(event.seq)]) } -describe('image offload recovery', () => { +/** Assert each listed replacement is immediately preceded by its `compaction/prune` shadow price. */ +function expectShadowPriced(session: Session, pairs: readonly [number, number][]): void { + const events = session.snapshotEvents() + for (const [original, replacement] of pairs) { + const prune = events[replacement - 1] + expect(prune).toMatchObject({ + type: 'compaction/prune', + data: { shadowedRange: { start: original, end: original }, shadowedSeqs: [original] }, + }) + expect(prune?.type === 'compaction/prune' ? prune.data.shadowedTokenCount : 0).toBeGreaterThan(0) + } +} + +describe('compaction-image-offload', () => { it('replaces the node carrying the named count of images and retries without a retry event', async () => { const adapter = new ScriptedAdapter([offloadRequired(2), textResponse('sent')]) const ctx = await harness(adapter) @@ -113,6 +129,7 @@ describe('image offload recovery', () => { expect(events[original]).toMatchObject({ type: 'user/message', surfaceOp: 'append' }) expect(types.indexOf('assistant/attempt')).toBeLessThan(replacement) expect(replacement).toBeLessThan(types.indexOf('assistant/message')) + expectShadowPriced(agent.session, replacements(agent.session)) // The original event keeps its content; only the replacement carries the marks. const durable = events[original]! expect(durable.type === 'user/message' ? durable.data.content[0] : undefined).not.toHaveProperty('offloaded') @@ -148,7 +165,9 @@ describe('image offload recovery', () => { expect(adapter.requests).toHaveLength(2) expect(offloadedNames(adapter.requests[1]!)).toEqual(['replacement', 'second']) // Both nodes carried one occurrence, so the recovery replaced the replacement node and 'second'. + // The first replacement is the test's own; the recovery priced the two it appended. expect(replacements(agent.session).slice(1).map(([original]) => original)).toEqual([3, 2]) + expectShadowPriced(agent.session, replacements(agent.session).slice(1)) }) it('replaces a tool result node and leaves blocks after the count untouched', async () => { @@ -192,6 +211,23 @@ describe('image offload recovery', () => { expect(replaced.type === 'tool/result' ? replaced.data.message.source.callId : undefined).toBe(callId) }) + it('leaves every other failure to downstream recovery', async () => { + const adapter = new ScriptedAdapter([() => { + throw new LlmError('provider outage', 'SERVER') + }]) + const ctx = await harness(adapter) + const agent = await ctx.agentLoop.create(SessionId('offload-other-failure'), { provider: 'mock', model: 'mock' }) + const delegated: string[] = [] + ctx.on('agent/request-error', ({ failure }, next) => { + delegated.push(failure.code) + return next() + }) + agent.followup(createUserMessage({ content: [image('a')], source: { kind: 'user' } })) + await agent.whenIdle() + expect(delegated).toEqual(['SERVER']) + expect(replacements(agent.session)).toHaveLength(0) + }) + it('delegates IMAGE_OFFLOAD_REQUIRED once nothing remains to offload', async () => { const adapter = new ScriptedAdapter([offloadRequired(1)]) const ctx = await harness(adapter) diff --git a/packages/compaction/compaction-image-offload/tsconfig.json b/packages/compaction/compaction-image-offload/tsconfig.json new file mode 100644 index 0000000000..e6649c163f --- /dev/null +++ b/packages/compaction/compaction-image-offload/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { "path": "../../../vendor/cosmokit" }, + { "path": "../../../vendor/cordis" }, + { "path": "../../../vendor/schemastery" }, + { "path": "../../llm/llm" }, + { "path": "../../llm/token-meter" }, + { "path": "../../core/session" }, + { "path": "../../core/agent" }, + { "path": "../compaction" } + ] +} diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index 7a002de1b1..591526ac9c 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-deepseek/README.md -README.md: 476dfa93129caa867aacd9d7120de58a7ac6700c -README.zh.md: c6fc5c0ced2da5afd78bfc101f68e7b3c70c107a +README.md: 346f2fa48e36ce0a116311b61085fc26d0b76dab +README.zh.md: 32ebcf21097d07e0fd36c273d11839f397e533da diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 476dfa9312..346f2fa48e 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -156,7 +156,7 @@ The selected DeepSeek model receives the harness system prompt, message history, #### Token effect -Provider tokenization governs exact text and image-token input. The adapter declares per-route `imageRequestPricing`: it prices each occurrence a surface replacement marks offloaded as its placeholder text and each retained image at its projected dimensions with the published v4 vision accounting (14px patch grid, 3:1 downsampling, 384-token cap, worst-case alignment pad). This lets the token meter price image pressure before a request; reported usage remains authoritative. Reasoning passback carries every reasoned turn's chain of thought into later requests, while offloaded images stop costing visual tokens. A request whose retained occurrences exceed the file-mode or inline-fallback budget (`maxRequestFilesBytes`, `maxImagesPerRequest`, both quanta) at their exact request-version bytes fails with `IMAGE_OFFLOAD_REQUIRED` naming the additional oldest occurrences to offload, and `dsh-llm-retry` replaces the carrying surface nodes with marked copies and retries. Cache-read usage is reported when available. `totalTokens` is the exact `prompt_tokens + completion_tokens` aggregate and is omitted if a supplied `total_tokens` disagrees. +Provider tokenization governs exact text and image-token input. The adapter declares per-route `imageRequestPricing`: it prices each occurrence a surface replacement marks offloaded as its placeholder text and each retained image at its projected dimensions with the published v4 vision accounting (14px patch grid, 3:1 downsampling, 384-token cap, worst-case alignment pad). This lets the token meter price image pressure before a request; reported usage remains authoritative. Reasoning passback carries every reasoned turn's chain of thought into later requests, while offloaded images stop costing visual tokens. A request whose retained occurrences exceed the file-mode or inline-fallback budget (`maxRequestFilesBytes`, `maxImagesPerRequest`, both quanta) at their exact request-version bytes fails with `IMAGE_OFFLOAD_REQUIRED` naming the additional oldest occurrences to offload, and `dsh-compaction-image-offload` replaces the carrying surface nodes with marked copies and retries. Cache-read usage is reported when available. `totalTokens` is the exact `prompt_tokens + completion_tokens` aggregate and is omitted if a supplied `total_tokens` disagrees. #### KV Cache effect diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index c6fc5c0ced..32ebcf2109 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -156,7 +156,7 @@ Files 模式通过 `maxRequestFilesBytes` 与 `maxImagesPerRequest` 限制保留 #### Token 影响 -提供方分词决定精确的文本与图片 token 输入。适配器声明按路由的 `imageRequestPricing`:把表层替换标记为已省略的每个出现位置按其占位文本计价,并按投影后的尺寸使用官方公布的 v4 视觉计量(14px patch 网格、3:1 降采样、单图 384 token 上限、最坏对齐 pad)为每张保留图片计价。这使 token 计量服务可以在请求发出前为图片压力定价;上报的 usage 仍是权威值。推理回传会把每个推理轮次的思维链带进后续请求,而已省略的图片不再消耗视觉 token。保留的出现位置按精确请求版本字节超过 file 模式或内联回退预算(`maxRequestFilesBytes`、`maxImagesPerRequest` 与两个量子)的请求,以 `IMAGE_OFFLOAD_REQUIRED` 失败并说明还需省略多少最老的出现位置,由 `dsh-llm-retry` 用带标记的副本替换承载节点并重试。可用时报告缓存读取用量。`totalTokens` 是精确的 `prompt_tokens + completion_tokens` 汇总值;提供方给出的 `total_tokens` 不一致时省略该值。 +提供方分词决定精确的文本与图片 token 输入。适配器声明按路由的 `imageRequestPricing`:把表层替换标记为已省略的每个出现位置按其占位文本计价,并按投影后的尺寸使用官方公布的 v4 视觉计量(14px patch 网格、3:1 降采样、单图 384 token 上限、最坏对齐 pad)为每张保留图片计价。这使 token 计量服务可以在请求发出前为图片压力定价;上报的 usage 仍是权威值。推理回传会把每个推理轮次的思维链带进后续请求,而已省略的图片不再消耗视觉 token。保留的出现位置按精确请求版本字节超过 file 模式或内联回退预算(`maxRequestFilesBytes`、`maxImagesPerRequest` 与两个量子)的请求,以 `IMAGE_OFFLOAD_REQUIRED` 失败并说明还需省略多少最老的出现位置,由 `dsh-compaction-image-offload` 用带标记的副本替换承载节点并重试。可用时报告缓存读取用量。`totalTokens` 是精确的 `prompt_tokens + completion_tokens` 汇总值;提供方给出的 `total_tokens` 不一致时省略该值。 #### KV Cache 影响 diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 75b1476aeb..4a38297b50 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-pi-ai/README.md -README.md: 326028b8c2f64dec2a49241752e2285308031de8 -README.zh.md: 502390b50de611f434e561f0567e5fc319fe7550 +README.md: dd2d43fc9f522d1a717ab66eb7c54ceed0f1b5eb +README.zh.md: b1ef96da154900a60647ce1cd86edba0a4502ef0 diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 326028b8c2..dd2d43fc9f 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -176,7 +176,7 @@ Read these pages when the package-level contract is not enough. They move from t #### What the model sees -The selected catalog model receives `GenerateOptions.system`, history, tools, and sampling fields supported by pi-ai's common streaming API. Each retained image is preceded by text naming its complete attachment id and actual request dimensions. When the current execution filesystem maps the attachment provider's host object, the text also carries a read-only normalized-object path and warns that normalization or request projection may have resized or re-encoded the upload. Each occurrence a surface replacement marks offloaded keeps its own identity and currently resolved access in replacement text, and its normalized attachment is not read or transformed. When the retained occurrences' exact base64 payload still exceeds the route's `maxRequestImageBytes`, the call fails with `IMAGE_OFFLOAD_REQUIRED` so `dsh-llm-retry` replaces the carrying surface nodes with marked copies and retries the step. Provider-native replay metadata is restored only when the adapter validates it for the historical content. +The selected catalog model receives `GenerateOptions.system`, history, tools, and sampling fields supported by pi-ai's common streaming API. Each retained image is preceded by text naming its complete attachment id and actual request dimensions. When the current execution filesystem maps the attachment provider's host object, the text also carries a read-only normalized-object path and warns that normalization or request projection may have resized or re-encoded the upload. Each occurrence a surface replacement marks offloaded keeps its own identity and currently resolved access in replacement text, and its normalized attachment is not read or transformed. When the retained occurrences' exact base64 payload still exceeds the route's `maxRequestImageBytes`, the call fails with `IMAGE_OFFLOAD_REQUIRED` so `dsh-compaction-image-offload` replaces the carrying surface nodes with marked copies and retries the step. Provider-native replay metadata is restored only when the adapter validates it for the historical content. #### Token effect diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index 502390b50d..b1ef96da15 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -176,7 +176,7 @@ pi-ai 不提供的路由需要 `api`、`baseURL` 与非空 `models` 列表;无 #### 模型看到什么 -所选目录模型会收到 `GenerateOptions.system`、历史、工具与 pi-ai 通用流式 API 支持的采样字段。每张保留图片前都会有文本,注明其完整附件 id 与实际请求尺寸。当前执行文件系统可以映射附件提供方的宿主对象时,该文本还会携带只读规范化对象路径,并警告规范化或请求投影可能缩放或重新编码上传内容。表层替换标记为已卸载的每个出现位置都会在替换文本中保留自己的身份与当前已解析访问方式,其规范化附件不会读取或变换。当保留的出现位置按精确 base64 载荷仍超过路由的 `maxRequestImageBytes` 时,调用以 `IMAGE_OFFLOAD_REQUIRED` 失败,由 `dsh-llm-retry` 用带标记的副本替换承载节点并重试 step。提供方原生回放元数据只在适配器针对历史内容校验通过后恢复。 +所选目录模型会收到 `GenerateOptions.system`、历史、工具与 pi-ai 通用流式 API 支持的采样字段。每张保留图片前都会有文本,注明其完整附件 id 与实际请求尺寸。当前执行文件系统可以映射附件提供方的宿主对象时,该文本还会携带只读规范化对象路径,并警告规范化或请求投影可能缩放或重新编码上传内容。表层替换标记为已卸载的每个出现位置都会在替换文本中保留自己的身份与当前已解析访问方式,其规范化附件不会读取或变换。当保留的出现位置按精确 base64 载荷仍超过路由的 `maxRequestImageBytes` 时,调用以 `IMAGE_OFFLOAD_REQUIRED` 失败,由 `dsh-compaction-image-offload` 用带标记的副本替换承载节点并重试 step。提供方原生回放元数据只在适配器针对历史内容校验通过后恢复。 #### Token 影响 diff --git a/packages/llm/llm-retry/README.i18n.yaml b/packages/llm/llm-retry/README.i18n.yaml index 9084498243..295b07d56e 100644 --- a/packages/llm/llm-retry/README.i18n.yaml +++ b/packages/llm/llm-retry/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-retry/README.md -README.md: 52740300adb9e791fb6bcbcd1f4845e684f10667 -README.zh.md: 0855be01fa06e9f2b8194480dcfbb8faf2e54451 +README.md: 0ea815130212a165582540e43aad7d59fad32d2c +README.zh.md: 43a0187c68d272e2764d34db35fa26e51a9d3a83 diff --git a/packages/llm/llm-retry/README.md b/packages/llm/llm-retry/README.md index 52740300ad..0ea8151302 100644 --- a/packages/llm/llm-retry/README.md +++ b/packages/llm/llm-retry/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`@deepseek-ai/dsh-llm-retry` is the retry executor for failed model requests: it applies each provider's resolved retry policy at the agent loop's open-step `agent/request-error` extension point, so every retry re-runs the same step inside the same open turn over the same durable history. It does not wrap the streaming call itself — every adapter call remains one provider attempt, and direct `ctx.llm.stream()` consumers stay single-attempt. Retry scheduling is durable: the plugin appends `llm/retry` events to the session log before waiting, and cancellation during backoff leaves the log consistent. Normal mode retries a bounded set of failure codes up to `maxRetries` with exponential backoff; always mode asks downstream recovery first, then retries every failure without an attempt limit. It also owns one durable repair: an adapter's `IMAGE_OFFLOAD_REQUIRED` failure replaces the surface nodes carrying the count of oldest images the failure names with copies marked `offloaded`, then retries the step outside any provider retry budget. +`@deepseek-ai/dsh-llm-retry` is the retry executor for failed model requests: it applies each provider's resolved retry policy at the agent loop's open-step `agent/request-error` extension point, so every retry re-runs the same step inside the same open turn over the same durable history. It does not wrap the streaming call itself — every adapter call remains one provider attempt, and direct `ctx.llm.stream()` consumers stay single-attempt. Retry scheduling is durable: the plugin appends `llm/retry` events to the session log before waiting, and cancellation during backoff leaves the log consistent. Normal mode retries a bounded set of failure codes up to `maxRetries` with exponential backoff; always mode asks downstream recovery first, then retries every failure without an attempt limit. ## Table of Contents @@ -55,7 +55,7 @@ Each scheduled retry is durable before its wait: the plugin appends a non-surfac ### Failures and recovery -A failure before any final adapter is selected has no provider policy and delegates downstream unchanged. In normal mode, a failure code outside the eligible set, or an exhausted budget, delegates; in always mode, an over-cap provider delay uses the configured local backoff so the policy cannot terminate on that instruction. Nothing here is model-visible: no retry event, delay, provider error, or failed partial output reaches the model or derived messages. An `IMAGE_OFFLOAD_REQUIRED` failure carrying `offloadImages` is handled before any policy: the plugin walks the surface in request order, replaces each node carrying one of that many oldest retained image occurrences with a copy whose occurrences are marked `offloaded` (a `surfaceOp: replace` like compaction's), and returns `retry`; when nothing remains to offload it delegates. That retry spends no retry budget and logs no `llm/retry` event, and the retried request sends placeholder text for the marked images ([decision](../../../.agents/notes/implemented/architecture/2026-09-02-durable-image-offload.md)). +A failure before any final adapter is selected has no provider policy and delegates downstream unchanged. In normal mode, a failure code outside the eligible set, or an exhausted budget, delegates; in always mode, an over-cap provider delay uses the configured local backoff so the policy cannot terminate on that instruction. Nothing here is model-visible: no retry event, delay, provider error, or failed partial output reaches the model or derived messages. ----- @@ -77,13 +77,12 @@ The executor is built on one rule: **durable before wait, open-step boundaries.* |---|---| | [`src/index.ts`](src/index.ts) | The function plugin: waterfall listener, policy lookup, backoff, durable event appends | | [`src/history.ts`](src/history.ts) | Durable retry-history lookup from the session log | -| [`src/image-offload.ts`](src/image-offload.ts) | The surface replacements that offload images for `IMAGE_OFFLOAD_REQUIRED` failures | | [`src/types.ts`](src/types.ts) | Browser-safe `llm/retry` and `llm/retry-started` event payload types | | [`src/brand.ts`](src/brand.ts) | The `RetryId` brand shared by the event payloads | ### Recovery flow -A failed step arrives on the waterfall with its provider and resolved policy. An `IMAGE_OFFLOAD_REQUIRED` failure naming `offloadImages` is repaired at once: the carrying surface nodes are replaced and the step retries with no delay. Always mode settles downstream recovery first and honors a downstream `retry` decision; normal mode first checks that the failure code is eligible and the budget is not exhausted. The plugin computes the delay — provider `Retry-After` when valid and within bounds, otherwise local bounded exponential backoff with symmetric jitter — appends the `llm/retry` event, waits on a cancellable timer, appends `llm/retry-started`, and returns `{ kind: 'retry' }`. The loop then re-runs the failed step inside the same open turn over the same durable history. +A failed step arrives on the waterfall with its provider and resolved policy. Always mode settles downstream recovery first and honors a downstream `retry` decision; normal mode first checks that the failure code is eligible and the budget is not exhausted. The plugin computes the delay — provider `Retry-After` when valid and within bounds, otherwise local bounded exponential backoff with symmetric jitter — appends the `llm/retry` event, waits on a cancellable timer, appends `llm/retry-started`, and returns `{ kind: 'retry' }`. The loop then re-runs the failed step inside the same open turn over the same durable history. ### Waterfall composition @@ -113,7 +112,7 @@ Read these pages when the package-level contract is not enough. They move from t #### What the model sees -No retry event, delay, provider error, or failed partial output is model-visible. The retried step reconstructs the same explicit provider/model request from durable surface history unless a downstream recovery policy deliberately changes that surface; failed chunks never enter derived messages. An `IMAGE_OFFLOAD_REQUIRED` repair is the one exception: the retried request replaces the offloaded images with placeholder text naming each image and its read-only path. +No retry event, delay, provider error, or failed partial output is model-visible. The retried step reconstructs the same explicit provider/model request from durable surface history unless a downstream recovery policy deliberately changes that surface; failed chunks never enter derived messages. #### Token effect diff --git a/packages/llm/llm-retry/README.zh.md b/packages/llm/llm-retry/README.zh.md index 0855be01fa..43a0187c68 100644 --- a/packages/llm/llm-retry/README.zh.md +++ b/packages/llm/llm-retry/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`@deepseek-ai/dsh-llm-retry` 是失败模型请求的重试执行器:它在 agent loop 的打开步骤 `agent/request-error` 扩展点上应用各提供方解析后的重试策略,因此每次重试都会在同一个打开的轮次内重跑同一个步骤(基于同一份持久历史)。它不包装流式调用本身——每次适配器调用仍是一次提供方尝试,直接 `ctx.llm.stream()` 消费方仍是单次尝试。重试调度是持久的:插件在等待之前就把 `llm/retry` 事件追加进会话日志,退避期间取消会让日志保持一致。normal mode 以指数退避重试一组有界的失败 code,最多 `maxRetries` 次;always mode 先询问下游恢复,然后无尝试上限地重试每个失败。它还负责一种持久修复:adapter 以 `IMAGE_OFFLOAD_REQUIRED` 失败时,把承载失败所报数量的最老图片的表层节点替换为标了 `offloaded` 的副本,再重试该步骤,不占任何提供方重试预算。 +`@deepseek-ai/dsh-llm-retry` 是失败模型请求的重试执行器:它在 agent loop 的打开步骤 `agent/request-error` 扩展点上应用各提供方解析后的重试策略,因此每次重试都会在同一个打开的轮次内重跑同一个步骤(基于同一份持久历史)。它不包装流式调用本身——每次适配器调用仍是一次提供方尝试,直接 `ctx.llm.stream()` 消费方仍是单次尝试。重试调度是持久的:插件在等待之前就把 `llm/retry` 事件追加进会话日志,退避期间取消会让日志保持一致。normal mode 以指数退避重试一组有界的失败 code,最多 `maxRetries` 次;always mode 先询问下游恢复,然后无尝试上限地重试每个失败。 ## 目录 @@ -55,7 +55,7 @@ kind: "package-reference" ### 失败与恢复 -在任何最终适配器被选中之前发生的失败没有提供方策略,原样委派下游。normal mode 中,不在合格集合内的失败 code 或已耗尽的预算会委派;always mode 中,超上限的提供方延迟使用配置的本地退避,因此策略不会因该指令终止。这里没有任何模型可见内容:重试事件、延迟、提供方错误或失败的部分输出都不会到达模型或派生消息。带 `offloadImages` 的 `IMAGE_OFFLOAD_REQUIRED` 失败在任何策略之前处理:插件按请求顺序遍历表层,把承载那些最老保留图片的每个节点替换为标了 `offloaded` 的副本(和 compaction 一样的 `surfaceOp: replace`),然后返回 `retry`;没有可省略的图片时委派下游。这次重试不占重试预算,也不追加 `llm/retry` 事件,重试的请求为被标记的图片发送占位文本([决定](../../../.agents/notes/implemented/architecture/2026-09-02-durable-image-offload.zh.md))。 +在任何最终适配器被选中之前发生的失败没有提供方策略,原样委派下游。normal mode 中,不在合格集合内的失败 code 或已耗尽的预算会委派;always mode 中,超上限的提供方延迟使用配置的本地退避,因此策略不会因该指令终止。这里没有任何模型可见内容:重试事件、延迟、提供方错误或失败的部分输出都不会到达模型或派生消息。 ----- @@ -77,13 +77,12 @@ kind: "package-reference" |---|---| | [`src/index.ts`](src/index.ts) | 函数插件:waterfall 监听器、策略查找、退避、持久事件追加 | | [`src/history.ts`](src/history.ts) | 从会话日志查找持久重试历史 | -| [`src/image-offload.ts`](src/image-offload.ts) | 为 `IMAGE_OFFLOAD_REQUIRED` 失败省略图片的表层替换 | | [`src/types.ts`](src/types.ts) | 浏览器安全的 `llm/retry` 与 `llm/retry-started` 事件载荷类型 | | [`src/brand.ts`](src/brand.ts) | 事件载荷共享的 `RetryId` 品牌 | ### 恢复流程 -失败步骤连同其提供方与解析后的策略一起到达 waterfall。带 `offloadImages` 的 `IMAGE_OFFLOAD_REQUIRED` 失败会立即修复:替换承载节点,步骤无延迟重试。always mode 先结算下游恢复,并遵循下游的 `retry` 决定;normal mode 先检查失败 code 是否合格、预算是否未耗尽。插件计算延迟——有效且在边界内的提供方 `Retry-After`,否则带对称抖动的本地有界指数退避——追加 `llm/retry` 事件,在可取消定时器上等待,追加 `llm/retry-started`,然后返回 `{ kind: 'retry' }`。loop 随后在同一个打开的轮次内重跑失败步骤(仍基于同一份持久历史)。 +失败步骤连同其提供方与解析后的策略一起到达 waterfall。always mode 先结算下游恢复,并遵循下游的 `retry` 决定;normal mode 先检查失败 code 是否合格、预算是否未耗尽。插件计算延迟——有效且在边界内的提供方 `Retry-After`,否则带对称抖动的本地有界指数退避——追加 `llm/retry` 事件,在可取消定时器上等待,追加 `llm/retry-started`,然后返回 `{ kind: 'retry' }`。loop 随后在同一个打开的轮次内重跑失败步骤(仍基于同一份持久历史)。 ### Waterfall 组合 @@ -113,7 +112,7 @@ kind: "package-reference" #### 模型看到什么 -重试事件、延迟、提供方错误或失败的部分输出都不会对模型可见。除非下游恢复策略刻意改变表面,重试步骤从持久表面历史重建同一显式提供方/模型请求;失败分片绝不进入派生消息。`IMAGE_OFFLOAD_REQUIRED` 修复是唯一的例外:重试的请求把被省略的图片替换为注明图片及其只读路径的占位文本。 +重试事件、延迟、提供方错误或失败的部分输出都不会对模型可见。除非下游恢复策略刻意改变表面,重试步骤从持久表面历史重建同一显式提供方/模型请求;失败分片绝不进入派生消息。 #### Token 影响 diff --git a/packages/llm/llm-retry/src/index.ts b/packages/llm/llm-retry/src/index.ts index 0971ff6263..89ffc14c40 100644 --- a/packages/llm/llm-retry/src/index.ts +++ b/packages/llm/llm-retry/src/index.ts @@ -10,11 +10,9 @@ import type { Context, Events } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { z as zod } from 'zod' import type { Agent, RequestErrorAction } from '@deepseek-ai/dsh-agent' -import { IMAGE_OFFLOAD_REQUIRED_CODE } from '@deepseek-ai/dsh-llm' import type { LlmFailure, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-session-projection' import { RetryId } from './brand.ts' -import { offloadOldestImages } from './image-offload.ts' import type { LlmRetryEventData } from './types.ts' export type { LlmRetryEventData, LlmRetryStartedEventData } from './types.ts' @@ -197,11 +195,6 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna { agent, turn, step, provider, failure, retryPolicy: policy, signal }: Parameters[0], next: () => Promise, ): Promise { - // A durable surface repair, not a provider retry: it spends no retry budget and logs no retry event. - if (failure.code === IMAGE_OFFLOAD_REQUIRED_CODE && failure.offloadImages !== undefined - && offloadOldestImages(agent.session, failure.offloadImages)) { - return { kind: 'retry' } - } if (policy === undefined) return next() if (policy.mode === 'always') { if (signal.aborted || lifetime.signal.aborted) return diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml index 5c59f00429..322f93abd6 100644 --- a/packages/llm/llm/README.i18n.yaml +++ b/packages/llm/llm/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm/README.md -README.md: 398c6e17d5b5da631a48d510844b2156a8f0579d -README.zh.md: 83293a94d5e5560ef1f5efeee4d4c3db038e9be4 +README.md: a6f20a157fbd1bdeb3293b06739c009b93ab46a3 +README.zh.md: 9b24bfc3e83928929436b5ca19d09902559a6c3e diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index 398c6e17d5..a6f20a157f 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -101,7 +101,7 @@ The service is built on one separation: **the logical contract is provider-neutr ### Main flow -A request is validated against its exact model's capability — context window, output default, reasoning efforts, and input modalities — and any adapter-configured defaults are materialized, then the whole request is deep-frozen. `prepareCall()` binds those facts, detached context, and retry policy to the exact adapter generation that performs terminal dispatch, so HMR or dynamic settings cannot combine one generation's image capability with another generation's endpoint. An image-capable adapter projects durable references into route-specific request versions; `resolveImageAttachmentAccess()` separately maps an attachment provider's optional host object into the current tool execution world without changing the request image or its `variantId`. A text-only route receives deterministic per-image placeholders, including nested tool-result images, without rewriting append-only session history. Durable `FileBlock` references never reach any adapter: request assembly replaces each one, nested tool-result occurrences included, with deterministic handle text naming the file and its saved read-only path, resolved through the mounted attachment and filesystem providers. `ctx.llm.fileRequestText(ref)` exposes that exact synchronous projection to request measurement. An image occurrence a surface replacement marked `offloaded: true` reaches every route as placeholder text through `projectOffloadedImages()`. An image-capable route whose retained occurrences exceed its `LlmImageRequestBudget` at their exact bytes fails with `IMAGE_OFFLOAD_REQUIRED` naming the additional oldest occurrences (`requiredImageOffload()`), never with an unlogged projection; `dsh-llm-retry` replaces the carrying surface nodes with marked copies and retries. Adapters that charge visual tokens declare per-route `imageRequestPricing`, which `ctx.llm.imageRequestPricing(provider, model)` resolves synchronously for the token meter. Dispatch goes through the `llm/stream` waterfall, then chunks return as token-level deltas and every adapter outcome reaches the consumer as one terminal `finish` chunk. +A request is validated against its exact model's capability — context window, output default, reasoning efforts, and input modalities — and any adapter-configured defaults are materialized, then the whole request is deep-frozen. `prepareCall()` binds those facts, detached context, and retry policy to the exact adapter generation that performs terminal dispatch, so HMR or dynamic settings cannot combine one generation's image capability with another generation's endpoint. An image-capable adapter projects durable references into route-specific request versions; `resolveImageAttachmentAccess()` separately maps an attachment provider's optional host object into the current tool execution world without changing the request image or its `variantId`. A text-only route receives deterministic per-image placeholders, including nested tool-result images, without rewriting append-only session history. Durable `FileBlock` references never reach any adapter: request assembly replaces each one, nested tool-result occurrences included, with deterministic handle text naming the file and its saved read-only path, resolved through the mounted attachment and filesystem providers. `ctx.llm.fileRequestText(ref)` exposes that exact synchronous projection to request measurement. An image occurrence a surface replacement marked `offloaded: true` reaches every route as placeholder text through `projectOffloadedImages()`. An image-capable route whose retained occurrences exceed its `LlmImageRequestBudget` at their exact bytes fails with `IMAGE_OFFLOAD_REQUIRED` naming the additional oldest occurrences (`requiredImageOffload()`), never with an unlogged projection; `dsh-compaction-image-offload` replaces the carrying surface nodes with marked copies and retries. Adapters that charge visual tokens declare per-route `imageRequestPricing`, which `ctx.llm.imageRequestPricing(provider, model)` resolves synchronously for the token meter. Dispatch goes through the `llm/stream` waterfall, then chunks return as token-level deltas and every adapter outcome reaches the consumer as one terminal `finish` chunk. ### Invariants diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md index 83293a94d5..9b24bfc3e8 100644 --- a/packages/llm/llm/README.zh.md +++ b/packages/llm/llm/README.zh.md @@ -101,7 +101,7 @@ for await (const chunk of ctx.llm.stream({ ### 主流程 -请求会对照其精确模型的能力——上下文窗口、输出默认值、推理强度与输入模态——校验,填入任何适配器配置的默认值,然后整个请求被深度冻结。`prepareCall()` 把这些事实、分离的上下文与重试策略绑定到执行最终分发的精确适配器代次,因此 HMR 或动态设置无法把一个代次的图片能力与另一代次的端点混用。支持图片的适配器把持久引用投影为路由专用请求版本;`resolveImageAttachmentAccess()` 会单独把附件提供方的可选宿主对象映射进当前工具执行世界,而不改变请求图片或其 `variantId`。纯文本路由接收确定性的逐图片占位符,包括嵌套工具结果图片,而不会改写仅追加会话历史。持久 `FileBlock` 引用永远不会到达任何适配器:请求组装把每个引用(包括嵌套工具结果中的出现)替换为确定性 handle 文本,指出文件与其只读保存路径,路径经由挂载的附件与文件系统提供方解析。`ctx.llm.fileRequestText(ref)` 向请求计量公开相同的同步投影。表层替换标为 `offloaded: true` 的图片出现位置,经 `projectOffloadedImages()` 以占位文本到达每条路由。支持图片的路由在保留的出现位置按精确字节超过其 `LlmImageRequestBudget` 时,以 `IMAGE_OFFLOAD_REQUIRED` 失败并说明还需省略多少最老的出现位置(`requiredImageOffload()`),绝不发送未记录的投影;`dsh-llm-retry` 用带标记的副本替换承载这些图片的表层节点并重试。对视觉 token 收费的适配器声明按路由的 `imageRequestPricing`,`ctx.llm.imageRequestPricing(provider, model)` 为 token meter 同步解析它。分发经过 `llm/stream` waterfall,随后分片以 token 级增量返回,每个适配器结果都以唯一一个终止 `finish` 分片到达消费方。 +请求会对照其精确模型的能力——上下文窗口、输出默认值、推理强度与输入模态——校验,填入任何适配器配置的默认值,然后整个请求被深度冻结。`prepareCall()` 把这些事实、分离的上下文与重试策略绑定到执行最终分发的精确适配器代次,因此 HMR 或动态设置无法把一个代次的图片能力与另一代次的端点混用。支持图片的适配器把持久引用投影为路由专用请求版本;`resolveImageAttachmentAccess()` 会单独把附件提供方的可选宿主对象映射进当前工具执行世界,而不改变请求图片或其 `variantId`。纯文本路由接收确定性的逐图片占位符,包括嵌套工具结果图片,而不会改写仅追加会话历史。持久 `FileBlock` 引用永远不会到达任何适配器:请求组装把每个引用(包括嵌套工具结果中的出现)替换为确定性 handle 文本,指出文件与其只读保存路径,路径经由挂载的附件与文件系统提供方解析。`ctx.llm.fileRequestText(ref)` 向请求计量公开相同的同步投影。表层替换标为 `offloaded: true` 的图片出现位置,经 `projectOffloadedImages()` 以占位文本到达每条路由。支持图片的路由在保留的出现位置按精确字节超过其 `LlmImageRequestBudget` 时,以 `IMAGE_OFFLOAD_REQUIRED` 失败并说明还需省略多少最老的出现位置(`requiredImageOffload()`),绝不发送未记录的投影;`dsh-compaction-image-offload` 用带标记的副本替换承载这些图片的表层节点并重试。对视觉 token 收费的适配器声明按路由的 `imageRequestPricing`,`ctx.llm.imageRequestPricing(provider, model)` 为 token meter 同步解析它。分发经过 `llm/stream` waterfall,随后分片以 token 级增量返回,每个适配器结果都以唯一一个终止 `finish` 分片到达消费方。 ### 不变式 diff --git a/packages/llm/llm/src/error.ts b/packages/llm/llm/src/error.ts index 6c5f7ad5b4..b762c41afa 100644 --- a/packages/llm/llm/src/error.ts +++ b/packages/llm/llm/src/error.ts @@ -165,8 +165,9 @@ export function isHarnessError(value: unknown): value is HarnessError { /** * Canonical code for a request an image-capable route cannot send until more * of its images are offloaded. The failure's `offloadImages` names how many - * more of the oldest retained occurrences must be offloaded; `dsh-llm-retry` - * replaces the surface nodes carrying them with copies marked `offloaded` and - * retries the step, so the model never receives an unlogged projection. + * more of the oldest retained occurrences must be offloaded; + * `dsh-compaction-image-offload` replaces the surface nodes carrying them + * with copies marked `offloaded` and retries the step, so the model never + * receives an unlogged projection. */ export const IMAGE_OFFLOAD_REQUIRED_CODE = 'IMAGE_OFFLOAD_REQUIRED' diff --git a/packages/llm/llm/src/types.ts b/packages/llm/llm/src/types.ts index b0df82c35b..4df39ac68f 100644 --- a/packages/llm/llm/src/types.ts +++ b/packages/llm/llm/src/types.ts @@ -51,9 +51,9 @@ export interface LlmFailure { /** * With code `IMAGE_OFFLOAD_REQUIRED`: how many more of the oldest retained * image occurrences the route needs offloaded before the same request fits - * its exact byte accounting. `dsh-llm-retry` replaces the surface nodes - * carrying that many oldest retained occurrences with copies marked - * `offloaded` and retries the step. + * its exact byte accounting. `dsh-compaction-image-offload` replaces the + * surface nodes carrying that many oldest retained occurrences with copies + * marked `offloaded` and retries the step. */ readonly offloadImages?: number } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b205881126..bacea3f486 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1340,6 +1340,9 @@ importers: '@deepseek-ai/dsh-compaction-basic': specifier: workspace:^ version: link:../../compaction/compaction-basic + '@deepseek-ai/dsh-compaction-image-offload': + specifier: workspace:^ + version: link:../../compaction/compaction-image-offload '@deepseek-ai/dsh-compaction-tool-result-pruner': specifier: workspace:^ version: link:../../compaction/compaction-tool-result-pruner @@ -4605,6 +4608,40 @@ importers: specifier: workspace:^ version: link:../../core/tools + packages/compaction/compaction-image-offload: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../test-support/agent-loop-testkit + '@deepseek-ai/dsh-compaction': + specifier: workspace:^ + version: link:../compaction + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection + '@deepseek-ai/dsh-token-meter': + specifier: workspace:^ + version: link:../../llm/token-meter + packages/compaction/compaction-tool-result-pruner: dependencies: '@deepseek-ai/dsh-util-values': @@ -10715,6 +10752,9 @@ importers: '@deepseek-ai/dsh-compaction-basic': specifier: workspace:^ version: link:../../packages/compaction/compaction-basic + '@deepseek-ai/dsh-compaction-image-offload': + specifier: workspace:^ + version: link:../../packages/compaction/compaction-image-offload '@deepseek-ai/dsh-compaction-tool-result-pruner': specifier: workspace:^ version: link:../../packages/compaction/compaction-tool-result-pruner @@ -17139,13 +17179,13 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standardwebhooks@1.1.1: + resolution: {integrity: sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==} + stat-mode@1.0.0: resolution: {integrity: sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==} engines: {node: '>= 6'} - standardwebhooks@1.1.1: - resolution: {integrity: sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==} - statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} @@ -24257,13 +24297,13 @@ snapshots: stackback@0.0.2: {} - stat-mode@1.0.0: {} - standardwebhooks@1.1.1: dependencies: '@stablelib/base64': 1.0.1 fast-sha256: 1.3.0 + stat-mode@1.0.0: {} + statuses@2.0.2: {} std-env@4.1.0: {} diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index d34b14bec4..2457f6cf05 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -31,6 +31,7 @@ "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-compaction": "workspace:^", "@deepseek-ai/dsh-compaction-basic": "workspace:^", + "@deepseek-ai/dsh-compaction-image-offload": "workspace:^", "@deepseek-ai/dsh-compaction-tool-result-pruner": "workspace:^", "@deepseek-ai/dsh-cordis-host-runner": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", diff --git a/snapshots/sdk/inline-image-prompt/session.v2.jsonl b/snapshots/sdk/inline-image-prompt/session.v2.jsonl index a4af1cdade..1e04205be3 100644 --- a/snapshots/sdk/inline-image-prompt/session.v2.jsonl +++ b/snapshots/sdk/inline-image-prompt/session.v2.jsonl @@ -11,9 +11,10 @@ {"type":"session/title","data":{"title":"Inspect these images, then reply","messageSeqs":[7],"source":{"kind":"fallback"}}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"}} -{"type":"assistant/attempt","data":{"turn":1,"step":1,"stream":[{"type":"chunk","time":1788839885455,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"DeepSeek base64 request images exceed the route budget; 3 more oldest occurrence(s) must be offloaded.","code":"IMAGE_OFFLOAD_REQUIRED","offloadImages":3}}}}]}} +{"type":"assistant/attempt","data":{"turn":1,"step":1,"stream":[{"type":"chunk","time":1788851314955,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"DeepSeek base64 request images exceed the route budget; 3 more oldest occurrence(s) must be offloaded.","code":"IMAGE_OFFLOAD_REQUIRED","offloadImages":3}}}}]}} +{"type":"compaction/prune","data":{"shadowedRange":{"start":7,"end":7},"shadowedSeqs":[7],"shadowedTokenCount":318}} {"type":"user/message","data":{"content":[{"type":"text","text":"Inspect these images, then reply with exactly "},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69},"offloaded":true},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69},"offloaded":true},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69},"offloaded":true},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"text","text":"the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"sourceEventSeqs":[7],"surfaceOp":{"op":"replace","start":7,"end":7}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"system":"{{system}}","tools":"{{tools}}"},"reason":"series"}} -{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:3}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1788839885461,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"chunk","time":1788839885461,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1788839885461,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1788839885461,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:3}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1788851314960,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"chunk","time":1788851314960,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1788851314960,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1788851314960,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/tsconfig.base.json b/tsconfig.base.json index 04c09303a1..1eeeca67bc 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -279,6 +279,7 @@ "@deepseek-ai/dsh-compaction": ["./packages/compaction/compaction/src"], "@deepseek-ai/dsh-compaction/invariant": ["./packages/compaction/compaction/src/invariant.ts"], "@deepseek-ai/dsh-compaction-basic": ["./packages/compaction/compaction-basic/src"], + "@deepseek-ai/dsh-compaction-image-offload": ["./packages/compaction/compaction-image-offload/src"], "@deepseek-ai/dsh-compaction-tool-result-pruner": ["./packages/compaction/compaction-tool-result-pruner/src"], "@deepseek-ai/dsh-credentials": ["./packages/credentials/credentials/src"], "@deepseek-ai/dsh-credentials/invariant": ["./packages/credentials/credentials/src/invariant.ts"], diff --git a/tsconfig.host.json b/tsconfig.host.json index 1c68bd9d0b..fadbb85364 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -269,6 +269,7 @@ { "path": "./packages/compaction/compaction-basic" }, { "path": "./packages/compaction/command-compact" }, { "path": "./packages/compaction/compaction-tool-result-pruner" }, + { "path": "./packages/compaction/compaction-image-offload" }, { "path": "./packages/web/web" }, { "path": "./packages/web/web-search-exa" }, { "path": "./packages/web/web-search-perplexity" }, From 18739de7c62b9cfa25c1fffd056e8c7c83e8960b Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 9 Sep 2026 16:28:17 +0800 Subject: [PATCH 16/79] feat(client): improve trajectory JSON string display --- .../ui-primitives/src/JsonTree.module.css | 133 +++++++-- .../client/ui-primitives/src/JsonTree.tsx | 259 ++++++++++-------- .../src/client/TrajectoryTable.tsx | 9 +- 3 files changed, 273 insertions(+), 128 deletions(-) diff --git a/packages/client/ui-primitives/src/JsonTree.module.css b/packages/client/ui-primitives/src/JsonTree.module.css index 33ec3f30d3..59ed4ba033 100644 --- a/packages/client/ui-primitives/src/JsonTree.module.css +++ b/packages/client/ui-primitives/src/JsonTree.module.css @@ -29,17 +29,17 @@ .container { box-sizing: border-box; - width: max-content; - min-width: 100%; + width: 100%; + min-width: 0; margin: 0; - padding: 6px 8px 8px; + padding: 6px 8px 8px 8px; white-space: pre; } .expandedTopLevel { box-sizing: border-box; - width: max-content; - min-width: 100%; + width: 100%; + min-width: 0; padding: 6px 8px 8px 14px; } @@ -66,11 +66,13 @@ .row { position: relative; box-sizing: border-box; - min-width: 100%; + min-width: 0; min-height: 16px; margin: 0; padding: 0 0 0 10px; list-style: none; + overflow-wrap: anywhere; + white-space: pre-wrap; } .row:not(.topLevelBracket):hover:not(:has(.row:hover))::after, @@ -81,13 +83,13 @@ top: 0; right: 0; left: 0; - height: 16px; + height: var(--json-tree-row-height, 16px); background: var(--json-tree-hover); content: ''; pointer-events: none; } -.row > span:not(.expander) { +.row > span:not(.expander, .copySlot) { position: relative; z-index: 1; } @@ -106,6 +108,74 @@ color: var(--json-tree-string); } +.stringField { + display: flow-root; +} + +.stringText { + display: block; + max-height: calc(var(--json-tree-collapsed-lines) * 16px); + padding-left: 12px; + overflow: hidden; + text-indent: -12px; + word-break: break-all; +} + +.stringField[data-expanded='true'] > .stringText { + max-height: none; + overflow: visible; +} + +.stringToggleSlot { + display: flex; + float: right; + align-items: flex-end; + height: calc(var(--json-tree-collapsed-lines) * 16px); + margin-left: 2px; + shape-outside: inset(calc((var(--json-tree-collapsed-lines) - 1) * 16px) 0 0); + pointer-events: none; +} + +.stringToggle { + display: inline-flex; + align-items: baseline; + gap: 2px; + margin: 0; + padding: 0; + border: 0; + color: var(--dsw-alias-label-secondary); + background: transparent; + font: inherit; + text-indent: 0; + white-space: nowrap; + cursor: pointer; + pointer-events: auto; +} + +.stringCollapse { + margin-left: 4px; +} + +.stringToggle:hover { + color: var(--dsw-alias-label-primary); +} + +.stringToggle:focus-visible { + outline: 1px solid var(--dsw-alias-state-business-primary); + outline-offset: 1px; +} + +.row:has(> .stringField) { + --json-tree-row-height: 100%; +} + +.summary { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .numberValue { color: var(--json-tree-number); } @@ -134,13 +204,42 @@ color: var(--dsw-alias-label-tertiary); } -.copyAnchor { - position: fixed; - z-index: 3; +.copySlot { display: inline-flex; + width: 20px; + height: 16px; + opacity: 0; + pointer-events: none; } -.copyButton { +.row > .copySlot { + position: absolute; + z-index: 2; + top: 0; + right: 0; +} + +.row:has(> .stringField) > .copySlot { + top: auto; + bottom: 0; +} + +.row:has(> .copySlot) > .stringField .stringToggleSlot { + padding-right: 24px; +} + +.row:has(> .copySlot) > .stringField .stringCollapse { + margin-right: 24px; +} + +.row:hover:not(:has(.row:hover)) > .copySlot, +.row[data-json-copy-active] > .copySlot, +.copySlot:focus-within { + opacity: 1; + pointer-events: auto; +} + +.actionButton { box-sizing: border-box; display: inline-flex; align-items: center; @@ -152,22 +251,20 @@ border: 0; border-radius: 3px; color: var(--dsw-alias-label-secondary); - background: var(--dsw-alias-bg-layer-1); - box-shadow: -5px 0 5px var(--dsw-alias-bg-layer-1); + background: transparent; cursor: pointer; } -.copyButton:hover { +.actionButton:hover { color: var(--dsw-alias-label-primary); - background: var(--dsw-alias-interactive-bg-hover); } -.copyButton:focus-visible { +.actionButton:focus-visible { outline: 1px solid var(--dsw-alias-state-business-primary); outline-offset: -1px; } -.copyButton[data-state='failed'] { +.actionButton[data-state='failed'] { color: var(--dsw-alias-state-error-primary); } diff --git a/packages/client/ui-primitives/src/JsonTree.tsx b/packages/client/ui-primitives/src/JsonTree.tsx index 8399f3681e..064e8c323e 100644 --- a/packages/client/ui-primitives/src/JsonTree.tsx +++ b/packages/client/ui-primitives/src/JsonTree.tsx @@ -1,10 +1,10 @@ import clsx from 'clsx' -import { useEffect, useId, useRef, useState } from 'react' +import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react' import type { + CSSProperties, KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent, ReactNode, - UIEvent as ReactUIEvent, } from 'react' import { IconCheckOutline16, IconCopyOutline16 } from './icons/index.tsx' import { Menu } from './Menu.tsx' @@ -65,12 +65,6 @@ interface RowTarget { value: unknown } -interface CopyTarget extends RowTarget { - left: number - side: 'bottom' | 'top' - top: number -} - function isExpandableValue(value: unknown): value is object | unknown[] { return typeof value === 'object' && value !== null && !(value instanceof Date) } @@ -224,6 +218,7 @@ function NodeField({ } interface JsonTreeNodeProps { + collapsedStringLines: number field?: string initialExpanded: boolean labels: JsonTreeLabels @@ -231,11 +226,81 @@ interface JsonTreeNodeProps { onClaimTabStop: (id: string) => void onRowHover: (row: HTMLElement, target: RowTarget) => void path: JsonPath + renderCopy: ((path: JsonPath) => ReactNode) | undefined tabStopId: string | null value: unknown } +function JsonString({ + collapsedStringLines, + field, + labels, + lastElement, + value, +}: { + collapsedStringLines: number + field: string | undefined + labels: JsonTreeLabels + lastElement: boolean + value: string +}) { + const contentsId = useId() + const contentRef = useRef(null) + const [expanded, setExpanded] = useState(false) + const [truncated, setTruncated] = useState(false) + + useLayoutEffect(() => { + const content = contentRef.current as HTMLSpanElement + const measure = () => { + const lineHeight = Number.parseFloat(getComputedStyle(content).lineHeight) + setTruncated(content.scrollHeight > lineHeight * collapsedStringLines) + } + measure() + if (typeof ResizeObserver === 'undefined') return + const observer = new ResizeObserver(measure) + observer.observe(content) + return () => { observer.disconnect() } + }, [collapsedStringLines, field, lastElement, value]) + + return ( + + + {truncated && !expanded && ( + + + + )} + {field !== undefined && {fieldText(field)}:} + {primitiveValue(value)} + {!lastElement && ,} + {truncated && expanded && ( + + )} + + + ) +} + function JsonTreeNode({ + collapsedStringLines, field, initialExpanded, labels, @@ -243,6 +308,7 @@ function JsonTreeNode({ onClaimTabStop, onRowHover, path, + renderCopy, tabStopId, value, }: JsonTreeNodeProps) { @@ -281,10 +347,23 @@ function JsonTreeNode({ onRowHover(event.currentTarget, { path, value }) }} > + {renderCopy?.(path)} {children} ) + if (typeof value === 'string') { + return row( + , + ) + } + if (!container) { return row(( <> @@ -322,14 +401,17 @@ function JsonTreeNode({ onClick={toggle} onKeyDown={onExpanderKeyDown} /> - - {previewValue(value, 0)} - {!lastElement && ,} + + + {previewValue(value, 0)} + {!lastElement && ,} + {expanded && (
    {entries.map(([key, item], index) => ( ))}
@@ -356,7 +439,7 @@ function formattedPath(path: JsonPath): string { }, '$') } -function copyText(target: CopyTarget, mode: 'json' | 'path' | 'prettyJson' | 'value'): string { +function copyText(target: RowTarget, mode: 'json' | 'path' | 'prettyJson' | 'value'): string { if (mode === 'path') return formattedPath(target.path) if (mode === 'prettyJson') return JSON.stringify(target.value, null, 2) if (mode === 'json') return JSON.stringify(target.value) @@ -376,6 +459,8 @@ export interface JsonTreeProps { label: string /** Optional positioning class owned by the caller. */ className?: string | undefined + /** Maximum visible lines per collapsed string; defaults to 3. */ + collapsedStringLines?: number /** Whether JSON rows expose copy actions. */ copyable?: boolean /** Whether the top-level object or array is always expanded. */ @@ -393,6 +478,7 @@ export function JsonTree({ data, label, className, + collapsedStringLines = 3, copyable = true, expandTopLevel = true, labels, @@ -407,12 +493,11 @@ export function JsonTree({ ? null : pathId([Array.isArray(data) ? firstExpandableIndex : firstExpandableEntry[0]]) : isExpandableValue(data) && rootEntries.length > 0 ? pathId([]) : null - const rootRef = useRef(null) const activeRowRef = useRef() const copyButtonRef = useRef(null) const copyMenuOpenRef = useRef(false) const resetTimer = useRef>() - const [copyTarget, setCopyTarget] = useState() + const [copyTarget, setCopyTarget] = useState() const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>('idle') const [copyMenuOpen, setCopyMenuOpen] = useState(false) const [tabStopId, setTabStopId] = useState(initialTabStopId) @@ -431,33 +516,6 @@ export function JsonTree({ setCopyMenuOpen(false) } - const copyPosition = (row: HTMLElement): Pick => { - const root = rootRef.current - /* v8 ignore next -- row events and viewport listeners run only after the root ref mounts. */ - if (root === null) throw new Error('JsonTree root is not mounted') - const rootRect = root.getBoundingClientRect() - const rowRect = row.getBoundingClientRect() - return { - left: rootRect.left + root.clientWidth - 26, - side: rowRect.top - rootRect.top > root.clientHeight / 2 ? 'top' : 'bottom', - top: rowRect.top, - } - } - - const positionCopyButton = (row: HTMLElement, target: RowTarget) => { - const position = copyPosition(row) - setCopyTarget({ ...target, ...position }) - } - - const repositionCopyButton = (row: HTMLElement) => { - const position = copyPosition(row) - setCopyTarget((current) => { - /* v8 ignore next -- an active row and its copy target are installed together. */ - if (current === undefined) return current - return { ...current, ...position } - }) - } - useEffect(() => () => { if (resetTimer.current !== undefined) clearTimeout(resetTimer.current) activeRowRef.current?.removeAttribute('data-json-copy-active') @@ -473,19 +531,6 @@ export function JsonTree({ setTabStopId(initialTabStopId) }, [data, expandTopLevel, initialTabStopId]) - useEffect(() => { - const reposition = () => { - const row = activeRowRef.current - if (row !== undefined) repositionCopyButton(row) - } - window.addEventListener('scroll', reposition, true) - window.addEventListener('resize', reposition) - return () => { - window.removeEventListener('scroll', reposition, true) - window.removeEventListener('resize', reposition) - } - }, []) - const handleRowHover = (row: HTMLElement, target: RowTarget) => { if (!copyable || copyMenuOpenRef.current) return if (activeRowRef.current === row) return @@ -493,7 +538,7 @@ export function JsonTree({ setCopyState('idle') copyMenuOpenRef.current = false setCopyMenuOpen(false) - positionCopyButton(row, target) + setCopyTarget(target) } const handleRootMouseOver = (event: ReactMouseEvent) => { @@ -503,11 +548,6 @@ export function JsonTree({ if (event.target.closest('[data-json-copy-button]') === null) clearCopyTarget() } - const handleScroll = (_event: ReactUIEvent) => { - const row = activeRowRef.current - if (row !== undefined) repositionCopyButton(row) - } - const copy = async (mode: 'json' | 'path' | 'prettyJson' | 'value') => { /* v8 ignore next -- copy controls only render while their target exists. */ if (copyTarget === undefined) return @@ -530,15 +570,59 @@ export function JsonTree({ ? labels.copyFailed : copyTargetIsObject ? labels.copyPrettyJson : labels.copyValue + const renderCopy = copyable ? (path: JsonPath) => ( + + {copyTarget !== undefined && pathId(copyTarget.path) === pathId(path) && ( + void copy(defaultCopyMode)} + onContextMenu={(event) => { + event.preventDefault() + event.stopPropagation() + copyMenuOpenRef.current = true + setCopyMenuOpen(true) + }} + > + {copyState === 'copied' + ? + : } + + )} + items={copyTargetIsObject ? objectCopyMenuItems(labels) : valueCopyMenuItems(labels)} + onSelect={(id) => { + void copy(id as 'json' | 'path' | 'prettyJson' | 'value') + copyMenuOpenRef.current = false + setCopyMenuOpen(false) + }} + onClose={clearCopyTarget} + getAnchorRect={() => ( + copyButtonRef.current as HTMLButtonElement + ).getBoundingClientRect()} + /> + )} + + ) : undefined + return (
{ if (!copyMenuOpenRef.current) clearCopyTarget() }} - onScroll={handleScroll} > {expandTopLevel ? ( @@ -551,6 +635,7 @@ export function JsonTree({ handleRowHover(event.currentTarget, { path: [], value: data }) }} > + {renderCopy?.([])} {rootOpen}
( ))}
@@ -581,6 +668,7 @@ export function JsonTree({ : (
)} - {copyTarget !== undefined && ( - - void copy(defaultCopyMode)} - onContextMenu={(event) => { - event.preventDefault() - event.stopPropagation() - copyMenuOpenRef.current = true - setCopyMenuOpen(true) - }} - > - {copyState === 'copied' - ? - : } - - )} - items={copyTargetIsObject ? objectCopyMenuItems(labels) : valueCopyMenuItems(labels)} - onSelect={(id) => { - void copy(id as 'json' | 'path' | 'prettyJson' | 'value') - copyMenuOpenRef.current = false - setCopyMenuOpen(false) - }} - onClose={clearCopyTarget} - getAnchorRect={() => ( - copyButtonRef.current as HTMLButtonElement - ).getBoundingClientRect()} - /> - - )} ) } diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index d2a952704f..18417419b9 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -232,8 +232,8 @@ function jsonTreeLabels(t: TrajectoryTranslate): JsonTreeLabels { copyCompactJson: t('copy.compactJson'), copied: t('copied'), copyFailed: t('copy.failed'), - collapseNode: t('json.collapseNode'), - expandNode: t('json.expandNode'), + collapseNode: t('collapse'), + expandNode: t('expand'), copyButtonTitle: action => t('copy.optionsHint', { action }), } } @@ -856,6 +856,7 @@ function RequestOptions({ return ( {t('record.parameters')} Date: Wed, 9 Sep 2026 16:39:16 +0800 Subject: [PATCH 17/79] fix(trajectory): hide timing source in overview --- .../src/client/TrajectoryTable.tsx | 29 +++++++++++++------ 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index 18417419b9..b517578e80 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -1546,14 +1546,20 @@ function MarkdownRecordContent({ ) } -function RecordTiming({ record, t }: { record: TableRecord; t: TrajectoryTranslate }) { +function RecordTiming({ + record, + preview = false, + t, +}: { record: TableRecord; preview?: boolean; t: TrajectoryTranslate }) { return record.cell.kind === 'message' && record.cell.assistantMetrics !== undefined ? : (
{t('timing.started')}
{t('timing.duration')}
{formatElapsedSeconds(record.cell.timeSeconds, t)}
-
{t('timing.source')}
{record.cell.timeSeconds === null ? t('timing.notAvailable') : t('timing.sessionTimestamps')}
+ {!preview && ( +
{t('timing.source')}
{record.cell.timeSeconds === null ? t('timing.notAvailable') : t('timing.sessionTimestamps')}
+ )}
) } @@ -1562,14 +1568,16 @@ function RequestTiming({ assistant, anchor, request, + preview = false, t, }: { assistant: TableRecord | undefined anchor: TableRecord | undefined request: TrajectoryRequestNumber | undefined + preview?: boolean t: TrajectoryTranslate }) { - if (assistant !== undefined) return + if (assistant !== undefined) return if (request?.startedAt !== undefined) { const duration = request.completedAt === null || request.completedAt === undefined ? null @@ -1578,10 +1586,12 @@ function RequestTiming({
{t('timing.started')}
{t('timing.duration')}
{formatElapsedSeconds(duration, t)}
-
-
{t('timing.source')}
-
{duration === null ? t('timing.sessionTimestampsRunning') : t('timing.sessionTimestamps')}
-
+ {!preview && ( +
+
{t('timing.source')}
+
{duration === null ? t('timing.sessionTimestampsRunning') : t('timing.sessionTimestamps')}
+
+ )}
) } @@ -2932,6 +2942,7 @@ export function TrajectoryTable({ assistant={selectedRequestAssistant} anchor={selectedRequestAnchor} request={selectedRequestInfo} + preview t={t} /> @@ -3157,12 +3168,12 @@ export function TrajectoryTable({ selectRequest(selectedAssistantRequestTarget, 'timing') }} > - + )} {(selected.cell.kind === 'tool' || selected.cell.kind === 'subtool') && ( { activateTab('timing') }}> - + )} From e15a9b1beccdfdeee11eee72d5c489752cde7f2d Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 9 Sep 2026 16:39:09 +0800 Subject: [PATCH 18/79] feat(web): add interactive sidebar terminals --- .../2026-09-09-web-sidebar-terminal.i18n.yaml | 6 + .../2026-09-09-web-sidebar-terminal.md | 45 ++ .../2026-09-09-web-sidebar-terminal.zh.md | 45 ++ THIRD_PARTY_NOTICES.md | 3 + .../sidebar-terminal/running.expected.md | 1 + .../tests/fixtures/sidebar-terminal.patch.yml | 6 + apps/web/tests/sidebar-right.e2e.ts | 28 +- apps/web/tests/sidebar-terminal.e2e.ts | 145 +++++ apps/web/tsconfig.json | 1 + docs/capability-seams.i18n.yaml | 4 +- docs/capability-seams.md | 4 + docs/capability-seams.zh.md | 4 + docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 40 +- docs/config-catalog.zh.md | 38 ++ docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 2 +- docs/event-producer-consumer.zh.md | 2 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 4 + docs/module-graph.zh.md | 4 + docs/subsystems/subprocess.i18n.yaml | 4 +- docs/subsystems/subprocess.md | 11 +- docs/subsystems/subprocess.zh.md | 11 +- docs/subsystems/workspace.i18n.yaml | 4 +- docs/subsystems/workspace.md | 83 +++ docs/subsystems/workspace.zh.md | 83 +++ packages/api/README.i18n.yaml | 4 +- packages/api/README.md | 1 + packages/api/README.zh.md | 1 + packages/api/remotes/package.json | 3 +- packages/api/remotes/src/client/index.ts | 5 +- packages/api/remotes/tsconfig.client.json | 3 + packages/api/remotes/tsconfig.host.json | 3 + .../api/terminal-controller/README.i18n.yaml | 6 + packages/api/terminal-controller/README.md | 90 ++++ packages/api/terminal-controller/README.zh.md | 90 ++++ packages/api/terminal-controller/package.json | 92 ++++ .../src/client/close-requests.ts | 73 +++ .../terminal-controller/src/client/index.ts | 145 +++++ .../terminal-controller/src/client/model.ts | 284 ++++++++++ packages/api/terminal-controller/src/index.ts | 341 ++++++++++++ .../api/terminal-controller/src/shells.ts | 28 + .../api/terminal-controller/src/stream.ts | 75 +++ .../api/terminal-controller/src/terminal.ts | 169 ++++++ packages/api/terminal-controller/src/types.ts | 51 ++ .../tests/controller.spec.ts | 508 ++++++++++++++++++ .../tests/model.client.spec.ts | 503 +++++++++++++++++ .../tests/recovery.client.spec.ts | 369 +++++++++++++ .../terminal-controller/tests/stream.spec.ts | 80 +++ .../tests/terminal.spec.ts | 220 ++++++++ .../terminal-controller/tsconfig.client.json | 37 ++ .../terminal-controller/tsconfig.host.json | 53 ++ .../api/terminal-controller/tsconfig.json | 11 + .../api/terminal-controller/tsdown.config.ts | 7 + packages/bundle/web-app/cordis.patch.yml | 4 + packages/bundle/web-app/package.json | 4 +- packages/client/tsdown.client.ts | 3 +- .../client/ui-sidebar-right/README.i18n.yaml | 4 +- packages/client/ui-sidebar-right/README.md | 8 +- packages/client/ui-sidebar-right/README.zh.md | 8 +- .../src/client/contract/slots.ts | 2 +- .../ui-sidebar-right/src/client/index.ts | 8 +- .../ui-sidebar-right/src/client/service.ts | 65 ++- .../src/client/shell/SidebarRight.tsx | 15 +- .../ui-sidebar-right/src/client/tab-domain.ts | 4 +- .../src/client/tab-registry.ts | 2 + .../src/client/tabs/guide/GuideBody.tsx | 5 +- .../tests/apply.client.spec.ts | 4 +- .../tests/guide-body.client.spec.tsx | 8 +- .../tests/seat.client.spec.tsx | 29 +- .../tests/service.client.spec.ts | 159 +++++- .../tests/tab-domain.client.spec.ts | 2 +- .../ui-sidebar-terminal/README.i18n.yaml | 6 + packages/client/ui-sidebar-terminal/README.md | 80 +++ .../client/ui-sidebar-terminal/README.zh.md | 80 +++ .../client/ui-sidebar-terminal/package.json | 76 +++ .../src/client/TerminalBody.module.css | 9 + .../src/client/TerminalBody.tsx | 126 +++++ .../src/client/TerminalCleanup.module.css | 3 + .../src/client/TerminalCleanup.tsx | 29 + .../src/client/TerminalIcon.tsx | 9 + .../src/client/TerminalRecovery.tsx | 29 + .../src/client/TerminalTitle.tsx | 60 +++ .../ui-sidebar-terminal/src/client/face.ts | 19 + .../ui-sidebar-terminal/src/client/index.ts | 81 +++ .../ui-sidebar-terminal/src/client/locales.ts | 30 ++ .../ui-sidebar-terminal/src/css-modules.d.ts | 6 + .../client/ui-sidebar-terminal/src/index.ts | 4 + .../tests/apply.client.spec.ts | 175 ++++++ .../tests/cleanup.client.spec.tsx | 53 ++ .../tests/recovery.client.spec.tsx | 83 +++ .../tests/terminal-body.client.spec.tsx | 299 +++++++++++ .../client/ui-sidebar-terminal/tsconfig.json | 51 ++ .../ui-sidebar-terminal/tsdown.config.ts | 3 + packages/e2b/subprocess-e2b/README.i18n.yaml | 4 +- packages/e2b/subprocess-e2b/README.md | 2 + packages/e2b/subprocess-e2b/README.zh.md | 2 + packages/e2b/subprocess-e2b/src/index.ts | 25 +- packages/e2b/subprocess-e2b/src/terminal.ts | 10 +- .../subprocess-e2b/tests/subprocess.spec.ts | 2 +- .../e2b/subprocess-e2b/tests/terminal.spec.ts | 57 +- .../src/client/slot-catalog.ts | 7 +- .../extensions/tool-cordis/src/api-catalog.ts | 96 +++- .../fs/tool-fs-search/tests/tools.spec.ts | 1 + .../shell/pwsh-local/tests/executor.spec.ts | 1 + .../subprocess-local/README.i18n.yaml | 4 +- .../subprocess/subprocess-local/README.md | 2 + .../subprocess/subprocess-local/README.zh.md | 2 + .../subprocess/subprocess-local/src/index.ts | 21 +- .../subprocess-local/src/terminal.ts | 22 +- .../tests/fixtures/process-exit-host.ts | 1 + .../tests/linux-scope.spec.ts | 1 + .../subprocess-local/tests/local.spec.ts | 59 +- .../tests/native-containment.spec.ts | 1 + .../subprocess-local/tests/terminal.spec.ts | 41 ++ .../subprocess/subprocess/README.i18n.yaml | 4 +- packages/subprocess/subprocess/README.md | 2 + packages/subprocess/subprocess/README.zh.md | 2 + packages/subprocess/subprocess/src/index.ts | 22 +- packages/subprocess/subprocess/src/types.ts | 16 + .../subprocess/tests/service.spec.ts | 2 + packages/terminal/terminal-bash/src/index.ts | 1 + .../terminal-bash/tests/index.spec.ts | 3 + .../terminal-bash/tests/session.spec.ts | 2 + pnpm-lock.yaml | 157 ++++++ scripts/gen-cordis-catalog.ts | 9 + scripts/gen-doc-graphs.ts | 7 + .../verify-package-readme-model-experience.ts | 2 + tsconfig.base.json | 5 + tsconfig.client.json | 2 + tsconfig.host.json | 2 + 132 files changed, 5985 insertions(+), 101 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md create mode 100644 .agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md create mode 100644 apps/web/tests/expected/sidebar-terminal/running.expected.md create mode 100644 apps/web/tests/fixtures/sidebar-terminal.patch.yml create mode 100644 apps/web/tests/sidebar-terminal.e2e.ts create mode 100644 packages/api/terminal-controller/README.i18n.yaml create mode 100644 packages/api/terminal-controller/README.md create mode 100644 packages/api/terminal-controller/README.zh.md create mode 100644 packages/api/terminal-controller/package.json create mode 100644 packages/api/terminal-controller/src/client/close-requests.ts create mode 100644 packages/api/terminal-controller/src/client/index.ts create mode 100644 packages/api/terminal-controller/src/client/model.ts create mode 100644 packages/api/terminal-controller/src/index.ts create mode 100644 packages/api/terminal-controller/src/shells.ts create mode 100644 packages/api/terminal-controller/src/stream.ts create mode 100644 packages/api/terminal-controller/src/terminal.ts create mode 100644 packages/api/terminal-controller/src/types.ts create mode 100644 packages/api/terminal-controller/tests/controller.spec.ts create mode 100644 packages/api/terminal-controller/tests/model.client.spec.ts create mode 100644 packages/api/terminal-controller/tests/recovery.client.spec.ts create mode 100644 packages/api/terminal-controller/tests/stream.spec.ts create mode 100644 packages/api/terminal-controller/tests/terminal.spec.ts create mode 100644 packages/api/terminal-controller/tsconfig.client.json create mode 100644 packages/api/terminal-controller/tsconfig.host.json create mode 100644 packages/api/terminal-controller/tsconfig.json create mode 100644 packages/api/terminal-controller/tsdown.config.ts create mode 100644 packages/client/ui-sidebar-terminal/README.i18n.yaml create mode 100644 packages/client/ui-sidebar-terminal/README.md create mode 100644 packages/client/ui-sidebar-terminal/README.zh.md create mode 100644 packages/client/ui-sidebar-terminal/package.json create mode 100644 packages/client/ui-sidebar-terminal/src/client/TerminalBody.module.css create mode 100644 packages/client/ui-sidebar-terminal/src/client/TerminalBody.tsx create mode 100644 packages/client/ui-sidebar-terminal/src/client/TerminalCleanup.module.css create mode 100644 packages/client/ui-sidebar-terminal/src/client/TerminalCleanup.tsx create mode 100644 packages/client/ui-sidebar-terminal/src/client/TerminalIcon.tsx create mode 100644 packages/client/ui-sidebar-terminal/src/client/TerminalRecovery.tsx create mode 100644 packages/client/ui-sidebar-terminal/src/client/TerminalTitle.tsx create mode 100644 packages/client/ui-sidebar-terminal/src/client/face.ts create mode 100644 packages/client/ui-sidebar-terminal/src/client/index.ts create mode 100644 packages/client/ui-sidebar-terminal/src/client/locales.ts create mode 100644 packages/client/ui-sidebar-terminal/src/css-modules.d.ts create mode 100644 packages/client/ui-sidebar-terminal/src/index.ts create mode 100644 packages/client/ui-sidebar-terminal/tests/apply.client.spec.ts create mode 100644 packages/client/ui-sidebar-terminal/tests/cleanup.client.spec.tsx create mode 100644 packages/client/ui-sidebar-terminal/tests/recovery.client.spec.tsx create mode 100644 packages/client/ui-sidebar-terminal/tests/terminal-body.client.spec.tsx create mode 100644 packages/client/ui-sidebar-terminal/tsconfig.json create mode 100644 packages/client/ui-sidebar-terminal/tsdown.config.ts diff --git a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml new file mode 100644 index 0000000000..3da12183fa --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md +2026-09-09-web-sidebar-terminal.md: de51d0bb849de0599dfdaf86d2293c17618059cf +2026-09-09-web-sidebar-terminal.zh.md: e6aacebc424efc12457b4a31f0b2660a3ad6225b diff --git a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md new file mode 100644 index 0000000000..de51d0bb84 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md @@ -0,0 +1,45 @@ +# Agent Note: Web sidebar terminals + +Status: implemented + +English | [中文](2026-09-09-web-sidebar-terminal.zh.md) + +## Problem + +Web users need an interactive shell beside a Session to inspect the workspace and run commands. The Agent's persistent terminal tools control prompts and wait for semantic results; a human terminal instead needs raw keyboard input, normal shell configuration and a full screen. Browser rendering and transport can disappear while a command is still running. + +## Decision + +`api-terminal-controller` owns user terminals by Session and exposes the `terminal` Remote namespace. `ui-sidebar-terminal` registers native right-sidebar tabs, xterm.js rendering and FitAddon sizing. A new terminal starts the execution environment's default shell immediately. The existing sidebar controls open additional tabs; double-clicking a tab title renames its terminal. Terminal processes use the composed subprocess provider and Session sandbox policy. Shell resolution occurs only during creation; reading limits and reconnecting an existing process do not depend on the default executable remaining available. Interactive shell configuration supplies Tab completion and optional inline suggestions. + +Close and replacement remove the tab synchronously and run process cleanup in the background. The Client first records the unfinished close request under a terminal-specific localStorage key; success removes it, and startup retries requests that remain. A cleanup failure produces a lightweight notification with a retry action without reopening the tab. Independent keys prevent another window from overwriting unrelated cleanup requests. Collapse, tab/Session switching, floating, fullscreen and browser disconnection preserve the process. Component cleanup and `TabDomain.signal` only detach browser work because the same lifetime can end during plugin reload. Failed process cleanup retains ownership, including failures after allocation but before create publication. Session owner disposal and Host plugin disposal also clean up terminals. + +Sidebar layout, open-tab mappings, selection and process PIDs are not persisted. When the Session header mounts, the Client queries `terminal.list` and opens retained Host terminals as new tabs. Their `params.terminalId` association exists only in the current page. New and recovered views use different `createWhenMissing` values: only a new view may allocate a process; a recovered target that disappears reports an error. Host state supplies recovery identities and titles, so the browser does not maintain a second active-terminal registry. + +The caller retains a terminal ID in memory before creating it. Repeating create with the same Session and open ID does not allocate a second process. Closing an uncertain create uses that ID even when no creation response arrived. The Host records closed IDs before awaiting allocation, preventing a delayed create from reviving a closed terminal. Each attachment begins with a consistent, bounded serialized xterm screen; ordered output follows through the Gateway's existing multiplexed Remote stream. Output and screen snapshots share one operation queue. Followers retain final output on normal closure and fail explicitly on buffer overflow. Browser render acknowledgement prevents React batching from dropping increments. + +The latest attachment controls input and dimensions; other attachments remain read-only. Every physical stream opening gets a fresh input attachment identity, including automatic transport recovery. Input RPCs are serialized, and results from a superseded attachment cannot downgrade a replacement connection. Terminal output creates no model input, Agent tool result or Session event. The existing [persistent Agent terminal decision](2026-07-16-persistent-pty-sessions.md) continues to govern model-owned sessions; this feature extends the [portable subprocess provider](../architecture/2026-07-28-portable-execution-world-consumers.md) with terminal environment facts and resize. + +## Alternatives considered + +**Add shell selection and terminal-specific creation controls.** Opening a terminal uses the execution environment's default shell, and the sidebar already owns tab creation and titles. Another picker, process list, header and add control would duplicate navigation around the terminal screen. + +**Keep the tab visible until process cleanup finishes.** A slow or failed termination would delay the user's close action. Saving the cleanup intent allows immediate removal while preserving failure reporting and retry. + +**Persist sidebar layout or an active tab-to-process registry.** Layout persistence is outside this feature. An additional active registry duplicates Host state and can restore stale associations. Only an unfinished close is an independent user request that must survive page reload. + +**Share the Agent terminal registry.** Its controlled prompts and semantic send/wait behavior would change human shell configuration and blur process ownership. User terminals share the subprocess capability instead. + +**Add a dedicated terminal WebSocket.** The existing Remote stream transport already owns authentication, cancellation and reconnection. A second carrier would duplicate those responsibilities. + +**Replay only a bounded raw byte tail.** A tail can begin inside an escape sequence or omit an alternate-screen transition. A serialized terminal screen provides a consistent recovery point with bounded history. + +**Kill when a React body or tab signal is disposed.** Unmount, Session switching and plugin reload can end those lifetimes without an explicit close. Cleanup must follow the user's close operation. + +**Build a Web completion engine.** Native shell completion already handles commands, paths and configured plugins through ordinary PTY input. An independent completion UI adds shell-specific parsing and synchronization; it is outside this feature. + +## Consequences + +A kept-open terminal retains a process and bounded screen memory. Reload restores Host-retained terminals rather than the previous sidebar layout; Host restart does not restore processes. An exited shell remains visible without automatic respawn. Background cleanup may outlive its tab, and unavailable browser storage limits retry recovery to the current page. Native PTY support and descendant cleanup guarantees remain provider-specific. One writable attachment avoids competing resize and input streams, while explicit takeover permits recovery from another page. Changing sandbox mode requires closing retained terminals first. + +The implementation retains the Agent-terminal and portable-execution notes because their ownership and provider decisions remain independently useful; neither is superseded by browser terminals. diff --git a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md new file mode 100644 index 0000000000..e6aacebc42 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md @@ -0,0 +1,45 @@ +# Agent Note: Web sidebar terminals + +Status: implemented + +[English](2026-09-09-web-sidebar-terminal.md) | 中文 + +## 问题 + +Web 用户需要在 Session 旁使用交互式 shell 检查工作区和运行命令。Agent 的持久终端工具控制提示符并等待语义结果;人工终端需要原始键盘输入、正常 shell 配置和完整屏幕。命令仍在运行时,浏览器渲染和网络连接可能消失。 + +## 决定 + +`api-terminal-controller` 按 Session 管理用户终端并提供 `terminal` Remote namespace。`ui-sidebar-terminal` 注册原生右侧栏标签页,使用 xterm.js 渲染和 FitAddon 测量尺寸。新终端立即启动执行环境的默认 shell。已有侧栏控件负责打开更多标签页,双击标签页标题可重命名终端。终端进程使用组合的 subprocess provider 和 Session sandbox policy。shell 只在创建时解析;读取限制和重新连接已有进程不依赖默认可执行文件仍然可用。交互式 shell 配置提供 Tab 补全和可选的内联建议。 + +关闭和替换会同步移除标签页,并在后台清理进程。Client 先以终端独立的 localStorage key 保存未完成的关闭请求;成功后删除,启动时重试剩余请求。清理失败时显示带重试操作的轻量通知,不重新打开标签页。独立 key 避免其他窗口覆盖无关的清理请求。折叠、切换标签页或 Session、浮动、全屏和浏览器断线均保留进程。组件清理和 `TabDomain.signal` 只停止浏览器工作,因为插件重新加载也会结束这些生命周期。进程清理失败时保留所有权,包括分配完成但 create 尚未发布时的失败。Session owner 和 Host 插件卸载也会清理终端。 + +侧栏布局、打开标签页映射、选中项和进程 PID 不持久化。Session header 挂载时,Client 查询 `terminal.list`,把 Host 保留的终端打开为新标签页。`params.terminalId` 关联只在当前页面中保留。新视图与恢复视图使用不同的 `createWhenMissing`:只有新视图可以分配进程,恢复目标消失时显示错误。恢复标识和标题来自 Host 状态,浏览器不维护第二份活跃终端注册表。 + +调用者在创建之前把终端 ID 保留在内存中。相同 Session 和未关闭 ID 的重复 create 不再分配进程。创建结果不确定时,关闭仍使用该 ID,即使没有收到创建响应。Host 在等待分配完成前记录已关闭 ID,防止迟到的 create 复活已关闭终端。每次连接先接收一致、有界的 xterm 序列化屏幕,后续有序输出使用 Gateway 已有的复用 Remote stream。输出和屏幕快照共享操作队列。订阅者正常关闭时保留末尾输出,缓存超限时明确失败。浏览器在完成渲染后确认帧,避免 React 批处理丢失增量。 + +最新连接控制输入和尺寸,其他连接保持只读。每次物理流建立都创建新的输入连接标识,包括传输自动恢复。输入 RPC 按序发送,旧连接的结果不能把新连接降级为失败。终端输出不产生模型输入、Agent 工具结果或 Session 事件。[Agent 持久终端决策](2026-07-16-persistent-pty-sessions.zh.md)仍约束模型拥有的终端;此功能为[可移植 subprocess provider](../architecture/2026-07-28-portable-execution-world-consumers.zh.md)增加执行环境事实和 resize。 + +## 考虑过的替代方案 + +**增加 shell 选择和终端专用创建控件。** 打开终端使用执行环境的默认 shell,侧栏已经负责标签页创建与标题。额外的选择器、进程列表、标题栏和添加控件会在终端屏幕周围重复导航功能。 + +**进程清理完成前保留标签页。** 缓慢或失败的终止会拖延用户关闭操作。保存清理意图后,可以立即移除标签页,同时保留错误反馈与重试。 + +**持久化侧栏布局或活跃标签页到进程的注册表。** 布局持久化不属于此功能。额外的活跃注册表重复 Host 状态,可能恢复过期关联。只有未完成的关闭操作是需要跨页面刷新保留的独立用户请求。 + +**共用 Agent 终端注册表。** 受控提示符和语义化 send/wait 会改变人工 shell 配置并混淆进程所有权。用户终端只共享 subprocess 能力。 + +**增加专用终端 WebSocket。** 现有 Remote stream 已管理认证、取消和重连。第二条传输通道会重复这些职责。 + +**只重放有界原始字节尾部。** 字节尾部可能从转义序列中间开始,或缺失备用屏幕切换。序列化终端屏幕能在保留有界历史的同时提供一致恢复点。 + +**React 正文或 tab signal 卸载时 kill。** 组件卸载、Session 切换和插件重载都可能结束这些生命周期,而用户并未关闭终端。清理必须跟随显式关闭操作。 + +**自建 Web 补全引擎。** shell 原生补全通过普通 PTY 输入处理命令、路径和已配置插件。独立补全界面需要针对 shell 解析和同步,不属于此功能。 + +## 影响 + +保留终端会保留进程和有界屏幕内存。刷新恢复的是 Host 保留的终端,不是此前的侧栏布局;Host 重启不恢复进程。shell 退出后保持可见,不自动重启。后台清理可能比标签页存活更久,浏览器存储不可用时只能在当前页面保留重试能力。原生 PTY 支持和后代进程清理保证仍由 provider 决定。单一可写连接避免竞争的输入和尺寸流,显式接管允许从另一页面恢复操作。改变 sandbox mode 前需要关闭保留的终端。 + +Agent 终端和可移植执行环境两篇记录仍保留,其所有权与 provider 决策继续独立有效,不被浏览器终端取代。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 31eebdab8d..7bde8e075f 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -56,7 +56,10 @@ External packages installed for runtime use or distributed inside the prebuilt b | [`@standard-schema/spec`](https://github.com/standard-schema/standard-schema) | MIT | | [`@tanstack/react-virtual`](https://github.com/TanStack/virtual) | MIT | | [`@vscode/ripgrep`](https://github.com/microsoft/vscode-ripgrep) | MIT | +| [`@xterm/addon-fit`](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-fit) | MIT | +| [`@xterm/addon-serialize`](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-serialize) | MIT | | [`@xterm/headless`](https://github.com/xtermjs/xterm.js) | MIT | +| [`@xterm/xterm`](https://github.com/xtermjs/xterm.js) | MIT | | [`@yarnpkg/parsers`](https://github.com/yarnpkg/berry) | BSD-2-Clause | | [`acorn`](https://github.com/acornjs/acorn) | MIT | | [`anser`](https://github.com/IonicaBizau/anser) | MIT | diff --git a/apps/web/tests/expected/sidebar-terminal/running.expected.md b/apps/web/tests/expected/sidebar-terminal/running.expected.md new file mode 100644 index 0000000000..91f8d58a99 --- /dev/null +++ b/apps/web/tests/expected/sidebar-terminal/running.expected.md @@ -0,0 +1 @@ +- textbox "Terminal" diff --git a/apps/web/tests/fixtures/sidebar-terminal.patch.yml b/apps/web/tests/fixtures/sidebar-terminal.patch.yml new file mode 100644 index 0000000000..0c055161f1 --- /dev/null +++ b/apps/web/tests/fixtures/sidebar-terminal.patch.yml @@ -0,0 +1,6 @@ +- id: terminal-controller + config: + shell: + path: /bin/bash + name: bash + args: [--noprofile, --norc, -i] diff --git a/apps/web/tests/sidebar-right.e2e.ts b/apps/web/tests/sidebar-right.e2e.ts index c6a7ec07cd..603ce4076f 100644 --- a/apps/web/tests/sidebar-right.e2e.ts +++ b/apps/web/tests/sidebar-right.e2e.ts @@ -132,6 +132,7 @@ async function resetSidebar(page: Page): Promise { const column = page.locator('[data-rightbar-col]') await expandOf(page).waitFor({ timeout: 15_000 }) await ensureExpanded(page, column) + await column.locator('[data-sidebar-right-guide-entry="files"]').click() await expect.poll(async () => await tabTitles(column)).toEqual(['Files']) await width(column) return column @@ -390,6 +391,10 @@ describe('web e2e: shipped right Sidebar', () => { expect(await centreY(selector), selector).toBe(textLine) } + await expect.poll(async () => await tabTitles(column)).toEqual(['Start']) + await expect.poll(async () => await column.locator('[data-sidebar-right-guide-entry]').count()).toBe(2) + await column.locator('[data-sidebar-right-guide-entry="files"]').click() + // A manual guide is closable beside Files and suppresses another add // control in its pane until it is closed. const addTab = column.locator('[data-dockkit-add-tab]') @@ -752,10 +757,13 @@ describe('web e2e: shipped right Sidebar', () => { ) await expect.poll(async () => await panes.count()).toBe(2) - const splitFiles = panes.nth(1).locator('[data-dockkit-tab]').filter({ hasText: 'Files' }) - expect(await splitFiles.locator('[data-dockkit-tab-close]').count()).toBe(1) - await dragTo(page, splitFiles, await pointIn(panes.first(), 0.5, 0.5)) + const splitGuide = panes.nth(1).locator('[data-dockkit-tab]').filter({ hasText: 'Start' }) + expect(await splitGuide.locator('[data-dockkit-tab-close]').count()).toBe(1) + await dragTo(page, splitGuide, await pointIn(panes.first(), 0.5, 0.5)) await expect.poll(async () => await tabTitles(panes.nth(1))).toEqual([SAMPLE_NAME]) + const movedGuide = panes.first().locator('[data-dockkit-tab]').filter({ hasText: 'Start' }) + await movedGuide.hover() + await movedGuide.locator('[data-dockkit-tab-close]').click() // Neither pane holds a guide, so both offer an add control. const filePane = panes.filter({ has: page.locator('[data-dockkit-tab-title]', { hasText: SAMPLE_NAME }) }) @@ -892,7 +900,7 @@ describe('web e2e: shipped right Sidebar', () => { expect(await panes.count()).toBe(2) expect(await splitButtons.count()).toBe(0) - // 5. A manual guide and the document float while Files stays docked. + // 5. The split's guide and the document float while Files stays docked. const floatOne = panes.last().locator('[data-dockkit-tab]').filter({ hasText: SAMPLE_NAME }) await floatByDrag(page, floatOne) await expect.poll(async () => await floats.count()).toBe(1) @@ -901,8 +909,7 @@ describe('web e2e: shipped right Sidebar', () => { await dragElement(page, floats.first().locator('[data-dockkit-float-grip]'), { x: box.x + 140, y: box.y + 90 }) await expect.poll(async () => (await floats.first().boundingBox())?.x ?? box.x).not.toBe(box.x) - await panes.last().locator('[data-dockkit-add-tab]').click() - await expect.poll(async () => await tabTitles(panes.last())).toEqual(['Files', 'Start']) + await expect.poll(async () => await tabTitles(panes.last())).toEqual(['Start']) const second = panes.last().locator('[data-dockkit-tab]').filter({ hasText: 'Start' }) await floatByDrag(page, second) await expect.poll(async () => await floats.count()).toBe(2) @@ -957,7 +964,7 @@ describe('web e2e: shipped right Sidebar', () => { // Any other tab standing alone closes together with the column. Open the // sample file, close the guide (an ordinary close with two tabs), then // close the file: the column collapses in the same gesture, and the - // settle rule reseeds the current default, so reopening shows Files. + // settle rule reseeds the current default, so reopening shows Start. await page.getByRole('button', { name: `Open ${SAMPLE_NAME}` }).click() await expect.poll(async () => await tabTitles(column)).toEqual(['Start', SAMPLE_NAME]) await column.locator('[data-dockkit-tab]').first().hover() @@ -967,8 +974,8 @@ describe('web e2e: shipped right Sidebar', () => { await column.locator('[data-dockkit-tab-close]').first().click() await expect.poll(async () => await column.locator('[data-sidebar-right-open]').count()).toBe(0) await expandOf(page).click() - await expect.poll(async () => await tabTitles(column)).toEqual(['Files']) - expect(await column.locator('[data-files-state="tree"]').count()).toBe(1) + await expect.poll(async () => await tabTitles(column)).toEqual(['Start']) + expect(await column.locator('[data-sidebar-right-guide]').count()).toBe(1) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) @@ -994,6 +1001,7 @@ describe('web e2e: shipped right Sidebar', () => { await ensureExpanded(page, column) await expect.poll(async () => await column.locator('[data-dockkit-tab]').count()).toBeGreaterThan(0) + await column.locator('[data-sidebar-right-guide-entry="files"]').click() await column.locator('[data-dockkit-add-tab]').click() await expect.poll(async () => await tabTitles(column)).toEqual(['Files', 'Start']) // No "more" control on the chip: the chip carries its close, and the menu @@ -1041,6 +1049,8 @@ describe('web e2e: shipped right Sidebar', () => { const column = zhPage.locator('[data-rightbar-col]') await expandOf(zhPage).waitFor({ timeout: 20_000 }) await expandOf(zhPage).click() + await expect.poll(async () => await tabTitles(column)).toEqual(['开始']) + await column.locator('[data-sidebar-right-guide-entry="files"]').click() await expect.poll(async () => await tabTitles(column)).toEqual(['文件']) await column.locator('[data-dockkit-add-tab]').click() diff --git a/apps/web/tests/sidebar-terminal.e2e.ts b/apps/web/tests/sidebar-terminal.e2e.ts new file mode 100644 index 0000000000..ed416301b6 --- /dev/null +++ b/apps/web/tests/sidebar-terminal.e2e.ts @@ -0,0 +1,145 @@ +/** Shipped sidebar terminal over the real Loader, Remote mux, Chromium and local PTY. */ +import { mkdir } from 'node:fs/promises' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { chromium, type Browser, type Page } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import type {} from '@deepseek-ai/dsh-api-terminal-controller' +import { compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' + +const expected = fileURLToPath(new URL('./expected/sidebar-terminal/running.expected.md', import.meta.url)) +const shots = fileURLToPath(new URL('../../../.artifacts/screenshots/sidebar-terminal/', import.meta.url)) + +async function openTerminal(page: Page): Promise { + const expand = page.locator('[data-sidebar-right-expand]') + if (await expand.isVisible()) await expand.click() + else await page.locator('[data-dockkit-add-tab]').click() + await page.locator('[data-sidebar-right-guide-entry="terminal"]').click() + await expect.poll(async () => await page.locator('.xterm-rows:visible').innerText()).toContain('bash-') +} + +async function command(page: Page, text: string): Promise { + await page.locator('.xterm-helper-textarea:visible').click() + await page.keyboard.insertText(text) + await page.keyboard.press('Enter') +} + +function alive(pid: number): boolean { + try { + process.kill(pid, 0) + // A container's init may retain a reparented zombie; it cannot run after terminal cleanup. + if (process.platform === 'linux') { + const stat = readFileSync(`/proc/${pid}/stat`, 'utf8') + const state = stat.slice(stat.lastIndexOf(')') + 2, stat.lastIndexOf(')') + 3) + return state !== 'Z' && state !== 'X' + } + return true + } catch (error) { + if (['ESRCH', 'ENOENT'].includes((error as NodeJS.ErrnoException).code ?? '')) return false + throw error + } +} + +describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({ extraOverlayPath: fileURLToPath(new URL('./fixtures/sidebar-terminal.patch.yml', import.meta.url)) }) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + const agent = scaffold.ctx.agents.list()[0] + if (agent === undefined) throw new Error('Workspace did not create a Session') + agent.session.append('turn/start', { turn: 1 }) + agent.session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'Open a terminal.' }], source: { kind: 'user' } }), { surfaceOp: 'append' }) + agent.session.append('step/start', { turn: 1, step: 1 }) + agent.session.append('assistant/message', { stream: [], turn: 1, step: 1, message: createMessage({ role: 'assistant', content: [{ type: 'text', text: 'Ready for terminal input.' }], source: { kind: 'model', provider: 'fixture', model: 'fixture' } }) }, { surfaceOp: 'append' }) + agent.session.append('step/end', { turn: 1, step: 1 }) + agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + await scaffold.ctx.sessions.flush(agent.session) + await page.getByText('Ready for terminal input.').waitFor() + await mkdir(shots, { recursive: true }) + }, 180_000) + + afterAll(async () => { await browser?.close(); await scaffold?.close() }) + + it('completes commands, preserves the process through collapse and reload, resizes, and kills on tab close', async () => { + onTestFailed(() => saveFailureShot(page, 'sidebar-terminal')) + await openTerminal(page) + const terminal = page.locator('[data-sidebar-terminal]') + await command(page, "PS1=''; printf '\\033cTERMINAL_READY\\n'") + const screen = page.locator('.xterm-rows:visible') + await expect.poll(async () => await screen.innerText()).toContain('TERMINAL_READY') + const aria = await terminal.ariaSnapshot() + await compareOrRefreshGolden(expected, aria, webSnapshotMode()) + await command(page, "printf 'DSH_PID:%s\\n' \"$$\"") + await expect.poll(async () => await screen.innerText()).toMatch(/DSH_PID:\d+/u) + const pid = Number((await screen.innerText()).match(/DSH_PID:(\d+)/u)?.[1]) + expect(alive(pid)).toBe(true) + await command(page, 'dsh_terminal_completion_probe(){ printf "completed_from_shell\\n"; }') + await page.keyboard.press('Control+l') + await page.keyboard.insertText('dsh_terminal_completion_pro') + await page.keyboard.press('Tab') + await page.keyboard.press('Enter') + await expect.poll(async () => await screen.innerText()).toContain('completed_from_shell') + await command(page, "printf 'PERSIST:%s\\n' \"$TERM\"") + await expect.poll(async () => await screen.innerText()).toContain('PERSIST:xterm-256color') + await page.locator('[data-dockkit-tab-title]').getByText('bash', { exact: true }).dblclick() + await page.getByRole('textbox', { name: 'Terminal name', exact: true }).fill('Development') + await page.getByRole('textbox', { name: 'Terminal name', exact: true }).press('Enter') + await expect.poll(async () => await page.locator('[data-dockkit-tab-title]').allInnerTexts()).toContain('Development') + await openTerminal(page) + await command(page, "printf 'SECOND_PID:%s\\n' \"$$\"") + await expect.poll(async () => await screen.innerText()).toMatch(/SECOND_PID:\d+/u) + const secondPid = Number((await screen.innerText()).match(/SECOND_PID:(\d+)/u)?.[1]) + expect(secondPid).not.toBe(pid) + await page.locator('[data-dockkit-tab]').filter({ hasText: 'Development' }).click() + await expect.poll(async () => await screen.innerText()).toContain('PERSIST:xterm-256color') + expect(alive(secondPid)).toBe(true) + await page.getByRole('button', { name: 'Collapse right sidebar', exact: true }).click() + expect(alive(pid)).toBe(true) + await page.locator('[data-sidebar-right-expand]').click() + const terminals = () => scaffold.ctx.terminalController.list(scaffold.ctx.agents.list()[0]!) + const dockedCols = terminals()[0]!.cols + await page.getByRole('button', { name: 'Fullscreen', exact: true }).click() + await expect.poll(() => terminals()[0]!.cols).toBeGreaterThan(dockedCols) + await command(page, "printf 'SIZE:'; stty size") + await expect.poll(async () => await screen.innerText()).toContain(`SIZE:${terminals()[0]!.rows} ${terminals()[0]!.cols}`) + await page.screenshot({ path: `${shots}/fullscreen.png`, fullPage: true }) + await page.reload({ waitUntil: 'load' }) + await page.locator('[data-dockkit-tab]').filter({ hasText: 'Development' }).waitFor({ timeout: 15_000 }) + await expect.poll(async () => await page.locator('[data-dockkit-tab-title]').allInnerTexts()).toEqual(['Start', 'Development', 'bash']) + expect(terminals()).toHaveLength(2) + expect(alive(pid)).toBe(true) + expect(alive(secondPid)).toBe(true) + await expect.poll(async () => await screen.innerText()).toContain(`SECOND_PID:${secondPid}`) + const secondTab = page.locator('[data-dockkit-tab]').filter({ hasText: 'bash' }) + await secondTab.hover() + await secondTab.locator('[data-dockkit-tab-close]').click() + await expect.poll(async () => await secondTab.count()).toBe(0) + await expect.poll(() => alive(secondPid), { timeout: 10_000 }).toBe(false) + await expect.poll(async () => await screen.innerText()).toContain('PERSIST:xterm-256color') + await command(page, "printf 'RECOVERED_PID:%s\\n' \"$$\"") + await expect.poll(async () => await screen.innerText()).toContain(`RECOVERED_PID:${pid}`) + await page.screenshot({ path: `${shots}/recovered.png`, fullPage: true }) + await command(page, "sleep 120 & printf 'CHILD_PID:%s\\n' $!") + await expect.poll(async () => await screen.innerText()).toMatch(/CHILD_PID:\d+/u) + const childPid = Number((await screen.innerText()).match(/CHILD_PID:(\d+)/u)?.[1]) + expect(alive(childPid)).toBe(true) + const tab = page.locator('[data-dockkit-tab]').filter({ hasText: 'Development' }) + await tab.hover() + await tab.locator('[data-dockkit-tab-close]').click() + await expect.poll(() => alive(pid), { timeout: 10_000 }).toBe(false) + await expect.poll(() => alive(childPid), { timeout: 10_000 }).toBe(false) + await expect.poll(() => scaffold.ctx.terminalController.list(scaffold.ctx.agents.list()[0]!).length).toBe(0) + expect(tripwire.pageErrors).toEqual([]) + }) +}) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 3b01e3990c..78b182d9a6 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -94,6 +94,7 @@ "tests/feedback-release.e2e.ts", "tests/agent-team-panel.e2e.ts", "tests/sidebar-right.e2e.ts", + "tests/sidebar-terminal.e2e.ts", "tests/startup-auto-selection.e2e.ts", "tests/produced-files.e2e.ts", "tests/produced-file-mentions.e2e.ts", diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index ab602f097d..a377e4ca6b 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/capability-seams.md -capability-seams.md: 9cc7a6fc50b81e9f82d58600c84d1ff6494634c9 -capability-seams.zh.md: e898bdf3c2c728a1bae427bbb737eb5269a1c500 +capability-seams.md: 826b8b546242b6581846b52a3a5919c333610d42 +capability-seams.zh.md: 106f4b75180322820604a2f254f34dc8aa57abdf diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 9cc7a6fc50..826b8b5462 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -46,6 +46,8 @@ flowchart LR svc_settingsController["ctx.settingsController
Host settings-surface Remote controller"] pkg_api_workspace_files["api-workspace-files"] svc_workspaceFiles["ctx.workspaceFiles
Host workspace file Remote service"] + pkg_api_terminal_controller["api-terminal-controller"] + svc_terminalController["ctx.terminalController
Session interactive terminal Remote controller"] pkg_api_workspace_controller["api-workspace-controller"] svc_workspaceController["ctx.workspaceController
Host Workspace Remote controller"] svc_directoryPickerController["ctx.directoryPickerController
Host directory-picking Remote controller"] @@ -234,6 +236,7 @@ flowchart LR pkg_api_session_controller --> svc_sessionSkillCatalog pkg_api_settings_controller --> svc_credentialsController pkg_api_settings_controller --> svc_settingsController + pkg_api_terminal_controller --> svc_terminalController pkg_api_workspace_controller --> svc_directoryPickerController pkg_api_workspace_controller --> svc_workspaceController pkg_api_workspace_files --> svc_workspaceFiles @@ -487,6 +490,7 @@ flowchart LR | `ctx.credentialsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | Projects the credential-reference seam onto the generated Remote namespace: batch fan-out, view projection, and refusal mapping live here, not on the seam Definition. | | `ctx.settingsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | Projects the user-settings seam onto the generated Remote namespace: the read is always redacted and every refusal is classified here, not on the seam Definition. | | `ctx.workspaceFiles` | `core` | [`api-workspace-files`](../packages/api/workspace-files) | - | - | - | Serves stat, paged text, byte windows, directory listings, and the change feed for files inside a Session's workspace root, confined by lstat, containment, and a stat re-check. | +| `ctx.terminalController` | `core` | [`api-terminal-controller`](../packages/api/terminal-controller) | - | - | - | Owns user terminal processes, default shell resolution and bounded screen recovery through the subprocess provider and typed Remote transport. | | `ctx.workspaceController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | Owns Workspace commands and reconnect-safe Workspace state delivery through the generated Remote namespace. | | `ctx.directoryPickerController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | Carries the picking seam onto the wire: capability gating, cancellation, and the seam-coded failures a browser directory flow discriminates on. | | `ctx.invariants` | `core` | [`invariants`](../packages/runtime-diagnostics/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index e898bdf3c2..106f4b7518 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -48,6 +48,8 @@ flowchart LR svc_settingsController["ctx.settingsController
Host settings-surface Remote controller"] pkg_api_workspace_files["api-workspace-files"] svc_workspaceFiles["ctx.workspaceFiles
Host workspace file Remote service"] + pkg_api_terminal_controller["api-terminal-controller"] + svc_terminalController["ctx.terminalController
Session interactive terminal Remote controller"] pkg_api_workspace_controller["api-workspace-controller"] svc_workspaceController["ctx.workspaceController
Host Workspace Remote controller"] svc_directoryPickerController["ctx.directoryPickerController
Host directory-picking Remote controller"] @@ -236,6 +238,7 @@ flowchart LR pkg_api_session_controller --> svc_sessionSkillCatalog pkg_api_settings_controller --> svc_credentialsController pkg_api_settings_controller --> svc_settingsController + pkg_api_terminal_controller --> svc_terminalController pkg_api_workspace_controller --> svc_directoryPickerController pkg_api_workspace_controller --> svc_workspaceController pkg_api_workspace_files --> svc_workspaceFiles @@ -489,6 +492,7 @@ flowchart LR | `ctx.credentialsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | 把凭据引用 seam 投影到生成的 Remote namespace:批量扇出、视图投影与拒绝映射都在这里,而不在 seam Definition 上。 | | `ctx.settingsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | 把用户设置 seam 投影到生成的 Remote namespace:读取一律脱敏,所有拒绝在这里分类,而不在 seam Definition 上。 | | `ctx.workspaceFiles` | `core` | [`api-workspace-files`](../packages/api/workspace-files) | - | - | - | 为会话工作区根内的文件提供 stat、分页文本、字节窗口、目录列举与变更流,经 lstat、包含关系与 stat 重检限定。 | +| `ctx.terminalController` | `core` | [`api-terminal-controller`](../packages/api/terminal-controller) | - | - | - | 通过子进程提供方与类型化 Remote 传输管理用户终端进程、解析默认 shell,并恢复有界终端屏幕。 | | `ctx.workspaceController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | 通过生成的 Remote namespace 负责 Workspace 命令和可在重连后收敛的 Workspace 状态投递。 | | `ctx.directoryPickerController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | 把选目录 seam 送上线:能力门禁、取消传播,以及浏览器目录流程用于分支判断的 seam 错误码。 | | `ctx.invariants` | `core` | [`invariants`](../packages/runtime-diagnostics/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | 配套子路径注册所属包本地的检查;该服务负责选择、唯一性、子 fiber,以及标明所属包的失败。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 2f4b0e6e79..cc4bab3bc9 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 4e92bbfcf4bcf190b9b17436eefc3255dda9132a -config-catalog.zh.md: df4b107f28040962f38802a77f8b91c47ad2931d +config-catalog.md: bba3732438bc323b034625185962c5bce2382f3c +config-catalog.zh.md: 8c6168c46035e703a7c54e121dd20514e2ddf009 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 4e92bbfcf4..bba3732438 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -229,6 +229,43 @@ export interface Config { Source: [`packages/api/settings-controller/src/index.ts:36`](../packages/api/settings-controller/src/index.ts) + + +## `@deepseek-ai/dsh-api-terminal-controller` + +Requires: `subprocess` · `sandboxPolicy` · `sessionProjections` · `typert` + +```ts config-catalog +/** Deployment limits and an optional shell profile. */ +export interface Config { + /** Explicit shell profile; omission uses the execution environment's default shell. */ + readonly shell?: { + /** Executable path or PATH name, verified by the subprocess provider. */ + path: string + /** User-visible profile name. */ + name: string + /** Arguments passed to the interactive shell. */ + args: string[] + } | undefined + /** Maximum retained terminals and pending allocations per Session. */ + readonly maxTerminals: number + /** Maximum terminal width in columns. */ + readonly maxCols: number + /** Maximum terminal height in rows. */ + readonly maxRows: number + /** Screen history rows retained for reconnecting clients. */ + readonly scrollback: number + /** Maximum queued UTF-8 frame bytes per output follower before disconnection. */ + readonly maxBufferedBytes: number + /** Maximum UTF-8 bytes in one input request. */ + readonly maxInputBytes: number + /** Provider process-termination grace period in milliseconds. */ + readonly disposeGraceMs: number +} +``` + +Source: [`packages/api/terminal-controller/src/index.ts:28`](../packages/api/terminal-controller/src/index.ts) + ## `@deepseek-ai/dsh-api-workspace-files` @@ -2575,7 +2612,7 @@ export interface Config { } ``` -Source: [`packages/e2b/subprocess-e2b/src/index.ts:26`](../packages/e2b/subprocess-e2b/src/index.ts) +Source: [`packages/e2b/subprocess-e2b/src/index.ts:28`](../packages/e2b/subprocess-e2b/src/index.ts) @@ -3484,6 +3521,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-ui-sidebar-documentpreview` ([`packages/client/ui-sidebar-documentpreview/src/index.ts`](../packages/client/ui-sidebar-documentpreview/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar-files` ([`packages/client/ui-sidebar-files/src/index.ts`](../packages/client/ui-sidebar-files/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar-right` ([`packages/client/ui-sidebar-right/src/index.ts`](../packages/client/ui-sidebar-right/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-sidebar-terminal` ([`packages/client/ui-sidebar-terminal/src/index.ts`](../packages/client/ui-sidebar-terminal/src/index.ts)) - `@deepseek-ai/dsh-client-ui-skill` ([`packages/client/ui-skill/src/index.ts`](../packages/client/ui-skill/src/index.ts)) - `@deepseek-ai/dsh-client-ui-subagent` ([`packages/client/ui-subagent/src/index.ts`](../packages/client/ui-subagent/src/index.ts)) - `@deepseek-ai/dsh-client-ui-theme` ([`packages/client/ui-theme/src/index.ts`](../packages/client/ui-theme/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index df4b107f28..8c6168c460 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -231,6 +231,43 @@ export interface Config { 来源:[`packages/api/settings-controller/src/index.ts:36`](../packages/api/settings-controller/src/index.ts) + + +## `@deepseek-ai/dsh-api-terminal-controller` + +Requires: `subprocess` · `sandboxPolicy` · `sessionProjections` · `typert` + +```ts config-catalog +/** Deployment limits and an optional shell profile. */ +export interface Config { + /** Explicit shell profile; omission uses the execution environment's default shell. */ + readonly shell?: { + /** Executable path or PATH name, verified by the subprocess provider. */ + path: string + /** User-visible profile name. */ + name: string + /** Arguments passed to the interactive shell. */ + args: string[] + } | undefined + /** Maximum retained terminals and pending allocations per Session. */ + readonly maxTerminals: number + /** Maximum terminal width in columns. */ + readonly maxCols: number + /** Maximum terminal height in rows. */ + readonly maxRows: number + /** Screen history rows retained for reconnecting clients. */ + readonly scrollback: number + /** Maximum queued UTF-8 frame bytes per output follower before disconnection. */ + readonly maxBufferedBytes: number + /** Maximum UTF-8 bytes in one input request. */ + readonly maxInputBytes: number + /** Provider process-termination grace period in milliseconds. */ + readonly disposeGraceMs: number +} +``` + +来源: [`packages/api/terminal-controller/src/index.ts:28`](../packages/api/terminal-controller/src/index.ts) + ## `@deepseek-ai/dsh-api-workspace-files` @@ -3486,6 +3523,7 @@ export interface Config { - `@deepseek-ai/dsh-client-ui-sidebar-documentpreview`([`packages/client/ui-sidebar-documentpreview/src/index.ts`](../packages/client/ui-sidebar-documentpreview/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar-files`([`packages/client/ui-sidebar-files/src/index.ts`](../packages/client/ui-sidebar-files/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar-right`([`packages/client/ui-sidebar-right/src/index.ts`](../packages/client/ui-sidebar-right/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-sidebar-terminal`([`packages/client/ui-sidebar-terminal/src/index.ts`](../packages/client/ui-sidebar-terminal/src/index.ts)) - `@deepseek-ai/dsh-client-ui-skill`([`packages/client/ui-skill/src/index.ts`](../packages/client/ui-skill/src/index.ts)) - `@deepseek-ai/dsh-client-ui-subagent`([`packages/client/ui-subagent/src/index.ts`](../packages/client/ui-subagent/src/index.ts)) - `@deepseek-ai/dsh-client-ui-theme`([`packages/client/ui-theme/src/index.ts`](../packages/client/ui-theme/src/index.ts)) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 09eecc6d42..ad3f023604 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/event-producer-consumer.md -event-producer-consumer.md: 2239c9786f9e18b6a8e9514bfd53da7f87cfb8a4 -event-producer-consumer.zh.md: e43d48e2ee163843752bcda9fe3578839e05252b +event-producer-consumer.md: 3114d32853689ea267a923490a1a667a0a54b1f0 +event-producer-consumer.zh.md: 42915ab51d194d4827bf047135d39a9f3198a353 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 2239c9786f..3114d32853 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -80,7 +80,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event string | Dispatchers | Listeners | | --- | --- | --- | -| `internal/dispatch` | - | `agent-team`, [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`terminal-bash`](../packages/terminal/terminal-bash), [`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), [`webhook`](../packages/webhook/webhook), [`workflow`](../packages/workflow/workflow) | +| `internal/dispatch` | - | `agent-team`, [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`terminal-bash`](../packages/terminal/terminal-bash), `terminal-controller`, [`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), [`webhook`](../packages/webhook/webhook), [`workflow`](../packages/workflow/workflow) | | `internal/plugin` | - | `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), `modules` | | `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets), `gateway` | | `internal/status` | - | [`agent`](../packages/core/agent), `inspector` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index e43d48e2ee..42915ab51d 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -82,7 +82,7 @@ | Event string | Dispatchers | Listeners | | --- | --- | --- | -| `internal/dispatch` | - | `agent-team`, [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`terminal-bash`](../packages/terminal/terminal-bash), [`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), [`webhook`](../packages/webhook/webhook), [`workflow`](../packages/workflow/workflow) | +| `internal/dispatch` | - | `agent-team`, [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`terminal-bash`](../packages/terminal/terminal-bash), `terminal-controller`, [`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), [`webhook`](../packages/webhook/webhook), [`workflow`](../packages/workflow/workflow) | | `internal/plugin` | - | `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), `modules` | | `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets), `gateway` | | `internal/status` | - | [`agent`](../packages/core/agent), `inspector` | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index e6eaa903f2..8e198a04a4 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: cbaa8cb04a7ec2406b1f6e7f2c1dbf0a8dff0527 -module-graph.zh.md: 148b2730fccc1740d1c2f24d766f2aa7213093cf +module-graph.md: 4e0db7406fa7b18bfef88e4a4828578aebcca327 +module-graph.zh.md: 590a3cb3de2fbd85a52bc8fabe5921905f148186 diff --git a/docs/module-graph.md b/docs/module-graph.md index cbaa8cb04a..4e0db7406f 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -115,6 +115,7 @@ flowchart TD pkg_api_remotes["api-remotes"] pkg_api_session_controller["api-session-controller"] pkg_api_settings_controller["api-settings-controller"] + pkg_api_terminal_controller["api-terminal-controller"] pkg_api_workspace_controller["api-workspace-controller"] pkg_api_workspace_files["api-workspace-files"] end @@ -176,6 +177,7 @@ flowchart TD pkg_client_ui_sidebar_documentpreview["client-ui-sidebar-documentpreview"] pkg_client_ui_sidebar_files["client-ui-sidebar-files"] pkg_client_ui_sidebar_right["client-ui-sidebar-right"] + pkg_client_ui_sidebar_terminal["client-ui-sidebar-terminal"] pkg_client_ui_skill["client-ui-skill"] pkg_client_ui_slots["client-ui-slots"] pkg_client_ui_subagent["client-ui-subagent"] @@ -1198,6 +1200,7 @@ flowchart TD | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | `llm` | — | | [`llm`](../packages/llm/llm) | `llm` | — | | [`api-gateway`](../packages/api/gateway) | `api` | — | +| [`api-terminal-controller`](../packages/api/terminal-controller) | `api` | — | | [`api-workspace-files`](../packages/api/workspace-files) | `api` | — | | [`cmdline`](../packages/boot/cmdline) | `boot` | — | | [`acp-app`](../packages/bundle/acp-app) | `bundle` | — | @@ -1244,6 +1247,7 @@ flowchart TD | [`client-ui-sidebar-documentpreview`](../packages/client/ui-sidebar-documentpreview) | `client` | — | | [`client-ui-sidebar-files`](../packages/client/ui-sidebar-files) | `client` | — | | [`client-ui-sidebar-right`](../packages/client/ui-sidebar-right) | `client` | — | +| [`client-ui-sidebar-terminal`](../packages/client/ui-sidebar-terminal) | `client` | — | | [`client-ui-skill`](../packages/client/ui-skill) | `client` | — | | [`client-ui-slots`](../packages/client/ui-slots) | `client` | — | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | — | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 148b2730fc..590a3cb3de 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -117,6 +117,7 @@ flowchart TD pkg_api_remotes["api-remotes"] pkg_api_session_controller["api-session-controller"] pkg_api_settings_controller["api-settings-controller"] + pkg_api_terminal_controller["api-terminal-controller"] pkg_api_workspace_controller["api-workspace-controller"] pkg_api_workspace_files["api-workspace-files"] end @@ -178,6 +179,7 @@ flowchart TD pkg_client_ui_sidebar_documentpreview["client-ui-sidebar-documentpreview"] pkg_client_ui_sidebar_files["client-ui-sidebar-files"] pkg_client_ui_sidebar_right["client-ui-sidebar-right"] + pkg_client_ui_sidebar_terminal["client-ui-sidebar-terminal"] pkg_client_ui_skill["client-ui-skill"] pkg_client_ui_slots["client-ui-slots"] pkg_client_ui_subagent["client-ui-subagent"] @@ -1200,6 +1202,7 @@ flowchart TD | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | `llm` | — | | [`llm`](../packages/llm/llm) | `llm` | — | | [`api-gateway`](../packages/api/gateway) | `api` | — | +| [`api-terminal-controller`](../packages/api/terminal-controller) | `api` | — | | [`api-workspace-files`](../packages/api/workspace-files) | `api` | — | | [`cmdline`](../packages/boot/cmdline) | `boot` | — | | [`acp-app`](../packages/bundle/acp-app) | `bundle` | — | @@ -1246,6 +1249,7 @@ flowchart TD | [`client-ui-sidebar-documentpreview`](../packages/client/ui-sidebar-documentpreview) | `client` | — | | [`client-ui-sidebar-files`](../packages/client/ui-sidebar-files) | `client` | — | | [`client-ui-sidebar-right`](../packages/client/ui-sidebar-right) | `client` | — | +| [`client-ui-sidebar-terminal`](../packages/client/ui-sidebar-terminal) | `client` | — | | [`client-ui-skill`](../packages/client/ui-skill) | `client` | — | | [`client-ui-slots`](../packages/client/ui-slots) | `client` | — | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | — | diff --git a/docs/subsystems/subprocess.i18n.yaml b/docs/subsystems/subprocess.i18n.yaml index 90672cfb7f..e816893e36 100644 --- a/docs/subsystems/subprocess.i18n.yaml +++ b/docs/subsystems/subprocess.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/subprocess.md -subprocess.md: 782623760e70648a108ac51252b610e27bffbe45 -subprocess.zh.md: 7b8e590a843899b5f00a309a913e1dcb152c5c1b +subprocess.md: 71040fb28b8cf919f72f8c727a7d0c7add30dada +subprocess.zh.md: dda7356108305ac7e2f3182608b248ce6fa781de diff --git a/docs/subsystems/subprocess.md b/docs/subsystems/subprocess.md index 782623760e..71040fb28b 100644 --- a/docs/subsystems/subprocess.md +++ b/docs/subsystems/subprocess.md @@ -240,7 +240,9 @@ interface SubprocessOutcome { `spawnTerminal(spec)` is the non-pipe process primitive. The provider allocates the controlling terminal and owns UTF-8 text transport, foreground-process-group inspection and signalling, and one awaited TERM-to-KILL operation that reaches quiescence for every session member the provider can still observe; providers document substrate-specific observability limits. The PTY backend remains responsible for prompt detection, readiness inference, scrollback, sandbox policy, and persistent-session ownership; ordinary `spawn()` cannot reconstruct controlling-terminal semantics. -The terminal spec fully specifies argv, cwd, environment overrides, dimensions, cleanup grace, and optional allocation cancellation. Its handle exposes `pid`, ordered output, `done`, `write`, `inspectForeground`, `signalForeground`, and awaited `terminate`; the exact public shapes are generated into the [`ctx.subprocess` service catalog](#ctxsubprocess--subprocessruntime-abstract-seam). +The terminal spec fully specifies argv, cwd, environment overrides, terminal type, dimensions, cleanup grace, and optional allocation cancellation. Its handle exposes `pid`, ordered output, `done`, `write`, `resize`, `inspectForeground`, `signalForeground`, and awaited `terminate`; [`SubprocessTerminalSpawnSpec` and `SubprocessTerminalHandle`](../../packages/subprocess/subprocess/src/types.ts) define these fields and operations. `resize(cols, rows)` updates the live PTY dimensions and rejects after process exit. + +`terminalEnvironment(signal?)` returns `SubprocessTerminalEnvironment`: the execution environment platform (`posix` or `windows`) and optional `defaultShell`. These facts come from the provider rather than the Web server or browser. `resolveExecutable` verifies shell candidates; `SubprocessExecutableNotFoundError` identifies a missing executable, while provider and transport failures remain errors. ## Service behavior @@ -300,6 +302,13 @@ Implementations must honor these semantics: */ abstract resolveExecutable( command: string, env?: Readonly>, signal?: AbortSignal, ): Promise +/** + * Inspect shell-selection facts in the provider's execution environment. + * @param signal - cancellation of remote environment inspection. + * @returns platform and preferred shell; executable lookup and allocation remain separate operations. + */ +abstract terminalEnvironment(signal?: AbortSignal): Promise + /** * Start one managed child process from a fully-specified spec; this seam * applies no defaults. diff --git a/docs/subsystems/subprocess.zh.md b/docs/subsystems/subprocess.zh.md index 7b8e590a84..dda7356108 100644 --- a/docs/subsystems/subprocess.zh.md +++ b/docs/subsystems/subprocess.zh.md @@ -240,7 +240,9 @@ interface SubprocessOutcome { `spawnTerminal(spec)` 是非管道进程原语。提供方分配控制终端,并负责 UTF-8 文本传输、前台进程组检查与信号发送,以及一项须等待的 TERM→KILL 操作;该操作会使提供方仍可观察到的每个会话成员完全停稳,提供方则会记录执行基底特有的可观察性限制。PTY 后端仍负责提示符检测、就绪推断、scrollback、沙箱策略和持久会话所有权;普通 `spawn()` 无法重建控制终端语义。 -终端 spec 完全指定 argv、cwd、环境覆盖、尺寸、清理宽限期与可选的分配取消。其句柄公开 `pid`、有序输出、`done`、`write`、`inspectForeground`、`signalForeground` 和须等待的 `terminate`;确切的公共形状生成到 [`ctx.subprocess` 服务目录](#ctxsubprocess--subprocessruntime-abstract-seam)中。 +终端 spec 完全指定 argv、cwd、环境覆盖、终端类型、尺寸、清理宽限期与可选的分配取消。其句柄公开 `pid`、有序输出、`done`、`write`、`resize`、`inspectForeground`、`signalForeground` 和须等待的 `terminate`;[`SubprocessTerminalSpawnSpec` 与 `SubprocessTerminalHandle`](../../packages/subprocess/subprocess/src/types.ts) 定义这些字段和操作。`resize(cols, rows)` 更新正在运行的 PTY 尺寸,进程退出后拒绝调用。 + +`terminalEnvironment(signal?)` 返回 `SubprocessTerminalEnvironment`:执行环境平台(`posix` 或 `windows`)与可选的 `defaultShell`。这些事实来自提供方,而非 Web 服务器或浏览器。`resolveExecutable` 验证候选 shell;`SubprocessExecutableNotFoundError` 表示可执行文件不存在,提供方与传输故障仍作为错误报告。 ## 服务行为 @@ -300,6 +302,13 @@ Implementations must honor these semantics: */ abstract resolveExecutable( command: string, env?: Readonly>, signal?: AbortSignal, ): Promise +/** + * Inspect shell-selection facts in the provider's execution environment. + * @param signal - cancellation of remote environment inspection. + * @returns platform and preferred shell; executable lookup and allocation remain separate operations. + */ +abstract terminalEnvironment(signal?: AbortSignal): Promise + /** * Start one managed child process from a fully-specified spec; this seam * applies no defaults. diff --git a/docs/subsystems/workspace.i18n.yaml b/docs/subsystems/workspace.i18n.yaml index 154640d962..cb54a2833a 100644 --- a/docs/subsystems/workspace.i18n.yaml +++ b/docs/subsystems/workspace.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/workspace.md -workspace.md: a7c4b8843f0a0b2d2fb251157f7928a40df1e405 -workspace.zh.md: 3474cbf147848b04a4ec51147983a343bcc1e91d +workspace.md: f37af82d7f8cec018d24a22534bc0b7342425d34 +workspace.zh.md: 02dd976abde63992b65f09ea4852cedf0d0b4b36 diff --git a/docs/subsystems/workspace.md b/docs/subsystems/workspace.md index a7c4b8843f..f37af82d7f 100644 --- a/docs/subsystems/workspace.md +++ b/docs/subsystems/workspace.md @@ -183,6 +183,89 @@ Host service backing the generated `ctx.remote.directoryPicker` namespace. The s Source: [`packages/api/workspace-controller/src/directory-picker.ts`](../../packages/api/workspace-controller/src/directory-picker.ts) + + +### `ctx.terminalController` — `TerminalController` + +Typed Remote control of transient Session-owned terminal processes. + +```ts cordis-catalog +/** + * Read the Session working directory and terminal limits without resolving a shell. + * @param agent - Session owner supplied by the Gateway. + * @param signal - request cancellation. + * @returns the Session workspace directory and terminal limits. + */ +@Remote environment(agent: Agent, signal: AbortSignal): TerminalEnvironment + +/** + * List running and exited terminals available for reattachment. + * @param agent - Session owner supplied by the Gateway. + * @returns terminals retained for this Host lifetime. + */ +@Remote list(agent: Agent): WebTerminalInfo[] + +/** + * Allocate an interactive shell once for a caller-generated identity. + * @param agent - Session owner supplied by the Gateway. + * @param request - initial dimensions and idempotency identity. + * @param signal - allocation cancellation; committed terminals survive disconnection. + * @returns the existing or newly committed terminal. + */ +@Remote async create(agent: Agent, request: TerminalCreateRequest, signal: AbortSignal): Promise + +/** + * Attach to a terminal without binding its process lifetime to the transport. + * @param agent - Session owner supplied by the Gateway. + * @param id - terminal identity. + * @param attachmentId - new exclusive input attachment. + * @param signal - physical stream cancellation. + * @returns screen recovery followed by output and metadata changes. + */ +@Remote({ mode: 'stream' }) follow(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, signal: AbortSignal): AsyncIterable + +/** + * Deliver raw input, including Tab completion and control characters. + * @param agent - Session owner supplied by the Gateway. + * @param id - terminal identity. + * @param attachmentId - current writable attachment. + * @param data - input bytes represented as UTF-8 text. + * @returns after provider input acceptance. + */ +@Remote async write(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, data: string): Promise + +/** + * Update the dimensions of the PTY and recovery screen. + * @param agent - Session owner supplied by the Gateway. + * @param id - terminal identity. + * @param attachmentId - current writable attachment. + * @param cols - column count. + * @param rows - row count. + * @returns after the resize completes. + */ +@Remote async resize(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, cols: number, rows: number): Promise + +/** + * Rename a terminal without changing its shell. + * @param agent - Session owner supplied by the Gateway. + * @param id - terminal identity. + * @param title - nonempty display title, at most 120 characters. + */ +@Remote rename(agent: Agent, id: WebTerminalId, title: string): void + +/** + * Close an identity to future creation and kill its process range; repeated closes succeed. + * @param agent - Session owner supplied by the Gateway. + * @param id - terminal identity. + * @returns after provider cleanup succeeds. A failure retains the terminal for retry. + */ +@Remote async close(agent: Agent, id: WebTerminalId): Promise +``` + +Types: [Agent](core.md) + +Source: [`packages/api/terminal-controller/src/index.ts`](../../packages/api/terminal-controller/src/index.ts) + ### `ctx.workspaceController` — `WorkspaceController` diff --git a/docs/subsystems/workspace.zh.md b/docs/subsystems/workspace.zh.md index 3474cbf147..02dd976abd 100644 --- a/docs/subsystems/workspace.zh.md +++ b/docs/subsystems/workspace.zh.md @@ -183,6 +183,89 @@ Host service backing the generated `ctx.remote.directoryPicker` namespace. The s Source: [`packages/api/workspace-controller/src/directory-picker.ts`](../../packages/api/workspace-controller/src/directory-picker.ts) + + +### `ctx.terminalController` — `TerminalController` + +Typed Remote control of transient Session-owned terminal processes. + +```ts cordis-catalog +/** + * Read the Session working directory and terminal limits without resolving a shell. + * @param agent - Session owner supplied by the Gateway. + * @param signal - request cancellation. + * @returns the Session workspace directory and terminal limits. + */ +@Remote environment(agent: Agent, signal: AbortSignal): TerminalEnvironment + +/** + * List running and exited terminals available for reattachment. + * @param agent - Session owner supplied by the Gateway. + * @returns terminals retained for this Host lifetime. + */ +@Remote list(agent: Agent): WebTerminalInfo[] + +/** + * Allocate an interactive shell once for a caller-generated identity. + * @param agent - Session owner supplied by the Gateway. + * @param request - initial dimensions and idempotency identity. + * @param signal - allocation cancellation; committed terminals survive disconnection. + * @returns the existing or newly committed terminal. + */ +@Remote async create(agent: Agent, request: TerminalCreateRequest, signal: AbortSignal): Promise + +/** + * Attach to a terminal without binding its process lifetime to the transport. + * @param agent - Session owner supplied by the Gateway. + * @param id - terminal identity. + * @param attachmentId - new exclusive input attachment. + * @param signal - physical stream cancellation. + * @returns screen recovery followed by output and metadata changes. + */ +@Remote({ mode: 'stream' }) follow(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, signal: AbortSignal): AsyncIterable + +/** + * Deliver raw input, including Tab completion and control characters. + * @param agent - Session owner supplied by the Gateway. + * @param id - terminal identity. + * @param attachmentId - current writable attachment. + * @param data - input bytes represented as UTF-8 text. + * @returns after provider input acceptance. + */ +@Remote async write(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, data: string): Promise + +/** + * Update the dimensions of the PTY and recovery screen. + * @param agent - Session owner supplied by the Gateway. + * @param id - terminal identity. + * @param attachmentId - current writable attachment. + * @param cols - column count. + * @param rows - row count. + * @returns after the resize completes. + */ +@Remote async resize(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, cols: number, rows: number): Promise + +/** + * Rename a terminal without changing its shell. + * @param agent - Session owner supplied by the Gateway. + * @param id - terminal identity. + * @param title - nonempty display title, at most 120 characters. + */ +@Remote rename(agent: Agent, id: WebTerminalId, title: string): void + +/** + * Close an identity to future creation and kill its process range; repeated closes succeed. + * @param agent - Session owner supplied by the Gateway. + * @param id - terminal identity. + * @returns after provider cleanup succeeds. A failure retains the terminal for retry. + */ +@Remote async close(agent: Agent, id: WebTerminalId): Promise +``` + +Types: [Agent](core.zh.md) + +Source: [`packages/api/terminal-controller/src/index.ts`](../../packages/api/terminal-controller/src/index.ts) + ### `ctx.workspaceController` — `WorkspaceController` diff --git a/packages/api/README.i18n.yaml b/packages/api/README.i18n.yaml index ce98751e3d..290b5a8eb2 100644 --- a/packages/api/README.i18n.yaml +++ b/packages/api/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/README.md -README.md: 20455e6b5622ffd6e826b1d4b427838f96f6610f -README.zh.md: 0294b1823e6a09cae4591b4c6081a546d8d60494 +README.md: a60e47948ffcb54e07b33ff63a1ea92e3c05998e +README.zh.md: 9e374c90c808f4314ba6565038bdba6bcca80cf9 diff --git a/packages/api/README.md b/packages/api/README.md index 20455e6b56..a60e47948f 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -31,6 +31,7 @@ The packages below provide the Remote layer; the package READMEs own the exhaust | [`session-controller/`](session-controller/README.md) | Owns Session commands, history streams, live control state, and Agent/Session identity policy. | `ctx.sessionController` / `ctx.remote.session` | | [`settings-controller/`](settings-controller/README.md) | Owns the configuration-surface reads and writes over the settings-domain seams. | `ctx.settingsController`, `ctx.credentialsController` / `ctx.remote.settings`, `ctx.remote.credentials` | | [`workspace-controller/`](workspace-controller/README.md) | Owns Workspace mutations and the complete Client Workspace projection. | `ctx.workspaceController` / `ctx.remote.workspace` | +| [`terminal-controller/`](terminal-controller/README.md) | Session-owned interactive shells, screen recovery and browser terminal control. | `ctx.terminalController` / `ctx.remote.terminal` | | [`workspace-files/`](workspace-files/README.md) | Owns bounded workspace file access — `stat`, paged `read`, `list`, and the agent-write `changes` feed — and the Client `file` resource provider over it. | `ctx.workspaceFiles` / `ctx.remote.workspaceFiles` | Remote calls run Client → Host over the application's shared Connection. API Gateway owns Remote transport, while the controller packages own Session, configuration-surface, and Workspace behavior. Feature packages register exact Connection Fetch routes for responses that do not fit Remote invocation, such as streamed downloads. diff --git a/packages/api/README.zh.md b/packages/api/README.zh.md index 0294b1823e..9e374c90c8 100644 --- a/packages/api/README.zh.md +++ b/packages/api/README.zh.md @@ -31,6 +31,7 @@ kind: "package-group" | [`session-controller/`](session-controller/README.zh.md) | 拥有 Session 命令、历史 stream、实时控制状态与 Agent/Session 身份策略。 | `ctx.sessionController` / `ctx.remote.session` | | [`settings-controller/`](settings-controller/README.zh.md) | 拥有 settings 域各 seam 之上的配置界面读写。 | `ctx.settingsController`、`ctx.credentialsController` / `ctx.remote.settings`、`ctx.remote.credentials` | | [`workspace-controller/`](workspace-controller/README.zh.md) | 拥有 Workspace 变更与完整 Client Workspace 投影。 | `ctx.workspaceController` / `ctx.remote.workspace` | +| [`terminal-controller/`](terminal-controller/README.zh.md) | Session 拥有的交互式 shell、屏幕恢复和浏览器终端控制。 | `ctx.terminalController` / `ctx.remote.terminal` | | [`workspace-files/`](workspace-files/README.zh.md) | 拥有有界的工作区文件访问——`stat`、分页 `read`、`list` 与 agent 写入的 `changes` 流——以及其上的 Client `file` 资源提供者。 | `ctx.workspaceFiles` / `ctx.remote.workspaceFiles` | Remote 调用沿 Client → Host 方向运行在应用共享的 Connection 之上。API Gateway 拥有 Remote 传输,各 controller 包分别拥有 Session、配置界面与 Workspace 行为。流式下载等不适合 Remote 调用的响应由功能包注册精确的 Connection Fetch 路由。 diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index 0d7280d631..6f64346ae5 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -85,6 +85,7 @@ "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-api-workspace-files": "workspace:^", "zod": "^4.4.3", - "@deepseek-ai/dsh-command-feedback": "workspace:^" + "@deepseek-ai/dsh-command-feedback": "workspace:^", + "@deepseek-ai/dsh-api-terminal-controller": "workspace:^" } } diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index fa0dae245e..250a1efc5d 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -15,6 +15,7 @@ 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' import workspaceRemote from '@deepseek-ai/dsh-api-workspace-controller/remote' +import terminalRemote from '@deepseek-ai/dsh-api-terminal-controller/remote' import workspaceFilesRemote from '@deepseek-ai/dsh-api-workspace-files/remote' import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' @@ -38,6 +39,8 @@ export type {} from '@deepseek-ai/dsh-api-workspace-controller/remote' export type * from '@deepseek-ai/dsh-api-workspace-controller/types' export type {} from '@deepseek-ai/dsh-api-workspace-files/remote' export type * from '@deepseek-ai/dsh-api-workspace-files/types' +export type {} from '@deepseek-ai/dsh-api-terminal-controller/remote' +export type * from '@deepseek-ai/dsh-api-terminal-controller/types' export type { SessionJob as JobView } from '@deepseek-ai/dsh-api-session-controller/types' // The forwarded-event allowlist's selection seat: without it in the consumer's // compilation face `TypertRemoteEvent` is `never` and every `$on` call fails. @@ -153,7 +156,7 @@ export async function apply(ctx: Context): Promise<() => Promise> { for (const contribution of [ agentPresetsRemote, commandsRemote, settingsControllerRemote, goalsRemote, llmRemote, dynamicRemote, pluginInventoryRemote, messageFeedbackRemote, sessionFeedbackRemote, fileUploadsRemote, sessionReferencesRemote, - subagentsRemote, sessionRemote, workspaceRemote, workspaceFilesRemote, + subagentsRemote, sessionRemote, workspaceRemote, workspaceFilesRemote, terminalRemote, ]) { disposers.push(await ctx.remote.$mount(contribution)) } diff --git a/packages/api/remotes/tsconfig.client.json b/packages/api/remotes/tsconfig.client.json index 7222a73c98..16f3789b71 100644 --- a/packages/api/remotes/tsconfig.client.json +++ b/packages/api/remotes/tsconfig.client.json @@ -82,6 +82,9 @@ }, { "path": "../../feedback/command-feedback" + }, + { + "path": "../terminal-controller/tsconfig.client.json" } ] } diff --git a/packages/api/remotes/tsconfig.host.json b/packages/api/remotes/tsconfig.host.json index 99a3e5cc75..e17db7a665 100644 --- a/packages/api/remotes/tsconfig.host.json +++ b/packages/api/remotes/tsconfig.host.json @@ -67,6 +67,9 @@ }, { "path": "../../typert/protocol" + }, + { + "path": "../terminal-controller/tsconfig.host.json" } ] } diff --git a/packages/api/terminal-controller/README.i18n.yaml b/packages/api/terminal-controller/README.i18n.yaml new file mode 100644 index 0000000000..2b60551cd2 --- /dev/null +++ b/packages/api/terminal-controller/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/api/terminal-controller/README.md +README.md: f6755cab8def6bc1f5842ee387a20c5ed51e7e7f +README.zh.md: fef6e6b45cef87e0a3f1c433dec13baad59ab75a diff --git a/packages/api/terminal-controller/README.md b/packages/api/terminal-controller/README.md new file mode 100644 index 0000000000..f6755cab8d --- /dev/null +++ b/packages/api/terminal-controller/README.md @@ -0,0 +1,90 @@ +--- +description: "Interactive user terminals with execution-environment shell defaults, bounded screen recovery and typed Remote control." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-api-terminal-controller + +English | [中文](README.zh.md) + +## Summary + +Open the execution environment's default shell in a Session workspace from the Web sidebar. Reconnect to existing processes and close their complete provider-owned process ranges. Terminal output stays outside the Agent transcript. Keeping a terminal open retains its process and a bounded screen buffer. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +The Web bundle mounts this package with the subprocess provider, sandbox policy and Typert Gateway. `remote.terminal` exposes `environment`, `list`, `create`, `follow`, `write`, `resize`, `rename` and `close`; each operation is scoped by Session identity. + +New terminals use the execution environment's declared default shell. Only when the provider omits that default does resolution use `/bin/sh` on POSIX or `cmd.exe` on Windows. An optional `shell` profile overrides that choice with executable `path`, display `name` and `args` (default `[]`). The provider verifies the executable during creation; resolution failure is reported without trying another shell. Environment lookup returns the working directory and limits without resolving a shell, so an unavailable default does not prevent reattaching to an existing process. Automatic POSIX profiles start interactively, and PowerShell uses `-NoLogo`, so completion and startup configuration remain shell-owned. The Session workspace supplies the initial directory; its sandbox policy also applies to the terminal. + +| Configuration | Default | Meaning | +|---|---|---| +| `shell` | omitted | Use the execution environment's default shell, or one explicit profile | +| `maxTerminals` | `8` | Retained terminals and pending allocations per Session | +| `maxCols`, `maxRows` | `500`, `200` | Maximum PTY dimensions | +| `scrollback` | `1000` | Retained screen history rows | +| `maxBufferedBytes` | `2097152` | Output queued for one follower | +| `maxInputBytes` | `65536` | Maximum input request bytes | +| `disposeGraceMs` | `1000` | Provider termination grace in milliseconds | + + +## Understand the implementation + +
+Implementation internals — click to expand + +The Host uses `ctx.subprocess.spawnTerminal` with `TERM=xterm-256color`; it never launches a desktop terminal application. Unary control uses the Gateway, and `follow` uses its multiplexed Remote stream transport. Headless xterm and its serializer produce each opening screen after all preceding output writes, then monotone output sequences identify subsequent frames. Slow followers fail explicitly; a new attachment restores the current screen. + +The latest attachment owns input and resize. Detachment releases input control without killing the process. Explicit close awaits process cleanup and final output; cleanup failure retains the resource for retry. The Session remembers closed identities and rejects their delayed or repeated creation, including creation already in progress when close arrives. A new terminal uses a new identity. Pending allocations remain owned even if cancellation and cleanup both fail. Session owner disposal and controller disposal also terminate owned processes. An open or pending terminal prevents changing that Session's sandbox mode. + +Client views keep the association between sidebar tabs and terminal identities in memory. Recovery queries the Host for retained terminals; a new view may create a process, while a recovered view reports a missing target without creating a replacement. The Client model acknowledges screen writes after the browser emulator processes them, serializes input and ignores stale attachment responses. + +Closing saves an unfinished cleanup request before releasing the tab, then awaits Host cleanup in the background. A failure exposes a retry notification. Each request has its own terminal-ID localStorage key and is removed after successful cleanup; startup retries saved requests. This stores cleanup intent, not sidebar layout, open-tab mappings, selected tabs or process PIDs. If browser storage is unavailable, cleanup remains usable in memory but cannot be recovered after reload. + +
+ + +## Further Exploration + +- [Subprocess](../../subprocess/subprocess/README.md) +- [Right Sidebar](../../client/ui-sidebar-right/README.md) +- [Web terminal decision](../../../.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md) + + +## Model Experience + +None, as this package handles user terminal interaction without adding model input. + +#### KV Cache effect + +None; terminal output travels only between the browser and Host. + +## Known Limitations and Deferred Work + + + +- Processes and screens survive browser reloads, but not Host or Session owner disposal. There is no durable terminal restoration or automatic shell respawn. +- The subprocess provider determines native PTY availability and process-tree cleanup guarantees. Finding an executable does not prove PTY allocation will succeed. +- Screen recovery retains bounded history, not a complete transcript. Only one attachment at a time can write or resize. + + +### Dev Note + +
+Working context for maintainers — click to expand + +No runtime invariant companion is published. One owner orders terminal metadata and screen updates; the provider exposes no independently observed dimensions to compare. + +
diff --git a/packages/api/terminal-controller/README.zh.md b/packages/api/terminal-controller/README.zh.md new file mode 100644 index 0000000000..fef6e6b45c --- /dev/null +++ b/packages/api/terminal-controller/README.zh.md @@ -0,0 +1,90 @@ +--- +description: "用户交互式终端:执行环境默认 shell、有界屏幕恢复和类型化 Remote 控制。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-api-terminal-controller + +[English](README.md) | 中文 + +## 概述 + +从 Web 侧栏在会话工作区打开执行环境的默认 shell。重新连接已有进程,并关闭 provider 管理的完整进程范围。终端输出不进入 Agent 对话记录。保留终端会占用进程和有界屏幕缓存。 + +## 目录 + +- [使用此包](#use-this-package) +- [了解实现](#understand-the-implementation) +- [延伸阅读](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用此包 + +Web bundle 将此包与 subprocess provider、sandbox policy 和 Typert Gateway 一起挂载。`remote.terminal` 提供 `environment`、`list`、`create`、`follow`、`write`、`resize`、`rename` 和 `close`;每个操作均按 Session 标识限定范围。 + +新终端使用执行环境声明的默认 shell。仅当 provider 未声明默认值时,才在 POSIX 使用 `/bin/sh`,在 Windows 使用 `cmd.exe`。可选的 `shell` profile 通过可执行路径 `path`、显示名称 `name` 和参数 `args`(默认 `[]`)覆盖这一选择。provider 在创建时验证可执行文件;解析失败会直接报告,不尝试其他 shell。环境查询只返回工作目录和限制,不解析 shell,因此默认 shell 不可用时仍可重新连接已有进程。POSIX 自动 profile 以交互模式启动,PowerShell 使用 `-NoLogo`,补全和启动配置仍由 shell 提供。初始目录来自 Session 工作区,终端遵循同一 sandbox policy。 + +| 配置 | 默认值 | 含义 | +|---|---|---| +| `shell` | 省略 | 使用执行环境默认 shell,或指定一个 profile | +| `maxTerminals` | `8` | 每个 Session 保留的终端和创建请求上限 | +| `maxCols`、`maxRows` | `500`、`200` | PTY 最大尺寸 | +| `scrollback` | `1000` | 屏幕历史行数 | +| `maxBufferedBytes` | `2097152` | 单个订阅者的输出排队上限 | +| `maxInputBytes` | `65536` | 单次输入请求的字节上限 | +| `disposeGraceMs` | `1000` | provider 终止宽限期,单位毫秒 | + + +## 了解实现 + +
+实现细节 + +Host 通过 `ctx.subprocess.spawnTerminal` 创建 `TERM=xterm-256color` 的终端,不启动桌面终端应用。控制请求走 Gateway,`follow` 使用其复用的 Remote stream。Headless xterm 和序列化 addon 在此前输出写入后生成初始屏幕,后续增量携带单调序号。过慢的订阅者明确失败;重新连接恢复当前屏幕。 + +最新连接持有输入和尺寸控制权。断开连接只释放输入权,不结束进程。显式关闭等待进程清理和最后输出;清理失败时保留资源以便重试。Session 记住已关闭的标识并拒绝迟到或重复的创建请求,包括关闭到达时仍在进行的创建。新终端使用新标识。取消创建且清理失败时,已分配的进程仍有所有者。Session owner 和 controller 卸载也会终止所拥有的进程。存在终端或创建请求时不能改变该 Session 的 sandbox mode。 + +Client 视图只在内存中关联侧栏标签页与终端标识。恢复操作查询 Host 保留的终端;新视图可以创建进程,恢复视图在目标缺失时显示错误,不创建替代进程。Client 模型在浏览器完成屏幕解析后确认帧,按序发送输入,并忽略旧连接迟到的响应。 + +关闭时先保存未完成的清理请求并释放标签页,再在后台等待 Host 清理。失败时提供重试通知。每个请求使用独立的终端 ID localStorage key,清理成功后删除;启动时重试已保存的请求。保存的是清理意图,不是侧栏布局、打开标签页映射、选中标签页或进程 PID。浏览器存储不可用时,内存中的清理仍可工作,但刷新后无法恢复该请求。 + +
+ + +## 延伸阅读 + +- [Subprocess](../../subprocess/subprocess/README.zh.md) +- [Right Sidebar](../../client/ui-sidebar-right/README.zh.md) +- [Web terminal decision](../../../.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md) + + +## 模型体验 + +无;此包只处理用户交互式终端,不向模型请求添加内容。 + +#### KV 缓存影响 + +无;终端输出只在浏览器与 Host 之间传输。 + +## 已知限制与延期工作 + + + +- 浏览器刷新保留进程和屏幕;Host 或 Session owner 卸载不保留。不提供持久终端恢复或自动重启 shell。 +- 原生 PTY 可用性和进程树清理保证由 subprocess provider 决定。找到可执行文件并不保证 PTY 分配成功。 +- 屏幕恢复只保留有界历史,不保留完整记录。同一时刻只有一个连接可输入或调整尺寸。 + + +### 开发备注 + +
+维护说明 + +不发布运行时 invariant companion。终端元数据与屏幕更新由同一对象按序写入,没有独立的进程尺寸观测可供比较。 + +
diff --git a/packages/api/terminal-controller/package.json b/packages/api/terminal-controller/package.json new file mode 100644 index 0000000000..2427e57898 --- /dev/null +++ b/packages/api/terminal-controller/package.json @@ -0,0 +1,92 @@ +{ + "name": "@deepseek-ai/dsh-api-terminal-controller", + "description": "Session-owned interactive terminals with shell discovery, screen recovery and typed Remote control", + "version": "0.1.5-alpha.1", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/api/terminal-controller" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./typert": { + "types": "./lib/typert.host.d.ts", + "default": "./lib/typert.host.js" + }, + "./remote": { + "types": "./lib/typert.remote-client.d.ts", + "default": "./lib/typert.remote-client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-api-gateway" + ], + "external": [ + "@deepseek-ai/dsh-api-gateway/client" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "MIT", + "dependencies": { + "@deepseek-ai/dsh-deque": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", + "@deepseek-ai/schemastery": "workspace:^", + "@xterm/headless": "^6.0.0", + "@xterm/addon-serialize": "^0.14.0", + "zod": "^4.4.3" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-api-gateway": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-fs": "workspace:^", + "@deepseek-ai/dsh-sandbox": "workspace:^", + "@deepseek-ai/dsh-sandbox-policy": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", + "@deepseek-ai/dsh-util-crypto": "workspace:^" + }, + "files": [ + "lib/index.js", + "lib/client.js", + "lib/types/**/*.js", + "lib/types/**/*.d.ts", + "lib/typert.host.js", + "lib/typert.host.d.ts", + "lib/typert.remote-client.js", + "lib/typert.remote-client.d.ts" + ] +} diff --git a/packages/api/terminal-controller/src/client/close-requests.ts b/packages/api/terminal-controller/src/client/close-requests.ts new file mode 100644 index 0000000000..f91581a4b5 --- /dev/null +++ b/packages/api/terminal-controller/src/client/close-requests.ts @@ -0,0 +1,73 @@ +/** Unfinished close requests survive reload independently of the removed sidebar tabs. */ +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { WebTerminalId } from '../types.ts' + +/** An explicit cleanup request; no process or open-tab metadata is mirrored here. */ +export interface TerminalCloseRequest { + readonly sessionId: SessionId + readonly id: WebTerminalId + readonly title: string +} + +const PREFIX = 'dsh.terminal.close.v1.' + +/** Each request has its own storage key, so other browser windows cannot overwrite its cleanup. */ +export class TerminalCloseRequests { + private readonly requests = new Map() + + constructor() { + try { + if (typeof localStorage === 'undefined') return + for (let index = 0; index < localStorage.length; index++) { + const key = localStorage.key(index) + if (key?.startsWith(PREFIX)) this.load(key) + } + } catch (error) { console.error('Terminal cleanup recovery failed:', error) } + } + + /** + * Read cleanup work still awaiting Host confirmation. + * @returns unfinished requests owned by this browser instance. + */ + pending(): readonly TerminalCloseRequest[] { return [...this.requests.values()] } + + /** + * Retain cleanup across reload before removing a tab. + * @param request - close intent to save before removing its tab. + */ + save(request: TerminalCloseRequest): void { + this.requests.set(request.id, request) + try { + if (typeof localStorage !== 'undefined') localStorage.setItem(PREFIX + request.id, JSON.stringify(request)) + } catch (error) { console.error('Terminal cleanup persistence failed:', error) } + } + + /** + * Forget confirmed cleanup in memory and browser storage. + * @param id - terminal whose Host cleanup succeeded. + */ + remove(id: WebTerminalId): void { + this.requests.delete(id) + try { + if (typeof localStorage !== 'undefined') localStorage.removeItem(PREFIX + id) + } catch (error) { console.error('Terminal cleanup persistence failed:', error) } + } + + private load(key: string): void { + try { + const raw = localStorage.getItem(key) + if (raw === null) return + const parsed: unknown = JSON.parse(raw) + if (!isRequest(parsed) || key !== PREFIX + parsed.id) throw new Error('Invalid terminal cleanup request') + this.requests.set(parsed.id, parsed) + } catch (error) { console.error('Terminal cleanup recovery failed:', error) } + } +} + +function isRequest(value: unknown): value is TerminalCloseRequest { + if (typeof value !== 'object' || value === null) return false + const request = value as Record + return typeof request.sessionId === 'string' && request.sessionId.length > 0 + && typeof request.id === 'string' && /^[\w-]{1,128}$/u.test(request.id) + && typeof request.title === 'string' +} diff --git a/packages/api/terminal-controller/src/client/index.ts b/packages/api/terminal-controller/src/client/index.ts new file mode 100644 index 0000000000..b49d86c437 --- /dev/null +++ b/packages/api/terminal-controller/src/client/index.ts @@ -0,0 +1,145 @@ +/** Client terminal model service; views are keyed independently from Host terminal identities. */ +import { Service, type Context } from '@deepseek-ai/cordis' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type {} from '@deepseek-ai/dsh-api-gateway/client' +import { TerminalView, type TerminalRemote } from './model.ts' +import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' +import { randomUUID } from '@deepseek-ai/dsh-util-crypto' +import type { WebTerminalId, WebTerminalInfo } from '../types.ts' +import { TerminalCloseRequests, type TerminalCloseRequest } from './close-requests.ts' + +export type { TerminalView, TerminalViewState, TerminalRenderFrame, TerminalRemote } from './model.ts' + +declare module '@deepseek-ai/cordis' { + interface Context { + /** React-free browser terminal views and explicit process cleanup. */ + webTerminals: ClientTerminals + } +} + +/** A failed background close that can be retried without restoring its tab. */ +export interface TerminalCloseFailure { + readonly id: WebTerminalId + readonly title: string + readonly message: string +} + +/** Session and occurrence lookup, independent tab and terminal identities and background cleanup. */ +export class ClientTerminals extends Service { + /** Failed cleanup tasks; successful and in-progress closes have no visible notification. */ + readonly closeFailures: SnapshotStore = createSnapshotStore([]) + private readonly requests = new TerminalCloseRequests() + private readonly closing = new Map>() + private readonly closed = new Set(this.requests.pending().map(request => request.id)) + private disposed = false + private readonly views = new Map>() + + /** + * @param ctx - Client root Context with Gateway and terminal Remote namespace. + * @param remote - generated terminal namespace. + */ + constructor(ctx: Context, private readonly remote: TerminalRemote) { + super(ctx, 'webTerminals') + ctx.effect(() => async () => { + this.disposed = true + for (const views of this.views.values()) for (const view of views.values()) view.dispose() + this.views.clear() + await Promise.all(this.closing.values()) + }, 'terminal-controller.client.views') + for (const request of this.requests.pending()) this.cleanup(request) + } + + /** + * Return the stable model for one sidebar occurrence. + * @param sessionId - owning Session. + * @param key - sidebar occurrence key. + * @param terminalId - existing Host identity when restoring a listed terminal. + * @returns its observable state and terminal commands. + */ + view(sessionId: SessionId, key: string, terminalId?: WebTerminalId): TerminalView { + let views = this.views.get(sessionId) + if (views === undefined) { views = new Map(); this.views.set(sessionId, views) } + let view = views.get(key) + if (view === undefined) { + const id = terminalId ?? randomUUID() as WebTerminalId + view = new TerminalView(sessionId, this.remote, this.ctx.remote, id, terminalId === undefined) + views.set(key, view) + void view.refresh() + } + return view + } + + /** + * Save a close intent and release the tab immediately; cleanup outlives DOM unmount and reload. + * @param sessionId - owning Session. + * @param key - sidebar occurrence key, including an inactive restored tab. + * @param terminalId - restored identity if the tab has no model yet. + */ + close(sessionId: SessionId, key: string, terminalId?: WebTerminalId): void { + const views = this.views.get(sessionId) + const view = views?.get(key) + const id = view?.id ?? terminalId + if (id === undefined) return + const request: TerminalCloseRequest = { sessionId, id, title: view?.state.getSnapshot().title ?? key } + this.closed.add(id) + this.requests.save(request) + views?.delete(key) + if (views?.size === 0) this.views.delete(sessionId) + this.cleanup(request, view) + } + + /** + * Query Host terminals that have neither a tab in this page nor an unfinished close. + * @param sessionId - Session being displayed. + * @returns terminals available for opening as recovered tabs. + */ + async recover(sessionId: SessionId): Promise { + const result = await this.remote.list(sessionId) + if (!result.ok) throw new Error(result.error.message) + const held = new Set([...(this.views.get(sessionId)?.values() ?? [])].map(view => view.id)) + const closing = new Set(this.requests.pending().map(request => request.id)) + return result.value.filter(info => !held.has(info.id) && !closing.has(info.id) && !this.closed.has(info.id)) + } + + /** + * Retry a saved close request without reopening its tab. + * @param id - failed terminal identity. + */ + retryClose(id: WebTerminalId): void { + const record = this.requests.pending().find(item => item.id === id) + if (record !== undefined) this.cleanup(record) + } + + private cleanup(record: TerminalCloseRequest, view?: TerminalView): void { + if (this.closing.has(record.id) || this.disposed) return + this.closeFailures.set(this.closeFailures.getSnapshot().filter(failure => failure.id !== record.id)) + const pending = (async () => { + if (view !== undefined) await view.close() + else { + const result = await this.remote.close(record.sessionId, record.id) + if (!result.ok) throw new Error(result.error.message) + } + this.requests.remove(record.id) + })().catch((error: unknown) => { + if (!this.disposed) this.closeFailures.set([...this.closeFailures.getSnapshot(), { + id: record.id, title: record.title, + message: error instanceof Error ? error.message : String(error), + }]) + }).finally(() => { + view?.dispose() + this.closing.delete(record.id) + }) + this.closing.set(record.id, pending) + } +} + +/** Required Client transport and terminal namespace. */ +export const inject = ['remote', 'remote.terminal'] + +/** + * Install the Client terminal models. + * @param ctx - Client root Context. + */ +export function apply(ctx: Context): void { + new ClientTerminals(ctx, ctx.remote.terminal) +} diff --git a/packages/api/terminal-controller/src/client/model.ts b/packages/api/terminal-controller/src/client/model.ts new file mode 100644 index 0000000000..8c4336f3b9 --- /dev/null +++ b/packages/api/terminal-controller/src/client/model.ts @@ -0,0 +1,284 @@ +/** React-free browser terminal state and reconnecting Remote-stream ownership. */ +import { randomUUID } from '@deepseek-ai/dsh-util-crypto' +import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' +import { RemoteStreamCarrierError, type ClientRemote, type RemoteStream } from '@deepseek-ai/dsh-api-gateway/client' +import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import type {} from '@deepseek-ai/dsh-api-terminal-controller/remote' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { + TerminalAttachmentId, TerminalEnvironment, TerminalFrame, + WebTerminalId, WebTerminalInfo, +} from '../types.ts' + +/** The generated terminal namespace's browser-facing operations. */ +export type TerminalRemote = ClientRemote['terminal'] + +/** One screen write awaiting the DOM emulator's callback. */ +export interface TerminalRenderFrame { + readonly revision: number + readonly frame: Extract +} + +/** Observable state of one sidebar occurrence. */ +export interface TerminalViewState { + readonly phase: 'idle' | 'loading' | 'creating' | 'connecting' | 'connected' | 'disconnected' | 'closing' | 'closed' | 'failed' + readonly environment?: TerminalEnvironment | undefined + readonly title?: string | undefined + readonly info?: WebTerminalInfo | undefined + readonly writable: boolean + readonly render?: TerminalRenderFrame | undefined + readonly error?: string | undefined +} + +/** A view survives DOM unmount; its process only ends on explicit close. */ +export class TerminalView { + /** Observable controls, process metadata and the next screen update awaiting acknowledgement. */ + readonly state: SnapshotStore = createSnapshotStore({ phase: 'idle', writable: false }) + private readonly lifetime = new AbortController() + private stream: RemoteStream | undefined + private mounted = false + private attachmentId: TerminalAttachmentId | undefined + private pendingRender: { revision: number; resolve: () => void } | undefined + private revision = 0 + private creation: Promise | undefined + private loading: Promise | undefined + private closing: Promise | undefined + private writes: Promise = Promise.resolve() + private queuedInput = 0 + + /** + * @param sessionId - Session owning the terminal. + * @param remote - typed terminal Remote operations. + * @param gateway - reconnecting stream factory. + * @param id - Host terminal identity, reused when recovering an item from its Session list. + * @param createWhenMissing - allow allocation only for a new tab, never a listed terminal. + */ + constructor( + private readonly sessionId: SessionId, + private readonly remote: TerminalRemote, + private readonly gateway: Pick, + readonly id: WebTerminalId, + private readonly createWhenMissing = true, + ) {} + + /** + * Attach the DOM lifetime, starting the default shell or reconnecting the saved process. + * @returns a detach callback that leaves the terminal process alive. + */ + mount(): () => void { + this.mounted = true + if (this.state.getSnapshot().info === undefined) void this.refresh() + else this.connect() + return () => { + this.mounted = false + this.detach() + } + } + + /** + * Start or recover this tab, deduplicating mounts and retries during allocation. + * Only a new tab may allocate a shell; listed terminals cannot be silently replaced. + * @returns after environment lookup and creation or recovery settle. + */ + refresh(): Promise { + if (this.loading !== undefined) return this.loading + if (this.closing !== undefined || this.lifetime.signal.aborted) return Promise.resolve() + this.patch({ phase: 'loading', error: undefined }) + this.loading = (async () => { + const [environment, available] = await Promise.all([ + this.remote.environment(this.sessionId, this.lifetime.signal), this.remote.list(this.sessionId), + ]) + if (this.lifetime.signal.aborted || this.closing !== undefined) return + this.patch({ environment: valueOf(environment) }) + const info = valueOf(available).find(item => item.id === this.id) + if (info !== undefined) this.adopt(info) + else if (this.createWhenMissing) await this.create(valueOf(environment)) + else throw new Error('This terminal no longer exists on the Host. Open a new terminal.') + })().catch((error: unknown) => { this.fail(error) }).finally(() => { this.loading = undefined }) + return this.loading + } + + private async create(environment: TerminalEnvironment): Promise { + this.patch({ phase: 'creating', error: undefined }) + this.creation = (async () => { + const info = valueOf(await this.remote.create(this.sessionId, { + id: this.id, cols: Math.min(80, environment.maxCols), rows: Math.min(24, environment.maxRows), + }, this.lifetime.signal)) + if (!this.lifetime.signal.aborted) this.adopt(info) + })().finally(() => { this.creation = undefined }) + await this.creation + } + + private adopt(info: WebTerminalInfo): void { + this.patch({ info, title: info.title }) + if (this.mounted && this.closing === undefined) this.connect() + } + + /** Reattach with a fresh screen and regain input control. */ + connect(): void { + const info = this.state.getSnapshot().info + if (info === undefined || !this.mounted || this.closing !== undefined || this.lifetime.signal.aborted) return + this.detach() + const stream = this.gateway.$stream({ + name: 'Browser terminal output', + open: (signal) => { + const attachmentId = randomUUID() as TerminalAttachmentId + this.attachmentId = attachmentId + return this.remote.follow(this.sessionId, info.id, attachmentId, signal) + }, + ended: () => new Error('Terminal attachment ended; reconnect to recover its state'), + carrierFailed: () => { if (this.stream === stream) this.patch({ phase: 'disconnected', writable: false }) }, + }) + this.stream = stream + this.patch({ phase: 'connecting', writable: false, error: undefined, render: undefined }) + void this.consume(stream) + } + + /** + * Release the next stream item after xterm has parsed this frame. + * @param revision - locally delivered render revision. + */ + acknowledge(revision: number): void { + if (this.pendingRender?.revision !== revision) return + this.pendingRender.resolve() + this.pendingRender = undefined + } + + /** + * Serialize raw input so concurrent RPC requests cannot reorder keystrokes. + * @param data - input from the terminal emulator. + */ + write(data: string): void { + const state = this.state.getSnapshot() + const attachmentId = this.attachmentId + if (!state.writable || state.info === undefined || attachmentId === undefined) return + const bytes = new TextEncoder().encode(data).byteLength + if (this.queuedInput + bytes > (state.environment?.maxInputBytes ?? 0)) { this.fail(new Error('Terminal input buffer is full')); return } + this.queuedInput += bytes + const id = state.info.id + this.writes = this.writes.then(async () => { + if (this.attachmentId !== attachmentId) return + valueOf(await this.remote.write(this.sessionId, id, attachmentId, data)) + }).catch((error: unknown) => { if (this.attachmentId === attachmentId) this.fail(error) }).finally(() => { this.queuedInput -= bytes }) + } + + /** + * Resize only from the currently writable view. + * @param cols - measured column count. + * @param rows - measured row count. + */ + resize(cols: number, rows: number): void { + const state = this.state.getSnapshot() + const attachmentId = this.attachmentId + if (!state.writable || state.info === undefined || attachmentId === undefined) return + if (state.info.cols === cols && state.info.rows === rows) return + const id = state.info.id + cols = Math.min(cols, state.environment?.maxCols ?? cols) + rows = Math.min(rows, state.environment?.maxRows ?? rows) + this.writes = this.writes.then(async () => { + if (this.attachmentId !== attachmentId) return + valueOf(await this.remote.resize(this.sessionId, id, attachmentId, cols, rows)) + }).catch((error: unknown) => { if (this.attachmentId === attachmentId) this.fail(error) }) + } + + /** + * Update the Host terminal's display name. + * @param title - user-entered terminal title. + * @returns after the rename settles and its result is reflected in view state. + */ + async rename(title: string): Promise { + if (title.trim() === this.state.getSnapshot().title || this.lifetime.signal.aborted) return + try { + valueOf(await this.remote.rename(this.sessionId, this.id, title)) + const current = this.state.getSnapshot().info + this.patch({ ...(current === undefined ? {} : { info: { ...current, title: title.trim() } }), title: title.trim() }) + } catch (error) { this.fail(error) } + } + + /** + * Explicitly terminate this view's process independently of its DOM lifetime. + * @returns after Host process cleanup succeeds; failures remain retryable by the owner. + */ + close(): Promise { + if (this.closing !== undefined) return this.closing + this.patch({ phase: 'closing', writable: false, error: undefined }) + this.detach() + this.closing = (async () => { + // Even a refused or lost creation response may leave an allocation to close. + await this.creation?.catch(() => {}) + valueOf(await this.remote.close(this.sessionId, this.id)) + this.detach() + this.patch({ phase: 'closed', writable: false }) + })().catch((error: unknown) => { this.closing = undefined; this.fail(error); throw error }) + return this.closing + } + + /** Stop Client work on plugin unload without closing Host terminals. */ + dispose(): void { + this.mounted = false + this.lifetime.abort() + this.detach() + } + + private detach(): void { + const previous = this.stream + this.stream = undefined + this.attachmentId = undefined + this.pendingRender?.resolve() + this.pendingRender = undefined + if (previous !== undefined) void previous.dispose() + } + + private async consume(stream: RemoteStream): Promise { + let generation = 0 + let sequence = 0 + try { + for await (const item of stream) { + if (this.stream !== stream) return + const frame = item.value + if (generation !== item.generation) { + if (frame.type !== 'snapshot') throw new Error('Terminal output generation is missing its screen snapshot') + generation = item.generation + sequence = frame.sequence + item.accept() + } else if (frame.type === 'output') { + if (frame.sequence !== sequence + 1) throw new Error('Terminal output sequence has a gap') + sequence = frame.sequence + } else if (frame.type === 'snapshot') throw new Error('Unexpected terminal screen snapshot') + if (frame.type !== 'output') { + this.patch({ info: frame.info, title: frame.info.title, phase: 'connected', writable: frame.info.state === 'running' && frame.info.controllerId === this.attachmentId }) + } + if (frame.type !== 'state') { + const revision = ++this.revision + await new Promise((resolve) => { + this.pendingRender = { revision, resolve } + const aborted = (): void => { this.acknowledge(revision) } + item.signal.addEventListener('abort', aborted, { once: true }) + this.pendingRender.resolve = () => { item.signal.removeEventListener('abort', aborted); resolve() } + this.patch({ render: { revision, frame } }) + if (item.signal.aborted) aborted() + }) + } + } + } catch (error) { + if (this.stream === stream) { + if (this.state.getSnapshot().info?.state === 'exited') this.patch({ phase: 'closed', writable: false }) + else this.fail(error) + } + } + } + + private patch(patch: Partial): void { + if (this.lifetime.signal.aborted) return + this.state.set({ ...this.state.getSnapshot(), ...patch }) + } + + private fail(error: unknown): void { + this.patch({ phase: error instanceof RemoteStreamCarrierError ? 'disconnected' : 'failed', writable: false, error: error instanceof Error ? error.message : String(error) }) + } +} + +function valueOf(result: RemoteResult): T { + if (!result.ok) throw new Error(result.error.message) + return result.value +} diff --git a/packages/api/terminal-controller/src/index.ts b/packages/api/terminal-controller/src/index.ts new file mode 100644 index 0000000000..9efb1d07df --- /dev/null +++ b/packages/api/terminal-controller/src/index.ts @@ -0,0 +1,341 @@ +/** Session-scoped browser terminals over the composed subprocess and sandbox providers. */ +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import type { SubprocessTerminalHandle } from '@deepseek-ai/dsh-subprocess' +import type {} from '@deepseek-ai/dsh-sandbox-policy' +import type {} from '@deepseek-ai/dsh-sandbox' +import type {} from '@deepseek-ai/dsh-session-projection' +import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import { resolveShell } from './shells.ts' +import { BrowserTerminal } from './terminal.ts' +import type { + TerminalAttachmentId, TerminalCreateRequest, TerminalEnvironment, TerminalFrame, + WebTerminalId, WebTerminalInfo, +} from './types.ts' + +export type * from './types.ts' + +declare module '@deepseek-ai/cordis' { + interface Context { + /** Interactive user terminals, separate from the Agent terminal tool registry. */ + terminalController: TerminalController + } +} + +/** Deployment limits and an optional shell profile. */ +export interface Config { + /** Explicit shell profile; omission uses the execution environment's default shell. */ + readonly shell?: { + /** Executable path or PATH name, verified by the subprocess provider. */ + path: string + /** User-visible profile name. */ + name: string + /** Arguments passed to the interactive shell. */ + args: string[] + } | undefined + /** Maximum retained terminals and pending allocations per Session. */ + readonly maxTerminals: number + /** Maximum terminal width in columns. */ + readonly maxCols: number + /** Maximum terminal height in rows. */ + readonly maxRows: number + /** Screen history rows retained for reconnecting clients. */ + readonly scrollback: number + /** Maximum queued UTF-8 frame bytes per output follower before disconnection. */ + readonly maxBufferedBytes: number + /** Maximum UTF-8 bytes in one input request. */ + readonly maxInputBytes: number + /** Provider process-termination grace period in milliseconds. */ + readonly disposeGraceMs: number +} + +interface OwnedSession { + readonly lifetime: AbortController + readonly closedIds: Set + cleanup?: Promise + readonly terminals: Map + readonly pending: Map> + readonly allocations: Map +} + +/** Typed Remote control of transient Session-owned terminal processes. */ +export class TerminalController extends TypertRemoteService { + static inject = ['subprocess', 'sandboxPolicy', 'sessionProjections', 'typert'] + static Config: z = z.object({ + shell: z.union([z.object({ + path: z.string().required(), name: z.string().required(), args: z.array(z.string()).default([]), + }), z.const(undefined)]), + maxTerminals: z.number().step(1).min(1).default(8), + maxCols: z.number().step(1).min(2).default(500), + maxRows: z.number().step(1).min(1).default(200), + scrollback: z.number().step(1).min(0).default(1000), + maxBufferedBytes: z.number().step(1).min(1024).default(2 * 1024 * 1024), + maxInputBytes: z.number().step(1).min(1).default(64 * 1024), + disposeGraceMs: z.number().step(1).min(1).default(1000), + }) + + private readonly owners = new Map() + private readonly lifetime = new AbortController() + + /** + * @param ctx - Host context carrying typed Remote and execution providers. + * @param config - validated terminal limits and optional shell profile. + */ + constructor(ctx: Context, private readonly config: Config) { + super(ctx, 'terminalController', { namespace: 'terminal' }) + ctx.on('internal/dispatch', (_mode, eventName, args) => { + if (eventName !== 'session/event') return + const [session, event] = args as [Session, SessionEvent] + if (event.type !== 'sandbox/mode') return + const owner = this.owners.get(session.id) + if (owner === undefined || owner.terminals.size + owner.pending.size + owner.allocations.size === 0) return + const current = ctx.sessionProjections.stateOf(session, 'sandboxMode') ?? ctx.sandboxPolicy.defaultMode + if (event.data.mode !== current) throw new Error('Close browser terminals before changing the Session sandbox mode') + }, { global: true }) + ctx.effect(() => async () => { + this.lifetime.abort(new Error('Terminal controller disposed')) + const results = await Promise.allSettled([...this.owners].map(([id, owner]) => this.disposeOwner(id, owner))) + const errors = results.filter(result => result.status === 'rejected').map(result => result.reason as unknown) + if (errors.length > 0) throw new AggregateError(errors, 'Browser terminal cleanup failed') + }, 'terminal-controller.processes') + } + + /** + * Read the Session working directory and terminal limits without resolving a shell. + * @param agent - Session owner supplied by the Gateway. + * @param signal - request cancellation. + * @returns the Session workspace directory and terminal limits. + */ + @Remote + environment(agent: Agent, signal: AbortSignal): TerminalEnvironment { + signal.throwIfAborted() + const { sandboxPolicy } = this.execution(agent) + return { cwd: sandboxPolicy.resolve({ session: agent.session }).workspaceRoot, + maxInputBytes: this.config.maxInputBytes, maxCols: this.config.maxCols, + maxRows: this.config.maxRows, scrollback: this.config.scrollback } + } + + /** + * List running and exited terminals available for reattachment. + * @param agent - Session owner supplied by the Gateway. + * @returns terminals retained for this Host lifetime. + */ + @Remote + list(agent: Agent): WebTerminalInfo[] { + const owner = this.owners.get(agent.id) + if (owner === undefined) return [] + return [...owner.terminals.values(), ...owner.allocations.values()].map(terminal => terminal.info) + } + + /** + * Allocate an interactive shell once for a caller-generated identity. + * @param agent - Session owner supplied by the Gateway. + * @param request - initial dimensions and idempotency identity. + * @param signal - allocation cancellation; committed terminals survive disconnection. + * @returns the existing or newly committed terminal. + */ + @Remote + async create(agent: Agent, request: TerminalCreateRequest, signal: AbortSignal): Promise { + this.lifetime.signal.throwIfAborted() + if (!/^[\w-]{1,128}$/u.test(request.id)) throw new Error('Invalid terminal identity') + this.dimensions(request.cols, request.rows) + const owner = this.owner(agent) + owner.lifetime.signal.throwIfAborted() + this.requireOpen(owner, request.id) + const existing = owner.terminals.get(request.id) + if (existing !== undefined) return existing.info + const pending = owner.pending.get(request.id) + if (pending !== undefined) { + const terminal = await pending + this.requireOpen(owner, request.id) + return terminal.info + } + if (owner.allocations.has(request.id)) throw new Error('Close the failed terminal allocation before creating it again') + if (new Set([...owner.terminals.keys(), ...owner.pending.keys(), ...owner.allocations.keys()]).size >= this.config.maxTerminals) throw new Error('Session terminal limit reached') + const allocation = this.spawn(agent, owner, request, AbortSignal.any([signal, this.lifetime.signal, owner.lifetime.signal])) + owner.pending.set(request.id, allocation) + try { + const terminal = await allocation + owner.terminals.set(request.id, terminal) + owner.allocations.delete(request.id) + this.requireOpen(owner, request.id) + return terminal.info + } finally { + owner.pending.delete(request.id) + } + } + + /** + * Attach to a terminal without binding its process lifetime to the transport. + * @param agent - Session owner supplied by the Gateway. + * @param id - terminal identity. + * @param attachmentId - new exclusive input attachment. + * @param signal - physical stream cancellation. + * @returns screen recovery followed by output and metadata changes. + */ + @Remote({ mode: 'stream' }) + follow(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, signal: AbortSignal): AsyncIterable { + if (!/^[\w-]{1,128}$/u.test(attachmentId)) throw new Error('Invalid terminal attachment identity') + return this.terminal(agent, id).follow(attachmentId, signal) + } + + /** + * Deliver raw input, including Tab completion and control characters. + * @param agent - Session owner supplied by the Gateway. + * @param id - terminal identity. + * @param attachmentId - current writable attachment. + * @param data - input bytes represented as UTF-8 text. + * @returns after provider input acceptance. + */ + @Remote + async write(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, data: string): Promise { + if (Buffer.byteLength(data, 'utf8') > this.config.maxInputBytes) throw new Error('Terminal input exceeds the configured limit') + await this.terminal(agent, id).write(attachmentId, data) + } + + /** + * Update the dimensions of the PTY and recovery screen. + * @param agent - Session owner supplied by the Gateway. + * @param id - terminal identity. + * @param attachmentId - current writable attachment. + * @param cols - column count. + * @param rows - row count. + * @returns after the resize completes. + */ + @Remote + async resize(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, cols: number, rows: number): Promise { + this.dimensions(cols, rows) + await this.terminal(agent, id).resize(attachmentId, cols, rows) + } + + /** + * Rename a terminal without changing its shell. + * @param agent - Session owner supplied by the Gateway. + * @param id - terminal identity. + * @param title - nonempty display title, at most 120 characters. + */ + @Remote + rename(agent: Agent, id: WebTerminalId, title: string): void { + if (title.trim().length === 0 || title.length > 120) throw new Error('Terminal title must contain 1–120 characters') + this.terminal(agent, id).rename(title.trim()) + } + + /** + * Close an identity to future creation and kill its process range; repeated closes succeed. + * @param agent - Session owner supplied by the Gateway. + * @param id - terminal identity. + * @returns after provider cleanup succeeds. A failure retains the terminal for retry. + */ + @Remote + async close(agent: Agent, id: WebTerminalId): Promise { + const owner = this.owner(agent) + owner.closedIds.add(id) + await owner.pending.get(id)?.catch(() => { /* Creation reports its failure; close still owns any allocated process. */ }) + const terminal = owner.terminals.get(id) + if (terminal !== undefined) { + await terminal.close() + owner.terminals.delete(id) + } else { + const allocation = owner.allocations.get(id) + if (allocation === undefined) return + await allocation.handle.terminate() + owner.allocations.delete(id) + } + } + + private owner(agent: Agent): OwnedSession { + let owner = this.owners.get(agent.id) + if (owner === undefined) { + owner = { terminals: new Map(), pending: new Map(), allocations: new Map(), closedIds: new Set(), lifetime: new AbortController() } + this.owners.set(agent.id, owner) + const owned = owner + agent.ctx.effect(() => async () => { await this.disposeOwner(agent.id, owned) }, 'terminal-controller.owner') + } + return owner + } + + private disposeOwner(id: SessionId, owner: OwnedSession): Promise { + if (owner.cleanup !== undefined) return owner.cleanup + owner.lifetime.abort(new Error('Terminal Session owner disposed')) + owner.cleanup = (async () => { + await Promise.allSettled(owner.pending.values()) + const results = await Promise.allSettled([ + ...[...owner.terminals.values()].map(terminal => terminal.close()), + ...[...owner.allocations.values()].map(allocation => allocation.handle.terminate()), + ]) + const errors = results.filter(result => result.status === 'rejected').map(result => result.reason as unknown) + if (errors.length > 0) throw new AggregateError(errors, 'Session terminal cleanup failed') + owner.terminals.clear() + owner.allocations.clear() + this.owners.delete(id) + })().catch((error: unknown) => { delete owner.cleanup; throw error }) + return owner.cleanup + } + + private terminal(agent: Agent, id: WebTerminalId): BrowserTerminal { + const terminal = this.owners.get(agent.id)?.terminals.get(id) + if (terminal === undefined) throw new Error('Terminal no longer exists in this Session') + return terminal + } + + private requireOpen(owner: OwnedSession, id: WebTerminalId): void { + if (owner.closedIds.has(id)) throw new Error('Terminal was closed in this Session') + } + + private dimensions(cols: number, rows: number): void { + if (!Number.isSafeInteger(cols) || cols < 2 || cols > this.config.maxCols + || !Number.isSafeInteger(rows) || rows < 1 || rows > this.config.maxRows) throw new Error('Terminal dimensions exceed the configured limits') + } + + private execution(agent: Agent): { subprocess: Context['subprocess']; sandboxPolicy: Context['sandboxPolicy'] } { + // The Agent context selects execution providers but does not inject consumer services. + const subprocess = agent.ctx.get('subprocess') + const sandboxPolicy = agent.ctx.get('sandboxPolicy') + if (subprocess === undefined || sandboxPolicy === undefined) throw new Error('The Session execution environment requires subprocess and sandbox policy providers') + return { subprocess, sandboxPolicy } + } + + private async spawn(agent: Agent, owner: OwnedSession, request: TerminalCreateRequest, signal: AbortSignal): Promise { + const environment = this.environment(agent, signal) + const { subprocess, sandboxPolicy } = this.execution(agent) + const shell = await resolveShell(subprocess, this.config.shell, signal) + const policy = sandboxPolicy.resolve({ session: agent.session }) + let argv = [shell.path, ...shell.args] + if (policy.mode !== 'danger-full-access') { + const sandbox = agent.ctx.get('sandbox') + if (sandbox === undefined) throw new Error('The Session sandbox mode requires an execution sandbox provider') + argv = sandbox.confine(argv, { ...policy, mode: policy.mode }).argv + } + const handle = await subprocess.spawnTerminal({ + argv, cwd: environment.cwd, cols: request.cols, rows: request.rows, + terminalType: 'xterm-256color', env: { DSH_SESSION_ID: agent.id }, + graceMs: this.config.disposeGraceMs, signal, + }) + const allocation = { + handle, + info: { + id: request.id, shell, title: shell.name, cwd: environment.cwd, + cols: request.cols, rows: request.rows, state: 'running', exitCode: null, + } as WebTerminalInfo, + } + owner.allocations.set(request.id, allocation) + try { + signal.throwIfAborted() + return new BrowserTerminal(handle, allocation.info, this.config.scrollback, this.config.maxBufferedBytes) + } catch (error) { + allocation.info = { ...allocation.info, state: 'failed', error: error instanceof Error ? error.message : String(error) } + try { + await handle.terminate() + owner.allocations.delete(request.id) + } catch (cleanupError) { + throw new AggregateError([error, cleanupError], 'Terminal allocation cleanup failed') + } + throw error + } + } +} + +/** Browser terminal service plugin. */ +export default TerminalController diff --git a/packages/api/terminal-controller/src/shells.ts b/packages/api/terminal-controller/src/shells.ts new file mode 100644 index 0000000000..284a46f319 --- /dev/null +++ b/packages/api/terminal-controller/src/shells.ts @@ -0,0 +1,28 @@ +/** Shell selection and executable verification use the target execution provider. */ +import type { SubprocessRuntime } from '@deepseek-ai/dsh-subprocess' +import type { TerminalShell } from './types.ts' + +/** + * Resolve the configured shell or the execution environment's default shell. + * @param subprocess - target execution provider. + * @param configured - optional profile overriding the environment's default shell. + * @param signal - resolution cancellation. + * @returns one verified shell; a declared default that cannot resolve rejects. + */ +export async function resolveShell( + subprocess: SubprocessRuntime, configured: TerminalShell | undefined, signal: AbortSignal, +): Promise { + let shell = configured + if (shell === undefined) { + const environment = await subprocess.terminalEnvironment(signal) + shell = profile(environment.defaultShell ?? (environment.platform === 'windows' ? 'cmd.exe' : '/bin/sh')) + } + const path = await subprocess.resolveExecutable(shell.path, undefined, signal) + return { ...shell, path } +} + +function profile(path: string): TerminalShell { + const name = path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1) + const kind = name.toLowerCase().replace(/\.exe$/u, '') + return { path, name, args: kind === 'cmd' ? [] : kind === 'pwsh' || kind === 'powershell' ? ['-NoLogo'] : ['-i'] } +} diff --git a/packages/api/terminal-controller/src/stream.ts b/packages/api/terminal-controller/src/stream.ts new file mode 100644 index 0000000000..4ee89f254f --- /dev/null +++ b/packages/api/terminal-controller/src/stream.ts @@ -0,0 +1,75 @@ +/** A bounded output queue for one Remote stream generation. */ +import { Deque } from '@deepseek-ai/dsh-deque' +import type { TerminalFrame } from './types.ts' + +/** Slow followers fail explicitly; a later attachment recovers from the screen. */ +export class TerminalFollower { + private readonly queue = new Deque<{ frame: TerminalFrame; bytes: number }>() + private bytes = 0 + private wake: (() => void) | undefined + private closed = false + private finished = false + private failure: Error | undefined + + /** @param maxBytes - maximum queued UTF-8 bytes for this follower. */ + constructor(private readonly maxBytes: number) {} + + /** + * Queue a frame or fail this follower when its byte limit is exceeded. + * @param frame - next ordered frame. + */ + push(frame: TerminalFrame): void { + if (this.closed || this.finished) return + const bytes = Buffer.byteLength(JSON.stringify(frame), 'utf8') + if (this.bytes + bytes > this.maxBytes) { + this.failure = new Error('Terminal output consumer exceeded its buffer; reconnect to recover the current screen') + this.close() + return + } + this.queue.pushBack({ frame, bytes }) + this.bytes += bytes + this.wake?.() + } + + /** Finish after delivering every queued frame, including the final exit state. */ + finish(): void { + this.finished = true + this.wake?.() + } + + /** Stop this follower without stopping its terminal. */ + close(): void { + this.closed = true + this.queue.clear() + this.bytes = 0 + this.wake?.() + } + + /** + * Drain until detached or failed. + * @param signal - Remote generation cancellation. + * @returns ordered terminal frames. + */ + async *read(signal: AbortSignal): AsyncIterable { + const abort = (): void => { this.close() } + signal.addEventListener('abort', abort, { once: true }) + if (signal.aborted) abort() + try { + while (!this.closed) { + const next = this.queue.popFront() + if (next !== undefined) { + this.bytes -= next.bytes + yield next.frame + } else { + if (this.finished) break + await new Promise((resolve) => { this.wake = resolve }) + this.wake = undefined + } + } + if (this.failure !== undefined) throw this.failure + } finally { + signal.removeEventListener('abort', abort) + this.close() + } + } +} diff --git a/packages/api/terminal-controller/src/terminal.ts b/packages/api/terminal-controller/src/terminal.ts new file mode 100644 index 0000000000..103426cf72 --- /dev/null +++ b/packages/api/terminal-controller/src/terminal.ts @@ -0,0 +1,169 @@ +/** One PTY, a bounded terminal emulator and its detachable browser followers. */ +import { createRequire } from 'node:module' +import { StringDecoder } from 'node:string_decoder' +import type { Terminal as HeadlessTerminal } from '@xterm/headless' +import type { SerializeAddon as Serializer } from '@xterm/addon-serialize' +import type { SubprocessTerminalHandle } from '@deepseek-ai/dsh-subprocess' +import { TerminalFollower } from './stream.ts' +import type { TerminalAttachmentId, TerminalFrame, WebTerminalInfo } from './types.ts' + +const require = createRequire(import.meta.url) +const { Terminal } = require('@xterm/headless') as typeof import('@xterm/headless') +const { SerializeAddon } = require('@xterm/addon-serialize') as typeof import('@xterm/addon-serialize') + +/** Process lifetime is independent of follower and component lifetimes. */ +export class BrowserTerminal { + private readonly screen: HeadlessTerminal + private readonly serializer: Serializer + private readonly followers = new Set() + private sequence = 0 + private operations: Promise = Promise.resolve() + private readonly drained: Promise + private closing: Promise | undefined + private controller: { id: TerminalAttachmentId; follower: TerminalFollower } | undefined + + /** + * @param handle - allocated terminal process range. + * @param info - initial metadata. + * @param scrollback - maximum retained scrollback rows. + * @param maxBufferedBytes - per-follower queue cap. + */ + constructor( + private readonly handle: SubprocessTerminalHandle, + public info: WebTerminalInfo, + scrollback: number, + private readonly maxBufferedBytes: number, + ) { + this.screen = new Terminal({ cols: info.cols, rows: info.rows, scrollback, allowProposedApi: true }) + this.serializer = new SerializeAddon() + this.screen.loadAddon(this.serializer) + this.drained = this.consume() + } + + /** + * Attach with exclusive input control; an older attachment becomes read-only. + * @param id - browser attachment identity. + * @param signal - attachment cancellation; never terminates the process. + * @returns a consistent screen followed by ordered output and state changes. + */ + async *follow(id: TerminalAttachmentId, signal: AbortSignal): AsyncIterable { + signal.throwIfAborted() + const follower = new TerminalFollower(this.maxBufferedBytes) + const baseline = await this.enqueue(() => { + signal.throwIfAborted() + this.controller = { id, follower } + this.info = { ...this.info, controllerId: id } + this.broadcast({ type: 'state', info: this.info }) + const snapshot: TerminalFrame = { type: 'snapshot', sequence: this.sequence, screen: this.serializer.serialize(), info: this.info } + this.followers.add(follower) + return snapshot + }) + try { + yield baseline + yield* follower.read(signal) + } finally { + this.followers.delete(follower) + follower.close() + if (this.controller?.follower === follower) { + this.controller = undefined + const { controllerId: _controllerId, ...info } = this.info + this.info = info + this.broadcast({ type: 'state', info }) + } + } + } + + /** + * Send raw terminal input without command interpretation. + * @param id - current writable attachment. + * @param data - UTF-8 input, including shell completion/control keys. + * @returns when the provider accepts the input. + */ + write(id: TerminalAttachmentId, data: string): Promise { + return this.enqueue(async () => { this.requireController(id); await this.handle.write(data) }) + } + + /** + * Resize the PTY and recovery screen in the same operation order as output. + * @param id - current writable attachment. + * @param cols - validated column count. + * @param rows - validated row count. + * @returns when the provider and emulator use the new dimensions. + */ + resize(id: TerminalAttachmentId, cols: number, rows: number): Promise { + return this.enqueue(async () => { + this.requireController(id) + await this.handle.resize(cols, rows) + this.screen.resize(cols, rows) + this.info = { ...this.info, cols, rows } + this.broadcast({ type: 'state', info: this.info }) + }) + } + + /** + * Publish a display name to every attached view. + * @param title - validated user title. + */ + rename(title: string): void { + this.info = { ...this.info, title } + this.broadcast({ type: 'state', info: this.info }) + } + + /** + * Terminate the complete provider-owned process range before releasing its screen. + * @returns after process cleanup and final output drainage; failures remain retryable. + */ + close(): Promise { + if (this.closing !== undefined) return this.closing + this.closing = (async () => { + await this.handle.terminate() + await this.drained + for (const follower of this.followers) follower.finish() + this.followers.clear() + this.screen.dispose() + })().catch((error: unknown) => { this.closing = undefined; throw error }) + return this.closing + } + + private requireController(id: TerminalAttachmentId): void { + if (this.closing !== undefined || this.info.state !== 'running') throw new Error('Terminal is not running') + if (this.controller?.id !== id) throw new Error('Terminal input is controlled by another attachment') + } + + private broadcast(frame: TerminalFrame): void { + for (const follower of this.followers) follower.push(frame) + } + + private enqueue(operation: () => T | Promise): Promise { + const pending = this.operations.then(operation) + this.operations = pending.catch(() => { /* The caller owns this operation's failure; later cleanup must still run. */ }) + return pending + } + + private async consume(): Promise { + const decoder = new StringDecoder('utf8') + const outcome = this.handle.done.then(value => ({ value }), (error: unknown) => ({ error })) + try { + for await (const chunk of this.handle.output) { + // Node Readable's iterator is untyped; this provider explicitly emits Buffer chunks. + const data = decoder.write(chunk as Buffer) + await this.output(data) + } + await this.output(decoder.end()) + const result = await outcome + if ('error' in result) throw result.error + this.info = { ...this.info, state: 'exited', exitCode: result.value.exitCode } + } catch (error) { + this.info = { ...this.info, state: 'failed', error: error instanceof Error ? error.message : String(error) } + } + this.broadcast({ type: 'state', info: this.info }) + } + + private async output(data: string): Promise { + if (data.length === 0) return + await this.enqueue(async () => { + await new Promise((resolve) => { this.screen.write(data, resolve) }) + this.broadcast({ type: 'output', sequence: ++this.sequence, data }) + }) + } +} diff --git a/packages/api/terminal-controller/src/types.ts b/packages/api/terminal-controller/src/types.ts new file mode 100644 index 0000000000..94cc827d4c --- /dev/null +++ b/packages/api/terminal-controller/src/types.ts @@ -0,0 +1,51 @@ +/** Browser terminal identities, metadata and screen-stream frames. */ +import type { Branded } from '@deepseek-ai/dsh-brand' + +/** A terminal identity scoped to one Session and one Host lifetime. */ +export type WebTerminalId = Branded<'WebTerminalId'> +/** An attachment allowed to write and resize one terminal. */ +export type TerminalAttachmentId = Branded<'TerminalAttachmentId'> + +/** An executable shell verified in the subprocess provider's execution environment. */ +export interface TerminalShell { + readonly path: string + readonly args: readonly string[] + readonly name: string +} + +/** Working directory and limits shared by new and restored terminals. */ +export interface TerminalEnvironment { + readonly cwd: string + readonly maxInputBytes: number + readonly maxCols: number + readonly maxRows: number + readonly scrollback: number +} + +/** Host-owned terminal state; process exit never creates a replacement shell. */ +export interface WebTerminalInfo { + readonly id: WebTerminalId + readonly title: string + readonly shell: TerminalShell + /** Initial working directory; shell directory changes do not update this field. */ + readonly cwd: string + readonly cols: number + readonly rows: number + readonly state: 'running' | 'exited' | 'failed' + readonly exitCode: number | null + readonly error?: string + readonly controllerId?: TerminalAttachmentId +} + +/** Create is idempotent for an open identity; closed identities cannot be recreated. */ +export interface TerminalCreateRequest { + readonly id: WebTerminalId + readonly cols: number + readonly rows: number +} + +/** Every attachment begins with a complete bounded screen, then ordered output. */ +export type TerminalFrame = + | { readonly type: 'snapshot'; readonly sequence: number; readonly screen: string; readonly info: WebTerminalInfo } + | { readonly type: 'output'; readonly sequence: number; readonly data: string } + | { readonly type: 'state'; readonly info: WebTerminalInfo } diff --git a/packages/api/terminal-controller/tests/controller.spec.ts b/packages/api/terminal-controller/tests/controller.spec.ts new file mode 100644 index 0000000000..09859f9a8c --- /dev/null +++ b/packages/api/terminal-controller/tests/controller.spec.ts @@ -0,0 +1,508 @@ +/** Session identity, allocation races, confinement and real PTY behavior. */ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { PassThrough } from 'node:stream' +import { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox' +import { SubprocessExecutableNotFoundError, type SubprocessRuntime, type SubprocessTerminalEnvironment, type SubprocessTerminalHandle } from '@deepseek-ai/dsh-subprocess' +import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { TerminalController, type Config } from '../src/index.ts' +import { resolveShell } from '../src/shells.ts' +import type { TerminalAttachmentId, WebTerminalId } from '../src/types.ts' + +const roots: Context[] = [] +afterEach(async () => { await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose())) }) +const config: Config = { shell: { path: '/bin/bash', name: 'bash', args: ['--noprofile', '--norc', '-i'] }, maxTerminals: 2, maxCols: 200, maxRows: 100, scrollback: 100, maxBufferedBytes: 100_000, maxInputBytes: 1000, disposeGraceMs: 100 } +const id = 'test-terminal' as WebTerminalId +const request = { id, cols: 80, rows: 24 } +const signal = (): AbortSignal => new AbortController().signal + +function owner(ctx: Context, id = 'session'): Agent { + return { id: id as SessionId, ctx, session: { id: id as SessionId } } as unknown as Agent +} + +function fixture(overrides: Partial = {}) { + const ctx = new Context() + roots.push(ctx) + const effects = vi.spyOn(ctx.fiber, 'effect') + const sandboxPolicy = { defaultMode: 'danger-full-access', resolve: vi.fn((): SandboxExecutionPolicy => ({ mode: 'danger-full-access', workspaceRoot: '/workspace' })) } + const projections = { stateOf: vi.fn((): SandboxMode | null => null) } + ctx.provide('sandboxPolicy', sandboxPolicy as never) + ctx.provide('sessionProjections', projections as never) + const output = new PassThrough() + const done = Promise.withResolvers<{ exitCode: number; signal: null }>() + const handle = { + pid: 123, output, done: done.promise, write: vi.fn(async () => {}), resize: vi.fn(async () => {}), + inspectForeground: async () => undefined, signalForeground: async () => 123, + terminate: vi.fn(async () => { output.end(); done.resolve({ exitCode: 0, signal: null }) }) } + const checked: SubprocessTerminalHandle = handle + const subprocess = { terminalEnvironment: vi.fn(async (): Promise => ({ platform: 'posix', defaultShell: '/bin/bash' })), resolveExecutable: vi.fn(async (path: string) => path), spawnTerminal: vi.fn(async () => checked) } + ctx.provide('subprocess', subprocess as never) + const controller = new TerminalController(ctx, { ...config, ...overrides }) + const disposeEffect = (label: string): Promise => { + const index = effects.mock.calls.findIndex(call => call[1] === label) + const result = effects.mock.results[index] + if (result?.type !== 'return' || typeof result.value !== 'function') throw new Error(`Missing effect: ${label}`) + return result.value() + } + return { ctx, agent: owner(ctx), controller, subprocess, handle, sandboxPolicy, projections, disposeEffect } +} + +describe('TerminalController', () => { + it('resolves execution services from an Agent plugin context without consumer injections', async () => { + const { ctx, controller } = fixture() + let agent: Agent | undefined + await ctx.plugin((child) => { agent = owner(child) }) + if (agent === undefined) throw new Error('Agent plugin did not load') + expect(controller.environment(agent, signal())).toMatchObject({ cwd: '/workspace' }) + await controller.create(agent, request, signal()) + await controller.close(agent, id) + }) + + it('creates the environment default shell and keeps an existing identity when that default changes', async () => { + const { controller, agent, subprocess } = fixture({ shell: undefined }) + subprocess.terminalEnvironment.mockResolvedValue({ platform: 'posix', defaultShell: '/usr/local/bin/zsh' }) + expect(controller.environment(agent, signal())).toEqual({ cwd: '/workspace', maxCols: 200, maxRows: 100, maxInputBytes: 1000, scrollback: 100 }) + expect(subprocess.terminalEnvironment).not.toHaveBeenCalled() + const created = await controller.create(agent, request, signal()) + expect(created.shell.path).toBe('/usr/local/bin/zsh') + expect(subprocess.spawnTerminal).toHaveBeenCalledWith(expect.objectContaining({ argv: ['/usr/local/bin/zsh', '-i'] })) + subprocess.terminalEnvironment.mockResolvedValue({ platform: 'posix', defaultShell: '/bin/sh' }) + expect(await controller.create(agent, request, signal())).toBe(created) + expect(subprocess.terminalEnvironment).toHaveBeenCalledOnce() + expect(subprocess.spawnTerminal).toHaveBeenCalledOnce() + }) + + it('restores a running terminal after its default executable becomes unavailable', async () => { + const { controller, agent, subprocess } = fixture({ shell: undefined }) + await controller.create(agent, request, signal()) + subprocess.resolveExecutable.mockRejectedValue(new SubprocessExecutableNotFoundError('default shell was removed')) + expect(controller.environment(agent, signal())).toMatchObject({ cwd: '/workspace', maxInputBytes: 1000 }) + expect(controller.list(agent)).toMatchObject([{ id, state: 'running' }]) + const stream = controller.follow(agent, id, 'restored' as TerminalAttachmentId, signal())[Symbol.asyncIterator]() + try { + expect(await stream.next()).toMatchObject({ done: false, value: { type: 'snapshot', info: { id, state: 'running' } } }) + expect(subprocess.resolveExecutable).toHaveBeenCalledOnce() + await expect(controller.create(agent, { ...request, id: 'new-terminal' as WebTerminalId }, signal())).rejects.toThrow('default shell was removed') + expect(subprocess.spawnTerminal).toHaveBeenCalledOnce() + } finally { await stream.return?.() } + }) + + it('honors cancellation before reading environment limits', () => { + const { controller, agent, subprocess, sandboxPolicy } = fixture() + const abort = new AbortController() + abort.abort(new Error('request cancelled')) + expect(() => controller.environment(agent, abort.signal)).toThrow('request cancelled') + expect(subprocess.terminalEnvironment).not.toHaveBeenCalled() + expect(sandboxPolicy.resolve).not.toHaveBeenCalled() + }) + + it('deduplicates concurrent creates, scopes access by Session, and preserves terminals when a stream aborts', async () => { + const { controller, agent, ctx, subprocess, handle } = fixture() + const [first, second] = await Promise.all([controller.create(agent, request, signal()), controller.create(agent, request, signal())]) + expect(first.id).toBe(second.id) + expect(subprocess.spawnTerminal).toHaveBeenCalledOnce() + expect(subprocess.spawnTerminal).toHaveBeenCalledWith(expect.objectContaining({ terminalType: 'xterm-256color', cwd: '/workspace' })) + expect(controller.list(owner(ctx, 'other'))).toEqual([]) + expect(() => controller.follow(owner(ctx, 'other'), id, 'writer' as TerminalAttachmentId, signal())).toThrow('no longer exists') + const abort = new AbortController() + const stream = controller.follow(agent, id, 'writer' as TerminalAttachmentId, abort.signal)[Symbol.asyncIterator]() + const firstFrame = await stream.next() + if (firstFrame.done === true) throw new Error('Terminal stream ended before its snapshot') + expect(firstFrame.value.type).toBe('snapshot') + abort.abort() + await stream.return?.() + expect(handle.terminate).not.toHaveBeenCalled() + await controller.close(agent, id) + expect(handle.terminate).toHaveBeenCalledOnce() + expect(controller.list(agent)).toEqual([]) + await controller.close(agent, id) + }) + + it('waits for pending allocation when explicitly closing and rolls back cancellation before publication', async () => { + const { controller, agent, subprocess, handle } = fixture() + const allocation = Promise.withResolvers() + subprocess.spawnTerminal.mockImplementationOnce(() => allocation.promise) + const creating = controller.create(agent, request, signal()) + const rejected = expect(creating).rejects.toThrow('closed in this Session') + const closing = controller.close(agent, id) + allocation.resolve(handle) + await rejected + await closing + expect(controller.list(agent)).toEqual([]) + expect(handle.terminate).toHaveBeenCalledOnce() + }) + + it('rejects a delayed create after close even when that identity has never been allocated', async () => { + const { controller, agent, subprocess } = fixture() + await controller.close(agent, id) + await controller.close(agent, id) + await expect(controller.create(agent, request, signal())).rejects.toThrow('closed in this Session') + expect(subprocess.spawnTerminal).not.toHaveBeenCalled() + const nextId = 'next-terminal' as WebTerminalId + await expect(controller.create(agent, { ...request, id: nextId }, signal())).resolves.toMatchObject({ id: nextId }) + expect(subprocess.spawnTerminal).toHaveBeenCalledOnce() + }) + + it('rejects original, duplicate and later creates when close arrives during allocation', async () => { + const { controller, agent, subprocess, handle } = fixture() + const allocation = Promise.withResolvers() + const spawning = Promise.withResolvers() + subprocess.spawnTerminal.mockImplementationOnce(() => { spawning.resolve(undefined); return allocation.promise }) + const creating = controller.create(agent, request, signal()) + const duplicate = controller.create(agent, request, signal()) + const rejected = expect(creating).rejects.toThrow('closed in this Session') + const duplicateRejected = expect(duplicate).rejects.toThrow('closed in this Session') + try { + await spawning.promise + const closing = controller.close(agent, id) + await expect(controller.create(agent, request, signal())).rejects.toThrow('closed in this Session') + allocation.resolve(handle) + await Promise.all([rejected, duplicateRejected, closing]) + await expect(controller.create(agent, request, signal())).rejects.toThrow('closed in this Session') + expect(handle.terminate).toHaveBeenCalledOnce() + expect(subprocess.spawnTerminal).toHaveBeenCalledOnce() + expect(controller.list(agent)).toEqual([]) + } finally { allocation.resolve(handle) } + }) + + it('retains failed allocation cleanup for a later explicit close', async () => { + const { controller, agent, subprocess, handle } = fixture() + const abort = new AbortController() + subprocess.spawnTerminal.mockImplementationOnce(async () => { abort.abort(new Error('request disconnected')); return handle }) + vi.mocked(handle.terminate).mockRejectedValueOnce(new Error('cleanup failed')) + await expect(controller.create(agent, request, abort.signal)).rejects.toThrow('cleanup failed') + expect(controller.list(agent)).toMatchObject([{ id, state: 'failed' }]) + await controller.close(agent, id) + expect(handle.terminate).toHaveBeenCalledTimes(2) + expect(controller.list(agent)).toEqual([]) + }) + + it('retains a terminal when process cleanup fails so closing can be retried', async () => { + const { controller, agent, handle } = fixture() + await controller.create(agent, request, signal()) + vi.mocked(handle.terminate).mockRejectedValueOnce(new Error('process still alive')) + await expect(controller.close(agent, id)).rejects.toThrow('still alive') + expect(controller.list(agent)).toHaveLength(1) + await expect(controller.create(agent, request, signal())).rejects.toThrow('closed in this Session') + await controller.close(agent, id) + expect(controller.list(agent)).toHaveLength(0) + }) + + it('rejects invalid dimensions, unavailable shells and oversized input before executing them', async () => { + const { controller, agent, subprocess } = fixture() + await expect(controller.create(agent, { ...request, cols: 1 }, signal())).rejects.toThrow('dimensions') + subprocess.resolveExecutable.mockRejectedValueOnce(new SubprocessExecutableNotFoundError('shell unavailable')) + await expect(controller.create(agent, request, signal())).rejects.toThrow('unavailable') + expect(subprocess.spawnTerminal).not.toHaveBeenCalled() + await expect(controller.write(agent, id, 'writer' as TerminalAttachmentId, 'x'.repeat(1001))).rejects.toThrow('input') + }) + + it('keeps the committed identity on repeated creation and counts pending reservations toward the Session limit', async () => { + const { controller, agent, subprocess, handle } = fixture({ maxTerminals: 1 }) + const allocation = Promise.withResolvers() + const spawning = Promise.withResolvers() + subprocess.spawnTerminal.mockImplementationOnce(() => { spawning.resolve(undefined); return allocation.promise }) + const creating = controller.create(agent, request, signal()) + try { + await spawning.promise + await expect(controller.create(agent, { ...request, id: 'another' as WebTerminalId }, signal())).rejects.toThrow('limit reached') + } finally { allocation.resolve(handle) } + const first = await creating + expect(await controller.create(agent, request, signal())).toBe(first) + expect(subprocess.spawnTerminal).toHaveBeenCalledOnce() + }) + + it('keeps failed allocations reserved until their cleanup succeeds', async () => { + const { controller, agent, subprocess, handle } = fixture({ maxTerminals: 1 }) + const abort = new AbortController() + subprocess.spawnTerminal.mockImplementationOnce(async () => { abort.abort('lost request'); return handle }) + handle.terminate.mockRejectedValueOnce(new Error('still alive')) + await expect(controller.create(agent, request, abort.signal)).rejects.toThrow('cleanup failed') + expect(controller.list(agent)).toMatchObject([{ id, state: 'failed', error: 'lost request' }]) + await expect(controller.create(agent, request, signal())).rejects.toThrow('Close the failed terminal allocation') + await expect(controller.create(agent, { ...request, id: 'another' as WebTerminalId }, signal())).rejects.toThrow('limit reached') + await controller.close(agent, id) + expect(controller.list(agent)).toEqual([]) + }) + + it('lets close reclaim an allocation whose request failed while close was waiting', async () => { + const { controller, agent, subprocess, handle } = fixture() + const allocation = Promise.withResolvers() + const abort = new AbortController() + subprocess.spawnTerminal.mockImplementationOnce(() => allocation.promise) + const creating = controller.create(agent, request, abort.signal) + const rejected = expect(creating).rejects.toThrow('cancelled') + const closing = controller.close(agent, id) + abort.abort(new Error('cancelled')) + allocation.resolve(handle) + await rejected + await closing + expect(handle.terminate).toHaveBeenCalledOnce() + expect(controller.list(agent)).toEqual([]) + }) + + it('routes input, resize and trimmed names through the active attachment', async () => { + const { controller, agent, handle } = fixture({ maxInputBytes: 6 }) + await controller.create(agent, request, signal()) + const attachmentId = 'writer' as TerminalAttachmentId + const stream = controller.follow(agent, id, attachmentId, signal())[Symbol.asyncIterator]() + await stream.next() + try { + await controller.write(agent, id, attachmentId, '终端') + expect(handle.write).toHaveBeenCalledWith('终端') + await expect(controller.write(agent, id, attachmentId, '终端!')).rejects.toThrow('input exceeds') + await controller.resize(agent, id, attachmentId, 200, 100) + expect(handle.resize).toHaveBeenCalledWith(200, 100) + controller.rename(agent, id, ' server logs ') + expect(controller.list(agent)).toMatchObject([{ title: 'server logs', cols: 200, rows: 100 }]) + } finally { await stream.return?.() } + }) + + it.each([{ cols: 1.5, rows: 24 }, { cols: 201, rows: 24 }, { cols: 80, rows: 1.5 }, { cols: 80, rows: 0 }, { cols: 80, rows: 101 }])('rejects dimensions $cols × $rows before allocation or resize', async (dimensions) => { + const { controller, agent, subprocess, handle } = fixture() + await expect(controller.create(agent, { ...request, ...dimensions }, signal())).rejects.toThrow('dimensions') + await expect(controller.resize(agent, id, 'writer' as TerminalAttachmentId, dimensions.cols, dimensions.rows)).rejects.toThrow('dimensions') + expect(subprocess.spawnTerminal).not.toHaveBeenCalled() + expect(handle.resize).not.toHaveBeenCalled() + }) + + it('rejects invalid wire identities and names without changing retained terminals', async () => { + const { controller, agent } = fixture() + await expect(controller.create(agent, { ...request, id: '../terminal' as WebTerminalId }, signal())).rejects.toThrow('Invalid terminal identity') + expect(() => controller.follow(agent, id, '' as TerminalAttachmentId, signal())).toThrow('attachment identity') + expect(() => { controller.rename(agent, id, ' ') }).toThrow('1–120') + expect(() => { controller.rename(agent, id, 'x'.repeat(121)) }).toThrow('1–120') + await controller.close(agent, id) + expect(controller.list(agent)).toEqual([]) + }) + + it('uses the Session sandbox policy to confine its selected shell', async () => { + const { controller, agent, ctx, sandboxPolicy, subprocess } = fixture() + const policy: SandboxExecutionPolicy = { mode: 'workspace-write', workspaceRoot: '/workspace', sessionId: agent.id } + sandboxPolicy.resolve.mockReturnValue(policy) + const confine = vi.fn((argv: readonly string[]) => ({ argv: ['sandbox-runner', ...argv] })) + ctx.provide('sandbox', { confine } as never) + await controller.create(agent, request, signal()) + expect(confine).toHaveBeenCalledWith(['/bin/bash', '--noprofile', '--norc', '-i'], policy) + expect(subprocess.spawnTerminal).toHaveBeenCalledWith(expect.objectContaining({ argv: ['sandbox-runner', '/bin/bash', '--noprofile', '--norc', '-i'], env: { DSH_SESSION_ID: agent.id }, graceMs: 100 })) + }) + + it('rejects a confined Session without a sandbox provider before spawning', async () => { + const { controller, agent, sandboxPolicy, subprocess } = fixture() + sandboxPolicy.resolve.mockReturnValue({ mode: 'read-only', workspaceRoot: '/workspace' }) + await expect(controller.create(agent, request, signal())).rejects.toThrow('requires an execution sandbox provider') + expect(subprocess.spawnTerminal).not.toHaveBeenCalled() + }) + + it.each(['subprocess', 'sandboxPolicy'] as const)('fails clearly when the Session lacks %s', (missing) => { + const { controller, subprocess, sandboxPolicy } = fixture() + const isolated = new Context() + roots.push(isolated) + if (missing !== 'subprocess') isolated.provide('subprocess', subprocess as never) + if (missing !== 'sandboxPolicy') isolated.provide('sandboxPolicy', sandboxPolicy as never) + expect(() => controller.environment(owner(isolated), signal())).toThrow('requires subprocess and sandbox policy providers') + }) + + it('blocks sandbox-mode changes only while that Session retains a terminal', async () => { + const { controller, agent, ctx, projections } = fixture() + const mode = (mode: SandboxMode): void => { ctx.emit('session/event', agent.session, { type: 'sandbox/mode', data: { mode } } as SessionEvent) } + ctx.emit('session/disposed', agent.session) + ctx.emit('session/event', agent.session, { type: 'turn/start', data: { turn: 1 } } as SessionEvent) + expect(() => { mode('workspace-write') }).not.toThrow() + await controller.create(agent, request, signal()) + expect(() => { mode('danger-full-access') }).not.toThrow() + expect(() => { mode('workspace-write') }).toThrow('Close browser terminals') + projections.stateOf.mockReturnValue('read-only') + expect(() => { mode('read-only') }).not.toThrow() + expect(() => { mode('danger-full-access') }).toThrow('Close browser terminals') + await controller.close(agent, id) + expect(() => { mode('workspace-write') }).not.toThrow() + }) + + it('terminates committed processes when the Session effect ends', async () => { + const { controller, agent, handle, disposeEffect } = fixture() + await controller.create(agent, request, signal()) + await disposeEffect('terminal-controller.owner') + expect(handle.terminate).toHaveBeenCalledOnce() + expect(controller.list(agent)).toEqual([]) + }) + + it('cleans up a Session when its real Agent plugin fiber is disposed', async () => { + const { controller, ctx, handle } = fixture() + let agent: Agent | undefined + const fiber = await ctx.plugin((child) => { agent = owner(child) }) + if (agent === undefined) throw new Error('Agent plugin did not load') + await controller.create(agent, request, signal()) + await fiber.dispose() + expect(handle.terminate).toHaveBeenCalledOnce() + expect(controller.list(agent)).toEqual([]) + }) + + it('waits for pending creation during Host disposal and rolls back its process before resolving', async () => { + const { controller, agent, subprocess, handle, disposeEffect } = fixture() + const allocation = Promise.withResolvers() + const spawning = Promise.withResolvers() + subprocess.spawnTerminal.mockImplementationOnce(() => { spawning.resolve(undefined); return allocation.promise }) + const creating = controller.create(agent, request, signal()) + const rejected = expect(creating).rejects.toThrow('disposed') + await spawning.promise + const disposing = disposeEffect('terminal-controller.processes') + allocation.resolve(handle) + await rejected + await disposing + expect(handle.terminate).toHaveBeenCalledOnce() + expect(controller.list(agent)).toEqual([]) + await expect(controller.create(agent, request, signal())).rejects.toThrow('controller disposed') + }) + + it('shares cleanup between the Session and Host effects while awaiting process termination', async () => { + const { controller, agent, handle, disposeEffect } = fixture() + await controller.create(agent, request, signal()) + const stopping = Promise.withResolvers() + const stopped = Promise.withResolvers() + const terminate = handle.terminate.getMockImplementation() + if (terminate === undefined) throw new Error('Missing process cleanup fixture') + handle.terminate.mockImplementationOnce(async () => { stopping.resolve(undefined); await stopped.promise; await terminate() }) + const ownerDisposal = disposeEffect('terminal-controller.owner') + try { + await stopping.promise + const hostDisposal = disposeEffect('terminal-controller.processes') + stopped.resolve(undefined) + await Promise.all([ownerDisposal, hostDisposal]) + expect(handle.terminate).toHaveBeenCalledOnce() + expect(controller.list(agent)).toEqual([]) + } finally { stopped.resolve(undefined) } + }) + + it('retains cleanup failures from both Session and Host disposal for explicit retry', async () => { + const { controller, agent, handle, disposeEffect } = fixture() + await controller.create(agent, request, signal()) + handle.terminate.mockRejectedValueOnce(new Error('still alive')) + await expect(disposeEffect('terminal-controller.owner')).rejects.toThrow('Session terminal cleanup failed') + await expect(controller.create(agent, request, signal())).rejects.toThrow('Session owner disposed') + handle.terminate.mockRejectedValueOnce(new Error('still alive again')) + await expect(disposeEffect('terminal-controller.processes')).rejects.toThrow('Browser terminal cleanup failed') + expect(controller.list(agent)).toHaveLength(1) + await controller.close(agent, id) + expect(controller.list(agent)).toEqual([]) + expect(handle.terminate).toHaveBeenCalledTimes(3) + }) + + it('reclaims retained failed allocations when the owning Session ends', async () => { + const { controller, agent, subprocess, handle, disposeEffect } = fixture() + const abort = new AbortController() + subprocess.spawnTerminal.mockImplementationOnce(async () => { abort.abort(new Error('disconnected')); return handle }) + handle.terminate.mockRejectedValueOnce(new Error('first cleanup failed')) + await expect(controller.create(agent, request, abort.signal)).rejects.toThrow('cleanup failed') + await disposeEffect('terminal-controller.owner') + expect(handle.terminate).toHaveBeenCalledTimes(2) + expect(controller.list(agent)).toEqual([]) + }) +}) + +describe('shell resolution', () => { + it('accepts an omitted shell profile and defaults configured arguments to an empty list', () => { + // Loader input is unvalidated; the schema's declared type describes its normalized output. + const parse = (input: unknown): Config => TerminalController.Config(input as Config) + expect(parse({}).shell).toBeUndefined() + expect(parse({ shell: { path: 'custom', name: 'Project shell' } }).shell).toEqual({ path: 'custom', name: 'Project shell', args: [] }) + expect(() => parse({ shell: { name: 'Project shell' } })).toThrow() + }) + + it.each([ + { platform: 'posix' as const, path: '/bin/sh', name: 'sh', args: ['-i'] }, + { platform: 'windows' as const, path: 'cmd.exe', name: 'cmd.exe', args: [] }, + ])('uses the conservative $platform fallback only when the provider omits its default shell', async ({ platform, path, name, args }) => { + const { subprocess } = fixture() + subprocess.terminalEnvironment.mockResolvedValue({ platform }) + const runtime = subprocess as unknown as SubprocessRuntime + const requestSignal = signal() + expect(await resolveShell(runtime, undefined, requestSignal)).toEqual({ path, name, args }) + expect(subprocess.resolveExecutable).toHaveBeenCalledExactlyOnceWith(path, undefined, requestSignal) + }) + + it.each([ + { platform: 'posix' as const, defaultShell: '/usr/local/bin/fish', name: 'fish', args: ['-i'] }, + { platform: 'windows' as const, defaultShell: 'C:\\Windows\\CMD.EXE', name: 'CMD.EXE', args: [] }, + { platform: 'windows' as const, defaultShell: 'C:\\PowerShell\\powershell.exe', name: 'powershell.exe', args: ['-NoLogo'] }, + { platform: 'posix' as const, defaultShell: '/usr/local/bin/pwsh', name: 'pwsh', args: ['-NoLogo'] }, + ])('resolves only the declared default $defaultShell with its interactive arguments', async ({ platform, defaultShell, name, args }) => { + const { subprocess } = fixture() + subprocess.terminalEnvironment.mockResolvedValue({ platform, defaultShell }) + subprocess.resolveExecutable.mockResolvedValue('/resolved/shell') + const runtime = subprocess as unknown as SubprocessRuntime + const requestSignal = signal() + expect(await resolveShell(runtime, undefined, requestSignal)).toEqual({ path: '/resolved/shell', name, args }) + expect(subprocess.terminalEnvironment).toHaveBeenCalledExactlyOnceWith(requestSignal) + expect(subprocess.resolveExecutable).toHaveBeenCalledExactlyOnceWith(defaultShell, undefined, requestSignal) + }) + + it('uses an explicit profile without querying the environment and preserves its name and arguments', async () => { + const { subprocess } = fixture() + subprocess.resolveExecutable.mockResolvedValue('/resolved/custom') + const runtime = subprocess as unknown as SubprocessRuntime + const requestSignal = signal() + const configured = { path: 'custom', name: 'Project shell', args: ['--project'] } + expect(await resolveShell(runtime, configured, requestSignal)).toEqual({ ...configured, path: '/resolved/custom' }) + expect(subprocess.terminalEnvironment).not.toHaveBeenCalled() + expect(subprocess.resolveExecutable).toHaveBeenCalledExactlyOnceWith('custom', undefined, requestSignal) + }) + + it.each([new SubprocessExecutableNotFoundError('missing default'), new Error('transport unavailable')])('reports default resolution failure without attempting fallback: %s', async (failure) => { + const { subprocess } = fixture() + subprocess.resolveExecutable.mockRejectedValue(failure) + const runtime = subprocess as unknown as SubprocessRuntime + await expect(resolveShell(runtime, undefined, signal())).rejects.toBe(failure) + expect(subprocess.resolveExecutable).toHaveBeenCalledOnce() + }) + + it('reports an unavailable configured shell without consulting the provider default', async () => { + const { subprocess } = fixture() + const failure = new SubprocessExecutableNotFoundError('missing configured shell') + subprocess.resolveExecutable.mockRejectedValue(failure) + const runtime = subprocess as unknown as SubprocessRuntime + await expect(resolveShell(runtime, { path: '/missing', name: 'missing', args: [] }, signal())).rejects.toBe(failure) + expect(subprocess.resolveExecutable).toHaveBeenCalledOnce() + expect(subprocess.terminalEnvironment).not.toHaveBeenCalled() + }) + + it('propagates environment cancellation before verifying an executable', async () => { + const { subprocess } = fixture() + const cancelled = new Error('request disconnected') + subprocess.terminalEnvironment.mockRejectedValue(cancelled) + const runtime = subprocess as unknown as SubprocessRuntime + await expect(resolveShell(runtime, undefined, signal())).rejects.toBe(cancelled) + expect(subprocess.resolveExecutable).not.toHaveBeenCalled() + }) +}) + +it.skipIf(process.platform === 'win32')('runs a real interactive shell with completion, TERM and live window dimensions', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'dsh-web-terminal-')) + const ctx = new Context() + const runtime = await ctx.plugin(LocalSubprocessRuntime) + try { + const handle = await ctx.subprocess.spawnTerminal({ argv: ['/bin/bash', '--noprofile', '--norc', '-i'], cwd, rows: 24, cols: 80, terminalType: 'xterm-256color', graceMs: 100, env: { PS1: 'READY> ', PS2: '' } }) + let output = '' + handle.output.on('data', (data: Buffer) => { output += data.toString('utf8') }) + try { + await expect.poll(() => output).toContain('READY>') + await handle.resize(100, 30) + await handle.write("printf 'TERM:%s\\n' \"$TERM\"; stty size\r") + await expect.poll(() => output).toContain('TERM:xterm-256color') + await expect.poll(() => output).toContain('30 100') + // Bash's builtin completion expands this unambiguous command before Enter. + await handle.write('histor\t') + await expect.poll(() => output).toContain('history') + } finally { await handle.terminate() } + await expect(handle.done).resolves.toBeDefined() + } finally { + await runtime.dispose() + await ctx.fiber.dispose() + await rm(cwd, { recursive: true, force: true }) + } +}) diff --git a/packages/api/terminal-controller/tests/model.client.spec.ts b/packages/api/terminal-controller/tests/model.client.spec.ts new file mode 100644 index 0000000000..24be9f983d --- /dev/null +++ b/packages/api/terminal-controller/tests/model.client.spec.ts @@ -0,0 +1,503 @@ +/** Browser view ownership across slow RPCs, remounts and transport generations. */ +import { afterEach, expect, it, vi } from 'vitest' +import { RemoteStream, RemoteStreamCarrierError, type ClientRemote, type RemoteStreamOptions } from '@deepseek-ai/dsh-api-gateway/client' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import { RemoteError, type RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import type {} from '@deepseek-ai/dsh-api-terminal-controller/remote' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { TerminalView, type TerminalRemote } from '../src/client/model.ts' +import type { TerminalEnvironment, TerminalFrame, WebTerminalId, WebTerminalInfo } from '../src/types.ts' + +const sessionId = 'session' as SessionId +const info: WebTerminalInfo = { id: 'terminal' as WebTerminalId, shell: { name: 'bash', path: '/bin/bash', args: ['-i'] }, title: 'bash', cwd: '/workspace', rows: 24, cols: 80, state: 'running', exitCode: null } +const environment: TerminalEnvironment = { + cwd: info.cwd, maxInputBytes: 1000, maxCols: 200, maxRows: 100, scrollback: 100, +} +const success = (value: T): RemoteResult => ({ ok: true, value }) +const cleanups: (() => void | Promise)[] = [] +afterEach(async () => { for (const close of cleanups.splice(0).reverse()) await close() }) + +function untilAborted(signal?: AbortSignal): Promise { + return new Promise((resolve) => { + if (signal?.aborted) resolve() + else signal?.addEventListener('abort', () => { resolve() }, { once: true }) + }) +} + +function failure(message: string): RemoteResult { + return { ok: false, error: new RemoteError('gateway/bad-request', message, {}) } +} + +function fixture(prepareStream?: (stream: RemoteStream) => void) { + const remote: TerminalRemote = { + environment: vi.fn(async () => success(environment)), list: vi.fn(async () => success([])), + create: vi.fn(async (_sessionId, request) => success({ ...info, id: request.id })), + close: vi.fn(async () => success(undefined)), rename: vi.fn(async () => success(undefined)), + write: vi.fn(async () => success(undefined)), resize: vi.fn(async () => success(undefined)), + follow: vi.fn(async function* (_sessionId, id, attachmentId, signal) { + yield { type: 'snapshot', sequence: 0, screen: 'ready', info: { ...info, id, controllerId: attachmentId } } + await untilAborted(signal) + }), + } + const options: RemoteStreamOptions[] = [] + const streams: { dispose(): Promise; restart(): void }[] = [] + const generation = createSnapshotStore[0]['generation']['getSnapshot']>>(undefined) + const gateway: Pick = { + $stream: (option) => { + options.push(option) + const stream = new RemoteStream({ generation }, option) + streams.push(stream) + prepareStream?.(stream) + return stream + }, + } + cleanups.push(async () => { await Promise.all(streams.map(stream => stream.dispose())) }) + const model = new TerminalView(sessionId, remote, gateway, info.id) + cleanups.push(() => { model.dispose() }) + return { model, remote, gateway, options, streams, generation } +} + +async function mount(model: TerminalView) { + const detach = model.mount() + await model.refresh() + return detach +} + +function acknowledge(model: TerminalView): void { + const render = model.state.getSnapshot().render + if (render !== undefined) model.acknowledge(render.revision) +} + +async function connected(model: TerminalView): Promise { + await mount(model) + await expect.poll(() => model.state.getSnapshot().writable).toBe(true) + acknowledge(model) +} + +it('does not let a late input failure downgrade a newer connection', async () => { + const { model, remote } = fixture() + await mount(model) + await expect.poll(() => model.state.getSnapshot().writable).toBe(true) + acknowledge(model) + const input = Promise.withResolvers>() + vi.mocked(remote.write).mockReturnValueOnce(input.promise) + model.write('x') + await expect.poll(() => vi.mocked(remote.write).mock.calls.length).toBe(1) + model.connect() + await expect.poll(() => vi.mocked(remote.follow).mock.calls.length).toBe(2) + await expect.poll(() => model.state.getSnapshot().writable).toBe(true) + model.write('current input') + input.reject(new Error('old attachment was replaced')) + await expect.poll(() => vi.mocked(remote.write).mock.calls.some(call => call[3] === 'current input')).toBe(true) + expect(model.state.getSnapshot()).toMatchObject({ phase: 'connected', writable: true, error: undefined }) +}) + +it('waits for render acknowledgement before publishing subsequent output', async () => { + const { model, remote } = fixture() + vi.mocked(remote.follow).mockImplementation(async function* (_sessionId, id, controllerId, signal) { + const frames: TerminalFrame[] = [ + { type: 'snapshot', sequence: 0, screen: '', info: { ...info, id, controllerId } }, + { type: 'output', sequence: 1, data: 'first' }, + { type: 'output', sequence: 2, data: 'second' }, + ] + for (const frame of frames) yield frame + await untilAborted(signal) + }) + await mount(model) + await expect.poll(() => model.state.getSnapshot().render?.frame.type).toBe('snapshot') + acknowledge(model) + await expect.poll(() => model.state.getSnapshot().render?.frame).toMatchObject({ type: 'output', sequence: 1 }) + acknowledge(model) + await expect.poll(() => model.state.getSnapshot().render?.frame).toMatchObject({ type: 'output', sequence: 2 }) +}) + +it('serializes input and resizes, clamps geometry, and suppresses unchanged sizes', async () => { + const { model, remote } = fixture() + await connected(model) + const input = Promise.withResolvers>() + vi.mocked(remote.write).mockReturnValueOnce(input.promise) + model.resize(80, 24) + model.write('first') + model.write('second') + model.resize(500, 300) + await expect.poll(() => vi.mocked(remote.write).mock.calls.length).toBe(1) + expect(remote.resize).not.toHaveBeenCalled() + input.resolve(success(undefined)) + await expect.poll(() => vi.mocked(remote.resize).mock.calls.length).toBe(1) + const attachmentId = vi.mocked(remote.follow).mock.calls[0]![2] + const id = model.state.getSnapshot().info!.id + expect(vi.mocked(remote.write).mock.calls.map(call => call[3])).toEqual(['first', 'second']) + expect(remote.resize).toHaveBeenCalledWith(sessionId, id, attachmentId, 200, 100) + model.resize(90, 24) + await expect.poll(() => vi.mocked(remote.resize).mock.calls.length).toBe(2) +}) + +it('bounds queued input by UTF-8 bytes and releases the byte budget after settlement', async () => { + const { model, remote } = fixture() + vi.mocked(remote.environment).mockResolvedValue(success({ ...environment, maxInputBytes: 6 })) + await connected(model) + const input = Promise.withResolvers>() + vi.mocked(remote.write).mockReturnValueOnce(input.promise) + model.write('界界') + await expect.poll(() => vi.mocked(remote.write).mock.calls.length).toBe(1) + model.write('a') + expect(model.state.getSnapshot()).toMatchObject({ phase: 'failed', error: 'Terminal input buffer is full' }) + input.resolve(success(undefined)) + model.connect() + await expect.poll(() => model.state.getSnapshot().writable).toBe(true) + model.write('界界') + await expect.poll(() => vi.mocked(remote.write).mock.calls.length).toBe(2) + expect(vi.mocked(remote.write).mock.calls.map(call => call[3])).toEqual(['界界', '界界']) +}) + +it('drops queued commands from a detached attachment and ignores its late resize failure', async () => { + const { model, remote } = fixture() + await connected(model) + const input = Promise.withResolvers>() + vi.mocked(remote.resize).mockReturnValueOnce(input.promise) + model.resize(100, 30) + await expect.poll(() => vi.mocked(remote.resize).mock.calls.length).toBe(1) + model.write('stale') + model.resize(110, 35) + model.connect() + await expect.poll(() => model.state.getSnapshot().writable).toBe(true) + model.write('current') + input.resolve(failure('old attachment')) + await expect.poll(() => vi.mocked(remote.write).mock.calls.length).toBe(1) + expect(vi.mocked(remote.write).mock.calls[0]?.[3]).toBe('current') + expect(remote.resize).toHaveBeenCalledOnce() + expect(model.state.getSnapshot().phase).toBe('connected') +}) + +it('publishes current input and resize failures and can reconnect for another attempt', async () => { + const { model, remote } = fixture() + await connected(model) + vi.mocked(remote.write).mockResolvedValueOnce(failure('input refused')) + model.write('x') + await expect.poll(() => model.state.getSnapshot().error).toBe('input refused') + model.connect() + await expect.poll(() => model.state.getSnapshot().writable).toBe(true) + vi.mocked(remote.resize).mockResolvedValueOnce(failure('resize refused')) + model.resize(100, 30) + await expect.poll(() => model.state.getSnapshot().error).toBe('resize refused') +}) + +it('treats carrier loss as disconnected only for the active attachment', async () => { + const { model, options } = fixture() + await connected(model) + const first = options[0]! + first.carrierFailed?.(new RemoteStreamCarrierError('offline')) + expect(model.state.getSnapshot()).toMatchObject({ phase: 'disconnected', writable: false }) + model.connect() + await expect.poll(() => model.state.getSnapshot().writable).toBe(true) + first.carrierFailed?.(new RemoteStreamCarrierError('late old failure')) + expect(model.state.getSnapshot().phase).toBe('connected') +}) + +it.each([ + ['output before snapshot', [{ type: 'output', sequence: 1, data: 'orphan' }], 'missing its screen snapshot'], + ['sequence gap', [{ type: 'snapshot', sequence: 4, screen: '', info }, { type: 'output', sequence: 6, data: 'gap' }], 'sequence has a gap'], + ['repeated snapshot', [{ type: 'snapshot', sequence: 4, screen: '', info }, { type: 'snapshot', sequence: 4, screen: '', info }], 'Unexpected terminal screen snapshot'], +] satisfies readonly (readonly [string, readonly TerminalFrame[], string])[])('refuses a malformed output stream: %s', async (_name, frames, message) => { + const { model, remote } = fixture() + vi.mocked(remote.follow).mockImplementation(async function* () { yield* frames }) + await mount(model) + if (frames[0]?.type === 'snapshot') { + await expect.poll(() => model.state.getSnapshot().render).toBeDefined() + acknowledge(model) + } + await expect.poll(() => model.state.getSnapshot().error).toContain(message) + expect(model.state.getSnapshot().writable).toBe(false) +}) + +it('retains an exited screen and closes controls when its stream ends', async () => { + const { model, remote } = fixture() + vi.mocked(remote.follow).mockImplementation(async function* (_sessionId, id, controllerId) { + yield { type: 'snapshot', sequence: 0, screen: 'last screen', info: { ...info, id, controllerId } } + yield { type: 'state', info: { ...info, id, controllerId, state: 'exited', exitCode: 3 } } + }) + await connected(model) + await expect.poll(() => model.state.getSnapshot().phase).toBe('closed') + expect(model.state.getSnapshot()).toMatchObject({ writable: false, info: { state: 'exited', exitCode: 3 }, render: { frame: { screen: 'last screen' } } }) +}) + +it('reports an attachment ending while the shell is still running and permits manual reconnect', async () => { + const { model, remote } = fixture() + vi.mocked(remote.follow).mockImplementationOnce(async function* (_sessionId, id, controllerId) { + yield { type: 'snapshot', sequence: 0, screen: '', info: { ...info, id, controllerId } } + }) + await connected(model) + await expect.poll(() => model.state.getSnapshot().error).toContain('Terminal attachment ended') + model.connect() + await expect.poll(() => model.state.getSnapshot().writable).toBe(true) +}) + +it('publishes remote control transfer without replacing the retained screen', async () => { + const { model, remote } = fixture() + vi.mocked(remote.follow).mockImplementation(async function* (_sessionId, id, controllerId, signal) { + yield { type: 'snapshot', sequence: 0, screen: 'retained', info: { ...info, id, controllerId } } + yield { type: 'state', info: { ...info, id } } + await untilAborted(signal) + }) + await connected(model) + await expect.poll(() => model.state.getSnapshot().writable).toBe(false) + expect(model.state.getSnapshot().render?.frame).toMatchObject({ type: 'snapshot', screen: 'retained' }) +}) + +/** Pause delivery after the real Gateway iterator settles, before the model observes it. */ +function deliveryBarrier() { + const ready = Promise.withResolvers() + const release = Promise.withResolvers() + let held = false + function prepare(stream: RemoteStream): void { + if (held) return + held = true + const iterator = stream[Symbol.asyncIterator]() + let first = true + vi.spyOn(stream, Symbol.asyncIterator).mockReturnValue({ + async next() { + if (!first) return iterator.next() + first = false + try { + return await iterator.next() + } finally { + ready.resolve(undefined) + await release.promise + } + }, + async return() { return iterator.return!() }, + }) + } + cleanups.push(() => { release.resolve(undefined) }) + return { ready: ready.promise, release: () => { release.resolve(undefined) }, prepare } +} + +it('ignores an already delivered screen when its attachment is replaced before the model receives it', async () => { + const barrier = deliveryBarrier() + const { model, remote } = fixture(barrier.prepare) + await mount(model) + await barrier.ready + model.connect() + await expect.poll(() => model.state.getSnapshot().writable).toBe(true) + const current = model.state.getSnapshot() + barrier.release() + await model.rename('settled') + expect(model.state.getSnapshot().render).toBe(current.render) + expect(remote.follow).toHaveBeenCalledTimes(2) +}) + +it('ignores an already settled stream error after a newer attachment has become writable', async () => { + const barrier = deliveryBarrier() + const { model, remote } = fixture(barrier.prepare) + vi.mocked(remote.follow).mockImplementationOnce(async function* () { + yield* [] + throw new Error('retired attachment failed') + }) + await mount(model) + await barrier.ready + model.connect() + await expect.poll(() => model.state.getSnapshot().writable).toBe(true) + barrier.release() + await model.rename('settled') + expect(model.state.getSnapshot()).toMatchObject({ phase: 'connected', writable: true, error: undefined }) +}) + +it('releases a pending screen render on carrier generation cancellation and accepts the replacement screen', async () => { + const { model, remote, streams } = fixture() + await mount(model) + await expect.poll(() => model.state.getSnapshot().render).toBeDefined() + const revision = model.state.getSnapshot().render!.revision + streams[0]!.restart() + await expect.poll(() => model.state.getSnapshot().render?.revision).toBe(revision + 1) + expect(remote.follow).toHaveBeenCalledTimes(2) + expect(model.state.getSnapshot().writable).toBe(true) +}) + +it('does not wait for the DOM callback of a screen whose generation was already cancelled at delivery', async () => { + const barrier = deliveryBarrier() + const { model, remote, streams } = fixture(barrier.prepare) + await mount(model) + await barrier.ready + streams[0]!.restart() + barrier.release() + await expect.poll(() => model.state.getSnapshot().render?.revision).toBe(2) + expect(remote.follow).toHaveBeenCalledTimes(2) + expect(model.state.getSnapshot().writable).toBe(true) +}) + +it.each(['write', 'resize'] as const)('keeps a fresh attachment writable when a pending %s fails after automatic transport recovery', async (operation) => { + const { model, remote, generation } = fixture() + generation.set({ id: 1, host: { home: '/home/fixture' } }) + const disconnected = Promise.withResolvers() + vi.mocked(remote.follow).mockImplementationOnce(async function* (_sessionId, id, controllerId, signal) { + yield { type: 'snapshot', sequence: 0, screen: 'before disconnect', info: { ...info, id, controllerId } } + await Promise.race([disconnected.promise, untilAborted(signal)]) + if (signal?.aborted) return + throw new RemoteStreamCarrierError('connection lost') + }) + await connected(model) + const pending = Promise.withResolvers>() + if (operation === 'write') { + vi.mocked(remote.write).mockReturnValueOnce(pending.promise) + model.write('old input') + } else { + vi.mocked(remote.resize).mockReturnValueOnce(pending.promise) + model.resize(100, 30) + } + await expect.poll(() => vi.mocked(remote[operation]).mock.calls.length).toBe(1) + const oldAttachmentId = vi.mocked(remote.follow).mock.calls[0]![2] + generation.set(undefined) + disconnected.resolve(undefined) + await expect.poll(() => model.state.getSnapshot().phase).toBe('disconnected') + expect(model.state.getSnapshot().writable).toBe(false) + expect(remote.follow).toHaveBeenCalledOnce() + + generation.set({ id: 2, host: { home: '/home/fixture' } }) + await expect.poll(() => model.state.getSnapshot().writable).toBe(true) + expect(remote.follow).toHaveBeenCalledTimes(2) + const attachmentId = vi.mocked(remote.follow).mock.calls[1]![2] + expect(attachmentId).not.toBe(oldAttachmentId) + expect(model.state.getSnapshot().info?.controllerId).toBe(attachmentId) + expect(model.state.getSnapshot().render?.frame).toMatchObject({ type: 'snapshot', screen: 'ready' }) + + model.write('fresh input') + pending.reject(new Error('old attachment was replaced')) + await expect.poll(() => vi.mocked(remote.write).mock.calls.some(call => call[3] === 'fresh input')).toBe(true) + expect(model.state.getSnapshot()).toMatchObject({ phase: 'connected', writable: true, error: undefined }) + expect(vi.mocked(remote.write).mock.calls.at(-1)?.[2]).toBe(attachmentId) + expect(remote.create).toHaveBeenCalledOnce() + expect(remote.close).not.toHaveBeenCalled() +}) + +it('ignores controls before discovery and reconnects an existing process after remount', async () => { + const { model, remote } = fixture() + model.connect() + model.write('early') + model.resize(100, 30) + model.acknowledge(100) + expect(remote.follow).not.toHaveBeenCalled() + expect(remote.write).not.toHaveBeenCalled() + expect(remote.resize).not.toHaveBeenCalled() + const unmount = await mount(model) + await expect.poll(() => model.state.getSnapshot().writable).toBe(true) + unmount() + model.connect() + model.write('detached') + model.resize(100, 30) + expect(remote.follow).toHaveBeenCalledOnce() + expect(remote.write).not.toHaveBeenCalled() + expect(remote.resize).not.toHaveBeenCalled() + model.mount() + await expect.poll(() => remote.follow).toHaveBeenCalledTimes(2) + await expect.poll(() => model.state.getSnapshot().writable).toBe(true) + expect(remote.create).toHaveBeenCalledOnce() +}) + +it('keeps controls inert when process metadata or the attachment is unavailable', async () => { + const { model, remote } = fixture() + await connected(model) + const state = model.state.getSnapshot() + model.state.set({ ...state, info: undefined }) + model.write('missing metadata') + model.resize(100, 30) + expect(remote.write).not.toHaveBeenCalled() + expect(remote.resize).not.toHaveBeenCalled() + model.state.set({ ...state, environment: undefined }) + model.resize(100, 30) + await expect.poll(() => remote.resize).toHaveBeenCalledOnce() + expect(vi.mocked(remote.resize).mock.calls[0]?.slice(-2)).toEqual([100, 30]) + model.write('unknown budget') + expect(model.state.getSnapshot().error).toBe('Terminal input buffer is full') + expect(remote.write).not.toHaveBeenCalled() +}) + +it('renames an existing terminal, skips unchanged names, and exposes rename failures', async () => { + const { model, remote } = fixture() + await connected(model) + await model.rename(` ${info.title} `) + expect(remote.rename).not.toHaveBeenCalled() + await model.rename(' Build ') + expect(model.state.getSnapshot()).toMatchObject({ title: 'Build', info: { title: 'Build' } }) + vi.mocked(remote.rename).mockRejectedValueOnce('rename connection lost') + await model.rename('Other') + expect(model.state.getSnapshot()).toMatchObject({ phase: 'failed', error: 'rename connection lost', title: 'Build' }) + model.dispose() + await model.rename('Ignored') + expect(remote.rename).toHaveBeenCalledTimes(2) +}) + +it('classifies a discovery carrier failure as disconnected and supports explicit retry', async () => { + const { model, remote } = fixture() + vi.mocked(remote.environment).mockRejectedValueOnce(new RemoteStreamCarrierError('offline')) + await model.refresh() + expect(model.state.getSnapshot()).toMatchObject({ phase: 'disconnected', error: 'offline' }) + expect(remote.create).not.toHaveBeenCalled() + await model.refresh() + expect(model.state.getSnapshot().info?.id).toBe(info.id) + expect(remote.create).toHaveBeenCalledOnce() +}) + +it('retains state when disposal overtakes successful allocation or a failed discovery', async () => { + for (const operation of ['creation', 'discovery'] as const) { + const { model, remote } = fixture() + const creation = Promise.withResolvers>() + const discovery = Promise.withResolvers>() + if (operation === 'creation') vi.mocked(remote.create).mockReturnValueOnce(creation.promise) + else vi.mocked(remote.environment).mockReturnValueOnce(discovery.promise) + const loading = model.refresh() + if (operation === 'creation') await expect.poll(() => remote.create).toHaveBeenCalledOnce() + model.dispose() + const before = model.state.getSnapshot() + if (operation === 'creation') creation.resolve(success(info)) + else discovery.reject(new Error('late discovery failure')) + await loading + expect(model.state.getSnapshot()).toBe(before) + expect(remote.follow).not.toHaveBeenCalled() + } +}) + +it('ignores a successful rename after the view is disposed', async () => { + const { model, remote } = fixture() + await connected(model) + const rename = Promise.withResolvers>() + vi.mocked(remote.rename).mockReturnValueOnce(rename.promise) + const pending = model.rename('Late') + model.dispose() + const before = model.state.getSnapshot() + rename.resolve(success(undefined)) + await pending + expect(model.state.getSnapshot()).toBe(before) +}) + +it('allows retry after failed process cleanup and never reconnects while close is pending', async () => { + const { model, remote } = fixture() + await connected(model) + const closing = Promise.withResolvers>() + vi.mocked(remote.close).mockReturnValueOnce(closing.promise) + const first = model.close() + expect(model.close()).toBe(first) + model.connect() + expect(remote.follow).toHaveBeenCalledOnce() + closing.resolve(failure('close refused')) + await expect(first).rejects.toThrow('close refused') + expect(model.state.getSnapshot()).toMatchObject({ phase: 'failed', error: 'close refused' }) + await model.close() + expect(model.state.getSnapshot()).toMatchObject({ phase: 'closed', writable: false }) + expect(remote.close).toHaveBeenCalledTimes(2) +}) + +it('does not publish a close result or reconnect after disposal', async () => { + const { model, remote } = fixture() + await connected(model) + const closing = Promise.withResolvers>() + vi.mocked(remote.close).mockReturnValueOnce(closing.promise) + const pending = model.close() + model.dispose() + const before = model.state.getSnapshot() + closing.resolve(success(undefined)) + await pending + model.mount() + model.connect() + expect(model.state.getSnapshot()).toBe(before) + expect(remote.follow).toHaveBeenCalledOnce() +}) diff --git a/packages/api/terminal-controller/tests/recovery.client.spec.ts b/packages/api/terminal-controller/tests/recovery.client.spec.ts new file mode 100644 index 0000000000..f9c2438fd2 --- /dev/null +++ b/packages/api/terminal-controller/tests/recovery.client.spec.ts @@ -0,0 +1,369 @@ +/** Reloaded tab identities and nonblocking close requests use independent lifetimes. */ +import { afterEach, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import { RemoteError, type RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import { RemoteStream, type ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' +import type {} from '@deepseek-ai/dsh-api-terminal-controller/remote' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { WebTerminalId, WebTerminalInfo, TerminalEnvironment } from '../src/types.ts' +import { TerminalView, type TerminalRemote } from '../src/client/model.ts' +import { TerminalCloseRequests } from '../src/client/close-requests.ts' +import * as TerminalClient from '../src/client/index.ts' + +const sessionId = 'session' as SessionId +const info: WebTerminalInfo = { id: 'terminal' as WebTerminalId, shell: { name: 'zsh', path: '/bin/zsh', args: ['-i'] }, title: 'zsh', cwd: '/workspace', rows: 24, cols: 80, state: 'running', exitCode: null } +const environment: TerminalEnvironment = { cwd: info.cwd, maxInputBytes: 1000, maxCols: 200, maxRows: 100, scrollback: 100 } +const success = (value: T): RemoteResult => ({ ok: true, value }) +const failure = (message: string): RemoteResult => ({ ok: false, error: new RemoteError('gateway/bad-request', message, {}) }) +const cleanups: (() => void | Promise)[] = [] +afterEach(async () => { for (const cleanup of cleanups.splice(0).reverse()) await cleanup(); vi.unstubAllGlobals(); vi.restoreAllMocks() }) + +function storage() { + const data = new Map() + vi.stubGlobal('localStorage', { get length() { return data.size }, key: (index: number) => [...data.keys()][index] ?? null, getItem: (key: string) => data.get(key) ?? null, setItem: (key: string, value: string) => { data.set(key, value) }, removeItem: (key: string) => { data.delete(key) } }) + return data +} + +function fixture() { + const remote: TerminalRemote = { + environment: vi.fn(async () => success(environment)), list: vi.fn(async () => success([])), + create: vi.fn(async (_session, request) => success({ ...info, id: request.id })), + close: vi.fn(async () => success(undefined)), rename: vi.fn(async () => success(undefined)), + write: vi.fn(async () => success(undefined)), resize: vi.fn(async () => success(undefined)), + follow: vi.fn(async function* (_session, id, controllerId, signal) { + yield { type: 'snapshot', sequence: 0, screen: 'retained', info: { ...info, id, controllerId } } + await new Promise((resolve) => { + if (signal?.aborted) resolve() + else signal?.addEventListener('abort', () => { resolve() }, { once: true }) + }) + }), + } + const gateway: Pick = { $stream: options => new RemoteStream({ generation: createSnapshotStore(undefined) }, options) } + function view() { + const model = new TerminalView(sessionId, remote, gateway, info.id) + cleanups.push(() => { model.dispose() }) + return model + } + async function service() { + const ctx = new Context() + ctx.provide('remote', { ...gateway, terminal: remote } as never) + ctx.provide('remote.terminal', remote) + const fiber = ctx.plugin(TerminalClient) + cleanups.push(async () => { await ctx.fiber.dispose() }) + await fiber + return { service: ctx.webTerminals, dispose: () => fiber.dispose() } + } + return { remote, view, service } +} + +it('starts the default shell once across overlapping mounts and retries, with no shell choice in the request', async () => { + const h = fixture() + const model = h.view() + const creation = Promise.withResolvers>() + vi.mocked(h.remote.create).mockReturnValueOnce(creation.promise) + model.mount() + const loading = model.refresh() + expect(model.refresh()).toBe(loading) + await expect.poll(() => vi.mocked(h.remote.create).mock.calls.length).toBe(1) + expect(vi.mocked(h.remote.create).mock.calls[0]?.[1]).toEqual({ id: info.id, cols: 80, rows: 24 }) + creation.resolve(success(info)) + await loading + await expect.poll(() => model.state.getSnapshot().writable).toBe(true) + expect(h.remote.create).toHaveBeenCalledOnce() +}) + +it('recovers a listed process without creating another, including its title and screen', async () => { + const h = fixture() + vi.mocked(h.remote.list).mockResolvedValue(success([{ ...info, title: 'Build' }])) + const model = h.view() + model.mount() + await model.refresh() + expect(h.remote.create).not.toHaveBeenCalled() + await expect.poll(() => model.state.getSnapshot().render?.frame).toMatchObject({ type: 'snapshot', screen: 'retained' }) + expect(h.remote.follow).toHaveBeenCalledWith(sessionId, info.id, expect.any(String), expect.any(AbortSignal)) +}) + +it('does not recreate a recovered terminal that disappeared after the recovery list was shown', async () => { + const h = fixture() + const { service } = await h.service() + vi.mocked(h.remote.list).mockResolvedValueOnce(success([info])) + expect(await service.recover(sessionId)).toEqual([info]) + const model = service.view(sessionId, 'recovered', info.id) + await model.refresh() + expect(model.state.getSnapshot()).toMatchObject({ phase: 'failed', writable: false }) + expect(model.state.getSnapshot().error).toBeDefined() + expect(h.remote.create).not.toHaveBeenCalled() + model.mount() + await model.refresh() + expect(h.remote.create).not.toHaveBeenCalled() +}) + +it('retries lost create acknowledgements with the saved id and closes after a refused allocation', async () => { + const h = fixture() + const model = h.view() + vi.mocked(h.remote.create).mockResolvedValueOnce(failure('response lost')) + await model.refresh() + expect(model.state.getSnapshot().error).toBe('response lost') + await model.refresh() + expect(vi.mocked(h.remote.create).mock.calls.map(call => call[1].id)).toEqual([info.id, info.id]) + await model.close() + expect(h.remote.close).toHaveBeenCalledWith(sessionId, info.id) +}) + +it('waits for an in-flight creation while close detaches immediately and prevents a late connection', async () => { + const h = fixture() + const model = h.view() + const creation = Promise.withResolvers>() + vi.mocked(h.remote.create).mockReturnValueOnce(creation.promise) + const unmount = model.mount() + await expect.poll(() => vi.mocked(h.remote.create).mock.calls.length).toBe(1) + const closing = model.close() + expect(model.close()).toBe(closing) + unmount() + expect(h.remote.close).not.toHaveBeenCalled() + creation.resolve(failure('cancelled allocation')) + await closing + expect(h.remote.close).toHaveBeenCalledWith(sessionId, info.id) + expect(h.remote.follow).not.toHaveBeenCalled() +}) + +it('does not allocate after close or disposal overtakes discovery', async () => { + for (const action of ['close', 'dispose'] as const) { + const h = fixture() + const model = h.view() + const discovery = Promise.withResolvers>() + vi.mocked(h.remote.environment).mockReturnValueOnce(discovery.promise) + const loading = model.refresh() + await model[action]() + discovery.resolve(success(environment)) + await loading + await model.refresh() + expect(h.remote.create).not.toHaveBeenCalled() + } +}) + +it('updates a restored inactive terminal title and ignores late results after disposal', async () => { + const h = fixture() + const model = h.view() + await model.rename(' Build ') + expect(h.remote.rename).toHaveBeenCalledWith(sessionId, info.id, ' Build ') + expect(model.state.getSnapshot().title).toBe('Build') + const rename = Promise.withResolvers>() + vi.mocked(h.remote.rename).mockReturnValueOnce(rename.promise) + const pending = model.rename('Late') + model.dispose() + rename.resolve(success(undefined)) + await pending + expect(model.state.getSnapshot().title).toBe('Build') +}) + +it('removes a view synchronously, keeps cleanup retryable, and never deletes a replacement view', async () => { + storage() + const h = fixture() + const { service } = await h.service() + const first = service.view(sessionId, 'tab') + first.mount() + await first.refresh() + const closing = Promise.withResolvers>() + vi.mocked(h.remote.close).mockReturnValueOnce(closing.promise) + service.close(sessionId, 'tab') + const replacement = service.view(sessionId, 'tab') + expect(replacement).not.toBe(first) + expect(service.closeFailures.getSnapshot()).toEqual([]) + closing.resolve(failure('termination refused')) + await expect.poll(() => service.closeFailures.getSnapshot().length).toBe(1) + const failed = service.closeFailures.getSnapshot()[0]! + service.retryClose(failed.id) + service.retryClose(failed.id) + await expect.poll(() => service.closeFailures.getSnapshot()).toEqual([]) + expect(service.view(sessionId, 'tab')).toBe(replacement) + await expect.poll(() => new TerminalCloseRequests().pending()).toEqual([]) +}) + +it('closes an inactive restored tab by tab identity and retries saved close requests after reload', async () => { + storage() + const h = fixture() + const first = await h.service() + first.service.close(sessionId, 'inactive', 'inactive' as WebTerminalId) + await expect.poll(() => vi.mocked(h.remote.close).mock.calls.length).toBe(1) + expect(h.remote.close).toHaveBeenCalledWith(sessionId, 'inactive') + await first.dispose() + const pending = new TerminalCloseRequests() + pending.save({ sessionId, id: 'second' as WebTerminalId, title: 'Second' }) + await h.service() + await expect.poll(() => vi.mocked(h.remote.close).mock.calls.length).toBe(2) + expect(h.remote.close).toHaveBeenCalledWith(sessionId, 'second') + await expect.poll(() => new TerminalCloseRequests().pending()).toEqual([]) +}) + +it('keeps concurrent windows close requests independent and removes only the settled request', () => { + storage() + const a = new TerminalCloseRequests() + const b = new TerminalCloseRequests() + a.save({ sessionId, id: 'a' as WebTerminalId, title: 'A' }) + b.save({ sessionId, id: 'b' as WebTerminalId, title: 'B' }) + expect(new TerminalCloseRequests().pending().map(request => request.id)).toEqual(['a', 'b']) + a.remove('a' as WebTerminalId) + expect(new TerminalCloseRequests().pending().map(request => request.id)).toEqual(['b']) +}) + +it('recovers Host terminals by Session while excluding held, pending-close, and already-closed identities', async () => { + storage() + const h = fixture() + const { service } = await h.service() + const held = service.view(sessionId, 'held') + await held.refresh() + const closing = service.view(sessionId, 'closing') + await closing.refresh() + const done = service.view(sessionId, 'closed') + await done.refresh() + const unheld: WebTerminalInfo = { ...info, id: 'unheld' as WebTerminalId } + const pending = Promise.withResolvers>() + vi.mocked(h.remote.close).mockReturnValueOnce(pending.promise) + service.close(sessionId, 'closing') + service.close(sessionId, 'closed') + await expect.poll(() => new TerminalCloseRequests().pending().length).toBe(1) + vi.mocked(h.remote.list).mockResolvedValue(success([ + { ...info, id: held.id }, { ...info, id: closing.id }, { ...info, id: done.id }, unheld, + ])) + expect(await service.recover(sessionId)).toEqual([unheld]) + expect(h.remote.list).toHaveBeenLastCalledWith(sessionId) + const otherSession = 'other-session' as SessionId + vi.mocked(h.remote.list).mockResolvedValueOnce(success([info])) + expect(await service.recover(otherSession)).toEqual([info]) + pending.resolve(success(undefined)) + await expect.poll(() => new TerminalCloseRequests().pending()).toEqual([]) + expect(await service.recover(sessionId)).toEqual([unheld]) +}) + +it('queries current held views after a slow recovery response and reports discovery errors', async () => { + const h = fixture() + const { service } = await h.service() + const listed = Promise.withResolvers>() + vi.mocked(h.remote.list).mockReturnValueOnce(listed.promise) + const recovering = service.recover(sessionId) + vi.mocked(h.remote.list).mockResolvedValueOnce(success([info])) + const model = service.view(sessionId, 'recovered', info.id) + await model.refresh() + listed.resolve(success([info])) + expect(await recovering).toEqual([]) + expect(service.view(sessionId, 'recovered', info.id)).toBe(model) + expect(model.id).toBe(info.id) + vi.mocked(h.remote.list).mockResolvedValueOnce(failure('Session unavailable')) + await expect(service.recover(sessionId)).rejects.toThrow('Session unavailable') +}) + +it('ignores an unknown tab and retries only saved close requests', async () => { + storage() + const h = fixture() + const { service } = await h.service() + service.close(sessionId, 'unknown') + service.retryClose('unknown' as WebTerminalId) + expect(h.remote.close).not.toHaveBeenCalled() + expect(new TerminalCloseRequests().pending()).toEqual([]) +}) + +it('retains an inactive close failure with its tab title until retry succeeds', async () => { + const data = storage() + const h = fixture() + const { service } = await h.service() + vi.mocked(h.remote.close).mockResolvedValueOnce(failure('Host refused cleanup')) + service.close(sessionId, 'Build', info.id) + expect(data.has(`dsh.terminal.close.v1.${info.id}`)).toBe(true) + await expect.poll(() => service.closeFailures.getSnapshot()).toEqual([{ id: info.id, title: 'Build', message: 'Host refused cleanup' }]) + vi.mocked(h.remote.list).mockResolvedValueOnce(success([info])) + expect(await service.recover(sessionId)).toEqual([]) + const pending = Promise.withResolvers>() + vi.mocked(h.remote.close).mockReturnValueOnce(pending.promise) + service.retryClose(info.id) + service.retryClose(info.id) + expect(service.closeFailures.getSnapshot()).toEqual([]) + expect(h.remote.close).toHaveBeenCalledTimes(2) + pending.resolve(success(undefined)) + await expect.poll(() => data.size).toBe(0) +}) + +it('preserves a close failure from a non-Error rejection', async () => { + storage() + const h = fixture() + const { service } = await h.service() + vi.mocked(h.remote.close).mockRejectedValueOnce('carrier closed') + service.close(sessionId, 'Build', info.id) + await expect.poll(() => service.closeFailures.getSnapshot()).toEqual([{ id: info.id, title: 'Build', message: 'carrier closed' }]) +}) + +it('waits for pending cleanup during service disposal without publishing a late failure', async () => { + storage() + const h = fixture() + const { service, dispose } = await h.service() + const pending = Promise.withResolvers>() + vi.mocked(h.remote.close).mockReturnValueOnce(pending.promise) + service.close(sessionId, 'Build', info.id) + let finished = false + const disposing = dispose().then(() => { finished = true }) + expect(finished).toBe(false) + pending.resolve(failure('transport is stopping')) + await disposing + expect(service.closeFailures.getSnapshot()).toEqual([]) + expect(new TerminalCloseRequests().pending()).toEqual([{ sessionId, id: info.id, title: 'Build' }]) + service.retryClose(info.id) + expect(h.remote.close).toHaveBeenCalledOnce() +}) + +it('saves close intents without persisting any active terminal or sidebar state', async () => { + const data = storage() + const h = fixture() + const { service } = await h.service() + const model = service.view(sessionId, 'new-tab') + await model.refresh() + expect(model.id).toMatch(/^[0-9a-f-]{36}$/) + expect(data.size).toBe(0) + const pending = Promise.withResolvers>() + vi.mocked(h.remote.close).mockReturnValueOnce(pending.promise) + service.close(sessionId, 'new-tab') + const request = { sessionId, id: model.id, title: info.title } + expect([...data.entries()]).toEqual([[`dsh.terminal.close.v1.${model.id}`, JSON.stringify(request)]]) + pending.resolve(success(undefined)) + await expect.poll(() => data.size).toBe(0) +}) + +it.each(['{broken', 'null', '{}', '[{}]', '{"sessionId":"s","id":"bad/id","title":"x"}', '{"sessionId":"s","id":"different","title":"x"}'])('discards malformed saved cleanup: %s', (raw) => { + const data = storage() + data.set('dsh.terminal.close.v1.terminal', raw) + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + expect(new TerminalCloseRequests().pending()).toEqual([]) + expect(error).toHaveBeenCalledOnce() +}) + +it('skips unrelated storage and cleanup keys removed during enumeration', () => { + const getItem = vi.fn(() => null) + vi.stubGlobal('localStorage', { + length: 3, + key: (index: number) => ['unrelated', 'dsh.terminal.close.v1.gone', null][index], + getItem, + }) + expect(new TerminalCloseRequests().pending()).toEqual([]) + expect(getItem).toHaveBeenCalledExactlyOnceWith('dsh.terminal.close.v1.gone') +}) + +it('keeps close requests usable without browser storage', () => { + vi.stubGlobal('localStorage', undefined) + const requests = new TerminalCloseRequests() + requests.save({ sessionId, id: info.id, title: 'Build' }) + expect(requests.pending()).toEqual([{ sessionId, id: info.id, title: 'Build' }]) + requests.remove(info.id) + expect(requests.pending()).toEqual([]) +}) + +it('keeps cleanup usable in memory when storage access itself is denied', () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.stubGlobal('localStorage', undefined) + Object.defineProperty(globalThis, 'localStorage', { configurable: true, get() { throw new Error('storage denied') } }) + const requests = new TerminalCloseRequests() + requests.save({ sessionId, id: info.id, title: 'Build' }) + expect(requests.pending()).toHaveLength(1) + requests.remove(info.id) + expect(requests.pending()).toEqual([]) + expect(error).toHaveBeenCalledTimes(3) +}) diff --git a/packages/api/terminal-controller/tests/stream.spec.ts b/packages/api/terminal-controller/tests/stream.spec.ts new file mode 100644 index 0000000000..a5fd9357ff --- /dev/null +++ b/packages/api/terminal-controller/tests/stream.spec.ts @@ -0,0 +1,80 @@ +/** Output followers drain on process exit and discard queued data on detach. */ +import { describe, expect, it } from 'vitest' +import { TerminalFollower } from '../src/stream.ts' +import type { TerminalFrame } from '../src/types.ts' + +const frame: TerminalFrame = { type: 'output', sequence: 1, data: '终端' } +const signal = (): AbortSignal => new AbortController().signal + +describe('TerminalFollower', () => { + it('accepts the exact byte limit and restores capacity after each frame is consumed', async () => { + const follower = new TerminalFollower(Buffer.byteLength(JSON.stringify(frame), 'utf8')) + const stream = follower.read(signal())[Symbol.asyncIterator]() + try { + follower.push(frame) + expect(await stream.next()).toEqual({ done: false, value: frame }) + follower.push(frame) + follower.finish() + follower.push({ ...frame, sequence: 2 }) + expect(await stream.next()).toEqual({ done: false, value: frame }) + expect(await stream.next()).toEqual({ done: true, value: undefined }) + } finally { follower.close(); await stream.return?.() } + }) + + it('fails accumulated overflow using encoded byte size', async () => { + const follower = new TerminalFollower(Buffer.byteLength(JSON.stringify(frame), 'utf8')) + follower.push(frame) + follower.push(frame) + await expect(follower.read(signal())[Symbol.asyncIterator]().next()).rejects.toThrow('reconnect to recover') + }) + + it('discards queued data when detached and ignores later output', async () => { + const follower = new TerminalFollower(1000) + follower.push(frame) + follower.close() + follower.push(frame) + expect(await follower.read(signal())[Symbol.asyncIterator]().next()).toEqual({ done: true, value: undefined }) + }) + + it('does not deliver buffered output to an already-aborted request', async () => { + const follower = new TerminalFollower(1000) + follower.push(frame) + const abort = new AbortController() + abort.abort() + expect(await follower.read(abort.signal)[Symbol.asyncIterator]().next()).toEqual({ done: true, value: undefined }) + }) + + it('wakes a waiting reader for output and then for graceful completion', async () => { + const follower = new TerminalFollower(1000) + const stream = follower.read(signal())[Symbol.asyncIterator]() + try { + const reading = stream.next() + follower.push(frame) + expect(await reading).toEqual({ done: false, value: frame }) + const finishing = stream.next() + follower.finish() + expect(await finishing).toEqual({ done: true, value: undefined }) + } finally { follower.close(); await stream.return?.() } + }) + + it('wakes a waiting reader on cancellation', async () => { + const follower = new TerminalFollower(1000) + const abort = new AbortController() + const stream = follower.read(abort.signal)[Symbol.asyncIterator]() + try { + const reading = stream.next() + abort.abort() + expect(await reading).toEqual({ done: true, value: undefined }) + } finally { follower.close(); await stream.return?.() } + }) + + it('closes the follower when its consumer returns before EOF', async () => { + const follower = new TerminalFollower(1000) + const stream = follower.read(signal())[Symbol.asyncIterator]() + follower.push(frame) + await stream.next() + await stream.return?.() + follower.push(frame) + expect(await follower.read(signal())[Symbol.asyncIterator]().next()).toEqual({ done: true, value: undefined }) + }) +}) diff --git a/packages/api/terminal-controller/tests/terminal.spec.ts b/packages/api/terminal-controller/tests/terminal.spec.ts new file mode 100644 index 0000000000..86c229e158 --- /dev/null +++ b/packages/api/terminal-controller/tests/terminal.spec.ts @@ -0,0 +1,220 @@ +/** Terminal screen continuity and process ownership under real output scheduling. */ +import { PassThrough } from 'node:stream' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { SubprocessOutcome, SubprocessTerminalHandle } from '@deepseek-ai/dsh-subprocess' +import { BrowserTerminal } from '../src/terminal.ts' +import { TerminalFollower } from '../src/stream.ts' +import type { TerminalAttachmentId, TerminalFrame, WebTerminalId, WebTerminalInfo } from '../src/types.ts' + +const info: WebTerminalInfo = { id: 'terminal-test' as WebTerminalId, title: 'bash', shell: { path: '/bin/bash', name: 'bash', args: ['-i'] }, cwd: '/workspace', cols: 80, rows: 24, state: 'running', exitCode: null } +const attachment = (id: string): TerminalAttachmentId => id as TerminalAttachmentId +const cleanups: (() => Promise)[] = [] +afterEach(async () => { await Promise.all(cleanups.splice(0).map(close => close())) }) + +function fixture() { + const output = new PassThrough() + const outcome = Promise.withResolvers() + const handle = { + pid: 123, output, done: outcome.promise, write: vi.fn(async () => {}), resize: vi.fn(async () => {}), + inspectForeground: async () => undefined, signalForeground: async () => 123, + terminate: vi.fn(async () => { output.end(); outcome.resolve({ exitCode: 0, signal: null }) }), + } + const checked: SubprocessTerminalHandle = handle + const terminal = new BrowserTerminal(checked, info, 100, 100_000) + cleanups.push(() => terminal.close()) + return { terminal, output, outcome, handle } +} + +async function attach(terminal: BrowserTerminal, id = 'first') { + const controller = new AbortController() + const iterator = terminal.follow(attachment(id), controller.signal)[Symbol.asyncIterator]() + const baseline = await readFrame(iterator) + cleanups.push(async () => { controller.abort(); await iterator.return?.() }) + return { controller, iterator, baseline } +} + +describe('BrowserTerminal', () => { + it('restores the screen after detach without re-executing the shell or replaying duplicate output', async () => { + const { terminal, output, handle } = fixture() + const first = await attach(terminal) + expect(first.baseline).toMatchObject({ type: 'snapshot', sequence: 0 }) + output.write(Buffer.from('hello\r\n')) + expect(await readFrame(first.iterator)).toMatchObject({ type: 'output', sequence: 1, data: 'hello\r\n' }) + first.controller.abort() + await first.iterator.return?.() + expect(handle.terminate).not.toHaveBeenCalled() + output.write(Buffer.from('world')) + await expect.poll(() => terminal.info.state).toBe('running') + const second = await attach(terminal, 'second') + // The baseline and its output queue share the same ordered screen write queue. + const frames = [second.baseline] + if (second.baseline.type === 'snapshot' && !second.baseline.screen.includes('world')) frames.push(await readFrame(second.iterator)) + expect(JSON.stringify(frames)).toContain('world') + expect(JSON.stringify(frames)).toContain('hello') + expect(handle.terminate).not.toHaveBeenCalled() + }) + + it('preserves split UTF-8, gives the newest attachment input, and resizes both PTY and recovery screen', async () => { + const { terminal, output, handle } = fixture() + const first = await attach(terminal) + const encoded = Buffer.from('终端') + output.write(encoded.subarray(0, 2)) + output.write(encoded.subarray(2)) + expect(await readFrame(first.iterator)).toMatchObject({ type: 'output', data: '终端' }) + const second = await attach(terminal, 'second') + await expect(terminal.write(attachment('first'), 'ignored')).rejects.toThrow('another attachment') + await terminal.write(attachment('second'), '\t') + expect(handle.write).toHaveBeenCalledWith('\t') + await terminal.resize(attachment('second'), 100, 30) + expect(handle.resize).toHaveBeenCalledWith(100, 30) + expect(terminal.info).toMatchObject({ cols: 100, rows: 30 }) + second.controller.abort() + await second.iterator.return?.() + expect(terminal.info.controllerId).toBeUndefined() + }) + + it('keeps exit facts and screen until explicit cleanup and never starts a replacement process', async () => { + const { terminal, output, outcome, handle } = fixture() + const first = await attach(terminal) + output.end('done') + outcome.resolve({ exitCode: 7, signal: null }) + expect(await readFrame(first.iterator)).toMatchObject({ type: 'output', data: 'done' }) + expect(await readFrame(first.iterator)).toMatchObject({ type: 'state', info: { state: 'exited', exitCode: 7 } }) + expect(handle.terminate).not.toHaveBeenCalled() + const second = await attach(terminal, 'second') + expect(second.baseline).toMatchObject({ type: 'snapshot', info: { state: 'exited', exitCode: 7 } }) + await terminal.close() + expect(handle.terminate).toHaveBeenCalledOnce() + }) + + it('drains final output and exit state before closing followers', async () => { + const { terminal, handle, output, outcome } = fixture() + const first = await attach(terminal) + vi.mocked(handle.terminate).mockImplementationOnce(async () => { + output.end('FINAL OUTPUT\r\n') + outcome.resolve({ exitCode: 0, signal: null }) + }) + await terminal.close() + expect(await readFrame(first.iterator)).toMatchObject({ type: 'output', data: 'FINAL OUTPUT\r\n' }) + expect(await readFrame(first.iterator)).toMatchObject({ type: 'state', info: { state: 'exited' } }) + expect((await first.iterator.next()).done).toBe(true) + }) + + it('rejects input before attachment, during close and after process exit', async () => { + const { terminal, handle, output, outcome } = fixture() + await expect(terminal.write(attachment('first'), 'ignored')).rejects.toThrow('another attachment') + const first = await attach(terminal) + output.end() + outcome.resolve({ exitCode: 0, signal: null }) + expect(await readFrame(first.iterator)).toMatchObject({ type: 'state', info: { state: 'exited' } }) + await expect(terminal.write(attachment('first'), 'ignored')).rejects.toThrow('not running') + await expect(terminal.resize(attachment('first'), 100, 30)).rejects.toThrow('not running') + const closing = terminal.close() + await expect(terminal.write(attachment('first'), 'ignored')).rejects.toThrow('not running') + await closing + expect(handle.write).not.toHaveBeenCalled() + expect(handle.resize).not.toHaveBeenCalled() + }) + + it('preserves the input controller when an older follower detaches', async () => { + const { terminal, handle } = fixture() + const first = await attach(terminal) + const second = await attach(terminal, 'second') + first.controller.abort() + await first.iterator.return?.() + expect(terminal.info.controllerId).toBe(attachment('second')) + terminal.rename('build output') + expect(await readFrame(second.iterator)).toMatchObject({ type: 'state', info: { title: 'build output', controllerId: 'second' } }) + await terminal.write(attachment('second'), 'pwd\r') + expect(handle.write).toHaveBeenCalledWith('pwd\r') + }) + + it('does not grant input to attachments cancelled before their snapshot is ready', async () => { + const { terminal, handle } = fixture() + const aborted = new AbortController() + aborted.abort(new Error('already detached')) + await expect(terminal.follow(attachment('cancelled'), aborted.signal)[Symbol.asyncIterator]().next()).rejects.toThrow('already detached') + await attach(terminal) + const writing = Promise.withResolvers() + const written = Promise.withResolvers() + handle.write.mockImplementationOnce(async () => { writing.resolve(undefined); await written.promise }) + const pendingWrite = terminal.write(attachment('first'), 'pwd\r') + try { + await writing.promise + const abort = new AbortController() + const pendingAttachment = terminal.follow(attachment('late'), abort.signal)[Symbol.asyncIterator]().next() + const rejected = expect(pendingAttachment).rejects.toThrow('detached while waiting') + abort.abort(new Error('detached while waiting')) + written.resolve(undefined) + await pendingWrite + await rejected + expect(terminal.info.controllerId).toBe(attachment('first')) + } finally { written.resolve(undefined) } + }) + + it('continues accepting operations after a provider write or resize fails', async () => { + const { terminal, handle } = fixture() + await attach(terminal) + handle.write.mockRejectedValueOnce(new Error('input transport failed')) + handle.resize.mockRejectedValueOnce(new Error('resize transport failed')) + await expect(terminal.write(attachment('first'), 'failed')).rejects.toThrow('input transport failed') + await expect(terminal.resize(attachment('first'), 100, 30)).rejects.toThrow('resize transport failed') + expect(terminal.info).toMatchObject({ cols: 80, rows: 24 }) + await terminal.write(attachment('first'), 'accepted') + await terminal.resize(attachment('first'), 90, 25) + expect(handle.write).toHaveBeenLastCalledWith('accepted') + expect(terminal.info).toMatchObject({ cols: 90, rows: 25 }) + }) + + it.each([new Error('process wait failed'), 'remote process wait failed'])('publishes a failed process outcome and retains its recovery screen: %s', async (failure) => { + const { terminal, output, outcome } = fixture() + const first = await attach(terminal) + output.end('last output') + outcome.reject(failure) + expect(await readFrame(first.iterator)).toMatchObject({ type: 'output', data: 'last output' }) + expect(await readFrame(first.iterator)).toMatchObject({ type: 'state', info: { state: 'failed', error: failure instanceof Error ? failure.message : failure } }) + const second = await attach(terminal, 'second') + expect(second.baseline).toMatchObject({ type: 'snapshot', screen: 'last output', info: { state: 'failed' } }) + }) + + it('publishes output-stream failure even when the process wait succeeds', async () => { + const { terminal, output, outcome } = fixture() + const first = await attach(terminal) + output.destroy(new Error('output transport failed')) + outcome.resolve({ exitCode: 0, signal: null }) + expect(await readFrame(first.iterator)).toMatchObject({ type: 'state', info: { state: 'failed', error: 'output transport failed' } }) + }) + + it('flushes incomplete UTF-8 at EOF before publishing the process exit', async () => { + const { terminal, output, outcome } = fixture() + const first = await attach(terminal) + output.end(Buffer.from([0xe7, 0xbb])) + outcome.resolve({ exitCode: 0, signal: null }) + expect(await readFrame(first.iterator)).toMatchObject({ type: 'output', data: '�' }) + expect(await readFrame(first.iterator)).toMatchObject({ type: 'state', info: { state: 'exited' } }) + }) + + it('shares concurrent close attempts and permits retry after termination fails', async () => { + const { terminal, handle } = fixture() + await attach(terminal) + handle.terminate.mockRejectedValueOnce(new Error('process range remains alive')) + const first = terminal.close() + expect(terminal.close()).toBe(first) + await expect(first).rejects.toThrow('remains alive') + await terminal.write(attachment('first'), 'retry cleanup next') + await terminal.close() + expect(handle.terminate).toHaveBeenCalledTimes(2) + }) + + it('reports overflow rather than silently discarding output', async () => { + const follower = new TerminalFollower(64) + follower.push({ type: 'output', sequence: 1, data: 'x'.repeat(128) }) + await expect(follower.read(new AbortController().signal)[Symbol.asyncIterator]().next()).rejects.toThrow('buffer') + }) +}) + +async function readFrame(iterator: AsyncIterator): Promise { + const result = await iterator.next() + if (result.done === true) throw new Error('Terminal stream ended before its expected frame') + return result.value +} diff --git a/packages/api/terminal-controller/tsconfig.client.json b/packages/api/terminal-controller/tsconfig.client.json new file mode 100644 index 0000000000..4b78263b57 --- /dev/null +++ b/packages/api/terminal-controller/tsconfig.client.json @@ -0,0 +1,37 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo" + }, + "files": [ + "src/client/index.ts", + "src/client/model.ts", + "src/types.ts", + "src/client/close-requests.ts" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../gateway/tsconfig.client.json" + }, + { + "path": "../../client/store" + }, + { + "path": "../../core/session" + }, + { + "path": "../../util/brand" + }, + { + "path": "../../typert/protocol" + }, + { + "path": "../../util/crypto" + } + ] +} diff --git a/packages/api/terminal-controller/tsconfig.host.json b/packages/api/terminal-controller/tsconfig.host.json new file mode 100644 index 0000000000..132c120afa --- /dev/null +++ b/packages/api/terminal-controller/tsconfig.host.json @@ -0,0 +1,53 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo" + }, + "files": [ + "src/index.ts", + "src/types.ts", + "src/terminal.ts", + "src/stream.ts", + "src/shells.ts" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../core/session" + }, + { + "path": "../../fs/fs" + }, + { + "path": "../../sandbox/sandbox" + }, + { + "path": "../../sandbox/sandbox-policy" + }, + { + "path": "../../session/session-projection" + }, + { + "path": "../../subprocess/subprocess" + }, + { + "path": "../../typert/protocol" + }, + { + "path": "../../util/brand" + }, + { + "path": "../../util/deque" + } + ] +} diff --git a/packages/api/terminal-controller/tsconfig.json b/packages/api/terminal-controller/tsconfig.json new file mode 100644 index 0000000000..2eca820546 --- /dev/null +++ b/packages/api/terminal-controller/tsconfig.json @@ -0,0 +1,11 @@ +{ + "files": [], + "references": [ + { + "path": "./tsconfig.host.json" + }, + { + "path": "./tsconfig.client.json" + } + ] +} diff --git a/packages/api/terminal-controller/tsdown.config.ts b/packages/api/terminal-controller/tsdown.config.ts new file mode 100644 index 0000000000..cb9d252eb2 --- /dev/null +++ b/packages/api/terminal-controller/tsdown.config.ts @@ -0,0 +1,7 @@ +import { clientBundle } from '../../client/tsdown.client.ts' + +export default clientBundle( + '@deepseek-ai/dsh-api-terminal-controller', + ['lib/types/index.js'], + { hostPhase: true }, +) diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index cf4603031f..77664dd497 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -107,6 +107,8 @@ # Workspace file service: bounded read, directory listing, and the # agent-write change feed inside the session workspace root. + - id: terminal-controller + name: '@deepseek-ai/dsh-api-terminal-controller' - id: workspace-files name: '@deepseek-ai/dsh-api-workspace-files' @@ -231,6 +233,8 @@ name: '@deepseek-ai/dsh-client-ui-sidebar-documentpreview' # The right Sidebar's workspace file tree tab type. + - id: ui-sidebar-terminal + name: '@deepseek-ai/dsh-client-ui-sidebar-terminal' - id: ui-sidebar-files name: '@deepseek-ai/dsh-client-ui-sidebar-files' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 692a6be952..abf467259f 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -118,7 +118,9 @@ "@deepseek-ai/dsh-workspace": "workspace:^", "@deepseek-ai/schemastery": "workspace:^", "commander": "^15.0.0", - "open": "^11.0.0" + "open": "^11.0.0", + "@deepseek-ai/dsh-api-terminal-controller": "workspace:^", + "@deepseek-ai/dsh-client-ui-sidebar-terminal": "workspace:^" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index 7104855a97..996ef921db 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -10,7 +10,7 @@ */ import { readFile } from 'node:fs/promises' import { existsSync, globSync, readFileSync } from 'node:fs' -import { isBuiltin } from 'node:module' +import { createRequire, isBuiltin } from 'node:module' import { basename, dirname, isAbsolute, relative, resolve as resolvePath, sep } from 'node:path' import { fileURLToPath } from 'node:url' import type { UserConfig } from 'tsdown' @@ -616,6 +616,7 @@ const SOURCEMAP_COMMENT = /\n\/\/# sourceMappingURL=.*\s*$/ /** Resolve an emitted JS asset import against its source-tree counterpart. */ function sourceAssetPath(source: string, importer: string): string { + if (!source.startsWith('.') && !isAbsolute(source)) return createRequire(importer).resolve(source) const emitted = resolvePath(dirname(importer), source) if (existsSync(emitted)) return emitted const boundary = emitted.indexOf(TYPES_MARKER) diff --git a/packages/client/ui-sidebar-right/README.i18n.yaml b/packages/client/ui-sidebar-right/README.i18n.yaml index b8a821215f..0b4a9bd86c 100644 --- a/packages/client/ui-sidebar-right/README.i18n.yaml +++ b/packages/client/ui-sidebar-right/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-sidebar-right/README.md -README.md: 128f0e41a1b1a90f39f4db36f1a8ca8dfa3089f7 -README.zh.md: 66f2e8d6a07635d7e94ee50395c274951911e517 +README.md: 1a0a34119cc91f2e7c829e8b0c1dc56ad74d2631 +README.zh.md: 7d611eb4a4d4daa0c00baa8658b8b3e84288471e diff --git a/packages/client/ui-sidebar-right/README.md b/packages/client/ui-sidebar-right/README.md index 128f0e41a1..1a0a34119c 100644 --- a/packages/client/ui-sidebar-right/README.md +++ b/packages/client/ui-sidebar-right/README.md @@ -67,7 +67,7 @@ After every action the kit's settle planner keeps the surface populated: a docke The docked surface's last tab carries one more rule, decided in the store's `closeTab` and mirrored to the kit through `canCloseTab`: the guide standing as the only docked tab draws no close control and no menu close item — its chip sits quiet, and with no extension item contributed a secondary press opens no menu — and a programmatic close of it records nothing; any other tab standing alone closes together with the column in one entry — the settle planner reseeds the current default page for the next expansion. Floating panels take no part in the rule: they render whether or not the column is expanded, and their tabs close freely. -State is memory-only. A reload returns every session to the collapsed default; switching sessions keeps each surface where it was. +Layout, open tabs, navigation parameters and selection are memory-only. A reload constructs the collapsed default; content plugins can then reopen their own live content, as [terminal recovery](../ui-sidebar-terminal/README.md#use-this-package) does. Switching sessions keeps each surface where it was. ## Extension seats @@ -93,12 +93,14 @@ Two more seats extend what is already there: `sidebar.right.tab.guide` (chain) r The Tab domain retains navigation, an abort signal, and bound actions per (Session, tab id). A private assembly callback adopts each Session's store and reconciles records on its commits. Only record removal or plugin unload aborts the signal; closing the sidebar and switching Sessions retain records, while undo restores a new occurrence. `useTabInfo()` composes framework-bound store and navigation hooks without manual component subscriptions or render-time record creation. `tab.actions` always target their own Session; `tab.visible` distinguishes bodies from titles, and floating tabs remain visible when the sidebar closes. `adopt` is absent from the public controller. +Tab owners register `registerCloseHandler(kind, handler)` through an effect. A handler can save background cleanup and return synchronously, allowing immediate removal, or return a Promise that the sidebar awaits before explicit close or replacement. A thrown or rejected handler preserves the tab. Collapse, presentation changes and plugin disposal do not invoke close handlers; the tab abort signal identifies occurrence disposal, not an explicit close. + ## The guide Default pages depend on the number of registered guide entries, not the number of tab types or open tabs. Exactly one entry opens its page directly (Files in the shipped composition); zero or multiple entries open the guide. Explicitly adding a guide still opens the guide, even with one entry. The sole docked guide is the only tab that cannot close; closing any other sole tab also collapses the column. The chip, context menu, and `close` API apply the same rule. -The guide tab is one entry capsule (a glyph and a title, nothing else) per `guide` entry the registered types contributed, in `order`, centred in the body; the guide has no words of its own. Picking a capsule calls `tab.actions.openTab(entry.kind, { replaceTab: true })`, so the guide gives way to the page it opened. A pane holds at most one guide tab. The strip's add control is drawn only while its pane holds none and opens one there with `openTab('guide', { paneId, revealIfOpened: false })`, so a guide in another pane does not capture the click; opening the guide into a pane that already has one focuses it instead; a guide dragged, dropped, or docked into such a pane merges into it — the arriving guide closes and the pane's own is focused; `duplicateTab` on the guide records nothing. A split or an emptied root pane uses the same default-page rule, one tab per new pane. A plain `openTab('guide')` keeps the tree-wide reveal every open has. The product allows two horizontal panes, initially equal, with divider ratios limited to 20%–80%. Insufficient width blocks a new split; with two panes already present, a body drop moves the tab between panes instead of creating a third. At the two-pane limit, split controls are hidden; closing back to one pane restores them. +The guide tab is one entry capsule (a glyph and a title, nothing else) per `guide` entry the registered types contributed, in `order`, centred in the body; the guide has no words of its own. Picking a capsule replaces the guide with its page. An entry may set `revealIfOpened: false` to create another tab; omission keeps the normal reveal of an existing page. A pane holds at most one guide tab. The strip's add control is drawn only while its pane holds none and opens one there with `openTab('guide', { paneId, revealIfOpened: false })`, so a guide in another pane does not capture the click; opening the guide into a pane that already has one focuses it instead; a guide dragged, dropped, or docked into such a pane merges into it — the arriving guide closes and the pane's own is focused; `duplicateTab` on the guide records nothing. A split or an emptied root pane uses the same default-page rule, one tab per new pane. A plain `openTab('guide')` keeps the tree-wide reveal every open has. The product allows two horizontal panes, initially equal, with divider ratios limited to 20%–80%. Insufficient width blocks a new split; with two panes already present, a body drop moves the tab between panes instead of creating a third. At the two-pane limit, split controls are hidden; closing back to one pane restores them. ## Copy @@ -118,7 +120,7 @@ None; this package neither assembles nor sends a provider request. -- **Memory-only.** Nothing is persisted; a reload starts every session collapsed. +- **Memory-only layout.** Sidebar state is not persisted; content-specific recovery cannot reproduce the previous layout or selection. - **No surface without a session.** State is keyed by session id, so the hero screen shows nothing on the right. - **Hard-coded stacking.** The panel and the float host use fixed z-index values because the client has no z-index token layer yet. - **Undo is not exposed.** The recorded sequence is stepped only through the `@internal` service methods; product controls are deliberately absent. diff --git a/packages/client/ui-sidebar-right/README.zh.md b/packages/client/ui-sidebar-right/README.zh.md index 66f2e8d6a0..7d611eb4a4 100644 --- a/packages/client/ui-sidebar-right/README.zh.md +++ b/packages/client/ui-sidebar-right/README.zh.md @@ -67,7 +67,7 @@ kind: "package-reference" 停靠面的最后一个 tab 还多带一条规则,由 store 的 `closeTab` 决定并经 `canCloseTab` 镜像给套件:作为唯一停靠 tab 的引导页不画关闭控件也不画菜单里的关闭项——它的 chip 呈安静样式,在没有扩展条目时次键按下也不弹出菜单——对它的编程式关闭什么都不记录;任何其它 tab 独自留下时,点击关闭会连同整列一起收起,记为一条历史——settle planner 为下次展开重新播种当前默认页。浮动面板不参与这条规则:它们无论列是否展开都会渲染,其 tab 照常关闭。 -状态只在内存中。刷新会让每个会话回到折叠的默认态;切换会话则让每个停靠面留在原处。 +布局、打开的标签页、导航参数和选中项只在内存中。刷新会构造折叠的默认态;内容插件随后可重新打开自己仍存活的内容,例如[终端恢复](../ui-sidebar-terminal/README.zh.md#use-this-package)。切换会话则让每个停靠面留在原处。 ## 扩展席位 @@ -93,12 +93,14 @@ tab 类型分两阶段注册,随包发布的引导类型走的正是别的包 Tab域按(Session,Tab id)保留导航、中止信号与绑定动作;私有装配回调收养各会话的store,并在每次提交时对齐记录。记录消失或插件卸载才中止signal,收起和切会话不销毁记录;undo恢复的是新occurrence。`useTabInfo()` 组合框架绑定的store与导航hook,不在组件中手写订阅或在渲染时创建记录。`tab.actions` 始终作用于自己的会话;`tab.visible` 区分正文与标题,浮窗不受整栏收起影响。`adopt` 不在公开控制器上。 +标签页所有者通过 effect 注册 `registerCloseHandler(kind, handler)`。handler 可以保存后台清理任务并同步返回,让标签页立即移除;也可以返回 Promise,让侧栏在显式关闭或替换前等待。handler 抛错或拒绝时保留标签页。折叠、展示方式改变和插件卸载不调用关闭 handler;tab abort signal 标识 occurrence 卸载,不代表显式关闭。 + ## 引导页 默认页取决于已注册的引导入口数,不取决于 tab 类型数或已打开的 tab 数。恰好一个入口时直接打开对应页面(随包组合中为 Files);没有入口或有多个入口时打开引导页。即使只有一个入口,显式添加引导页仍会打开引导页。只有作为唯一停靠 tab 的引导页不可关闭;关闭其它任何唯一 tab 时会同时收起整列。chip、上下文菜单与 `close` API 使用同一规则。 -引导 tab 是各已注册类型贡献的每个 `guide` 条目一个入口胶囊(一个图标加一个标题,别无其他),按 `order` 排列并在正文中居中;引导页自己没有文字。选中一个胶囊会调用 `tab.actions.openTab(entry.kind, { replaceTab: true })`,于是引导页让位给它打开的页。一个格最多持有一个引导 tab。tab 条的添加控件只在该格没有引导 tab 时绘制,并以 `openTab('guide', { paneId, revealIfOpened: false })` 在该格打开一个,这样别的格里的引导页不会截走这次点击;把引导页开进已有引导页的格则改为聚焦它;把引导页拖入、放入或收回到这样的格会合并进去——来者关闭,该格自己的被聚焦;对引导页 `duplicateTab` 不记录任何东西。分栏或被清空的根格使用相同的默认页规则,每个新格一个 tab。普通的 `openTab('guide')` 保留每次打开都有的整树聚焦。产品最多保留左右两格,默认均分,分隔条限定20%~80%。宽度不足以容纳两格时不允许新分栏;已有两格时,正文拖放用于跨格移动,不再创建第三格。达到两格上限时隐藏分栏控件;关闭回单格后恢复。 +引导 tab 是各已注册类型贡献的每个 `guide` 条目一个入口胶囊(一个图标加一个标题,别无其他),按 `order` 排列并在正文中居中;引导页自己没有文字。选中一个胶囊会用对应页面替换引导页。条目可设置 `revealIfOpened: false` 来新建另一个 tab;省略时保留聚焦已有页面的默认行为。一个格最多持有一个引导 tab。tab 条的添加控件只在该格没有引导 tab 时绘制,并以 `openTab('guide', { paneId, revealIfOpened: false })` 在该格打开一个,这样别的格里的引导页不会截走这次点击;把引导页开进已有引导页的格则改为聚焦它;把引导页拖入、放入或收回到这样的格会合并进去——来者关闭,该格自己的被聚焦;对引导页 `duplicateTab` 不记录任何东西。分栏或被清空的根格使用相同的默认页规则,每个新格一个 tab。普通的 `openTab('guide')` 保留每次打开都有的整树聚焦。产品最多保留左右两格,默认均分,分隔条限定20%~80%。宽度不足以容纳两格时不允许新分栏;已有两格时,正文拖放用于跨格移动,不再创建第三格。达到两格上限时隐藏分栏控件;关闭回单格后恢复。 ## 文案 @@ -118,7 +120,7 @@ None; this package neither assembles nor sends a provider request. -- **只在内存中。** 不持久化任何东西;刷新让每个会话从折叠态开始。 +- **布局只在内存中。** 侧栏状态不持久化;内容自身的恢复无法还原此前的布局或选中项。 - **没有会话就没有停靠面。** 状态按会话 id 键控,因此 hero 画面右侧什么都不显示。 - **硬编码的层叠。** 面板与浮窗宿主使用固定的 z-index 值,因为客户端还没有 z-index token 层。 - **未暴露撤销。** 记录的序列只能通过 `@internal` 服务方法步进;产品控件是有意缺席的。 diff --git a/packages/client/ui-sidebar-right/src/client/contract/slots.ts b/packages/client/ui-sidebar-right/src/client/contract/slots.ts index f964ee97c6..87fe12883f 100644 --- a/packages/client/ui-sidebar-right/src/client/contract/slots.ts +++ b/packages/client/ui-sidebar-right/src/client/contract/slots.ts @@ -126,7 +126,7 @@ export interface SidebarRightTabActions { */ openTab(kind: K, options?: SidebarRightTabPlacement & { readonly params?: SidebarRightTabParamsFor }): void /** Close this tab. */ - close(): void + close(): void | Promise } /** Live information shared by a tab's body, title, and guide replacement. */ diff --git a/packages/client/ui-sidebar-right/src/client/index.ts b/packages/client/ui-sidebar-right/src/client/index.ts index 6e2df4fe40..556296f186 100644 --- a/packages/client/ui-sidebar-right/src/client/index.ts +++ b/packages/client/ui-sidebar-right/src/client/index.ts @@ -49,7 +49,7 @@ export type { ExpandButtonProps } from './shell/ExpandButton.tsx' export type { SidebarRightState, SurfaceState } from './stores.ts' export type { ISidebarRight, SidebarRightBinding, SidebarRightOpenResourceOptions, SidebarRightOpenTabOptions, - SidebarRightPlacement, SurfaceActions, + SidebarRightPlacement, SidebarRightCloseHandler, SurfaceActions, } from './service.ts' export type { SidebarRightGuideBox, SidebarRightGuideEntry, SidebarRightTabClaim, SidebarRightTabDefinition, @@ -135,7 +135,7 @@ export function apply(ctx: ClientContext): void { }, } const layout: ILayout = ctx.layout - const injected: Omit = { + const injected: Omit = { syncPresentation({ shown, track, fullscreen }) { if (shown) layout.openRightbar(track, fullscreen) else layout.closeRightbar() @@ -162,6 +162,10 @@ export function apply(ctx: ClientContext): void { store, inject: (sessionId): SidebarRightInjected => ({ ...injected, + closeTab: (tabId) => { + const pending = controller.closeIn(sessionId, tabId) + if (pending !== undefined) void pending.catch((error: unknown) => { console.error('Sidebar tab close failed:', error) }) + }, keyedHooks: { tabNavigation: key => controller.tabDomain.occurrence(sessionId, { id: key as TabId }).navigation }, occurrence: tab => controller.tabDomain.occurrence(sessionId, tab), }), diff --git a/packages/client/ui-sidebar-right/src/client/service.ts b/packages/client/ui-sidebar-right/src/client/service.ts index 81e003b06a..f68b2fe1f9 100644 --- a/packages/client/ui-sidebar-right/src/client/service.ts +++ b/packages/client/ui-sidebar-right/src/client/service.ts @@ -28,13 +28,13 @@ * callers use the service's navigation methods. */ import type { FloatRect, PaneId, TabId, TabRecord } from '@deepseek-ai/dsh-client-ui-dockkit' -import { activeDockPaneId, canSplit, dockPaneIds, findTabPane, getPane } from '@deepseek-ai/dsh-client-ui-dockkit' +import { activeDockPaneId, canSplit, findContentTab, dockPaneIds, findTabPane, getPane } from '@deepseek-ai/dsh-client-ui-dockkit' import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { SidebarRightNavigationParams, SidebarRightResourceParams, SidebarRightTabParamsFor } from './contract/params.ts' import { pageAddress } from './contract/seed.ts' import type { SidebarRightTabClaim, SidebarRightTabRegistry } from './tab-registry.ts' -import type { SidebarRightState, SurfaceState } from './stores.ts' +import { canCloseTab, type SidebarRightState, type SurfaceState } from './stores.ts' import type { createSidebarRightStore } from './stores.ts' import { TabDomain, type PinResource } from './tab-domain.ts' @@ -134,6 +134,9 @@ export interface SidebarRightOpenTabOptions extends S /** The scheme every resource address carries; anything else is not a resource this face opens. */ const RESOURCE_SCHEME = 'dsh-resource://' +/** Explicit close/replacement hook. Return void for immediate removal, or a promise to defer removal until cleanup succeeds. */ +export type SidebarRightCloseHandler = (sessionId: SessionId, tab: TabRecord) => void | Promise + /** The outward right-Sidebar face (`ctx.sidebarRight`). */ export interface ISidebarRight { /** @@ -160,7 +163,7 @@ export interface ISidebarRight { * Close one tab of the mounted session; the sole docked guide remains open. * @param tabId - the tab to close. */ - close(tabId: TabId): void + close(tabId: TabId): void | Promise /** * The active tab of the active pane. * @returns the record, or `undefined` when no seat is mounted. @@ -203,6 +206,20 @@ export interface ISidebarRight { /** Cross-plugin right-Sidebar face (ctx.sidebarRight). */ export class SidebarRightController implements ISidebarRight { private binding: SidebarRightBinding | undefined + private readonly closeHandlers = new Map() + private readonly closing = new Map>() + + /** + * Register resource cleanup before explicit removal. Failure preserves the tab. + * @param kind - tab kind owned by the registering plugin. + * @param handler - saves background cleanup synchronously, or returns cleanup to await before removal. + * @returns an effect-scoped unregister callback. + */ + registerCloseHandler(kind: string, handler: SidebarRightCloseHandler): () => void { + if (this.closeHandlers.has(kind)) throw new Error(`sidebarRight: close handler already registered for ${kind}`) + this.closeHandlers.set(kind, handler) + return () => { if (this.closeHandlers.get(kind) === handler) this.closeHandlers.delete(kind) } + } /** * The Tab domain this controller navigates into; synced from each adopted @@ -291,10 +308,33 @@ export class SidebarRightController implements ISidebarRight { * Not part of `ISidebarRight`: the Tab domain's path. * @param sessionId - the session the tab is in. * @param tabId - the tab to close. + * @returns resource cleanup completion when the tab type owns an asynchronous close handler. */ - closeIn(sessionId: SessionId, tabId: TabId): void { + closeIn(sessionId: SessionId, tabId: TabId): void | Promise { const actions = this.actionsFor(sessionId) - if (actions !== undefined) actions.closeTab(sessionId, tabId) + const surface = this.adopted.get(sessionId)?.store.getSnapshot().bySession[sessionId] + if (actions === undefined || surface === undefined) return + const tab = surface.layout.tabs[tabId] + if (tab === undefined || !canCloseTab(surface, tabId)) return + return this.removeAfterCleanup(sessionId, tab, () => { actions.closeTab(sessionId, tabId) }) + } + + private removeAfterCleanup(sessionId: SessionId, tab: TabRecord, commit: () => void): void | Promise { + const handler = this.closeHandlers.get(tab.kind) + if (handler === undefined) { commit(); return } + const key = `${sessionId}:${tab.id}` + const existing = this.closing.get(key) + if (existing !== undefined) return existing + const adoption = this.adopted.get(sessionId) + const occurrence = this.tabDomain.occurrence(sessionId, tab) + const completion = handler(sessionId, tab) + if (completion === undefined) { commit(); return } + const pending = completion.then(() => { + if (this.adopted.get(sessionId) !== adoption || occurrence.signal.aborted) return + commit() + }).finally(() => { this.closing.delete(key) }) + this.closing.set(key, pending) + return pending } /** Claim a resource and place it in one session; an address outside the scheme or one no type claims throws. */ @@ -332,22 +372,31 @@ export class SidebarRightController implements ISidebarRight { placement: SidebarRightPlacement, params: SidebarRightNavigationParams, ): void { - actions.openContent(sessionId, { + const commit = (): void => { actions.openContent(sessionId, { kind: claim.kind, contentId: claim.contentId, title: claim.title, ...placement.paneId === undefined ? {} : { paneId: placement.paneId }, ...placement.replaceTab === undefined ? {} : { replaceTab: placement.replaceTab }, ...placement.revealIfOpened === undefined ? {} : { revealIfOpened: placement.revealIfOpened }, - }, (tabId) => { this.tabDomain.navigate(sessionId, tabId, { address, params }) }) + }, (tabId) => { this.tabDomain.navigate(sessionId, tabId, { address, params }) }) } + const layout = this.adopted.get(sessionId)?.store.getSnapshot().bySession[sessionId]?.layout + const replaced = placement.replaceTab === undefined ? undefined : layout?.tabs[placement.replaceTab] + const revealed = layout === undefined || placement.revealIfOpened === false + ? undefined : findContentTab(layout, claim.contentId, claim.kind) + if (replaced === undefined || replaced.id === revealed) { commit(); return } + const pending = this.removeAfterCleanup(sessionId, replaced, commit) + if (pending !== undefined) void pending.catch((error: unknown) => { console.error('Sidebar tab replacement failed:', error) }) } /** * Close one tab of the mounted session; the sole docked guide remains open. * @param tabId - the tab to close. + * @returns resource cleanup completion when the tab type owns an asynchronous close handler. */ - close(tabId: TabId): void { + close(tabId: TabId): void | Promise { const { sessionId, actions } = this.require() + if (this.adopted.has(sessionId)) return this.closeIn(sessionId, tabId) actions.closeTab(sessionId, tabId) } diff --git a/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx b/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx index 355eedc190..476134c47d 100644 --- a/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx +++ b/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx @@ -97,6 +97,8 @@ export interface SidebarRightInjected { * the guide opened by kind, through the same path as every other open. */ readonly openTab: (kind: string, options?: SidebarRightOpenTabOptions) => void + /** Close through the resource owner's cleanup handler. */ + readonly closeTab: (tabId: TabId) => void readonly hooks: { readonly tabTypes: HostObservable } @@ -123,6 +125,7 @@ interface PanelProps { readonly t: RightbarSeatProps['t'] readonly renderSlot: Children['renderSlot'] readonly openTab: SidebarRightInjected['openTab'] + readonly closeTab: SidebarRightInjected['closeTab'] readonly useTabTypes: RightbarSeatProps['useTabTypes'] readonly useTabNavigation: RightbarSeatProps['useTabNavigation'] readonly useStore: Store['useStore'] @@ -145,7 +148,7 @@ function guideIn(layout: LayoutState, paneId: PaneId): TabId | undefined { * @param openTab - the navigation face's `openTab`, which the strip's add control asks for a guide through. * @returns the intents the kit reports gestures to. */ -export function intentsFor(sessionId: SessionId, actions: Store['actions'], openTab: PanelProps['openTab']): DockIntents { +export function intentsFor(sessionId: SessionId, actions: Store['actions'], openTab: PanelProps['openTab'], closeTab?: PanelProps['closeTab']): DockIntents { return { focusTab: (tabId) => { actions.focusTab(sessionId, tabId) }, focusPane: (paneId) => { actions.focusPane(sessionId, paneId) }, @@ -155,7 +158,7 @@ export function intentsFor(sessionId: SessionId, actions: Store['actions'], open // guides in other panes; the store settles the open on a guide the pane // already holds, so the ask is idempotent all the same. addTab: (paneId) => { openTab(GUIDE_KIND, { paneId, revealIfOpened: false }) }, - closeTab: (tabId) => { actions.closeTab(sessionId, tabId) }, + closeTab: closeTab ?? ((tabId) => { actions.closeTab(sessionId, tabId) }), duplicateTab: (tabId) => { actions.duplicateTab(sessionId, tabId) }, floatTab: (tabId, rect?: FloatRect) => { actions.floatTab(sessionId, tabId, rect) }, unfloatPane: (paneId) => { actions.unfloatPane(sessionId, paneId) }, @@ -311,7 +314,7 @@ function SidebarPanel(panel: PanelProps & { width: number; panelRef: RefObject guideIn(surface.layout, paneId) === undefined} canCloseTab={tabId => canCloseTab(surface, tabId)} - intents={intentsFor(sessionId, actions, openTab)} + intents={intentsFor(sessionId, actions, openTab, panel.closeTab)} labels={dockLabels(t)} renderTab={bodiesFor(panel)} renderTabTitle={titlesFor(panel)} @@ -334,7 +337,7 @@ function Floats(panel: PanelProps): ReactNode { canCloseTab(surface, tabId)} - intents={intentsFor(sessionId, actions, openTab)} + intents={intentsFor(sessionId, actions, openTab, panel.closeTab)} labels={dockLabels(t)} renderTab={bodiesFor(panel)} renderTabTitle={titlesFor(panel)} @@ -351,7 +354,7 @@ function Floats(panel: PanelProps): ReactNode { * which session it is acting on, because this is the seat that knows both. */ export function RightbarSeat({ - sessionId, width, viewportWidth, canShow, useStore, actions, t, renderSlot, syncPresentation, bindService, openTab, + sessionId, width, viewportWidth, canShow, useStore, actions, t, renderSlot, syncPresentation, bindService, openTab, closeTab, useTabTypes, useTabNavigation, occurrence, }: RightbarSeatProps): ReactNode { // One store instance per session, so this map holds this session's surface. @@ -416,7 +419,7 @@ export function RightbarSeat({ if (surface === undefined) return null const panel: PanelProps = { - sessionId, actions, t, renderSlot, surface, openTab, useTabTypes, useTabNavigation, useStore, occurrence, + sessionId, actions, t, renderSlot, surface, openTab, closeTab, useTabTypes, useTabNavigation, useStore, occurrence, fullscreen, autoFullscreen, reportRoom, } return ( diff --git a/packages/client/ui-sidebar-right/src/client/tab-domain.ts b/packages/client/ui-sidebar-right/src/client/tab-domain.ts index 53b259584e..533cf866c4 100644 --- a/packages/client/ui-sidebar-right/src/client/tab-domain.ts +++ b/packages/client/ui-sidebar-right/src/client/tab-domain.ts @@ -34,7 +34,7 @@ export interface SidebarRightNavigator { /** Open a page type in one session; see `ISidebarRight.openTab`. */ openTabIn(sessionId: SessionId, kind: string, options?: SidebarRightOpenTabOptions): void /** Close a tab of one session. */ - closeIn(sessionId: SessionId, tabId: TabId): void + closeIn(sessionId: SessionId, tabId: TabId): void | Promise } /** `ctx.resources.pin`: hold an address's content open for as long as `signal` lives. */ @@ -175,7 +175,7 @@ export class TabDomain { openTab: (kind, options = {}) => { navigator.openTabIn(sessionId, kind, { ...place(options), params: options.params }) }, - close: () => { navigator.closeIn(sessionId, tabId) }, + close: () => navigator.closeIn(sessionId, tabId), }, } this.session(sessionId).set(tabId, held) diff --git a/packages/client/ui-sidebar-right/src/client/tab-registry.ts b/packages/client/ui-sidebar-right/src/client/tab-registry.ts index 47c737b98f..5284716d5f 100644 --- a/packages/client/ui-sidebar-right/src/client/tab-registry.ts +++ b/packages/client/ui-sidebar-right/src/client/tab-registry.ts @@ -68,6 +68,8 @@ export interface SidebarRightGuideEntry { readonly title: () => string /** Optional glyph, drawn before the title. */ readonly icon?: ComponentType + /** Defaults to true; false makes this entry create another tab instead of focusing an existing one. */ + readonly revealIfOpened?: boolean } /** A guide entry as the registry lists it: with the kind of the type that contributed it, which is what picking it opens. */ diff --git a/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.tsx b/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.tsx index 5a9851739a..b3d07685dd 100644 --- a/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.tsx +++ b/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.tsx @@ -67,7 +67,10 @@ export function GuideBody({ useTabInfo, useGuideEntries, renderSlotChain }: Guid const options = { hookContext: useTabInfo, fallback: ( - { tab.actions.openTab(entry.kind, { replaceTab: true }) }} /> + { tab.actions.openTab(entry.kind, { + replaceTab: true, + ...entry.revealIfOpened === undefined ? {} : { revealIfOpened: entry.revealIfOpened }, + }) }} /> ), } satisfies ChainRenderOpts & { hookContext: HookContextOf<'sidebar.right.tab.guide'> } return renderSlotChain('sidebar.right.tab.guide', {}, options) diff --git a/packages/client/ui-sidebar-right/tests/apply.client.spec.ts b/packages/client/ui-sidebar-right/tests/apply.client.spec.ts index 543a18f666..6dbce4eea3 100644 --- a/packages/client/ui-sidebar-right/tests/apply.client.spec.ts +++ b/packages/client/ui-sidebar-right/tests/apply.client.spec.ts @@ -163,7 +163,7 @@ describe('ui-sidebar-right apply', () => { // Held and pinned from the store's own commit: no seat synced anything. const occurrence = ctx.sidebarRight.tabDomain.occurrence(SESSION, guide) expect(resources.pin).toHaveBeenCalledWith('sidebar://guide', occurrence.signal) - occurrence.tabActions.close() + void occurrence.tabActions.close() expect(instance.getSnapshot().bySession[SESSION]?.layout.tabs[guide.id]).toBeUndefined() expect(occurrence.signal.aborted).toBe(true) }) @@ -199,7 +199,7 @@ describe('ui-sidebar-right apply', () => { await fiber.dispose() expect(signal.aborted).toBe(true) // The adoption went with the plugin: a late action from the dead occurrence changes nothing. - tabActions.close() + void tabActions.close() expect(instance.getSnapshot().bySession[SESSION]?.layout.tabs[guide.id]).toBeDefined() expect(ctx.get('sidebarRight')).toBeUndefined() expect(ctx.get('sidebarRightTabs')).toBeUndefined() diff --git a/packages/client/ui-sidebar-right/tests/guide-body.client.spec.tsx b/packages/client/ui-sidebar-right/tests/guide-body.client.spec.tsx index 09955a10b5..3f88ad5e6a 100644 --- a/packages/client/ui-sidebar-right/tests/guide-body.client.spec.tsx +++ b/packages/client/ui-sidebar-right/tests/guide-body.client.spec.tsx @@ -73,7 +73,7 @@ describe('GuideBody', () => { cleanup() }) - it('picking a box opens that type in the guide\'s own place', () => { + it('picking a box without a reveal preference preserves the default opening behavior', () => { const { view, openTab } = mountGuide([box('files', 10)]) const entry = view.container.querySelector('[data-sidebar-right-guide-entry="files"]') if (entry === null) throw new Error('expected the files box') @@ -82,6 +82,12 @@ describe('GuideBody', () => { cleanup() }) + it.each([false, true])('passes an entry\'s explicit revealIfOpened=%s preference when replacing the guide', (revealIfOpened) => { + const { view, openTab } = mountGuide([{ ...box('terminal', 20), revealIfOpened }]) + fireEvent.click(view.getByRole('button', { name: 'terminal title' })) + expect(openTab).toHaveBeenCalledExactlyOnceWith('terminal', { replaceTab: true, revealIfOpened }) + }) + it('draws an empty guide while no type contributed an entry, and follows the registry when one does', () => { const { view, guideEntries, boxes } = mountGuide([]) expect(view.container.querySelector('[data-sidebar-right-guide]')).not.toBeNull() diff --git a/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx b/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx index 22c8527b8b..2ad4b7adbe 100644 --- a/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx +++ b/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx @@ -138,7 +138,7 @@ describe('RightbarSeat presentation', () => { if (entryCount !== 1) { expect(h.view.container.querySelectorAll('[data-dockkit-tab-close]')).toHaveLength(0) const before = h.layout() - act(() => { h.controller.close(initial.id) }) + act(() => { void h.controller.close(initial.id) }) expect(h.layout()).toBe(before) fireEvent.contextMenu(element(h.view.container, '[data-dockkit-tab]')) expect(document.querySelector('[data-dockkit-tab-menu] [role^="menuitem"]')).toBeNull() @@ -146,7 +146,7 @@ describe('RightbarSeat presentation', () => { return } expect(h.view.container.querySelector(`[data-dockkit-tab-close="${initial.id}"]`)).not.toBeNull() - act(() => { h.controller.close(initial.id) }) + act(() => { void h.controller.close(initial.id) }) expect(h.layout().expanded).toBe(false) const reseeded = Object.values(h.layout().tabs)[0]! expect(reseeded.kind).toBe('text') @@ -157,12 +157,12 @@ describe('RightbarSeat presentation', () => { expect(h.view.container.querySelector('[data-dockkit-add-tab]')).toBeNull() expect(h.view.container.querySelector(`[data-dockkit-tab-close="${reseeded.id}"]`)).not.toBeNull() expect(h.view.container.querySelector(`[data-dockkit-tab-close="${guide.id}"]`)).not.toBeNull() - act(() => { h.controller.close(reseeded.id) }) + act(() => { void h.controller.close(reseeded.id) }) expect(h.layout().tabs[reseeded.id]).toBeUndefined() expect(h.view.container.querySelector(`[data-dockkit-tab-close="${guide.id}"]`)).toBeNull() const preview = h.open('ordinary.txt') expect(h.view.container.querySelector(`[data-dockkit-tab-close="${preview.id}"]`)).not.toBeNull() - act(() => { h.controller.close(preview.id) }) + act(() => { void h.controller.close(preview.id) }) expect(h.layout().tabs[preview.id]).toBeUndefined() expect(Object.keys(h.layout().tabs)).toEqual([guide.id]) expect(h.view.container.querySelectorAll('[data-dockkit-tab-close]')).toHaveLength(0) @@ -437,7 +437,7 @@ describe('slot-owned useTabInfo', () => { expect(Object.values(h.layout().tabs).map(tab => tab.title)).toContain('b.txt') expect(h.controller.active()?.contentId).toBe(otherTab.contentId) expect(h.bodies.get(otherTab.id)?.tab.navigation.params).toEqual({ line: 9 }) - act(() => { info.tab.actions.close() }) + act(() => { void info.tab.actions.close() }) expect(info.tab.signal.aborted).toBe(true) expect(otherInfo.tab.signal.aborted).toBe(false) await h.runtime.sessions.setCurrent(SESSION) @@ -579,3 +579,22 @@ describe('intentsFor — the kit\'s gestures as one session\'s store actions', ( expect(openTab).toHaveBeenCalledWith('guide', { paneId: PANE_1, revealIfOpened: false }) }) }) + +it('keeps a resource tab and reports cleanup rejection from its close button', async () => { + const h = await mountSeat() + const tab = h.open('terminal') + const failure = new Error('process still running') + const release = h.controller.registerCloseHandler('text', async () => { throw failure }) + const logged = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + fireEvent.click(element(h.view.container, `[data-dockkit-tab-close="${tab.id}"]`)) + await expect.poll(() => logged.mock.calls).toEqual([['Sidebar tab close failed:', failure]]) + expect(h.layout().tabs[tab.id]).toBeDefined() + release() + fireEvent.click(element(h.view.container, `[data-dockkit-tab-close="${tab.id}"]`)) + expect(h.layout().tabs[tab.id]).toBeUndefined() + } finally { + logged.mockRestore() + release() + } +}) diff --git a/packages/client/ui-sidebar-right/tests/service.client.spec.ts b/packages/client/ui-sidebar-right/tests/service.client.spec.ts index d5f1ec099d..6827aa5824 100644 --- a/packages/client/ui-sidebar-right/tests/service.client.spec.ts +++ b/packages/client/ui-sidebar-right/tests/service.client.spec.ts @@ -82,7 +82,7 @@ describe('SidebarRightController — opening', () => { const { controller } = harness() expect(() => { controller.openResource('dsh-resource://file/session/s-test/a.txt') }).toThrow('no session surface is mounted') expect(() => { controller.openTab('guide') }).toThrow('no session surface is mounted') - expect(() => { controller.close('tab1' as TabId) }).toThrow('no session surface is mounted') + expect(() => { void controller.close('tab1' as TabId) }).toThrow('no session surface is mounted') expect(() => { controller.toggleExpanded() }).toThrow('no session surface is mounted') expect(() => { controller.focus('tab1' as TabId) }).toThrow('no session surface is mounted') expect(() => { controller.split() }).toThrow('no session surface is mounted') @@ -283,7 +283,7 @@ describe('SidebarRightController — opening', () => { publish() controller.openResource('dsh-resource://file/session/s-test/a.txt') publish() - controller.close(tabOf('a.txt')) + void controller.close(tabOf('a.txt')) expect(titles()).not.toContain('a.txt') }) @@ -459,7 +459,7 @@ describe('SidebarRightController — a tab\'s own actions', () => { // session's closes this session's, aborts its occurrence at once although // no seat draws the session, and the other keeps its own. expect(otherSurface.layout.tabs[guide.id]).toBeDefined() - guideOccurrence.tabActions.close() + void guideOccurrence.tabActions.close() expect(layout().tabs[guide.id]).toBeUndefined() expect(guideOccurrence.signal.aborted).toBe(true) expect(other.getSnapshot().bySession[OTHER]).toBe(otherSurface) @@ -479,14 +479,14 @@ describe('SidebarRightController — a tab\'s own actions', () => { const before = instance.getSnapshot().bySession tabActions.openResource(B_TXT) tabActions.openTab('guide') - tabActions.close() + void tabActions.close() expect(instance.getSnapshot().bySession).toBe(before) // Adopted, they land; released, they stop again. const release = adopt(SESSION, instance) tabActions.openResource(B_TXT) expect(titles()).toContain('b.txt') release() - tabActions.close() + void tabActions.close() expect(titles()).toContain('a.txt') }) @@ -578,3 +578,152 @@ describe('SidebarRightController — binding lifetime', () => { expect(() => { controller.toggleExpanded() }).not.toThrow() }) }) + +describe('explicit tab resource cleanup', () => { + it.each(['close', 'replace'] as const)('immediately removes the tab after a synchronous cleanup handler during %s', (operation) => { + const h = harness() + const release = h.adopt(SESSION, h.instance) + const unbind = h.publish() + h.controller.openResource('dsh-resource://file/session/s-test/terminal') + const tabId = h.tabOf('terminal') + const original = h.layout().tabs[tabId] + const handler = vi.fn(() => { expect(h.layout().tabs[tabId]).toBe(original) }) + const unregister = h.controller.registerCloseHandler('text', handler) + try { + const before = h.entries() + if (operation === 'close') expect(h.controller.close(tabId)).toBeUndefined() + else h.controller.openTab('guide', { replaceTab: tabId, revealIfOpened: false }) + expect(handler).toHaveBeenCalledExactlyOnceWith(SESSION, original) + expect(h.layout().tabs[tabId]).toBeUndefined() + expect(h.entries()).toBe(before + 1) + expect(h.controller.closeIn(SESSION, tabId)).toBeUndefined() + expect(handler).toHaveBeenCalledOnce() + } finally { + unregister() + unbind() + release() + } + }) + + it('awaits cleanup once before closing and never closes on collapse', async () => { + const h = harness() + h.adopt(SESSION, h.instance) + h.publish() + h.controller.openResource('dsh-resource://file/session/s-test/terminal') + h.publish() + const tabId = h.tabOf('terminal') + const cleanup = Promise.withResolvers() + const handler = vi.fn(() => cleanup.promise) + h.controller.registerCloseHandler('text', handler) + h.controller.toggleExpanded() + expect(handler).not.toHaveBeenCalled() + const first = h.controller.closeIn(SESSION, tabId) + const second = h.controller.closeIn(SESSION, tabId) + expect(first).toBe(second) + expect(handler).toHaveBeenCalledOnce() + expect(h.layout().tabs[tabId]).toBeDefined() + cleanup.resolve(undefined) + await first + expect(h.layout().tabs[tabId]).toBeUndefined() + }) + + it('keeps the tab after failed cleanup and permits retry', async () => { + const h = harness() + h.adopt(SESSION, h.instance) + h.publish() + h.controller.openResource('dsh-resource://file/session/s-test/terminal') + h.publish() + const tabId = h.tabOf('terminal') + const handler = vi.fn().mockRejectedValueOnce(new Error('still running')).mockResolvedValue(undefined) + h.controller.registerCloseHandler('text', handler) + await expect(h.controller.closeIn(SESSION, tabId)).rejects.toThrow('still running') + expect(h.layout().tabs[tabId]).toBeDefined() + await h.controller.closeIn(SESSION, tabId) + expect(h.layout().tabs[tabId]).toBeUndefined() + }) + + it('awaits cleanup before replacing in a single history entry, and skips cleanup when revealing the same tab', async () => { + const h = harness() + h.adopt(SESSION, h.instance) + h.publish() + const address = 'dsh-resource://file/session/s-test/terminal' + h.controller.openResource(address) + h.publish() + const tabId = h.tabOf('terminal') + const cleanup = Promise.withResolvers() + const handler = vi.fn(() => cleanup.promise) + h.controller.registerCloseHandler('text', handler) + h.controller.openResource(address, { replaceTab: tabId }) + expect(handler).not.toHaveBeenCalled() + const before = h.entries() + h.controller.openTab('guide', { replaceTab: tabId }) + expect(h.entries()).toBe(before) + expect(h.layout().tabs[tabId]).toBeDefined() + cleanup.resolve(undefined) + await expect.poll(() => h.layout().tabs[tabId]).toBeUndefined() + expect(h.entries()).toBe(before + 1) + }) +}) + +it('releases close handlers without a stale disposer removing a replacement', async () => { + const h = harness() + h.adopt(SESSION, h.instance) + h.publish() + const first = vi.fn(async () => {}) + const second = vi.fn(async () => {}) + const release = h.controller.registerCloseHandler('text', first) + expect(() => h.controller.registerCloseHandler('text', second)).toThrow('already registered') + release() + const releaseSecond = h.controller.registerCloseHandler('text', second) + release() + h.controller.openResource('dsh-resource://file/session/s-test/one') + h.publish() + await h.controller.closeIn(SESSION, h.tabOf('one')) + expect(first).not.toHaveBeenCalled() + expect(second).toHaveBeenCalledOnce() + releaseSecond() + h.controller.openResource('dsh-resource://file/session/s-test/two') + h.publish() + const tabId = h.tabOf('two') + h.controller.openTab('guide', { replaceTab: tabId, revealIfOpened: false }) + expect(h.layout().tabs[tabId]).toBeUndefined() + expect(second).toHaveBeenCalledOnce() +}) + +it.each(['adoption', 'occurrence'] as const)('does not commit a pending close after its %s ends', async (lifetime) => { + const h = harness() + const release = h.adopt(SESSION, h.instance) + h.publish() + h.controller.openResource('dsh-resource://file/session/s-test/terminal') + h.publish() + const tabId = h.tabOf('terminal') + const cleanup = Promise.withResolvers() + h.controller.registerCloseHandler('text', () => cleanup.promise) + const pending = h.controller.closeIn(SESSION, tabId) + if (lifetime === 'adoption') release() + else h.instance.actions.closeTab(SESSION, tabId) + const entries = h.entries() + cleanup.resolve(undefined) + await pending + expect(h.entries()).toBe(entries) + if (lifetime === 'adoption') expect(h.layout().tabs[tabId]).toBeDefined() +}) + +it('reports failed replacement cleanup and retains the original resource', async () => { + const h = harness() + h.adopt(SESSION, h.instance) + h.publish() + h.controller.openResource('dsh-resource://file/session/s-test/terminal') + h.publish() + const tabId = h.tabOf('terminal') + const error = new Error('process cleanup failed') + h.controller.registerCloseHandler('text', async () => { throw error }) + const reported = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + h.controller.openTab('guide', { replaceTab: tabId, revealIfOpened: false }) + await expect.poll(() => reported.mock.calls).toEqual([['Sidebar tab replacement failed:', error]]) + expect(h.layout().tabs[tabId]).toBeDefined() + } finally { + reported.mockRestore() + } +}) diff --git a/packages/client/ui-sidebar-right/tests/tab-domain.client.spec.ts b/packages/client/ui-sidebar-right/tests/tab-domain.client.spec.ts index 793bda8083..c51e9ca64e 100644 --- a/packages/client/ui-sidebar-right/tests/tab-domain.client.spec.ts +++ b/packages/client/ui-sidebar-right/tests/tab-domain.client.spec.ts @@ -191,7 +191,7 @@ describe('TabDomain — a tab\'s own actions', () => { const { tabActions } = domain.occurrence(SESSION, recordOf(current(), tabId)) tabActions.openTab('files', { replaceTab: true }) expect(navigator.openTabIn).toHaveBeenLastCalledWith(SESSION, 'files', { replaceTab: tabId }) - tabActions.close() + void tabActions.close() expect(navigator.closeIn).toHaveBeenCalledWith(SESSION, tabId) }) diff --git a/packages/client/ui-sidebar-terminal/README.i18n.yaml b/packages/client/ui-sidebar-terminal/README.i18n.yaml new file mode 100644 index 0000000000..cae62f6540 --- /dev/null +++ b/packages/client/ui-sidebar-terminal/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/client/ui-sidebar-terminal/README.md +README.md: cf915dc73c9009b4b91d084d9b7aee466e4f2c75 +README.zh.md: a7aa321d106d49061ae7b092f6e116cab153627c diff --git a/packages/client/ui-sidebar-terminal/README.md b/packages/client/ui-sidebar-terminal/README.md new file mode 100644 index 0000000000..cf915dc73c --- /dev/null +++ b/packages/client/ui-sidebar-terminal/README.md @@ -0,0 +1,80 @@ +--- +description: "Open, recover and control interactive shell tabs in the Web right sidebar." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-ui-sidebar-terminal + +English | [中文](README.zh.md) + +## Summary + +Open the execution environment's default shell beside a conversation without leaving the Web app. Rename terminals in their tabs and recover retained processes after reloading the page. Collapse the sidebar to keep commands running; close a terminal tab to request process termination. Tab completion follows the shell configuration. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +Open the right sidebar and select **New terminal** on the Start page; first select **New tab** when another content tab is open. The default shell starts immediately. Use the sidebar's **New tab** control to open more terminals. + +Double-click the terminal's tab title to rename it. **Take control** makes the current attachment writable when another page owns input. A failed connection offers **Reconnect**. An exited shell remains visible with its exit code and never restarts automatically. + +Closing or replacing a terminal tab removes it immediately and ends its process in the background. A cleanup failure shows a small notification with **Retry**; retrying does not reopen the tab. Collapsing, switching tabs or Sessions, floating and fullscreen presentation preserve the process. + +Displaying a Session after a page reload reopens its retained Host terminals as new tabs. A recovery failure offers **Retry terminal recovery**. A recovered process that disappears reports an error instead of starting another shell. The [sidebar layout remains memory-only](../../client/ui-sidebar-right/README.md#state). + + +## Understand the implementation + +
+Implementation internals — click to expand + +This plugin registers the `terminal` type and body/title seats with the right sidebar. The React-free terminal model belongs to `api-terminal-controller`; keyed framework hooks expose its state. xterm.js and FitAddon render the screen and measure the viewport. The body reserves an 8px gap below the tab strip within the pane height. Input, including Tab and control characters, travels unchanged to the PTY. + +A Session header contribution queries Host terminals and opens recovery tabs. Their navigation parameters carry `terminalId` only within the current page; a recovered view cannot allocate a replacement process. The sidebar's close handler schedules cleanup through the [terminal controller](../../api/terminal-controller/README.md#understand-the-implementation) and returns synchronously. Browser component cleanup and the tab's abort signal only detach browser work. + +
+ + +## Further Exploration + +- [Subprocess](../../subprocess/subprocess/README.md) +- [Right Sidebar](../../client/ui-sidebar-right/README.md) +- [Web terminal decision](../../../.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md) + + +## Model Experience + +None, as this package handles user terminal interaction without adding model input. + +#### KV Cache effect + +None; terminal output travels only between the browser and Host. + +## Known Limitations and Deferred Work + + + +- The default shell or native PTY startup can fail. The tab reports the failure without launching a different shell. +- Completion menus and inline suggestions depend on shell configuration. The Web UI adds no independent completion engine. +- Terminal history is bounded. The feature does not send terminal output to the Agent, provide split terminal panes inside a tab, or restore processes after Host restart. + + +### Dev Note + +
+Working context for maintainers — click to expand + +No runtime invariant companion is published. One owner orders terminal metadata and screen updates; the provider exposes no independently observed dimensions to compare. + +
diff --git a/packages/client/ui-sidebar-terminal/README.zh.md b/packages/client/ui-sidebar-terminal/README.zh.md new file mode 100644 index 0000000000..a7aa321d10 --- /dev/null +++ b/packages/client/ui-sidebar-terminal/README.zh.md @@ -0,0 +1,80 @@ +--- +description: "在 Web 右侧栏打开、恢复和控制交互式 shell 标签页。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-ui-sidebar-terminal + +[English](README.md) | 中文 + +## 概述 + +在对话旁打开执行环境的默认 shell,无需离开 Web 应用。在标签页上重命名终端,并在刷新页面后恢复保留的进程。折叠侧栏让命令继续运行,关闭终端标签页则请求结束进程。Tab 补全使用 shell 的配置。 + +## 目录 + +- [使用此包](#use-this-package) +- [了解实现](#understand-the-implementation) +- [延伸阅读](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用此包 + +展开右侧栏,在开始页选择**新建终端**;已有其他标签页时,先选择**新标签页**。默认 shell 会立即启动。通过侧栏的**新标签页**控件打开更多终端。 + +双击终端标签页标题即可重命名。另一页面持有输入权时,**接管输入**让当前连接可写。连接失败时可以**重新连接**。shell 退出后仍显示屏幕和退出码,不自动重启。 + +关闭或替换终端标签页会立即移除标签页,并在后台结束进程。清理失败时显示带**重试**操作的轻量通知;重试不会重新打开标签页。折叠、切换标签页或 Session、浮动和全屏都保留进程。 + +刷新页面后显示某个 Session,会把 Host 保留的终端重新打开为新标签页。恢复失败时可点击**重试恢复终端**。恢复目标进程消失时显示错误,不启动另一个 shell。[侧栏布局仅保存在内存中](../../client/ui-sidebar-right/README.zh.md#state)。 + + +## 了解实现 + +
+实现细节 + +插件向右侧栏注册 `terminal` 类型及正文和标题 seat。无 React 依赖的终端模型属于 `api-terminal-controller`,通过框架 keyed hooks 暴露状态。xterm.js 与 FitAddon 负责屏幕渲染和视口测量。正文在面板高度内为页签条下方预留 8px 间距。输入原样传到 PTY,包括 Tab 和控制字符。 + +Session header contribution 查询 Host 终端并打开恢复标签页。导航参数中的 `terminalId` 只在当前页面内保留;恢复视图不能分配替代进程。侧栏关闭 handler 通过[终端 controller](../../api/terminal-controller/README.zh.md#understand-the-implementation)安排清理并同步返回。浏览器组件清理和 tab 的 abort signal 只停止浏览器工作。 + +
+ + +## 延伸阅读 + +- [Subprocess](../../subprocess/subprocess/README.zh.md) +- [Right Sidebar](../../client/ui-sidebar-right/README.zh.md) +- [Web terminal decision](../../../.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md) + + +## 模型体验 + +无;此包只处理用户交互式终端,不向模型请求添加内容。 + +#### KV 缓存影响 + +无;终端输出只在浏览器与 Host 之间传输。 + +## 已知限制与延期工作 + + + +- 默认 shell 或原生 PTY 可能启动失败。标签页显示错误,不启动其他 shell。 +- 补全菜单和内联建议取决于 shell 配置,Web UI 不提供独立补全引擎。 +- 终端历史有上限。此功能不向 Agent 发送终端输出,不在单个标签页内拆分终端,也不在 Host 重启后恢复进程。 + + +### 开发备注 + +
+维护说明 + +不发布运行时 invariant companion。终端元数据与屏幕更新由同一对象按序写入,没有独立的进程尺寸观测可供比较。 + +
diff --git a/packages/client/ui-sidebar-terminal/package.json b/packages/client/ui-sidebar-terminal/package.json new file mode 100644 index 0000000000..19a008c6c8 --- /dev/null +++ b/packages/client/ui-sidebar-terminal/package.json @@ -0,0 +1,76 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-sidebar-terminal", + "description": "Interactive shell tabs for the right Sidebar", + "version": "0.1.5-alpha.1", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-sidebar-terminal" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-api-terminal-controller", + "@deepseek-ai/dsh-client-ui-sidebar-right", + "@deepseek-ai/dsh-api-remotes" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-dockkit": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^", + "@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@testing-library/react": "^16.1.0", + "@types/react": "~18.3.1", + "@types/react-dom": "~18.3.0", + "react": "^18.2.0", + "clsx": "^2.0.0", + "react-dom": "^18.2.0", + "@deepseek-ai/dsh-api-terminal-controller": "workspace:^", + "@xterm/xterm": "^6.0.0", + "@xterm/addon-fit": "^0.11.0", + "@deepseek-ai/dsh-client-ui-layout": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^" + }, + "files": [ + "lib/index.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ] +} diff --git a/packages/client/ui-sidebar-terminal/src/client/TerminalBody.module.css b/packages/client/ui-sidebar-terminal/src/client/TerminalBody.module.css new file mode 100644 index 0000000000..811de9cacc --- /dev/null +++ b/packages/client/ui-sidebar-terminal/src/client/TerminalBody.module.css @@ -0,0 +1,9 @@ +.root { display: flex; flex: 1; flex-direction: column; box-sizing: border-box; height: 100%; min-height: 0; padding-top: 8px; color: var(--dsw-alias-label-primary); font-size: 13px; } +.title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.name { width: 120px; min-width: 48px; max-width: 100%; padding: 0 4px; color: inherit; font: inherit; background: var(--dsw-alias-bg-l1); border: 1px solid var(--dsw-alias-border-l3); border-radius: 4px; outline: none; } +.screen { flex: 1; min-height: 0; min-width: 0; overflow: hidden; padding: 8px; background: #17191d; } +.screen :global(.xterm) { height: 100%; } +.status { display: flex; align-items: center; gap: 8px; padding: 5px 12px; background: var(--dsw-alias-bg-l2); } +.root button { padding: 6px 10px; border: 1px solid var(--dsw-alias-border-l3); border-radius: 6px; color: inherit; background: transparent; cursor: pointer; } +.root button:hover { background: var(--dsw-alias-bg-l2); } +.error { margin: 0; padding: 10px 12px; overflow-wrap: anywhere; color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-l2); } diff --git a/packages/client/ui-sidebar-terminal/src/client/TerminalBody.tsx b/packages/client/ui-sidebar-terminal/src/client/TerminalBody.tsx new file mode 100644 index 0000000000..b9916e0935 --- /dev/null +++ b/packages/client/ui-sidebar-terminal/src/client/TerminalBody.tsx @@ -0,0 +1,126 @@ +/** Sidebar terminal screen and connection recovery. */ +import { useEffect, useLayoutEffect, useRef, type ReactNode } from 'react' +import { Terminal } from '@xterm/xterm' +import { FitAddon } from '@xterm/addon-fit' +import type { TerminalViewState, TerminalView } from '@deepseek-ai/dsh-api-terminal-controller/client' +import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { TerminalInjected } from './face.ts' +import type {} from './locales.ts' +import '@xterm/xterm/css/xterm.css' +import css from './TerminalBody.module.css' + +/** Standard sidebar owner share plus terminal model and localized copy. */ +export type TerminalBodyProps = PropsRuntime<'sidebar.right.pane.tab'> & PropsLocale<'sidebarTerminal'> & InjectFace + +/** + * Mount an automatically started or restored terminal. + * @param props - sidebar occurrence, model lookup and translated copy. + * @returns the terminal screen and any pending or exceptional state. + */ +export function TerminalBody({ useTabInfo, useTerminal, view, t }: TerminalBodyProps): ReactNode { + const { tab } = useTabInfo() + const model = view(tab.id) + const state = useTerminal(tab.id) + useEffect(() => model.mount(), [model]) + if (state === undefined) return null + const error = state.error ?? state.info?.error + let status: string | undefined + if (state.phase === 'idle' || state.phase === 'loading') status = t('loading') + else if (state.phase === 'creating' || state.phase === 'connecting' || state.phase === 'disconnected') status = t(state.phase) + else if (state.info?.state === 'exited') status = t('exited', { code: String(state.info.exitCode ?? '—') }) + else if (state.info?.state === 'failed') status = t('unavailable') + else if (state.phase === 'closed') status = t('closed') + const retry = state.phase === 'failed' || state.phase === 'disconnected' + const readOnly = state.phase === 'connected' && state.info?.state === 'running' && !state.writable + return ( +
+ {(status !== undefined || retry || readOnly) &&
+ {status} + {readOnly && <>{t('readonly')} } + {retry && (state.info === undefined + ? + : )} +
} + {state.info !== undefined && } + {error !== undefined &&

{t('failed', { message: error })}

} +
+ ) +} + +/* oxlint-disable typescript/no-non-null-assertion -- React sets the DOM ref, then these effects initialize and use the emulator. */ +function TerminalScreen({ state, model, visible, label }: { + state: TerminalViewState + model: TerminalView + visible: boolean + label: string +}): ReactNode { + const element = useRef(null) + const terminal = useRef() + const fit = useRef() + const lastRevision = useRef(0) + const current = useRef({ state, visible }) + current.current = { state, visible } + + useLayoutEffect(() => { + const node = element.current! + const xterm = new Terminal({ cursorBlink: true, fontSize: 13, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace', scrollback: current.current.state.environment?.scrollback ?? 0, theme: { background: '#17191d', foreground: '#e7e9ee' } }) + const addon = new FitAddon() + xterm.loadAddon(addon) + xterm.open(node) + xterm.textarea?.setAttribute('aria-label', label) + terminal.current = xterm + fit.current = addon + lastRevision.current = 0 + const input = xterm.onData((data) => { model.write(data) }) + const measure = (): void => { + if (!current.current.visible || !current.current.state.writable || node.clientWidth === 0 || node.clientHeight === 0) return + fitScreen(xterm, addon, current.current.state, model) + } + const observer = new ResizeObserver(measure) + observer.observe(node) + return () => { + observer.disconnect() + input.dispose() + xterm.dispose() + terminal.current = undefined + fit.current = undefined + } + }, [model]) + + useLayoutEffect(() => { + const xterm = terminal.current! + const render = state.render + if (render === undefined || render.revision <= lastRevision.current) return + lastRevision.current = render.revision + if (render.frame.type === 'snapshot') { + xterm.reset() + xterm.resize(render.frame.info.cols, render.frame.info.rows) + } + xterm.write(render.frame.type === 'snapshot' ? render.frame.screen : render.frame.data, () => { model.acknowledge(render.revision) }) + }, [state.render, model]) + + useLayoutEffect(() => { + const xterm = terminal.current! + xterm.options.disableStdin = !state.writable + if (visible && state.writable && element.current?.clientWidth && element.current.clientHeight) { + fitScreen(xterm, fit.current!, state, model) + } else if (state.info !== undefined && !state.writable) xterm.resize(state.info.cols, state.info.rows) + }, [visible, state.writable, state.info?.cols, state.info?.rows, model]) + useEffect(() => { + terminal.current!.textarea?.setAttribute('aria-label', label) + }, [label]) + useEffect(() => { if (visible && state.writable) terminal.current!.focus() }, [visible, state.writable]) + return
+} +/* oxlint-enable typescript/no-non-null-assertion */ + +function fitScreen(xterm: Terminal, fit: FitAddon, state: TerminalViewState, model: TerminalView): void { + const dimensions = fit.proposeDimensions() + const environment = state.environment + if (dimensions === undefined || environment === undefined) return + const cols = Math.min(dimensions.cols, environment.maxCols) + const rows = Math.min(dimensions.rows, environment.maxRows) + if (cols < 2 || rows < 1) return + xterm.resize(cols, rows) + model.resize(cols, rows) +} diff --git a/packages/client/ui-sidebar-terminal/src/client/TerminalCleanup.module.css b/packages/client/ui-sidebar-terminal/src/client/TerminalCleanup.module.css new file mode 100644 index 0000000000..c031726424 --- /dev/null +++ b/packages/client/ui-sidebar-terminal/src/client/TerminalCleanup.module.css @@ -0,0 +1,3 @@ +.stack { position: fixed; right: 20px; bottom: 20px; display: grid; gap: 8px; max-width: min(440px, calc(100vw - 40px)); pointer-events: auto; } +.notice { display: flex; align-items: center; gap: 12px; padding: 12px 16px; border: 1px solid var(--dsw-alias-border-l3); border-radius: 8px; color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-l1); box-shadow: 0 4px 20px #0002; font-size: 13px; overflow-wrap: anywhere; } +.notice button { flex-shrink: 0; padding: 4px 8px; border: 1px solid var(--dsw-alias-border-l3); border-radius: 4px; color: inherit; background: transparent; cursor: pointer; } diff --git a/packages/client/ui-sidebar-terminal/src/client/TerminalCleanup.tsx b/packages/client/ui-sidebar-terminal/src/client/TerminalCleanup.tsx new file mode 100644 index 0000000000..97783a36a6 --- /dev/null +++ b/packages/client/ui-sidebar-terminal/src/client/TerminalCleanup.tsx @@ -0,0 +1,29 @@ +/** Failed background cleanup remains actionable after the originating tab disappears. */ +import type { ReactNode } from 'react' +import type { TerminalCloseFailure } from '@deepseek-ai/dsh-api-terminal-controller/client' +import type { WebTerminalId } from '@deepseek-ai/dsh-api-terminal-controller/types' +import type { HostObservable, InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type {} from '@deepseek-ai/dsh-client-ui-layout/client' +import type {} from './locales.ts' +import css from './TerminalCleanup.module.css' + +/** The model owns cleanup; this seat only shows failures and requests retries. */ +export interface TerminalCleanupInjected { + readonly hooks: { readonly closeFailures: HostObservable } + /** @param id - failed cleanup identity. */ + readonly retryClose: (id: WebTerminalId) => void +} + +/** + * Render failed cleanup tasks without recreating or blocking any sidebar tab. + * @param props - root overlay hooks, retry command and localized copy. + * @returns a compact alert stack, empty when no close has failed. + */ +export function TerminalCleanup({ useCloseFailures, retryClose, t }: PropsRuntime<'shell.overlay'> & PropsLocale<'sidebarTerminal'> & InjectFace): ReactNode { + const failures = useCloseFailures(value => value) + if (failures.length === 0) return null + return
{failures.map(failure =>
+ {t('cleanupFailed', { title: failure.title, message: failure.message })} + +
)}
+} diff --git a/packages/client/ui-sidebar-terminal/src/client/TerminalIcon.tsx b/packages/client/ui-sidebar-terminal/src/client/TerminalIcon.tsx new file mode 100644 index 0000000000..fc9d995c10 --- /dev/null +++ b/packages/client/ui-sidebar-terminal/src/client/TerminalIcon.tsx @@ -0,0 +1,9 @@ +/** Terminal prompt glyph shared by the guide and tab title. */ +import type { ReactNode } from 'react' + +/** Render the terminal's decorative prompt icon. @returns a sixteen-pixel glyph. */ +export function TerminalIcon(): ReactNode { + return +} diff --git a/packages/client/ui-sidebar-terminal/src/client/TerminalRecovery.tsx b/packages/client/ui-sidebar-terminal/src/client/TerminalRecovery.tsx new file mode 100644 index 0000000000..6557af4b3a --- /dev/null +++ b/packages/client/ui-sidebar-terminal/src/client/TerminalRecovery.tsx @@ -0,0 +1,29 @@ +/** A Session header lifetime restores retained Host terminals without saving sidebar layout. */ +import { useEffect, useState, type ReactNode } from 'react' +import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type {} from './locales.ts' + +/** The plugin coordinates once-per-page recovery; the component only owns an error notice. */ +export interface TerminalRecoveryInjected { + /** @returns after retained terminals have been opened as sidebar tabs. */ + readonly restore: () => Promise +} + +/** + * Restore terminals when a Session is displayed, with a retry action on lookup failure. + * @param props - Session header lifetime, restoration callback and localized copy. + * @returns nothing on success, or an unobtrusive retry control. + */ +export function TerminalRecovery({ restore, t }: PropsRuntime<'conversation.session.header.actions'> & PropsLocale<'sidebarTerminal'> & TerminalRecoveryInjected): ReactNode { + const [error, setError] = useState() + const [attempt, setAttempt] = useState(0) + useEffect(() => { + let active = true + void restore().then(() => { if (active) setError(undefined) }, (reason: unknown) => { + if (active) setError(reason instanceof Error ? reason.message : String(reason)) + }) + return () => { active = false } + }, [restore, attempt]) + return error === undefined ? null : +} diff --git a/packages/client/ui-sidebar-terminal/src/client/TerminalTitle.tsx b/packages/client/ui-sidebar-terminal/src/client/TerminalTitle.tsx new file mode 100644 index 0000000000..59cb1246b2 --- /dev/null +++ b/packages/client/ui-sidebar-terminal/src/client/TerminalTitle.tsx @@ -0,0 +1,60 @@ +/** Live terminal names in docked and floating tab chrome. */ +import { useLayoutEffect, useRef, useState, type ReactNode } from 'react' +import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import { TerminalIcon } from './TerminalIcon.tsx' +import type { TerminalInjected } from './face.ts' +import type {} from './locales.ts' +import css from './TerminalBody.module.css' + +/** + * Render the terminal name, editable in place on double-click. + * @param props - sidebar occurrence, terminal model and localized copy. + * @returns the terminal icon and current name or its editor. + */ +export function TerminalTitle({ useTabInfo, useTerminal, view, t }: PropsRuntime<'sidebar.right.pane.tab.title'> & PropsLocale<'sidebarTerminal'> & InjectFace): ReactNode { + const { tab } = useTabInfo() + const title = useTerminal(tab.id, state => state?.info?.title ?? state?.title) ?? tab.title + const [editing, setEditing] = useState(false) + const input = useRef(null) + const label = useRef(null) + const cancelled = useRef(false) + useLayoutEffect(() => { + if (editing) return + // Dockkit captures the pointer on its drag handle, which receives the double-click. + const chip = label.current?.closest('[data-dockkit-tab], [data-dockkit-float-grip]') + const rename = (event: Event): void => { + event.stopPropagation() + cancelled.current = false + setEditing(true) + } + chip?.addEventListener('dblclick', rename) + return () => { chip?.removeEventListener('dblclick', rename) } + }, [editing]) + useLayoutEffect(() => { + if (!editing) return + input.current?.focus() + input.current?.select() + }, [editing]) + return <> + + {editing ? { event.stopPropagation() }} + onClick={(event) => { event.stopPropagation() }} + onDoubleClick={(event) => { event.stopPropagation() }} + onBlur={(event) => { + setEditing(false) + const next = event.currentTarget.value.trim() + if (!cancelled.current && next !== '' && next !== title) void view(tab.id).rename(next) + }} + onKeyDown={(event) => { + event.stopPropagation() + // oxlint-disable-next-line typescript/no-deprecated -- Some IMEs report composition only through keyCode 229. + if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) return + if (event.key === 'Escape') { + cancelled.current = true + event.currentTarget.blur() + } else if (event.key === 'Enter') event.currentTarget.blur() + }} /> + : {title}} + +} diff --git a/packages/client/ui-sidebar-terminal/src/client/face.ts b/packages/client/ui-sidebar-terminal/src/client/face.ts new file mode 100644 index 0000000000..95920e23b1 --- /dev/null +++ b/packages/client/ui-sidebar-terminal/src/client/face.ts @@ -0,0 +1,19 @@ +/** Injected terminal commands and keyed observable state. */ +import type { WebTerminalId } from '@deepseek-ai/dsh-api-terminal-controller/types' +import type { TerminalView, TerminalViewState } from '@deepseek-ai/dsh-api-terminal-controller/client' +import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots' + +/** The terminal's React-free model is resolved by sidebar occurrence. */ +export interface TerminalInjected { + /** @param key - sidebar occurrence key. @returns its terminal commands. */ + readonly view: (key: string) => TerminalView + readonly keyedHooks: { readonly terminal: (key: string) => HostObservable } +} + + +declare module '@deepseek-ai/dsh-client-ui-sidebar-right/client' { + interface SidebarRightTabParamsMap { + /** An existing Host terminal selected from the Session terminal list. */ + terminal: { terminalId: WebTerminalId } + } +} diff --git a/packages/client/ui-sidebar-terminal/src/client/index.ts b/packages/client/ui-sidebar-terminal/src/client/index.ts new file mode 100644 index 0000000000..ce38b0feb0 --- /dev/null +++ b/packages/client/ui-sidebar-terminal/src/client/index.ts @@ -0,0 +1,81 @@ +/** Register interactive terminal tabs and explicit process cleanup with the sidebar. */ +import type { Context } from '@deepseek-ai/cordis' +import type { WebTerminalId } from '@deepseek-ai/dsh-api-terminal-controller/types' +import type { TabId } from '@deepseek-ai/dsh-client-ui-sidebar-right/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type {} from '@deepseek-ai/dsh-api-terminal-controller/client' +import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-client-locale/client' +import type {} from '@deepseek-ai/dsh-client-ui-session/client' +import { TerminalIcon } from './TerminalIcon.tsx' +import { TerminalBody } from './TerminalBody.tsx' +import { TerminalTitle } from './TerminalTitle.tsx' +import { TerminalRecovery, type TerminalRecoveryInjected } from './TerminalRecovery.tsx' +import { TerminalCleanup, type TerminalCleanupInjected } from './TerminalCleanup.tsx' +import type { TerminalInjected } from './face.ts' +import { en, zh } from './locales.ts' + +/** Services needed by the terminal's two sidebar seats. */ +export const inject = ['slots', 'locale', 'sidebarRight', 'sidebarRightTabs', 'webTerminals'] + +/** + * Register the terminal type, observable views and background process cleanup. + * @param ctx - Client root Context with sidebar and terminal services. + */ +export function apply(ctx: Context): void { + let disposed = false + const recovered = new Map>() + ctx.effect(() => () => { disposed = true; recovered.clear() }, 'ui-sidebar-terminal.lifetime') + const terminalId = (sessionId: SessionId, key: string): WebTerminalId | undefined => { + const params = ctx.sidebarRight.tabDomain.occurrence(sessionId, { id: key as TabId }).navigation.getSnapshot().params + return (params as { terminalId?: WebTerminalId } | undefined)?.terminalId + } + const view = (sessionId: SessionId, key: string) => ctx.webTerminals.view(sessionId, key, terminalId(sessionId, key)) + const namespace = 'sidebarTerminal' + const id = '@deepseek-ai/dsh-client-ui-sidebar-terminal' + const t = ctx.locale.bind(namespace) + ctx.effect(() => ctx.locale.register(namespace, { zh, en }), 'ui-sidebar-terminal.copy') + ctx.effect(() => ctx.sidebarRightTabs.register({ + id, kind: 'terminal', priority: 'builtin', title: () => t('title'), + guide: [{ order: 20, title: () => t('new'), icon: TerminalIcon, revealIfOpened: false }], + }), 'ui-sidebar-terminal.type') + ctx.effect(() => ctx.sidebarRight.registerCloseHandler('terminal', (sessionId, tab) => { + ctx.webTerminals.close(sessionId, tab.id, terminalId(sessionId, tab.id)) + }), 'ui-sidebar-terminal.close') + const inject = (sessionId: SessionId): TerminalInjected => ({ + view: key => view(sessionId, key), + keyedHooks: { terminal: key => view(sessionId, key).state }, + }) + ctx.effect(() => ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register( + { name: 'sidebar.right.pane.tab', key: id, locale: namespace, inject }, TerminalBody, + )), 'ui-sidebar-terminal.body') + ctx.effect(() => ctx.slots.inject('sidebar.right.pane.tab.title', () => ctx.slots.register( + { name: 'sidebar.right.pane.tab.title', key: id, locale: namespace, inject }, TerminalTitle, + )), 'ui-sidebar-terminal.title') + ctx.effect(() => ctx.slots.inject('conversation.session.header.actions', () => ctx.slots.register({ + name: 'conversation.session.header.actions', id, locale: namespace, + inject: (sessionId): TerminalRecoveryInjected => ({ + restore: () => { + let pending = recovered.get(sessionId) + if (pending === undefined) { + pending = ctx.webTerminals.recover(sessionId).then((terminals) => { + if (disposed) return + for (const info of terminals) ctx.sidebarRight.openTabIn(sessionId, 'terminal', { + revealIfOpened: false, params: { terminalId: info.id }, + }) + }).catch((error: unknown) => { recovered.delete(sessionId); throw error }) + recovered.set(sessionId, pending) + } + return pending + }, + }), + }, TerminalRecovery)), 'ui-sidebar-terminal.recovery') + ctx.effect(() => ctx.slots.inject('shell.overlay', () => ctx.slots.register({ + name: 'shell.overlay', id, locale: namespace, + inject: (): TerminalCleanupInjected => ({ + hooks: { closeFailures: ctx.webTerminals.closeFailures }, + retryClose: (terminalId) => { ctx.webTerminals.retryClose(terminalId) }, + }), + }, TerminalCleanup)), 'ui-sidebar-terminal.cleanup') +} diff --git a/packages/client/ui-sidebar-terminal/src/client/locales.ts b/packages/client/ui-sidebar-terminal/src/client/locales.ts new file mode 100644 index 0000000000..cdaad90ecc --- /dev/null +++ b/packages/client/ui-sidebar-terminal/src/client/locales.ts @@ -0,0 +1,30 @@ +/** Copy owned by the sidebar terminal feature. */ +import type {} from '@deepseek-ai/dsh-client-ui-slots' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + sidebarTerminal: keyof typeof zh + } +} + +/** Simplified Chinese terminal copy. */ +export const zh = { + recoveryFailed: '恢复终端失败:{message}', retryRecovery: '重试恢复终端', + title: '终端', new: '新建终端', loading: '正在读取终端环境…', creating: '正在启动…', + connecting: '正在连接…', disconnected: '连接已断开。', reconnect: '重新连接', + readonly: '此页面当前只读。', control: '接管输入', + closed: '终端已关闭。', exited: '进程已退出({code})', failed: '终端错误:{message}', + rename: '终端名称', unavailable: '不可用', retry: '重试', + cleanupFailed: '终端「{title}」未能结束:{message}', +} satisfies Record + +/** English terminal copy. */ +export const en = { + recoveryFailed: 'Terminal recovery failed: {message}', retryRecovery: 'Retry terminal recovery', + title: 'Terminal', new: 'New terminal', loading: 'Reading terminal environment…', creating: 'Starting…', + connecting: 'Connecting…', disconnected: 'Disconnected.', reconnect: 'Reconnect', + readonly: 'This view is read-only.', control: 'Take control', + closed: 'Terminal closed.', exited: 'Process exited ({code})', failed: 'Terminal error: {message}', + rename: 'Terminal name', unavailable: 'Unavailable', retry: 'Retry', + cleanupFailed: 'Terminal “{title}” could not be ended: {message}', +} satisfies Record diff --git a/packages/client/ui-sidebar-terminal/src/css-modules.d.ts b/packages/client/ui-sidebar-terminal/src/css-modules.d.ts new file mode 100644 index 0000000000..bc5e482353 --- /dev/null +++ b/packages/client/ui-sidebar-terminal/src/css-modules.d.ts @@ -0,0 +1,6 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} + +declare module '*.css' diff --git a/packages/client/ui-sidebar-terminal/src/index.ts b/packages/client/ui-sidebar-terminal/src/index.ts new file mode 100644 index 0000000000..d7b0a4904f --- /dev/null +++ b/packages/client/ui-sidebar-terminal/src/index.ts @@ -0,0 +1,4 @@ +/** Host companion for the interactive terminal Client plugin. */ + +/** Mount the browser-only plugin through the Client loader. */ +export function apply(): void {} diff --git a/packages/client/ui-sidebar-terminal/tests/apply.client.spec.ts b/packages/client/ui-sidebar-terminal/tests/apply.client.spec.ts new file mode 100644 index 0000000000..21f616a07d --- /dev/null +++ b/packages/client/ui-sidebar-terminal/tests/apply.client.spec.ts @@ -0,0 +1,175 @@ +// @vitest-environment jsdom +/** Terminal type, copy, seats and explicit cleanup follow the plugin lifetime. */ +import { Context } from '@deepseek-ai/cordis' +import { expect, it, vi } from 'vitest' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { WebTerminalId, WebTerminalInfo } from '@deepseek-ai/dsh-api-terminal-controller/types' +import { SidebarRightTabRegistry } from '@deepseek-ai/dsh-client-ui-sidebar-right/src/client/tab-registry.ts' +import type { SidebarRightCloseHandler } from '@deepseek-ai/dsh-client-ui-sidebar-right/client' +import { apply, inject } from '../src/client/index.ts' +import { apply as hostApply } from '../src/index.ts' +import { TerminalBody } from '../src/client/TerminalBody.tsx' +import { TerminalTitle } from '../src/client/TerminalTitle.tsx' +import { TerminalRecovery, type TerminalRecoveryInjected } from '../src/client/TerminalRecovery.tsx' +import { TerminalCleanup, type TerminalCleanupInjected } from '../src/client/TerminalCleanup.tsx' +import type { TerminalInjected } from '../src/client/face.ts' +import { en, zh } from '../src/client/locales.ts' + +vi.mock('@xterm/xterm', () => ({ Terminal: vi.fn() })) + +const terminalInfo = (id: string): WebTerminalInfo => ({ id: id as WebTerminalId, title: id, shell: { path: '/bin/sh', name: 'sh', args: ['-i'] }, cwd: '/workspace', cols: 80, rows: 24, state: 'running', exitCode: null }) + +async function mountPlugin() { + const ctx = new Context() + const tabs = new SidebarRightTabRegistry(ctx) + ctx.provide('sidebarRightTabs', tabs) + const entries: { + name: string + key?: string + id?: string + locale: string + component: unknown + inject: (id: SessionId) => unknown + }[] = [] + const dictionaries = new Map() + let closeHandler: SidebarRightCloseHandler | undefined + const model = { state: {} } + const terminals = { + view: vi.fn(() => model), close: vi.fn(), closeFailures: {}, retryClose: vi.fn(), + recover: vi.fn(async (_sessionId: SessionId): Promise => []), + } + let params: { terminalId: WebTerminalId } | undefined + const occurrence = vi.fn(() => ({ navigation: { getSnapshot: () => ({ params }) } })) + const openTabIn = vi.fn() + ctx.provide('webTerminals', terminals as never) + ctx.provide('sidebarRight', { + tabDomain: { occurrence }, openTabIn, + registerCloseHandler: (kind: string, handler: SidebarRightCloseHandler) => { expect(kind).toBe('terminal'); closeHandler = handler; return () => { closeHandler = undefined } }, + } as never) + ctx.provide('slots', { + inject: (_name: string, register: () => () => void) => register(), + register: (options: Omit, component: unknown) => { const entry = { ...options, component }; entries.push(entry); return () => { entries.splice(entries.indexOf(entry), 1) } }, + } as never) + ctx.provide('locale', { + bind: () => (key: string) => key, + register: (name: string, values: unknown) => { dictionaries.set(name, values); return () => { dictionaries.delete(name) } }, + } as never) + const fiber = await ctx.plugin({ inject, apply }) + return { + tabs, entries, dictionaries, terminals, model, occurrence, openTabIn, + get closeHandler() { return closeHandler }, + setParams(next: typeof params) { params = next }, + async dispose() { await fiber.dispose(); await ctx.fiber.dispose() }, + } +} + +it('registers terminal views, recovery and cleanup, then releases every contribution on unload', async () => { + expect(hostApply).not.toThrow() + const h = await mountPlugin() + try { + const definition = h.tabs.get('terminal')! + expect(definition.title('sidebar://terminal')).toBe('title') + expect(definition.guide?.map(entry => [entry.order, entry.title(), entry.revealIfOpened])).toEqual([[20, 'new', false]]) + expect(h.dictionaries.get('sidebarTerminal')).toEqual({ en, zh }) + expect(h.entries.map(entry => [entry.name, entry.component, entry.locale])).toEqual([ + ['sidebar.right.pane.tab', TerminalBody, 'sidebarTerminal'], + ['sidebar.right.pane.tab.title', TerminalTitle, 'sidebarTerminal'], + ['conversation.session.header.actions', TerminalRecovery, 'sidebarTerminal'], + ['shell.overlay', TerminalCleanup, 'sidebarTerminal'], + ]) + const sessionId = 'session' as SessionId + const face = h.entries[0]!.inject(sessionId) as TerminalInjected + expect(face.view('tab')).toBe(h.model) + expect(h.terminals.view).toHaveBeenLastCalledWith(sessionId, 'tab', undefined) + const terminalId = 'retained' as WebTerminalId + h.setParams({ terminalId }) + expect(face.keyedHooks.terminal('tab')).toBe(h.model.state) + expect(h.terminals.view).toHaveBeenLastCalledWith(sessionId, 'tab', terminalId) + expect(h.occurrence).toHaveBeenLastCalledWith(sessionId, { id: 'tab' }) + if (h.closeHandler === undefined) throw new Error('Terminal close handler was not registered') + expect(h.closeHandler(sessionId, { id: 'tab' } as Parameters[1])).toBeUndefined() + expect(h.terminals.close).toHaveBeenLastCalledWith(sessionId, 'tab', terminalId) + h.setParams(undefined) + expect(h.closeHandler(sessionId, { id: 'new-tab' } as Parameters[1])).toBeUndefined() + expect(h.terminals.close).toHaveBeenLastCalledWith(sessionId, 'new-tab', undefined) + const cleanupFace = h.entries[3]!.inject(sessionId) as TerminalCleanupInjected + expect(cleanupFace.hooks.closeFailures).toBe(h.terminals.closeFailures) + cleanupFace.retryClose('terminal' as Parameters[0]) + expect(h.terminals.retryClose).toHaveBeenCalledWith('terminal') + } finally { + await h.dispose() + } + expect(h.closeHandler).toBeUndefined() + expect(h.tabs.get('terminal')).toBeUndefined() + expect(h.entries).toEqual([]) + expect(h.dictionaries.size).toBe(0) +}) + +it('shares pending and completed recovery across Session headers and opens each returned terminal once', async () => { + const h = await mountPlugin() + const pending = Promise.withResolvers() + h.terminals.recover.mockImplementationOnce(() => pending.promise) + const sessionId = 'session' as SessionId + const recovery = h.entries[2]!.inject(sessionId) as TerminalRecoveryInjected + const remounted = h.entries[2]!.inject(sessionId) as TerminalRecoveryInjected + const completion = recovery.restore() + try { + expect(remounted.restore()).toBe(completion) + expect(h.terminals.recover).toHaveBeenCalledExactlyOnceWith(sessionId) + expect(h.openTabIn).not.toHaveBeenCalled() + pending.resolve([terminalInfo('build'), terminalInfo('tests')]) + await completion + expect(h.openTabIn.mock.calls).toEqual([ + [sessionId, 'terminal', { revealIfOpened: false, params: { terminalId: 'build' } }], + [sessionId, 'terminal', { revealIfOpened: false, params: { terminalId: 'tests' } }], + ]) + await remounted.restore() + expect(h.terminals.recover).toHaveBeenCalledTimes(1) + expect(h.openTabIn).toHaveBeenCalledTimes(2) + const otherSession = 'other-session' as SessionId + await (h.entries[2]!.inject(otherSession) as TerminalRecoveryInjected).restore() + expect(h.terminals.recover).toHaveBeenLastCalledWith(otherSession) + expect(h.terminals.recover).toHaveBeenCalledTimes(2) + expect(h.openTabIn).toHaveBeenCalledTimes(2) + } finally { + pending.resolve([]) + await completion + await h.dispose() + } +}) + +it('allows a failed Host lookup to be retried without marking the Session recovered', async () => { + const h = await mountPlugin() + const unavailable = new Error('Host unavailable') + h.terminals.recover.mockRejectedValueOnce(unavailable) + const sessionId = 'session' as SessionId + const recovery = h.entries[2]!.inject(sessionId) as TerminalRecoveryInjected + try { + await expect(recovery.restore()).rejects.toBe(unavailable) + expect(h.openTabIn).not.toHaveBeenCalled() + await recovery.restore() + await recovery.restore() + expect(h.terminals.recover).toHaveBeenCalledTimes(2) + } finally { + await h.dispose() + } +}) + +it('does not open retained terminals when their lookup completes after plugin unload', async () => { + const h = await mountPlugin() + const pending = Promise.withResolvers() + h.terminals.recover.mockImplementationOnce(() => pending.promise) + const recovery = h.entries[2]!.inject('session' as SessionId) as TerminalRecoveryInjected + const completion = recovery.restore() + try { + expect(h.terminals.recover).toHaveBeenCalledOnce() + await h.dispose() + pending.resolve([terminalInfo('build')]) + await completion + expect(h.openTabIn).not.toHaveBeenCalled() + } finally { + pending.resolve([]) + await completion + await h.dispose() + } +}) diff --git a/packages/client/ui-sidebar-terminal/tests/cleanup.client.spec.tsx b/packages/client/ui-sidebar-terminal/tests/cleanup.client.spec.tsx new file mode 100644 index 0000000000..fb1f61aab4 --- /dev/null +++ b/packages/client/ui-sidebar-terminal/tests/cleanup.client.spec.tsx @@ -0,0 +1,53 @@ +// @vitest-environment jsdom +/** Cleanup errors remain reachable after the terminal tab has disappeared. */ +import { afterEach, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, within } from '@testing-library/react' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import type { TerminalCloseFailure } from '@deepseek-ai/dsh-api-terminal-controller/client' +import type { WebTerminalId } from '@deepseek-ai/dsh-api-terminal-controller/types' +import { TerminalCleanup } from '../src/client/TerminalCleanup.tsx' +import { en } from '../src/client/locales.ts' + +afterEach(cleanup) + +type CleanupProps = Parameters[0] +const unusedHook = (): never => { throw new Error('Terminal cleanup does not consume global hooks') } +const standard: Omit = { + useSessions: unusedHook, useSessionPendingInteraction: unusedHook, usePanelInfo: unusedHook, + useWorkspaces: unusedHook, useResource: unusedHook, +} + +it('stays absent for ordinary closes and offers a retry only after cleanup fails', () => { + let failures: readonly TerminalCloseFailure[] = [] + const retryClose = vi.fn() + const useCloseFailures: CleanupProps['useCloseFailures'] = selector => selector(failures) + const props: CleanupProps = { ...standard, useCloseFailures, retryClose, t: makeTranslate(en) } + const view = render() + expect(view.container.childElementCount).toBe(0) + failures = [{ id: 'terminal' as WebTerminalId, title: 'Build', message: 'Host unavailable' }] + view.rerender() + expect(view.getByRole('alert').textContent).toContain('Terminal “Build” could not be ended: Host unavailable') + fireEvent.click(view.getByRole('button', { name: en.retry })) + expect(retryClose).toHaveBeenCalledWith('terminal') + failures = [] + view.rerender() + expect(view.container.childElementCount).toBe(0) +}) + +it('keeps cleanup failures separate and retries the terminal selected by the user', () => { + const failures: readonly TerminalCloseFailure[] = [ + { id: 'build' as WebTerminalId, title: 'Build', message: 'Host unavailable' }, + { id: 'tests' as WebTerminalId, title: 'Tests', message: 'Process did not exit' }, + ] + const retryClose = vi.fn() + const useCloseFailures: CleanupProps['useCloseFailures'] = selector => selector(failures) + const props: CleanupProps = { ...standard, useCloseFailures, retryClose, t: makeTranslate(en) } + const view = render() + const notices = view.getAllByRole('alert') + expect(notices.map(notice => notice.textContent)).toEqual([ + 'Terminal “Build” could not be ended: Host unavailableRetry', + 'Terminal “Tests” could not be ended: Process did not exitRetry', + ]) + fireEvent.click(within(notices[1]!).getByRole('button', { name: en.retry })) + expect(retryClose).toHaveBeenCalledExactlyOnceWith('tests') +}) diff --git a/packages/client/ui-sidebar-terminal/tests/recovery.client.spec.tsx b/packages/client/ui-sidebar-terminal/tests/recovery.client.spec.tsx new file mode 100644 index 0000000000..853ea8ff3e --- /dev/null +++ b/packages/client/ui-sidebar-terminal/tests/recovery.client.spec.tsx @@ -0,0 +1,83 @@ +// @vitest-environment jsdom +/** Session terminal recovery hides ordinary work and keeps failed lookup retryable. */ +import { afterEach, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render } from '@testing-library/react' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { TerminalRecovery } from '../src/client/TerminalRecovery.tsx' +import { en } from '../src/client/locales.ts' + +afterEach(cleanup) + +function props(restore: () => Promise): Parameters[0] { + // The renderer supplies Session shares; recovery needs only the injected callback and copy. + return { restore, t: makeTranslate(en) } as Parameters[0] +} + +it('restores on mount without adding controls during a successful lookup', async () => { + const pending = Promise.withResolvers() + const restore = vi.fn(() => pending.promise) + const view = render() + try { + expect(restore).toHaveBeenCalledOnce() + expect(view.container.childElementCount).toBe(0) + await act(async () => { pending.resolve(undefined); await pending.promise }) + view.rerender() + expect(restore).toHaveBeenCalledOnce() + expect(view.container.childElementCount).toBe(0) + } finally { + await act(async () => { pending.resolve(undefined); await pending.promise }) + } +}) + +it.each([new Error('Host unavailable'), 'Host unavailable'])('offers recovery retry for a failed lookup: %s', async (reason) => { + const pending = Promise.withResolvers() + const restore = vi.fn<() => Promise>().mockRejectedValueOnce(reason).mockImplementationOnce(() => pending.promise) + const view = render() + try { + const retry = await view.findByRole('button', { name: en.retryRecovery }) + expect(retry.getAttribute('title')).toBe('Terminal recovery failed: Host unavailable') + fireEvent.click(retry) + expect(restore).toHaveBeenCalledTimes(2) + expect(view.container.childElementCount).toBe(0) + await act(async () => { pending.resolve(undefined); await pending.promise }) + expect(view.container.childElementCount).toBe(0) + } finally { + await act(async () => { pending.resolve(undefined); await pending.promise }) + } +}) + +it('keeps the current Session failure when an earlier Session lookup later succeeds', async () => { + const pending = Promise.withResolvers() + const previous = vi.fn(() => pending.promise) + const current = vi.fn(async () => { throw new Error('Current Session failed') }) + const view = render() + try { + view.rerender() + const retry = await view.findByRole('button', { name: en.retryRecovery }) + expect(retry.getAttribute('title')).toContain('Current Session failed') + await act(async () => { pending.resolve(undefined); await pending.promise }) + expect(view.getByRole('button', { name: en.retryRecovery }).getAttribute('title')).toContain('Current Session failed') + } finally { + await act(async () => { pending.resolve(undefined); await pending.promise }) + } +}) + +it('ignores an earlier Session failure after a replacement Session recovers', async () => { + const pending = Promise.withResolvers() + const previous = vi.fn(() => pending.promise) + const completed = Promise.resolve() + const current = vi.fn(() => completed) + const view = render() + try { + view.rerender() + await act(async () => { await completed }) + await act(async () => { + pending.reject(new Error('Previous Session failed')) + await expect(pending.promise).rejects.toThrow('Previous Session failed') + }) + expect(view.container.childElementCount).toBe(0) + } finally { + pending.resolve(undefined) + await Promise.allSettled([pending.promise]) + } +}) diff --git a/packages/client/ui-sidebar-terminal/tests/terminal-body.client.spec.tsx b/packages/client/ui-sidebar-terminal/tests/terminal-body.client.spec.tsx new file mode 100644 index 0000000000..8f89680527 --- /dev/null +++ b/packages/client/ui-sidebar-terminal/tests/terminal-body.client.spec.tsx @@ -0,0 +1,299 @@ +// @vitest-environment jsdom +/** Terminal startup, title editing and xterm's screen lifetime. */ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render } from '@testing-library/react' +import type { ReactNode } from 'react' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import type { TerminalViewState } from '@deepseek-ai/dsh-api-terminal-controller/client' +import type { WebTerminalId } from '@deepseek-ai/dsh-api-terminal-controller/types' +import { TerminalBody, type TerminalBodyProps } from '../src/client/TerminalBody.tsx' +import { TerminalTitle } from '../src/client/TerminalTitle.tsx' +import { en, zh } from '../src/client/locales.ts' + +const fake = vi.hoisted(() => ({ + terminals: [] as FakeTerminal[], + dimensions: { cols: 120, rows: 40 } as { cols: number; rows: number } | undefined, +})) +class FakeTerminal { + options: { disableStdin?: boolean } + textarea: HTMLTextAreaElement | undefined = document.createElement('textarea') + input: ((data: string) => void) | undefined + readonly disposeInput = vi.fn() + readonly resize = vi.fn() + readonly reset = vi.fn() + readonly focus = vi.fn() + readonly dispose = vi.fn() + readonly write = vi.fn((_data: string, callback: () => void) => { callback() }) + readonly loadAddon = vi.fn() + constructor(options: object) { this.options = options; fake.terminals.push(this) } + open(node: HTMLElement) { node.appendChild(this.textarea!) } + onData(input: (data: string) => void) { this.input = input; return { dispose: this.disposeInput } } +} +vi.mock('@xterm/xterm', () => ({ Terminal: vi.fn(function (options: object) { return new FakeTerminal(options) }) })) +vi.mock('@xterm/addon-fit', () => ({ FitAddon: class { proposeDimensions() { return fake.dimensions } } })) + +let measure: (() => void) | undefined +const disconnect = vi.fn() +let boxWidth = 800 +let boxHeight = 600 +beforeEach(() => { + fake.terminals.length = 0 + fake.dimensions = { cols: 120, rows: 40 } + boxWidth = 800 + boxHeight = 600 + vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => boxWidth) + vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => boxHeight) + vi.stubGlobal('ResizeObserver', class { constructor(callback: () => void) { measure = callback } observe() {} disconnect = disconnect }) +}) +afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.unstubAllGlobals(); disconnect.mockClear(); measure = undefined }) + +const info = { id: 'terminal' as WebTerminalId, title: 'bash', shell: { path: '/bin/bash', name: 'bash', args: ['-i'] }, cwd: '/workspace', cols: 80, rows: 24, state: 'running' as const, exitCode: null } +const environment = { cwd: '/workspace', maxCols: 200, maxRows: 100, scrollback: 1000, maxInputBytes: 1000 } +const idle: TerminalViewState = { phase: 'idle', writable: false, environment } +const titleSurfaces = [ + { + name: 'docked tab', selector: '[data-dockkit-tab]', + wrap: (title: ReactNode) =>
{title}
, + }, + { + name: 'floating grip', selector: '[data-dockkit-float-grip]', + wrap: (title: ReactNode) =>
{title}
, + }, +] +function mount(initial: TerminalViewState | undefined = idle) { + let state: TerminalViewState | undefined = initial + let visible = true + const detach = vi.fn() + const model = { + mount: vi.fn(() => detach), refresh: vi.fn(async () => {}), + rename: vi.fn(async () => {}), connect: vi.fn(), write: vi.fn(), resize: vi.fn(), acknowledge: vi.fn(), + } + const openTab = vi.fn() + const tab = () => ({ tab: { id: 'tab', title: 'Terminal', visible, actions: { openTab } } }) + // The test supplies the owner and model hooks consumed here; the remaining slot props are framework-owned. + const props = { + view: () => model, + useTerminal: (_key: string, select?: (value: TerminalViewState | undefined) => unknown) => select === undefined ? state : select(state), + useTabInfo: tab, t: makeTranslate(en), + } as unknown as TerminalBodyProps + const view = render() + return { + view, props, model, detach, openTab, + update(next: TerminalViewState | undefined, shown = visible) { + state = next; visible = shown; view.rerender() + }, + } +} + +it('mounts automatic startup and offers retry only when startup fails', () => { + const h = mount({ ...idle, phase: 'loading', environment: undefined }) + expect(h.model.mount).toHaveBeenCalledOnce() + expect(h.view.getByRole('status').textContent).toBe(en.loading) + expect(h.view.queryByRole('combobox')).toBeNull() + expect(h.view.queryByRole('button')).toBeNull() + h.update({ ...idle, phase: 'failed', error: 'unavailable' }) + expect(h.view.getByRole('alert').textContent).toContain('unavailable') + fireEvent.click(h.view.getByRole('button', { name: en.retry })) + expect(h.model.refresh).toHaveBeenCalledOnce() + h.update({ ...idle, phase: 'disconnected' }) + fireEvent.click(h.view.getByRole('button', { name: en.retry })) + expect(h.model.refresh).toHaveBeenCalledTimes(2) + h.update({ ...idle, phase: 'creating' }) + expect(h.view.getByRole('status').textContent).toBe(en.creating) + h.update({ ...idle, phase: 'closing' }) + expect(h.view.queryByRole('status')).toBeNull() + expect(h.view.queryByRole('button')).toBeNull() + h.update(undefined) + expect(h.view.container.childElementCount).toBe(0) + expect(h.model.mount).toHaveBeenCalledOnce() +}) + +it('shows only the terminal screen while connected and detaches on unmount', () => { + const h = mount({ ...idle, info, phase: 'connected', writable: true }) + expect(h.view.getByRole('textbox', { name: en.title })).toBeDefined() + expect(h.view.queryByRole('status')).toBeNull() + expect(h.view.queryByRole('button')).toBeNull() + expect(h.view.queryByRole('combobox')).toBeNull() + expect(h.view.queryByRole('heading')).toBeNull() + expect(h.view.queryByText('/workspace')).toBeNull() + expect(h.view.container.querySelector('header')).toBeNull() + h.view.unmount() + expect(h.detach).toHaveBeenCalledOnce() +}) + +it('keeps one emulator across rename and locale updates, applies snapshots and output once, and releases listeners', () => { + const snapshot: TerminalViewState = { ...idle, info, phase: 'connected', writable: true, render: { revision: 1, frame: { type: 'snapshot', sequence: 0, info, screen: 'restored' } } } + const h = mount(snapshot) + const terminal = fake.terminals[0]! + expect(terminal.reset).toHaveBeenCalledOnce() + expect(terminal.write).toHaveBeenCalledWith('restored', expect.any(Function)) + expect(h.model.acknowledge).toHaveBeenCalledWith(1) + expect(terminal.focus).toHaveBeenCalledOnce() + terminal.input?.('help\t') + expect(h.model.write).toHaveBeenCalledWith('help\t') + h.update({ ...snapshot, render: { revision: 2, frame: { type: 'output', sequence: 1, data: 'live' } } }) + expect(terminal.write).toHaveBeenCalledWith('live', expect.any(Function)) + h.update({ ...snapshot, render: { revision: 2, frame: { type: 'output', sequence: 1, data: 'duplicate' } } }) + expect(terminal.write).toHaveBeenCalledTimes(2) + h.update({ ...snapshot, info: { ...info, title: 'Development' } }) + h.view.rerender() + expect(fake.terminals).toHaveLength(1) + expect(terminal.textarea?.getAttribute('aria-label')).toBe(zh.title) + h.view.unmount() + expect(terminal.dispose).toHaveBeenCalledOnce() + expect(terminal.disposeInput).toHaveBeenCalledOnce() + expect(disconnect).toHaveBeenCalledOnce() +}) + +it('displays disconnect, close and exit states and offers explicit reconnection and takeover', () => { + const h = mount({ ...idle, info, phase: 'connecting' }) + expect(h.view.getByRole('status').textContent).toBe(en.connecting) + h.update({ ...idle, info, phase: 'connected' }) + expect(h.view.getByRole('status').textContent).toContain(en.readonly) + expect(fake.terminals[0]!.options.disableStdin).toBe(true) + fireEvent.click(h.view.getByRole('button', { name: en.control })) + expect(h.model.connect).toHaveBeenCalledOnce() + for (const phase of ['disconnected', 'failed'] as const) { + h.update({ ...idle, info, phase }) + fireEvent.click(h.view.getByRole('button', { name: en.reconnect })) + } + expect(h.model.connect).toHaveBeenCalledTimes(3) + h.update({ ...idle, info, phase: 'closing' }) + expect(h.view.queryByRole('status')).toBeNull() + h.update({ ...idle, info, phase: 'closed' }) + expect(h.view.getByRole('status').textContent).toBe(en.closed) + h.update({ ...idle, info: { ...info, state: 'exited', exitCode: 5 }, phase: 'connected' }) + expect(h.view.getByRole('status').textContent).toBe('Process exited (5)') + h.update({ ...idle, info: { ...info, state: 'exited', error: 'provider stopped' }, phase: 'connected' }) + expect(h.view.getByRole('status').textContent).toBe('Process exited (—)') + expect(h.view.getByRole('alert').textContent).toContain('provider stopped') + h.update({ ...idle, info: { ...info, state: 'failed', error: 'provider unreachable' }, phase: 'connected' }) + expect(h.view.getByRole('status').textContent).toBe(en.unavailable) +}) + +it('fits only visible writable terminals with measurable dimensions and clamps the provider limits', () => { + const state: TerminalViewState = { ...idle, info, phase: 'connected', writable: true } + const h = mount(state) + const terminal = fake.terminals[0]! + expect(h.model.resize).toHaveBeenCalledWith(120, 40) + h.model.resize.mockClear() + fake.dimensions = { cols: 500, rows: 300 } + measure?.() + expect(h.model.resize).toHaveBeenCalledWith(200, 100) + h.model.resize.mockClear() + h.update(state, false) + measure?.() + h.update({ ...state, writable: false }, true) + measure?.() + expect(h.model.resize).not.toHaveBeenCalled() + expect(terminal.resize).toHaveBeenLastCalledWith(80, 24) + boxWidth = 0 + h.update(state) + measure?.() + boxWidth = 800; boxHeight = 0 + measure?.() + h.update({ ...state, info: { ...info, cols: 81 } }) + expect(h.model.resize).not.toHaveBeenCalled() + boxHeight = 600 + fake.dimensions = undefined + measure?.() + h.update({ ...state, environment: undefined }) + fake.dimensions = { cols: 40, rows: 20 } + measure?.() + h.update(state) + for (const dimensions of [{ cols: 1, rows: 20 }, { cols: 40, rows: 0 }]) { fake.dimensions = dimensions; measure?.() } + expect(h.model.resize).not.toHaveBeenCalled() +}) + +it.each(titleSurfaces)('shows saved and attached terminal names in the $name', (surface) => { + const h = mount(idle) + const chip = () => surface.wrap() + const title = render(chip()) + expect(title.container.textContent).toBe('Terminal') + h.update({ ...idle, title: 'Saved terminal' }) + title.rerender(chip()) + expect(title.container.textContent).toBe('Saved terminal') + h.update({ ...idle, info }) + title.rerender(chip()) + expect(title.container.textContent).toBe('bash') + expect(title.container.querySelector('svg')?.getAttribute('aria-hidden')).toBe('true') +}) + +it.each(titleSurfaces)('renames from the $name pointer target, preserving dragging and isolating editor shortcuts', (surface) => { + const h = mount({ ...idle, title: 'Saved terminal' }) + const outer = vi.fn() + const title = render(
+ {surface.wrap()} +
) + fireEvent.pointerDown(title.getByText('Saved terminal')) + expect(outer).toHaveBeenCalledOnce() + outer.mockClear() + fireEvent.doubleClick(title.container.querySelector(surface.selector)!) + const input = title.getByRole('textbox', { name: en.rename }) as HTMLInputElement + expect(document.activeElement).toBe(input) + expect(input.selectionStart).toBe(0) + expect(input.selectionEnd).toBe('Saved terminal'.length) + fireEvent.pointerDown(input) + fireEvent.click(input) + fireEvent.doubleClick(input) + fireEvent.keyDown(input, { key: 'ArrowLeft' }) + fireEvent.change(input, { target: { value: ' Development ' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + expect(h.model.rename).toHaveBeenCalledWith('Development') + expect(h.model.rename).toHaveBeenCalledOnce() + expect(title.queryByRole('textbox', { name: en.rename })).toBeNull() + expect(outer).not.toHaveBeenCalled() +}) + +it.each(titleSurfaces)('handles Escape, unchanged names and IME composition while editing the $name', (surface) => { + const h = mount({ ...idle, info }) + const title = render(surface.wrap()) + const edit = () => { + fireEvent.doubleClick(title.container.querySelector(surface.selector)!) + return title.getByRole('textbox', { name: en.rename }) + } + let input = edit() + fireEvent.change(input, { target: { value: 'Cancelled' } }) + fireEvent.keyDown(input, { key: 'Escape' }) + expect(h.model.rename).not.toHaveBeenCalled() + input = edit() + fireEvent.blur(input) + expect(h.model.rename).not.toHaveBeenCalled() + input = edit() + fireEvent.change(input, { target: { value: ' ' } }) + fireEvent.blur(input) + expect(h.model.rename).not.toHaveBeenCalled() + input = edit() + fireEvent.change(input, { target: { value: '开发' } }) + fireEvent.keyDown(input, { key: 'Enter', isComposing: true }) + fireEvent.keyDown(input, { key: 'Enter', keyCode: 229 }) + expect(title.getByRole('textbox', { name: en.rename })).toBe(input) + expect(h.model.rename).not.toHaveBeenCalled() + fireEvent.blur(input) + expect(h.model.rename).toHaveBeenCalledWith('开发') +}) + +it.each(titleSurfaces)('removes the native $name listener when its title unmounts', (surface) => { + const h = mount({ ...idle, info }) + const outer = vi.fn() + const seat = (shown: boolean) =>
+ {surface.wrap(shown && )} +
+ const title = render(seat(false)) + const chip = title.container.querySelector(surface.selector)! + const add = vi.spyOn(chip, 'addEventListener') + const remove = vi.spyOn(chip, 'removeEventListener') + title.rerender(seat(true)) + const listener = add.mock.calls.find(([type]) => type === 'dblclick')?.[1] + expect(listener).toBeTypeOf('function') + title.rerender(seat(false)) + expect(remove).toHaveBeenCalledExactlyOnceWith('dblclick', listener) + fireEvent.doubleClick(chip) + expect(outer).toHaveBeenCalledOnce() + expect(h.model.rename).not.toHaveBeenCalled() +}) + +it('starts a recovered screen with no local history when environment discovery is unavailable', () => { + mount({ ...idle, info, environment: undefined }) + expect(fake.terminals[0]!.options).toHaveProperty('scrollback', 0) +}) diff --git a/packages/client/ui-sidebar-terminal/tsconfig.json b/packages/client/ui-sidebar-terminal/tsconfig.json new file mode 100644 index 0000000000..4e229078ec --- /dev/null +++ b/packages/client/ui-sidebar-terminal/tsconfig.json @@ -0,0 +1,51 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../api/remotes/tsconfig.client.json" + }, + { + "path": "../../core/session" + }, + { + "path": "../locale" + }, + { + "path": "../store" + }, + { + "path": "../ui-dockkit" + }, + { + "path": "../ui-primitives" + }, + { + "path": "../ui-session" + }, + { + "path": "../ui-sidebar-right" + }, + { + "path": "../ui-slots" + }, + { + "path": "../../api/terminal-controller/tsconfig.client.json" + }, + { + "path": "../ui-layout" + }, + { + "path": "../ui-conversation" + } + ] +} diff --git a/packages/client/ui-sidebar-terminal/tsdown.config.ts b/packages/client/ui-sidebar-terminal/tsdown.config.ts new file mode 100644 index 0000000000..016643c5fb --- /dev/null +++ b/packages/client/ui-sidebar-terminal/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-sidebar-terminal', ['lib/types/index.js']) diff --git a/packages/e2b/subprocess-e2b/README.i18n.yaml b/packages/e2b/subprocess-e2b/README.i18n.yaml index 36fde54534..aa75116ae8 100644 --- a/packages/e2b/subprocess-e2b/README.i18n.yaml +++ b/packages/e2b/subprocess-e2b/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/e2b/subprocess-e2b/README.md -README.md: 1c88e8b8650b93d326086f53311840922656244b -README.zh.md: 74c43ae98724e652618026c9eb3191998fbf52a9 +README.md: 11cb60e3966b2dd7bd20021a9cb6e3806b41df3e +README.zh.md: 14834748bbc82d87d3178ab75c7b470184b4e8c3 diff --git a/packages/e2b/subprocess-e2b/README.md b/packages/e2b/subprocess-e2b/README.md index 1c88e8b865..11cb60e396 100644 --- a/packages/e2b/subprocess-e2b/README.md +++ b/packages/e2b/subprocess-e2b/README.md @@ -125,6 +125,8 @@ Read these pages when the package-level contract is not enough. They move from t ----- +Terminal environment discovery reads the sandbox environment, and terminal resize uses the E2B PTY resize API. The caller-provided `terminalType` is applied to the remote child environment. Shell lookup misses are distinguished from transport errors. + ## Model Experience diff --git a/packages/e2b/subprocess-e2b/README.zh.md b/packages/e2b/subprocess-e2b/README.zh.md index 74c43ae987..14834748bb 100644 --- a/packages/e2b/subprocess-e2b/README.zh.md +++ b/packages/e2b/subprocess-e2b/README.zh.md @@ -125,6 +125,8 @@ agent 可以在沙箱中打开交互式终端、发送输入、读取输出, ----- +终端环境探测读取 sandbox 环境,终端 resize 使用 E2B PTY resize API。调用者指定的 `terminalType` 应用于远端子进程环境。shell 查找缺失与传输错误分别报告。 + ## 模型体验 diff --git a/packages/e2b/subprocess-e2b/src/index.ts b/packages/e2b/subprocess-e2b/src/index.ts index 62a8802d79..31d7e1bd54 100644 --- a/packages/e2b/subprocess-e2b/src/index.ts +++ b/packages/e2b/subprocess-e2b/src/index.ts @@ -9,18 +9,20 @@ import { posix } from 'node:path' import { inspect } from 'node:util' import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' -import { SubprocessRuntime } from '@deepseek-ai/dsh-subprocess' +import { SubprocessRuntime, SubprocessExecutableNotFoundError } from '@deepseek-ai/dsh-subprocess' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import type { SubprocessHandle, SubprocessSpawnSpec, SubprocessTerminalHandle, + SubprocessTerminalEnvironment, SubprocessTerminalSpawnSpec, } from '@deepseek-ai/dsh-subprocess' -import { e2bControlEnvs, quoteE2BShellArg } from '@deepseek-ai/dsh-e2b' +import { CommandExitError, e2bControlEnvs, quoteE2BShellArg } from '@deepseek-ai/dsh-e2b' import { E2BSubprocessHandle } from './process.ts' import { asError, signalOpts } from './remote.ts' import { spawnE2BTerminal } from './terminal.ts' +import { readRemoteEnvironment, scrubRemoteEnvironment } from './environment.ts' /** Configuration for the E2B subprocess adapter. */ export interface Config { @@ -120,7 +122,7 @@ export class E2BSubprocessRuntime extends SubprocessRuntime { await sandbox.commands.run( `test -f ${quoteE2BShellArg(command)} -a -x ${quoteE2BShellArg(command)}`, { envs: e2bControlEnvs(), ...signalOpts(signal) }, - ) + ).catch((error: unknown) => { throw executableLookupError(command, error) }) signal?.throwIfAborted() return command } @@ -134,7 +136,7 @@ export class E2BSubprocessRuntime extends SubprocessRuntime { const result = await sandbox.commands.run( `${prefix}command -v -- ${quoteE2BShellArg(command)}`, { cwd: this.ctx.e2b.cwd, envs: e2bControlEnvs(), ...signalOpts(signal) }, - ) + ).catch((error: unknown) => { throw executableLookupError(command, error) }) signal?.throwIfAborted() const executable = result.stdout.trim() if (executable.includes('\n') || (!posix.isAbsolute(executable) && !executable.includes('/'))) { @@ -144,6 +146,14 @@ export class E2BSubprocessRuntime extends SubprocessRuntime { return posix.resolve(this.ctx.e2b.cwd, executable) } + /** @inheritdoc */ + async terminalEnvironment(signal?: AbortSignal): Promise { + const sandbox = await this.ctx.e2b.getSandbox() + const environment = scrubRemoteEnvironment(await readRemoteEnvironment(sandbox, signal)) + const defaultShell = environment.get('SHELL') + return { platform: 'posix', ...defaultShell === undefined ? {} : { defaultShell } } + } + /** @inheritdoc */ spawn(spec: SubprocessSpawnSpec): SubprocessHandle { if (this.disposing) throw new Error('subprocess-e2b: service is disposing') @@ -229,3 +239,10 @@ export class E2BSubprocessRuntime extends SubprocessRuntime { } export default E2BSubprocessRuntime + +function executableLookupError(command: string, error: unknown): unknown { + if (error instanceof CommandExitError && (error.exitCode === 1 || error.exitCode === 127)) { + return new SubprocessExecutableNotFoundError(`subprocess-e2b: executable ${JSON.stringify(command)} was not found`, { cause: error }) + } + return error +} diff --git a/packages/e2b/subprocess-e2b/src/terminal.ts b/packages/e2b/subprocess-e2b/src/terminal.ts index 29af8c29c5..1740e88077 100644 --- a/packages/e2b/subprocess-e2b/src/terminal.ts +++ b/packages/e2b/subprocess-e2b/src/terminal.ts @@ -310,6 +310,14 @@ export class E2BTerminalHandle implements SubprocessTerminalHandle { }) } + /** @inheritdoc */ + resize(cols: number, rows: number): Promise { + return this.trackOperation(async (signal) => { + if (this.topLevelExited) throw new Error('terminal process has exited') + await this.sandbox.pty.resize(this.pid, { cols, rows }, { signal }) + }) + } + /** @inheritdoc */ inspectForeground(): Promise { return this.trackOperation(signal => this.inspectForegroundOnce(signal)) @@ -479,7 +487,7 @@ export async function spawnE2BTerminal( try { const ambient = await readRemoteEnvironment(sandbox, spec.signal) controlEnvs = bootstrapEnvironment(ambient) - const environment = serializeRemoteEnvironment(ambient, spec.env) + const environment = serializeRemoteEnvironment(ambient, { ...spec.env, TERM: spec.terminalType }) const argv = serializeValues(spec.argv, 'argv') stateDirectoryCreated = true await sandbox.files.makeDir(stateDir, signalOpts(spec.signal)) diff --git a/packages/e2b/subprocess-e2b/tests/subprocess.spec.ts b/packages/e2b/subprocess-e2b/tests/subprocess.spec.ts index c1f1a3e4d7..8e9938c9cc 100644 --- a/packages/e2b/subprocess-e2b/tests/subprocess.spec.ts +++ b/packages/e2b/subprocess-e2b/tests/subprocess.spec.ts @@ -464,7 +464,7 @@ describe('E2BSubprocessHandle', () => { for (const graceMs of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) { expect(() => service.spawn(spec({ graceMs }))).toThrow('graceMs must be a positive finite number') void expect(service.spawnTerminal({ - argv: ['bash'], cwd: '/w', rows: 24, cols: 80, graceMs, + argv: ['bash'], cwd: '/w', rows: 24, cols: 80, terminalType: 'dumb', graceMs, })).rejects.toThrow('graceMs must be a positive finite number') } }) diff --git a/packages/e2b/subprocess-e2b/tests/terminal.spec.ts b/packages/e2b/subprocess-e2b/tests/terminal.spec.ts index bc6118b0ff..e726269502 100644 --- a/packages/e2b/subprocess-e2b/tests/terminal.spec.ts +++ b/packages/e2b/subprocess-e2b/tests/terminal.spec.ts @@ -11,7 +11,7 @@ import { type Sandbox, } from '@deepseek-ai/dsh-e2b' import type E2BRuntime from '@deepseek-ai/dsh-e2b' -import type { SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import { SubprocessExecutableNotFoundError, type SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess' import E2BSubprocessRuntime from '@deepseek-ai/dsh-subprocess-e2b' import { spawnE2BTerminal } from '../src/terminal.ts' @@ -85,6 +85,7 @@ class FakeTerminalSandbox { readonly commands: string[] = [] readonly commandOptions: CommandOptions[] = [] readonly inputs: Array<{ pid: number; data: Buffer }> = [] + readonly sizes: Array<{ pid: number; cols: number; rows: number }> = [] readonly removed: string[] = [] readonly directories: string[] = [] readonly writes = new Map() @@ -196,6 +197,10 @@ class FakeTerminalSandbox { }, }, pty: { + resize: async (pid: number, size: { cols: number; rows: number }, options?: { signal?: AbortSignal }): Promise => { + options?.signal?.throwIfAborted() + this.sizes.push({ pid, ...size }) + }, create: async (options: Parameters[0]): Promise => { this.createOptions = options if (this.createError !== undefined) throw this.createError @@ -240,6 +245,7 @@ function spec(overrides: Partial = {}): SubprocessT cwd: '/workspace', rows: 24, cols: 80, + terminalType: 'dumb', graceMs: 5, env: { TERM: 'dumb', DSH_SESSION_ID: 'owner', TOKEN_EXPLICIT: 'kept' }, ...overrides, @@ -270,6 +276,21 @@ function testSpawn( } describe('E2B terminal allocation', () => { + it('preserves the requested terminal type and resizes the remote PTY only while running', async () => { + const fake = new FakeTerminalSandbox() + const terminal = await testSpawn(runtime(fake), spec({ terminalType: 'xterm-256color' }), '/runtime/interactive') + try { + expect(fake.writes.get('/runtime/interactive/environment')).toContain('TERM=xterm-256color\0') + await terminal.resize(132, 40) + expect(fake.sizes).toEqual([{ pid: terminal.pid, cols: 132, rows: 40 }]) + fake.handle.succeed() + await terminal.done + await expect(terminal.resize(80, 24)).rejects.toThrow('terminal process has exited') + } finally { + await terminal.terminate() + } + }) + it('hides bootstrap-shell bytes and preserves requested-shell bytes across the output boundary', async () => { const fake = new FakeTerminalSandbox() const terminal = await testSpawn(runtime(fake), spec(), '/runtime/terminal-one') @@ -813,6 +834,40 @@ describe('E2B subprocess terminal service', () => { await expect(ctx.subprocess.resolveExecutable('node')).rejects.toThrow('did not resolve') }) + it.each(['/missing/shell', 'missing-shell'])('classifies missing remote executable %s and preserves other failures', async (command) => { + const { ctx, fake, fiber } = await service() + try { + for (const exitCode of [1, 127]) { + const error = commandError(exitCode) + fake.commandFailure = error + await expect(ctx.subprocess.resolveExecutable(command)).rejects.toMatchObject({ + constructor: SubprocessExecutableNotFoundError, + cause: error, + }) + } + for (const error of [commandError(126), new Error('sandbox disconnected')]) { + fake.commandFailure = error + await expect(ctx.subprocess.resolveExecutable(command)).rejects.toBe(error) + } + } finally { + await fiber.dispose() + } + }) + + it('discovers terminal environment facts from the remote sandbox and propagates transport failures', async () => { + const { ctx, fake, fiber } = await service() + try { + fake.ambient = 'SHELL=/opt/fish\0' + await expect(ctx.subprocess.terminalEnvironment()).resolves.toEqual({ platform: 'posix', defaultShell: '/opt/fish' }) + fake.ambient = 'PATH=/usr/bin\0' + await expect(ctx.subprocess.terminalEnvironment()).resolves.toEqual({ platform: 'posix' }) + fake.commandFailure = new Error('sandbox disconnected') + await expect(ctx.subprocess.terminalEnvironment()).rejects.toThrow('sandbox disconnected') + } finally { + await fiber.dispose() + } + }) + it('rejects a non-positive poll cadence at load', async () => { const ctx = new Context() ctx.provide('e2b', runtime(new FakeTerminalSandbox())) diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 0945d530e8..8d0c1d2a2e 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -1112,6 +1112,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'client-ui-agent-preset AgentPresetLabel id \'agent-preset\'', 'client-ui-jobs JobListAction id \'job-list\'', 'client-ui-schedule ScheduleCatalogAction id \'schedule-catalog\'', + 'client-ui-sidebar-terminal TerminalRecovery', 'experimental-client-ui-agent-team TeamAction id \'agent-team\'', ], replaceRisk: 'none', @@ -2032,7 +2033,9 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ hookContext: '', slotInject: '', declaredBy: 'an entry in \'root\' (client-ui-layout), so it exists while that entry is mounted', - occupants: [], + occupants: [ + 'client-ui-sidebar-terminal TerminalCleanup', + ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'shell.overlay\', () => ctx.slots.register(\n { name: \'shell.overlay\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', source: 'packages/client/ui-layout/src/client/index.ts:91', @@ -2263,6 +2266,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'client-ui-sidebar-documentpreview TextPreview', 'client-ui-sidebar-files FilesBody', 'client-ui-sidebar-right GuideBody', + 'client-ui-sidebar-terminal TerminalBody', ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.right.pane.tab\', () => ctx.slots.register(\n { name: \'sidebar.right.pane.tab\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', @@ -2308,6 +2312,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'client-ui-sidebar-documentpreview TextTitle', 'client-ui-sidebar-files FilesTitle', 'client-ui-sidebar-right GuideTitle', + 'client-ui-sidebar-terminal TerminalTitle', ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.right.pane.tab.title\', () => ctx.slots.register(\n { name: \'sidebar.right.pane.tab.title\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 4606b99848..5b7b37ffd1 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -2381,6 +2381,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'command', description: 'absolute executable path or bare PATH name.' }, { name: 'env', description: 'explicit environment entries used for lookup.' }, { name: 'signal', description: 'aborts remote or local lookup.' }], returns: 'a canonical executable path.', }, + { + signature: 'abstract terminalEnvironment(signal?: AbortSignal): Promise', + description: 'Inspect shell-selection facts in the provider\'s execution environment.', + parameters: [{ name: 'signal', description: 'cancellation of remote environment inspection.' }], + returns: 'platform and preferred shell; executable lookup and allocation remain separate operations.', + }, { signature: 'abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle', description: 'Start one managed child process from a fully-specified spec; this seam applies no defaults.', @@ -2451,6 +2457,60 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'terminalController', + summary: 'Typed Remote control of transient Session-owned terminal processes.', + description: 'Typed Remote control of transient Session-owned terminal processes.', + methods: [ + { + signature: '@Remote environment(agent: Agent, signal: AbortSignal): TerminalEnvironment', + description: 'Read the Session working directory and terminal limits without resolving a shell.', + parameters: [{ name: 'agent', description: 'Session owner supplied by the Gateway.' }, { name: 'signal', description: 'request cancellation.' }], + returns: 'the Session workspace directory and terminal limits.', + }, + { + signature: '@Remote list(agent: Agent): WebTerminalInfo[]', + description: 'List running and exited terminals available for reattachment.', + parameters: [{ name: 'agent', description: 'Session owner supplied by the Gateway.' }], + returns: 'terminals retained for this Host lifetime.', + }, + { + signature: '@Remote async create(agent: Agent, request: TerminalCreateRequest, signal: AbortSignal): Promise', + description: 'Allocate an interactive shell once for a caller-generated identity.', + parameters: [{ name: 'agent', description: 'Session owner supplied by the Gateway.' }, { name: 'request', description: 'initial dimensions and idempotency identity.' }, { name: 'signal', description: 'allocation cancellation; committed terminals survive disconnection.' }], + returns: 'the existing or newly committed terminal.', + }, + { + signature: '@Remote({ mode: \'stream\' }) follow(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, signal: AbortSignal): AsyncIterable', + description: 'Attach to a terminal without binding its process lifetime to the transport.', + parameters: [{ name: 'agent', description: 'Session owner supplied by the Gateway.' }, { name: 'id', description: 'terminal identity.' }, { name: 'attachmentId', description: 'new exclusive input attachment.' }, { name: 'signal', description: 'physical stream cancellation.' }], + returns: 'screen recovery followed by output and metadata changes.', + }, + { + signature: '@Remote async write(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, data: string): Promise', + description: 'Deliver raw input, including Tab completion and control characters.', + parameters: [{ name: 'agent', description: 'Session owner supplied by the Gateway.' }, { name: 'id', description: 'terminal identity.' }, { name: 'attachmentId', description: 'current writable attachment.' }, { name: 'data', description: 'input bytes represented as UTF-8 text.' }], + returns: 'after provider input acceptance.', + }, + { + signature: '@Remote async resize(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, cols: number, rows: number): Promise', + description: 'Update the dimensions of the PTY and recovery screen.', + parameters: [{ name: 'agent', description: 'Session owner supplied by the Gateway.' }, { name: 'id', description: 'terminal identity.' }, { name: 'attachmentId', description: 'current writable attachment.' }, { name: 'cols', description: 'column count.' }, { name: 'rows', description: 'row count.' }], + returns: 'after the resize completes.', + }, + { + signature: '@Remote rename(agent: Agent, id: WebTerminalId, title: string): void', + description: 'Rename a terminal without changing its shell.', + parameters: [{ name: 'agent', description: 'Session owner supplied by the Gateway.' }, { name: 'id', description: 'terminal identity.' }, { name: 'title', description: 'nonempty display title, at most 120 characters.' }], + }, + { + signature: '@Remote async close(agent: Agent, id: WebTerminalId): Promise', + description: 'Close an identity to future creation and kill its process range; repeated closes succeed.', + parameters: [{ name: 'agent', description: 'Session owner supplied by the Gateway.' }, { name: 'id', description: 'terminal identity.' }], + returns: 'after provider cleanup succeeds. A failure retains the terminal for retry.', + }, + ], + }, { key: 'terminals', summary: 'In-process registry for replaceable PTY backends and exact-Agent sessions.', @@ -5824,13 +5884,17 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SubprocessStdio', declaration: 'export interface SubprocessStdio {\n stdin: SubprocessStdinMode;\n stdout: SubprocessOutputMode;\n stderr: SubprocessOutputMode;\n}', }, + { + name: 'SubprocessTerminalEnvironment', + declaration: 'export interface SubprocessTerminalEnvironment {\n platform: \'posix\' | \'windows\';\n defaultShell?: string;\n}', + }, { name: 'SubprocessTerminalForeground', declaration: 'export interface SubprocessTerminalForeground {\n processGroupId: number;\n inputWaiting: boolean;\n}', }, { name: 'SubprocessTerminalHandle', - declaration: 'export interface SubprocessTerminalHandle {\n readonly pid: number;\n readonly output: Readable;\n readonly done: Promise;\n write(data: string): Promise;\n inspectForeground(): Promise;\n signalForeground(signal: SubprocessTerminalSignal): Promise;\n terminate(): Promise;\n}', + declaration: 'export interface SubprocessTerminalHandle {\n readonly pid: number;\n readonly output: Readable;\n readonly done: Promise;\n write(data: string): Promise;\n resize(cols: number, rows: number): Promise;\n inspectForeground(): Promise;\n signalForeground(signal: SubprocessTerminalSignal): Promise;\n terminate(): Promise;\n}', }, { name: 'SubprocessTerminalSignal', @@ -5838,7 +5902,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SubprocessTerminalSpawnSpec', - declaration: 'export interface SubprocessTerminalSpawnSpec {\n argv: readonly string[];\n cwd: string;\n env?: Record | undefined;\n rows: number;\n cols: number;\n graceMs: number;\n signal?: AbortSignal | undefined;\n}', + declaration: 'export interface SubprocessTerminalSpawnSpec {\n argv: readonly string[];\n cwd: string;\n env?: Record | undefined;\n rows: number;\n cols: number;\n terminalType: string;\n graceMs: number;\n signal?: AbortSignal | undefined;\n}', }, { name: 'SurfaceEvent', @@ -5920,6 +5984,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'TeamWaitResult', declaration: 'export interface TeamWaitResult {\n readonly timedOut: boolean;\n}', }, + { + name: 'TerminalAttachmentId', + declaration: 'export type TerminalAttachmentId = Branded<\'TerminalAttachmentId\'>;', + }, { name: 'TerminalBackend', declaration: 'export interface TerminalBackend {\n readonly type: string;\n spawn(spec: TerminalBackendSpawnSpec): Promise;\n}', @@ -5936,6 +6004,18 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'TerminalCallView', declaration: 'export interface TerminalCallView {\n card: \'terminal\';\n title: string;\n description?: string;\n cwd?: string;\n}', }, + { + name: 'TerminalCreateRequest', + declaration: 'export interface TerminalCreateRequest {\n readonly id: WebTerminalId;\n readonly cols: number;\n readonly rows: number;\n}', + }, + { + name: 'TerminalEnvironment', + declaration: 'export interface TerminalEnvironment {\n readonly cwd: string;\n readonly maxInputBytes: number;\n readonly maxCols: number;\n readonly maxRows: number;\n readonly scrollback: number;\n}', + }, + { + name: 'TerminalFrame', + declaration: 'export type TerminalFrame = {\n readonly type: \'snapshot\';\n readonly sequence: number;\n readonly screen: string;\n readonly info: WebTerminalInfo;\n} | {\n readonly type: \'output\';\n readonly sequence: number;\n readonly data: string;\n} | {\n readonly type: \'state\';\n readonly info: WebTerminalInfo;\n};', + }, { name: 'TerminalReadRequest', declaration: 'export interface TerminalReadRequest {\n offset?: number;\n count?: number;\n}', @@ -5980,6 +6060,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'TerminalSessionStatus', declaration: 'export type TerminalSessionStatus = {\n kind: \'running\';\n} | {\n kind: \'exited\';\n exitCode: number | null;\n signal: NodeJS.Signals | null;\n};', }, + { + name: 'TerminalShell', + declaration: 'export interface TerminalShell {\n readonly path: string;\n readonly args: readonly string[];\n readonly name: string;\n}', + }, { name: 'TerminalSignal', declaration: 'export type TerminalSignal = \'SIGINT\' | \'SIGTERM\' | \'SIGKILL\' | \'SIGTSTP\' | \'SIGHUP\';', @@ -6348,6 +6432,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'WebSource', declaration: 'export interface WebSource {\n url: string;\n title?: string;\n snippet?: string;\n publishedAt?: string;\n}', }, + { + name: 'WebTerminalId', + declaration: 'export type WebTerminalId = Branded<\'WebTerminalId\'>;', + }, + { + name: 'WebTerminalInfo', + declaration: 'export interface WebTerminalInfo {\n readonly id: WebTerminalId;\n readonly title: string;\n readonly shell: TerminalShell;\n readonly cwd: string;\n readonly cols: number;\n readonly rows: number;\n readonly state: \'running\' | \'exited\' | \'failed\';\n readonly exitCode: number | null;\n readonly error?: string;\n readonly controllerId?: TerminalAttachmentId;\n}', + }, { name: 'WebUpgradeRoute', declaration: 'export interface WebUpgradeRoute {\n path: string;\n handler: (req: IncomingMessage, socket: Duplex, head: Buffer) => void | Promise;\n}', diff --git a/packages/fs/tool-fs-search/tests/tools.spec.ts b/packages/fs/tool-fs-search/tests/tools.spec.ts index 3ecfd86a5b..d2582bcf55 100644 --- a/packages/fs/tool-fs-search/tests/tools.spec.ts +++ b/packages/fs/tool-fs-search/tests/tools.spec.ts @@ -146,6 +146,7 @@ class FakeHandle implements SubprocessHandle { * assert on the exact spawn specs and settled handles. */ class FakeSubprocess extends SubprocessRuntime { + async terminalEnvironment() { return { platform: 'posix' as const } } spawns: SubprocessSpawnSpec[] = [] override async resolveExecutable(command: string): Promise { return command } override spawnTerminal(): Promise { throw new Error('search tools spawn pipes, never terminals') } diff --git a/packages/shell/pwsh-local/tests/executor.spec.ts b/packages/shell/pwsh-local/tests/executor.spec.ts index 6f8a4f28b3..0a7b6834d9 100644 --- a/packages/shell/pwsh-local/tests/executor.spec.ts +++ b/packages/shell/pwsh-local/tests/executor.spec.ts @@ -188,6 +188,7 @@ describe('resolvePwshPath and candidatePwshPaths (pure, every platform)', () => describe('spawn construction (pure, every platform)', () => { /** A subprocess service that records spawn specs and settles instantly. */ class CapturingSubprocessRuntime extends SubprocessRuntime { + async terminalEnvironment() { return { platform: 'posix' as const } } specs: SubprocessSpawnSpec[] = [] done: Promise = Promise.resolve({ exitCode: 0, signal: null }) stderrText = '' diff --git a/packages/subprocess/subprocess-local/README.i18n.yaml b/packages/subprocess/subprocess-local/README.i18n.yaml index 7979b68d4d..3cef0557d1 100644 --- a/packages/subprocess/subprocess-local/README.i18n.yaml +++ b/packages/subprocess/subprocess-local/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subprocess/subprocess-local/README.md -README.md: 63d4d5d403d3559778dcad76a8c96cf677f1a977 -README.zh.md: f0a8b629b2ed17b6a6e9385a6eb565443c21ab26 +README.md: 11fd4cbcd4150cae75f15d5b5cc2d5294c2576ae +README.zh.md: 4bc0bc284770b68ef9cd8c6d09f39ccc8866d0f2 diff --git a/packages/subprocess/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md index 63d4d5d403..11fd4cbcd4 100644 --- a/packages/subprocess/subprocess-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -113,6 +113,8 @@ Read these pages when the provider-level contract is not enough. They move from ----- +Terminal allocation advertises the caller-provided `terminalType` through TERM and node-pty. Dynamic resize updates the existing PTY. Output backpressure pauses native reads until the consumer drains; explicit termination resumes a paused reader to receive the exit notification. + ## Model Experience diff --git a/packages/subprocess/subprocess-local/README.zh.md b/packages/subprocess/subprocess-local/README.zh.md index f0a8b629b2..4bc0bc2847 100644 --- a/packages/subprocess/subprocess-local/README.zh.md +++ b/packages/subprocess/subprocess-local/README.zh.md @@ -113,6 +113,8 @@ spill 文件以 `0600` 权限、`O_EXCL` 与随机名称在 `0700` 每进程目 ----- +终端分配通过 TERM 和 node-pty 使用调用者指定的 `terminalType`。动态 resize 更新已有 PTY。输出背压会暂停原生读取,待消费者排空后恢复;显式终止会恢复暂停的读取,以接收退出通知。 + ## 模型体验 diff --git a/packages/subprocess/subprocess-local/src/index.ts b/packages/subprocess/subprocess-local/src/index.ts index beaeef58ba..85d80f4465 100644 --- a/packages/subprocess/subprocess-local/src/index.ts +++ b/packages/subprocess/subprocess-local/src/index.ts @@ -10,15 +10,17 @@ import { constants } from 'node:fs' import { access, stat } from 'node:fs/promises' +import { userInfo } from 'node:os' import { delimiter, extname, isAbsolute, resolve } from 'node:path' import { Context } from '@deepseek-ai/cordis' import * as nodePty from 'node-pty' import type { IPtyForkOptions } from 'node-pty' -import { SubprocessRuntime } from '@deepseek-ai/dsh-subprocess' +import { SubprocessRuntime, SubprocessExecutableNotFoundError } from '@deepseek-ai/dsh-subprocess' import type { SubprocessHandle, SubprocessSpawnSpec, SubprocessTerminalHandle, + SubprocessTerminalEnvironment, SubprocessTerminalSpawnSpec, } from '@deepseek-ai/dsh-subprocess' import { @@ -147,7 +149,7 @@ export class LocalSubprocessRuntime extends SubprocessRuntime { } } signal?.throwIfAborted() - throw new Error(absolute + throw new SubprocessExecutableNotFoundError(absolute ? `subprocess-local: command ${JSON.stringify(command)} is not an executable file` : `subprocess-local: command ${JSON.stringify(command)} was not found on PATH`) } @@ -226,6 +228,15 @@ export class LocalSubprocessRuntime extends SubprocessRuntime { ) } + /** @inheritdoc */ + // oxlint-disable-next-line typescript/require-await -- Keep the provider promise rejection semantics for cancelled inspection. + async terminalEnvironment(signal?: AbortSignal): Promise { + signal?.throwIfAborted() + const platform = process.platform === 'win32' ? 'windows' : 'posix' + const defaultShell = platform === 'windows' ? process.env.ComSpec : process.env.SHELL ?? userInfo().shell ?? undefined + return { platform, ...defaultShell === undefined ? {} : { defaultShell } } + } + // Local PTY allocation is synchronous, but the provider contract permits remote asynchronous allocation. // oxlint-disable-next-line typescript/require-await -- Preserve promise rejection semantics at the async provider contract. async spawnTerminal(spec: SubprocessTerminalSpawnSpec): Promise { @@ -236,11 +247,11 @@ export class LocalSubprocessRuntime extends SubprocessRuntime { spec.signal?.throwIfAborted() const env = targetEnvironment(spec) const options: IPtyForkOptions = { - name: 'dumb', + name: spec.terminalType, rows: spec.rows, cols: spec.cols, cwd: spec.cwd, - env, + env: { ...env, TERM: spec.terminalType }, } const inspector = this.terminalInspector ?? createProcessInspector() const containmentMode = this.selectContainmentMode('terminal') @@ -248,7 +259,7 @@ export class LocalSubprocessRuntime extends SubprocessRuntime { ? prepareLinuxTerminalScope(spec, { ...env, PWD: spec.cwd, - TERM: 'dumb', + TERM: spec.terminalType, }) : undefined if (scope !== undefined) { diff --git a/packages/subprocess/subprocess-local/src/terminal.ts b/packages/subprocess/subprocess-local/src/terminal.ts index 8d165dbd20..3f9f9c9811 100644 --- a/packages/subprocess/subprocess-local/src/terminal.ts +++ b/packages/subprocess/subprocess-local/src/terminal.ts @@ -65,6 +65,7 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { private cleanup: Promise | undefined private managedOwnerCleaned = false private exited = false + private outputPaused = false private trackedDescendants: ProcessIdentity[] = [] /** The spawned shell's start identity; scans stop adopting members once the root pid no longer carries it. */ private readonly rootIdentity: ProcessIdentity | undefined @@ -86,7 +87,19 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { this.pid = terminal.pid this.rootIdentity = inspector.snapshot().tree(this.pid).find(member => member.pid === this.pid) this.done = this.outcome.promise - this.dataDisposable = terminal.onData((data) => { this.output.write(Buffer.from(data, 'utf8')) }) + const resume = (): void => { + if (!this.outputPaused) return + this.outputPaused = false + if (!this.exited) terminal.resume() + } + this.output.on('drain', resume) + this.output.once('close', () => { this.output.off('drain', resume) }) + this.dataDisposable = terminal.onData((data) => { + if (!this.output.write(Buffer.from(data, 'utf8')) && this.cleanup === undefined && !this.outputPaused) { + this.outputPaused = true + terminal.pause() + } + }) this.exitDisposable = terminal.onExit(({ exitCode, signal: exitSignal }) => { if (this.exited) return this.exited = true @@ -115,6 +128,12 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { this.terminal.write(data) } + // oxlint-disable-next-line typescript/require-await -- Provider operations share promise rejection semantics. + async resize(cols: number, rows: number): Promise { + if (this.exited) throw new Error('terminal process has exited') + this.terminal.resize(cols, rows) + } + // Local inspection is synchronous; the seam returns a promise for remote transports. // oxlint-disable-next-line typescript/require-await -- Preserve promise rejection semantics at the async provider contract. async inspectForeground(): Promise { @@ -154,6 +173,7 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { terminate(): Promise { if (this.cleanup !== undefined) return this.cleanup + if (this.outputPaused) { this.outputPaused = false; this.terminal.resume() } const cleanup = this.closeOnce() this.cleanup = cleanup void cleanup.catch(() => { this.cleanup = undefined }) diff --git a/packages/subprocess/subprocess-local/tests/fixtures/process-exit-host.ts b/packages/subprocess/subprocess-local/tests/fixtures/process-exit-host.ts index 721c5134c3..080b503152 100644 --- a/packages/subprocess/subprocess-local/tests/fixtures/process-exit-host.ts +++ b/packages/subprocess/subprocess-local/tests/fixtures/process-exit-host.ts @@ -48,6 +48,7 @@ if (kind === 'ordinary') { cwd: process.cwd(), rows: 24, cols: 80, + terminalType: 'dumb', graceMs: 30_000, }) } diff --git a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts index a392d35d08..8c54ca20b1 100644 --- a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts +++ b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts @@ -537,6 +537,7 @@ describe('Linux PTY bootstrap reuse', () => { env: { TARGET: 'yes' }, rows: 24, cols: 80, + terminalType: 'dumb', graceMs: 100, } as const diff --git a/packages/subprocess/subprocess-local/tests/local.spec.ts b/packages/subprocess/subprocess-local/tests/local.spec.ts index 4ca9f64439..813f24d071 100644 --- a/packages/subprocess/subprocess-local/tests/local.spec.ts +++ b/packages/subprocess/subprocess-local/tests/local.spec.ts @@ -1,4 +1,6 @@ import { PassThrough } from 'node:stream' +import os from 'node:os' +import { syncBuiltinESMExports } from 'node:module' import { describe, expect, it, vi } from 'vitest' import { basename, dirname, relative, resolve } from 'node:path' import { Context } from '@deepseek-ai/cordis' @@ -40,6 +42,51 @@ function spec(command: string, overrides: Partial = {}): Su } describe('LocalSubprocessRuntime', () => { + it('discovers the platform shell without inventing a missing default and honors cancellation', async () => { + let loginShell: string | null = '/account/shell' + const userInfo = vi.spyOn(os, 'userInfo').mockImplementation(() => ({ + uid: 1, gid: 1, username: 'terminal-user', homedir: '/home/terminal-user', shell: loginShell, + })) + let fiber: Awaited> | undefined + let restorePlatform: (() => void) | undefined + try { + syncBuiltinESMExports() + const ctx = new Context() + fiber = await ctx.plugin(LocalSubprocessRuntime) + const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux') + restorePlatform = () => { platform.mockRestore() } + vi.stubEnv('SHELL', '/environment/shell') + await expect(ctx.subprocess.terminalEnvironment()).resolves.toEqual({ + platform: 'posix', defaultShell: '/environment/shell', + }) + expect(userInfo).not.toHaveBeenCalled() + vi.stubEnv('SHELL', undefined) + await expect(ctx.subprocess.terminalEnvironment()).resolves.toEqual({ + platform: 'posix', defaultShell: '/account/shell', + }) + loginShell = null + await expect(ctx.subprocess.terminalEnvironment()).resolves.toEqual({ platform: 'posix' }) + platform.mockReturnValue('win32') + vi.stubEnv('ComSpec', 'C:\\Windows\\System32\\cmd.exe') + await expect(ctx.subprocess.terminalEnvironment()).resolves.toEqual({ + platform: 'windows', defaultShell: 'C:\\Windows\\System32\\cmd.exe', + }) + vi.stubEnv('ComSpec', undefined) + await expect(ctx.subprocess.terminalEnvironment()).resolves.toEqual({ platform: 'windows' }) + platform.mockReturnValue('linux') + userInfo.mockClear() + const reason = new Error('terminal inspection cancelled') + await expect(ctx.subprocess.terminalEnvironment(AbortSignal.abort(reason))).rejects.toBe(reason) + expect(userInfo).not.toHaveBeenCalled() + } finally { + restorePlatform?.() + vi.unstubAllEnvs() + userInfo.mockRestore() + syncBuiltinESMExports() + await fiber?.dispose() + } + }) + it('places the host-exit finalizer before listeners that predate the service', async () => { const baseline = new Set(process.listeners('exit')) const prior = vi.fn() @@ -223,7 +270,7 @@ describe('LocalSubprocessRuntime', () => { const ctx = new Context() const fiber = await ctx.plugin(LocalSubprocessRuntime) const base: SubprocessTerminalSpawnSpec = { - argv: ['bash'], cwd: process.cwd(), rows: 24, cols: 80, graceMs: 10, + argv: ['bash'], cwd: process.cwd(), rows: 24, cols: 80, terminalType: 'dumb', graceMs: 10, } await expect(ctx.subprocess.spawnTerminal({ ...base, argv: [] })).rejects.toThrow('must contain a program') await expect(ctx.subprocess.spawnTerminal({ ...base, argv: [''] })).rejects.toThrow('must contain a program') @@ -240,6 +287,7 @@ describe('LocalSubprocessRuntime', () => { output: new PassThrough(), done: Promise.resolve({ exitCode: 0, signal: null }), write: async () => {}, + resize: async () => {}, inspectForeground: async () => undefined, signalForeground: async () => 1, terminate, @@ -267,6 +315,7 @@ describe('LocalSubprocessRuntime', () => { output: new PassThrough(), done: Promise.resolve({ exitCode: 0, signal: null }), write: async () => {}, + resize: async () => {}, inspectForeground: async () => undefined, signalForeground: async () => 1, terminate: vi.fn(async () => { throw firstFailure }), @@ -319,6 +368,7 @@ describe('LocalSubprocessRuntime', () => { output: new PassThrough(), done: Promise.resolve({ exitCode: 0, signal: null }), write: async () => {}, + resize: async () => {}, inspectForeground: async () => undefined, signalForeground: async () => 1, terminate: vi.fn(async () => { throw failure }), @@ -394,7 +444,7 @@ describe('LocalSubprocessRuntime', () => { const fiber = await ctx.plugin(IsolatedLocalSubprocessRuntime) const service = ctx.subprocess as InstanceType const handle = await ctx.subprocess.spawnTerminal({ - argv: ['shell'], cwd: process.cwd(), rows: 24, cols: 80, graceMs: 1, + argv: ['shell'], cwd: process.cwd(), rows: 24, cols: 80, terminalType: 'dumb', graceMs: 1, }) expect((service as unknown as { terminals: Set }).terminals.size).toBe(1) exitListener?.({ exitCode: 0 }) @@ -486,6 +536,7 @@ describe('LocalSubprocessRuntime', () => { cwd: targetCwd, rows: 24, cols: 80, + terminalType: 'dumb', graceMs: 10, env: { PWD: '/stale-parent-cwd', TERM: 'xterm-256color', TARGET_VALUE: 'preserved' }, }) @@ -566,7 +617,7 @@ describe('LocalSubprocessRuntime', () => { runtime.terminalInspector = inspector await expect(runtime.spawnTerminal({ - argv: ['shell'], cwd: process.cwd(), rows: 24, cols: 80, graceMs: 10, + argv: ['shell'], cwd: process.cwd(), rows: 24, cols: 80, terminalType: 'dumb', graceMs: 10, })).rejects.toBe(launchFailure) expect(cleanup).toHaveBeenCalledOnce() } finally { @@ -613,7 +664,7 @@ describe('LocalSubprocessRuntime', () => { signalProcess: () => {}, } const handle = await ctx.subprocess.spawnTerminal({ - argv: ['shell'], cwd: process.cwd(), rows: 24, cols: 80, graceMs: 1, + argv: ['shell'], cwd: process.cwd(), rows: 24, cols: 80, terminalType: 'dumb', graceMs: 1, }) exitListener?.({ exitCode: 0 }) await handle.done diff --git a/packages/subprocess/subprocess-local/tests/native-containment.spec.ts b/packages/subprocess/subprocess-local/tests/native-containment.spec.ts index 67bc46ecff..623d43cc01 100644 --- a/packages/subprocess/subprocess-local/tests/native-containment.spec.ts +++ b/packages/subprocess/subprocess-local/tests/native-containment.spec.ts @@ -228,6 +228,7 @@ while :; do sleep 60; done cwd: scratch, rows: 24, cols: 80, + terminalType: 'dumb', graceMs: 100, }) const output = captureTerminalOutput(handle) diff --git a/packages/subprocess/subprocess-local/tests/terminal.spec.ts b/packages/subprocess/subprocess-local/tests/terminal.spec.ts index 7b02470f7c..bf34abdd79 100644 --- a/packages/subprocess/subprocess-local/tests/terminal.spec.ts +++ b/packages/subprocess/subprocess-local/tests/terminal.spec.ts @@ -13,6 +13,9 @@ import type { SubprocessTerminalSignal } from '@deepseek-ai/dsh-subprocess' class FakePty { pid = 123 + readonly pause = vi.fn() + readonly resume = vi.fn() + readonly resize = vi.fn() readonly writes: string[] = [] readonly kills: string[] = [] autoExitOnKill = true @@ -114,6 +117,41 @@ function makeHandle(pty: FakePty, inspector: ProcessInspector, graceMs: number): } describe('LocalTerminalHandle', () => { + it('pauses native output until the consumer drains and resumes before termination', async () => { + const pty = new FakePty() + const handle = makeHandle(pty, new FakeInspector(), 10) + const chunk = 'x'.repeat(handle.output.readableHighWaterMark + handle.output.writableHighWaterMark) + pty.emitData(chunk) + expect(pty.pause).toHaveBeenCalledOnce() + const chunks: Buffer[] = [] + handle.output.on('data', (data: Buffer) => { chunks.push(data) }) + await vi.waitFor(() => { expect(pty.resume).toHaveBeenCalledOnce() }) + expect(Buffer.concat(chunks).toString()).toBe(chunk) + handle.output.pause() + pty.emitData(chunk) + expect(pty.pause).toHaveBeenCalledTimes(2) + await handle.terminate() + handle.output.emit('drain') + expect(pty.resume).toHaveBeenCalledTimes(2) + handle.output.destroy() + }) + + it('does not resume native output when a pending drain follows the PTY exit', async () => { + const pty = new FakePty() + const handle = makeHandle(pty, new FakeInspector(), 10) + try { + pty.emitData('x'.repeat(handle.output.readableHighWaterMark + handle.output.writableHighWaterMark)) + expect(pty.pause).toHaveBeenCalledOnce() + pty.emitExit(0) + await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null }) + handle.output.emit('drain') + expect(pty.resume).not.toHaveBeenCalled() + } finally { + await handle.terminate() + handle.output.destroy() + } + }) + it('terminates a managed range with TERM when it stops within the grace period', async () => { vi.useFakeTimers() const pty = new FakePty() @@ -419,6 +457,8 @@ describe('LocalTerminalHandle', () => { pty.emitData('hello €') await handle.write('input\r') + await handle.resize(100, 30) + expect(pty.resize).toHaveBeenCalledWith(100, 30) expect(pty.writes).toEqual(['input\r']) expect(await handle.inspectForeground()).toEqual({ processGroupId: 456, inputWaiting: true }) expect(inspector.stdinChecks).toEqual([[456, 123]]) @@ -427,6 +467,7 @@ describe('LocalTerminalHandle', () => { pty.emitExit(7, 9) pty.emitExit(0) + await expect(handle.resize(80, 24)).rejects.toThrow('terminal process has exited') expect(await handle.done).toEqual({ exitCode: null, signal: 'SIGKILL' }) await handle.terminate() expect(Buffer.concat(chunks).toString('utf8')).toBe('hello €') diff --git a/packages/subprocess/subprocess/README.i18n.yaml b/packages/subprocess/subprocess/README.i18n.yaml index c539bbce10..c2f88b1719 100644 --- a/packages/subprocess/subprocess/README.i18n.yaml +++ b/packages/subprocess/subprocess/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subprocess/subprocess/README.md -README.md: 5b6ee4d3c1ab8a7b7071e40e845ad28a5213aa8b -README.zh.md: 6e0b4f2ca37e952d74701e6061c99fa941d81892 +README.md: d881878f4bc0acdcb585982c12a1caf106e44574 +README.zh.md: c10a1f4d3786b0746273429436b61a1c186ff561 diff --git a/packages/subprocess/subprocess/README.md b/packages/subprocess/subprocess/README.md index 5b6ee4d3c1..d881878f4b 100644 --- a/packages/subprocess/subprocess/README.md +++ b/packages/subprocess/subprocess/README.md @@ -123,6 +123,8 @@ Read these pages when the package-level contract is not enough. They move from t ----- +Terminal consumers use `terminalEnvironment()` to read the provider platform and preferred shell, and `resolveExecutable()` to verify candidates. A completed lookup miss throws `SubprocessExecutableNotFoundError`; transport failures remain distinct. `spawnTerminal` requires `terminalType` and initial dimensions, and its handle supports `resize(cols, rows)` without reallocating the process. + ## Model Experience diff --git a/packages/subprocess/subprocess/README.zh.md b/packages/subprocess/subprocess/README.zh.md index 6e0b4f2ca3..c10a1f4d37 100644 --- a/packages/subprocess/subprocess/README.zh.md +++ b/packages/subprocess/subprocess/README.zh.md @@ -123,6 +123,8 @@ spawn 会立即返回活动句柄,而不公开目标身份。`done` 独立报 ----- +终端消费者通过 `terminalEnvironment()` 读取 provider 平台和首选 shell,通过 `resolveExecutable()` 验证候选。确定未找到可执行文件时抛出 `SubprocessExecutableNotFoundError`,传输故障仍单独报告。`spawnTerminal` 要求 `terminalType` 和初始尺寸,返回的 handle 通过 `resize(cols, rows)` 调整尺寸,不重新分配进程。 + ## 模型体验 diff --git a/packages/subprocess/subprocess/src/index.ts b/packages/subprocess/subprocess/src/index.ts index 0cda343664..c914b48ede 100644 --- a/packages/subprocess/subprocess/src/index.ts +++ b/packages/subprocess/subprocess/src/index.ts @@ -12,7 +12,7 @@ import { Context, Service } from '@deepseek-ai/cordis' import { proxyEnvironmentForChild } from '@deepseek-ai/dsh-http-proxy' import { DSH_ENV_PREFIX } from './types.ts' import type { SubprocessHandle, SubprocessSpawnSpec } from './types.ts' -import type { SubprocessTerminalHandle, SubprocessTerminalSpawnSpec } from './types.ts' +import type { SubprocessTerminalEnvironment, SubprocessTerminalHandle, SubprocessTerminalSpawnSpec } from './types.ts' export { DSH_ENV_PREFIX } from './types.ts' export type { @@ -30,6 +30,7 @@ export type { SubprocessStdinMode, SubprocessStdio, SubprocessTerminalForeground, + SubprocessTerminalEnvironment, SubprocessTerminalHandle, SubprocessTerminalSignal, SubprocessTerminalSpawnSpec, @@ -134,6 +135,13 @@ export abstract class SubprocessRuntime extends Service { signal?: AbortSignal, ): Promise + /** + * Inspect shell-selection facts in the provider's execution environment. + * @param signal - cancellation of remote environment inspection. + * @returns platform and preferred shell; executable lookup and allocation remain separate operations. + */ + abstract terminalEnvironment(signal?: AbortSignal): Promise + /** * Start one managed child process from a fully-specified spec; this seam * applies no defaults. @@ -154,3 +162,15 @@ export abstract class SubprocessRuntime extends Service { } export default SubprocessRuntime + +/** Executable lookup completed without finding an executable file. */ +export class SubprocessExecutableNotFoundError extends Error { + /** + * @param message - provider-specific lookup diagnostic. + * @param options - original provider failure, when available. + */ + constructor(message: string, options?: ErrorOptions) { + super(message, options) + this.name = 'SubprocessExecutableNotFoundError' + } +} diff --git a/packages/subprocess/subprocess/src/types.ts b/packages/subprocess/subprocess/src/types.ts index 8f07e05fa9..809bd61064 100644 --- a/packages/subprocess/subprocess/src/types.ts +++ b/packages/subprocess/subprocess/src/types.ts @@ -198,6 +198,14 @@ export interface SubprocessHandle { */ export type SubprocessTerminalSignal = 'SIGINT' | 'SIGTERM' | 'SIGKILL' | 'SIGTSTP' | 'SIGHUP' +/** Shell-selection facts from the subprocess provider's execution environment. */ +export interface SubprocessTerminalEnvironment { + /** Operating-system family that interprets executable paths and shell arguments. */ + platform: 'posix' | 'windows' + /** Login or environment-selected shell, when the provider can resolve one. */ + defaultShell?: string +} + /** A fully specified terminal-process spawn. */ export interface SubprocessTerminalSpawnSpec { /** Executable and arguments; `argv[0]` is the program. */ @@ -210,6 +218,8 @@ export interface SubprocessTerminalSpawnSpec { rows: number /** Initial terminal column count. */ cols: number + /** Terminal emulation advertised to the child through TERM. */ + terminalType: string /** TERM-to-KILL cleanup grace for the complete terminal session. */ graceMs: number /** Cancellation of terminal allocation; a published handle owns its later lifetime. */ @@ -242,6 +252,12 @@ export interface SubprocessTerminalHandle { * @param data - text to deliver without implicit newline conversion. */ write(data: string): Promise + /** + * Change the terminal dimensions and notify its foreground application. + * @param cols - positive terminal column count. + * @param rows - positive terminal row count. + */ + resize(cols: number, rows: number): Promise /** * Inspect the current foreground process group. * @returns its id and input-wait fact, or undefined when no foreground group can be resolved. diff --git a/packages/subprocess/subprocess/tests/service.spec.ts b/packages/subprocess/subprocess/tests/service.spec.ts index 1e71605781..7692c6519b 100644 --- a/packages/subprocess/subprocess/tests/service.spec.ts +++ b/packages/subprocess/subprocess/tests/service.spec.ts @@ -16,6 +16,7 @@ import type { * is all an implementation owes the abstract class. */ class StubSubprocessRuntime extends SubprocessRuntime { + async terminalEnvironment() { return { platform: 'posix' as const } } async resolveExecutable(command: string): Promise { return `/bin/${command}` } @@ -42,6 +43,7 @@ class StubSubprocessRuntime extends SubprocessRuntime { output: new PassThrough(), done: Promise.resolve({ exitCode: 0, signal: null }), write: async () => {}, + resize: async () => {}, inspectForeground: async () => ({ processGroupId: 1, inputWaiting: true }), signalForeground: async () => 1, terminate: async () => {}, diff --git a/packages/terminal/terminal-bash/src/index.ts b/packages/terminal/terminal-bash/src/index.ts index 9d38bc0c9e..741a0401e1 100644 --- a/packages/terminal/terminal-bash/src/index.ts +++ b/packages/terminal/terminal-bash/src/index.ts @@ -200,6 +200,7 @@ export class BashTerminalBackend implements TerminalBackend { env: childEnvironment(spec, this.config.shellDialect), rows: this.config.rows, cols: this.config.cols, + terminalType: 'dumb', graceMs: this.config.disposeGraceMs, signal: spec.signal, }) diff --git a/packages/terminal/terminal-bash/tests/index.spec.ts b/packages/terminal/terminal-bash/tests/index.spec.ts index af707d8269..629c82ffe7 100644 --- a/packages/terminal/terminal-bash/tests/index.spec.ts +++ b/packages/terminal/terminal-bash/tests/index.spec.ts @@ -72,6 +72,7 @@ function terminalHandle(): SubprocessTerminalHandle { output, done: Promise.resolve({ exitCode: 0, signal: null }), write: async () => {}, + resize: async () => {}, inspectForeground: async () => ({ processGroupId: 123, inputWaiting: true }), signalForeground: async () => 123, terminate: async () => { output.end() }, @@ -79,6 +80,7 @@ function terminalHandle(): SubprocessTerminalHandle { } class StubSubprocessRuntime extends SubprocessRuntime { + async terminalEnvironment() { return { platform: 'posix' as const } } async resolveExecutable(command: string): Promise { return command } spawn(_spec: SubprocessSpawnSpec): SubprocessHandle { throw new Error('unused') } async spawnTerminal(_spec: SubprocessTerminalSpawnSpec): Promise { @@ -336,6 +338,7 @@ describe('BashTerminalBackend startup rollback', () => { output, done: outcome.promise, write: async () => {}, + resize: async () => {}, inspectForeground: async () => ({ processGroupId: 123, inputWaiting: true }), signalForeground: async () => 123, async terminate() { diff --git a/packages/terminal/terminal-bash/tests/session.spec.ts b/packages/terminal/terminal-bash/tests/session.spec.ts index b28c579375..d8051db3b6 100644 --- a/packages/terminal/terminal-bash/tests/session.spec.ts +++ b/packages/terminal/terminal-bash/tests/session.spec.ts @@ -86,6 +86,8 @@ class FakeTerminal implements SubprocessTerminalHandle { }) } + async resize(_cols: number, _rows: number): Promise {} + async write(data: string): Promise { if (this.throwWrite) throw new Error('write failed') this.writes.push(data) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bcea481379..8f66333696 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -908,6 +908,9 @@ importers: '@deepseek-ai/dsh-api-settings-controller': specifier: workspace:^ version: link:../settings-controller + '@deepseek-ai/dsh-api-terminal-controller': + specifier: workspace:^ + version: link:../terminal-controller '@deepseek-ai/dsh-api-workspace-controller': specifier: workspace:^ version: link:../workspace-controller @@ -1139,6 +1142,67 @@ importers: specifier: workspace:^ version: link:../../typert/protocol + packages/api/terminal-controller: + dependencies: + '@deepseek-ai/dsh-deque': + specifier: workspace:^ + version: link:../../util/deque + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:^ + version: link:../../typert/protocol + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + '@xterm/addon-serialize': + specifier: ^0.14.0 + version: 0.14.0 + '@xterm/headless': + specifier: ^6.0.0 + version: 6.0.0 + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-api-gateway': + specifier: workspace:^ + version: link:../gateway + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-client-store': + specifier: workspace:^ + version: link:../../client/store + '@deepseek-ai/dsh-fs': + specifier: workspace:^ + version: link:../../fs/fs + '@deepseek-ai/dsh-sandbox': + specifier: workspace:^ + version: link:../../sandbox/sandbox + '@deepseek-ai/dsh-sandbox-policy': + specifier: workspace:^ + version: link:../../sandbox/sandbox-policy + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection + '@deepseek-ai/dsh-subprocess': + specifier: workspace:^ + version: link:../../subprocess/subprocess + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local + '@deepseek-ai/dsh-util-crypto': + specifier: workspace:^ + version: link:../../util/crypto + packages/api/workspace-controller: dependencies: '@deepseek-ai/dsh-deque': @@ -1759,6 +1823,9 @@ importers: '@deepseek-ai/dsh-api-settings-controller': specifier: workspace:^ version: link:../../api/settings-controller + '@deepseek-ai/dsh-api-terminal-controller': + specifier: workspace:^ + version: link:../../api/terminal-controller '@deepseek-ai/dsh-api-workspace-controller': specifier: workspace:^ version: link:../../api/workspace-controller @@ -1885,6 +1952,9 @@ importers: '@deepseek-ai/dsh-client-ui-sidebar-right': specifier: workspace:^ version: link:../../client/ui-sidebar-right + '@deepseek-ai/dsh-client-ui-sidebar-terminal': + specifier: workspace:^ + version: link:../../client/ui-sidebar-terminal '@deepseek-ai/dsh-client-ui-skill': specifier: workspace:^ version: link:../../client/ui-skill @@ -3973,6 +4043,78 @@ importers: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) + packages/client/ui-sidebar-terminal: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes + '@deepseek-ai/dsh-api-terminal-controller': + specifier: workspace:^ + version: link:../../api/terminal-controller + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-store': + specifier: workspace:^ + version: link:../store + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../../test-support/client-runtime + '@deepseek-ai/dsh-client-ui-conversation': + specifier: workspace:^ + version: link:../ui-conversation + '@deepseek-ai/dsh-client-ui-dockkit': + specifier: workspace:^ + version: link:../ui-dockkit + '@deepseek-ai/dsh-client-ui-layout': + specifier: workspace:^ + version: link:../ui-layout + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-session': + specifier: workspace:^ + version: link:../ui-session + '@deepseek-ai/dsh-client-ui-sidebar-right': + specifier: workspace:^ + version: link:../ui-sidebar-right + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@testing-library/react': + specifier: ^16.1.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + '@types/react-dom': + specifier: ~18.3.0 + version: 18.3.7(@types/react@18.3.31) + '@xterm/addon-fit': + specifier: ^0.11.0 + version: 0.11.0 + '@xterm/xterm': + specifier: ^6.0.0 + version: 6.0.0 + clsx: + specifier: ^2.0.0 + version: 2.1.1 + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + packages/client/ui-skill: devDependencies: '@deepseek-ai/cordis': @@ -14407,9 +14549,18 @@ packages: resolution: {integrity: sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==} engines: {node: '>=10.0.0'} + '@xterm/addon-fit@0.11.0': + resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==} + + '@xterm/addon-serialize@0.14.0': + resolution: {integrity: sha512-uteyTU1EkrQa2Ux6P/uFl2fzmXI46jy5uoQMKEOM0fKTyiW7cSn0WrFenHm5vO5uEXX/GpwW/FgILvv3r0WbkA==} + '@xterm/headless@6.0.0': resolution: {integrity: sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw==} + '@xterm/xterm@6.0.0': + resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==} + '@yao-pkg/pkg-fetch@3.6.4': resolution: {integrity: sha512-2JXvS9HbMudLlzEjSaJ7bLNnF/WlTv7iaTAJp2Tk8pBsgwCyL9p4rIN8cztHlyZF7qVzr1EaBjd7DQooFk0azQ==} hasBin: true @@ -21013,8 +21164,14 @@ snapshots: '@xmldom/xmldom@0.8.15': {} + '@xterm/addon-fit@0.11.0': {} + + '@xterm/addon-serialize@0.14.0': {} + '@xterm/headless@6.0.0': {} + '@xterm/xterm@6.0.0': {} + '@yao-pkg/pkg-fetch@3.6.4': dependencies: picocolors: 1.1.1 diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index a599410f68..2006a997c4 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -125,6 +125,7 @@ export const SERVICE_PAGE: Record = { workspaceRegistry: 'workspace.md', workspaceController: 'workspace.md', workspaceFiles: 'workspace.md', + terminalController: 'workspace.md', directoryPickerController: 'workspace.md', } @@ -147,6 +148,7 @@ export const SERVICE_PAGE: Record = { * to a model as `cordis_runtime_inspect what:"client"`). */ export const SERVICE_WALK_EXEMPTIONS: Record = { + webTerminals: 'client-side terminal view models — packages/api/terminal-controller/README.md owns the API', appReady: 'not a service: launcher-provided successful-startup signal — packages/boot/cmdline/README.md owns the launcher contract', appExit: 'not a service: launcher-provided bounded process-exit callback — packages/boot/cmdline/README.md owns the launcher contract', cmdlineArgs: 'not a service: launcher-provided immutable app argument accessor — packages/boot/cmdline/README.md owns the launcher contract', @@ -394,6 +396,7 @@ export const LINK_MAP: Readonly> = { SubprocessOutputReader: 'subprocess.md', SubprocessSpawnSpec: 'subprocess.md', SubprocessTerminalHandle: 'subprocess.md', + SubprocessTerminalEnvironment: 'subprocess.md', SubprocessTerminalSpawnSpec: 'subprocess.md', CodeRunRequest: 'code-runtime.md', CodeRunResult: 'code-runtime.md', @@ -771,6 +774,12 @@ export const TYPE_LINK_EXEMPTIONS: Readonly> = { WorkspaceFileRange: 'Host workspace file endpoint contract is owned by packages/api/workspace-files/README.md', WorkspaceFileStat: 'Host workspace file endpoint contract is owned by packages/api/workspace-files/README.md', WorkspaceFileText: 'Host workspace file endpoint contract is owned by packages/api/workspace-files/README.md', + TerminalEnvironment: 'Browser terminal environment fields are owned by packages/api/terminal-controller/README.md', + WebTerminalInfo: 'Browser terminal metadata is owned by packages/api/terminal-controller/README.md', + TerminalCreateRequest: 'Browser terminal allocation fields are owned by packages/api/terminal-controller/README.md', + TerminalAttachmentId: 'Browser terminal input ownership is owned by packages/api/terminal-controller/README.md', + TerminalFrame: 'Browser terminal stream frames are owned by packages/api/terminal-controller/README.md', + WebTerminalId: 'Browser terminal identity is owned by packages/api/terminal-controller/README.md', } /** Repository data policy consumed by the Cordis catalog projector. */ diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 6c9ee96fb9..4f039af002 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -199,6 +199,13 @@ const SERVICE_ROLES: ServiceRole[] = [ mode: 'core', note: 'Serves stat, paged text, byte windows, directory listings, and the change feed for files inside a Session\'s workspace root, confined by lstat, containment, and a stat re-check.', }, + { + key: 'terminalController', + pkg: 'api-terminal-controller', + title: 'Session interactive terminal Remote controller', + mode: 'core', + note: 'Owns user terminal processes, default shell resolution and bounded screen recovery through the subprocess provider and typed Remote transport.', + }, { key: 'workspaceController', pkg: 'api-workspace-controller', diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 19be6bbc26..294bd11da5 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -44,6 +44,8 @@ const NO_MODEL_EXPERIENCE_SECTION: Readonly> = { * blocks. A package moves on or off this list with its context behavior. */ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { + 'packages/api/terminal-controller': { kind: 'none', reason: 'User-owned terminal processes and screen streams never enter model requests or Session events.' }, + 'packages/client/ui-sidebar-terminal': { kind: 'none', reason: 'The browser renders user terminal screens without exposing them to the model.' }, 'packages/attachment/attachment': { kind: 'indirect', reason: 'The storage seam delegates model request rendering to provider adapters.' }, 'packages/attachment/attachment-local': { kind: 'indirect', reason: 'The local backend delegates model request rendering to provider adapters.' }, 'packages/shell/shell': { kind: 'indirect', reason: 'The service interface delegates all model rendering to dsh-tool-bash.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 83cabd9e4a..09b3e76e05 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -28,6 +28,11 @@ // not declaration path aliases, keep each package/vendor source compiled // under its own tsconfig boundary. "paths": { + "@deepseek-ai/dsh-api-terminal-controller": ["./packages/api/terminal-controller/src/index.ts"], + "@deepseek-ai/dsh-api-terminal-controller/types": ["./packages/api/terminal-controller/src/types.ts"], + "@deepseek-ai/dsh-api-terminal-controller/client": ["./packages/api/terminal-controller/src/client/index.ts"], + "@deepseek-ai/dsh-client-ui-sidebar-terminal": ["./packages/client/ui-sidebar-terminal/src/index.ts"], + "@deepseek-ai/dsh-client-ui-sidebar-terminal/client": ["./packages/client/ui-sidebar-terminal/src/client/index.ts"], "@deepseek-ai/cordis": ["./vendor/cordis/src"], "@deepseek-ai/cosmokit": ["./vendor/cosmokit/src"], "@deepseek-ai/schemastery": ["./vendor/schemastery/src"], diff --git a/tsconfig.client.json b/tsconfig.client.json index 8ffb94127a..b831138e97 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -41,6 +41,8 @@ "packages/client/*/tests/**/*.host.spec.ts" ], "references": [ + { "path": "./packages/client/ui-sidebar-terminal" }, + { "path": "./packages/api/terminal-controller/tsconfig.client.json" }, // Producer-to-UI spill tests execute the real nested dispatch worker. { "path": "./packages/code-runtime/code-runtime-worker-thread" }, // Shared leaf: web e2e boots the real host webserver (fixture + real-host diff --git a/tsconfig.host.json b/tsconfig.host.json index c5a3f118b3..bbc7885490 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -96,6 +96,7 @@ "apps/web/tests/goal-multi-turn-actions.e2e.ts", "apps/web/tests/agent-team-panel.e2e.ts", "apps/web/tests/sidebar-right.e2e.ts", + "apps/web/tests/sidebar-terminal.e2e.ts", "apps/web/tests/chat-scroll-fixture.ts", "apps/web/tests/chat-scroll-contract.e2e.ts", "apps/web/tests/chat-long-interactions.e2e.ts", @@ -133,6 +134,7 @@ "scripts/client-bundle-purity.spec.ts" ], "references": [ + { "path": "./packages/api/terminal-controller/tsconfig.host.json" }, { "path": "./packages/client/ui-deliverables/tsconfig.host.json" }, { "path": "./vendor/cosmokit" }, { "path": "./vendor/schemastery" }, From 819c2b2442c8025534747b28eeb3ae3501da4d2a Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 9 Sep 2026 16:47:16 +0800 Subject: [PATCH 19/79] feat(client): show expanded JSON strings as raw text --- .../ui-primitives/src/JsonTree.module.css | 38 ++- .../client/ui-primitives/src/JsonTree.tsx | 233 +++++++++++------- 2 files changed, 175 insertions(+), 96 deletions(-) diff --git a/packages/client/ui-primitives/src/JsonTree.module.css b/packages/client/ui-primitives/src/JsonTree.module.css index 59ed4ba033..536e7bc052 100644 --- a/packages/client/ui-primitives/src/JsonTree.module.css +++ b/packages/client/ui-primitives/src/JsonTree.module.css @@ -109,6 +109,8 @@ } .stringField { + position: relative; + z-index: 1; display: flow-root; } @@ -121,9 +123,31 @@ word-break: break-all; } -.stringField[data-expanded='true'] > .stringText { - max-height: none; - overflow: visible; +.stringRaw { + margin: 4px 0 0 12px; + overflow: auto; + color: var(--dsw-alias-label-primary); + font: inherit; + overflow-wrap: normal; + white-space: pre; + word-break: normal; +} + +.stringActions { + position: absolute; + top: 1px; + right: 4px; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 4px; + width: fit-content; + height: 16px; +} + +.stringActions > .copySlot { + opacity: 1; + pointer-events: auto; } .stringToggleSlot { @@ -152,10 +176,6 @@ pointer-events: auto; } -.stringCollapse { - margin-left: 4px; -} - .stringToggle:hover { color: var(--dsw-alias-label-primary); } @@ -228,10 +248,6 @@ padding-right: 24px; } -.row:has(> .copySlot) > .stringField .stringCollapse { - margin-right: 24px; -} - .row:hover:not(:has(.row:hover)) > .copySlot, .row[data-json-copy-active] > .copySlot, .copySlot:focus-within { diff --git a/packages/client/ui-primitives/src/JsonTree.tsx b/packages/client/ui-primitives/src/JsonTree.tsx index 064e8c323e..10a97dfd2a 100644 --- a/packages/client/ui-primitives/src/JsonTree.tsx +++ b/packages/client/ui-primitives/src/JsonTree.tsx @@ -226,7 +226,7 @@ interface JsonTreeNodeProps { onClaimTabStop: (id: string) => void onRowHover: (row: HTMLElement, target: RowTarget) => void path: JsonPath - renderCopy: ((path: JsonPath) => ReactNode) | undefined + renderCopy: ((target: RowTarget, persistent?: boolean) => ReactNode) | undefined tabStopId: string | null value: unknown } @@ -236,20 +236,24 @@ function JsonString({ field, labels, lastElement, + renderCopy, value, }: { collapsedStringLines: number field: string | undefined labels: JsonTreeLabels lastElement: boolean + renderCopy: ((persistent?: boolean) => ReactNode) | undefined value: string }) { const contentsId = useId() const contentRef = useRef(null) + const rawRef = useRef(null) const [expanded, setExpanded] = useState(false) const [truncated, setTruncated] = useState(false) useLayoutEffect(() => { + if (expanded) return const content = contentRef.current as HTMLSpanElement const measure = () => { const lineHeight = Number.parseFloat(getComputedStyle(content).lineHeight) @@ -260,32 +264,60 @@ function JsonString({ const observer = new ResizeObserver(measure) observer.observe(content) return () => { observer.disconnect() } - }, [collapsedStringLines, field, lastElement, value]) + }, [collapsedStringLines, expanded, field, lastElement, value]) - return ( - - - {truncated && !expanded && ( - - - - )} - {field !== undefined && {fieldText(field)}:} - {primitiveValue(value)} - {!lastElement && ,} - {truncated && expanded && ( + useLayoutEffect(() => { + if (!expanded) return + const raw = rawRef.current as HTMLPreElement + const clips: HTMLElement[] = [] + const tree = raw.closest(`.${css.root}`) as HTMLElement + for (let parent = tree.parentElement; parent !== null; parent = parent.parentElement) { + if (/auto|scroll|hidden|clip/.test(getComputedStyle(parent).overflowY)) clips.push(parent) + } + const measure = () => { + let top = 0 + let bottom = window.innerHeight + for (const clip of clips) { + const rect = clip.getBoundingClientRect() + const style = getComputedStyle(clip) + top = Math.max(top, rect.top + clip.clientTop) + bottom = Math.min(bottom, rect.top + clip.clientTop + clip.clientHeight + - Number.parseFloat(style.paddingBottom)) + } + const available = bottom - Math.max(top, raw.getBoundingClientRect().top) + raw.style.maxHeight = `${Math.max(16, available - 4)}px` + } + measure() + const observer = new ResizeObserver(measure) + observer.observe(raw) + for (const clip of clips) observer.observe(clip) + window.addEventListener('resize', measure) + window.addEventListener('scroll', measure, true) + return () => { + observer.disconnect() + window.removeEventListener('resize', measure) + window.removeEventListener('scroll', measure, true) + } + }, [expanded, value]) + + if (expanded) { + const fieldId = `${contentsId}-field` + return ( +
+ {field !== undefined && {fieldText(field)}:} +
+          {value}
+        
+
- )} + {renderCopy?.(true)} +
+
+ ) + } + + return ( + <> + {renderCopy?.()} + + + {truncated && ( + + + + )} + {field !== undefined && {fieldText(field)}:} + {primitiveValue(value)} + {!lastElement && ,} + -
+ ) } @@ -347,7 +407,7 @@ function JsonTreeNode({ onRowHover(event.currentTarget, { path, value }) }} > - {renderCopy?.(path)} + {typeof value !== 'string' && renderCopy?.({ path, value })} {children}
) @@ -360,6 +420,7 @@ function JsonTreeNode({ value={value} labels={labels} lastElement={lastElement} + renderCopy={renderCopy === undefined ? undefined : persistent => renderCopy({ path, value }, persistent)} />, ) } @@ -494,7 +555,7 @@ export function JsonTree({ : pathId([Array.isArray(data) ? firstExpandableIndex : firstExpandableEntry[0]]) : isExpandableValue(data) && rootEntries.length > 0 ? pathId([]) : null const activeRowRef = useRef() - const copyButtonRef = useRef(null) + const copyButtonRef = useRef(null) const copyMenuOpenRef = useRef(false) const resetTimer = useRef>() const [copyTarget, setCopyTarget] = useState() @@ -548,11 +609,10 @@ export function JsonTree({ if (event.target.closest('[data-json-copy-button]') === null) clearCopyTarget() } - const copy = async (mode: 'json' | 'path' | 'prettyJson' | 'value') => { - /* v8 ignore next -- copy controls only render while their target exists. */ - if (copyTarget === undefined) return + const copy = async (target: RowTarget, mode: 'json' | 'path' | 'prettyJson' | 'value') => { + setCopyTarget(target) try { - await navigator.clipboard.writeText(copyText(copyTarget, mode)) + await navigator.clipboard.writeText(copyText(target, mode)) setCopyState('copied') } catch { setCopyState('failed') @@ -562,58 +622,61 @@ export function JsonTree({ } const [rootOpen, rootClose] = bracketOf(data) - const copyTargetIsObject = typeof copyTarget?.value === 'object' && copyTarget.value !== null - const defaultCopyMode = copyTargetIsObject ? 'prettyJson' : 'value' - const copyTitle = copyState === 'copied' - ? labels.copied - : copyState === 'failed' - ? labels.copyFailed - : copyTargetIsObject ? labels.copyPrettyJson : labels.copyValue - - const renderCopy = copyable ? (path: JsonPath) => ( - - {copyTarget !== undefined && pathId(copyTarget.path) === pathId(path) && ( - void copy(defaultCopyMode)} - onContextMenu={(event) => { - event.preventDefault() - event.stopPropagation() - copyMenuOpenRef.current = true - setCopyMenuOpen(true) - }} - > - {copyState === 'copied' - ? - : } - - )} - items={copyTargetIsObject ? objectCopyMenuItems(labels) : valueCopyMenuItems(labels)} - onSelect={(id) => { - void copy(id as 'json' | 'path' | 'prettyJson' | 'value') - copyMenuOpenRef.current = false - setCopyMenuOpen(false) - }} - onClose={clearCopyTarget} - getAnchorRect={() => ( - copyButtonRef.current as HTMLButtonElement - ).getBoundingClientRect()} - /> - )} - - ) : undefined + const renderCopy = copyable ? (target: RowTarget, persistent = false) => { + const active = copyTarget !== undefined && pathId(copyTarget.path) === pathId(target.path) + const state = active ? copyState : 'idle' + const object = typeof target.value === 'object' && target.value !== null + const copyTitle = state === 'copied' + ? labels.copied + : state === 'failed' + ? labels.copyFailed + : object ? labels.copyPrettyJson : labels.copyValue + return ( + + {(persistent || active) && ( + void copy(target, object ? 'prettyJson' : 'value')} + onContextMenu={(event) => { + event.preventDefault() + event.stopPropagation() + copyButtonRef.current = event.currentTarget + setCopyTarget(target) + copyMenuOpenRef.current = true + setCopyMenuOpen(true) + }} + > + {state === 'copied' + ? + : } + + )} + items={object ? objectCopyMenuItems(labels) : valueCopyMenuItems(labels)} + onSelect={(id) => { + void copy(target, id as 'json' | 'path' | 'prettyJson' | 'value') + copyMenuOpenRef.current = false + setCopyMenuOpen(false) + }} + onClose={clearCopyTarget} + getAnchorRect={() => ( + copyButtonRef.current as HTMLButtonElement + ).getBoundingClientRect()} + /> + )} + + ) + } : undefined return (
- {renderCopy?.([])} + {renderCopy?.({ path: [], value: data })} {rootOpen}
Date: Wed, 9 Sep 2026 17:10:44 +0800 Subject: [PATCH 20/79] fix(client): restore assistant timing from recorded streams --- ...6-embedded-stream-record-readers.i18n.yaml | 4 +-- ...26-09-06-embedded-stream-record-readers.md | 2 +- ...09-06-embedded-stream-record-readers.zh.md | 2 +- apps/web/tests/navigation-panes.e2e.ts | 25 ++++++++++++++++--- apps/web/tests/turn-tail-actions.e2e.ts | 4 +-- packages/client/ui-chat/README.i18n.yaml | 4 +-- packages/client/ui-chat/README.md | 2 ++ packages/client/ui-chat/README.zh.md | 2 ++ .../client/conversation-nodes/assistant.ts | 23 +++++++++++++++-- ...nversation-node-definitions.client.spec.ts | 11 ++++++-- .../client/ui-trajectory/README.i18n.yaml | 4 +-- packages/client/ui-trajectory/README.md | 2 ++ packages/client/ui-trajectory/README.zh.md | 2 ++ .../client/trajectory-assistant-definition.ts | 21 ++++++++++++++-- .../conversation-definitions.client.spec.ts | 11 ++++++-- .../web/navigation-panes/timing.expected.md | 12 +++++++++ 16 files changed, 110 insertions(+), 21 deletions(-) create mode 100644 snapshots/web/navigation-panes/timing.expected.md diff --git a/.agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.i18n.yaml index e78d7bdc51..a5e163f76e 100644 --- a/.agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.md -2026-09-06-embedded-stream-record-readers.md: 972fee634833cef5fd7b0a54f69780b9370f0cc3 -2026-09-06-embedded-stream-record-readers.zh.md: faba6e179887a9943926f2c73aa8e42a10cee300 +2026-09-06-embedded-stream-record-readers.md: 76e109de577d070093bb7123aa50d9a4ae7fcbe5 +2026-09-06-embedded-stream-record-readers.zh.md: 33f4b31117197c47e7f2ff1637bc13fa2cbf1a8c diff --git a/.agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.md b/.agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.md index 972fee6348..76e109de57 100644 --- a/.agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.md +++ b/.agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.md @@ -20,7 +20,7 @@ After v2 embedded streams settlement widened with the message content and Chat a - Run readers: `runFirstTokenTime` and `runFirstVisibleTime` reconstruct the first qualifying member's time from `time0` and the `dt` gaps and stop scanning there; a name-bearing Tool-call run yields `time0` without reading a fragment. - Stream readers: `assistantStreamFirstTokenTime`, `assistantStreamHasVisibleContent`, `assistantStreamHasVisibleText`, `lastAssistantStreamChunk(stream, type)` (backward scan), `assistantStreamChunks(stream, type)`, `joinAssistantStreamText`, and `assembleAssistantStream`, which feeds a `BlockAssembler` one joined delta per run (assembly only concatenates, so blocks, usage, finish, and replay state equal the per-member result). `RawStreamChunkType` excludes the delta types, so a raw-chunk lookup can never silently skip packed members. -Session Stats reads `assistantStreamFirstTokenTime`; the token meter reads `lastAssistantStreamChunk(stream, 'usage')` and assembles provider output through `assembleAssistantStream`; the subagent output fold appends `joinAssistantStreamText`; the Session Controller scans `assistantStreamChunks(stream, 'block-end')` for images. +Session Stats, Chat, and Trajectory read `assistantStreamFirstTokenTime` from both `assistant/attempt` and `assistant/message`, retaining the Step's first token across retries. Chat and Trajectory settle content from the assembled message while reading timing independently, so reopening history retains TTFT and decoding metrics without expanding streams. The token meter reads `lastAssistantStreamChunk(stream, 'usage')` and assembles provider output through `assembleAssistantStream`; the subagent output fold appends `joinAssistantStreamText`; the Session Controller scans `assistantStreamChunks(stream, 'block-end')` for images. `expandAssistantStream` keeps its strict validation and its remaining callers, which need every member or validate the stream at a durable boundary: Session restore validation, the v1-to-v2 migration validator and publication Worker replay, the reconnect baseline, and test support. diff --git a/.agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.zh.md b/.agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.zh.md index faba6e1798..33f4b31117 100644 --- a/.agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.zh.md @@ -20,7 +20,7 @@ Session 格式 v2 将每次模型尝试的紧凑流(`AssistantStreamRecord[]` - Run 读取器:`runFirstTokenTime` 与 `runFirstVisibleTime` 从 `time0` 与 `dt` 间隔重建首个合格成员的时间并停止扫描;带名称的 Tool-call run 直接产出 `time0`,不读片段。 - 流读取器:`assistantStreamFirstTokenTime`、`assistantStreamHasVisibleContent`、`assistantStreamHasVisibleText`、`lastAssistantStreamChunk(stream, type)`(逆向扫描)、`assistantStreamChunks(stream, type)`、`joinAssistantStreamText` 与 `assembleAssistantStream`(每个 run 向 `BlockAssembler` 喂入一个拼接后的 delta;组装只做拼接,因此 blocks、usage、finish 与 replay state 与逐成员结果一致)。`RawStreamChunkType` 排除 delta 类型,因此原始 chunk 查找不可能静默跳过打包成员。 -Session Stats 读取 `assistantStreamFirstTokenTime`;token 计量读取 `lastAssistantStreamChunk(stream, 'usage')` 并通过 `assembleAssistantStream` 组装提供商输出;子代理输出折叠追加 `joinAssistantStreamText`;Session Controller 用 `assistantStreamChunks(stream, 'block-end')` 扫描镜像。 +Session Stats、Chat 与 Trajectory 从 `assistant/attempt` 和 `assistant/message` 读取 `assistantStreamFirstTokenTime`,跨重试保留步骤的首个 token。Chat 与 Trajectory 从组装后的消息结算内容,并独立读取计时,因此重新打开历史时无需展开流便能保留 TTFT 与解码指标。token 计量读取 `lastAssistantStreamChunk(stream, 'usage')` 并通过 `assembleAssistantStream` 组装提供商输出;子代理输出折叠追加 `joinAssistantStreamText`;Session Controller 用 `assistantStreamChunks(stream, 'block-end')` 扫描镜像。 `expandAssistantStream` 保留其严格校验与其余调用方(需要每个成员或在持久边界校验流):Session 恢复校验、v1-to-v2 迁移校验器与发布 Worker 重放、重连基线、测试支撑。 diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index d51e08bb49..e99568cb1f 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -24,6 +24,7 @@ import { expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './supp const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/navigation-panes', import.meta.url)) const SEED = join(SNAPSHOT_DIR, 'session.v3.jsonl') const TRAJECTORY_EXPECTED = join(SNAPSHOT_DIR, 'trajectory.expected.md') +const TIMING_EXPECTED = join(SNAPSHOT_DIR, 'timing.expected.md') const SEARCH_EXPECTED = join(SNAPSHOT_DIR, 'search-results.expected.md') const TERMINAL_EXPECTED = join(SNAPSHOT_DIR, 'terminal-card.expected.md') const MODE = webSnapshotMode() @@ -97,7 +98,8 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { const raw = await readFile(SEED, 'utf8') expect(fixtureUserPrompts(raw), 'seed fixture must carry exactly the two drive prompts') .toEqual([PROMPT_TURN1, PROMPT_TURN2]) - await seedSession(scaffold, raw, SEED_ID) + // The inspector's calendar date must not depend on the day the test runs. + await seedSession(scaffold, raw, SEED_ID, undefined, { createdAt: Date.UTC(2026, 0, 1) }) } browser = await chromium.launch() }, 120_000) @@ -264,13 +266,30 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { await page.evaluate(() => { document.body.removeAttribute('data-ds-dark-theme') }) await page.getByRole('tab', { name: 'Result' }).click() await expect.poll(() => page.getByText('NAVIGATION_OK', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1) - expect(await page.locator('[data-timeline-span="message"][data-assistant-timing="true"]').count()).toBe(0) + expect(await page.locator('[data-timeline-span="message"][data-assistant-timing="true"]').count()).toBeGreaterThan(0) const snapshot = (await captureStableAria(page, '[class*="viewArea"]', scaffold.workspaceCwd)) .split(SEED_ID).join('{{seededId}}') await compareOrRefreshGolden(TRAJECTORY_EXPECTED, snapshot, MODE) await details.getByRole('button', { name: 'Close details' }).click() }, 60_000) + it.skipIf(MODE === 'record')('restores Assistant timing from recorded history', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-timing')) + await ensureSeedOpen(page) + await page.getByRole('tab', { name: 'Trajectory' }).click() + await page.getByRole('button', { name: 'Request #1', exact: true }).click() + const details = page.getByRole('complementary', { name: 'Event details' }) + await details.getByRole('tab', { name: 'Timing', exact: true }).click() + const panel = details.getByRole('tabpanel', { name: 'Timing' }) + for (const metric of ['TTFT', 'Generation', 'Throughput']) { + const value = panel.getByText(metric, { exact: true }).locator('..').locator('dd') + await expect.poll(() => value.textContent()).toMatch(/^\d/) + } + expect(await panel.getByText('First token unavailable', { exact: true }).count()).toBe(0) + const snapshot = await captureStableAria(page, '#trajectory-detail-panel', scaffold.workspaceCwd) + await compareOrRefreshGolden(TIMING_EXPECTED, snapshot, MODE) + }, 60_000) + it.skipIf(MODE === 'record')('downloads through the Session Header and /export with one dialog', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-export')) await ensureSeedOpen(page) @@ -505,7 +524,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { it.skipIf(MODE === 'record')('keeps the recorded fixture inventory exact', async () => { await assertFixtureInventory(SNAPSHOT_DIR, [ 'session.v3.jsonl', 'search-results.expected.md', 'trajectory.expected.md', - 'terminal-card.expected.md', + 'terminal-card.expected.md', 'timing.expected.md', ]) }) }) diff --git a/apps/web/tests/turn-tail-actions.e2e.ts b/apps/web/tests/turn-tail-actions.e2e.ts index 0a176a9af0..fdc1ef26f5 100644 --- a/apps/web/tests/turn-tail-actions.e2e.ts +++ b/apps/web/tests/turn-tail-actions.e2e.ts @@ -201,8 +201,8 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => { await timeTrigger.click() const timeDialog = page.getByRole('dialog', { name: 'Turn time and speed' }) expect(await timeDialog.count()).toBe(1) - expect(await timeDialog.getByText(/tok\/s/).count()).toBe(0) - expect(await timeDialog.getByText('Time to first token (TTFT)', { exact: true }).count()).toBe(0) + expect(await timeDialog.getByText(/tok\/s/).count()).toBeGreaterThan(0) + expect(await timeDialog.getByText('Time to first token (TTFT)', { exact: true }).count()).toBe(1) await page.keyboard.press('Escape') await trigger.click() diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 4db27677d0..ffbb411643 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-chat/README.md -README.md: 860dcb9eeb9c92a14d9128d9d8c95c29796726f8 -README.zh.md: 4dd06a16b19b14cacc0ded603cc1cf8869dd1a43 +README.md: 3e99cd599a834a9629193015e9d76468bf6480f4 +README.zh.md: d431cf567dda8d78ef2c696959ae2574feaa49d3 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 860dcb9eeb..3e99cd599a 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -34,6 +34,8 @@ Each nonempty appended `system/message` owns a collapsed prompt row, including a A completed Turn shows an expandable usage row only when the loaded window includes `turn/start` and every started model attempt reports safe, exact usage. The row omits unavailable optional buckets. Incomplete or contradictory accounting hides the complete disclosure instead of presenting a partial total. +Reply timing survives reopening history: recorded token timestamps supply first-token latency and generation duration, including the Step's first token from an earlier retry attempt. Missing Step starts or token timestamps leave the affected metrics unavailable. + ----- diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 4dd06a16b1..d431cf567d 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -36,6 +36,8 @@ kind: "package-reference" 只有当已加载窗口包含 `turn/start`,且每次已启动的模型尝试都报告安全、精确的用量时,已完成 Turn 才显示可展开的用量行。该行会省略不可用的可选用量桶。记账不完整或相互矛盾时,整个详情都不显示,避免把部分总量冒充完整结果。 +重新打开历史后仍保留回复计时:记录中的 token 时间戳提供首 token 延迟和生成耗时,包括步骤中更早重试尝试的首个 token。缺少步骤起点或 token 时间戳时,受影响的指标保持不可用。 + ----- diff --git a/packages/client/ui-chat/src/client/conversation-nodes/assistant.ts b/packages/client/ui-chat/src/client/conversation-nodes/assistant.ts index 41093124a8..aecfa43c5e 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/assistant.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/assistant.ts @@ -4,6 +4,7 @@ import type { ConversationNodeContext, ConversationNodeDefinition, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { StreamChunk } from '@deepseek-ai/dsh-llm' +import { assistantStreamFirstTokenTime } from '@deepseek-ai/dsh-llm/assistant-stream' import type {} from '@deepseek-ai/dsh-llm-retry/types' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { AssistantChatData } from '../contract/chat-nodes.ts' @@ -161,6 +162,17 @@ function updateChunk( } } +/** The Step retains its first token across live chunks and settled retry attempts. */ +function settleTiming( + state: AssistantState, + event: SessionEvent<'assistant/message' | 'assistant/attempt'>, +): AssistantState { + return { + ...state, + firstTokenTime: state.firstTokenTime ?? assistantStreamFirstTokenTime(event.data.stream), + } +} + function settleMessage( state: AssistantState, match: ConversationMatch, @@ -168,7 +180,7 @@ function settleMessage( ): AssistantState { const blocks = toAssistantBlocks(event.data.message.content) return { - ...state, + ...settleTiming(state, event), blocks, visibleBlocks: countVisibleBlocks(blocks), hidden: false, @@ -236,6 +248,11 @@ function fallbackState(context: ConversationNodeContext): Assist state = updateChunk(state, match.event.data.chunk, match.event.seq, match.event.time) continue } + if (match.event.type === 'assistant/attempt') { + state ??= initialState(match.event.data.turn, match.event.data.step) + state = settleTiming(state, match.event) + continue + } if (match.event.type === 'assistant/message') { state ??= initialState(match.event.data.turn, match.event.data.step) state = settleMessage(state, match, match.event) @@ -296,6 +313,7 @@ export const assistantDefinition: ConversationNodeDefinition = { match: (event) => { if (event.type === 'step/start') return { id: `${event.data.turn}:${event.data.step}`, role: 'start' } if (event.type === 'assistant/live-chunk' + || event.type === 'assistant/attempt' || (event.type === 'assistant/message' && event.surfaceOp === 'append')) { return { id: `${event.data.turn}:${event.data.step}`, role: 'update' } } @@ -313,13 +331,14 @@ export const assistantDefinition: ConversationNodeDefinition = { return updateChunk(context.state, match.event.data.chunk, match.event.seq, match.event.time) } if (match.event.type === 'assistant/message') return settleMessage(context.state, match, match.event) + if (match.event.type === 'assistant/attempt') return settleTiming(context.state, match.event) if (match.event.type === 'llm/retry') { return resetForRetry(context.state) } return context.state }, publication: (match) => { - if (match.event.type === 'step/start') return 'none' + if (match.event.type === 'step/start' || match.event.type === 'assistant/attempt') return 'none' if (match.event.type !== 'assistant/live-chunk') return 'immediate' const type = match.event.data.chunk.type return type === 'usage' || type === 'finish' ? 'none' : 'animation-frame' diff --git a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts index 85fa2b6eb0..5134b2193c 100644 --- a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts +++ b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts @@ -1056,9 +1056,16 @@ describe('built-in conversation node Definitions', () => { const finalNode = (node(finalizedPacked, 'assistant-step')?.data as AssistantChatData).finalNode expect(finalNode).toMatchObject({ blocks: [{ kind: 'text', text: 'done' }], - timing: { firstTokenTime: null }, + timing: { firstTokenTime: 1_999 }, }) + const windowed = assembler(finalizedInputs.slice(2), true) + const timing = () => (node(snapshot(windowed), 'assistant-step')?.data as AssistantChatData).finalNode?.timing + expect(timing()).toMatchObject({ stepStartTime: null, firstTokenTime: 1_999 }) + windowed.prepend(finalizedInputs.slice(0, 2), false) + windowed.flush() + expect(timing()).toEqual(finalNode?.timing) + const namedToolHistory = [ at(40, 'turn/start', { turn: 3 }), at(41, 'step/start', { turn: 3, step: 1 }), @@ -1083,7 +1090,7 @@ describe('built-in conversation node Definitions', () => { const namedTool = (node(namedToolPacked, 'assistant-step')?.data as AssistantChatData).finalNode expect(namedTool).toMatchObject({ blocks: [{ kind: 'tool-call', callId: 'call-2', name: 'read', argsRaw: '' }], - timing: { firstTokenTime: null }, + timing: { firstTokenTime: 4_000 }, }) }) diff --git a/packages/client/ui-trajectory/README.i18n.yaml b/packages/client/ui-trajectory/README.i18n.yaml index 8f91bd2d8b..741beb718f 100644 --- a/packages/client/ui-trajectory/README.i18n.yaml +++ b/packages/client/ui-trajectory/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-trajectory/README.md -README.md: 2b8ecff50b498be529ccf7e35e528f7554084940 -README.zh.md: 8463bd0a68c32753138425d968db229726b5bd1a +README.md: 099ce8c9330040e106ab4ab1bfbf155d32a38e79 +README.zh.md: 7b00cd8ccf8dfdc36f6df55e3b0e7c1ae8fe5847 diff --git a/packages/client/ui-trajectory/README.md b/packages/client/ui-trajectory/README.md index 2b8ecff50b..099ce8c933 100644 --- a/packages/client/ui-trajectory/README.md +++ b/packages/client/ui-trajectory/README.md @@ -33,6 +33,8 @@ Selection, timeline navigation, folding, and search cover the React-visible wind ### The timing overview +Historical replies retain TTFT, generation duration, and throughput when their recorded streams contain token timestamps. TTFT measures from the Step start to its first token, including output from an earlier retry attempt; an unloaded Step start or a stream without tokens leaves the corresponding metric unavailable. + A fixed Overview above the ledger projects real record start/duration timing from left to right; Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range; wheel gestures zoom the time domain; a right-button click clears the selected interval, and a right-button drag pans an already zoomed viewport. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows. ----- diff --git a/packages/client/ui-trajectory/README.zh.md b/packages/client/ui-trajectory/README.zh.md index 8463bd0a68..7b00cd8ccf 100644 --- a/packages/client/ui-trajectory/README.zh.md +++ b/packages/client/ui-trajectory/README.zh.md @@ -33,6 +33,8 @@ Trajectory 标签页让你以按轮次组织的事件记录表和交互式时间 ### 时间概览 +当记录的流包含 token 时间戳时,历史回复会保留 TTFT、生成耗时与吞吐量。TTFT 从步骤开始计量到首个 token,包括更早重试尝试中的输出;步骤起点尚未加载或流中没有 token 时,对应指标保持不可用。 + 固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;助手时间条区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻与耗时详情。拖选区间会把记录表聚焦到该闭区间内任何时刻处于活动的记录;滚轮手势用于缩放时间域;右键单击会清除所选区间,在已放大的视口上按住右键拖动则会平移。初始视图与流式更新都停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。 ----- diff --git a/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts b/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts index 2bc68039d6..e7d4706be0 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts @@ -5,6 +5,7 @@ import type { PartialAssistant, RequestView, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { StreamChunk } from '@deepseek-ai/dsh-llm' +import { assistantStreamFirstTokenTime } from '@deepseek-ai/dsh-llm/assistant-stream' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import { trajectoryNode } from './trajectory-definition-common.ts' import { @@ -191,6 +192,17 @@ function updateChunk( } } +/** The Step retains its first token across live chunks and settled retry attempts. */ +function settleTiming( + state: AssistantState, + event: SessionEvent<'assistant/message' | 'assistant/attempt'>, +): AssistantState { + return { + ...state, + firstTokenTime: state.firstTokenTime ?? assistantStreamFirstTokenTime(event.data.stream), + } +} + function settleMessage( state: AssistantState, match: ConversationMatch, @@ -198,7 +210,7 @@ function settleMessage( ): AssistantState { const blocks = toAssistantBlocks(event.data.message.content) return { - ...state, + ...settleTiming(state, event), sawChunk: false, blocks, visibleBlocks: countVisibleBlocks(blocks), @@ -226,6 +238,9 @@ function fallbackState(context: ConversationNodeContext): Assist if (event.type === 'assistant/live-chunk') { state ??= initialState(event.data.turn, event.data.step, event.seq, event.time, false) state = updateChunk(state, event.data.chunk, event.seq, event.time) + } else if (event.type === 'assistant/attempt') { + state ??= initialState(event.data.turn, event.data.step, event.seq, event.time, false) + state = settleTiming(state, event) } else if (event.type === 'assistant/message') { state ??= initialState(event.data.turn, event.data.step, event.seq, event.time, false) state = settleMessage(state, match, event) @@ -325,6 +340,7 @@ const trajectoryAssistantDefinition: ConversationNodeDefinition } if (event.type === 'assistant/live-chunk' || event.type === 'assistant/message' + || event.type === 'assistant/attempt' || event.type === 'llm/retry' || event.type === 'step/end') { return { id: `${event.data.turn}:${event.data.step}`, role: 'update' } @@ -348,6 +364,7 @@ const trajectoryAssistantDefinition: ConversationNodeDefinition return updateChunk(context.state, match.event.data.chunk, match.event.seq, match.event.time) } if (match.event.type === 'assistant/message') return settleMessage(context.state, match, match.event) + if (match.event.type === 'assistant/attempt') return settleTiming(context.state, match.event) if (match.event.type === 'step/end') return { ...context.state, stepEnd: match } if (match.event.type !== 'llm/retry') return context.state const data = match.event.data @@ -372,7 +389,7 @@ const trajectoryAssistantDefinition: ConversationNodeDefinition } }, publication: (match) => { - if (match.event.type === 'step/start') return 'none' + if (match.event.type === 'step/start' || match.event.type === 'assistant/attempt') return 'none' if (match.event.type !== 'assistant/live-chunk') return 'immediate' const type = match.event.data.chunk.type return type === 'usage' || type === 'finish' ? 'none' : 'animation-frame' diff --git a/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts b/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts index abcf09646f..3ccd5fdd9d 100644 --- a/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts +++ b/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts @@ -335,13 +335,20 @@ describe('Trajectory conversation Definitions', () => { const finalizedPacked = snapshot(assembler(finalizedInputs)) expect(finalizedPacked.eventNodes.find(node => node.kind === 'assistant')).toMatchObject({ blocks: [{ kind: 'text', text: 'done' }], - timing: { firstTokenTime: null }, + timing: { firstTokenTime: 3_000 }, }) expect(finalizedPacked.requests).toMatchObject([{ purpose: 'assistant', retry: 1, }]) + const windowed = assembler(finalizedInputs.slice(2), true) + const assistant = () => snapshot(windowed).eventNodes.find(node => node.kind === 'assistant') + expect(assistant()).toMatchObject({ timing: { stepStartTime: null, firstTokenTime: 3_000 } }) + windowed.prepend(finalizedInputs.slice(0, 2), false) + windowed.flush() + expect(assistant()).toEqual(finalizedPacked.eventNodes.find(node => node.kind === 'assistant')) + const namedToolHistory = [ at(40, 'turn/start', { turn: 3 }), at(41, 'step/start', { turn: 3, step: 1 }), @@ -365,7 +372,7 @@ describe('Trajectory conversation Definitions', () => { const namedToolPacked = snapshot(assembler(namedToolInputs)) expect(namedToolPacked.eventNodes.find(node => node.kind === 'assistant')).toMatchObject({ blocks: [{ kind: 'tool-call', callId: 'call-2', name: 'read', argsRaw: '' }], - timing: { firstTokenTime: null }, + timing: { firstTokenTime: 4_000 }, }) }) diff --git a/snapshots/web/navigation-panes/timing.expected.md b/snapshots/web/navigation-panes/timing.expected.md new file mode 100644 index 0000000000..0a7a17c389 --- /dev/null +++ b/snapshots/web/navigation-panes/timing.expected.md @@ -0,0 +1,12 @@ +- tabpanel "Timing": + - term: Started + - definition: + - button "2026-01-01 {{clock}}" + - term: Total duration + - definition: {{duration}} + - term: TTFT + - definition: {{duration}} + - term: Generation + - definition: {{duration}} + - term: Throughput + - definition: {{throughput}} tok/s From ac09b7b51980219614ba3300064a064c27b8a980 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 9 Sep 2026 17:16:15 +0800 Subject: [PATCH 21/79] fix(trajectory): narrow Chinese role column --- .../ui-trajectory/src/client/TrajectoryTable.module.css | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css index 35266eed00..410842358a 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css @@ -137,6 +137,10 @@ width: 122px; } +.eventColumn:where(:lang(zh)) { + width: 84px; +} + .contentColumn { width: auto; } @@ -448,6 +452,10 @@ width: 76px; } +.kindSlot:where(:lang(zh)) { + width: 44px; +} + .kindSlot :global([role='tooltip']) { border: 0; background: var(--dsw-alias-bg-layer-2); From e4f0871b80b2d0218c94e207aa87d71be720a149 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 9 Sep 2026 17:19:03 +0800 Subject: [PATCH 22/79] fix(trajectory): clarify overview overflow and tighten spacing --- .../src/client/TrajectoryTable.module.css | 22 ++++++++++++------- .../src/client/TrajectoryTable.tsx | 20 +++++++++++++++++ 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css index 410842358a..414e123d9e 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css @@ -1046,6 +1046,7 @@ .detailBodySummary > .overview { flex: 0 1 auto; min-height: 0; + padding-bottom: 0; overflow: auto; overscroll-behavior: contain; } @@ -1184,13 +1185,13 @@ display: flex; flex: 1 1 0; max-height: max-content; - min-height: 28px; + min-height: 24px; flex-direction: column; overflow: hidden; } .overviewSection + .overviewSection { - padding-top: 8px; + padding-top: 2px; } .overviewHeading { @@ -1199,7 +1200,7 @@ align-items: flex-end; box-sizing: border-box; width: 100%; - height: 28px; + height: 24px; margin: 0; padding: 0 0 3px 14px; color: var(--dsw-alias-label-secondary); @@ -1251,6 +1252,10 @@ background: var(--dsw-alias-bg-layer-1); } +.overviewPreview[data-scroll-more] { + mask-image: linear-gradient(to bottom, #000 calc(100% - 12px), transparent); +} + .overviewPreview > :last-child { margin-bottom: 0; padding-bottom: 0; @@ -1258,6 +1263,7 @@ .overviewPreview .jsonPreview > :first-child, .overviewPreview .schemaTree > :first-child { + padding-top: 2px; padding-bottom: 0; } @@ -1413,7 +1419,7 @@ } .markdownPreview { - padding: 6px 14px 8px; + padding: 2px 14px 8px; } .markdownPayload { @@ -1443,7 +1449,7 @@ } .schemaPreview .schemaIntro { - padding-top: 6px; + padding-top: 2px; } .schemaName { @@ -1490,7 +1496,7 @@ .payloadPreview { min-height: 0; - padding: 6px 14px 8px; + padding: 2px 14px 8px; } .sourceBlocks { @@ -1741,7 +1747,7 @@ .resultBlocksPreview { min-height: 0; - padding: 6px 14px 8px; + padding: 2px 14px 8px; gap: 6px; } @@ -1756,7 +1762,7 @@ } .overviewPreview .noPayload { - padding: 6px 14px 8px; + padding: 2px 14px 0; } .noPayload { diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index b517578e80..1ac861ffee 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -1787,6 +1787,23 @@ function OverviewSection({ onOpen: () => void children: ReactNode }) { + const previewRef = useRef(null) + const [hasMore, setHasMore] = useState(false) + const measureOverflow = useCallback((preview: HTMLElement) => { + setHasMore(preview.scrollHeight - preview.clientHeight - preview.scrollTop > 1) + }, []) + + useLayoutEffect(() => { + const preview = previewRef.current as HTMLDivElement + const measure = () => { measureOverflow(preview) } + measure() + if (typeof ResizeObserver === 'undefined') return + const observer = new ResizeObserver(measure) + observer.observe(preview) + for (const child of preview.children) observer.observe(child) + return () => { observer.disconnect() } + }, [children, measureOverflow]) + return (

@@ -1800,8 +1817,11 @@ function OverviewSection({

{ measureOverflow(event.currentTarget) }} > {children}
From 1549483397c82551a2d28e2be3e78aabc494aa77 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 9 Sep 2026 17:19:10 +0800 Subject: [PATCH 23/79] feat(client): expand reasoning-only assistant output by default --- .../ui-chat/src/client/chat/AssistantMarkdown.tsx | 9 ++++++++- .../client/ui-chat/src/client/chat/ReasoningRow.tsx | 13 ++++++++++--- .../ui-trajectory/src/client/TrajectoryTable.tsx | 13 ++++++++++++- 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx index 3212801e00..51300f6d02 100644 --- a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx @@ -58,6 +58,8 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ return { resolve: value => localPathMediaUrl(protocol, origin, value) } }, []) const last = blocks.length - 1 + const hasBodyOrToolCall = blocks.some(block => block.kind === 'tool-call' + || (block.kind === 'text' && block.text.trim() !== '')) // Tool-call heads render as tool rows in the chat view's grouping pass, so // a node that is only those heads (or empty) would paint an empty root // between tool groups — skip the shell unless something visible remains. @@ -89,7 +91,12 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ hidden={reasoningHidden} reveal={revealProcess} > - + , ) break diff --git a/packages/client/ui-chat/src/client/chat/ReasoningRow.tsx b/packages/client/ui-chat/src/client/chat/ReasoningRow.tsx index bc7859b364..bb22eea778 100644 --- a/packages/client/ui-chat/src/client/chat/ReasoningRow.tsx +++ b/packages/client/ui-chat/src/client/chat/ReasoningRow.tsx @@ -22,11 +22,18 @@ function latestLine(text: string): string { * the complete text. * @param props.text - complete or streaming reasoning text. * @param props.running - whether this block is the streaming tail. + * @param props.defaultExpanded - expanded state until the reader toggles it. * @param props.t - conversation locale seat for the running status. * @returns the reasoning disclosure. */ -export function ReasoningRow({ text, running, t }: { text: string; running: boolean; t: ChatViewSlotProps['t'] }) { - const [expanded, setExpanded] = useState(false) +export function ReasoningRow({ text, running, defaultExpanded, t }: { + text: string + running: boolean + defaultExpanded: boolean + t: ChatViewSlotProps['t'] +}) { + const [expandedOverride, setExpandedOverride] = useState() + const expanded = expandedOverride ?? defaultExpanded const summary = (running ? latestLine(text) : firstLine(text)).replaceAll('**', '') return ( @@ -47,7 +54,7 @@ export function ReasoningRow({ text, running, t }: { text: string; running: bool open={expanded} expandable expandOnRowClick - onToggle={() => { setExpanded(value => !value) }} + onToggle={() => { setExpandedOverride(!expanded) }} collapsedContent={( <> diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index 1ac861ffee..a47c17212e 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -1863,7 +1863,7 @@ export function TrajectoryTable({ const [selectedRecordId, setSelectedRecordId] = useState(null) const [selectedRequest, setSelectedRequest] = useState(null) const [activeTab, setActiveTab] = useState('overview') - const [thinkingExpanded, setThinkingExpanded] = useState(false) + const [thinkingDisclosure, setThinkingDisclosure] = useState<{ recordId: string; expanded: boolean }>() const [detailsWidth, setDetailsWidth] = useState(null) const [toolRequestOffset, setToolRequestOffset] = useState(null) const detailsResizeDrag = useRef(null) @@ -1895,6 +1895,17 @@ export function TrajectoryTable({ const selected = selectedTemplate === undefined ? undefined : currentRecord(selectedTemplate) + const thinkingExpanded = thinkingDisclosure?.recordId === selectedRecordId + ? thinkingDisclosure.expanded + : selected?.cell.kind === 'message' + && Boolean(selected.cell.thinkingDetail?.trim()) + && !selected.cell.outputDetail?.trim() + && !selected.cell.sourceBlocks?.some(block => block.type === 'tool-call') + const setThinkingExpanded = (expanded: boolean) => { + if (selectedRecordId !== null) { + setThinkingDisclosure({ recordId: selectedRecordId, expanded }) + } + } const selectedIndex = selected?.cell.index ?? null useEffect(() => { onSelectedIndexChange?.(selectedIndex) From c6fb28a16468c6e36e8c5d36ccaf9d1e2afff5f9 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 9 Sep 2026 17:19:18 +0800 Subject: [PATCH 24/79] fix(client): use a collapse icon for expanded JSON strings --- packages/client/ui-primitives/src/JsonTree.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/client/ui-primitives/src/JsonTree.tsx b/packages/client/ui-primitives/src/JsonTree.tsx index 10a97dfd2a..b5f0d7a5e8 100644 --- a/packages/client/ui-primitives/src/JsonTree.tsx +++ b/packages/client/ui-primitives/src/JsonTree.tsx @@ -317,13 +317,24 @@ function JsonString({
{renderCopy?.(true)}
From 00b16cb63ae571093ef54183cbc1ce026476666c Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 9 Sep 2026 17:29:00 +0800 Subject: [PATCH 25/79] fix(web): align terminal preview, styles, and snapshots --- packages/api/terminal-controller/README.i18n.yaml | 4 ++-- packages/api/terminal-controller/README.md | 2 +- packages/api/terminal-controller/README.zh.md | 2 +- packages/api/terminal-controller/src/terminal.ts | 13 ++++++------- .../api/terminal-controller/tests/terminal.spec.ts | 8 ++++++++ .../src/client/TerminalBody.module.css | 4 ++-- .../src/client/TerminalCleanup.module.css | 4 ++-- .../session/cordis-inspect-jsdoc/session.v3.jsonl | 2 +- 8 files changed, 23 insertions(+), 16 deletions(-) diff --git a/packages/api/terminal-controller/README.i18n.yaml b/packages/api/terminal-controller/README.i18n.yaml index 2b60551cd2..eba346c0ea 100644 --- a/packages/api/terminal-controller/README.i18n.yaml +++ b/packages/api/terminal-controller/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/terminal-controller/README.md -README.md: f6755cab8def6bc1f5842ee387a20c5ed51e7e7f -README.zh.md: fef6e6b45cef87e0a3f1c433dec13baad59ab75a +README.md: 08def8e53f6e9362b2bd99f03dc552115dcf9539 +README.zh.md: 4cd9d42272e4ff6a588305056450d145dc7dbb12 diff --git a/packages/api/terminal-controller/README.md b/packages/api/terminal-controller/README.md index f6755cab8d..08def8e53f 100644 --- a/packages/api/terminal-controller/README.md +++ b/packages/api/terminal-controller/README.md @@ -45,7 +45,7 @@ New terminals use the execution environment's declared default shell. Only when
Implementation internals — click to expand -The Host uses `ctx.subprocess.spawnTerminal` with `TERM=xterm-256color`; it never launches a desktop terminal application. Unary control uses the Gateway, and `follow` uses its multiplexed Remote stream transport. Headless xterm and its serializer produce each opening screen after all preceding output writes, then monotone output sequences identify subsequent frames. Slow followers fail explicitly; a new attachment restores the current screen. +The Host uses `ctx.subprocess.spawnTerminal` with `TERM=xterm-256color`; it never launches a desktop terminal application. Streaming UTF-8 decoding preserves split characters and leading BOMs, and replaces incomplete trailing bytes at EOF. Unary control uses the Gateway, and `follow` uses its multiplexed Remote stream transport. Headless xterm and its serializer produce each opening screen after all preceding output writes, then monotone output sequences identify subsequent frames. Slow followers fail explicitly; a new attachment restores the current screen. The latest attachment owns input and resize. Detachment releases input control without killing the process. Explicit close awaits process cleanup and final output; cleanup failure retains the resource for retry. The Session remembers closed identities and rejects their delayed or repeated creation, including creation already in progress when close arrives. A new terminal uses a new identity. Pending allocations remain owned even if cancellation and cleanup both fail. Session owner disposal and controller disposal also terminate owned processes. An open or pending terminal prevents changing that Session's sandbox mode. diff --git a/packages/api/terminal-controller/README.zh.md b/packages/api/terminal-controller/README.zh.md index fef6e6b45c..4cd9d42272 100644 --- a/packages/api/terminal-controller/README.zh.md +++ b/packages/api/terminal-controller/README.zh.md @@ -45,7 +45,7 @@ Web bundle 将此包与 subprocess provider、sandbox policy 和 Typert Gateway
实现细节 -Host 通过 `ctx.subprocess.spawnTerminal` 创建 `TERM=xterm-256color` 的终端,不启动桌面终端应用。控制请求走 Gateway,`follow` 使用其复用的 Remote stream。Headless xterm 和序列化 addon 在此前输出写入后生成初始屏幕,后续增量携带单调序号。过慢的订阅者明确失败;重新连接恢复当前屏幕。 +Host 通过 `ctx.subprocess.spawnTerminal` 创建 `TERM=xterm-256color` 的终端,不启动桌面终端应用。流式 UTF-8 解码保留跨块字符和开头的 BOM,并在 EOF 将不完整的尾部字节替换为替代字符。控制请求走 Gateway,`follow` 使用其复用的 Remote stream。Headless xterm 和序列化 addon 在此前输出写入后生成初始屏幕,后续增量携带单调序号。过慢的订阅者明确失败;重新连接恢复当前屏幕。 最新连接持有输入和尺寸控制权。断开连接只释放输入权,不结束进程。显式关闭等待进程清理和最后输出;清理失败时保留资源以便重试。Session 记住已关闭的标识并拒绝迟到或重复的创建请求,包括关闭到达时仍在进行的创建。新终端使用新标识。取消创建且清理失败时,已分配的进程仍有所有者。Session owner 和 controller 卸载也会终止所拥有的进程。存在终端或创建请求时不能改变该 Session 的 sandbox mode。 diff --git a/packages/api/terminal-controller/src/terminal.ts b/packages/api/terminal-controller/src/terminal.ts index 103426cf72..90688d9d78 100644 --- a/packages/api/terminal-controller/src/terminal.ts +++ b/packages/api/terminal-controller/src/terminal.ts @@ -1,15 +1,14 @@ /** One PTY, a bounded terminal emulator and its detachable browser followers. */ import { createRequire } from 'node:module' -import { StringDecoder } from 'node:string_decoder' import type { Terminal as HeadlessTerminal } from '@xterm/headless' import type { SerializeAddon as Serializer } from '@xterm/addon-serialize' import type { SubprocessTerminalHandle } from '@deepseek-ai/dsh-subprocess' import { TerminalFollower } from './stream.ts' import type { TerminalAttachmentId, TerminalFrame, WebTerminalInfo } from './types.ts' -const require = createRequire(import.meta.url) -const { Terminal } = require('@xterm/headless') as typeof import('@xterm/headless') -const { SerializeAddon } = require('@xterm/addon-serialize') as typeof import('@xterm/addon-serialize') +// Literal createRequire calls keep CommonJS xterm entries reachable in the Preview image. +const { Terminal } = createRequire(import.meta.url)('@xterm/headless') as typeof import('@xterm/headless') +const { SerializeAddon } = createRequire(import.meta.url)('@xterm/addon-serialize') as typeof import('@xterm/addon-serialize') /** Process lifetime is independent of follower and component lifetimes. */ export class BrowserTerminal { @@ -141,15 +140,15 @@ export class BrowserTerminal { } private async consume(): Promise { - const decoder = new StringDecoder('utf8') + const decoder = new TextDecoder('utf-8', { ignoreBOM: true }) const outcome = this.handle.done.then(value => ({ value }), (error: unknown) => ({ error })) try { for await (const chunk of this.handle.output) { // Node Readable's iterator is untyped; this provider explicitly emits Buffer chunks. - const data = decoder.write(chunk as Buffer) + const data = decoder.decode(chunk as Buffer, { stream: true }) await this.output(data) } - await this.output(decoder.end()) + await this.output(decoder.decode()) const result = await outcome if ('error' in result) throw result.error this.info = { ...this.info, state: 'exited', exitCode: result.value.exitCode } diff --git a/packages/api/terminal-controller/tests/terminal.spec.ts b/packages/api/terminal-controller/tests/terminal.spec.ts index 86c229e158..43c0446458 100644 --- a/packages/api/terminal-controller/tests/terminal.spec.ts +++ b/packages/api/terminal-controller/tests/terminal.spec.ts @@ -59,6 +59,7 @@ describe('BrowserTerminal', () => { const first = await attach(terminal) const encoded = Buffer.from('终端') output.write(encoded.subarray(0, 2)) + await expect.poll(() => output.readableLength).toBe(0) output.write(encoded.subarray(2)) expect(await readFrame(first.iterator)).toMatchObject({ type: 'output', data: '终端' }) const second = await attach(terminal, 'second') @@ -194,6 +195,13 @@ describe('BrowserTerminal', () => { expect(await readFrame(first.iterator)).toMatchObject({ type: 'state', info: { state: 'exited' } }) }) + it('preserves a leading UTF-8 BOM in the terminal output stream', async () => { + const { terminal, output } = fixture() + const first = await attach(terminal) + output.write(Buffer.from('\uFEFF终端')) + expect(await readFrame(first.iterator)).toMatchObject({ type: 'output', data: '\uFEFF终端' }) + }) + it('shares concurrent close attempts and permits retry after termination fails', async () => { const { terminal, handle } = fixture() await attach(terminal) diff --git a/packages/client/ui-sidebar-terminal/src/client/TerminalBody.module.css b/packages/client/ui-sidebar-terminal/src/client/TerminalBody.module.css index 811de9cacc..817d30bef0 100644 --- a/packages/client/ui-sidebar-terminal/src/client/TerminalBody.module.css +++ b/packages/client/ui-sidebar-terminal/src/client/TerminalBody.module.css @@ -1,9 +1,9 @@ .root { display: flex; flex: 1; flex-direction: column; box-sizing: border-box; height: 100%; min-height: 0; padding-top: 8px; color: var(--dsw-alias-label-primary); font-size: 13px; } .title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.name { width: 120px; min-width: 48px; max-width: 100%; padding: 0 4px; color: inherit; font: inherit; background: var(--dsw-alias-bg-l1); border: 1px solid var(--dsw-alias-border-l3); border-radius: 4px; outline: none; } +.name { width: 120px; min-width: 48px; max-width: 100%; padding: 0 4px; color: inherit; font: inherit; background: var(--dsw-alias-bg-l1); border: 0.5px solid var(--dsw-alias-border-l3); border-radius: 4px; outline: none; } .screen { flex: 1; min-height: 0; min-width: 0; overflow: hidden; padding: 8px; background: #17191d; } .screen :global(.xterm) { height: 100%; } .status { display: flex; align-items: center; gap: 8px; padding: 5px 12px; background: var(--dsw-alias-bg-l2); } -.root button { padding: 6px 10px; border: 1px solid var(--dsw-alias-border-l3); border-radius: 6px; color: inherit; background: transparent; cursor: pointer; } +.root button { padding: 6px 10px; border: 0.5px solid var(--dsw-alias-border-l3); border-radius: 6px; color: inherit; background: transparent; cursor: pointer; } .root button:hover { background: var(--dsw-alias-bg-l2); } .error { margin: 0; padding: 10px 12px; overflow-wrap: anywhere; color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-l2); } diff --git a/packages/client/ui-sidebar-terminal/src/client/TerminalCleanup.module.css b/packages/client/ui-sidebar-terminal/src/client/TerminalCleanup.module.css index c031726424..aabc259179 100644 --- a/packages/client/ui-sidebar-terminal/src/client/TerminalCleanup.module.css +++ b/packages/client/ui-sidebar-terminal/src/client/TerminalCleanup.module.css @@ -1,3 +1,3 @@ .stack { position: fixed; right: 20px; bottom: 20px; display: grid; gap: 8px; max-width: min(440px, calc(100vw - 40px)); pointer-events: auto; } -.notice { display: flex; align-items: center; gap: 12px; padding: 12px 16px; border: 1px solid var(--dsw-alias-border-l3); border-radius: 8px; color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-l1); box-shadow: 0 4px 20px #0002; font-size: 13px; overflow-wrap: anywhere; } -.notice button { flex-shrink: 0; padding: 4px 8px; border: 1px solid var(--dsw-alias-border-l3); border-radius: 4px; color: inherit; background: transparent; cursor: pointer; } +.notice { display: flex; align-items: center; gap: 12px; padding: 12px 16px; border: 0.5px solid var(--dsw-alias-border-l3); border-radius: 8px; color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-l1); box-shadow: 0 4px 20px #0002; font-size: 13px; overflow-wrap: anywhere; } +.notice button { flex-shrink: 0; padding: 4px 8px; border: 0.5px solid var(--dsw-alias-border-l3); border-radius: 4px; color: inherit; background: transparent; cursor: pointer; } diff --git a/snapshots/session/cordis-inspect-jsdoc/session.v3.jsonl b/snapshots/session/cordis-inspect-jsdoc/session.v3.jsonl index 86ad93cc03..fc145a45b4 100644 --- a/snapshots/session/cordis-inspect-jsdoc/session.v3.jsonl +++ b/snapshots/session/cordis-inspect-jsdoc/session.v3.jsonl @@ -19,7 +19,7 @@ {"type":"step/start","data":{"turn":1,"step":2}} {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-subprocess-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"subprocess\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1788778146017,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1788778146017,"index":0,"dt":[],"id":"inspect-subprocess-api","name":"cordis_inspect_query","args":["{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"subprocess\"}}"]},{"type":"chunk","time":1788778146017,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"inspect-subprocess-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"subprocess\"}}"}}},{"type":"chunk","time":1788778146017,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1788778146017,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":2,"callId":"inspect-subprocess-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"subprocess\"}}"}} -{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"inspect-subprocess-api"},"content":[{"type":"tool-result","toolCallId":"inspect-subprocess-api","content":[{"type":"text","text":"{\n \"platform\": \"host\",\n \"provider\": \"Service\",\n \"method\": \"listService\",\n \"data\": {\n \"mode\": \"service\",\n \"service\": {\n \"key\": \"subprocess\",\n \"description\": \"Abstract subprocess service. Subclass, implement spawn, and load the subclass as a plugin — it registers as `ctx.subprocess` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior).\\n\\nImplementations must honor these semantics:\\n\\n- Executable paths belong to one execution world shared with the mounted filesystem provider.\\n- spawn returns a live handle synchronously. Target identity remains provider-private; `done` resolves with the spawned command's exit facts and may reject for spawn or provider failures.\\n- Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another's output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here.\\n- SubprocessHandle.terminate (and the spec's abort signal) starts the provider's documented procedure against its managed range. SubprocessHandle.waitForExit observes that same range so a consumer-owned teardown ladder can hold each tier on real quiescence; each provider documents its signalling and observability limits.\\n- Disposal of the service terminates all still-running managed processes and awaits their exit.\\n- spawnTerminal owns terminal allocation, text transport, foreground groups, signalling, and whole-session quiescence behind one awaited termination method; readiness and persistent-shell policy stay in the PTY consumer. Its output stream ends after queued terminal output when the top-level process exits.\",\n \"access\": {\n \"optional\": {\n \"expression\": \"ctx.get(\\\"subprocess\\\")\",\n \"requiresUndefinedCheck\": true\n },\n \"hardDependency\": {\n \"inject\": [\n \"subprocess\"\n ],\n \"expression\": \"ctx.subprocess\"\n }\n },\n \"methods\": [\n {\n \"signature\": \"abstract resolveExecutable( command: string, env?: Readonly>, signal?: AbortSignal, ): Promise\",\n \"description\": \"Resolve one configured executable in this provider's execution world. Absolute paths are verified; bare names use the provider's scrubbed PATH plus explicit environment overrides. Relative paths containing separators are rejected: the resolution base is undefined, so providers fail loud instead of guessing.\",\n \"parameters\": [\n {\n \"name\": \"command\",\n \"description\": \"absolute executable path or bare PATH name.\"\n },\n {\n \"name\": \"env\",\n \"description\": \"explicit environment entries used for lookup.\"\n },\n {\n \"name\": \"signal\",\n \"description\": \"aborts remote or local lookup.\"\n }\n ],\n \"returns\": \"a canonical executable path.\"\n },\n {\n \"signature\": \"abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle\",\n \"description\": \"Start one managed child process from a fully-specified spec; this seam applies no defaults.\",\n \"parameters\": [\n {\n \"name\": \"spec\",\n \"description\": \"argv, directory, stdio dispositions, grace, cancellation, and environment.\"\n }\n ],\n \"returns\": \"the live process handle (streams/readers, signalling, outcome promise).\",\n \"throws\": [\n \"synchronously when pre-aborted or when argv, cwd, environment, or grace is invalid before handle creation.\"\n ]\n },\n {\n \"signature\": \"abstract spawnTerminal(spec: SubprocessTerminalSpawnSpec): Promise\",\n \"description\": \"Allocate a real terminal and start one owned process session. This is the only non-pipe process primitive: implementations own terminal byte I/O, foreground groups, signals, and whole-session quiescence.\",\n \"parameters\": [\n {\n \"name\": \"spec\",\n \"description\": \"fully specified argv, cwd, environment, dimensions, grace, and allocation cancellation.\"\n }\n ],\n \"returns\": \"the live terminal handle after allocation succeeds.\"\n }\n ]\n },\n \"referencedTypes\": [\n {\n \"name\": \"SubprocessCollect\",\n \"declaration\": \"export interface SubprocessCollect {\\n maxBytes: number;\\n spill?: {\\n maxBytes: number;\\n };\\n}\"\n },\n {\n \"name\": \"SubprocessCollectedOutputs\",\n \"declaration\": \"export interface SubprocessCollectedOutputs {\\n readonly stdout?: SubprocessOutputReader;\\n readonly stderr?: SubprocessOutputReader;\\n}\"\n },\n {\n \"name\": \"SubprocessHandle\",\n \"declaration\": \"export interface SubprocessHandle {\\n readonly stdin: Writable | undefined;\\n readonly stdout: Readable | undefined;\\n readonly stderr: Readable | undefined;\\n readonly collected: SubprocessCollectedOutputs;\\n readonly done: Promise;\\n terminate(): void;\\n waitForExit(signal?: AbortSignal): Promise;\\n}\"\n },\n {\n \"name\": \"SubprocessOutcome\",\n \"declaration\": \"export interface SubprocessOutcome {\\n exitCode: number | null;\\n signal: NodeJS.Signals | null;\\n}\"\n },\n {\n \"name\": \"SubprocessOutputMode\",\n \"declaration\": \"export type SubprocessOutputMode = 'pipe' | 'inherit' | SubprocessCollect;\"\n },\n {\n \"name\": \"SubprocessOutputRead\",\n \"declaration\": \"export interface SubprocessOutputRead {\\n text: string;\\n nextOffset: number;\\n lossy: boolean;\\n spillPath?: string;\\n}\"\n },\n {\n \"name\": \"SubprocessOutputReader\",\n \"declaration\": \"export interface SubprocessOutputReader {\\n readFrom(fromByte: number): SubprocessOutputRead;\\n}\"\n },\n {\n \"name\": \"SubprocessSpawnSpec\",\n \"declaration\": \"export interface SubprocessSpawnSpec {\\n argv: readonly string[];\\n cwd: string;\\n stdio: SubprocessStdio;\\n graceMs: number;\\n signal?: AbortSignal | undefined;\\n env?: NodeJS.ProcessEnv | undefined;\\n}\"\n },\n {\n \"name\": \"SubprocessStdinMode\",\n \"declaration\": \"export type SubprocessStdinMode = 'ignore' | 'pipe' | {\\n readonly data: string;\\n};\"\n },\n {\n \"name\": \"SubprocessStdio\",\n \"declaration\": \"export interface SubprocessStdio {\\n stdin: SubprocessStdinMode;\\n stdout: SubprocessOutputMode;\\n stderr: SubprocessOutputMode;\\n}\"\n },\n {\n \"name\": \"SubprocessTerminalForeground\",\n \"declaration\": \"export interface SubprocessTerminalForeground {\\n processGroupId: number;\\n inputWaiting: boolean;\\n}\"\n },\n {\n \"name\": \"SubprocessTerminalHandle\",\n \"declaration\": \"export interface SubprocessTerminalHandle {\\n readonly pid: number;\\n readonly output: Readable;\\n readonly done: Promise;\\n write(data: string): Promise;\\n inspectForeground(): Promise;\\n signalForeground(signal: SubprocessTerminalSignal): Promise;\\n terminate(): Promise;\\n}\"\n },\n {\n \"name\": \"SubprocessTerminalSignal\",\n \"declaration\": \"export type SubprocessTerminalSignal = 'SIGINT' | 'SIGTERM' | 'SIGKILL' | 'SIGTSTP' | 'SIGHUP';\"\n },\n {\n \"name\": \"SubprocessTerminalSpawnSpec\",\n \"declaration\": \"export interface SubprocessTerminalSpawnSpec {\\n argv: readonly string[];\\n cwd: string;\\n env?: Record | undefined;\\n rows: number;\\n cols: number;\\n graceMs: number;\\n signal?: AbortSignal | undefined;\\n}\"\n }\n ]\n }\n}"}],"isError":false}],"role":"user","id":"{{message:7}}"}},"sourceEventSeqs":[19],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"inspect-subprocess-api"},"content":[{"type":"tool-result","toolCallId":"inspect-subprocess-api","content":[{"type":"text","text":"{\n \"platform\": \"host\",\n \"provider\": \"Service\",\n \"method\": \"listService\",\n \"data\": {\n \"mode\": \"service\",\n \"service\": {\n \"key\": \"subprocess\",\n \"description\": \"Abstract subprocess service. Subclass, implement spawn, and load the subclass as a plugin — it registers as `ctx.subprocess` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior).\\n\\nImplementations must honor these semantics:\\n\\n- Executable paths belong to one execution world shared with the mounted filesystem provider.\\n- spawn returns a live handle synchronously. Target identity remains provider-private; `done` resolves with the spawned command's exit facts and may reject for spawn or provider failures.\\n- Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another's output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here.\\n- SubprocessHandle.terminate (and the spec's abort signal) starts the provider's documented procedure against its managed range. SubprocessHandle.waitForExit observes that same range so a consumer-owned teardown ladder can hold each tier on real quiescence; each provider documents its signalling and observability limits.\\n- Disposal of the service terminates all still-running managed processes and awaits their exit.\\n- spawnTerminal owns terminal allocation, text transport, foreground groups, signalling, and whole-session quiescence behind one awaited termination method; readiness and persistent-shell policy stay in the PTY consumer. Its output stream ends after queued terminal output when the top-level process exits.\",\n \"access\": {\n \"optional\": {\n \"expression\": \"ctx.get(\\\"subprocess\\\")\",\n \"requiresUndefinedCheck\": true\n },\n \"hardDependency\": {\n \"inject\": [\n \"subprocess\"\n ],\n \"expression\": \"ctx.subprocess\"\n }\n },\n \"methods\": [\n {\n \"signature\": \"abstract resolveExecutable( command: string, env?: Readonly>, signal?: AbortSignal, ): Promise\",\n \"description\": \"Resolve one configured executable in this provider's execution world. Absolute paths are verified; bare names use the provider's scrubbed PATH plus explicit environment overrides. Relative paths containing separators are rejected: the resolution base is undefined, so providers fail loud instead of guessing.\",\n \"parameters\": [\n {\n \"name\": \"command\",\n \"description\": \"absolute executable path or bare PATH name.\"\n },\n {\n \"name\": \"env\",\n \"description\": \"explicit environment entries used for lookup.\"\n },\n {\n \"name\": \"signal\",\n \"description\": \"aborts remote or local lookup.\"\n }\n ],\n \"returns\": \"a canonical executable path.\"\n },\n {\n \"signature\": \"abstract terminalEnvironment(signal?: AbortSignal): Promise\",\n \"description\": \"Inspect shell-selection facts in the provider's execution environment.\",\n \"parameters\": [\n {\n \"name\": \"signal\",\n \"description\": \"cancellation of remote environment inspection.\"\n }\n ],\n \"returns\": \"platform and preferred shell; executable lookup and allocation remain separate operations.\"\n },\n {\n \"signature\": \"abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle\",\n \"description\": \"Start one managed child process from a fully-specified spec; this seam applies no defaults.\",\n \"parameters\": [\n {\n \"name\": \"spec\",\n \"description\": \"argv, directory, stdio dispositions, grace, cancellation, and environment.\"\n }\n ],\n \"returns\": \"the live process handle (streams/readers, signalling, outcome promise).\",\n \"throws\": [\n \"synchronously when pre-aborted or when argv, cwd, environment, or grace is invalid before handle creation.\"\n ]\n },\n {\n \"signature\": \"abstract spawnTerminal(spec: SubprocessTerminalSpawnSpec): Promise\",\n \"description\": \"Allocate a real terminal and start one owned process session. This is the only non-pipe process primitive: implementations own terminal byte I/O, foreground groups, signals, and whole-session quiescence.\",\n \"parameters\": [\n {\n \"name\": \"spec\",\n \"description\": \"fully specified argv, cwd, environment, dimensions, grace, and allocation cancellation.\"\n }\n ],\n \"returns\": \"the live terminal handle after allocation succeeds.\"\n }\n ]\n },\n \"referencedTypes\": [\n {\n \"name\": \"SubprocessCollect\",\n \"declaration\": \"export interface SubprocessCollect {\\n maxBytes: number;\\n spill?: {\\n maxBytes: number;\\n };\\n}\"\n },\n {\n \"name\": \"SubprocessCollectedOutputs\",\n \"declaration\": \"export interface SubprocessCollectedOutputs {\\n readonly stdout?: SubprocessOutputReader;\\n readonly stderr?: SubprocessOutputReader;\\n}\"\n },\n {\n \"name\": \"SubprocessHandle\",\n \"declaration\": \"export interface SubprocessHandle {\\n readonly stdin: Writable | undefined;\\n readonly stdout: Readable | undefined;\\n readonly stderr: Readable | undefined;\\n readonly collected: SubprocessCollectedOutputs;\\n readonly done: Promise;\\n terminate(): void;\\n waitForExit(signal?: AbortSignal): Promise;\\n}\"\n },\n {\n \"name\": \"SubprocessOutcome\",\n \"declaration\": \"export interface SubprocessOutcome {\\n exitCode: number | null;\\n signal: NodeJS.Signals | null;\\n}\"\n },\n {\n \"name\": \"SubprocessOutputMode\",\n \"declaration\": \"export type SubprocessOutputMode = 'pipe' | 'inherit' | SubprocessCollect;\"\n },\n {\n \"name\": \"SubprocessOutputRead\",\n \"declaration\": \"export interface SubprocessOutputRead {\\n text: string;\\n nextOffset: number;\\n lossy: boolean;\\n spillPath?: string;\\n}\"\n },\n {\n \"name\": \"SubprocessOutputReader\",\n \"declaration\": \"export interface SubprocessOutputReader {\\n readFrom(fromByte: number): SubprocessOutputRead;\\n}\"\n },\n {\n \"name\": \"SubprocessSpawnSpec\",\n \"declaration\": \"export interface SubprocessSpawnSpec {\\n argv: readonly string[];\\n cwd: string;\\n stdio: SubprocessStdio;\\n graceMs: number;\\n signal?: AbortSignal | undefined;\\n env?: NodeJS.ProcessEnv | undefined;\\n}\"\n },\n {\n \"name\": \"SubprocessStdinMode\",\n \"declaration\": \"export type SubprocessStdinMode = 'ignore' | 'pipe' | {\\n readonly data: string;\\n};\"\n },\n {\n \"name\": \"SubprocessStdio\",\n \"declaration\": \"export interface SubprocessStdio {\\n stdin: SubprocessStdinMode;\\n stdout: SubprocessOutputMode;\\n stderr: SubprocessOutputMode;\\n}\"\n },\n {\n \"name\": \"SubprocessTerminalEnvironment\",\n \"declaration\": \"export interface SubprocessTerminalEnvironment {\\n platform: 'posix' | 'windows';\\n defaultShell?: string;\\n}\"\n },\n {\n \"name\": \"SubprocessTerminalForeground\",\n \"declaration\": \"export interface SubprocessTerminalForeground {\\n processGroupId: number;\\n inputWaiting: boolean;\\n}\"\n },\n {\n \"name\": \"SubprocessTerminalHandle\",\n \"declaration\": \"export interface SubprocessTerminalHandle {\\n readonly pid: number;\\n readonly output: Readable;\\n readonly done: Promise;\\n write(data: string): Promise;\\n resize(cols: number, rows: number): Promise;\\n inspectForeground(): Promise;\\n signalForeground(signal: SubprocessTerminalSignal): Promise;\\n terminate(): Promise;\\n}\"\n },\n {\n \"name\": \"SubprocessTerminalSignal\",\n \"declaration\": \"export type SubprocessTerminalSignal = 'SIGINT' | 'SIGTERM' | 'SIGKILL' | 'SIGTSTP' | 'SIGHUP';\"\n },\n {\n \"name\": \"SubprocessTerminalSpawnSpec\",\n \"declaration\": \"export interface SubprocessTerminalSpawnSpec {\\n argv: readonly string[];\\n cwd: string;\\n env?: Record | undefined;\\n rows: number;\\n cols: number;\\n terminalType: string;\\n graceMs: number;\\n signal?: AbortSignal | undefined;\\n}\"\n }\n ]\n }\n}"}],"isError":false}],"role":"user","id":"{{message:7}}"}},"sourceEventSeqs":[19],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} {"type":"step/start","data":{"turn":1,"step":3}} {"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"CORDIS_INSPECT_JSDOC_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:8}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1788778146037,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1788778146037,"index":0,"dt":[],"texts":["CORDIS_INSPECT_JSDOC_OK"]},{"type":"chunk","time":1788778146037,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CORDIS_INSPECT_JSDOC_OK"}}},{"type":"chunk","time":1788778146037,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1788778146037,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} From 3a32c617a3436f0e9451a0dd08dfe75b62255294 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 9 Sep 2026 17:38:02 +0800 Subject: [PATCH 26/79] feat(trajectory): remember JSON string wrapping preference --- .../ui-primitives/src/JsonTree.module.css | 10 ++++ .../client/ui-primitives/src/JsonTree.tsx | 53 ++++++++++++++++++- .../src/client/TrajectoryTable.tsx | 50 ++++++++++++----- .../src/client/TrajectoryView.tsx | 9 +++- .../client/ui-trajectory/src/client/index.ts | 6 +++ .../ui-trajectory/src/client/locales.ts | 2 + .../src/client/string-wrapping-store.ts | 13 +++++ 7 files changed, 128 insertions(+), 15 deletions(-) create mode 100644 packages/client/ui-trajectory/src/client/string-wrapping-store.ts diff --git a/packages/client/ui-primitives/src/JsonTree.module.css b/packages/client/ui-primitives/src/JsonTree.module.css index 536e7bc052..9abbc23bdc 100644 --- a/packages/client/ui-primitives/src/JsonTree.module.css +++ b/packages/client/ui-primitives/src/JsonTree.module.css @@ -145,6 +145,12 @@ height: 16px; } +.stringRaw[data-wrap='true'] { + overflow-wrap: anywhere; + white-space: pre-wrap; + word-break: break-all; +} + .stringActions > .copySlot { opacity: 1; pointer-events: auto; @@ -275,6 +281,10 @@ color: var(--dsw-alias-label-primary); } +.actionButton[aria-pressed='true'] { + color: var(--dsw-alias-state-business-primary); +} + .actionButton:focus-visible { outline: 1px solid var(--dsw-alias-state-business-primary); outline-offset: -1px; diff --git a/packages/client/ui-primitives/src/JsonTree.tsx b/packages/client/ui-primitives/src/JsonTree.tsx index b5f0d7a5e8..17e846d983 100644 --- a/packages/client/ui-primitives/src/JsonTree.tsx +++ b/packages/client/ui-primitives/src/JsonTree.tsx @@ -219,6 +219,7 @@ function NodeField({ interface JsonTreeNodeProps { collapsedStringLines: number + stringWrapping: JsonTreeProps['stringWrapping'] field?: string initialExpanded: boolean labels: JsonTreeLabels @@ -233,6 +234,7 @@ interface JsonTreeNodeProps { function JsonString({ collapsedStringLines, + stringWrapping, field, labels, lastElement, @@ -240,6 +242,7 @@ function JsonString({ value, }: { collapsedStringLines: number + stringWrapping: JsonTreeProps['stringWrapping'] field: string | undefined labels: JsonTreeLabels lastElement: boolean @@ -250,6 +253,7 @@ function JsonString({ const contentRef = useRef(null) const rawRef = useRef(null) const [expanded, setExpanded] = useState(false) + const [wrapped, setWrapped] = useState(false) const [truncated, setTruncated] = useState(false) useLayoutEffect(() => { @@ -309,12 +313,41 @@ function JsonString({ ref={rawRef} id={contentsId} className={css.stringRaw} + data-wrap={wrapped} tabIndex={0} aria-labelledby={field === undefined ? undefined : fieldId} > {value}
+ {stringWrapping !== undefined && ( + + )} @@ -372,6 +408,7 @@ function JsonString({ function JsonTreeNode({ collapsedStringLines, + stringWrapping, field, initialExpanded, labels, @@ -427,6 +464,7 @@ function JsonTreeNode({ return row( boolean + /** Remember a user toggle for future expansions. @param wrapped - Whether to wrap long lines. */ + setDefault: (wrapped: boolean) => void + } | undefined /** Whether JSON rows expose copy actions. */ copyable?: boolean /** Whether the top-level object or array is always expanded. */ @@ -551,6 +599,7 @@ export function JsonTree({ label, className, collapsedStringLines = 3, + stringWrapping, copyable = true, expandTopLevel = true, labels, @@ -721,6 +770,7 @@ export function JsonTree({ {t('source.notRecorded')}

const data = typeof source === 'object' && source !== null @@ -896,6 +905,7 @@ function MessageSource({ record, t }: { record: TableRecord; t: TrajectoryTransl return ( {t('record.toolsMissing')}

return (
@@ -1302,6 +1317,7 @@ function ToolCatalog({ )} {t('record.parameters')} {selectedRequestOptions !== undefined && ( { activateTab('options') }}> - + )} { activateTab('usage') }}> @@ -2981,7 +3005,7 @@ export function TrajectoryTable({ )} {selectedRequestInfo !== undefined && activeTab === 'options' && ( - + )} {selectedRequestInfo !== undefined && activeTab === 'usage' && ( + )} {!promptSelected && selected?.cell.kind === 'compacted' @@ -3179,16 +3203,16 @@ export function TrajectoryTable({ <> {selected.cell.inputDetail && ( { activateTab('input') }}> - + )} {selected.cell.outputDetail && ( { activateTab('output') }}> - + )} { activateTab('schema') }}> - + )} @@ -3233,16 +3257,16 @@ export function TrajectoryTable({ /> )} {!promptSelected && selected !== undefined && activeTab === 'source' && ( - + )} {!promptSelected && selected !== undefined && activeTab === 'input' && ( - + )} {!promptSelected && selected !== undefined && activeTab === 'output' && ( - + )} {!promptSelected && selected !== undefined && activeTab === 'schema' && ( - + )} {!promptSelected && selected !== undefined && activeTab === 'timing' && ( diff --git a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx index 4f8bc63d9c..22a2492d0b 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx @@ -7,6 +7,7 @@ import type { } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { InjectFace, PropsLocale, PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { JsonTreeProps } from '@deepseek-ai/dsh-client-ui-primitives' import { TrajectoryTable, type TrajectoryRequestNumber, @@ -74,6 +75,8 @@ function partialStructureSignature(partial: TrajectorySnapshot['partial']): stri /** Session-bound controls not already supplied by the conversation view slot. */ export interface TrajectoryViewInjected { + /** Shared wrapping preference read by expansion handlers. */ + jsonStringWrapping?: Omit, 'label'> hooks: { duration: SnapshotStore } @@ -128,7 +131,7 @@ function addUsage( export function TrajectoryView({ useSession, useTrajectory, useDuration, loadOlder, loadImage, setActualDuration, - viewRequest, completeViewRequest, renderSlot, t, + viewRequest, completeViewRequest, renderSlot, t, jsonStringWrapping, }: ConvViewProps & PropsRenderSlots<'conversation.trajectory.images'> & InjectFace @@ -541,6 +544,10 @@ export function TrajectoryView({
stringWrapping.getSnapshot(), + setDefault: (value) => { stringWrapping.set(value) }, + }, loadOlder: async () => { const before = trajectory.getSnapshot() await session.loadOlder() diff --git a/packages/client/ui-trajectory/src/client/locales.ts b/packages/client/ui-trajectory/src/client/locales.ts index 6bdc621413..c3b69be06d 100644 --- a/packages/client/ui-trajectory/src/client/locales.ts +++ b/packages/client/ui-trajectory/src/client/locales.ts @@ -114,6 +114,7 @@ export const zh = { 'record.payloadJson': '参数 JSON', 'record.outputJson': '结果 JSON', 'record.thinking': '思考', + 'record.wrapLines': '自动换行', 'record.systemPromptMissing': '本次请求没有系统提示词', 'record.toolsMissing': '本次请求没有工具', 'record.systemPrompt': '系统提示词', @@ -307,6 +308,7 @@ export const en: Record = { 'record.payloadJson': 'Payload JSON', 'record.outputJson': 'Result JSON', 'record.thinking': 'Thinking', + 'record.wrapLines': 'Wrap lines', 'record.systemPromptMissing': 'No system prompt in this request', 'record.toolsMissing': 'No tools in this request', 'record.systemPrompt': 'System Prompt', diff --git a/packages/client/ui-trajectory/src/client/string-wrapping-store.ts b/packages/client/ui-trajectory/src/client/string-wrapping-store.ts new file mode 100644 index 0000000000..bcd86dfccc --- /dev/null +++ b/packages/client/ui-trajectory/src/client/string-wrapping-store.ts @@ -0,0 +1,13 @@ +import { + createSnapshotStore, type SnapshotStore, +} from '@deepseek-ai/dsh-client-store' + +/** + * Create the browser-wide default for expanded JSON strings. + * @returns A persisted preference sampled only when a string is expanded. + */ +export function createTrajectoryStringWrappingStore(): SnapshotStore { + return createSnapshotStore(false, { + persist: { name: 'dsh.trajectory.jsonStringWrapping' }, + }) +} From 632b8e19b4ba1297bc8c5931ab209b8d010d8ed2 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 9 Sep 2026 18:15:09 +0800 Subject: [PATCH 27/79] feat(trajectory): add a PTC code inspector --- ...9-ptc-trajectory-code-inspection.i18n.yaml | 6 + ...26-09-09-ptc-trajectory-code-inspection.md | 29 ++ ...09-09-ptc-trajectory-code-inspection.zh.md | 29 ++ apps/web/tests/ptc-round.e2e.ts | 45 ++- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- .../ui-primitives/src/markdown/CodeBlock.tsx | 10 +- .../client/ui-trajectory/README.i18n.yaml | 4 +- packages/client/ui-trajectory/README.md | 2 + packages/client/ui-trajectory/README.zh.md | 2 + .../src/client/TrajectoryTable.module.css | 139 ++++++++++ .../src/client/TrajectoryTable.tsx | 258 +++++++++++++++--- .../ui-trajectory/src/client/code-program.ts | 57 ++++ .../ui-trajectory/src/client/locales.ts | 12 + .../tests/code-inspector.client.spec.tsx | 116 ++++++++ .../tests/code-program.client.spec.ts | 60 ++++ snapshots/web/ptc-round/code.expected.md | 7 + .../web/ptc-round/trajectory.expected.md | 22 ++ 19 files changed, 759 insertions(+), 47 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.md create mode 100644 .agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.zh.md create mode 100644 packages/client/ui-trajectory/src/client/code-program.ts create mode 100644 packages/client/ui-trajectory/tests/code-inspector.client.spec.tsx create mode 100644 packages/client/ui-trajectory/tests/code-program.client.spec.ts create mode 100644 snapshots/web/ptc-round/code.expected.md create mode 100644 snapshots/web/ptc-round/trajectory.expected.md diff --git a/.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.i18n.yaml b/.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.i18n.yaml new file mode 100644 index 0000000000..d273d9ed77 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.md +2026-09-09-ptc-trajectory-code-inspection.md: 55cbbae97371b69b7574ebb0674c2178b586b83a +2026-09-09-ptc-trajectory-code-inspection.zh.md: 94b6993a4e4e2d1f3e93256f79a68121f910169d diff --git a/.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.md b/.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.md new file mode 100644 index 0000000000..55cbbae973 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.md @@ -0,0 +1,29 @@ +# Agent Note: Inspect recorded PTC source in Trajectory + +Status: implemented + +English | [中文](2026-09-09-ptc-trajectory-code-inspection.zh.md) + +## Problem + +PTC programs arrive as JSON string arguments. Escaping makes long programs difficult to read in a generic argument tree, while historical calls may use a different runtime language from the current deployment. Recorded result text can contain both printed output and a returned value without retaining their separation. + +## Decision + +[Trajectory](../../../../packages/client/ui-trajectory/README.md) derives a code inspector from recorded `run_code` arguments. It preserves source bytes for copying and exposes the original JSON through a separate toggle. Syntax highlighting requires an unambiguous TypeScript or Python hint in that call's recorded parameter description; unknown or conflicting hints leave plain text. Unsupported arguments retain the generic inspector. + +The result view preserves recorded text and uses a tree only for complete JSON objects or arrays. Errors retain captured output. Code views sample the shared wrapping preference when opened and keep their own choice while mounted. + +The [PTC runtime decision](2026-06-15-ptc.md) still owns execution and settlement; the [client presentation decision](../architecture/2026-08-23-client-derived-tool-presentation.md) still owns deriving UI from recorded facts. This inspector adds no Session events or host presentation fields. + +## Alternatives considered + +**Keep source inside the argument tree.** JSON escaping obscures program structure and makes copying executable source cumbersome. + +**Use the active runtime language or infer it from source.** Either can mislabel a historical program. Recorded schema hints constrain highlighting without changing the recording. + +**Split printed output from return values.** Historical rendered text does not establish that distinction; a parser could invent a separation the producer never recorded. + +## Consequences + +Readers can inspect and copy recorded programs without changing replay data. Schemas with no recognizable language hint receive no syntax highlighting. Existing component tests cover fallback, exact copying, output states, and independent wrapping; the [PTC browser scenario](../../../../apps/web/tests/ptc-round.e2e.ts) pins the assembled inspector and verifies overflow and the original-JSON round trip. diff --git a/.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.zh.md b/.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.zh.md new file mode 100644 index 0000000000..94b6993a4e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 在 Trajectory 中检查已记录的 PTC 源码 + +Status: implemented + +[English](2026-09-09-ptc-trajectory-code-inspection.md) | 中文 + +## 问题 + +PTC 程序以 JSON 字符串参数传入。转义使长程序在通用参数树中难以阅读,而历史调用使用的运行时语言可能不同于当前部署。记录的结果文本可以同时包含打印输出与返回值,却未保留二者的分界。 + +## 决策 + +[Trajectory](../../../../packages/client/ui-trajectory/README.zh.md) 从已记录的 `run_code` 参数派生代码检查器。复制时保留源码字节,并通过独立切换按钮展示原始 JSON。语法高亮要求该调用记录的参数说明包含明确且无冲突的 TypeScript 或 Python 提示;未知或冲突的提示使用纯文本。不支持的参数保留通用检查器。 + +结果视图保留记录的文本,只对完整 JSON 对象或数组使用树形展示。错误保留已捕获的输出。代码视图在打开时读取共享换行偏好,并在挂载期间保留自身的选择。 + +[PTC 运行时决策](2026-06-15-ptc.zh.md) 仍负责执行与结算;[客户端展示决策](../architecture/2026-08-23-client-derived-tool-presentation.zh.md) 仍负责从已记录事实派生 UI。此检查器不增加 Session 事件或宿主展示字段。 + +## 考虑过的替代方案 + +**把源码保留在参数树中。** JSON 转义遮蔽程序结构,也使复制可执行源码变得繁琐。 + +**使用当前运行时语言,或从源码推断语言。** 两种方式都可能错误标注历史程序。已记录 Schema 的提示约束高亮,无需修改记录。 + +**拆分打印输出与返回值。** 历史渲染文本无法确定二者的分界;解析器可能凭空添加生产方从未记录的分隔。 + +## 后果 + +读者可以检查和复制已记录的程序,无需修改回放数据。Schema 没有可识别的语言提示时不提供语法高亮。现有组件测试覆盖回退、原样复制、输出状态与独立换行;[PTC 浏览器场景](../../../../apps/web/tests/ptc-round.e2e.ts) 固定组装后的检查器展示,并验证溢出和原始 JSON 的往返切换。 diff --git a/apps/web/tests/ptc-round.e2e.ts b/apps/web/tests/ptc-round.e2e.ts index 045c475bb6..54bcc461c9 100644 --- a/apps/web/tests/ptc-round.e2e.ts +++ b/apps/web/tests/ptc-round.e2e.ts @@ -8,13 +8,15 @@ import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import type { SessionEvent } from '@deepseek-ai/dsh-session' import { - acknowledgeReloadConnectionLoss, captureExpandedTurnProcessAria, compareOrRefreshGolden, fixtureUserPrompts, + acknowledgeReloadConnectionLoss, captureExpandedTurnProcessAria, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts' const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/ptc-round/session.v3.jsonl', import.meta.url)) const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/ptc-round/ui.expected.md', import.meta.url)) +const TRAJECTORY_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/ptc-round/trajectory.expected.md', import.meta.url)) +const CODE_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/ptc-round/code.expected.md', import.meta.url)) const MODE = webSnapshotMode() // Elicits the successful and failed sub-rows this scenario asserts. @@ -151,6 +153,47 @@ describe('web e2e: PTC mode round renders nested sub-calls', () => { await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) }) + it.skipIf(MODE === 'record')('inspects recorded PTC source, wrapping, and original JSON in the trajectory', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-ptc-inspector')) + const call = sessionEvents.find(event => event.type === 'tool/call' && event.data.name === 'run_code') + if (call?.type !== 'tool/call') throw new Error('recorded PTC call missing') + const args = JSON.parse(call.data.arguments) as { code: string; description: string } + await page.getByRole('tab', { name: 'Trajectory', exact: true }).click() + const row = page.locator('tr[data-kind="tool"]').filter({ hasText: 'run_code' }).first() + await row.click() + await page.getByRole('tab', { name: 'Code', exact: true }).waitFor() + expect(await page.getByRole('tabpanel').textContent()).toContain(args.description) + expect(await page.getByRole('tabpanel').locator('dl').first().locator('dt').allTextContents()) + .toEqual(['Hierarchy', 'Status']) + const overview = await Promise.all([1, 2].map(index => captureStableAria( + page, `[role="tabpanel"] [class*="overviewSections"] > section:nth-child(${index})`, scaffold.workspaceCwd, + ))) + await compareOrRefreshGolden(TRAJECTORY_EXPECTED, overview.join('\n'), MODE) + + await page.getByRole('button', { name: 'Code', exact: true }).click() + const source = page.locator('[data-line-numbers] pre') + await source.waitFor() + expect(await source.textContent()).toBe(args.code.endsWith('\n') ? args.code.slice(0, -1) : args.code) + const wrap = page.getByRole('button', { name: 'Wrap lines', exact: true }) + expect(await wrap.getAttribute('aria-pressed')).toBe('false') + const content = page.locator('[data-wrap]').filter({ has: source }) + expect(await content.evaluate(element => element.scrollWidth > element.clientWidth)).toBe(true) + await wrap.click() + expect(await wrap.getAttribute('aria-pressed')).toBe('true') + await expect.poll(() => content.evaluate(element => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1) + const code = await captureStableAria(page, '[role="tabpanel"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(CODE_EXPECTED, code, MODE) + + await page.getByRole('button', { name: 'Original JSON' }).click() + const json = page.getByRole('tree', { name: 'parameters JSON' }) + await json.waitFor() + expect(await json.textContent()).toContain(args.description) + await page.getByRole('button', { name: 'Original JSON' }).click() + expect(await source.textContent()).toBe(args.code.endsWith('\n') ? args.code.slice(0, -1) : args.code) + await page.getByRole('tab', { name: 'Summary', exact: true }).click() + expect(await wrap.getAttribute('aria-pressed')).toBe('true') + }) + it.skipIf(MODE === 'record')('stayed clean: no page errors, no reconnect churn', () => { expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 138d94440f..b10bb57879 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md -README.md: 8fd59c5ecc98a609f1383e428701cf590da95e73 -README.zh.md: 2478330825681f65aa83415c698cf0edc54fdad6 +README.md: 6b92450428e1b109db127e99de282e215a98eb8a +README.zh.md: dc8e07af8021c65cca1694cb8a8e8e8579269619 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 8fd59c5ecc..6b92450428 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -52,7 +52,7 @@ Check this table before writing a control in a feature package. A plugin cannot | `HoverCard` | Hover preview the pointer can rest on and select from; optional copy button. | | `Toast` | Transient top-center banner held for the owner's `holdMs`. | | `JsonTree`, `JsonBlock` | Read-only JSON inspection. | -| `MarkdownText`, `CodeBlock` | Untrusted GFM with TeX math, and highlighted code. `CodeBlock` accepts opt-in `lineNumbers`; copied source excludes the gutter. | +| `MarkdownText`, `CodeBlock` | Untrusted GFM with TeX math, and highlighted code. `CodeBlock` accepts opt-in `lineNumbers`; copied source excludes the gutter. Set `showHeader={false}` when the owner supplies its own language and copy toolbar. | | `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, `WebBlock` | The agent-output card matching each tool-result intent. | | `icons/*`, `FishLogo`, `BrandWordmark`, `ReferenceIcon`, `LinkIcon`, `DocumentFileIcon` | Glyphs and brand marks, all riding `currentColor`. | | `FileTypeIcon` | The coloured file-type sheet (code, html, image, markdown, pdf, sheet, slides, document, other); `classifyFileType` picks the kind from a path's extension. | diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 2478330825..dc8e07af80 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -52,7 +52,7 @@ kind: "package-library" | `HoverCard` | 指针可停留、可选中的悬停预览;可选带复制按钮。 | | `Toast` | 顶部居中的瞬时横幅,保持时长由所有者的 `holdMs` 决定。 | | `JsonTree`、`JsonBlock` | 只读 JSON 查看。 | -| `MarkdownText`、`CodeBlock` | 不可信 GFM 与 TeX 数学,以及高亮代码。`CodeBlock` 可通过 `lineNumbers` 开启行号;复制的源码不含行号栏。 | +| `MarkdownText`、`CodeBlock` | 不可信 GFM 与 TeX 数学,以及高亮代码。`CodeBlock` 可通过 `lineNumbers` 开启行号;复制的源码不含行号栏。调用方提供自己的语言与复制工具栏时,设置 `showHeader={false}`。 | | `TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock`、`WebBlock` | 与各类工具结果意图对应的 agent 输出卡片。 | | `icons/*`、`FishLogo`、`BrandWordmark`、`ReferenceIcon`、`LinkIcon`、`DocumentFileIcon` | 字形与品牌标识,全部随 `currentColor`。 | | `FileTypeIcon` | 彩色文件类型纸片(code、html、image、markdown、pdf、sheet、slides、document、other);`classifyFileType` 按路径扩展名选出类型。 | diff --git a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx index fff6a00a45..9e2dddc600 100644 --- a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx +++ b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx @@ -27,6 +27,8 @@ export interface CodeBlockProps { className?: string | undefined /** Show a numbered gutter without adding numbers to copied source. Defaults to false. */ lineNumbers?: boolean | undefined + /** Show the language and copy header; false when the caller supplies a toolbar. Defaults to true. */ + showHeader?: boolean | undefined /** Copy-button idle label; the owner passes localized copy (this package is cordis-free, so copy arrives via props). */ copyLabel: string /** Copy-button label during the post-copy confirmation window. */ @@ -59,7 +61,9 @@ function renderLine(line: readonly HighlightSpan[], index: number): ReactNode { ) } -export function CodeBlock({ code, lang, streaming, className, lineNumbers = false, copyLabel, copiedLabel }: CodeBlockProps) { +export function CodeBlock({ + code, lang, streaming, className, lineNumbers = false, showHeader = true, copyLabel, copiedLabel, +}: CodeBlockProps) { const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code const sourceLines = lineNumbers ? trimmed.split('\n') : undefined const rootRef = useRef(null) @@ -177,7 +181,7 @@ export function CodeBlock({ code, lang, streaming, className, lineNumbers = fals style={sourceLines === undefined ? undefined : { '--dsl-code-block-line-number-width': `${Math.max(2, String(sourceLines.length).length)}ch`, } as CSSProperties}> -
+ {showHeader &&
{lang ?? ''}
@@ -186,7 +190,7 @@ export function CodeBlock({ code, lang, streaming, className, lineNumbers = fals
-
+
} {body}
) diff --git a/packages/client/ui-trajectory/README.i18n.yaml b/packages/client/ui-trajectory/README.i18n.yaml index 741beb718f..02a420834f 100644 --- a/packages/client/ui-trajectory/README.i18n.yaml +++ b/packages/client/ui-trajectory/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-trajectory/README.md -README.md: 099ce8c9330040e106ab4ab1bfbf155d32a38e79 -README.zh.md: 7b00cd8ccf8dfdc36f6df55e3b0e7c1ae8fe5847 +README.md: 80d8898e0fabd4671581a84198250fe408d51009 +README.zh.md: 5c85faaadb56be6dc3cc85753494c7fd88333c92 diff --git a/packages/client/ui-trajectory/README.md b/packages/client/ui-trajectory/README.md index 099ce8c933..80d8898e0f 100644 --- a/packages/client/ui-trajectory/README.md +++ b/packages/client/ui-trajectory/README.md @@ -29,6 +29,8 @@ Open the Trajectory tab in the conversation's view ring to inspect agent activit ### Inspecting records +For `run_code`, the row shows the program description and the inspector opens numbered, highlighted source. The Code tab provides wrapping, a `{}` toggle for the original JSON arguments, and exact source copying, including trailing newlines. Each newly opened code view takes the last wrapping choice; changing it leaves other open views as they are. Output preserves the recorded text, using a tree for complete JSON objects or arrays. Highlighting uses only an unambiguous TypeScript or Python hint in the recorded tool schema; missing or conflicting hints leave plain source. See the [PTC inspection decision](../../../.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.md) for replay constraints. + Selection, timeline navigation, folding, and search cover the React-visible window. Request numbers and cumulative usage cover the complete resident snapshot. Selecting a record opens a local inspector for token usage, duration, Input, Output, Timing, and durable images. Image URLs use the Conversation-owned per-session cache, so Chat and Trajectory share one authorized read per attachment. A user record shows the generic-file count beside its text, while a record without text shows its image and file counts. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. ### The timing overview diff --git a/packages/client/ui-trajectory/README.zh.md b/packages/client/ui-trajectory/README.zh.md index 7b00cd8ccf..5c85faaadb 100644 --- a/packages/client/ui-trajectory/README.zh.md +++ b/packages/client/ui-trajectory/README.zh.md @@ -29,6 +29,8 @@ Trajectory 标签页让你以按轮次组织的事件记录表和交互式时间 ### 检查记录 +对于 `run_code`,记录行展示程序说明,检查器显示带行号和语法高亮的源码。“代码”页提供换行、查看原始 JSON 参数的 `{}` 切换按钮,以及保留末尾换行的源码原样复制。每个新打开的代码视图采用上次选择的换行状态;切换时,其他已打开视图保持原状。输出保留记录中的文本,完整 JSON 对象或数组使用树形展示。高亮只采用已记录工具 Schema 中明确且无冲突的 TypeScript 或 Python 提示;提示缺失或冲突时显示纯文本源码。回放约束见 [PTC 检查决策](../../../.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.zh.md)。 + 选择、时间线导航、折叠与搜索只覆盖 React 可见窗口。请求编号与累计用量覆盖完整的驻留 snapshot。选择记录会打开局部检查器,查看 token 用量、耗时、输入、输出、计时与持久图片。图片 URL 使用 Conversation 拥有的逐会话缓存,因此 Chat 与 Trajectory 对每个附件共享一次已授权读取。用户记录会在文本旁显示通用文件数量;记录没有文本时,则显示图片与文件数量。独立运行的压缩请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。 ### 时间概览 diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css index 414e123d9e..c743e0e503 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css @@ -1261,6 +1261,145 @@ padding-bottom: 0; } +.programDescription { + flex: none; + margin: 0; + padding: 8px 14px 2px; + color: var(--dsw-alias-label-primary); + font: var(--dsw-font-xs-13); + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.programIcon { + margin-right: 4px; + vertical-align: -2px; +} + +.programSummary { + margin-left: 5px; + font-family: var(--dsw-font-family); +} + +.detailBodyProgram, +.programPanel { + display: flex; + min-height: 0; + flex-direction: column; + overflow: hidden; +} + +.programPanel { + flex: 1; + background: var(--dsw-alias-bg-layer-1); +} + +.programActions { + display: inline-flex; + flex: none; + height: 16px; + align-items: center; + gap: 4px; + margin-left: auto; + padding-right: 14px; +} + +.programLanguage { + padding-right: 4px; + color: var(--dsw-alias-label-tertiary); + font: 11px/16px var(--ds-font-family-code); +} + +.programAction { + display: inline-flex; + box-sizing: border-box; + width: 20px; + height: 16px; + align-items: center; + justify-content: center; + padding: 0; + border: 0; + color: var(--dsw-alias-label-secondary); + background: transparent; + cursor: pointer; +} + +.programAction:hover { + color: var(--dsw-alias-label-primary); +} + +.programAction[aria-pressed='true'] { + color: var(--dsw-alias-state-business-primary); +} + +.programAction[data-state='failed'] { + color: var(--dsw-alias-state-error-primary); +} + +.programAction:focus-visible { + outline: 1px solid var(--dsw-alias-state-business-primary); + outline-offset: -1px; +} + +.programContent { + min-width: 0; + min-height: 0; +} + +.programPanel > .programContent { + flex: 1; + overflow: auto; + overscroll-behavior: contain; +} + +.programSource { + margin: 0 !important; + border-radius: 0; + background: var(--dsw-alias-bg-layer-1); + --dsl-code-block-line-white-space: pre; +} + +.programSource :where(pre) { + padding: 2px 14px 8px; + overflow: visible; + border-radius: 0; + background: var(--dsw-alias-bg-layer-1) !important; + font: 12px/16px var(--ds-font-family-code); + overflow-wrap: normal; + word-break: normal; + tab-size: 2; +} + +.programSource pre code > :global(.line) { + box-sizing: border-box; + width: max-content; + min-width: 100%; +} + +.programContent[data-wrap='true'] .programSource { + --dsl-code-block-line-white-space: pre-wrap; +} + +.programContent[data-wrap='true'] .programSource pre code > :global(.line) { + width: auto; + overflow-wrap: anywhere; + word-break: break-all; +} + +.programOutput { + margin: 0; + padding: 2px 14px 8px; + color: var(--dsw-alias-label-primary); + font: 12px/16px var(--ds-font-family-code); + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.programError::first-line { + color: var(--dsw-alias-state-error-primary); + font-weight: 600; +} + .overviewPreview .jsonPreview > :first-child, .overviewPreview .schemaTree > :first-child { padding-top: 2px; diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index 2736c6fd04..eb743e5881 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -1,16 +1,21 @@ /** Turn-aware trajectory event ledger with a local record inspector. */ -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react' import type { CSSProperties, ReactNode } from 'react' import { useVirtualizer } from '@tanstack/react-virtual' import { + CodeBlock, + IconCheckOutline16, IconChevronRightOutline14, + IconCodeOutline16, + IconCopyOutline16, IconSettingsOutline16, IconSparkle16, IconUserOutline16, JsonTree, MarkdownText, Tooltip, + writeClipboard, } from '@deepseek-ai/dsh-client-ui-primitives' import type { JsonTreeLabels, JsonTreeProps, MarkdownLabels } from '@deepseek-ai/dsh-client-ui-primitives' import { structuredPatch } from 'diff' @@ -30,6 +35,7 @@ import type { TrajectoryTurnModel } from './layout.ts' import { trajectoryPreviewText } from './trajectory-preview.ts' import type { TrajectoryKey, TrajectoryTranslate } from './locales.ts' import { COMPACTION_INTERRUPTED_ERROR } from './copy-codes.ts' +import { codeProgram, type CodeProgram } from './code-program.ts' import css from './TrajectoryTable.module.css' const BOTTOM_FOLLOW_THRESHOLD_PX = 2 @@ -991,8 +997,11 @@ function detailTabs(record: TableRecord): readonly DetailTabItem[] { } return [ { id: 'overview', labelKey: 'tab.summary' }, - ...(record.cell.inputDetail ? [{ id: 'input', labelKey: 'tab.payload' } as const] : []), - ...(record.cell.outputDetail ? [{ id: 'output', labelKey: 'tab.result' } as const] : []), + ...(record.cell.inputDetail ? [{ + id: 'input', labelKey: codeProgram(record.cell) === undefined ? 'tab.payload' : 'code.source', + } as const] : []), + ...(record.cell.outputDetail || codeProgram(record.cell) !== undefined + ? [{ id: 'output', labelKey: 'tab.result' } as const] : []), { id: 'schema', labelKey: 'tab.schema' }, { id: 'timing', labelKey: 'tab.timing' }, ] @@ -1000,6 +1009,8 @@ function detailTabs(record: TableRecord): readonly DetailTabItem[] { function recordDisplayText(cell: TrajectoryCellProps, t: TrajectoryTranslate): string { if (isToolCallOnly(cell, t)) return '' + const program = codeProgram(cell) + if (program !== undefined) return `${cell.text.split(' · ', 1)[0]} · ${program.description.replace(/\s+/g, ' ')}` if (cell.previewMarkdown !== undefined) { const preview = trajectoryPreviewText(cell.previewMarkdown) if (cell.text === '') return preview @@ -1099,10 +1110,11 @@ function RecordListText({ return ( <> + {toolCallText.name === 'run_code' && } {toolCallText.name || '—'} {toolCallText.args !== undefined && ( - + {toolCallText.args} )} @@ -1801,13 +1813,159 @@ function parseJsonContainer(value: string): object | undefined { } } +function InspectorCopyButton({ text, label, t }: { + text: string + label: string + t: TrajectoryTranslate +}) { + const [state, setState] = useState<'idle' | 'copied' | 'failed'>('idle') + useEffect(() => { + if (state === 'idle') return + const timer = setTimeout(() => { setState('idle') }, 1_500) + return () => { clearTimeout(timer) } + }, [state]) + const title = state === 'idle' ? label : t(state === 'copied' ? 'copied' : 'copy.failed') + return ( + + ) +} + +function ProgramInput({ program, rawInput, initialWrapped, stringWrapping, onOpen, t }: { + program: CodeProgram + rawInput: string + initialWrapped: boolean + stringWrapping: JsonTreeProps['stringWrapping'] + onOpen?: () => void + t: TrajectoryTranslate +}) { + const contentsId = useId() + const [wrapped, setWrapped] = useState(initialWrapped) + const [showJson, setShowJson] = useState(false) + const actions = ( + + {!showJson && program.language !== undefined && ( + {program.language} + )} + {!showJson && ( + + )} + {onOpen === undefined && ( + + )} + + + ) + const body = ( +
+ {showJson + ? + : } +
+ ) + return onOpen === undefined + ? ( +
+
+ {t('code.source')} + {actions} +
+ {body} +
+ ) + : {body} +} + +function ProgramOutput({ record, stringWrapping, onOpen, t }: { + record: TableRecord + stringWrapping: JsonTreeProps['stringWrapping'] + onOpen?: () => void + t: TrajectoryTranslate +}) { + const output = record.cell.outputDetail + const json = output === undefined || record.cell.isError === true ? undefined : parseJsonContainer(output) + const actions = output === undefined ? undefined : ( + + + + ) + const body = ( +
+ {output === undefined || output === '' + ?

{t(stateOf(record) === 'running' ? 'code.running' : 'record.noOutput')}

+ : json !== undefined + ? + :
{output}
} +
+ ) + return onOpen === undefined + ? ( +
+
+ {t('code.output')} + {actions} +
+ {body} +
+ ) + : {body} +} + function OverviewSection({ label, onOpen, + actions, children, }: { label: string onOpen: () => void + actions?: ReactNode children: ReactNode }) { const previewRef = useRef(null) @@ -1838,6 +1996,7 @@ function OverviewSection({ {label} + {actions}
(null) const [selectedRequest, setSelectedRequest] = useState(null) const [activeTab, setActiveTab] = useState('overview') + const [codeWrappingOnOpen, setCodeWrappingOnOpen] = useState(false) const [thinkingDisclosure, setThinkingDisclosure] = useState<{ recordId: string; expanded: boolean }>() const [detailsWidth, setDetailsWidth] = useState(null) const [toolRequestOffset, setToolRequestOffset] = useState(null) @@ -1919,6 +2079,7 @@ export function TrajectoryTable({ const selected = selectedTemplate === undefined ? undefined : currentRecord(selectedTemplate) + const selectedProgram = selected === undefined ? undefined : codeProgram(selected.cell) const thinkingExpanded = thinkingDisclosure?.recordId === selectedRecordId ? thinkingDisclosure.expanded : selected?.cell.kind === 'message' @@ -2130,6 +2291,7 @@ export function TrajectoryTable({ } const activateTab = (tab: DetailTab) => { + setCodeWrappingOnOpen(stringWrapping?.getDefault() ?? false) tabHistory.current.delete(tab) tabHistory.current.add(tab) setActiveTab(tab) @@ -2146,6 +2308,7 @@ export function TrajectoryTable({ } const selectRecord = useCallback((index: number) => { + setCodeWrappingOnOpen(stringWrapping?.getDefault() ?? false) const record = allRecords.find(candidate => candidate.cell.index === index) onRecordSelect?.(index) setSelectedRequest(null) @@ -2155,7 +2318,7 @@ export function TrajectoryTable({ const available = new Set(tabs.map(tab => tab.id)) const recent = [...tabHistory.current].reverse().find(tab => available.has(tab)) setActiveTab(recent ?? tabs[0]?.id ?? 'overview') - }, [allRecords, onRecordSelect]) + }, [allRecords, onRecordSelect, stringWrapping]) useEffect(() => { if ( recordSelection === null @@ -2845,7 +3008,7 @@ export function TrajectoryTable({ {selected.cell.kind === 'compacted' ? sectionLabel(selected.turn, t) - : `${sectionLabel(selected.turn, t)} · ${selected.group}`} + : selected.group} )} @@ -2879,7 +3042,9 @@ export function TrajectoryTable({ id="trajectory-detail-panel" className={activeTab === 'overview' ? `${css.detailBody} ${css.detailBodySummary}` - : css.detailBody} + : selectedProgram !== undefined && (activeTab === 'input' || activeTab === 'output') + ? `${css.detailBody} ${css.detailBodyProgram}` + : css.detailBody} role="tabpanel" aria-labelledby={`trajectory-detail-${activeTab}`} > @@ -3091,6 +3256,9 @@ export function TrajectoryTable({ && selectedState !== undefined && activeTab === 'overview' && ( <> + {selectedProgram !== undefined && selectedProgram.description !== '' && ( +

{selectedProgram.description}

+ )}
- {isMarkdownRecord(selected) + {selectedProgram !== undefined ? ( <> - { activateTab('rendered') }}> - - + { activateTab('input') }} t={t} /> + { activateTab('output') }} t={t} /> ) - : ( - <> - {selected.cell.inputDetail && ( - { activateTab('input') }}> - + : isMarkdownRecord(selected) + ? ( + <> + { activateTab('rendered') }}> + - )} - {selected.cell.outputDetail && ( - { activateTab('output') }}> - + + ) + : ( + <> + {selected.cell.inputDetail && ( + { activateTab('input') }}> + + + )} + {selected.cell.outputDetail && ( + { activateTab('output') }}> + + + )} + { activateTab('schema') }}> + - )} - { activateTab('schema') }}> - - - - )} + + )} {selectedAssistantRequestTarget !== undefined && ( )} {!promptSelected && selected !== undefined && activeTab === 'input' && ( - + selectedProgram === undefined + ? + : )} {!promptSelected && selected !== undefined && activeTab === 'output' && ( - + selectedProgram === undefined + ? + : )} {!promptSelected && selected !== undefined && activeTab === 'schema' && ( diff --git a/packages/client/ui-trajectory/src/client/code-program.ts b/packages/client/ui-trajectory/src/client/code-program.ts new file mode 100644 index 0000000000..98762cdcbd --- /dev/null +++ b/packages/client/ui-trajectory/src/client/code-program.ts @@ -0,0 +1,57 @@ +/** Read replayable PTC source and language hints from recorded tool arguments and schemas. */ +import type { TrajectoryCellProps } from './trajectory-record.ts' + +/** Validated source and original arguments for one recorded run_code call. */ +export interface CodeProgram { + source: string + description: string + arguments: Record + language: 'typescript' | 'python' | undefined +} + +function record(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? value as Record + : undefined +} + +function parseRecord(value: string | undefined): Record | undefined { + if (value === undefined) return undefined + try { + return record(JSON.parse(value)) + } catch { + return undefined + } +} + +function recordedLanguage(schemaRaw: string | undefined): CodeProgram['language'] { + const schema = parseRecord(schemaRaw) + const properties = record(record(schema?.parameters)?.properties) + const description = record(properties?.code)?.description + if (typeof description !== 'string') return undefined + const typescript = /\bTypeScript\b/i.test(description) + const python = /\bPython\b/i.test(description) + if (typescript === python) return undefined + return typescript ? 'typescript' : 'python' +} + +/** + * Resolve a PTC program without guessing its language from source or current runtime settings. + * @param cell - Recorded tool arguments and the schema visible at call time. + * @returns The program, or undefined for another tool or unsupported arguments. + */ +export function codeProgram(cell: TrajectoryCellProps): CodeProgram | undefined { + if (cell.kind !== 'tool' && cell.kind !== 'subtool') return undefined + if (cell.text.split(' · ', 1)[0] !== 'run_code') return undefined + const args = parseRecord(cell.inputDetail) + if (typeof args?.code !== 'string') return undefined + if (args.description !== undefined && typeof args.description !== 'string') return undefined + return { + source: args.code, + description: typeof args.description === 'string' && args.description.trim() !== '' + ? args.description + : args.code.split(/\r?\n/).find(line => line.trim() !== '')?.trim() ?? '', + arguments: args, + language: recordedLanguage(cell.schemaDetail), + } +} diff --git a/packages/client/ui-trajectory/src/client/locales.ts b/packages/client/ui-trajectory/src/client/locales.ts index c3b69be06d..c2fbe6b25c 100644 --- a/packages/client/ui-trajectory/src/client/locales.ts +++ b/packages/client/ui-trajectory/src/client/locales.ts @@ -115,6 +115,12 @@ export const zh = { 'record.outputJson': '结果 JSON', 'record.thinking': '思考', 'record.wrapLines': '自动换行', + 'code.source': '代码', + 'code.output': '输出', + 'code.copySource': '复制代码', + 'code.copyOutput': '复制输出', + 'code.originalJson': '原始 JSON', + 'code.running': '执行中…', 'record.systemPromptMissing': '本次请求没有系统提示词', 'record.toolsMissing': '本次请求没有工具', 'record.systemPrompt': '系统提示词', @@ -309,6 +315,12 @@ export const en: Record = { 'record.outputJson': 'Result JSON', 'record.thinking': 'Thinking', 'record.wrapLines': 'Wrap lines', + 'code.source': 'Code', + 'code.output': 'Output', + 'code.copySource': 'Copy code', + 'code.copyOutput': 'Copy output', + 'code.originalJson': 'Original JSON', + 'code.running': 'Running…', 'record.systemPromptMissing': 'No system prompt in this request', 'record.toolsMissing': 'No tools in this request', 'record.systemPrompt': 'System Prompt', diff --git a/packages/client/ui-trajectory/tests/code-inspector.client.spec.tsx b/packages/client/ui-trajectory/tests/code-inspector.client.spec.tsx new file mode 100644 index 0000000000..748463b6ea --- /dev/null +++ b/packages/client/ui-trajectory/tests/code-inspector.client.spec.tsx @@ -0,0 +1,116 @@ +// @vitest-environment jsdom +/** PTC source, output, and independent wrapping through the trajectory inspector. */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, waitFor, within } from '@testing-library/react' +import { TrajectoryTable } from '../src/client/TrajectoryTable.tsx' +import type { TrajectoryCellProps } from '../src/client/trajectory-record.ts' +import { t } from './locale.client.ts' + +afterEach(() => { + cleanup() + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +const SOURCE = 'const value = await tools.bash({ command: "pwd" })\nreturn value\n' +const ARGS = JSON.stringify({ code: SOURCE, description: 'Read the working directory' }) +const CELL: TrajectoryCellProps = { + index: 1, kind: 'tool', text: `run_code · ${ARGS}`, callId: 'code-1', + inputDetail: ARGS, outputDetail: 'printed line\n42', result: 'printed line', timeSeconds: 0.5, +} + +function Harness({ cell = CELL, getDefault = () => false, setDefault = () => {} }: { + cell?: TrajectoryCellProps + getDefault?: () => boolean + setDefault?: (value: boolean) => void +}) { + return null} t={t} + stringWrapping={{ label: 'Wrap lines', getDefault, setDefault }} + collapsedTurns={new Set()} onToggleTurn={() => {}} + collapsedAssistants={new Set()} onToggleAssistant={() => {}} + /> +} + +describe('PTC trajectory inspector', () => { + it('shows the description, numbered source, original arguments, and verbatim output', () => { + const view = render() + const ui = within(view.container) + fireEvent.click(ui.getByRole('row', { name: /run_code Read the working directory/ })) + expect(ui.getAllByRole('tab').map(tab => tab.textContent)).toEqual(['Summary', 'Code', 'Result', 'Schema', 'Timing']) + expect(ui.getByRole('tabpanel').textContent).toContain('Read the working directory') + expect(view.container.querySelector('[data-line-numbers] pre')?.textContent).toBe(SOURCE.trimEnd()) + expect(ui.getByRole('tabpanel').textContent).toContain('printed line\n42') + fireEvent.click(ui.getByRole('button', { name: 'Code' })) + expect(ui.getByRole('tab', { name: 'Code' }).getAttribute('aria-selected')).toBe('true') + fireEvent.click(ui.getByRole('button', { name: 'Original JSON' })) + expect(ui.getByRole('tree', { name: 'parameters JSON' }).textContent).toContain('description:') + fireEvent.click(ui.getByRole('button', { name: 'Original JSON' })) + expect(view.container.querySelector('[data-line-numbers] pre')?.textContent).toBe(SOURCE.trimEnd()) + }) + + it('copies exact source, raw JSON, and output with feedback', async () => { + const writeText = vi.fn().mockResolvedValue(undefined) + vi.stubGlobal('navigator', { clipboard: { writeText } }) + const view = render() + const ui = within(view.container) + fireEvent.click(ui.getByRole('row', { name: /run_code Read/ })) + fireEvent.click(ui.getByRole('button', { name: 'Copy code' })) + await waitFor(() => { expect(writeText).toHaveBeenLastCalledWith(SOURCE) }) + await waitFor(() => { expect(ui.getByRole('button', { name: 'Copied' })).toBeTruthy() }) + fireEvent.click(ui.getByRole('button', { name: 'Copy output' })) + await waitFor(() => { expect(writeText).toHaveBeenLastCalledWith('printed line\n42') }) + fireEvent.click(ui.getByRole('tab', { name: 'Code' })) + fireEvent.click(ui.getByRole('button', { name: 'Original JSON' })) + fireEvent.click(ui.getByRole('button', { name: 'Copy JSON' })) + await waitFor(() => { expect(writeText).toHaveBeenLastCalledWith(ARGS) }) + }) + + it('samples the last wrapping choice on open without changing another open inspector', () => { + let preference = false + const props = { getDefault: () => preference, setDefault: (value: boolean) => { preference = value } } + const first = render() + const second = render() + const a = within(first.container) + const b = within(second.container) + fireEvent.click(a.getByRole('row', { name: /run_code Read/ })) + fireEvent.click(b.getByRole('row', { name: /run_code Read/ })) + fireEvent.click(a.getByRole('button', { name: 'Wrap lines' })) + expect(preference).toBe(true) + expect(a.getByRole('button', { name: 'Wrap lines' }).getAttribute('aria-pressed')).toBe('true') + expect(b.getByRole('button', { name: 'Wrap lines' }).getAttribute('aria-pressed')).toBe('false') + fireEvent.click(b.getByRole('tab', { name: 'Code' })) + expect(b.getByRole('button', { name: 'Wrap lines' }).getAttribute('aria-pressed')).toBe('true') + fireEvent.click(b.getByRole('button', { name: 'Wrap lines' })) + expect(preference).toBe(false) + expect(a.getByRole('button', { name: 'Wrap lines' }).getAttribute('aria-pressed')).toBe('true') + fireEvent.click(a.getByRole('button', { name: 'Close details' })) + fireEvent.click(a.getByRole('row', { name: /run_code Read/ })) + expect(a.getByRole('button', { name: 'Wrap lines' }).getAttribute('aria-pressed')).toBe('false') + }) + + it('shows pending execution and keeps a failed run’s captured output', () => { + const pending = { ...CELL, timeSeconds: null } + delete pending.result + delete pending.outputDetail + const view = render() + const ui = within(view.container) + fireEvent.click(ui.getByRole('row', { name: /run_code Read/ })) + expect(ui.getByRole('tabpanel').textContent).toContain('Running…') + view.rerender() + expect(ui.getByRole('tabpanel').textContent).toContain('Failed') + expect(ui.getByRole('tabpanel').textContent).toContain('Error: execution failed\nCaptured output:\nstep one') + }) + + it('uses a tree for complete JSON output and generic input for malformed arguments', () => { + const view = render() + const ui = within(view.container) + fireEvent.click(ui.getByRole('row', { name: /run_code Read/ })) + expect(ui.getByRole('tree', { name: 'Result JSON' }).textContent).toContain('passed:12') + view.rerender() + expect(ui.getByRole('tab', { name: 'Payload' })).toBeTruthy() + expect(ui.queryByRole('tab', { name: 'Code' })).toBeNull() + expect(ui.getByRole('tree', { name: 'Payload JSON' }).textContent).toContain('code:3') + }) +}) diff --git a/packages/client/ui-trajectory/tests/code-program.client.spec.ts b/packages/client/ui-trajectory/tests/code-program.client.spec.ts new file mode 100644 index 0000000000..c0c96ca044 --- /dev/null +++ b/packages/client/ui-trajectory/tests/code-program.client.spec.ts @@ -0,0 +1,60 @@ +/** Recorded PTC argument validation and historical language selection. */ +import { describe, expect, it } from 'vitest' +import { codeProgram } from '../src/client/code-program.ts' +import type { TrajectoryCellProps } from '../src/client/trajectory-record.ts' + +const CELL: TrajectoryCellProps = { + index: 1, kind: 'tool', text: 'run_code · {}', timeSeconds: 1, + inputDetail: JSON.stringify({ code: 'return 42\n', description: 'Compute the answer' }), +} + +describe('recorded code programs', () => { + it('keeps the program and arguments verbatim', () => { + expect(codeProgram(CELL)).toEqual({ + source: 'return 42\n', description: 'Compute the answer', + arguments: { code: 'return 42\n', description: 'Compute the answer' }, language: undefined, + }) + }) + + it.each([ + ['The program: the body of an async TypeScript function.', 'typescript'], + ['The program: the body of an async Python function.', 'python'], + ['A TypeScript or Python program.', undefined], + ['An executable program.', undefined], + ])('uses the recorded parameter description %s', (description, language) => { + const schemaDetail = JSON.stringify({ parameters: { properties: { code: { description } } } }) + expect(codeProgram({ ...CELL, schemaDetail })?.language).toBe(language) + }) + + it.each([undefined, '{', '{}', 'null', '[]'])('keeps missing or unusable schemas plain: %s', (schemaDetail) => { + expect(codeProgram({ ...CELL, ...(schemaDetail === undefined ? {} : { schemaDetail }) })?.language).toBeUndefined() + }) + + it('uses the first nonempty source line when a description is unavailable', () => { + const source = '\n const x = 1\nreturn x\n' + for (const description of [undefined, '', ' ']) { + expect(codeProgram({ ...CELL, inputDetail: JSON.stringify({ code: source, description }) })) + .toMatchObject({ source, description: 'const x = 1' }) + } + }) + + it('retains empty source and unknown argument fields for JSON inspection', () => { + expect(codeProgram({ ...CELL, inputDetail: '{"code":"","extra":true}' })) + .toMatchObject({ source: '', description: '', arguments: { code: '', extra: true } }) + }) + + it.each([undefined, '{', '[]', 'null', '42', '{}', '{"code":42}', '{"code":"x","description":[]}' ])( + 'declines incomplete or unsupported arguments: %s', (inputDetail) => { + const cell = { ...CELL } + if (inputDetail === undefined) delete cell.inputDetail + else cell.inputDetail = inputDetail + expect(codeProgram(cell)).toBeUndefined() + }, + ) + + it('does not specialize other tools or non-tool records', () => { + expect(codeProgram({ ...CELL, kind: 'message' })).toBeUndefined() + expect(codeProgram({ ...CELL, text: 'run_code_extra · {}' })).toBeUndefined() + expect(codeProgram({ ...CELL, kind: 'subtool' })?.source).toBe('return 42\n') + }) +}) diff --git a/snapshots/web/ptc-round/code.expected.md b/snapshots/web/ptc-round/code.expected.md new file mode 100644 index 0000000000..5e1d42ae59 --- /dev/null +++ b/snapshots/web/ptc-round/code.expected.md @@ -0,0 +1,7 @@ +- tabpanel "Code": + - text: Code typescript + - button "Wrap lines" [pressed] + - button "Original JSON" + - button "Copy code": + - img + - code: "const bashResult = await tools.bash({ command: \"echo CODE_ROUND_OK\", description: \"Echo CODE_ROUND_OK\" }); let readError = null; try { await tools.read({ file_path: \"missing.txt\" }); } catch (e) { readError = { toolName: e.toolName, message: e.message }; } return { bash: bashResult.stdout.text.trim(), readError };" diff --git a/snapshots/web/ptc-round/trajectory.expected.md b/snapshots/web/ptc-round/trajectory.expected.md new file mode 100644 index 0000000000..ed88d3b743 --- /dev/null +++ b/snapshots/web/ptc-round/trajectory.expected.md @@ -0,0 +1,22 @@ +- heading "Code typescript Wrap lines Copy code" [level=3]: + - button "Code": + - text: Code + - img + - text: typescript + - button "Wrap lines" + - button "Copy code": + - img +- code: "const bashResult = await tools.bash({ command: \"echo CODE_ROUND_OK\", description: \"Echo CODE_ROUND_OK\" }); let readError = null; try { await tools.read({ file_path: \"missing.txt\" }); } catch (e) { readError = { toolName: e.toolName, message: e.message }; } return { bash: bashResult.stdout.text.trim(), readError };" +- heading "Output Copy output" [level=3]: + - button "Output": + - text: Output + - img + - button "Copy output": + - img +- text: "{" +- tree "Result JSON": + - treeitem "bash:\"CODE_ROUND_OK\"," + - 'treeitem "Expand readError:{toolName: \"read\", message: \"cannot read \\\"{{cwd}}/workspace/missing.txt\\\": not found\"}"': + - button "Expand" + - text: "readError:{toolName: \"read\", message: \"cannot read \\\"{{cwd}}/workspace/missing.txt\\\": not found\"}" +- text: "}" From 0a525601a76ea0f37faab1ad29e9eb11b2319c6e Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 9 Sep 2026 18:17:47 +0800 Subject: [PATCH 28/79] fix(web): harden sidebar terminal lifecycle and recovery --- .../2026-09-09-web-sidebar-terminal.i18n.yaml | 4 +- .../2026-09-09-web-sidebar-terminal.md | 4 +- .../2026-09-09-web-sidebar-terminal.zh.md | 4 +- .../sidebar-terminal/limit.expected.md | 1 + .../tests/fixtures/sidebar-terminal.patch.yml | 1 + apps/web/tests/sidebar-terminal.e2e.ts | 35 ++++- .../api/terminal-controller/README.i18n.yaml | 4 +- packages/api/terminal-controller/README.md | 8 +- packages/api/terminal-controller/README.zh.md | 8 +- .../terminal-controller/src/client/index.ts | 17 ++- .../terminal-controller/src/client/model.ts | 65 ++++++--- packages/api/terminal-controller/src/index.ts | 5 +- .../api/terminal-controller/src/terminal.ts | 17 ++- packages/api/terminal-controller/src/types.ts | 10 ++ .../tests/controller.spec.ts | 4 +- .../tests/model.client.spec.ts | 74 ++++++++-- .../tests/recovery.client.spec.ts | 70 +++++++++- .../tests/terminal.spec.ts | 10 +- .../client/ui-sidebar-right/README.i18n.yaml | 4 +- packages/client/ui-sidebar-right/README.md | 2 +- packages/client/ui-sidebar-right/README.zh.md | 2 +- .../src/client/contract/slots.ts | 2 +- .../ui-sidebar-right/src/client/index.ts | 4 +- .../ui-sidebar-right/src/client/service.ts | 41 ++---- .../ui-sidebar-right/src/client/tab-domain.ts | 4 +- .../tests/apply.client.spec.ts | 4 +- .../tests/seat.client.spec.tsx | 14 +- .../tests/service.client.spec.ts | 127 +++++------------- .../tests/tab-domain.client.spec.ts | 2 +- .../ui-sidebar-terminal/README.i18n.yaml | 4 +- packages/client/ui-sidebar-terminal/README.md | 2 +- .../client/ui-sidebar-terminal/README.zh.md | 2 +- .../src/client/TerminalBody.tsx | 2 +- .../ui-sidebar-terminal/src/client/locales.ts | 10 ++ .../tests/apply.client.spec.ts | 4 +- .../tests/terminal-body.client.spec.tsx | 16 ++- packages/e2b/subprocess-e2b/README.i18n.yaml | 4 +- packages/e2b/subprocess-e2b/README.md | 2 +- packages/e2b/subprocess-e2b/README.zh.md | 2 +- packages/e2b/subprocess-e2b/src/index.ts | 2 +- .../e2b/subprocess-e2b/tests/terminal.spec.ts | 2 + .../subprocess-local/README.i18n.yaml | 4 +- .../subprocess/subprocess-local/README.md | 2 +- .../subprocess/subprocess-local/README.zh.md | 2 +- .../subprocess/subprocess-local/src/index.ts | 2 +- .../subprocess-local/tests/local.spec.ts | 6 + 46 files changed, 384 insertions(+), 231 deletions(-) create mode 100644 apps/web/tests/expected/sidebar-terminal/limit.expected.md diff --git a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml index 3da12183fa..d976a5b494 100644 --- a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md -2026-09-09-web-sidebar-terminal.md: de51d0bb849de0599dfdaf86d2293c17618059cf -2026-09-09-web-sidebar-terminal.zh.md: e6aacebc424efc12457b4a31f0b2660a3ad6225b +2026-09-09-web-sidebar-terminal.md: 4339e6aa0bbb28383a9f2b42336965d44b1a0e07 +2026-09-09-web-sidebar-terminal.zh.md: aec1286b0d6e594813735acfd06dbaf34f261972 diff --git a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md index de51d0bb84..4339e6aa0b 100644 --- a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md +++ b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md @@ -12,13 +12,13 @@ Web users need an interactive shell beside a Session to inspect the workspace an `api-terminal-controller` owns user terminals by Session and exposes the `terminal` Remote namespace. `ui-sidebar-terminal` registers native right-sidebar tabs, xterm.js rendering and FitAddon sizing. A new terminal starts the execution environment's default shell immediately. The existing sidebar controls open additional tabs; double-clicking a tab title renames its terminal. Terminal processes use the composed subprocess provider and Session sandbox policy. Shell resolution occurs only during creation; reading limits and reconnecting an existing process do not depend on the default executable remaining available. Interactive shell configuration supplies Tab completion and optional inline suggestions. -Close and replacement remove the tab synchronously and run process cleanup in the background. The Client first records the unfinished close request under a terminal-specific localStorage key; success removes it, and startup retries requests that remain. A cleanup failure produces a lightweight notification with a retry action without reopening the tab. Independent keys prevent another window from overwriting unrelated cleanup requests. Collapse, tab/Session switching, floating, fullscreen and browser disconnection preserve the process. Component cleanup and `TabDomain.signal` only detach browser work because the same lifetime can end during plugin reload. Failed process cleanup retains ownership, including failures after allocation but before create publication. Session owner disposal and Host plugin disposal also clean up terminals. +Close and replacement remove the tab synchronously and run process cleanup in the background. The Client first records the unfinished close request under a terminal-specific localStorage key; success removes it, and startup retries requests that remain. A cleanup failure produces a lightweight notification with a retry action without reopening the tab. Independent keys prevent another window from overwriting unrelated cleanup requests. Collapse, tab/Session switching, floating, fullscreen and browser disconnection preserve the process. Component cleanup and `TabDomain.signal` only detach browser work because the same lifetime can end during plugin reload. Failed process cleanup retains ownership, including failures after allocation but before create publication. Session owner disposal and Host plugin disposal also clean up terminals. A definitive missing-Session response retires its saved close request because the Session owns process cleanup; transport failures remain retryable. Client plugin disposal awaits every detached stream so a replacement plugin does not inherit unfinished Client cleanup. Sidebar layout, open-tab mappings, selection and process PIDs are not persisted. When the Session header mounts, the Client queries `terminal.list` and opens retained Host terminals as new tabs. Their `params.terminalId` association exists only in the current page. New and recovered views use different `createWhenMissing` values: only a new view may allocate a process; a recovered target that disappears reports an error. Host state supplies recovery identities and titles, so the browser does not maintain a second active-terminal registry. The caller retains a terminal ID in memory before creating it. Repeating create with the same Session and open ID does not allocate a second process. Closing an uncertain create uses that ID even when no creation response arrived. The Host records closed IDs before awaiting allocation, preventing a delayed create from reviving a closed terminal. Each attachment begins with a consistent, bounded serialized xterm screen; ordered output follows through the Gateway's existing multiplexed Remote stream. Output and screen snapshots share one operation queue. Followers retain final output on normal closure and fail explicitly on buffer overflow. Browser render acknowledgement prevents React batching from dropping increments. -The latest attachment controls input and dimensions; other attachments remain read-only. Every physical stream opening gets a fresh input attachment identity, including automatic transport recovery. Input RPCs are serialized, and results from a superseded attachment cannot downgrade a replacement connection. Terminal output creates no model input, Agent tool result or Session event. The existing [persistent Agent terminal decision](2026-07-16-persistent-pty-sessions.md) continues to govern model-owned sessions; this feature extends the [portable subprocess provider](../architecture/2026-07-28-portable-execution-world-consumers.md) with terminal environment facts and resize. +The latest attachment controls input and dimensions; other attachments remain read-only. Every physical stream opening gets a fresh input attachment identity, including automatic transport recovery. Input RPCs are serialized, and results from a superseded attachment cannot downgrade a replacement connection. Terminal output creates no model input, Agent tool result or Session event. The existing [persistent Agent terminal decision](2026-07-16-persistent-pty-sessions.md) continues to govern model-owned sessions; this feature extends the [portable subprocess provider](../architecture/2026-07-28-portable-execution-world-consumers.md) with terminal environment facts and resize. Control-transfer and process-exit refusals only disable input, preserving the healthy output view. Client-owned error identifiers are translated by the terminal UI, including guidance to close retained exited terminals when the quota is full. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md index e6aacebc42..aec1286b0d 100644 --- a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md +++ b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md @@ -12,13 +12,13 @@ Web 用户需要在 Session 旁使用交互式 shell 检查工作区和运行命 `api-terminal-controller` 按 Session 管理用户终端并提供 `terminal` Remote namespace。`ui-sidebar-terminal` 注册原生右侧栏标签页,使用 xterm.js 渲染和 FitAddon 测量尺寸。新终端立即启动执行环境的默认 shell。已有侧栏控件负责打开更多标签页,双击标签页标题可重命名终端。终端进程使用组合的 subprocess provider 和 Session sandbox policy。shell 只在创建时解析;读取限制和重新连接已有进程不依赖默认可执行文件仍然可用。交互式 shell 配置提供 Tab 补全和可选的内联建议。 -关闭和替换会同步移除标签页,并在后台清理进程。Client 先以终端独立的 localStorage key 保存未完成的关闭请求;成功后删除,启动时重试剩余请求。清理失败时显示带重试操作的轻量通知,不重新打开标签页。独立 key 避免其他窗口覆盖无关的清理请求。折叠、切换标签页或 Session、浮动、全屏和浏览器断线均保留进程。组件清理和 `TabDomain.signal` 只停止浏览器工作,因为插件重新加载也会结束这些生命周期。进程清理失败时保留所有权,包括分配完成但 create 尚未发布时的失败。Session owner 和 Host 插件卸载也会清理终端。 +关闭和替换会同步移除标签页,并在后台清理进程。Client 先以终端独立的 localStorage key 保存未完成的关闭请求;成功后删除,启动时重试剩余请求。清理失败时显示带重试操作的轻量通知,不重新打开标签页。独立 key 避免其他窗口覆盖无关的清理请求。折叠、切换标签页或 Session、浮动、全屏和浏览器断线均保留进程。组件清理和 `TabDomain.signal` 只停止浏览器工作,因为插件重新加载也会结束这些生命周期。进程清理失败时保留所有权,包括分配完成但 create 尚未发布时的失败。Session owner 和 Host 插件卸载也会清理终端。 明确的 Session 不存在响应会清除已保存的关闭请求,因为进程清理由 Session 负责;传输失败仍可重试。Client 插件卸载等待所有断开的流结束,避免替换插件继承未完成的 Client 清理。 侧栏布局、打开标签页映射、选中项和进程 PID 不持久化。Session header 挂载时,Client 查询 `terminal.list`,把 Host 保留的终端打开为新标签页。`params.terminalId` 关联只在当前页面中保留。新视图与恢复视图使用不同的 `createWhenMissing`:只有新视图可以分配进程,恢复目标消失时显示错误。恢复标识和标题来自 Host 状态,浏览器不维护第二份活跃终端注册表。 调用者在创建之前把终端 ID 保留在内存中。相同 Session 和未关闭 ID 的重复 create 不再分配进程。创建结果不确定时,关闭仍使用该 ID,即使没有收到创建响应。Host 在等待分配完成前记录已关闭 ID,防止迟到的 create 复活已关闭终端。每次连接先接收一致、有界的 xterm 序列化屏幕,后续有序输出使用 Gateway 已有的复用 Remote stream。输出和屏幕快照共享操作队列。订阅者正常关闭时保留末尾输出,缓存超限时明确失败。浏览器在完成渲染后确认帧,避免 React 批处理丢失增量。 -最新连接控制输入和尺寸,其他连接保持只读。每次物理流建立都创建新的输入连接标识,包括传输自动恢复。输入 RPC 按序发送,旧连接的结果不能把新连接降级为失败。终端输出不产生模型输入、Agent 工具结果或 Session 事件。[Agent 持久终端决策](2026-07-16-persistent-pty-sessions.zh.md)仍约束模型拥有的终端;此功能为[可移植 subprocess provider](../architecture/2026-07-28-portable-execution-world-consumers.zh.md)增加执行环境事实和 resize。 +最新连接控制输入和尺寸,其他连接保持只读。每次物理流建立都创建新的输入连接标识,包括传输自动恢复。输入 RPC 按序发送,旧连接的结果不能把新连接降级为失败。终端输出不产生模型输入、Agent 工具结果或 Session 事件。[Agent 持久终端决策](2026-07-16-persistent-pty-sessions.zh.md)仍约束模型拥有的终端;此功能为[可移植 subprocess provider](../architecture/2026-07-28-portable-execution-world-consumers.zh.md)增加执行环境事实和 resize。 控制权转移和进程退出导致的拒绝只禁用输入,保留健康的输出视图。Client 自产错误标识由终端 UI 翻译,包括名额用满时关闭已保留的退出终端的提示。 ## 考虑过的替代方案 diff --git a/apps/web/tests/expected/sidebar-terminal/limit.expected.md b/apps/web/tests/expected/sidebar-terminal/limit.expected.md new file mode 100644 index 0000000000..845c392f43 --- /dev/null +++ b/apps/web/tests/expected/sidebar-terminal/limit.expected.md @@ -0,0 +1 @@ +- alert: "Terminal error: The terminal limit has been reached. Close unused terminals and try again. Exited terminals also count toward the limit." diff --git a/apps/web/tests/fixtures/sidebar-terminal.patch.yml b/apps/web/tests/fixtures/sidebar-terminal.patch.yml index 0c055161f1..cc5d4b94f3 100644 --- a/apps/web/tests/fixtures/sidebar-terminal.patch.yml +++ b/apps/web/tests/fixtures/sidebar-terminal.patch.yml @@ -1,5 +1,6 @@ - id: terminal-controller config: + maxTerminals: 2 shell: path: /bin/bash name: bash diff --git a/apps/web/tests/sidebar-terminal.e2e.ts b/apps/web/tests/sidebar-terminal.e2e.ts index ed416301b6..ea79df7ec2 100644 --- a/apps/web/tests/sidebar-terminal.e2e.ts +++ b/apps/web/tests/sidebar-terminal.e2e.ts @@ -12,12 +12,13 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor const expected = fileURLToPath(new URL('./expected/sidebar-terminal/running.expected.md', import.meta.url)) const shots = fileURLToPath(new URL('../../../.artifacts/screenshots/sidebar-terminal/', import.meta.url)) -async function openTerminal(page: Page): Promise { +async function openTerminal(page: Page, waitForShell = true): Promise { const expand = page.locator('[data-sidebar-right-expand]') if (await expand.isVisible()) await expand.click() - else await page.locator('[data-dockkit-add-tab]').click() - await page.locator('[data-sidebar-right-guide-entry="terminal"]').click() - await expect.poll(async () => await page.locator('.xterm-rows:visible').innerText()).toContain('bash-') + const entry = page.locator('[data-sidebar-right-guide-entry="terminal"]') + if (!await entry.isVisible()) await page.locator('[data-dockkit-add-tab]').click() + await entry.click() + if (waitForShell) await expect.poll(async () => await page.locator('.xterm-rows:visible').innerText()).toContain('bash-') } async function command(page: Page, text: string): Promise { @@ -142,4 +143,30 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => { await expect.poll(() => scaffold.ctx.terminalController.list(scaffold.ctx.agents.list()[0]!).length).toBe(0) expect(tripwire.pageErrors).toEqual([]) }) + + it('explains that exited terminals count toward the quota and permits creation after closing one', async () => { + onTestFailed(() => saveFailureShot(page, 'sidebar-terminal-quota')) + const terminals = () => scaffold.ctx.terminalController.list(scaffold.ctx.agents.list()[0]!) + for (let count = 1; count <= 2; count++) { + await openTerminal(page) + await command(page, 'exit') + await expect.poll(() => terminals().filter(info => info.state === 'exited').length).toBe(count) + } + await openTerminal(page, false) + const alert = page.getByRole('alert') + await expect.poll(async () => await alert.innerText()).toContain('Exited terminals also count toward the limit.') + const expectedLimit = fileURLToPath(new URL('./expected/sidebar-terminal/limit.expected.md', import.meta.url)) + await compareOrRefreshGolden(expectedLimit, await alert.ariaSnapshot(), webSnapshotMode()) + const failed = page.locator('[data-dockkit-tab][aria-selected="true"]') + await failed.hover() + await failed.locator('[data-dockkit-tab-close]').click() + const exited = page.locator('[data-dockkit-tab]').filter({ hasText: 'bash' }).first() + await exited.hover() + await exited.locator('[data-dockkit-tab-close]').click() + await expect.poll(() => terminals().length).toBe(1) + await openTerminal(page) + await expect.poll(() => terminals().filter(info => info.state === 'running').length).toBe(1) + expect(tripwire.pageErrors).toEqual([]) + }) + }) diff --git a/packages/api/terminal-controller/README.i18n.yaml b/packages/api/terminal-controller/README.i18n.yaml index eba346c0ea..4ede4369b5 100644 --- a/packages/api/terminal-controller/README.i18n.yaml +++ b/packages/api/terminal-controller/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/terminal-controller/README.md -README.md: 08def8e53f6e9362b2bd99f03dc552115dcf9539 -README.zh.md: 4cd9d42272e4ff6a588305056450d145dc7dbb12 +README.md: 63da44832aa6087f43b9d9ecdafc56010f17245e +README.zh.md: abcaa83d41ff525d9e7a60ae49c27636a5c8b411 diff --git a/packages/api/terminal-controller/README.md b/packages/api/terminal-controller/README.md index 08def8e53f..63da44832a 100644 --- a/packages/api/terminal-controller/README.md +++ b/packages/api/terminal-controller/README.md @@ -47,11 +47,11 @@ New terminals use the execution environment's declared default shell. Only when The Host uses `ctx.subprocess.spawnTerminal` with `TERM=xterm-256color`; it never launches a desktop terminal application. Streaming UTF-8 decoding preserves split characters and leading BOMs, and replaces incomplete trailing bytes at EOF. Unary control uses the Gateway, and `follow` uses its multiplexed Remote stream transport. Headless xterm and its serializer produce each opening screen after all preceding output writes, then monotone output sequences identify subsequent frames. Slow followers fail explicitly; a new attachment restores the current screen. -The latest attachment owns input and resize. Detachment releases input control without killing the process. Explicit close awaits process cleanup and final output; cleanup failure retains the resource for retry. The Session remembers closed identities and rejects their delayed or repeated creation, including creation already in progress when close arrives. A new terminal uses a new identity. Pending allocations remain owned even if cancellation and cleanup both fail. Session owner disposal and controller disposal also terminate owned processes. An open or pending terminal prevents changing that Session's sandbox mode. +The latest attachment owns input and resize. Detachment releases input control without killing the process. Explicit close awaits process cleanup and final output; cleanup failure retains the resource for retry. The Session remembers closed identities and rejects their delayed or repeated creation, including creation already in progress when close arrives. A new terminal uses a new identity. Pending allocations remain owned even if cancellation and cleanup both fail. Session owner disposal and controller disposal also terminate owned processes. An open or pending terminal prevents changing that Session's sandbox mode. Input or resize refused after control transfer or process exit leaves the output attachment intact and disables input; rejected input is not replayed. -Client views keep the association between sidebar tabs and terminal identities in memory. Recovery queries the Host for retained terminals; a new view may create a process, while a recovered view reports a missing target without creating a replacement. The Client model acknowledges screen writes after the browser emulator processes them, serializes input and ignores stale attachment responses. +Client views keep the association between sidebar tabs and terminal identities in memory. Recovery queries the Host for retained terminals; a new view may create a process, while a recovered view reports a missing target without creating a replacement. The Client model acknowledges screen writes after the browser emulator processes them, serializes input and ignores stale attachment responses. Client-owned errors carry locale keys. Plugin disposal awaits active and previously detached output streams without closing Host processes. -Closing saves an unfinished cleanup request before releasing the tab, then awaits Host cleanup in the background. A failure exposes a retry notification. Each request has its own terminal-ID localStorage key and is removed after successful cleanup; startup retries saved requests. This stores cleanup intent, not sidebar layout, open-tab mappings, selected tabs or process PIDs. If browser storage is unavailable, cleanup remains usable in memory but cannot be recovered after reload. +Closing saves an unfinished cleanup request before releasing the tab, then awaits Host cleanup in the background. A failure exposes a retry notification. Each request has its own terminal-ID localStorage key and is removed after successful cleanup or a definitive `session/not-found` response; startup retries saved requests. Transport failures retain the request. This stores cleanup intent, not sidebar layout, open-tab mappings, selected tabs or process PIDs. If browser storage is unavailable, cleanup remains usable in memory but cannot be recovered after reload.
@@ -75,7 +75,7 @@ None; terminal output travels only between the browser and Host. -- Processes and screens survive browser reloads, but not Host or Session owner disposal. There is no durable terminal restoration or automatic shell respawn. +- Processes and screens survive browser reloads, but not Host or Session owner disposal. There is no durable terminal restoration or automatic shell respawn. Exited terminals count toward `maxTerminals`; close unused tabs to release their screens and quota. - The subprocess provider determines native PTY availability and process-tree cleanup guarantees. Finding an executable does not prove PTY allocation will succeed. - Screen recovery retains bounded history, not a complete transcript. Only one attachment at a time can write or resize. diff --git a/packages/api/terminal-controller/README.zh.md b/packages/api/terminal-controller/README.zh.md index 4cd9d42272..abcaa83d41 100644 --- a/packages/api/terminal-controller/README.zh.md +++ b/packages/api/terminal-controller/README.zh.md @@ -47,11 +47,11 @@ Web bundle 将此包与 subprocess provider、sandbox policy 和 Typert Gateway Host 通过 `ctx.subprocess.spawnTerminal` 创建 `TERM=xterm-256color` 的终端,不启动桌面终端应用。流式 UTF-8 解码保留跨块字符和开头的 BOM,并在 EOF 将不完整的尾部字节替换为替代字符。控制请求走 Gateway,`follow` 使用其复用的 Remote stream。Headless xterm 和序列化 addon 在此前输出写入后生成初始屏幕,后续增量携带单调序号。过慢的订阅者明确失败;重新连接恢复当前屏幕。 -最新连接持有输入和尺寸控制权。断开连接只释放输入权,不结束进程。显式关闭等待进程清理和最后输出;清理失败时保留资源以便重试。Session 记住已关闭的标识并拒绝迟到或重复的创建请求,包括关闭到达时仍在进行的创建。新终端使用新标识。取消创建且清理失败时,已分配的进程仍有所有者。Session owner 和 controller 卸载也会终止所拥有的进程。存在终端或创建请求时不能改变该 Session 的 sandbox mode。 +最新连接持有输入和尺寸控制权。断开连接只释放输入权,不结束进程。显式关闭等待进程清理和最后输出;清理失败时保留资源以便重试。Session 记住已关闭的标识并拒绝迟到或重复的创建请求,包括关闭到达时仍在进行的创建。新终端使用新标识。取消创建且清理失败时,已分配的进程仍有所有者。Session owner 和 controller 卸载也会终止所拥有的进程。存在终端或创建请求时不能改变该 Session 的 sandbox mode。 控制权转移或进程退出后被拒绝的输入和尺寸请求保留输出连接并禁用输入,不重发被拒绝的输入。 -Client 视图只在内存中关联侧栏标签页与终端标识。恢复操作查询 Host 保留的终端;新视图可以创建进程,恢复视图在目标缺失时显示错误,不创建替代进程。Client 模型在浏览器完成屏幕解析后确认帧,按序发送输入,并忽略旧连接迟到的响应。 +Client 视图只在内存中关联侧栏标签页与终端标识。恢复操作查询 Host 保留的终端;新视图可以创建进程,恢复视图在目标缺失时显示错误,不创建替代进程。Client 模型在浏览器完成屏幕解析后确认帧,按序发送输入,并忽略旧连接迟到的响应。 Client 自产错误携带本地化键。插件卸载等待活跃及先前断开的输出流结束,不关闭 Host 进程。 -关闭时先保存未完成的清理请求并释放标签页,再在后台等待 Host 清理。失败时提供重试通知。每个请求使用独立的终端 ID localStorage key,清理成功后删除;启动时重试已保存的请求。保存的是清理意图,不是侧栏布局、打开标签页映射、选中标签页或进程 PID。浏览器存储不可用时,内存中的清理仍可工作,但刷新后无法恢复该请求。 +关闭时先保存未完成的清理请求并释放标签页,再在后台等待 Host 清理。失败时提供重试通知。每个请求使用独立的终端 ID localStorage key,清理成功或收到明确的 `session/not-found` 响应后删除;启动时重试已保存的请求。传输失败时保留请求。保存的是清理意图,不是侧栏布局、打开标签页映射、选中标签页或进程 PID。浏览器存储不可用时,内存中的清理仍可工作,但刷新后无法恢复该请求。
@@ -75,7 +75,7 @@ Client 视图只在内存中关联侧栏标签页与终端标识。恢复操作 -- 浏览器刷新保留进程和屏幕;Host 或 Session owner 卸载不保留。不提供持久终端恢复或自动重启 shell。 +- 浏览器刷新保留进程和屏幕;Host 或 Session owner 卸载不保留。不提供持久终端恢复或自动重启 shell。 已退出的终端仍计入 `maxTerminals`;关闭不用的标签页可释放其屏幕和名额。 - 原生 PTY 可用性和进程树清理保证由 subprocess provider 决定。找到可执行文件并不保证 PTY 分配成功。 - 屏幕恢复只保留有界历史,不保留完整记录。同一时刻只有一个连接可输入或调整尺寸。 diff --git a/packages/api/terminal-controller/src/client/index.ts b/packages/api/terminal-controller/src/client/index.ts index b49d86c437..1daec7edd8 100644 --- a/packages/api/terminal-controller/src/client/index.ts +++ b/packages/api/terminal-controller/src/client/index.ts @@ -1,5 +1,6 @@ /** Client terminal model service; views are keyed independently from Host terminal identities. */ import { Service, type Context } from '@deepseek-ai/cordis' +import { remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type {} from '@deepseek-ai/dsh-api-gateway/client' import { TerminalView, type TerminalRemote } from './model.ts' @@ -8,7 +9,7 @@ import { randomUUID } from '@deepseek-ai/dsh-util-crypto' import type { WebTerminalId, WebTerminalInfo } from '../types.ts' import { TerminalCloseRequests, type TerminalCloseRequest } from './close-requests.ts' -export type { TerminalView, TerminalViewState, TerminalRenderFrame, TerminalRemote } from './model.ts' +export type { TerminalView, TerminalViewState, TerminalViewIssue, TerminalRenderFrame, TerminalRemote } from './model.ts' declare module '@deepseek-ai/cordis' { interface Context { @@ -42,9 +43,9 @@ export class ClientTerminals extends Service { super(ctx, 'webTerminals') ctx.effect(() => async () => { this.disposed = true - for (const views of this.views.values()) for (const view of views.values()) view.dispose() + const detaching = [...this.views.values()].flatMap(views => [...views.values()].map(view => view.dispose())) this.views.clear() - await Promise.all(this.closing.values()) + await Promise.all([...detaching, ...this.closing.values()]) }, 'terminal-controller.client.views') for (const request of this.requests.pending()) this.cleanup(request) } @@ -117,16 +118,20 @@ export class ClientTerminals extends Service { if (view !== undefined) await view.close() else { const result = await this.remote.close(record.sessionId, record.id) - if (!result.ok) throw new Error(result.error.message) + if (!result.ok) throw result.error } this.requests.remove(record.id) })().catch((error: unknown) => { + if (remoteErrorOf(error)?.code === 'session/not-found') { + this.requests.remove(record.id) + return + } if (!this.disposed) this.closeFailures.set([...this.closeFailures.getSnapshot(), { id: record.id, title: record.title, message: error instanceof Error ? error.message : String(error), }]) - }).finally(() => { - view?.dispose() + }).then(async () => { + await view?.dispose() this.closing.delete(record.id) }) this.closing.set(record.id, pending) diff --git a/packages/api/terminal-controller/src/client/model.ts b/packages/api/terminal-controller/src/client/model.ts index 8c4336f3b9..7c19a4cac8 100644 --- a/packages/api/terminal-controller/src/client/model.ts +++ b/packages/api/terminal-controller/src/client/model.ts @@ -2,7 +2,7 @@ import { randomUUID } from '@deepseek-ai/dsh-util-crypto' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' import { RemoteStreamCarrierError, type ClientRemote, type RemoteStream } from '@deepseek-ai/dsh-api-gateway/client' -import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import { RemoteError, remoteErrorOf, type RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import type {} from '@deepseek-ai/dsh-api-terminal-controller/remote' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { @@ -13,6 +13,20 @@ import type { /** The generated terminal namespace's browser-facing operations. */ export type TerminalRemote = ClientRemote['terminal'] +/** Product error identifiers translated by the terminal UI. */ +export type TerminalViewIssue = 'missingTerminal' | 'inputFull' | 'attachmentEnded' | 'invalidOutput' | 'terminalLimit' + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface RemoteErrorDetailsMap { + /** Client terminal failure preserved through the Remote stream supervisor. */ + 'terminal/view': { readonly issue: TerminalViewIssue } + } +} + +class TerminalViewError extends RemoteError<'terminal/view'> { + constructor(issue: TerminalViewIssue, message: string = issue) { super('terminal/view', message, { issue }) } +} + /** One screen write awaiting the DOM emulator's callback. */ export interface TerminalRenderFrame { readonly revision: number @@ -28,6 +42,7 @@ export interface TerminalViewState { readonly writable: boolean readonly render?: TerminalRenderFrame | undefined readonly error?: string | undefined + readonly issue?: TerminalViewIssue | undefined } /** A view survives DOM unmount; its process only ends on explicit close. */ @@ -45,6 +60,7 @@ export class TerminalView { private closing: Promise | undefined private writes: Promise = Promise.resolve() private queuedInput = 0 + private readonly detaching = new Set>() /** * @param sessionId - Session owning the terminal. @@ -83,7 +99,7 @@ export class TerminalView { refresh(): Promise { if (this.loading !== undefined) return this.loading if (this.closing !== undefined || this.lifetime.signal.aborted) return Promise.resolve() - this.patch({ phase: 'loading', error: undefined }) + this.patch({ phase: 'loading', error: undefined, issue: undefined }) this.loading = (async () => { const [environment, available] = await Promise.all([ this.remote.environment(this.sessionId, this.lifetime.signal), this.remote.list(this.sessionId), @@ -93,13 +109,13 @@ export class TerminalView { const info = valueOf(available).find(item => item.id === this.id) if (info !== undefined) this.adopt(info) else if (this.createWhenMissing) await this.create(valueOf(environment)) - else throw new Error('This terminal no longer exists on the Host. Open a new terminal.') + else throw new TerminalViewError('missingTerminal') })().catch((error: unknown) => { this.fail(error) }).finally(() => { this.loading = undefined }) return this.loading } private async create(environment: TerminalEnvironment): Promise { - this.patch({ phase: 'creating', error: undefined }) + this.patch({ phase: 'creating', error: undefined, issue: undefined }) this.creation = (async () => { const info = valueOf(await this.remote.create(this.sessionId, { id: this.id, cols: Math.min(80, environment.maxCols), rows: Math.min(24, environment.maxRows), @@ -126,11 +142,11 @@ export class TerminalView { this.attachmentId = attachmentId return this.remote.follow(this.sessionId, info.id, attachmentId, signal) }, - ended: () => new Error('Terminal attachment ended; reconnect to recover its state'), + ended: () => new TerminalViewError('attachmentEnded'), carrierFailed: () => { if (this.stream === stream) this.patch({ phase: 'disconnected', writable: false }) }, }) this.stream = stream - this.patch({ phase: 'connecting', writable: false, error: undefined, render: undefined }) + this.patch({ phase: 'connecting', writable: false, error: undefined, issue: undefined, render: undefined }) void this.consume(stream) } @@ -153,11 +169,11 @@ export class TerminalView { const attachmentId = this.attachmentId if (!state.writable || state.info === undefined || attachmentId === undefined) return const bytes = new TextEncoder().encode(data).byteLength - if (this.queuedInput + bytes > (state.environment?.maxInputBytes ?? 0)) { this.fail(new Error('Terminal input buffer is full')); return } + if (this.queuedInput + bytes > (state.environment?.maxInputBytes ?? 0)) { this.fail(new TerminalViewError('inputFull')); return } this.queuedInput += bytes const id = state.info.id this.writes = this.writes.then(async () => { - if (this.attachmentId !== attachmentId) return + if (this.attachmentId !== attachmentId || !this.state.getSnapshot().writable) return valueOf(await this.remote.write(this.sessionId, id, attachmentId, data)) }).catch((error: unknown) => { if (this.attachmentId === attachmentId) this.fail(error) }).finally(() => { this.queuedInput -= bytes }) } @@ -176,7 +192,7 @@ export class TerminalView { cols = Math.min(cols, state.environment?.maxCols ?? cols) rows = Math.min(rows, state.environment?.maxRows ?? rows) this.writes = this.writes.then(async () => { - if (this.attachmentId !== attachmentId) return + if (this.attachmentId !== attachmentId || !this.state.getSnapshot().writable) return valueOf(await this.remote.resize(this.sessionId, id, attachmentId, cols, rows)) }).catch((error: unknown) => { if (this.attachmentId === attachmentId) this.fail(error) }) } @@ -201,7 +217,7 @@ export class TerminalView { */ close(): Promise { if (this.closing !== undefined) return this.closing - this.patch({ phase: 'closing', writable: false, error: undefined }) + this.patch({ phase: 'closing', writable: false, error: undefined, issue: undefined }) this.detach() this.closing = (async () => { // Even a refused or lost creation response may leave an allocation to close. @@ -213,11 +229,15 @@ export class TerminalView { return this.closing } - /** Stop Client work on plugin unload without closing Host terminals. */ - dispose(): void { + /** + * Stop Client work on plugin unload without closing Host terminals. + * @returns after active and previously detached stream iterators have closed. + */ + async dispose(): Promise { this.mounted = false this.lifetime.abort() this.detach() + await Promise.all(this.detaching) } private detach(): void { @@ -226,7 +246,10 @@ export class TerminalView { this.attachmentId = undefined this.pendingRender?.resolve() this.pendingRender = undefined - if (previous !== undefined) void previous.dispose() + if (previous !== undefined) { + const cleanup = previous.dispose().finally(() => { this.detaching.delete(cleanup) }) + this.detaching.add(cleanup) + } } private async consume(stream: RemoteStream): Promise { @@ -237,14 +260,14 @@ export class TerminalView { if (this.stream !== stream) return const frame = item.value if (generation !== item.generation) { - if (frame.type !== 'snapshot') throw new Error('Terminal output generation is missing its screen snapshot') + if (frame.type !== 'snapshot') throw new TerminalViewError('invalidOutput', 'Terminal output generation is missing its screen snapshot') generation = item.generation sequence = frame.sequence item.accept() } else if (frame.type === 'output') { - if (frame.sequence !== sequence + 1) throw new Error('Terminal output sequence has a gap') + if (frame.sequence !== sequence + 1) throw new TerminalViewError('invalidOutput', 'Terminal output sequence has a gap') sequence = frame.sequence - } else if (frame.type === 'snapshot') throw new Error('Unexpected terminal screen snapshot') + } else if (frame.type === 'snapshot') throw new TerminalViewError('invalidOutput', 'Unexpected terminal screen snapshot') if (frame.type !== 'output') { this.patch({ info: frame.info, title: frame.info.title, phase: 'connected', writable: frame.info.state === 'running' && frame.info.controllerId === this.attachmentId }) } @@ -274,11 +297,17 @@ export class TerminalView { } private fail(error: unknown): void { - this.patch({ phase: error instanceof RemoteStreamCarrierError ? 'disconnected' : 'failed', writable: false, error: error instanceof Error ? error.message : String(error) }) + const failure = remoteErrorOf(error) + if (failure?.code === 'terminal/control-unavailable') { + this.patch({ writable: false, error: undefined, issue: undefined }) + return + } + const issue = failure?.code === 'terminal/view' ? failure.details.issue : failure?.code === 'terminal/limit-reached' ? 'terminalLimit' : undefined + this.patch({ phase: error instanceof RemoteStreamCarrierError ? 'disconnected' : 'failed', writable: false, issue, error: error instanceof Error ? error.message : String(error) }) } } function valueOf(result: RemoteResult): T { - if (!result.ok) throw new Error(result.error.message) + if (!result.ok) throw result.error return result.value } diff --git a/packages/api/terminal-controller/src/index.ts b/packages/api/terminal-controller/src/index.ts index 9efb1d07df..d55771ae27 100644 --- a/packages/api/terminal-controller/src/index.ts +++ b/packages/api/terminal-controller/src/index.ts @@ -7,7 +7,7 @@ import type { SubprocessTerminalHandle } from '@deepseek-ai/dsh-subprocess' import type {} from '@deepseek-ai/dsh-sandbox-policy' import type {} from '@deepseek-ai/dsh-sandbox' import type {} from '@deepseek-ai/dsh-session-projection' -import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import { resolveShell } from './shells.ts' import { BrowserTerminal } from './terminal.ts' import type { @@ -153,7 +153,7 @@ export class TerminalController extends TypertRemoteService { return terminal.info } if (owner.allocations.has(request.id)) throw new Error('Close the failed terminal allocation before creating it again') - if (new Set([...owner.terminals.keys(), ...owner.pending.keys(), ...owner.allocations.keys()]).size >= this.config.maxTerminals) throw new Error('Session terminal limit reached') + if (new Set([...owner.terminals.keys(), ...owner.pending.keys(), ...owner.allocations.keys()]).size >= this.config.maxTerminals) throw new RemoteError('terminal/limit-reached', 'Session terminal limit reached', { limit: this.config.maxTerminals }) const allocation = this.spawn(agent, owner, request, AbortSignal.any([signal, this.lifetime.signal, owner.lifetime.signal])) owner.pending.set(request.id, allocation) try { @@ -232,6 +232,7 @@ export class TerminalController extends TypertRemoteService { async close(agent: Agent, id: WebTerminalId): Promise { const owner = this.owner(agent) owner.closedIds.add(id) + // create publishes the allocation before this wait settles; close owns it even if create then rejects. await owner.pending.get(id)?.catch(() => { /* Creation reports its failure; close still owns any allocated process. */ }) const terminal = owner.terminals.get(id) if (terminal !== undefined) { diff --git a/packages/api/terminal-controller/src/terminal.ts b/packages/api/terminal-controller/src/terminal.ts index 90688d9d78..9e8dfcafff 100644 --- a/packages/api/terminal-controller/src/terminal.ts +++ b/packages/api/terminal-controller/src/terminal.ts @@ -1,14 +1,21 @@ /** One PTY, a bounded terminal emulator and its detachable browser followers. */ import { createRequire } from 'node:module' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import type { Terminal as HeadlessTerminal } from '@xterm/headless' import type { SerializeAddon as Serializer } from '@xterm/addon-serialize' import type { SubprocessTerminalHandle } from '@deepseek-ai/dsh-subprocess' import { TerminalFollower } from './stream.ts' import type { TerminalAttachmentId, TerminalFrame, WebTerminalInfo } from './types.ts' -// Literal createRequire calls keep CommonJS xterm entries reachable in the Preview image. -const { Terminal } = createRequire(import.meta.url)('@xterm/headless') as typeof import('@xterm/headless') -const { SerializeAddon } = createRequire(import.meta.url)('@xterm/addon-serialize') as typeof import('@xterm/addon-serialize') +const { Terminal, SerializeAddon } = loadXterm() + +function loadXterm() { + // The Preview's CommonJS wrapper owns its outer require binding; these literal calls also retain the CJS entries. + const require = createRequire(import.meta.url) + const { Terminal } = require('@xterm/headless') as typeof import('@xterm/headless') + const { SerializeAddon } = require('@xterm/addon-serialize') as typeof import('@xterm/addon-serialize') + return { Terminal, SerializeAddon } +} /** Process lifetime is independent of follower and component lifetimes. */ export class BrowserTerminal { @@ -125,8 +132,8 @@ export class BrowserTerminal { } private requireController(id: TerminalAttachmentId): void { - if (this.closing !== undefined || this.info.state !== 'running') throw new Error('Terminal is not running') - if (this.controller?.id !== id) throw new Error('Terminal input is controlled by another attachment') + if (this.closing !== undefined || this.info.state !== 'running') throw new RemoteError('terminal/control-unavailable', 'Terminal is not running', { reason: 'not-running' }) + if (this.controller?.id !== id) throw new RemoteError('terminal/control-unavailable', 'Terminal input is controlled by another attachment', { reason: 'read-only' }) } private broadcast(frame: TerminalFrame): void { diff --git a/packages/api/terminal-controller/src/types.ts b/packages/api/terminal-controller/src/types.ts index 94cc827d4c..b474c9ad47 100644 --- a/packages/api/terminal-controller/src/types.ts +++ b/packages/api/terminal-controller/src/types.ts @@ -1,5 +1,15 @@ /** Browser terminal identities, metadata and screen-stream frames. */ import type { Branded } from '@deepseek-ai/dsh-brand' +import type {} from '@deepseek-ai/dsh-typert-protocol' + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface RemoteErrorDetailsMap { + /** Input or resize was refused without invalidating the output attachment. */ + 'terminal/control-unavailable': { readonly reason: 'read-only' | 'not-running' } + /** Retained screens and pending allocations consume the Session's terminal quota. */ + 'terminal/limit-reached': { readonly limit: number } + } +} /** A terminal identity scoped to one Session and one Host lifetime. */ export type WebTerminalId = Branded<'WebTerminalId'> diff --git a/packages/api/terminal-controller/tests/controller.spec.ts b/packages/api/terminal-controller/tests/controller.spec.ts index 09859f9a8c..7910182675 100644 --- a/packages/api/terminal-controller/tests/controller.spec.ts +++ b/packages/api/terminal-controller/tests/controller.spec.ts @@ -210,7 +210,7 @@ describe('TerminalController', () => { const creating = controller.create(agent, request, signal()) try { await spawning.promise - await expect(controller.create(agent, { ...request, id: 'another' as WebTerminalId }, signal())).rejects.toThrow('limit reached') + await expect(controller.create(agent, { ...request, id: 'another' as WebTerminalId }, signal())).rejects.toMatchObject({ code: 'terminal/limit-reached', details: { limit: 1 } }) } finally { allocation.resolve(handle) } const first = await creating expect(await controller.create(agent, request, signal())).toBe(first) @@ -225,7 +225,7 @@ describe('TerminalController', () => { await expect(controller.create(agent, request, abort.signal)).rejects.toThrow('cleanup failed') expect(controller.list(agent)).toMatchObject([{ id, state: 'failed', error: 'lost request' }]) await expect(controller.create(agent, request, signal())).rejects.toThrow('Close the failed terminal allocation') - await expect(controller.create(agent, { ...request, id: 'another' as WebTerminalId }, signal())).rejects.toThrow('limit reached') + await expect(controller.create(agent, { ...request, id: 'another' as WebTerminalId }, signal())).rejects.toMatchObject({ code: 'terminal/limit-reached', details: { limit: 1 } }) await controller.close(agent, id) expect(controller.list(agent)).toEqual([]) }) diff --git a/packages/api/terminal-controller/tests/model.client.spec.ts b/packages/api/terminal-controller/tests/model.client.spec.ts index 24be9f983d..4ce6fd6b8c 100644 --- a/packages/api/terminal-controller/tests/model.client.spec.ts +++ b/packages/api/terminal-controller/tests/model.client.spec.ts @@ -1,4 +1,5 @@ /** Browser view ownership across slow RPCs, remounts and transport generations. */ +import { setImmediate } from 'node:timers/promises' import { afterEach, expect, it, vi } from 'vitest' import { RemoteStream, RemoteStreamCarrierError, type ClientRemote, type RemoteStreamOptions } from '@deepseek-ai/dsh-api-gateway/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' @@ -53,7 +54,7 @@ function fixture(prepareStream?: (stream: RemoteStream) => void) { } cleanups.push(async () => { await Promise.all(streams.map(stream => stream.dispose())) }) const model = new TerminalView(sessionId, remote, gateway, info.id) - cleanups.push(() => { model.dispose() }) + cleanups.push(() => model.dispose()) return { model, remote, gateway, options, streams, generation } } @@ -141,7 +142,7 @@ it('bounds queued input by UTF-8 bytes and releases the byte budget after settle model.write('界界') await expect.poll(() => vi.mocked(remote.write).mock.calls.length).toBe(1) model.write('a') - expect(model.state.getSnapshot()).toMatchObject({ phase: 'failed', error: 'Terminal input buffer is full' }) + expect(model.state.getSnapshot()).toMatchObject({ phase: 'failed', issue: 'inputFull' }) input.resolve(success(undefined)) model.connect() await expect.poll(() => model.state.getSnapshot().writable).toBe(true) @@ -227,7 +228,7 @@ it('reports an attachment ending while the shell is still running and permits ma yield { type: 'snapshot', sequence: 0, screen: '', info: { ...info, id, controllerId } } }) await connected(model) - await expect.poll(() => model.state.getSnapshot().error).toContain('Terminal attachment ended') + await expect.poll(() => model.state.getSnapshot().issue).toBe('attachmentEnded') model.connect() await expect.poll(() => model.state.getSnapshot().writable).toBe(true) }) @@ -407,7 +408,7 @@ it('keeps controls inert when process metadata or the attachment is unavailable' await expect.poll(() => remote.resize).toHaveBeenCalledOnce() expect(vi.mocked(remote.resize).mock.calls[0]?.slice(-2)).toEqual([100, 30]) model.write('unknown budget') - expect(model.state.getSnapshot().error).toBe('Terminal input buffer is full') + expect(model.state.getSnapshot().issue).toBe('inputFull') expect(remote.write).not.toHaveBeenCalled() }) @@ -421,7 +422,7 @@ it('renames an existing terminal, skips unchanged names, and exposes rename fail vi.mocked(remote.rename).mockRejectedValueOnce('rename connection lost') await model.rename('Other') expect(model.state.getSnapshot()).toMatchObject({ phase: 'failed', error: 'rename connection lost', title: 'Build' }) - model.dispose() + await model.dispose() await model.rename('Ignored') expect(remote.rename).toHaveBeenCalledTimes(2) }) @@ -446,7 +447,7 @@ it('retains state when disposal overtakes successful allocation or a failed disc else vi.mocked(remote.environment).mockReturnValueOnce(discovery.promise) const loading = model.refresh() if (operation === 'creation') await expect.poll(() => remote.create).toHaveBeenCalledOnce() - model.dispose() + await model.dispose() const before = model.state.getSnapshot() if (operation === 'creation') creation.resolve(success(info)) else discovery.reject(new Error('late discovery failure')) @@ -462,7 +463,7 @@ it('ignores a successful rename after the view is disposed', async () => { const rename = Promise.withResolvers>() vi.mocked(remote.rename).mockReturnValueOnce(rename.promise) const pending = model.rename('Late') - model.dispose() + await model.dispose() const before = model.state.getSnapshot() rename.resolve(success(undefined)) await pending @@ -492,7 +493,7 @@ it('does not publish a close result or reconnect after disposal', async () => { const closing = Promise.withResolvers>() vi.mocked(remote.close).mockReturnValueOnce(closing.promise) const pending = model.close() - model.dispose() + await model.dispose() const before = model.state.getSnapshot() closing.resolve(success(undefined)) await pending @@ -501,3 +502,60 @@ it('does not publish a close result or reconnect after disposal', async () => { expect(model.state.getSnapshot()).toBe(before) expect(remote.follow).toHaveBeenCalledOnce() }) + +it.each(['write', 'resize'] as const)('keeps the output connection when %s loses control before its state frame arrives', async (operation) => { + const { model, remote } = fixture() + const transfer = Promise.withResolvers() + const response = Promise.withResolvers>() + cleanups.push(() => { transfer.resolve(undefined); response.resolve(success(undefined)) }) + vi.mocked(remote.follow).mockImplementation(async function* (_session, id, controllerId, signal) { + yield { type: 'snapshot', sequence: 0, screen: 'retained screen', info: { ...info, id, controllerId } } + await transfer.promise + yield { type: 'state', info: { ...info, id } } + await untilAborted(signal) + }) + await connected(model) + vi.mocked(remote[operation]).mockReturnValueOnce(response.promise) + if (operation === 'write') model.write('before transfer') + else model.resize(100, 30) + model.write('queued before transfer') + await expect.poll(() => vi.mocked(remote[operation]).mock.calls.length).toBe(1) + response.resolve({ ok: false, error: new RemoteError('terminal/control-unavailable', 'Another window owns input', { reason: 'read-only' }) }) + await expect.poll(() => model.state.getSnapshot().writable).toBe(false) + expect(model.state.getSnapshot()).toMatchObject({ phase: 'connected', error: undefined, issue: undefined, render: { frame: { screen: 'retained screen' } } }) + transfer.resolve(undefined) + await expect.poll(() => model.state.getSnapshot().info?.controllerId).toBeUndefined() + expect(remote.follow).toHaveBeenCalledOnce() + expect(vi.mocked(remote.write).mock.calls.some(call => call[3] === 'queued before transfer')).toBe(false) +}) + +it('keeps an exited screen when a pending input is refused after the exit state arrives', async () => { + const { model, remote } = fixture() + const exit = Promise.withResolvers() + const response = Promise.withResolvers>() + cleanups.push(() => { exit.resolve(undefined); response.resolve(success(undefined)) }) + vi.mocked(remote.follow).mockImplementation(async function* (_session, id, controllerId, signal) { + yield { type: 'snapshot', sequence: 0, screen: 'final screen', info: { ...info, id, controllerId } } + await exit.promise + yield { type: 'state', info: { ...info, id, state: 'exited', exitCode: 0 } } + await untilAborted(signal) + }) + await connected(model) + vi.mocked(remote.write).mockReturnValueOnce(response.promise) + model.write('exit race') + await expect.poll(() => vi.mocked(remote.write).mock.calls.length).toBe(1) + exit.resolve(undefined) + await expect.poll(() => model.state.getSnapshot().info?.state).toBe('exited') + response.resolve({ ok: false, error: new RemoteError('terminal/control-unavailable', 'Terminal is not running', { reason: 'not-running' }) }) + await setImmediate() + expect(model.state.getSnapshot()).toMatchObject({ phase: 'connected', writable: false, error: undefined, issue: undefined, render: { frame: { screen: 'final screen' } } }) +}) + +it('exposes a localized quota error and clears it after a successful retry', async () => { + const { model, remote } = fixture() + vi.mocked(remote.create).mockResolvedValueOnce({ ok: false, error: new RemoteError('terminal/limit-reached', 'Session terminal limit reached', { limit: 8 }) }) + await model.refresh() + expect(model.state.getSnapshot()).toMatchObject({ phase: 'failed', issue: 'terminalLimit' }) + await connected(model) + expect(model.state.getSnapshot()).toMatchObject({ phase: 'connected', issue: undefined, error: undefined }) +}) diff --git a/packages/api/terminal-controller/tests/recovery.client.spec.ts b/packages/api/terminal-controller/tests/recovery.client.spec.ts index f9c2438fd2..28db262070 100644 --- a/packages/api/terminal-controller/tests/recovery.client.spec.ts +++ b/packages/api/terminal-controller/tests/recovery.client.spec.ts @@ -1,4 +1,5 @@ /** Reloaded tab identities and nonblocking close requests use independent lifetimes. */ +import { setImmediate } from 'node:timers/promises' import { afterEach, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' @@ -42,7 +43,7 @@ function fixture() { const gateway: Pick = { $stream: options => new RemoteStream({ generation: createSnapshotStore(undefined) }, options) } function view() { const model = new TerminalView(sessionId, remote, gateway, info.id) - cleanups.push(() => { model.dispose() }) + cleanups.push(() => model.dispose()) return model } async function service() { @@ -92,7 +93,7 @@ it('does not recreate a recovered terminal that disappeared after the recovery l const model = service.view(sessionId, 'recovered', info.id) await model.refresh() expect(model.state.getSnapshot()).toMatchObject({ phase: 'failed', writable: false }) - expect(model.state.getSnapshot().error).toBeDefined() + expect(model.state.getSnapshot().issue).toBe('missingTerminal') expect(h.remote.create).not.toHaveBeenCalled() model.mount() await model.refresh() @@ -152,7 +153,7 @@ it('updates a restored inactive terminal title and ignores late results after di const rename = Promise.withResolvers>() vi.mocked(h.remote.rename).mockReturnValueOnce(rename.promise) const pending = model.rename('Late') - model.dispose() + await model.dispose() rename.resolve(success(undefined)) await pending expect(model.state.getSnapshot().title).toBe('Build') @@ -367,3 +368,66 @@ it('keeps cleanup usable in memory when storage access itself is denied', () => expect(requests.pending()).toEqual([]) expect(error).toHaveBeenCalledTimes(3) }) + +it.each(['saved', 'view'] as const)('clears a %s close request after the Host confirms that its Session does not exist', async (source) => { + const data = storage() + const h = fixture() + const requests = new TerminalCloseRequests() + if (source === 'saved') requests.save({ sessionId, id: info.id, title: 'Build' }) + vi.mocked(h.remote.close).mockResolvedValue({ ok: false, error: new RemoteError('session/not-found', 'Deleted Session', { sessionId }) }) + const { service, dispose } = await h.service() + if (source === 'view') { + const view = service.view(sessionId, 'tab') + await view.refresh() + service.close(sessionId, 'tab') + } + await expect.poll(() => vi.mocked(h.remote.close).mock.calls.length).toBe(1) + await dispose() + expect(data.size).toBe(0) + expect(service.closeFailures.getSnapshot()).toEqual([]) + expect(new TerminalCloseRequests().pending()).toEqual([]) + await h.service() + expect(h.remote.close).toHaveBeenCalledOnce() +}) + +it('waits for both active and detached stream finalizers during plugin disposal without closing Host processes', async () => { + const h = fixture() + const { service, dispose } = await h.service() + const started = [Promise.withResolvers(), Promise.withResolvers()] + const release = [Promise.withResolvers(), Promise.withResolvers()] + const finished = [Promise.withResolvers(), Promise.withResolvers()] + cleanups.push(() => { for (const barrier of release) barrier.resolve(undefined) }) + let index = 0 + vi.mocked(h.remote.follow).mockImplementation(async function* (_session, id, controllerId, signal) { + const current = index++ + try { + yield { type: 'snapshot', sequence: 0, screen: 'screen', info: { ...info, id, controllerId } } + await new Promise((resolve) => { + if (signal?.aborted) resolve() + else signal?.addEventListener('abort', () => { resolve() }, { once: true }) + }) + } finally { + started[current]!.resolve(undefined) + await release[current]!.promise + finished[current]!.resolve(undefined) + } + }) + const model = service.view(sessionId, 'tab') + model.mount() + await expect.poll(() => model.state.getSnapshot().writable).toBe(true) + model.connect() + await started[0]!.promise + await expect.poll(() => model.state.getSnapshot().writable).toBe(true) + let disposed = false + const disposing = dispose().then(() => { disposed = true }) + await started[1]!.promise + release[1]!.resolve(undefined) + await finished[1]!.promise + // Drain runnable disposal continuations; only the held old finalizer may keep teardown pending. + await setImmediate() + expect(disposed).toBe(false) + release[0]!.resolve(undefined) + await disposing + expect(disposed).toBe(true) + expect(h.remote.close).not.toHaveBeenCalled() +}) diff --git a/packages/api/terminal-controller/tests/terminal.spec.ts b/packages/api/terminal-controller/tests/terminal.spec.ts index 43c0446458..bf6bd16318 100644 --- a/packages/api/terminal-controller/tests/terminal.spec.ts +++ b/packages/api/terminal-controller/tests/terminal.spec.ts @@ -63,7 +63,7 @@ describe('BrowserTerminal', () => { output.write(encoded.subarray(2)) expect(await readFrame(first.iterator)).toMatchObject({ type: 'output', data: '终端' }) const second = await attach(terminal, 'second') - await expect(terminal.write(attachment('first'), 'ignored')).rejects.toThrow('another attachment') + await expect(terminal.write(attachment('first'), 'ignored')).rejects.toMatchObject({ code: 'terminal/control-unavailable', details: { reason: 'read-only' } }) await terminal.write(attachment('second'), '\t') expect(handle.write).toHaveBeenCalledWith('\t') await terminal.resize(attachment('second'), 100, 30) @@ -103,15 +103,15 @@ describe('BrowserTerminal', () => { it('rejects input before attachment, during close and after process exit', async () => { const { terminal, handle, output, outcome } = fixture() - await expect(terminal.write(attachment('first'), 'ignored')).rejects.toThrow('another attachment') + await expect(terminal.write(attachment('first'), 'ignored')).rejects.toMatchObject({ code: 'terminal/control-unavailable', details: { reason: 'read-only' } }) const first = await attach(terminal) output.end() outcome.resolve({ exitCode: 0, signal: null }) expect(await readFrame(first.iterator)).toMatchObject({ type: 'state', info: { state: 'exited' } }) - await expect(terminal.write(attachment('first'), 'ignored')).rejects.toThrow('not running') - await expect(terminal.resize(attachment('first'), 100, 30)).rejects.toThrow('not running') + await expect(terminal.write(attachment('first'), 'ignored')).rejects.toMatchObject({ code: 'terminal/control-unavailable', details: { reason: 'not-running' } }) + await expect(terminal.resize(attachment('first'), 100, 30)).rejects.toMatchObject({ code: 'terminal/control-unavailable', details: { reason: 'not-running' } }) const closing = terminal.close() - await expect(terminal.write(attachment('first'), 'ignored')).rejects.toThrow('not running') + await expect(terminal.write(attachment('first'), 'ignored')).rejects.toMatchObject({ code: 'terminal/control-unavailable', details: { reason: 'not-running' } }) await closing expect(handle.write).not.toHaveBeenCalled() expect(handle.resize).not.toHaveBeenCalled() diff --git a/packages/client/ui-sidebar-right/README.i18n.yaml b/packages/client/ui-sidebar-right/README.i18n.yaml index 0b4a9bd86c..797bddd458 100644 --- a/packages/client/ui-sidebar-right/README.i18n.yaml +++ b/packages/client/ui-sidebar-right/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-sidebar-right/README.md -README.md: 1a0a34119cc91f2e7c829e8b0c1dc56ad74d2631 -README.zh.md: 7d611eb4a4d4daa0c00baa8658b8b3e84288471e +README.md: 9278274ed8c4ab6e52890acd58d006c10788c053 +README.zh.md: ced37253897b43d7e6f74c850c75f844e855bb3b diff --git a/packages/client/ui-sidebar-right/README.md b/packages/client/ui-sidebar-right/README.md index 1a0a34119c..9278274ed8 100644 --- a/packages/client/ui-sidebar-right/README.md +++ b/packages/client/ui-sidebar-right/README.md @@ -93,7 +93,7 @@ Two more seats extend what is already there: `sidebar.right.tab.guide` (chain) r The Tab domain retains navigation, an abort signal, and bound actions per (Session, tab id). A private assembly callback adopts each Session's store and reconciles records on its commits. Only record removal or plugin unload aborts the signal; closing the sidebar and switching Sessions retain records, while undo restores a new occurrence. `useTabInfo()` composes framework-bound store and navigation hooks without manual component subscriptions or render-time record creation. `tab.actions` always target their own Session; `tab.visible` distinguishes bodies from titles, and floating tabs remain visible when the sidebar closes. `adopt` is absent from the public controller. -Tab owners register `registerCloseHandler(kind, handler)` through an effect. A handler can save background cleanup and return synchronously, allowing immediate removal, or return a Promise that the sidebar awaits before explicit close or replacement. A thrown or rejected handler preserves the tab. Collapse, presentation changes and plugin disposal do not invoke close handlers; the tab abort signal identifies occurrence disposal, not an explicit close. +Tab owners register `registerCloseHandler(kind, handler)` through an effect. A handler synchronously retains any background cleanup before allowing explicit close or replacement. The resource owner tracks completion and retry; the sidebar does not wait. A thrown handler preserves the tab. Collapse, presentation changes and plugin disposal do not invoke close handlers; the tab abort signal identifies occurrence disposal, not an explicit close. ## The guide diff --git a/packages/client/ui-sidebar-right/README.zh.md b/packages/client/ui-sidebar-right/README.zh.md index 7d611eb4a4..ced3725389 100644 --- a/packages/client/ui-sidebar-right/README.zh.md +++ b/packages/client/ui-sidebar-right/README.zh.md @@ -93,7 +93,7 @@ tab 类型分两阶段注册,随包发布的引导类型走的正是别的包 Tab域按(Session,Tab id)保留导航、中止信号与绑定动作;私有装配回调收养各会话的store,并在每次提交时对齐记录。记录消失或插件卸载才中止signal,收起和切会话不销毁记录;undo恢复的是新occurrence。`useTabInfo()` 组合框架绑定的store与导航hook,不在组件中手写订阅或在渲染时创建记录。`tab.actions` 始终作用于自己的会话;`tab.visible` 区分正文与标题,浮窗不受整栏收起影响。`adopt` 不在公开控制器上。 -标签页所有者通过 effect 注册 `registerCloseHandler(kind, handler)`。handler 可以保存后台清理任务并同步返回,让标签页立即移除;也可以返回 Promise,让侧栏在显式关闭或替换前等待。handler 抛错或拒绝时保留标签页。折叠、展示方式改变和插件卸载不调用关闭 handler;tab abort signal 标识 occurrence 卸载,不代表显式关闭。 +标签页所有者通过 effect 注册 `registerCloseHandler(kind, handler)`。handler 在允许显式关闭或替换前同步保存后台清理任务。资源所有者跟踪完成和重试,侧栏不等待清理。handler 抛错时保留标签页。折叠、展示方式改变和插件卸载不调用关闭 handler;tab abort signal 标识 occurrence 卸载,不代表显式关闭。 ## 引导页 diff --git a/packages/client/ui-sidebar-right/src/client/contract/slots.ts b/packages/client/ui-sidebar-right/src/client/contract/slots.ts index 87fe12883f..f964ee97c6 100644 --- a/packages/client/ui-sidebar-right/src/client/contract/slots.ts +++ b/packages/client/ui-sidebar-right/src/client/contract/slots.ts @@ -126,7 +126,7 @@ export interface SidebarRightTabActions { */ openTab(kind: K, options?: SidebarRightTabPlacement & { readonly params?: SidebarRightTabParamsFor }): void /** Close this tab. */ - close(): void | Promise + close(): void } /** Live information shared by a tab's body, title, and guide replacement. */ diff --git a/packages/client/ui-sidebar-right/src/client/index.ts b/packages/client/ui-sidebar-right/src/client/index.ts index 556296f186..5ffcbd34e1 100644 --- a/packages/client/ui-sidebar-right/src/client/index.ts +++ b/packages/client/ui-sidebar-right/src/client/index.ts @@ -163,8 +163,8 @@ export function apply(ctx: ClientContext): void { inject: (sessionId): SidebarRightInjected => ({ ...injected, closeTab: (tabId) => { - const pending = controller.closeIn(sessionId, tabId) - if (pending !== undefined) void pending.catch((error: unknown) => { console.error('Sidebar tab close failed:', error) }) + try { controller.closeIn(sessionId, tabId) } + catch (error) { console.error('Sidebar tab close failed:', error) } }, keyedHooks: { tabNavigation: key => controller.tabDomain.occurrence(sessionId, { id: key as TabId }).navigation }, occurrence: tab => controller.tabDomain.occurrence(sessionId, tab), diff --git a/packages/client/ui-sidebar-right/src/client/service.ts b/packages/client/ui-sidebar-right/src/client/service.ts index f68b2fe1f9..35086a46ca 100644 --- a/packages/client/ui-sidebar-right/src/client/service.ts +++ b/packages/client/ui-sidebar-right/src/client/service.ts @@ -134,8 +134,8 @@ export interface SidebarRightOpenTabOptions extends S /** The scheme every resource address carries; anything else is not a resource this face opens. */ const RESOURCE_SCHEME = 'dsh-resource://' -/** Explicit close/replacement hook. Return void for immediate removal, or a promise to defer removal until cleanup succeeds. */ -export type SidebarRightCloseHandler = (sessionId: SessionId, tab: TabRecord) => void | Promise +/** Synchronous close/replacement hook; resource owners retain any background cleanup. */ +export type SidebarRightCloseHandler = (sessionId: SessionId, tab: TabRecord) => void /** The outward right-Sidebar face (`ctx.sidebarRight`). */ export interface ISidebarRight { @@ -163,7 +163,7 @@ export interface ISidebarRight { * Close one tab of the mounted session; the sole docked guide remains open. * @param tabId - the tab to close. */ - close(tabId: TabId): void | Promise + close(tabId: TabId): void /** * The active tab of the active pane. * @returns the record, or `undefined` when no seat is mounted. @@ -207,12 +207,11 @@ export interface ISidebarRight { export class SidebarRightController implements ISidebarRight { private binding: SidebarRightBinding | undefined private readonly closeHandlers = new Map() - private readonly closing = new Map>() /** * Register resource cleanup before explicit removal. Failure preserves the tab. * @param kind - tab kind owned by the registering plugin. - * @param handler - saves background cleanup synchronously, or returns cleanup to await before removal. + * @param handler - saves any background cleanup before returning and allowing removal. * @returns an effect-scoped unregister callback. */ registerCloseHandler(kind: string, handler: SidebarRightCloseHandler): () => void { @@ -308,33 +307,19 @@ export class SidebarRightController implements ISidebarRight { * Not part of `ISidebarRight`: the Tab domain's path. * @param sessionId - the session the tab is in. * @param tabId - the tab to close. - * @returns resource cleanup completion when the tab type owns an asynchronous close handler. */ - closeIn(sessionId: SessionId, tabId: TabId): void | Promise { + closeIn(sessionId: SessionId, tabId: TabId): void { const actions = this.actionsFor(sessionId) const surface = this.adopted.get(sessionId)?.store.getSnapshot().bySession[sessionId] if (actions === undefined || surface === undefined) return const tab = surface.layout.tabs[tabId] if (tab === undefined || !canCloseTab(surface, tabId)) return - return this.removeAfterCleanup(sessionId, tab, () => { actions.closeTab(sessionId, tabId) }) + this.removeAfterCleanup(sessionId, tab, () => { actions.closeTab(sessionId, tabId) }) } - private removeAfterCleanup(sessionId: SessionId, tab: TabRecord, commit: () => void): void | Promise { - const handler = this.closeHandlers.get(tab.kind) - if (handler === undefined) { commit(); return } - const key = `${sessionId}:${tab.id}` - const existing = this.closing.get(key) - if (existing !== undefined) return existing - const adoption = this.adopted.get(sessionId) - const occurrence = this.tabDomain.occurrence(sessionId, tab) - const completion = handler(sessionId, tab) - if (completion === undefined) { commit(); return } - const pending = completion.then(() => { - if (this.adopted.get(sessionId) !== adoption || occurrence.signal.aborted) return - commit() - }).finally(() => { this.closing.delete(key) }) - this.closing.set(key, pending) - return pending + private removeAfterCleanup(sessionId: SessionId, tab: TabRecord, commit: () => void): void { + this.closeHandlers.get(tab.kind)?.(sessionId, tab) + commit() } /** Claim a resource and place it in one session; an address outside the scheme or one no type claims throws. */ @@ -385,18 +370,16 @@ export class SidebarRightController implements ISidebarRight { const revealed = layout === undefined || placement.revealIfOpened === false ? undefined : findContentTab(layout, claim.contentId, claim.kind) if (replaced === undefined || replaced.id === revealed) { commit(); return } - const pending = this.removeAfterCleanup(sessionId, replaced, commit) - if (pending !== undefined) void pending.catch((error: unknown) => { console.error('Sidebar tab replacement failed:', error) }) + this.removeAfterCleanup(sessionId, replaced, commit) } /** * Close one tab of the mounted session; the sole docked guide remains open. * @param tabId - the tab to close. - * @returns resource cleanup completion when the tab type owns an asynchronous close handler. */ - close(tabId: TabId): void | Promise { + close(tabId: TabId): void { const { sessionId, actions } = this.require() - if (this.adopted.has(sessionId)) return this.closeIn(sessionId, tabId) + if (this.adopted.has(sessionId)) { this.closeIn(sessionId, tabId); return } actions.closeTab(sessionId, tabId) } diff --git a/packages/client/ui-sidebar-right/src/client/tab-domain.ts b/packages/client/ui-sidebar-right/src/client/tab-domain.ts index 533cf866c4..53b259584e 100644 --- a/packages/client/ui-sidebar-right/src/client/tab-domain.ts +++ b/packages/client/ui-sidebar-right/src/client/tab-domain.ts @@ -34,7 +34,7 @@ export interface SidebarRightNavigator { /** Open a page type in one session; see `ISidebarRight.openTab`. */ openTabIn(sessionId: SessionId, kind: string, options?: SidebarRightOpenTabOptions): void /** Close a tab of one session. */ - closeIn(sessionId: SessionId, tabId: TabId): void | Promise + closeIn(sessionId: SessionId, tabId: TabId): void } /** `ctx.resources.pin`: hold an address's content open for as long as `signal` lives. */ @@ -175,7 +175,7 @@ export class TabDomain { openTab: (kind, options = {}) => { navigator.openTabIn(sessionId, kind, { ...place(options), params: options.params }) }, - close: () => navigator.closeIn(sessionId, tabId), + close: () => { navigator.closeIn(sessionId, tabId) }, }, } this.session(sessionId).set(tabId, held) diff --git a/packages/client/ui-sidebar-right/tests/apply.client.spec.ts b/packages/client/ui-sidebar-right/tests/apply.client.spec.ts index 6dbce4eea3..543a18f666 100644 --- a/packages/client/ui-sidebar-right/tests/apply.client.spec.ts +++ b/packages/client/ui-sidebar-right/tests/apply.client.spec.ts @@ -163,7 +163,7 @@ describe('ui-sidebar-right apply', () => { // Held and pinned from the store's own commit: no seat synced anything. const occurrence = ctx.sidebarRight.tabDomain.occurrence(SESSION, guide) expect(resources.pin).toHaveBeenCalledWith('sidebar://guide', occurrence.signal) - void occurrence.tabActions.close() + occurrence.tabActions.close() expect(instance.getSnapshot().bySession[SESSION]?.layout.tabs[guide.id]).toBeUndefined() expect(occurrence.signal.aborted).toBe(true) }) @@ -199,7 +199,7 @@ describe('ui-sidebar-right apply', () => { await fiber.dispose() expect(signal.aborted).toBe(true) // The adoption went with the plugin: a late action from the dead occurrence changes nothing. - void tabActions.close() + tabActions.close() expect(instance.getSnapshot().bySession[SESSION]?.layout.tabs[guide.id]).toBeDefined() expect(ctx.get('sidebarRight')).toBeUndefined() expect(ctx.get('sidebarRightTabs')).toBeUndefined() diff --git a/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx b/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx index 2ad4b7adbe..274750463a 100644 --- a/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx +++ b/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx @@ -138,7 +138,7 @@ describe('RightbarSeat presentation', () => { if (entryCount !== 1) { expect(h.view.container.querySelectorAll('[data-dockkit-tab-close]')).toHaveLength(0) const before = h.layout() - act(() => { void h.controller.close(initial.id) }) + act(() => { h.controller.close(initial.id) }) expect(h.layout()).toBe(before) fireEvent.contextMenu(element(h.view.container, '[data-dockkit-tab]')) expect(document.querySelector('[data-dockkit-tab-menu] [role^="menuitem"]')).toBeNull() @@ -146,7 +146,7 @@ describe('RightbarSeat presentation', () => { return } expect(h.view.container.querySelector(`[data-dockkit-tab-close="${initial.id}"]`)).not.toBeNull() - act(() => { void h.controller.close(initial.id) }) + act(() => { h.controller.close(initial.id) }) expect(h.layout().expanded).toBe(false) const reseeded = Object.values(h.layout().tabs)[0]! expect(reseeded.kind).toBe('text') @@ -157,12 +157,12 @@ describe('RightbarSeat presentation', () => { expect(h.view.container.querySelector('[data-dockkit-add-tab]')).toBeNull() expect(h.view.container.querySelector(`[data-dockkit-tab-close="${reseeded.id}"]`)).not.toBeNull() expect(h.view.container.querySelector(`[data-dockkit-tab-close="${guide.id}"]`)).not.toBeNull() - act(() => { void h.controller.close(reseeded.id) }) + act(() => { h.controller.close(reseeded.id) }) expect(h.layout().tabs[reseeded.id]).toBeUndefined() expect(h.view.container.querySelector(`[data-dockkit-tab-close="${guide.id}"]`)).toBeNull() const preview = h.open('ordinary.txt') expect(h.view.container.querySelector(`[data-dockkit-tab-close="${preview.id}"]`)).not.toBeNull() - act(() => { void h.controller.close(preview.id) }) + act(() => { h.controller.close(preview.id) }) expect(h.layout().tabs[preview.id]).toBeUndefined() expect(Object.keys(h.layout().tabs)).toEqual([guide.id]) expect(h.view.container.querySelectorAll('[data-dockkit-tab-close]')).toHaveLength(0) @@ -437,7 +437,7 @@ describe('slot-owned useTabInfo', () => { expect(Object.values(h.layout().tabs).map(tab => tab.title)).toContain('b.txt') expect(h.controller.active()?.contentId).toBe(otherTab.contentId) expect(h.bodies.get(otherTab.id)?.tab.navigation.params).toEqual({ line: 9 }) - act(() => { void info.tab.actions.close() }) + act(() => { info.tab.actions.close() }) expect(info.tab.signal.aborted).toBe(true) expect(otherInfo.tab.signal.aborted).toBe(false) await h.runtime.sessions.setCurrent(SESSION) @@ -580,11 +580,11 @@ describe('intentsFor — the kit\'s gestures as one session\'s store actions', ( }) }) -it('keeps a resource tab and reports cleanup rejection from its close button', async () => { +it('keeps a resource tab and reports a synchronous cleanup failure from its close button', async () => { const h = await mountSeat() const tab = h.open('terminal') const failure = new Error('process still running') - const release = h.controller.registerCloseHandler('text', async () => { throw failure }) + const release = h.controller.registerCloseHandler('text', () => { throw failure }) const logged = vi.spyOn(console, 'error').mockImplementation(() => {}) try { fireEvent.click(element(h.view.container, `[data-dockkit-tab-close="${tab.id}"]`)) diff --git a/packages/client/ui-sidebar-right/tests/service.client.spec.ts b/packages/client/ui-sidebar-right/tests/service.client.spec.ts index 6827aa5824..cbbe814090 100644 --- a/packages/client/ui-sidebar-right/tests/service.client.spec.ts +++ b/packages/client/ui-sidebar-right/tests/service.client.spec.ts @@ -82,7 +82,7 @@ describe('SidebarRightController — opening', () => { const { controller } = harness() expect(() => { controller.openResource('dsh-resource://file/session/s-test/a.txt') }).toThrow('no session surface is mounted') expect(() => { controller.openTab('guide') }).toThrow('no session surface is mounted') - expect(() => { void controller.close('tab1' as TabId) }).toThrow('no session surface is mounted') + expect(() => { controller.close('tab1' as TabId) }).toThrow('no session surface is mounted') expect(() => { controller.toggleExpanded() }).toThrow('no session surface is mounted') expect(() => { controller.focus('tab1' as TabId) }).toThrow('no session surface is mounted') expect(() => { controller.split() }).toThrow('no session surface is mounted') @@ -283,7 +283,7 @@ describe('SidebarRightController — opening', () => { publish() controller.openResource('dsh-resource://file/session/s-test/a.txt') publish() - void controller.close(tabOf('a.txt')) + controller.close(tabOf('a.txt')) expect(titles()).not.toContain('a.txt') }) @@ -459,7 +459,7 @@ describe('SidebarRightController — a tab\'s own actions', () => { // session's closes this session's, aborts its occurrence at once although // no seat draws the session, and the other keeps its own. expect(otherSurface.layout.tabs[guide.id]).toBeDefined() - void guideOccurrence.tabActions.close() + guideOccurrence.tabActions.close() expect(layout().tabs[guide.id]).toBeUndefined() expect(guideOccurrence.signal.aborted).toBe(true) expect(other.getSnapshot().bySession[OTHER]).toBe(otherSurface) @@ -479,14 +479,14 @@ describe('SidebarRightController — a tab\'s own actions', () => { const before = instance.getSnapshot().bySession tabActions.openResource(B_TXT) tabActions.openTab('guide') - void tabActions.close() + tabActions.close() expect(instance.getSnapshot().bySession).toBe(before) // Adopted, they land; released, they stop again. const release = adopt(SESSION, instance) tabActions.openResource(B_TXT) expect(titles()).toContain('b.txt') release() - void tabActions.close() + tabActions.close() expect(titles()).toContain('a.txt') }) @@ -591,12 +591,12 @@ describe('explicit tab resource cleanup', () => { const unregister = h.controller.registerCloseHandler('text', handler) try { const before = h.entries() - if (operation === 'close') expect(h.controller.close(tabId)).toBeUndefined() + if (operation === 'close') h.controller.close(tabId) else h.controller.openTab('guide', { replaceTab: tabId, revealIfOpened: false }) expect(handler).toHaveBeenCalledExactlyOnceWith(SESSION, original) expect(h.layout().tabs[tabId]).toBeUndefined() expect(h.entries()).toBe(before + 1) - expect(h.controller.closeIn(SESSION, tabId)).toBeUndefined() + h.controller.closeIn(SESSION, tabId) expect(handler).toHaveBeenCalledOnce() } finally { unregister() @@ -605,44 +605,7 @@ describe('explicit tab resource cleanup', () => { } }) - it('awaits cleanup once before closing and never closes on collapse', async () => { - const h = harness() - h.adopt(SESSION, h.instance) - h.publish() - h.controller.openResource('dsh-resource://file/session/s-test/terminal') - h.publish() - const tabId = h.tabOf('terminal') - const cleanup = Promise.withResolvers() - const handler = vi.fn(() => cleanup.promise) - h.controller.registerCloseHandler('text', handler) - h.controller.toggleExpanded() - expect(handler).not.toHaveBeenCalled() - const first = h.controller.closeIn(SESSION, tabId) - const second = h.controller.closeIn(SESSION, tabId) - expect(first).toBe(second) - expect(handler).toHaveBeenCalledOnce() - expect(h.layout().tabs[tabId]).toBeDefined() - cleanup.resolve(undefined) - await first - expect(h.layout().tabs[tabId]).toBeUndefined() - }) - - it('keeps the tab after failed cleanup and permits retry', async () => { - const h = harness() - h.adopt(SESSION, h.instance) - h.publish() - h.controller.openResource('dsh-resource://file/session/s-test/terminal') - h.publish() - const tabId = h.tabOf('terminal') - const handler = vi.fn().mockRejectedValueOnce(new Error('still running')).mockResolvedValue(undefined) - h.controller.registerCloseHandler('text', handler) - await expect(h.controller.closeIn(SESSION, tabId)).rejects.toThrow('still running') - expect(h.layout().tabs[tabId]).toBeDefined() - await h.controller.closeIn(SESSION, tabId) - expect(h.layout().tabs[tabId]).toBeUndefined() - }) - - it('awaits cleanup before replacing in a single history entry, and skips cleanup when revealing the same tab', async () => { + it('does not invoke cleanup on collapse or when revealing the same tab', () => { const h = harness() h.adopt(SESSION, h.instance) h.publish() @@ -650,27 +613,39 @@ describe('explicit tab resource cleanup', () => { h.controller.openResource(address) h.publish() const tabId = h.tabOf('terminal') - const cleanup = Promise.withResolvers() - const handler = vi.fn(() => cleanup.promise) + const handler = vi.fn() h.controller.registerCloseHandler('text', handler) + h.controller.toggleExpanded() h.controller.openResource(address, { replaceTab: tabId }) expect(handler).not.toHaveBeenCalled() - const before = h.entries() - h.controller.openTab('guide', { replaceTab: tabId }) - expect(h.entries()).toBe(before) expect(h.layout().tabs[tabId]).toBeDefined() - cleanup.resolve(undefined) - await expect.poll(() => h.layout().tabs[tabId]).toBeUndefined() - expect(h.entries()).toBe(before + 1) + }) + + it.each(['close', 'replace'] as const)('preserves a tab when its synchronous %s handler throws', (operation) => { + const h = harness() + h.adopt(SESSION, h.instance) + h.publish() + h.controller.openResource('dsh-resource://file/session/s-test/terminal') + const tabId = h.tabOf('terminal') + const release = h.controller.registerCloseHandler('text', () => { throw new Error('cannot retain cleanup') }) + const perform = () => { + if (operation === 'close') h.controller.closeIn(SESSION, tabId) + else h.controller.openTab('guide', { replaceTab: tabId, revealIfOpened: false }) + } + expect(perform).toThrow('cannot retain cleanup') + expect(h.layout().tabs[tabId]).toBeDefined() + release() + perform() + expect(h.layout().tabs[tabId]).toBeUndefined() }) }) -it('releases close handlers without a stale disposer removing a replacement', async () => { +it('releases close handlers without a stale disposer removing a replacement', () => { const h = harness() h.adopt(SESSION, h.instance) h.publish() - const first = vi.fn(async () => {}) - const second = vi.fn(async () => {}) + const first = vi.fn(() => {}) + const second = vi.fn(() => {}) const release = h.controller.registerCloseHandler('text', first) expect(() => h.controller.registerCloseHandler('text', second)).toThrow('already registered') release() @@ -678,7 +653,7 @@ it('releases close handlers without a stale disposer removing a replacement', as release() h.controller.openResource('dsh-resource://file/session/s-test/one') h.publish() - await h.controller.closeIn(SESSION, h.tabOf('one')) + h.controller.closeIn(SESSION, h.tabOf('one')) expect(first).not.toHaveBeenCalled() expect(second).toHaveBeenCalledOnce() releaseSecond() @@ -689,41 +664,3 @@ it('releases close handlers without a stale disposer removing a replacement', as expect(h.layout().tabs[tabId]).toBeUndefined() expect(second).toHaveBeenCalledOnce() }) - -it.each(['adoption', 'occurrence'] as const)('does not commit a pending close after its %s ends', async (lifetime) => { - const h = harness() - const release = h.adopt(SESSION, h.instance) - h.publish() - h.controller.openResource('dsh-resource://file/session/s-test/terminal') - h.publish() - const tabId = h.tabOf('terminal') - const cleanup = Promise.withResolvers() - h.controller.registerCloseHandler('text', () => cleanup.promise) - const pending = h.controller.closeIn(SESSION, tabId) - if (lifetime === 'adoption') release() - else h.instance.actions.closeTab(SESSION, tabId) - const entries = h.entries() - cleanup.resolve(undefined) - await pending - expect(h.entries()).toBe(entries) - if (lifetime === 'adoption') expect(h.layout().tabs[tabId]).toBeDefined() -}) - -it('reports failed replacement cleanup and retains the original resource', async () => { - const h = harness() - h.adopt(SESSION, h.instance) - h.publish() - h.controller.openResource('dsh-resource://file/session/s-test/terminal') - h.publish() - const tabId = h.tabOf('terminal') - const error = new Error('process cleanup failed') - h.controller.registerCloseHandler('text', async () => { throw error }) - const reported = vi.spyOn(console, 'error').mockImplementation(() => {}) - try { - h.controller.openTab('guide', { replaceTab: tabId, revealIfOpened: false }) - await expect.poll(() => reported.mock.calls).toEqual([['Sidebar tab replacement failed:', error]]) - expect(h.layout().tabs[tabId]).toBeDefined() - } finally { - reported.mockRestore() - } -}) diff --git a/packages/client/ui-sidebar-right/tests/tab-domain.client.spec.ts b/packages/client/ui-sidebar-right/tests/tab-domain.client.spec.ts index c51e9ca64e..793bda8083 100644 --- a/packages/client/ui-sidebar-right/tests/tab-domain.client.spec.ts +++ b/packages/client/ui-sidebar-right/tests/tab-domain.client.spec.ts @@ -191,7 +191,7 @@ describe('TabDomain — a tab\'s own actions', () => { const { tabActions } = domain.occurrence(SESSION, recordOf(current(), tabId)) tabActions.openTab('files', { replaceTab: true }) expect(navigator.openTabIn).toHaveBeenLastCalledWith(SESSION, 'files', { replaceTab: tabId }) - void tabActions.close() + tabActions.close() expect(navigator.closeIn).toHaveBeenCalledWith(SESSION, tabId) }) diff --git a/packages/client/ui-sidebar-terminal/README.i18n.yaml b/packages/client/ui-sidebar-terminal/README.i18n.yaml index cae62f6540..769eb9afc0 100644 --- a/packages/client/ui-sidebar-terminal/README.i18n.yaml +++ b/packages/client/ui-sidebar-terminal/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-sidebar-terminal/README.md -README.md: cf915dc73c9009b4b91d084d9b7aee466e4f2c75 -README.zh.md: a7aa321d106d49061ae7b092f6e116cab153627c +README.md: 2dba3ea839f89f505286fa6b977aef3fb0e45ab0 +README.zh.md: 63d483a37fcb9b913c20e47251aaaba3e0e21ffd diff --git a/packages/client/ui-sidebar-terminal/README.md b/packages/client/ui-sidebar-terminal/README.md index cf915dc73c..2dba3ea839 100644 --- a/packages/client/ui-sidebar-terminal/README.md +++ b/packages/client/ui-sidebar-terminal/README.md @@ -27,7 +27,7 @@ Open the execution environment's default shell beside a conversation without lea Open the right sidebar and select **New terminal** on the Start page; first select **New tab** when another content tab is open. The default shell starts immediately. Use the sidebar's **New tab** control to open more terminals. -Double-click the terminal's tab title to rename it. **Take control** makes the current attachment writable when another page owns input. A failed connection offers **Reconnect**. An exited shell remains visible with its exit code and never restarts automatically. +Double-click the terminal's tab title to rename it. **Take control** makes the current attachment writable when another page owns input. A failed connection offers **Reconnect**. An exited shell remains visible with its exit code and never restarts automatically. Exited terminals count toward the Session limit; close unused tabs when the limit is reached. Closing or replacing a terminal tab removes it immediately and ends its process in the background. A cleanup failure shows a small notification with **Retry**; retrying does not reopen the tab. Collapsing, switching tabs or Sessions, floating and fullscreen presentation preserve the process. diff --git a/packages/client/ui-sidebar-terminal/README.zh.md b/packages/client/ui-sidebar-terminal/README.zh.md index a7aa321d10..63d483a37f 100644 --- a/packages/client/ui-sidebar-terminal/README.zh.md +++ b/packages/client/ui-sidebar-terminal/README.zh.md @@ -27,7 +27,7 @@ kind: "package-reference" 展开右侧栏,在开始页选择**新建终端**;已有其他标签页时,先选择**新标签页**。默认 shell 会立即启动。通过侧栏的**新标签页**控件打开更多终端。 -双击终端标签页标题即可重命名。另一页面持有输入权时,**接管输入**让当前连接可写。连接失败时可以**重新连接**。shell 退出后仍显示屏幕和退出码,不自动重启。 +双击终端标签页标题即可重命名。另一页面持有输入权时,**接管输入**让当前连接可写。连接失败时可以**重新连接**。shell 退出后仍显示屏幕和退出码,不自动重启。 已退出的终端计入 Session 数量上限;达到上限时请关闭不用的标签页。 关闭或替换终端标签页会立即移除标签页,并在后台结束进程。清理失败时显示带**重试**操作的轻量通知;重试不会重新打开标签页。折叠、切换标签页或 Session、浮动和全屏都保留进程。 diff --git a/packages/client/ui-sidebar-terminal/src/client/TerminalBody.tsx b/packages/client/ui-sidebar-terminal/src/client/TerminalBody.tsx index b9916e0935..9a14a55a14 100644 --- a/packages/client/ui-sidebar-terminal/src/client/TerminalBody.tsx +++ b/packages/client/ui-sidebar-terminal/src/client/TerminalBody.tsx @@ -23,7 +23,7 @@ export function TerminalBody({ useTabInfo, useTerminal, view, t }: TerminalBodyP const state = useTerminal(tab.id) useEffect(() => model.mount(), [model]) if (state === undefined) return null - const error = state.error ?? state.info?.error + const error = state.issue === undefined ? state.error ?? state.info?.error : t(state.issue) let status: string | undefined if (state.phase === 'idle' || state.phase === 'loading') status = t('loading') else if (state.phase === 'creating' || state.phase === 'connecting' || state.phase === 'disconnected') status = t(state.phase) diff --git a/packages/client/ui-sidebar-terminal/src/client/locales.ts b/packages/client/ui-sidebar-terminal/src/client/locales.ts index cdaad90ecc..ebda63a422 100644 --- a/packages/client/ui-sidebar-terminal/src/client/locales.ts +++ b/packages/client/ui-sidebar-terminal/src/client/locales.ts @@ -16,6 +16,11 @@ export const zh = { closed: '终端已关闭。', exited: '进程已退出({code})', failed: '终端错误:{message}', rename: '终端名称', unavailable: '不可用', retry: '重试', cleanupFailed: '终端「{title}」未能结束:{message}', + missingTerminal: '此终端已不存在,请新建终端。', + inputFull: '输入缓冲区已满,请重新连接后重试。', + attachmentEnded: '终端连接已结束,请重新连接。', + invalidOutput: '终端画面传输异常,请重新连接。', + terminalLimit: '终端数量已达上限,请关闭不用的终端后重试。已退出的终端也计入数量。', } satisfies Record /** English terminal copy. */ @@ -27,4 +32,9 @@ export const en = { closed: 'Terminal closed.', exited: 'Process exited ({code})', failed: 'Terminal error: {message}', rename: 'Terminal name', unavailable: 'Unavailable', retry: 'Retry', cleanupFailed: 'Terminal “{title}” could not be ended: {message}', + missingTerminal: 'This terminal no longer exists. Open a new terminal.', + inputFull: 'The input buffer is full. Reconnect and try again.', + attachmentEnded: 'The terminal connection ended. Reconnect to continue.', + invalidOutput: 'The terminal screen could not be received. Reconnect to recover it.', + terminalLimit: 'The terminal limit has been reached. Close unused terminals and try again. Exited terminals also count toward the limit.', } satisfies Record diff --git a/packages/client/ui-sidebar-terminal/tests/apply.client.spec.ts b/packages/client/ui-sidebar-terminal/tests/apply.client.spec.ts index 21f616a07d..3fe6dd4e4d 100644 --- a/packages/client/ui-sidebar-terminal/tests/apply.client.spec.ts +++ b/packages/client/ui-sidebar-terminal/tests/apply.client.spec.ts @@ -87,10 +87,10 @@ it('registers terminal views, recovery and cleanup, then releases every contribu expect(h.terminals.view).toHaveBeenLastCalledWith(sessionId, 'tab', terminalId) expect(h.occurrence).toHaveBeenLastCalledWith(sessionId, { id: 'tab' }) if (h.closeHandler === undefined) throw new Error('Terminal close handler was not registered') - expect(h.closeHandler(sessionId, { id: 'tab' } as Parameters[1])).toBeUndefined() + h.closeHandler(sessionId, { id: 'tab' } as Parameters[1]) expect(h.terminals.close).toHaveBeenLastCalledWith(sessionId, 'tab', terminalId) h.setParams(undefined) - expect(h.closeHandler(sessionId, { id: 'new-tab' } as Parameters[1])).toBeUndefined() + h.closeHandler(sessionId, { id: 'new-tab' } as Parameters[1]) expect(h.terminals.close).toHaveBeenLastCalledWith(sessionId, 'new-tab', undefined) const cleanupFace = h.entries[3]!.inject(sessionId) as TerminalCleanupInjected expect(cleanupFace.hooks.closeFailures).toBe(h.terminals.closeFailures) diff --git a/packages/client/ui-sidebar-terminal/tests/terminal-body.client.spec.tsx b/packages/client/ui-sidebar-terminal/tests/terminal-body.client.spec.tsx index 8f89680527..c20a5e7920 100644 --- a/packages/client/ui-sidebar-terminal/tests/terminal-body.client.spec.tsx +++ b/packages/client/ui-sidebar-terminal/tests/terminal-body.client.spec.tsx @@ -60,7 +60,7 @@ const titleSurfaces = [ wrap: (title: ReactNode) =>
{title}
, }, ] -function mount(initial: TerminalViewState | undefined = idle) { +function mount(initial: TerminalViewState | undefined = idle, dictionary = en) { let state: TerminalViewState | undefined = initial let visible = true const detach = vi.fn() @@ -74,7 +74,7 @@ function mount(initial: TerminalViewState | undefined = idle) { const props = { view: () => model, useTerminal: (_key: string, select?: (value: TerminalViewState | undefined) => unknown) => select === undefined ? state : select(state), - useTabInfo: tab, t: makeTranslate(en), + useTabInfo: tab, t: makeTranslate(dictionary), } as unknown as TerminalBodyProps const view = render() return { @@ -297,3 +297,15 @@ it('starts a recovered screen with no local history when environment discovery i mount({ ...idle, info, environment: undefined }) expect(fake.terminals[0]!.options).toHaveProperty('scrollback', 0) }) + + +it.each([en, zh])('translates known terminal failures while retaining unknown Host diagnostics', (dictionary) => { + const h = mount(idle, dictionary) + for (const issue of ['missingTerminal', 'inputFull', 'attachmentEnded', 'invalidOutput', 'terminalLimit'] as const) { + h.update({ ...idle, phase: 'failed', issue, error: 'raw diagnostic' }) + expect(h.view.getByRole('alert').textContent).toContain(dictionary[issue]) + expect(h.view.getByRole('alert').textContent).not.toContain('raw diagnostic') + } + h.update({ ...idle, phase: 'failed', error: 'Host permission denied' }) + expect(h.view.getByRole('alert').textContent).toContain('Host permission denied') +}) diff --git a/packages/e2b/subprocess-e2b/README.i18n.yaml b/packages/e2b/subprocess-e2b/README.i18n.yaml index aa75116ae8..934fe327cb 100644 --- a/packages/e2b/subprocess-e2b/README.i18n.yaml +++ b/packages/e2b/subprocess-e2b/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/e2b/subprocess-e2b/README.md -README.md: 11cb60e3966b2dd7bd20021a9cb6e3806b41df3e -README.zh.md: 14834748bbc82d87d3178ab75c7b470184b4e8c3 +README.md: 344abc6b8c9c26306696d59d9a56415eb69fbd3d +README.zh.md: 1a96f3a85da34349f02a78bcd7b51dbeb35e025c diff --git a/packages/e2b/subprocess-e2b/README.md b/packages/e2b/subprocess-e2b/README.md index 11cb60e396..344abc6b8c 100644 --- a/packages/e2b/subprocess-e2b/README.md +++ b/packages/e2b/subprocess-e2b/README.md @@ -25,7 +25,7 @@ English | [中文](README.zh.md) ## Use this package -Use this package when the agent's shell commands and terminals should run inside the remote sandbox rather than on your machine. It is the command half of the E2B family: commands, terminals, and files share one remote world. +Use this package when the agent's shell commands and terminals should run inside the remote sandbox rather than on your machine. It is the command half of the E2B family: commands, terminals, and files share one remote world. `terminalEnvironment()` reports the remote sandbox’s nonempty `SHELL`; an empty or absent value leaves shell fallback to the consumer. ### When to choose it diff --git a/packages/e2b/subprocess-e2b/README.zh.md b/packages/e2b/subprocess-e2b/README.zh.md index 14834748bb..1a96f3a85d 100644 --- a/packages/e2b/subprocess-e2b/README.zh.md +++ b/packages/e2b/subprocess-e2b/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -当 agent 的 shell 命令与终端应在远程沙箱内而非你的机器上运行时,使用本包。它是 E2B 家族的命令半边:命令、终端与文件共享同一个远程世界。 +当 agent 的 shell 命令与终端应在远程沙箱内而非你的机器上运行时,使用本包。它是 E2B 家族的命令半边:命令、终端与文件共享同一个远程世界。 `terminalEnvironment()` 返回远端沙箱中非空的 `SHELL`;空值或缺失值由消费者决定 shell 回退。 ### 何时选择 diff --git a/packages/e2b/subprocess-e2b/src/index.ts b/packages/e2b/subprocess-e2b/src/index.ts index 31d7e1bd54..4957aea42a 100644 --- a/packages/e2b/subprocess-e2b/src/index.ts +++ b/packages/e2b/subprocess-e2b/src/index.ts @@ -150,7 +150,7 @@ export class E2BSubprocessRuntime extends SubprocessRuntime { async terminalEnvironment(signal?: AbortSignal): Promise { const sandbox = await this.ctx.e2b.getSandbox() const environment = scrubRemoteEnvironment(await readRemoteEnvironment(sandbox, signal)) - const defaultShell = environment.get('SHELL') + const defaultShell = environment.get('SHELL') || undefined return { platform: 'posix', ...defaultShell === undefined ? {} : { defaultShell } } } diff --git a/packages/e2b/subprocess-e2b/tests/terminal.spec.ts b/packages/e2b/subprocess-e2b/tests/terminal.spec.ts index e726269502..e65e5226a2 100644 --- a/packages/e2b/subprocess-e2b/tests/terminal.spec.ts +++ b/packages/e2b/subprocess-e2b/tests/terminal.spec.ts @@ -859,6 +859,8 @@ describe('E2B subprocess terminal service', () => { try { fake.ambient = 'SHELL=/opt/fish\0' await expect(ctx.subprocess.terminalEnvironment()).resolves.toEqual({ platform: 'posix', defaultShell: '/opt/fish' }) + fake.ambient = 'SHELL=\0' + await expect(ctx.subprocess.terminalEnvironment()).resolves.toEqual({ platform: 'posix' }) fake.ambient = 'PATH=/usr/bin\0' await expect(ctx.subprocess.terminalEnvironment()).resolves.toEqual({ platform: 'posix' }) fake.commandFailure = new Error('sandbox disconnected') diff --git a/packages/subprocess/subprocess-local/README.i18n.yaml b/packages/subprocess/subprocess-local/README.i18n.yaml index 3cef0557d1..5f2f9dc7c4 100644 --- a/packages/subprocess/subprocess-local/README.i18n.yaml +++ b/packages/subprocess/subprocess-local/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subprocess/subprocess-local/README.md -README.md: 11fd4cbcd4150cae75f15d5b5cc2d5294c2576ae -README.zh.md: 4bc0bc284770b68ef9cd8c6d09f39ccc8866d0f2 +README.md: 27cbdd6973b0b829e7448b3db131ae1041038c68 +README.zh.md: 564c3cc1947e97b9cb4ae2d68c1c0c77bcc0d104 diff --git a/packages/subprocess/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md index 11fd4cbcd4..27cbdd6973 100644 --- a/packages/subprocess/subprocess-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -29,7 +29,7 @@ Mount the provider beside its consumers and start processes exactly as the subpr ### Mounting the provider -Load the provider in the same composition as its consumers. It has no config fields: every choice arrives on the spawn request, so deployment-varying decisions stay with the caller's configuration. +Load the provider in the same composition as its consumers. It has no config fields: every choice arrives on the spawn request, so deployment-varying decisions stay with the caller's configuration. `terminalEnvironment()` reads a nonempty `SHELL` on POSIX, falling back to the account login shell, or a nonempty `ComSpec` on Windows. Empty values are omitted so the consumer can choose its platform fallback. ```yaml - name: '@deepseek-ai/dsh-subprocess-local' diff --git a/packages/subprocess/subprocess-local/README.zh.md b/packages/subprocess/subprocess-local/README.zh.md index 4bc0bc2847..564c3cc194 100644 --- a/packages/subprocess/subprocess-local/README.zh.md +++ b/packages/subprocess/subprocess-local/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ### 挂载提供方 -在与消费方相同的组合中加载本提供方。它没有任何配置字段:每项选择都随 spawn 请求到达,因此随部署变化的决策留在调用方的配置里。 +在与消费方相同的组合中加载本提供方。它没有任何配置字段:每项选择都随 spawn 请求到达,因此随部署变化的决策留在调用方的配置里。 `terminalEnvironment()` 在 POSIX 读取非空的 `SHELL`,缺失时使用账户登录 shell;在 Windows 读取非空的 `ComSpec`。空值会被省略,由消费者选择平台回退。 ```yaml - name: '@deepseek-ai/dsh-subprocess-local' diff --git a/packages/subprocess/subprocess-local/src/index.ts b/packages/subprocess/subprocess-local/src/index.ts index 85d80f4465..e7307dce7b 100644 --- a/packages/subprocess/subprocess-local/src/index.ts +++ b/packages/subprocess/subprocess-local/src/index.ts @@ -233,7 +233,7 @@ export class LocalSubprocessRuntime extends SubprocessRuntime { async terminalEnvironment(signal?: AbortSignal): Promise { signal?.throwIfAborted() const platform = process.platform === 'win32' ? 'windows' : 'posix' - const defaultShell = platform === 'windows' ? process.env.ComSpec : process.env.SHELL ?? userInfo().shell ?? undefined + const defaultShell = platform === 'windows' ? process.env.ComSpec || undefined : process.env.SHELL || userInfo().shell || undefined return { platform, ...defaultShell === undefined ? {} : { defaultShell } } } diff --git a/packages/subprocess/subprocess-local/tests/local.spec.ts b/packages/subprocess/subprocess-local/tests/local.spec.ts index 813f24d071..644bf16f5e 100644 --- a/packages/subprocess/subprocess-local/tests/local.spec.ts +++ b/packages/subprocess/subprocess-local/tests/local.spec.ts @@ -64,6 +64,10 @@ describe('LocalSubprocessRuntime', () => { await expect(ctx.subprocess.terminalEnvironment()).resolves.toEqual({ platform: 'posix', defaultShell: '/account/shell', }) + vi.stubEnv('SHELL', '') + await expect(ctx.subprocess.terminalEnvironment()).resolves.toEqual({ platform: 'posix', defaultShell: '/account/shell' }) + loginShell = '' + await expect(ctx.subprocess.terminalEnvironment()).resolves.toEqual({ platform: 'posix' }) loginShell = null await expect(ctx.subprocess.terminalEnvironment()).resolves.toEqual({ platform: 'posix' }) platform.mockReturnValue('win32') @@ -73,6 +77,8 @@ describe('LocalSubprocessRuntime', () => { }) vi.stubEnv('ComSpec', undefined) await expect(ctx.subprocess.terminalEnvironment()).resolves.toEqual({ platform: 'windows' }) + vi.stubEnv('ComSpec', '') + await expect(ctx.subprocess.terminalEnvironment()).resolves.toEqual({ platform: 'windows' }) platform.mockReturnValue('linux') userInfo.mockClear() const reason = new Error('terminal inspection cancelled') From f0810edd7fc6a6467d3ee7d25b6c07d3deff9d9e Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 9 Sep 2026 18:19:51 +0800 Subject: [PATCH 29/79] fix(trajectory): expand preview thinking by default --- packages/client/ui-trajectory/src/client/TrajectoryTable.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index eb743e5881..67cf0d838d 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -2084,8 +2084,6 @@ export function TrajectoryTable({ ? thinkingDisclosure.expanded : selected?.cell.kind === 'message' && Boolean(selected.cell.thinkingDetail?.trim()) - && !selected.cell.outputDetail?.trim() - && !selected.cell.sourceBlocks?.some(block => block.type === 'tool-call') const setThinkingExpanded = (expanded: boolean) => { if (selectedRecordId !== null) { setThinkingDisclosure({ recordId: selectedRecordId, expanded }) From f36e1a3d1d1f4c291b010636b84eb2ea8bf8aca5 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 9 Sep 2026 18:20:13 +0800 Subject: [PATCH 30/79] fix(trajectory): show turn and ordinal step in detail titles --- packages/client/ui-trajectory/src/client/TrajectoryTable.tsx | 2 +- packages/client/ui-trajectory/src/client/locales.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index 67cf0d838d..f7759adf00 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -3006,7 +3006,7 @@ export function TrajectoryTable({ {selected.cell.kind === 'compacted' ? sectionLabel(selected.turn, t) - : selected.group} + : `${sectionLabel(selected.turn, t)} · ${selected.group}`} )} diff --git a/packages/client/ui-trajectory/src/client/locales.ts b/packages/client/ui-trajectory/src/client/locales.ts index c2fbe6b25c..7e76444ca1 100644 --- a/packages/client/ui-trajectory/src/client/locales.ts +++ b/packages/client/ui-trajectory/src/client/locales.ts @@ -37,7 +37,7 @@ export const zh = { 'turn.label': '第 {turn} 轮', 'section.betweenTurns': '轮次之间', 'group.message': '消息', - 'group.step': '步骤 {step}', + 'group.step': '第 {step} 步', 'group.compaction': '压缩 {seq}', 'status.failed': '失败', 'status.pending': '等待中', From a73aca1978c22adbd8927ce08472cc1d9628b7c8 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 9 Sep 2026 18:34:11 +0800 Subject: [PATCH 31/79] test(client): cover assistant thinking disclosure defaults --- .../tests/reasoning-row.client.spec.tsx | 31 +++++++++++++++++-- .../ui-trajectory/tests/table.client.spec.tsx | 26 ++++++++++++---- 2 files changed, 49 insertions(+), 8 deletions(-) diff --git a/packages/client/ui-chat/tests/reasoning-row.client.spec.tsx b/packages/client/ui-chat/tests/reasoning-row.client.spec.tsx index 439b79bc7a..0711d89770 100644 --- a/packages/client/ui-chat/tests/reasoning-row.client.spec.tsx +++ b/packages/client/ui-chat/tests/reasoning-row.client.spec.tsx @@ -14,6 +14,26 @@ const t = makeTranslate(zh, commonZh) const renderMessageImages: AssistantMarkdownProps['renderMessageImages'] = () => null describe('ReasoningRow', () => { + it('collapses when body text arrives and preserves a later manual expansion', () => { + const reasoning = { kind: 'reasoning' as const, text: 'Inspect the session\nCheck persistence' } + const view = render( + , + ) + expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('true') + view.rerender( + , + ) + expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('false') + fireEvent.click(view.getByText('思考')) + view.rerender( + , + ) + expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('true') + expect(view.getByText(/Check persistence/)).toBeTruthy() + }) + it('follows the latest streaming line, then restores the settled first line', () => { const view = render( { />, ) expect(view.getByText('运行中')).toBeTruthy() + expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('true') + fireEvent.click(view.getByText('思考')) expect(view.getByText('Newest reasoning tokens').parentElement?.getAttribute('data-follow-end')) .toBe('true') @@ -62,6 +84,7 @@ describe('ReasoningRow', () => { ) const row = view.getByRole('button') + fireEvent.click(view.getByText('思考')) fireEvent.click(view.getByText('Inspect the session')) expect(row.getAttribute('aria-expanded')).toBe('true') expect(view.getByText(/Check persistence/)).toBeTruthy() @@ -91,6 +114,7 @@ describe('ReasoningRow', () => { />, ) + fireEvent.click(view.getByText('思考')) expect(view.getByText('Comparing checkout and merge bases')).toBeTruthy() expect(view.queryByText('**Comparing checkout and merge bases**')).toBeNull() @@ -98,7 +122,7 @@ describe('ReasoningRow', () => { expect(view.container.querySelector('[class*="thinkBody"]')?.textContent).toBe(text) }) - it('expanded Think drops the inline summary and renders plain prose, no IN card', () => { + it('opens reasoning-only replies as plain prose and allows manual collapse', () => { const view = render( { renderMessageImages={renderMessageImages} />, ) - fireEvent.click(view.getByText('思考')) + expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('true') expect(view.getAllByText(/Inspect the session/)).toHaveLength(1) expect(view.queryByText('IN')).toBeNull() expect(view.container.querySelector('[class*="ioCard"]')).toBeNull() expect(view.container.querySelector('[class*="thinkBody"]')).not.toBeNull() + fireEvent.click(view.getByText('思考')) + expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('false') + expect(view.queryByText(/Check persistence/)).toBeNull() }) }) diff --git a/packages/client/ui-trajectory/tests/table.client.spec.tsx b/packages/client/ui-trajectory/tests/table.client.spec.tsx index b16475162a..a60a53a1c0 100644 --- a/packages/client/ui-trajectory/tests/table.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/table.client.spec.tsx @@ -231,7 +231,12 @@ describe('TrajectoryTable', () => { expect(panel.querySelector('[data-summary-scroll-region]')).toBeNull() }) - it('keeps long thinking collapsed until the user asks to render it', () => { + it.each([ + { outputDetail: undefined, toolCall: false }, + { outputDetail: 'Visible answer', toolCall: false }, + { outputDetail: undefined, toolCall: true }, + { outputDetail: 'Visible answer', toolCall: true }, + ])('opens thinking with output=$outputDetail and toolCall=$toolCall', ({ outputDetail, toolCall }) => { const thinking = 'private chain '.repeat(1_000) const turns: readonly TrajectoryTurnModel[] = [{ turn: 1, @@ -242,6 +247,10 @@ describe('TrajectoryTable', () => { kind: 'message', text: 'private chain…', thinkingDetail: thinking, + outputDetail, + sourceBlocks: toolCall ? [{ + type: 'tool-call', content: '{}', callId: 'call-1', toolName: 'read', + }] : undefined, timeSeconds: 1, }], }], @@ -249,14 +258,19 @@ describe('TrajectoryTable', () => { render() fireEvent.click(screen.getByRole('row', { name: /ASSISTANT/ })) + fireEvent.click(screen.getByRole('tab', { name: 'Preview' })) const toggle = screen.getByRole('button', { name: 'Thinking' }) - expect(toggle.getAttribute('aria-expanded')).toBe('false') - expect(screen.queryByText(thinking)).toBeNull() - - fireEvent.click(toggle) - expect(screen.getByRole('button', { name: 'Thinking' })).toBe(toggle) expect(toggle.getAttribute('aria-expanded')).toBe('true') expect(toggle.parentElement?.textContent?.length).toBeGreaterThan(thinking.length) + + fireEvent.click(toggle) + expect(toggle.getAttribute('aria-expanded')).toBe('false') + expect(screen.queryByText(thinking)).toBeNull() + fireEvent.click(screen.getByRole('tab', { name: 'Summary' })) + expect(screen.getByRole('button', { name: 'Thinking' }).getAttribute('aria-expanded')).toBe('false') + fireEvent.click(screen.getByRole('tab', { name: 'Preview' })) + fireEvent.click(screen.getByRole('button', { name: 'Thinking' })) + expect(screen.getByRole('button', { name: 'Thinking' }).getAttribute('aria-expanded')).toBe('true') }) it('keeps raw HTML tags in a Markdown-derived context preview', () => { From 5230c6052572af05ceb80fe301a23a1c79dd7862 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 9 Sep 2026 18:34:11 +0800 Subject: [PATCH 32/79] test(trajectory): expect ordinal step detail titles --- packages/client/ui-trajectory/tests/views.client.spec.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/client/ui-trajectory/tests/views.client.spec.tsx b/packages/client/ui-trajectory/tests/views.client.spec.tsx index 79a0e14b0e..d0a00549d4 100644 --- a/packages/client/ui-trajectory/tests/views.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.client.spec.tsx @@ -538,7 +538,7 @@ describe('tab switching in ConversationRoot', () => { fireEvent.keyDown(screen.getByRole('row', { name: /工具/ }), { key: 'Enter' }) expect(screen.getByRole('complementary', { name: '事件详情' })).toBeTruthy() - expect(screen.getByText('第 1 轮 · 步骤 1')).toBeTruthy() + expect(screen.getByText('第 1 轮 · 第 1 步')).toBeTruthy() expect(screen.getByText('已完成')).toBeTruthy() expect(screen.getByRole('tab', { name: '结果' })).toBeTruthy() From 9c6b282535675cf2a1d62c1e07a346d6425b046c Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 9 Sep 2026 18:34:12 +0800 Subject: [PATCH 33/79] test(client): cover raw JSON strings and wrapping preferences --- .../tests/json-tree.client.spec.tsx | 86 ++++++++++++------- .../string-wrapping-store.client.spec.ts | 26 ++++++ 2 files changed, 83 insertions(+), 29 deletions(-) create mode 100644 packages/client/ui-trajectory/tests/string-wrapping-store.client.spec.ts diff --git a/packages/client/ui-primitives/tests/json-tree.client.spec.tsx b/packages/client/ui-primitives/tests/json-tree.client.spec.tsx index 691ba88652..2279b5cb6e 100644 --- a/packages/client/ui-primitives/tests/json-tree.client.spec.tsx +++ b/packages/client/ui-primitives/tests/json-tree.client.spec.tsx @@ -13,8 +13,10 @@ function JsonTree(props: Omit, 'label' } let writeText: ReturnType +let originalClipboard: PropertyDescriptor | undefined beforeEach(() => { + originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard') writeText = vi.fn().mockResolvedValue(undefined) Object.defineProperty(navigator, 'clipboard', { configurable: true, @@ -25,9 +27,62 @@ beforeEach(() => { afterEach(() => { cleanup() vi.useRealTimers() + vi.restoreAllMocks() + vi.unstubAllGlobals() + if (originalClipboard === undefined) Reflect.deleteProperty(navigator, 'clipboard') + else Object.defineProperty(navigator, 'clipboard', originalClipboard) }) describe('JsonTree', () => { + it('keeps raw strings intact and samples the wrapping preference on every expansion', async () => { + vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockReturnValue(200) + const computedStyle = window.getComputedStyle.bind(window) + vi.spyOn(window, 'getComputedStyle').mockImplementation((element) => { + const style = computedStyle(element) + style.lineHeight = '16px' + return style + }) + vi.stubGlobal('ResizeObserver', class { + observe() {} + disconnect() {} + }) + let wrapped = false + const stringWrapping = { + label: 'Wrap lines', + getDefault: () => wrapped, + setDefault: (value: boolean) => { wrapped = value }, + } + const original = ` leading spaces\n\t"quoted" \\${'long'.repeat(100)}\nlast line\n` + render() + const [first, second] = within(screen.getByRole('tree')).getAllByRole('treeitem') + const a = within(first as HTMLElement) + const b = within(second as HTMLElement) + fireEvent.click(a.getByRole('button', { name: 'Expand JSON node' })) + fireEvent.click(b.getByRole('button', { name: 'Expand JSON node' })) + expect(a.getByRole('button', { name: 'Wrap lines' }).getAttribute('aria-pressed')).toBe('false') + + fireEvent.click(a.getByRole('button', { name: 'Wrap lines' })) + expect(wrapped).toBe(true) + expect(a.getByRole('button', { name: 'Wrap lines' }).getAttribute('aria-pressed')).toBe('true') + expect(b.getByRole('button', { name: 'Wrap lines' }).getAttribute('aria-pressed')).toBe('false') + + fireEvent.click(b.getByRole('button', { name: 'Collapse JSON node' })) + fireEvent.click(b.getByRole('button', { name: 'Expand JSON node' })) + expect(b.getByRole('button', { name: 'Wrap lines' }).getAttribute('aria-pressed')).toBe('true') + fireEvent.click(b.getByRole('button', { name: 'Wrap lines' })) + expect(wrapped).toBe(false) + expect(a.getByRole('button', { name: 'Wrap lines' }).getAttribute('aria-pressed')).toBe('true') + + fireEvent.click(a.getByRole('button', { name: 'Collapse JSON node' })) + fireEvent.click(a.getByRole('button', { name: 'Expand JSON node' })) + const toggle = a.getByRole('button', { name: 'Wrap lines' }) + expect(toggle.getAttribute('aria-pressed')).toBe('false') + const contents = document.getElementById(toggle.getAttribute('aria-controls') as string) + expect(contents?.textContent).toBe(original) + fireEvent.click(a.getByRole('button', { name: 'Copy value' })) + await waitFor(() => { expect(writeText).toHaveBeenCalledWith(original) }) + }) + it('keeps the top level open and renders expandable value previews', () => { render( { view.unmount() }) - it('keeps copy placement synchronized and clears stale targets', () => { + it('keeps the copy action on its hovered row and clears stale targets', () => { const view = render() const root = view.container.firstElementChild as HTMLElement const tree = screen.getByRole('tree') const firstRow = within(tree).getAllByRole('treeitem')[0] as HTMLElement const secondRow = within(tree).getAllByRole('treeitem')[1] as HTMLElement - Object.defineProperty(root, 'clientHeight', { configurable: true, value: 100 }) - Object.defineProperty(root, 'clientWidth', { configurable: true, value: 300 }) - vi.spyOn(root, 'getBoundingClientRect').mockReturnValue({ - bottom: 100, - height: 100, - left: 10, - right: 310, - top: 0, - width: 300, - x: 10, - y: 0, - toJSON: () => ({}), - }) - vi.spyOn(firstRow, 'getBoundingClientRect').mockReturnValue({ - bottom: 91, - height: 16, - left: 10, - right: 200, - top: 75, - width: 190, - x: 10, - y: 75, - toJSON: () => ({}), - }) - fireEvent.mouseOver(firstRow) const copyButton = screen.getByRole('button', { name: 'Copy pretty JSON' }) - expect((copyButton.closest('span')?.parentElement as HTMLElement).style.left).toBe('284px') + expect(firstRow.contains(copyButton)).toBe(true) fireEvent.mouseOver(copyButton) expect(screen.getByRole('button', { name: 'Copy pretty JSON' })).toBeDefined() fireEvent.mouseOver(firstRow) fireEvent.scroll(root) - fireEvent.scroll(window) - fireEvent.resize(window) fireEvent.contextMenu(copyButton) fireEvent.mouseOver(secondRow) diff --git a/packages/client/ui-trajectory/tests/string-wrapping-store.client.spec.ts b/packages/client/ui-trajectory/tests/string-wrapping-store.client.spec.ts new file mode 100644 index 0000000000..7ab269f2cf --- /dev/null +++ b/packages/client/ui-trajectory/tests/string-wrapping-store.client.spec.ts @@ -0,0 +1,26 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, expect, it } from 'vitest' +import { createTrajectoryStringWrappingStore } from '../src/client/string-wrapping-store.ts' + +const key = 'dsh.trajectory.jsonStringWrapping' +let original: string | null + +beforeEach(() => { + original = localStorage.getItem(key) + localStorage.removeItem(key) +}) + +afterEach(() => { + if (original === null) localStorage.removeItem(key) + else localStorage.setItem(key, original) +}) + +it('restores the last wrapping choice when the view is recreated', () => { + const first = createTrajectoryStringWrappingStore() + expect(first.getSnapshot()).toBe(false) + first.set(true) + const second = createTrajectoryStringWrappingStore() + expect(second.getSnapshot()).toBe(true) + second.set(false) + expect(createTrajectoryStringWrappingStore().getSnapshot()).toBe(false) +}) From 508490b531324bb611ae91463b1ed4874d66934c Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 9 Sep 2026 18:35:07 +0800 Subject: [PATCH 34/79] test(trajectory): omit absent optional fixture fields --- packages/client/ui-trajectory/tests/table.client.spec.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/client/ui-trajectory/tests/table.client.spec.tsx b/packages/client/ui-trajectory/tests/table.client.spec.tsx index a60a53a1c0..9723935bd4 100644 --- a/packages/client/ui-trajectory/tests/table.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/table.client.spec.tsx @@ -247,10 +247,10 @@ describe('TrajectoryTable', () => { kind: 'message', text: 'private chain…', thinkingDetail: thinking, - outputDetail, - sourceBlocks: toolCall ? [{ + ...(outputDetail === undefined ? {} : { outputDetail }), + ...(toolCall ? { sourceBlocks: [{ type: 'tool-call', content: '{}', callId: 'call-1', toolName: 'read', - }] : undefined, + }] } : {}), timeSeconds: 1, }], }], From a9b5faef0b3cad409ea3331105fdb571823416bd Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 9 Sep 2026 18:49:03 +0800 Subject: [PATCH 35/79] test(chat): collapse live thinking before checking its tail --- apps/web/tests/lifecycle-chrome.e2e.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index f42337be37..d5af569cbc 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -227,7 +227,12 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () try { await input.press('Enter') if (MODE !== 'record') { - const liveTail = page.locator('[data-variant="think"][data-state="running"] [data-follow-end]') + const thinking = page.locator('[data-variant="think"][data-state="running"]') + const disclosure = thinking.getByRole('button') + await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('true') + await disclosure.click() + await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('false') + const liveTail = thinking.locator('[data-follow-end]') await expect.poll(async () => { if (await liveTail.count() !== 1) return false return await liveTail.evaluate((element) => { From 5b8c445eebef155f7934ac0faec033fbe9a4c224 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 9 Sep 2026 19:46:13 +0800 Subject: [PATCH 36/79] fix(snapshots): preserve released inline image fixture --- snapshots/sdk/inline-image-prompt/session.v2.jsonl | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/snapshots/sdk/inline-image-prompt/session.v2.jsonl b/snapshots/sdk/inline-image-prompt/session.v2.jsonl index 1e04205be3..4a58b74f05 100644 --- a/snapshots/sdk/inline-image-prompt/session.v2.jsonl +++ b/snapshots/sdk/inline-image-prompt/session.v2.jsonl @@ -2,19 +2,15 @@ {"type":"permission/preset","data":{"preset":"danger-full-access"}} {"type":"sandbox/mode","data":{"mode":"danger-full-access"}} {"type":"approval/policy","data":{"policy":"never"}} -{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Inspect these images, then reply with exactly "},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"text","text":"the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Inspect this image, then reply with exactly "},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"text","text":"the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":1,"step":1}} -{"type":"user/message","data":{"content":[{"type":"text","text":"Inspect these images, then reply with exactly "},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"text","text":"the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Inspect this image, then reply with exactly "},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"text","text":"the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{message:2}}"},"surfaceOp":"append"} -{"type":"session/title","data":{"title":"Inspect these images, then reply","messageSeqs":[7],"source":{"kind":"fallback"}}} +{"type":"session/title","data":{"title":"Inspect this image, then reply","messageSeqs":[7],"source":{"kind":"fallback"}}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"}} -{"type":"assistant/attempt","data":{"turn":1,"step":1,"stream":[{"type":"chunk","time":1788851314955,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"DeepSeek base64 request images exceed the route budget; 3 more oldest occurrence(s) must be offloaded.","code":"IMAGE_OFFLOAD_REQUIRED","offloadImages":3}}}}]}} -{"type":"compaction/prune","data":{"shadowedRange":{"start":7,"end":7},"shadowedSeqs":[7],"shadowedTokenCount":318}} -{"type":"user/message","data":{"content":[{"type":"text","text":"Inspect these images, then reply with exactly "},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69},"offloaded":true},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69},"offloaded":true},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69},"offloaded":true},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"text","text":"the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"sourceEventSeqs":[7],"surfaceOp":{"op":"replace","start":7,"end":7}} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"system":"{{system}}","tools":"{{tools}}"},"reason":"series"}} -{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:3}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1788851314960,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"chunk","time":1788851314960,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1788851314960,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1788851314960,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:3}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1788199050354,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"chunk","time":1788199050354,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1788199050354,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1788199050354,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} From 573b0443207a7d214255b85e30f35db2ee815a34 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 9 Sep 2026 19:52:42 +0800 Subject: [PATCH 37/79] refactor(client): share the line wrapping icon --- packages/client/ui-primitives/src/JsonTree.tsx | 15 ++------------- packages/client/ui-primitives/src/icons/index.tsx | 8 ++++++++ .../ui-primitives/tests/icons.client.spec.tsx | 4 ++-- .../ui-trajectory/src/client/TrajectoryTable.tsx | 6 ++---- 4 files changed, 14 insertions(+), 19 deletions(-) diff --git a/packages/client/ui-primitives/src/JsonTree.tsx b/packages/client/ui-primitives/src/JsonTree.tsx index 17e846d983..1e67b05e2b 100644 --- a/packages/client/ui-primitives/src/JsonTree.tsx +++ b/packages/client/ui-primitives/src/JsonTree.tsx @@ -6,7 +6,7 @@ import type { MouseEvent as ReactMouseEvent, ReactNode, } from 'react' -import { IconCheckOutline16, IconCopyOutline16 } from './icons/index.tsx' +import { IconCheckOutline16, IconCopyOutline16, IconWrapLinesOutline16 } from './icons/index.tsx' import { Menu } from './Menu.tsx' import type { MenuEntry } from './Menu.tsx' import css from './JsonTree.module.css' @@ -334,18 +334,7 @@ function JsonString({ stringWrapping.setDefault(next) }} > - + )} )} {onOpen === undefined && ( From e7fc4e8fdc34a375ff5a4883bfa98766bb4fc855 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 9 Sep 2026 19:52:42 +0800 Subject: [PATCH 38/79] fix(client): isolate JSON copy feedback and guard string measurement --- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 + packages/client/ui-primitives/README.zh.md | 2 + .../client/ui-primitives/src/JsonTree.tsx | 227 +++++++++++------- .../tests/json-tree.client.spec.tsx | 118 ++++++++- 5 files changed, 254 insertions(+), 99 deletions(-) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 7646128306..294912ed9e 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md -README.md: d783a2d4eaaac887c5357fe30efc5e47249066be -README.zh.md: 33dc17db52e7263aa6aab81549a3b95f7dbfc59d +README.md: 888662a9e1150b3cf410f2f8202d0f27cba0e961 +README.zh.md: c1d5a4c45ff364925bb66506c0ea1bf8dae0df5d diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index d783a2d4ea..888662a9e1 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -74,6 +74,8 @@ The catalog above lists what each export is for; this section covers the behavio `MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. When the owner passes a `pathImages` vocabulary, image destinations that are local media paths rewrite to displayable URLs on settled renders only (the same streaming gate as file mentions); without a vocabulary, local destinations remain inert alt text. A load or decode failure replaces the image with its authored alt text, or the original destination when alt is empty. Changing the image source permits a fresh load. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax. `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `projectUserText` projects sent user text into inline plain runs and reference chips for the message bubble and queue rows. +`JsonTree` clamps collapsed strings to `collapsedStringLines` (three by default). Expanded strings show raw text, retain sibling commas, and fit within the window and outer scrolling containers. Resize and ancestor-scroll events update that limit. Row copy feedback updates independently of JSON value rendering; pending clipboard writes cannot update a different row or an unmounted tree. + ### Localizing copy The atoms cannot read the application locale, so every piece of user-facing copy arrives through required label props. `HoverCard`, `TerminalBlock`, `JsonTree`, `CodeBlock`, `MarkdownText`, `JsonBlock`, `ConnectionIndicator`, `Modal`, `DiffBlock`, `ReadBlock`, `SearchBlock`, and `WebBlock` accept complete localized labels. The package owns no language fallback; omission fails typechecking, and each feature maps its typed `t` seat into the primitive's label interface. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 33dc17db52..c1d5a4c45f 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -74,6 +74,8 @@ kind: "package-library" `MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。当 owner 传入 `pathImages` 词表时,本地媒体路径的图片目标只在落定渲染阶段重写为可展示 URL(与 file mentions 相同的流式门);不传词表时本地目标保持惰性 alt 文本。加载或解码失败后,图片替换为作者的 alt 文本;alt 为空时显示原始目标路径。图片源变化后可重新加载。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`projectUserText` 把已发送的用户文本投影为行内普通文本段与引用 chip,供消息气泡和排队行使用。 +`JsonTree` 把折叠字符串限制为 `collapsedStringLines` 行(默认三行)。展开后显示原始文本、保留同级逗号,并限制在窗口与外层滚动容器内;尺寸变化和祖先滚动事件会更新此限制。行复制反馈独立于 JSON 值渲染更新;尚未完成的剪贴板写入不会更新另一行或已卸载的树。 + ### 本地化文案 这些原子组件无法读取应用 locale,因此每段面向用户的文案都必须通过 label prop 提供。`HoverCard`、`TerminalBlock`、`JsonTree`、`CodeBlock`、`MarkdownText`、`JsonBlock`、`ConnectionIndicator`、`Modal`、`DiffBlock`、`ReadBlock`、`SearchBlock` 与 `WebBlock` 接收完整的本地化 label。本包不拥有语言回退;遗漏会导致类型检查失败,各功能会把带类型的 `t` 席位映射到 primitive 的 label 接口。 diff --git a/packages/client/ui-primitives/src/JsonTree.tsx b/packages/client/ui-primitives/src/JsonTree.tsx index 1e67b05e2b..7a3f2e8b7f 100644 --- a/packages/client/ui-primitives/src/JsonTree.tsx +++ b/packages/client/ui-primitives/src/JsonTree.tsx @@ -1,5 +1,5 @@ import clsx from 'clsx' -import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react' import type { CSSProperties, KeyboardEvent as ReactKeyboardEvent, @@ -65,6 +65,105 @@ interface RowTarget { value: unknown } +type CopyMode = 'json' | 'path' | 'prettyJson' | 'value' + +interface CopySnapshot { + id: string + target: RowTarget + state: 'idle' | 'copied' | 'failed' + menuOpen: boolean +} + +/** Notify only the old and new row actions; JSON values do not subscribe to hover state. */ +function createCopyStore() { + let current: CopySnapshot | undefined + const listeners = new Map void>>() + return { + get: () => current, + set(next: CopySnapshot | undefined) { + const previous = current?.id + current = next + for (const id of new Set([previous, next?.id])) { + if (id === undefined) continue + for (const listener of listeners.get(id) ?? []) listener() + } + }, + subscribe(id: string, listener: () => void) { + let row = listeners.get(id) + if (row === undefined) listeners.set(id, row = new Set()) + row.add(listener) + return () => { + row.delete(listener) + if (row.size === 0) listeners.delete(id) + } + }, + } +} + +function JsonCopyAction({ store, target, persistent, labels, onCopy, onClose }: { + store: ReturnType + target: RowTarget + persistent: boolean + labels: JsonTreeLabels + onCopy: (target: RowTarget, mode: CopyMode) => Promise + onClose: () => void +}) { + const id = pathId(target.path) + const subscribe = useCallback((listener: () => void) => store.subscribe(id, listener), [id, store]) + const getSnapshot = () => { + const current = store.get() + return current?.id === id ? current : undefined + } + const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot) + const buttonRef = useRef(null) + const state = snapshot?.state ?? 'idle' + const object = typeof target.value === 'object' && target.value !== null + const copyTitle = state === 'copied' + ? labels.copied + : state === 'failed' + ? labels.copyFailed + : object ? labels.copyPrettyJson : labels.copyValue + return ( + + {(persistent || snapshot !== undefined) && ( + void onCopy(target, object ? 'prettyJson' : 'value')} + onContextMenu={(event) => { + event.preventDefault() + event.stopPropagation() + store.set({ id, target, state, menuOpen: true }) + }} + > + {state === 'copied' + ? + : } + + )} + items={object ? objectCopyMenuItems(labels) : valueCopyMenuItems(labels)} + onSelect={(mode) => { + void onCopy(target, mode as CopyMode) + }} + onClose={onClose} + getAnchorRect={() => (buttonRef.current as HTMLButtonElement).getBoundingClientRect()} + /> + )} + + ) +} + function isExpandableValue(value: unknown): value is object | unknown[] { return typeof value === 'object' && value !== null && !(value instanceof Date) } @@ -273,6 +372,8 @@ function JsonString({ useLayoutEffect(() => { if (!expanded) return const raw = rawRef.current as HTMLPreElement + // Keep raw text within the window and clipping ancestors outside the tree. + // Capture scrolling because an ancestor can move the string without resizing it. const clips: HTMLElement[] = [] const tree = raw.closest(`.${css.root}`) as HTMLElement for (let parent = tree.parentElement; parent !== null; parent = parent.parentElement) { @@ -292,13 +393,13 @@ function JsonString({ raw.style.maxHeight = `${Math.max(16, available - 4)}px` } measure() - const observer = new ResizeObserver(measure) - observer.observe(raw) - for (const clip of clips) observer.observe(clip) + const observer = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(measure) + observer?.observe(raw) + for (const clip of clips) observer?.observe(clip) window.addEventListener('resize', measure) window.addEventListener('scroll', measure, true) return () => { - observer.disconnect() + observer?.disconnect() window.removeEventListener('resize', measure) window.removeEventListener('scroll', measure, true) } @@ -319,6 +420,7 @@ function JsonString({ > {value} + {!lastElement && ,}
{stringWrapping !== undefined && ( - )} - items={object ? objectCopyMenuItems(labels) : valueCopyMenuItems(labels)} - onSelect={(id) => { - void copy(target, id as 'json' | 'path' | 'prettyJson' | 'value') - copyMenuOpenRef.current = false - setCopyMenuOpen(false) - }} - onClose={clearCopyTarget} - getAnchorRect={() => ( - copyButtonRef.current as HTMLButtonElement - ).getBoundingClientRect()} - /> - )} - - ) - } : undefined + const renderCopy = copyable ? (target: RowTarget, persistent = false) => ( + + ) : undefined return (
{ - if (!copyMenuOpenRef.current) clearCopyTarget() + if (!copyStore.get()?.menuOpen) clearCopyTarget() }} > {expandTopLevel diff --git a/packages/client/ui-primitives/tests/json-tree.client.spec.tsx b/packages/client/ui-primitives/tests/json-tree.client.spec.tsx index 2279b5cb6e..7a7c335ac0 100644 --- a/packages/client/ui-primitives/tests/json-tree.client.spec.tsx +++ b/packages/client/ui-primitives/tests/json-tree.client.spec.tsx @@ -12,12 +12,12 @@ function JsonTree(props: Omit, 'label' return } -let writeText: ReturnType +let writeText: ReturnType> let originalClipboard: PropertyDescriptor | undefined beforeEach(() => { originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard') - writeText = vi.fn().mockResolvedValue(undefined) + writeText = vi.fn().mockResolvedValue(undefined) Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText }, @@ -33,15 +33,115 @@ afterEach(() => { else Object.defineProperty(navigator, 'clipboard', originalClipboard) }) +function stubStringLayout(scrollHeight = 200): void { + vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockReturnValue(scrollHeight) + const computedStyle = window.getComputedStyle.bind(window) + vi.spyOn(window, 'getComputedStyle').mockImplementation((element) => { + const style = computedStyle(element) + style.lineHeight = '16px' + if (style.paddingBottom === '') style.paddingBottom = '0px' + return style + }) +} + describe('JsonTree', () => { - it('keeps raw strings intact and samples the wrapping preference on every expansion', async () => { - vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockReturnValue(200) - const computedStyle = window.getComputedStyle.bind(window) - vi.spyOn(window, 'getComputedStyle').mockImplementation((element) => { - const style = computedStyle(element) - style.lineHeight = '16px' - return style + it('ignores clipboard settlement after the row changes or the tree unmounts', async () => { + vi.useFakeTimers() + const pending: (() => void)[] = [] + writeText.mockImplementation(() => new Promise((resolve) => { pending.push(resolve) })) + const view = render() + const rows = screen.getAllByRole('treeitem') + fireEvent.mouseOver(rows[0] as HTMLElement) + fireEvent.click(screen.getByRole('button', { name: 'Copy value' })) + fireEvent.mouseOver(rows[1] as HTMLElement) + await act(async () => { pending[0]!() }) + expect(screen.queryByRole('button', { name: 'Copied' })).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Copy value' })) + view.unmount() + const timerCount = vi.getTimerCount() + await act(async () => { pending[1]!() }) + expect(vi.getTimerCount()).toBe(timerCount) + }) + + it('updates copy actions without rereading JSON properties on hover', () => { + const readValue = vi.fn(() => 'payload '.repeat(100)) + const data = Object.fromEntries(Array.from({ length: 500 }, (_, index) => [ + `field${index}`, + { get value() { return readValue() } }, + ])) + render() + const rows = within(screen.getByRole('tree')).getAllByRole('treeitem') + readValue.mockClear() + + fireEvent.mouseOver(rows[0] as HTMLElement) + expect(within(rows[0] as HTMLElement).getByRole('button', { name: 'Copy pretty JSON' })).toBeTruthy() + fireEvent.mouseOver(rows[1] as HTMLElement) + expect(within(rows[0] as HTMLElement).queryByRole('button', { name: 'Copy pretty JSON' })).toBeNull() + expect(within(rows[1] as HTMLElement).getByRole('button', { name: 'Copy pretty JSON' })).toBeTruthy() + expect(readValue).not.toHaveBeenCalled() + }) + + it('expands raw strings without ResizeObserver and keeps the visible viewport limit', () => { + stubStringLayout() + vi.stubGlobal('ResizeObserver', undefined) + let rawTop = 150 + const view = render( +
+ +
, + ) + const clip = view.container.firstElementChild as HTMLElement + vi.spyOn(clip, 'clientTop', 'get').mockReturnValue(2) + vi.spyOn(clip, 'clientHeight', 'get').mockReturnValue(140) + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + return new DOMRect(0, this === clip ? 100 : rawTop, 200, 140) }) + const rows = within(screen.getByRole('tree')).getAllByRole('treeitem') + fireEvent.click(within(rows[0] as HTMLElement).getByRole('button', { name: 'Expand JSON node' })) + const raw = rows[0]?.querySelector('pre') as HTMLPreElement + expect(raw.textContent).toBe('raw\ntext') + expect(raw.style.maxHeight).toBe('78px') + expect(raw.nextElementSibling?.textContent).toBe(',') + + rawTop = 80 + fireEvent.scroll(clip) + expect(raw.style.maxHeight).toBe('126px') + rawTop = 300 + fireEvent.resize(window) + expect(raw.style.maxHeight).toBe('16px') + fireEvent.click(within(rows[0] as HTMLElement).getByRole('button', { name: 'Collapse JSON node' })) + expect(raw.isConnected).toBe(false) + rawTop = 100 + fireEvent.scroll(clip) + expect(raw.style.maxHeight).toBe('16px') + + fireEvent.click(within(rows[1] as HTMLElement).getByRole('button', { name: 'Expand JSON node' })) + expect(rows[1]?.querySelector('pre')?.nextElementSibling?.textContent).not.toBe(',') + }) + + it('shows the string expander only beyond the configured collapsed line count', () => { + stubStringLayout(48) + let resize: (() => void) | undefined + const disconnect = vi.fn() + vi.stubGlobal('ResizeObserver', class { + constructor(callback: () => void) { resize = callback } + observe() {} + disconnect = disconnect + }) + const data = { text: 'three lines of text' } + const view = render() + expect(screen.queryByRole('button', { name: 'Expand JSON node' })).toBeNull() + view.rerender() + expect(screen.getByRole('button', { name: 'Expand JSON node' })).toBeTruthy() + vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockReturnValue(16) + act(() => { resize?.() }) + expect(screen.queryByRole('button', { name: 'Expand JSON node' })).toBeNull() + view.unmount() + expect(disconnect).toHaveBeenCalledTimes(2) + }) + + it('keeps raw strings intact and samples the wrapping preference on every expansion', async () => { + stubStringLayout() vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} From 2bc1a7e347174c2bf14bc8d0e661f150ff101a3e Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 9 Sep 2026 19:52:42 +0800 Subject: [PATCH 39/79] test(trajectory): pin thinking defaults when switching records --- .../ui-trajectory/tests/table.client.spec.tsx | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/client/ui-trajectory/tests/table.client.spec.tsx b/packages/client/ui-trajectory/tests/table.client.spec.tsx index 9723935bd4..5a882141bb 100644 --- a/packages/client/ui-trajectory/tests/table.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/table.client.spec.tsx @@ -273,6 +273,26 @@ describe('TrajectoryTable', () => { expect(screen.getByRole('button', { name: 'Thinking' }).getAttribute('aria-expanded')).toBe('true') }) + it('opens thinking on another record after collapsing the selected record', () => { + const turns: readonly TrajectoryTurnModel[] = [{ + turn: 1, + groups: [{ + title: 'Step 1', + cells: [1, 2].map(index => ({ + index, kind: 'message', text: `Answer ${index}`, outputDetail: `Answer ${index}`, + thinkingDetail: `Reasoning ${index}`, timeSeconds: 1, + })), + }], + }] + render() + fireEvent.click(screen.getByRole('row', { name: /ASSISTANT, Answer 1/ })) + fireEvent.click(screen.getByRole('button', { name: 'Thinking' })) + expect(screen.getByRole('button', { name: 'Thinking' }).getAttribute('aria-expanded')).toBe('false') + fireEvent.click(screen.getByRole('row', { name: /ASSISTANT, Answer 2/ })) + expect(screen.getByRole('button', { name: 'Thinking' }).getAttribute('aria-expanded')).toBe('true') + expect(screen.getByText('Reasoning 2')).toBeTruthy() + }) + it('keeps raw HTML tags in a Markdown-derived context preview', () => { const html = [ '', From b6858789f0513079fcd16fbbe4bfe6348e489076 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 9 Sep 2026 19:52:42 +0800 Subject: [PATCH 40/79] fix(trajectory): identify PTC calls from recorded tool metadata --- ...9-ptc-trajectory-code-inspection.i18n.yaml | 4 +-- ...26-09-09-ptc-trajectory-code-inspection.md | 4 +-- ...09-09-ptc-trajectory-code-inspection.zh.md | 4 +-- .../client/ui-trajectory/README.i18n.yaml | 4 +-- packages/client/ui-trajectory/README.md | 2 +- packages/client/ui-trajectory/README.zh.md | 2 +- .../src/client/TrajectoryCell.tsx | 1 + .../src/client/TrajectoryTable.tsx | 30 ++++++++++--------- .../ui-trajectory/src/client/code-program.ts | 7 ++++- .../client/ui-trajectory/src/client/layout.ts | 3 +- .../src/client/trajectory-record.ts | 2 ++ .../tests/code-inspector.client.spec.tsx | 2 +- .../tests/code-program.client.spec.ts | 14 +++++++-- 13 files changed, 50 insertions(+), 29 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.i18n.yaml b/.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.i18n.yaml index d273d9ed77..c9f4dad8fa 100644 --- a/.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.md -2026-09-09-ptc-trajectory-code-inspection.md: 55cbbae97371b69b7574ebb0674c2178b586b83a -2026-09-09-ptc-trajectory-code-inspection.zh.md: 94b6993a4e4e2d1f3e93256f79a68121f910169d +2026-09-09-ptc-trajectory-code-inspection.md: 477d199f519b5d515e5d58430bd902d9d209e46b +2026-09-09-ptc-trajectory-code-inspection.zh.md: f305569176214c63ac549b0ec5103289e3e57a88 diff --git a/.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.md b/.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.md index 55cbbae973..477d199f51 100644 --- a/.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.md +++ b/.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.md @@ -10,7 +10,7 @@ PTC programs arrive as JSON string arguments. Escaping makes long programs diffi ## Decision -[Trajectory](../../../../packages/client/ui-trajectory/README.md) derives a code inspector from recorded `run_code` arguments. It preserves source bytes for copying and exposes the original JSON through a separate toggle. Syntax highlighting requires an unambiguous TypeScript or Python hint in that call's recorded parameter description; unknown or conflicting hints leave plain text. Unsupported arguments retain the generic inspector. +[Trajectory](../../../../packages/client/ui-trajectory/README.md) identifies calls by their recorded tool name and derives a code inspector from validated `run_code` arguments. The validated program carries the original JSON text; copying preserves source and argument bytes and exposes the original JSON through a separate toggle. Syntax highlighting requires an unambiguous TypeScript or Python hint in that call's recorded parameter description; unknown or conflicting hints leave plain text. Unsupported arguments retain the generic inspector. The result view preserves recorded text and uses a tree only for complete JSON objects or arrays. Errors retain captured output. Code views sample the shared wrapping preference when opened and keep their own choice while mounted. @@ -26,4 +26,4 @@ The [PTC runtime decision](2026-06-15-ptc.md) still owns execution and settlemen ## Consequences -Readers can inspect and copy recorded programs without changing replay data. Schemas with no recognizable language hint receive no syntax highlighting. Existing component tests cover fallback, exact copying, output states, and independent wrapping; the [PTC browser scenario](../../../../apps/web/tests/ptc-round.e2e.ts) pins the assembled inspector and verifies overflow and the original-JSON round trip. +Readers can inspect and copy recorded programs without changing replay data. Schemas with no recognizable language hint receive no syntax highlighting. Component tests cover recorded-name recognition, schema fallback, exact source and argument copying, output states, and independent wrapping. JSON-tree tests cover clipping geometry, missing `ResizeObserver`, clipboard settlement after hover changes or unmount, and value-read counts during hover. Thinking tests cover body arrival, manual disclosure, and switching records; the [PTC browser scenario](../../../../apps/web/tests/ptc-round.e2e.ts) pins the assembled inspector and verifies overflow and the original-JSON round trip. diff --git a/.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.zh.md b/.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.zh.md index 94b6993a4e..f305569176 100644 --- a/.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.zh.md +++ b/.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.zh.md @@ -10,7 +10,7 @@ PTC 程序以 JSON 字符串参数传入。转义使长程序在通用参数树 ## 决策 -[Trajectory](../../../../packages/client/ui-trajectory/README.zh.md) 从已记录的 `run_code` 参数派生代码检查器。复制时保留源码字节,并通过独立切换按钮展示原始 JSON。语法高亮要求该调用记录的参数说明包含明确且无冲突的 TypeScript 或 Python 提示;未知或冲突的提示使用纯文本。不支持的参数保留通用检查器。 +[Trajectory](../../../../packages/client/ui-trajectory/README.zh.md) 按已记录的工具名识别调用,并从已验证的 `run_code` 参数派生代码检查器。验证后的程序携带原始 JSON 文本;复制时保留源码与参数字节,并通过独立切换按钮展示原始 JSON。语法高亮要求该调用记录的参数说明包含明确且无冲突的 TypeScript 或 Python 提示;未知或冲突的提示使用纯文本。不支持的参数保留通用检查器。 结果视图保留记录的文本,只对完整 JSON 对象或数组使用树形展示。错误保留已捕获的输出。代码视图在打开时读取共享换行偏好,并在挂载期间保留自身的选择。 @@ -26,4 +26,4 @@ PTC 程序以 JSON 字符串参数传入。转义使长程序在通用参数树 ## 后果 -读者可以检查和复制已记录的程序,无需修改回放数据。Schema 没有可识别的语言提示时不提供语法高亮。现有组件测试覆盖回退、原样复制、输出状态与独立换行;[PTC 浏览器场景](../../../../apps/web/tests/ptc-round.e2e.ts) 固定组装后的检查器展示,并验证溢出和原始 JSON 的往返切换。 +读者可以检查和复制已记录的程序,无需修改回放数据。Schema 没有可识别的语言提示时不提供语法高亮。组件测试覆盖记录工具名识别、Schema 回退、源码与参数原样复制、输出状态及独立换行。JSON 树测试覆盖裁剪几何、缺少 `ResizeObserver`、悬停切换或卸载后剪贴板写入落定,以及悬停期间读取值的次数。思考测试覆盖正文到达、手动展开折叠及记录切换;[PTC 浏览器场景](../../../../apps/web/tests/ptc-round.e2e.ts) 固定组装后的检查器展示,并验证溢出和原始 JSON 的往返切换。 diff --git a/packages/client/ui-trajectory/README.i18n.yaml b/packages/client/ui-trajectory/README.i18n.yaml index 02a420834f..f969a1bf95 100644 --- a/packages/client/ui-trajectory/README.i18n.yaml +++ b/packages/client/ui-trajectory/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-trajectory/README.md -README.md: 80d8898e0fabd4671581a84198250fe408d51009 -README.zh.md: 5c85faaadb56be6dc3cc85753494c7fd88333c92 +README.md: 6530eb83563620fc26761c421204427c9698732b +README.zh.md: 09ffd59282e407f61126b6e5064cf75740bb52c1 diff --git a/packages/client/ui-trajectory/README.md b/packages/client/ui-trajectory/README.md index 80d8898e0f..6530eb8356 100644 --- a/packages/client/ui-trajectory/README.md +++ b/packages/client/ui-trajectory/README.md @@ -29,7 +29,7 @@ Open the Trajectory tab in the conversation's view ring to inspect agent activit ### Inspecting records -For `run_code`, the row shows the program description and the inspector opens numbered, highlighted source. The Code tab provides wrapping, a `{}` toggle for the original JSON arguments, and exact source copying, including trailing newlines. Each newly opened code view takes the last wrapping choice; changing it leaves other open views as they are. Output preserves the recorded text, using a tree for complete JSON objects or arrays. Highlighting uses only an unambiguous TypeScript or Python hint in the recorded tool schema; missing or conflicting hints leave plain source. See the [PTC inspection decision](../../../.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.md) for replay constraints. +Calls are identified by their recorded tool name. For `run_code`, the row shows the program description and the inspector opens numbered, highlighted source. The Code tab provides wrapping, a `{}` toggle for the original JSON arguments, and exact source copying, including trailing newlines. Copying the original arguments retains their recorded JSON whitespace. Each newly opened code view takes the last wrapping choice; changing it leaves other open views as they are. Output preserves the recorded text, using a tree for complete JSON objects or arrays. Highlighting uses only an unambiguous TypeScript or Python hint in the recorded tool schema; missing or conflicting hints leave plain source. See the [PTC inspection decision](../../../.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.md) for replay constraints. Selection, timeline navigation, folding, and search cover the React-visible window. Request numbers and cumulative usage cover the complete resident snapshot. Selecting a record opens a local inspector for token usage, duration, Input, Output, Timing, and durable images. Image URLs use the Conversation-owned per-session cache, so Chat and Trajectory share one authorized read per attachment. A user record shows the generic-file count beside its text, while a record without text shows its image and file counts. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. diff --git a/packages/client/ui-trajectory/README.zh.md b/packages/client/ui-trajectory/README.zh.md index 5c85faaadb..09ffd59282 100644 --- a/packages/client/ui-trajectory/README.zh.md +++ b/packages/client/ui-trajectory/README.zh.md @@ -29,7 +29,7 @@ Trajectory 标签页让你以按轮次组织的事件记录表和交互式时间 ### 检查记录 -对于 `run_code`,记录行展示程序说明,检查器显示带行号和语法高亮的源码。“代码”页提供换行、查看原始 JSON 参数的 `{}` 切换按钮,以及保留末尾换行的源码原样复制。每个新打开的代码视图采用上次选择的换行状态;切换时,其他已打开视图保持原状。输出保留记录中的文本,完整 JSON 对象或数组使用树形展示。高亮只采用已记录工具 Schema 中明确且无冲突的 TypeScript 或 Python 提示;提示缺失或冲突时显示纯文本源码。回放约束见 [PTC 检查决策](../../../.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.zh.md)。 +调用按已记录的工具名识别。对于 `run_code`,记录行展示程序说明,检查器显示带行号和语法高亮的源码。“代码”页提供换行、查看原始 JSON 参数的 `{}` 切换按钮,以及保留末尾换行的源码原样复制。复制原始参数时保留记录中 JSON 的空白。每个新打开的代码视图采用上次选择的换行状态;切换时,其他已打开视图保持原状。输出保留记录中的文本,完整 JSON 对象或数组使用树形展示。高亮只采用已记录工具 Schema 中明确且无冲突的 TypeScript 或 Python 提示;提示缺失或冲突时显示纯文本源码。回放约束见 [PTC 检查决策](../../../.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.zh.md)。 选择、时间线导航、折叠与搜索只覆盖 React 可见窗口。请求编号与累计用量覆盖完整的驻留 snapshot。选择记录会打开局部检查器,查看 token 用量、耗时、输入、输出、计时与持久图片。图片 URL 使用 Conversation 拥有的逐会话缓存,因此 Chat 与 Trajectory 对每个附件共享一次已授权读取。用户记录会在文本旁显示通用文件数量;记录没有文本时,则显示图片与文件数量。独立运行的压缩请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。 diff --git a/packages/client/ui-trajectory/src/client/TrajectoryCell.tsx b/packages/client/ui-trajectory/src/client/TrajectoryCell.tsx index a25b09833e..bb0b247979 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryCell.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryCell.tsx @@ -57,6 +57,7 @@ export function TrajectoryCell({ assistantMetrics: _assistantMetrics, result: _result, callId: _callId, + toolName: _toolName, isError: _isError, timeSeconds, startedAt: _startedAt, diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index 9d4479bbab..6babf88c4e 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -36,7 +36,7 @@ import type { TrajectoryTurnModel } from './layout.ts' import { trajectoryPreviewText } from './trajectory-preview.ts' import type { TrajectoryKey, TrajectoryTranslate } from './locales.ts' import { COMPACTION_INTERRUPTED_ERROR } from './copy-codes.ts' -import { codeProgram, type CodeProgram } from './code-program.ts' +import { codeProgram, PTC_TOOL_NAME, type CodeProgram } from './code-program.ts' import css from './TrajectoryTable.module.css' const BOTTOM_FOLLOW_THRESHOLD_PX = 2 @@ -190,6 +190,7 @@ interface ParentRecords { } interface ToolCallTextParts { + program: boolean name: string args?: string } @@ -1011,7 +1012,7 @@ function detailTabs(record: TableRecord): readonly DetailTabItem[] { function recordDisplayText(cell: TrajectoryCellProps, t: TrajectoryTranslate): string { if (isToolCallOnly(cell, t)) return '' const program = codeProgram(cell) - if (program !== undefined) return `${cell.text.split(' · ', 1)[0]} · ${program.description.replace(/\s+/g, ' ')}` + if (program !== undefined) return `${PTC_TOOL_NAME} · ${program.description.replace(/\s+/g, ' ')}` if (cell.previewMarkdown !== undefined) { const preview = trajectoryPreviewText(cell.previewMarkdown) if (cell.text === '') return preview @@ -1033,13 +1034,15 @@ function recordResultText(cell: TrajectoryCellProps): string | undefined { } function toolCallTextParts( - kind: TrajectoryCellKind, + cell: TrajectoryCellProps, text: string, ): ToolCallTextParts | undefined { - if (kind !== 'tool' && kind !== 'subtool') return undefined + if (cell.kind !== 'tool' && cell.kind !== 'subtool') return undefined + const program = cell.toolName === PTC_TOOL_NAME const separator = text.indexOf(' · ') - if (separator === -1) return { name: text } + if (separator === -1) return { name: text, program } return { + program, name: text.slice(0, separator), args: text.slice(separator + 3), } @@ -1072,7 +1075,7 @@ function RecordPresentation({ const displayText = useMemo( () => recordDisplayText(cell, t), [ - cell.kind, cell.text, cell.previewMarkdown, + cell.kind, cell.text, cell.toolName, cell.previewMarkdown, cell.inputDetail, cell.outputDetail, cell.thinkingDetail, t, ], ) @@ -1081,7 +1084,7 @@ function RecordPresentation({ [cell.result, cell.resultPreviewMarkdown], ) const toolCallOnly = isToolCallOnly(cell, t) - const toolCallText = toolCallTextParts(cell.kind, displayText) + const toolCallText = toolCallTextParts(cell, displayText) const listDisplayText = toolCallOnly ? t('record.toolCallOnly') : toolCallText === undefined @@ -1111,11 +1114,11 @@ function RecordListText({ return ( <> - {toolCallText.name === 'run_code' && } + {toolCallText.program && } {toolCallText.name || '—'} {toolCallText.args !== undefined && ( - + {toolCallText.args} )} @@ -1840,9 +1843,8 @@ function InspectorCopyButton({ text, label, t }: { ) } -function ProgramInput({ program, rawInput, initialWrapped, stringWrapping, onOpen, t }: { +function ProgramInput({ program, initialWrapped, stringWrapping, onOpen, t }: { program: CodeProgram - rawInput: string initialWrapped: boolean stringWrapping: JsonTreeProps['stringWrapping'] onOpen?: () => void @@ -1891,7 +1893,7 @@ function ProgramInput({ program, rawInput, initialWrapped, stringWrapping, onOpe )} @@ -3350,7 +3352,7 @@ export function TrajectoryTable({ ? ( <> { activateTab('input') }} t={t} /> { activateTab('output') }} t={t} /> @@ -3437,7 +3439,7 @@ export function TrajectoryTable({ selectedProgram === undefined ? : )} {!promptSelected && selected !== undefined && activeTab === 'output' && ( diff --git a/packages/client/ui-trajectory/src/client/code-program.ts b/packages/client/ui-trajectory/src/client/code-program.ts index 98762cdcbd..e444029a20 100644 --- a/packages/client/ui-trajectory/src/client/code-program.ts +++ b/packages/client/ui-trajectory/src/client/code-program.ts @@ -1,8 +1,12 @@ /** Read replayable PTC source and language hints from recorded tool arguments and schemas. */ import type { TrajectoryCellProps } from './trajectory-record.ts' +/** Recorded name of the programmatic tool-calling entry point. */ +export const PTC_TOOL_NAME = 'run_code' + /** Validated source and original arguments for one recorded run_code call. */ export interface CodeProgram { + rawInput: string source: string description: string arguments: Record @@ -42,11 +46,12 @@ function recordedLanguage(schemaRaw: string | undefined): CodeProgram['language' */ export function codeProgram(cell: TrajectoryCellProps): CodeProgram | undefined { if (cell.kind !== 'tool' && cell.kind !== 'subtool') return undefined - if (cell.text.split(' · ', 1)[0] !== 'run_code') return undefined + if (cell.toolName !== PTC_TOOL_NAME || cell.inputDetail === undefined) return undefined const args = parseRecord(cell.inputDetail) if (typeof args?.code !== 'string') return undefined if (args.description !== undefined && typeof args.description !== 'string') return undefined return { + rawInput: cell.inputDetail, source: args.code, description: typeof args.description === 'string' && args.description.trim() !== '' ? args.description diff --git a/packages/client/ui-trajectory/src/client/layout.ts b/packages/client/ui-trajectory/src/client/layout.ts index fd361899d0..7691b7f74a 100644 --- a/packages/client/ui-trajectory/src/client/layout.ts +++ b/packages/client/ui-trajectory/src/client/layout.ts @@ -1062,8 +1062,9 @@ function expandSubCalls( function summarizeCall( name: string, argsRaw: string, -): Pick { +): Pick { return { + toolName: name, text: name, ...(argsRaw === '' ? {} : { previewMarkdown: argsRaw }), } diff --git a/packages/client/ui-trajectory/src/client/trajectory-record.ts b/packages/client/ui-trajectory/src/client/trajectory-record.ts index 2bcb268f22..017143c487 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-record.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-record.ts @@ -80,6 +80,8 @@ export interface TrajectoryCellProps extends HTMLAttributes { resultPreviewMarkdown?: string /** Tool call id used to link message source blocks to tool records. */ callId?: string + /** Recorded tool name; independent of the display summary. */ + toolName?: string /** Tool-only result failure state. */ isError?: boolean /** Own duration in seconds, or `null` when no duration is known. */ diff --git a/packages/client/ui-trajectory/tests/code-inspector.client.spec.tsx b/packages/client/ui-trajectory/tests/code-inspector.client.spec.tsx index 748463b6ea..fab29e0637 100644 --- a/packages/client/ui-trajectory/tests/code-inspector.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/code-inspector.client.spec.tsx @@ -15,7 +15,7 @@ afterEach(() => { const SOURCE = 'const value = await tools.bash({ command: "pwd" })\nreturn value\n' const ARGS = JSON.stringify({ code: SOURCE, description: 'Read the working directory' }) const CELL: TrajectoryCellProps = { - index: 1, kind: 'tool', text: `run_code · ${ARGS}`, callId: 'code-1', + index: 1, kind: 'tool', toolName: 'run_code', text: `run_code · ${ARGS}`, callId: 'code-1', inputDetail: ARGS, outputDetail: 'printed line\n42', result: 'printed line', timeSeconds: 0.5, } diff --git a/packages/client/ui-trajectory/tests/code-program.client.spec.ts b/packages/client/ui-trajectory/tests/code-program.client.spec.ts index c0c96ca044..a9bd21c807 100644 --- a/packages/client/ui-trajectory/tests/code-program.client.spec.ts +++ b/packages/client/ui-trajectory/tests/code-program.client.spec.ts @@ -4,13 +4,14 @@ import { codeProgram } from '../src/client/code-program.ts' import type { TrajectoryCellProps } from '../src/client/trajectory-record.ts' const CELL: TrajectoryCellProps = { - index: 1, kind: 'tool', text: 'run_code · {}', timeSeconds: 1, + index: 1, kind: 'tool', toolName: 'run_code', text: 'run_code · {}', timeSeconds: 1, inputDetail: JSON.stringify({ code: 'return 42\n', description: 'Compute the answer' }), } describe('recorded code programs', () => { it('keeps the program and arguments verbatim', () => { expect(codeProgram(CELL)).toEqual({ + rawInput: CELL.inputDetail, source: 'return 42\n', description: 'Compute the answer', arguments: { code: 'return 42\n', description: 'Compute the answer' }, language: undefined, }) @@ -52,9 +53,18 @@ describe('recorded code programs', () => { }, ) + it('uses recorded tool metadata independently of display text', () => { + expect(codeProgram({ ...CELL, text: 'Execute program' })?.source).toBe('return 42\n') + const withoutName = { ...CELL } + delete withoutName.toolName + expect(codeProgram(withoutName)).toBeUndefined() + const rawInput = ' { "code": "return 42" }\n' + expect(codeProgram({ ...CELL, inputDetail: rawInput })?.rawInput).toBe(rawInput) + }) + it('does not specialize other tools or non-tool records', () => { expect(codeProgram({ ...CELL, kind: 'message' })).toBeUndefined() - expect(codeProgram({ ...CELL, text: 'run_code_extra · {}' })).toBeUndefined() + expect(codeProgram({ ...CELL, toolName: 'run_code_extra' })).toBeUndefined() expect(codeProgram({ ...CELL, kind: 'subtool' })?.source).toBe('return 42\n') }) }) From 29c09d320fd9d2325c034ed1bcc15bc01354486c Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 9 Sep 2026 19:52:42 +0800 Subject: [PATCH 41/79] refactor(client): remove unused JSON disclosure locale keys --- packages/client/locale/src/locales/en.ts | 2 -- packages/client/locale/src/locales/zh.ts | 2 -- 2 files changed, 4 deletions(-) diff --git a/packages/client/locale/src/locales/en.ts b/packages/client/locale/src/locales/en.ts index 70ce31598c..d16b4913ae 100644 --- a/packages/client/locale/src/locales/en.ts +++ b/packages/client/locale/src/locales/en.ts @@ -34,8 +34,6 @@ export const en = { 'unknown': 'Unknown', 'none': 'None', 'truncated': 'Truncated', - 'json.collapseNode': 'Collapse JSON node', - 'json.expandNode': 'Expand JSON node', 'json.label': 'JSON', 'markdown.footnotes': 'Footnotes', 'markdown.truncatedCharacters': '… truncated at {total} characters', diff --git a/packages/client/locale/src/locales/zh.ts b/packages/client/locale/src/locales/zh.ts index 30cee308d5..2e932cef41 100644 --- a/packages/client/locale/src/locales/zh.ts +++ b/packages/client/locale/src/locales/zh.ts @@ -32,8 +32,6 @@ export const zh = { 'unknown': '未知', 'none': '无', 'truncated': '已截断', - 'json.collapseNode': '收起 JSON 节点', - 'json.expandNode': '展开 JSON 节点', 'json.label': 'JSON', 'markdown.footnotes': '脚注', 'markdown.truncatedCharacters': '… 已截断,共 {total} 字符', From d28625c6d5397cac5046d08eb3440348b7cd6c56 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 9 Sep 2026 19:52:55 +0800 Subject: [PATCH 42/79] fix(web): restore terminal snapshots across host and sidebar lifecycles --- .../2026-09-09-web-sidebar-terminal.i18n.yaml | 4 +- .../2026-09-09-web-sidebar-terminal.md | 4 +- .../2026-09-09-web-sidebar-terminal.zh.md | 4 +- apps/web/package.json | 3 +- .../tests/details-session-lifecycle.e2e.ts | 3 + apps/web/tests/document-preview.e2e.ts | 1 + apps/web/tests/sidebar-terminal.e2e.ts | 83 +++++++++++-------- docs/subsystems/workspace.i18n.yaml | 4 +- docs/subsystems/workspace.md | 8 +- docs/subsystems/workspace.zh.md | 8 +- .../api/terminal-controller/README.i18n.yaml | 4 +- packages/api/terminal-controller/README.md | 2 +- packages/api/terminal-controller/README.zh.md | 2 +- packages/api/terminal-controller/src/index.ts | 8 +- .../tests/controller.spec.ts | 42 +++++----- .../client/ui-sidebar-right/README.i18n.yaml | 4 +- packages/client/ui-sidebar-right/README.md | 6 +- packages/client/ui-sidebar-right/README.zh.md | 4 +- packages/client/ui-sidebar-right/package.json | 3 +- .../ui-sidebar-right/src/client/service.ts | 3 +- .../src/client/tab-registry.ts | 4 +- .../src/client/tabs/guide/GuideBody.tsx | 5 +- .../tests/guide-body.client.spec.tsx | 5 -- .../tests/service.client.spec.ts | 19 +++++ .../ui-sidebar-terminal/src/client/index.ts | 6 +- .../tests/apply.client.spec.ts | 7 +- .../extensions/tool-cordis/src/api-catalog.ts | 6 +- pnpm-lock.yaml | 6 ++ 28 files changed, 150 insertions(+), 108 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml index d976a5b494..4151a21804 100644 --- a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md -2026-09-09-web-sidebar-terminal.md: 4339e6aa0bbb28383a9f2b42336965d44b1a0e07 -2026-09-09-web-sidebar-terminal.zh.md: aec1286b0d6e594813735acfd06dbaf34f261972 +2026-09-09-web-sidebar-terminal.md: d95968712e7f99b8e83d020273bb02818b4b2824 +2026-09-09-web-sidebar-terminal.zh.md: 2b8ed2fa1317dce510e01d840acd3b99f40eaccf diff --git a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md index 4339e6aa0b..d95968712e 100644 --- a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md +++ b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md @@ -10,11 +10,11 @@ Web users need an interactive shell beside a Session to inspect the workspace an ## Decision -`api-terminal-controller` owns user terminals by Session and exposes the `terminal` Remote namespace. `ui-sidebar-terminal` registers native right-sidebar tabs, xterm.js rendering and FitAddon sizing. A new terminal starts the execution environment's default shell immediately. The existing sidebar controls open additional tabs; double-clicking a tab title renames its terminal. Terminal processes use the composed subprocess provider and Session sandbox policy. Shell resolution occurs only during creation; reading limits and reconnecting an existing process do not depend on the default executable remaining available. Interactive shell configuration supplies Tab completion and optional inline suggestions. +`api-terminal-controller` owns user terminals by Session and exposes the `terminal` Remote namespace. `ui-sidebar-terminal` registers native right-sidebar tabs, xterm.js rendering and FitAddon sizing. A new terminal starts the execution environment's default shell immediately. The terminal type declares independent instances, so ordinary page deduplication cannot collapse separate processes when opening or docking tabs. The existing sidebar controls open additional tabs; double-clicking a tab title renames its terminal. Terminal processes use the composed subprocess provider and Session sandbox policy. Shell resolution occurs only during creation; reading limits and reconnecting an existing process do not depend on the default executable remaining available. Interactive shell configuration supplies Tab completion and optional inline suggestions. Close and replacement remove the tab synchronously and run process cleanup in the background. The Client first records the unfinished close request under a terminal-specific localStorage key; success removes it, and startup retries requests that remain. A cleanup failure produces a lightweight notification with a retry action without reopening the tab. Independent keys prevent another window from overwriting unrelated cleanup requests. Collapse, tab/Session switching, floating, fullscreen and browser disconnection preserve the process. Component cleanup and `TabDomain.signal` only detach browser work because the same lifetime can end during plugin reload. Failed process cleanup retains ownership, including failures after allocation but before create publication. Session owner disposal and Host plugin disposal also clean up terminals. A definitive missing-Session response retires its saved close request because the Session owns process cleanup; transport failures remain retryable. Client plugin disposal awaits every detached stream so a replacement plugin does not inherit unfinished Client cleanup. -Sidebar layout, open-tab mappings, selection and process PIDs are not persisted. When the Session header mounts, the Client queries `terminal.list` and opens retained Host terminals as new tabs. Their `params.terminalId` association exists only in the current page. New and recovered views use different `createWhenMissing` values: only a new view may allocate a process; a recovered target that disappears reports an error. Host state supplies recovery identities and titles, so the browser does not maintain a second active-terminal registry. +Sidebar layout, open-tab mappings, selection and process PIDs are not persisted. When the Session header mounts, the Client queries `terminal.list` and opens retained Host terminals as new tabs. Listing takes the Session ID directly because history can outlive its Agent and terminal owner; an offline Session has no retained terminals to restore. Their `params.terminalId` association exists only in the current page. New and recovered views use different `createWhenMissing` values: only a new view may allocate a process; a recovered target that disappears reports an error. Host state supplies recovery identities and titles, so the browser does not maintain a second active-terminal registry. The caller retains a terminal ID in memory before creating it. Repeating create with the same Session and open ID does not allocate a second process. Closing an uncertain create uses that ID even when no creation response arrived. The Host records closed IDs before awaiting allocation, preventing a delayed create from reviving a closed terminal. Each attachment begins with a consistent, bounded serialized xterm screen; ordered output follows through the Gateway's existing multiplexed Remote stream. Output and screen snapshots share one operation queue. Followers retain final output on normal closure and fail explicitly on buffer overflow. Browser render acknowledgement prevents React batching from dropping increments. diff --git a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md index aec1286b0d..2b8ed2fa13 100644 --- a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md +++ b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md @@ -10,11 +10,11 @@ Web 用户需要在 Session 旁使用交互式 shell 检查工作区和运行命 ## 决定 -`api-terminal-controller` 按 Session 管理用户终端并提供 `terminal` Remote namespace。`ui-sidebar-terminal` 注册原生右侧栏标签页,使用 xterm.js 渲染和 FitAddon 测量尺寸。新终端立即启动执行环境的默认 shell。已有侧栏控件负责打开更多标签页,双击标签页标题可重命名终端。终端进程使用组合的 subprocess provider 和 Session sandbox policy。shell 只在创建时解析;读取限制和重新连接已有进程不依赖默认可执行文件仍然可用。交互式 shell 配置提供 Tab 补全和可选的内联建议。 +`api-terminal-controller` 按 Session 管理用户终端并提供 `terminal` Remote namespace。`ui-sidebar-terminal` 注册原生右侧栏标签页,使用 xterm.js 渲染和 FitAddon 测量尺寸。新终端立即启动执行环境的默认 shell。终端类型声明独立实例,因此打开或停靠标签页时,普通页面的去重规则不会合并不同进程。已有侧栏控件负责打开更多标签页,双击标签页标题可重命名终端。终端进程使用组合的 subprocess provider 和 Session sandbox policy。shell 只在创建时解析;读取限制和重新连接已有进程不依赖默认可执行文件仍然可用。交互式 shell 配置提供 Tab 补全和可选的内联建议。 关闭和替换会同步移除标签页,并在后台清理进程。Client 先以终端独立的 localStorage key 保存未完成的关闭请求;成功后删除,启动时重试剩余请求。清理失败时显示带重试操作的轻量通知,不重新打开标签页。独立 key 避免其他窗口覆盖无关的清理请求。折叠、切换标签页或 Session、浮动、全屏和浏览器断线均保留进程。组件清理和 `TabDomain.signal` 只停止浏览器工作,因为插件重新加载也会结束这些生命周期。进程清理失败时保留所有权,包括分配完成但 create 尚未发布时的失败。Session owner 和 Host 插件卸载也会清理终端。 明确的 Session 不存在响应会清除已保存的关闭请求,因为进程清理由 Session 负责;传输失败仍可重试。Client 插件卸载等待所有断开的流结束,避免替换插件继承未完成的 Client 清理。 -侧栏布局、打开标签页映射、选中项和进程 PID 不持久化。Session header 挂载时,Client 查询 `terminal.list`,把 Host 保留的终端打开为新标签页。`params.terminalId` 关联只在当前页面中保留。新视图与恢复视图使用不同的 `createWhenMissing`:只有新视图可以分配进程,恢复目标消失时显示错误。恢复标识和标题来自 Host 状态,浏览器不维护第二份活跃终端注册表。 +侧栏布局、打开标签页映射、选中项和进程 PID 不持久化。Session header 挂载时,Client 查询 `terminal.list`,把 Host 保留的终端打开为新标签页。列表直接使用 Session ID,因为历史记录可以比 Agent 和终端 owner 存活更久;离线 Session 没有需要恢复的保留终端。`params.terminalId` 关联只在当前页面中保留。新视图与恢复视图使用不同的 `createWhenMissing`:只有新视图可以分配进程,恢复目标消失时显示错误。恢复标识和标题来自 Host 状态,浏览器不维护第二份活跃终端注册表。 调用者在创建之前把终端 ID 保留在内存中。相同 Session 和未关闭 ID 的重复 create 不再分配进程。创建结果不确定时,关闭仍使用该 ID,即使没有收到创建响应。Host 在等待分配完成前记录已关闭 ID,防止迟到的 create 复活已关闭终端。每次连接先接收一致、有界的 xterm 序列化屏幕,后续有序输出使用 Gateway 已有的复用 Remote stream。输出和屏幕快照共享操作队列。订阅者正常关闭时保留末尾输出,缓存超限时明确失败。浏览器在完成渲染后确认帧,避免 React 批处理丢失增量。 diff --git a/apps/web/package.json b/apps/web/package.json index 6c6dbc31aa..0a59473f65 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -55,6 +55,7 @@ "vite": "^6.0.0", "vitest": "^4.1.8", "ws": "8.21.0", - "@deepseek-ai/dsh-launch-environment": "workspace:^" + "@deepseek-ai/dsh-launch-environment": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^" } } diff --git a/apps/web/tests/details-session-lifecycle.e2e.ts b/apps/web/tests/details-session-lifecycle.e2e.ts index 14245fafa0..a02bafac00 100644 --- a/apps/web/tests/details-session-lifecycle.e2e.ts +++ b/apps/web/tests/details-session-lifecycle.e2e.ts @@ -243,6 +243,7 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S await select(original, 'LIGHTHOUSE') await open() + await column.locator('[data-sidebar-right-guide-entry="files"]').click() // The content-box panel adds its one rendered border pixel outside the // CSS width assigned by the grid solver. await expect.poll(() => sidebarSnapshot(page), { timeout: 5_000 }) @@ -256,6 +257,7 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S await expect.poll(() => split.isDisabled()).toBe(false) await split.click() await expect.poll(() => panes.count()).toBe(2) + await panes.last().locator('[data-sidebar-right-guide-entry="files"]').click() await panes.first().locator('[data-dockkit-tab]').filter({ hasText: 'Files' }).click() await expect.poll(() => panes.first().locator('[data-files-state="tree"]').count()).toBe(1) const retainedA = await paneSnapshot(page) @@ -278,6 +280,7 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S await expect.poll(() => detailsTrack(page)).toBe(0) await open() expect(await panel.getAttribute('data-sidebar-right-panel')).toBe('push') + await column.locator('[data-sidebar-right-guide-entry="files"]').click() await column.locator('[data-files-state="tree"]').waitFor({ timeout: 15_000 }) const workspaceDirectory = column.locator('[data-files-entry="directory"] > button').filter({ hasText: /^workspace$/ }) await workspaceDirectory.waitFor({ timeout: 15_000 }) diff --git a/apps/web/tests/document-preview.e2e.ts b/apps/web/tests/document-preview.e2e.ts index 58b9913ce6..a15ec15e31 100644 --- a/apps/web/tests/document-preview.e2e.ts +++ b/apps/web/tests/document-preview.e2e.ts @@ -123,6 +123,7 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () const column = page.locator('[data-rightbar-col]') await page.locator('[data-sidebar-right-expand]').click() + await column.locator('[data-sidebar-right-guide-entry="files"]').click() await column.locator('[data-files-state="tree"]').waitFor({ state: 'visible' }) await column.locator('[data-files-reload]').click() const filesTab = column.locator('[data-dockkit-tab]').filter({ has: page.getByText('Files', { exact: true }) }) diff --git a/apps/web/tests/sidebar-terminal.e2e.ts b/apps/web/tests/sidebar-terminal.e2e.ts index ea79df7ec2..73030f48be 100644 --- a/apps/web/tests/sidebar-terminal.e2e.ts +++ b/apps/web/tests/sidebar-terminal.e2e.ts @@ -1,11 +1,12 @@ /** Shipped sidebar terminal over the real Loader, Remote mux, Chromium and local PTY. */ import { mkdir } from 'node:fs/promises' -import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { chromium, type Browser, type Page } from 'playwright' -import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, onTestFailed, vi } from 'vitest' import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-api-terminal-controller' +import type { SubprocessTerminalHandle } from '@deepseek-ai/dsh-subprocess' +import { createProcessInspector, type ProcessIdentity } from '@deepseek-ai/dsh-subprocess-local/src/process-inspector.ts' import { compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts' import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' @@ -27,29 +28,23 @@ async function command(page: Page, text: string): Promise { await page.keyboard.press('Enter') } -function alive(pid: number): boolean { - try { - process.kill(pid, 0) - // A container's init may retain a reparented zombie; it cannot run after terminal cleanup. - if (process.platform === 'linux') { - const stat = readFileSync(`/proc/${pid}/stat`, 'utf8') - const state = stat.slice(stat.lastIndexOf(')') + 2, stat.lastIndexOf(')') + 3) - return state !== 'Z' && state !== 'X' - } - return true - } catch (error) { - if (['ESRCH', 'ENOENT'].includes((error as NodeJS.ErrnoException).code ?? '')) return false - throw error - } -} - describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => { let scaffold: WebScaffold let browser: Browser let page: Page let tripwire: ReturnType - beforeAll(async () => { + let handles: SubprocessTerminalHandle[] + const inspector = createProcessInspector() + const alive = (identity: ProcessIdentity) => inspector.isAlive(identity) + const processIdentity = (index: number): ProcessIdentity => { + const pid = handles[index]!.pid + const identity = inspector.snapshot().tree(pid).find(member => member.pid === pid) + if (identity === undefined) throw new Error(`Terminal process ${pid} is missing`) + return identity + } + + beforeEach(async () => { scaffold = await launchWebScaffold({ extraOverlayPath: fileURLToPath(new URL('./fixtures/sidebar-terminal.patch.yml', import.meta.url)) }) browser = await chromium.launch() page = await newEnglishPage(browser) @@ -59,6 +54,15 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => { await connectFreshWorkspace(page, scaffold.workspaceCwd) const agent = scaffold.ctx.agents.list()[0] if (agent === undefined) throw new Error('Workspace did not create a Session') + handles = [] + const subprocess = agent.ctx.get('subprocess') + if (subprocess === undefined) throw new Error('Session subprocess provider is missing') + const spawn = subprocess.spawnTerminal.bind(subprocess) + vi.spyOn(subprocess, 'spawnTerminal').mockImplementation(async (spec) => { + const handle = await spawn(spec) + handles.push(handle) + return handle + }) agent.session.append('turn/start', { turn: 1 }) agent.session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'Open a terminal.' }], source: { kind: 'user' } }), { surfaceOp: 'append' }) agent.session.append('step/start', { turn: 1, step: 1 }) @@ -70,7 +74,11 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => { await mkdir(shots, { recursive: true }) }, 180_000) - afterAll(async () => { await browser?.close(); await scaffold?.close() }) + afterEach(async () => { + try { await browser?.close() } finally { + try { await scaffold?.close() } finally { vi.restoreAllMocks() } + } + }) it('completes commands, preserves the process through collapse and reload, resizes, and kills on tab close', async () => { onTestFailed(() => saveFailureShot(page, 'sidebar-terminal')) @@ -84,7 +92,8 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => { await command(page, "printf 'DSH_PID:%s\\n' \"$$\"") await expect.poll(async () => await screen.innerText()).toMatch(/DSH_PID:\d+/u) const pid = Number((await screen.innerText()).match(/DSH_PID:(\d+)/u)?.[1]) - expect(alive(pid)).toBe(true) + const firstProcess = processIdentity(0) + expect(alive(firstProcess)).toBe(true) await command(page, 'dsh_terminal_completion_probe(){ printf "completed_from_shell\\n"; }') await page.keyboard.press('Control+l') await page.keyboard.insertText('dsh_terminal_completion_pro') @@ -101,14 +110,16 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => { await command(page, "printf 'SECOND_PID:%s\\n' \"$$\"") await expect.poll(async () => await screen.innerText()).toMatch(/SECOND_PID:\d+/u) const secondPid = Number((await screen.innerText()).match(/SECOND_PID:(\d+)/u)?.[1]) - expect(secondPid).not.toBe(pid) + // Shell PIDs belong to the sandbox namespace; process liveness uses Host identities. + const secondProcess = processIdentity(1) + expect(secondProcess.pid).not.toBe(firstProcess.pid) await page.locator('[data-dockkit-tab]').filter({ hasText: 'Development' }).click() await expect.poll(async () => await screen.innerText()).toContain('PERSIST:xterm-256color') - expect(alive(secondPid)).toBe(true) + expect(alive(secondProcess)).toBe(true) await page.getByRole('button', { name: 'Collapse right sidebar', exact: true }).click() - expect(alive(pid)).toBe(true) + expect(alive(firstProcess)).toBe(true) await page.locator('[data-sidebar-right-expand]').click() - const terminals = () => scaffold.ctx.terminalController.list(scaffold.ctx.agents.list()[0]!) + const terminals = () => scaffold.ctx.terminalController.list(scaffold.ctx.agents.list()[0]!.id) const dockedCols = terminals()[0]!.cols await page.getByRole('button', { name: 'Fullscreen', exact: true }).click() await expect.poll(() => terminals()[0]!.cols).toBeGreaterThan(dockedCols) @@ -117,36 +128,38 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => { await page.screenshot({ path: `${shots}/fullscreen.png`, fullPage: true }) await page.reload({ waitUntil: 'load' }) await page.locator('[data-dockkit-tab]').filter({ hasText: 'Development' }).waitFor({ timeout: 15_000 }) - await expect.poll(async () => await page.locator('[data-dockkit-tab-title]').allInnerTexts()).toEqual(['Start', 'Development', 'bash']) + await expect.poll(async () => await page.locator('[data-dockkit-tab-title]').allInnerTexts()).toEqual(['Development', 'bash']) expect(terminals()).toHaveLength(2) - expect(alive(pid)).toBe(true) - expect(alive(secondPid)).toBe(true) + expect(alive(firstProcess)).toBe(true) + expect(alive(secondProcess)).toBe(true) await expect.poll(async () => await screen.innerText()).toContain(`SECOND_PID:${secondPid}`) const secondTab = page.locator('[data-dockkit-tab]').filter({ hasText: 'bash' }) await secondTab.hover() await secondTab.locator('[data-dockkit-tab-close]').click() await expect.poll(async () => await secondTab.count()).toBe(0) - await expect.poll(() => alive(secondPid), { timeout: 10_000 }).toBe(false) + await expect.poll(() => alive(secondProcess), { timeout: 10_000 }).toBe(false) await expect.poll(async () => await screen.innerText()).toContain('PERSIST:xterm-256color') await command(page, "printf 'RECOVERED_PID:%s\\n' \"$$\"") await expect.poll(async () => await screen.innerText()).toContain(`RECOVERED_PID:${pid}`) await page.screenshot({ path: `${shots}/recovered.png`, fullPage: true }) + const previousMembers = new Set(inspector.snapshot().tree(firstProcess.pid).map(member => member.pid)) await command(page, "sleep 120 & printf 'CHILD_PID:%s\\n' $!") await expect.poll(async () => await screen.innerText()).toMatch(/CHILD_PID:\d+/u) - const childPid = Number((await screen.innerText()).match(/CHILD_PID:(\d+)/u)?.[1]) - expect(alive(childPid)).toBe(true) + const descendants = inspector.snapshot().tree(firstProcess.pid).filter(member => member.pid !== firstProcess.pid) + expect(descendants.some(member => !previousMembers.has(member.pid))).toBe(true) + expect(descendants.every(alive)).toBe(true) const tab = page.locator('[data-dockkit-tab]').filter({ hasText: 'Development' }) await tab.hover() await tab.locator('[data-dockkit-tab-close]').click() - await expect.poll(() => alive(pid), { timeout: 10_000 }).toBe(false) - await expect.poll(() => alive(childPid), { timeout: 10_000 }).toBe(false) - await expect.poll(() => scaffold.ctx.terminalController.list(scaffold.ctx.agents.list()[0]!).length).toBe(0) + await expect.poll(() => alive(firstProcess), { timeout: 10_000 }).toBe(false) + await expect.poll(() => descendants.some(alive), { timeout: 10_000 }).toBe(false) + await expect.poll(() => scaffold.ctx.terminalController.list(scaffold.ctx.agents.list()[0]!.id).length).toBe(0) expect(tripwire.pageErrors).toEqual([]) }) it('explains that exited terminals count toward the quota and permits creation after closing one', async () => { onTestFailed(() => saveFailureShot(page, 'sidebar-terminal-quota')) - const terminals = () => scaffold.ctx.terminalController.list(scaffold.ctx.agents.list()[0]!) + const terminals = () => scaffold.ctx.terminalController.list(scaffold.ctx.agents.list()[0]!.id) for (let count = 1; count <= 2; count++) { await openTerminal(page) await command(page, 'exit') diff --git a/docs/subsystems/workspace.i18n.yaml b/docs/subsystems/workspace.i18n.yaml index e50df6993c..7c7d1973a1 100644 --- a/docs/subsystems/workspace.i18n.yaml +++ b/docs/subsystems/workspace.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/workspace.md -workspace.md: 94031d6803f5de3a4ddb3e3654d40bebe74a92cc -workspace.zh.md: 2b131f9bfd51ecf63bd11b6e91ed181bc126ab94 +workspace.md: b0c194e7c0a8134a126bfd3a786615d67245e5d3 +workspace.zh.md: db4c1284a5481e48425abd004b4c7ea4db3ce742 diff --git a/docs/subsystems/workspace.md b/docs/subsystems/workspace.md index 94031d6803..b0c194e7c0 100644 --- a/docs/subsystems/workspace.md +++ b/docs/subsystems/workspace.md @@ -199,11 +199,11 @@ Typed Remote control of transient Session-owned terminal processes. @Remote environment(agent: Agent, signal: AbortSignal): TerminalEnvironment /** - * List running and exited terminals available for reattachment. - * @param agent - Session owner supplied by the Gateway. + * List retained terminals without resolving or activating an Agent. + * @param sessionId - displayed Session identity, including offline history. * @returns terminals retained for this Host lifetime. */ -@Remote list(agent: Agent): WebTerminalInfo[] +@Remote list(sessionId: SessionId): WebTerminalInfo[] /** * Allocate an interactive shell once for a caller-generated identity. @@ -262,7 +262,7 @@ Typed Remote control of transient Session-owned terminal processes. @Remote async close(agent: Agent, id: WebTerminalId): Promise ``` -Types: [Agent](core.md) +Types: [Agent](core.md) · [SessionId](core.md) Source: [`packages/api/terminal-controller/src/index.ts`](../../packages/api/terminal-controller/src/index.ts) diff --git a/docs/subsystems/workspace.zh.md b/docs/subsystems/workspace.zh.md index 2b131f9bfd..db4c1284a5 100644 --- a/docs/subsystems/workspace.zh.md +++ b/docs/subsystems/workspace.zh.md @@ -199,11 +199,11 @@ Typed Remote control of transient Session-owned terminal processes. @Remote environment(agent: Agent, signal: AbortSignal): TerminalEnvironment /** - * List running and exited terminals available for reattachment. - * @param agent - Session owner supplied by the Gateway. + * List retained terminals without resolving or activating an Agent. + * @param sessionId - displayed Session identity, including offline history. * @returns terminals retained for this Host lifetime. */ -@Remote list(agent: Agent): WebTerminalInfo[] +@Remote list(sessionId: SessionId): WebTerminalInfo[] /** * Allocate an interactive shell once for a caller-generated identity. @@ -262,7 +262,7 @@ Typed Remote control of transient Session-owned terminal processes. @Remote async close(agent: Agent, id: WebTerminalId): Promise ``` -Types: [Agent](core.zh.md) +Types: [Agent](core.zh.md) · [SessionId](core.zh.md) Source: [`packages/api/terminal-controller/src/index.ts`](../../packages/api/terminal-controller/src/index.ts) diff --git a/packages/api/terminal-controller/README.i18n.yaml b/packages/api/terminal-controller/README.i18n.yaml index 4ede4369b5..b3c9756b81 100644 --- a/packages/api/terminal-controller/README.i18n.yaml +++ b/packages/api/terminal-controller/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/terminal-controller/README.md -README.md: 63da44832aa6087f43b9d9ecdafc56010f17245e -README.zh.md: abcaa83d41ff525d9e7a60ae49c27636a5c8b411 +README.md: e6ffceff8cd3c16ad7a6d61697dec870bba0fe14 +README.zh.md: 609493cfc0fa127473538bc6ce79e6083f6d8703 diff --git a/packages/api/terminal-controller/README.md b/packages/api/terminal-controller/README.md index 63da44832a..e6ffceff8c 100644 --- a/packages/api/terminal-controller/README.md +++ b/packages/api/terminal-controller/README.md @@ -25,7 +25,7 @@ Open the execution environment's default shell in a Session workspace from the W ## Use this package -The Web bundle mounts this package with the subprocess provider, sandbox policy and Typert Gateway. `remote.terminal` exposes `environment`, `list`, `create`, `follow`, `write`, `resize`, `rename` and `close`; each operation is scoped by Session identity. +The Web bundle mounts this package with the subprocess provider, sandbox policy and Typert Gateway. `remote.terminal` exposes `environment`, `list`, `create`, `follow`, `write`, `resize`, `rename` and `close`; each operation is scoped by Session identity. Listing reads retained Host terminals directly, so viewing an offline Session neither activates an Agent nor produces a recovery error. New terminals use the execution environment's declared default shell. Only when the provider omits that default does resolution use `/bin/sh` on POSIX or `cmd.exe` on Windows. An optional `shell` profile overrides that choice with executable `path`, display `name` and `args` (default `[]`). The provider verifies the executable during creation; resolution failure is reported without trying another shell. Environment lookup returns the working directory and limits without resolving a shell, so an unavailable default does not prevent reattaching to an existing process. Automatic POSIX profiles start interactively, and PowerShell uses `-NoLogo`, so completion and startup configuration remain shell-owned. The Session workspace supplies the initial directory; its sandbox policy also applies to the terminal. diff --git a/packages/api/terminal-controller/README.zh.md b/packages/api/terminal-controller/README.zh.md index abcaa83d41..609493cfc0 100644 --- a/packages/api/terminal-controller/README.zh.md +++ b/packages/api/terminal-controller/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用此包 -Web bundle 将此包与 subprocess provider、sandbox policy 和 Typert Gateway 一起挂载。`remote.terminal` 提供 `environment`、`list`、`create`、`follow`、`write`、`resize`、`rename` 和 `close`;每个操作均按 Session 标识限定范围。 +Web bundle 将此包与 subprocess provider、sandbox policy 和 Typert Gateway 一起挂载。`remote.terminal` 提供 `environment`、`list`、`create`、`follow`、`write`、`resize`、`rename` 和 `close`;每个操作均按 Session 标识限定范围。列表直接读取 Host 保留的终端,因此查看离线 Session 不会激活 Agent,也不会产生恢复错误。 新终端使用执行环境声明的默认 shell。仅当 provider 未声明默认值时,才在 POSIX 使用 `/bin/sh`,在 Windows 使用 `cmd.exe`。可选的 `shell` profile 通过可执行路径 `path`、显示名称 `name` 和参数 `args`(默认 `[]`)覆盖这一选择。provider 在创建时验证可执行文件;解析失败会直接报告,不尝试其他 shell。环境查询只返回工作目录和限制,不解析 shell,因此默认 shell 不可用时仍可重新连接已有进程。POSIX 自动 profile 以交互模式启动,PowerShell 使用 `-NoLogo`,补全和启动配置仍由 shell 提供。初始目录来自 Session 工作区,终端遵循同一 sandbox policy。 diff --git a/packages/api/terminal-controller/src/index.ts b/packages/api/terminal-controller/src/index.ts index d55771ae27..7b854476b7 100644 --- a/packages/api/terminal-controller/src/index.ts +++ b/packages/api/terminal-controller/src/index.ts @@ -118,13 +118,13 @@ export class TerminalController extends TypertRemoteService { } /** - * List running and exited terminals available for reattachment. - * @param agent - Session owner supplied by the Gateway. + * List retained terminals without resolving or activating an Agent. + * @param sessionId - displayed Session identity, including offline history. * @returns terminals retained for this Host lifetime. */ @Remote - list(agent: Agent): WebTerminalInfo[] { - const owner = this.owners.get(agent.id) + list(sessionId: SessionId): WebTerminalInfo[] { + const owner = this.owners.get(sessionId) if (owner === undefined) return [] return [...owner.terminals.values(), ...owner.allocations.values()].map(terminal => terminal.info) } diff --git a/packages/api/terminal-controller/tests/controller.spec.ts b/packages/api/terminal-controller/tests/controller.spec.ts index 7910182675..619750f9ff 100644 --- a/packages/api/terminal-controller/tests/controller.spec.ts +++ b/packages/api/terminal-controller/tests/controller.spec.ts @@ -82,7 +82,7 @@ describe('TerminalController', () => { await controller.create(agent, request, signal()) subprocess.resolveExecutable.mockRejectedValue(new SubprocessExecutableNotFoundError('default shell was removed')) expect(controller.environment(agent, signal())).toMatchObject({ cwd: '/workspace', maxInputBytes: 1000 }) - expect(controller.list(agent)).toMatchObject([{ id, state: 'running' }]) + expect(controller.list(agent.id)).toMatchObject([{ id, state: 'running' }]) const stream = controller.follow(agent, id, 'restored' as TerminalAttachmentId, signal())[Symbol.asyncIterator]() try { expect(await stream.next()).toMatchObject({ done: false, value: { type: 'snapshot', info: { id, state: 'running' } } }) @@ -107,7 +107,7 @@ describe('TerminalController', () => { expect(first.id).toBe(second.id) expect(subprocess.spawnTerminal).toHaveBeenCalledOnce() expect(subprocess.spawnTerminal).toHaveBeenCalledWith(expect.objectContaining({ terminalType: 'xterm-256color', cwd: '/workspace' })) - expect(controller.list(owner(ctx, 'other'))).toEqual([]) + expect(controller.list('other' as SessionId)).toEqual([]) expect(() => controller.follow(owner(ctx, 'other'), id, 'writer' as TerminalAttachmentId, signal())).toThrow('no longer exists') const abort = new AbortController() const stream = controller.follow(agent, id, 'writer' as TerminalAttachmentId, abort.signal)[Symbol.asyncIterator]() @@ -119,7 +119,7 @@ describe('TerminalController', () => { expect(handle.terminate).not.toHaveBeenCalled() await controller.close(agent, id) expect(handle.terminate).toHaveBeenCalledOnce() - expect(controller.list(agent)).toEqual([]) + expect(controller.list(agent.id)).toEqual([]) await controller.close(agent, id) }) @@ -133,7 +133,7 @@ describe('TerminalController', () => { allocation.resolve(handle) await rejected await closing - expect(controller.list(agent)).toEqual([]) + expect(controller.list(agent.id)).toEqual([]) expect(handle.terminate).toHaveBeenCalledOnce() }) @@ -166,7 +166,7 @@ describe('TerminalController', () => { await expect(controller.create(agent, request, signal())).rejects.toThrow('closed in this Session') expect(handle.terminate).toHaveBeenCalledOnce() expect(subprocess.spawnTerminal).toHaveBeenCalledOnce() - expect(controller.list(agent)).toEqual([]) + expect(controller.list(agent.id)).toEqual([]) } finally { allocation.resolve(handle) } }) @@ -176,10 +176,10 @@ describe('TerminalController', () => { subprocess.spawnTerminal.mockImplementationOnce(async () => { abort.abort(new Error('request disconnected')); return handle }) vi.mocked(handle.terminate).mockRejectedValueOnce(new Error('cleanup failed')) await expect(controller.create(agent, request, abort.signal)).rejects.toThrow('cleanup failed') - expect(controller.list(agent)).toMatchObject([{ id, state: 'failed' }]) + expect(controller.list(agent.id)).toMatchObject([{ id, state: 'failed' }]) await controller.close(agent, id) expect(handle.terminate).toHaveBeenCalledTimes(2) - expect(controller.list(agent)).toEqual([]) + expect(controller.list(agent.id)).toEqual([]) }) it('retains a terminal when process cleanup fails so closing can be retried', async () => { @@ -187,10 +187,10 @@ describe('TerminalController', () => { await controller.create(agent, request, signal()) vi.mocked(handle.terminate).mockRejectedValueOnce(new Error('process still alive')) await expect(controller.close(agent, id)).rejects.toThrow('still alive') - expect(controller.list(agent)).toHaveLength(1) + expect(controller.list(agent.id)).toHaveLength(1) await expect(controller.create(agent, request, signal())).rejects.toThrow('closed in this Session') await controller.close(agent, id) - expect(controller.list(agent)).toHaveLength(0) + expect(controller.list(agent.id)).toHaveLength(0) }) it('rejects invalid dimensions, unavailable shells and oversized input before executing them', async () => { @@ -223,11 +223,11 @@ describe('TerminalController', () => { subprocess.spawnTerminal.mockImplementationOnce(async () => { abort.abort('lost request'); return handle }) handle.terminate.mockRejectedValueOnce(new Error('still alive')) await expect(controller.create(agent, request, abort.signal)).rejects.toThrow('cleanup failed') - expect(controller.list(agent)).toMatchObject([{ id, state: 'failed', error: 'lost request' }]) + expect(controller.list(agent.id)).toMatchObject([{ id, state: 'failed', error: 'lost request' }]) await expect(controller.create(agent, request, signal())).rejects.toThrow('Close the failed terminal allocation') await expect(controller.create(agent, { ...request, id: 'another' as WebTerminalId }, signal())).rejects.toMatchObject({ code: 'terminal/limit-reached', details: { limit: 1 } }) await controller.close(agent, id) - expect(controller.list(agent)).toEqual([]) + expect(controller.list(agent.id)).toEqual([]) }) it('lets close reclaim an allocation whose request failed while close was waiting', async () => { @@ -243,7 +243,7 @@ describe('TerminalController', () => { await rejected await closing expect(handle.terminate).toHaveBeenCalledOnce() - expect(controller.list(agent)).toEqual([]) + expect(controller.list(agent.id)).toEqual([]) }) it('routes input, resize and trimmed names through the active attachment', async () => { @@ -259,7 +259,7 @@ describe('TerminalController', () => { await controller.resize(agent, id, attachmentId, 200, 100) expect(handle.resize).toHaveBeenCalledWith(200, 100) controller.rename(agent, id, ' server logs ') - expect(controller.list(agent)).toMatchObject([{ title: 'server logs', cols: 200, rows: 100 }]) + expect(controller.list(agent.id)).toMatchObject([{ title: 'server logs', cols: 200, rows: 100 }]) } finally { await stream.return?.() } }) @@ -278,7 +278,7 @@ describe('TerminalController', () => { expect(() => { controller.rename(agent, id, ' ') }).toThrow('1–120') expect(() => { controller.rename(agent, id, 'x'.repeat(121)) }).toThrow('1–120') await controller.close(agent, id) - expect(controller.list(agent)).toEqual([]) + expect(controller.list(agent.id)).toEqual([]) }) it('uses the Session sandbox policy to confine its selected shell', async () => { @@ -329,7 +329,7 @@ describe('TerminalController', () => { await controller.create(agent, request, signal()) await disposeEffect('terminal-controller.owner') expect(handle.terminate).toHaveBeenCalledOnce() - expect(controller.list(agent)).toEqual([]) + expect(controller.list(agent.id)).toEqual([]) }) it('cleans up a Session when its real Agent plugin fiber is disposed', async () => { @@ -340,7 +340,7 @@ describe('TerminalController', () => { await controller.create(agent, request, signal()) await fiber.dispose() expect(handle.terminate).toHaveBeenCalledOnce() - expect(controller.list(agent)).toEqual([]) + expect(controller.list(agent.id)).toEqual([]) }) it('waits for pending creation during Host disposal and rolls back its process before resolving', async () => { @@ -356,7 +356,7 @@ describe('TerminalController', () => { await rejected await disposing expect(handle.terminate).toHaveBeenCalledOnce() - expect(controller.list(agent)).toEqual([]) + expect(controller.list(agent.id)).toEqual([]) await expect(controller.create(agent, request, signal())).rejects.toThrow('controller disposed') }) @@ -375,7 +375,7 @@ describe('TerminalController', () => { stopped.resolve(undefined) await Promise.all([ownerDisposal, hostDisposal]) expect(handle.terminate).toHaveBeenCalledOnce() - expect(controller.list(agent)).toEqual([]) + expect(controller.list(agent.id)).toEqual([]) } finally { stopped.resolve(undefined) } }) @@ -387,9 +387,9 @@ describe('TerminalController', () => { await expect(controller.create(agent, request, signal())).rejects.toThrow('Session owner disposed') handle.terminate.mockRejectedValueOnce(new Error('still alive again')) await expect(disposeEffect('terminal-controller.processes')).rejects.toThrow('Browser terminal cleanup failed') - expect(controller.list(agent)).toHaveLength(1) + expect(controller.list(agent.id)).toHaveLength(1) await controller.close(agent, id) - expect(controller.list(agent)).toEqual([]) + expect(controller.list(agent.id)).toEqual([]) expect(handle.terminate).toHaveBeenCalledTimes(3) }) @@ -401,7 +401,7 @@ describe('TerminalController', () => { await expect(controller.create(agent, request, abort.signal)).rejects.toThrow('cleanup failed') await disposeEffect('terminal-controller.owner') expect(handle.terminate).toHaveBeenCalledTimes(2) - expect(controller.list(agent)).toEqual([]) + expect(controller.list(agent.id)).toEqual([]) }) }) diff --git a/packages/client/ui-sidebar-right/README.i18n.yaml b/packages/client/ui-sidebar-right/README.i18n.yaml index 3c693bf003..c057a3c9db 100644 --- a/packages/client/ui-sidebar-right/README.i18n.yaml +++ b/packages/client/ui-sidebar-right/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-sidebar-right/README.md -README.md: 3ee8049eff334e7443e206b8e26c246ca815a19d -README.zh.md: 3c0986438caffbdde1221b7caaf89e228f09a210 +README.md: b7084379b5d6e4757fa741d54414216a63be7204 +README.zh.md: 096a134eecd40df354b3c77f7829a1bfa9d9f22c diff --git a/packages/client/ui-sidebar-right/README.md b/packages/client/ui-sidebar-right/README.md index 3ee8049eff..b7084379b5 100644 --- a/packages/client/ui-sidebar-right/README.md +++ b/packages/client/ui-sidebar-right/README.md @@ -84,7 +84,7 @@ Two more seats extend what is already there: `sidebar.right.tab.guide` (chain) r ## `ctx.sidebarRight` -`openResource(address, options?)` and `openTab(kind, options?)` are the navigation controller, and every way into the column calls one of them: the conversation's file links and a tool row's line reference (`openResource(fileAddress, { params: { line } })`), the strip's add control and a guide entry box (`openTab`), a file tree's rows (`tab.actions.openResource`). A resource address is a `dsh-resource:///…` URI; without `options.kind` the registry claims it (globs and `canOpen`, best band wins), with it that kind's type in force opens it. A page is named by kind; the tab is recorded under an address this package composes and nobody else spells (`contract/seed.ts`). Both run the same steps as one history entry: a resource tab already showing the same (kind, contentId) is focused wherever it sits unless `revealIfOpened: false`; page tabs always deduplicate within the target pane, regardless of that option; otherwise a new tab lands in `options.replaceTab`'s pane and slot (closing that tab), else `options.paneId`, else the active docked pane; the panel expands, because content the user cannot see is not opened. Then the Tab domain records the navigation — `params` reach the body as `navigation.params`, with `revision` stepped — outside the layout history. `params` is typed by what is opened: a viewer for a resource type merges its entry into `SidebarRightResourceParamsMap` (the text preview declares `{ line?: number }`); a page type that takes parameters merges into `SidebarRightTabParamsMap` under its kind; values are JSON-shaped by convention, unchecked at run time. An address outside `dsh-resource://`, one no type claims, or a kind nothing registered throws: that is a wiring mistake, not a user error. +`openResource(address, options?)` and `openTab(kind, options?)` are the navigation controller, and every way into the column calls one of them: the conversation's file links and a tool row's line reference (`openResource(fileAddress, { params: { line } })`), the strip's add control and a guide entry box (`openTab`), a file tree's rows (`tab.actions.openResource`). A resource address is a `dsh-resource:///…` URI; without `options.kind` the registry claims it (globs and `canOpen`, best band wins), with it that kind's type in force opens it. A page is named by kind; the tab is recorded under an address this package composes and nobody else spells (`contract/seed.ts`). Both run the same steps as one history entry: a resource tab already showing the same (kind, contentId) is focused wherever it sits unless `revealIfOpened: false`; ordinary page tabs always deduplicate within the target pane, regardless of that option; otherwise a new tab lands in `options.replaceTab`'s pane and slot (closing that tab), else `options.paneId`, else the active docked pane; the panel expands, because content the user cannot see is not opened. Then the Tab domain records the navigation — `params` reach the body as `navigation.params`, with `revision` stepped — outside the layout history. `params` is typed by what is opened: a viewer for a resource type merges its entry into `SidebarRightResourceParamsMap` (the text preview declares `{ line?: number }`); a page type that takes parameters merges into `SidebarRightTabParamsMap` under its kind; values are JSON-shaped by convention, unchecked at run time. An address outside `dsh-resource://`, one no type claims, or a kind nothing registered throws: that is a wiring mistake, not a user error. `close(tabId)` closes a tab; `active()` reads the active tab. `isExpanded()` and `toggleExpanded()` read and drive the column's expansion; the presentation switch is the panel's own control and not part of this face. Layout operations, for callers that arrange the column programmatically, each recorded like the gesture it stands in for: `focus(tabId)` focuses a tab and its pane; `split(paneId?)` splits a docked pane (the active one by default) under the same pane budget and room rule as the strip's control and returns the new pane's id, or `undefined` — recording nothing — when it cannot; `float(tabId, rect?)` takes a docked tab out into a panel; `dock(paneId)` returns a floating panel to the active docked pane. A tab or pane that does not exist, or already is where the call would put it, is left alone. The face exposes operations only: no layout snapshot, no operation log, no lookup by address. `_undo()` / `_redo()` step the mounted surface's history; they are `@internal` — the sequence has no user-facing control, and these exist for tests. Commands need a mounted session surface; with none, they throw rather than write into a surface nobody draws. @@ -98,9 +98,11 @@ Tab owners register `registerCloseHandler(kind, handler)` through an effect. A h ## The guide +A tab type can declare `multiple: true` for independent instances such as terminals. Each open receives a distinct content address, so placement and docking preserve those instances; ordinary pages retain per-pane uniqueness. + Default pages depend on the number of registered guide entries, not the number of tab types or open tabs. Exactly one entry opens its page directly; zero or multiple entries open the guide. Explicitly adding a guide still opens the guide, even with one entry. The sole docked guide is the only tab that cannot close; closing any other sole tab also collapses the column. The chip, context menu, and `close` API apply the same rule. -The guide tab is one entry capsule (a glyph and a title, nothing else) per `guide` entry the registered types contributed, in `order`, centred in the body; the guide has no words of its own. Picking a capsule replaces the guide with its page. An entry may set `revealIfOpened: false` to create another tab; omission keeps the normal reveal of an existing page. A pane holds at most one guide tab. The strip's add control is drawn only while its pane holds none and opens one there with `openTab('guide', { paneId, revealIfOpened: false })`, so a guide in another pane does not capture the click; opening the guide into a pane that already has one focuses it instead; a guide dragged, dropped, or docked into such a pane merges into it — the arriving guide closes and the pane's own is focused; `duplicateTab` on the guide records nothing. A split, an expanded empty root pane, and the pane a sole tab vacates by dropping on its own edge use the same default-page rule, one tab per new pane; the self-edge drop leaves the dragged tab focused. A plain `openTab('guide')` opens or focuses the guide only within the active or named pane. Splitting an empty pane does nothing and returns no new pane. The product allows two horizontal panes, initially equal, with divider ratios limited to 20%–80%. Insufficient width blocks a new split; with two panes already present, a body drop moves the tab between panes instead of creating a third. At the two-pane limit, split controls are hidden; closing back to one pane restores them. +The guide tab is one entry capsule (a glyph and a title, nothing else) per `guide` entry the registered types contributed, in `order`, centred in the body; the guide has no words of its own. Picking a capsule replaces the guide with its page. A pane holds at most one guide tab. The strip's add control is drawn only while its pane holds none and opens one there with `openTab('guide', { paneId, revealIfOpened: false })`, so a guide in another pane does not capture the click; opening the guide into a pane that already has one focuses it instead; a guide dragged, dropped, or docked into such a pane merges into it — the arriving guide closes and the pane's own is focused; `duplicateTab` on the guide records nothing. A split, an expanded empty root pane, and the pane a sole tab vacates by dropping on its own edge use the same default-page rule, one tab per new pane; the self-edge drop leaves the dragged tab focused. A plain `openTab('guide')` opens or focuses the guide only within the active or named pane. Splitting an empty pane does nothing and returns no new pane. The product allows two horizontal panes, initially equal, with divider ratios limited to 20%–80%. Insufficient width blocks a new split; with two panes already present, a body drop moves the tab between panes instead of creating a third. At the two-pane limit, split controls are hidden; closing back to one pane restores them. ## Copy diff --git a/packages/client/ui-sidebar-right/README.zh.md b/packages/client/ui-sidebar-right/README.zh.md index 3c0986438c..096a134eec 100644 --- a/packages/client/ui-sidebar-right/README.zh.md +++ b/packages/client/ui-sidebar-right/README.zh.md @@ -98,9 +98,11 @@ Tab域按(Session,Tab id)保留导航、中止信号与绑定动作;私 ## 引导页 +终端等需要独立实例的 tab 类型可声明 `multiple: true`。每次打开都会获得独立内容地址,因此移动和停靠会保留这些实例;普通页面仍在每个格内去重。 + 默认页取决于已注册的引导入口数,不取决于 tab 类型数或已打开的 tab 数。恰好一个入口时直接打开对应页面;没有入口或有多个入口时打开引导页。即使只有一个入口,显式添加引导页仍会打开引导页。只有作为唯一停靠 tab 的引导页不可关闭;关闭其它任何唯一 tab 时会同时收起整列。chip、上下文菜单与 `close` API 使用同一规则。 -引导 tab 是各已注册类型贡献的每个 `guide` 条目一个入口胶囊(一个图标加一个标题,别无其他),按 `order` 排列并在正文中居中;引导页自己没有文字。选中一个胶囊会用对应页面替换引导页。条目可设置 `revealIfOpened: false` 来新建另一个 tab;省略时保留聚焦已有页面的默认行为。一个格最多持有一个引导 tab。tab 条的添加控件只在该格没有引导 tab 时绘制,并以 `openTab('guide', { paneId, revealIfOpened: false })` 在该格打开一个,这样别的格里的引导页不会截走这次点击;把引导页开进已有引导页的格则改为聚焦它;把引导页拖入、放入或收回到这样的格会合并进去——来者关闭,该格自己的被聚焦;对引导页 `duplicateTab` 不记录任何东西。分栏、展开且为空的根格、以及唯一 tab 拖到本格边缘分屏后腾出的格使用相同的默认页规则,每个新格一个 tab;这种本格边缘拖放让被拖的 tab 保持聚焦。普通的 `openTab('guide')` 只在活跃或指定分栏内打开或聚焦引导页。对空分栏执行 split 不产生变化,也不返回新分栏。产品最多保留左右两格,默认均分,分隔条限定20%~80%。宽度不足以容纳两格时不允许新分栏;已有两格时,正文拖放用于跨格移动,不再创建第三格。达到两格上限时隐藏分栏控件;关闭回单格后恢复。 +引导 tab 是各已注册类型贡献的每个 `guide` 条目一个入口胶囊(一个图标加一个标题,别无其他),按 `order` 排列并在正文中居中;引导页自己没有文字。选中一个胶囊会用对应页面替换引导页。一个格最多持有一个引导 tab。tab 条的添加控件只在该格没有引导 tab 时绘制,并以 `openTab('guide', { paneId, revealIfOpened: false })` 在该格打开一个,这样别的格里的引导页不会截走这次点击;把引导页开进已有引导页的格则改为聚焦它;把引导页拖入、放入或收回到这样的格会合并进去——来者关闭,该格自己的被聚焦;对引导页 `duplicateTab` 不记录任何东西。分栏、展开且为空的根格、以及唯一 tab 拖到本格边缘分屏后腾出的格使用相同的默认页规则,每个新格一个 tab;这种本格边缘拖放让被拖的 tab 保持聚焦。普通的 `openTab('guide')` 只在活跃或指定分栏内打开或聚焦引导页。对空分栏执行 split 不产生变化,也不返回新分栏。产品最多保留左右两格,默认均分,分隔条限定20%~80%。宽度不足以容纳两格时不允许新分栏;已有两格时,正文拖放用于跨格移动,不再创建第三格。达到两格上限时隐藏分栏控件;关闭回单格后恢复。 ## 文案 diff --git a/packages/client/ui-sidebar-right/package.json b/packages/client/ui-sidebar-right/package.json index c8fad0215c..634c4c32ba 100644 --- a/packages/client/ui-sidebar-right/package.json +++ b/packages/client/ui-sidebar-right/package.json @@ -67,7 +67,8 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "picomatch": "^4.0.4", - "clsx": "^2.0.0" + "clsx": "^2.0.0", + "@deepseek-ai/dsh-util-crypto": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-sidebar-right/src/client/service.ts b/packages/client/ui-sidebar-right/src/client/service.ts index 38704ee48d..def6c222cc 100644 --- a/packages/client/ui-sidebar-right/src/client/service.ts +++ b/packages/client/ui-sidebar-right/src/client/service.ts @@ -27,6 +27,7 @@ * The registration adopts Session stores and injects the mounted seat binding; * callers use the service's navigation methods. */ +import { randomUUID } from '@deepseek-ai/dsh-util-crypto' import type { FloatRect, PaneId, TabId, TabRecord } from '@deepseek-ai/dsh-client-ui-dockkit' import { activeDockPaneId, canSplit, findContentTab, dockPaneIds, findTabPane, getPane } from '@deepseek-ai/dsh-client-ui-dockkit' import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots' @@ -344,7 +345,7 @@ export class SidebarRightController implements ISidebarRight { ): void { const definition = this.tabs.get(kind) if (definition === undefined) throw new Error(`sidebarRight: no tab type is registered as "${kind}"`) - const address = pageAddress(kind) + const address = definition.multiple === true ? `${pageAddress(kind)}/${randomUUID()}` : pageAddress(kind) this.place(sessionId, actions, { kind, contentId: address, title: definition.title(address) }, address, options, options.params) } diff --git a/packages/client/ui-sidebar-right/src/client/tab-registry.ts b/packages/client/ui-sidebar-right/src/client/tab-registry.ts index 5284716d5f..13c4490308 100644 --- a/packages/client/ui-sidebar-right/src/client/tab-registry.ts +++ b/packages/client/ui-sidebar-right/src/client/tab-registry.ts @@ -68,8 +68,6 @@ export interface SidebarRightGuideEntry { readonly title: () => string /** Optional glyph, drawn before the title. */ readonly icon?: ComponentType - /** Defaults to true; false makes this entry create another tab instead of focusing an existing one. */ - readonly revealIfOpened?: boolean } /** A guide entry as the registry lists it: with the kind of the type that contributed it, which is what picking it opens. */ @@ -90,6 +88,8 @@ export interface SidebarRightTabDefinition { readonly id: string /** Type discriminator: what the tabs of this type are, and what `openTab` names. */ readonly kind: string + /** Each open by kind creates independent content; omission keeps one page per kind in each pane. */ + readonly multiple?: boolean /** * Resource-address globs this type recognizes; omit for a page type, which is * opened by kind and recognizes no address. diff --git a/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.tsx b/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.tsx index b3d07685dd..5a9851739a 100644 --- a/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.tsx +++ b/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.tsx @@ -67,10 +67,7 @@ export function GuideBody({ useTabInfo, useGuideEntries, renderSlotChain }: Guid const options = { hookContext: useTabInfo, fallback: ( - { tab.actions.openTab(entry.kind, { - replaceTab: true, - ...entry.revealIfOpened === undefined ? {} : { revealIfOpened: entry.revealIfOpened }, - }) }} /> + { tab.actions.openTab(entry.kind, { replaceTab: true }) }} /> ), } satisfies ChainRenderOpts & { hookContext: HookContextOf<'sidebar.right.tab.guide'> } return renderSlotChain('sidebar.right.tab.guide', {}, options) diff --git a/packages/client/ui-sidebar-right/tests/guide-body.client.spec.tsx b/packages/client/ui-sidebar-right/tests/guide-body.client.spec.tsx index 3f88ad5e6a..2648088cc4 100644 --- a/packages/client/ui-sidebar-right/tests/guide-body.client.spec.tsx +++ b/packages/client/ui-sidebar-right/tests/guide-body.client.spec.tsx @@ -82,11 +82,6 @@ describe('GuideBody', () => { cleanup() }) - it.each([false, true])('passes an entry\'s explicit revealIfOpened=%s preference when replacing the guide', (revealIfOpened) => { - const { view, openTab } = mountGuide([{ ...box('terminal', 20), revealIfOpened }]) - fireEvent.click(view.getByRole('button', { name: 'terminal title' })) - expect(openTab).toHaveBeenCalledExactlyOnceWith('terminal', { replaceTab: true, revealIfOpened }) - }) it('draws an empty guide while no type contributed an entry, and follows the registry when one does', () => { const { view, guideEntries, boxes } = mountGuide([]) diff --git a/packages/client/ui-sidebar-right/tests/service.client.spec.ts b/packages/client/ui-sidebar-right/tests/service.client.spec.ts index 5624fa70ff..de35bc78f0 100644 --- a/packages/client/ui-sidebar-right/tests/service.client.spec.ts +++ b/packages/client/ui-sidebar-right/tests/service.client.spec.ts @@ -80,6 +80,25 @@ function harness() { } describe('SidebarRightController — opening', () => { + it('keeps independently opened instances distinct when they return to the same pane', () => { + const h = harness() + h.tabs.register({ id: 'test/terminal', kind: 'terminal', multiple: true, title: () => 'terminal' }) + const release = h.publish() + try { + h.controller.openTab('terminal') + h.controller.openTab('terminal') + const terminals = Object.values(h.layout().tabs).filter(tab => tab.kind === 'terminal') + expect(terminals).toHaveLength(2) + expect(terminals[0]!.contentId).not.toBe(terminals[1]!.contentId) + const pane = findTabPane(h.layout(), terminals[0]!.id).id + h.instance.actions.floatTab(SESSION, terminals[0]!.id) + const floating = findTabPane(h.layout(), terminals[0]!.id).id + h.instance.actions.unfloatPane(SESSION, floating) + expect(getPane(h.layout(), pane).tabs).toContain(terminals[0]!.id) + expect(getPane(h.layout(), pane).tabs).toContain(terminals[1]!.id) + } finally { release() } + }) + it('refuses every write while no seat is mounted', () => { const { controller } = harness() expect(() => { controller.openResource('dsh-resource://file/session/s-test/a.txt') }).toThrow('no session surface is mounted') diff --git a/packages/client/ui-sidebar-terminal/src/client/index.ts b/packages/client/ui-sidebar-terminal/src/client/index.ts index ce38b0feb0..0ba160121c 100644 --- a/packages/client/ui-sidebar-terminal/src/client/index.ts +++ b/packages/client/ui-sidebar-terminal/src/client/index.ts @@ -37,8 +37,8 @@ export function apply(ctx: Context): void { const t = ctx.locale.bind(namespace) ctx.effect(() => ctx.locale.register(namespace, { zh, en }), 'ui-sidebar-terminal.copy') ctx.effect(() => ctx.sidebarRightTabs.register({ - id, kind: 'terminal', priority: 'builtin', title: () => t('title'), - guide: [{ order: 20, title: () => t('new'), icon: TerminalIcon, revealIfOpened: false }], + id, kind: 'terminal', multiple: true, priority: 'builtin', title: () => t('title'), + guide: [{ order: 20, title: () => t('new'), icon: TerminalIcon }], }), 'ui-sidebar-terminal.type') ctx.effect(() => ctx.sidebarRight.registerCloseHandler('terminal', (sessionId, tab) => { ctx.webTerminals.close(sessionId, tab.id, terminalId(sessionId, tab.id)) @@ -62,7 +62,7 @@ export function apply(ctx: Context): void { pending = ctx.webTerminals.recover(sessionId).then((terminals) => { if (disposed) return for (const info of terminals) ctx.sidebarRight.openTabIn(sessionId, 'terminal', { - revealIfOpened: false, params: { terminalId: info.id }, + params: { terminalId: info.id }, }) }).catch((error: unknown) => { recovered.delete(sessionId); throw error }) recovered.set(sessionId, pending) diff --git a/packages/client/ui-sidebar-terminal/tests/apply.client.spec.ts b/packages/client/ui-sidebar-terminal/tests/apply.client.spec.ts index 3fe6dd4e4d..70d7f1d473 100644 --- a/packages/client/ui-sidebar-terminal/tests/apply.client.spec.ts +++ b/packages/client/ui-sidebar-terminal/tests/apply.client.spec.ts @@ -69,7 +69,8 @@ it('registers terminal views, recovery and cleanup, then releases every contribu try { const definition = h.tabs.get('terminal')! expect(definition.title('sidebar://terminal')).toBe('title') - expect(definition.guide?.map(entry => [entry.order, entry.title(), entry.revealIfOpened])).toEqual([[20, 'new', false]]) + expect(definition.guide?.map(entry => [entry.order, entry.title()])).toEqual([[20, 'new']]) + expect(definition.multiple).toBe(true) expect(h.dictionaries.get('sidebarTerminal')).toEqual({ en, zh }) expect(h.entries.map(entry => [entry.name, entry.component, entry.locale])).toEqual([ ['sidebar.right.pane.tab', TerminalBody, 'sidebarTerminal'], @@ -120,8 +121,8 @@ it('shares pending and completed recovery across Session headers and opens each pending.resolve([terminalInfo('build'), terminalInfo('tests')]) await completion expect(h.openTabIn.mock.calls).toEqual([ - [sessionId, 'terminal', { revealIfOpened: false, params: { terminalId: 'build' } }], - [sessionId, 'terminal', { revealIfOpened: false, params: { terminalId: 'tests' } }], + [sessionId, 'terminal', { params: { terminalId: 'build' } }], + [sessionId, 'terminal', { params: { terminalId: 'tests' } }], ]) await remounted.restore() expect(h.terminals.recover).toHaveBeenCalledTimes(1) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index e04684be10..7873cd4653 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -2475,9 +2475,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'the Session workspace directory and terminal limits.', }, { - signature: '@Remote list(agent: Agent): WebTerminalInfo[]', - description: 'List running and exited terminals available for reattachment.', - parameters: [{ name: 'agent', description: 'Session owner supplied by the Gateway.' }], + signature: '@Remote list(sessionId: SessionId): WebTerminalInfo[]', + description: 'List retained terminals without resolving or activating an Agent.', + parameters: [{ name: 'sessionId', description: 'displayed Session identity, including offline history.' }], returns: 'terminals retained for this Host lifetime.', }, { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a5fc141f2b..05c8eeb0c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -621,6 +621,9 @@ importers: '@deepseek-ai/dsh-pwsh-local': specifier: workspace:^ version: link:../../packages/shell/pwsh-local + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../packages/subprocess/subprocess-local '@types/node': specifier: ^22.0.0 version: 22.20.0 @@ -4040,6 +4043,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-util-crypto': + specifier: workspace:^ + version: link:../../util/crypto '@testing-library/react': specifier: ^16.1.0 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) From b5e7fca4a53892b7c26eaca7c7d1fe1b91229fa1 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 10 Sep 2026 16:47:50 +0800 Subject: [PATCH 43/79] feat(compaction): record image offload decisions without message replacement --- ...2026-09-02-durable-image-offload.i18n.yaml | 6 + .../2026-09-02-durable-image-offload.md | 1 + .../2026-09-02-durable-image-offload.zh.md | 1 + .agents/notes/archived/manifest.json | 3 + ...2026-09-10-image-offload-events.i18n.yaml} | 6 +- .../2026-09-10-image-offload-events.md | 47 ++ .../2026-09-10-image-offload-events.zh.md | 47 ++ ...0-unified-image-request-pipeline.i18n.yaml | 4 +- ...26-08-20-unified-image-request-pipeline.md | 4 +- ...08-20-unified-image-request-pipeline.zh.md | 4 +- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 2 +- docs/architecture.zh.md | 2 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 4 +- docs/config-catalog.zh.md | 4 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 10 +- docs/module-graph.zh.md | 10 +- docs/persistence-catalog.i18n.yaml | 4 +- docs/persistence-catalog.md | 48 +- docs/persistence-catalog.zh.md | 48 +- docs/subsystems/llm-streaming.i18n.yaml | 4 +- docs/subsystems/llm-streaming.md | 7 +- docs/subsystems/llm-streaming.zh.md | 7 +- docs/subsystems/session.i18n.yaml | 4 +- docs/subsystems/session.md | 41 +- docs/subsystems/session.zh.md | 41 +- .../tests/manual-compaction.spec.ts | 22 + .../compaction-image-offload/README.i18n.yaml | 4 +- .../compaction-image-offload/README.md | 22 +- .../compaction-image-offload/README.zh.md | 22 +- .../compaction-image-offload/package.json | 8 +- .../src/image-offload.ts | 125 ++-- .../compaction-image-offload/src/index.ts | 14 +- .../tests/image-offload.spec.ts | 95 +-- .../compaction-image-offload/tsconfig.json | 4 +- .../README.i18n.yaml | 4 +- .../compaction-tool-result-pruner/README.md | 2 +- .../README.zh.md | 2 +- .../src/index.ts | 7 +- .../tests/tool-result-pruner.spec.ts | 19 + packages/core/agent-loop/README.i18n.yaml | 4 +- packages/core/agent-loop/README.md | 4 +- packages/core/agent-loop/README.zh.md | 4 +- packages/core/agent-loop/src/agent.ts | 6 +- packages/core/session/README.i18n.yaml | 4 +- packages/core/session/README.md | 10 +- packages/core/session/README.zh.md | 10 +- packages/core/session/src/image-offload.ts | 39 ++ packages/core/session/src/index.ts | 24 +- .../core/session/src/known-event-types.ts | 1 + packages/core/session/src/surface.ts | 92 ++- packages/core/session/src/types.ts | 17 + .../core/session/tests/image-offload.spec.ts | 136 +++++ .../extensions/tool-cordis/src/api-catalog.ts | 8 +- packages/llm/llm-deepseek/README.i18n.yaml | 4 +- packages/llm/llm-deepseek/README.md | 2 +- packages/llm/llm-deepseek/README.zh.md | 2 +- packages/llm/llm-pi-ai/README.i18n.yaml | 4 +- packages/llm/llm-pi-ai/README.md | 4 +- packages/llm/llm-pi-ai/README.zh.md | 4 +- packages/llm/llm/README.i18n.yaml | 4 +- packages/llm/llm/README.md | 4 +- packages/llm/llm/README.zh.md | 4 +- packages/llm/llm/src/types.ts | 11 +- packages/llm/token-meter/README.i18n.yaml | 4 +- packages/llm/token-meter/README.md | 6 +- packages/llm/token-meter/README.zh.md | 6 +- .../token-meter/src/breakdown-projection.ts | 2 +- packages/llm/token-meter/src/estimate.ts | 7 +- packages/llm/token-meter/src/index.ts | 12 + .../llm/token-meter/src/usage-projection.ts | 2 +- .../context-breakdown-projection.spec.ts | 6 +- .../token-meter/tests/route-pricing.spec.ts | 46 +- .../tests/token-usage-projection.spec.ts | 2 +- pnpm-lock.yaml | 6 - .../python-snapshot-image-offload.mjs | 43 ++ scripts/smoke-python-runtime.py | 9 + .../advanced/result.json | 540 +++++++++++++----- .../advanced/session.v3.jsonl | 32 +- scripts/type-equiv.manifest.json | 5 + .../sdk/inline-image-prompt/session.v3.jsonl | 7 +- 83 files changed, 1331 insertions(+), 527 deletions(-) create mode 100644 .agents/notes/archived/architecture/2026-09-02-durable-image-offload.i18n.yaml rename .agents/notes/{implemented => archived}/architecture/2026-09-02-durable-image-offload.md (99%) rename .agents/notes/{implemented => archived}/architecture/2026-09-02-durable-image-offload.zh.md (99%) rename .agents/notes/implemented/architecture/{2026-09-02-durable-image-offload.i18n.yaml => 2026-09-10-image-offload-events.i18n.yaml} (58%) create mode 100644 .agents/notes/implemented/architecture/2026-09-10-image-offload-events.md create mode 100644 .agents/notes/implemented/architecture/2026-09-10-image-offload-events.zh.md create mode 100644 packages/core/session/src/image-offload.ts create mode 100644 packages/core/session/tests/image-offload.spec.ts create mode 100644 scripts/fixtures/python-snapshot-image-offload.mjs diff --git a/.agents/notes/archived/architecture/2026-09-02-durable-image-offload.i18n.yaml b/.agents/notes/archived/architecture/2026-09-02-durable-image-offload.i18n.yaml new file mode 100644 index 0000000000..4968362944 --- /dev/null +++ b/.agents/notes/archived/architecture/2026-09-02-durable-image-offload.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/archived/architecture/2026-09-02-durable-image-offload.md +2026-09-02-durable-image-offload.md: db03e9f52499c1790b3a0c03853f3aea23315bec +2026-09-02-durable-image-offload.zh.md: 2b6d0a4aa65f9a39b88888506f7e85b4cfff48cf diff --git a/.agents/notes/implemented/architecture/2026-09-02-durable-image-offload.md b/.agents/notes/archived/architecture/2026-09-02-durable-image-offload.md similarity index 99% rename from .agents/notes/implemented/architecture/2026-09-02-durable-image-offload.md rename to .agents/notes/archived/architecture/2026-09-02-durable-image-offload.md index d4cc791dee..db03e9f524 100644 --- a/.agents/notes/implemented/architecture/2026-09-02-durable-image-offload.md +++ b/.agents/notes/archived/architecture/2026-09-02-durable-image-offload.md @@ -1,6 +1,7 @@ # Agent Note: Durable image offload by surface replacement Status: implemented +Archived: 2026-09-10 English | [中文](2026-09-02-durable-image-offload.zh.md) diff --git a/.agents/notes/implemented/architecture/2026-09-02-durable-image-offload.zh.md b/.agents/notes/archived/architecture/2026-09-02-durable-image-offload.zh.md similarity index 99% rename from .agents/notes/implemented/architecture/2026-09-02-durable-image-offload.zh.md rename to .agents/notes/archived/architecture/2026-09-02-durable-image-offload.zh.md index b31a4747db..2b6d0a4aa6 100644 --- a/.agents/notes/implemented/architecture/2026-09-02-durable-image-offload.zh.md +++ b/.agents/notes/archived/architecture/2026-09-02-durable-image-offload.zh.md @@ -1,6 +1,7 @@ # Agent Note: 以表层替换实现持久的图片 offload Status: implemented +Archived: 2026-09-10 [English](2026-09-02-durable-image-offload.md) | 中文 diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index cded9f6c7e..0d758eec58 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -358,6 +358,9 @@ "architecture/2026-09-01-streamed-tool-call-identity.i18n.yaml": "sha256:5193bf7429a0300f743ea7e6087c3175a7d28c2c468ca1cfd1f77702b68b6399", "architecture/2026-09-01-streamed-tool-call-identity.md": "sha256:303433b95c22571bf6a801da604b42a4c7231f5f6d07daeb518354f0d7019141", "architecture/2026-09-01-streamed-tool-call-identity.zh.md": "sha256:53728d3ee06b29d6023e28024fb3ab6fa4a7473ae5e55368cd19690719d120f3", + "architecture/2026-09-02-durable-image-offload.i18n.yaml": "sha256:d06155597e7df4ca4c15a2902ab664d69cc2fde1e7963e61543d20a49930d2b9", + "architecture/2026-09-02-durable-image-offload.md": "sha256:02b7becba3dd73a4e627b85705e8bd5dd55978cb041f4d3a0fd1420a76e4de92", + "architecture/2026-09-02-durable-image-offload.zh.md": "sha256:a0464b8272086d663ee698efd0b2edd6224d40770d7a8fd88a6d8bfe030b2a1d", "architecture/2026-09-02-protocol-specific-model-listing-discovery.i18n.yaml": "sha256:ba8a62a9fa3263de4162709cb1aaf81f6ad4feee5011d7a5d8b24eb92ad81d30", "architecture/2026-09-02-protocol-specific-model-listing-discovery.md": "sha256:9db9c47559c17f0e2931b9e646f1d2d17b590df4d5699d4e779ed6d7f65fc371", "architecture/2026-09-02-protocol-specific-model-listing-discovery.zh.md": "sha256:9a266590d49093a097bcdb5d09865757cdd07625cfaaddce1303046d580551f1", diff --git a/.agents/notes/implemented/architecture/2026-09-02-durable-image-offload.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-10-image-offload-events.i18n.yaml similarity index 58% rename from .agents/notes/implemented/architecture/2026-09-02-durable-image-offload.i18n.yaml rename to .agents/notes/implemented/architecture/2026-09-10-image-offload-events.i18n.yaml index b998cd4005..4e15ab443a 100644 --- a/.agents/notes/implemented/architecture/2026-09-02-durable-image-offload.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-10-image-offload-events.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-02-durable-image-offload.md -2026-09-02-durable-image-offload.md: d4cc791deec64567b6920c878cb39439fb51c24d -2026-09-02-durable-image-offload.zh.md: b31a4747db98328ea445d5891d1c813631aeaa94 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-10-image-offload-events.md +2026-09-10-image-offload-events.md: d9ade125733422cbb597669e4f5dce0d042b1d7c +2026-09-10-image-offload-events.zh.md: 7d439fc71caadf409dc73182162b2c5cf6e439e4 diff --git a/.agents/notes/implemented/architecture/2026-09-10-image-offload-events.md b/.agents/notes/implemented/architecture/2026-09-10-image-offload-events.md new file mode 100644 index 0000000000..d9ade12573 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-10-image-offload-events.md @@ -0,0 +1,47 @@ +# Agent Note: Durable image offload events + +Status: implemented + +English | [中文](2026-09-10-image-offload-events.zh.md) + +## Problem + +A request can exceed an image-capable route's byte or image-count budget. Recomputing the omitted prefix for each request lets a route switch, inline fallback, or compaction restore previously omitted images. The model-visible image set then depends on unlogged request preparation. The [unified image pipeline](../feature/2026-08-20-unified-image-request-pipeline.md) owns attachment normalization and request versions; durable omission needs its own recorded decision. + +## Decision + +`dsh-compaction-image-offload` owns recovery on the `agent/request-error` waterfall. An `IMAGE_OFFLOAD_REQUIRED` failure supplies `offloadImages`; the plugin selects that many oldest retained input-image occurrences in current surface order, appends one `image/offload` event, and returns `retry`. Assistant output images are excluded. If no retained input image remains, the plugin delegates. Other failures do not enter this recovery. Offload does not consume the provider retry budget or emit `llm/retry`. + +The event payload is `{ targets: [{ seq, imageIndexes }] }`. Each target identifies a current `user/message` or `tool/result` node. Its strictly increasing, zero-based indexes enumerate every image in depth-first content order, including nested tool results and previously omitted images. Equal attachment ids remain distinct occurrences. Exact selections remain unambiguous after positional replacements reorder the surface; an attachment id or a raw-log prefix cannot identify the selected set. + +The event has no `surfaceOp`. It neither creates a message nor replaces a message node. Session validates all targets before commit and rejects missing or shadowed nodes, output images, duplicate targets, invalid indexes, and already omitted occurrences. Reconstruction derives immutable message copies with `ImageBlock.offloaded: true` at the recorded positions. Original events, message ids, sources, and unaffected blocks remain unchanged. Resume, fork, and replay apply the same selections from the log. + +Selection, budgets, and retry policy stay in the compaction plugin. Session owns only durable validation and reconstruction. The instance `deriveEventMessage()` and `deriveMessages()` return projected messages; pure reconstructors pass `foldSurface(events).offloadedMessages` to the exported `deriveEventMessage()`. Rewriters preserving images must consume projected messages, including tool-result pruning and summarizer input. + +`contentGeneration` advances for message replacements and image-offload events so cached history and request series are refreshed. `replaceGeneration` advances only for replacements; image omission cannot masquerade as successful text compaction. An unchanged request envelope gets a `request/header` with reason `series`; a simultaneous envelope change carries `startsSeries: true`. + +Adapters prepare only retained images and render marked occurrences as `offloadedImageText` with the current execution-world access path. Route-local budget checks report required counts, including the tighter inline fallback budget, but do not edit history. These checks are provisional: a provider failure that identifies uncacheable images can replace local counting without moving the durable decision into the adapter. + +Token accounting applies selections to the affected nodes' image occurrences without replacing their identities or changing prior usage-anchor snapshots. Route pricing substitutes placeholder text for image cost. The fixed structural heuristic excludes the `offloaded` marker, so image omission does not change that heuristic or require a `compaction/prune` shadow price. The context-pressure and context-breakdown checkpoint versions advance to reject cached estimates that counted the marker. + +`image/offload` is required on read. Its addition changes the recognized event vocabulary, not the Session envelope or operation grammar, so it does not advance `SESSION_FORMAT_VERSION`. A reader without this event type refuses the log instead of silently restoring omitted images. + +## Alternatives considered + +**Reuse `compaction/prune` and `surfaceOp: replace`.** The [archived replacement design](../../archived/architecture/2026-09-02-durable-image-offload.md) records full carrying-message copies and couples omission to node replacement and shadow-price accounting. A dedicated event expresses which images are omitted while retaining message identity and keeping retry policy in the same plugin. + +**Record a request-local projection outcome.** A record that does not affect subsequent derivation cannot prevent omitted images from returning. Logging whole request bodies repeats message history for a decision that needs only occurrence indexes. + +**Use an attachment id or a single watermark.** Attachments can occur repeatedly, and surface order can differ from event-sequence order. Explicit per-node indexes distinguish these cases without redefining a watermark after every replacement. + +**Move selection into the loop or adapters.** The request-error extension already owns repair and retry. The adapter knows the route budget; the plugin knows the session input order. Neither requires route-specific policy in the shared loop. + +## Consequences + +Omission remains effective when a route budget grows, an inline fallback ends, or compaction reduces context. It does not delete attachment bytes. Reading the placeholder's access path creates a new image occurrence that can be retained independently. The execution-world path is still resolved during serialization, as it is for retained image handles; this event records the selected image set, not filesystem mappings. + +Session adds a required event fold and one content-generation signal. Consumers reconstructing model input must use projected messages. Human views can still show original images from immutable events. The plugin no longer requires token-meter or compaction services merely to record omission. + +## Testing + +Session tests cover nested and repeated occurrences, atomic rejection, cache invalidation, pure folding, restore, fork, and untouched source events. Plugin tests cover consecutive failures, current surface order, exhausted recovery, fresh request headers, and disposal. Token-meter tests preserve node identity and prior usage anchors while repricing only selected occurrences. Tool-result pruning and compaction tests ensure later rewrites consume the projected history. TypeScript and Python SDK expected outputs exercise the required event through shipped profiles. diff --git a/.agents/notes/implemented/architecture/2026-09-10-image-offload-events.zh.md b/.agents/notes/implemented/architecture/2026-09-10-image-offload-events.zh.md new file mode 100644 index 0000000000..7d439fc71c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-10-image-offload-events.zh.md @@ -0,0 +1,47 @@ +# Agent Note:持久图片省略事件 + +Status: implemented + +[English](2026-09-10-image-offload-events.md) | 中文 + +## 问题 + +请求可能超过图片路由的字节或图片数量预算。每次请求重新计算省略前缀,会让路由切换、内联回退或压缩恢复先前省略的图片。模型可见的图片集合因此取决于未记录的请求准备过程。[统一图片流水线](../feature/2026-08-20-unified-image-request-pipeline.zh.md)负责附件归一化与请求版本,持久省略需要单独记录决策。 + +## 决策 + +`dsh-compaction-image-offload` 在 `agent/request-error` waterfall 上负责恢复。`IMAGE_OFFLOAD_REQUIRED` 失败提供 `offloadImages`,插件按当前 surface 顺序选择该数量最旧且仍保留的输入图片出现位置,追加一个 `image/offload` 事件,再返回 `retry`。Assistant 输出图片不参与选择。没有保留的输入图片时,插件委托后续处理。其他失败不进入这项恢复。图片省略不消耗提供方重试预算,也不产生 `llm/retry`。 + +事件载荷为 `{ targets: [{ seq, imageIndexes }] }`。每个目标指向当前的 `user/message` 或 `tool/result` 节点。索引从零开始且严格递增,按内容深度优先顺序枚举所有图片,包括嵌套工具结果和先前省略的图片。相同附件 ID 的多次出现分别计数。位置替换改变 surface 顺序后,明确选择仍没有歧义,附件 ID 或原始日志前缀无法标识这个集合。 + +事件不带 `surfaceOp`,不创建消息,也不替换消息节点。Session 在提交前校验全部目标,拒绝缺失或已被遮蔽的节点、输出图片、重复目标、无效索引和已被省略的位置。重建过程派生不可变消息副本,将记录的位置标为 `ImageBlock.offloaded: true`。原始事件、消息 ID、来源和未受影响的内容块保持不变。恢复、分叉和回放从日志应用相同的选择。 + +选择策略、预算和重试策略保留在 compaction 插件中。Session 只负责持久数据校验和重建。实例方法 `deriveEventMessage()` 与 `deriveMessages()` 返回派生消息,纯重建函数将 `foldSurface(events).offloadedMessages` 传给导出的 `deriveEventMessage()`。需要保留图片的改写必须读取派生消息,包括工具结果裁剪和摘要输入。 + +消息替换和图片省略事件都会推进 `contentGeneration`,使历史缓存和请求系列刷新。`replaceGeneration` 只随替换推进,图片省略不能被判断为文本压缩成功。请求配置未变时写入原因是 `series` 的 `request/header`,配置同时变化时携带 `startsSeries: true`。 + +适配器只准备保留的图片,将带标记的位置渲染为包含当前执行环境访问路径的 `offloadedImageText`。路由本地预算检查报告所需数量,包括内联回退的更低预算,但不编辑历史。这些检查是暂时方案,未来提供方报告无法缓存的图片时,可以替代本地计数,无需把持久决策移入适配器。 + +Token 计量将选择应用到受影响节点的图片出现位置,不替换节点身份,也不修改已有用量锚点快照。路由计价以占位文本替代图片费用。固定结构启发式不计 `offloaded` 标记,因此图片省略不改变该启发式,也不需要 `compaction/prune` 的遮蔽价格。上下文压力和分类明细检查点版本递增,拒绝曾将该标记计入的缓存估算。 + +`image/offload` 是读取时必须识别的事件。它增加已知事件类型,不改变 Session 信封或操作语法,因此不推进 `SESSION_FORMAT_VERSION`。不认识该类型的读取器会拒绝日志,避免静默恢复已省略的图片。 + +## 考虑过的替代方案 + +**复用 `compaction/prune` 和 `surfaceOp: replace`。** [已归档的替换设计](../../archived/architecture/2026-09-02-durable-image-offload.md)记录携带图片的完整消息副本,把图片省略与节点替换和遮蔽价格计量绑定。专用事件表达被省略的图片,保留消息身份,重试策略仍留在同一个插件中。 + +**记录单次请求的投影结果。** 不影响后续派生的记录无法阻止省略的图片返回。记录完整请求体会重复消息历史,而决策只需要图片出现位置索引。 + +**使用附件 ID 或单个分界点。** 附件可能重复出现,surface 顺序也可能与事件序号顺序不同。逐节点记录明确索引能够区分这些情况,无需在每次替换后重新定义分界点。 + +**把选择移入循环或适配器。** 请求错误扩展点已经负责修复与重试。适配器知道路由预算,插件知道会话输入顺序,两者都不需要在共享循环中加入路由策略。 + +## 影响 + +路由预算增加、内联回退结束或压缩减少上下文时,省略仍然有效。省略不删除附件字节。读取占位文本中的访问路径会创建新的图片出现位置,可以独立保留。执行环境路径仍在序列化时解析,与保留图片的标识文本一致。这个事件记录选择的图片集合,不记录文件系统映射。 + +Session 增加必需事件的折叠和一个内容版本信号。重建模型输入的消费者必须使用派生消息。面向人的视图仍可从不可变事件显示原始图片。插件记录省略时不再需要 token-meter 或 compaction 服务。 + +## 测试 + +Session 测试覆盖嵌套和重复图片、原子拒绝、缓存失效、纯折叠、恢复、分叉及原始事件不变。插件测试覆盖连续失败、当前 surface 顺序、无图可省略时的恢复、新请求头和释放。Token-meter 测试在仅重计所选位置价格时保留节点身份和已有用量锚点。工具结果裁剪和压缩测试保证后续改写读取派生历史。TypeScript 和 Python SDK 预期输出通过发布的 profile 覆盖必需事件。 diff --git a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml index 83a9c06d66..341afbac12 100644 --- a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md -2026-08-20-unified-image-request-pipeline.md: 736c8fb7f6f58db137d2b3cb9f5a007db16dd67b -2026-08-20-unified-image-request-pipeline.zh.md: 532662291ba2c7257906a2bf4f52792d5285ec1c +2026-08-20-unified-image-request-pipeline.md: 285f380909ef744078daa429e9fc06ca609cb530 +2026-08-20-unified-image-request-pipeline.zh.md: 4d7db2871b88d6656b58e6d47dc77ab7b91cf4c4 diff --git a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md index 736c8fb7f6..285f380909 100644 --- a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md +++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md @@ -26,7 +26,7 @@ Batch admission prepares and verifies every normalized attachment once before pu The `variantId` and cache path cover the normalized attachment id, transform version, route pixel and byte budgets, and fixed encoder parameters. A new cache entry is fully decoded before publication. Cache hits use a header probe to check format, 8-bit sRGB/sRGBA facts, dimensions, and alpha without decoding the complete raster again; a mismatch regenerates the entry. DeepSeek Files and pi-ai inline base64 therefore use the same deterministic bytes for the same policy. Inline accounting uses the derived byte length after base64 expansion, not the normalized attachment byte count. Equal in-process `variantId` calls share one transform and cache write. Each caller can cancel its own wait; the shared transform is aborted only after every waiter has cancelled. Callers preserve order by applying `Promise.all` to singular `readImageRequest` calls. The local implementation runs normalization and request transforms through one FIFO limiter; `imageCompressionConcurrency` is configurable from 1 through 8 and defaults to 2. Batch publication remains sequential after every normalized attachment has been prepared. -Request-size offload is a deterministic oldest-first projection. Before reading attachments, each route uses `min(attachmentBytes, requestVersionMaxBytes)` as a conservative upper bound and removes the oldest over-budget prefix. Only retained attachments are read and transformed, so an omitted missing or corrupt object cannot block the request. A second projection uses exact derived lengths without bringing omitted images back. DeepSeek defaults to 128MiB and 600 referenced images. Its removed prefix advances past successive 64MiB byte boundaries and in 20-image count quanta, so 129 one-megabyte images remove the oldest 65, retain 64MiB, and keep that prefix stable until total history passes 192MiB. Pi-ai retains a configurable base64 request bound. Each omitted image becomes a per-image placeholder that retains its identity and access resolved for the current tool execution world, including nested tool-result images, while append-only session history keeps the original references. +Request-size offload follows the [durable image-offload decision](../architecture/2026-09-10-image-offload-events.md). Routes check retained occurrences against their request-version byte and image-count budgets and report `IMAGE_OFFLOAD_REQUIRED` with a removal count. The compaction plugin records exact oldest-first occurrences in `image/offload`; Session derivation applies the marks. Adapters prepare only retained attachments and render each marked occurrence as placeholder text with its identity and current execution-world access path, including nested tool-result images. Original message events retain the attachment references. ### Stable handles @@ -62,7 +62,7 @@ Historical attachment objects that later disappear or fail integrity verificatio ## Verification -Package tests generate 16-bit RGB and RGBA PNG fixtures, prove 8-bit conversion and clean 8-bit passthrough, retain alpha under byte pressure, stop lazy encoding after the first fitting candidate, keep the smallest ladder output above an unreachable byte target, cover square and wide 640,000-pixel projections, singleflight equal variants and uploads without shared-cancellation leaks, bound transform concurrency, preserve cache and upload identity, skip attachment reads for conservatively offloaded history, prepare batches once, reject inconsistent Files responses, refresh near-expiry ids without retrieve, recover once from single-id, multiple-id, and ambiguous stale responses, fall back to bounded all-inline requests after file resolution failure, paginate before quota deletion, normalize provider diagnostics, project text-only history, and share normal/compaction request bytes. Keyless assembled snapshots cover the real tool schemas and image request path. A credentialed test uses the built-in `deepseek-official` route and its configured endpoint, never a custom provider entry. +Package tests generate 16-bit RGB and RGBA PNG fixtures, prove 8-bit conversion and clean 8-bit passthrough, retain alpha under byte pressure, stop lazy encoding after the first fitting candidate, keep the smallest ladder output above an unreachable byte target, cover square and wide 640,000-pixel projections, singleflight equal variants and uploads without shared-cancellation leaks, bound transform concurrency, preserve cache and upload identity, skip attachment reads for durably offloaded history, prepare batches once, reject inconsistent Files responses, refresh near-expiry ids without retrieve, recover once from single-id, multiple-id, and ambiguous stale responses, fall back to bounded all-inline requests after file resolution failure, paginate before quota deletion, normalize provider diagnostics, project text-only history, and share normal/compaction request bytes. Keyless assembled snapshots cover the real tool schemas and image request path. A credentialed test uses the built-in `deepseek-official` route and its configured endpoint, never a custom provider entry. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md index 532662291b..4d7db2871b 100644 --- a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md +++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md @@ -26,7 +26,7 @@ Status: implemented `variantId` 和缓存路径覆盖规范化附件 ID、变换策略版本、路由像素和字节预算及固定编码参数。新缓存条目在发布前会完整解码。缓存命中只探测文件头,校验格式、8-bit sRGB/sRGBA、尺寸和透明通道,不会再次完整解码光栅;不匹配时会重新生成。因此,同一策略下的 DeepSeek Files 和 pi-ai 内联 base64 使用相同的确定性字节。内联计量使用派生字节经过 base64 膨胀后的长度,不使用规范化附件字节数。同一进程内相同 `variantId` 的调用共享一次变换和缓存写入。每个调用方可以取消自己的等待;只有全部等待方都取消时,共享变换才会中止。调用方对单数 `readImageRequest` 使用 `Promise.all` 保持结果顺序。本地实现通过一个 FIFO 限流器运行规范化和请求变换,`imageCompressionConcurrency` 的可配置范围为 1 至 8,默认值为 2。全部规范化附件准备完成后,批次仍按顺序发布。 -请求大小 offload 是确定性的从旧到新投影。读取附件前,每条路由先以 `min(附件字节数, 请求版本字节上限)` 作为保守上界,移除超出预算的最旧前缀。系统只读取并转换保留的附件,因此已省略的缺失或损坏对象不会阻塞请求。第二次投影使用确切派生长度,但不会重新加入已省略图片。DeepSeek 默认上限为 128MiB 和 600 张引用图片。被移除前缀会越过连续的 64MiB 字节边界,并按 20 张图片数量步长递增,因此 129 张 1MiB 图片会移除最旧的 65 张并保留 64MiB;持久历史超过 192MiB 前,该前缀保持不变。Pi-ai 保留可配置的 base64 请求上限。每张省略图片都会变成逐图占位文本,保留自己的身份和本次工具执行环境解析出的访问方式,嵌套工具结果图片也使用相同规则;追加式会话历史继续保留原始引用。 +请求大小 offload 遵循[持久图片省略决策](../architecture/2026-09-10-image-offload-events.zh.md)。路由根据请求版本字节和图片数量预算检查保留的位置,通过 `IMAGE_OFFLOAD_REQUIRED` 报告所需移除数量。Compaction 插件在 `image/offload` 中记录明确的最旧图片出现位置,Session 派生时应用标记。适配器只准备保留的附件,将每个带标记的位置渲染为包含其身份和当前执行环境访问路径的占位文本,嵌套工具结果图片也适用。原始消息事件保留附件引用。 ### 稳定句柄 @@ -62,7 +62,7 @@ Status: implemented ## Verification -包测试会生成 16-bit RGB 和 RGBA PNG fixture,验证 8-bit 转换与干净 8-bit 字节直通、字节压力下保留透明通道、首个候选合规后停止编码、字节目标不可达时保留最小阶梯产物、正方形和宽屏 640,000 像素投影、相同变体与上传 singleflight 且不会共享取消、变换并发上限、缓存与上传身份、跳过已保守 offload 的历史附件读取、批量只准备一次、Files 响应不一致、进入刷新余量时不查询远端并更新 ID、单个 ID、多个 ID 和模糊失效响应只恢复一次、文件解析失败后回退到有界全内联请求、删除配额文件前完成分页、规范化提供方诊断、纯文本投影,以及普通请求与压缩共享请求字节。无需密钥的组装快照覆盖真实工具 schema 和图片请求路径。使用凭据的测试只使用内置 `deepseek-official` 路由及其已配置端点,不使用自定义提供方条目。 +包测试会生成 16-bit RGB 和 RGBA PNG fixture,验证 8-bit 转换与干净 8-bit 字节直通、字节压力下保留透明通道、首个候选合规后停止编码、字节目标不可达时保留最小阶梯产物、正方形和宽屏 640,000 像素投影、相同变体与上传 singleflight 且不会共享取消、变换并发上限、缓存与上传身份、跳过已持久省略的历史附件读取、批量只准备一次、Files 响应不一致、进入刷新余量时不查询远端并更新 ID、单个 ID、多个 ID 和模糊失效响应只恢复一次、文件解析失败后回退到有界全内联请求、删除配额文件前完成分页、规范化提供方诊断、纯文本投影,以及普通请求与压缩共享请求字节。无需密钥的组装快照覆盖真实工具 schema 和图片请求路径。使用凭据的测试只使用内置 `deepseek-official` 路由及其已配置端点,不使用自定义提供方条目。 ## Consequences diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index d591a9fa36..a5848b07b1 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: 688341582044e72e8548c8e6b1535450793ddce4 -architecture.zh.md: 6817986015a2aa90d6fcb094da3e972ccfe08483 +architecture.md: 83394c1f3055a200d56c129154ad06c1d0fb741f +architecture.zh.md: 0f60930304677ea3d2b8dbd571a99533d56bff09 diff --git a/docs/architecture.md b/docs/architecture.md index 6883415820..83394c1f30 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -106,7 +106,7 @@ turn/end Input reaches the driver through one inbox. Some messages wake it immediately; injected context waits in the inbox until another message does. -`agent/pre-step` decides the accepted input. Listeners may rewrite or reject claimed messages; a rejected or empty first claim closes a durable turn without a step. An enter decision may set `startsRequestSeries`: the loop logs a fresh `request/header` (reason `series`, or `change` with `startsSeries: true` when the envelope also changed). Wrapping listeners preserve that declaration with `{ ...decision, messages }`. After assembly and `step/start`, `agent/request` and `prepareCall()` resolve the actual route before the system prompt and accepted users are committed; cancellation during either async phase commits neither. The prepared call capability governs prompt admission, not the preceding `request/context`. Every attempt synchronously reconciles the same rendered assembly, appends users only on the first attempt, logs header/context as needed, and derives and freezes the request before streaming the bound call. Retries do not repeat assembly or `agent/pre-step`. Surface replacements after attachment start a new request series, including during the first resumed pre-step; unchanged resume continues the series. The first admitted step reserves the system head before user messages even for an empty prompt (no wire message). The prompt travels only as `system/message` history: an empty rendering clears all active system nodes, leaving no old prompt model-visible; capable routes can append non-empty updates after the cached prefix; incapable routes and new request series consolidate non-empty prompt text at the first system node, with logged empty replacements for non-empty later system nodes ([decision](../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md); [decision rule](../packages/core/agent-loop/README.md#understand-the-implementation)). +`agent/pre-step` decides the accepted input. Listeners may rewrite or reject claimed messages; a rejected or empty first claim closes a durable turn without a step. An enter decision may set `startsRequestSeries`: the loop logs a fresh `request/header` (reason `series`, or `change` with `startsSeries: true` when the envelope also changed). Wrapping listeners preserve that declaration with `{ ...decision, messages }`. After assembly and `step/start`, `agent/request` and `prepareCall()` resolve the actual route before the system prompt and accepted users are committed; cancellation during either async phase commits neither. The prepared call capability governs prompt admission, not the preceding `request/context`. Every attempt synchronously reconciles the same rendered assembly, appends users only on the first attempt, logs header/context as needed, and derives and freezes the request before streaming the bound call. Retries do not repeat assembly or `agent/pre-step`. Surface replacements and image-offload decisions after attachment start a new request series, including during the first resumed pre-step; unchanged resume continues the series. The first admitted step reserves the system head before user messages even for an empty prompt (no wire message). The prompt travels only as `system/message` history: an empty rendering clears all active system nodes, leaving no old prompt model-visible; capable routes can append non-empty updates after the cached prefix; incapable routes and new request series consolidate non-empty prompt text at the first system node, with logged empty replacements for non-empty later system nodes ([decision](../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md); [decision rule](../packages/core/agent-loop/README.md#understand-the-implementation)). The loop sends immutable requests while keeping cancellation live. It reuses message-freeze provenance only for identities it has fully frozen; [agent-loop](../packages/core/agent-loop/README.md) owns the request construction rules. diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 6817986015..0f60930304 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -110,7 +110,7 @@ turn/end 输入通过同一个 inbox 到达驱动器。有些消息会立即唤醒它;注入的上下文会留在 inbox 中,直到另一条消息将其唤醒。 -`agent/pre-step` 决定接纳的输入。监听器可以改写或拒绝已领取消息;首次领取被拒绝或为空时,关闭不含步骤的持久轮次。enter 决策可设置 `startsRequestSeries`:循环记录新的 `request/header`(原因为 `series`,或在封装同时变化时为携带 `startsSeries: true` 的 `change`)。包装监听器通过 `{ ...decision, messages }` 保留该声明。组装与 `step/start` 之后,`agent/request` 和 `prepareCall()` 先解析实际路由,再提交系统提示词与已接纳用户消息;在任一异步阶段取消都不会提交这两者。提示词准入依据已准备调用的能力,而非先前的 `request/context`。每次尝试同步协调同一份已渲染组装结果、仅在首次尝试追加用户消息、按需记录 header/context、派生并冻结请求,再通过绑定调用发起流式请求。重试不重复组装或 `agent/pre-step`。附接后的 surface 替换开启新请求序列,包括恢复后的首次 pre-step 中发生的替换;未变化的恢复延续序列。首次接纳的步骤在用户消息之前预留系统头节点,即使提示词为空(不产生协议消息)。提示词仅通过 `system/message` 历史传递:空渲染文本清除所有生效的系统节点,模型不再看到旧提示词;具备能力的路由可在缓存前缀之后追加非空更新;不具备能力的路由与新请求序列将非空提示词文本归并到首个系统节点,并为非空的后续系统节点记录空内容替换([决策](../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md);[决策规则](../packages/core/agent-loop/README.zh.md#understand-the-implementation))。 +`agent/pre-step` 决定接纳的输入。监听器可以改写或拒绝已领取消息;首次领取被拒绝或为空时,关闭不含步骤的持久轮次。enter 决策可设置 `startsRequestSeries`:循环记录新的 `request/header`(原因为 `series`,或在封装同时变化时为携带 `startsSeries: true` 的 `change`)。包装监听器通过 `{ ...decision, messages }` 保留该声明。组装与 `step/start` 之后,`agent/request` 和 `prepareCall()` 先解析实际路由,再提交系统提示词与已接纳用户消息;在任一异步阶段取消都不会提交这两者。提示词准入依据已准备调用的能力,而非先前的 `request/context`。每次尝试同步协调同一份已渲染组装结果、仅在首次尝试追加用户消息、按需记录 header/context、派生并冻结请求,再通过绑定调用发起流式请求。重试不重复组装或 `agent/pre-step`。附接后的 surface 替换和图片省略决定开启新请求序列,包括恢复后的首次 pre-step 中发生的替换;未变化的恢复延续序列。首次接纳的步骤在用户消息之前预留系统头节点,即使提示词为空(不产生协议消息)。提示词仅通过 `system/message` 历史传递:空渲染文本清除所有生效的系统节点,模型不再看到旧提示词;具备能力的路由可在缓存前缀之后追加非空更新;不具备能力的路由与新请求序列将非空提示词文本归并到首个系统节点,并为非空的后续系统节点记录空内容替换([决策](../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md);[决策规则](../packages/core/agent-loop/README.zh.md#understand-the-implementation))。 循环发送不可变请求,同时保留实时取消能力。只有已由该循环完整冻结的消息对象身份才能复用冻结证明;[agent-loop](../packages/core/agent-loop/README.zh.md)拥有请求构造规则。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 17b10e78f5..bd7aae88b7 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 80cbbe1e52b67fb26115af236220505190e5e4b3 -config-catalog.zh.md: fbdfbb6393b39b468ee29c1ce121def4f05224a7 +config-catalog.md: 07788bc02bc0abc049a72cab978e1565a5be0453 +config-catalog.zh.md: 58229a8415a6a76d3fad160bf9c06a47d8f84a33 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 80cbbe1e52..07788bc02b 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -488,14 +488,14 @@ Source: [`packages/compaction/compaction-basic/src/types.ts:38`](../packages/com ## `@deepseek-ai/dsh-compaction-image-offload` -Requires: `agents` · `tokenMeter` +Requires: `agents` ```ts config-catalog /** The executor has no configuration; image-capable routes own their budgets. */ export type Config = Readonly> ``` -Source: [`packages/compaction/compaction-image-offload/src/index.ts:23`](../packages/compaction/compaction-image-offload/src/index.ts) +Source: [`packages/compaction/compaction-image-offload/src/index.ts:21`](../packages/compaction/compaction-image-offload/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index fbdfbb6393..58229a8415 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -490,14 +490,14 @@ export interface ModelCompactPolicyConfig extends CompactionPolicyConfig { ## `@deepseek-ai/dsh-compaction-image-offload` -需要:`agents` · `tokenMeter` +需要:`agents` ```ts config-catalog /** The executor has no configuration; image-capable routes own their budgets. */ export type Config = Readonly> ``` -来源:[`packages/compaction/compaction-image-offload/src/index.ts:23`](../packages/compaction/compaction-image-offload/src/index.ts) +来源:[`packages/compaction/compaction-image-offload/src/index.ts:21`](../packages/compaction/compaction-image-offload/src/index.ts) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 4e62dcb33d..389cb56d48 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: ee27139709884395c0ca27c92f1b6bf1c5238412 -module-graph.zh.md: 36ac7a369faebbf318f4da2210fbd8692e948f29 +module-graph.md: 8d12245db52ff973c10d5157c6cc67b6dadb71ee +module-graph.zh.md: 5ac44d3ea182b5496b5a07dc82a31cfd0a4bf1fe diff --git a/docs/module-graph.md b/docs/module-graph.md index ee27139709..8d12245db5 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -559,6 +559,9 @@ flowchart TD pkg_api_workspace_controller --> pkg_storage_domain pkg_api_workspace_controller --> pkg_typert_protocol pkg_api_workspace_controller --> pkg_workspace + pkg_compaction_image_offload --> pkg_agent + pkg_compaction_image_offload --> pkg_llm + pkg_compaction_image_offload --> pkg_session pkg_file_reference --> pkg_agent pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants @@ -920,11 +923,6 @@ flowchart TD pkg_api_settings_controller --> pkg_typert_protocol pkg_web_app --> pkg_shell_env pkg_web_app --> pkg_system_prompt - pkg_compaction_image_offload --> pkg_agent - pkg_compaction_image_offload --> pkg_compaction - pkg_compaction_image_offload --> pkg_llm - pkg_compaction_image_offload --> pkg_session - pkg_compaction_image_offload --> pkg_token_meter pkg_compaction_tool_result_pruner --> pkg_compaction pkg_compaction_tool_result_pruner --> pkg_llm pkg_compaction_tool_result_pruner --> pkg_session @@ -1348,6 +1346,7 @@ flowchart TD | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`web`](../packages/web/web) | | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`shell`](../packages/shell/shell) | | [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`host-directory-picker`](../packages/host/directory-picker), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | +| [`compaction-image-offload`](../packages/compaction/compaction-image-offload) | `compaction` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell) | @@ -1419,7 +1418,6 @@ flowchart TD | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) | | [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`credentials`](../packages/credentials/credentials), [`native-command`](../packages/util/native-command), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) | -| [`compaction-image-offload`](../packages/compaction/compaction-image-offload) | `compaction` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 36ac7a369f..5ac44d3ea1 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -561,6 +561,9 @@ flowchart TD pkg_api_workspace_controller --> pkg_storage_domain pkg_api_workspace_controller --> pkg_typert_protocol pkg_api_workspace_controller --> pkg_workspace + pkg_compaction_image_offload --> pkg_agent + pkg_compaction_image_offload --> pkg_llm + pkg_compaction_image_offload --> pkg_session pkg_file_reference --> pkg_agent pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants @@ -922,11 +925,6 @@ flowchart TD pkg_api_settings_controller --> pkg_typert_protocol pkg_web_app --> pkg_shell_env pkg_web_app --> pkg_system_prompt - pkg_compaction_image_offload --> pkg_agent - pkg_compaction_image_offload --> pkg_compaction - pkg_compaction_image_offload --> pkg_llm - pkg_compaction_image_offload --> pkg_session - pkg_compaction_image_offload --> pkg_token_meter pkg_compaction_tool_result_pruner --> pkg_compaction pkg_compaction_tool_result_pruner --> pkg_llm pkg_compaction_tool_result_pruner --> pkg_session @@ -1350,6 +1348,7 @@ flowchart TD | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`web`](../packages/web/web) | | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`shell`](../packages/shell/shell) | | [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`host-directory-picker`](../packages/host/directory-picker), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | +| [`compaction-image-offload`](../packages/compaction/compaction-image-offload) | `compaction` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell) | @@ -1421,7 +1420,6 @@ flowchart TD | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) | | [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`credentials`](../packages/credentials/credentials), [`native-command`](../packages/util/native-command), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) | -| [`compaction-image-offload`](../packages/compaction/compaction-image-offload) | `compaction` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 389c95a40b..24b8169ce9 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/persistence-catalog.md -persistence-catalog.md: 46e91f23a7aad053791df190f769ad911f334b68 -persistence-catalog.zh.md: 087915b83d4dfd5c45a1583658d6ebe091904a3f +persistence-catalog.md: 8621cd16ad625e0c73f56faf197aa095f1fa8410 +persistence-catalog.zh.md: 99589be298ea3e42285fa85476c28effcd476a6c diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 46e91f23a7..8621cd16ad 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -83,7 +83,7 @@ export type SessionEvent = { }[T] ``` -Sources: [`packages/core/session/src/types.ts:404`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:412`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:434`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:465`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:421`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:429`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:451`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:482`](../packages/core/session/src/types.ts) ## Events @@ -210,7 +210,7 @@ Source: [`packages/interaction/user-approval/src/index.ts:33`](../packages/inter 'assistant/attempt': { turn: number; step: number; stream: AssistantStreamRecord[] } ``` -Source: [`packages/core/session/src/types.ts:335`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:344`](../packages/core/session/src/types.ts) @@ -240,7 +240,7 @@ Source: [`packages/core/session/src/types.ts:335`](../packages/core/session/src/ Types: [TokenUsage](subsystems/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:321`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:330`](../packages/core/session/src/types.ts) ### `command/*` @@ -514,6 +514,26 @@ Source: [`packages/hooks/hook-protocol/src/types.ts:19`](../packages/hooks/hook- Source: [`packages/hooks/hook-protocol/src/types.ts:31`](../packages/hooks/hook-protocol/src/types.ts) +### `image/*` + + + +#### `image/offload` — log-only + +```ts persistence-catalog +/** + * Permanently omit the selected input-image occurrences from subsequent + * model requests. Each target names a current user/message or tool/result + * node; image indexes are zero-based depth-first positions within that + * message, including nested tool results and already offloaded images. + * Targets are unique and each index list is nonempty and strictly increasing. + * This event changes derived content without replacing message nodes. + */ +'image/offload': { targets: ImageOffloadTarget[] } +``` + +Source: [`packages/core/session/src/types.ts:278`](../packages/core/session/src/types.ts) + ### `llm/*` @@ -605,7 +625,7 @@ Source: [`packages/plan/plan-mode/src/index.ts:46`](../packages/plan/plan-mode/s 'request/context': RequestContext ``` -Source: [`packages/core/session/src/types.ts:377`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:386`](../packages/core/session/src/types.ts) @@ -624,7 +644,7 @@ Source: [`packages/core/session/src/types.ts:377`](../packages/core/session/src/ } ``` -Source: [`packages/core/session/src/types.ts:365`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:374`](../packages/core/session/src/types.ts) ### `sandbox/*` @@ -699,7 +719,7 @@ Source: [`packages/schedule/schedule/src/types.ts:219`](../packages/schedule/sch 'session/end-seed': { inherited?: true } ``` -Source: [`packages/core/session/src/types.ts:400`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:409`](../packages/core/session/src/types.ts) @@ -761,7 +781,7 @@ Source: [`packages/session/session-log-deepseek/src/types.ts:81`](../packages/se 'step/end': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:289`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:298`](../packages/core/session/src/types.ts) @@ -772,7 +792,7 @@ Source: [`packages/core/session/src/types.ts:289`](../packages/core/session/src/ 'step/start': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:287`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:296`](../packages/core/session/src/types.ts) ### `subagent/*` @@ -848,7 +868,7 @@ Source: [`packages/subagent/tool-subagent/src/model-selection-state.ts:17`](../p 'system/message': { turn: number; step: number; message: SystemMessage } ``` -Source: [`packages/core/session/src/types.ts:310`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:319`](../packages/core/session/src/types.ts) ### `team/*` @@ -941,7 +961,7 @@ Source: [`packages/todo/tool-todo/src/types.ts:31`](../packages/todo/tool-todo/s Types: [ToolCallId](subsystems/core.md) -Source: [`packages/core/session/src/types.ts:341`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:350`](../packages/core/session/src/types.ts) @@ -1017,7 +1037,7 @@ Source: [`packages/core/tools/src/types.ts:40`](../packages/core/tools/src/types } ``` -Source: [`packages/core/session/src/types.ts:353`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:362`](../packages/core/session/src/types.ts) ### `tool-workflow/*` @@ -1097,7 +1117,7 @@ Source: [`packages/workflow/tool-workflow/src/types.ts:47`](../packages/workflow Types: [TurnEndReason](subsystems/session.md) -Source: [`packages/core/session/src/types.ts:285`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:294`](../packages/core/session/src/types.ts) @@ -1113,7 +1133,7 @@ Source: [`packages/core/session/src/types.ts:285`](../packages/core/session/src/ 'turn/start': { turn: number } ``` -Source: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:285`](../packages/core/session/src/types.ts) ### `user/*` @@ -1132,7 +1152,7 @@ Source: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/ 'user/message': UserMessage ``` -Source: [`packages/core/session/src/types.ts:297`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:306`](../packages/core/session/src/types.ts) ### `web/*` diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 087915b83d..99589be298 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -85,7 +85,7 @@ export type SessionEvent = { }[T] ``` -来源:[`packages/core/session/src/types.ts:404`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:412`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:434`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:465`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:421`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:429`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:451`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:482`](../packages/core/session/src/types.ts) ## 事件 @@ -212,7 +212,7 @@ export type SessionEvent = { 'assistant/attempt': { turn: number; step: number; stream: AssistantStreamRecord[] } ``` -来源:[`packages/core/session/src/types.ts:335`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:344`](../packages/core/session/src/types.ts) @@ -242,7 +242,7 @@ export type SessionEvent = { 类型:[TokenUsage](subsystems/llm-streaming.zh.md) -来源:[`packages/core/session/src/types.ts:321`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:330`](../packages/core/session/src/types.ts) ### `command/*` @@ -516,6 +516,26 @@ export type SessionEvent = { 来源:[`packages/hooks/hook-protocol/src/types.ts:31`](../packages/hooks/hook-protocol/src/types.ts) +### `image/*` + + + +#### `image/offload` — log-only + +```ts persistence-catalog +/** + * Permanently omit the selected input-image occurrences from subsequent + * model requests. Each target names a current user/message or tool/result + * node; image indexes are zero-based depth-first positions within that + * message, including nested tool results and already offloaded images. + * Targets are unique and each index list is nonempty and strictly increasing. + * This event changes derived content without replacing message nodes. + */ +'image/offload': { targets: ImageOffloadTarget[] } +``` + +来源:[`packages/core/session/src/types.ts:278`](../packages/core/session/src/types.ts) + ### `llm/*` @@ -607,7 +627,7 @@ export type SessionEvent = { 'request/context': RequestContext ``` -来源:[`packages/core/session/src/types.ts:377`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:386`](../packages/core/session/src/types.ts) @@ -626,7 +646,7 @@ export type SessionEvent = { } ``` -来源:[`packages/core/session/src/types.ts:365`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:374`](../packages/core/session/src/types.ts) ### `sandbox/*` @@ -701,7 +721,7 @@ export type SessionEvent = { 'session/end-seed': { inherited?: true } ``` -来源:[`packages/core/session/src/types.ts:400`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:409`](../packages/core/session/src/types.ts) @@ -763,7 +783,7 @@ export type SessionEvent = { 'step/end': { turn: number; step: number } ``` -来源:[`packages/core/session/src/types.ts:289`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:298`](../packages/core/session/src/types.ts) @@ -774,7 +794,7 @@ export type SessionEvent = { 'step/start': { turn: number; step: number } ``` -来源:[`packages/core/session/src/types.ts:287`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:296`](../packages/core/session/src/types.ts) ### `subagent/*` @@ -850,7 +870,7 @@ export type SessionEvent = { 'system/message': { turn: number; step: number; message: SystemMessage } ``` -来源:[`packages/core/session/src/types.ts:310`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:319`](../packages/core/session/src/types.ts) ### `team/*` @@ -943,7 +963,7 @@ export type SessionEvent = { 类型:[ToolCallId](subsystems/core.zh.md) -来源:[`packages/core/session/src/types.ts:341`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:350`](../packages/core/session/src/types.ts) @@ -1019,7 +1039,7 @@ export type SessionEvent = { } ``` -来源:[`packages/core/session/src/types.ts:353`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:362`](../packages/core/session/src/types.ts) ### `tool-workflow/*` @@ -1099,7 +1119,7 @@ export type SessionEvent = { 类型:[TurnEndReason](subsystems/session.zh.md) -来源:[`packages/core/session/src/types.ts:285`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:294`](../packages/core/session/src/types.ts) @@ -1115,7 +1135,7 @@ export type SessionEvent = { 'turn/start': { turn: number } ``` -来源:[`packages/core/session/src/types.ts:276`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:285`](../packages/core/session/src/types.ts) ### `user/*` @@ -1134,7 +1154,7 @@ export type SessionEvent = { 'user/message': UserMessage ``` -来源:[`packages/core/session/src/types.ts:297`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:306`](../packages/core/session/src/types.ts) ### `web/*` diff --git a/docs/subsystems/llm-streaming.i18n.yaml b/docs/subsystems/llm-streaming.i18n.yaml index d40a3c25aa..f3e34f2660 100644 --- a/docs/subsystems/llm-streaming.i18n.yaml +++ b/docs/subsystems/llm-streaming.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/llm-streaming.md -llm-streaming.md: 087e7efc5dbe150706a0f64cc41719e2f13b297b -llm-streaming.zh.md: 04c8ed89bc21f0ffdb747ee4553e70a8fcfc7f4b +llm-streaming.md: 0971ebab7179403f7b12c08ba5ccab43ef9bd959 +llm-streaming.zh.md: 18bc1df59d51c4929a8b347bb193323dd8b50975 diff --git a/docs/subsystems/llm-streaming.md b/docs/subsystems/llm-streaming.md index 087e7efc5d..0971ebab71 100644 --- a/docs/subsystems/llm-streaming.md +++ b/docs/subsystems/llm-streaming.md @@ -247,9 +247,8 @@ interface LlmFailure { /** * With code `IMAGE_OFFLOAD_REQUIRED`: how many more of the oldest retained * image occurrences the route needs offloaded before the same request fits - * its exact byte accounting. `dsh-compaction-image-offload` replaces the - * surface nodes carrying that many oldest retained occurrences with copies - * marked `offloaded` and retries the step. + * its exact byte accounting. `dsh-compaction-image-offload` records the + * selected occurrences in an `image/offload` event and retries the step. */ readonly offloadImages?: number } @@ -257,7 +256,7 @@ interface LlmFailure { ## Request-image pricing -An adapter whose provider charges visual tokens for request images declares per-route pricing by overriding `LlmAdapter.imageRequestPricing`, and `ctx.llm.imageRequestPricing(provider, model)` resolves it synchronously for consumers. The token meter resolves the routed model's pricing on every measurement so compaction pressure, retention, and range selection price image history as the routed request actually sends it; the DeepSeek adapter prices each retained occurrence at its per-model pixel-budget projection with the published v4 vision accounting and each occurrence a surface replacement marks offloaded as its placeholder text, while provider usage remains the authoritative anchor for completed requests. +An adapter whose provider charges visual tokens for request images declares per-route pricing by overriding `LlmAdapter.imageRequestPricing`, and `ctx.llm.imageRequestPricing(provider, model)` resolves it synchronously for consumers. The token meter resolves the routed model's pricing on every measurement so compaction pressure, retention, and range selection price image history as the routed request actually sends it; the DeepSeek adapter prices each retained occurrence at its per-model pixel-budget projection with the published v4 vision accounting and each occurrence selected by a logged image-offload decision as its placeholder text, while provider usage remains the authoritative anchor for completed requests. ```ts type-equiv /** diff --git a/docs/subsystems/llm-streaming.zh.md b/docs/subsystems/llm-streaming.zh.md index 04c8ed89bc..18bc1df59d 100644 --- a/docs/subsystems/llm-streaming.zh.md +++ b/docs/subsystems/llm-streaming.zh.md @@ -249,9 +249,8 @@ interface LlmFailure { /** * With code `IMAGE_OFFLOAD_REQUIRED`: how many more of the oldest retained * image occurrences the route needs offloaded before the same request fits - * its exact byte accounting. `dsh-compaction-image-offload` replaces the - * surface nodes carrying that many oldest retained occurrences with copies - * marked `offloaded` and retries the step. + * its exact byte accounting. `dsh-compaction-image-offload` records the + * selected occurrences in an `image/offload` event and retries the step. */ readonly offloadImages?: number } @@ -259,7 +258,7 @@ interface LlmFailure { ## 请求图片定价 -提供方对请求图片收取视觉 token 的适配器通过覆写 `LlmAdapter.imageRequestPricing` 声明按路由的定价,消费方经 `ctx.llm.imageRequestPricing(provider, model)` 同步解析。token 计量服务在每次计量时解析路由模型的定价,使 compaction 的压力、保留与选段都按路由请求实际发送的形式为图片历史计价;DeepSeek 适配器按模型像素预算的投影用官方公布的 v4 视觉计量为每个保留的出现位置定价,并把表层替换标记为已省略的出现位置按其占位文本定价,已完成请求仍以 provider usage 为权威锚点。 +提供方对请求图片收取视觉 token 的适配器通过覆写 `LlmAdapter.imageRequestPricing` 声明按路由的定价,消费方经 `ctx.llm.imageRequestPricing(provider, model)` 同步解析。token 计量服务在每次计量时解析路由模型的定价,使 compaction 的压力、保留与选段都按路由请求实际发送的形式为图片历史计价;DeepSeek 适配器按模型像素预算的投影用官方公布的 v4 视觉计量为每个保留的出现位置定价,并把日志中的图片省略决策选中的出现位置按其占位文本定价,已完成请求仍以 provider usage 为权威锚点。 ```ts type-equiv /** diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index c7296b9ca5..c3144812e7 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session.md -session.md: 2b99ca8267d35978f245e6bdbf020226c976e363 -session.zh.md: e6cff2d239caf7cd99be13463b412479a6f6e79b +session.md: f29790c1667e40f5d0307529941b75759743b394 +session.zh.md: 696970d2305544cafcf3c01bdb8d1503e9cee8f9 diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index 2b99ca8267..f29790c166 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -25,6 +25,15 @@ interface UserMessage extends Message { * compact raw streams so persistence stores one durable settlement per attempt. */ interface SessionEventMap { + /** + * Permanently omit the selected input-image occurrences from subsequent + * model requests. Each target names a current user/message or tool/result + * node; image indexes are zero-based depth-first positions within that + * message, including nested tool results and already offloaded images. + * Targets are unique and each index list is nonempty and strictly increasing. + * This event changes derived content without replacing message nodes. + */ + 'image/offload': { targets: ImageOffloadTarget[] } /** * Opens turn `turn` before the loop claims queued input or runs pre-step. * Rejection, empty input, cancellation, or failure may close it with no @@ -339,6 +348,18 @@ Required for `SurfaceEventType` events — every message-producing event must de `assistant/message` cannot carry `sourceEventSeqs`; its `stream` owns exact provider evidence. Other surface events omit the field when they cite no earlier event and use a complete non-empty list when they do. +`image/offload` uses exact per-node indexes. It changes derived input content without adding a surface node; `contentGeneration` advances so request series and cached derivation are refreshed. Reconstructors apply `foldSurface(events).offloadedMessages` through `deriveEventMessage(event, offloadedMessages)`. + +```ts type-equiv +/** Exact input-image occurrences selected by one durable offload decision. */ +interface ImageOffloadTarget { + /** Current message-producing event containing these occurrences. */ + seq: SessionSeq + /** Zero-based depth-first image indexes within the immutable message. */ + imageIndexes: number[] +} +``` + ### `SessionSurface` — the live readonly surface projection `Session.surface` returns the session's stable `SessionSurface` view. The same incremental manager validates append candidates before commit and advances this projection from committed events; callers can observe membership and replacement generation but cannot invoke validation. @@ -352,6 +373,8 @@ interface SessionSurface { readonly nodes: readonly SessionSeq[] /** Monotonic count of committed positional replacements. */ readonly replaceGeneration: number + /** Monotonic count of committed replacements and image-offload decisions. */ + readonly contentGeneration: number } ``` @@ -380,6 +403,8 @@ interface SurfaceFoldResult { nodes: SessionSeq[] /** Replacement operations in event order. */ replacements: SurfaceFoldReplacement[] + /** Immutable image-offloaded messages, keyed by their original event sequences. */ + offloadedMessages: ReadonlyMap } ``` @@ -566,21 +591,21 @@ declare class Session { * append records its `surfaceOp`, so a raw event with no marker (a chunk, a * turn boundary) is correctly absent, and a compaction `replace` deletes the * shadowed nodes from the derivation. The projection rules are - * {@link deriveEventMessage}, folded per node. + * {@link deriveEventMessage}, with logged image-offload selections applied + * without changing node membership or message identity. * - * CACHED: each surface node is projected exactly once, when first seen — a - * call costs O(new nodes), and a surface rewrite (a `replace`; - * {@link SessionSurface.replaceGeneration}) rebuilds. The returned array is + * CACHED: pure tail growth costs O(new nodes); a replacement or image offload + * ({@link SessionSurface.contentGeneration}) rebuilds. The returned array is * a fresh snapshot per call (later appends never grow an array a caller * already holds); the `Message` objects in it are SHARED and **deep-frozen**. - * Their content reuses the already frozen durable event data, so the cache - * needs no second deep clone and consumers still cannot mutate the log. + * Unchanged content reuses frozen event data; offloaded blocks are frozen + * derived copies. Consumers cannot mutate the log through either form. * @returns a fresh array of the shared, frozen derived history. */ deriveMessages(): Message[]; /** - * Instance face of the pure per-node `deriveEventMessage` export from - * `surface.ts`. + * Project one event with all committed image-offload selections applied. + * The original durable event remains unchanged. * @param event - the event to project. * @returns the derived message, or null when the event produces none. */ diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index e6cff2d239..696970d230 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -25,6 +25,15 @@ interface UserMessage extends Message { * compact raw streams so persistence stores one durable settlement per attempt. */ interface SessionEventMap { + /** + * Permanently omit the selected input-image occurrences from subsequent + * model requests. Each target names a current user/message or tool/result + * node; image indexes are zero-based depth-first positions within that + * message, including nested tool results and already offloaded images. + * Targets are unique and each index list is nonempty and strictly increasing. + * This event changes derived content without replacing message nodes. + */ + 'image/offload': { targets: ImageOffloadTarget[] } /** * Opens turn `turn` before the loop claims queued input or runs pre-step. * Rejection, empty input, cancellation, or failure may close it with no @@ -341,6 +350,18 @@ type SurfaceIntent = { `assistant/message` 不能携带 `sourceEventSeqs`;它的 `stream` 拥有精确 provider 证据。其他 surface event 不引用较早 event 时省略该字段,需要引用时使用完整非空 list。 +`image/offload` 使用逐节点的明确索引,不增加 surface 节点,但改变派生输入内容。`contentGeneration` 推进,使请求系列和派生缓存刷新。重建函数通过 `deriveEventMessage(event, offloadedMessages)` 应用 `foldSurface(events).offloadedMessages`。 + +```ts type-equiv +/** Exact input-image occurrences selected by one durable offload decision. */ +interface ImageOffloadTarget { + /** Current message-producing event containing these occurrences. */ + seq: SessionSeq + /** Zero-based depth-first image indexes within the immutable message. */ + imageIndexes: number[] +} +``` + ### `SessionSurface`:实时只读 surface 投影 `Session.surface` 返回会话稳定的 `SessionSurface` 视图。同一个增量管理器在提交前校验追加候选事件,并根据已提交事件推进该投影;调用方可以观察成员关系和替换代次,但不能调用校验。 @@ -354,6 +375,8 @@ interface SessionSurface { readonly nodes: readonly SessionSeq[] /** Monotonic count of committed positional replacements. */ readonly replaceGeneration: number + /** Monotonic count of committed replacements and image-offload decisions. */ + readonly contentGeneration: number } ``` @@ -382,6 +405,8 @@ interface SurfaceFoldResult { nodes: SessionSeq[] /** Replacement operations in event order. */ replacements: SurfaceFoldReplacement[] + /** Immutable image-offloaded messages, keyed by their original event sequences. */ + offloadedMessages: ReadonlyMap } ``` @@ -568,21 +593,21 @@ declare class Session { * append records its `surfaceOp`, so a raw event with no marker (a chunk, a * turn boundary) is correctly absent, and a compaction `replace` deletes the * shadowed nodes from the derivation. The projection rules are - * {@link deriveEventMessage}, folded per node. + * {@link deriveEventMessage}, with logged image-offload selections applied + * without changing node membership or message identity. * - * CACHED: each surface node is projected exactly once, when first seen — a - * call costs O(new nodes), and a surface rewrite (a `replace`; - * {@link SessionSurface.replaceGeneration}) rebuilds. The returned array is + * CACHED: pure tail growth costs O(new nodes); a replacement or image offload + * ({@link SessionSurface.contentGeneration}) rebuilds. The returned array is * a fresh snapshot per call (later appends never grow an array a caller * already holds); the `Message` objects in it are SHARED and **deep-frozen**. - * Their content reuses the already frozen durable event data, so the cache - * needs no second deep clone and consumers still cannot mutate the log. + * Unchanged content reuses frozen event data; offloaded blocks are frozen + * derived copies. Consumers cannot mutate the log through either form. * @returns a fresh array of the shared, frozen derived history. */ deriveMessages(): Message[]; /** - * Instance face of the pure per-node `deriveEventMessage` export from - * `surface.ts`. + * Project one event with all committed image-offload selections applied. + * The original durable event remains unchanged. * @param event - the event to project. * @returns the derived message, or null when the event produces none. */ diff --git a/packages/compaction/compaction-basic/tests/manual-compaction.spec.ts b/packages/compaction/compaction-basic/tests/manual-compaction.spec.ts index 9a118cd5a9..6bae4a86c7 100644 --- a/packages/compaction/compaction-basic/tests/manual-compaction.spec.ts +++ b/packages/compaction/compaction-basic/tests/manual-compaction.spec.ts @@ -237,6 +237,28 @@ function compactEvents(session: Session): SessionEvent[] { } describe('compactNow through the real loop', () => { + it('passes logged image omissions to the summarizer without changing the original message', async () => { + const { ctx, agent, compact } = await loopHarness() + try { + agent.followup(createUserMessage({ + content: [{ type: 'text', text: PROMPT }, { + type: 'image', + attachment: { attachmentId: `sha256:${'a'.repeat(64)}` as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, + }], + source: { kind: 'user' }, + })) + await agent.whenIdle() + const source = agent.session.snapshotEvents().find(event => event.type === 'user/message')! + agent.session.append('image/offload', { targets: [{ seq: source.seq, imageIndexes: [0] }] }) + expect(await compact.compactNow(agent, SIGNAL)).not.toBeNull() + const image = compact.calls[0]?.messages.flatMap(message => message.content).find(block => block.type === 'image') + expect(image).toMatchObject({ offloaded: true }) + expect(JSON.stringify(source)).not.toContain('offloaded') + } finally { + await ctx.fiber.dispose() + } + }) + it('holds a prompt accepted during summarization until the standalone bracket is flushed', async () => { const harness = await loopHarness() const { agent, compact, adapter, log } = harness diff --git a/packages/compaction/compaction-image-offload/README.i18n.yaml b/packages/compaction/compaction-image-offload/README.i18n.yaml index d1e93ee24a..83e8e5a47b 100644 --- a/packages/compaction/compaction-image-offload/README.i18n.yaml +++ b/packages/compaction/compaction-image-offload/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/compaction/compaction-image-offload/README.md -README.md: 7c349e7f7d205108d9348485eb0bc988aa5af091 -README.zh.md: ba51fc001fbf57efdf1bd62f01cfd4403a85668a +README.md: a34b8c43d0c1de2afa9d4bd4c9638c75bed98f51 +README.zh.md: 9d6c309eed697fcccde043fd87447bf1499410ec diff --git a/packages/compaction/compaction-image-offload/README.md b/packages/compaction/compaction-image-offload/README.md index 7c349e7f7d..a34b8c43d0 100644 --- a/packages/compaction/compaction-image-offload/README.md +++ b/packages/compaction/compaction-image-offload/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Image-heavy conversations continue when older images exceed a model route's budget. The plugin permanently replaces those images with text naming each attachment and its read-only path, then retries the request without spending the provider retry budget. Later requests retain that choice across route changes, resume, and replay. Token accounting follows the replacements, and provider cache reuse ends at the first changed message. +Image-heavy conversations continue when older images exceed a model route's budget. The plugin permanently replaces those images with text naming each attachment and its available read-only path, then retries without spending the provider retry budget. Later requests retain that choice across route changes, resume, and replay. Token accounting follows the logged selections, and provider cache reuse ends at the first changed message. ## Table of Contents @@ -25,7 +25,7 @@ Image-heavy conversations continue when older images exceed a model route's budg ## Use this package -Mount this plugin in every composition that runs the agent loop with an image-capable route and the token meter. The shipped `dsh` base does. Without it, an `IMAGE_OFFLOAD_REQUIRED` failure reaches ordinary recovery and ends the turn as an error. The plugin has no configuration: the DeepSeek adapter enforces its file-mode and inline-fallback budgets, the pi-ai adapter its base64 bound, and each reports the count it needs offloaded. +Mount this plugin in every composition that runs the agent loop with an image-capable route. The shipped `dsh` base does. Without it, an `IMAGE_OFFLOAD_REQUIRED` failure reaches ordinary recovery and ends the turn as an error. The plugin has no configuration: the DeepSeek adapter enforces its file-mode and inline-fallback budgets, the pi-ai adapter its base64 bound, and each reports the count it needs offloaded. ### Minimal configuration @@ -35,7 +35,7 @@ Mount this plugin in every composition that runs the agent loop with an image-ca ### What you can observe -Each offload appends, per replaced node, one `compaction/prune` event carrying the node's heuristic token price and then the replacement node itself: a `user/message` or `tool/result` copy of the original with the affected image blocks marked `offloaded: true`, a `surfaceOp` of `replace`, and `sourceEventSeqs` naming the original. The original event stays in the log untouched. The retried request follows; the loop logs a fresh `request/header` after the surface change, as it does after any compaction. +Each decision appends one `image/offload` event identifying the selected occurrences by current message-event sequence and depth-first image index. The message events and surface node identities remain unchanged. The retried request follows a fresh `request/header` identifying a new message series. ### Failures and recovery @@ -49,9 +49,9 @@ The plugin acts only on `IMAGE_OFFLOAD_REQUIRED` failures that carry `offloadIma
Implementation internals — click to expand -The plugin is one function plugin with one `agent/request-error` listener. `offloadOldestImages()` walks `session.surface.nodes`, marks the first `offloadImages` retained occurrences across `user/message` and `tool/result` nodes, nested tool results included, and for each changed node appends the `compaction/prune` shadow price followed by the marked copy under `surfaceOp: { op: 'replace' }`. It reuses the compaction seam's events and the session's replacement mechanism; no session or agent-loop change exists for it. +The recovery listener owns selection and retry policy. It reads Session's projected messages, selects the oldest retained input images in request order, and commits the complete selection in one event. Session validates and applies those exact references during append and replay; its shared derivation supplies requests, compaction, and later message rewrites. Token measurement folds the same selections without replacement shadow prices. -No runtime invariant companion is published: Session validates each replacement's surface metadata, and the `compaction/prune` protocol is owned by the compaction seam's companion. +No runtime invariant companion is published: Session rejects invalid or repeated image references before commit, and this executor retains no separate mutable offload state.
@@ -60,9 +60,9 @@ No runtime invariant companion is published: Session validates each replacement' ## Further Exploration -- [Durable image offload by surface replacement](../../../.agents/notes/implemented/architecture/2026-09-02-durable-image-offload.md) — the decision this executor implements and the alternatives it replaced. -- [compaction seam](../compaction/README.md) — the `compaction/prune` shadow-price protocol. -- [compaction-tool-result-pruner](../compaction-tool-result-pruner/README.md) — the sibling executor that trims tool outputs by the same replacement mechanism. +- [Dedicated image-offload events](../../../.agents/notes/implemented/architecture/2026-09-10-image-offload-events.md) — durable selections, ownership, and rejected alternatives. +- [compaction seam](../compaction/README.md) — the neighboring summary and text-pruning operations. +- [compaction-tool-result-pruner](../compaction-tool-result-pruner/README.md) — the sibling executor that trims tool outputs while preserving image selections. - [dsh-llm](../../llm/llm/README.md) — `ImageBlock.offloaded`, `IMAGE_OFFLOAD_REQUIRED`, and the placeholder projection. - [llm-deepseek adapter](../../llm/llm-deepseek/README.md) and [llm-pi-ai adapter](../../llm/llm-pi-ai/README.md) — the route budgets that report offload counts. @@ -75,15 +75,15 @@ No runtime invariant companion is published: Session validates each replacement' #### What the model sees -Every image occurrence a replacement marked reaches the model as the route's placeholder text (`offloadedImageText`) naming the attachment and its read-only path instead of the image; unmarked occurrences stay images. The set never shrinks on its own, so the model can rely on an offloaded image staying offloaded and read it back through the path when it needs the content again. +Every selected image occurrence reaches the model as placeholder text (`offloadedImageText`) naming the attachment and its available read-only path; unselected occurrences stay images. Selections persist across requests. A new tool read may introduce a new occurrence of the same attachment without restoring the old one. #### Token effect -An offloaded occurrence costs its placeholder text instead of visual tokens. The token meter prices the replaced node from the marks on the surface. +An offloaded occurrence costs its placeholder text instead of visual tokens. The token meter applies the logged selections when pricing each node. Reference-only heuristic counts exclude offload metadata. #### KV Cache effect -A replacement turns earlier images into placeholder text, so provider cache reuse ends at the first replaced message for that request. Because the replacement never reverts, the prefix stays stable afterwards. +A selection turns earlier images into placeholder text, so provider cache reuse ends at the first changed image for that request. The selected occurrences remain omitted afterwards. ## Known Limitations and Deferred Work diff --git a/packages/compaction/compaction-image-offload/README.zh.md b/packages/compaction/compaction-image-offload/README.zh.md index ba51fc001f..9d6c309eed 100644 --- a/packages/compaction/compaction-image-offload/README.zh.md +++ b/packages/compaction/compaction-image-offload/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -当较早的图片超出模型路由的预算时,图片密集的会话仍可继续。本插件永久将这些图片替换为注明附件及其只读路径的文本,然后重试请求,不消耗提供方重试预算。后续请求在切换路由、恢复和回放时都保留这一选择。token 计量随替换更新,提供方缓存只能复用到第一条被修改的消息之前。 +当较早的图片超出模型路由的预算时,图片密集的会话仍可继续。本插件永久将这些图片替换为注明附件及其可用只读路径的文本,然后重试,不消耗提供方重试预算。后续请求在切换路由、恢复和回放时都保留这一选择。token 计量随日志记录的选择更新,提供方缓存只能复用到第一条被修改的消息之前。 ## 目录 @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -凡是运行 agent loop、带有支持图片的路由并挂了 token meter 的组合,都应挂载本插件,随附的 `dsh` 基础配置已经挂载。没有它,`IMAGE_OFFLOAD_REQUIRED` 失败会进入普通恢复并以错误结束该轮次。本插件没有配置:DeepSeek adapter 执行其 file 模式和内联回退预算,pi-ai adapter 执行其 base64 上限,各自上报需要省略的数量。 +凡是运行 agent loop(智能体循环)并带有支持图片的路由的组合,都应挂载本插件,随附的 `dsh` 基础配置已经挂载。没有它,`IMAGE_OFFLOAD_REQUIRED` 失败会进入普通恢复并以错误结束该轮次。本插件没有配置:DeepSeek 适配器执行其 file 模式和内联回退预算,pi-ai 适配器执行其 base64 上限,各自上报需要省略的数量。 ### 最小可用组合 @@ -35,7 +35,7 @@ kind: "package-reference" ### 你可以观察到什么 -每次省略会为每个被替换节点追加一条携带该节点启发式 token 价格的 `compaction/prune` 事件,紧接着追加替换节点本身:原消息的 `user/message` 或 `tool/result` 副本,受影响的图片块标了 `offloaded: true`,`surfaceOp` 为 `replace`,`sourceEventSeqs` 指向原节点。原事件原封不动留在日志里。随后是重试的请求;表层变化后 loop 会像任何一次 compaction 之后一样记录新的 `request/header`。 +每次决定追加一条 `image/offload` 事件,通过当前消息事件的序号和消息内深度优先的图片序号指定省略位置。消息事件和表层节点标识保持不变。重试请求前会追加新的 `request/header`,标明新的消息序列。 ### 失败与恢复 @@ -49,9 +49,9 @@ kind: "package-reference"
实现内部——点击展开 -本插件是只有一个 `agent/request-error` 监听器的函数插件。`offloadOldestImages()` 遍历 `session.surface.nodes`,在 `user/message` 和 `tool/result` 节点(含嵌套工具结果)中给前 `offloadImages` 个保留的出现位置打标记,对每个有变化的节点先追加 `compaction/prune` 影子价格,再以 `surfaceOp: { op: 'replace' }` 追加带标记的副本。它复用 compaction seam 的事件和 session 的替换机制,没有为它改动 session 或 agent loop。 +恢复监听器负责选图和重试策略。它读取 Session 派生的消息,按请求顺序选取最早的保留输入图片,并用一条事件提交本次选择。Session 在追加和回放时校验并应用这些确切引用,共享派生结果供请求、压缩(compaction)和后续消息改写使用。token 测量应用同一份选择,不依赖替换影子价格。 -本包不发布运行时 invariant 伴生插件:Session 校验每次替换的表层元数据,`compaction/prune` 协议由 compaction seam 的伴生插件负责。 +本包不发布运行时 invariant 伴生插件:Session 在提交前拒绝无效或重复省略的图片引用,执行器不维护独立可变的省略状态。
@@ -60,9 +60,9 @@ kind: "package-reference" ## 进一步探索 -- [以表层替换实现持久的图片 offload](../../../.agents/notes/implemented/architecture/2026-09-02-durable-image-offload.zh.md)——本执行器实现的决定及其替代的方案。 -- [compaction seam](../compaction/README.zh.md)——`compaction/prune` 影子定价协议。 -- [compaction-tool-result-pruner](../compaction-tool-result-pruner/README.zh.md)——用同一替换机制修剪工具输出的兄弟执行器。 +- [独立的图片省略事件](../../../.agents/notes/implemented/architecture/2026-09-10-image-offload-events.zh.md),记录持久选择、职责和被否决的方案。 +- [compaction seam](../compaction/README.zh.md),相邻的摘要和文本剪枝操作。 +- [compaction-tool-result-pruner](../compaction-tool-result-pruner/README.zh.md),保留图片选择并修剪工具输出的兄弟执行器。 - [dsh-llm](../../llm/llm/README.zh.md)——`ImageBlock.offloaded`、`IMAGE_OFFLOAD_REQUIRED` 与占位投影。 - [llm-deepseek 适配器](../../llm/llm-deepseek/README.zh.md)与 [llm-pi-ai 适配器](../../llm/llm-pi-ai/README.zh.md)——上报省略数量的路由预算。 @@ -75,15 +75,15 @@ kind: "package-reference" #### 模型看到的内容 -替换标记过的每个图片出现位置,以路由的占位文本(`offloadedImageText`)到达模型,文本注明附件及其只读路径而不是图片本身;未标记的出现位置仍是图片。该集合不会自行缩小,模型可以确信被省略的图片会一直被省略,再需要内容时通过路径读回。 +每个选中的图片出现位置以占位文本(`offloadedImageText`)到达模型,文本注明附件及其可用只读路径,未选中的位置仍是图片。选择在后续请求中持续生效。新的工具读取可以引入同一附件的新出现位置,不会恢复旧位置。 #### Token 影响 -被省略的出现位置只花费占位文本,不再花费视觉 token。token meter 按表层上的标记为被替换节点定价。 +被省略的出现位置只花费占位文本,不再花费视觉 token。token meter 为每个节点定价时应用日志记录的选择。仅针对引用的启发式计数不计入省略元数据。 #### KV Cache 影响 -一次替换把较早的图片换成占位文本,该请求的 provider 缓存复用因此止于第一条被替换的消息。替换永不回退,之后的前缀保持稳定。 +一次选择把较早的图片换成占位文本,该请求的提供方缓存复用因此止于第一张被修改的图片。所选位置之后仍保持省略。 ## 已知限制与延期工作 diff --git a/packages/compaction/compaction-image-offload/package.json b/packages/compaction/compaction-image-offload/package.json index 395d9deb51..1839b38223 100644 --- a/packages/compaction/compaction-image-offload/package.json +++ b/packages/compaction/compaction-image-offload/package.json @@ -28,10 +28,8 @@ "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-compaction": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-token-meter": "workspace:^" + "@deepseek-ai/dsh-session": "workspace:^" }, "dependencies": { "@deepseek-ai/schemastery": "workspace:^" @@ -41,10 +39,8 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", - "@deepseek-ai/dsh-compaction": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^", - "@deepseek-ai/dsh-token-meter": "workspace:^" + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/compaction/compaction-image-offload/src/image-offload.ts b/packages/compaction/compaction-image-offload/src/image-offload.ts index 02a496b4a7..dc58279a7c 100644 --- a/packages/compaction/compaction-image-offload/src/image-offload.ts +++ b/packages/compaction/compaction-image-offload/src/image-offload.ts @@ -1,98 +1,45 @@ -/** - * The image offload replacement: mark the oldest retained image occurrences - * on the surface `offloaded` by replacing each carrying node with a marked - * copy, priced through the shared `compaction/prune` shadow-price protocol. - * A replacement never reverts, so every later request and measurement reads - * the offloaded set from the log. - * - * @module @deepseek-ai/dsh-compaction-image-offload/image-offload - */ +/** Select and log permanent image omissions in current model-request order. */ -import type { Context } from '@deepseek-ai/cordis' -import type { ContentBlock, Message, ToolResultMessage } from '@deepseek-ai/dsh-llm' -import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' -import type {} from '@deepseek-ai/dsh-compaction' -import type {} from '@deepseek-ai/dsh-token-meter' - -/** Mark the first `remaining.count` retained occurrences, keeping unchanged blocks by identity. */ -function markOldestImages(blocks: readonly ContentBlock[], remaining: { count: number }): readonly ContentBlock[] { - let next: ContentBlock[] | undefined - for (const [index, block] of blocks.entries()) { - if (remaining.count > 0 && block.type === 'image' && block.offloaded !== true) { - remaining.count -= 1 - next ??= blocks.slice(0, index) - next.push({ ...block, offloaded: true }) - continue - } - if (remaining.count > 0 && block.type === 'tool-result') { - const content = markOldestImages(block.content, remaining) - if (content !== block.content) { - next ??= blocks.slice(0, index) - next.push({ ...block, content: content as ContentBlock[] }) - continue - } - } - next?.push(block) - } - return next ?? blocks -} - -/** The surface node types that carry model input images. */ -type ImageNode = SessionEvent<'user/message'> | SessionEvent<'tool/result'> - -/** The message one image node derives. */ -function messageOf(event: ImageNode): Message { - return event.type === 'user/message' ? event.data : event.data.message -} +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { ImageOffloadTarget, Session } from '@deepseek-ai/dsh-session' /** - * Append the shadow price of one node and then its marked copy in place of - * the original, following the `compaction/prune` protocol so pure consumers - * subtract the replaced node's heuristic price without per-node state. - */ -function replaceNode(ctx: Context, session: Session, event: ImageNode, content: readonly ContentBlock[]): void { - session.append('compaction/prune', { - shadowedRange: { start: event.seq, end: event.seq }, - shadowedSeqs: [event.seq], - shadowedTokenCount: ctx.tokenMeter.estimateMessage(messageOf(event)), - }) - const options = { - surfaceOp: { op: 'replace' as const, startSeq: event.seq, endSeq: event.seq }, - sourceEventSeqs: [event.seq], - } - if (event.type === 'user/message') { - session.append('user/message', { ...event.data, content: content as ContentBlock[] }, options) - } else { - session.append('tool/result', { - ...event.data, - message: { ...event.data.message, content: content as ToolResultMessage['content'] }, - }, options) - } -} - -/** - * Offload the `count` oldest retained image occurrences, in model request - * order, by replacing each surface node that carries one with a copy whose - * occurrences are marked `offloaded`. Assistant nodes carry model output, not - * input images, and are skipped. - * @param ctx - the plugin context, for the token meter's heuristic price. - * @param session - the session whose surface the retried request reads. - * @param count - how many more oldest retained occurrences the adapter needs offloaded. + * Record one decision omitting the oldest retained input-image occurrences. + * Assistant nodes carry model output and are excluded. Image indexes count + * every occurrence, including previously offloaded ones, within each message. + * @param session - session whose next request applies the decision. + * @param count - additional retained occurrences the adapter needs omitted. * @returns whether any occurrence remained to offload. */ -export function offloadOldestImages(ctx: Context, session: Session, count: number): boolean { - const remaining = { count } - let replaced = false - for (const seq of [...session.surface.nodes]) { - if (remaining.count === 0) break - // oxlint-disable-next-line typescript/no-non-null-assertion -- surface nodes index the durable log +export function offloadOldestImages(session: Session, count: number): boolean { + const targets: ImageOffloadTarget[] = [] + for (const seq of session.surface.nodes) { + if (count === 0) break + // oxlint-disable-next-line typescript/no-non-null-assertion -- current surface nodes index the durable log const event = session.eventAt(seq)! if (event.type !== 'user/message' && event.type !== 'tool/result') continue - const original = messageOf(event).content - const content = markOldestImages(original, remaining) - if (content === original) continue - replaceNode(ctx, session, event, content) - replaced = true + // oxlint-disable-next-line typescript/no-non-null-assertion -- both input node types produce a message + const message = session.deriveEventMessage(event)! + const imageIndexes: number[] = [] + let imageIndex = 0 + const visit = (blocks: readonly ContentBlock[]): void => { + for (const block of blocks) { + if (count === 0) break + if (block.type === 'image') { + if (block.offloaded !== true) { + imageIndexes.push(imageIndex) + count -= 1 + } + imageIndex += 1 + } else if (block.type === 'tool-result') { + visit(block.content) + } + } + } + visit(message.content) + if (imageIndexes.length > 0) targets.push({ seq, imageIndexes }) } - return replaced + if (targets.length === 0) return false + session.append('image/offload', { targets }) + return true } diff --git a/packages/compaction/compaction-image-offload/src/index.ts b/packages/compaction/compaction-image-offload/src/index.ts index ee55cee4fc..92394190e9 100644 --- a/packages/compaction/compaction-image-offload/src/index.ts +++ b/packages/compaction/compaction-image-offload/src/index.ts @@ -1,11 +1,9 @@ /** * Image offload executor for the compaction seam. When an image-capable route - * fails a request with `IMAGE_OFFLOAD_REQUIRED`, the plugin replaces the - * surface nodes carrying the named count of oldest retained image occurrences - * with copies marked `offloaded`, prices each replaced node through the - * shared `compaction/prune` protocol, and retries the step on the - * `agent/request-error` waterfall. Every route then sends placeholder text - * for those occurrences, and the replacement never reverts. + * fails a request with `IMAGE_OFFLOAD_REQUIRED`, the plugin records one + * `image/offload` decision selecting the oldest retained input occurrences + * and retries the step on the `agent/request-error` waterfall. Every route + * sends placeholder text for those occurrences in subsequent requests. * * @module @deepseek-ai/dsh-compaction-image-offload */ @@ -17,7 +15,7 @@ import { IMAGE_OFFLOAD_REQUIRED_CODE } from '@deepseek-ai/dsh-llm' import { offloadOldestImages } from './image-offload.ts' export const name = 'compaction-image-offload' -export const inject = ['agents', 'tokenMeter'] +export const inject = ['agents'] /** The executor has no configuration; image-capable routes own their budgets. */ export type Config = Readonly> @@ -34,7 +32,7 @@ export function apply(ctx: Context, _config: Config = {}): void { ctx.on('agent/request-error', ({ agent, failure }, next): Promise => { if (failure.code !== IMAGE_OFFLOAD_REQUIRED_CODE || failure.offloadImages === undefined) return next() // A durable surface repair, not a provider retry: it spends no retry budget and logs no retry event. - if (!offloadOldestImages(ctx, agent.session, failure.offloadImages)) return next() + if (!offloadOldestImages(agent.session, failure.offloadImages)) return next() return Promise.resolve({ kind: 'retry' }) }) } diff --git a/packages/compaction/compaction-image-offload/tests/image-offload.spec.ts b/packages/compaction/compaction-image-offload/tests/image-offload.spec.ts index 07a8d454bb..c4cd576aa9 100644 --- a/packages/compaction/compaction-image-offload/tests/image-offload.spec.ts +++ b/packages/compaction/compaction-image-offload/tests/image-offload.spec.ts @@ -1,19 +1,16 @@ /** * Image offload recovery: an adapter's `IMAGE_OFFLOAD_REQUIRED` failure - * replaces the surface nodes carrying the named count of oldest images with - * marked copies, each priced by a preceding `compaction/prune` event, and - * retries the step without a retry event. + * records exact image occurrences and retries without replacing messages. */ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { createAssistantMessage, createToolResultMessage, createUserMessage, IMAGE_OFFLOAD_REQUIRED_CODE, LlmAdapter, LlmError, ToolCallId } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' -import { isReplacementSurfaceEvent, SessionId, SessionSeq } from '@deepseek-ai/dsh-session' +import { isReplacementSurfaceEvent, SessionId } from '@deepseek-ai/dsh-session' import type { Session } from '@deepseek-ai/dsh-session' -import TokenMeter from '@deepseek-ai/dsh-token-meter' import * as offload from '../src/index.ts' type ScriptEntry = StreamChunk[] | (() => never) @@ -51,14 +48,19 @@ function offloadRequired(offloadImages: number): () => never { async function harness(adapter: ScriptedAdapter): Promise { const ctx = new Context() + contexts.push(ctx) await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(TokenMeter) await ctx.plugin(Object.assign((inner: Context) => { offload.apply(inner, {}) }, { inject: offload.inject })) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) return ctx } +const contexts: Context[] = [] +afterEach(async () => { + await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) +}) + function image(name: string): Extract { return { type: 'image', @@ -85,21 +87,12 @@ function replacements(session: Session): [number, number][] { .map(event => [Number(event.sourceEventSeqs?.[0]), Number(event.seq)]) } -/** Assert each listed replacement is immediately preceded by its `compaction/prune` shadow price. */ -function expectShadowPriced(session: Session, pairs: readonly [number, number][]): void { - const events = session.snapshotEvents() - for (const [original, replacement] of pairs) { - const prune = events[replacement - 1] - expect(prune).toMatchObject({ - type: 'compaction/prune', - data: { shadowedRange: { start: original, end: original }, shadowedSeqs: [original] }, - }) - expect(prune?.type === 'compaction/prune' ? prune.data.shadowedTokenCount : 0).toBeGreaterThan(0) - } +function decisions(session: Session) { + return session.snapshotEvents().filter(event => event.type === 'image/offload') } describe('compaction-image-offload', () => { - it('replaces the node carrying the named count of images and retries without a retry event', async () => { + it('logs one exact image selection and retries without replacing the message', async () => { const adapter = new ScriptedAdapter([offloadRequired(2), textResponse('sent')]) const ctx = await harness(adapter) const agent = await ctx.agentLoop.create(SessionId('offload-required'), { provider: 'mock', model: 'mock' }) @@ -123,14 +116,16 @@ describe('compaction-image-offload', () => { const types = events.map(event => event.type) expect(types.filter(type => type === 'llm/retry')).toHaveLength(0) expect(types.filter(type => type === 'assistant/attempt')).toHaveLength(1) - // One node carried both occurrences, so one replacement lands between the failed attempt and the retry. - expect(replacements(agent.session)).toHaveLength(1) - const [original, replacement] = replacements(agent.session)[0]! + expect(replacements(agent.session)).toHaveLength(0) + expect(types).not.toContain('compaction/prune') + expect(decisions(agent.session)).toHaveLength(1) + const decision = decisions(agent.session)[0]! + const original = events.find(event => event.type === 'user/message')!.seq + expect(decision.data).toEqual({ targets: [{ seq: original, imageIndexes: [0, 1] }] }) expect(events[original]).toMatchObject({ type: 'user/message', surfaceOp: 'append' }) - expect(types.indexOf('assistant/attempt')).toBeLessThan(replacement) - expect(replacement).toBeLessThan(types.indexOf('assistant/message')) - expectShadowPriced(agent.session, replacements(agent.session)) - // The original event keeps its content; only the replacement carries the marks. + expect(types.indexOf('assistant/attempt')).toBeLessThan(decision.seq) + expect(decision.seq).toBeLessThan(types.indexOf('assistant/message')) + expect(events[decision.seq + 1]).toMatchObject({ type: 'request/header', data: { reason: 'series' } }) const durable = events[original]! expect(durable.type === 'user/message' ? durable.data.content[0] : undefined).not.toHaveProperty('offloaded') }) @@ -164,13 +159,13 @@ describe('compaction-image-offload', () => { expect(adapter.requests).toHaveLength(2) expect(offloadedNames(adapter.requests[1]!)).toEqual(['replacement', 'second']) - // Both nodes carried one occurrence, so the recovery replaced the replacement node and 'second'. - // The first replacement is the test's own; the recovery priced the two it appended. - expect(replacements(agent.session).slice(1).map(([original]) => original)).toEqual([3, 2]) - expectShadowPriced(agent.session, replacements(agent.session).slice(1)) + expect(replacements(agent.session)).toHaveLength(1) + expect(decisions(agent.session).map(event => event.data.targets)).toEqual([[ + { seq: 3, imageIndexes: [0] }, { seq: 2, imageIndexes: [0] }, + ]]) }) - it('replaces a tool result node and leaves blocks after the count untouched', async () => { + it('offloads a nested tool-result occurrence and leaves later images untouched', async () => { const adapter = new ScriptedAdapter([offloadRequired(1), textResponse('sent')]) const ctx = await harness(adapter) const agent = await ctx.agentLoop.create(SessionId('offload-tool-result'), { provider: 'mock', model: 'mock' }) @@ -205,10 +200,40 @@ describe('compaction-image-offload', () => { await agent.whenIdle() expect(offloadedNames(adapter.requests[1]!)).toEqual(['first']) - const [replacement] = replacements(agent.session) - expect(replacement).toEqual([Number(result.seq), expect.any(Number) as never]) - const replaced = agent.session.eventAt(SessionSeq(replacement![1]))! - expect(replaced.type === 'tool/result' ? replaced.data.message.source.callId : undefined).toBe(callId) + expect(replacements(agent.session)).toHaveLength(0) + expect(decisions(agent.session)[0]?.data).toEqual({ targets: [{ seq: result.seq, imageIndexes: [0] }] }) + expect(agent.session.deriveEventMessage(result)?.source).toEqual(result.data.message.source) + }) + + it('advances across consecutive failures and preserves the first request snapshot', async () => { + const adapter = new ScriptedAdapter([offloadRequired(1), offloadRequired(1), textResponse('sent')]) + const ctx = await harness(adapter) + const agent = await ctx.agentLoop.create(SessionId('offload-repeat'), { provider: 'mock', model: 'mock' }) + agent.followup(createUserMessage({ + content: [image('first'), image('second'), image('third')], source: { kind: 'user' }, + })) + await agent.whenIdle() + expect(adapter.requests.map(offloadedNames)).toEqual([[], ['first'], ['first', 'second']]) + expect(decisions(agent.session).map(event => event.data.targets[0]?.imageIndexes)).toEqual([[0], [1]]) + expect(agent.session.surface.replaceGeneration).toBe(0) + expect(agent.session.surface.contentGeneration).toBe(2) + }) + + it('removes the recovery listener when its plugin is disposed', async () => { + const ctx = new Context() + contexts.push(ctx) + await mountAgentLoopTestDependencies(ctx) + const fiber = ctx.plugin(offload) + await fiber + await fiber.dispose() + const adapter = new ScriptedAdapter([offloadRequired(1)]) + ctx.llm.registerAdapter(['mock'], adapter) + await ctx.plugin(AgentLoop, { agents: [] }) + const agent = await ctx.agentLoop.create(SessionId('offload-unloaded'), { provider: 'mock', model: 'mock' }) + agent.followup(createUserMessage({ content: [image('a')], source: { kind: 'user' } })) + await agent.whenIdle() + expect(adapter.requests).toHaveLength(1) + expect(decisions(agent.session)).toEqual([]) }) it('leaves every other failure to downstream recovery', async () => { diff --git a/packages/compaction/compaction-image-offload/tsconfig.json b/packages/compaction/compaction-image-offload/tsconfig.json index e6649c163f..0260251f8b 100644 --- a/packages/compaction/compaction-image-offload/tsconfig.json +++ b/packages/compaction/compaction-image-offload/tsconfig.json @@ -12,9 +12,7 @@ { "path": "../../../vendor/cordis" }, { "path": "../../../vendor/schemastery" }, { "path": "../../llm/llm" }, - { "path": "../../llm/token-meter" }, { "path": "../../core/session" }, - { "path": "../../core/agent" }, - { "path": "../compaction" } + { "path": "../../core/agent" } ] } diff --git a/packages/compaction/compaction-tool-result-pruner/README.i18n.yaml b/packages/compaction/compaction-tool-result-pruner/README.i18n.yaml index 91860dfaa2..5f4ff48769 100644 --- a/packages/compaction/compaction-tool-result-pruner/README.i18n.yaml +++ b/packages/compaction/compaction-tool-result-pruner/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/compaction/compaction-tool-result-pruner/README.md -README.md: a7a41b49966304278b3b3a45babfe6311667e4a5 -README.zh.md: 7e3ba252ffecc366db3af3058bfdc7c4d2e2ab83 +README.md: 7b05154bc02035bb86933e77a60904613f72bbed +README.zh.md: 06ecb2b3a63f4511cc334878b5c02358c0fad0fd diff --git a/packages/compaction/compaction-tool-result-pruner/README.md b/packages/compaction/compaction-tool-result-pruner/README.md index a7a41b4996..7b05154bc0 100644 --- a/packages/compaction/compaction-tool-result-pruner/README.md +++ b/packages/compaction/compaction-tool-result-pruner/README.md @@ -41,7 +41,7 @@ With these rows, oversized tool results are trimmed automatically as part of con ### What gets trimmed -Every tool result whose text exceeds the threshold is replaced by a trimmed version: the configured head, a short "middle pruned" marker, and the configured tail. Rich content such as images and structured blocks keeps its order. The replacement keeps the tool call, step, errors, and metadata — only the text content changes. If a replacement cannot be recorded, the run fails and the trims already applied stay in place. +Every tool result whose text exceeds the threshold is replaced by a trimmed version: the configured head, a short "middle pruned" marker, and the configured tail. Rich content such as images and structured blocks keeps its order and all logged image-offload selections. The replacement keeps the tool call, step, errors, and metadata — only the text content changes. If a replacement cannot be recorded, the run fails and the trims already applied stay in place. ### Setting the size limits diff --git a/packages/compaction/compaction-tool-result-pruner/README.zh.md b/packages/compaction/compaction-tool-result-pruner/README.zh.md index 7e3ba252ff..06ecb2b3a6 100644 --- a/packages/compaction/compaction-tool-result-pruner/README.zh.md +++ b/packages/compaction/compaction-tool-result-pruner/README.zh.md @@ -41,7 +41,7 @@ kind: "package-reference" ### 什么会被修剪 -每个文本超过阈值的工具结果都会被替换为修剪版本:配置的头部、简短的「middle pruned」标记与配置的尾部。图片与结构化块等富内容保持原有顺序。替换保留工具调用、步骤、错误与元数据——只有文本内容发生变化。如果替换无法被记录,运行会失败,已应用的修剪仍会保留。 +每个文本超过阈值的工具结果都会被替换为修剪版本:配置的头部、简短的「middle pruned」标记与配置的尾部。图片与结构化块等富内容保持原有顺序,并保留日志中的所有图片省略选择。替换保留工具调用、步骤、错误与元数据——只有文本内容发生变化。如果替换无法被记录,运行会失败,已应用的修剪仍会保留。 ### 设置大小限制 diff --git a/packages/compaction/compaction-tool-result-pruner/src/index.ts b/packages/compaction/compaction-tool-result-pruner/src/index.ts index 18c752d8f1..0d17483dde 100644 --- a/packages/compaction/compaction-tool-result-pruner/src/index.ts +++ b/packages/compaction/compaction-tool-result-pruner/src/index.ts @@ -144,13 +144,14 @@ export class ToolResultPruner extends Service { const pruned: PrunedEntry[] = [] let charsRemoved = 0 for (const { seq, event } of candidates) { - const result = event.data.message.content[0] + const original = session.deriveEventMessage(event) as ToolResultMessage + const result = original.content[0] const content = this.pruneContent(result.content) if (content === null) continue const charsBefore = this.measureContent(result.content) const charsAfter = this.measureContent(content) const message = freezeMessage({ - ...event.data.message, + ...original, content: [{ ...result, content, @@ -162,7 +163,7 @@ export class ToolResultPruner extends Service { session.append('compaction/prune', { shadowedRange: { start: seq, end: seq }, shadowedSeqs: [seq], - shadowedTokenCount: this.ctx.tokenMeter.estimateMessage(event.data.message), + shadowedTokenCount: this.ctx.tokenMeter.estimateMessage(original), }) const replacement = session.append('tool/result', { ...event.data, diff --git a/packages/compaction/compaction-tool-result-pruner/tests/tool-result-pruner.spec.ts b/packages/compaction/compaction-tool-result-pruner/tests/tool-result-pruner.spec.ts index a09d0a3c53..93e30bac5f 100644 --- a/packages/compaction/compaction-tool-result-pruner/tests/tool-result-pruner.spec.ts +++ b/packages/compaction/compaction-tool-result-pruner/tests/tool-result-pruner.spec.ts @@ -5,6 +5,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId, + SessionSeq, } from '@deepseek-ai/dsh-session' import type { SurfaceEvent } from '@deepseek-ai/dsh-session' import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant' @@ -78,6 +79,24 @@ function appendToolStep( } describe('tool-result pruning configuration', () => { + it('preserves a logged image offload when pruning the same result later', () => { + const session = Session.create(SessionId('prune-offloaded')) + const seq = appendToolStep(session, 1, 'shot', [ + { type: 'text', text: 'x'.repeat(200) }, + { type: 'image', attachment: { + attachmentId: `sha256:${'a'.repeat(64)}` as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1, + } }, + ]) + session.append('image/offload', { targets: [{ seq: SessionSeq(seq), imageIndexes: [0] }] }) + const pruned = service().pruneSession(session) + expect(pruned.pruned).toHaveLength(1) + const replacement = session.snapshotEvents().at(-1)! + expect(replacement.type).toBe('tool/result') + expect(JSON.stringify(session.deriveEventMessage(replacement))).toContain('"offloaded":true') + expect(JSON.stringify(Session.create(SessionId('restored-offloaded'), session.snapshotEvents()).deriveMessages())).toContain('"offloaded":true') + expect(JSON.stringify(session.eventAt(SessionSeq(seq)))).not.toContain('offloaded') + }) + it('resolves detached immutable defaults and partial overrides', () => { const raw = { thresholdChars: 100, headChars: 20, tailChars: 10 } const resolved = resolveConfig(raw) diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml index 2936b69d34..c28a426f41 100644 --- a/packages/core/agent-loop/README.i18n.yaml +++ b/packages/core/agent-loop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/agent-loop/README.md -README.md: cff0eb77b8464d49908bbd7b0e7a1b0658ed2f3c -README.zh.md: 94fb5406193300abc3077462407fdc8b1dd742a0 +README.md: eaa80c2c8b8511ab5b5bec306a1a6dc3922f799f +README.zh.md: 5dabc106ff208cb7d93f27d5847f326735164267 diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index cff0eb77b8..eaa80c2c8b 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -90,7 +90,7 @@ The package is the one concrete implementation of the public `Agent` contract. I ### Request headers and adapter defaults -After `agent/request`, `ctx.llm.prepareCall()` validates adapter-owned fields and resolves reasoning-effort and output-token defaults under the active turn signal. The loop retains that exact adapter through resolution, `request/header` logging, and dispatch. It writes a full header for the first request, a changed envelope (config or tools — the prompt is not part of the header), an explicit message-series start, a request after surface replacement (an in-place prompt replacement or compaction), and resume; unchanged steps, retries, and ordinary later turns in the same series inherit the latest header, and an in-history prompt append is not a replacement, so the request that follows it inherits the header too. Beside the header, the loop logs `request/context` — provider, model, `contextWindow`, and the route's `systemPromptUpdate` mode from `prepareCall()` — only when one of those differs from the latest snapshot. Before the next waterfall, the loop removes adapter-default fields so the current route resolves them again, while explicit settings persist. An unhandled route still fails with `NO_ADAPTER`. +After `agent/request`, `ctx.llm.prepareCall()` validates adapter-owned fields and resolves reasoning-effort and output-token defaults under the active turn signal. The loop retains that exact adapter through resolution, `request/header` logging, and dispatch. It writes a full header for the first request, a changed envelope (config or tools — the prompt is not part of the header), an explicit message-series start, a request after a surface replacement or image-offload decision, and resume; unchanged steps, retries, and ordinary later turns in the same series inherit the latest header, and an in-history prompt append is not a replacement, so the request that follows it inherits the header too. Beside the header, the loop logs `request/context` — provider, model, `contextWindow`, and the route's `systemPromptUpdate` mode from `prepareCall()` — only when one of those differs from the latest snapshot. Before the next waterfall, the loop removes adapter-default fields so the current route resolves them again, while explicit settings persist. An unhandled route still fails with `NO_ADAPTER`. The loop deep-freezes each derived message identity on its first request and reuses that proof only within the same agent. Restored messages keep their identity; request construction does not freeze their containing event wrappers. Each request freezes its local canonical header, fresh message array, and envelope while leaving the cancellation signal live. The [request-freeze decision](../../../.agents/notes/implemented/simplification/2026-09-06-agent-request-freeze-provenance.md) explains ownership and measurement. @@ -118,7 +118,7 @@ The loop is the production acquisition point for session write handles. When `ct The driver owns one agent for its lifetime and runs inside `ctx.agents.withInitiator(agent, ...)`. Its package-internal `ReactLoopInbox` constructor registers the standard `inbox` projection on the agent scope, then uses that projection for structural commands and loop-only claims. Registry reference counting keeps the shared key active until the last agent scope unloads. At a turn boundary it opens the durable turn, then atomically claims pending next-step input plus one queued prompt; between steps it claims only next-step input. The driver assembles the prompt and tools, projects runtime context, and runs `agent/pre-step`. A rejected decision or empty first batch opens no step. On the first attempt after acceptance, `step/start` precedes the `agent/request` waterfall and `prepareCall()`; neither async phase sees the pending system prompt or accepted users committed to history, and cancellation during either commits neither. For every attempt, the loop then synchronously reconciles the rendered prompt against the surviving `system/message` nodes using the prepared call capability, appends the accepted `user/message` batch only on the first attempt, logs the header and context as needed, and derives and freezes the request before streaming through that bound prepared call. Retries reuse the same rendered assembly without repeating assembly, `agent/pre-step`, or user admission. Reconciliation sees pre-step and retry compaction; a broken series consolidates the prompt at the head rather than appending an update after already-committed users. The request is `header.config`, `deriveMessages()`, and `header.tools`; it carries no `system` field. Each model attempt emits one process-local `start`, emits every `chunk` only after the matching durable assistant-frame settlement, and emits exactly one terminal `end`; final assembly or message-append failure settles it as `aborted`, while `committed` follows the durable `assistant/message`. Each successful model call appends one message anchor, and a cancelled stream appends an `interrupted: true` anchor with the delivered prefix so the next request contains what the user saw. Within a step, exclusive calls form barriers and parallel-safe calls use the bounded rolling pool; policy, durable results, and result context remain model-ordered. -Prompt admission uses the actual `prepareCall()` result, not the preceding `request/context`. With no system node, even an empty prompt is appended (reserving node 0 without a wire message). On an incapable route or at a new request series, a non-empty rendering is consolidated at the first system node: each non-empty later system node receives a logged per-node empty replacement, then the head is rewritten if needed. Dormant empty tails need no replacement and do not determine the effective text. Consolidation applies even when the latest effective text is unchanged. On a continuing `in-history` series, an unchanged effective prompt produces no event and a non-empty change appends. An empty rendering clears every non-empty later system node through a logged per-node empty replacement, then empties the head if needed, regardless of route or series state. No older instructions remain model-visible. An empty head with no active later system node means no prompt; repeated clears and resume leave it empty. A restored non-empty prompt follows the same route/series rule: a continuing capable route may append it, while an incapable route or new series refills the head. A step starts a series when the pre-step decision declares `startsRequestSeries`, when the surface replace generation changed since attachment or the last request (compaction or any replacement), or when visible tool schemas changed. Resume and a provider or model swap alone continue the series; the prepared route still governs admission. Per-node empty replacements preserve intervening history without a surface delete operation. +Prompt admission uses the actual `prepareCall()` result, not the preceding `request/context`. With no system node, even an empty prompt is appended (reserving node 0 without a wire message). On an incapable route or at a new request series, a non-empty rendering is consolidated at the first system node: each non-empty later system node receives a logged per-node empty replacement, then the head is rewritten if needed. Dormant empty tails need no replacement and do not determine the effective text. Consolidation applies even when the latest effective text is unchanged. On a continuing `in-history` series, an unchanged effective prompt produces no event and a non-empty change appends. An empty rendering clears every non-empty later system node through a logged per-node empty replacement, then empties the head if needed, regardless of route or series state. No older instructions remain model-visible. An empty head with no active later system node means no prompt; repeated clears and resume leave it empty. A restored non-empty prompt follows the same route/series rule: a continuing capable route may append it, while an incapable route or new series refills the head. A step starts a series when the pre-step decision declares `startsRequestSeries`, when `session.surface.contentGeneration` changed since attachment or the last request (a replacement or image-offload decision), or when visible tool schemas changed. Resume and a provider or model swap alone continue the series; the prepared route still governs admission. Per-node empty replacements preserve intervening history without a surface delete operation. ### Failure and cancellation diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md index 94fb540619..5dabc106ff 100644 --- a/packages/core/agent-loop/README.zh.md +++ b/packages/core/agent-loop/README.zh.md @@ -90,7 +90,7 @@ const handle = await ctx.agents.create({ ### 请求 header 与适配器默认值 -`agent/request` 返回后,`ctx.llm.prepareCall()` 会在活跃轮次信号下校验适配器持有的字段,并解析推理强度和输出 token 默认值。循环会在解析、`request/header` 记录与分派期间保留同一个适配器。循环会为首次请求、变化的 envelope(配置或工具——提示词不属于 header)、显式消息序列起点、surface 替换(原地替换提示词或压缩(compaction))后的请求及恢复写入完整 header;同一序列内内容未变的步骤、重试与普通后续轮次继承最新 header,历史内追加提示词不是替换,因此紧随其后的请求同样继承 header。在 header 之外,循环还会记录 `request/context`——提供方、模型、`contextWindow` 以及来自 `prepareCall()` 的路由 `systemPromptUpdate` 模式——且仅在其中任何一项与最新快照不同时记录。下一次 waterfall 分发前,循环移除适配器默认字段,使当前路由重新解析它们;显式设置则保留。未处理的路由仍以 `NO_ADAPTER` 失败。 +`agent/request` 返回后,`ctx.llm.prepareCall()` 会在活跃轮次信号下校验适配器持有的字段,并解析推理强度和输出 token 默认值。循环会在解析、`request/header` 记录与分派期间保留同一个适配器。循环会为首次请求、变化的 envelope(配置或工具——提示词不属于 header)、显式消息序列起点、surface 替换或图片省略决定后的请求及恢复写入完整 header;同一序列内内容未变的步骤、重试与普通后续轮次继承最新 header,历史内追加提示词不是替换,因此紧随其后的请求同样继承 header。在 header 之外,循环还会记录 `request/context`——提供方、模型、`contextWindow` 以及来自 `prepareCall()` 的路由 `systemPromptUpdate` 模式——且仅在其中任何一项与最新快照不同时记录。下一次 waterfall 分发前,循环移除适配器默认字段,使当前路由重新解析它们;显式设置则保留。未处理的路由仍以 `NO_ADAPTER` 失败。 循环在每个派生消息对象首次进入请求时执行深冻结,并且仅在同一 agent 内复用该证明。恢复的消息保留对象身份;构造请求不会冻结包含消息的事件包装对象。每个请求都会冻结本地规范化 header、新消息数组和请求封装,同时保留取消信号的可变性。[请求冻结决策](../../../.agents/notes/implemented/simplification/2026-09-06-agent-request-freeze-provenance.zh.md)解释了所有权与测量依据。 @@ -118,7 +118,7 @@ const handle = await ctx.agents.create({ 驱动器在其整个生命周期内拥有一个 agent,并在 `ctx.agents.withInitiator(agent, ...)` 内运行。其包内部 `ReactLoopInbox` 构造函数在 agent 作用域上注册标准 `inbox` 投影,随后将该投影用于结构化命令与仅供 loop 使用的领取操作。注册表引用计数会使共享 key 持续有效,直至最后一个 agent 作用域卸载。在轮次边界,它先打开持久轮次,再原子领取待处理的 next-step 输入与一条排队提示词;在步骤之间则只领取 next-step 输入。驱动器组装提示词与工具、投影运行时上下文,并运行 `agent/pre-step`。被拒绝的决定或空的首批输入不打开步骤。接纳后的首次尝试先记录 `step/start`,再运行 `agent/request` waterfall 与 `prepareCall()`;这两个异步阶段都看不到待提交的系统提示词与已接纳用户消息进入历史,在任一阶段取消都不会提交这两者。每次尝试时,循环随后依据已准备调用的能力,同步将渲染后的提示词与存活的 `system/message` 节点协调一致、仅在首次尝试追加已接纳的 `user/message` 批次、按需记录 header 与 context,再派生并冻结请求,通过该绑定的已准备调用发起流式请求。重试复用同一份已渲染组装结果,不重复组装、`agent/pre-step` 或用户消息准入。协调过程可见 pre-step 与重试中的压缩;序列中断时将提示词归并到头部,而非在已提交用户消息之后追加更新。请求由 `header.config`、`deriveMessages()` 与 `header.tools` 构成,不携带 `system` 字段。每次模型尝试会发出一个进程本地 `start`,仅在匹配的持久 assistant-frame 结算之后发出各个 `chunk`,并恰好发出一个终态 `end`;最终组装或消息追加失败时以 `aborted` 结算,`committed` 则出现在持久 `assistant/message` 之后。每次成功的模型调用都恰好追加一个 message 锚点,被取消的流则追加带 `interrupted: true` 的锚点并携带已交付前缀,使下一次请求包含用户看到的内容。在步骤内,独占调用形成屏障,并行安全调用使用有界滚动池;策略、持久结果与结果上下文保持模型顺序。 -提示词准入依据实际的 `prepareCall()` 结果,而非先前的 `request/context`。没有系统节点时,即使提示词为空也追加(预留第 0 号节点,但不产生协议消息)。在不具备能力的路由上或新请求序列开始时,非空渲染文本归并到首个系统节点:每个非空的后续系统节点分别收到有日志记录的空内容替换,随后按需重写头节点。未生效的空尾节点无需替换,也不决定有效文本。即使最新有效文本未变,也执行归并。延续中的 `in-history` 序列在有效提示词不变时不产生事件,非空变更则追加。无论路由或序列状态如何,空渲染文本都会通过有日志记录的逐节点空内容替换清除每个非空的后续系统节点,再按需清空头节点。模型不会继续看到旧指令。空头节点且没有生效的后续系统节点表示没有提示词;重复清除与恢复会话都保持为空。重新提供的非空提示词遵循同一路由/序列规则:延续中的具备能力路由可以追加它,不具备能力的路由或新序列则重新填充头节点。以下情况开启序列:pre-step 决定声明 `startsRequestSeries`、surface 替换 generation 自附接或上次请求以来发生变化(压缩或任何替换)、可见工具 schema 变化。恢复与单纯的提供方或模型切换都延续序列;准入仍由已准备的路由决定。逐节点的空内容替换保留其间历史,无需 surface 删除操作。 +提示词准入依据实际的 `prepareCall()` 结果,而非先前的 `request/context`。没有系统节点时,即使提示词为空也追加(预留第 0 号节点,但不产生协议消息)。在不具备能力的路由上或新请求序列开始时,非空渲染文本归并到首个系统节点:每个非空的后续系统节点分别收到有日志记录的空内容替换,随后按需重写头节点。未生效的空尾节点无需替换,也不决定有效文本。即使最新有效文本未变,也执行归并。延续中的 `in-history` 序列在有效提示词不变时不产生事件,非空变更则追加。无论路由或序列状态如何,空渲染文本都会通过有日志记录的逐节点空内容替换清除每个非空的后续系统节点,再按需清空头节点。模型不会继续看到旧指令。空头节点且没有生效的后续系统节点表示没有提示词;重复清除与恢复会话都保持为空。重新提供的非空提示词遵循同一路由/序列规则:延续中的具备能力路由可以追加它,不具备能力的路由或新序列则重新填充头节点。以下情况开启序列:pre-step 决定声明 `startsRequestSeries`、`session.surface.contentGeneration` 自附接或上次请求以来发生变化(替换或图片省略决定)、可见工具 schema 变化。恢复与单纯的提供方或模型切换都延续序列;准入仍由已准备的路由决定。逐节点的空内容替换保留其间历史,无需 surface 删除操作。 ### 失败与取消 diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 06e1f51b57..e79fcd5e40 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -99,7 +99,7 @@ export class ReactLoopAgent implements Agent { public readonly options: AgentOptions, public readonly session: Session, ) { - this.requestSurfaceGeneration = session.surface.replaceGeneration + this.requestSurfaceGeneration = session.surface.contentGeneration this.dispatch = agentEvents(loopCtx, this) this.scope = createScope(loopCtx, this) this.ctx = this.scope.ctx @@ -364,7 +364,7 @@ export class ReactLoopAgent implements Agent { const commits = this.systemPrompt.project(renderedPrompt, { inHistory: preparedCall?.systemPromptUpdate === 'in-history', startsSeries: startsRequestSeries - || this.requestSurfaceGeneration !== this.session.surface.replaceGeneration + || this.requestSurfaceGeneration !== this.session.surface.contentGeneration || this.toolsChanged(assembly.tools), }) for (const { message, intent } of commits) { @@ -558,7 +558,7 @@ export class ReactLoopAgent implements Agent { signal: AbortSignal, ): GenerateOptions { const { session } = this - const surfaceGeneration = session.surface.replaceGeneration + const surfaceGeneration = session.surface.contentGeneration const header = canonicalHeader({ config, ...preparedCall === undefined ? {} : { adapterDefaults: preparedCall.adapterDefaults }, diff --git a/packages/core/session/README.i18n.yaml b/packages/core/session/README.i18n.yaml index cd2a556c3a..7759f67faf 100644 --- a/packages/core/session/README.i18n.yaml +++ b/packages/core/session/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/session/README.md -README.md: 6ed688e475c0b1bc759d6ab8bd84e6537608bb40 -README.zh.md: fcadf48c048d8eba9a9dfb31a098eca27f2ac27b +README.md: 7b1963955dc936e3c541ef9d06a3e9f039fc0af9 +README.zh.md: 483813cb197d4f3cf87424013e9ad9e7d742756b diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 6ed688e475..7b1963955d 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -49,6 +49,8 @@ session.deriveMessages() // the derived model history Surface events (`system/message`, `user/message`, `assistant/message`, `tool/result`) require `surfaceOp` in both typed events and append input. A replacement uses exactly `{ op: 'replace', startSeq, endSeq }`, with inclusive `SessionSeq` endpoints in current surface order. An Assistant message embeds its exact compact provider stream and forbids `sourceEventSeqs`. Known log-only events forbid both metadata fields and never produce a message. +`image/offload` records exact input-image occurrences without creating or replacing a message. Session rejects missing, shadowed, output-image, or already offloaded targets before commit. `deriveMessages()` and the instance `deriveEventMessage()` apply the selections; rewrites preserving images must use these projected messages. Pure reconstructors pass `foldSurface(events).offloadedMessages` to the exported `deriveEventMessage()`. + Append, seed/restore, and event adoption/snapshot reject any `header.system` and exactly empty optional request-header fields (`tools: []`, `adapterDefaults: {}`) instead of normalizing input. Tool-result `data.error` is allowed only when `message.content[0].isError === true`; failure identity remains optional. Rejected appends do not change the log, derived state, or event feed. Adoption validates event-local metadata but not referenced history or replacement membership. `system/message` holds the rendered system prompt: the first one is surface node 0, the prepared call capability governs admission, with a non-empty rendering consolidated at the first system node on an incapable route or appended after cached history inside a continuing `in-history` series; empty system nodes project to no message, so clearing the prompt requires logged empty replacements of all active system nodes, not just the latest; the surface fold rejects a replacement covering node 0 while it is a `system/message` unless the replacing event is itself a `system/message` over exactly that node, while later system nodes carry no protection and a compaction range may shadow them ([decision](../../../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md)). @@ -81,11 +83,11 @@ This section explains how the package realizes the behavior above; the observabl ### Design concept -The package is built on event sourcing: a `Session` is an append-only log of typed `SessionEvent`s, and everything else — model history, transcripts, telemetry, titles, persistence — derives from that stream. The surface is a derived projection: an incremental manager validates append candidates, advances the ordered view from committed events, and tracks a `replaceGeneration` that bumps on every committed rewrite. Model-visible means logged: anything that reaches a model request must be reconstructable from the log. Each model attempt that reaches settlement commits one event: `assistant/message` carries the assembled model-visible message plus its compact timed stream, while `assistant/attempt` retains a failed, retried, cancelled, or stream-error attempt without adding model history. A hard process loss before settlement leaves no durable attempt stream. +The package is built on event sourcing: a `Session` is an append-only log of typed `SessionEvent`s, and everything else — model history, transcripts, telemetry, titles, persistence — derives from that stream. The surface is a derived projection: an incremental manager validates append candidates, advances the ordered view from committed events, and tracks `replaceGeneration` for positional replacements and `contentGeneration` for replacements and image-offload decisions. Model-visible means logged: anything that reaches a model request must be reconstructable from the log. Each model attempt that reaches settlement commits one event: `assistant/message` carries the assembled model-visible message plus its compact timed stream, while `assistant/attempt` retains a failed, retried, cancelled, or stream-error attempt without adding model history. A hard process loss before settlement leaves no durable attempt stream. ### Request headers -`request/header` stores a full canonical snapshot of the non-history request envelope with reason `initial`, `resume`, `change`, or `series`. An explicit message-series start or a surface replacement writes a `series` snapshot when the envelope is unchanged; a simultaneous change uses `startsSeries: true`. Same-series steps, retries, and ordinary later turns inherit the latest snapshot. `adapterDefaults` distinguishes values resolved by the adapter from explicit settings, and `foldRequestHeader()` selects the latest snapshot. This self-contained record supports partial-window rendering and exact reconstruction at the cost of growth per message series; the [reconstructable-requests Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md) owns the detail. +`request/header` stores a full canonical snapshot of the non-history request envelope with reason `initial`, `resume`, `change`, or `series`. An explicit message-series start, a surface replacement, or an image-offload decision writes a `series` snapshot when the envelope is unchanged; a simultaneous change uses `startsSeries: true`. Same-series steps, retries, and ordinary later turns inherit the latest snapshot. `adapterDefaults` distinguishes values resolved by the adapter from explicit settings, and `foldRequestHeader()` selects the latest snapshot. This self-contained record supports partial-window rendering and exact reconstruction at the cost of growth per message series; the [reconstructable-requests Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md) owns the detail. ### Source map @@ -105,7 +107,7 @@ Every append uses the shared iterative `snapshotJsonValue()` pass, which reads, ### Derived history -`deriveMessages()` caches each surface node's projection once and returns a fresh array per call over shared, deep-frozen messages; each of the four surface event types (`system/message`, `user/message`, `assistant/message`, `tool/result`) projects its own message kind — the system-role prompt (an empty-content system node projects to no message), user content verbatim, the assembled assistant message with its provider and model, or a user-role tool result. Embedded Assistant streams and `assistant/attempt` events remain replay and diagnostic data only. A surface rewrite rebuilds the projection — there is no raw-log fallback, so the surface is the single source of derived history. +`deriveMessages()` caches deep-frozen projections and returns a fresh array per call. The four surface event types (`system/message`, `user/message`, `assistant/message`, `tool/result`) supply their recorded message identities and content; an empty-content system node projects to no message. Image-offload selections mark derived input-image blocks without mutating recorded messages. Replacements and offload decisions invalidate the cache. Embedded Assistant streams and `assistant/attempt` events remain replay and diagnostic data only. ### The request header @@ -135,7 +137,7 @@ The package-level contract is enough for most consumers; read these when you nee #### What the model sees -The model receives the complete messages from `system/message`, `user/message`, `assistant/message`, and `tool/result` surface entries verbatim, the system prompt first — identities, roles, sources, and content blocks are the same values established at creation, and projections never mint identities. Direct prompts and injected context remain separate `user/message` events whose sources preserve their provenance. Embedded streams, `assistant/attempt`, boundaries, and other log-only facts add no message. +The model receives the complete messages from `system/message`, `user/message`, `assistant/message`, and `tool/result` surface entries with logged image selections applied, the system prompt first. Identities, roles, sources, and unmodified blocks retain their original values; projections never mint identities. Direct prompts and injected context remain separate `user/message` events whose sources preserve their provenance. Embedded streams, `assistant/attempt`, boundaries, and other log-only facts add no message. #### Token effect diff --git a/packages/core/session/README.zh.md b/packages/core/session/README.zh.md index fcadf48c04..483813cb19 100644 --- a/packages/core/session/README.zh.md +++ b/packages/core/session/README.zh.md @@ -49,6 +49,8 @@ session.deriveMessages() // the derived model history 表层事件(`system/message`、`user/message`、`assistant/message`、`tool/result`)在类型化事件与追加输入中都必须带有 `surfaceOp`。替换操作仅接受 `{ op: 'replace', startSeq, endSeq }`,端点为包含边界的 `SessionSeq`,按当前 surface 顺序解释。assistant 消息会嵌入精确、紧凑的提供方流,并禁止 `sourceEventSeqs`。已知仅日志事件禁止这两个元数据字段,且从不产生消息。 +`image/offload` 记录确切的输入图片出现位置,不创建或替换消息。Session 在提交前拒绝缺失、已被遮蔽、属于输出图片或已被省略的目标。`deriveMessages()` 和实例方法 `deriveEventMessage()` 应用这些选择,需要保留图片的改写必须读取这些派生消息。纯重建函数将 `foldSurface(events).offloadedMessages` 传给导出的 `deriveEventMessage()`。 + 追加、seed/restore 与事件 adoption/snapshot 会拒绝任何 `header.system` 及恰好为空的可选请求头字段(`tools: []`、`adapterDefaults: {}`),而不规范化输入。工具结果的 `data.error` 仅在 `message.content[0].isError === true` 时允许存在;失败标识仍是可选的。被拒绝的追加不会改变日志、派生状态或事件流。Adoption 校验事件局部元数据,但不校验所引用的历史或替换端点是否属于 surface。 `system/message` 承载渲染后的系统提示词:第一条是 surface 第 0 号节点,准入依据已准备调用的能力,不具备能力的路由将非空渲染文本归并到首个系统节点,延续中的 `in-history` 序列则在缓存历史之后追加;空系统节点不投影为消息,因此清除提示词必须为所有生效的系统节点记录空内容替换,而非仅替换最新节点;当第 0 号节点是 `system/message` 时,surface 折叠拒绝覆盖它的替换,除非替换事件本身是恰好覆盖该节点的 `system/message`,而后续系统节点不受保护,压缩范围可以遮蔽它们([决策](../../../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md))。 @@ -81,11 +83,11 @@ session.deriveMessages() // the derived model history ### 设计理念 -该包建立在事件溯源之上:`Session` 是类型化 `SessionEvent` 的仅追加日志,其他一切——模型历史、transcript(文本记录)、遥测、标题、持久化——都从这条流派生。surface 是派生投影:一个增量管理器校验追加候选、根据已提交事件推进有序视图,并跟踪每次已提交重写都会递增的 `replaceGeneration`。模型可见即已记录:任何到达模型请求的内容都必须能从日志重建。每个完成结算的模型尝试都会提交一个事件:`assistant/message` 携带组装后的模型可见 message 及其紧凑带时间 stream,`assistant/attempt` 则保留失败、重试、取消或 stream error attempt,且不添加模型历史。如果进程在 settlement 前硬中断,则不会留下持久 attempt stream。 +该包建立在事件溯源之上:`Session` 是类型化 `SessionEvent` 的仅追加日志,其他一切——模型历史、transcript(文本记录)、遥测、标题、持久化——都从这条流派生。surface 是派生投影:一个增量管理器校验追加候选、根据已提交事件推进有序视图,通过 `replaceGeneration` 跟踪位置替换,通过 `contentGeneration` 跟踪位置替换和图片省略决定。模型可见即已记录:任何到达模型请求的内容都必须能从日志重建。每个完成结算的模型尝试都会提交一个事件:`assistant/message` 携带组装后的模型可见 message 及其紧凑带时间 stream,`assistant/attempt` 则保留失败、重试、取消或 stream error attempt,且不添加模型历史。如果进程在 settlement 前硬中断,则不会留下持久 attempt stream。 ### 请求头 -`request/header` 存储非历史请求封装的完整规范快照,原因为 `initial`、`resume`、`change` 或 `series`。显式消息序列起点或表层替换会在请求封装不变时写入 `series` 快照;同时发生变化时使用 `startsSeries: true`。同一序列内的步骤、重试与普通后续轮次继承最新快照。`adapterDefaults` 区分由适配器解析的值与显式设置,`foldRequestHeader()` 选择最新快照。这种自包含记录以每个消息序列增加存储为代价,支持局部窗口渲染与精确重建;细节由[可重建请求 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md)负责。 +`request/header` 存储非历史请求封装的完整规范快照,原因为 `initial`、`resume`、`change` 或 `series`。显式消息序列起点、表层替换或图片省略决定会在请求封装不变时写入 `series` 快照;同时发生变化时使用 `startsSeries: true`。同一序列内的步骤、重试与普通后续轮次继承最新快照。`adapterDefaults` 区分由适配器解析的值与显式设置,`foldRequestHeader()` 选择最新快照。这种自包含记录以每个消息序列增加存储为代价,支持局部窗口渲染与精确重建;细节由[可重建请求 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md)负责。 ### 源码地图 @@ -105,7 +107,7 @@ session.deriveMessages() // the derived model history ### 派生历史 -`deriveMessages()` 把每个 surface 节点的投影缓存一次,每次调用都返回共享、深度冻结消息之上的新数组;四种 surface 事件类型(`system/message`、`user/message`、`assistant/message`、`tool/result`)各自投影自己的消息种类——system 角色的提示词(空内容的系统节点投影为无消息)、user 内容原样、带提供方与模型的组装 assistant 消息,或 user 角色的工具结果。嵌入式 Assistant stream 与 `assistant/attempt` 事件只保留回放和诊断数据。surface 重写会重建投影——不存在原始日志回退,因此 surface 是派生历史的唯一来源。 +`deriveMessages()` 缓存深度冻结的派生消息,每次调用返回新数组。四种 surface 事件类型(`system/message`、`user/message`、`assistant/message`、`tool/result`)提供记录的消息身份和内容,空内容的系统节点不派生消息。图片省略选择为派生的输入图片块添加标记,不修改记录的消息。替换和省略决策使缓存失效。嵌入式 Assistant stream 与 `assistant/attempt` 事件只保留回放和诊断数据。 ### 请求头 @@ -135,7 +137,7 @@ session.deriveMessages() // the derived model history #### 模型看到什么 -模型会原样接收 `system/message`、`user/message`、`assistant/message` 与 `tool/result` surface 条目中的完整消息,系统提示词在先——标识、角色、来源与内容块都与创建时确定的值相同,投影从不生成标识。直接提示词与注入上下文仍是彼此独立的 `user/message` 事件,各事件的来源会保留其出处。嵌入式 stream、`assistant/attempt`、边界与其他仅日志事实不会添加消息。 +模型会接收 `system/message`、`user/message`、`assistant/message` 与 `tool/result` surface 条目中的消息,并应用日志中的图片省略选择,系统提示词在先。消息标识、角色和来源保持不变,投影不生成标识。直接提示词与注入上下文仍是独立的 `user/message` 事件,各事件的来源保留其出处。嵌入式 stream、`assistant/attempt`、边界与其他仅日志事实不添加消息。 #### Token 影响 diff --git a/packages/core/session/src/image-offload.ts b/packages/core/session/src/image-offload.ts new file mode 100644 index 0000000000..831b2196dc --- /dev/null +++ b/packages/core/session/src/image-offload.ts @@ -0,0 +1,39 @@ +/** Exact application of logged image selections; selection policy belongs to compaction-image-offload. */ + +import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm' +import { deepFreeze } from '@deepseek-ai/dsh-util-values' + +/** + * Project selected image occurrences to immutable offloaded blocks. + * @param message - message projected before this decision. + * @param indexes - nonempty, strictly increasing depth-first image indexes. + * @returns an immutable message with the same identity and selected images marked. + * @throws when a selected occurrence is missing or already offloaded. + */ +export function offloadMessageImages(message: Message, indexes: readonly number[]): Message { + let imageIndex = 0 + let selected = 0 + const visit = (blocks: readonly ContentBlock[]): ContentBlock[] => { + let next: ContentBlock[] | undefined + for (const [index, block] of blocks.entries()) { + let projected = block + if (block.type === 'image') { + if (imageIndex === indexes[selected]) { + if (block.offloaded === true) throw new Error(`image/offload: image index ${imageIndex} is already offloaded`) + projected = { ...block, offloaded: true } + selected += 1 + } + imageIndex += 1 + } else if (block.type === 'tool-result') { + const content = visit(block.content) + if (content !== block.content) projected = { ...block, content } + } + if (projected !== block) next ??= blocks.slice(0, index) + next?.push(projected) + } + return next ?? blocks as ContentBlock[] + } + const content = visit(message.content) + if (selected !== indexes.length) throw new Error(`image/offload: image index ${indexes[selected]} does not exist`) + return deepFreeze({ ...message, content }) +} diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 9db8327409..c93a6fdf61 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -16,7 +16,7 @@ import type { Message } from '@deepseek-ai/dsh-llm' import { SESSION_FORMAT_VERSION, SessionLogOffset, SessionSeq } from './types.ts' import type { TypertLookup } from '@deepseek-ai/dsh-typert-protocol' import type { CreateSessionOptions, EpochHeader, PrepareSessionOptions, RequestContext, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SessionId, SessionSeedEventState, SurfaceIntent, SurfaceEventType } from './types.ts' -import { deriveEventMessage, SurfaceManager, validateSessionEventData, validateSurfaceMetadata } from './surface.ts' +import { SurfaceManager, validateSessionEventData, validateSurfaceMetadata } from './surface.ts' import type { SessionSurface } from './surface.ts' import { foldRequestHeader } from './request-header.ts' @@ -801,7 +801,7 @@ export class Session { private derived: Message[] = [] /** Surface position (nodes projected) the cache has reached. */ private derivedNodes = 0 - /** {@link SurfaceManager.replaceGeneration} the cache was built under. */ + /** {@link SurfaceManager.contentGeneration} the cache was built under. */ private derivedGeneration = 0 /** @@ -811,21 +811,21 @@ export class Session { * append records its `surfaceOp`, so a raw event with no marker (a chunk, a * turn boundary) is correctly absent, and a compaction `replace` deletes the * shadowed nodes from the derivation. The projection rules are - * {@link deriveEventMessage}, folded per node. + * {@link deriveEventMessage}, with logged image-offload selections applied + * without changing node membership or message identity. * - * CACHED: each surface node is projected exactly once, when first seen — a - * call costs O(new nodes), and a surface rewrite (a `replace`; - * {@link SessionSurface.replaceGeneration}) rebuilds. The returned array is + * CACHED: pure tail growth costs O(new nodes); a replacement or image offload + * ({@link SessionSurface.contentGeneration}) rebuilds. The returned array is * a fresh snapshot per call (later appends never grow an array a caller * already holds); the `Message` objects in it are SHARED and **deep-frozen**. - * Their content reuses the already frozen durable event data, so the cache - * needs no second deep clone and consumers still cannot mutate the log. + * Unchanged content reuses frozen event data; offloaded blocks are frozen + * derived copies. Consumers cannot mutate the log through either form. * @returns a fresh array of the shared, frozen derived history. */ deriveMessages(): Message[] { const surface = this.surface const nodes = surface.nodes - const generation = surface.replaceGeneration + const generation = surface.contentGeneration if (generation !== this.derivedGeneration) { this.derived = [] this.derivedNodes = 0 @@ -846,13 +846,13 @@ export class Session { } /** - * Instance face of the pure per-node `deriveEventMessage` export from - * `surface.ts`. + * Project one event with all committed image-offload selections applied. + * The original durable event remains unchanged. * @param event - the event to project. * @returns the derived message, or null when the event produces none. */ deriveEventMessage(event: SessionEvent): Message | null { - return deriveEventMessage(event) + return this.surfaceManager.deriveEventMessage(event) } } diff --git a/packages/core/session/src/known-event-types.ts b/packages/core/session/src/known-event-types.ts index dd6411240b..30cb73fa47 100644 --- a/packages/core/session/src/known-event-types.ts +++ b/packages/core/session/src/known-event-types.ts @@ -40,6 +40,7 @@ export const KNOWN_SESSION_EVENT_TYPES: ReadonlySet = new Set([ 'goal/change', 'hook/invoked', 'hook/result', + 'image/offload', 'llm/retry', 'llm/retry-started', 'model/selection', diff --git a/packages/core/session/src/surface.ts b/packages/core/session/src/surface.ts index 5d8ce74fe2..797281dbb5 100644 --- a/packages/core/session/src/surface.ts +++ b/packages/core/session/src/surface.ts @@ -11,6 +11,7 @@ import type { Message } from '@deepseek-ai/dsh-llm' import { SessionLogOffset, SessionSeq } from './types.ts' import { KNOWN_SESSION_EVENT_TYPES } from './known-event-types.ts' +import { offloadMessageImages } from './image-offload.ts' import type { SessionEvent, SessionSeqCursor, @@ -79,17 +80,21 @@ export function isReplacementSurfaceEvent( /** * Project a single event into the LLM message it derives to, or null when it * produces none — a non-surface event (attempt, boundary, log-only record) or an - * empty-content assistant/message (which exists only to host usage). This is - * THE per-node projection rule: `Session.deriveMessages` folds it over the - * live surface, external reconstructors and pure projections fold the same - * function over a log prefix's surface to rebuild the exact messages any - * request was built from. The returned message is the already frozen message - * nested in the event wrapper and shared by delivery, durable history, and - * model requests. + * empty-content assistant/message (which exists only to host usage). A caller + * reconstructing model input supplies the same prefix's `offloadedMessages` + * from {@link foldSurface}; without that map this function reads original + * event content. Session instance methods apply the live projection. Messages + * are immutable and unchanged content retains its durable identity. * @param event - the event to project. + * @param offloadedMessages - image projections from the same log prefix's surface fold. * @returns the derived message, or null when the event produces none. */ -export function deriveEventMessage(event: SessionEvent): Message | null { +export function deriveEventMessage( + event: SessionEvent, + offloadedMessages?: ReadonlyMap, +): Message | null { + const projected = offloadedMessages?.get(event.seq) + if (projected !== undefined) return projected // Intentionally non-exhaustive: only message-producing events derive // history; turn/step boundaries, failed attempts, and errors are trace/replay // data. @@ -184,6 +189,8 @@ export interface SurfaceFoldResult { nodes: SessionSeq[] /** Replacement operations in event order. */ replacements: SurfaceFoldReplacement[] + /** Immutable image-offloaded messages, keyed by their original event sequences. */ + offloadedMessages: ReadonlyMap } /** Readonly live projection of the message-producing session events. */ @@ -192,12 +199,16 @@ export interface SessionSurface { readonly nodes: readonly SessionSeq[] /** Monotonic count of committed positional replacements. */ readonly replaceGeneration: number + /** Monotonic count of committed replacements and image-offload decisions. */ + readonly contentGeneration: number } /** Mutable state shared by complete and incremental folds. */ interface SurfaceFoldState { nodes: SessionSeq[] replaceGeneration: number + contentGeneration: number + offloadedMessages: Map } /** A validated replacement transition that has not mutated fold state yet. */ @@ -211,10 +222,50 @@ interface SurfaceReplacePlan extends SurfaceFoldReplacement { type SurfacePlan = | { kind: 'append'; seq: SessionSeq } | SurfaceReplacePlan + | { kind: 'image-offload'; messages: ReadonlyMap } /** Create an empty surface fold state. */ function createFoldState(): SurfaceFoldState { - return { nodes: [], replaceGeneration: 0 } + return { nodes: [], replaceGeneration: 0, contentGeneration: 0, offloadedMessages: new Map() } +} + +/** Validate exact image references before publishing any part of the decision. */ +function planImageOffload( + state: SurfaceFoldState, + event: SessionEvent<'image/offload'>, + events: readonly SessionEvent[], + baseSeq: SessionLogOffset, +): Extract { + const data: unknown = event.data + if (!isRecord(data) || Object.keys(data).length !== 1 || !Array.isArray(data['targets']) || data['targets'].length === 0) { + throw new Error('image/offload: data must contain a nonempty targets array') + } + const messages = new Map() + const nodes = new Set(state.nodes) + for (const target of data['targets'] as unknown[]) { + if (!isRecord(target) || Object.keys(target).length !== 2 || !isEventSeq(target['seq']) + || !Array.isArray(target['imageIndexes']) || target['imageIndexes'].length === 0) { + throw new Error('image/offload: each target must contain a seq and nonempty imageIndexes') + } + const seq = target['seq'] + if (messages.has(seq)) throw new Error(`image/offload: duplicate target seq ${seq}`) + if (!nodes.has(seq)) throw new Error(`image/offload: target seq ${seq} is not a current surface node`) + const source = events[seq - baseSeq] + if (source?.type !== 'user/message' && source?.type !== 'tool/result') { + throw new Error(`image/offload: target seq ${seq} must be user/message or tool/result`) + } + let previous = -1 + for (const index of target['imageIndexes'] as unknown[]) { + if (!isEventSeq(index) || index <= previous) { + throw new Error('image/offload: imageIndexes must be strictly increasing non-negative safe integers') + } + previous = index + } + const message = state.offloadedMessages.get(seq) + ?? (source.type === 'user/message' ? source.data : source.data.message) + messages.set(seq, offloadMessageImages(message, target['imageIndexes'] as number[])) + } + return { kind: 'image-offload', messages } } /** Whether a runtime value is a non-negative safe event sequence. */ @@ -429,6 +480,7 @@ function planSurfaceEvent( throw new Error(`session event seq ${event.seq} is not contiguous; expected ${expectedSeq}`) } const surfaceOp = validateSurfaceMetadata(event) + if (event.type === 'image/offload') return planImageOffload(state, event, events, baseSeq) if (surfaceOp === undefined) return if (surfaceOp === 'append') { return { kind: 'append', seq: event.seq } @@ -468,6 +520,10 @@ function applySurfacePlan( } else if (plan?.kind === 'replace') { state.nodes.splice(plan.startIdx, plan.endIdx - plan.startIdx + 1, plan.seq) state.replaceGeneration += 1 + state.contentGeneration += 1 + } else if (plan?.kind === 'image-offload') { + for (const [seq, message] of plan.messages) state.offloadedMessages.set(seq, message) + state.contentGeneration += 1 } if (plan?.kind !== 'replace') return return { @@ -497,7 +553,7 @@ export function foldSurface(events: readonly SessionEvent[]): SurfaceFoldResult ) if (replacement !== undefined) replacements.push(replacement) } - return { nodes: [...state.nodes], replacements } + return { nodes: [...state.nodes], replacements, offloadedMessages: new Map(state.offloadedMessages) } } /** Incremental ordered surface view and append-boundary validator. */ @@ -540,6 +596,22 @@ export class SurfaceManager implements SessionSurface { return this._state.replaceGeneration } + /** Monotonic count of committed changes to existing model-visible content. */ + get contentGeneration(): number { + if (this._lastProcessedSeq < this.baseSeq + this.log.length - 1) this._processDelta() + return this._state.contentGeneration + } + + /** + * Project one message with every committed image-offload decision applied. + * @param event - message-producing or log-only event. + * @returns its immutable projected message, or null when it produces none. + */ + deriveEventMessage(event: SessionEvent): Message | null { + if (this._lastProcessedSeq < this.baseSeq + this.log.length - 1) this._processDelta() + return deriveEventMessage(event, this._state.offloadedMessages) + } + /** Surface event sequences in model-visible order. */ get nodes(): readonly SessionSeq[] { if (this._lastProcessedSeq < this.baseSeq + this.log.length - 1) this._processDelta() diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 139fccd5a5..83bc37fbde 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -267,6 +267,15 @@ export type RequestHeaderReason = 'initial' | 'resume' | 'change' | 'series' * compact raw streams so persistence stores one durable settlement per attempt. */ export interface SessionEventMap { + /** + * Permanently omit the selected input-image occurrences from subsequent + * model requests. Each target names a current user/message or tool/result + * node; image indexes are zero-based depth-first positions within that + * message, including nested tool results and already offloaded images. + * Targets are unique and each index list is nonempty and strictly increasing. + * This event changes derived content without replacing message nodes. + */ + 'image/offload': { targets: ImageOffloadTarget[] } /** * Opens turn `turn` before the loop claims queued input or runs pre-step. * Rejection, empty input, cancellation, or failure may close it with no @@ -400,6 +409,14 @@ export interface SessionEventMap { 'session/end-seed': { inherited?: true } } +/** Exact input-image occurrences selected by one durable offload decision. */ +export interface ImageOffloadTarget { + /** Current message-producing event containing these occurrences. */ + seq: SessionSeq + /** Zero-based depth-first image indexes within the immutable message. */ + imageIndexes: number[] +} + /** The appendable event-type keys of {@link SessionEventMap}, plugin-merged extensions included. */ export type SessionEventType = keyof SessionEventMap diff --git a/packages/core/session/tests/image-offload.spec.ts b/packages/core/session/tests/image-offload.spec.ts new file mode 100644 index 0000000000..ab6806e0d9 --- /dev/null +++ b/packages/core/session/tests/image-offload.spec.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'vitest' +import { createAssistantMessage, createToolResultMessage, createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, ImageBlock } from '@deepseek-ai/dsh-llm' +import { deriveEventMessage, foldSurface, Session, SessionId, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionEventMap } from '@deepseek-ai/dsh-session' + +const image: ImageBlock = { + type: 'image', + attachment: { attachmentId: `sha256:${'a'.repeat(64)}` as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, +} + +function input(session: Session, content: ContentBlock[] = [image, image]) { + return session.append('user/message', createUserMessage({ content, source: { kind: 'user' } }), { surfaceOp: 'append' }) +} + +function marked(session: Session): boolean[] { + const result: boolean[] = [] + const visit = (content: readonly ContentBlock[]): void => { + for (const block of content) { + if (block.type === 'image') result.push(block.offloaded === true) + if (block.type === 'tool-result') visit(block.content) + } + } + for (const message of session.deriveMessages()) visit(message.content) + return result +} + +describe('durable image selections', () => { + it('preserves message identity, original data, and previously derived snapshots', () => { + const session = Session.create(SessionId('images')) + const source = input(session, [ + { type: 'text', text: 'before' }, image, + { type: 'tool-result', toolCallId: ToolCallId('nested'), content: [image, { type: 'text', text: 'after' }] }, + { type: 'tool-result', toolCallId: ToolCallId('text'), content: [{ type: 'text', text: 'unchanged' }] }, + image, + ]) + const before = session.deriveMessages() + session.append('image/offload', { targets: [{ seq: source.seq, imageIndexes: [1] }] }) + expect(marked(session)).toEqual([false, true, false]) + const after = session.deriveMessages() + expect(after[0]?.id).toBe(before[0]?.id) + expect(after[0]).toBe(session.deriveEventMessage(source)) + expect(Object.isFrozen(after[0])).toBe(true) + expect(Object.isFrozen(after[0]?.content)).toBe(true) + expect(after[0]?.content[0]).toBe(before[0]?.content[0]) + expect(after[0]?.content[3]).toBe(before[0]?.content[3]) + expect(JSON.stringify(source)).not.toContain('offloaded') + expect(JSON.stringify(before)).not.toContain('offloaded') + expect(session.surface.nodes).toEqual([source.seq]) + expect(session.surface.replaceGeneration).toBe(0) + expect(session.surface.contentGeneration).toBe(1) + expect(session.deriveEventMessage(session.snapshotEvents().at(-1)!)).toBeNull() + session.append('image/offload', { targets: [{ seq: source.seq, imageIndexes: [0, 2] }] }) + expect(marked(session)).toEqual([true, true, true]) + expect(session.surface.contentGeneration).toBe(2) + expect(after[0]).not.toBe(session.deriveMessages()[0]) + }) + + it('reconstructs the same selections through pure folding, resume, restore, and fork', () => { + const session = Session.create(SessionId('source')) + const source = input(session) + const before = session.snapshotEvents() + session.append('image/offload', { targets: [{ seq: source.seq, imageIndexes: [0] }] }) + const events = session.snapshotEvents() + const fold = foldSurface(events) + expect(deriveEventMessage(source, fold.offloadedMessages)).toEqual(session.deriveMessages()[0]) + expect(marked(Session.create(SessionId('before'), before))).toEqual([false, false]) + expect(marked(Session.create(SessionId('resume'), events))).toEqual([true, false]) + const restored = Session.fromRestore(session.id, events, session.header, SessionLogOffset(0), 'shared-frozen') + expect(marked(restored)).toEqual([true, false]) + const childId = SessionId('child') + const child = Session.create(childId, events, { + ...session.header, id: childId, parentSession: session.id, isSeeded: true, + }, SessionLogOffset(events.length)) + expect(marked(child)).toEqual([true, false]) + input(child, [image]) + expect(marked(child)).toEqual([true, false, false]) + expect(marked(session)).toEqual([true, false]) + }) + + it('counts all images in a tool result without changing its call identity', () => { + const session = Session.create(SessionId('tool')) + const source = session.append('tool/result', { + turn: 1, step: 1, + message: createToolResultMessage({ callId: ToolCallId('shot'), isError: false, content: [image, image] }), + }, { surfaceOp: 'append' }) + session.append('image/offload', { targets: [{ seq: source.seq, imageIndexes: [1] }] }) + expect(marked(session)).toEqual([false, true]) + expect(session.deriveEventMessage(source)?.source).toEqual(source.data.message.source) + }) + + it.each([ + null, {}, { targets: [] }, { targets: 'bad' }, { targets: [null] }, + { targets: [{ seq: 0, imageIndexes: [] }] }, + { targets: [{ seq: 0, imageIndexes: [0], extra: true }] }, + { targets: [{ seq: 0, imageIndexes: [0] }], extra: true }, + { targets: [{ seq: -1, imageIndexes: [0] }] }, + { targets: [{ seq: 0, imageIndexes: [-1] }] }, + { targets: [{ seq: 0, imageIndexes: [0.5] }] }, + { targets: [{ seq: 0, imageIndexes: ['0'] }] }, + { targets: [{ seq: 0, imageIndexes: [1, 0] }] }, + { targets: [{ seq: 0, imageIndexes: [0, 0] }] }, + { targets: [{ seq: 0, imageIndexes: [2] }] }, + { targets: [{ seq: 1, imageIndexes: [0] }] }, + { targets: [{ seq: 0, imageIndexes: [0] }, { seq: 0, imageIndexes: [1] }] }, + ])('rejects malformed selections at append and replay without partial application: %j', (data) => { + const session = Session.create(SessionId('invalid')) + input(session) + const events = session.snapshotEvents() + const candidate = { type: 'image/offload', seq: SessionSeq(1), time: 0, data } as SessionEvent + expect(() => session.append('image/offload', data as SessionEventMap['image/offload'])).toThrow(/image\/offload/) + expect(() => Session.create(SessionId('seed'), [...events, candidate])).toThrow(/image\/offload/) + expect(() => foldSurface([...events, candidate])).toThrow(/image\/offload/) + expect(session.snapshotEvents()).toEqual(events) + expect(marked(session)).toEqual([false, false]) + expect(session.surface.contentGeneration).toBe(0) + session.append('image/offload', { targets: [{ seq: SessionSeq(0), imageIndexes: [1] }] }) + expect(marked(session)).toEqual([false, true]) + }) + + it('rejects repeated offloads and shadowed or assistant targets', () => { + const session = Session.create(SessionId('invalid-targets')) + const source = input(session) + session.append('image/offload', { targets: [{ seq: source.seq, imageIndexes: [0] }] }) + expect(() => session.append('image/offload', { targets: [{ seq: source.seq, imageIndexes: [0] }] })).toThrow(/already offloaded/) + session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'summary' }], source: { kind: 'user' } }), { + surfaceOp: { op: 'replace', startSeq: source.seq, endSeq: source.seq }, sourceEventSeqs: [source.seq], + }) + expect(() => session.append('image/offload', { targets: [{ seq: source.seq, imageIndexes: [1] }] })).toThrow(/not a current/) + const assistant = session.append('assistant/message', { + turn: 1, step: 1, stream: [], + message: createAssistantMessage({ content: [image], source: { provider: 'mock', model: 'mock' } }), + }, { surfaceOp: 'append' }) + expect(() => session.append('image/offload', { targets: [{ seq: assistant.seq, imageIndexes: [0] }] })).toThrow(/must be user\/message/) + }) +}) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 37b5006a62..d5a9d4871b 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -4358,6 +4358,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ImageMediaType', declaration: 'export type ImageMediaType = \'image/png\' | \'image/jpeg\' | \'image/webp\' | \'image/gif\';', }, + { + name: 'ImageOffloadTarget', + declaration: 'export interface ImageOffloadTarget {\n seq: SessionSeq;\n imageIndexes: number[];\n}', + }, { name: 'ImageRequestPolicy', declaration: 'export interface ImageRequestPolicy {\n maxPixels: number;\n maxBytes: number;\n}', @@ -5104,7 +5108,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionEventMap', - declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': UserMessage;\n \'system/message\': {\n turn: number;\n step: number;\n message: SystemMessage;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n message: AssistantMessage;\n stream: AssistantStreamRecord[];\n usage?: TokenUsage;\n interrupted?: true;\n };\n \'assistant/attempt\': {\n turn: number;\n step: number;\n stream: AssistantStreamRecord[];\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: ToolCallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n \'request/header\': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n startsSeries?: true;\n };\n \'request/context\': RequestContext;\n \'session/end-seed\': {\n inherited?: true;\n };\n}', + declaration: 'export interface SessionEventMap {\n \'image/offload\': {\n targets: ImageOffloadTarget[];\n };\n \'turn/start\': {\n turn: number;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': UserMessage;\n \'system/message\': {\n turn: number;\n step: number;\n message: SystemMessage;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n message: AssistantMessage;\n stream: AssistantStreamRecord[];\n usage?: TokenUsage;\n interrupted?: true;\n };\n \'assistant/attempt\': {\n turn: number;\n step: number;\n stream: AssistantStreamRecord[];\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: ToolCallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n \'request/header\': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n startsSeries?: true;\n };\n \'request/context\': RequestContext;\n \'session/end-seed\': {\n inherited?: true;\n };\n}', }, { name: 'SessionEventMetadataFilter', @@ -5444,7 +5448,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionSurface', - declaration: 'export interface SessionSurface {\n readonly nodes: readonly SessionSeq[];\n readonly replaceGeneration: number;\n}', + declaration: 'export interface SessionSurface {\n readonly nodes: readonly SessionSeq[];\n readonly replaceGeneration: number;\n readonly contentGeneration: number;\n}', }, { name: 'SessionSurfaceSnapshot', diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index 20c5809cf8..0f00d3a148 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-deepseek/README.md -README.md: 8995c40f0ca9960496ff121f833cd4b143791e01 -README.zh.md: 80f0fc2c5579b36b5abf772d6aa1cfcde26b2fe1 +README.md: 331b9d5350ab69dfeb4ca15671cb62ed4d6646d6 +README.zh.md: 542cb99fb32cbf1418852716663f6d58b2908a76 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 8995c40f0c..331b9d5350 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -156,7 +156,7 @@ The selected DeepSeek model receives the harness system prompt, message history, #### Token effect -Provider tokenization governs exact text and image-token input. The adapter declares per-route `imageRequestPricing`: it prices each occurrence a surface replacement marks offloaded as its placeholder text and each retained image at its projected dimensions with the published v4 vision accounting (14px patch grid, 3:1 downsampling, 384-token cap, worst-case alignment pad). This lets the token meter price image pressure before a request; reported usage remains authoritative. Reasoning passback carries every reasoned turn's chain of thought into later requests, while offloaded images stop costing visual tokens. A request whose retained occurrences exceed the file-mode or inline-fallback budget (`maxRequestFilesBytes`, `maxImagesPerRequest`, both quanta) at their exact request-version bytes fails with `IMAGE_OFFLOAD_REQUIRED` naming the additional oldest occurrences to offload, and `dsh-compaction-image-offload` replaces the carrying surface nodes with marked copies and retries. Cache-read usage is reported when available. `totalTokens` is the exact `prompt_tokens + completion_tokens` aggregate and is omitted if a supplied `total_tokens` disagrees. +Provider tokenization governs exact text and image-token input. The adapter declares per-route `imageRequestPricing`: it prices each occurrence selected by a logged image-offload decision as its placeholder text and each retained image at its projected dimensions with the published v4 vision accounting (14px patch grid, 3:1 downsampling, 384-token cap, worst-case alignment pad). This lets the token meter price image pressure before a request; reported usage remains authoritative. Reasoning passback carries every reasoned turn's chain of thought into later requests, while offloaded images stop costing visual tokens. A request whose retained occurrences exceed the file-mode or inline-fallback budget (`maxRequestFilesBytes`, `maxImagesPerRequest`, both quanta) at their exact request-version bytes fails with `IMAGE_OFFLOAD_REQUIRED` naming the additional oldest occurrences to offload, and `dsh-compaction-image-offload` records the selected occurrences in an `image/offload` event and retries. Cache-read usage is reported when available. `totalTokens` is the exact `prompt_tokens + completion_tokens` aggregate and is omitted if a supplied `total_tokens` disagrees. #### KV Cache effect diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index 80f0fc2c55..542cb99fb3 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -156,7 +156,7 @@ Files 模式通过 `maxRequestFilesBytes` 与 `maxImagesPerRequest` 限制保留 #### Token 影响 -提供方分词决定精确的文本与图片 token 输入。适配器声明按路由的 `imageRequestPricing`:把表层替换标记为已省略的每个出现位置按其占位文本计价,并按投影后的尺寸使用公开的 v4 视觉计量规则(14 px patch 网格、3:1 降采样、单图 384 token 上限、最坏情况下的对齐 pad)为每张保留图片计价。这使 token 计量服务可以在请求发出前为图片压力定价;上报的 usage 仍是权威值。推理回传会把每个推理轮次的思维链带进后续请求,而已省略的图片不再消耗视觉 token。保留的出现位置按精确请求版本字节超过 file 模式或内联回退预算(`maxRequestFilesBytes`、`maxImagesPerRequest` 与两个量子)的请求,以 `IMAGE_OFFLOAD_REQUIRED` 失败并说明还需省略多少最老的出现位置,由 `dsh-compaction-image-offload` 用带标记的副本替换承载节点并重试。可用时报告缓存读取用量。`totalTokens` 是精确的 `prompt_tokens + completion_tokens` 汇总值;提供方给出的 `total_tokens` 不一致时省略该值。 +提供方分词决定精确的文本与图片 token 输入。适配器声明按路由的 `imageRequestPricing`:把日志中的图片省略决策选中的每个出现位置按其占位文本计价,并按投影后的尺寸使用公开的 v4 视觉计量规则(14 px patch 网格、3:1 降采样、单图 384 token 上限、最坏情况下的对齐 pad)为每张保留图片计价。这使 token 计量服务可以在请求发出前为图片压力定价;上报的 usage 仍是权威值。推理回传会把每个推理轮次的思维链带进后续请求,而已省略的图片不再消耗视觉 token。保留的出现位置按精确请求版本字节超过 file 模式或内联回退预算(`maxRequestFilesBytes`、`maxImagesPerRequest` 与两个量子)的请求,以 `IMAGE_OFFLOAD_REQUIRED` 失败并说明还需省略多少最老的出现位置,由 `dsh-compaction-image-offload` 用 `image/offload` 事件记录所选位置并重试。可用时报告缓存读取用量。`totalTokens` 是精确的 `prompt_tokens + completion_tokens` 汇总值;提供方给出的 `total_tokens` 不一致时省略该值。 #### KV Cache 影响 diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 4d003661c5..988a7298f0 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-pi-ai/README.md -README.md: bcd37db5a2d42b23ac2d88c54d129876cdae230e -README.zh.md: 8a6ee2806f995aeb5e909cd2b585cd861afd10e7 +README.md: a49281b5ddc63c1fe4ce628857ff6f267dc498cd +README.zh.md: 78aed7db98e217691351f49f3f8ee41e74177e17 diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index bcd37db5a2..a49281b5dd 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -180,7 +180,7 @@ Read these pages when the package-level contract is not enough. They move from t #### What the model sees -The selected catalog model receives one system prompt (`GenerateOptions.system`, otherwise the text of a leading `system` history message; a leading system message with empty text sends none), the remaining history, tools, and sampling fields supported by pi-ai's common streaming API. Each retained image is preceded by text naming its complete attachment id and actual request dimensions. When the current execution filesystem maps the attachment provider's host object, the text also carries a read-only normalized-object path and warns that normalization or request projection may have resized or re-encoded the upload. Each occurrence a surface replacement marks offloaded keeps its own identity and currently resolved access in replacement text, and its normalized attachment is not read or transformed. When the retained occurrences' exact base64 payload still exceeds the route's `maxRequestImageBytes`, the call fails with `IMAGE_OFFLOAD_REQUIRED` so `dsh-compaction-image-offload` replaces the carrying surface nodes with marked copies and retries the step. Provider-native replay metadata is restored only when the adapter validates it for the historical content. +The selected catalog model receives one system prompt (`GenerateOptions.system`, otherwise the text of a leading `system` history message; a leading system message with empty text sends none), the remaining history, tools, and sampling fields supported by pi-ai's common streaming API. Each retained image is preceded by text naming its complete attachment id and actual request dimensions. When the current execution filesystem maps the attachment provider's host object, the text also carries a read-only normalized-object path and warns that normalization or request projection may have resized or re-encoded the upload. Each occurrence selected by a logged image-offload decision keeps its own identity and currently resolved access in replacement text, and its normalized attachment is not read or transformed. When the retained occurrences' exact base64 payload still exceeds the route's `maxRequestImageBytes`, the call fails with `IMAGE_OFFLOAD_REQUIRED` so `dsh-compaction-image-offload` records the selected occurrences in an `image/offload` event and retries the step. Provider-native replay metadata is restored only when the adapter validates it for the historical content. #### Token effect @@ -188,7 +188,7 @@ Provider tokenization governs exact input. Retained images add the stable attach #### KV Cache effect -Conversion preserves logical request order, while image handles and offload placeholders add model-visible text. A changed execution-world path rewrites a historical handle and can prevent reuse from that image even when attachment identity and request bytes stay stable. Changing adapter instance, provider, model, or another upstream token has the same suffix effect. An offload replacement turns an earlier image into placeholder text, so reuse ends at that message; the replacement never reverts, so the prefix stays stable afterwards. +Conversion preserves logical request order, while image handles and offload placeholders add model-visible text. A changed execution-world path rewrites a historical handle and can prevent reuse from that image even when attachment identity and request bytes stay stable. Changing adapter instance, provider, model, or another upstream token has the same suffix effect. An offload decision turns an earlier image into placeholder text, so reuse ends at that message; the omission never reverts, so the prefix stays stable afterwards. ### Provider response diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index 8a6ee2806f..78aed7db98 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -180,7 +180,7 @@ Settings 写入会在合并组合层与用户层后严格校验每个新增或 #### 模型看到什么 -所选目录模型会收到一条系统提示词(`GenerateOptions.system`,否则取历史中首条 `system` 消息的文本;首条 system 消息文本为空时不发送系统提示词)、其余历史、工具与 pi-ai 通用流式 API 支持的采样字段。每张保留图片前都会有文本,注明其完整附件 id 与实际请求尺寸。当前执行文件系统可以映射附件提供方的宿主对象时,该文本还会携带只读规范化对象路径,并警告规范化或请求投影可能缩放或重新编码上传内容。表层替换标记为已卸载的每个出现位置都会在替换文本中保留自己的身份与当前已解析访问方式,其规范化附件不会读取或变换。当保留的出现位置按精确 base64 载荷仍超过路由的 `maxRequestImageBytes` 时,调用以 `IMAGE_OFFLOAD_REQUIRED` 失败,由 `dsh-compaction-image-offload` 用带标记的副本替换承载节点并重试步骤。提供方原生回放元数据只在适配器针对历史内容校验通过后恢复。 +所选目录模型会收到一条系统提示词(`GenerateOptions.system`,否则取历史中首条 `system` 消息的文本;首条 system 消息文本为空时不发送系统提示词)、其余历史、工具与 pi-ai 通用流式 API 支持的采样字段。每张保留图片前都会有文本,注明其完整附件 id 与实际请求尺寸。当前执行文件系统可以映射附件提供方的宿主对象时,该文本还会携带只读规范化对象路径,并警告规范化或请求投影可能缩放或重新编码上传内容。日志中的图片省略决策选中的每个出现位置都会在替换文本中保留自己的身份与当前已解析访问方式,其规范化附件不会读取或变换。当保留的出现位置按精确 base64 载荷仍超过路由的 `maxRequestImageBytes` 时,调用以 `IMAGE_OFFLOAD_REQUIRED` 失败,由 `dsh-compaction-image-offload` 用 `image/offload` 事件记录所选位置并重试步骤。提供方原生回放元数据只在适配器针对历史内容校验通过后恢复。 #### Token 影响 @@ -188,7 +188,7 @@ Settings 写入会在合并组合层与用户层后严格校验每个新增或 #### KV Cache 影响 -转换保持逻辑请求顺序,图片句柄与卸载占位符则会添加模型可见文本。即使附件身份与请求字节保持稳定,执行世界路径变化也会改写历史句柄,并可能从该图片起阻止复用。更换适配器实例、提供方、模型或其他上游 token 具有相同的后缀影响。一次省略替换会把较早图片换成占位文本,因此复用在该消息处结束;替换永不回退,此后前缀保持稳定。 +转换保持逻辑请求顺序,图片句柄与卸载占位符则会添加模型可见文本。即使附件身份与请求字节保持稳定,执行世界路径变化也会改写历史句柄,并可能从该图片起阻止复用。更换适配器实例、提供方、模型或其他上游 token 具有相同的后缀影响。一次省略决策会把较早图片换成占位文本,因此复用在该消息处结束;省略永不回退,此后前缀保持稳定。 ### 提供方响应 diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml index 8861559970..f24e25706f 100644 --- a/packages/llm/llm/README.i18n.yaml +++ b/packages/llm/llm/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm/README.md -README.md: 0817cf64a65dbd1d4aad8873470c961b691a9cd1 -README.zh.md: f28fe85818cb95a3d2576f3a37efa9cb58c1e3ee +README.md: 8e4e547190a039959f7db29898945f2ed68ff874 +README.zh.md: 9164f4a6e6107e439b732539cddeb14716bc3fdc diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index 0817cf64a6..8e4e547190 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -101,7 +101,7 @@ The service is built on one separation: **the logical contract is provider-neutr ### Main flow -A request is validated against its exact model's capability — context window, output default, reasoning efforts, input modalities, and `systemPromptUpdate` mode — and any adapter-configured defaults are materialized, then the whole request is deep-frozen. `prepareCall()` binds those facts, detached context, and retry policy to the exact adapter generation that performs terminal dispatch, so HMR or dynamic settings cannot combine one generation's image capability with another generation's endpoint. An image-capable adapter projects durable references into route-specific request versions; `resolveImageAttachmentAccess()` separately maps an attachment provider's optional host object into the current tool execution world without changing the request image or its `variantId`. A text-only route receives deterministic per-image placeholders, including nested tool-result images, without rewriting append-only session history. Durable `FileBlock` references never reach any adapter: request assembly replaces each one, nested tool-result occurrences included, with deterministic handle text naming the file and its saved read-only path, resolved through the mounted attachment and filesystem providers. `ctx.llm.fileRequestText(ref)` exposes that exact synchronous projection to request measurement. An image occurrence a surface replacement marked `offloaded: true` reaches every route as placeholder text through `projectOffloadedImages()`. An image-capable route whose retained occurrences exceed its `LlmImageRequestBudget` at their exact bytes fails with `IMAGE_OFFLOAD_REQUIRED` naming the additional oldest occurrences (`requiredImageOffload()`), never with an unlogged projection; `dsh-compaction-image-offload` replaces the carrying surface nodes with marked copies and retries. Adapters that charge visual tokens declare per-route `imageRequestPricing`, which `ctx.llm.imageRequestPricing(provider, model)` resolves synchronously for the token meter. Dispatch goes through the `llm/stream` waterfall, then chunks return as token-level deltas and every adapter outcome reaches the consumer as one terminal `finish` chunk. +A request is validated against its exact model's capability — context window, output default, reasoning efforts, input modalities, and `systemPromptUpdate` mode — and any adapter-configured defaults are materialized, then the whole request is deep-frozen. `prepareCall()` binds those facts, detached context, and retry policy to the exact adapter generation that performs terminal dispatch, so HMR or dynamic settings cannot combine one generation's image capability with another generation's endpoint. An image-capable adapter projects durable references into route-specific request versions; `resolveImageAttachmentAccess()` separately maps an attachment provider's optional host object into the current tool execution world without changing the request image or its `variantId`. A text-only route receives deterministic per-image placeholders, including nested tool-result images, without rewriting append-only session history. Durable `FileBlock` references never reach any adapter: request assembly replaces each one, nested tool-result occurrences included, with deterministic handle text naming the file and its saved read-only path, resolved through the mounted attachment and filesystem providers. `ctx.llm.fileRequestText(ref)` exposes that exact synchronous projection to request measurement. An image occurrence derived with `offloaded: true` reaches every route as placeholder text through `projectOffloadedImages()`. An image-capable route whose retained occurrences exceed its `LlmImageRequestBudget` at their exact bytes fails with `IMAGE_OFFLOAD_REQUIRED` naming the additional oldest occurrences (`requiredImageOffload()`), never with an unlogged projection; `dsh-compaction-image-offload` logs the selected occurrences in one `image/offload` event and retries. Adapters that charge visual tokens declare per-route `imageRequestPricing`, which `ctx.llm.imageRequestPricing(provider, model)` resolves synchronously for the token meter. Dispatch goes through the `llm/stream` waterfall, then chunks return as token-level deltas and every adapter outcome reaches the consumer as one terminal `finish` chunk. File detection reads current content, including nested tool results, on every request without caching message identities or freeze state. The [file-scan decision](../../../.agents/notes/implemented/simplification/2026-09-07-file-content-scan.md) records the measured traversal cost. @@ -141,7 +141,7 @@ None, as the LLM service adds no content; adapters choose when to add the shared #### KV Cache effect -Reasoning-effort materialization preserves the assembled request prefix. Image identity and request-preview text are deterministic, while an optional execution-world path is resolved for each request; a changed path or an offload replacement can prevent reuse from that image. +Reasoning-effort materialization preserves the assembled request prefix. Image identity and request-preview text are deterministic, while an optional execution-world path is resolved for each request; a changed path or an offload decision can prevent reuse from that image. ## Known Limitations and Deferred Work diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md index f28fe85818..9164f4a6e6 100644 --- a/packages/llm/llm/README.zh.md +++ b/packages/llm/llm/README.zh.md @@ -101,7 +101,7 @@ for await (const chunk of ctx.llm.stream({ ### 主流程 -请求会对照其精确模型的能力校验,包括上下文窗口、输出默认值、推理强度、输入模态与 `systemPromptUpdate` 模式,填入任何适配器配置的默认值,然后整个请求被深度冻结。`prepareCall()` 把这些事实、分离的上下文与重试策略绑定到执行最终分发的精确适配器代次,因此 HMR(热模块替换)或动态设置无法把一个代次的图片能力与另一代次的端点混用。支持图片的适配器把持久引用投影为路由专用请求版本;`resolveImageAttachmentAccess()` 会单独把附件提供方的可选宿主对象映射进当前工具执行世界,而不改变请求图片或其 `variantId`。纯文本路由接收确定性的逐图片占位符,包括嵌套工具结果图片,而不会改写仅追加会话历史。持久 `FileBlock` 引用永远不会到达任何适配器:请求组装把每个引用(包括嵌套工具结果中的出现)替换为确定性 句柄文本,指出文件与其只读保存路径,路径经由挂载的附件与文件系统提供方解析。`ctx.llm.fileRequestText(ref)` 向请求计量公开相同的同步投影。表层替换标为 `offloaded: true` 的图片出现位置,经 `projectOffloadedImages()` 以占位文本到达每条路由。支持图片的路由在保留的出现位置按精确字节超过其 `LlmImageRequestBudget` 时,以 `IMAGE_OFFLOAD_REQUIRED` 失败并说明还需省略多少最老的出现位置(`requiredImageOffload()`),绝不发送未记录的投影;`dsh-compaction-image-offload` 用带标记的副本替换承载这些图片的表层节点并重试。对视觉 token 收费的适配器声明按路由的 `imageRequestPricing`,`ctx.llm.imageRequestPricing(provider, model)` 为 token meter 同步解析它。分发经过 `llm/stream` waterfall(瀑布式事件),随后分片以 token 级增量返回,每个适配器结果都以唯一一个终止 `finish` 分片到达消费方。 +请求会对照其精确模型的能力校验,包括上下文窗口、输出默认值、推理强度、输入模态与 `systemPromptUpdate` 模式,填入任何适配器配置的默认值,然后整个请求被深度冻结。`prepareCall()` 把这些事实、分离的上下文与重试策略绑定到执行最终分发的精确适配器代次,因此 HMR(热模块替换)或动态设置无法把一个代次的图片能力与另一代次的端点混用。支持图片的适配器把持久引用投影为路由专用请求版本;`resolveImageAttachmentAccess()` 会单独把附件提供方的可选宿主对象映射进当前工具执行世界,而不改变请求图片或其 `variantId`。纯文本路由接收确定性的逐图片占位符,包括嵌套工具结果图片,而不会改写仅追加会话历史。持久 `FileBlock` 引用永远不会到达任何适配器:请求组装把每个引用(包括嵌套工具结果中的出现)替换为确定性 句柄文本,指出文件与其只读保存路径,路径经由挂载的附件与文件系统提供方解析。`ctx.llm.fileRequestText(ref)` 向请求计量公开相同的同步投影。派生后带有 `offloaded: true` 的图片出现位置,经 `projectOffloadedImages()` 以占位文本到达每条路由。支持图片的路由在保留的出现位置按精确字节超过其 `LlmImageRequestBudget` 时,以 `IMAGE_OFFLOAD_REQUIRED` 失败并说明还需省略多少最老的出现位置(`requiredImageOffload()`),绝不发送未记录的投影;`dsh-compaction-image-offload` 用一条 `image/offload` 事件记录所选位置并重试。对视觉 token 收费的适配器声明按路由的 `imageRequestPricing`,`ctx.llm.imageRequestPricing(provider, model)` 为 token meter 同步解析它。分发经过 `llm/stream` waterfall(瀑布式事件),随后分片以 token 级增量返回,每个适配器结果都以唯一一个终止 `finish` 分片到达消费方。 文件检测在每次请求时读取当前内容,包括嵌套工具结果,不缓存消息身份或冻结状态。[文件扫描决策](../../../.agents/notes/implemented/simplification/2026-09-07-file-content-scan.zh.md)记录了实测遍历成本。 @@ -141,7 +141,7 @@ for await (const chunk of ctx.llm.stream({ #### KV Cache 影响 -推理强度的具体化会保留已组装请求前缀。图片身份与请求预览文本是确定性的,可选执行世界路径则按请求解析;路径变化或一次省略替换可能从该图片起阻止复用。 +推理强度的具体化会保留已组装请求前缀。图片身份与请求预览文本是确定性的,可选执行世界路径则按请求解析;路径变化或一次省略决策可能从该图片起阻止复用。 ## 已知限制与延期工作 diff --git a/packages/llm/llm/src/types.ts b/packages/llm/llm/src/types.ts index 9c56962228..910003c827 100644 --- a/packages/llm/llm/src/types.ts +++ b/packages/llm/llm/src/types.ts @@ -51,9 +51,8 @@ export interface LlmFailure { /** * With code `IMAGE_OFFLOAD_REQUIRED`: how many more of the oldest retained * image occurrences the route needs offloaded before the same request fits - * its exact byte accounting. `dsh-compaction-image-offload` replaces the - * surface nodes carrying that many oldest retained occurrences with copies - * marked `offloaded` and retries the step. + * its exact byte accounting. `dsh-compaction-image-offload` records the + * selected occurrences in an `image/offload` event and retries the step. */ readonly offloadImages?: number } @@ -81,9 +80,9 @@ export interface ImageBlock { /** Immutable bytes and intrinsic display metadata owned by the attachment service. */ attachment: ImageAttachmentRef /** - * Set on a surface replacement once the occurrence is offloaded from - * requests: every route sends its placeholder text, which names the image - * and its read-only path, instead of the image. + * Derived from a durable image-offload decision or preserved by a message + * rewrite. Every route sends placeholder text naming the image and its + * available read-only path instead of image bytes. */ offloaded?: true } diff --git a/packages/llm/token-meter/README.i18n.yaml b/packages/llm/token-meter/README.i18n.yaml index 59588a5921..98030bed78 100644 --- a/packages/llm/token-meter/README.i18n.yaml +++ b/packages/llm/token-meter/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/token-meter/README.md -README.md: f5ad725ec8e1a614da2ad3ea95813acd85d4a5c8 -README.zh.md: 3ed307eaa58dd4f5e5ee3981a06e0c6b487d70a0 +README.md: 37b9614a6c684a27d44b8b8bb65108029d58612f +README.zh.md: f2fb79132ec7deded9051a85db90cc1995a5f336 diff --git a/packages/llm/token-meter/README.md b/packages/llm/token-meter/README.md index f5ad725ec8..37b9614a6c 100644 --- a/packages/llm/token-meter/README.md +++ b/packages/llm/token-meter/README.md @@ -40,7 +40,7 @@ const { totalTokens, surfaceTokens, nodes } = ctx.tokenMeter.measure(session) const price = ctx.tokenMeter.estimateMessage(message) ``` -Each measurement resolves the effective envelope's provider/model through the optional `llm` service. Image occurrences use the routed request's visual-token price plus model-visible text when the adapter declares pricing, and occurrences a surface replacement marked `offloaded` are priced as the route's placeholder text exactly as the surface sends them; other routes keep the fixed heuristic. File occurrences use the exact route-independent handle text that the same `llm` service resolves for adapter dispatch, including its current execution-world path or explicit no-path message. Each node also carries route-independent `heuristicTokens` for replacement shadow prices. Provider usage is reused only when the latest successful call's canonical request envelope matches the measured envelope and its total is no lower than that call's full route-priced anchor; otherwise the complete current envelope and surface are estimated. Surface changes stay signed relative to a matching anchor repriced under the same route, including negative deltas after shrinking replacements. +Each measurement resolves the effective envelope's provider/model through the optional `llm` service. Image occurrences use the routed request's visual-token price plus model-visible text when the adapter declares pricing, and occurrences selected by an `image/offload` event are priced as the route's placeholder text exactly as the surface sends them; other routes keep the fixed heuristic. File occurrences use the exact route-independent handle text that the same `llm` service resolves for adapter dispatch, including its current execution-world path or explicit no-path message. Each node also carries route-independent `heuristicTokens` for replacement shadow prices. Provider usage is reused only when the latest successful call's canonical request envelope matches the measured envelope and its total is no lower than that call's full route-priced anchor; otherwise the complete current envelope and surface are estimated. Surface changes stay signed relative to a matching anchor repriced under the same route, including negative deltas after shrinking replacements. The measurement anchor includes the priced surface immediately before the successful `assistant/message`, including system and user messages admitted after `step/start` and replacements made before a retry. With unchanged durable output, the completed call has zero surface delta: its prompt is already included in provider usage. Later surface changes remain signed deltas against that anchor. @@ -48,6 +48,8 @@ The measurement anchor includes the priced surface immediately before the succes When the composition provides `ctx.sessionProjections`, token-meter registers three projection units. `tokenUsage` carries the complete durable log's `uncachedInputTokens`, `outputTokens`, `cacheReadTokens`, and `cacheWriteTokens`. A final assistant-message sample replaces streaming usage from the same attempt; `llm/retry-started` ends that replacement scope, so a retry in the same step contributes another billed attempt. `contextPressure` carries optional `pressureTokens` (the newest provider-reported prompt size), optional `projectedTokens` (what the next request's prompt would cost), and optional `contextWindow` from the newest `request/context` record. `contextBreakdown` carries heuristic `systemTokens`, `toolsTokens`, and `messageTokens` — the context's composition, not its provider-billed size. Unloading the plugin removes all three keys. +Image offload reprices existing node identities while preserving prior usage anchors. The fixed reference heuristic excludes `offloaded` metadata, so an offload decision does not change `contextBreakdown` or the scalar heuristic total; route-aware measurement replaces the selected visual prices with placeholder text. + `contextBreakdown` classifies the last nonempty surviving `system/message` in surface order as `systemTokens`; empty dormant nodes contribute nothing, and no nonempty system means zero. `messageTokens` includes every other visible node, including superseded prompts. Their sum always equals `measure().nodes[].heuristicTokens`, including after unmetered replacements, compaction, and per-node prompt clearing. `toolsTokens` follows the latest `request/header`. All three use the fixed heuristic, not route image pricing or file-handle projection; they are approximate composition, not billing or `projectedTokens`. `deriveTurnTokenUsage(events)` folds one complete turn into exact per-attempt and whole-turn usage for browser consumers. It returns no result when lifecycle evidence is missing, counts are unsafe, or exact totals conflict; each corresponding aggregate appears only when every participating attempt reports its optional cache, reasoning, or route value. @@ -98,7 +100,7 @@ Each `measure()` call synchronizes the fold to the current durable tail, then re ### Projection semantics -`contextBreakdown` retains plain-JSON `{ seq, heuristicTokens, system }` entries in surface order and reuses the measurement plan/commit fold. Its state and surface transitions are O(current retained surface), not O(1) and not O(total historical log); replaced entries and message bodies are not retained. State version 4 invalidates scalar checkpoints and replays the log. `contextPressure` remains the scalar shadow-price consumer: replacements without adjacent claims contribute zero delta. The usage fold retains one last-sample slot because legal logs never report usage for an earlier step after a later step reports usage. +`contextBreakdown` retains plain-JSON `{ seq, heuristicTokens, system }` entries in surface order and reuses the measurement plan/commit fold. Its state and surface transitions are O(current retained surface), not O(1) and not O(total historical log); replaced entries and message bodies are not retained. State version 5 invalidates checkpoints priced with offload metadata. `contextPressure` remains the scalar shadow-price consumer: replacements without adjacent claims contribute zero delta. The usage fold retains one last-sample slot because legal logs never report usage for an earlier step after a later step reports usage. diff --git a/packages/llm/token-meter/README.zh.md b/packages/llm/token-meter/README.zh.md index 3ed307eaa5..f2fb79132e 100644 --- a/packages/llm/token-meter/README.zh.md +++ b/packages/llm/token-meter/README.zh.md @@ -40,7 +40,7 @@ const { totalTokens, surfaceTokens, nodes } = ctx.tokenMeter.measure(session) const price = ctx.tokenMeter.estimateMessage(message) ``` -每次测量都会通过可选的 `llm` 服务解析生效 envelope 的提供方/模型。适配器声明图片定价时,图片出现处使用路由请求的视觉 token 价格加模型可见文本,表层替换标为已省略的出现位置则按路由的占位文本计价,与表层实际发送的一致;其他路由保持固定启发式规则。文件出现处使用同一个 `llm` 服务为适配器分发解析的确切、与路由无关的 句柄文本,其中包含当前执行世界路径或明确的无路径说明。每个节点还携带与路由无关的 `heuristicTokens`,供替换影子价使用。只有当最新成功调用的规范请求 envelope 与已测量 envelope 匹配、且其总量不低于该调用完整路由定价锚点时,才复用提供方用量;否则会对完整当前 envelope 与表面做估算。表面变更保持相对于按同一路由重新定价的匹配锚点的带符号值,包括缩减替换后的负 delta。 +每次测量都会通过可选的 `llm` 服务解析生效 envelope 的提供方/模型。适配器声明图片定价时,图片出现处使用路由请求的视觉 token 价格加模型可见文本,`image/offload` 事件选中的出现位置则按路由的占位文本计价,与表层实际发送的一致;其他路由保持固定启发式规则。文件出现处使用同一个 `llm` 服务为适配器分发解析的确切、与路由无关的 句柄文本,其中包含当前执行世界路径或明确的无路径说明。每个节点还携带与路由无关的 `heuristicTokens`,供替换影子价使用。只有当最新成功调用的规范请求 envelope 与已测量 envelope 匹配、且其总量不低于该调用完整路由定价锚点时,才复用提供方用量;否则会对完整当前 envelope 与表面做估算。表面变更保持相对于按同一路由重新定价的匹配锚点的带符号值,包括缩减替换后的负 delta。 测量锚点包含成功的 `assistant/message` 之前的已计价表面,包括 `step/start` 之后接纳的系统与用户消息,以及重试之前执行的替换。持久输出未变时,完成调用的表面增量为零:其提示词已包含在提供方用量中。后续表面变更仍是相对于该锚点的带符号增量。 @@ -48,6 +48,8 @@ const price = ctx.tokenMeter.estimateMessage(message) 当组合提供 `ctx.sessionProjections` 时,token-meter 注册三个投影单元。`tokenUsage` 携带完整持久日志中的 `uncachedInputTokens`、`outputTokens`、`cacheReadTokens` 与 `cacheWriteTokens`。最终 assistant 消息样本会替换同一次尝试的流式用量;`llm/retry-started` 会结束该替换范围,因此同一步骤中的重试会贡献另一次计费用量。`contextPressure` 携带可选 `pressureTokens`(提供方报告的最新提示词规模)、可选 `projectedTokens`(下一个请求的提示词将花费多少)与来自最新一条 `request/context` 记录的可选 `contextWindow`。`contextBreakdown` 携带启发式 `systemTokens`、`toolsTokens` 与 `messageTokens`——上下文的构成,而非提供方计费规模。卸载插件会移除全部三个键。 +图片省略重新计算现有节点的价格,同时保留此前的用量锚点。固定引用启发式规则不计入 `offloaded` 元数据,因此一次省略决定不改变 `contextBreakdown` 或标量启发式总量,按路由的测量则把所选图片的视觉价格换成占位文本价格。 + `contextBreakdown` 把 surface 顺序中最后一个非空且存活的 `system/message` 归入 `systemTokens`;休眠的空节点不贡献 token,没有非空系统消息时为零。`messageTokens` 包含其余所有可见节点,包括被取代的提示词。两者之和始终等于 `measure().nodes[].heuristicTokens`,未计量替换、压缩和逐节点清空提示词之后也成立。`toolsTokens` 跟随最新 `request/header`。三个数字都使用固定启发式规则,而非路由图片定价或文件句柄投影;它们是近似构成,不是计费数据或 `projectedTokens`。 `deriveTurnTokenUsage(events)` 为浏览器消费方把一个完整轮次折叠为精确的逐次尝试与整轮用量。生命周期证据缺失、计数不安全或精确总量矛盾时不返回结果;只有每次参与的尝试都报告可选缓存、推理或路由值时,相应汇总才会出现。 @@ -98,7 +100,7 @@ const price = ctx.tokenMeter.estimateMessage(message) ### 投影语义 -`contextBreakdown` 按 surface 顺序保留纯 JSON 的 `{ seq, heuristicTokens, system }` 条目,并复用测量服务的 plan/commit fold。其状态与 surface 转换成本为 O(当前保留 surface),不是 O(1),也不是 O(完整历史日志);被替换条目和消息正文不保留。状态版本 4 使标量检查点失效并重放日志。`contextPressure` 仍是标量影子价消费方:没有相邻 claim 的替换贡献零增量。用量 fold 保留一个最后样本槽,因为合法日志不会在更晚步骤报告用量后再次报告更早步骤的用量。 +`contextBreakdown` 按 surface 顺序保留纯 JSON 的 `{ seq, heuristicTokens, system }` 条目,并复用测量服务的 plan/commit fold。其状态与 surface 转换成本为 O(当前保留 surface),不是 O(1),也不是 O(完整历史日志);被替换条目和消息正文不保留。状态版本 5 使计入省略元数据的检查点失效。`contextPressure` 仍是标量影子价消费方:没有相邻 claim 的替换贡献零增量。用量 fold 保留一个最后样本槽,因为合法日志不会在更晚步骤报告用量后再次报告更早步骤的用量。 diff --git a/packages/llm/token-meter/src/breakdown-projection.ts b/packages/llm/token-meter/src/breakdown-projection.ts index d97338d47a..dd6d4a5d55 100644 --- a/packages/llm/token-meter/src/breakdown-projection.ts +++ b/packages/llm/token-meter/src/breakdown-projection.ts @@ -47,7 +47,7 @@ type ContextBreakdownState = z.infer */ export const contextBreakdownProjectionDefinition = { key: 'contextBreakdown', - stateVersion: 4, + stateVersion: 5, stateSchema: contextBreakdownStateSchema, init: (): ContextBreakdownState => ({ nodes: [], diff --git a/packages/llm/token-meter/src/estimate.ts b/packages/llm/token-meter/src/estimate.ts index c82d621242..99ebbb2aaf 100644 --- a/packages/llm/token-meter/src/estimate.ts +++ b/packages/llm/token-meter/src/estimate.ts @@ -21,11 +21,16 @@ export const ROLE_OVERHEAD = 4 /** * Structural JSON price of one block outside the typed pricing arms: the * fixed heuristic for merge-extended blocks and for image references, whose - * request price is route-owned rather than fixed. + * request price is route-owned rather than fixed. Image offload marks do not + * change this reference-only heuristic; route pricing owns their placeholders. * @param block - block to price without mutation. * @returns heuristic tokens for the block's JSON structure. */ export function estimateStructuralBlock(block: ContentBlock): number { + if (block.type === 'image') { + const { offloaded: _offloaded, ...reference } = block + return BLOCK_OVERHEAD + Math.ceil(JSON.stringify(reference).length / CHARS_PER_TOKEN) + } return BLOCK_OVERHEAD + Math.ceil(JSON.stringify(block).length / CHARS_PER_TOKEN) } diff --git a/packages/llm/token-meter/src/index.ts b/packages/llm/token-meter/src/index.ts index 348213ef4d..1c1b885085 100644 --- a/packages/llm/token-meter/src/index.ts +++ b/packages/llm/token-meter/src/index.ts @@ -248,6 +248,18 @@ export class TokenMeter extends Service { let nextAnchor = state.anchor switch (event.type) { + case 'image/offload': { + const offloaded = new Map(event.data.targets.map(target => [target.seq, new Set(target.imageIndexes)])) + state.surface = state.surface.map((node) => { + const indexes = offloaded.get(node.seq) + if (indexes === undefined) return node + return { + ...node, + images: node.images.map((image, index) => (indexes.has(index) ? { ...image, offloaded: true as const } : image)), + } + }) + break + } case 'request/header': nextHeader = canonicalHeader(event.data.header) break diff --git a/packages/llm/token-meter/src/usage-projection.ts b/packages/llm/token-meter/src/usage-projection.ts index 815baff91d..2238316fab 100644 --- a/packages/llm/token-meter/src/usage-projection.ts +++ b/packages/llm/token-meter/src/usage-projection.ts @@ -172,7 +172,7 @@ export const tokenUsageProjectionDefinition = { */ export const contextPressureProjectionDefinition = { key: 'contextPressure', - stateVersion: 4, + stateVersion: 5, stateSchema: contextPressureStateSchema, init: () => ({ surfaceTokens: 0 }), apply: (state, event) => { diff --git a/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts b/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts index 769f00e0ec..8ea7d2bd81 100644 --- a/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts +++ b/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts @@ -405,7 +405,7 @@ describe('contextBreakdown session projection', () => { ctx.sessionProjections.checkpoint(session), )) as ReturnType const row = checkpoint['contextBreakdown']! - expect(row.ver).toBe(4) + expect(row.ver).toBe(5) expect(ctx.sessionProjections.viewCheckpoint(checkpoint).contextBreakdown).toEqual(projected(ctx, session)) const replacement = session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'summary' }], source: { kind: 'user' }, @@ -424,7 +424,7 @@ describe('contextBreakdown session projection', () => { stale, session.snapshotEvents(), SessionLogOffset(0), session.header, session.inheritedEventCount, ) expect(replayed.snapshot.values.contextBreakdown).toEqual(projected(ctx, session)) - expect(replayed.checkpoint['contextBreakdown']?.ver).toBe(4) + expect(replayed.checkpoint['contextBreakdown']?.ver).toBe(5) const invalid = { ...checkpoint, contextBreakdown: { @@ -458,7 +458,7 @@ describe('contextBreakdown session projection', () => { systemTokens: 8, toolsTokens: staleValue.toolsTokens, messageTokens: 9, }) expect(restored.checkpoint).toEqual(current) - expect(restored.checkpoint['contextBreakdown']?.ver).toBe(4) + expect(restored.checkpoint['contextBreakdown']?.ver).toBe(5) } finally { await ctx.fiber.dispose() } diff --git a/packages/llm/token-meter/tests/route-pricing.spec.ts b/packages/llm/token-meter/tests/route-pricing.spec.ts index 0ffdeffdb9..a3102d9507 100644 --- a/packages/llm/token-meter/tests/route-pricing.spec.ts +++ b/packages/llm/token-meter/tests/route-pricing.spec.ts @@ -202,14 +202,14 @@ describe('request projection pricing', () => { expect(unknownRoute.nodes[0]!.tokens).toBe(estimateMessage(message)) }) - it('prices an occurrence a surface replacement marked offloaded as the route placeholder', async () => { + it('reprices logged offloads without changing surface node identities or heuristic totals', async () => { const placeholder = '[offloaded]' const markedPricing: LlmImageRequestPricing = { priceImages: images => images.map(block => (block.offloaded === true ? { visualTokens: 0, text: placeholder } : { visualTokens: VISUAL_TOKENS, text: HANDLE_TEXT })), } - const { meter, session } = await harness(() => markedPricing) + const { ctx, meter, session } = await harness(() => markedPricing) session.append('turn/start', { turn: 1 }) const older = imageMessage('older') const newer = imageMessage('newer') @@ -219,15 +219,18 @@ describe('request projection pricing', () => { const before = meter.measure(session) expect(before.nodes.map(node => node.tokens)).toEqual([routedMessageTokens(older), routedMessageTokens(newer)]) - session.append('user/message', { - ...older, - content: older.content.map(block => (block.type === 'image' ? { ...block, offloaded: true as const } : block)), - }, { surfaceOp: { op: 'replace', startSeq: olderSeq, endSeq: olderSeq }, sourceEventSeqs: [olderSeq] }) + session.append('image/offload', { targets: [{ seq: olderSeq, imageIndexes: [0] }] }) const after = meter.measure(session) const imageFree = estimateMessage({ ...older, content: older.content.filter(block => block.type !== 'image') }) expect(after.nodes[0]!.tokens).toBe(imageFree + estimateContent([{ type: 'text', text: placeholder }])) expect(after.nodes[1]!.tokens).toBe(routedMessageTokens(newer)) expect(after.totalTokens).toBeLessThan(before.totalTokens) + expect(after.nodes.map(node => node.seq)).toEqual(before.nodes.map(node => node.seq)) + expect(after.nodes.map(node => node.heuristicTokens)).toEqual(before.nodes.map(node => node.heuristicTokens)) + const breakdown = ctx.sessionProjections.snapshot(session).values.contextBreakdown + expect(breakdown?.messageTokens).toBe(after.nodes.reduce((total, node) => total + node.heuristicTokens, 0)) + const restored = Session.create(SessionId('offloaded-restored'), session.snapshotEvents()) + expect(meter.measure(restored).surfaceTokens).toBe(after.surfaceTokens) }) it('fails loud when a route answers a mismatched occurrence count', async () => { @@ -239,6 +242,37 @@ describe('request projection pricing', () => { .toThrow('route image pricing answered 0 prices for 1 occurrences') }) + it('offloads one of two equal attachments without rewriting the prior usage anchor', async () => { + const placeholder = '[offloaded]' + const { ctx, meter, session } = await harness(() => ({ + priceImages: images => images.map(block => block.offloaded === true + ? { visualTokens: 0, text: placeholder } + : { visualTokens: VISUAL_TOKENS, text: HANDLE_TEXT }), + })) + try { + const ref = imageRef('same') + const source = session.append('user/message', createUserMessage({ + source: { kind: 'user' }, + content: [{ type: 'image', attachment: ref }, { type: 'image', attachment: ref }], + }), { surfaceOp: 'append' }) + appendSuccessfulCall(session, header('vision'), { inputTokens: 5000, outputTokens: 10 }) + const before = meter.measure(session) + session.append('image/offload', { targets: [{ seq: source.seq, imageIndexes: [0] }] }) + const after = meter.measure(session) + const saving = VISUAL_TOKENS + estimateContent([{ type: 'text', text: HANDLE_TEXT }]) + - estimateContent([{ type: 'text', text: placeholder }]) + expect(after.baseline).toEqual(before.baseline) + expect(after.surfaceDeltaTokens - before.surfaceDeltaTokens).toBe(-saving) + expect(after.totalTokens).toBe(before.totalTokens - saving) + expect(after.nodes[0]?.heuristicTokens).toBe(before.nodes[0]?.heuristicTokens) + const projection = session.deriveMessages()[0]! + expect(projection.content).toEqual([{ type: 'image', attachment: ref, offloaded: true }, { type: 'image', attachment: ref }]) + expect(estimateMessage(projection)).toBe(after.nodes[0]?.heuristicTokens) + } finally { + await ctx.fiber.dispose() + } + }) + it('prices nested tool-result images through the same route pricing', async () => { const { meter, session } = await harness(() => fixedPricing) const nested = createUserMessage({ diff --git a/packages/llm/token-meter/tests/token-usage-projection.spec.ts b/packages/llm/token-meter/tests/token-usage-projection.spec.ts index d57d3f8d11..34c00ab3dc 100644 --- a/packages/llm/token-meter/tests/token-usage-projection.spec.ts +++ b/packages/llm/token-meter/tests/token-usage-projection.spec.ts @@ -439,7 +439,7 @@ describe('contextPressure session projection', () => { const checkpoint = JSON.parse(JSON.stringify( ctx.sessionProjections.checkpoint(session), )) as ReturnType - expect(checkpoint.contextPressure?.ver).toBe(4) + expect(checkpoint.contextPressure?.ver).toBe(5) await meterFiber.dispose() expect(ctx.sessionProjections.snapshot(session).values).not.toHaveProperty('contextPressure') diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 077c47eba8..8729d360f6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4703,9 +4703,6 @@ importers: '@deepseek-ai/dsh-agent-loop-testkit': specifier: workspace:^ version: link:../../test-support/agent-loop-testkit - '@deepseek-ai/dsh-compaction': - specifier: workspace:^ - version: link:../compaction '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -4715,9 +4712,6 @@ importers: '@deepseek-ai/dsh-session-projection': specifier: workspace:^ version: link:../../session/session-projection - '@deepseek-ai/dsh-token-meter': - specifier: workspace:^ - version: link:../../llm/token-meter packages/compaction/compaction-tool-result-pruner: dependencies: diff --git a/scripts/fixtures/python-snapshot-image-offload.mjs b/scripts/fixtures/python-snapshot-image-offload.mjs new file mode 100644 index 0000000000..a4b767ccdf --- /dev/null +++ b/scripts/fixtures/python-snapshot-image-offload.mjs @@ -0,0 +1,43 @@ +/** One admitted image and one authored failure for the Python SDK event snapshot. */ + +/** Cordis fixture identity. */ +export const name = 'python-snapshot-image-offload' + +/** Admission and request hooks supplied by the shipped SDK profile. */ +export const inject = ['attachments', 'agents', 'llm'] + +/** + * @param {import('@deepseek-ai/cordis').Context} ctx - Scenario-local host context. + * @param {{ parentSessionId: string }} config - The one session receiving the authored failure. + */ +export function apply(ctx, config) { + let injected = false + let failed = false + ctx.on('agent/pre-step', async ({ agent }, next) => { + const decision = await next() + if (agent.id !== config.parentSessionId || injected || decision.kind === 'reject') return decision + const content = await ctx.attachments.admitPromptContent([{ + type: 'image', + mediaType: 'image/png', + data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC', + }]) + injected = true + return { + ...decision, + messages: [...decision.messages, { id: 'python-snapshot-image', role: 'user', content, source: { kind: 'plugin', plugin: name } }], + } + }) + ctx.on('llm/stream', async function* (options, next) { + if (options.sessionId !== config.parentSessionId || options.purpose !== undefined || failed) { + yield* next() + return + } + failed = true + yield { + type: 'finish', + reason: { kind: 'error', failure: { + code: 'IMAGE_OFFLOAD_REQUIRED', message: 'authored image budget failure', offloadImages: 1, + } }, + } + }) +} diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index 43d0ec2ccb..6ffe52490d 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -1290,6 +1290,9 @@ def smoke_sdk_snapshot(base_url: str, executable: Path, update_snapshots: bool) sessions = dsh_home / "sessions" patch = write_advanced_profile_patch(root, "snapshot.patch.yml", sessions) feedback_patch = write_profile_patch(root, "feedback.patch.yml", sessions, [{"insert": [ + {"id": "snapshot-image-offload", "name": ( + Path(__file__).resolve().parent / "fixtures/python-snapshot-image-offload.mjs" + ).as_uri(), "config": {"parentSessionId": SNAPSHOT_SESSION_ID}}, {"id": "snapshot-workflow-order", "name": ( Path(__file__).resolve().parent / "fixtures/python-snapshot-workflow-order.mjs" ).as_uri(), "config": { @@ -1319,6 +1322,12 @@ def smoke_sdk_snapshot(base_url: str, executable: Path, update_snapshots: bool) result = harness.run(SNAPSHOT_PROMPT, session_id=SNAPSHOT_SESSION_ID) assert result.final_response == SNAPSHOT_FINAL_TEXT, result.final_response + offloads = [event for event in result.events if event.get("type") == "image/offload"] + if len(offloads) != 1 or "surfaceOp" in offloads[0]: + raise AssertionError(f"advanced snapshot expected one standalone image offload: {offloads}") + targets = offloads[0]["data"]["targets"] + if len(targets) != 1 or targets[0]["imageIndexes"] != [0]: + raise AssertionError(f"advanced snapshot selected unexpected image occurrences: {targets}") feedback_types = [event.get("type") for event in result.events if str(event.get("type")).startswith("feedback/")] if feedback_types != ["feedback/record", "feedback/record", "feedback/message-put", "feedback/message-put", "feedback/message-delete"]: diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/result.json b/scripts/snapshots/python-sdk-single-exe/advanced/result.json index 59860b2c2a..3c3e85d4b9 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/result.json +++ b/scripts/snapshots/python-sdk-single-exe/advanced/result.json @@ -129,9 +129,35 @@ "surfaceOp": "append" }, { - "type": "request/header", + "type": "user/message", "seq": 10, "time": 0, + "data": { + "id": "{{messageId}}", + "role": "user", + "content": [ + { + "type": "image", + "attachment": { + "attachmentId": "sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640", + "mediaType": "image/png", + "width": 1, + "height": 1, + "bytes": 69 + } + } + ], + "source": { + "kind": "plugin", + "plugin": "python-snapshot-image-offload" + } + }, + "surfaceOp": "append" + }, + { + "type": "request/header", + "seq": 11, + "time": 0, "data": { "header": { "config": { @@ -165,7 +191,7 @@ }, { "type": "request/context", - "seq": 11, + "seq": 12, "time": 0, "data": { "provider": "deepseek-official", @@ -175,7 +201,7 @@ }, { "type": "session/title", - "seq": 12, + "seq": 13, "time": 0, "data": { "title": "Run the advanced packaged-runtime snapsh", @@ -187,19 +213,95 @@ } } }, + { + "type": "assistant/attempt", + "seq": 14, + "time": 0, + "data": { + "turn": 1, + "step": 1, + "stream": [ + { + "type": "chunk", + "time": 0, + "chunk": { + "type": "finish", + "reason": { + "kind": "error", + "failure": { + "code": "IMAGE_OFFLOAD_REQUIRED", + "message": "authored image budget failure", + "offloadImages": 1 + } + } + } + } + ] + } + }, + { + "type": "image/offload", + "seq": 15, + "time": 0, + "data": { + "targets": [ + { + "seq": 10, + "imageIndexes": [ + 0 + ] + } + ] + } + }, + { + "type": "request/header", + "seq": 16, + "time": 0, + "data": { + "header": { + "config": { + "provider": "deepseek-official", + "model": "smoke-model", + "maxTokens": 256000, + "reasoningEffort": "high" + }, + "adapterDefaults": { + "reasoningEffort": true, + "maxTokens": true + }, + "tools": [ + "cordis_define", + "cordis_inspect_list", + "cordis_inspect_query", + "cordis_inspect_self", + "cordis_run", + "cordis_stop", + "cordis_undefine", + "job_kill", + "job_list", + "job_output", + "run_code", + "subagent", + "workflow" + ] + }, + "reason": "series" + } + }, { "type": "session-log-deepseek/delivery-accepted", - "seq": 13, + "seq": 17, "time": 0, "data": { "sessionId": "{{parent}}", "sessionFormatVersion": 3, - "throughSeq": 12 + "throughSeq": 16 } }, { "type": "assistant/message", - "seq": 14, + "seq": 18, "time": 0, "data": { "turn": 1, @@ -289,7 +391,7 @@ }, { "type": "tool/call", - "seq": 15, + "seq": 19, "time": 0, "data": { "turn": 1, @@ -301,7 +403,7 @@ }, { "type": "tool/result", - "seq": 16, + "seq": 20, "time": 0, "data": { "turn": 1, @@ -333,13 +435,13 @@ } }, "sourceEventSeqs": [ - 15 + 19 ], "surfaceOp": "append" }, { "type": "step/end", - "seq": 17, + "seq": 21, "time": 0, "data": { "turn": 1, @@ -348,7 +450,7 @@ }, { "type": "step/start", - "seq": 18, + "seq": 22, "time": 0, "data": { "turn": 1, @@ -357,17 +459,17 @@ }, { "type": "session-log-deepseek/delivery-accepted", - "seq": 19, + "seq": 23, "time": 0, "data": { "sessionId": "{{parent}}", "sessionFormatVersion": 3, - "throughSeq": 18 + "throughSeq": 22 } }, { "type": "assistant/message", - "seq": 20, + "seq": 24, "time": 0, "data": { "turn": 1, @@ -457,7 +559,7 @@ }, { "type": "tool/call", - "seq": 21, + "seq": 25, "time": 0, "data": { "turn": 1, @@ -469,7 +571,7 @@ }, { "type": "tool/result", - "seq": 22, + "seq": 26, "time": 0, "data": { "turn": 1, @@ -502,13 +604,13 @@ } }, "sourceEventSeqs": [ - 21 + 25 ], "surfaceOp": "append" }, { "type": "step/end", - "seq": 23, + "seq": 27, "time": 0, "data": { "turn": 1, @@ -517,7 +619,7 @@ }, { "type": "step/start", - "seq": 24, + "seq": 28, "time": 0, "data": { "turn": 1, @@ -526,7 +628,7 @@ }, { "type": "system/message", - "seq": 25, + "seq": 29, "time": 0, "data": { "turn": 1, @@ -557,7 +659,7 @@ }, { "type": "request/header", - "seq": 26, + "seq": 30, "time": 0, "data": { "header": { @@ -594,17 +696,17 @@ }, { "type": "session-log-deepseek/delivery-accepted", - "seq": 27, + "seq": 31, "time": 0, "data": { "sessionId": "{{parent}}", "sessionFormatVersion": 3, - "throughSeq": 26 + "throughSeq": 30 } }, { "type": "assistant/message", - "seq": 28, + "seq": 32, "time": 0, "data": { "turn": 1, @@ -694,7 +796,7 @@ }, { "type": "tool/call", - "seq": 29, + "seq": 33, "time": 0, "data": { "turn": 1, @@ -706,7 +808,7 @@ }, { "type": "tool/ptc-dispatch-start", - "seq": 30, + "seq": 34, "time": 0, "data": { "rootCallId": "advanced-code", @@ -720,7 +822,7 @@ }, { "type": "tool/ptc-dispatch", - "seq": 31, + "seq": 35, "time": 0, "data": { "rootCallId": "advanced-code", @@ -741,7 +843,7 @@ }, { "type": "tool/result", - "seq": 32, + "seq": 36, "time": 0, "data": { "turn": 1, @@ -769,13 +871,13 @@ } }, "sourceEventSeqs": [ - 29 + 33 ], "surfaceOp": "append" }, { "type": "step/end", - "seq": 33, + "seq": 37, "time": 0, "data": { "turn": 1, @@ -784,7 +886,7 @@ }, { "type": "step/start", - "seq": 34, + "seq": 38, "time": 0, "data": { "turn": 1, @@ -793,17 +895,17 @@ }, { "type": "session-log-deepseek/delivery-accepted", - "seq": 35, + "seq": 39, "time": 0, "data": { "sessionId": "{{parent}}", "sessionFormatVersion": 3, - "throughSeq": 34 + "throughSeq": 38 } }, { "type": "assistant/message", - "seq": 36, + "seq": 40, "time": 0, "data": { "turn": 1, @@ -893,7 +995,7 @@ }, { "type": "tool/call", - "seq": 37, + "seq": 41, "time": 0, "data": { "turn": 1, @@ -905,7 +1007,7 @@ }, { "type": "subagent/catalog", - "seq": 38, + "seq": 42, "time": 0, "data": { "version": 0, @@ -917,7 +1019,7 @@ }, { "type": "tool/result", - "seq": 39, + "seq": 43, "time": 0, "data": { "turn": 1, @@ -945,13 +1047,13 @@ } }, "sourceEventSeqs": [ - 37 + 41 ], "surfaceOp": "append" }, { "type": "step/end", - "seq": 40, + "seq": 44, "time": 0, "data": { "turn": 1, @@ -960,7 +1062,7 @@ }, { "type": "step/start", - "seq": 41, + "seq": 45, "time": 0, "data": { "turn": 1, @@ -969,17 +1071,17 @@ }, { "type": "session-log-deepseek/delivery-accepted", - "seq": 42, + "seq": 46, "time": 0, "data": { "sessionId": "{{parent}}", "sessionFormatVersion": 3, - "throughSeq": 41 + "throughSeq": 45 } }, { "type": "assistant/message", - "seq": 43, + "seq": 47, "time": 0, "data": { "turn": 1, @@ -1069,7 +1171,7 @@ }, { "type": "tool/call", - "seq": 44, + "seq": 48, "time": 0, "data": { "turn": 1, @@ -1081,7 +1183,7 @@ }, { "type": "tool-workflow/run-start", - "seq": 45, + "seq": 49, "time": 0, "data": { "runId": "{{workflow-run}}", @@ -1090,7 +1192,7 @@ }, { "type": "subagent/catalog", - "seq": 46, + "seq": 50, "time": 0, "data": { "version": 0, @@ -1101,7 +1203,7 @@ }, { "type": "tool-workflow/agent-start", - "seq": 47, + "seq": 51, "time": 0, "data": { "runId": "{{workflow-run}}", @@ -1113,7 +1215,7 @@ }, { "type": "tool-workflow/agent-end", - "seq": 48, + "seq": 52, "time": 0, "data": { "runId": "{{workflow-run}}", @@ -1123,7 +1225,7 @@ }, { "type": "tool-workflow/run-end", - "seq": 49, + "seq": 53, "time": 0, "data": { "runId": "{{workflow-run}}", @@ -1132,7 +1234,7 @@ }, { "type": "tool/result", - "seq": 50, + "seq": 54, "time": 0, "data": { "turn": 1, @@ -1160,13 +1262,13 @@ } }, "sourceEventSeqs": [ - 44 + 48 ], "surfaceOp": "append" }, { "type": "step/end", - "seq": 51, + "seq": 55, "time": 0, "data": { "turn": 1, @@ -1175,7 +1277,7 @@ }, { "type": "step/start", - "seq": 52, + "seq": 56, "time": 0, "data": { "turn": 1, @@ -1184,17 +1286,17 @@ }, { "type": "session-log-deepseek/delivery-accepted", - "seq": 53, + "seq": 57, "time": 0, "data": { "sessionId": "{{parent}}", "sessionFormatVersion": 3, - "throughSeq": 52 + "throughSeq": 56 } }, { "type": "assistant/message", - "seq": 54, + "seq": 58, "time": 0, "data": { "turn": 1, @@ -1284,7 +1386,7 @@ }, { "type": "tool/call", - "seq": 55, + "seq": 59, "time": 0, "data": { "turn": 1, @@ -1296,7 +1398,7 @@ }, { "type": "tool/result", - "seq": 56, + "seq": 60, "time": 0, "data": { "turn": 1, @@ -1324,13 +1426,13 @@ } }, "sourceEventSeqs": [ - 55 + 59 ], "surfaceOp": "append" }, { "type": "step/end", - "seq": 57, + "seq": 61, "time": 0, "data": { "turn": 1, @@ -1339,7 +1441,7 @@ }, { "type": "step/start", - "seq": 58, + "seq": 62, "time": 0, "data": { "turn": 1, @@ -1348,7 +1450,7 @@ }, { "type": "system/message", - "seq": 59, + "seq": 63, "time": 0, "data": { "turn": 1, @@ -1369,17 +1471,17 @@ } }, "sourceEventSeqs": [ - 25 + 29 ], "surfaceOp": { "op": "replace", - "startSeq": 25, - "endSeq": 25 + "startSeq": 29, + "endSeq": 29 } }, { "type": "request/header", - "seq": 60, + "seq": 64, "time": 0, "data": { "header": { @@ -1415,17 +1517,17 @@ }, { "type": "session-log-deepseek/delivery-accepted", - "seq": 61, + "seq": 65, "time": 0, "data": { "sessionId": "{{parent}}", "sessionFormatVersion": 3, - "throughSeq": 60 + "throughSeq": 64 } }, { "type": "assistant/message", - "seq": 62, + "seq": 66, "time": 0, "data": { "turn": 1, @@ -1509,7 +1611,7 @@ }, { "type": "step/end", - "seq": 63, + "seq": 67, "time": 0, "data": { "turn": 1, @@ -1518,7 +1620,7 @@ }, { "type": "command/run", - "seq": 64, + "seq": 68, "time": 0, "data": { "commandId": "{{command:1}}", @@ -1530,7 +1632,7 @@ }, { "type": "feedback/record", - "seq": 65, + "seq": 69, "time": 0, "data": { "text": "The session needs a clearer explanation." @@ -1538,7 +1640,7 @@ }, { "type": "command/done", - "seq": 66, + "seq": 70, "time": 0, "data": { "commandId": "{{command:1}}", @@ -1548,7 +1650,7 @@ }, { "type": "feedback/record", - "seq": 67, + "seq": 71, "time": 0, "data": { "category": "other" @@ -1556,7 +1658,7 @@ }, { "type": "feedback/message-put", - "seq": 68, + "seq": 72, "time": 0, "data": { "sessionId": "{{parent}}", @@ -1573,7 +1675,7 @@ }, { "type": "feedback/message-put", - "seq": 69, + "seq": 73, "time": 0, "data": { "sessionId": "{{parent}}", @@ -1589,7 +1691,7 @@ }, { "type": "feedback/message-delete", - "seq": 70, + "seq": 74, "time": 0, "data": { "sessionId": "{{parent}}", @@ -1598,7 +1700,7 @@ }, { "type": "turn/end", - "seq": 71, + "seq": 75, "time": 0, "data": { "turn": 1, @@ -1789,9 +1891,41 @@ "payload": { "sessionId": "{{parent}}", "event": { - "type": "request/header", + "type": "user/message", "seq": 10, "time": 0, + "data": { + "id": "{{messageId}}", + "role": "user", + "content": [ + { + "type": "image", + "attachment": { + "attachmentId": "sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640", + "mediaType": "image/png", + "width": 1, + "height": 1, + "bytes": 69 + } + } + ], + "source": { + "kind": "plugin", + "plugin": "python-snapshot-image-offload" + } + }, + "surfaceOp": "append" + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "request/header", + "seq": 11, + "time": 0, "data": { "header": { "config": { @@ -1831,7 +1965,7 @@ "sessionId": "{{parent}}", "event": { "type": "request/context", - "seq": 11, + "seq": 12, "time": 0, "data": { "provider": "deepseek-official", @@ -1847,7 +1981,7 @@ "sessionId": "{{parent}}", "event": { "type": "session/title", - "seq": 12, + "seq": 13, "time": 0, "data": { "title": "Run the advanced packaged-runtime snapsh", @@ -1861,18 +1995,112 @@ } } }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/attempt", + "seq": 14, + "time": 0, + "data": { + "turn": 1, + "step": 1, + "stream": [ + { + "type": "chunk", + "time": 0, + "chunk": { + "type": "finish", + "reason": { + "kind": "error", + "failure": { + "code": "IMAGE_OFFLOAD_REQUIRED", + "message": "authored image budget failure", + "offloadImages": 1 + } + } + } + } + ] + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "image/offload", + "seq": 15, + "time": 0, + "data": { + "targets": [ + { + "seq": 10, + "imageIndexes": [ + 0 + ] + } + ] + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "request/header", + "seq": 16, + "time": 0, + "data": { + "header": { + "config": { + "provider": "deepseek-official", + "model": "smoke-model", + "maxTokens": 256000, + "reasoningEffort": "high" + }, + "adapterDefaults": { + "reasoningEffort": true, + "maxTokens": true + }, + "tools": [ + "cordis_define", + "cordis_inspect_list", + "cordis_inspect_query", + "cordis_inspect_self", + "cordis_run", + "cordis_stop", + "cordis_undefine", + "job_kill", + "job_list", + "job_output", + "run_code", + "subagent", + "workflow" + ] + }, + "reason": "series" + } + } + } + }, { "method": "session.event", "payload": { "sessionId": "{{parent}}", "event": { "type": "session-log-deepseek/delivery-accepted", - "seq": 13, + "seq": 17, "time": 0, "data": { "sessionId": "{{parent}}", "sessionFormatVersion": 3, - "throughSeq": 12 + "throughSeq": 16 } } } @@ -1883,7 +2111,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/message", - "seq": 14, + "seq": 18, "time": 0, "data": { "turn": 1, @@ -1979,7 +2207,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/call", - "seq": 15, + "seq": 19, "time": 0, "data": { "turn": 1, @@ -1997,7 +2225,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/result", - "seq": 16, + "seq": 20, "time": 0, "data": { "turn": 1, @@ -2029,7 +2257,7 @@ } }, "sourceEventSeqs": [ - 15 + 19 ], "surfaceOp": "append" } @@ -2041,7 +2269,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/end", - "seq": 17, + "seq": 21, "time": 0, "data": { "turn": 1, @@ -2056,7 +2284,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/start", - "seq": 18, + "seq": 22, "time": 0, "data": { "turn": 1, @@ -2071,12 +2299,12 @@ "sessionId": "{{parent}}", "event": { "type": "session-log-deepseek/delivery-accepted", - "seq": 19, + "seq": 23, "time": 0, "data": { "sessionId": "{{parent}}", "sessionFormatVersion": 3, - "throughSeq": 18 + "throughSeq": 22 } } } @@ -2087,7 +2315,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/message", - "seq": 20, + "seq": 24, "time": 0, "data": { "turn": 1, @@ -2183,7 +2411,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/call", - "seq": 21, + "seq": 25, "time": 0, "data": { "turn": 1, @@ -2201,7 +2429,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/result", - "seq": 22, + "seq": 26, "time": 0, "data": { "turn": 1, @@ -2234,7 +2462,7 @@ } }, "sourceEventSeqs": [ - 21 + 25 ], "surfaceOp": "append" } @@ -2246,7 +2474,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/end", - "seq": 23, + "seq": 27, "time": 0, "data": { "turn": 1, @@ -2261,7 +2489,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/start", - "seq": 24, + "seq": 28, "time": 0, "data": { "turn": 1, @@ -2276,7 +2504,7 @@ "sessionId": "{{parent}}", "event": { "type": "system/message", - "seq": 25, + "seq": 29, "time": 0, "data": { "turn": 1, @@ -2313,7 +2541,7 @@ "sessionId": "{{parent}}", "event": { "type": "request/header", - "seq": 26, + "seq": 30, "time": 0, "data": { "header": { @@ -2356,12 +2584,12 @@ "sessionId": "{{parent}}", "event": { "type": "session-log-deepseek/delivery-accepted", - "seq": 27, + "seq": 31, "time": 0, "data": { "sessionId": "{{parent}}", "sessionFormatVersion": 3, - "throughSeq": 26 + "throughSeq": 30 } } } @@ -2372,7 +2600,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/message", - "seq": 28, + "seq": 32, "time": 0, "data": { "turn": 1, @@ -2468,7 +2696,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/call", - "seq": 29, + "seq": 33, "time": 0, "data": { "turn": 1, @@ -2486,7 +2714,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/ptc-dispatch-start", - "seq": 30, + "seq": 34, "time": 0, "data": { "rootCallId": "advanced-code", @@ -2506,7 +2734,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/ptc-dispatch", - "seq": 31, + "seq": 35, "time": 0, "data": { "rootCallId": "advanced-code", @@ -2533,7 +2761,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/result", - "seq": 32, + "seq": 36, "time": 0, "data": { "turn": 1, @@ -2561,7 +2789,7 @@ } }, "sourceEventSeqs": [ - 29 + 33 ], "surfaceOp": "append" } @@ -2573,7 +2801,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/end", - "seq": 33, + "seq": 37, "time": 0, "data": { "turn": 1, @@ -2588,7 +2816,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/start", - "seq": 34, + "seq": 38, "time": 0, "data": { "turn": 1, @@ -2603,12 +2831,12 @@ "sessionId": "{{parent}}", "event": { "type": "session-log-deepseek/delivery-accepted", - "seq": 35, + "seq": 39, "time": 0, "data": { "sessionId": "{{parent}}", "sessionFormatVersion": 3, - "throughSeq": 34 + "throughSeq": 38 } } } @@ -2619,7 +2847,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/message", - "seq": 36, + "seq": 40, "time": 0, "data": { "turn": 1, @@ -2715,7 +2943,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/call", - "seq": 37, + "seq": 41, "time": 0, "data": { "turn": 1, @@ -2822,7 +3050,7 @@ "sessionId": "{{parent}}", "event": { "type": "subagent/catalog", - "seq": 38, + "seq": 42, "time": 0, "data": { "version": 0, @@ -3207,7 +3435,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/result", - "seq": 39, + "seq": 43, "time": 0, "data": { "turn": 1, @@ -3235,7 +3463,7 @@ } }, "sourceEventSeqs": [ - 37 + 41 ], "surfaceOp": "append" } @@ -3247,7 +3475,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/end", - "seq": 40, + "seq": 44, "time": 0, "data": { "turn": 1, @@ -3262,7 +3490,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/start", - "seq": 41, + "seq": 45, "time": 0, "data": { "turn": 1, @@ -3277,12 +3505,12 @@ "sessionId": "{{parent}}", "event": { "type": "session-log-deepseek/delivery-accepted", - "seq": 42, + "seq": 46, "time": 0, "data": { "sessionId": "{{parent}}", "sessionFormatVersion": 3, - "throughSeq": 41 + "throughSeq": 45 } } } @@ -3293,7 +3521,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/message", - "seq": 43, + "seq": 47, "time": 0, "data": { "turn": 1, @@ -3389,7 +3617,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/call", - "seq": 44, + "seq": 48, "time": 0, "data": { "turn": 1, @@ -3407,7 +3635,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool-workflow/run-start", - "seq": 45, + "seq": 49, "time": 0, "data": { "runId": "{{workflow-run}}", @@ -3511,7 +3739,7 @@ "sessionId": "{{parent}}", "event": { "type": "subagent/catalog", - "seq": 46, + "seq": 50, "time": 0, "data": { "version": 0, @@ -3528,7 +3756,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool-workflow/agent-start", - "seq": 47, + "seq": 51, "time": 0, "data": { "runId": "{{workflow-run}}", @@ -3912,7 +4140,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool-workflow/agent-end", - "seq": 48, + "seq": 52, "time": 0, "data": { "runId": "{{workflow-run}}", @@ -3928,7 +4156,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool-workflow/run-end", - "seq": 49, + "seq": 53, "time": 0, "data": { "runId": "{{workflow-run}}", @@ -3943,7 +4171,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/result", - "seq": 50, + "seq": 54, "time": 0, "data": { "turn": 1, @@ -3971,7 +4199,7 @@ } }, "sourceEventSeqs": [ - 44 + 48 ], "surfaceOp": "append" } @@ -3983,7 +4211,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/end", - "seq": 51, + "seq": 55, "time": 0, "data": { "turn": 1, @@ -3998,7 +4226,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/start", - "seq": 52, + "seq": 56, "time": 0, "data": { "turn": 1, @@ -4013,12 +4241,12 @@ "sessionId": "{{parent}}", "event": { "type": "session-log-deepseek/delivery-accepted", - "seq": 53, + "seq": 57, "time": 0, "data": { "sessionId": "{{parent}}", "sessionFormatVersion": 3, - "throughSeq": 52 + "throughSeq": 56 } } } @@ -4029,7 +4257,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/message", - "seq": 54, + "seq": 58, "time": 0, "data": { "turn": 1, @@ -4125,7 +4353,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/call", - "seq": 55, + "seq": 59, "time": 0, "data": { "turn": 1, @@ -4143,7 +4371,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/result", - "seq": 56, + "seq": 60, "time": 0, "data": { "turn": 1, @@ -4171,7 +4399,7 @@ } }, "sourceEventSeqs": [ - 55 + 59 ], "surfaceOp": "append" } @@ -4183,7 +4411,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/end", - "seq": 57, + "seq": 61, "time": 0, "data": { "turn": 1, @@ -4198,7 +4426,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/start", - "seq": 58, + "seq": 62, "time": 0, "data": { "turn": 1, @@ -4213,7 +4441,7 @@ "sessionId": "{{parent}}", "event": { "type": "system/message", - "seq": 59, + "seq": 63, "time": 0, "data": { "turn": 1, @@ -4234,12 +4462,12 @@ } }, "sourceEventSeqs": [ - 25 + 29 ], "surfaceOp": { "op": "replace", - "startSeq": 25, - "endSeq": 25 + "startSeq": 29, + "endSeq": 29 } } } @@ -4250,7 +4478,7 @@ "sessionId": "{{parent}}", "event": { "type": "request/header", - "seq": 60, + "seq": 64, "time": 0, "data": { "header": { @@ -4292,12 +4520,12 @@ "sessionId": "{{parent}}", "event": { "type": "session-log-deepseek/delivery-accepted", - "seq": 61, + "seq": 65, "time": 0, "data": { "sessionId": "{{parent}}", "sessionFormatVersion": 3, - "throughSeq": 60 + "throughSeq": 64 } } } @@ -4308,7 +4536,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/message", - "seq": 62, + "seq": 66, "time": 0, "data": { "turn": 1, @@ -4398,7 +4626,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/end", - "seq": 63, + "seq": 67, "time": 0, "data": { "turn": 1, @@ -4413,7 +4641,7 @@ "sessionId": "{{parent}}", "event": { "type": "command/run", - "seq": 64, + "seq": 68, "time": 0, "data": { "commandId": "{{command:1}}", @@ -4431,7 +4659,7 @@ "sessionId": "{{parent}}", "event": { "type": "feedback/record", - "seq": 65, + "seq": 69, "time": 0, "data": { "text": "The session needs a clearer explanation." @@ -4445,7 +4673,7 @@ "sessionId": "{{parent}}", "event": { "type": "command/done", - "seq": 66, + "seq": 70, "time": 0, "data": { "commandId": "{{command:1}}", @@ -4461,7 +4689,7 @@ "sessionId": "{{parent}}", "event": { "type": "feedback/record", - "seq": 67, + "seq": 71, "time": 0, "data": { "category": "other" @@ -4475,7 +4703,7 @@ "sessionId": "{{parent}}", "event": { "type": "feedback/message-put", - "seq": 68, + "seq": 72, "time": 0, "data": { "sessionId": "{{parent}}", @@ -4498,7 +4726,7 @@ "sessionId": "{{parent}}", "event": { "type": "feedback/message-put", - "seq": 69, + "seq": 73, "time": 0, "data": { "sessionId": "{{parent}}", @@ -4520,7 +4748,7 @@ "sessionId": "{{parent}}", "event": { "type": "feedback/message-delete", - "seq": 70, + "seq": 74, "time": 0, "data": { "sessionId": "{{parent}}", @@ -4535,7 +4763,7 @@ "sessionId": "{{parent}}", "event": { "type": "turn/end", - "seq": 71, + "seq": 75, "time": 0, "data": { "turn": 1, diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/session.v3.jsonl b/scripts/snapshots/python-sdk-single-exe/advanced/session.v3.jsonl index 0e056a97b6..81f5d72fb8 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/session.v3.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/advanced/session.v3.jsonl @@ -9,39 +9,43 @@ {"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{messageId}}"}},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Run the advanced packaged-runtime snapshot scenario."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"id":"{{messageId}}","role":"user","content":[{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}}],"source":{"kind":"plugin","plugin":"python-snapshot-image-offload"}},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["cordis_define","cordis_inspect_list","cordis_inspect_query","cordis_inspect_self","cordis_run","cordis_stop","cordis_undefine","job_kill","job_list","job_output","run_code","subagent","workflow"]},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"smoke-model","contextWindow":1000000}} {"type":"session/title","data":{"title":"Run the advanced packaged-runtime snapsh","messageSeqs":[8],"source":{"kind":"fallback"}}} -{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","sessionFormatVersion":3,"throughSeq":12}} +{"type":"assistant/attempt","data":{"turn":1,"step":1,"stream":[{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"code":"IMAGE_OFFLOAD_REQUIRED","message":"authored image budget failure","offloadImages":1}}}}]}} +{"type":"image/offload","data":{"targets":[{"seq":10,"imageIndexes":[0]}]}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["cordis_define","cordis_inspect_list","cordis_inspect_query","cordis_inspect_self","cordis_run","cordis_stop","cordis_undefine","job_kill","job_list","job_output","run_code","subagent","workflow"]},"reason":"series"}} +{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","sessionFormatVersion":3,"throughSeq":16}} {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-define","name":"cordis_define","arguments":"{\"plugin\": {\"kind\": \"new\", \"idPrefix\": \"snap\"}, \"name\": \"Snapshot Double\", \"purpose\": \"Expose a deterministic doubling tool for executable snapshot verification.\", \"code\": {\"host\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"advanced-define","name":"cordis_define","args":["{\"plugin\": {\"kind\": \"new\", \"idPrefix\": \"snap\"}, \"name\": \"Snapshot Double\", \"purpose\": \"Expose a deterministic doubling tool for executable snapshot verification.\", \"code\": {\"host\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-define","name":"cordis_define","arguments":"{\"plugin\": {\"kind\": \"new\", \"idPrefix\": \"snap\"}, \"name\": \"Snapshot Double\", \"purpose\": \"Expose a deterministic doubling tool for executable snapshot verification.\", \"code\": {\"host\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"advanced-define","name":"cordis_define","arguments":"{\"plugin\": {\"kind\": \"new\", \"idPrefix\": \"snap\"}, \"name\": \"Snapshot Double\", \"purpose\": \"Expose a deterministic doubling tool for executable snapshot verification.\", \"code\": {\"host\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}}"}} -{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"advanced-define"},"content":[{"type":"tool-result","toolCallId":"advanced-define","content":[{"type":"text","text":"Defined snap-1/pkg-1 (Snapshot Double); it is not running yet. Use cordis_run to activate this Package."}],"isError":false}],"role":"user","id":"{{messageId}}"},"meta":{"pluginId":"snap-1","packageId":"pkg-1"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"advanced-define"},"content":[{"type":"tool-result","toolCallId":"advanced-define","content":[{"type":"text","text":"Defined snap-1/pkg-1 (Snapshot Double); it is not running yet. Use cordis_run to activate this Package."}],"isError":false}],"role":"user","id":"{{messageId}}"},"meta":{"pluginId":"snap-1","packageId":"pkg-1"}},"sourceEventSeqs":[19],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} -{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","sessionFormatVersion":3,"throughSeq":18}} +{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","sessionFormatVersion":3,"throughSeq":22}} {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-run","name":"cordis_run","arguments":"{\"pluginId\": \"snap-1\", \"packageId\": \"pkg-1\", \"mode\": \"run\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"advanced-run","name":"cordis_run","args":["{\"pluginId\": \"snap-1\", \"packageId\": \"pkg-1\", \"mode\": \"run\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-run","name":"cordis_run","arguments":"{\"pluginId\": \"snap-1\", \"packageId\": \"pkg-1\", \"mode\": \"run\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":2,"callId":"advanced-run","name":"cordis_run","arguments":"{\"pluginId\": \"snap-1\", \"packageId\": \"pkg-1\", \"mode\": \"run\"}"}} -{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-run"},"content":[{"type":"tool-result","toolCallId":"advanced-run","content":[{"type":"text","text":"snap-1/pkg-1 is running (run-1)."}],"isError":false}],"role":"user","id":"{{messageId}}"},"meta":{"pluginId":"snap-1","packageId":"pkg-1","pluginRunId":"run-1"}},"sourceEventSeqs":[21],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-run"},"content":[{"type":"tool-result","toolCallId":"advanced-run","content":[{"type":"text","text":"snap-1/pkg-1 is running (run-1)."}],"isError":false}],"role":"user","id":"{{messageId}}"},"meta":{"pluginId":"snap-1","packageId":"pkg-1","pluginRunId":"run-1"}},"sourceEventSeqs":[25],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} {"type":"step/start","data":{"turn":1,"step":3}} {"type":"system/message","data":{"turn":1,"step":3,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{messageId}}"}},"sourceEventSeqs":[7],"surfaceOp":{"op":"replace","startSeq":7,"endSeq":7}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["cordis_define","cordis_inspect_list","cordis_inspect_query","cordis_inspect_self","cordis_run","cordis_stop","cordis_undefine","job_kill","job_list","job_output","run_code","snapshot_double","subagent","workflow"]},"reason":"change","startsSeries":true}} -{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","sessionFormatVersion":3,"throughSeq":26}} +{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","sessionFormatVersion":3,"throughSeq":30}} {"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"advanced-code","name":"run_code","args":["{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":3,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}} {"type":"tool/ptc-dispatch-start","data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:ptc:1","name":"snapshot_double","arguments":{"value":21}}} {"type":"tool/ptc-dispatch","data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:ptc:1","name":"snapshot_double","arguments":{"value":21},"isError":false,"content":[{"type":"text","text":"42"}]}} -{"type":"tool/result","data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"advanced-code"},"content":[{"type":"tool-result","toolCallId":"advanced-code","content":[{"type":"text","text":"42"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[29],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"advanced-code"},"content":[{"type":"tool-result","toolCallId":"advanced-code","content":[{"type":"text","text":"42"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[33],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":3}} {"type":"step/start","data":{"turn":1,"step":4}} -{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","sessionFormatVersion":3,"throughSeq":34}} +{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","sessionFormatVersion":3,"throughSeq":38}} {"type":"assistant/message","data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"advanced-direct-child","name":"subagent","args":["{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":4,"callId":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}} {"type":"subagent/catalog","data":{"version":0,"childId":"{{child-1}}","childCreatedAt":0,"mode":"one-shot","label":"Check direct child"}} -{"type":"tool/result","data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"advanced-direct-child"},"content":[{"type":"tool-result","toolCallId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[37],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"advanced-direct-child"},"content":[{"type":"tool-result","toolCallId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[41],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":4}} {"type":"step/start","data":{"turn":1,"step":5}} -{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","sessionFormatVersion":3,"throughSeq":41}} +{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","sessionFormatVersion":3,"throughSeq":45}} {"type":"assistant/message","data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"advanced-workflow","name":"workflow","args":["{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":5,"callId":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}} {"type":"tool-workflow/run-start","data":{"runId":"{{workflow-run}}","name":"advanced-exe-snapshot"}} @@ -49,18 +53,18 @@ {"type":"tool-workflow/agent-start","data":{"runId":"{{workflow-run}}","seq":1,"label":"workflow-child","phase":"Delegate","childId":"{{child-2}}"}} {"type":"tool-workflow/agent-end","data":{"runId":"{{workflow-run}}","seq":1,"outcome":"completed"}} {"type":"tool-workflow/run-end","data":{"runId":"{{workflow-run}}","stopReason":"completed"}} -{"type":"tool/result","data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"advanced-workflow"},"content":[{"type":"tool-result","toolCallId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-exe-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[44],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"advanced-workflow"},"content":[{"type":"tool-result","toolCallId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-exe-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[48],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":5}} {"type":"step/start","data":{"turn":1,"step":6}} -{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","sessionFormatVersion":3,"throughSeq":52}} +{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","sessionFormatVersion":3,"throughSeq":56}} {"type":"assistant/message","data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-undefine","name":"cordis_undefine","arguments":"{\"pluginId\": \"snap-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"advanced-undefine","name":"cordis_undefine","args":["{\"pluginId\": \"snap-1\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-undefine","name":"cordis_undefine","arguments":"{\"pluginId\": \"snap-1\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":6,"callId":"advanced-undefine","name":"cordis_undefine","arguments":"{\"pluginId\": \"snap-1\"}"}} -{"type":"tool/result","data":{"turn":1,"step":6,"message":{"source":{"kind":"tool","callId":"advanced-undefine"},"content":[{"type":"tool-result","toolCallId":"advanced-undefine","content":[{"type":"text","text":"Removed dynamic Plugin snap-1 and all of its Packages."}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[55],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":6,"message":{"source":{"kind":"tool","callId":"advanced-undefine"},"content":[{"type":"tool-result","toolCallId":"advanced-undefine","content":[{"type":"text","text":"Removed dynamic Plugin snap-1 and all of its Packages."}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[59],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":6}} {"type":"step/start","data":{"turn":1,"step":7}} -{"type":"system/message","data":{"turn":1,"step":7,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{messageId}}"}},"sourceEventSeqs":[25],"surfaceOp":{"op":"replace","startSeq":25,"endSeq":25}} +{"type":"system/message","data":{"turn":1,"step":7,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{messageId}}"}},"sourceEventSeqs":[29],"surfaceOp":{"op":"replace","startSeq":29,"endSeq":29}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["cordis_define","cordis_inspect_list","cordis_inspect_query","cordis_inspect_self","cordis_run","cordis_stop","cordis_undefine","job_kill","job_list","job_output","run_code","subagent","workflow"]},"reason":"change","startsSeries":true}} -{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","sessionFormatVersion":3,"throughSeq":60}} +{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","sessionFormatVersion":3,"throughSeq":64}} {"type":"assistant/message","data":{"turn":1,"step":7,"message":{"role":"assistant","content":[{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{message:1}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["ADVANCED_EXECUTABLE_OK"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":7}} {"type":"command/run","data":{"commandId":"{{command:1}}","name":"feedback","source":{"kind":"user"}}} diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index e7436d98be..fdde86a63f 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1,6 +1,11 @@ { "comment": "Maps each primary ` ```ts type-equiv ` or ` ```ts public-api ` block (by doc + declared symbol + projection) to the source declaration and original JSDoc it must match. Paired `.zh.md` blocks are byte-identical derivatives checked through their unsuffixed sibling and have no duplicate entry. Omit projection for the complete declaration; use public-api with a ` ```ts public-api ` block for a body-stripped public class declaration. verify-type-equiv.ts enforces a 1:1 correspondence between primary blocks and entries. Add an entry when you add a primary source-equivalence block; remove it when you remove the block.", "entries": [ + { + "doc": "docs/subsystems/session.md", + "symbol": "ImageOffloadTarget", + "source": "packages/core/session/src/types.ts" + }, { "doc": "docs/subsystems/core.md", "symbol": "Branded", diff --git a/snapshots/sdk/inline-image-prompt/session.v3.jsonl b/snapshots/sdk/inline-image-prompt/session.v3.jsonl index 174bc63301..287efd94a6 100644 --- a/snapshots/sdk/inline-image-prompt/session.v3.jsonl +++ b/snapshots/sdk/inline-image-prompt/session.v3.jsonl @@ -12,10 +12,9 @@ {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"}} {"type":"session/title","data":{"title":"Inspect these images, then reply","messageSeqs":[8],"source":{"kind":"fallback"}}} -{"type":"assistant/attempt","data":{"turn":1,"step":1,"stream":[{"type":"chunk","time":1788953601518,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"DeepSeek base64 request images exceed the route budget; 3 more oldest occurrence(s) must be offloaded.","code":"IMAGE_OFFLOAD_REQUIRED","offloadImages":3}}}}]}} -{"type":"compaction/prune","data":{"shadowedRange":{"start":8,"end":8},"shadowedSeqs":[8],"shadowedTokenCount":318}} -{"type":"user/message","data":{"content":[{"type":"text","text":"Inspect these images, then reply with exactly "},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69},"offloaded":true},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69},"offloaded":true},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69},"offloaded":true},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"text","text":"the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"sourceEventSeqs":[8],"surfaceOp":{"op":"replace","startSeq":8,"endSeq":8}} +{"type":"assistant/attempt","data":{"turn":1,"step":1,"stream":[{"type":"chunk","time":1789029408176,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"DeepSeek base64 request images exceed the route budget; 3 more oldest occurrence(s) must be offloaded.","code":"IMAGE_OFFLOAD_REQUIRED","offloadImages":3}}}}]}} +{"type":"image/offload","data":{"targets":[{"seq":8,"imageIndexes":[0,1,2]}]}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"tools":"{{tools}}"},"reason":"series"}} -{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:4}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1788953601528,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"chunk","time":1788953601528,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1788953601528,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1788953601528,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:4}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1789029408184,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"chunk","time":1789029408184,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1789029408184,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1789029408184,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} From debb4b9a9c14e87f0a9bc66c2cd99d8146c30057 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 11 Sep 2026 10:56:48 +0800 Subject: [PATCH 44/79] fix(compaction): recover durable image offload in summary requests --- .../2026-09-10-image-offload-events.i18n.yaml | 4 +- .../2026-09-10-image-offload-events.md | 2 + .../2026-09-10-image-offload-events.zh.md | 2 + docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 14 +- docs/config-catalog.zh.md | 14 +- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 1 + docs/event-producer-consumer.zh.md | 1 + docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 9 +- docs/module-graph.zh.md | 9 +- docs/subsystems/compaction.i18n.yaml | 4 +- docs/subsystems/compaction.md | 33 ++++- docs/subsystems/compaction.zh.md | 33 ++++- .../command-compact/README.i18n.yaml | 4 +- packages/compaction/command-compact/README.md | 4 +- .../compaction/command-compact/README.zh.md | 4 +- .../compaction/command-compact/src/index.ts | 4 +- .../tests/command-compact.spec.ts | 4 +- .../compaction-basic/README.i18n.yaml | 4 +- .../compaction/compaction-basic/README.md | 2 + .../compaction/compaction-basic/README.zh.md | 2 + .../compaction/compaction-basic/src/index.ts | 12 +- .../compaction/compaction-basic/src/region.ts | 18 ++- .../compaction-basic/src/summarizer.ts | 4 +- .../compaction-image-offload/README.i18n.yaml | 4 +- .../compaction-image-offload/README.md | 4 +- .../compaction-image-offload/README.zh.md | 4 +- .../compaction-image-offload/package.json | 11 +- .../src/image-offload.ts | 7 +- .../compaction-image-offload/src/index.ts | 27 ++-- .../tests/image-offload.spec.ts | 134 +++++++++++++++++- .../compaction-image-offload/tsconfig.json | 2 +- .../compaction/compaction/README.i18n.yaml | 4 +- packages/compaction/compaction/README.md | 2 + packages/compaction/compaction/README.zh.md | 2 + packages/compaction/compaction/src/index.ts | 16 +++ .../extensions/tool-cordis/src/api-catalog.ts | 8 ++ packages/llm/llm-deepseek/src/serialize.ts | 4 +- packages/llm/llm/src/content.ts | 5 +- packages/llm/llm/src/error.ts | 5 +- packages/llm/llm/tests/content.spec.ts | 12 +- pnpm-lock.yaml | 19 ++- scripts/gen-cordis-catalog.ts | 1 + .../acp/image-compaction/cordis.snapshot.yml | 3 + snapshots/acp/image-compaction/cordis.yml | 3 + .../image-compaction/summary-image-budget.mjs | 23 +++ 48 files changed, 388 insertions(+), 112 deletions(-) create mode 100644 snapshots/acp/image-compaction/summary-image-budget.mjs diff --git a/.agents/notes/implemented/architecture/2026-09-10-image-offload-events.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-10-image-offload-events.i18n.yaml index 4e15ab443a..3ff31505c2 100644 --- a/.agents/notes/implemented/architecture/2026-09-10-image-offload-events.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-10-image-offload-events.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-10-image-offload-events.md -2026-09-10-image-offload-events.md: d9ade125733422cbb597669e4f5dce0d042b1d7c -2026-09-10-image-offload-events.zh.md: 7d439fc71caadf409dc73182162b2c5cf6e439e4 +2026-09-10-image-offload-events.md: 0e2dbac4629232c578711c96fa24d8c4d8765f07 +2026-09-10-image-offload-events.zh.md: 7e8d104b58a9714abd30b5ceb143e54fb6af8f35 diff --git a/.agents/notes/implemented/architecture/2026-09-10-image-offload-events.md b/.agents/notes/implemented/architecture/2026-09-10-image-offload-events.md index d9ade12573..0e2dbac462 100644 --- a/.agents/notes/implemented/architecture/2026-09-10-image-offload-events.md +++ b/.agents/notes/implemented/architecture/2026-09-10-image-offload-events.md @@ -12,6 +12,8 @@ A request can exceed an image-capable route's byte or image-count budget. Recomp `dsh-compaction-image-offload` owns recovery on the `agent/request-error` waterfall. An `IMAGE_OFFLOAD_REQUIRED` failure supplies `offloadImages`; the plugin selects that many oldest retained input-image occurrences in current surface order, appends one `image/offload` event, and returns `retry`. Assistant output images are excluded. If no retained input image remains, the plugin delegates. Other failures do not enter this recovery. Offload does not consume the provider retry budget or emit `llm/retry`. +Summary requests bypass the agent error waterfall. `dsh-compaction-basic` preserves the full `LlmFailure`, checks cancellation and selection stability, and dispatches synchronous `compaction/summary-error` with the selected event seqs. The same image-offload plugin records omissions only within that selection. Synchronous recovery keeps the stability check and decision adjacent. The backend then re-derives input and pricing, including the shrink baseline. Each retry requires additional omitted occurrences; exhaustion delegates the failure. A later summary failure or cancellation preserves any recorded omissions, so command errors do not claim the conversation is unchanged. + The event payload is `{ targets: [{ seq, imageIndexes }] }`. Each target identifies a current `user/message` or `tool/result` node. Its strictly increasing, zero-based indexes enumerate every image in depth-first content order, including nested tool results and previously omitted images. Equal attachment ids remain distinct occurrences. Exact selections remain unambiguous after positional replacements reorder the surface; an attachment id or a raw-log prefix cannot identify the selected set. The event has no `surfaceOp`. It neither creates a message nor replaces a message node. Session validates all targets before commit and rejects missing or shadowed nodes, output images, duplicate targets, invalid indexes, and already omitted occurrences. Reconstruction derives immutable message copies with `ImageBlock.offloaded: true` at the recorded positions. Original events, message ids, sources, and unaffected blocks remain unchanged. Resume, fork, and replay apply the same selections from the log. diff --git a/.agents/notes/implemented/architecture/2026-09-10-image-offload-events.zh.md b/.agents/notes/implemented/architecture/2026-09-10-image-offload-events.zh.md index 7d439fc71c..7e8d104b58 100644 --- a/.agents/notes/implemented/architecture/2026-09-10-image-offload-events.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-10-image-offload-events.zh.md @@ -12,6 +12,8 @@ Status: implemented `dsh-compaction-image-offload` 在 `agent/request-error` waterfall 上负责恢复。`IMAGE_OFFLOAD_REQUIRED` 失败提供 `offloadImages`,插件按当前 surface 顺序选择该数量最旧且仍保留的输入图片出现位置,追加一个 `image/offload` 事件,再返回 `retry`。Assistant 输出图片不参与选择。没有保留的输入图片时,插件委托后续处理。其他失败不进入这项恢复。图片省略不消耗提供方重试预算,也不产生 `llm/retry`。 +摘要请求不经过 agent 错误 waterfall。`dsh-compaction-basic` 保留完整的 `LlmFailure`,检查取消和选区稳定性,再携带所选事件序号触发同步的 `compaction/summary-error`。同一个图片省略插件只在选区内记录省略。同步恢复使稳定性检查与决策相邻执行。后端随后重新派生输入并计价,包括判断摘要是否缩短输入的基准。每次重试都必须继续省略图片,无图可省略时委派失败。后续摘要失败或取消仍保留已经记录的省略,因此命令错误不声称会话未变。 + 事件载荷为 `{ targets: [{ seq, imageIndexes }] }`。每个目标指向当前的 `user/message` 或 `tool/result` 节点。索引从零开始且严格递增,按内容深度优先顺序枚举所有图片,包括嵌套工具结果和先前省略的图片。相同附件 ID 的多次出现分别计数。位置替换改变 surface 顺序后,明确选择仍没有歧义,附件 ID 或原始日志前缀无法标识这个集合。 事件不带 `surfaceOp`,不创建消息,也不替换消息节点。Session 在提交前校验全部目标,拒绝缺失或已被遮蔽的节点、输出图片、重复目标、无效索引和已被省略的位置。重建过程派生不可变消息副本,将记录的位置标为 `ImageBlock.offloaded: true`。原始事件、消息 ID、来源和未受影响的内容块保持不变。恢复、分叉和回放从日志应用相同的选择。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 30826f3906..23b8806f2c 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 37c6e6eb60b025988c932b008fb82a1379fd152b -config-catalog.zh.md: 8dbc29a64c5a0d7f0fded0630897e006a1362f03 +config-catalog.md: 5e327d8531385267c646b3f9d376187a68ab7326 +config-catalog.zh.md: c5e605e0fa74c561340c9bff80162bde4af49a30 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 37c6e6eb60..5e327d8531 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -484,19 +484,6 @@ export interface ModelCompactPolicyConfig extends CompactionPolicyConfig { Source: [`packages/compaction/compaction-basic/src/types.ts:38`](../packages/compaction/compaction-basic/src/types.ts) - - -## `@deepseek-ai/dsh-compaction-image-offload` - -Requires: `agents` - -```ts config-catalog -/** The executor has no configuration; image-capable routes own their budgets. */ -export type Config = Readonly> -``` - -Source: [`packages/compaction/compaction-image-offload/src/index.ts:21`](../packages/compaction/compaction-image-offload/src/index.ts) - ## `@deepseek-ai/dsh-compaction-tool-result-pruner` @@ -3509,6 +3496,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-command-feedback` — requires `commands` ([`packages/feedback/command-feedback/src/index.ts`](../packages/feedback/command-feedback/src/index.ts)) - `@deepseek-ai/dsh-command-goal` — requires `commands` · `goals` ([`packages/goal/command-goal/src/index.ts`](../packages/goal/command-goal/src/index.ts)) - `@deepseek-ai/dsh-commands` ([`packages/interaction/commands/src/index.ts`](../packages/interaction/commands/src/index.ts)) +- `@deepseek-ai/dsh-compaction-image-offload` — requires `agents` ([`packages/compaction/compaction-image-offload/src/index.ts`](../packages/compaction/compaction-image-offload/src/index.ts)) - `@deepseek-ai/dsh-cordis-client-runner` ([`packages/extensions/cordis-client-runner/src/index.ts`](../packages/extensions/cordis-client-runner/src/index.ts)) - `@deepseek-ai/dsh-deepseek-llm-api-extensions` ([`packages/llm/deepseek-llm-api-extensions/src/index.ts`](../packages/llm/deepseek-llm-api-extensions/src/index.ts)) - `@deepseek-ai/dsh-experimental-client-ui-agent-team` ([`packages/experimental/client-ui-agent-team/src/index.ts`](../packages/experimental/client-ui-agent-team/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 8dbc29a64c..c5e605e0fa 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -486,19 +486,6 @@ export interface ModelCompactPolicyConfig extends CompactionPolicyConfig { 来源:[`packages/compaction/compaction-basic/src/types.ts:38`](../packages/compaction/compaction-basic/src/types.ts) - - -## `@deepseek-ai/dsh-compaction-image-offload` - -需要:`agents` - -```ts config-catalog -/** The executor has no configuration; image-capable routes own their budgets. */ -export type Config = Readonly> -``` - -来源:[`packages/compaction/compaction-image-offload/src/index.ts:21`](../packages/compaction/compaction-image-offload/src/index.ts) - ## `@deepseek-ai/dsh-compaction-tool-result-pruner` @@ -3511,6 +3498,7 @@ export interface Config { - `@deepseek-ai/dsh-command-feedback` — 需要 `commands`([`packages/feedback/command-feedback/src/index.ts`](../packages/feedback/command-feedback/src/index.ts)) - `@deepseek-ai/dsh-command-goal` — 需要 `commands` · `goals`([`packages/goal/command-goal/src/index.ts`](../packages/goal/command-goal/src/index.ts)) - `@deepseek-ai/dsh-commands`([`packages/interaction/commands/src/index.ts`](../packages/interaction/commands/src/index.ts)) +- `@deepseek-ai/dsh-compaction-image-offload`,需要 `agents`([`packages/compaction/compaction-image-offload/src/index.ts`](../packages/compaction/compaction-image-offload/src/index.ts)) - `@deepseek-ai/dsh-cordis-client-runner`([`packages/extensions/cordis-client-runner/src/index.ts`](../packages/extensions/cordis-client-runner/src/index.ts)) - `@deepseek-ai/dsh-deepseek-llm-api-extensions`([`packages/llm/deepseek-llm-api-extensions/src/index.ts`](../packages/llm/deepseek-llm-api-extensions/src/index.ts)) - `@deepseek-ai/dsh-experimental-client-ui-agent-team`([`packages/experimental/client-ui-agent-team/src/index.ts`](../packages/experimental/client-ui-agent-team/src/index.ts)) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index e59424ea40..1695b36ad2 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/event-producer-consumer.md -event-producer-consumer.md: 280a68df728374f2d65634b5711ae0f025d8a4c6 -event-producer-consumer.zh.md: aac8158bad38eca367170f8dc455c26fca649656 +event-producer-consumer.md: 7f27d072254804e586414079b85a4078059f86a8 +event-producer-consumer.zh.md: 26083843ac8b0b0f314cf5a08139107661c963ee diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 280a68df72..7f27d07225 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -30,6 +30,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `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:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | +| `compaction/summary-error` | `waterfall` | [`packages/compaction/compaction/src/index.ts:99`](../packages/compaction/compaction/src/index.ts) | [`compaction-basic`](../packages/compaction/compaction-basic) (`waterfall`) | [`compaction-image-offload`](../packages/compaction/compaction-image-offload) | | `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` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index aac8158bad..26083843ac 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -32,6 +32,7 @@ | `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:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | +| `compaction/summary-error` | `waterfall` | [`packages/compaction/compaction/src/index.ts:99`](../packages/compaction/compaction/src/index.ts) | [`compaction-basic`](../packages/compaction/compaction-basic) (`waterfall`) | [`compaction-image-offload`](../packages/compaction/compaction-image-offload) | | `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` | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 763e27eb50..14296dc4e2 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: bcb1e3cd590b5466220c7f8f32bfd11bd884362f -module-graph.zh.md: 815e91b89c79abfed97fdc0e902740459e14793b +module-graph.md: 464614c2f00002cd66b1acde8bb6c9f41175c72e +module-graph.zh.md: c6a401f534700f22deca036bc0df29af670bf957 diff --git a/docs/module-graph.md b/docs/module-graph.md index bcb1e3cd59..464614c2f0 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -560,9 +560,6 @@ flowchart TD pkg_api_workspace_controller --> pkg_storage_domain pkg_api_workspace_controller --> pkg_typert_protocol pkg_api_workspace_controller --> pkg_workspace - pkg_compaction_image_offload --> pkg_agent - pkg_compaction_image_offload --> pkg_llm - pkg_compaction_image_offload --> pkg_session pkg_file_reference --> pkg_agent pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants @@ -786,6 +783,10 @@ flowchart TD pkg_hooks_codex --> pkg_tools pkg_command_compact --> pkg_commands pkg_command_compact --> pkg_compaction + pkg_compaction_image_offload --> pkg_agent + pkg_compaction_image_offload --> pkg_compaction + pkg_compaction_image_offload --> pkg_llm + pkg_compaction_image_offload --> pkg_session pkg_agent_instructions --> pkg_agent pkg_agent_instructions --> pkg_fs pkg_agent_instructions --> pkg_home_paths @@ -1353,7 +1354,6 @@ flowchart TD | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`web`](../packages/web/web) | | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`shell`](../packages/shell/shell) | | [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`host-directory-picker`](../packages/host/directory-picker), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | -| [`compaction-image-offload`](../packages/compaction/compaction-image-offload) | `compaction` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell) | @@ -1399,6 +1399,7 @@ flowchart TD | [`plan-mode`](../packages/plan/plan-mode) | `plan` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) | | [`hooks-codex`](../packages/hooks/hooks-codex) | `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), [`tools`](../packages/core/tools) | | [`command-compact`](../packages/compaction/command-compact) | `compaction` | [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction) | +| [`compaction-image-offload`](../packages/compaction/compaction-image-offload) | `compaction` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`agent-instructions`](../packages/context/agent-instructions) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`file-reference-local`](../packages/context/file-reference-local) | `context` | [`agent`](../packages/core/agent), [`file-reference`](../packages/context/file-reference), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) | `extensions` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 815e91b89c..c6a401f534 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -562,9 +562,6 @@ flowchart TD pkg_api_workspace_controller --> pkg_storage_domain pkg_api_workspace_controller --> pkg_typert_protocol pkg_api_workspace_controller --> pkg_workspace - pkg_compaction_image_offload --> pkg_agent - pkg_compaction_image_offload --> pkg_llm - pkg_compaction_image_offload --> pkg_session pkg_file_reference --> pkg_agent pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants @@ -788,6 +785,10 @@ flowchart TD pkg_hooks_codex --> pkg_tools pkg_command_compact --> pkg_commands pkg_command_compact --> pkg_compaction + pkg_compaction_image_offload --> pkg_agent + pkg_compaction_image_offload --> pkg_compaction + pkg_compaction_image_offload --> pkg_llm + pkg_compaction_image_offload --> pkg_session pkg_agent_instructions --> pkg_agent pkg_agent_instructions --> pkg_fs pkg_agent_instructions --> pkg_home_paths @@ -1355,7 +1356,6 @@ flowchart TD | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`web`](../packages/web/web) | | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`shell`](../packages/shell/shell) | | [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`host-directory-picker`](../packages/host/directory-picker), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | -| [`compaction-image-offload`](../packages/compaction/compaction-image-offload) | `compaction` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell) | @@ -1401,6 +1401,7 @@ flowchart TD | [`plan-mode`](../packages/plan/plan-mode) | `plan` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) | | [`hooks-codex`](../packages/hooks/hooks-codex) | `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), [`tools`](../packages/core/tools) | | [`command-compact`](../packages/compaction/command-compact) | `compaction` | [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction) | +| [`compaction-image-offload`](../packages/compaction/compaction-image-offload) | `compaction` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`agent-instructions`](../packages/context/agent-instructions) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`file-reference-local`](../packages/context/file-reference-local) | `context` | [`agent`](../packages/core/agent), [`file-reference`](../packages/context/file-reference), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) | `extensions` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | diff --git a/docs/subsystems/compaction.i18n.yaml b/docs/subsystems/compaction.i18n.yaml index c6b7cd2682..20c0221fab 100644 --- a/docs/subsystems/compaction.i18n.yaml +++ b/docs/subsystems/compaction.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/compaction.md -compaction.md: 6a8743feac0481626f171e3926c7f924508db54a -compaction.zh.md: 1b8d2a2d1e47067583593ca3fae039bc78ed06f9 +compaction.md: 33af5e6a4a4da4f18c93e49c03ef65764efc5c57 +compaction.zh.md: 2dffc032b116fd2c7c6963a9009e02cb612c763b diff --git a/docs/subsystems/compaction.md b/docs/subsystems/compaction.md index 6a8743feac..33af5e6a4a 100644 --- a/docs/subsystems/compaction.md +++ b/docs/subsystems/compaction.md @@ -81,7 +81,7 @@ type ManualCompactionErrorCode = | 'persistence' ``` -`changed` and `summary` leave the conversation surface unchanged but still close and persist the failed attempt in the log. `commit` may follow partial mutation; `persistence` means the in-memory bracket closed but its flush failed. Cancellation remains separate and throws the exact abort reason after required cleanup. +`changed` and `summary` close and persist the failed attempt without a summary replacement; image omissions recorded during recovery remain effective. `commit` may follow partial mutation; `persistence` means the in-memory bracket closed but its flush failed. Cancellation remains separate and throws the exact abort reason after required cleanup. Pressure compaction runs at the `agent/pre-step` waterfall before request derivation. Once pressure or canonical overflow qualifies, compaction-basic invokes optional [`ctx.toolResultPruner`](../../packages/compaction/compaction-tool-result-pruner/README.md) before range selection, remeasures through `ctx.tokenMeter`, and can advance the surface without a summary. Failed-request recovery runs through `agent/request-error` after the failed step closes and returns a retry action only when the surface replacement generation advances, even if later summary work throws after pruning; cancellation still wins. Region boundaries preserve tool-call/result pairing but not whole turns, allowing early closed steps of one oversized turn to compact. `dsh-compaction-basic` owns thresholds, retained-tail policy, overflow caps, and failure handling. @@ -235,4 +235,35 @@ pruneSession(session: Session): PruneResult Types: [ContentBlock](llm-streaming.md) · [Session](session.md) Source: [`packages/compaction/compaction-tool-result-pruner/src/index.ts`](../../packages/compaction/compaction-tool-result-pruner/src/index.ts) + + + +### `compaction/*` events + + + +#### `compaction/summary-error` — waterfall + +Recover a failed summary request by synchronously recording a durable change to its selected input. Return true only after making progress; the provider re-derives and re-prices the selection before retrying. Call next() when the failure cannot be recovered. Decisions survive a later summary failure or cancellation. + +```ts cordis-catalog +/** + * Recover a failed summary request by synchronously recording a durable + * change to its selected input. Return true only after making progress; + * the provider re-derives and re-prices the selection before retrying. + * Call next() when the failure cannot be recovered. Decisions survive a + * later summary failure or cancellation. + * @param payload.session - session containing the selected input. + * @param payload.sourceEventSeqs - selected message events in request order. + * @param payload.error - failure thrown by the summarizer. + * @param payload.signal - optional compaction cancellation signal. + * @param next - delegate to the next recovery listener. + * @mode waterfall + */ +'compaction/summary-error'(payload: { session: Session; sourceEventSeqs: readonly SessionSeq[]; error: unknown; signal?: AbortSignal }, next: () => boolean): boolean +``` + +Types: [Session](session.md) · [SessionSeq](session.md) + +Source: [`packages/compaction/compaction/src/index.ts`](../../packages/compaction/compaction/src/index.ts) diff --git a/docs/subsystems/compaction.zh.md b/docs/subsystems/compaction.zh.md index 1b8d2a2d1e..2dffc032b1 100644 --- a/docs/subsystems/compaction.zh.md +++ b/docs/subsystems/compaction.zh.md @@ -81,7 +81,7 @@ type ManualCompactionErrorCode = | 'persistence' ``` -`changed` 和 `summary` 保持会话表层不变,但仍会闭合失败尝试并将其持久化到日志。`commit` 可能发生在部分变更之后;`persistence` 表示内存中的标记对已闭合,但 flush 失败。取消独立于这些失败,并在完成必要清理后抛出原始 abort 原因。 +`changed` 和 `summary` 闭合失败尝试并将其持久化到日志,不写入摘要替换;恢复过程中记录的图片省略仍然有效。`commit` 可能发生在部分变更之后;`persistence` 表示内存中的标记对已闭合,但 flush 失败。取消独立于这些失败,并在完成必要清理后抛出原始 abort 原因。 压力压缩在 `agent/pre-step` waterfall(瀑布式事件)中运行,先于请求推导。一旦压力或规范化溢出满足条件,compaction-basic 会在选择范围前调用可选的 [`ctx.toolResultPruner`](../../packages/compaction/compaction-tool-result-pruner/README.zh.md),再通过 `ctx.tokenMeter` 重新测量,并且可以在不生成摘要的情况下推进 surface。失败请求的恢复在失败的步骤关闭后通过 `agent/request-error` 运行;仅当 surface replacement generation 前进时才返回重试动作,即便后续摘要工作在剪枝后抛异常亦如此;取消仍然优先。区域边界保持工具调用/结果配对,但不保持整个轮次,因此一个过大轮次中较早关闭的步骤可以被压缩。`dsh-compaction-basic` 拥有阈值、保留尾部策略、溢出上限与失败处理。 @@ -235,4 +235,35 @@ pruneSession(session: Session): PruneResult Types: [ContentBlock](llm-streaming.zh.md) · [Session](session.zh.md) Source: [`packages/compaction/compaction-tool-result-pruner/src/index.ts`](../../packages/compaction/compaction-tool-result-pruner/src/index.ts) + + + +### `compaction/*` events + + + +#### `compaction/summary-error` — waterfall + +Recover a failed summary request by synchronously recording a durable change to its selected input. Return true only after making progress; the provider re-derives and re-prices the selection before retrying. Call next() when the failure cannot be recovered. Decisions survive a later summary failure or cancellation. + +```ts cordis-catalog +/** + * Recover a failed summary request by synchronously recording a durable + * change to its selected input. Return true only after making progress; + * the provider re-derives and re-prices the selection before retrying. + * Call next() when the failure cannot be recovered. Decisions survive a + * later summary failure or cancellation. + * @param payload.session - session containing the selected input. + * @param payload.sourceEventSeqs - selected message events in request order. + * @param payload.error - failure thrown by the summarizer. + * @param payload.signal - optional compaction cancellation signal. + * @param next - delegate to the next recovery listener. + * @mode waterfall + */ +'compaction/summary-error'(payload: { session: Session; sourceEventSeqs: readonly SessionSeq[]; error: unknown; signal?: AbortSignal }, next: () => boolean): boolean +``` + +Types: [Session](session.zh.md) · [SessionSeq](session.zh.md) + +Source: [`packages/compaction/compaction/src/index.ts`](../../packages/compaction/compaction/src/index.ts) diff --git a/packages/compaction/command-compact/README.i18n.yaml b/packages/compaction/command-compact/README.i18n.yaml index 3d14a886b9..00d262d04d 100644 --- a/packages/compaction/command-compact/README.i18n.yaml +++ b/packages/compaction/command-compact/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/compaction/command-compact/README.md -README.md: b8881234a3892ac566e41f28fbccbbf9ffa7fc3d -README.zh.md: 821017a1a89bc7ffd07b91313f9bf56b42f143c2 +README.md: 4c9a550febd3cef13fea82c61f9bf96f65a17b64 +README.zh.md: ad31604e9e37c3b676da9b6497cab660e90bc0e4 diff --git a/packages/compaction/command-compact/README.md b/packages/compaction/command-compact/README.md index b8881234a3..4c9a550feb 100644 --- a/packages/compaction/command-compact/README.md +++ b/packages/compaction/command-compact/README.md @@ -42,8 +42,8 @@ The command turns each expected failure into a stable message you can show direc | Situation | Message you see | |---|---| | Compaction already running, or the agent is mid-turn | `Compaction is unavailable because this process has an active compaction, or the agent is not idle.` | -| The history changed while condensing | `The history selected for compaction changed before it could be replaced. The conversation is unchanged; the attempt is recorded in the session log.` | -| No useful summary could be produced | `Compaction could not produce a useful summary. The conversation is unchanged; the attempt is recorded in the session log.` | +| The history changed while condensing | `The history selected for compaction changed before it could be replaced. The attempt is recorded in the session log.` | +| No useful summary could be produced | `Compaction could not produce a useful summary. The attempt is recorded in the session log.` | | Condensation did not finish cleanly | `Compaction did not finish cleanly; some session history may have changed. Inspect the current session state before retrying.` | | The conversation could not be saved | `Compaction finished, but the session could not be saved.` | diff --git a/packages/compaction/command-compact/README.zh.md b/packages/compaction/command-compact/README.zh.md index 821017a1a8..ad31604e9e 100644 --- a/packages/compaction/command-compact/README.zh.md +++ b/packages/compaction/command-compact/README.zh.md @@ -42,8 +42,8 @@ kind: "package-reference" | 情形 | 你看到的消息 | |---|---| | 压缩已在运行,或 agent 正在轮次中 | `Compaction is unavailable because this process has an active compaction, or the agent is not idle.` | -| 压缩过程中历史发生了变化 | `The history selected for compaction changed before it could be replaced. The conversation is unchanged; the attempt is recorded in the session log.` | -| 无法产生有用的摘要 | `Compaction could not produce a useful summary. The conversation is unchanged; the attempt is recorded in the session log.` | +| 压缩过程中历史发生了变化 | `The history selected for compaction changed before it could be replaced. The attempt is recorded in the session log.` | +| 无法产生有用的摘要 | `Compaction could not produce a useful summary. The attempt is recorded in the session log.` | | 压缩未干净地完成 | `Compaction did not finish cleanly; some session history may have changed. Inspect the current session state before retrying.` | | 会话无法保存 | `Compaction finished, but the session could not be saved.` | diff --git a/packages/compaction/command-compact/src/index.ts b/packages/compaction/command-compact/src/index.ts index cd31b9e0a7..2e95de4ae4 100644 --- a/packages/compaction/command-compact/src/index.ts +++ b/packages/compaction/command-compact/src/index.ts @@ -33,12 +33,12 @@ function expectedFailure(error: ManualCompactionError): CommandResult { case 'changed': return { kind: 'error', - text: 'The history selected for compaction changed before it could be replaced. The conversation is unchanged; the attempt is recorded in the session log.', + text: 'The history selected for compaction changed before it could be replaced. The attempt is recorded in the session log.', } case 'summary': return { kind: 'error', - text: 'Compaction could not produce a useful summary. The conversation is unchanged; the attempt is recorded in the session log.', + text: 'Compaction could not produce a useful summary. The attempt is recorded in the session log.', } case 'commit': return { diff --git a/packages/compaction/command-compact/tests/command-compact.spec.ts b/packages/compaction/command-compact/tests/command-compact.spec.ts index bd312d2a99..4cf6f6ad15 100644 --- a/packages/compaction/command-compact/tests/command-compact.spec.ts +++ b/packages/compaction/command-compact/tests/command-compact.spec.ts @@ -208,8 +208,8 @@ describe('/compact human command', () => { it.each([ ['busy', 'Compaction is unavailable because this process has an active compaction, or the agent is not idle.'], ['cancelled', 'Compaction cancelled.'], - ['changed', 'The history selected for compaction changed before it could be replaced. The conversation is unchanged; the attempt is recorded in the session log.'], - ['summary', 'Compaction could not produce a useful summary. The conversation is unchanged; the attempt is recorded in the session log.'], + ['changed', 'The history selected for compaction changed before it could be replaced. The attempt is recorded in the session log.'], + ['summary', 'Compaction could not produce a useful summary. The attempt is recorded in the session log.'], ['commit', 'Compaction did not finish cleanly; some session history may have changed. Inspect the current session state before retrying.'], ['persistence', 'Compaction finished, but the session could not be saved.'], ] as const)('maps expected %s failures to direct errors', async (code, text) => { diff --git a/packages/compaction/compaction-basic/README.i18n.yaml b/packages/compaction/compaction-basic/README.i18n.yaml index 12e4a62f41..fba8635a73 100644 --- a/packages/compaction/compaction-basic/README.i18n.yaml +++ b/packages/compaction/compaction-basic/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/compaction/compaction-basic/README.md -README.md: 6f2c416f8c1adc87f04bbceb6a2ea1ba81fde2aa -README.zh.md: a830a83683c9aa87f974d090355a70570d785e79 +README.md: 68d5a321e04a90bfb726392d1fb179612f6e0626 +README.zh.md: f941cac74053a680cad859b68c7836e181065bc9 diff --git a/packages/compaction/compaction-basic/README.md b/packages/compaction/compaction-basic/README.md index 6f2c416f8c..68d5a321e0 100644 --- a/packages/compaction/compaction-basic/README.md +++ b/packages/compaction/compaction-basic/README.md @@ -119,6 +119,8 @@ A direct `ctx.llm.stream()` call uses the configured provider/model pair and cap ### The region transaction +Failed summary requests dispatch synchronous `compaction/summary-error` after checking cancellation and selection stability. A recovery listener must record a durable input change before requesting retry. The backend re-derives the selected messages and refreshes their token prices and shrink baseline. The image-offload plugin owns image selection; its recorded omissions remain effective if the summary later fails or is cancelled. + The transaction validates the surface span and the durable lock, appends `compaction/start`, summarizes through the hook, revalidates stability (whole-surface for automatic calls, selected-span for manual calls), rejects a summary that does not shrink its source, appends `compaction/summary` plus the replacement `user/message`, and makes exactly one `compaction/end` attempt. A live unmatched start is the durable lock: an unmatched marker before a newer `session/end-seed` is stale evidence from a prior lifecycle and does not block; one after that boundary reports `busy`. A failed close deliberately leaves a blocking orphan. Cancellation remains authoritative after cleanup and durability. ### Config resolution diff --git a/packages/compaction/compaction-basic/README.zh.md b/packages/compaction/compaction-basic/README.zh.md index a830a83683..f941cac740 100644 --- a/packages/compaction/compaction-basic/README.zh.md +++ b/packages/compaction/compaction-basic/README.zh.md @@ -119,6 +119,8 @@ kind: "package-reference" ### 区域事务 +摘要请求失败后,后端先检查取消和选区稳定性,再触发同步的 `compaction/summary-error`。恢复监听器必须先记录持久的输入变更,才能请求重试。后端重新派生所选消息,更新 token 价格及判断摘要是否缩短输入的基准。图片省略插件负责选图;后续摘要失败或取消后,已经记录的省略仍然有效。 + 事务验证表层范围与持久锁,追加 `compaction/start`,通过钩子生成摘要,重新验证稳定性(自动调用要求整个表层、手动调用只要求所选范围),拒绝不缩小源内容的摘要,追加 `compaction/summary` 与替换 `user/message`,并恰好进行一次 `compaction/end` 尝试。活动的未匹配 start 是持久锁:位于较新 `session/end-seed` 之前的未匹配标记是先前生命周期留下的陈旧证据,不会阻塞;位于该边界之后的标记报告 `busy`。闭合失败会有意留下阻塞性的未匹配标记。完成清理与持久化后,取消仍具有最终决定权。 ### 配置解析 diff --git a/packages/compaction/compaction-basic/src/index.ts b/packages/compaction/compaction-basic/src/index.ts index 7a6ce3daba..8be02cb64a 100644 --- a/packages/compaction/compaction-basic/src/index.ts +++ b/packages/compaction/compaction-basic/src/index.ts @@ -8,7 +8,6 @@ import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { CompactionEngine, ManualCompactionError } from '@deepseek-ai/dsh-compaction' import type { CompactionResult, CompactionTrigger } from '@deepseek-ai/dsh-compaction' -import type { TokenMeter } from '@deepseek-ai/dsh-token-meter' import type { Session, SessionSeq } from '@deepseek-ai/dsh-session' import { CONTEXT_WINDOW_EXCEEDED_CODE } from '@deepseek-ai/dsh-llm' import type { LlmCallConfig } from '@deepseek-ai/dsh-llm' @@ -46,9 +45,6 @@ export type { ResolvedTargetPolicy, } from './types.ts' -/** The region transaction's view of this service's dynamically dispatched summarizer. */ -type RegionSummarize = (input: SummarizationInput, agent: Agent, signal?: AbortSignal) => Promise - /** Resolve the exact provider/model durably routed for the latest request. */ function routedTarget( session: Session, @@ -421,10 +417,16 @@ export class BasicCompactionEngine extends CompactionEngine { } /** Bind the effective token meter and dynamically dispatched summarizer hook. */ - private regionDependencies(): { meter: TokenMeter; summarize: RegionSummarize } { + private regionDependencies(): Parameters[0] { return { meter: this.ctx.tokenMeter, summarize: (input, owner, abort) => this.summarize(input, owner, abort), + recover: (error, agent, sourceEventSeqs, signal) => this.ctx.waterfall('compaction/summary-error', { + session: agent.session, + sourceEventSeqs, + error, + ...signal === undefined ? {} : { signal }, + }, () => false), } } } diff --git a/packages/compaction/compaction-basic/src/region.ts b/packages/compaction/compaction-basic/src/region.ts index 8bdecee0cf..40caf48b2f 100644 --- a/packages/compaction/compaction-basic/src/region.ts +++ b/packages/compaction/compaction-basic/src/region.ts @@ -27,6 +27,7 @@ import type { SummarizationInput, SummaryResult } from './summarizer.ts' interface RegionDependencies { readonly meter: TokenMeter summarize(input: SummarizationInput, agent: Agent, signal?: AbortSignal): Promise + recover(error: unknown, agent: Agent, sourceEventSeqs: readonly SessionSeq[], signal?: AbortSignal): boolean } /** One validated inclusive span of current surface positions. */ @@ -225,6 +226,7 @@ export async function compactSurfaceRegion( agent, compactionId, options.sourceCommandId, + assertStable, signal, ) if (options.owner === null) signal?.throwIfAborted() @@ -388,9 +390,23 @@ async function summarizeCompaction( agent: Agent, compactionId: CompactionResult['compactionId'], sourceCommandId: CommandId | undefined, + assertStable: StabilityCheck, signal?: AbortSignal, ): Promise { - const summaryResult = await dependencies.summarize(prepared.input, agent, signal) + let summaryResult: SummaryResult + for (;;) { + signal?.throwIfAborted() + try { + summaryResult = await dependencies.summarize(prepared.input, agent, signal) + break + } catch (error: unknown) { + signal?.throwIfAborted() + assertStable(dependencies, agent.session, prepared) + if (!dependencies.recover(error, agent, prepared.shadowedSeqs, signal)) throw error + prepared = prepareCompaction(dependencies, agent.session, + validateSurfaceRegion(agent.session, prepared.start, prepared.end)) + } + } const checkpointMessage = createUserMessage({ content: frameSummary(summaryResult.summary), source: compactCheckpointSource(compactionId, sourceCommandId), diff --git a/packages/compaction/compaction-basic/src/summarizer.ts b/packages/compaction/compaction-basic/src/summarizer.ts index 5fe259a756..e015bf887d 100644 --- a/packages/compaction/compaction-basic/src/summarizer.ts +++ b/packages/compaction/compaction-basic/src/summarizer.ts @@ -196,9 +196,7 @@ function finishError(finish: FinishReason): Error | undefined { switch (finish.kind) { case 'error': case 'aborted': { - const error = new Error(finish.failure.message) as Error & { code?: string } - error.code = finish.failure.code - return error + return new LlmError(finish.failure.message, finish.failure.code, finish.failure) } case 'max-tokens': { const error = new Error('summarization truncated at the token cap (incomplete checkpoint)') as Error & { code?: string } diff --git a/packages/compaction/compaction-image-offload/README.i18n.yaml b/packages/compaction/compaction-image-offload/README.i18n.yaml index 83e8e5a47b..11ca85fe42 100644 --- a/packages/compaction/compaction-image-offload/README.i18n.yaml +++ b/packages/compaction/compaction-image-offload/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/compaction/compaction-image-offload/README.md -README.md: a34b8c43d0c1de2afa9d4bd4c9638c75bed98f51 -README.zh.md: 9d6c309eed697fcccde043fd87447bf1499410ec +README.md: ed22ccfe8817a70cf05d2f3ff0d54bc779702f4f +README.zh.md: c5830133533a44ec3c3cd36cab1593cda476177a diff --git a/packages/compaction/compaction-image-offload/README.md b/packages/compaction/compaction-image-offload/README.md index a34b8c43d0..ed22ccfe88 100644 --- a/packages/compaction/compaction-image-offload/README.md +++ b/packages/compaction/compaction-image-offload/README.md @@ -35,7 +35,7 @@ Mount this plugin in every composition that runs the agent loop with an image-ca ### What you can observe -Each decision appends one `image/offload` event identifying the selected occurrences by current message-event sequence and depth-first image index. The message events and surface node identities remain unchanged. The retried request follows a fresh `request/header` identifying a new message series. +Each decision appends one `image/offload` event identifying the selected occurrences by current message-event sequence and depth-first image index. The message events and surface node identities remain unchanged. An agent retry follows a fresh `request/header` identifying a new message series. Summary retries remain inside the same compaction bracket. ### Failures and recovery @@ -51,6 +51,8 @@ The plugin acts only on `IMAGE_OFFLOAD_REQUIRED` failures that carry `offloadIma The recovery listener owns selection and retry policy. It reads Session's projected messages, selects the oldest retained input images in request order, and commits the complete selection in one event. Session validates and applies those exact references during append and replay; its shared derivation supplies requests, compaction, and later message rewrites. Token measurement folds the same selections without replacement shadow prices. +Summary failures use the synchronous `compaction/summary-error` waterfall. The plugin selects only the supplied summary region and returns true after recording an omission. The compaction backend re-derives and re-prices that region before retrying; each retry omits additional retained occurrences, so recovery ends when none remain. Cancellation and unrelated selection changes reject the summary. Recorded omissions survive later failure or cancellation. + No runtime invariant companion is published: Session rejects invalid or repeated image references before commit, and this executor retains no separate mutable offload state. diff --git a/packages/compaction/compaction-image-offload/README.zh.md b/packages/compaction/compaction-image-offload/README.zh.md index 9d6c309eed..c583013353 100644 --- a/packages/compaction/compaction-image-offload/README.zh.md +++ b/packages/compaction/compaction-image-offload/README.zh.md @@ -35,7 +35,7 @@ kind: "package-reference" ### 你可以观察到什么 -每次决定追加一条 `image/offload` 事件,通过当前消息事件的序号和消息内深度优先的图片序号指定省略位置。消息事件和表层节点标识保持不变。重试请求前会追加新的 `request/header`,标明新的消息序列。 +每次决定追加一条 `image/offload` 事件,通过当前消息事件的序号和消息内深度优先的图片序号指定省略位置。消息事件和表层节点标识保持不变。agent 重试前会追加新的 `request/header`,标明新的消息序列。摘要重试保留在同一对压缩标记内。 ### 失败与恢复 @@ -51,6 +51,8 @@ kind: "package-reference" 恢复监听器负责选图和重试策略。它读取 Session 派生的消息,按请求顺序选取最早的保留输入图片,并用一条事件提交本次选择。Session 在追加和回放时校验并应用这些确切引用,共享派生结果供请求、压缩(compaction)和后续消息改写使用。token 测量应用同一份选择,不依赖替换影子价格。 +摘要失败使用同步的 `compaction/summary-error` waterfall。插件只在传入的摘要选区内选图,记录省略后返回 true。压缩后端重新派生该选区并计价,然后重试。每次重试都会继续省略保留的图片,没有可省略的位置时结束恢复。取消或无关的选区变更会使摘要失败。已经记录的省略在后续失败或取消后仍然有效。 + 本包不发布运行时 invariant 伴生插件:Session 在提交前拒绝无效或重复省略的图片引用,执行器不维护独立可变的省略状态。 diff --git a/packages/compaction/compaction-image-offload/package.json b/packages/compaction/compaction-image-offload/package.json index 1839b38223..c65c3637b3 100644 --- a/packages/compaction/compaction-image-offload/package.json +++ b/packages/compaction/compaction-image-offload/package.json @@ -28,19 +28,22 @@ "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-compaction": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^" }, - "dependencies": { - "@deepseek-ai/schemastery": "workspace:^" - }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", + "@deepseek-ai/dsh-attachment": "workspace:^", + "@deepseek-ai/dsh-compaction": "workspace:^", + "@deepseek-ai/dsh-compaction-basic": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^" + "@deepseek-ai/dsh-session-projection": "workspace:^", + "@deepseek-ai/dsh-token-meter": "workspace:^" } } diff --git a/packages/compaction/compaction-image-offload/src/image-offload.ts b/packages/compaction/compaction-image-offload/src/image-offload.ts index dc58279a7c..5421b0b5ee 100644 --- a/packages/compaction/compaction-image-offload/src/image-offload.ts +++ b/packages/compaction/compaction-image-offload/src/image-offload.ts @@ -1,19 +1,20 @@ /** Select and log permanent image omissions in current model-request order. */ import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import type { ImageOffloadTarget, Session } from '@deepseek-ai/dsh-session' +import type { ImageOffloadTarget, Session, SessionSeq } from '@deepseek-ai/dsh-session' /** * Record one decision omitting the oldest retained input-image occurrences. * Assistant nodes carry model output and are excluded. Image indexes count * every occurrence, including previously offloaded ones, within each message. * @param session - session whose next request applies the decision. + * @param sourceEventSeqs - input message events in the failed request's order. * @param count - additional retained occurrences the adapter needs omitted. * @returns whether any occurrence remained to offload. */ -export function offloadOldestImages(session: Session, count: number): boolean { +export function offloadOldestImages(session: Session, sourceEventSeqs: readonly SessionSeq[], count: number): boolean { const targets: ImageOffloadTarget[] = [] - for (const seq of session.surface.nodes) { + for (const seq of sourceEventSeqs) { if (count === 0) break // oxlint-disable-next-line typescript/no-non-null-assertion -- current surface nodes index the durable log const event = session.eventAt(seq)! diff --git a/packages/compaction/compaction-image-offload/src/index.ts b/packages/compaction/compaction-image-offload/src/index.ts index 92394190e9..b7074a242e 100644 --- a/packages/compaction/compaction-image-offload/src/index.ts +++ b/packages/compaction/compaction-image-offload/src/index.ts @@ -2,37 +2,38 @@ * Image offload executor for the compaction seam. When an image-capable route * fails a request with `IMAGE_OFFLOAD_REQUIRED`, the plugin records one * `image/offload` decision selecting the oldest retained input occurrences - * and retries the step on the `agent/request-error` waterfall. Every route + * and retries through the agent or compaction summary error waterfall. Every route * sends placeholder text for those occurrences in subsequent requests. * * @module @deepseek-ai/dsh-compaction-image-offload */ import type { Context } from '@deepseek-ai/cordis' -import z from '@deepseek-ai/schemastery' +import type {} from '@deepseek-ai/dsh-compaction' import type { RequestErrorAction } from '@deepseek-ai/dsh-agent' -import { IMAGE_OFFLOAD_REQUIRED_CODE } from '@deepseek-ai/dsh-llm' +import { IMAGE_OFFLOAD_REQUIRED_CODE, LlmError } from '@deepseek-ai/dsh-llm' import { offloadOldestImages } from './image-offload.ts' export const name = 'compaction-image-offload' export const inject = ['agents'] -/** The executor has no configuration; image-capable routes own their budgets. */ -export type Config = Readonly> - -/** Runtime schema for {@link Config}. */ -export const Config = z.object({}) as unknown as z - /** - * Mount the recovery listener. + * Mount agent and summary recovery listeners without configuration. * @param ctx - the plugin context. - * @param _config - no configuration. */ -export function apply(ctx: Context, _config: Config = {}): void { +export function apply(ctx: Context): void { ctx.on('agent/request-error', ({ agent, failure }, next): Promise => { if (failure.code !== IMAGE_OFFLOAD_REQUIRED_CODE || failure.offloadImages === undefined) return next() // A durable surface repair, not a provider retry: it spends no retry budget and logs no retry event. - if (!offloadOldestImages(agent.session, failure.offloadImages)) return next() + if (!offloadOldestImages(agent.session, agent.session.surface.nodes, failure.offloadImages)) return next() return Promise.resolve({ kind: 'retry' }) }) + ctx.on('compaction/summary-error', ({ session, sourceEventSeqs, error, signal }, next) => { + if (!(error instanceof LlmError) + || error.code !== IMAGE_OFFLOAD_REQUIRED_CODE + || error.failure.offloadImages === undefined) return next() + signal?.throwIfAborted() + if (!offloadOldestImages(session, sourceEventSeqs, error.failure.offloadImages)) return next() + return true + }) } diff --git a/packages/compaction/compaction-image-offload/tests/image-offload.spec.ts b/packages/compaction/compaction-image-offload/tests/image-offload.spec.ts index c4cd576aa9..b3dff95e7d 100644 --- a/packages/compaction/compaction-image-offload/tests/image-offload.spec.ts +++ b/packages/compaction/compaction-image-offload/tests/image-offload.spec.ts @@ -6,6 +6,11 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import type { Agent } from '@deepseek-ai/dsh-agent' +import BasicCompactionEngine from '@deepseek-ai/dsh-compaction-basic' +import TokenMeter from '@deepseek-ai/dsh-token-meter' +import { ImageVariantId } from '@deepseek-ai/dsh-attachment' +import { serializeRequestWithImages } from '@deepseek-ai/dsh-llm-deepseek/src/serialize.ts' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { createAssistantMessage, createToolResultMessage, createUserMessage, IMAGE_OFFLOAD_REQUIRED_CODE, LlmAdapter, LlmError, ToolCallId } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' @@ -18,13 +23,25 @@ type ScriptEntry = StreamChunk[] | (() => never) /** Replies one scripted stream per request and declares no retry policy. */ class ScriptedAdapter extends LlmAdapter { readonly requests: GenerateOptions[] = [] + serializeSummary = false - constructor(private readonly script: ScriptEntry[]) { + constructor(readonly script: ScriptEntry[]) { super() } async * stream(options: GenerateOptions): AsyncIterable { this.requests.push(options) + if (this.serializeSummary && options.purpose === 'compaction') { + await serializeRequestWithImages(options, { + representation: { kind: 'base64' }, + requestImages: new Map([image('first').attachment].map(ref => [ref.attachmentId, { + variantId: ImageVariantId(`sha256:${'b'.repeat(64)}`), attachment: ref, + data: new Uint8Array(ref.bytes), mediaType: ref.mediaType, bytes: ref.bytes, + width: ref.width, height: ref.height, depth: 'uchar', space: 'srgb', hasAlpha: false, + }])), + maxRequestImageBytes: 1, + }) + } const entry = this.script.shift() if (entry === undefined) throw new Error('script exhausted') if (typeof entry === 'function') entry() @@ -50,7 +67,7 @@ async function harness(adapter: ScriptedAdapter): Promise { const ctx = new Context() contexts.push(ctx) await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(Object.assign((inner: Context) => { offload.apply(inner, {}) }, { inject: offload.inject })) + await ctx.plugin(offload) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) return ctx @@ -91,6 +108,119 @@ function decisions(session: Session) { return session.snapshotEvents().filter(event => event.type === 'image/offload') } +async function summaryHarness(script: ScriptEntry[]) { + const adapter = new ScriptedAdapter(script) + const ctx = await harness(adapter) + await ctx.plugin(TokenMeter) + const compact = new BasicCompactionEngine(ctx, { auto: false, summarizationProvider: 'mock', summarizationModel: 'summary' }) + const agent = await ctx.agentLoop.create(SessionId('summary-offload'), { provider: 'mock', model: 'mock' }) + return { ctx, compact, agent, adapter } +} + +async function seedImages(agent: Agent, names: string[]) { + agent.followup(createUserMessage({ + content: [{ type: 'text', text: 'conversation details '.repeat(400) }, ...names.map(image)], + source: { kind: 'user' }, + })) + await agent.whenIdle() + const nodes = agent.session.surface.nodes + return { start: nodes.at(-2)!, end: nodes.at(-1)! } +} + +describe('summary image offload', () => { + it.each([new Error('summary failed'), new LlmError('no count', IMAGE_OFFLOAD_REQUIRED_CODE)])('delegates unhandled summary errors: %s', async (error) => { + const { ctx, agent } = await summaryHarness([]) + expect(ctx.waterfall('compaction/summary-error', { session: agent.session, sourceEventSeqs: [], error }, () => false)).toBe(false) + expect(decisions(agent.session)).toEqual([]) + }) + + it('recovers the real summary serializer with a tighter image budget and fresh pricing', async () => { + const { compact, agent, adapter } = await summaryHarness([textResponse('answer'), textResponse('checkpoint')]) + const span = await seedImages(agent, ['first', 'second']) + adapter.serializeSummary = true + const result = await compact.compactNow(agent, new AbortController().signal) + expect(result).not.toBeNull() + expect(adapter.requests.map(offloadedNames)).toEqual([[], [], ['first', 'second']]) + expect(adapter.requests.slice(1).map(request => request.model)).toEqual(['summary', 'summary']) + const events = agent.session.snapshotEvents() + const types = events.map(event => event.type) + expect(types.filter(type => type === 'compaction/start')).toHaveLength(1) + expect(types.filter(type => type === 'compaction/end')).toHaveLength(1) + const [decision] = decisions(agent.session) + expect(decision?.data.targets).toEqual([{ seq: span.start, imageIndexes: [0, 1] }]) + expect(decision!.seq).toBeLessThan(types.indexOf('compaction/summary')) + expect(events[span.start]).not.toHaveProperty('data.content.1.offloaded') + expect(types).not.toContain('compaction/prune') + expect(types).not.toContain('llm/retry') + }) + + it('offloads only the selected summary span and stops after exhausting its images', async () => { + const { compact, agent, adapter } = await summaryHarness([ + textResponse('before'), textResponse('selected'), textResponse('after'), + offloadRequired(1), offloadRequired(1), offloadRequired(1), + ]) + await seedImages(agent, ['outside-before']) + const selected = await seedImages(agent, ['first', 'second']) + await seedImages(agent, ['outside-after']) + agent.session.append('turn/start', { turn: 4 }) + await expect(compact.compactRegion(selected.start, selected.end, agent)).rejects.toMatchObject({ code: IMAGE_OFFLOAD_REQUIRED_CODE }) + expect(adapter.requests.slice(3).map(offloadedNames)).toEqual([[], ['first'], ['first', 'second']]) + expect(decisions(agent.session).map(event => event.data.targets)).toEqual([ + [{ seq: selected.start, imageIndexes: [0] }], [{ seq: selected.start, imageIndexes: [1] }], + ]) + expect(agent.session.surface.replaceGeneration).toBe(0) + expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'compaction/end', data: { error: expect.any(String) } }) + }) + + it('preserves omission when a subsequent summary failure is terminal', async () => { + const { compact, agent, adapter } = await summaryHarness([ + textResponse('answer'), offloadRequired(1), () => { throw new LlmError('provider outage', 'SERVER') }, + ]) + await seedImages(agent, ['first']) + await expect(compact.compactNow(agent, new AbortController().signal)).rejects.toMatchObject({ code: 'summary', cause: { code: 'SERVER' } }) + expect(adapter.requests).toHaveLength(3) + expect(decisions(agent.session)).toHaveLength(1) + expect(agent.session.surface.replaceGeneration).toBe(0) + }) + + it('does not record a decision after cancellation during a failed summary', async () => { + const controller = new AbortController() + const reason = new Error('cancel summary') + const { compact, agent, adapter } = await summaryHarness([ + textResponse('answer'), () => { controller.abort(reason); return offloadRequired(1)() }, + ]) + await seedImages(agent, ['first']) + await expect(compact.compactNow(agent, controller.signal)).rejects.toBe(reason) + expect(adapter.requests).toHaveLength(2) + expect(decisions(agent.session)).toEqual([]) + }) + + it('does not retry when cancellation follows a durable omission', async () => { + const { ctx, compact, agent, adapter } = await summaryHarness([textResponse('answer'), offloadRequired(1)]) + await seedImages(agent, ['first']) + const controller = new AbortController() + const reason = new Error('cancel after offload') + ctx.on('session/event', (_session, event) => { if (event.type === 'image/offload') controller.abort(reason) }) + await expect(compact.compactNow(agent, controller.signal)).rejects.toBe(reason) + expect(adapter.requests).toHaveLength(2) + expect(decisions(agent.session)).toHaveLength(1) + }) + + it('rejects a concurrently changed selection before recording omission', async () => { + const { compact, agent, adapter } = await summaryHarness([textResponse('answer')]) + const selected = await seedImages(agent, ['first']) + adapter.script.push(() => { + agent.session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'replacement' }], source: { kind: 'user' } }), { + surfaceOp: { op: 'replace', startSeq: selected.start, endSeq: selected.end }, + sourceEventSeqs: [selected.start, selected.end], + }) + return offloadRequired(1)() + }) + await expect(compact.compactNow(agent, new AbortController().signal)).rejects.toMatchObject({ code: 'changed' }) + expect(decisions(agent.session)).toEqual([]) + }) +}) + describe('compaction-image-offload', () => { it('logs one exact image selection and retries without replacing the message', async () => { const adapter = new ScriptedAdapter([offloadRequired(2), textResponse('sent')]) diff --git a/packages/compaction/compaction-image-offload/tsconfig.json b/packages/compaction/compaction-image-offload/tsconfig.json index 0260251f8b..e45a128c8e 100644 --- a/packages/compaction/compaction-image-offload/tsconfig.json +++ b/packages/compaction/compaction-image-offload/tsconfig.json @@ -10,7 +10,7 @@ "references": [ { "path": "../../../vendor/cosmokit" }, { "path": "../../../vendor/cordis" }, - { "path": "../../../vendor/schemastery" }, + { "path": "../compaction" }, { "path": "../../llm/llm" }, { "path": "../../core/session" }, { "path": "../../core/agent" } diff --git a/packages/compaction/compaction/README.i18n.yaml b/packages/compaction/compaction/README.i18n.yaml index a444c39af3..c3b4c9decc 100644 --- a/packages/compaction/compaction/README.i18n.yaml +++ b/packages/compaction/compaction/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/compaction/compaction/README.md -README.md: 101a549be11d94ec1e11a55645775f79afabdff8 -README.zh.md: 9e048fae5edf62679044e212b0bdd1c2f3753ebe +README.md: fecb3b6731108c493825f6fa0874ab865781fc2a +README.zh.md: 86e6a2935c408af7e247235378a022ad855c00a0 diff --git a/packages/compaction/compaction/README.md b/packages/compaction/compaction/README.md index 101a549be1..fecb3b6731 100644 --- a/packages/compaction/compaction/README.md +++ b/packages/compaction/compaction/README.md @@ -81,6 +81,8 @@ A backend that summarizes through `ctx.llm.stream()` must forward the abort sign ### Manual failure taxonomy +The synchronous `compaction/summary-error` waterfall lets a backend request durable input recovery for a failed summary and an explicit selection. A listener returns true only after recording progress, or calls `next()` to delegate. The backend checks cancellation and selection stability before dispatch, then re-derives and re-prices input before retrying. The event declaration documents its payload; image policy belongs to `dsh-compaction-image-offload`. + Expected manual failures throw `ManualCompactionError` with a stable `code` from a small closed set; only failures after the `compaction/start` marker are recorded — as a `compaction/end` carrying the error — while a `busy` rejection or pre-start cancellation leaves no record. The per-code semantics live in the [compaction subsystem reference](../../../docs/subsystems/compaction.md). diff --git a/packages/compaction/compaction/README.zh.md b/packages/compaction/compaction/README.zh.md index 9e048fae5e..86e6a2935c 100644 --- a/packages/compaction/compaction/README.zh.md +++ b/packages/compaction/compaction/README.zh.md @@ -81,6 +81,8 @@ kind: "package-reference" ### 手动失败分类 +同步的 `compaction/summary-error` waterfall 让后端为失败的摘要及明确选区请求持久输入恢复。监听器只有在记录实际进展后才返回 true,否则调用 `next()` 委派后续处理。后端在触发事件前检查取消和选区稳定性,重试前重新派生输入并计价。事件声明说明其载荷;图片策略由 `dsh-compaction-image-offload` 负责。 + 预期手动失败会抛出 `ManualCompactionError`,携带来自小型封闭集合的稳定 `code`;只有 `compaction/start` 标记之后发生的失败才会被记录——以携带错误的 `compaction/end` 形式——而 `busy` 拒绝或 start 之前的取消不会留下记录。每个错误码的语义见[压缩子系统参考](../../../docs/subsystems/compaction.zh.md)。 diff --git a/packages/compaction/compaction/src/index.ts b/packages/compaction/compaction/src/index.ts index cfbdd42209..eb8263f200 100644 --- a/packages/compaction/compaction/src/index.ts +++ b/packages/compaction/compaction/src/index.ts @@ -82,6 +82,22 @@ declare module '@deepseek-ai/cordis' { interface Context { compaction: CompactionEngine } + interface Events { + /** + * Recover a failed summary request by synchronously recording a durable + * change to its selected input. Return true only after making progress; + * the provider re-derives and re-prices the selection before retrying. + * Call next() when the failure cannot be recovered. Decisions survive a + * later summary failure or cancellation. + * @param payload.session - session containing the selected input. + * @param payload.sourceEventSeqs - selected message events in request order. + * @param payload.error - failure thrown by the summarizer. + * @param payload.signal - optional compaction cancellation signal. + * @param next - delegate to the next recovery listener. + * @mode waterfall + */ + 'compaction/summary-error'(payload: { session: Session; sourceEventSeqs: readonly SessionSeq[]; error: unknown; signal?: AbortSignal }, next: () => boolean): boolean + } } /** diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 2ea9d8fca8..dafbcdedce 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -3202,6 +3202,14 @@ export const EVENT_API: readonly EventApiEntry[] = [ description: 'A command was registered or unregistered. This is an unfiltered registry notification because a global or scoped change may affect any UI view. Observer failures are contained and cannot veto the registry mutation.', parameters: [], }, + { + name: 'compaction/summary-error', + mode: 'waterfall', + signature: '\'compaction/summary-error\'(payload: { session: Session; sourceEventSeqs: readonly SessionSeq[]; error: unknown; signal?: AbortSignal }, next: () => boolean): boolean', + summary: 'Recover a failed summary request by synchronously recording a durable change to its selected input.', + description: 'Recover a failed summary request by synchronously recording a durable change to its selected input. Return true only after making progress; the provider re-derives and re-prices the selection before retrying. Call next() when the failure cannot be recovered. Decisions survive a later summary failure or cancellation.', + parameters: [{ name: 'payload', description: '.signal - optional compaction cancellation signal.' }, { name: 'next', description: 'delegate to the next recovery listener.' }], + }, { name: 'cordis/dynamic-package', mode: 'emit', diff --git a/packages/llm/llm-deepseek/src/serialize.ts b/packages/llm/llm-deepseek/src/serialize.ts index 056d6d0e6e..a33d0cfbff 100644 --- a/packages/llm/llm-deepseek/src/serialize.ts +++ b/packages/llm/llm-deepseek/src/serialize.ts @@ -394,8 +394,8 @@ export function serializeRequest( /** * Reject a request whose retained occurrences, at their exact request-version * byte lengths under this representation, still exceed the route budget. The - * failure names how many more of the oldest retained occurrences the agent - * loop must offload; the surface, not this serializer, owns the offloaded set. + * failure names how many more oldest retained occurrences need durable + * omission before the request can be retried. */ function assertRetainedImagesFit(messages: readonly Message[], images: ImageSerializationOptions): void { const representation = images.representation.kind === 'file' ? 'raw' : 'base64' diff --git a/packages/llm/llm/src/content.ts b/packages/llm/llm/src/content.ts index eba2c2677b..b5c3e59f3d 100644 --- a/packages/llm/llm/src/content.ts +++ b/packages/llm/llm/src/content.ts @@ -211,12 +211,11 @@ function base64Length(bytes: number): number { /** * Visit every image occurrence of typed content in message order, including - * nested tool-result content. Budget accounting, adapter validation, and - * pricing share this request-content walk. + * nested tool-result content. * @param content - typed model content blocks. * @param visit - called once per occurrence. */ -export function visitImageBlocks(content: readonly ContentBlock[], visit: (block: ImageBlock) => void): void { +function visitImageBlocks(content: readonly ContentBlock[], visit: (block: ImageBlock) => void): void { for (const block of content) { if (block.type === 'image') visit(block) else if (block.type === 'tool-result') visitImageBlocks(block.content, visit) diff --git a/packages/llm/llm/src/error.ts b/packages/llm/llm/src/error.ts index b762c41afa..477735d178 100644 --- a/packages/llm/llm/src/error.ts +++ b/packages/llm/llm/src/error.ts @@ -166,8 +166,7 @@ export function isHarnessError(value: unknown): value is HarnessError { * Canonical code for a request an image-capable route cannot send until more * of its images are offloaded. The failure's `offloadImages` names how many * more of the oldest retained occurrences must be offloaded; - * `dsh-compaction-image-offload` replaces the surface nodes carrying them - * with copies marked `offloaded` and retries the step, so the model never - * receives an unlogged projection. + * `dsh-compaction-image-offload` records an `image/offload` selection before + * the agent or summarizer retries with freshly derived input. */ export const IMAGE_OFFLOAD_REQUIRED_CODE = 'IMAGE_OFFLOAD_REQUIRED' diff --git a/packages/llm/llm/tests/content.spec.ts b/packages/llm/llm/tests/content.spec.ts index 08797273b1..61821d8f9a 100644 --- a/packages/llm/llm/tests/content.spec.ts +++ b/packages/llm/llm/tests/content.spec.ts @@ -13,7 +13,6 @@ import { requiredImageOffload, resolveImageAttachmentAccess, requestImageHandleText, - visitImageBlocks, } from '../src/index.ts' import type { ContentBlock } from '../src/index.ts' @@ -35,10 +34,10 @@ function image(bytes: number, offloaded?: true): Extract { +describe('requiredImageOffload traversal', () => { it('visits image occurrences at every tool-result depth in message order', () => { const seen: number[] = [] - visitImageBlocks([ + const content: ContentBlock[] = [ { type: 'text', text: 'before' }, image(1), { @@ -55,7 +54,12 @@ describe('visitImageBlocks', () => { ], }, image(3), - ], block => seen.push(block.attachment.bytes)) + ] + expect(requiredImageOffload([createUserMessage({ content, source })], + { representation: 'raw', maxImages: 0 }, (block) => { + seen.push(block.attachment.bytes) + return block.attachment.bytes + })).toBe(4) expect(seen).toEqual([1, 2, 4, 3]) }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 85b79c96f6..9308443232 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4714,10 +4714,6 @@ importers: version: link:../../core/tools packages/compaction/compaction-image-offload: - dependencies: - '@deepseek-ai/schemastery': - specifier: link:../../../vendor/schemastery - version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -4731,15 +4727,30 @@ importers: '@deepseek-ai/dsh-agent-loop-testkit': specifier: workspace:^ version: link:../../test-support/agent-loop-testkit + '@deepseek-ai/dsh-attachment': + specifier: workspace:^ + version: link:../../attachment/attachment + '@deepseek-ai/dsh-compaction': + specifier: workspace:^ + version: link:../compaction + '@deepseek-ai/dsh-compaction-basic': + specifier: workspace:^ + version: link:../compaction-basic '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-llm-deepseek': + specifier: workspace:^ + version: link:../../llm/llm-deepseek '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session '@deepseek-ai/dsh-session-projection': specifier: workspace:^ version: link:../../session/session-projection + '@deepseek-ai/dsh-token-meter': + specifier: workspace:^ + version: link:../../llm/token-meter packages/compaction/compaction-tool-result-pruner: dependencies: diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index ed81fccc1f..51f1608d37 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -196,6 +196,7 @@ export const EVENT_SCOPE_PAGE: Record = { 'api-session': 'session.md', 'approval': 'approval.md', 'commands': 'commands.md', + 'compaction': 'compaction.md', 'cordis': 'extensions.md', 'authorization': 'credentials.md', 'credentials': 'credentials.md', diff --git a/snapshots/acp/image-compaction/cordis.snapshot.yml b/snapshots/acp/image-compaction/cordis.snapshot.yml index 8503d79a16..213a65ac8b 100644 --- a/snapshots/acp/image-compaction/cordis.snapshot.yml +++ b/snapshots/acp/image-compaction/cordis.snapshot.yml @@ -62,3 +62,6 @@ name: '@deepseek-ai/dsh-compaction-basic' config: retainTokens: 100 + +- insert: + - name: './summary-image-budget.mjs' diff --git a/snapshots/acp/image-compaction/cordis.yml b/snapshots/acp/image-compaction/cordis.yml index bbe7d3b08f..ee11ff4f15 100644 --- a/snapshots/acp/image-compaction/cordis.yml +++ b/snapshots/acp/image-compaction/cordis.yml @@ -40,3 +40,6 @@ name: '@deepseek-ai/dsh-compaction-basic' config: retainTokens: 100 + +- insert: + - name: './summary-image-budget.mjs' diff --git a/snapshots/acp/image-compaction/summary-image-budget.mjs b/snapshots/acp/image-compaction/summary-image-budget.mjs new file mode 100644 index 0000000000..f74dcd3afa --- /dev/null +++ b/snapshots/acp/image-compaction/summary-image-budget.mjs @@ -0,0 +1,23 @@ +/** Authored summary-route rejection for the image-compaction snapshot. */ +export const name = 'summary-image-budget' +export const inject = ['llm'] + +/** + * @param {import('@deepseek-ai/cordis').Context} ctx - Scenario-local context. + */ +export function apply(ctx) { + let failed = false + ctx.on('llm/stream', async function* (options, next) { + if (options.purpose !== 'compaction' || failed) { + yield* next() + return + } + failed = true + yield { + type: 'finish', + reason: { kind: 'error', failure: { + code: 'IMAGE_OFFLOAD_REQUIRED', message: 'authored summary image budget failure', offloadImages: 1, + } }, + } + }) +} From 73e10fb80c439cb2024709003f1717506fa3f89e Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 11 Sep 2026 10:58:36 +0800 Subject: [PATCH 45/79] fix(compaction): preserve summary diagnostics on cancellation --- packages/compaction/compaction-basic/src/region.ts | 2 +- .../compaction-image-offload/tests/image-offload.spec.ts | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/compaction/compaction-basic/src/region.ts b/packages/compaction/compaction-basic/src/region.ts index 40caf48b2f..4d678a4f13 100644 --- a/packages/compaction/compaction-basic/src/region.ts +++ b/packages/compaction/compaction-basic/src/region.ts @@ -400,7 +400,7 @@ async function summarizeCompaction( summaryResult = await dependencies.summarize(prepared.input, agent, signal) break } catch (error: unknown) { - signal?.throwIfAborted() + if (signal?.aborted === true) throw error assertStable(dependencies, agent.session, prepared) if (!dependencies.recover(error, agent, prepared.shadowedSeqs, signal)) throw error prepared = prepareCompaction(dependencies, agent.session, diff --git a/packages/compaction/compaction-image-offload/tests/image-offload.spec.ts b/packages/compaction/compaction-image-offload/tests/image-offload.spec.ts index b3dff95e7d..cd0aa0ba4e 100644 --- a/packages/compaction/compaction-image-offload/tests/image-offload.spec.ts +++ b/packages/compaction/compaction-image-offload/tests/image-offload.spec.ts @@ -364,6 +364,12 @@ describe('compaction-image-offload', () => { await agent.whenIdle() expect(adapter.requests).toHaveLength(1) expect(decisions(agent.session)).toEqual([]) + expect(ctx.waterfall('compaction/summary-error', { + session: agent.session, + sourceEventSeqs: agent.session.surface.nodes, + error: new LlmError('summary budget exceeded', IMAGE_OFFLOAD_REQUIRED_CODE, { offloadImages: 1 }), + }, () => false)).toBe(false) + expect(decisions(agent.session)).toEqual([]) }) it('leaves every other failure to downstream recovery', async () => { From 08dba5f5f6db7427e8ac39ce769b42f69da2c667 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 11 Sep 2026 11:00:17 +0800 Subject: [PATCH 46/79] test(compaction): record summary image recovery through ACP --- .../compaction-image-offload/tests/image-offload.spec.ts | 4 +++- snapshots/acp/image-compaction/session.v3.jsonl | 7 ++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/compaction/compaction-image-offload/tests/image-offload.spec.ts b/packages/compaction/compaction-image-offload/tests/image-offload.spec.ts index cd0aa0ba4e..d0130034c9 100644 --- a/packages/compaction/compaction-image-offload/tests/image-offload.spec.ts +++ b/packages/compaction/compaction-image-offload/tests/image-offload.spec.ts @@ -169,7 +169,9 @@ describe('summary image offload', () => { [{ seq: selected.start, imageIndexes: [0] }], [{ seq: selected.start, imageIndexes: [1] }], ]) expect(agent.session.surface.replaceGeneration).toBe(0) - expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'compaction/end', data: { error: expect.any(String) } }) + const end = agent.session.snapshotEvents().at(-1) + expect(end?.type).toBe('compaction/end') + expect(end?.type === 'compaction/end' && typeof end.data.error).toBe('string') }) it('preserves omission when a subsequent summary failure is terminal', async () => { diff --git a/snapshots/acp/image-compaction/session.v3.jsonl b/snapshots/acp/image-compaction/session.v3.jsonl index 81b568bfbe..da7df556fb 100644 --- a/snapshots/acp/image-compaction/session.v3.jsonl +++ b/snapshots/acp/image-compaction/session.v3.jsonl @@ -12,19 +12,20 @@ {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp","contextWindow":11600}} {"type":"session/title","data":{"title":"Here are six reference screenshots","messageSeqs":[8],"source":{"kind":"fallback"}}} -{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"Received six dashboard frames; ready to discuss."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:4}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1788699393857,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"chunk","time":1788699393857,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Received six dashboard frames; ready to discuss."}}},{"type":"chunk","time":1788699393857,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1788699393857,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"Received six dashboard frames; ready to discuss."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:4}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1789095569556,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"chunk","time":1789095569556,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Received six dashboard frames; ready to discuss."}}},{"type":"chunk","time":1789095569556,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1789095569556,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Now reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:5}}"}]}} {"type":"turn/start","data":{"turn":2}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"compaction/start","data":{"compactionId":"{{id:1}}","turn":2}} +{"type":"image/offload","data":{"targets":[{"seq":8,"imageIndexes":[0]}]}} {"type":"compaction/summary","data":{"compactionId":"{{id:1}}","summary":[{"type":"text","text":"Six identical 1x1 dashboard reference screenshots were shared and acknowledged."}],"rawOutput":[{"type":"text","text":"Six identical 1x1 dashboard reference screenshots were shared and acknowledged."}],"llmStreamCall":true,"shadowedRange":{"start":8,"end":8},"shadowedSeqs":[8],"shadowedTokenCount":366,"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp","maxTokens":8192,"usage":{"inputTokens":20,"outputTokens":16}}} -{"type":"user/message","data":{"content":[{"type":"text","text":"This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.\n\n"},{"type":"text","text":"Six identical 1x1 dashboard reference screenshots were shared and acknowledged."},{"type":"text","text":""}],"source":{"kind":"plugin","plugin":"compact","compactionId":"{{id:1}}"},"role":"user","id":"{{message:6}}"},"sourceEventSeqs":[19,20,8],"surfaceOp":{"op":"replace","startSeq":8,"endSeq":8}} +{"type":"user/message","data":{"content":[{"type":"text","text":"This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.\n\n"},{"type":"text","text":"Six identical 1x1 dashboard reference screenshots were shared and acknowledged."},{"type":"text","text":""}],"source":{"kind":"plugin","plugin":"compact","compactionId":"{{id:1}}"},"role":"user","id":"{{message:6}}"},"sourceEventSeqs":[19,21,8],"surfaceOp":{"op":"replace","startSeq":8,"endSeq":8}} {"type":"compaction/end","data":{"compactionId":"{{id:1}}","turn":2}} {"type":"step/start","data":{"turn":2,"step":1}} {"type":"user/message","data":{"content":[{"type":"text","text":"Now reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:5}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"tools":"{{tools}}"},"reason":"series"}} -{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:7}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1788699393888,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"chunk","time":1788699393888,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1788699393888,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1788699393888,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:7}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1789095569580,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"chunk","time":1789095569580,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1789095569580,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1789095569580,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} {"type":"step/end","data":{"turn":2,"step":1}} {"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}}} From c59d285f99e78ee567f9af6dcffa5dea87e2444b Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 11 Sep 2026 13:43:02 +0800 Subject: [PATCH 47/79] chore(compaction): mark existing history read for deferred migration --- .../compaction/compaction-image-offload/src/image-offload.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/compaction/compaction-image-offload/src/image-offload.ts b/packages/compaction/compaction-image-offload/src/image-offload.ts index 5421b0b5ee..b20d7cc431 100644 --- a/packages/compaction/compaction-image-offload/src/image-offload.ts +++ b/packages/compaction/compaction-image-offload/src/image-offload.ts @@ -16,7 +16,8 @@ export function offloadOldestImages(session: Session, sourceEventSeqs: readonly const targets: ImageOffloadTarget[] = [] for (const seq of sourceEventSeqs) { if (count === 0) break - // oxlint-disable-next-line typescript/no-non-null-assertion -- current surface nodes index the durable log + // Existing Session history read; migration deferred. Current surface nodes index the durable log. + // oxlint-disable-next-line typescript/no-non-null-assertion, typescript/no-deprecated const event = session.eventAt(seq)! if (event.type !== 'user/message' && event.type !== 'tool/result') continue // oxlint-disable-next-line typescript/no-non-null-assertion -- both input node types produce a message From 413ac14b16ab25f6a666ad467c6123273b587e58 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 11 Sep 2026 15:24:45 +0800 Subject: [PATCH 48/79] refactor: move image offload projection into its plugin --- .../2026-09-10-image-offload-events.i18n.yaml | 4 +- .../2026-09-10-image-offload-events.md | 8 +- .../2026-09-10-image-offload-events.zh.md | 8 +- ...plugin-owned-message-projections.i18n.yaml | 6 + ...-09-11-plugin-owned-message-projections.md | 35 +++++ ...-11-plugin-owned-message-projections.zh.md | 35 +++++ docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 2 +- docs/architecture.zh.md | 2 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 57 +++---- docs/module-graph.zh.md | 57 +++---- docs/persistence-catalog.i18n.yaml | 4 +- docs/persistence-catalog.md | 41 +++-- docs/persistence-catalog.zh.md | 37 +++-- docs/subsystems/compaction.i18n.yaml | 4 +- docs/subsystems/compaction.md | 15 ++ docs/subsystems/compaction.zh.md | 15 ++ docs/subsystems/session.i18n.yaml | 4 +- docs/subsystems/session.md | 90 +++++++---- docs/subsystems/session.zh.md | 90 +++++++---- packages/client/connection/package.json | 3 +- .../client/connection/src/client/fixture.ts | 5 +- .../client/connection/tsconfig.client.json | 3 + .../compaction/compaction-basic/package.json | 3 +- .../tests/manual-compaction.spec.ts | 2 + .../compaction-image-offload/README.i18n.yaml | 4 +- .../compaction-image-offload/README.md | 4 +- .../compaction-image-offload/README.zh.md | 4 +- .../compaction-image-offload/package.json | 12 +- .../src/image-offload.ts | 3 +- .../compaction-image-offload/src/index.ts | 4 +- .../src/project-message.ts} | 2 +- .../src/projection.ts | 74 +++++++++ .../tests/projection.spec.ts} | 32 ++-- .../compaction-image-offload/tsconfig.json | 27 +++- .../package.json | 3 +- .../tests/tool-result-pruner.spec.ts | 5 +- .../compaction/compaction/src/invariant.ts | 2 +- packages/core/session/README.i18n.yaml | 4 +- packages/core/session/README.md | 10 +- packages/core/session/README.zh.md | 10 +- packages/core/session/src/index.ts | 51 ++++-- .../core/session/src/known-event-types.ts | 5 + packages/core/session/src/surface.ts | 148 ++++++++++-------- packages/core/session/src/types.ts | 17 -- .../tests/gen-persistence-catalog.spec.ts | 13 ++ .../session/tests/message-projections.spec.ts | 114 ++++++++++++++ .../extensions/tool-cordis/src/api-catalog.ts | 23 ++- packages/llm/token-meter/package.json | 6 +- packages/llm/token-meter/src/index.ts | 1 + .../token-meter/tests/route-pricing.spec.ts | 5 +- packages/llm/token-meter/tsconfig.json | 3 + .../session-query/session-query/package.json | 3 + .../session-query/src/documents.ts | 3 +- .../session-query/session-query/src/index.ts | 2 + .../session-query/src/tracing.ts | 3 +- .../session-query/session-query/tsconfig.json | 3 + .../session-format-catalog/README.i18n.yaml | 4 +- .../session/session-format-catalog/README.md | 2 +- .../session-format-catalog/README.zh.md | 2 +- .../session-format-catalog/package.json | 12 +- .../session-format-catalog/src/current.ts | 2 + .../src/message-projections.ts | 7 + .../tests/catalog.spec.ts | 16 ++ .../session-format-catalog/tsconfig.json | 3 + .../src/generation.ts | 2 + .../session-telemetry-otel/src/index.ts | 2 +- pnpm-lock.yaml | 23 +++ scripts/gen-cordis-catalog.ts | 1 + scripts/gen-persistence-catalog.ts | 17 +- scripts/type-equiv.manifest.json | 14 +- tsconfig.base.json | 2 + 73 files changed, 908 insertions(+), 339 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-11-plugin-owned-message-projections.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-11-plugin-owned-message-projections.md create mode 100644 .agents/notes/implemented/architecture/2026-09-11-plugin-owned-message-projections.zh.md rename packages/{core/session/src/image-offload.ts => compaction/compaction-image-offload/src/project-message.ts} (93%) create mode 100644 packages/compaction/compaction-image-offload/src/projection.ts rename packages/{core/session/tests/image-offload.spec.ts => compaction/compaction-image-offload/tests/projection.spec.ts} (85%) create mode 100644 packages/core/session/tests/message-projections.spec.ts create mode 100644 packages/session/session-format-catalog/src/message-projections.ts diff --git a/.agents/notes/implemented/architecture/2026-09-10-image-offload-events.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-10-image-offload-events.i18n.yaml index 3ff31505c2..3a9f8ed124 100644 --- a/.agents/notes/implemented/architecture/2026-09-10-image-offload-events.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-10-image-offload-events.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-10-image-offload-events.md -2026-09-10-image-offload-events.md: 0e2dbac4629232c578711c96fa24d8c4d8765f07 -2026-09-10-image-offload-events.zh.md: 7e8d104b58a9714abd30b5ceb143e54fb6af8f35 +2026-09-10-image-offload-events.md: f540dc98cee8daf3e360b14234a2ac21d0f53aab +2026-09-10-image-offload-events.zh.md: 868fca59ba5fc1941de752127f61b091aedb234f diff --git a/.agents/notes/implemented/architecture/2026-09-10-image-offload-events.md b/.agents/notes/implemented/architecture/2026-09-10-image-offload-events.md index 0e2dbac462..f540dc98ce 100644 --- a/.agents/notes/implemented/architecture/2026-09-10-image-offload-events.md +++ b/.agents/notes/implemented/architecture/2026-09-10-image-offload-events.md @@ -16,9 +16,9 @@ Summary requests bypass the agent error waterfall. `dsh-compaction-basic` preser The event payload is `{ targets: [{ seq, imageIndexes }] }`. Each target identifies a current `user/message` or `tool/result` node. Its strictly increasing, zero-based indexes enumerate every image in depth-first content order, including nested tool results and previously omitted images. Equal attachment ids remain distinct occurrences. Exact selections remain unambiguous after positional replacements reorder the surface; an attachment id or a raw-log prefix cannot identify the selected set. -The event has no `surfaceOp`. It neither creates a message nor replaces a message node. Session validates all targets before commit and rejects missing or shadowed nodes, output images, duplicate targets, invalid indexes, and already omitted occurrences. Reconstruction derives immutable message copies with `ImageBlock.offloaded: true` at the recorded positions. Original events, message ids, sources, and unaffected blocks remain unchanged. Resume, fork, and replay apply the same selections from the log. +The event has no `surfaceOp`. It neither creates a message nor replaces a message node. The plugin's pure projection validates all targets before Session commits the event and rejects missing or shadowed nodes, output images, duplicate targets, invalid indexes, and already omitted occurrences. Reconstruction derives immutable message copies with `ImageBlock.offloaded: true` at the recorded positions. Original events, message ids, sources, and unaffected blocks remain unchanged. Resume, fork, and replay apply the same selections from the log. -Selection, budgets, and retry policy stay in the compaction plugin. Session owns only durable validation and reconstruction. The instance `deriveEventMessage()` and `deriveMessages()` return projected messages; pure reconstructors pass `foldSurface(events).offloadedMessages` to the exported `deriveEventMessage()`. Rewriters preserving images must consume projected messages, including tool-result pruning and summarizer input. +The compaction plugin owns selection, event declaration, image validation and projection, and retry policy. [Plugin-owned message projections](2026-09-11-plugin-owned-message-projections.md) owns the generic Session integration and explicit detached assembly, superseding core-owned interpretation. The instance `deriveEventMessage()` and `deriveMessages()` return projected messages; pure reconstructors pass `foldSurface(events, projections).projectedMessages` to the exported `deriveEventMessage()`. Rewriters preserving images must consume projected messages, including tool-result pruning and summarizer input. `contentGeneration` advances for message replacements and image-offload events so cached history and request series are refreshed. `replaceGeneration` advances only for replacements; image omission cannot masquerade as successful text compaction. An unchanged request envelope gets a `request/header` with reason `series`; a simultaneous envelope change carries `startsSeries: true`. @@ -42,8 +42,8 @@ Token accounting applies selections to the affected nodes' image occurrences wit Omission remains effective when a route budget grows, an inline fallback ends, or compaction reduces context. It does not delete attachment bytes. Reading the placeholder's access path creates a new image occurrence that can be retained independently. The execution-world path is still resolved during serialization, as it is for retained image handles; this event records the selected image set, not filesystem mappings. -Session adds a required event fold and one content-generation signal. Consumers reconstructing model input must use projected messages. Human views can still show original images from immutable events. The plugin no longer requires token-meter or compaction services merely to record omission. +Session invokes the registered pure projection and maintains one content-generation signal. Consumers reconstructing model input must use projected messages. Human views can still show original images from immutable events. The plugin requires neither token-meter nor compaction services merely to record omission. ## Testing -Session tests cover nested and repeated occurrences, atomic rejection, cache invalidation, pure folding, restore, fork, and untouched source events. Plugin tests cover consecutive failures, current surface order, exhausted recovery, fresh request headers, and disposal. Token-meter tests preserve node identity and prior usage anchors while repricing only selected occurrences. Tool-result pruning and compaction tests ensure later rewrites consume the projected history. TypeScript and Python SDK expected outputs exercise the required event through shipped profiles. +Plugin projection tests cover nested and repeated occurrences, atomic rejection, cache invalidation, pure folding, restore, fork, and untouched source events. Recovery tests cover consecutive failures, current surface order, exhausted recovery, fresh request headers, and disposal. Token-meter tests preserve node identity and prior usage anchors while repricing only selected occurrences. Tool-result pruning and compaction tests ensure later rewrites consume the projected history. TypeScript and Python SDK expected outputs exercise the required event through shipped profiles. diff --git a/.agents/notes/implemented/architecture/2026-09-10-image-offload-events.zh.md b/.agents/notes/implemented/architecture/2026-09-10-image-offload-events.zh.md index 7e8d104b58..868fca59ba 100644 --- a/.agents/notes/implemented/architecture/2026-09-10-image-offload-events.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-10-image-offload-events.zh.md @@ -16,9 +16,9 @@ Status: implemented 事件载荷为 `{ targets: [{ seq, imageIndexes }] }`。每个目标指向当前的 `user/message` 或 `tool/result` 节点。索引从零开始且严格递增,按内容深度优先顺序枚举所有图片,包括嵌套工具结果和先前省略的图片。相同附件 ID 的多次出现分别计数。位置替换改变 surface 顺序后,明确选择仍没有歧义,附件 ID 或原始日志前缀无法标识这个集合。 -事件不带 `surfaceOp`,不创建消息,也不替换消息节点。Session 在提交前校验全部目标,拒绝缺失或已被遮蔽的节点、输出图片、重复目标、无效索引和已被省略的位置。重建过程派生不可变消息副本,将记录的位置标为 `ImageBlock.offloaded: true`。原始事件、消息 ID、来源和未受影响的内容块保持不变。恢复、分叉和回放从日志应用相同的选择。 +事件不带 `surfaceOp`,不创建消息,也不替换消息节点。插件的纯投影在 Session 提交事件前校验全部目标,拒绝缺失或已被遮蔽的节点、输出图片、重复目标、无效索引和已被省略的位置。重建过程派生不可变消息副本,将记录的位置标为 `ImageBlock.offloaded: true`。原始事件、消息 ID、来源和未受影响的内容块保持不变。恢复、分叉和回放从日志应用相同的选择。 -选择策略、预算和重试策略保留在 compaction 插件中。Session 只负责持久数据校验和重建。实例方法 `deriveEventMessage()` 与 `deriveMessages()` 返回派生消息,纯重建函数将 `foldSurface(events).offloadedMessages` 传给导出的 `deriveEventMessage()`。需要保留图片的改写必须读取派生消息,包括工具结果裁剪和摘要输入。 +compaction 插件拥有选图、事件声明、图片校验与投影,以及重试策略。[插件拥有消息投影](2026-09-11-plugin-owned-message-projections.zh.md)负责通用 Session 集成和显式独立装配,取代由核心负责解释的职责划分。实例方法 `deriveEventMessage()` 与 `deriveMessages()` 返回派生消息,纯重建函数将 `foldSurface(events, projections).projectedMessages` 传给导出的 `deriveEventMessage()`。需要保留图片的改写必须读取派生消息,包括工具结果裁剪和摘要输入。 消息替换和图片省略事件都会推进 `contentGeneration`,使历史缓存和请求系列刷新。`replaceGeneration` 只随替换推进,图片省略不能被判断为文本压缩成功。请求配置未变时写入原因是 `series` 的 `request/header`,配置同时变化时携带 `startsSeries: true`。 @@ -42,8 +42,8 @@ Token 计量将选择应用到受影响节点的图片出现位置,不替换 路由预算增加、内联回退结束或压缩减少上下文时,省略仍然有效。省略不删除附件字节。读取占位文本中的访问路径会创建新的图片出现位置,可以独立保留。执行环境路径仍在序列化时解析,与保留图片的标识文本一致。这个事件记录选择的图片集合,不记录文件系统映射。 -Session 增加必需事件的折叠和一个内容版本信号。重建模型输入的消费者必须使用派生消息。面向人的视图仍可从不可变事件显示原始图片。插件记录省略时不再需要 token-meter 或 compaction 服务。 +Session 调用已注册的纯投影,并维护一个内容版本信号。重建模型输入的消费方必须使用派生消息。面向人的视图仍可从不可变事件显示原始图片。插件记录省略时不需要 token-meter 或 compaction 服务。 ## 测试 -Session 测试覆盖嵌套和重复图片、原子拒绝、缓存失效、纯折叠、恢复、分叉及原始事件不变。插件测试覆盖连续失败、当前 surface 顺序、无图可省略时的恢复、新请求头和释放。Token-meter 测试在仅重计所选位置价格时保留节点身份和已有用量锚点。工具结果裁剪和压缩测试保证后续改写读取派生历史。TypeScript 和 Python SDK 预期输出通过发布的 profile 覆盖必需事件。 +插件的投影测试覆盖嵌套和重复图片、原子拒绝、缓存失效、纯折叠、恢复、分叉及原始事件不变。恢复测试覆盖连续失败、当前 surface 顺序、无图可省略时的恢复、新请求头和释放。Token-meter 测试在仅重计所选位置价格时保留节点身份和已有用量锚点。工具结果裁剪和压缩测试保证后续改写读取派生历史。TypeScript 和 Python SDK 预期输出通过发布的 profile 覆盖必需事件。 diff --git a/.agents/notes/implemented/architecture/2026-09-11-plugin-owned-message-projections.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-11-plugin-owned-message-projections.i18n.yaml new file mode 100644 index 0000000000..7a33bd7068 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-11-plugin-owned-message-projections.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-11-plugin-owned-message-projections.md +2026-09-11-plugin-owned-message-projections.md: 2c58904e229bedcd44ba738506a3a98f34740d1c +2026-09-11-plugin-owned-message-projections.zh.md: 337308bcc43d80e76653d996143a28b917d93a42 diff --git a/.agents/notes/implemented/architecture/2026-09-11-plugin-owned-message-projections.md b/.agents/notes/implemented/architecture/2026-09-11-plugin-owned-message-projections.md new file mode 100644 index 0000000000..2c58904e22 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-11-plugin-owned-message-projections.md @@ -0,0 +1,35 @@ +# Agent Note: Plugin-owned message projections + +Status: implemented + +English | [中文](2026-09-11-plugin-owned-message-projections.zh.md) + +## Problem + +The dedicated [image-offload event](2026-09-10-image-offload-events.md) changes derived message content without replacing nodes. Implementing its image traversal and target validation inside Session makes each feature-specific message transformation a core change. The ordinary session-projection registry derives domain state but does not participate in canonical model-history derivation. + +## Decision + +The event-owning plugin supplies a pure `SessionMessageProjection`: one event type and a synchronous interpreter of the preceding history. It validates the complete durable payload and returns immutable updates for existing messages, preserving their identities. Session owns atomic acceptance, shared live and detached folding, and content-generation cache invalidation. It contains no image selection or image projection implementation. + +The `@messageProjection` tag on the owning `SessionEventMap` member generates the required-interpreter inventory. Missing definitions reject append, seeded creation, restore, and pure folding. This inventory does not make unknown third-party event names readable or change `ignorable` compatibility. A projection event cannot also declare a surface operation. + +The compaction-image-offload plugin registers its definition through a fiber-owned `ctx.sessions.registerMessageProjection()` effect. Each event has one registered owner. Removing a definition invalidates pending committed decisions and cached reads that used it; a replacement definition requires restoring the session. Rejected, uncommitted candidates retain no interpreter dependency. + +Detached readers pass definitions explicitly. The current Session format catalog assembles the same browser-safe plugin exports for persistence validation, offline queries, and browser fixture replay. This static assembly does not mount recovery listeners. Live sessions use the registered composition, while compaction invariants borrow that composition's definitions. No process-global registry or import-time registration is involved. + +The image event payload and required-on-read semantics remain owned by the image-offload note. Its indexes, selection policy, retry behavior, and SDK recordings are unchanged; this decision partially supersedes that note's core-owned interpretation. + +## Alternatives considered + +**Keep image interpretation in Session.** That gives detached callers an implicit built-in, but turns a plugin-owned transformation into core event-specific code. Explicit assembly preserves deterministic replay without assigning the algorithm to core. + +**Use the ordinary session-projection registry.** Its folds expose domain state after commit. They cannot veto an invalid append or supply canonical `deriveMessages()` content without another integration point. + +**Transform only the outgoing request.** Compaction, fork, offline replay, and request invariants also derive messages. A send-time transformation does not cover these consumers. + +## Consequences + +Adding a content-changing event requires its declaration, pure interpreter, live registration, and detached assembly entry. Ordinary log-only events need none of this. Consumers of the pure fold use the generic `projectedMessages` result. The event envelope and stored image decisions remain unchanged, so no structural Session format version is added. + +Tests cover missing interpreters, generic atomic projection, loaded windows, restore and fork, duplicate registration, fiber disposal, cached and pending reads, and rejected candidates. Image-specific tests remain with their plugin. The catalog checks that every generated required type has a detached interpreter and rejects invalid image references. Existing TypeScript and Python image SDK recordings exercise the same durable event through shipped profiles. diff --git a/.agents/notes/implemented/architecture/2026-09-11-plugin-owned-message-projections.zh.md b/.agents/notes/implemented/architecture/2026-09-11-plugin-owned-message-projections.zh.md new file mode 100644 index 0000000000..337308bcc4 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-11-plugin-owned-message-projections.zh.md @@ -0,0 +1,35 @@ +# Agent Note: 插件拥有消息投影 + +Status: implemented + +[English](2026-09-11-plugin-owned-message-projections.md) | 中文 + +## 问题 + +独立的[图片省略事件](2026-09-10-image-offload-events.zh.md)修改派生消息内容,不替换节点。如果图片遍历和目标校验在 Session 内实现,每种功能专用的消息转换都需要修改核心代码。普通的 session-projection 注册表派生领域状态,不参与模型历史的标准派生过程。 + +## 决策 + +事件所属的插件提供纯 `SessionMessageProjection`,包含一个事件类型和同步解释此前历史的处理器。它校验完整的持久载荷,返回现有消息的不可变更新,并保留消息身份。Session 负责原子接受、实时与独立回放共用的折叠,以及内容版本驱动的缓存失效。它不包含选图或图片投影实现。 + +所属 `SessionEventMap` 成员上的 `@messageProjection` 标记生成必需处理器清单。缺少处理器时,追加、带历史的创建、恢复和纯折叠都会被拒绝。这份清单不会让未知的第三方事件名变得可读,也不改变 `ignorable` 的兼容规则。投影事件不能同时声明 surface 操作。 + +compaction-image-offload 插件通过 fiber 拥有的 `ctx.sessions.registerMessageProjection()` effect 注册处理器。每种事件只有一个注册者。移除处理器后,尚未应用的已提交决策和使用过它的缓存读取都会失效。替换处理器需要重新恢复会话。被拒绝且未提交的候选事件不会保留对处理器的依赖。 + +独立读取器显式传入处理器。当前 Session 格式目录装配相同的浏览器安全插件导出,供持久数据校验、离线查询和浏览器 fixture 回放使用。这项静态装配不挂载恢复监听器。实时会话使用已注册的组合,压缩 invariant 借用该组合的处理器。不使用进程全局注册表,也不在导入模块时注册。 + +图片事件载荷和读取时必须识别的语义仍由图片省略记录负责。索引、选图策略、重试行为和 SDK 录制保持不变。本决策部分取代该记录中由核心负责解释事件的职责划分。 + +## 考虑过的替代方案 + +**保留 Session 内的图片解释逻辑。** 独立调用者可以隐式获得内置处理器,但插件拥有的转换会变成核心中特定事件的代码。显式装配能够保留确定性回放,无需让核心拥有算法。 + +**使用普通的 session-projection 注册表。** 它的折叠在提交后公开领域状态。没有额外集成点,它无法否决无效追加,也无法提供标准 `deriveMessages()` 内容。 + +**只转换发出的请求。** 压缩、分叉、离线回放和请求 invariant 也会派生消息。发送时转换无法覆盖这些消费方。 + +## 影响 + +增加修改内容的事件,需要事件声明、纯处理器、实时注册和独立装配项。普通的仅记录日志事件不需要这些机制。纯折叠的消费方使用通用的 `projectedMessages` 结果。事件信封和已经存储的图片决策保持不变,因此不增加结构性的 Session 格式版本。 + +测试覆盖缺少处理器、通用原子投影、已加载窗口、恢复与分叉、重复注册、fiber 释放、缓存与待应用读取,以及被拒绝的候选事件。图片专用测试保留在插件包。目录检查每种生成的必需类型都有独立处理器,并拒绝无效图片引用。现有 TypeScript 和 Python 图片 SDK 录制通过发布的 profile 覆盖相同的持久事件。 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index ac103fce3f..a5fd7f9f7b 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: e2fa9f3e92b2ffca8d2ae252eb396314f874d375 -architecture.zh.md: 5ef3e0a834fc7efc3b9018457572470d3b989c73 +architecture.md: d6187c120f925059be3e094fc55e78d6dec04a5a +architecture.zh.md: 078d99dc06cf49233d0ca4c036bab89a3f664d57 diff --git a/docs/architecture.md b/docs/architecture.md index e2fa9f3e92..d6187c120f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -118,7 +118,7 @@ The session log is the source of the context the model sees. `deriveMessages()` Session consumers know only the current logical format. Header-only `stat` and `list` rescan each Session directory, select its numerically highest canonical generation, and translate a supported historical header without loading events or publishing a successor. A stored-session `open` selects that same generation, refuses a future version, or decodes and composes the static adjacent migration chain once before returning validated current logical events. A read open uses that in-memory result without publishing a successor; a write open first encodes, verifies, and exclusively publishes the final version-named successor beside the unchanged source. Ordinary repair of an unsealed interrupted tail remains a handle consumer responsibility; migration inserts a missing interrupted `turn/end` only for the bounded released restart already sealed by a later `turn/start`. JSONL v0 uses `session.jsonl[.zstd]`, v1 and later use lowercase `session.vN.jsonl[.zstd]`, and committed generation paths are never renamed, replaced, or deleted. The JSONL provider owns physical framing, compression, generation selection, and exclusive publication, while each adjacent migration package owns exactly one `vN -> vN+1` step ([decision](../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md)). -**Model-visible means logged.** Anything that reaches a model request must be reconstructable from the log, and a runtime invariant asserts it. This is why a new model-visible input requires a new session event: extend `SessionEventMap` and render from the log. +**Model-visible means logged.** Anything that reaches a model request must be reconstructable from the log, and a runtime invariant asserts it. A new model-visible input requires a session event. Plugins that change existing message content register [pure message projections](subsystems/session.md#plugin-owned-message-projections); detached readers supply the same definitions explicitly. **Projection seam.** `dsh-session-projection` owns `ctx.sessionProjections`: registered units fold committed events incrementally, host consumers read one typed state with `stateOf()`, and carriers batch cropped client views with `snapshot()`. A host reader either requires this service during activation or fails explicitly when the registry or required key is absent. Contributors may retain `ctx.inject(['sessionProjections'], ...)` registration without silently defaulting a missing host value. The agent loop registers shared `turnBoundary` state for its readers ([decision](../.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.md)). diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 5ef3e0a834..078d99dc06 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -122,7 +122,7 @@ turn/end Session 消费方只了解当前逻辑格式。仅 header 的 `stat` 与 `list` 会重新扫描每个 Session 目录,选择数值最高的规范 generation,并在不加载事件或发布后继的情况下转换受支持的历史 header。已存储 Session 的 `open` 选择同一 generation,拒绝未来版本,或只 Decode 并组合一次构建时静态确定的相邻迁移链,再返回经过校验的当前逻辑事件。只读 open 直接使用这份内存结果,不发布后继;写 open 则先编码、校验并在未改变源的旁边排他发布最终版本命名的后继。未被后续事件封住的普通中断尾部仍由句柄消费方修复;只有在后续 `turn/start` 已经封住一种有限的已发布 restart 时,migration 才会插入缺失的 interrupted `turn/end`。JSONL v0 使用 `session.jsonl[.zstd]`,v1 及后续版本使用小写 `session.vN.jsonl[.zstd]`;已提交 generation 路径绝不重命名、替换或删除。JSONL provider 负责物理 framing、压缩、generation 选择与排他发布,每个相邻迁移包只负责一个 `vN -> vN+1` 步骤([决策](../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md))。 -**模型可见即已记录。** 抵达模型请求的一切都必须能从日志重建,并由一项运行时不变量断言这一点。因此,新增一项模型可见输入就需要新增一个会话事件:扩展 `SessionEventMap` 并从日志渲染。 +**模型可见即已记录。** 抵达模型请求的一切都必须能从日志重建,并由一项运行时不变量断言这一点。新增模型可见输入需要一个会话事件。修改现有消息内容的插件注册[纯消息投影](subsystems/session.zh.md#plugin-owned-message-projections),独立读取器显式传入相同的处理器。 **投影 seam。** `dsh-session-projection` 提供 `ctx.sessionProjections`:已注册单元增量折叠已提交事件,host 消费方通过 `stateOf()` 读取单个类型化状态,载体通过 `snapshot()` 批量取得裁剪后的客户端视图。host 读取方要么在激活时要求该服务,要么在注册表或必需 key 缺席时明确失败。贡献方可以保留 `ctx.inject(['sessionProjections'], ...)` 注册,但不能为缺失的 host 值静默提供默认值。agent loop 为读取方注册共享的 `turnBoundary` 状态([决策](../.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.zh.md))。 diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 14296dc4e2..98678eac1f 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: 464614c2f00002cd66b1acde8bb6c9f41175c72e -module-graph.zh.md: c6a401f534700f22deca036bc0df29af670bf957 +module-graph.md: 51732a536a5ff6f063e27144d22a0d8e8a66f6bd +module-graph.zh.md: 72edfa6c295a59e4f078b818ceac399d93c8bdb6 diff --git a/docs/module-graph.md b/docs/module-graph.md index 464614c2f0..51732a536a 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -698,11 +698,6 @@ flowchart TD pkg_terminal_bash --> pkg_session_projection pkg_terminal_bash --> pkg_subprocess pkg_terminal_bash --> pkg_terminal - pkg_token_meter --> pkg_compaction - pkg_token_meter --> pkg_llm - pkg_token_meter --> pkg_llm_retry - pkg_token_meter --> pkg_session - pkg_token_meter --> pkg_session_projection pkg_agent_loop --> pkg_agent pkg_agent_loop --> pkg_invariants pkg_agent_loop --> pkg_llm @@ -901,6 +896,12 @@ flowchart TD pkg_plugin_package_inventory_deepseek --> pkg_agent_presets pkg_plugin_package_inventory_deepseek --> pkg_deepseek_llm_api_extensions pkg_plugin_package_inventory_deepseek --> pkg_session + pkg_token_meter --> pkg_compaction + pkg_token_meter --> pkg_compaction_image_offload + pkg_token_meter --> pkg_llm + pkg_token_meter --> pkg_llm_retry + pkg_token_meter --> pkg_session + pkg_token_meter --> pkg_session_projection pkg_session_query --> pkg_brand pkg_session_query --> pkg_llm pkg_session_query --> pkg_session @@ -909,14 +910,6 @@ flowchart TD pkg_session_query --> pkg_session_projection_cache pkg_session_query --> pkg_session_title pkg_session_query --> pkg_tool_todo - pkg_acp --> pkg_agent - pkg_acp --> pkg_attachment - pkg_acp --> pkg_llm - pkg_acp --> pkg_mcp_client - pkg_acp --> pkg_session - pkg_acp --> pkg_session_persistence - pkg_acp --> pkg_token_meter - pkg_acp --> pkg_user_approval pkg_api_settings_controller --> pkg_agent_presets pkg_api_settings_controller --> pkg_credentials pkg_api_settings_controller --> pkg_native_command @@ -925,10 +918,6 @@ flowchart TD pkg_api_settings_controller --> pkg_typert_protocol pkg_web_app --> pkg_shell_env pkg_web_app --> pkg_system_prompt - pkg_compaction_tool_result_pruner --> pkg_compaction - pkg_compaction_tool_result_pruner --> pkg_llm - pkg_compaction_tool_result_pruner --> pkg_session - pkg_compaction_tool_result_pruner --> pkg_token_meter pkg_tool_cordis --> pkg_agent pkg_tool_cordis --> pkg_cordis_host_runner pkg_tool_cordis --> pkg_llm @@ -1011,13 +1000,18 @@ flowchart TD pkg_tool_session_query --> pkg_system_prompt pkg_tool_session_query --> pkg_timeout pkg_tool_session_query --> pkg_tools - pkg_compaction_basic --> pkg_agent - pkg_compaction_basic --> pkg_commands - pkg_compaction_basic --> pkg_compaction - pkg_compaction_basic --> pkg_compaction_tool_result_pruner - pkg_compaction_basic --> pkg_llm - pkg_compaction_basic --> pkg_session - pkg_compaction_basic --> pkg_token_meter + pkg_acp --> pkg_agent + pkg_acp --> pkg_attachment + pkg_acp --> pkg_llm + pkg_acp --> pkg_mcp_client + pkg_acp --> pkg_session + pkg_acp --> pkg_session_persistence + pkg_acp --> pkg_token_meter + pkg_acp --> pkg_user_approval + pkg_compaction_tool_result_pruner --> pkg_compaction + pkg_compaction_tool_result_pruner --> pkg_llm + pkg_compaction_tool_result_pruner --> pkg_session + pkg_compaction_tool_result_pruner --> pkg_token_meter pkg_session_reference --> pkg_agent pkg_session_reference --> pkg_compaction pkg_session_reference --> pkg_llm @@ -1106,6 +1100,13 @@ flowchart TD pkg_api_session_controller --> pkg_util_values pkg_api_session_controller --> pkg_util_workspace_path pkg_api_session_controller --> pkg_workspace + pkg_compaction_basic --> pkg_agent + pkg_compaction_basic --> pkg_commands + pkg_compaction_basic --> pkg_compaction + pkg_compaction_basic --> pkg_compaction_tool_result_pruner + pkg_compaction_basic --> pkg_llm + pkg_compaction_basic --> pkg_session + pkg_compaction_basic --> pkg_token_meter pkg_experimental_agent_team --> pkg_agent pkg_experimental_agent_team --> pkg_invariants pkg_experimental_agent_team --> pkg_llm @@ -1384,7 +1385,6 @@ flowchart TD | [`bash-sandbox`](../packages/shell/bash-sandbox) | `shell` | [`bash-local`](../packages/shell/bash-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell) | | [`pwsh-sandbox`](../packages/shell/pwsh-sandbox) | `shell` | [`pwsh-local`](../packages/shell/pwsh-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell) | | [`terminal-bash`](../packages/terminal/terminal-bash) | `terminal` | [`agent`](../packages/core/agent), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subprocess`](../packages/subprocess/subprocess), [`terminal`](../packages/terminal/terminal) | -| [`token-meter`](../packages/llm/token-meter) | `llm` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`agent-tool-presentation`](../packages/core/agent-tool-presentation) | `core` | [`tools`](../packages/core/tools) | | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | @@ -1422,11 +1422,10 @@ flowchart TD | [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`session`](../packages/core/session) | +| [`token-meter`](../packages/llm/token-meter) | `llm` | [`compaction`](../packages/compaction/compaction), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`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-title`](../packages/session/session-title), [`tool-todo`](../packages/todo/tool-todo) | -| [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) | | [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`credentials`](../packages/credentials/credentials), [`native-command`](../packages/util/native-command), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) | -| [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) | | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | @@ -1437,7 +1436,8 @@ flowchart TD | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`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), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval), [`util-time`](../packages/util/time) | | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | -| [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | +| [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) | +| [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`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), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) | | [`webhook-github`](../packages/webhook/webhook-github) | `webhook` | [`credentials`](../packages/credentials/credentials), [`host-webserver`](../packages/host/webserver), [`session`](../packages/core/session), [`webhook`](../packages/webhook/webhook) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | @@ -1448,6 +1448,7 @@ flowchart TD | [`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), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`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-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | +| [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index c6a401f534..72edfa6c29 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -700,11 +700,6 @@ flowchart TD pkg_terminal_bash --> pkg_session_projection pkg_terminal_bash --> pkg_subprocess pkg_terminal_bash --> pkg_terminal - pkg_token_meter --> pkg_compaction - pkg_token_meter --> pkg_llm - pkg_token_meter --> pkg_llm_retry - pkg_token_meter --> pkg_session - pkg_token_meter --> pkg_session_projection pkg_agent_loop --> pkg_agent pkg_agent_loop --> pkg_invariants pkg_agent_loop --> pkg_llm @@ -903,6 +898,12 @@ flowchart TD pkg_plugin_package_inventory_deepseek --> pkg_agent_presets pkg_plugin_package_inventory_deepseek --> pkg_deepseek_llm_api_extensions pkg_plugin_package_inventory_deepseek --> pkg_session + pkg_token_meter --> pkg_compaction + pkg_token_meter --> pkg_compaction_image_offload + pkg_token_meter --> pkg_llm + pkg_token_meter --> pkg_llm_retry + pkg_token_meter --> pkg_session + pkg_token_meter --> pkg_session_projection pkg_session_query --> pkg_brand pkg_session_query --> pkg_llm pkg_session_query --> pkg_session @@ -911,14 +912,6 @@ flowchart TD pkg_session_query --> pkg_session_projection_cache pkg_session_query --> pkg_session_title pkg_session_query --> pkg_tool_todo - pkg_acp --> pkg_agent - pkg_acp --> pkg_attachment - pkg_acp --> pkg_llm - pkg_acp --> pkg_mcp_client - pkg_acp --> pkg_session - pkg_acp --> pkg_session_persistence - pkg_acp --> pkg_token_meter - pkg_acp --> pkg_user_approval pkg_api_settings_controller --> pkg_agent_presets pkg_api_settings_controller --> pkg_credentials pkg_api_settings_controller --> pkg_native_command @@ -927,10 +920,6 @@ flowchart TD pkg_api_settings_controller --> pkg_typert_protocol pkg_web_app --> pkg_shell_env pkg_web_app --> pkg_system_prompt - pkg_compaction_tool_result_pruner --> pkg_compaction - pkg_compaction_tool_result_pruner --> pkg_llm - pkg_compaction_tool_result_pruner --> pkg_session - pkg_compaction_tool_result_pruner --> pkg_token_meter pkg_tool_cordis --> pkg_agent pkg_tool_cordis --> pkg_cordis_host_runner pkg_tool_cordis --> pkg_llm @@ -1013,13 +1002,18 @@ flowchart TD pkg_tool_session_query --> pkg_system_prompt pkg_tool_session_query --> pkg_timeout pkg_tool_session_query --> pkg_tools - pkg_compaction_basic --> pkg_agent - pkg_compaction_basic --> pkg_commands - pkg_compaction_basic --> pkg_compaction - pkg_compaction_basic --> pkg_compaction_tool_result_pruner - pkg_compaction_basic --> pkg_llm - pkg_compaction_basic --> pkg_session - pkg_compaction_basic --> pkg_token_meter + pkg_acp --> pkg_agent + pkg_acp --> pkg_attachment + pkg_acp --> pkg_llm + pkg_acp --> pkg_mcp_client + pkg_acp --> pkg_session + pkg_acp --> pkg_session_persistence + pkg_acp --> pkg_token_meter + pkg_acp --> pkg_user_approval + pkg_compaction_tool_result_pruner --> pkg_compaction + pkg_compaction_tool_result_pruner --> pkg_llm + pkg_compaction_tool_result_pruner --> pkg_session + pkg_compaction_tool_result_pruner --> pkg_token_meter pkg_session_reference --> pkg_agent pkg_session_reference --> pkg_compaction pkg_session_reference --> pkg_llm @@ -1108,6 +1102,13 @@ flowchart TD pkg_api_session_controller --> pkg_util_values pkg_api_session_controller --> pkg_util_workspace_path pkg_api_session_controller --> pkg_workspace + pkg_compaction_basic --> pkg_agent + pkg_compaction_basic --> pkg_commands + pkg_compaction_basic --> pkg_compaction + pkg_compaction_basic --> pkg_compaction_tool_result_pruner + pkg_compaction_basic --> pkg_llm + pkg_compaction_basic --> pkg_session + pkg_compaction_basic --> pkg_token_meter pkg_experimental_agent_team --> pkg_agent pkg_experimental_agent_team --> pkg_invariants pkg_experimental_agent_team --> pkg_llm @@ -1386,7 +1387,6 @@ flowchart TD | [`bash-sandbox`](../packages/shell/bash-sandbox) | `shell` | [`bash-local`](../packages/shell/bash-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell) | | [`pwsh-sandbox`](../packages/shell/pwsh-sandbox) | `shell` | [`pwsh-local`](../packages/shell/pwsh-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell) | | [`terminal-bash`](../packages/terminal/terminal-bash) | `terminal` | [`agent`](../packages/core/agent), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subprocess`](../packages/subprocess/subprocess), [`terminal`](../packages/terminal/terminal) | -| [`token-meter`](../packages/llm/token-meter) | `llm` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`agent-tool-presentation`](../packages/core/agent-tool-presentation) | `core` | [`tools`](../packages/core/tools) | | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | @@ -1424,11 +1424,10 @@ flowchart TD | [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`session`](../packages/core/session) | +| [`token-meter`](../packages/llm/token-meter) | `llm` | [`compaction`](../packages/compaction/compaction), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`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-title`](../packages/session/session-title), [`tool-todo`](../packages/todo/tool-todo) | -| [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) | | [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`credentials`](../packages/credentials/credentials), [`native-command`](../packages/util/native-command), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) | -| [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) | | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | @@ -1439,7 +1438,8 @@ flowchart TD | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`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), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval), [`util-time`](../packages/util/time) | | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | -| [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | +| [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) | +| [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`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), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) | | [`webhook-github`](../packages/webhook/webhook-github) | `webhook` | [`credentials`](../packages/credentials/credentials), [`host-webserver`](../packages/host/webserver), [`session`](../packages/core/session), [`webhook`](../packages/webhook/webhook) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | @@ -1450,6 +1450,7 @@ flowchart TD | [`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), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`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-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | +| [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 937e20fe19..0bc6326a5c 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/persistence-catalog.md -persistence-catalog.md: 69982dcf3cb162bc95d7b9f1c3bc2375977ca33d -persistence-catalog.zh.md: efb8b5b53b6d2c8ffdd96b3bc2e9c2f797926d7d +persistence-catalog.md: 331e55e7a1568c722b090dabfc48f858b42e4f62 +persistence-catalog.zh.md: c7840d1b404102f9a3a40fae76daf4a9f1d456a3 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 69982dcf3c..331e55e7a1 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -83,7 +83,7 @@ export type SessionEvent = { }[T] ``` -Sources: [`packages/core/session/src/types.ts:421`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:429`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:451`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:482`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:404`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:412`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:434`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:465`](../packages/core/session/src/types.ts) ## Events @@ -210,7 +210,7 @@ Source: [`packages/interaction/user-approval/src/index.ts:33`](../packages/inter 'assistant/attempt': { turn: number; step: number; stream: AssistantStreamRecord[] } ``` -Source: [`packages/core/session/src/types.ts:344`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:335`](../packages/core/session/src/types.ts) @@ -240,7 +240,7 @@ Source: [`packages/core/session/src/types.ts:344`](../packages/core/session/src/ Types: [TokenUsage](subsystems/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:330`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:321`](../packages/core/session/src/types.ts) ### `command/*` @@ -522,17 +522,16 @@ Source: [`packages/hooks/hook-protocol/src/types.ts:31`](../packages/hooks/hook- ```ts persistence-catalog /** - * Permanently omit the selected input-image occurrences from subsequent - * model requests. Each target names a current user/message or tool/result - * node; image indexes are zero-based depth-first positions within that - * message, including nested tool results and already offloaded images. - * Targets are unique and each index list is nonempty and strictly increasing. - * This event changes derived content without replacing message nodes. + * Permanently omit selected input-image occurrences from subsequent model requests. + * Targets name unique current user/message or tool/result nodes. Nonempty, strictly + * increasing indexes count all images in depth-first order, including nested tool + * results and already omitted images. Message nodes and identities remain unchanged. + * @messageProjection */ 'image/offload': { targets: ImageOffloadTarget[] } ``` -Source: [`packages/core/session/src/types.ts:278`](../packages/core/session/src/types.ts) +Source: [`packages/compaction/compaction-image-offload/src/projection.ts:25`](../packages/compaction/compaction-image-offload/src/projection.ts) ### `llm/*` @@ -625,7 +624,7 @@ Source: [`packages/plan/plan-mode/src/index.ts:47`](../packages/plan/plan-mode/s 'request/context': RequestContext ``` -Source: [`packages/core/session/src/types.ts:386`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:377`](../packages/core/session/src/types.ts) @@ -644,7 +643,7 @@ Source: [`packages/core/session/src/types.ts:386`](../packages/core/session/src/ } ``` -Source: [`packages/core/session/src/types.ts:374`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:365`](../packages/core/session/src/types.ts) ### `sandbox/*` @@ -719,7 +718,7 @@ Source: [`packages/schedule/schedule/src/types.ts:219`](../packages/schedule/sch 'session/end-seed': { inherited?: true } ``` -Source: [`packages/core/session/src/types.ts:409`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:400`](../packages/core/session/src/types.ts) @@ -781,7 +780,7 @@ Source: [`packages/session/session-log-deepseek/src/types.ts:81`](../packages/se 'step/end': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:298`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:289`](../packages/core/session/src/types.ts) @@ -792,7 +791,7 @@ Source: [`packages/core/session/src/types.ts:298`](../packages/core/session/src/ 'step/start': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:296`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:287`](../packages/core/session/src/types.ts) ### `subagent/*` @@ -868,7 +867,7 @@ Source: [`packages/subagent/tool-subagent/src/model-selection-state.ts:17`](../p 'system/message': { turn: number; step: number; message: SystemMessage } ``` -Source: [`packages/core/session/src/types.ts:319`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:310`](../packages/core/session/src/types.ts) ### `team/*` @@ -961,7 +960,7 @@ Source: [`packages/todo/tool-todo/src/types.ts:31`](../packages/todo/tool-todo/s Types: [ToolCallId](subsystems/core.md) -Source: [`packages/core/session/src/types.ts:350`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:341`](../packages/core/session/src/types.ts) @@ -1037,7 +1036,7 @@ Source: [`packages/core/tools/src/types.ts:40`](../packages/core/tools/src/types } ``` -Source: [`packages/core/session/src/types.ts:362`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:353`](../packages/core/session/src/types.ts) ### `tool-workflow/*` @@ -1117,7 +1116,7 @@ Source: [`packages/workflow/tool-workflow/src/types.ts:47`](../packages/workflow Types: [TurnEndReason](subsystems/session.md) -Source: [`packages/core/session/src/types.ts:294`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:285`](../packages/core/session/src/types.ts) @@ -1133,7 +1132,7 @@ Source: [`packages/core/session/src/types.ts:294`](../packages/core/session/src/ 'turn/start': { turn: number } ``` -Source: [`packages/core/session/src/types.ts:285`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/types.ts) ### `user/*` @@ -1152,7 +1151,7 @@ Source: [`packages/core/session/src/types.ts:285`](../packages/core/session/src/ 'user/message': UserMessage ``` -Source: [`packages/core/session/src/types.ts:306`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:297`](../packages/core/session/src/types.ts) ### `web/*` diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index efb8b5b53b..c7840d1b40 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -212,7 +212,7 @@ export type SessionEvent = { 'assistant/attempt': { turn: number; step: number; stream: AssistantStreamRecord[] } ``` -来源:[`packages/core/session/src/types.ts:344`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:335`](../packages/core/session/src/types.ts) @@ -242,7 +242,7 @@ export type SessionEvent = { 类型:[TokenUsage](subsystems/llm-streaming.zh.md) -来源:[`packages/core/session/src/types.ts:330`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:321`](../packages/core/session/src/types.ts) ### `command/*` @@ -524,17 +524,16 @@ export type SessionEvent = { ```ts persistence-catalog /** - * Permanently omit the selected input-image occurrences from subsequent - * model requests. Each target names a current user/message or tool/result - * node; image indexes are zero-based depth-first positions within that - * message, including nested tool results and already offloaded images. - * Targets are unique and each index list is nonempty and strictly increasing. - * This event changes derived content without replacing message nodes. + * Permanently omit selected input-image occurrences from subsequent model requests. + * Targets name unique current user/message or tool/result nodes. Nonempty, strictly + * increasing indexes count all images in depth-first order, including nested tool + * results and already omitted images. Message nodes and identities remain unchanged. + * @messageProjection */ 'image/offload': { targets: ImageOffloadTarget[] } ``` -来源:[`packages/core/session/src/types.ts:278`](../packages/core/session/src/types.ts) +来源:[`packages/compaction/compaction-image-offload/src/projection.ts:25`](../packages/compaction/compaction-image-offload/src/projection.ts) ### `llm/*` @@ -627,7 +626,7 @@ export type SessionEvent = { 'request/context': RequestContext ``` -来源:[`packages/core/session/src/types.ts:386`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:377`](../packages/core/session/src/types.ts) @@ -646,7 +645,7 @@ export type SessionEvent = { } ``` -来源:[`packages/core/session/src/types.ts:374`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:365`](../packages/core/session/src/types.ts) ### `sandbox/*` @@ -721,7 +720,7 @@ export type SessionEvent = { 'session/end-seed': { inherited?: true } ``` -来源:[`packages/core/session/src/types.ts:409`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:400`](../packages/core/session/src/types.ts) @@ -783,7 +782,7 @@ export type SessionEvent = { 'step/end': { turn: number; step: number } ``` -来源:[`packages/core/session/src/types.ts:298`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:289`](../packages/core/session/src/types.ts) @@ -794,7 +793,7 @@ export type SessionEvent = { 'step/start': { turn: number; step: number } ``` -来源:[`packages/core/session/src/types.ts:296`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:287`](../packages/core/session/src/types.ts) ### `subagent/*` @@ -870,7 +869,7 @@ export type SessionEvent = { 'system/message': { turn: number; step: number; message: SystemMessage } ``` -来源:[`packages/core/session/src/types.ts:319`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:310`](../packages/core/session/src/types.ts) ### `team/*` @@ -963,7 +962,7 @@ export type SessionEvent = { 类型:[ToolCallId](subsystems/core.zh.md) -来源:[`packages/core/session/src/types.ts:350`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:341`](../packages/core/session/src/types.ts) @@ -1039,7 +1038,7 @@ export type SessionEvent = { } ``` -来源:[`packages/core/session/src/types.ts:362`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:353`](../packages/core/session/src/types.ts) ### `tool-workflow/*` @@ -1119,7 +1118,7 @@ export type SessionEvent = { 类型:[TurnEndReason](subsystems/session.zh.md) -来源:[`packages/core/session/src/types.ts:294`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:276`](../packages/core/session/src/types.ts) @@ -1154,7 +1153,7 @@ export type SessionEvent = { 'user/message': UserMessage ``` -来源:[`packages/core/session/src/types.ts:306`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:297`](../packages/core/session/src/types.ts) ### `web/*` diff --git a/docs/subsystems/compaction.i18n.yaml b/docs/subsystems/compaction.i18n.yaml index 20c0221fab..671930e3d1 100644 --- a/docs/subsystems/compaction.i18n.yaml +++ b/docs/subsystems/compaction.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/compaction.md -compaction.md: 33af5e6a4a4da4f18c93e49c03ef65764efc5c57 -compaction.zh.md: 2dffc032b116fd2c7c6963a9009e02cb612c763b +compaction.md: 6e135025ab6095e2365e172847bc493d41c50774 +compaction.zh.md: 9b82fb7a7b9e55f2aefa6d561922948fd328c8e9 diff --git a/docs/subsystems/compaction.md b/docs/subsystems/compaction.md index 33af5e6a4a..6e135025ab 100644 --- a/docs/subsystems/compaction.md +++ b/docs/subsystems/compaction.md @@ -22,6 +22,21 @@ The markers are lock time points, not an exclusive container. An unrelated idle These variants are merged inside a `declare module '@deepseek-ai/dsh-session/types'` block, so — unlike the top-level types on the other subsystem pages — they are not pasted as a drift-checked ` ```ts type-equiv ` block (the `verify-type-equiv` extractor matches only top-level declarations by name). The payload table above is the catalog entry; follow the source link for the authoritative fields. + +## Image offload + +`compaction-image-offload` owns the `image/offload` declaration and its pure message projection. Each target identifies a current input node and exact depth-first image occurrences. The event preserves node and message identities and carries no `surfaceOp`. The [package README](../../packages/compaction/compaction-image-offload/README.md) owns recovery policy, registration, and detached replay. + +```ts type-equiv +/** Exact input-image occurrences selected by one durable offload decision. */ +interface ImageOffloadTarget { + /** Current message-producing event containing these occurrences. */ + seq: SessionSeq + /** Zero-based depth-first image indexes within the immutable message. */ + imageIndexes: number[] +} +``` + ## `CompactionResult` What a successful compaction returns to its caller: the bookkeeping-event seqs, safe summary projection, shadowed range and seqs, and estimated token count. diff --git a/docs/subsystems/compaction.zh.md b/docs/subsystems/compaction.zh.md index 2dffc032b1..9b82fb7a7b 100644 --- a/docs/subsystems/compaction.zh.md +++ b/docs/subsystems/compaction.zh.md @@ -22,6 +22,21 @@ 这些变体在 `declare module '@deepseek-ai/dsh-session/types'` 块内合并,因此——与其他子系统页面上的顶层类型不同——它们不以漂移检查的 ` ```ts type-equiv ` 块粘贴(`verify-type-equiv` 提取器只按名称匹配顶层声明)。上方的载荷表即为目录条目;权威字段请循源码链接查看。 + +## 图片省略 + +`compaction-image-offload` 拥有 `image/offload` 声明及其纯消息投影。每个目标指定当前输入节点和按深度优先计数的确切图片位置。事件保留节点和消息身份,不携带 `surfaceOp`。[包 README](../../packages/compaction/compaction-image-offload/README.zh.md)负责恢复策略、注册和独立回放说明。 + +```ts type-equiv +/** Exact input-image occurrences selected by one durable offload decision. */ +interface ImageOffloadTarget { + /** Current message-producing event containing these occurrences. */ + seq: SessionSeq + /** Zero-based depth-first image indexes within the immutable message. */ + imageIndexes: number[] +} +``` + ## `CompactionResult` 成功压缩向调用方返回:记账事件 seq、安全摘要投影、被遮蔽的范围与 seq,以及估算 token 数。 diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index 7541226663..5d45fca0b7 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session.md -session.md: 7a44f3661b848a03a842c4c9d22b8e4861daf61a -session.zh.md: e051b668b2715786f3e99d553e46234e2fd51d16 +session.md: 4058d432d7d4a2bd0b7811b88af5f4b5d3c30271 +session.zh.md: f037b9b8a66f7221949d9a887a561aed2ba5b6f5 diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index 7a44f3661b..4058d432d7 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -25,15 +25,6 @@ interface UserMessage extends Message { * compact raw streams so persistence stores one durable settlement per attempt. */ interface SessionEventMap { - /** - * Permanently omit the selected input-image occurrences from subsequent - * model requests. Each target names a current user/message or tool/result - * node; image indexes are zero-based depth-first positions within that - * message, including nested tool results and already offloaded images. - * Targets are unique and each index list is nonempty and strictly increasing. - * This event changes derived content without replacing message nodes. - */ - 'image/offload': { targets: ImageOffloadTarget[] } /** * Opens turn `turn` before the loop claims queued input or runs pre-step. * Rejection, empty input, cancellation, or failure may close it with no @@ -348,15 +339,39 @@ Required for `SurfaceEventType` events — every message-producing event must de `assistant/message` cannot carry `sourceEventSeqs`; its `stream` owns exact provider evidence. Other surface events omit the field when they cite no earlier event and use a complete non-empty list when they do. -`image/offload` uses exact per-node indexes. It changes derived input content without adding a surface node; `contentGeneration` advances so request series and cached derivation are refreshed. Reconstructors apply `foldSurface(events).offloadedMessages` through `deriveEventMessage(event, offloadedMessages)`. + +### Plugin-owned message projections + +Content-changing plugins mark their event declaration with `@messageProjection` and register a pure definition through `ctx.sessions.registerMessageProjection()`. Session validates the complete decision through that definition before commit, applies its immutable message updates, and advances `contentGeneration`. It rejects missing interpreters, including on restore and detached folding; unloading a used definition blocks cached reads. Detached readers pass explicit definitions to `foldSurface(events, projections)` and apply `projectedMessages` through `deriveEventMessage()`. The installed format catalog assembles first-party definitions for offline readers. The [image-offload plugin](compaction.md#image-offload) owns its image-specific event and interpretation. ```ts type-equiv -/** Exact input-image occurrences selected by one durable offload decision. */ -interface ImageOffloadTarget { - /** Current message-producing event containing these occurrences. */ - seq: SessionSeq - /** Zero-based depth-first image indexes within the immutable message. */ - imageIndexes: number[] +/** Readonly history immediately before a message-projection event. */ +interface SessionMessageProjectionContext { + /** Current message-producing event sequences in model-visible order. */ + nodes: readonly SessionSeq[] + /** Contiguous event window; entries at or beyond the candidate seq are not committed inputs. */ + events: readonly SessionEvent[] + /** Absolute sequence of the window's first event. */ + baseSeq: SessionLogOffset + /** Previously projected messages keyed by their original event sequences. */ + messages: ReadonlyMap +} +``` + +```ts type-equiv +/** Pure interpretation of one plugin-owned event that changes existing message content. */ +interface SessionMessageProjection { + /** Event interpreted by this definition; declare it with `@messageProjection` in SessionEventMap. */ + type: T + /** + * Validate the complete durable decision before returning any updates. Preserve + * message identities and publish immutable copies without mutating the input. + * @param event - candidate event, not yet applied to the supplied history. + * @param context - history preceding this decision. + * @returns changed current messages keyed by their original sequences. + * @throws when the durable decision cannot be applied to this history. + */ + project(event: SessionEvent, context: SessionMessageProjectionContext): ReadonlyMap } ``` @@ -364,7 +379,7 @@ interface ImageOffloadTarget { `Session.surface` returns the session's stable `SessionSurface` view. The same incremental manager validates append candidates before commit and advances this projection from committed events; callers can observe membership and replacement generation but cannot invoke validation. -`SurfaceManager(log, baseSeq?)` can instead fold a contiguous loaded window whose first event has the absolute sequence `baseSeq`. Every event remains contiguous in that absolute sequence space, and a replacement that crosses the window head fails because its declared range is absent. +`SurfaceManager(log, baseSeq?, projections?)` can instead fold a contiguous loaded window whose first event has the absolute sequence `baseSeq`. Every event remains contiguous in that absolute sequence space, and a replacement that crosses the window head fails because its declared range is absent. ```ts type-equiv /** Readonly live projection of the message-producing session events. */ @@ -373,14 +388,14 @@ interface SessionSurface { readonly nodes: readonly SessionSeq[] /** Monotonic count of committed positional replacements. */ readonly replaceGeneration: number - /** Monotonic count of committed replacements and image-offload decisions. */ + /** Monotonic count of committed replacements and plugin-owned message changes. */ readonly contentGeneration: number } ``` ### `SurfaceFoldReplacement` and `SurfaceFoldResult` — a complete surface replay -`foldSurface(events)` returns detached current event sequences together with the actual sequences shadowed by each declared replacement range. The live manager uses the same transitions without retaining replacement history. Its `replaceGeneration` increments for each committed replacement so incremental consumers can distinguish pure tail growth from a rewrite. +`foldSurface(events, projections)` returns detached current event sequences together with the actual sequences shadowed by each declared replacement range. The live manager uses the same transitions without retaining replacement history. Its `replaceGeneration` increments for each committed replacement so incremental consumers can distinguish pure tail growth from a rewrite. ```ts type-equiv /** One replacement operation observed while folding a session surface. */ @@ -403,8 +418,8 @@ interface SurfaceFoldResult { nodes: SessionSeq[] /** Replacement operations in event order. */ replacements: SurfaceFoldReplacement[] - /** Immutable image-offloaded messages, keyed by their original event sequences. */ - offloadedMessages: ReadonlyMap + /** Immutable projected messages, keyed by their original event sequences. */ + projectedMessages: ReadonlyMap } ``` @@ -459,7 +474,7 @@ declare class Session { * When this lifecycle appends the marker, it occupies this seq before the * store attaches and therefore does not publish either. Otherwise this seq * holds an ordinary published write. - */ + */ readonly firstLiveSeq: SessionLogOffset; /** * Create a detached session by validating and snapshotting borrowed seed @@ -468,14 +483,17 @@ declare class Session { * @param seed - optional borrowed replay or fork events. * @param header - optional borrowed storage metadata. * @param inheritedEventCount - exact fork-inherited prefix length for a seeded header. + * @param projections - pure interpreters for plugin-owned message changes. * @returns a detached session. + * @throws when a seed event requires a missing message interpreter or fails validation. */ static create( id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader, inheritedEventCount?: SessionLogOffset, - ): Session; + projections?: readonly SessionMessageProjection[], + ): Session; /** * Restore a detached session by adopting an independently owned or deeply frozen seed. * Runtime-required event fields, event envelopes, sequence continuity, surface @@ -487,7 +505,9 @@ declare class Session { * @param header - independently owned storage metadata. * @param inheritedEventCount - exact fork-inherited prefix length decoded from storage. * @param eventState - aliasing state carried from the operation that produced the seed. + * @param projections - pure interpreters for plugin-owned message changes. * @returns a restored detached session. + * @throws when a seed event requires a missing message interpreter or fails validation. */ static fromRestore( id: SessionId, @@ -495,7 +515,8 @@ declare class Session { header: SessionHeader, inheritedEventCount: SessionLogOffset, eventState: SessionSeedEventState, - ): Session; + projections?: readonly SessionMessageProjection[], + ): Session; /** * Return the immutable event stored at one exact sequence number. * @deprecated Existing logic may remain unmigrated for now, but new calls are prohibited. @@ -517,7 +538,7 @@ declare class Session { snapshotEvents( fromSeq: SessionLogOffset = SessionLogOffset(0), toSeqExclusive: SessionLogOffset = this.seq, - ): readonly SessionEvent[]; + ): readonly SessionEvent[]; /** * Return this Session's events after its fork-inherited prefix. * @deprecated Existing logic may remain unmigrated for now, but new calls are prohibited. @@ -574,7 +595,7 @@ declare class Session { type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [opts: SurfaceIntent] : [] - ): SessionEvent; + ): SessionEvent; /** * The {@link EpochHeader} in force after the log's last header event — the * header the NEXT request will be compared against — or undefined before @@ -597,20 +618,20 @@ declare class Session { * append records its `surfaceOp`, so a raw event with no marker (a chunk, a * turn boundary) is correctly absent, and a compaction `replace` deletes the * shadowed nodes from the derivation. The projection rules are - * {@link deriveEventMessage}, with logged image-offload selections applied + * {@link deriveEventMessage}, with logged message projections applied * without changing node membership or message identity. * - * CACHED: pure tail growth costs O(new nodes); a replacement or image offload + * CACHED: pure tail growth costs O(new nodes); a replacement or message projection * ({@link SessionSurface.contentGeneration}) rebuilds. The returned array is * a fresh snapshot per call (later appends never grow an array a caller * already holds); the `Message` objects in it are SHARED and **deep-frozen**. - * Unchanged content reuses frozen event data; offloaded blocks are frozen + * Unchanged content reuses frozen event data; projected blocks are frozen * derived copies. Consumers cannot mutate the log through either form. * @returns a fresh array of the shared, frozen derived history. */ deriveMessages(): Message[]; /** - * Project one event with all committed image-offload selections applied. + * Project one event with all committed message projections applied. * The original durable event remains unchanged. * @param event - the event to project. * @returns the derived message, or null when the event produces none. @@ -881,6 +902,15 @@ In-memory session store (`ctx.sessions`). Persistence is intentionally not implemented here — the agent lifecycle attaches a session-log writer to each published session's write handle; a session published outside that lifecycle persists nothing. ```ts cordis-catalog +/** + * Register one event interpreter for live creation, restore, and fork. + * Disposing the contribution makes sessions that used it refuse further derivation. + * @param projection - pure definition owned by the event's plugin. + * @returns the fiber-owned disposer. + * @throws when another definition already owns this event type. + */ +registerMessageProjection(projection: SessionMessageProjection): () => void + /** * Create a session owned by the calling fiber: disposing that fiber stops * event notification and removes the session from the store. `options.seed` diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index e051b668b2..f037b9b8a6 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -25,15 +25,6 @@ interface UserMessage extends Message { * compact raw streams so persistence stores one durable settlement per attempt. */ interface SessionEventMap { - /** - * Permanently omit the selected input-image occurrences from subsequent - * model requests. Each target names a current user/message or tool/result - * node; image indexes are zero-based depth-first positions within that - * message, including nested tool results and already offloaded images. - * Targets are unique and each index list is nonempty and strictly increasing. - * This event changes derived content without replacing message nodes. - */ - 'image/offload': { targets: ImageOffloadTarget[] } /** * Opens turn `turn` before the loop claims queued input or runs pre-step. * Rejection, empty input, cancellation, or failure may close it with no @@ -350,15 +341,39 @@ type SurfaceIntent = { `assistant/message` 不能携带 `sourceEventSeqs`;它的 `stream` 拥有精确 provider 证据。其他 surface event 不引用较早 event 时省略该字段,需要引用时使用完整非空 list。 -`image/offload` 使用逐节点的明确索引,不增加 surface 节点,但改变派生输入内容。`contentGeneration` 推进,使请求系列和派生缓存刷新。重建函数通过 `deriveEventMessage(event, offloadedMessages)` 应用 `foldSurface(events).offloadedMessages`。 + +### 插件拥有的消息投影 + +修改内容的插件用 `@messageProjection` 标记事件声明,并通过 `ctx.sessions.registerMessageProjection()` 注册纯处理器。Session 在提交前通过处理器校验完整决策,应用其不可变消息更新,并推进 `contentGeneration`。缺少处理器时拒绝操作,包括恢复和独立折叠,卸载已经使用的处理器后也会拒绝读取缓存。独立读取器将处理器显式传给 `foldSurface(events, projections)`,并通过 `deriveEventMessage()` 应用 `projectedMessages`。当前格式目录为离线读取器装配第一方处理器。[图片省略插件](compaction.zh.md#image-offload)拥有图片专用的事件及解释逻辑。 ```ts type-equiv -/** Exact input-image occurrences selected by one durable offload decision. */ -interface ImageOffloadTarget { - /** Current message-producing event containing these occurrences. */ - seq: SessionSeq - /** Zero-based depth-first image indexes within the immutable message. */ - imageIndexes: number[] +/** Readonly history immediately before a message-projection event. */ +interface SessionMessageProjectionContext { + /** Current message-producing event sequences in model-visible order. */ + nodes: readonly SessionSeq[] + /** Contiguous event window; entries at or beyond the candidate seq are not committed inputs. */ + events: readonly SessionEvent[] + /** Absolute sequence of the window's first event. */ + baseSeq: SessionLogOffset + /** Previously projected messages keyed by their original event sequences. */ + messages: ReadonlyMap +} +``` + +```ts type-equiv +/** Pure interpretation of one plugin-owned event that changes existing message content. */ +interface SessionMessageProjection { + /** Event interpreted by this definition; declare it with `@messageProjection` in SessionEventMap. */ + type: T + /** + * Validate the complete durable decision before returning any updates. Preserve + * message identities and publish immutable copies without mutating the input. + * @param event - candidate event, not yet applied to the supplied history. + * @param context - history preceding this decision. + * @returns changed current messages keyed by their original sequences. + * @throws when the durable decision cannot be applied to this history. + */ + project(event: SessionEvent, context: SessionMessageProjectionContext): ReadonlyMap } ``` @@ -366,7 +381,7 @@ interface ImageOffloadTarget { `Session.surface` 返回会话稳定的 `SessionSurface` 视图。同一个增量管理器在提交前校验追加候选事件,并根据已提交事件推进该投影;调用方可以观察成员关系和替换代次,但不能调用校验。 -`SurfaceManager(log, baseSeq?)` 也可以折叠一个连续的已加载窗口,其第一个事件的绝对序号为 `baseSeq`。每个事件在该绝对序号空间中仍保持连续;如果替换跨过窗口头部,由于其声明的范围并不存在,该替换会失败。 +`SurfaceManager(log, baseSeq?, projections?)` 也可以折叠一个连续的已加载窗口,其第一个事件的绝对序号为 `baseSeq`。每个事件在该绝对序号空间中仍保持连续;如果替换跨过窗口头部,由于其声明的范围并不存在,该替换会失败。 ```ts type-equiv /** Readonly live projection of the message-producing session events. */ @@ -375,14 +390,14 @@ interface SessionSurface { readonly nodes: readonly SessionSeq[] /** Monotonic count of committed positional replacements. */ readonly replaceGeneration: number - /** Monotonic count of committed replacements and image-offload decisions. */ + /** Monotonic count of committed replacements and plugin-owned message changes. */ readonly contentGeneration: number } ``` ### `SurfaceFoldReplacement` 与 `SurfaceFoldResult`:完整的 surface 回放 -`foldSurface(events)` 返回一份独立的当前事件 seq 列表,以及每个声明的替换范围实际遮蔽的 seq。实时管理器复用同一套状态转换,但不保留替换历史。每提交一次替换,其 `replaceGeneration` 就递增一次,使增量消费方能够区分纯尾部增长与重写。 +`foldSurface(events, projections)` 返回一份独立的当前事件 seq 列表,以及每个声明的替换范围实际遮蔽的 seq。实时管理器复用同一套状态转换,但不保留替换历史。每提交一次替换,其 `replaceGeneration` 就递增一次,使增量消费方能够区分纯尾部增长与重写。 ```ts type-equiv /** One replacement operation observed while folding a session surface. */ @@ -405,8 +420,8 @@ interface SurfaceFoldResult { nodes: SessionSeq[] /** Replacement operations in event order. */ replacements: SurfaceFoldReplacement[] - /** Immutable image-offloaded messages, keyed by their original event sequences. */ - offloadedMessages: ReadonlyMap + /** Immutable projected messages, keyed by their original event sequences. */ + projectedMessages: ReadonlyMap } ``` @@ -461,7 +476,7 @@ declare class Session { * When this lifecycle appends the marker, it occupies this seq before the * store attaches and therefore does not publish either. Otherwise this seq * holds an ordinary published write. - */ + */ readonly firstLiveSeq: SessionLogOffset; /** * Create a detached session by validating and snapshotting borrowed seed @@ -470,14 +485,17 @@ declare class Session { * @param seed - optional borrowed replay or fork events. * @param header - optional borrowed storage metadata. * @param inheritedEventCount - exact fork-inherited prefix length for a seeded header. + * @param projections - pure interpreters for plugin-owned message changes. * @returns a detached session. + * @throws when a seed event requires a missing message interpreter or fails validation. */ static create( id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader, inheritedEventCount?: SessionLogOffset, - ): Session; + projections?: readonly SessionMessageProjection[], + ): Session; /** * Restore a detached session by adopting an independently owned or deeply frozen seed. * Runtime-required event fields, event envelopes, sequence continuity, surface @@ -489,7 +507,9 @@ declare class Session { * @param header - independently owned storage metadata. * @param inheritedEventCount - exact fork-inherited prefix length decoded from storage. * @param eventState - aliasing state carried from the operation that produced the seed. + * @param projections - pure interpreters for plugin-owned message changes. * @returns a restored detached session. + * @throws when a seed event requires a missing message interpreter or fails validation. */ static fromRestore( id: SessionId, @@ -497,7 +517,8 @@ declare class Session { header: SessionHeader, inheritedEventCount: SessionLogOffset, eventState: SessionSeedEventState, - ): Session; + projections?: readonly SessionMessageProjection[], + ): Session; /** * Return the immutable event stored at one exact sequence number. * @deprecated Existing logic may remain unmigrated for now, but new calls are prohibited. @@ -519,7 +540,7 @@ declare class Session { snapshotEvents( fromSeq: SessionLogOffset = SessionLogOffset(0), toSeqExclusive: SessionLogOffset = this.seq, - ): readonly SessionEvent[]; + ): readonly SessionEvent[]; /** * Return this Session's events after its fork-inherited prefix. * @deprecated Existing logic may remain unmigrated for now, but new calls are prohibited. @@ -576,7 +597,7 @@ declare class Session { type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [opts: SurfaceIntent] : [] - ): SessionEvent; + ): SessionEvent; /** * The {@link EpochHeader} in force after the log's last header event — the * header the NEXT request will be compared against — or undefined before @@ -599,20 +620,20 @@ declare class Session { * append records its `surfaceOp`, so a raw event with no marker (a chunk, a * turn boundary) is correctly absent, and a compaction `replace` deletes the * shadowed nodes from the derivation. The projection rules are - * {@link deriveEventMessage}, with logged image-offload selections applied + * {@link deriveEventMessage}, with logged message projections applied * without changing node membership or message identity. * - * CACHED: pure tail growth costs O(new nodes); a replacement or image offload + * CACHED: pure tail growth costs O(new nodes); a replacement or message projection * ({@link SessionSurface.contentGeneration}) rebuilds. The returned array is * a fresh snapshot per call (later appends never grow an array a caller * already holds); the `Message` objects in it are SHARED and **deep-frozen**. - * Unchanged content reuses frozen event data; offloaded blocks are frozen + * Unchanged content reuses frozen event data; projected blocks are frozen * derived copies. Consumers cannot mutate the log through either form. * @returns a fresh array of the shared, frozen derived history. */ deriveMessages(): Message[]; /** - * Project one event with all committed image-offload selections applied. + * Project one event with all committed message projections applied. * The original durable event remains unchanged. * @param event - the event to project. * @returns the derived message, or null when the event produces none. @@ -885,6 +906,15 @@ In-memory session store (`ctx.sessions`). Persistence is intentionally not implemented here — the agent lifecycle attaches a session-log writer to each published session's write handle; a session published outside that lifecycle persists nothing. ```ts cordis-catalog +/** + * Register one event interpreter for live creation, restore, and fork. + * Disposing the contribution makes sessions that used it refuse further derivation. + * @param projection - pure definition owned by the event's plugin. + * @returns the fiber-owned disposer. + * @throws when another definition already owns this event type. + */ +registerMessageProjection(projection: SessionMessageProjection): () => void + /** * Create a session owned by the calling fiber: disposing that fiber stops * event notification and removes the session from the store. `options.seed` diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index 4732dcdd71..b82ccee015 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -36,7 +36,8 @@ "dependencies": { "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/schemastery": "workspace:^", - "zod": "^4.4.3" + "zod": "^4.4.3", + "@deepseek-ai/dsh-session-format-catalog": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index a27749f61a..40541ed6de 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -1,5 +1,6 @@ // Standalone browser fixture for UI development without a server. +import { currentSessionMessageProjections } from '@deepseek-ai/dsh-session-format-catalog/message-projections' import { createAssistantMessage, createSystemMessage, @@ -1278,7 +1279,7 @@ function contextBreakdownOf(log: readonly SessionEvent[]): FixtureContextBreakdo : headerEvent.data.header let systemTokens = 0 let messageTokens = 0 - for (const seq of foldSurface(log).nodes) { + for (const seq of foldSurface(log, currentSessionMessageProjections).nodes) { const event = log[seq] if (event === undefined) continue const message = deriveEventMessage(event) @@ -3105,7 +3106,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { const query = searchTokenSpans(request.query).tokens.map(token => token.value) const matches = sessions.flatMap((summary) => { const log = logs.get(summary.sessionId) ?? [] - const current = new Set(foldSurface(log).nodes) + const current = new Set(foldSurface(log, currentSessionMessageProjections).nodes) const best = log.flatMap((event): FixtureSearchCandidate[] => { if (!current.has(event.seq)) return [] const eventText = searchEventText(event) diff --git a/packages/client/connection/tsconfig.client.json b/packages/client/connection/tsconfig.client.json index 4266a1defc..6ec8fad11a 100644 --- a/packages/client/connection/tsconfig.client.json +++ b/packages/client/connection/tsconfig.client.json @@ -53,6 +53,9 @@ }, { "path": "../../util/brand" + }, + { + "path": "../../session/session-format-catalog" } ] } diff --git a/packages/compaction/compaction-basic/package.json b/packages/compaction/compaction-basic/package.json index 82f980e727..281cac91fc 100644 --- a/packages/compaction/compaction-basic/package.json +++ b/packages/compaction/compaction-basic/package.json @@ -61,6 +61,7 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^" + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-compaction-image-offload": "workspace:^" } } diff --git a/packages/compaction/compaction-basic/tests/manual-compaction.spec.ts b/packages/compaction/compaction-basic/tests/manual-compaction.spec.ts index 6bae4a86c7..26d957ce92 100644 --- a/packages/compaction/compaction-basic/tests/manual-compaction.spec.ts +++ b/packages/compaction/compaction-basic/tests/manual-compaction.spec.ts @@ -1,3 +1,4 @@ +import { imageOffloadProjection } from '@deepseek-ai/dsh-compaction-image-offload/projection' import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import AgentLoop from '@deepseek-ai/dsh-agent-loop' @@ -99,6 +100,7 @@ interface LoopHarness { async function loopHarness(): Promise { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) + ctx.sessions.registerMessageProjection(imageOffloadProjection) await ctx.plugin(InvariantRegistry) await ctx.plugin(SessionInvariant) await ctx.plugin(AgentInvariant) diff --git a/packages/compaction/compaction-image-offload/README.i18n.yaml b/packages/compaction/compaction-image-offload/README.i18n.yaml index 11ca85fe42..c3da3ba739 100644 --- a/packages/compaction/compaction-image-offload/README.i18n.yaml +++ b/packages/compaction/compaction-image-offload/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/compaction/compaction-image-offload/README.md -README.md: ed22ccfe8817a70cf05d2f3ff0d54bc779702f4f -README.zh.md: c5830133533a44ec3c3cd36cab1593cda476177a +README.md: 8e4d812c043234e07207c7326882856b445b0950 +README.zh.md: 06ed168e750cffbaa6594454ac4459f0ceb99788 diff --git a/packages/compaction/compaction-image-offload/README.md b/packages/compaction/compaction-image-offload/README.md index ed22ccfe88..8e4d812c04 100644 --- a/packages/compaction/compaction-image-offload/README.md +++ b/packages/compaction/compaction-image-offload/README.md @@ -49,11 +49,11 @@ The plugin acts only on `IMAGE_OFFLOAD_REQUIRED` failures that carry `offloadIma
Implementation internals — click to expand -The recovery listener owns selection and retry policy. It reads Session's projected messages, selects the oldest retained input images in request order, and commits the complete selection in one event. Session validates and applies those exact references during append and replay; its shared derivation supplies requests, compaction, and later message rewrites. Token measurement folds the same selections without replacement shadow prices. +This package owns the event declaration, reference validation, immutable image projection, selection, and retry policy. It registers `imageOffloadProjection` with Session before accepting image-offload events. The same browser-safe definition is exported from `./projection` for detached replay; the installed format catalog assembles it for offline readers. Missing registration rejects live restore, and unloading a used definition blocks further message derivation. Token measurement folds the same selections without replacement shadow prices. Summary failures use the synchronous `compaction/summary-error` waterfall. The plugin selects only the supplied summary region and returns true after recording an omission. The compaction backend re-derives and re-prices that region before retrying; each retry omits additional retained occurrences, so recovery ends when none remain. Cancellation and unrelated selection changes reject the summary. Recorded omissions survive later failure or cancellation. -No runtime invariant companion is published: Session rejects invalid or repeated image references before commit, and this executor retains no separate mutable offload state. +No runtime invariant companion is published: the pure projection rejects invalid or repeated image references before Session commits the event, and this executor retains no separate mutable offload state.
diff --git a/packages/compaction/compaction-image-offload/README.zh.md b/packages/compaction/compaction-image-offload/README.zh.md index c583013353..06ed168e75 100644 --- a/packages/compaction/compaction-image-offload/README.zh.md +++ b/packages/compaction/compaction-image-offload/README.zh.md @@ -49,11 +49,11 @@ kind: "package-reference"
实现内部——点击展开 -恢复监听器负责选图和重试策略。它读取 Session 派生的消息,按请求顺序选取最早的保留输入图片,并用一条事件提交本次选择。Session 在追加和回放时校验并应用这些确切引用,共享派生结果供请求、压缩(compaction)和后续消息改写使用。token 测量应用同一份选择,不依赖替换影子价格。 +本包拥有事件声明、引用校验、不可变图片投影、选图和重试策略。它在接受图片省略事件前向 Session 注册 `imageOffloadProjection`。相同的浏览器安全处理器通过 `./projection` 导出,供独立回放使用,当前格式目录为离线读取器装配该处理器。缺少注册时拒绝实时恢复,卸载已经使用的处理器后拒绝继续派生消息。token 测量应用同一份选择,不依赖替换影子价格。 摘要失败使用同步的 `compaction/summary-error` waterfall。插件只在传入的摘要选区内选图,记录省略后返回 true。压缩后端重新派生该选区并计价,然后重试。每次重试都会继续省略保留的图片,没有可省略的位置时结束恢复。取消或无关的选区变更会使摘要失败。已经记录的省略在后续失败或取消后仍然有效。 -本包不发布运行时 invariant 伴生插件:Session 在提交前拒绝无效或重复省略的图片引用,执行器不维护独立可变的省略状态。 +本包不发布运行时 invariant 伴生插件:纯投影在 Session 提交事件前拒绝无效或重复省略的图片引用,执行器不维护独立可变的省略状态。
diff --git a/packages/compaction/compaction-image-offload/package.json b/packages/compaction/compaction-image-offload/package.json index 2977e144e1..89c8984885 100644 --- a/packages/compaction/compaction-image-offload/package.json +++ b/packages/compaction/compaction-image-offload/package.json @@ -18,11 +18,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, - "./package.json": "./package.json" + "./package.json": "./package.json", + "./projection": { + "types": "./lib/types/projection.d.ts", + "default": "./lib/types/projection.js" + } }, "files": [ "lib/index.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.js" ], "license": "MIT", "peerDependencies": { @@ -45,5 +50,8 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^" + }, + "dependencies": { + "@deepseek-ai/dsh-util-values": "workspace:^" } } diff --git a/packages/compaction/compaction-image-offload/src/image-offload.ts b/packages/compaction/compaction-image-offload/src/image-offload.ts index b20d7cc431..5c686ba4c8 100644 --- a/packages/compaction/compaction-image-offload/src/image-offload.ts +++ b/packages/compaction/compaction-image-offload/src/image-offload.ts @@ -1,7 +1,8 @@ /** Select and log permanent image omissions in current model-request order. */ import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import type { ImageOffloadTarget, Session, SessionSeq } from '@deepseek-ai/dsh-session' +import type { Session, SessionSeq } from '@deepseek-ai/dsh-session' +import type { ImageOffloadTarget } from './projection.ts' /** * Record one decision omitting the oldest retained input-image occurrences. diff --git a/packages/compaction/compaction-image-offload/src/index.ts b/packages/compaction/compaction-image-offload/src/index.ts index b7074a242e..5415cd5c4c 100644 --- a/packages/compaction/compaction-image-offload/src/index.ts +++ b/packages/compaction/compaction-image-offload/src/index.ts @@ -13,15 +13,17 @@ import type {} from '@deepseek-ai/dsh-compaction' import type { RequestErrorAction } from '@deepseek-ai/dsh-agent' import { IMAGE_OFFLOAD_REQUIRED_CODE, LlmError } from '@deepseek-ai/dsh-llm' import { offloadOldestImages } from './image-offload.ts' +import { imageOffloadProjection } from './projection.ts' export const name = 'compaction-image-offload' -export const inject = ['agents'] +export const inject = ['agents', 'sessions'] /** * Mount agent and summary recovery listeners without configuration. * @param ctx - the plugin context. */ export function apply(ctx: Context): void { + ctx.sessions.registerMessageProjection(imageOffloadProjection) ctx.on('agent/request-error', ({ agent, failure }, next): Promise => { if (failure.code !== IMAGE_OFFLOAD_REQUIRED_CODE || failure.offloadImages === undefined) return next() // A durable surface repair, not a provider retry: it spends no retry budget and logs no retry event. diff --git a/packages/core/session/src/image-offload.ts b/packages/compaction/compaction-image-offload/src/project-message.ts similarity index 93% rename from packages/core/session/src/image-offload.ts rename to packages/compaction/compaction-image-offload/src/project-message.ts index 831b2196dc..8d5754e2ee 100644 --- a/packages/core/session/src/image-offload.ts +++ b/packages/compaction/compaction-image-offload/src/project-message.ts @@ -1,4 +1,4 @@ -/** Exact application of logged image selections; selection policy belongs to compaction-image-offload. */ +/** Immutable application of the image occurrences recorded by image/offload. */ import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm' import { deepFreeze } from '@deepseek-ai/dsh-util-values' diff --git a/packages/compaction/compaction-image-offload/src/projection.ts b/packages/compaction/compaction-image-offload/src/projection.ts new file mode 100644 index 0000000000..1dd0e81bd0 --- /dev/null +++ b/packages/compaction/compaction-image-offload/src/projection.ts @@ -0,0 +1,74 @@ +/** Browser-safe durable image selection declaration and pure replay definition. */ + +import type { Message } from '@deepseek-ai/dsh-llm' +import type { SessionSeq } from '@deepseek-ai/dsh-session/types' +import type { SessionMessageProjection } from '@deepseek-ai/dsh-session/surface' +import { offloadMessageImages } from './project-message.ts' + +/** Exact input-image occurrences selected by one durable offload decision. */ +export interface ImageOffloadTarget { + /** Current message-producing event containing these occurrences. */ + seq: SessionSeq + /** Zero-based depth-first image indexes within the immutable message. */ + imageIndexes: number[] +} + +declare module '@deepseek-ai/dsh-session/types' { + interface SessionEventMap { + /** + * Permanently omit selected input-image occurrences from subsequent model requests. + * Targets name unique current user/message or tool/result nodes. Nonempty, strictly + * increasing indexes count all images in depth-first order, including nested tool + * results and already omitted images. Message nodes and identities remain unchanged. + * @messageProjection + */ + 'image/offload': { targets: ImageOffloadTarget[] } + } +} + +/** Whether a durable value is a JSON object. */ +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** Whether a durable occurrence index or sequence is canonical. */ +function isIndex(value: unknown): value is SessionSeq { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 && !Object.is(value, -0) +} + +/** Atomic validation and reconstruction shared by live sessions and detached replay. */ +export const imageOffloadProjection: SessionMessageProjection<'image/offload'> = { + type: 'image/offload', + project(event, context) { + const data: unknown = event.data + if (!isRecord(data) || Object.keys(data).length !== 1 || !Array.isArray(data['targets']) || data['targets'].length === 0) { + throw new Error('image/offload: data must contain a nonempty targets array') + } + const messages = new Map() + const nodes = new Set(context.nodes) + for (const target of data['targets'] as unknown[]) { + if (!isRecord(target) || Object.keys(target).length !== 2 || !isIndex(target['seq']) + || !Array.isArray(target['imageIndexes']) || target['imageIndexes'].length === 0) { + throw new Error('image/offload: each target must contain a seq and nonempty imageIndexes') + } + const seq = target['seq'] + if (messages.has(seq)) throw new Error(`image/offload: duplicate target seq ${seq}`) + if (!nodes.has(seq)) throw new Error(`image/offload: target seq ${seq} is not a current surface node`) + const source = context.events[seq - context.baseSeq] + if (source?.type !== 'user/message' && source?.type !== 'tool/result') { + throw new Error(`image/offload: target seq ${seq} must be user/message or tool/result`) + } + let previous = -1 + for (const index of target['imageIndexes'] as unknown[]) { + if (!isIndex(index) || index <= previous) { + throw new Error('image/offload: imageIndexes must be strictly increasing non-negative safe integers') + } + previous = index + } + const message = context.messages.get(seq) + ?? (source.type === 'user/message' ? source.data : source.data.message) + messages.set(seq, offloadMessageImages(message, target['imageIndexes'] as number[])) + } + return messages + }, +} diff --git a/packages/core/session/tests/image-offload.spec.ts b/packages/compaction/compaction-image-offload/tests/projection.spec.ts similarity index 85% rename from packages/core/session/tests/image-offload.spec.ts rename to packages/compaction/compaction-image-offload/tests/projection.spec.ts index ab6806e0d9..36d0ec00df 100644 --- a/packages/core/session/tests/image-offload.spec.ts +++ b/packages/compaction/compaction-image-offload/tests/projection.spec.ts @@ -3,6 +3,12 @@ import { createAssistantMessage, createToolResultMessage, createUserMessage, Too import type { ContentBlock, ImageBlock } from '@deepseek-ai/dsh-llm' import { deriveEventMessage, foldSurface, Session, SessionId, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionEventMap } from '@deepseek-ai/dsh-session' +import { imageOffloadProjection } from '../src/projection.ts' + +function createSession(...args: Parameters): Session { + args[4] = [imageOffloadProjection] + return Session.create(...args) +} const image: ImageBlock = { type: 'image', @@ -27,7 +33,7 @@ function marked(session: Session): boolean[] { describe('durable image selections', () => { it('preserves message identity, original data, and previously derived snapshots', () => { - const session = Session.create(SessionId('images')) + const session = createSession(SessionId('images')) const source = input(session, [ { type: 'text', text: 'before' }, image, { type: 'tool-result', toolCallId: ToolCallId('nested'), content: [image, { type: 'text', text: 'after' }] }, @@ -57,19 +63,19 @@ describe('durable image selections', () => { }) it('reconstructs the same selections through pure folding, resume, restore, and fork', () => { - const session = Session.create(SessionId('source')) + const session = createSession(SessionId('source')) const source = input(session) const before = session.snapshotEvents() session.append('image/offload', { targets: [{ seq: source.seq, imageIndexes: [0] }] }) const events = session.snapshotEvents() - const fold = foldSurface(events) - expect(deriveEventMessage(source, fold.offloadedMessages)).toEqual(session.deriveMessages()[0]) - expect(marked(Session.create(SessionId('before'), before))).toEqual([false, false]) - expect(marked(Session.create(SessionId('resume'), events))).toEqual([true, false]) - const restored = Session.fromRestore(session.id, events, session.header, SessionLogOffset(0), 'shared-frozen') + const fold = foldSurface(events, [imageOffloadProjection]) + expect(deriveEventMessage(source, fold.projectedMessages)).toEqual(session.deriveMessages()[0]) + expect(marked(createSession(SessionId('before'), before))).toEqual([false, false]) + expect(marked(createSession(SessionId('resume'), events))).toEqual([true, false]) + const restored = Session.fromRestore(session.id, events, session.header, SessionLogOffset(0), 'shared-frozen', [imageOffloadProjection]) expect(marked(restored)).toEqual([true, false]) const childId = SessionId('child') - const child = Session.create(childId, events, { + const child = createSession(childId, events, { ...session.header, id: childId, parentSession: session.id, isSeeded: true, }, SessionLogOffset(events.length)) expect(marked(child)).toEqual([true, false]) @@ -79,7 +85,7 @@ describe('durable image selections', () => { }) it('counts all images in a tool result without changing its call identity', () => { - const session = Session.create(SessionId('tool')) + const session = createSession(SessionId('tool')) const source = session.append('tool/result', { turn: 1, step: 1, message: createToolResultMessage({ callId: ToolCallId('shot'), isError: false, content: [image, image] }), @@ -104,13 +110,13 @@ describe('durable image selections', () => { { targets: [{ seq: 1, imageIndexes: [0] }] }, { targets: [{ seq: 0, imageIndexes: [0] }, { seq: 0, imageIndexes: [1] }] }, ])('rejects malformed selections at append and replay without partial application: %j', (data) => { - const session = Session.create(SessionId('invalid')) + const session = createSession(SessionId('invalid')) input(session) const events = session.snapshotEvents() const candidate = { type: 'image/offload', seq: SessionSeq(1), time: 0, data } as SessionEvent expect(() => session.append('image/offload', data as SessionEventMap['image/offload'])).toThrow(/image\/offload/) - expect(() => Session.create(SessionId('seed'), [...events, candidate])).toThrow(/image\/offload/) - expect(() => foldSurface([...events, candidate])).toThrow(/image\/offload/) + expect(() => createSession(SessionId('seed'), [...events, candidate])).toThrow(/image\/offload/) + expect(() => foldSurface([...events, candidate], [imageOffloadProjection])).toThrow(/image\/offload/) expect(session.snapshotEvents()).toEqual(events) expect(marked(session)).toEqual([false, false]) expect(session.surface.contentGeneration).toBe(0) @@ -119,7 +125,7 @@ describe('durable image selections', () => { }) it('rejects repeated offloads and shadowed or assistant targets', () => { - const session = Session.create(SessionId('invalid-targets')) + const session = createSession(SessionId('invalid-targets')) const source = input(session) session.append('image/offload', { targets: [{ seq: source.seq, imageIndexes: [0] }] }) expect(() => session.append('image/offload', { targets: [{ seq: source.seq, imageIndexes: [0] }] })).toThrow(/already offloaded/) diff --git a/packages/compaction/compaction-image-offload/tsconfig.json b/packages/compaction/compaction-image-offload/tsconfig.json index e45a128c8e..26fb004b6b 100644 --- a/packages/compaction/compaction-image-offload/tsconfig.json +++ b/packages/compaction/compaction-image-offload/tsconfig.json @@ -8,11 +8,26 @@ "src" ], "references": [ - { "path": "../../../vendor/cosmokit" }, - { "path": "../../../vendor/cordis" }, - { "path": "../compaction" }, - { "path": "../../llm/llm" }, - { "path": "../../core/session" }, - { "path": "../../core/agent" } + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../compaction" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/session" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../util/values" + } ] } diff --git a/packages/compaction/compaction-tool-result-pruner/package.json b/packages/compaction/compaction-tool-result-pruner/package.json index 93eedeba7f..b693fed200 100644 --- a/packages/compaction/compaction-tool-result-pruner/package.json +++ b/packages/compaction/compaction-tool-result-pruner/package.json @@ -46,6 +46,7 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", - "@deepseek-ai/dsh-token-meter": "workspace:^" + "@deepseek-ai/dsh-token-meter": "workspace:^", + "@deepseek-ai/dsh-compaction-image-offload": "workspace:^" } } diff --git a/packages/compaction/compaction-tool-result-pruner/tests/tool-result-pruner.spec.ts b/packages/compaction/compaction-tool-result-pruner/tests/tool-result-pruner.spec.ts index 93e30bac5f..dc94a7c682 100644 --- a/packages/compaction/compaction-tool-result-pruner/tests/tool-result-pruner.spec.ts +++ b/packages/compaction/compaction-tool-result-pruner/tests/tool-result-pruner.spec.ts @@ -1,3 +1,4 @@ +import { imageOffloadProjection } from '@deepseek-ai/dsh-compaction-image-offload/projection' import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { ToolCallId , createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm' @@ -80,7 +81,7 @@ function appendToolStep( describe('tool-result pruning configuration', () => { it('preserves a logged image offload when pruning the same result later', () => { - const session = Session.create(SessionId('prune-offloaded')) + const session = Session.create(SessionId('prune-offloaded'), undefined, undefined, undefined, [imageOffloadProjection]) const seq = appendToolStep(session, 1, 'shot', [ { type: 'text', text: 'x'.repeat(200) }, { type: 'image', attachment: { @@ -93,7 +94,7 @@ describe('tool-result pruning configuration', () => { const replacement = session.snapshotEvents().at(-1)! expect(replacement.type).toBe('tool/result') expect(JSON.stringify(session.deriveEventMessage(replacement))).toContain('"offloaded":true') - expect(JSON.stringify(Session.create(SessionId('restored-offloaded'), session.snapshotEvents()).deriveMessages())).toContain('"offloaded":true') + expect(JSON.stringify(Session.create(SessionId('restored-offloaded'), session.snapshotEvents(), undefined, undefined, [imageOffloadProjection]).deriveMessages())).toContain('"offloaded":true') expect(JSON.stringify(session.eventAt(SessionSeq(seq)))).not.toContain('offloaded') }) diff --git a/packages/compaction/compaction/src/invariant.ts b/packages/compaction/compaction/src/invariant.ts index 5682f066db..7880e5fb24 100644 --- a/packages/compaction/compaction/src/invariant.ts +++ b/packages/compaction/compaction/src/invariant.ts @@ -295,7 +295,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant openTurn: null, compaction: undefined, surfaceEvents, - surface: new SurfaceManager(surfaceEvents), + surface: new SurfaceManager(surfaceEvents, undefined, ctx.sessions.messageProjections), } traces.set(session, trace) // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. diff --git a/packages/core/session/README.i18n.yaml b/packages/core/session/README.i18n.yaml index e67793ae4e..86d2b2f948 100644 --- a/packages/core/session/README.i18n.yaml +++ b/packages/core/session/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/session/README.md -README.md: 0fbede71e77b1e5ff5e46363112136eaf75f86e0 -README.zh.md: 32c0702500cb5671eced07ca48601365157b809f +README.md: 98e7744aa76a73148e30a56c34b44c4c5d52bd0e +README.zh.md: 28b6f1f02838420861a76409046c0e9a12bac1b5 diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 0fbede71e7..98e7744aa7 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -49,7 +49,7 @@ session.deriveMessages() // the derived model history Surface events (`system/message`, `user/message`, `assistant/message`, `tool/result`) require `surfaceOp` in both typed events and append input. A replacement uses exactly `{ op: 'replace', startSeq, endSeq }`, with inclusive `SessionSeq` endpoints in current surface order. An Assistant message embeds its exact compact provider stream and forbids `sourceEventSeqs`. Known log-only events forbid both metadata fields and never produce a message. -`image/offload` records exact input-image occurrences without creating or replacing a message. Session rejects missing, shadowed, output-image, or already offloaded targets before commit. `deriveMessages()` and the instance `deriveEventMessage()` apply the selections; rewrites preserving images must use these projected messages. Pure reconstructors pass `foldSurface(events).offloadedMessages` to the exported `deriveEventMessage()`. +Plugins declare content-changing events with `@messageProjection` and register a pure definition through `ctx.sessions.registerMessageProjection()`. Session calls the definition before accepting an event and caches its immutable message updates. Missing definitions reject append and restore; unloading a used definition also blocks cached reads. Detached constructors and `foldSurface(events, projections)` require explicit definitions. Reconstructors pass the fold's `projectedMessages` to `deriveEventMessage()`; live instance methods apply the same projections automatically. [Plugin-owned message projections](../../../.agents/notes/implemented/architecture/2026-09-11-plugin-owned-message-projections.md) defines ownership and offline assembly. Append, seed/restore, and event adoption/snapshot reject any `header.system` and exactly empty optional request-header fields (`tools: []`, `adapterDefaults: {}`) instead of normalizing input. Tool-result `data.error` is allowed only when `message.content[0].isError === true`; failure identity remains optional. Rejected appends do not change the log, derived state, or event feed. Adoption validates event-local metadata but not referenced history or replacement membership. @@ -83,11 +83,11 @@ This section explains how the package realizes the behavior above; the observabl ### Design concept -The package is built on event sourcing: a `Session` is an append-only log of typed `SessionEvent`s, and everything else — model history, transcripts, telemetry, titles, persistence — derives from that stream. The surface is a derived projection: an incremental manager validates append candidates, advances the ordered view from committed events, and tracks `replaceGeneration` for positional replacements and `contentGeneration` for replacements and image-offload decisions. Model-visible means logged: anything that reaches a model request must be reconstructable from the log. Each model attempt that reaches settlement commits one event: `assistant/message` carries the assembled model-visible message plus its compact timed stream, while `assistant/attempt` retains a failed, retried, cancelled, or stream-error attempt without adding model history. A hard process loss before settlement leaves no durable attempt stream. +The package is built on event sourcing: a `Session` is an append-only log of typed `SessionEvent`s, and everything else — model history, transcripts, telemetry, titles, persistence — derives from that stream. The surface is a derived projection: an incremental manager validates append candidates, advances the ordered view from committed events, and tracks `replaceGeneration` for positional replacements and `contentGeneration` for replacements and plugin-owned message changes. Model-visible means logged: anything that reaches a model request must be reconstructable from the log. Each model attempt that reaches settlement commits one event: `assistant/message` carries the assembled model-visible message plus its compact timed stream, while `assistant/attempt` retains a failed, retried, cancelled, or stream-error attempt without adding model history. A hard process loss before settlement leaves no durable attempt stream. ### Request headers -`request/header` stores a full canonical snapshot of the non-history request envelope with reason `initial`, `resume`, `change`, or `series`. An explicit message-series start, a surface replacement, or an image-offload decision writes a `series` snapshot when the envelope is unchanged; a simultaneous change uses `startsSeries: true`. Same-series steps, retries, and ordinary later turns inherit the latest snapshot. `adapterDefaults` distinguishes values resolved by the adapter from explicit settings, and `foldRequestHeader()` selects the latest snapshot. This self-contained record supports partial-window rendering and exact reconstruction at the cost of growth per message series; the [reconstructable-requests Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md) owns the detail. +`request/header` stores a full canonical snapshot of the non-history request envelope with reason `initial`, `resume`, `change`, or `series`. An explicit message-series start, a surface replacement, or a plugin-owned message change writes a `series` snapshot when the envelope is unchanged; a simultaneous change uses `startsSeries: true`. Same-series steps, retries, and ordinary later turns inherit the latest snapshot. `adapterDefaults` distinguishes values resolved by the adapter from explicit settings, and `foldRequestHeader()` selects the latest snapshot. This self-contained record supports partial-window rendering and exact reconstruction at the cost of growth per message series; the [reconstructable-requests Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md) owns the detail. ### Source map @@ -107,7 +107,7 @@ Every append uses the shared iterative `snapshotJsonValue()` pass, which reads, ### Derived history -`deriveMessages()` caches deep-frozen projections and returns a fresh array per call. The four surface event types (`system/message`, `user/message`, `assistant/message`, `tool/result`) supply their recorded message identities and content; an empty-content system node projects to no message. Image-offload selections mark derived input-image blocks without mutating recorded messages. Replacements and offload decisions invalidate the cache. Embedded Assistant streams and `assistant/attempt` events remain replay and diagnostic data only. +`deriveMessages()` caches deep-frozen projections and returns a fresh array per call. The four surface event types (`system/message`, `user/message`, `assistant/message`, `tool/result`) supply their recorded message identities and content; an empty-content system node projects to no message. Plugin-owned projections change derived content without mutating recorded messages. Replacements and projection decisions invalidate the cache. Embedded Assistant streams and `assistant/attempt` events remain replay and diagnostic data only. ### The request header @@ -137,7 +137,7 @@ The package-level contract is enough for most consumers; read these when you nee #### What the model sees -The model receives the complete messages from `system/message`, `user/message`, `assistant/message`, and `tool/result` surface entries with logged image selections applied, the system prompt first. Identities, roles, sources, and unmodified blocks retain their original values; projections never mint identities. Direct prompts and injected context remain separate `user/message` events whose sources preserve their provenance. Embedded streams, `assistant/attempt`, boundaries, and other log-only facts add no message. +The model receives the complete messages from `system/message`, `user/message`, `assistant/message`, and `tool/result` surface entries with logged projections applied, the system prompt first. Identities, roles, sources, and unmodified blocks retain their original values; projections never mint identities. Direct prompts and injected context remain separate `user/message` events whose sources preserve their provenance. Embedded streams, `assistant/attempt`, boundaries, and other log-only facts add no message. #### Token effect diff --git a/packages/core/session/README.zh.md b/packages/core/session/README.zh.md index 32c0702500..28b6f1f028 100644 --- a/packages/core/session/README.zh.md +++ b/packages/core/session/README.zh.md @@ -49,7 +49,7 @@ session.deriveMessages() // the derived model history 表层事件(`system/message`、`user/message`、`assistant/message`、`tool/result`)在类型化事件与追加输入中都必须带有 `surfaceOp`。替换操作仅接受 `{ op: 'replace', startSeq, endSeq }`,端点为包含边界的 `SessionSeq`,按当前 surface 顺序解释。assistant 消息会嵌入精确、紧凑的提供方流,并禁止 `sourceEventSeqs`。已知仅日志事件禁止这两个元数据字段,且从不产生消息。 -`image/offload` 记录确切的输入图片出现位置,不创建或替换消息。Session 在提交前拒绝缺失、已被遮蔽、属于输出图片或已被省略的目标。`deriveMessages()` 和实例方法 `deriveEventMessage()` 应用这些选择,需要保留图片的改写必须读取这些派生消息。纯重建函数将 `foldSurface(events).offloadedMessages` 传给导出的 `deriveEventMessage()`。 +插件用 `@messageProjection` 声明修改内容的事件,并通过 `ctx.sessions.registerMessageProjection()` 注册纯处理器。Session 在接受事件前调用处理器,并缓存其不可变消息更新。缺少处理器时拒绝追加和恢复,卸载已经使用的处理器后也会拒绝读取缓存。独立构造函数和 `foldSurface(events, projections)` 必须显式接收处理器。重建函数将折叠结果的 `projectedMessages` 传给 `deriveEventMessage()`,实时实例方法自动应用相同的投影。[插件拥有消息投影](../../../.agents/notes/implemented/architecture/2026-09-11-plugin-owned-message-projections.zh.md)说明职责划分和离线装配。 追加、seed/restore 与事件 adoption/snapshot 会拒绝任何 `header.system` 及恰好为空的可选请求头字段(`tools: []`、`adapterDefaults: {}`),而不规范化输入。工具结果的 `data.error` 仅在 `message.content[0].isError === true` 时允许存在;失败标识仍是可选的。被拒绝的追加不会改变日志、派生状态或事件流。Adoption 校验事件局部元数据,但不校验所引用的历史或替换端点是否属于 surface。 @@ -83,11 +83,11 @@ session.deriveMessages() // the derived model history ### 设计理念 -该包建立在事件溯源之上:`Session` 是类型化 `SessionEvent` 的仅追加日志,其他一切——模型历史、transcript(文本记录)、遥测、标题、持久化——都从这条流派生。surface 是派生投影:一个增量管理器校验追加候选、根据已提交事件推进有序视图,通过 `replaceGeneration` 跟踪位置替换,通过 `contentGeneration` 跟踪位置替换和图片省略决定。模型可见即已记录:任何到达模型请求的内容都必须能从日志重建。每个完成结算的模型尝试都会提交一个事件:`assistant/message` 携带组装后的模型可见 message 及其紧凑带时间 stream,`assistant/attempt` 则保留失败、重试、取消或 stream error attempt,且不添加模型历史。如果进程在 settlement 前硬中断,则不会留下持久 attempt stream。 +该包建立在事件溯源之上:`Session` 是类型化 `SessionEvent` 的仅追加日志,其他一切——模型历史、transcript(文本记录)、遥测、标题、持久化——都从这条流派生。surface 是派生投影:一个增量管理器校验追加候选、根据已提交事件推进有序视图,通过 `replaceGeneration` 跟踪位置替换,通过 `contentGeneration` 跟踪位置替换和插件拥有的消息变更。模型可见即已记录:任何到达模型请求的内容都必须能从日志重建。每个完成结算的模型尝试都会提交一个事件:`assistant/message` 携带组装后的模型可见 message 及其紧凑带时间 stream,`assistant/attempt` 则保留失败、重试、取消或 stream error attempt,且不添加模型历史。如果进程在 settlement 前硬中断,则不会留下持久 attempt stream。 ### 请求头 -`request/header` 存储非历史请求封装的完整规范快照,原因为 `initial`、`resume`、`change` 或 `series`。显式消息序列起点、表层替换或图片省略决定会在请求封装不变时写入 `series` 快照;同时发生变化时使用 `startsSeries: true`。同一序列内的步骤、重试与普通后续轮次继承最新快照。`adapterDefaults` 区分由适配器解析的值与显式设置,`foldRequestHeader()` 选择最新快照。这种自包含记录以每个消息序列增加存储为代价,支持局部窗口渲染与精确重建;细节由[可重建请求 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md)负责。 +`request/header` 存储非历史请求封装的完整规范快照,原因为 `initial`、`resume`、`change` 或 `series`。显式消息序列起点、表层替换或插件拥有的消息变更会在请求封装不变时写入 `series` 快照;同时发生变化时使用 `startsSeries: true`。同一序列内的步骤、重试与普通后续轮次继承最新快照。`adapterDefaults` 区分由适配器解析的值与显式设置,`foldRequestHeader()` 选择最新快照。这种自包含记录以每个消息序列增加存储为代价,支持局部窗口渲染与精确重建;细节由[可重建请求 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md)负责。 ### 源码地图 @@ -107,7 +107,7 @@ session.deriveMessages() // the derived model history ### 派生历史 -`deriveMessages()` 缓存深度冻结的派生消息,每次调用返回新数组。四种 surface 事件类型(`system/message`、`user/message`、`assistant/message`、`tool/result`)提供记录的消息身份和内容,空内容的系统节点不派生消息。图片省略选择为派生的输入图片块添加标记,不修改记录的消息。替换和省略决策使缓存失效。嵌入式 Assistant stream 与 `assistant/attempt` 事件只保留回放和诊断数据。 +`deriveMessages()` 缓存深度冻结的派生消息,每次调用返回新数组。四种 surface 事件类型(`system/message`、`user/message`、`assistant/message`、`tool/result`)提供记录的消息身份和内容,空内容的系统节点不派生消息。插件拥有的投影修改派生内容,不修改记录的消息。替换和投影决策使缓存失效。嵌入式 Assistant stream 与 `assistant/attempt` 事件只保留回放和诊断数据。 ### 请求头 @@ -137,7 +137,7 @@ session.deriveMessages() // the derived model history #### 模型看到什么 -模型会接收 `system/message`、`user/message`、`assistant/message` 与 `tool/result` surface 条目中的消息,并应用日志中的图片省略选择,系统提示词在先。消息标识、角色和来源保持不变,投影不生成标识。直接提示词与注入上下文仍是独立的 `user/message` 事件,各事件的来源保留其出处。嵌入式 stream、`assistant/attempt`、边界与其他仅日志事实不添加消息。 +模型会接收 `system/message`、`user/message`、`assistant/message` 与 `tool/result` surface 条目中的消息,并应用日志中的投影,系统提示词在先。消息标识、角色、来源及未修改的内容块保持不变,投影不生成标识。直接提示词与注入上下文仍是独立的 `user/message` 事件,各事件的来源保留其出处。嵌入式 stream、`assistant/attempt`、边界与其他仅日志事实不添加消息。 #### Token 影响 diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 11012a2815..b7fe8f8568 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -17,7 +17,7 @@ import { SESSION_FORMAT_VERSION, SessionLogOffset, SessionSeq } from './types.ts import type { TypertLookup } from '@deepseek-ai/dsh-typert-protocol' import type { CreateSessionOptions, EpochHeader, PrepareSessionOptions, RequestContext, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SessionId, SessionSeedEventState, SurfaceIntent, SurfaceEventType } from './types.ts' import { SurfaceManager, validateSessionEventData, validateSurfaceMetadata } from './surface.ts' -import type { SessionSurface } from './surface.ts' +import type { SessionSurface, SessionMessageProjection } from './surface.ts' import { foldRequestHeader } from './request-header.ts' export * from './types.ts' @@ -25,7 +25,7 @@ export { SessionPreparation } from './preparation.ts' export type { SessionPreparationOptions } from './preparation.ts' export type { AssistantMessage, SystemMessage, ToolResultMessage, UserMessage } from '@deepseek-ai/dsh-llm' export { interruptedTurnClosers, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from './repair.ts' -export type { SessionSurface, SurfaceFoldReplacement, SurfaceFoldResult } from './surface.ts' +export type { SessionSurface, SurfaceFoldReplacement, SurfaceFoldResult, SessionMessageProjection, SessionMessageProjectionContext } from './surface.ts' export { deriveEventMessage, foldSurface, isAppendSurfaceEvent, isReplacementSurfaceEvent, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts' export { canonicalHeader, foldRequestHeader, headerEquals } from './request-header.ts' export { KNOWN_SESSION_EVENT_TYPES } from './known-event-types.ts' @@ -446,7 +446,7 @@ const attachments = new WeakMap() export class Session { private log: SessionEvent[] = [] /** Single incremental owner of surface acceptance and projection state. */ - private readonly surfaceManager = new SurfaceManager(this.log) + private readonly surfaceManager: SurfaceManager /** The ordered surface over this session's event log. */ get surface(): SessionSurface { @@ -503,15 +503,18 @@ export class Session { * @param seed - optional borrowed replay or fork events. * @param header - optional borrowed storage metadata. * @param inheritedEventCount - exact fork-inherited prefix length for a seeded header. + * @param projections - pure interpreters for plugin-owned message changes. * @returns a detached session. + * @throws when a seed event requires a missing message interpreter or fails validation. */ static create( id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader, inheritedEventCount?: SessionLogOffset, + projections?: readonly SessionMessageProjection[], ): Session { - return new Session(id, seed, header, 'snapshot', inheritedEventCount) + return new Session(id, seed, header, 'snapshot', inheritedEventCount, projections) } /** @@ -525,7 +528,9 @@ export class Session { * @param header - independently owned storage metadata. * @param inheritedEventCount - exact fork-inherited prefix length decoded from storage. * @param eventState - aliasing state carried from the operation that produced the seed. + * @param projections - pure interpreters for plugin-owned message changes. * @returns a restored detached session. + * @throws when a seed event requires a missing message interpreter or fails validation. */ static fromRestore( id: SessionId, @@ -533,6 +538,7 @@ export class Session { header: SessionHeader, inheritedEventCount: SessionLogOffset, eventState: SessionSeedEventState, + projections?: readonly SessionMessageProjection[], ): Session { return new Session( id, @@ -540,6 +546,7 @@ export class Session { header, eventState, inheritedEventCount, + projections, ) } @@ -549,7 +556,9 @@ export class Session { header?: SessionHeader, mode: 'snapshot' | SessionSeedEventState = 'snapshot', suppliedInheritedEventCount?: SessionLogOffset, + projections: readonly SessionMessageProjection[] = [], ) { + this.surfaceManager = new SurfaceManager(this.log, SessionLogOffset(0), projections) const restoredHeader = mode === 'snapshot' ? undefined : validateRestoredSessionHeader(id, header) if (seed !== undefined) { // Validate the seed to the SAME invariants `append` enforces, so a @@ -818,14 +827,14 @@ export class Session { * append records its `surfaceOp`, so a raw event with no marker (a chunk, a * turn boundary) is correctly absent, and a compaction `replace` deletes the * shadowed nodes from the derivation. The projection rules are - * {@link deriveEventMessage}, with logged image-offload selections applied + * {@link deriveEventMessage}, with logged message projections applied * without changing node membership or message identity. * - * CACHED: pure tail growth costs O(new nodes); a replacement or image offload + * CACHED: pure tail growth costs O(new nodes); a replacement or message projection * ({@link SessionSurface.contentGeneration}) rebuilds. The returned array is * a fresh snapshot per call (later appends never grow an array a caller * already holds); the `Message` objects in it are SHARED and **deep-frozen**. - * Unchanged content reuses frozen event data; offloaded blocks are frozen + * Unchanged content reuses frozen event data; projected blocks are frozen * derived copies. Consumers cannot mutate the log through either form. * @returns a fresh array of the shared, frozen derived history. */ @@ -853,7 +862,7 @@ export class Session { } /** - * Project one event with all committed image-offload selections applied. + * Project one event with all committed message projections applied. * The original durable event remains unchanged. * @param event - the event to project. * @returns the derived message, or null when the event produces none. @@ -899,6 +908,29 @@ export class SessionForkError extends Error { export class SessionStore extends Service { private store = new Map() private counter = 0 + private readonly projections: SessionMessageProjection[] = [] + + /** Borrowed definitions for detached replay; contributions live until their registering fibers unload. */ + get messageProjections(): readonly SessionMessageProjection[] { + return this.projections + } + + /** + * Register one event interpreter for live creation, restore, and fork. + * Disposing the contribution makes sessions that used it refuse further derivation. + * @param projection - pure definition owned by the event's plugin. + * @returns the fiber-owned disposer. + * @throws when another definition already owns this event type. + */ + registerMessageProjection(projection: SessionMessageProjection): () => void { + if (this.projections.some(item => item.type === projection.type)) { + throw new Error(`session message projection "${projection.type}" is already registered`) + } + return this.ctx.effect(() => { + this.projections.push(projection) + return () => { this.projections.splice(this.projections.indexOf(projection), 1) } + }, 'sessions.registerMessageProjection()') + } constructor(ctx: Context) { super(ctx, 'sessions') @@ -986,6 +1018,7 @@ export class SessionStore extends Service { options.meta, options.inheritedEventCount, eventState, + this.projections, ) case undefined: break @@ -1007,7 +1040,7 @@ export class SessionStore extends Service { ...meta?.delegationDepth === undefined ? {} : { delegationDepth: meta.delegationDepth }, ...meta?.agentPreset === undefined ? {} : { agentPreset: meta.agentPreset }, } - return Session.create(sessionId, seed, header, options?.inheritedEventCount) + return Session.create(sessionId, seed, header, options?.inheritedEventCount, this.projections) } /** diff --git a/packages/core/session/src/known-event-types.ts b/packages/core/session/src/known-event-types.ts index 30cb73fa47..3e595250e1 100644 --- a/packages/core/session/src/known-event-types.ts +++ b/packages/core/session/src/known-event-types.ts @@ -78,3 +78,8 @@ export const KNOWN_SESSION_EVENT_TYPES: ReadonlySet = new Set([ 'user/message', 'web/deepseek-search-llm-request', ]) + +/** Event types whose model-visible effects require an explicit pure interpreter. */ +export const MESSAGE_PROJECTION_EVENT_TYPES: ReadonlySet = new Set([ + 'image/offload', +]) diff --git a/packages/core/session/src/surface.ts b/packages/core/session/src/surface.ts index 797281dbb5..3a2abbc393 100644 --- a/packages/core/session/src/surface.ts +++ b/packages/core/session/src/surface.ts @@ -10,15 +10,42 @@ import type { Message } from '@deepseek-ai/dsh-llm' import { SessionLogOffset, SessionSeq } from './types.ts' -import { KNOWN_SESSION_EVENT_TYPES } from './known-event-types.ts' -import { offloadMessageImages } from './image-offload.ts' +import { KNOWN_SESSION_EVENT_TYPES, MESSAGE_PROJECTION_EVENT_TYPES } from './known-event-types.ts' import type { SessionEvent, + SessionEventType, SessionSeqCursor, SurfaceEvent, SurfaceOp, } from './types.ts' +/** Readonly history immediately before a message-projection event. */ +export interface SessionMessageProjectionContext { + /** Current message-producing event sequences in model-visible order. */ + nodes: readonly SessionSeq[] + /** Contiguous event window; entries at or beyond the candidate seq are not committed inputs. */ + events: readonly SessionEvent[] + /** Absolute sequence of the window's first event. */ + baseSeq: SessionLogOffset + /** Previously projected messages keyed by their original event sequences. */ + messages: ReadonlyMap +} + +/** Pure interpretation of one plugin-owned event that changes existing message content. */ +export interface SessionMessageProjection { + /** Event interpreted by this definition; declare it with `@messageProjection` in SessionEventMap. */ + type: T + /** + * Validate the complete durable decision before returning any updates. Preserve + * message identities and publish immutable copies without mutating the input. + * @param event - candidate event, not yet applied to the supplied history. + * @param context - history preceding this decision. + * @returns changed current messages keyed by their original sequences. + * @throws when the durable decision cannot be applied to this history. + */ + project(event: SessionEvent, context: SessionMessageProjectionContext): ReadonlyMap +} + /** Runtime counterpart of the message-producing event union. */ const SURFACE_EVENT_TYPES = new Set([ 'system/message', @@ -81,19 +108,19 @@ export function isReplacementSurfaceEvent( * Project a single event into the LLM message it derives to, or null when it * produces none — a non-surface event (attempt, boundary, log-only record) or an * empty-content assistant/message (which exists only to host usage). A caller - * reconstructing model input supplies the same prefix's `offloadedMessages` + * reconstructing model input supplies the same prefix's `projectedMessages` * from {@link foldSurface}; without that map this function reads original * event content. Session instance methods apply the live projection. Messages * are immutable and unchanged content retains its durable identity. * @param event - the event to project. - * @param offloadedMessages - image projections from the same log prefix's surface fold. + * @param projectedMessages - message projections from the same log prefix's surface fold. * @returns the derived message, or null when the event produces none. */ export function deriveEventMessage( event: SessionEvent, - offloadedMessages?: ReadonlyMap, + projectedMessages?: ReadonlyMap, ): Message | null { - const projected = offloadedMessages?.get(event.seq) + const projected = projectedMessages?.get(event.seq) if (projected !== undefined) return projected // Intentionally non-exhaustive: only message-producing events derive // history; turn/step boundaries, failed attempts, and errors are trace/replay @@ -189,8 +216,8 @@ export interface SurfaceFoldResult { nodes: SessionSeq[] /** Replacement operations in event order. */ replacements: SurfaceFoldReplacement[] - /** Immutable image-offloaded messages, keyed by their original event sequences. */ - offloadedMessages: ReadonlyMap + /** Immutable projected messages, keyed by their original event sequences. */ + projectedMessages: ReadonlyMap } /** Readonly live projection of the message-producing session events. */ @@ -199,7 +226,7 @@ export interface SessionSurface { readonly nodes: readonly SessionSeq[] /** Monotonic count of committed positional replacements. */ readonly replaceGeneration: number - /** Monotonic count of committed replacements and image-offload decisions. */ + /** Monotonic count of committed replacements and plugin-owned message changes. */ readonly contentGeneration: number } @@ -208,7 +235,8 @@ interface SurfaceFoldState { nodes: SessionSeq[] replaceGeneration: number contentGeneration: number - offloadedMessages: Map + projectedMessages: Map + projections: Set } /** A validated replacement transition that has not mutated fold state yet. */ @@ -222,50 +250,11 @@ interface SurfaceReplacePlan extends SurfaceFoldReplacement { type SurfacePlan = | { kind: 'append'; seq: SessionSeq } | SurfaceReplacePlan - | { kind: 'image-offload'; messages: ReadonlyMap } + | { kind: 'project'; projection: SessionMessageProjection; messages: ReadonlyMap } /** Create an empty surface fold state. */ function createFoldState(): SurfaceFoldState { - return { nodes: [], replaceGeneration: 0, contentGeneration: 0, offloadedMessages: new Map() } -} - -/** Validate exact image references before publishing any part of the decision. */ -function planImageOffload( - state: SurfaceFoldState, - event: SessionEvent<'image/offload'>, - events: readonly SessionEvent[], - baseSeq: SessionLogOffset, -): Extract { - const data: unknown = event.data - if (!isRecord(data) || Object.keys(data).length !== 1 || !Array.isArray(data['targets']) || data['targets'].length === 0) { - throw new Error('image/offload: data must contain a nonempty targets array') - } - const messages = new Map() - const nodes = new Set(state.nodes) - for (const target of data['targets'] as unknown[]) { - if (!isRecord(target) || Object.keys(target).length !== 2 || !isEventSeq(target['seq']) - || !Array.isArray(target['imageIndexes']) || target['imageIndexes'].length === 0) { - throw new Error('image/offload: each target must contain a seq and nonempty imageIndexes') - } - const seq = target['seq'] - if (messages.has(seq)) throw new Error(`image/offload: duplicate target seq ${seq}`) - if (!nodes.has(seq)) throw new Error(`image/offload: target seq ${seq} is not a current surface node`) - const source = events[seq - baseSeq] - if (source?.type !== 'user/message' && source?.type !== 'tool/result') { - throw new Error(`image/offload: target seq ${seq} must be user/message or tool/result`) - } - let previous = -1 - for (const index of target['imageIndexes'] as unknown[]) { - if (!isEventSeq(index) || index <= previous) { - throw new Error('image/offload: imageIndexes must be strictly increasing non-negative safe integers') - } - previous = index - } - const message = state.offloadedMessages.get(seq) - ?? (source.type === 'user/message' ? source.data : source.data.message) - messages.set(seq, offloadMessageImages(message, target['imageIndexes'] as number[])) - } - return { kind: 'image-offload', messages } + return { nodes: [], replaceGeneration: 0, contentGeneration: 0, projectedMessages: new Map(), projections: new Set() } } /** Whether a runtime value is a non-negative safe event sequence. */ @@ -475,12 +464,21 @@ function planSurfaceEvent( expectedSeq: SessionSeq, events: readonly SessionEvent[], baseSeq: SessionLogOffset, + projections: readonly SessionMessageProjection[], ): SurfacePlan | undefined { if (event.seq !== expectedSeq) { throw new Error(`session event seq ${event.seq} is not contiguous; expected ${expectedSeq}`) } const surfaceOp = validateSurfaceMetadata(event) - if (event.type === 'image/offload') return planImageOffload(state, event, events, baseSeq) + const projection = projections.find(item => item.type === event.type) + if (projection !== undefined) { + return { kind: 'project', projection, messages: projection.project(event, { + nodes: state.nodes, events, baseSeq, messages: state.projectedMessages, + }) } + } + if (MESSAGE_PROJECTION_EVENT_TYPES.has(event.type)) { + throw new Error(`session event "${event.type}" requires a message projection; load its owning plugin or supply its projection definition`) + } if (surfaceOp === undefined) return if (surfaceOp === 'append') { return { kind: 'append', seq: event.seq } @@ -505,8 +503,9 @@ function applySurfaceEvent( expectedSeq: SessionSeq, events: readonly SessionEvent[], baseSeq: SessionLogOffset, + projections: readonly SessionMessageProjection[], ): SurfaceFoldReplacement | undefined { - const plan = planSurfaceEvent(state, event, expectedSeq, events, baseSeq) + const plan = planSurfaceEvent(state, event, expectedSeq, events, baseSeq, projections) return applySurfacePlan(state, plan) } @@ -521,8 +520,9 @@ function applySurfacePlan( state.nodes.splice(plan.startIdx, plan.endIdx - plan.startIdx + 1, plan.seq) state.replaceGeneration += 1 state.contentGeneration += 1 - } else if (plan?.kind === 'image-offload') { - for (const [seq, message] of plan.messages) state.offloadedMessages.set(seq, message) + } else if (plan?.kind === 'project') { + for (const [seq, message] of plan.messages) state.projectedMessages.set(seq, message) + state.projections.add(plan.projection) state.contentGeneration += 1 } if (plan?.kind !== 'replace') return @@ -537,10 +537,11 @@ function applySurfacePlan( /** * Replay a complete session log through the canonical surface fold. * @param events - session events in contiguous seq order. + * @param projections - pure interpreters for plugin-owned message changes; required definitions must be supplied. * @returns detached current sequences and replacement history. - * @throws when an event violates surface metadata, source-event references, range, or tool-result rewrite rules. + * @throws when an interpreter is missing or an event violates its projection, surface metadata, provenance, or replacement rules. */ -export function foldSurface(events: readonly SessionEvent[]): SurfaceFoldResult { +export function foldSurface(events: readonly SessionEvent[], projections: readonly SessionMessageProjection[] = []): SurfaceFoldResult { const state = createFoldState() const replacements: SurfaceFoldReplacement[] = [] for (const [index, event] of events.entries()) { @@ -550,10 +551,11 @@ export function foldSurface(events: readonly SessionEvent[]): SurfaceFoldResult SessionSeq(index), events, SessionLogOffset(0), + projections, ) if (replacement !== undefined) replacements.push(replacement) } - return { nodes: [...state.nodes], replacements, offloadedMessages: new Map(state.offloadedMessages) } + return { nodes: [...state.nodes], replacements, projectedMessages: new Map(state.projectedMessages) } } /** Incremental ordered surface view and append-boundary validator. */ @@ -568,10 +570,12 @@ export class SurfaceManager implements SessionSurface { /** * @param log - Contiguous complete log or loaded event window. * @param baseSeq - Absolute sequence of the window's first event. + * @param projections - live borrowed definitions; removing a used definition invalidates further reads. */ constructor( private log: readonly SessionEvent[], private readonly baseSeq: SessionLogOffset = SessionLogOffset(0), + private readonly projections: readonly SessionMessageProjection[] = [], ) { this._lastProcessedSeq = baseSeq === 0 ? -1 : SessionSeq(baseSeq - 1) } @@ -581,39 +585,44 @@ export class SurfaceManager implements SessionSurface { * @param event - candidate event that has not entered the log yet. */ validateNext(event: SessionEvent): void { + this._assertProjections() if (this._lastProcessedSeq < this.baseSeq + this.log.length - 1) this._processDelta() const expectedSeq = SessionSeq(this.baseSeq + this.log.length) this._pendingPlan = { event, expectedSeq, - plan: planSurfaceEvent(this._state, event, expectedSeq, this.log, this.baseSeq), + plan: planSurfaceEvent(this._state, event, expectedSeq, this.log, this.baseSeq, this.projections), } } /** Monotonic count of folded positional replacements. */ get replaceGeneration(): number { + this._assertProjections() if (this._lastProcessedSeq < this.baseSeq + this.log.length - 1) this._processDelta() return this._state.replaceGeneration } /** Monotonic count of committed changes to existing model-visible content. */ get contentGeneration(): number { + this._assertProjections() if (this._lastProcessedSeq < this.baseSeq + this.log.length - 1) this._processDelta() return this._state.contentGeneration } /** - * Project one message with every committed image-offload decision applied. + * Project one message with every committed message projection applied. * @param event - message-producing or log-only event. * @returns its immutable projected message, or null when it produces none. */ deriveEventMessage(event: SessionEvent): Message | null { + this._assertProjections() if (this._lastProcessedSeq < this.baseSeq + this.log.length - 1) this._processDelta() - return deriveEventMessage(event, this._state.offloadedMessages) + return deriveEventMessage(event, this._state.projectedMessages) } /** Surface event sequences in model-visible order. */ get nodes(): readonly SessionSeq[] { + this._assertProjections() if (this._lastProcessedSeq < this.baseSeq + this.log.length - 1) this._processDelta() return this._state.nodes } @@ -629,10 +638,25 @@ export class SurfaceManager implements SessionSurface { if (pending?.event === event && pending.expectedSeq === seq) { applySurfacePlan(this._state, pending.plan) } else { - applySurfaceEvent(this._state, event, SessionSeq(seq), this.log, this.baseSeq) + applySurfaceEvent(this._state, event, SessionSeq(seq), this.log, this.baseSeq, this.projections) } if (pending !== undefined && pending.expectedSeq <= seq) this._pendingPlan = undefined this._lastProcessedSeq = SessionSeq(seq) } } + + /** Cached messages cannot outlive the definitions that interpreted their log. */ + private _assertProjections(): void { + const candidate = this._pendingPlan + const pending = candidate !== undefined && this.log[candidate.expectedSeq - this.baseSeq] === candidate.event + ? candidate.plan : undefined + const required = pending?.kind === 'project' + ? [...this._state.projections, pending.projection] + : this._state.projections + for (const projection of required) { + if (!this.projections.includes(projection)) { + throw new Error(`session message projection "${projection.type}" was removed or replaced; restore the session with its owning plugin`) + } + } + } } diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 83bc37fbde..139fccd5a5 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -267,15 +267,6 @@ export type RequestHeaderReason = 'initial' | 'resume' | 'change' | 'series' * compact raw streams so persistence stores one durable settlement per attempt. */ export interface SessionEventMap { - /** - * Permanently omit the selected input-image occurrences from subsequent - * model requests. Each target names a current user/message or tool/result - * node; image indexes are zero-based depth-first positions within that - * message, including nested tool results and already offloaded images. - * Targets are unique and each index list is nonempty and strictly increasing. - * This event changes derived content without replacing message nodes. - */ - 'image/offload': { targets: ImageOffloadTarget[] } /** * Opens turn `turn` before the loop claims queued input or runs pre-step. * Rejection, empty input, cancellation, or failure may close it with no @@ -409,14 +400,6 @@ export interface SessionEventMap { 'session/end-seed': { inherited?: true } } -/** Exact input-image occurrences selected by one durable offload decision. */ -export interface ImageOffloadTarget { - /** Current message-producing event containing these occurrences. */ - seq: SessionSeq - /** Zero-based depth-first image indexes within the immutable message. */ - imageIndexes: number[] -} - /** The appendable event-type keys of {@link SessionEventMap}, plugin-merged extensions included. */ export type SessionEventType = keyof SessionEventMap diff --git a/packages/core/session/tests/gen-persistence-catalog.spec.ts b/packages/core/session/tests/gen-persistence-catalog.spec.ts index 4fee74793f..7c03a43dd0 100644 --- a/packages/core/session/tests/gen-persistence-catalog.spec.ts +++ b/packages/core/session/tests/gen-persistence-catalog.spec.ts @@ -13,6 +13,7 @@ import { collectLogEvents, collectSurfaceEventTypes, render, + renderKnownEventTypes, } from '../../../../scripts/gen-persistence-catalog.ts' /** Create a fixture scan root; `files` maps `packages/…`-relative paths to source. */ @@ -45,6 +46,18 @@ afterEach(() => { const OWNER_MANIFEST = '{ "name": "@deepseek-ai/dsh-session" }\n' describe('gen-persistence-catalog collectLogEvents', () => { + it('generates required interpreter types from plugin-owned event declarations', () => { + const events = collectLogEvents(make({ + 'packages/group/fix/src/types.ts': merge(' /**\n * Projects existing messages.\n * @messageProjection\n */\n \'fix/project\': { seq: number }'), + })) + expect(events[0]?.messageProjection).toBe(true) + expect(renderKnownEventTypes(annotateSurface(events, []))) + .toContain("MESSAGE_PROJECTION_EVENT_TYPES: ReadonlySet = new Set([\n 'fix/project',\n])") + expect(() => annotateSurface(events, ['fix/project'])).toThrow(/cannot declare both/) + const ordinary = events.map(({ messageProjection: _projection, ...event }) => event) + expect(renderKnownEventTypes(annotateSurface(ordinary, []))) + .toContain('MESSAGE_PROJECTION_EVENT_TYPES: ReadonlySet = new Set([\n])') + }) it('extracts a documented member of the owning top-level interface', () => { const events = collectLogEvents(make({ 'packages/core/fix/package.json': OWNER_MANIFEST, diff --git a/packages/core/session/tests/message-projections.spec.ts b/packages/core/session/tests/message-projections.spec.ts new file mode 100644 index 0000000000..e9943c1e1d --- /dev/null +++ b/packages/core/session/tests/message-projections.spec.ts @@ -0,0 +1,114 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import { deepFreeze } from '@deepseek-ai/dsh-util-values' +import SessionStore, { Session, SessionId, SessionSeq, SessionLogOffset, foldSurface, deriveEventMessage } from '../src/index.ts' +import type { SessionEvent, SessionMessageProjection } from '../src/index.ts' +import { MESSAGE_PROJECTION_EVENT_TYPES } from '../src/known-event-types.ts' +import { SurfaceManager } from '../src/surface.ts' + +declare module '@deepseek-ai/dsh-session/types' { + interface SessionEventMap { + 'test/project': { seq: SessionSeq; text: string } + } +} + +const projection: SessionMessageProjection<'test/project'> = { + type: 'test/project', + project(event, context) { + const source = context.events[event.data.seq - context.baseSeq]! + const original = deriveEventMessage(source, context.messages)! + if (event.data.text === 'reject') throw new Error('rejected decision') + return new Map([[source.seq, deepFreeze({ ...original, content: [{ type: 'text' as const, text: event.data.text }] })]]) + }, +} + +const contexts: Context[] = [] +afterEach(async () => { + await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) +}) + +function input(session: Session) { + return session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'original' }], source: { kind: 'user' } }), { surfaceOp: 'append' }) +} + +describe('plugin-owned message projections', () => { + it('refuses required events when no interpreter is supplied', () => { + const type = [...MESSAGE_PROJECTION_EVENT_TYPES][0]! + const event = { type, seq: SessionSeq(0), time: 0, data: {} } as SessionEvent + const session = Session.create(SessionId('missing')) + expect(() => session.append(event.type as 'test/project', event.data as never)).toThrow(/requires a message projection/) + expect(() => foldSurface([event])).toThrow(/requires a message projection/) + expect(() => Session.create(session.id, [event])).toThrow(/requires a message projection/) + expect(session.seq).toBe(0) + }) + + it('applies generic decisions atomically and replays them through detached folds', () => { + const definitions = [projection] + const session = Session.create(SessionId('pure'), undefined, undefined, undefined, definitions) + const source = input(session) + const original = session.deriveMessages() + expect(() => session.append('test/project', { seq: source.seq, text: 'reject' })).toThrow('rejected decision') + expect(session.deriveMessages()).toEqual(original) + session.append('test/project', { seq: source.seq, text: 'projected' }) + expect(session.deriveMessages()[0]?.content).toEqual([{ type: 'text', text: 'projected' }]) + expect(session.surface.replaceGeneration).toBe(0) + expect(session.surface.contentGeneration).toBe(1) + const folded = foldSurface(session.snapshotEvents(), definitions) + expect(deriveEventMessage(source, folded.projectedMessages)).toEqual(session.deriveMessages()[0]) + expect(source.data.content).toEqual([{ type: 'text', text: 'original' }]) + }) + + it('registers by fiber and refuses cached or pending decisions after disposal', async () => { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(SessionStore) + const fiber = await ctx.plugin((owner) => { owner.sessions.registerMessageProjection(projection) }) + expect(() => ctx.sessions.registerMessageProjection(projection)).toThrow(/already registered/) + const live = ctx.sessions.create(SessionId('live')) + const source = input(live) + live.append('test/project', { seq: source.seq, text: 'changed' }) + const pending = ctx.sessions.create(SessionId('pending')) + input(pending) + pending.append('test/project', { seq: source.seq, text: 'changed' }) + const before = live.deriveMessages() + const child = ctx.sessions.fork(live) + expect(child.deriveMessages()).toEqual(before) + const restored = ctx.sessions.prepare(SessionId('restore'), { + seed: live.snapshotEvents(), meta: { ...live.header, id: SessionId('restore') }, + inheritedEventCount: live.inheritedEventCount, eventState: 'shared-frozen', + }) + expect(restored.deriveMessages()).toEqual(before) + await fiber.dispose() + expect(ctx.sessions.messageProjections).toEqual([]) + for (const session of [live, pending, child, restored]) { + expect(() => session.deriveMessages()).toThrow(/was removed or replaced/) + expect(() => session.deriveEventMessage(source)).toThrow(/was removed or replaced/) + expect(() => session.surface.replaceGeneration).toThrow(/was removed or replaced/) + expect(() => session.surface.contentGeneration).toThrow(/was removed or replaced/) + expect(() => session.append('turn/start', { turn: 1 })).toThrow(/was removed or replaced/) + } + expect(before[0]?.content).toEqual([{ type: 'text', text: 'changed' }]) + }) + + it('applies a supplied interpreter to a loaded window with absolute sequences', () => { + const session = Session.create(SessionId('window'), undefined, undefined, undefined, [projection]) + input(session) + const source = input(session) + session.append('test/project', { seq: source.seq, text: 'window' }) + const events = session.snapshotEvents().slice(1) + const surface = new SurfaceManager(events, SessionLogOffset(1), [projection]) + expect(surface.nodes).toEqual([source.seq]) + expect(surface.deriveEventMessage(source)?.content).toEqual([{ type: 'text', text: 'window' }]) + }) + + it('does not retain an interpreter for a candidate that never committed', () => { + const session = Session.create(SessionId('candidate')) + const source = input(session) + const definitions: SessionMessageProjection[] = [projection] + const surface = new SurfaceManager(session.snapshotEvents(), undefined, definitions) + surface.validateNext({ type: 'test/project', seq: SessionSeq(1), time: 0, data: { seq: source.seq, text: 'unused' } }) + definitions.length = 0 + expect(surface.deriveEventMessage(source)).toBe(source.data) + }) +}) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index a0c5746554..05966710ba 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1869,6 +1869,13 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ summary: 'In-memory session store (`ctx.sessions`).', description: 'In-memory session store (`ctx.sessions`).\n\nPersistence is intentionally not implemented here — the agent lifecycle attaches a session-log writer to each published session\'s write handle; a session published outside that lifecycle persists nothing.', methods: [ + { + signature: 'registerMessageProjection(projection: SessionMessageProjection): () => void', + description: 'Register one event interpreter for live creation, restore, and fork. Disposing the contribution makes sessions that used it refuse further derivation.', + parameters: [{ name: 'projection', description: 'pure definition owned by the event\'s plugin.' }], + returns: 'the fiber-owned disposer.', + throws: ['when another definition already owns this event type.'], + }, { signature: 'create(id?: SessionId, options?: CreateSessionOptions): Session', description: 'Create a session owned by the calling fiber: disposing that fiber stops event notification and removes the session from the store. `options.seed` populates the session with a copy of those events (replay/fork); `options.meta` attaches creation metadata (validated absolute `cwd`, seed and parent lineage, and delegation depth) as the immutable SessionHeader (the store fills `version`/`id`/`createdAt`).\n\nFor an agent whose session must be torn down IN ORDER with its loop (so the loop\'s final events are published before the store attachment ends), do NOT use this — fold the session lifecycle into the agent\'s own effect via prepare + enter + announce (see `dsh-agent-loop`\'s creation transaction).', @@ -4370,10 +4377,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ImageMediaType', declaration: 'export type ImageMediaType = \'image/png\' | \'image/jpeg\' | \'image/webp\' | \'image/gif\';', }, - { - name: 'ImageOffloadTarget', - declaration: 'export interface ImageOffloadTarget {\n seq: SessionSeq;\n imageIndexes: number[];\n}', - }, { name: 'ImageRequestTarget', declaration: 'export interface ImageRequestTarget {\n width: number;\n height: number;\n maxBytes: number;\n}', @@ -5052,7 +5055,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'Session', - declaration: 'export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n readonly inheritedEventCount: SessionLogOffset;\n get id(): SessionId;\n readonly firstLiveSeq: SessionLogOffset;\n static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader, inheritedEventCount?: SessionLogOffset): Session;\n static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader, inheritedEventCount: SessionLogOffset, eventState: SessionSeedEventState): Session;\n eventAt(seq: SessionSeq): SessionEvent | undefined;\n snapshotEvents(fromSeq: SessionLogOffset = SessionLogOffset(0), toSeqExclusive: SessionLogOffset = this.seq): readonly SessionEvent[];\n ownEvents(): readonly SessionEvent[];\n isOwnSeq(seq: SessionSeq): boolean;\n get seq(): SessionLogOffset;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n}', + declaration: 'export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n readonly inheritedEventCount: SessionLogOffset;\n get id(): SessionId;\n readonly firstLiveSeq: SessionLogOffset;\n static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader, inheritedEventCount?: SessionLogOffset, projections?: readonly SessionMessageProjection[]): Session;\n static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader, inheritedEventCount: SessionLogOffset, eventState: SessionSeedEventState, projections?: readonly SessionMessageProjection[]): Session;\n eventAt(seq: SessionSeq): SessionEvent | undefined;\n snapshotEvents(fromSeq: SessionLogOffset = SessionLogOffset(0), toSeqExclusive: SessionLogOffset = this.seq): readonly SessionEvent[];\n ownEvents(): readonly SessionEvent[];\n isOwnSeq(seq: SessionSeq): boolean;\n get seq(): SessionLogOffset;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n}', }, { name: 'SessionAccess', @@ -5120,7 +5123,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionEventMap', - declaration: 'export interface SessionEventMap {\n \'image/offload\': {\n targets: ImageOffloadTarget[];\n };\n \'turn/start\': {\n turn: number;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': UserMessage;\n \'system/message\': {\n turn: number;\n step: number;\n message: SystemMessage;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n message: AssistantMessage;\n stream: AssistantStreamRecord[];\n usage?: TokenUsage;\n interrupted?: true;\n };\n \'assistant/attempt\': {\n turn: number;\n step: number;\n stream: AssistantStreamRecord[];\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: ToolCallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n \'request/header\': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n startsSeries?: true;\n };\n \'request/context\': RequestContext;\n \'session/end-seed\': {\n inherited?: true;\n };\n}', + declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': UserMessage;\n \'system/message\': {\n turn: number;\n step: number;\n message: SystemMessage;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n message: AssistantMessage;\n stream: AssistantStreamRecord[];\n usage?: TokenUsage;\n interrupted?: true;\n };\n \'assistant/attempt\': {\n turn: number;\n step: number;\n stream: AssistantStreamRecord[];\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: ToolCallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n \'request/header\': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n startsSeries?: true;\n };\n \'request/context\': RequestContext;\n \'session/end-seed\': {\n inherited?: true;\n };\n}', }, { name: 'SessionEventMetadataFilter', @@ -5278,6 +5281,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionLogSnapshot', declaration: 'export interface SessionLogSnapshot {\n session: SessionHeader;\n inheritedEventCount: SessionLogOffset;\n events: SessionEvent[];\n}', }, + { + name: 'SessionMessageProjection', + declaration: 'export interface SessionMessageProjection {\n type: T;\n project(event: SessionEvent, context: SessionMessageProjectionContext): ReadonlyMap;\n}', + }, + { + name: 'SessionMessageProjectionContext', + declaration: 'export interface SessionMessageProjectionContext {\n nodes: readonly SessionSeq[];\n events: readonly SessionEvent[];\n baseSeq: SessionLogOffset;\n messages: ReadonlyMap;\n}', + }, { name: 'SessionObservation', declaration: 'export interface SessionObservation extends Disposable {\n readonly source: \'live\' | \'prepared\';\n readonly header: SessionHeader;\n readonly inheritedEventCount: SessionLogOffsetType;\n readonly events: readonly SessionEvent[];\n readonly cursor: SessionSeqCursor;\n readonly revision?: SessionPersistenceRevision;\n readonly projections?: ProjectionSnapshot;\n retain(): SessionObservation;\n}', diff --git a/packages/llm/token-meter/package.json b/packages/llm/token-meter/package.json index 44223f3a96..263910342e 100644 --- a/packages/llm/token-meter/package.json +++ b/packages/llm/token-meter/package.json @@ -37,7 +37,8 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^" + "@deepseek-ai/dsh-session-projection": "workspace:^", + "@deepseek-ai/dsh-compaction-image-offload": "workspace:^" }, "dependencies": { "@deepseek-ai/dsh-util-values": "workspace:^", @@ -50,6 +51,7 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^" + "@deepseek-ai/dsh-session-projection": "workspace:^", + "@deepseek-ai/dsh-compaction-image-offload": "workspace:^" } } diff --git a/packages/llm/token-meter/src/index.ts b/packages/llm/token-meter/src/index.ts index 07264eb6d1..f721d26219 100644 --- a/packages/llm/token-meter/src/index.ts +++ b/packages/llm/token-meter/src/index.ts @@ -5,6 +5,7 @@ */ import { Context, Service } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-compaction-image-offload/projection' import z from '@deepseek-ai/schemastery' import { assembleAssistantStream } from '@deepseek-ai/dsh-llm' import type { LlmImageRequestPricing, LlmRuntime, Message, TokenUsage } from '@deepseek-ai/dsh-llm' diff --git a/packages/llm/token-meter/tests/route-pricing.spec.ts b/packages/llm/token-meter/tests/route-pricing.spec.ts index a3102d9507..360d1b6352 100644 --- a/packages/llm/token-meter/tests/route-pricing.spec.ts +++ b/packages/llm/token-meter/tests/route-pricing.spec.ts @@ -1,3 +1,4 @@ +import { imageOffloadProjection } from '@deepseek-ai/dsh-compaction-image-offload/projection' import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { @@ -85,7 +86,7 @@ async function harness(pricing: (model: string) => LlmImageRequestPricing | unde const llm = new LlmRuntime(ctx) llm.registerAdapter(['mock'], new PricingAdapter(pricing)) const meter = new TokenMeter(ctx) - return { ctx, meter, session: Session.create(SessionId('route-priced')) } + return { ctx, meter, session: Session.create(SessionId('route-priced'), undefined, undefined, undefined, [imageOffloadProjection]) } } /** Route price of one image-bearing message under the fixed pricing double. */ @@ -229,7 +230,7 @@ describe('request projection pricing', () => { expect(after.nodes.map(node => node.heuristicTokens)).toEqual(before.nodes.map(node => node.heuristicTokens)) const breakdown = ctx.sessionProjections.snapshot(session).values.contextBreakdown expect(breakdown?.messageTokens).toBe(after.nodes.reduce((total, node) => total + node.heuristicTokens, 0)) - const restored = Session.create(SessionId('offloaded-restored'), session.snapshotEvents()) + const restored = Session.create(SessionId('offloaded-restored'), session.snapshotEvents(), undefined, undefined, [imageOffloadProjection]) expect(meter.measure(restored).surfaceTokens).toBe(after.surfaceTokens) }) diff --git a/packages/llm/token-meter/tsconfig.json b/packages/llm/token-meter/tsconfig.json index 1d8de4abf6..51e81f5e71 100644 --- a/packages/llm/token-meter/tsconfig.json +++ b/packages/llm/token-meter/tsconfig.json @@ -31,6 +31,9 @@ }, { "path": "../../session/session-projection" + }, + { + "path": "../../compaction/compaction-image-offload" } ] } diff --git a/packages/session-query/session-query/package.json b/packages/session-query/session-query/package.json index e383b1b413..17eb37f1ae 100644 --- a/packages/session-query/session-query/package.json +++ b/packages/session-query/session-query/package.json @@ -58,5 +58,8 @@ "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-session-projection-cache": "workspace:^", "@deepseek-ai/cordis": "workspace:^" + }, + "dependencies": { + "@deepseek-ai/dsh-session-format-catalog": "workspace:^" } } diff --git a/packages/session-query/session-query/src/documents.ts b/packages/session-query/session-query/src/documents.ts index f51ae6b733..8788c8ea35 100644 --- a/packages/session-query/session-query/src/documents.ts +++ b/packages/session-query/session-query/src/documents.ts @@ -1,5 +1,6 @@ /** Shared event metadata and semantic-document projection. */ +import { currentSessionMessageProjections } from '@deepseek-ai/dsh-session-format-catalog/message-projections' import { foldSurface } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionId, SessionSeq } from '@deepseek-ai/dsh-session' import type { SessionEventRecord, SessionEventSearchDocument, SessionEventSurface } from './types.ts' @@ -56,7 +57,7 @@ export function buildSessionEventSearchDocuments( function classifySurface(events: readonly SessionEvent[]): Map { let folded: ReturnType try { - folded = foldSurface(events) + folded = foldSurface(events, currentSessionMessageProjections) } catch (error: unknown) { throw new SessionQueryError( /* v8 ignore next -- foldSurface throws Error instances */ diff --git a/packages/session-query/session-query/src/index.ts b/packages/session-query/session-query/src/index.ts index 44360042e9..838ce4c74f 100644 --- a/packages/session-query/session-query/src/index.ts +++ b/packages/session-query/session-query/src/index.ts @@ -4,6 +4,7 @@ * @module @deepseek-ai/dsh-session-query */ +import { currentSessionMessageProjections } from '@deepseek-ai/dsh-session-format-catalog/message-projections' import { Context, Service } from '@deepseek-ai/cordis' import { Session, @@ -187,6 +188,7 @@ export abstract class SessionQueryEngine extends Service { loaded.events, loaded.header, loaded.inheritedEventCount, + currentSessionMessageProjections, ) return { session: structuredClone(loaded.header), diff --git a/packages/session-query/session-query/src/tracing.ts b/packages/session-query/session-query/src/tracing.ts index 2424073e27..0cd3fccfbb 100644 --- a/packages/session-query/session-query/src/tracing.ts +++ b/packages/session-query/session-query/src/tracing.ts @@ -1,5 +1,6 @@ /** One-shot session-lineage and event-relationship tracing helpers. */ +import { currentSessionMessageProjections } from '@deepseek-ai/dsh-session-format-catalog/message-projections' import { foldSurface, isSurfaceEvent, snapshotSessionEvent } from '@deepseek-ai/dsh-session' import type { SessionEvent, @@ -183,7 +184,7 @@ function analyzeEventLog( ): EventLogAnalysis { let folded: ReturnType try { - folded = foldSurface(events) + folded = foldSurface(events, currentSessionMessageProjections) } catch (error: unknown) { throw new SessionQueryError( /* v8 ignore next -- foldSurface throws Error instances */ diff --git a/packages/session-query/session-query/tsconfig.json b/packages/session-query/session-query/tsconfig.json index ab19084ff9..3d6b2bd489 100644 --- a/packages/session-query/session-query/tsconfig.json +++ b/packages/session-query/session-query/tsconfig.json @@ -37,6 +37,9 @@ }, { "path": "../../session/session-projection-cache" + }, + { + "path": "../../session/session-format-catalog" } ] } diff --git a/packages/session/session-format-catalog/README.i18n.yaml b/packages/session/session-format-catalog/README.i18n.yaml index b009c83e2b..523dc45f64 100644 --- a/packages/session/session-format-catalog/README.i18n.yaml +++ b/packages/session/session-format-catalog/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-format-catalog/README.md -README.md: 8cebff66b4d788792075784dfc5c59a6da92a400 -README.zh.md: 5190c19927dfa917f7855f28a687711b724f6411 +README.md: 8232a28b2c2c31c0176adb62e07ed16a804791e8 +README.zh.md: 2c4c86da50640f672842d98e6cc254dca08a3470 diff --git a/packages/session/session-format-catalog/README.md b/packages/session/session-format-catalog/README.md index 8cebff66b4..8232a28b2c 100644 --- a/packages/session/session-format-catalog/README.md +++ b/packages/session/session-format-catalog/README.md @@ -44,7 +44,7 @@ Import `sessionFormatCatalog` from the package root. JSONL and fixture readers c Production historical reads select `{ recovery: 'recoverable', validation: 'transformed' }`. Worker and fixture verification select `{ recovery: 'strict', validation: 'current' }`. Transformed validation runs the released-current rules after migration but deliberately skips installed semantic validation for input that is already current. -The catalog contains all supported historical readers directly. A profile cannot add, remove, or reorder an edge by mounting a feature plugin. Its peer dependency on `dsh-session` supplies the installed current event vocabulary and current restoration rules, while historical edge validators remain frozen. +The catalog contains all supported historical readers directly. A profile cannot add, remove, or reorder an edge by mounting a feature plugin. Its peer dependency on `dsh-session` supplies the installed current event vocabulary and current restoration rules, while historical edge validators remain frozen. The browser-safe `./message-projections` export assembles current plugin-owned interpreters for detached constructors and surface folds; it does not mount recovery listeners. ----- diff --git a/packages/session/session-format-catalog/README.zh.md b/packages/session/session-format-catalog/README.zh.md index 5190c19927..2c4c86da50 100644 --- a/packages/session/session-format-catalog/README.zh.md +++ b/packages/session/session-format-catalog/README.zh.md @@ -44,7 +44,7 @@ const eventRecords = current.events.map(sessionFormatCatalog.encodeCurrentEvent) Production 历史读取使用 `{ recovery: 'recoverable', validation: 'transformed' }`。Worker 与 fixture 校验使用 `{ recovery: 'strict', validation: 'current' }`。Transformed validation 会在迁移后执行已发布 current 规则,但对已经是 current 的输入有意跳过已安装语义校验。 -该目录直接包含所有受支持的历史读取器。Profile 无法通过挂载功能插件来添加、移除或重新排列迁移边。它通过对 `dsh-session` 的对等依赖(peer dependency)获得已安装的当前事件词表与当前还原规则,而历史迁移边校验器保持冻结。 +该目录直接包含所有受支持的历史读取器。Profile 无法通过挂载功能插件来添加、移除或重新排列迁移边。它通过对 `dsh-session` 的对等依赖(peer dependency)获得已安装的当前事件词表与当前还原规则,而历史迁移边校验器保持冻结。浏览器安全的 `./message-projections` 导出为独立构造函数和 surface 折叠装配当前插件拥有的处理器,不挂载恢复监听器。 ----- diff --git a/packages/session/session-format-catalog/package.json b/packages/session/session-format-catalog/package.json index f3ea35a6c8..f9685053fd 100644 --- a/packages/session/session-format-catalog/package.json +++ b/packages/session/session-format-catalog/package.json @@ -19,18 +19,24 @@ "default": "./lib/index.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./message-projections": { + "types": "./lib/types/message-projections.d.ts", + "default": "./lib/types/message-projections.js" + } }, "files": [ "lib/index.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/types/**/*.js" ], "license": "MIT", "dependencies": { "@deepseek-ai/dsh-session-format": "workspace:^", "@deepseek-ai/dsh-session-format-v0-to-v1": "workspace:^", "@deepseek-ai/dsh-session-format-v1-to-v2": "workspace:^", - "@deepseek-ai/dsh-session-format-v2-to-v3": "workspace:^" + "@deepseek-ai/dsh-session-format-v2-to-v3": "workspace:^", + "@deepseek-ai/dsh-compaction-image-offload": "workspace:^" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", diff --git a/packages/session/session-format-catalog/src/current.ts b/packages/session/session-format-catalog/src/current.ts index a8b0f3f011..70fb574590 100644 --- a/packages/session/session-format-catalog/src/current.ts +++ b/packages/session/session-format-catalog/src/current.ts @@ -8,6 +8,7 @@ import { } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import type { SessionFormatArtifact, SessionFormatHeader } from '@deepseek-ai/dsh-session-format' +import { currentSessionMessageProjections } from './message-projections.ts' /** * Validate current logical metadata through the installed Session package. @@ -46,5 +47,6 @@ export function validateInstalledCurrentSessionArtifact(artifact: SessionFormatA artifact.header as unknown as SessionHeader, SessionLogOffset(artifact.inheritedEventCount), 'detached', + currentSessionMessageProjections, ) } diff --git a/packages/session/session-format-catalog/src/message-projections.ts b/packages/session/session-format-catalog/src/message-projections.ts new file mode 100644 index 0000000000..d011783c12 --- /dev/null +++ b/packages/session/session-format-catalog/src/message-projections.ts @@ -0,0 +1,7 @@ +/** Pure first-party message interpreters for detached current-format replay, including browser readers. */ + +import { imageOffloadProjection } from '@deepseek-ai/dsh-compaction-image-offload/projection' +import type { SessionMessageProjection } from '@deepseek-ai/dsh-session/surface' + +/** Installed interpretation definitions; recovery listeners are mounted separately by their owning plugins. */ +export const currentSessionMessageProjections: readonly SessionMessageProjection[] = [imageOffloadProjection] diff --git a/packages/session/session-format-catalog/tests/catalog.spec.ts b/packages/session/session-format-catalog/tests/catalog.spec.ts index 0c1d748615..a1f2abe167 100644 --- a/packages/session/session-format-catalog/tests/catalog.spec.ts +++ b/packages/session/session-format-catalog/tests/catalog.spec.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest' import type { SessionFormatEvent } from '@deepseek-ai/dsh-session-format' import { sessionFormatCatalog } from '../src/index.ts' +import { currentSessionMessageProjections } from '../src/message-projections.ts' +import { MESSAGE_PROJECTION_EVENT_TYPES } from '@deepseek-ai/dsh-session/src/known-event-types.ts' +import { validateInstalledCurrentSessionArtifact } from '../src/current.ts' +import { Session, SessionId } from '@deepseek-ai/dsh-session' +import { createUserMessage } from '@deepseek-ai/dsh-llm' function deepFreeze(value: T): T { if (value !== null && typeof value === 'object') { @@ -11,6 +16,17 @@ function deepFreeze(value: T): T { } describe('first-party Session format catalog', () => { + it('supplies every required current message interpreter and validates its durable references', () => { + expect(currentSessionMessageProjections.map(projection => projection.type).sort()) + .toEqual([...MESSAGE_PROJECTION_EVENT_TYPES].sort()) + const session = Session.create(SessionId('projection-catalog')) + session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'no image' }], source: { kind: 'user' } }), { surfaceOp: 'append' }) + const artifact = { + header: session.header, inheritedEventCount: 0, + events: [...session.snapshotEvents(), { type: 'image/offload', seq: 1, time: 0, data: { targets: [{ seq: 0, imageIndexes: [0] }] } }], + } + expect(() => validateInstalledCurrentSessionArtifact(artifact)).toThrow(/image index 0 does not exist/) + }) it('statically owns the complete adjacent v0 to v3 chain', () => { const header = { type: 'session', diff --git a/packages/session/session-format-catalog/tsconfig.json b/packages/session/session-format-catalog/tsconfig.json index cba30e5c55..42a9cc2ff3 100644 --- a/packages/session/session-format-catalog/tsconfig.json +++ b/packages/session/session-format-catalog/tsconfig.json @@ -28,6 +28,9 @@ }, { "path": "../session-format-v2-to-v3" + }, + { + "path": "../../compaction/compaction-image-offload" } ] } diff --git a/packages/session/session-persistence-jsonl/src/generation.ts b/packages/session/session-persistence-jsonl/src/generation.ts index 1e1fd9aa0f..1e5ded6771 100644 --- a/packages/session/session-persistence-jsonl/src/generation.ts +++ b/packages/session/session-persistence-jsonl/src/generation.ts @@ -7,6 +7,7 @@ * @module @deepseek-ai/dsh-session-persistence-jsonl/generation */ +import { currentSessionMessageProjections } from '@deepseek-ai/dsh-session-format-catalog/message-projections' import { createHash, randomBytes } from 'node:crypto' import { link as fsLink, @@ -536,6 +537,7 @@ async function verifyCurrentGeneration( generation.meta, generation.inheritedEventCount, 'detached', + currentSessionMessageProjections, ) assertCurrentAssistantStreams(generation.events) return { diff --git a/packages/session/session-telemetry-otel/src/index.ts b/packages/session/session-telemetry-otel/src/index.ts index 0722845a3b..48792dbe67 100644 --- a/packages/session/session-telemetry-otel/src/index.ts +++ b/packages/session/session-telemetry-otel/src/index.ts @@ -259,7 +259,7 @@ export class OpenTelemetrySessionBackend extends SessionTelemetryBackend { if (committed === undefined) return const session = Session.fromRestore( snapshot.meta.id, snapshot.events, snapshot.meta, snapshot.inheritedEventCount, - 'detached', + 'detached', ctx.sessions.messageProjections, ) // fromRestore appends a lifecycle marker that this submission did not commit. if (isFeedback(session, committed)) coordinator.captureSession(session, committed.seq) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 979ef53ad8..a9846aad7e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2016,6 +2016,9 @@ importers: '@deepseek-ai/dsh-credentials': specifier: workspace:^ version: link:../../credentials/credentials + '@deepseek-ai/dsh-session-format-catalog': + specifier: workspace:^ + version: link:../../session/session-format-catalog '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery @@ -4688,6 +4691,9 @@ importers: '@deepseek-ai/dsh-compaction': specifier: workspace:^ version: link:../compaction + '@deepseek-ai/dsh-compaction-image-offload': + specifier: workspace:^ + version: link:../compaction-image-offload '@deepseek-ai/dsh-compaction-tool-result-pruner': specifier: workspace:^ version: link:../compaction-tool-result-pruner @@ -4714,6 +4720,10 @@ importers: version: link:../../core/tools packages/compaction/compaction-image-offload: + dependencies: + '@deepseek-ai/dsh-util-values': + specifier: workspace:^ + version: link:../../util/values devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -4773,6 +4783,9 @@ importers: '@deepseek-ai/dsh-compaction': specifier: workspace:^ version: link:../compaction + '@deepseek-ai/dsh-compaction-image-offload': + specifier: workspace:^ + version: link:../compaction-image-offload '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants @@ -7306,6 +7319,9 @@ importers: '@deepseek-ai/dsh-compaction': specifier: workspace:^ version: link:../../compaction/compaction + '@deepseek-ai/dsh-compaction-image-offload': + specifier: workspace:^ + version: link:../../compaction/compaction-image-offload '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../llm @@ -7908,6 +7924,10 @@ importers: version: 18.3.1 packages/session-query/session-query: + dependencies: + '@deepseek-ai/dsh-session-format-catalog': + specifier: workspace:^ + version: link:../../session/session-format-catalog devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -8071,6 +8091,9 @@ importers: packages/session/session-format-catalog: dependencies: + '@deepseek-ai/dsh-compaction-image-offload': + specifier: workspace:^ + version: link:../../compaction/compaction-image-offload '@deepseek-ai/dsh-session-format': specifier: workspace:^ version: link:../session-format diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index ed9767edda..a74e474d1f 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -353,6 +353,7 @@ export const LINK_MAP: Readonly> = { SessionSelectModelRequest: 'session.md', SessionSelectModelValue: 'session.md', SessionSummary: 'session.md', + SessionMessageProjection: 'session.md', SessionUpdateQueueRequest: 'session.md', SessionUpdateQueueValue: 'session.md', EncodedFileUploadRequest: 'attachment.md', diff --git a/scripts/gen-persistence-catalog.ts b/scripts/gen-persistence-catalog.ts index 9d78884475..5b9e99cdb3 100644 --- a/scripts/gen-persistence-catalog.ts +++ b/scripts/gen-persistence-catalog.ts @@ -61,6 +61,8 @@ const LINK_MAP: Record = { /** One log event, extracted from a `SessionEventMap` declaration. */ export interface LogEventEntry { + /** Whether interpretation requires a plugin-owned message projection. */ + messageProjection?: true /** Scoped name, e.g. `turn/start`. */ name: string /** The scope prefix, e.g. `turn` (everything before the first `/`). */ @@ -234,7 +236,10 @@ export function collectLogEvents(scanRoot: string = root): LogEventEntry[] { violations.push(`${where} has no description prose. Say what the event records and what its payload means — the JSDoc becomes the catalog entry.`) } const declaration = declarationText(text, sf, member) - entries.push({ name, scope: name.split('/')[0] ?? name, payload, declaration, doc, source: src }) + const messageProjection = ts.getJSDocTags(member).some(tag => tag.tagName.text === 'messageProjection') + entries.push({ name, scope: name.split('/')[0] ?? name, payload, declaration, doc, source: src, + ...messageProjection ? { messageProjection: true as const } : {}, + }) } } } @@ -331,6 +336,11 @@ export function annotateSurface(events: LogEventEntry[], surfaceTypes: string[]) throw new Error(`gen-persistence-catalog: SurfaceEventType member(s) ${stale.map(t => `'${t}'`).join(', ')} name no declared log event (stale union member?).`) } const surface = new Set(surfaceTypes) + for (const event of events) { + if (event.messageProjection && surface.has(event.name)) { + throw new Error(`gen-persistence-catalog: '${event.name}' cannot declare both a message projection and a surface operation`) + } + } return events.map(e => ({ ...e, surface: surface.has(e.name) })) } @@ -424,6 +434,11 @@ export function renderKnownEventTypes(events: AnnotatedLogEventEntry[]): string ...names.map(name => ` '${name}',`), '])', '', + '/** Event types whose model-visible effects require an explicit pure interpreter. */', + 'export const MESSAGE_PROJECTION_EVENT_TYPES: ReadonlySet = new Set([', + ...events.filter(event => event.messageProjection).map(event => ` '${event.name}',`).sort(), + '])', + '', ].join('\n') } diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 298c2154d9..75be248b8d 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -2,9 +2,9 @@ "comment": "Maps each primary ` ```ts type-equiv ` or ` ```ts public-api ` block (by doc + declared symbol + projection) to the source declaration and original JSDoc it must match. Paired `.zh.md` blocks are byte-identical derivatives checked through their unsuffixed sibling and have no duplicate entry. Omit projection for the complete declaration; use public-api with a ` ```ts public-api ` block for a body-stripped public class declaration. verify-type-equiv.ts enforces a 1:1 correspondence between primary blocks and entries. Add an entry when you add a primary source-equivalence block; remove it when you remove the block.", "entries": [ { - "doc": "docs/subsystems/session.md", + "doc": "docs/subsystems/compaction.md", "symbol": "ImageOffloadTarget", - "source": "packages/core/session/src/types.ts" + "source": "packages/compaction/compaction-image-offload/src/projection.ts" }, { "doc": "docs/subsystems/core.md", @@ -2115,6 +2115,16 @@ "doc": "packages/client/ui-conversation/README.md", "symbol": "ComposerChainProps", "source": "packages/client/ui-conversation/src/client/contract/slots.ts" + }, + { + "doc": "docs/subsystems/session.md", + "symbol": "SessionMessageProjection", + "source": "packages/core/session/src/surface.ts" + }, + { + "doc": "docs/subsystems/session.md", + "symbol": "SessionMessageProjectionContext", + "source": "packages/core/session/src/surface.ts" } ] } diff --git a/tsconfig.base.json b/tsconfig.base.json index 377d4ba351..8ed531a3f1 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -252,6 +252,8 @@ // session-log-export lives under session-query/, so the generated aliases map only its // bare name; the browser half needs this explicit subpath for source-mode dynamic imports. "@deepseek-ai/dsh-session-log-export/client": ["./packages/session-query/session-log-export/src/client"], + "@deepseek-ai/dsh-compaction-image-offload/projection": ["./packages/compaction/compaction-image-offload/src/projection.ts"], + "@deepseek-ai/dsh-session-format-catalog/message-projections": ["./packages/session/session-format-catalog/src/message-projections.ts"], // BEGIN generated package aliases — pnpm run gen-tsconfig-paths "@deepseek-ai/dsh-acp": ["./packages/acp/acp/src"], "@deepseek-ai/dsh-acp-app": ["./packages/bundle/acp-app/src"], From 1d4d22c1ff126b3c43fade32596ddf7bc9cda58a Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 11 Sep 2026 15:26:54 +0800 Subject: [PATCH 49/79] test: complete projection fixtures and configuration catalog --- docs/config-catalog.i18n.yaml | 4 ++-- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- packages/core/session/tests/message-projections.spec.ts | 7 +++++-- .../session/session-format-catalog/tests/catalog.spec.ts | 2 +- 5 files changed, 10 insertions(+), 7 deletions(-) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index e12f9b02c4..74fb981f67 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: ac456fd50f29d7efc2c30aea9420083a76911c61 -config-catalog.zh.md: 9e49687d376d16d481d4417f37704c7a8512250e +config-catalog.md: a9dfb42b9eb5d6b779c10a92ef96e51c078dd449 +config-catalog.zh.md: 4d5547f22d95b50e9d4a30988192ff0eeb58032d diff --git a/docs/config-catalog.md b/docs/config-catalog.md index ac456fd50f..a9dfb42b9e 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -3500,7 +3500,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-command-feedback` — requires `commands` ([`packages/feedback/command-feedback/src/index.ts`](../packages/feedback/command-feedback/src/index.ts)) - `@deepseek-ai/dsh-command-goal` — requires `commands` · `goals` ([`packages/goal/command-goal/src/index.ts`](../packages/goal/command-goal/src/index.ts)) - `@deepseek-ai/dsh-commands` ([`packages/interaction/commands/src/index.ts`](../packages/interaction/commands/src/index.ts)) -- `@deepseek-ai/dsh-compaction-image-offload` — requires `agents` ([`packages/compaction/compaction-image-offload/src/index.ts`](../packages/compaction/compaction-image-offload/src/index.ts)) +- `@deepseek-ai/dsh-compaction-image-offload` — requires `agents` · `sessions` ([`packages/compaction/compaction-image-offload/src/index.ts`](../packages/compaction/compaction-image-offload/src/index.ts)) - `@deepseek-ai/dsh-cordis-client-runner` ([`packages/extensions/cordis-client-runner/src/index.ts`](../packages/extensions/cordis-client-runner/src/index.ts)) - `@deepseek-ai/dsh-deepseek-llm-api-extensions` ([`packages/llm/deepseek-llm-api-extensions/src/index.ts`](../packages/llm/deepseek-llm-api-extensions/src/index.ts)) - `@deepseek-ai/dsh-experimental-client-ui-agent-team` ([`packages/experimental/client-ui-agent-team/src/index.ts`](../packages/experimental/client-ui-agent-team/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 9e49687d37..4d5547f22d 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -3502,7 +3502,7 @@ export interface Config { - `@deepseek-ai/dsh-command-feedback` — 需要 `commands`([`packages/feedback/command-feedback/src/index.ts`](../packages/feedback/command-feedback/src/index.ts)) - `@deepseek-ai/dsh-command-goal` — 需要 `commands` · `goals`([`packages/goal/command-goal/src/index.ts`](../packages/goal/command-goal/src/index.ts)) - `@deepseek-ai/dsh-commands`([`packages/interaction/commands/src/index.ts`](../packages/interaction/commands/src/index.ts)) -- `@deepseek-ai/dsh-compaction-image-offload`,需要 `agents`([`packages/compaction/compaction-image-offload/src/index.ts`](../packages/compaction/compaction-image-offload/src/index.ts)) +- `@deepseek-ai/dsh-compaction-image-offload`,需要 `agents` 和 `sessions`([`packages/compaction/compaction-image-offload/src/index.ts`](../packages/compaction/compaction-image-offload/src/index.ts)) - `@deepseek-ai/dsh-cordis-client-runner`([`packages/extensions/cordis-client-runner/src/index.ts`](../packages/extensions/cordis-client-runner/src/index.ts)) - `@deepseek-ai/dsh-deepseek-llm-api-extensions`([`packages/llm/deepseek-llm-api-extensions/src/index.ts`](../packages/llm/deepseek-llm-api-extensions/src/index.ts)) - `@deepseek-ai/dsh-experimental-client-ui-agent-team`([`packages/experimental/client-ui-agent-team/src/index.ts`](../packages/experimental/client-ui-agent-team/src/index.ts)) diff --git a/packages/core/session/tests/message-projections.spec.ts b/packages/core/session/tests/message-projections.spec.ts index e9943c1e1d..055dac5ff6 100644 --- a/packages/core/session/tests/message-projections.spec.ts +++ b/packages/core/session/tests/message-projections.spec.ts @@ -63,7 +63,10 @@ describe('plugin-owned message projections', () => { const ctx = new Context() contexts.push(ctx) await ctx.plugin(SessionStore) - const fiber = await ctx.plugin((owner) => { owner.sessions.registerMessageProjection(projection) }) + const fiber = await ctx.plugin({ + inject: ['sessions'], + apply(owner: Context) { owner.sessions.registerMessageProjection(projection) }, + }) expect(() => ctx.sessions.registerMessageProjection(projection)).toThrow(/already registered/) const live = ctx.sessions.create(SessionId('live')) const source = input(live) @@ -75,7 +78,7 @@ describe('plugin-owned message projections', () => { const child = ctx.sessions.fork(live) expect(child.deriveMessages()).toEqual(before) const restored = ctx.sessions.prepare(SessionId('restore'), { - seed: live.snapshotEvents(), meta: { ...live.header, id: SessionId('restore') }, + seed: [...live.snapshotEvents()], meta: { ...live.header, id: SessionId('restore') }, inheritedEventCount: live.inheritedEventCount, eventState: 'shared-frozen', }) expect(restored.deriveMessages()).toEqual(before) diff --git a/packages/session/session-format-catalog/tests/catalog.spec.ts b/packages/session/session-format-catalog/tests/catalog.spec.ts index a1f2abe167..fdb7b4927d 100644 --- a/packages/session/session-format-catalog/tests/catalog.spec.ts +++ b/packages/session/session-format-catalog/tests/catalog.spec.ts @@ -22,7 +22,7 @@ describe('first-party Session format catalog', () => { const session = Session.create(SessionId('projection-catalog')) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'no image' }], source: { kind: 'user' } }), { surfaceOp: 'append' }) const artifact = { - header: session.header, inheritedEventCount: 0, + header: { ...session.header, delegationDepth: 0 }, inheritedEventCount: 0, events: [...session.snapshotEvents(), { type: 'image/offload', seq: 1, time: 0, data: { targets: [{ seq: 0, imageIndexes: [0] }] } }], } expect(() => validateInstalledCurrentSessionArtifact(artifact)).toThrow(/image index 0 does not exist/) From 401c3ef9770978a64e78ca0ef41b5bb7dd4f0a57 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 11 Sep 2026 15:29:33 +0800 Subject: [PATCH 50/79] test: type the stored projection fixture as format events --- packages/session/session-format-catalog/tests/catalog.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/session/session-format-catalog/tests/catalog.spec.ts b/packages/session/session-format-catalog/tests/catalog.spec.ts index fdb7b4927d..e3cb8e5e98 100644 --- a/packages/session/session-format-catalog/tests/catalog.spec.ts +++ b/packages/session/session-format-catalog/tests/catalog.spec.ts @@ -23,7 +23,7 @@ describe('first-party Session format catalog', () => { session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'no image' }], source: { kind: 'user' } }), { surfaceOp: 'append' }) const artifact = { header: { ...session.header, delegationDepth: 0 }, inheritedEventCount: 0, - events: [...session.snapshotEvents(), { type: 'image/offload', seq: 1, time: 0, data: { targets: [{ seq: 0, imageIndexes: [0] }] } }], + events: [...session.snapshotEvents() as unknown as SessionFormatEvent[], { type: 'image/offload', seq: 1, time: 0, data: { targets: [{ seq: 0, imageIndexes: [0] }] } }], } expect(() => validateInstalledCurrentSessionArtifact(artifact)).toThrow(/image index 0 does not exist/) }) From 325c8d880abf22a3474fea7489f321a6e265b300 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 11 Sep 2026 15:35:55 +0800 Subject: [PATCH 51/79] build: admit pure message projection leaves in browser bundles --- packages/client/connection/package.json | 4 ++-- packages/client/tsdown.client.ts | 2 +- .../compaction/compaction-image-offload/package.json | 4 ++-- packages/session/session-format-catalog/package.json | 4 ++-- pnpm-lock.yaml | 6 +++--- scripts/client-bundle-purity.spec.ts | 9 +++++++++ 6 files changed, 19 insertions(+), 10 deletions(-) diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index b82ccee015..b16c83b1e9 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -36,8 +36,7 @@ "dependencies": { "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/schemastery": "workspace:^", - "zod": "^4.4.3", - "@deepseek-ai/dsh-session-format-catalog": "workspace:^" + "zod": "^4.4.3" }, "files": [ "lib/index.js", @@ -57,6 +56,7 @@ "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-format-catalog": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-tool-todo": "workspace:^", "@deepseek-ai/dsh-util-values": "workspace:^" diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index 7104855a97..7a6ee46d7c 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -58,7 +58,7 @@ function styleInjectionModule( * Everything else under @deepseek-ai/* is either a module-table entry * (external) or a leak the purity gate rejects. */ -export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:file-reference|session|llm|tools|brand|deque|output-retention|typert-protocol|util-crypto|util-values|util-workspace-path)(?:\/|$)|@deepseek-ai\/dsh-token-meter\/client$|@deepseek-ai\/dsh-host-open-in-app\/shared$|@deepseek-ai\/dsh-agent-presets\/display$|@deepseek-ai\/dsh-spill-policy\/notice$)/ +export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:file-reference|session|llm|tools|brand|deque|output-retention|typert-protocol|util-crypto|util-values|util-workspace-path)(?:\/|$)|@deepseek-ai\/dsh-token-meter\/client$|@deepseek-ai\/dsh-host-open-in-app\/shared$|@deepseek-ai\/dsh-agent-presets\/display$|@deepseek-ai\/dsh-spill-policy\/notice$|@deepseek-ai\/dsh-session-format-catalog\/message-projections$|@deepseek-ai\/dsh-compaction-image-offload\/projection$)/ /** * Vendored framework libraries: rescoped into @deepseek-ai, so the gate below diff --git a/packages/compaction/compaction-image-offload/package.json b/packages/compaction/compaction-image-offload/package.json index 89c8984885..a00b22b0d1 100644 --- a/packages/compaction/compaction-image-offload/package.json +++ b/packages/compaction/compaction-image-offload/package.json @@ -26,8 +26,8 @@ }, "files": [ "lib/index.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.js" + "lib/types/**/*.js", + "lib/types/**/*.d.ts" ], "license": "MIT", "peerDependencies": { diff --git a/packages/session/session-format-catalog/package.json b/packages/session/session-format-catalog/package.json index f9685053fd..a34af0786d 100644 --- a/packages/session/session-format-catalog/package.json +++ b/packages/session/session-format-catalog/package.json @@ -27,8 +27,8 @@ }, "files": [ "lib/index.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.js" + "lib/types/**/*.js", + "lib/types/**/*.d.ts" ], "license": "MIT", "dependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a9846aad7e..9bef58a6c4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2016,9 +2016,6 @@ importers: '@deepseek-ai/dsh-credentials': specifier: workspace:^ version: link:../../credentials/credentials - '@deepseek-ai/dsh-session-format-catalog': - specifier: workspace:^ - version: link:../../session/session-format-catalog '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery @@ -2053,6 +2050,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-format-catalog': + specifier: workspace:^ + version: link:../../session/session-format-catalog '@deepseek-ai/dsh-settings': specifier: workspace:^ version: link:../../settings/settings diff --git a/scripts/client-bundle-purity.spec.ts b/scripts/client-bundle-purity.spec.ts index 510de0b7cd..07c8f97228 100644 --- a/scripts/client-bundle-purity.spec.ts +++ b/scripts/client-bundle-purity.spec.ts @@ -110,6 +110,15 @@ describe('client bundle purity gate', () => { expect(() => resolveId('@deepseek-ai/dsh-spill-policy/notice/internal')).toThrow(/purity/) }) + it('admits only pure message projection entries, not recovery plugins or format codecs', () => { + expect(resolveId('@deepseek-ai/dsh-session-format-catalog/message-projections')).toBeNull() + expect(resolveId('@deepseek-ai/dsh-compaction-image-offload/projection')).toBeNull() + expect(() => resolveId('@deepseek-ai/dsh-session-format-catalog')).toThrow(/purity/) + expect(() => resolveId('@deepseek-ai/dsh-compaction-image-offload')).toThrow(/purity/) + expect(() => resolveId('@deepseek-ai/dsh-session-format-catalog/message-projections/internal')).toThrow(/purity/) + expect(() => resolveId('@deepseek-ai/dsh-compaction-image-offload/projection/internal')).toThrow(/purity/) + }) + it('lets exact generated Remote contributions inline without admitting their package implementation', () => { expect(resolveId('@deepseek-ai/dsh-goal/remote')).toBeNull() expect(() => resolveId('@deepseek-ai/dsh-goal')).toThrow(/purity/) From 4e7193945da43bbc59d9abc12d6f17b38f2e86eb Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 11 Sep 2026 15:38:04 +0800 Subject: [PATCH 52/79] fix: expose the projection registration async disposer --- docs/subsystems/session.md | 2 +- packages/core/session/src/index.ts | 2 +- packages/extensions/tool-cordis/src/api-catalog.ts | 2 +- packages/session/session-format-catalog/tests/catalog.spec.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index 4058d432d7..85d55e87fc 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -909,7 +909,7 @@ Persistence is intentionally not implemented here — the agent lifecycle attach * @returns the fiber-owned disposer. * @throws when another definition already owns this event type. */ -registerMessageProjection(projection: SessionMessageProjection): () => void +registerMessageProjection(projection: SessionMessageProjection): () => Promise /** * Create a session owned by the calling fiber: disposing that fiber stops diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index b7fe8f8568..548603f613 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -922,7 +922,7 @@ export class SessionStore extends Service { * @returns the fiber-owned disposer. * @throws when another definition already owns this event type. */ - registerMessageProjection(projection: SessionMessageProjection): () => void { + registerMessageProjection(projection: SessionMessageProjection): () => Promise { if (this.projections.some(item => item.type === projection.type)) { throw new Error(`session message projection "${projection.type}" is already registered`) } diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 05966710ba..4836cd2588 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1870,7 +1870,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ description: 'In-memory session store (`ctx.sessions`).\n\nPersistence is intentionally not implemented here — the agent lifecycle attaches a session-log writer to each published session\'s write handle; a session published outside that lifecycle persists nothing.', methods: [ { - signature: 'registerMessageProjection(projection: SessionMessageProjection): () => void', + signature: 'registerMessageProjection(projection: SessionMessageProjection): () => Promise', description: 'Register one event interpreter for live creation, restore, and fork. Disposing the contribution makes sessions that used it refuse further derivation.', parameters: [{ name: 'projection', description: 'pure definition owned by the event\'s plugin.' }], returns: 'the fiber-owned disposer.', diff --git a/packages/session/session-format-catalog/tests/catalog.spec.ts b/packages/session/session-format-catalog/tests/catalog.spec.ts index e3cb8e5e98..335f406e89 100644 --- a/packages/session/session-format-catalog/tests/catalog.spec.ts +++ b/packages/session/session-format-catalog/tests/catalog.spec.ts @@ -25,7 +25,7 @@ describe('first-party Session format catalog', () => { header: { ...session.header, delegationDepth: 0 }, inheritedEventCount: 0, events: [...session.snapshotEvents() as unknown as SessionFormatEvent[], { type: 'image/offload', seq: 1, time: 0, data: { targets: [{ seq: 0, imageIndexes: [0] }] } }], } - expect(() => validateInstalledCurrentSessionArtifact(artifact)).toThrow(/image index 0 does not exist/) + expect(() => { validateInstalledCurrentSessionArtifact(artifact) }).toThrow(/image index 0 does not exist/) }) it('statically owns the complete adjacent v0 to v3 chain', () => { const header = { From 653a4164b800c4d3bc8808e8be5e9271a74f5312 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 11 Sep 2026 15:38:19 +0800 Subject: [PATCH 53/79] docs: sync the projection disposer signature --- docs/subsystems/session.zh.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index f037b9b8a6..f78f7e7b8a 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -913,7 +913,7 @@ Persistence is intentionally not implemented here — the agent lifecycle attach * @returns the fiber-owned disposer. * @throws when another definition already owns this event type. */ -registerMessageProjection(projection: SessionMessageProjection): () => void +registerMessageProjection(projection: SessionMessageProjection): () => Promise /** * Create a session owned by the calling fiber: disposing that fiber stops From 93581008aadac5349d7fc07bf2f3a581b9dbc908 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 11 Sep 2026 15:40:44 +0800 Subject: [PATCH 54/79] docs: refresh the paired Session API record --- docs/subsystems/session.i18n.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index 5d45fca0b7..60e3575b80 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session.md -session.md: 4058d432d7d4a2bd0b7811b88af5f4b5d3c30271 -session.zh.md: f037b9b8a66f7221949d9a887a561aed2ba5b6f5 +session.md: 85d55e87fc547e5d56a433b41e6d32308e724d45 +session.zh.md: f78f7e7b8a54a5b1627077de07705e22a216dabd From fabbcf8c7616a335bbb21eec89a5fbb900f6bad6 Mon Sep 17 00:00:00 2001 From: turtle1999 <301751225+turtle1999@users.noreply.github.com> Date: Sat, 12 Sep 2026 17:38:12 +0800 Subject: [PATCH 55/79] test(ci): cover control requests and fix cross-volume fixtures --- packages/fs/fs-local/tests/fsio.spec.ts | 3 ++- .../subprocess-local/tests/linux-scope.spec.ts | 16 ++++++++++++---- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/fs/fs-local/tests/fsio.spec.ts b/packages/fs/fs-local/tests/fsio.spec.ts index be3bef5b5b..40ed836309 100644 --- a/packages/fs/fs-local/tests/fsio.spec.ts +++ b/packages/fs/fs-local/tests/fsio.spec.ts @@ -110,7 +110,8 @@ describe('resolveLocalTarget', () => { await mkdir(join(dir, 'nested')) await writeFile(join(dir, 'created.txt'), 'native') const drive = parse(dir).root.slice(0, 2) - const driveCwd = `${drive}${relative(process.cwd(), dir)}` + // Resolve the temp drive independently when the checkout lives on another volume. + const driveCwd = `${drive}${relative(resolve(drive), dir)}` for (const cwd of [dir, driveCwd]) { for (const requested of ['created.txt', `${drive}nested/../created.txt`]) { const target = await resolveLocalTarget(cwd, requested) diff --git a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts index 0f3c4c79ac..be351e4d74 100644 --- a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts +++ b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts @@ -1,3 +1,4 @@ +import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import { EventEmitter } from 'node:events' import { existsSync, rmSync, unlinkSync, writeFileSync } from 'node:fs' import { PassThrough } from 'node:stream' @@ -42,6 +43,8 @@ class FakeChild extends EventEmitter { stdin = new PassThrough() stdout = new PassThrough() stderr = new PassThrough() + control = new PassThrough() + stdio = [this.stdin, this.stdout, this.stderr, null, null, null, null, this.control] kills: NodeJS.Signals[] = [] kill(signal: NodeJS.Signals): boolean { @@ -111,6 +114,7 @@ function spec() { function launch( query: LinuxScopeInternals['systemctlQuery'], overrides: LinuxScopeInternals = {}, + request: SubprocessSpawnSpec = spec(), ) { const child = new FakeChild() let options: { env?: NodeJS.ProcessEnv; cwd?: string; detached?: boolean } | undefined @@ -120,7 +124,7 @@ function launch( }) const spawnSync = vi.fn(() => ({ status: 0, stdout: '', stderr: '' })) const systemctlQuery = overrides.systemctlQuery ?? query - const result = launchLinuxScope(spec(), { TARGET: 'yes' }, { + const result = launchLinuxScope(request, { TARGET: 'yes' }, { spawn: overrides.spawn ?? spawn as never, spawnSync: overrides.spawnSync ?? spawnSync as never, ...systemctlQuery === undefined ? {} : { systemctlQuery }, @@ -255,10 +259,14 @@ describe('Linux scope establishment and quiescence', () => { result.owner.cleanup?.() }) - it('accepts request consumption followed by rapid --collect unload as stopped', async () => { + it.each([undefined, 'pipe'] as const)('accepts request consumption and rapid --collect unload with control %s', async (control) => { const states = [activeUnit(), unloadedUnit()] - const { child, result, requestPath } = launch(async () => states.shift() ?? missingUnit()) - expect(consumeLinuxLaunchRequest(requestPath)).toEqual({ cwd: '/target', env: { TARGET: 'yes' } }) + const controlOptions = control === undefined ? {} : { control } + const { child, result, requestPath } = launch(async () => states.shift() ?? missingUnit(), {}, { + ...spec(), stdio: { ...spec().stdio, ...controlOptions }, + }) + expect(consumeLinuxLaunchRequest(requestPath)).toEqual({ cwd: '/target', env: { TARGET: 'yes' }, ...controlOptions }) + expect(result.control).toBe(control === undefined ? undefined : child.control) const waiting = result.owner.waitForExit() child.exit(0, null) await expect(result.direct).resolves.toEqual({ exitCode: 0, signal: null }) From c8370240a8f9bcccf287f5337d17ca1cc2903db5 Mon Sep 17 00:00:00 2001 From: turtle1999 <301751225+turtle1999@users.noreply.github.com> Date: Sun, 13 Sep 2026 00:42:12 +0800 Subject: [PATCH 56/79] test(ci): pin gallery time and dispose projection cache fixtures --- apps/web/tests/clickable-links-gallery.e2e.ts | 8 +++++--- packages/subagent/subagent/tests/list-children.spec.ts | 5 ++++- .../subprocess/subprocess-local/tests/linux-scope.spec.ts | 1 - 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/web/tests/clickable-links-gallery.e2e.ts b/apps/web/tests/clickable-links-gallery.e2e.ts index 081d183c5b..48285d1c65 100644 --- a/apps/web/tests/clickable-links-gallery.e2e.ts +++ b/apps/web/tests/clickable-links-gallery.e2e.ts @@ -45,6 +45,8 @@ const OVERLAY = fileURLToPath(new URL('./produced-files.overlay.yml', import.met const MODE = webSnapshotMode() const SEED_ID = 'clickable-links-gallery-web-e2e' const DONE = 'LINK_GALLERY_DONE' +// Seeded events and browser time share a day independently of host timezones. +const GALLERY_TIME = Date.UTC(2026, 0, 15, 12) const GUIDE_URL = 'https://docs.example.test/guide' const API_URL = 'https://docs.example.test/api' @@ -192,7 +194,6 @@ const CALLS: GalleryCall[] = [ */ function galleryFixture(imageUrl: string): string { const session = Session.create(SessionId('clickable-links-gallery-source')) - const eventTimeOrigin = new Date().setHours(12, 0, 0, 0) session.append('turn/start', { turn: 1 }) const user = session.append('user/message', createUserMessage({ content: text('Assemble the link gallery: write the report and styles, inspect the sources, and summarize.'), @@ -289,7 +290,7 @@ function galleryFixture(imageUrl: string): string { }), ...session.snapshotEvents().map(event => JSON.stringify({ ...event, - time: eventTimeOrigin + event.seq * 1_000, + time: GALLERY_TIME + event.seq * 1_000, })), '', ].join('\n') @@ -305,9 +306,10 @@ describe('web e2e: clickable links gallery', () => { beforeAll(async () => { scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY }) imageUrl = new URL('/favicon.svg', scaffold.baseUrl).toString() - await seedSession(scaffold, galleryFixture(imageUrl), SEED_ID) + await seedSession(scaffold, galleryFixture(imageUrl), SEED_ID, undefined, { createdAt: GALLERY_TIME }) browser = await chromium.launch() page = await newEnglishPage(browser) + await page.clock.setFixedTime(GALLERY_TIME + 60_000) tripwire = watchConsole(page) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) diff --git a/packages/subagent/subagent/tests/list-children.spec.ts b/packages/subagent/subagent/tests/list-children.spec.ts index 6d83648d23..2794f825bd 100644 --- a/packages/subagent/subagent/tests/list-children.spec.ts +++ b/packages/subagent/subagent/tests/list-children.spec.ts @@ -35,10 +35,12 @@ import { seedStoredSession } from './persistence-helpers.ts' type Script = ConstructorParameters[0] const roots: string[] = [] +const projectionCacheDisposers: Array<() => Promise> = [] const persistenceDisposers: Array<() => Promise> = [] const projCacheRoots: string[] = [] afterEach(async () => { + await Promise.all(projectionCacheDisposers.splice(0).map(dispose => dispose())) await Promise.all(persistenceDisposers.splice(0).map(dispose => dispose())) for (const root of projCacheRoots.splice(0)) rmSync(root, { recursive: true, force: true }) for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }) @@ -64,7 +66,8 @@ async function setup( await ctx.plugin(Storage) await ctx.plugin({ name: storageJsonName, inject: storageJsonInject, apply: storageJsonApply, Config: storageJsonConfig }, { root }) await ctx.plugin({ name: storageDomainName, inject: storageDomainInject, apply: storageDomainApply, Config: storageDomainConfig }, { backend: 'json' }) - await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 }) + const cache = await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 }) + projectionCacheDisposers.push(() => cache.dispose()) } await ctx.plugin(TestSessionQuery) await ctx.plugin(SubagentRuntime) diff --git a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts index 6b5cd534dc..8aea04d9f8 100644 --- a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts +++ b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts @@ -1,4 +1,3 @@ -import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import { EventEmitter } from 'node:events' import { existsSync, rmSync, unlinkSync, writeFileSync } from 'node:fs' import { PassThrough } from 'node:stream' From a475a65bed54537903c3f87e4afb84b01dfc4329 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 11 Sep 2026 02:14:03 +0800 Subject: [PATCH 57/79] fix(client-test-runtime): isolate client instances --- ...7-23-client-plugin-loading-model.i18n.yaml | 4 +- .../2026-07-23-client-plugin-loading-model.md | 4 +- ...26-07-23-client-plugin-loading-model.zh.md | 4 +- ...ient-shells-and-dynamic-packages.i18n.yaml | 4 +- ...8-15-client-shells-and-dynamic-packages.md | 2 +- ...5-client-shells-and-dynamic-packages.zh.md | 2 +- ...-09-06-client-assembly-test-line.i18n.yaml | 4 +- .../2026-09-06-client-assembly-test-line.md | 10 +- ...2026-09-06-client-assembly-test-line.zh.md | 10 +- packages/client/connection/README.i18n.yaml | 4 +- packages/client/connection/README.md | 2 +- packages/client/connection/README.zh.md | 2 +- .../client/connection/src/client/index.ts | 52 ++++++-- packages/client/modules/README.i18n.yaml | 4 +- packages/client/modules/README.md | 2 +- packages/client/modules/README.zh.md | 2 +- packages/client/modules/src/client/index.ts | 24 ++-- .../modules/tests/loader.client.spec.ts | 20 ++- .../client-runtime/README.i18n.yaml | 4 +- .../test-support/client-runtime/README.md | 6 +- .../test-support/client-runtime/README.zh.md | 6 +- .../src/assembly/test-client.ts | 122 +++++++----------- .../assembly-test-client-node.client.spec.ts | 12 +- .../tests/assembly-test-client.client.spec.ts | 70 ++++++++-- .../test-support/remote-mock/README.i18n.yaml | 4 +- packages/test-support/remote-mock/README.md | 6 +- .../test-support/remote-mock/README.zh.md | 6 +- .../test-support/remote-mock/src/index.ts | 2 +- .../remote-mock/src/remote-mock.ts | 3 +- 29 files changed, 229 insertions(+), 168 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml index f0da51ef73..07c15ff846 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md -2026-07-23-client-plugin-loading-model.md: c5576b148c5991dd498d3aed605e3c2e3395774b -2026-07-23-client-plugin-loading-model.zh.md: c7d6982c2680995bd4698ddbff052a7708f69997 +2026-07-23-client-plugin-loading-model.md: d0f9b20f0adabda6cc7132e5411bcadd60c9e108 +2026-07-23-client-plugin-loading-model.zh.md: 27d5026ed03509d6408a16389246cb1321a37959 diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md index c5576b148c..d0f9b20f0a 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md @@ -58,12 +58,12 @@ What happens between `dsh web` starting and the UI appearing? Three stages: the Why is the roster yml rows and not a scan? Because which plugins compose into a deployment is a composition decision, not a package property — a package declaring `dsh.client` in the repo does not mean this deployment mounts it, so discovery-by-scan cannot make that call; the node half scans only what the tree actually mounted. -**Phase one — the module face.** The injected HTML installs `window.__ModuleLoader__` in queue mode, starts preloading every application combo URL, executes every bootstrap combo URL as a blocking classic script, assigns `window.__DSH_BOOT__`, and then starts the Vite main module. The kernel calls the facade's `create()` with the raw graph and shell seeds. The facade removes and materializes the modules registration with a bootstrap `require` that rejects every external, then calls its `createClientModuleSystem` export. The modules bundle parses the graph, constructs the system, memoizes its own exports, retains the instance in its module closure, and switches the same facade to live registration. The kernel then prefetches every `immediately` row in parallel. Rows in the same application combo share its execution; separate combos load independently when an immediate row, a requested dependency, or ordinary entry import reaches them. A prefetch failure is swallowed here because phase two's import retries and owns the loud failure. `immediately` remains a registration barrier, not a package identity. +**Phase one — the module face.** The injected HTML installs `window.__ModuleLoader__` in queue mode, starts preloading every application combo URL, executes every bootstrap combo URL as a blocking classic script, assigns `window.__DSH_BOOT__`, and then starts the Vite main module. The kernel calls the facade's `create()` with the raw graph and shell seeds. The facade removes and materializes the modules registration with a bootstrap `require` that rejects every external, then calls its `createClientModuleSystem` export. The modules bundle parses the graph, constructs and returns the system, memoizes its own exports, and switches the same facade to live registration. The kernel then prefetches every `immediately` row in parallel. Rows in the same application combo share its execution; separate combos load independently when an immediate row, a requested dependency, or ordinary entry import reaches them. A prefetch failure is swallowed here because phase two's import retries and owns the loud failure. `immediately` remains a registration barrier, not a package identity. **Phase two — the plugin face.** 1. The kernel mounts the vendored Loader and injects the module system as `internal` before any entry exists. Ordering matters: `tree.import`'s bare-import fallback must never run in a browser. -2. It creates every graph row uniformly. Importing the modules row returns the memoized bootstrap exports, whose `apply()` provides the closed-over system as `ctx.modules`; rows that require that service remain PENDING until then, so the modules row needs no special creation position. Render assembly is an ordinary host-graph row provided by `dsh-client-ui-renderer`; the kernel appends no assembly pseudo-entry. +2. It creates every graph row uniformly. Importing the modules row returns the memoized bootstrap exports, whose `apply()` reads that tree's `Loader.internal` and provides the same instance as `ctx.modules`; rows that require that service remain PENDING until then, so the modules row needs no special creation position. Render assembly is an ordinary host-graph row provided by `dsh-client-ui-renderer`; the kernel appends no assembly pseudo-entry. 3. Graph order governs synchronous factory availability; Cordis activation remains independent and proceeds through service waiting. 4. `settled` = every entry created + `loader.await()` quiescent + an all-ACTIVE sweep. The sweep lists each import-failed, FAILED, or PENDING fiber with its missing services. It exists because cordis inject waits have no timeout — the sweep is the fail-loud floor. 5. The framework-free loading page projects real fiber states via `internal/status`. After the sweep, the kernel calls `ctx.uiRenderer.mount(container)` and replaces the page with the real UI in one pass. diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md index c7d6982c26..27d5026ed0 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md @@ -58,12 +58,12 @@ Host 会快照每个已构建插件产物,并把每个调度阶段的有序 ro 为什么名册是 yml 行而不是扫描?因为哪些插件组合进一次部署是组合决策,不是包属性——一个在仓库中声明了 dsh.client 的包,不代表这次部署要挂载它,扫描发现无从替人做这个决定;node 半只扫描配置树实际挂载了的东西。 -**第一阶段——模块面。**注入的 HTML 以 queue 模式安装 `window.__ModuleLoader__`,开始预加载所有 application combo URL,以阻塞式 classic script 依次执行所有 bootstrap combo URL,赋值 `window.__DSH_BOOT__`,然后启动 Vite 主模块。内核把原始图和外壳 seed 传给 facade 的 `create()`。Facade 移除 modules registration,用拒绝全部 external 的 bootstrap `require` 将其物化,再调用其 `createClientModuleSystem` 导出。Modules bundle 解析图、构造系统、记忆化自身 exports、在模块闭包中保留该实例,并把同一 facade 切换到 live registration。随后内核并行预取每个 `immediately` row;同一 application combo 中的 row 共享一次执行,不同 combo 会在 immediate row、被请求依赖或普通 entry import 首次触及时独立加载。预取失败在这里被吞下,因为第二阶段 import 会重试并拥有那次大声失败。`immediately` 仍是 registration barrier,不是包身份。 +**第一阶段——模块面。**注入的 HTML 以 queue 模式安装 `window.__ModuleLoader__`,开始预加载所有 application combo URL,以阻塞式 classic script 依次执行所有 bootstrap combo URL,赋值 `window.__DSH_BOOT__`,然后启动 Vite 主模块。内核把原始图和外壳 seed 传给 facade 的 `create()`。Facade 移除 modules registration,用拒绝全部 external 的 bootstrap `require` 将其物化,再调用其 `createClientModuleSystem` 导出。Modules bundle 解析图、构造并返回系统、记忆化自身 exports,并把同一 facade 切换到 live registration。随后内核并行预取每个 `immediately` row;同一 application combo 中的 row 共享一次执行,不同 combo 会在 immediate row、被请求依赖或普通 entry import 首次触及时独立加载。预取失败在这里被吞下,因为第二阶段 import 会重试并拥有那次大声失败。`immediately` 仍是 registration barrier,不是包身份。 **第二阶段——插件面。** 1. 内核挂载 vendored Loader,在任何 entry 存在之前就把模块系统注入为 `internal`。顺序有讲究:`tree.import` 的裸 import 兜底分支在浏览器里绝不能跑到。 -2. 它统一创建每个 graph row。Import modules row 会返回记忆化的 bootstrap exports,其 `apply()` 把闭包中的系统提供为 `ctx.modules`;需要该 service 的 row 会保持 PENDING 直至此时,因此 modules row 无需特殊创建位置。渲染组装是由 `dsh-client-ui-renderer` 提供的普通 host graph row;内核不追加组装伪 entry。 +2. 它统一创建每个 graph row。Import modules row 会返回记忆化的 bootstrap exports,其 `apply()` 读取该树的 `Loader.internal`,把同一个实例提供为 `ctx.modules`;需要该 service 的 row 会保持 PENDING 直至此时,因此 modules row 无需特殊创建位置。渲染组装是由 `dsh-client-ui-renderer` 提供的普通 host graph row;内核不追加组装伪 entry。 3. Graph 顺序治理同步 factory 可用性;Cordis 激活与之独立,仍经服务等待推进。 4. `settled` = 每个 entry 已创建 + `loader.await()` 完全停稳 + 一次全 ACTIVE 扫描。扫描列出每个 import 失败、FAILED 或 PENDING 的 fiber 及其缺失的服务。它存在的理由:cordis 的 inject 等待没有超时——这次扫描就是大声失败的兜底线。 5. 不依赖框架的 loading 页经 `internal/status` 投影真实 fiber 状态。检查完成后,内核调用 `ctx.uiRenderer.mount(container)`,一次切换到真实 UI。 diff --git a/.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.i18n.yaml index 9898d344d1..d9bd4a9c76 100644 --- a/.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.md -2026-08-15-client-shells-and-dynamic-packages.md: 1d67c778b6a06849324dd6095a98d57dc41b94f9 -2026-08-15-client-shells-and-dynamic-packages.zh.md: db1e4e7e7b319c283ae39a94535d88d4dc71d60a +2026-08-15-client-shells-and-dynamic-packages.md: 89f89784512b86b70ee1b9460850e6f2f951a082 +2026-08-15-client-shells-and-dynamic-packages.zh.md: 5512d9262e979a94a65c25a6c1271b030e23ec70 diff --git a/.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.md b/.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.md index 1d67c778b6..89f8978451 100644 --- a/.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.md +++ b/.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.md @@ -51,7 +51,7 @@ The modules Node half injects the startup protocol into the served HTML in this 4. Assign `window.__DSH_BOOT__`, including all scheduling descriptors and every row's one-resource HMR combo URL. 5. Execute the Vite main module. -The bootstrap combo currently registers only the modules factory. The startup kernel passes the raw graph and shell seeds to `__ModuleLoader__.create()`. The facade removes the modules registration, materializes it with a `require` function that rejects every external, and invokes its `createClientModuleSystem` export. The modules bundle parses the graph, constructs `ClientModuleSystem`, caches its own exports as the modules row, retains the system in a module closure, and switches the same facade to live mode. The modules client face consequently has a zero-external bootstrap requirement. +The bootstrap combo currently registers only the modules factory. The startup kernel passes the raw graph and shell seeds to `__ModuleLoader__.create()`. The facade removes the modules registration, materializes it with a `require` function that rejects every external, and invokes its `createClientModuleSystem` export. The modules bundle parses the graph, constructs and returns `ClientModuleSystem`, caches its own exports as the modules row, and switches the same facade to live mode. The kernel installs that instance as its Loader's `internal`, and the modules plugin reads it there when it provides `ctx.modules`. The modules client face consequently has a zero-external bootstrap requirement and no module-global system identity. After the `immediately` tier has registered its factories, the kernel creates all Loader entries, awaits Cordis quiescence, and requires every fiber to be ACTIVE. It then calls `ctx.uiRenderer.mount(container)`. The dynamic `ui-renderer` package owns React, slot rendering, hydration of the existing boot DOM, and the React root lifecycle; the startup kernel and failure page remain React-free. diff --git a/.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.zh.md b/.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.zh.md index db1e4e7e7b..5512d9262e 100644 --- a/.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.zh.md @@ -51,7 +51,7 @@ Modules Node 半按以下顺序向实际返回的 HTML 注入启动协议: 4. 赋值 `window.__DSH_BOOT__`,其中包含全部调度描述及每个 row 的单资源 HMR combo URL。 5. 执行 Vite 主模块。 -Bootstrap combo 当前只登记 modules factory。启动内核把原始图与外壳 seed 传给 `__ModuleLoader__.create()`。Facade 移除 modules registration,用拒绝全部 external 的 `require` 函数将其物化,再调用其 `createClientModuleSystem` 导出。Modules bundle 解析图、构造 `ClientModuleSystem`、把自身 exports 缓存为 modules row、在模块闭包中保留该系统,并把同一 facade 切换到 live 模式。因此 modules client face 必须满足零 external 的自举要求。 +Bootstrap combo 当前只登记 modules factory。启动内核把原始图与外壳 seed 传给 `__ModuleLoader__.create()`。Facade 移除 modules registration,用拒绝全部 external 的 `require` 函数将其物化,再调用其 `createClientModuleSystem` 导出。Modules bundle 解析图、构造并返回 `ClientModuleSystem`、把自身 exports 缓存为 modules row,并把同一 facade 切换到 live 模式。内核把该实例装成自身 Loader 的 `internal`,modules 插件从这里读取并提供 `ctx.modules`。因此 modules client face 保持零 external 的自举要求,也没有模块级系统身份。 `immediately` 层级完成 factory 注册后,内核创建全部 Loader entry,等待 Cordis 静止,并要求每个 fiber 都进入 ACTIVE。随后调用 `ctx.uiRenderer.mount(container)`。动态 `ui-renderer` 包拥有 React、slot 渲染、已有启动 DOM 的 hydrate 和 React root 生命周期;启动内核与失败页保持 React-free。 diff --git a/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.i18n.yaml b/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.i18n.yaml index e5675ab2e6..5b40ff8642 100644 --- a/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.md -2026-09-06-client-assembly-test-line.md: 0d7ed86e4f0c8898441737c8d6a4c631586268a2 -2026-09-06-client-assembly-test-line.zh.md: 8b4381dbfa24819e1b1f086b7c318d388b681ef5 +2026-09-06-client-assembly-test-line.md: 36f7e2ab11248628dca7aff53cae4a9c9f84e441 +2026-09-06-client-assembly-test-line.zh.md: 0ef64a97e25b062a858ddbd1796f86e59591cd51 diff --git a/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.md b/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.md index 0d7ed86e4f..36f7e2ab11 100644 --- a/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.md +++ b/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.md @@ -18,7 +18,9 @@ A whole-client tier lives in `@deepseek-ai/dsh-client-test-runtime` under the de **The roster is read from the bundles, never copied.** `bundleRoster(bundles)` parses each bundle's `dsh.bundle.patch` with the include plugin's own YAML dialect (`entryListSchema`, which carries `!!js`) and composes the layers with its `applyEntryPatches`, then keeps every enabled row whose package declares `dsh.client.platform === 'web'`, carrying that declaration's `inject` and `immediately`. `webApp` is the `web` profile's roster (`dsh-base`, then `dsh-web-app`), computed at import. A spec names what it tests and derives the rest: `webApp.closure([row])` keeps a row plus its transitive `inject` cone; `pick` and `without` exist for deliberate cuts. The test runtime stays a Client-face package: it imports no Host module, uses no dynamic import for this, and its client-face `types` adds `node` beside `client-build-environment` so the reader can use `node:fs`. `closure` treats the shell's static platform modules (`PLATFORM_MODULES`) as satisfied without a row. -**The production boot path runs unchanged.** `TestClient.start(plan, mock)` installs the mock as the Connection carrier (`__DSH_TRANSPORT__ = { rpc: mock.rpc }`), loads each roster row's `/client` module, registers its factory through the production module facade's `pendingQueue`, boots through `bootClient`, optionally mounts through `mountClient`, and waits for `connected`. `reload(name)` rebuilds a Loader entry through client-hmr's exported `tearDownEntryFiber`; `unload(name)` removes it; `dispose()` tears down and then fails the test on any endpoint that had no rule. jsdom lacks `EventSource` and `ResizeObserver`; `start` installs inert stand-ins only where the global is absent. Boots and entry rebuilds run one at a time per worker, since the `connection` plugin reads the transport global at apply, and each installs the acting client's transport first; the transport and shims are held by reference count, the first client installing them and the last dispose restoring them, so overlapping clients in one test each connect to their own mock, also after a `reload` of the `connection` row. +**The production boot path runs unchanged.** `TestClient.start(plan, mock)` loads each roster row's `/client` module, binds the Connection row to `installConnection(ctx, { transport: { rpc: mock.rpc } })`, registers the resulting factories through the production module facade's `pendingQueue`, boots through `bootClient`, optionally mounts through `mountClient`, and waits for `connected`. `reload(name)` rebuilds a Loader entry through client-hmr's exported `tearDownEntryFiber`; `unload(name)` removes it; `dispose()` tears down and then fails the test on any endpoint that had no rule. jsdom lacks `EventSource` and `ResizeObserver`; `start` reference-counts inert stand-ins only where the global is absent. Module systems, Connection carriers, Remote mocks, reloads, and disposal are instance-owned, so clients start concurrently and do not coordinate through page globals. + +The modules plugin reads the module system from its own `ctx.loader.internal` when it activates. Reloading the bootstrap row republishes that same instance without invalidating its bootstrap code, while another client's Loader and `ctx.modules` remain independent. **`remote.` is a contract-free proxy, not the generated client.** The `@deepseek-ai/dsh-api-remotes` row is dropped because its generated clients exist only in built `lib/`. For every `remote.` a roster row injects, plus every namespace the mock has a rule for, the tier provides one Proxy: `ctx.remote..(...args)` calls the endpoint `/` over the roster's own Connection with the positional args, as a stream when the mock registered a stream script for it and as a unary call otherwise. Cordis resolves `ctx.remote.` to the service `remote.`, so the Gateway client itself is untouched. A unary answer returns unchanged; a unary rejection folds the way the generated client folds a carrier throw, through the Gateway client's exported `carrierFailure` and `cancelledFailure`, so product code that fires a Remote call without awaiting sees no rejection. Stream items and failures pass through as the stream yields them. @@ -32,7 +34,7 @@ A whole-client tier lives in `@deepseek-ai/dsh-client-test-runtime` under the de ## Product exports added for the tier -- `client/connection`: `ClientTransportHooks.rpc?` publishes the already decoded carrier the `?fixture` path used internally; `fetch` becomes optional. +- `client/connection`: `ClientTransportHooks.rpc?` publishes the already decoded carrier the `?fixture` path used internally; `fetch` becomes optional. `installConnection(ctx, options)` installs the same production service from instance-local transport, recovery, and location inputs. - `client/hmr`: `tearDownEntryFiber(entry)` is the registry-first fiber teardown `reload` already performed. - `client/modules`: `parseDshClient` and `exactPackageSpecifier` are exported from the client face and shared by the Host and roster reader. Test factories use the existing registration queue. The roster-to-boot-graph synthesis has only test consumers and lives in the tier. - `client/web`: `bootClient` and `mountClient` are extracted from `AppWebEntry.run()`, which now calls them. @@ -48,7 +50,7 @@ A whole-client tier lives in `@deepseek-ai/dsh-client-test-runtime` under the de **A hand-written test-side YAML and patch parser.** Rejected: `entryListSchema` and `applyEntryPatches` are the launcher's own and carry no Host Context merge; the tier writes only file reading, package.json location, and the web-row filter. -**A mock module standing in for the Gateway client.** Rejected: the mock must not interfere with Gateway internals; installing it on the Connection carrier keeps retry, folding, and stream semantics real. +**A mock module standing in for the Gateway client.** Rejected: the mock must not interfere with Gateway internals; passing it to the production Connection installer keeps retry, folding, and stream semantics real. **A second typed Gateway implementation with an `Api` generic, envelope and error classes, and a fixtures directory.** Rejected: it duplicates Gateway declarations and encoding. The mock derives method types from the generated namespace map and declares only unary or stream behavior at runtime. @@ -87,4 +89,4 @@ Product facts the tier surfaced and leaves as they are: ## Testing -`packages/test-support/remote-mock/tests/` covers rules, streams, the log, and the carrier face; the `assembly-` specs under `packages/test-support/client-runtime/tests/` cover the roster reader on the real bundles and on a scratch installation, module loading, the proxies including their fold, and `TestClient` under jsdom and plain Node. Seven converted specs use the tier. In `packages/client/ui-settings-general/tests/`, the shell and apply specs boot the whole `web` roster; the apply spec reads its Chinese copy from the Host settings document the mock answers and reconfigures the jsdom page URL for the off-loopback branch. In `packages/api/session-controller/tests/`, the Session, queue-store, and pending-submission specs drive their objects over the roster's real Connection through the `remote.` proxies, with the Gateway client's own `$stream` retry loop, over the gateway's dependency cone, and the client-apply spec boots the plugin's dependency cone, delivering Remote events as emit frames on `$events`. In `packages/api/workspace-controller/tests/`, the transport spec boots the plugin's cone for apply cases and the gateway cone for hand-built stream and controller cases, since a rostered plugin would share the follow endpoint. Each package keeps a `tests/remote/` module with its default responses and frame builders. The fixture tests include an expected assertion failure and independently observe completed client cleanup; settings reload tests observe replaced registration identities, and write tests assert every mutation argument. Teardown-failure tests execute the real tree disposer before reporting the injected failure and observe the `$events` stream's cancelled state. +`packages/test-support/remote-mock/tests/` covers rules, streams, the log, and the carrier face; the `assembly-` specs under `packages/test-support/client-runtime/tests/` cover the roster reader on the real bundles and on a scratch installation, module loading, the proxies including their fold, parallel client startup, instance-local module and Connection reload, and `TestClient` under jsdom and plain Node. Seven converted specs use the tier. In `packages/client/ui-settings-general/tests/`, the shell and apply specs boot the whole `web` roster; the apply spec reads its Chinese copy from the Host settings document the mock answers and reconfigures the jsdom page URL for the off-loopback branch. In `packages/api/session-controller/tests/`, the Session, queue-store, and pending-submission specs drive their objects over the roster's real Connection through the `remote.` proxies, with the Gateway client's own `$stream` retry loop, over the gateway's dependency cone, and the client-apply spec boots the plugin's dependency cone, delivering Remote events as emit frames on `$events`. In `packages/api/workspace-controller/tests/`, the transport spec boots the plugin's cone for apply cases and the gateway cone for hand-built stream and controller cases, since a rostered plugin would share the follow endpoint. Each package keeps a `tests/remote/` module with its default responses and frame builders. The fixture tests include an expected assertion failure and independently observe completed client cleanup; settings reload tests observe replaced registration identities, and write tests assert every mutation argument. Teardown-failure tests execute the real tree disposer before reporting the injected failure and observe the `$events` stream's cancelled state. diff --git a/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.zh.md b/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.zh.md index 8b4381dbfa..0ef64a97e2 100644 --- a/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.zh.md +++ b/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.zh.md @@ -18,7 +18,9 @@ API 客户端 spec 用一个可编程的 Remote 面假件驱动对象。这个 **roster 从 bundle 现读,绝不拷贝。** `bundleRoster(bundles)` 用 include 插件自己的 YAML 方言(带 `!!js` 的 `entryListSchema`)解析每个 bundle 的 `dsh.bundle.patch`,用它的 `applyEntryPatches` 合成各层,再保留每个未禁用且其包声明 `dsh.client.platform === 'web'` 的行,带上该声明的 `inject` 与 `immediately`。`webApp` 是 `web` profile 的 roster(先 `dsh-base`、再 `dsh-web-app`),import 时算出。spec 点名它要测的东西,其余推导:`webApp.closure([row])` 保留一行及其传递 `inject` 锥;`pick` 与 `without` 留给刻意裁剪。测试运行时仍是 Client 面的包:它不 import 任何 Host 模块,此处不用动态 import,其 client 面的 `types` 在 `client-build-environment` 之外加了 `node`,好让读取器使用 `node:fs`。`closure` 把 shell 静态种入的平台模块(`PLATFORM_MODULES`)视为无需行即已满足。 -**生产启动路径原样运行。** `TestClient.start(plan, mock)` 把 mock 装成 Connection 载体(`__DSH_TRANSPORT__ = { rpc: mock.rpc }`),加载每个 roster 行的 `/client` 模块,经生产模块 facade 的 `pendingQueue` 登记其工厂,经 `bootClient` 启动,可选经 `mountClient` 挂载,然后等 `connected`。`reload(name)` 经 client-hmr 导出的 `tearDownEntryFiber` 重建一个 Loader entry;`unload(name)` 移除它;`dispose()` 拆掉一切,然后对任何没有规则的端点让测试失败。jsdom 没有 `EventSource` 与 `ResizeObserver`;`start` 只在全局缺失处装惰性替身。每个 worker 内启动与 entry 重建逐个进行,因为 `connection` 插件在 apply 时读传输全局,每次都先装上当事客户端的传输;传输与替身按引用计数持有,第一个客户端安装、最后一次 dispose 恢复,因此同一测试里重叠的客户端各连各的 mock,`reload` 了 `connection` 行之后也是。 +**生产启动路径原样运行。** `TestClient.start(plan, mock)` 加载每个 roster 行的 `/client` 模块,把 Connection 行绑定到 `installConnection(ctx, { transport: { rpc: mock.rpc } })`,经生产模块 facade 的 `pendingQueue` 登记这些工厂,经 `bootClient` 启动,可选经 `mountClient` 挂载,然后等 `connected`。`reload(name)` 经 client-hmr 导出的 `tearDownEntryFiber` 重建一个 Loader entry;`unload(name)` 移除它;`dispose()` 拆掉一切,然后对任何没有规则的端点让测试失败。jsdom 没有 `EventSource` 与 `ResizeObserver`;`start` 只对全局缺失的惰性替身做引用计数。模块系统、Connection 载体、Remote mock、重载和销毁都归实例所有,因此客户端可以并行启动,不通过页面全局变量协调。 + +modules 插件激活时从自己的 `ctx.loader.internal` 读取模块系统。重载 bootstrap 行会重新发布同一个实例而不让 bootstrap 代码失效,另一个客户端的 Loader 与 `ctx.modules` 不受影响。 **`remote.` 是无契约代理,不是生成客户端。** `@deepseek-ai/dsh-api-remotes` 行被去掉,因为它生成的客户端只存在于构建后的 `lib/`。对 roster 行注入的每个 `remote.`,加上 mock 有规则的每个命名空间,本档各提供一个 Proxy:`ctx.remote..(...args)` 经 roster 自己的 Connection 用位置参数调用端点 `/`,mock 为它登记了流脚本就走流、否则走一元。Cordis 把 `ctx.remote.` 解析到服务 `remote.`,所以 Gateway 客户端本身不动。一元应答原样返回;一元拒绝按生成客户端折叠载体抛错的方式折叠,经 Gateway 客户端导出的 `carrierFailure` 与 `cancelledFailure`,因此不等待就发出 Remote 调用的产品代码看不到任何 reject。流的项与失败按流吐出的样子直传。 @@ -32,7 +34,7 @@ API 客户端 spec 用一个可编程的 Remote 面假件驱动对象。这个 ## 为本档新增的产品导出 -- `client/connection`:`ClientTransportHooks.rpc?` 公开 `?fixture` 路径内部已在用的已解码载体;`fetch` 变为可选。 +- `client/connection`:`ClientTransportHooks.rpc?` 公开 `?fixture` 路径内部已在用的已解码载体;`fetch` 变为可选。`installConnection(ctx, options)` 从实例局部的 transport、recovery 与 location 输入安装同一个生产服务。 - `client/hmr`:`tearDownEntryFiber(entry)` 就是 `reload` 本来执行的 registry 先行的 fiber 拆除。 - `client/modules`:`parseDshClient` 与 `exactPackageSpecifier` 从 client 面导出,由 Host 和 roster 读取器共用。测试工厂使用已有注册队列。roster 行到 boot graph 的合成只有测试消费者,放在本档里。 - `client/web`:`bootClient` 与 `mountClient` 从 `AppWebEntry.run()` 抽出,后者现在调用它们。 @@ -48,7 +50,7 @@ API 客户端 spec 用一个可编程的 Remote 面假件驱动对象。这个 **自写一套测试侧的 YAML 与补丁解析器。** 否决:`entryListSchema` 与 `applyEntryPatches` 就是启动器自己的,不带 Host Context 合并;本档只写读文件、定位 package.json 和 web 行过滤。 -**用一个 mock 模块替代 Gateway 客户端。** 否决:mock 不得干涉 Gateway 内部;装在 Connection 载体上让重试、折叠与流语义都保持真实。 +**用一个 mock 模块替代 Gateway 客户端。** 否决:mock 不得干涉 Gateway 内部;把它传给生产 Connection 安装函数会让重试、折叠与流语义都保持真实。 **第二套带类型的 Gateway 实现,包含 `Api` 泛型、信封与错误类以及 fixtures 目录。** 否决:它重复 Gateway 声明与编解码。mock 从生成的命名空间映射派生方法类型,运行时只声明一元或流行为。 @@ -87,4 +89,4 @@ spec 起的是真插件:整个 `web` roster 冷启动约五秒、热启动远 ## 测试 -`packages/test-support/remote-mock/tests/` 覆盖规则、流、日志与载体面;`packages/test-support/client-runtime/tests/` 下的 `assembly-` 系列 spec 覆盖在真 bundle 与临时安装上的 roster 读取器、模块加载、含折叠的代理,以及 jsdom 与纯 Node 下的 `TestClient`。七条改造后的 spec 使用本档。`packages/client/ui-settings-general/tests/` 下,shell 与 apply 两条起整个 `web` roster;apply 从 mock 应答的 Host settings 文档读它的中文文案,并为 off-loopback 分支重配 jsdom 页面 URL。`packages/api/session-controller/tests/` 下,Session、queue-store、pending-submission 三条在 gateway 依赖锥上经 `remote.` 代理走 roster 的真 Connection 驱动对象(`$stream` 的重试循环仍是 Gateway 客户端自己的),client-apply 起插件的依赖锥,把 Remote 事件作为 `$events` 上的 emit 帧投递。`packages/api/workspace-controller/tests/` 下,transport 的 apply 用例起插件锥、手工构造流与 controller 的用例起 gateway 锥,因为进了 roster 的插件会共用 follow 端点。每个包在 `tests/remote/` 保有自己的默认响应与帧构造。fixture 测试包含预期的断言失败,并独立观察客户端清理完成;settings 重载测试观察注册身份被替换,写入测试断言全部 mutation 参数。teardown 失败测试先执行真实树清理,再报告注入的失败,并观察 `$events` 流的取消状态。 +`packages/test-support/remote-mock/tests/` 覆盖规则、流、日志与载体面;`packages/test-support/client-runtime/tests/` 下的 `assembly-` 系列 spec 覆盖在真 bundle 与临时安装上的 roster 读取器、模块加载、含折叠的代理、并行客户端启动、实例局部的模块与 Connection 重载,以及 jsdom 与纯 Node 下的 `TestClient`。七条改造后的 spec 使用本档。`packages/client/ui-settings-general/tests/` 下,shell 与 apply 两条起整个 `web` roster;apply 从 mock 应答的 Host settings 文档读它的中文文案,并为 off-loopback 分支重配 jsdom 页面 URL。`packages/api/session-controller/tests/` 下,Session、queue-store、pending-submission 三条在 gateway 依赖锥上经 `remote.` 代理走 roster 的真 Connection 驱动对象(`$stream` 的重试循环仍是 Gateway 客户端自己的),client-apply 起插件的依赖锥,把 Remote 事件作为 `$events` 上的 emit 帧投递。`packages/api/workspace-controller/tests/` 下,transport 的 apply 用例起插件锥、手工构造流与 controller 的用例起 gateway 锥,因为进了 roster 的插件会共用 follow 端点。每个包在 `tests/remote/` 保有自己的默认响应与帧构造。fixture 测试包含预期的断言失败,并独立观察客户端清理完成;settings 重载测试观察注册身份被替换,写入测试断言全部 mutation 参数。teardown 失败测试先执行真实树清理,再报告注入的失败,并观察 `$events` 流的取消状态。 diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index 9793a90129..de8fb6c9a1 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/connection/README.md -README.md: 606f27f37a80c11c105a1431858efae7b8e9d86a -README.zh.md: d7885ad873fb1d7d82c7a613c8f66bc7356b2542 +README.md: e86d73ba24ee52ceea618ae79fd43445ed085083 +README.zh.md: daecaee091bf98a665eddb91211512bc886cea7b diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index 606f27f37a..e86d73ba24 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -25,7 +25,7 @@ The package carries browser-to-Host Remote calls, exact Fetch responses, and con ## Use this package -The browser uses HTTP POST for Remote unary calls. API Gateway owns the `/api/remote.mux` WebSocket and its logical streams; shell-owned compositions provide equivalent Remote streams through `connection.rpc.open` without opening a WebSocket. The Host half always provides the carrier-neutral RPC and exact `GET`/`HEAD`/`POST` route registries. When a Web carrier is present it also owns the sole `/api` route, Fetch bridge, browser authentication, and Host/Origin checks; a shell-owned carrier dispatches the shared Fetch handler directly. Each exact route declares buffered or streaming request-body handling before the bridge reads any bytes. Typert Gateway claims generated Remote endpoints, feature packages register non-JSON responses such as Session-log downloads and raw file uploads, and unclaimed requests return 404. Loopback hostname classification remains package-internal to the browser-facing Client state. Browser raw-body transfer is provided by [`dsh-client-file-upload`](../file-upload/README.md). +The browser uses HTTP POST for Remote unary calls. API Gateway owns the `/api/remote.mux` WebSocket and its logical streams; shell-owned compositions provide equivalent Remote streams through `connection.rpc.open` without opening a WebSocket. The browser plugin reads the page transport, recovery settings, and location, then delegates to `installConnection(ctx, options)`; non-page compositions call the same installer with an explicit carrier. Each invocation creates one Context-owned service, so several Client trees can use different carriers in one realm. The Host half always provides the carrier-neutral RPC and exact `GET`/`HEAD`/`POST` route registries. When a Web carrier is present it also owns the sole `/api` route, Fetch bridge, browser authentication, and Host/Origin checks; a shell-owned carrier dispatches the shared Fetch handler directly. Each exact route declares buffered or streaming request-body handling before the bridge reads any bytes. Typert Gateway claims generated Remote endpoints, feature packages register non-JSON responses such as Session-log downloads and raw file uploads, and unclaimed requests return 404. Loopback hostname classification remains package-internal to the browser-facing Client state. Browser raw-body transfer is provided by [`dsh-client-file-upload`](../file-upload/README.md). The browser fixture follows the [Session Controller fork semantics](../../api/session-controller/README.md#use-this-package). diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index d7885ad873..daecaee091 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -浏览器通过 HTTP POST 执行 Remote 一元调用;API Gateway 自己拥有 `/api/remote.mux` WebSocket 及其逻辑流。由 shell 持有的组合通过 `connection.rpc.open` 提供等价的 Remote 流,不打开 WebSocket。Host half 始终提供与载体无关的 RPC 注册表和精确 `GET`/`HEAD`/`POST` 路由注册表。存在 Web 载体时,它还持有唯一 `/api` route、Fetch bridge、浏览器认证与 Host/Origin 校验;由 shell 持有的载体则直接分派共享 Fetch handler。每条精确路由会在 bridge 读取任何字节前声明缓冲或流式请求体处理方式。Typert Gateway 认领生成的 Remote endpoint,功能包注册 Session 日志下载、原始文件上传等非 JSON 响应,未认领的请求返回 404。Loopback hostname 判定只供浏览器侧当前页面状态使用,留在包内。浏览器原始请求体传输由 [`dsh-client-file-upload`](../file-upload/README.zh.md) 提供。 +浏览器通过 HTTP POST 执行 Remote 一元调用;API Gateway 自己拥有 `/api/remote.mux` WebSocket 及其逻辑流。由 shell 持有的组合通过 `connection.rpc.open` 提供等价的 Remote 流,不打开 WebSocket。浏览器插件读取页面 transport、恢复设置与 location,再委托 `installConnection(ctx, options)`;非页面组合以显式载体调用同一个安装函数。每次调用都会创建一个归所属 Context 的服务,因此同一 realm 中的多棵 Client 树可以使用不同载体。Host half 始终提供与载体无关的 RPC 注册表和精确 `GET`/`HEAD`/`POST` 路由注册表。存在 Web 载体时,它还持有唯一 `/api` route、Fetch bridge、浏览器认证与 Host/Origin 校验;由 shell 持有的载体则直接分派共享 Fetch handler。每条精确路由会在 bridge 读取任何字节前声明缓冲或流式请求体处理方式。Typert Gateway 认领生成的 Remote endpoint,功能包注册 Session 日志下载、原始文件上传等非 JSON 响应,未认领的请求返回 404。Loopback hostname 判定只供浏览器侧当前页面状态使用,留在包内。浏览器原始请求体传输由 [`dsh-client-file-upload`](../file-upload/README.zh.md) 提供。 浏览器 fixture 遵循 [Session Controller 的 fork 语义](../../api/session-controller/README.zh.md#use-this-package)。 diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index 590004ef46..de95588852 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -72,10 +72,10 @@ export interface ConnectionStateSource { export const inject: string[] = [] /** - * Carrier override installed on the page global before plugin boot. The served - * web app leaves it unset and gets HTTP + WebSocket; a shell that owns a - * different physical transport (the worker preview's postMessage tunnel) - * provides both halves here instead of forking this plugin. + * Physical carrier selected when the Connection service is installed. The + * served web app omits it and gets HTTP + WebSocket; a shell that owns a + * different transport (the worker preview's postMessage tunnel) provides both + * halves instead of forking this plugin. */ export interface ClientTransportHooks { /** @@ -111,6 +111,22 @@ interface ClientTransportGlobal { __DSH_CONNECTION_RECOVERY__?: unknown } +/** Browser location fields used to select fixture mode and loopback authority. */ +export interface ConnectionLocation { + readonly hostname: string + readonly search: string +} + +/** Instance-local inputs for installing a Connection service. */ +export interface ConnectionInstallOptions { + /** Explicit physical carrier; omit for the browser HTTP + WebSocket carrier. */ + readonly transport?: ClientTransportHooks + /** Resolved reconnect timing; omitted fields use controller defaults. */ + readonly recovery?: ConnectionRecoveryConfig + /** Page location; omit for a non-browser composition. */ + readonly location?: ConnectionLocation +} + /** * The ctx.connection service API. API Gateway supplies generation readiness * and reset callbacks; Connection stays independent of downstream domain state. @@ -182,15 +198,16 @@ function watchBrowserNetwork(controller: ConnectionController): () => void { } /** - * Client plugin body: pick physical carriers by page mode and provide ctx.connection. - * @param ctx - client cordis context. + * Install one Context-owned Connection service from explicit composition inputs. + * @param ctx - client Cordis context. + * @param options - physical carrier, reconnect timing, and page location. */ -export function apply(ctx: Context): void { - const pageLocation = typeof location === 'undefined' ? undefined : location +export function installConnection(ctx: Context, options: ConnectionInstallOptions = {}): void { + const pageLocation = options.location const fixture = pageLocation !== undefined && new URLSearchParams(pageLocation.search).has('fixture') const fixtureRpc = fixture ? createFixtureConnectionRpc() : undefined - const transport = (globalThis as ClientTransportGlobal).__DSH_TRANSPORT__ - const recovery = resolveConnectionConfig((globalThis as ClientTransportGlobal).__DSH_CONNECTION_RECOVERY__) + const transport = options.transport + const recovery = options.recovery ?? {} const rpc = fixtureRpc ?? transport?.rpc ?? createWebConnectionRpc(transport?.fetch, transport?.openStream) let generationSource: ConnectionGenerationSource | undefined let owner: ConnectionOwner | undefined @@ -294,3 +311,18 @@ export function apply(ctx: Context): void { } ctx.provide('connection', handle) } + +/** + * Client plugin body: read the page composition and install its Connection service. + * @param ctx - client Cordis context. + */ +export function apply(ctx: Context): void { + const globals = globalThis as ClientTransportGlobal + const pageLocation = typeof location === 'undefined' ? undefined : location + const transport = globals.__DSH_TRANSPORT__ + installConnection(ctx, { + ...(transport === undefined ? {} : { transport }), + recovery: resolveConnectionConfig(globals.__DSH_CONNECTION_RECOVERY__), + ...(pageLocation === undefined ? {} : { location: pageLocation }), + }) +} diff --git a/packages/client/modules/README.i18n.yaml b/packages/client/modules/README.i18n.yaml index 0cb60fa5d4..d163a406e8 100644 --- a/packages/client/modules/README.i18n.yaml +++ b/packages/client/modules/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/modules/README.md -README.md: 4c9dc4a3a13cb6f24e03c927d0277e137ab97b9f -README.zh.md: bc5b78d258270962661ab27ca7eb1a58cc61f01c +README.md: c83796fc8d6c364b9fad6d44e25316e2c2f7e7af +README.zh.md: 9ffb68ebc07b5970edc45a54c4cfde7cdf4ef7d8 diff --git a/packages/client/modules/README.md b/packages/client/modules/README.md index 4c9dc4a3a1..c83796fc8d 100644 --- a/packages/client/modules/README.md +++ b/packages/client/modules/README.md @@ -73,7 +73,7 @@ The Node half snapshots each client bundle and available source map before publi The bundle route follows the injected `webServer` lifetime: it registers when the service is ready and is removed and re-registered when that service is replaced. Module composition and `fetchBundle()` remain available without a Web server. -The host contributes structured index rows that inject, into ``: the `window.__ModuleLoader__` queue facade, advisory preloads for every application combo, the parser-blocking bootstrap combo scripts, then the boot graph before the shell reads it. A Web carrier renders those rows into its index response; a shell-owned carrier can render the same rows without a Web server. The facade's `create()` materializes the modules bundle, delegates construction to its `createClientModuleSystem` export, and leaves the same facade in live-registration mode. +The host contributes structured index rows that inject, into ``: the `window.__ModuleLoader__` queue facade, advisory preloads for every application combo, the parser-blocking bootstrap combo scripts, then the boot graph before the shell reads it. A Web carrier renders those rows into its index response; a shell-owned carrier can render the same rows without a Web server. The facade's `create()` materializes the modules bundle, delegates construction to its `createClientModuleSystem` export, and leaves the same facade in live-registration mode. The shell installs that returned system as its Loader's `internal`; the modules plugin publishes that instance as `ctx.modules`, so separate Cordis trees never select an instance through module-global state. ### Source map diff --git a/packages/client/modules/README.zh.md b/packages/client/modules/README.zh.md index bc5b78d258..9ffb68ebc0 100644 --- a/packages/client/modules/README.zh.md +++ b/packages/client/modules/README.zh.md @@ -73,7 +73,7 @@ Node 半侧会在发布前快照每个客户端 bundle 及其现有 source map bundle 路由随注入的 `webServer` 生命周期注册:服务就绪时注册,服务被替换时移除并重新注册。模块组合与 `fetchBundle()` 在没有 Web server 时仍可用。 -宿主贡献结构化 index 行,并向 `` 注入:`window.__ModuleLoader__` queue facade、每个 application combo 的提示性 preload、阻塞 parser 的 bootstrap combo 脚本,然后才是外壳读取前的启动图。Web 载体把这些行渲染进 index 响应;由 shell 持有的载体则可以在没有 Web server 时渲染同一批行。facade 的 `create()` 物化 modules bundle、把构造委托给其 `createClientModuleSystem` 导出,并让同一 facade 进入 live registration 模式。 +宿主贡献结构化 index 行,并向 `` 注入:`window.__ModuleLoader__` queue facade、每个 application combo 的提示性 preload、阻塞 parser 的 bootstrap combo 脚本,然后才是外壳读取前的启动图。Web 载体把这些行渲染进 index 响应;由 shell 持有的载体则可以在没有 Web server 时渲染同一批行。facade 的 `create()` 物化 modules bundle、把构造委托给其 `createClientModuleSystem` 导出,并让同一 facade 进入 live registration 模式。外壳把返回的系统装成自身 Loader 的 `internal`;modules 插件将该实例发布为 `ctx.modules`,因此不同 Cordis 树不会通过模块级全局状态选择实例。 ### 源码索引 diff --git a/packages/client/modules/src/client/index.ts b/packages/client/modules/src/client/index.ts index d7bf39e518..db6d74bfe6 100644 --- a/packages/client/modules/src/client/index.ts +++ b/packages/client/modules/src/client/index.ts @@ -6,15 +6,15 @@ * parser-preloads this ordinary client bundle into the pending registration * queue. The HTML-installed loader facade materializes this bundle and calls * its bootstrap export, which constructs the system and retains the same - * exports for this package's graph row. The plugin face only enrolls that - * pre-existing instance by providing it as `ctx.modules`. + * exports for this package's graph row. The plugin face enrolls the module + * system attached to its own Loader as `ctx.modules`. * @module @deepseek-ai/dsh-client-modules/client */ import type { Context } from '@deepseek-ai/cordis' import { ClientModuleSystem } from './system.ts' import { parseBootManifest } from './manifest.ts' import type { - ClientBootstrapModule, ClientModuleCreateOptions, ClientModuleLoaderTarget, + ClientBootstrapModule, ClientModuleCreateOptions, ClientModuleLoader, ClientModuleLoaderTarget, } from './manifest.ts' export { ClientModuleSystem } @@ -26,37 +26,39 @@ export type { WebBootEntry, WebBootGraph, } from './manifest.ts' -let moduleSystem: ClientModuleSystem | undefined - /** * Build the live module system from the HTML facade's materialized modules bundle. * @param target - Stable registration facade whose pending queue becomes the live sink. * @param bootstrapModule - This bundle's id and already-materialized exports. * @param options - Raw boot graph, platform seed, and optional bundle transport. - * @returns The created module system, also published for this package's Cordis plugin face. + * @returns The created module system. */ export function createClientModuleSystem( target: ClientModuleLoaderTarget, bootstrapModule: ClientBootstrapModule, options: ClientModuleCreateOptions, ): ClientModuleSystem { - moduleSystem = new ClientModuleSystem({ + return new ClientModuleSystem({ manifest: parseBootManifest(options.boot), staticModules: options.staticModules, registrationTarget: target, bootstrapModule, ...(options.loadBundle === undefined ? {} : { loadBundle: options.loadBundle }), }) - return moduleSystem } +/** Required service: the Loader whose internal module system this plugin publishes. */ +export const inject = ['loader'] + /** * Enroll the kernel-built module system as `ctx.modules`. * @param ctx - client root context. */ export function apply(ctx: Context): void { - if (moduleSystem === undefined) { - throw new Error('client-modules: createClientModuleSystem must run before plugin boot') + const loader = ctx.get('loader') as { readonly internal?: unknown } | undefined + const modules = loader?.internal as ClientModuleLoader | undefined + if (modules?.version !== 'client') { + throw new Error('client-modules: the Loader has no client module system') } - ctx.reflect.provide('modules', moduleSystem) + ctx.reflect.provide('modules', modules) } diff --git a/packages/client/modules/tests/loader.client.spec.ts b/packages/client/modules/tests/loader.client.spec.ts index 8de76e8cce..12ebd8ea96 100644 --- a/packages/client/modules/tests/loader.client.spec.ts +++ b/packages/client/modules/tests/loader.client.spec.ts @@ -123,8 +123,8 @@ function bench( } describe('Cordis plugin face', () => { - it('rejects activation before the HTML facade creates the module system', () => { - expect(() => { apply(new Context()) }).toThrow('createClientModuleSystem must run before plugin boot') + it('rejects activation before the shell installs a client Loader internal', () => { + expect(() => { apply(new Context()) }).toThrow('the Loader has no client module system') }) }) @@ -293,11 +293,19 @@ describe('bootstrap module', () => { expect(b.fetched).toEqual([APPLICATION_URL]) }) - it('publishes the same closed-over system when the modules Cordis plugin activates', () => { + it('publishes the module system attached to its own Loader', () => { + const a = bench([]) const b = bench([]) - const ctx = new Context() - apply(ctx) - expect(ctx.modules).toBe(b.loader) + const ctxA = new Context() + const ctxB = new Context() + ctxA.reflect.provide('loader', { internal: a.loader }) + ctxB.reflect.provide('loader', { internal: b.loader }) + + apply(ctxA) + apply(ctxB) + + expect(ctxA.modules).toBe(a.loader) + expect(ctxB.modules).toBe(b.loader) }) it('rejects a second queued registration for the bootstrap id', () => { diff --git a/packages/test-support/client-runtime/README.i18n.yaml b/packages/test-support/client-runtime/README.i18n.yaml index 534cc2596a..e2ce3b25fe 100644 --- a/packages/test-support/client-runtime/README.i18n.yaml +++ b/packages/test-support/client-runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/test-support/client-runtime/README.md -README.md: 20f6454012d0d8e4c78d9a0ea0df960d22a71f5a -README.zh.md: d9c6888f17df3f37e699bb6be0f7ee44ce82d373 +README.md: 64a686f08ec2778de941bedfb70b3a1de2d542b9 +README.zh.md: a9c85ca3b281f62dc57bffdaf705ea3b33e84941 diff --git a/packages/test-support/client-runtime/README.md b/packages/test-support/client-runtime/README.md index 20f6454012..64a686f08e 100644 --- a/packages/test-support/client-runtime/README.md +++ b/packages/test-support/client-runtime/README.md @@ -66,7 +66,7 @@ expect(view.getByRole('alert')).toHaveTextContent('goal/not-found') ### Whole-client tier -The slot tier above mounts one feature against doubles. The whole-client tier boots the real assembly: `TestClient.start(plan, mock, options)` installs `{ rpc: mock.rpc }` as `globalThis.__DSH_TRANSPORT__`, imports every roster row's `/client` module in-process (or takes the plan's `provide` replacement), synthesizes the boot graph with `graphFromRoster` and hands the loaded modules to the production module system, boots through the production `bootClient`, optionally mounts `uiRenderer`, and waits for `ctx.connection.state === 'connected'`. It lives behind a deep import so slot-tier specs never load it: +The slot tier above mounts one feature against doubles. The whole-client tier boots the real assembly: `TestClient.start(plan, mock, options)` imports every roster row's `/client` module in-process (or takes the plan's `provide` replacement), binds `{ rpc: mock.rpc }` to that client's Connection module, synthesizes the boot graph with `graphFromRoster` and hands the loaded modules to the production module system, boots through the production `bootClient`, optionally mounts `uiRenderer`, and waits for `ctx.connection.state === 'connected'`. It lives behind a deep import so slot-tier specs never load it: ```text // @vitest-environment jsdom @@ -87,7 +87,7 @@ The [generic Remote proxy](../remote-mock/README.md#remote-proxy) supports every ### Roster and startup behavior -`webApp` is the `web` profile's browser roster, read when the assembly entry is first imported from its bundles (`dsh-base`, then `dsh-web-app`) as the launcher composes them, except that a patch matching nothing throws here where the launcher warns: each bundle's `dsh.bundle.patch` list is parsed with the include plugin's YAML dialect and composed by its `applyEntryPatches`, and every enabled row whose package declares `dsh.client.platform === 'web'` becomes a row carrying that declaration's `inject` and `immediately`; `bundleRoster(bundles)` does the same for any bundle list. Nothing is copied from the bundles, so a bundle change is visible at the next test run. `webApp.closure(names)` keeps the named rows plus everything they inject, transitively (the rows a spec needs to boot those plugins as the bundle composes them), `webApp.pick(names)` and `webApp.without(names)` cut it down by hand, all three throw on unknown names, and `ClientRoster.of(rows)` builds one inline. `remoteDefaultResponses` holds default responses for exactly the Remote endpoints the roster calls while booting with no sessions, no workspaces, and default settings; a spec layers its own `RemoteTable` on top with `mock.load(table)`, and any call without a rule fails the test at `dispose()` through `mock.assertNoUnmatched()`. `mount` requires a roster that provides `uiRenderer`; `start` fails loud otherwise instead of returning an empty container. `client.connection` is the roster's Connection service (no `Context` augmentation declares it), and `connectTimeoutMs` bounds the readiness wait, whose failure message lists the mock log. `reload(name)` rebuilds one Loader entry the way client-hmr does (registry-first teardown, then `entry.refresh()`), under the worker's boot turn with this client's carrier installed so a rebuilt `connection` row reads its own mock; `unload(name)` removes it and waits for plugin cleanup; `flush()` settles React inside `act`. jsdom implements neither `EventSource` (client-hmr opens one at apply) nor `ResizeObserver` (layout components observe size at mount), so `start` installs inert stubs for whichever global is absent and `dispose` removes exactly those — a jsdom gap, not a product requirement. The `@deepseek-ai/dsh-api-remotes` row is dropped from every roster: its generated Remote clients exist only in built `lib/`, and `remote.` is what the tier replaces anyway. `start` instead provides one contract-free proxy per `remote.` service the roster injects (plus the namespaces the mock has rules for at that point; a namespace first registered later has no proxy); `ctx.remote..(...args)` becomes a call on the endpoint `/` carrying the positional args, a stream when the mock registered a `stream()` script and a unary call otherwise, with the generated client's outcome folding (`gateway/internal` for carrier throws, `gateway/cancelled` on abort). An endpoint without a rule is still dispatched, so the mock logs it and `dispose()` fails the test. +`webApp` is the `web` profile's browser roster, read when the assembly entry is first imported from its bundles (`dsh-base`, then `dsh-web-app`) as the launcher composes them, except that a patch matching nothing throws here where the launcher warns: each bundle's `dsh.bundle.patch` list is parsed with the include plugin's YAML dialect and composed by its `applyEntryPatches`, and every enabled row whose package declares `dsh.client.platform === 'web'` becomes a row carrying that declaration's `inject` and `immediately`; `bundleRoster(bundles)` does the same for any bundle list. Nothing is copied from the bundles, so a bundle change is visible at the next test run. `webApp.closure(names)` keeps the named rows plus everything they inject, transitively (the rows a spec needs to boot those plugins as the bundle composes them), `webApp.pick(names)` and `webApp.without(names)` cut it down by hand, all three throw on unknown names, and `ClientRoster.of(rows)` builds one inline. `remoteDefaultResponses` holds default responses for exactly the Remote endpoints the roster calls while booting with no sessions, no workspaces, and default settings; a spec layers its own `RemoteTable` on top with `mock.load(table)`, and any call without a rule fails the test at `dispose()` through `mock.assertNoUnmatched()`. `mount` requires a roster that provides `uiRenderer`; `start` fails loud otherwise instead of returning an empty container. `client.connection` is the roster's Connection service (no `Context` augmentation declares it), and `connectTimeoutMs` bounds the readiness wait, whose failure message lists the mock log. `reload(name)` rebuilds one Loader entry the way client-hmr does (registry-first teardown, then `entry.refresh()`); each client retains its own module system and instance-bound Connection replacement, so starts and reloads can overlap without changing a page global. `unload(name)` removes an entry and waits for plugin cleanup; `flush()` settles React inside `act`. jsdom implements neither `EventSource` (client-hmr opens one at apply) nor `ResizeObserver` (layout components observe size at mount), so `start` reference-counts inert stubs for whichever global is absent and `dispose` removes exactly those — a jsdom gap, not a client identity channel. The `@deepseek-ai/dsh-api-remotes` row is dropped from every roster: its generated Remote clients exist only in built `lib/`, and `remote.` is what the tier replaces anyway. `start` instead provides one contract-free proxy per `remote.` service the roster injects (plus the namespaces the mock has rules for at that point; a namespace first registered later has no proxy); `ctx.remote..(...args)` becomes a call on the endpoint `/` carrying the positional args, a stream when the mock registered a `stream()` script and a unary call otherwise, with the generated client's outcome folding (`gateway/internal` for carrier throws, `gateway/cancelled` on abort). An endpoint without a rule is still dispatched, so the mock logs it and `dispose()` fails the test. ### When to use it @@ -126,7 +126,7 @@ The bench copies no production logic: it mounts the production `SlotRegistry`, p | [`src/settings-scope.ts`](src/settings-scope.ts) | `stubSettingsScope` with test-driven publications and a write spy | | [`src/assembly/roster.ts`](src/assembly/roster.ts) | `ClientRosterRow`, `ClientRoster` (`of`/`closure`/`pick`/`without`), the `AssemblyPlan` it annotates, and `graphFromRoster` | | [`src/assembly/modules.ts`](src/assembly/modules.ts) | Source `/client` imports and replacements, registered through the production module facade's pending factory queue | -| [`src/assembly/test-client.ts`](src/assembly/test-client.ts) | `TestClient`: transport install, jsdom shims, `bootClient`, mount, readiness wait, `reload`/`unload`/`dispose` | +| [`src/assembly/test-client.ts`](src/assembly/test-client.ts) | `TestClient`: instance-bound Connection, shared jsdom shims, `bootClient`, mount, readiness wait, `reload`/`unload`/`dispose` | | [`src/assembly/vitest.ts`](src/assembly/vitest.ts) | Test-scoped `mock` and lazy `start` fixtures | | [`src/assembly/remote-default-responses.ts`](src/assembly/remote-default-responses.ts) | `remoteDefaultResponses`: default responses of the Remote endpoints the roster calls at boot | | [`src/assembly/remote-proxies.ts`](src/assembly/remote-proxies.ts) | Contract-free `remote.` proxies over the Connection: `remoteNamespacesOf`, `remoteProxiesPlugin` | diff --git a/packages/test-support/client-runtime/README.zh.md b/packages/test-support/client-runtime/README.zh.md index d9c6888f17..a9c85ca3b2 100644 --- a/packages/test-support/client-runtime/README.zh.md +++ b/packages/test-support/client-runtime/README.zh.md @@ -66,7 +66,7 @@ expect(view.getByRole('alert')).toHaveTextContent('goal/not-found') ### 整体档 -上面的 slot 档把一个功能挂在替身上。整体档起真实装配:`TestClient.start(plan, mock, options)` 把 `{ rpc: mock.rpc }` 装到 `globalThis.__DSH_TRANSPORT__`,进程内 import 每个 roster 行的 `/client` 模块(或取计划里的 `provide` 替换),用 `graphFromRoster` 合成启动图并把已加载模块交给生产模块系统,经生产 `bootClient` 启动,按需挂载 `uiRenderer`,再等 `ctx.connection.state === 'connected'`。它藏在深 import 后面,slot 档测试永不加载它: +上面的 slot 档把一个功能挂在替身上。整体档起真实装配:`TestClient.start(plan, mock, options)` 进程内 import 每个 roster 行的 `/client` 模块(或取计划里的 `provide` 替换),把 `{ rpc: mock.rpc }` 绑定到该客户端的 Connection 模块,用 `graphFromRoster` 合成启动图并把已加载模块交给生产模块系统,经生产 `bootClient` 启动,按需挂载 `uiRenderer`,再等 `ctx.connection.state === 'connected'`。它藏在深 import 后面,slot 档测试永不加载它: ```text // @vitest-environment jsdom @@ -87,7 +87,7 @@ test('registers into the sidebar', async ({ remote, start }) => { ### Roster 与启动行为 -`webApp` 是 `web` profile 的浏览器 roster,首次 import 装配入口时从它的 bundle(先 `dsh-base`、再 `dsh-web-app`)按启动器的方式现读,只是匹配不到任何行的补丁在这里抛错、启动器只警告:每个 bundle 的 `dsh.bundle.patch` 列表用 include 插件的 YAML 方言解析、用它的 `applyEntryPatches` 合成,每个未禁用且其包声明 `dsh.client.platform === 'web'` 的行成为一行,带上该声明的 `inject` 与 `immediately`;`bundleRoster(bundles)` 对任意 bundle 列表做同样的事。没有任何东西从 bundle 拷贝出来,bundle 一改下次跑测试就能看见。`webApp.closure(names)` 保留点名的行及其传递注入的全部行(即按 bundle 组合方式起这些插件所需的行),`webApp.pick(names)` 与 `webApp.without(names)` 手工裁剪,三者都对未知名字抛错,`ClientRoster.of(rows)` 内联构造一份。`remoteDefaultResponses` 是 roster 在没有 session、没有 workspace、默认设置下启动时恰好会打的那些 Remote 端点的默认响应;测试用 `mock.load(table)` 在其上叠加自己的 `RemoteTable`,任何没有规则的调用都会在 `dispose()` 时经 `mock.assertNoUnmatched()` 让测试失败。`mount` 要求 roster 提供 `uiRenderer`;否则 `start` 响亮失败而不是返回一个空容器。`client.connection` 是 roster 的 Connection 服务(没有任何 `Context` 增强声明它),`connectTimeoutMs` 限定等就绪的时长,超时消息列出 mock log。`reload(name)` 按 client-hmr 的方式重建一个 Loader entry(先拆 registry,再 `entry.refresh()`),并在 worker 的启动轮次内装上本客户端的载体,重建的 `connection` 行因此读到自己的 mock;`unload(name)` 移除它并等待插件清理完成;`flush()` 在 `act` 内让 React 落定。jsdom 既没有 `EventSource`(client-hmr 在 apply 时打开一个)也没有 `ResizeObserver`(布局组件挂载时观察尺寸),所以 `start` 对缺失的全局装惰性桩、`dispose` 只移除它装的那些——这是 jsdom 的缺口,不是产品需求。每个 roster 里的 `@deepseek-ai/dsh-api-remotes` 行都会被去掉:它生成的 Remote 客户端只存在于构建后的 `lib/`,而 `remote.` 正是本档要替掉的东西。`start` 改为给 roster 注入的每个 `remote.` 服务(加上此刻 mock 登记过规则的命名空间;之后才首次登记的命名空间没有代理)提供一个无契约代理;`ctx.remote..(...args)` 变成对端点 `/` 的调用,携带位置参数,mock 登记了 `stream()` 脚本的走流、否则走一元,并沿用生成客户端的结果折叠(载体抛错折成 `gateway/internal`,中止折成 `gateway/cancelled`)。没有规则的端点照样发出,所以 mock 会记下它、`dispose()` 让测试失败。 +`webApp` 是 `web` profile 的浏览器 roster,首次 import 装配入口时从它的 bundle(先 `dsh-base`、再 `dsh-web-app`)按启动器的方式现读,只是匹配不到任何行的补丁在这里抛错、启动器只警告:每个 bundle 的 `dsh.bundle.patch` 列表用 include 插件的 YAML 方言解析、用它的 `applyEntryPatches` 合成,每个未禁用且其包声明 `dsh.client.platform === 'web'` 的行成为一行,带上该声明的 `inject` 与 `immediately`;`bundleRoster(bundles)` 对任意 bundle 列表做同样的事。没有任何东西从 bundle 拷贝出来,bundle 一改下次跑测试就能看见。`webApp.closure(names)` 保留点名的行及其传递注入的全部行(即按 bundle 组合方式起这些插件所需的行),`webApp.pick(names)` 与 `webApp.without(names)` 手工裁剪,三者都对未知名字抛错,`ClientRoster.of(rows)` 内联构造一份。`remoteDefaultResponses` 是 roster 在没有 session、没有 workspace、默认设置下启动时恰好会打的那些 Remote 端点的默认响应;测试用 `mock.load(table)` 在其上叠加自己的 `RemoteTable`,任何没有规则的调用都会在 `dispose()` 时经 `mock.assertNoUnmatched()` 让测试失败。`mount` 要求 roster 提供 `uiRenderer`;否则 `start` 响亮失败而不是返回一个空容器。`client.connection` 是 roster 的 Connection 服务(没有任何 `Context` 增强声明它),`connectTimeoutMs` 限定等就绪的时长,超时消息列出 mock log。`reload(name)` 按 client-hmr 的方式重建一个 Loader entry(先拆 registry,再 `entry.refresh()`);每个客户端都保留自己的模块系统和绑定实例的 Connection 替换,因此启动与重载可以重叠而无需改写页面全局变量。`unload(name)` 移除 entry 并等待插件清理完成,`flush()` 在 `act` 内让 React 落定。jsdom 既没有 `EventSource`(client-hmr 在 apply 时打开一个)也没有 `ResizeObserver`(布局组件挂载时观察尺寸),所以 `start` 对缺失的全局惰性桩做引用计数,`dispose` 只移除它装的那些——这是 jsdom 的缺口,不是客户端身份通道。每个 roster 里的 `@deepseek-ai/dsh-api-remotes` 行都会被去掉:它生成的 Remote 客户端只存在于构建后的 `lib/`,而 `remote.` 正是本档要替掉的东西。`start` 改为给 roster 注入的每个 `remote.` 服务(加上此刻 mock 登记过规则的命名空间;之后才首次登记的命名空间没有代理)提供一个无契约代理;`ctx.remote..(...args)` 变成对端点 `/` 的调用,携带位置参数,mock 登记了 `stream()` 脚本的走流、否则走一元,并沿用生成客户端的结果折叠(载体抛错折成 `gateway/internal`,中止折成 `gateway/cancelled`)。没有规则的端点照样发出,所以 mock 会记下它、`dispose()` 让测试失败。 ### 何时使用 @@ -126,7 +126,7 @@ test('registers into the sidebar', async ({ remote, start }) => { | [`src/settings-scope.ts`](src/settings-scope.ts) | 带测试驱动发布与写入 spy 的 `stubSettingsScope` | | [`src/assembly/roster.ts`](src/assembly/roster.ts) | `ClientRosterRow`、`ClientRoster`(`of`/`closure`/`pick`/`without`)、它所标注的 `AssemblyPlan`,以及 `graphFromRoster` | | [`src/assembly/modules.ts`](src/assembly/modules.ts) | 源码 `/client` 导入及替换,通过生产模块 facade 的待注册工厂队列登记 | -| [`src/assembly/test-client.ts`](src/assembly/test-client.ts) | `TestClient`:装传输、jsdom 桩、`bootClient`、挂载、等就绪、`reload`/`unload`/`dispose` | +| [`src/assembly/test-client.ts`](src/assembly/test-client.ts) | `TestClient`:绑定实例的 Connection、共享 jsdom 桩、`bootClient`、挂载、等就绪、`reload`/`unload`/`dispose` | | [`src/assembly/vitest.ts`](src/assembly/vitest.ts) | 测试级 `mock` 与懒启动 `start` fixture | | [`src/assembly/remote-default-responses.ts`](src/assembly/remote-default-responses.ts) | `remoteDefaultResponses`:roster 启动期 Remote 端点的默认响应 | | [`src/assembly/remote-proxies.ts`](src/assembly/remote-proxies.ts) | 经 Connection 的无契约 `remote.` 代理:`remoteNamespacesOf`、`remoteProxiesPlugin` | diff --git a/packages/test-support/client-runtime/src/assembly/test-client.ts b/packages/test-support/client-runtime/src/assembly/test-client.ts index fd99de38fd..12b58e7b5a 100644 --- a/packages/test-support/client-runtime/src/assembly/test-client.ts +++ b/packages/test-support/client-runtime/src/assembly/test-client.ts @@ -1,13 +1,16 @@ /** * Whole-client test carrier: boots an {@link AssemblyPlan} through the * production `bootClient` over an in-process module table, with a - * `RemoteMock` installed as the Connection carrier through `__DSH_TRANSPORT__.rpc`. + * `RemoteMock` bound to that client's Connection plugin instance. * @module @deepseek-ai/dsh-client-test-runtime/src/assembly/test-client */ import { Context, type Plugin } from '@deepseek-ai/cordis' import type { Entry } from '@deepseek-ai/cordis-plugin-loader' import { tearDownEntryFiber } from '@deepseek-ai/dsh-client-hmr/client' -import type { ClientTransportHooks, ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' +import { + installConnection, + type ConnectionHandle, +} from '@deepseek-ai/dsh-client-connection/client' import { bootClient } from '@deepseek-ai/dsh-client-web/src/boot-client.ts' import { mountClient } from '@deepseek-ai/dsh-client-web/src/mount.ts' import type { RemoteMock } from '@deepseek-ai/dsh-remote-mock' @@ -29,73 +32,35 @@ export interface TestClientOptions { readonly connectTimeoutMs?: number } -/** Page global the connection plugin reads its carrier from. */ -interface TransportGlobal { - __DSH_TRANSPORT__?: ClientTransportHooks -} - -const transportGlobal = globalThis as TransportGlobal - /** - * Process globals every live client in this worker shares: the transport the - * `connection` plugin reads at apply, and the jsdom shims. The first holder - * installs them and remembers what was there; the last release removes the - * shims and restores the transport. A holder installs its transport for its - * own boot and for each rebuild it runs, both under the worker's boot turn, so - * overlapping clients and out-of-order disposal neither clobber a booting or - * rebuilding client nor leak into later tests. + * jsdom shims shared by every live client in this worker. The first holder + * installs missing shims and the last release removes exactly those shims. */ -class SharedGlobals { +class SharedJsdomShims { private holders = 0 - private previousTransport: ClientTransportHooks | undefined private removeShims: (() => void) | undefined /** - * Point the transport global at `transport` for the boot or rebuild about to run; the holder must already hold. - * @param transport - carrier the next `connection` apply reads. - */ - install(transport: ClientTransportHooks): void { - transportGlobal.__DSH_TRANSPORT__ = transport - } - - /** - * Hold the globals with `transport` installed. - * @param transport - carrier the next boot reads. + * Hold the shims for one client. * @returns the release for this holder. */ - acquire(transport: ClientTransportHooks): () => void { + acquire(): () => void { if (this.holders === 0) { - this.previousTransport = transportGlobal.__DSH_TRANSPORT__ this.removeShims = installJsdomShims() } this.holders += 1 - this.install(transport) return () => { this.holders -= 1 if (this.holders > 0) return this.removeShims?.() this.removeShims = undefined - if (this.previousTransport === undefined) delete transportGlobal.__DSH_TRANSPORT__ - else transportGlobal.__DSH_TRANSPORT__ = this.previousTransport - this.previousTransport = undefined } } } -const sharedGlobals = new SharedGlobals() +const sharedJsdomShims = new SharedJsdomShims() -/** - * Boots and entry rebuilds run one at a time per worker: the transport global - * must stay the acting client's until its `connection` row applies. - */ -let bootTurn: Promise = Promise.resolve() - -/** Run `work` as the next boot turn; its failure is the caller's, never the next turn's. */ -function takeTurn(work: () => Promise): Promise { - const turn = bootTurn.then(work) - bootTurn = turn.catch(() => undefined) - return turn -} +const CONNECTION_PACKAGE = '@deepseek-ai/dsh-client-connection' /** Default readiness budget; the mock answers `$events` immediately, so a miss means a boot-time fixture is absent. */ const DEFAULT_CONNECT_TIMEOUT_MS = 5_000 @@ -194,16 +159,16 @@ async function awaitConnected(ctx: Context, mock: RemoteMock, timeoutMs: number) /** A booted client under test. */ export class TestClient { /** - * Load the roster's modules, then, holding this worker's boot turn, install - * the mock as the Connection carrier and the jsdom shims and boot through - * `bootClient` over the synthesized boot graph; afterwards optionally mount - * and wait for the connection. The `@deepseek-ai/dsh-api-remotes` row is + * Load the roster's modules, bind this client's mock to its Connection row, + * hold the jsdom shims, and boot through `bootClient` over the synthesized + * boot graph; afterwards optionally mount and wait for the connection. The + * `@deepseek-ai/dsh-api-remotes` row is * dropped from the roster: its generated Remote clients exist only in built * `lib/`, and the `remote.` services the roster injects (plus the * namespaces the mock has rules for at this point) are provided as * contract-free proxies over the same Connection instead; a `provide` entry * for that row is refused. On any failure the context is disposed, an owned - * mount removed, and this client's hold on the globals released before the + * mount removed, and this client's hold on the shims released before the * original error is rethrown. * @param plan - roster and annotations. * @param mock - Remote mock answering every Gateway call. @@ -223,7 +188,9 @@ export class TestClient { ? plan.roster.without([REMOTES_PACKAGE]) : plan.roster const ctx = new Context() - const transport: ClientTransportHooks = { rpc: mock.rpc } + const pageLocation = typeof location === 'undefined' + ? undefined + : { hostname: location.hostname, search: location.search } let mountPoint: MountPoint = { element: undefined, owned: false } let release: (() => void) | undefined const restore = (): void => { @@ -231,14 +198,24 @@ export class TestClient { release?.() } try { - const modules = await loadPluginModules({ ...plan, roster }) + const modules = new Map(await loadPluginModules({ ...plan, roster })) + const connection = modules.get(CONNECTION_PACKAGE) + if (connection !== undefined && plan.provide?.[CONNECTION_PACKAGE] === undefined) { + modules.set(CONNECTION_PACKAGE, { + ...connection, + apply: (connectionCtx) => { + installConnection(connectionCtx, { + transport: { rpc: mock.rpc }, + ...(pageLocation === undefined ? {} : { location: pageLocation }), + }) + }, + }) + } mountPoint = resolveMountPoint(options.mount) - await takeTurn(async () => { - release = sharedGlobals.acquire(transport) - const system = createInProcessModules(graphFromRoster(roster.rows), modules) - ctx.plugin(remoteProxiesPlugin(remoteNamespacesOf(modules.values(), mock), mock) as unknown as Plugin) - await bootClient({ ctx, modules: system, manifest: system.manifest }) - }) + release = sharedJsdomShims.acquire() + const system = createInProcessModules(graphFromRoster(roster.rows), modules) + ctx.plugin(remoteProxiesPlugin(remoteNamespacesOf(modules.values(), mock), mock) as unknown as Plugin) + await bootClient({ ctx, modules: system, manifest: system.manifest }) if (mountPoint.element !== undefined) { if (ctx.get('uiRenderer') === undefined) { throw new Error('client-test-runtime: mount requested, but the roster provides no `uiRenderer`') @@ -270,11 +247,6 @@ export class TestClient { private readonly restore: () => void, ) {} - /** The carrier this client's `connection` row reads when it applies. */ - private get transport(): ClientTransportHooks { - return { rpc: this.mock.rpc } - } - /** The roster's Connection service (no `Context` augmentation declares it); throws when the roster provides none. */ get connection(): ConnectionHandle { return connectionOf(this.ctx) @@ -286,20 +258,18 @@ export class TestClient { } /** - * Rebuild one Loader entry: client-hmr's registry-first fiber teardown, then `entry.refresh()`. The rebuild - * takes the worker's boot turn with this client's carrier installed, so a rebuilt `connection` row reads its - * own mock even while another client is live. Requires a live client: after `dispose()` the Loader holds no - * entries and the lookup throws before anything is installed. + * Rebuild one Loader entry: client-hmr's registry-first fiber teardown, then + * `entry.refresh()`. Each client's module table retains its own instance-bound + * Connection plugin, so reloads do not coordinate through process globals. + * Requires a live client: after `dispose()` the Loader holds no entries and + * the lookup throws before teardown. * @param name - package name of the row. */ async reload(name: string): Promise { const entry = this.entryOf(name) - await takeTurn(async () => { - sharedGlobals.install(this.transport) - await tearDownEntryFiber(entry) - await entry.refresh() - await this.ctx.loader.await() - }) + await tearDownEntryFiber(entry) + await entry.refresh() + await this.ctx.loader.await() } /** @@ -315,7 +285,7 @@ export class TestClient { /** * Dispose the plugin tree, then drop an owned mount and release this - * client's hold on the shared globals even when the tree fails to dispose, + * client's hold on the shared jsdom shims even when the tree fails to dispose, * then `mock.assertNoUnmatched()` last so its failure is the test's reason * without skipping the cleanup; when both the tree and the check fail, one * error carries both messages. The first call owns the teardown and reports diff --git a/packages/test-support/client-runtime/tests/assembly-test-client-node.client.spec.ts b/packages/test-support/client-runtime/tests/assembly-test-client-node.client.spec.ts index 26afee3272..3fa9aa305b 100644 --- a/packages/test-support/client-runtime/tests/assembly-test-client-node.client.spec.ts +++ b/packages/test-support/client-runtime/tests/assembly-test-client-node.client.spec.ts @@ -19,7 +19,7 @@ describe('TestClient (node environment)', () => { await client.flush() }, 60_000) - it('releases the shared globals even when the plugin tree fails to dispose, and still rethrows', async () => { + it('closes the client even when the plugin tree fails to dispose, and still rethrows', async () => { const mock = RemoteMock.create().load(remoteDefaultResponses) const client = await TestClient.start({ roster: API_ROSTER }, mock) const dispose = client.ctx.fiber.dispose.bind(client.ctx.fiber) @@ -38,7 +38,7 @@ describe('TestClient (node environment)', () => { await client.dispose() // idempotent after a failed teardown }) - it('rethrows a row that fails to apply, releases the globals, and lets the next boot take its turn', async () => { + it('rethrows a row that fails to apply and lets the next client boot', async () => { const failing = { apply(): void { throw new Error('apply boom') } } await expect(TestClient.start({ roster: TYPERT_ONLY, provide: { '@deepseek-ai/dsh-typert-registry': failing } }, RemoteMock.create())) .rejects.toThrow(/apply boom|typert-registry/) @@ -84,24 +84,24 @@ describe('TestClient (node environment)', () => { await first }) - it('refuses to mount without a DOM before installing the transport', async () => { + it('refuses to mount without a DOM without touching the page transport', async () => { await expect(TestClient.start({ roster: API_ROSTER }, RemoteMock.create(), { mount: true })) .rejects.toThrow('mount requires a DOM') expect(globals.__DSH_TRANSPORT__).toBeUndefined() }) - it('fails loud, then restores the transport, when the roster cannot provide a connection', async () => { + it('fails loud without touching the page transport when the roster cannot provide a connection', async () => { await expect(TestClient.start({ roster: TYPERT_ONLY }, RemoteMock.create())) .rejects.toThrow('provides no `connection` service') expect(globals.__DSH_TRANSPORT__).toBeUndefined() }) - it('skips readiness on request and restores a pre-existing transport on dispose', async () => { + it('skips readiness on request and leaves a pre-existing page transport untouched', async () => { const previous: ClientTransportHooks = { fetch: () => Promise.reject(new Error('unused')) } globals.__DSH_TRANSPORT__ = previous onTestFinished(() => { delete globals.__DSH_TRANSPORT__ }) const client = await TestClient.start({ roster: TYPERT_ONLY }, RemoteMock.create(), { awaitConnected: false }) - expect(globals.__DSH_TRANSPORT__).not.toBe(previous) + expect(globals.__DSH_TRANSPORT__).toBe(previous) expect(client.ctx.get('typert')).toBeDefined() expect(() => client.connection).toThrow('provides no `connection` service') await client.dispose() diff --git a/packages/test-support/client-runtime/tests/assembly-test-client.client.spec.ts b/packages/test-support/client-runtime/tests/assembly-test-client.client.spec.ts index b4d28d9fc6..33d6a9b12e 100644 --- a/packages/test-support/client-runtime/tests/assembly-test-client.client.spec.ts +++ b/packages/test-support/client-runtime/tests/assembly-test-client.client.spec.ts @@ -2,22 +2,24 @@ /** * TestClient over the web profile's roster read from its bundles: production * `bootClient` over in-process modules, every Remote call answered by a - * `RemoteMock` installed as the Connection carrier, mount, HMR-style reload, + * `RemoteMock` bound to that client's Connection instance, mount, HMR-style reload, * unload, and fail-loud teardown. */ import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import { RemoteMock, ok, openStream } from '@deepseek-ai/dsh-remote-mock' import { describe, expect, it, onTestFinished, vi } from 'vitest' -import type { AssemblyPlan, TestClientOptions } from '../src/assembly/index.ts' +import type { AssemblyPlan, ClientPluginModule, TestClientOptions } from '../src/assembly/index.ts' import { ClientRoster, TestClient, remoteDefaultResponses, webApp } from '../src/assembly/index.ts' /** The Gateway client and what it injects: the Typert registry and the Connection. */ const API_ROSTER = webApp.closure(['@deepseek-ai/dsh-api-gateway']) +const MODULES = '@deepseek-ai/dsh-client-modules' const SIDEBAR = '@deepseek-ai/dsh-client-ui-sidebar' +const PARALLEL_PROBE = '@deepseek-ai/dsh-client-test-parallel-probe' /** Declared by ui-sidebar, whose SlotMap merge is outside this package's compilation face. */ const SIDEBAR_SETTINGS = 'sidebar.settings' as never const BRAND = '@deepseek-ai/dsh-client-ui-brand-official' -const globals = globalThis as { __DSH_TRANSPORT__?: unknown; EventSource?: unknown; ResizeObserver?: unknown } +const globals = globalThis as { EventSource?: unknown; ResizeObserver?: unknown } /** The whole roster's first boot pays the cold module transform of every plugin package. */ const COLD_BOOT_TIMEOUT_MS = 60_000 @@ -37,12 +39,12 @@ describe('TestClient (jsdom)', () => { const container = client.container! expect(document.body.contains(container)).toBe(true) expect(container.childElementCount).toBeGreaterThan(0) - expect(globals.__DSH_TRANSPORT__).toBeDefined() + expect('__DSH_TRANSPORT__' in globalThis).toBe(false) expect(globals.EventSource).toBeDefined() expect(globals.ResizeObserver).toBeDefined() await client.dispose() expect(document.body.contains(container)).toBe(false) - expect(globals.__DSH_TRANSPORT__).toBeUndefined() + expect('__DSH_TRANSPORT__' in globalThis).toBe(false) expect(globals.EventSource).toBeUndefined() expect(globals.ResizeObserver).toBeUndefined() await client.dispose() @@ -60,7 +62,7 @@ describe('TestClient (jsdom)', () => { expect(globals.EventSource).toBeUndefined() }) - it('boots overlapping clients one at a time onto their own mocks and keeps the shared globals until the last dispose', async () => { + it('boots separate client instances against their own mocks and keeps shared shims until the last dispose', async () => { const mockA = RemoteMock.create().load(remoteDefaultResponses).unary('session/rename', ok({ title: 'a', seq: 1 })) const mockB = RemoteMock.create().load(remoteDefaultResponses).unary('session/rename', ok({ title: 'b', seq: 1 })) const [a, b] = await Promise.all([ @@ -74,20 +76,64 @@ describe('TestClient (jsdom)', () => { await expect(rename(b)).resolves.toEqual({ ok: true, value: { title: 'b', seq: 1 } }) expect(mockA.log.calls('session/rename')).toHaveLength(1) expect(mockB.log.calls('session/rename')).toHaveLength(1) - // A rebuilt connection row reads its own mock even after another client installed the transport last. await a.reload('@deepseek-ai/dsh-client-connection') await vi.waitFor(() => { expect(a.connection.state.getSnapshot()).toBe('connected') }) await expect(rename(a)).resolves.toEqual({ ok: true, value: { title: 'a', seq: 1 } }) expect(mockA.log.calls('session/rename')).toHaveLength(2) expect(mockB.log.calls('session/rename')).toHaveLength(1) await a.dispose() - expect(globals.__DSH_TRANSPORT__).toBeDefined() + expect('__DSH_TRANSPORT__' in globalThis).toBe(false) expect(globals.EventSource).toBeDefined() await b.dispose() - expect(globals.__DSH_TRANSPORT__).toBeUndefined() + expect('__DSH_TRANSPORT__' in globalThis).toBe(false) expect(globals.EventSource).toBeUndefined() }) + it('boots two plugin trees concurrently instead of serializing the worker', async () => { + let arrivals = 0 + const gate = Promise.withResolvers() + const overlap = Promise.withResolvers() + const probe: ClientPluginModule = { + async apply() { + arrivals += 1 + if (arrivals === 2) overlap.resolve(undefined) + await gate.promise + }, + } + const roster = ClientRoster.of([ + { name: MODULES, inject: [], immediately: true }, + { name: PARALLEL_PROBE, inject: [], immediately: false }, + ]) + const first = TestClient.start({ roster, provide: { [PARALLEL_PROBE]: probe } }, RemoteMock.create(), { awaitConnected: false }) + const second = TestClient.start({ roster, provide: { [PARALLEL_PROBE]: probe } }, RemoteMock.create(), { awaitConnected: false }) + let overlapFailure: unknown + try { + await vi.waitFor(() => { expect(arrivals).toBe(2) }, { timeout: 5_000 }) + await overlap.promise + } catch (error) { + overlapFailure = error + } finally { + gate.resolve(undefined) + } + const clients = await Promise.all([first, second]) + for (const client of clients) onTestFinished(() => client.dispose()) + if (overlapFailure !== undefined) throw overlapFailure + }, COLD_BOOT_TIMEOUT_MS) + + it('reloads the bootstrap modules row against its own Loader internal', async () => { + const roster = ClientRoster.of([{ name: MODULES, inject: [], immediately: true }]) + const a = await TestClient.start({ roster }, RemoteMock.create(), { awaitConnected: false }) + onTestFinished(() => a.dispose()) + const b = await TestClient.start({ roster }, RemoteMock.create(), { awaitConnected: false }) + onTestFinished(() => b.dispose()) + const modulesA = a.ctx.modules + const modulesB = b.ctx.modules + expect(modulesA).not.toBe(modulesB) + await a.reload(MODULES) + expect(a.ctx.modules).toBe(modulesA) + expect(b.ctx.modules).toBe(modulesB) + }) + it('boots the api subset without a mount and exposes the typed Remote', async () => { const client = await started({ roster: API_ROSTER }) expect(client.container).toBeUndefined() @@ -100,7 +146,7 @@ describe('TestClient (jsdom)', () => { await expect(TestClient.start({ roster: API_ROSTER }, RemoteMock.create().load(remoteDefaultResponses), { mount: true })) .rejects.toThrow('mount requested, but the roster provides no `uiRenderer`') expect(document.body.childElementCount).toBe(before) - expect(globals.__DSH_TRANSPORT__).toBeUndefined() + expect('__DSH_TRANSPORT__' in globalThis).toBe(false) }) it('creates no mount element when the roster cannot be loaded', async () => { @@ -108,7 +154,7 @@ describe('TestClient (jsdom)', () => { const before = document.body.childElementCount await expect(TestClient.start({ roster }, RemoteMock.create(), { mount: true })).rejects.toThrow() expect(document.body.childElementCount).toBe(before) - expect(globals.__DSH_TRANSPORT__).toBeUndefined() + expect('__DSH_TRANSPORT__' in globalThis).toBe(false) }) it('mounts into a caller-supplied element and leaves it in place on dispose', async () => { @@ -189,7 +235,7 @@ describe('TestClient (jsdom)', () => { const mock = RemoteMock.create().stream('$events', openStream([])) await expect(TestClient.start({ roster }, mock, { connectTimeoutMs: 300 })) .rejects.toThrow(/connection state is \S+ after 300ms; unmatched: \[unary workspace\/follow\]; streams: \[.*\$events \(open\).*\]/) - expect(globals.__DSH_TRANSPORT__).toBeUndefined() + expect('__DSH_TRANSPORT__' in globalThis).toBe(false) expect(globals.EventSource).toBeUndefined() }) }) diff --git a/packages/test-support/remote-mock/README.i18n.yaml b/packages/test-support/remote-mock/README.i18n.yaml index 66722a563c..b34a83ebed 100644 --- a/packages/test-support/remote-mock/README.i18n.yaml +++ b/packages/test-support/remote-mock/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/test-support/remote-mock/README.md -README.md: c3182b61a67aa6af799fb4fbac85083fbe805e59 -README.zh.md: 8199bc748d96df34de28d225db1289d76be6eed2 +README.md: 82979e37052aecee6ec716702f634d25c969f5ad +README.zh.md: 201ca011410dd5a4d65b6ebdfb0c47f23742b0d3 diff --git a/packages/test-support/remote-mock/README.md b/packages/test-support/remote-mock/README.md index c3182b61a6..82979e3705 100644 --- a/packages/test-support/remote-mock/README.md +++ b/packages/test-support/remote-mock/README.md @@ -26,7 +26,7 @@ English | [中文](README.zh.md) ### When to use it -Use it when a spec boots real client plugins that talk to `ctx.remote` and wants to script the Host side by endpoint name: whole-client jsdom specs through `__DSH_TRANSPORT__`, and unit specs that call `dispatch` / `open` directly. Endpoints are the Gateway's wire names (`session/page`, `settings/describe`); `args` is the caller's positional argument list with a trailing `AbortSignal` removed; a value is whatever the test registers and is answered unchanged. The only declaration is whether an endpoint is unary (`unary`) or a stream (`stream`). +Use it when a spec boots real client plugins that talk to `ctx.remote` and wants to script the Host side by endpoint name: whole-client specs bind `mock.rpc` to their Connection instance, while unit specs may call `mock.remote`, `dispatch`, or `open` directly. Endpoints are the Gateway's wire names (`session/page`, `settings/describe`); `args` is the caller's positional argument list with a trailing `AbortSignal` removed; a value is whatever the test registers and is answered unchanged. The only declaration is whether an endpoint is unary (`unary`) or a stream (`stream`). ### Use the Remote proxy @@ -77,7 +77,7 @@ A failed stream rejects the consumer's next read with the given `Error`. Consume ### Connect a client -`mock.rpc` is the `ClientConnectionRpc` face: install it as `globalThis.__DSH_TRANSPORT__ = { rpc: mock.rpc }` and the production `connection` plugin uses it in place of the HTTP caller, so every Remote call reaches `dispatch` and every stream `open` with no envelopes in between. Payloads carry `{ args }` as the whole-client proxies send them (an array) or as the Gateway's own endpoints send them (one object, delivered as one positional arg); a call whose signal aborts rejects with the abort reason. `RemoteMock.create()` registers one stream, `$events`, that answers the Gateway client's opening with `{ type: 'ready', clientId, host: { home } }` (host from `RemoteMockOptions.host`, default `/home/mock`) and stays open, which is what lets the assembled client reach `connected`; a spec overrides or fails it like any other stream. +`mock.rpc` is the `ClientConnectionRpc` face. Pass it as `{ transport: { rpc: mock.rpc } }` to the Connection installer, or let `TestClient` bind it to its instance, so every Remote call reaches `dispatch` and every stream reaches `open` with no envelopes in between. Payloads carry `{ args }` as the whole-client proxies send them (an array) or as the Gateway's own endpoints send them (one object, delivered as one positional arg); a call whose signal aborts rejects with the abort reason. `RemoteMock.create()` registers one stream, `$events`, that answers the Gateway client's opening with `{ type: 'ready', clientId, host: { home } }` (host from `RemoteMockOptions.host`, default `/home/mock`) and stays open, which is what lets the assembled client reach `connected`; a spec overrides or fails it like any other stream. ### Observe and assert @@ -129,7 +129,7 @@ None; this package neither assembles nor sends a provider request. -- **In-process carrier only** — `rpc` serves a client in the same realm through `__DSH_TRANSPORT__.rpc`; no HTTP or WebSocket carrier for browser-lane specs is provided. +- **In-process carrier only** — `rpc` serves a Connection instance in the same realm; no HTTP or WebSocket carrier for browser-lane specs is provided. - **Values cross by reference** — answers and stream items reach the client unserialized, so a non-JSON value that the real wire would reject passes through unchanged. - **Values are not checked** — a unary answer must be the result the caller reads (`{ ok, value }` or `{ ok: false, error }`); the mock passes it through unchanged and does not check those fields. - **No payload matching** — rules match on endpoint only; discriminate on business arguments inside a handler. diff --git a/packages/test-support/remote-mock/README.zh.md b/packages/test-support/remote-mock/README.zh.md index 8199bc748d..201ca01141 100644 --- a/packages/test-support/remote-mock/README.zh.md +++ b/packages/test-support/remote-mock/README.zh.md @@ -26,7 +26,7 @@ kind: "package-library" ### 何时使用 -当测试要启动与 `ctx.remote` 对话的真实客户端插件、并想按端点名脚本化 Host 侧时使用它:经 `__DSH_TRANSPORT__` 的整体 jsdom 测试,以及直接调用 `dispatch` / `open` 的单元测试。端点是 Gateway 的 wire 名(`session/page`、`settings/describe`);`args` 是调用方的位置参数列表,末尾的 `AbortSignal` 已剥掉;值就是测试登记的东西,原样应答。唯一的声明是端点是一元(`unary`)还是流(`stream`)。 +当测试要启动与 `ctx.remote` 对话的真实客户端插件、并想按端点名脚本化 Host 侧时使用它:整体客户端测试把 `mock.rpc` 绑定到各自的 Connection 实例,单元测试也可以直接调用 `mock.remote`、`dispatch` 或 `open`。端点是 Gateway 的 wire 名(`session/page`、`settings/describe`);`args` 是调用方的位置参数列表,末尾的 `AbortSignal` 已剥掉;值就是测试登记的东西,原样应答。唯一的声明是端点是一元(`unary`)还是流(`stream`)。 ### 使用 Remote Proxy @@ -77,7 +77,7 @@ await mock.streams.drained('session/follow') ### 接上客户端 -`mock.rpc` 是 `ClientConnectionRpc` 面:装成 `globalThis.__DSH_TRANSPORT__ = { rpc: mock.rpc }`,生产的 `connection` 插件就用它替代 HTTP 调用方,每次 Remote 调用直达 `dispatch`、每条流直达 `open`,中间没有信封。payload 携带 `{ args }`——整机代理发数组、Gateway 自身端点发一个对象(到达时是一个位置参数);signal 中止的调用以中止原因 reject。`RemoteMock.create()` 登记一条流 `$events`,用 `{ type: 'ready', clientId, host: { home } }`(host 来自 `RemoteMockOptions.host`,默认 `/home/mock`)应答 Gateway 客户端的打开并保持打开——这正是整机能达到 `connected` 的原因;测试可以像任何流一样覆盖或让它失败。 +`mock.rpc` 是 `ClientConnectionRpc` 面。把它作为 `{ transport: { rpc: mock.rpc } }` 传给 Connection 安装函数,或让 `TestClient` 将其绑定到自身实例,每次 Remote 调用就会直达 `dispatch`、每条流直达 `open`,中间没有信封。payload 携带 `{ args }`——整机代理发数组、Gateway 自身端点发一个对象(到达时是一个位置参数);signal 中止的调用以中止原因 reject。`RemoteMock.create()` 登记一条流 `$events`,用 `{ type: 'ready', clientId, host: { home } }`(host 来自 `RemoteMockOptions.host`,默认 `/home/mock`)应答 Gateway 客户端的打开并保持打开——这正是整机能达到 `connected` 的原因;测试可以像任何流一样覆盖或让它失败。 ### 观察与断言 @@ -129,7 +129,7 @@ await mock.streams.drained('session/follow') -- **仅进程内载体**——`rpc` 经 `__DSH_TRANSPORT__.rpc` 服务同一 realm 的客户端;不提供给浏览器车道测试用的 HTTP 或 WebSocket 载体。 +- **仅进程内载体**——`rpc` 服务同一 realm 中的 Connection 实例;不提供给浏览器车道测试用的 HTTP 或 WebSocket 载体。 - **值按引用传递**——应答与流项都未经序列化就到达客户端,真实线路会拒绝的非 JSON 值在这里原样通过。 - **不校验值**——一元应答必须是调用方读取的结果(`{ ok, value }` 或 `{ ok: false, error }`);mock 原样传递它,不检查这些字段。 - **不做 payload 匹配**——规则只按端点匹配;在 handler 内按业务参数判别。 diff --git a/packages/test-support/remote-mock/src/index.ts b/packages/test-support/remote-mock/src/index.ts index 9c78ee3a27..d7c1850c2e 100644 --- a/packages/test-support/remote-mock/src/index.ts +++ b/packages/test-support/remote-mock/src/index.ts @@ -2,7 +2,7 @@ * Endpoint-named mock for Typert Remote traffic: a table of unary answers and * stream scripts keyed by `/`, scripted-stream control, a * carrier log, and the Connection carrier face the `connection` plugin - * accepts through `__DSH_TRANSPORT__.rpc`. Values are whatever the test + * accepts through explicit installation or a page transport. Values are whatever the test * registers; the only declaration is whether an endpoint is unary or a * stream. Browser-safe: no DOM, React, or Node imports, no runtime import * from another harness package. diff --git a/packages/test-support/remote-mock/src/remote-mock.ts b/packages/test-support/remote-mock/src/remote-mock.ts index 00d10668a3..fca6a7a09c 100644 --- a/packages/test-support/remote-mock/src/remote-mock.ts +++ b/packages/test-support/remote-mock/src/remote-mock.ts @@ -90,8 +90,7 @@ class MissingUnaryRule extends Error {} /** * Endpoint-named Remote mock. `dispatch` / `open` are the core; `rpc` is the - * same core as the Connection carrier the `connection` plugin accepts through - * `__DSH_TRANSPORT__.rpc`. + * same core as the decoded carrier accepted by the Connection installer. */ export class RemoteMock { /** From 77db56687b4e3f237ea41876e04c66af2b5a5d07 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 11 Sep 2026 02:16:18 +0800 Subject: [PATCH 58/79] test(ui-message-feedback): use RemoteMock --- ...-09-06-client-assembly-test-line.i18n.yaml | 4 +- .../2026-09-06-client-assembly-test-line.md | 2 +- ...2026-09-06-client-assembly-test-line.zh.md | 2 +- .../client/ui-message-feedback/package.json | 1 + .../src/client/controller.ts | 13 +- .../ui-message-feedback/src/client/surface.ts | 2 +- .../tests/controller.client.spec.ts | 357 ++++++++---------- pnpm-lock.yaml | 3 + 8 files changed, 166 insertions(+), 218 deletions(-) diff --git a/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.i18n.yaml b/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.i18n.yaml index 5b40ff8642..b4898e5114 100644 --- a/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.md -2026-09-06-client-assembly-test-line.md: 36f7e2ab11248628dca7aff53cae4a9c9f84e441 -2026-09-06-client-assembly-test-line.zh.md: 0ef64a97e25b062a858ddbd1796f86e59591cd51 +2026-09-06-client-assembly-test-line.md: f3b43ec9a4b08ac7b3a9fc802136ae916da9eb65 +2026-09-06-client-assembly-test-line.zh.md: 95445c584e7484080610da2347a968f5202f14f8 diff --git a/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.md b/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.md index 36f7e2ab11..f3b43ec9a4 100644 --- a/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.md +++ b/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.md @@ -89,4 +89,4 @@ Product facts the tier surfaced and leaves as they are: ## Testing -`packages/test-support/remote-mock/tests/` covers rules, streams, the log, and the carrier face; the `assembly-` specs under `packages/test-support/client-runtime/tests/` cover the roster reader on the real bundles and on a scratch installation, module loading, the proxies including their fold, parallel client startup, instance-local module and Connection reload, and `TestClient` under jsdom and plain Node. Seven converted specs use the tier. In `packages/client/ui-settings-general/tests/`, the shell and apply specs boot the whole `web` roster; the apply spec reads its Chinese copy from the Host settings document the mock answers and reconfigures the jsdom page URL for the off-loopback branch. In `packages/api/session-controller/tests/`, the Session, queue-store, and pending-submission specs drive their objects over the roster's real Connection through the `remote.` proxies, with the Gateway client's own `$stream` retry loop, over the gateway's dependency cone, and the client-apply spec boots the plugin's dependency cone, delivering Remote events as emit frames on `$events`. In `packages/api/workspace-controller/tests/`, the transport spec boots the plugin's cone for apply cases and the gateway cone for hand-built stream and controller cases, since a rostered plugin would share the follow endpoint. Each package keeps a `tests/remote/` module with its default responses and frame builders. The fixture tests include an expected assertion failure and independently observe completed client cleanup; settings reload tests observe replaced registration identities, and write tests assert every mutation argument. Teardown-failure tests execute the real tree disposer before reporting the injected failure and observe the `$events` stream's cancelled state. +`packages/test-support/remote-mock/tests/` covers rules, streams, the log, and the carrier face; the `assembly-` specs under `packages/test-support/client-runtime/tests/` cover the roster reader on the real bundles and on a scratch installation, module loading, the proxies including their fold, parallel client startup, instance-local module and Connection reload, and `TestClient` under jsdom and plain Node. Eight converted specs use the tier. In `packages/client/ui-settings-general/tests/`, the shell and apply specs boot the whole `web` roster; the apply spec reads its Chinese copy from the Host settings document the mock answers and reconfigures the jsdom page URL for the off-loopback branch. In `packages/client/ui-message-feedback/tests/`, object-layer specs pass the `messageFeedback` RemoteMock namespace directly. In `packages/api/session-controller/tests/`, the Session, queue-store, and pending-submission specs drive their objects over the roster's real Connection through the `remote.` proxies, with the Gateway client's own `$stream` retry loop, over the gateway's dependency cone, and the client-apply spec boots the plugin's dependency cone, delivering Remote events as emit frames on `$events`. In `packages/api/workspace-controller/tests/`, the transport spec boots the plugin's cone for apply cases and the gateway cone for hand-built stream and controller cases, since a rostered plugin would share the follow endpoint. Each package keeps a `tests/remote/` module with its default responses and frame builders. The fixture tests include an expected assertion failure and independently observe completed client cleanup; settings reload tests observe replaced registration identities, and write tests assert every mutation argument. Teardown-failure tests execute the real tree disposer before reporting the injected failure and observe the `$events` stream's cancelled state. diff --git a/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.zh.md b/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.zh.md index 0ef64a97e2..95445c584e 100644 --- a/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.zh.md +++ b/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.zh.md @@ -89,4 +89,4 @@ spec 起的是真插件:整个 `web` roster 冷启动约五秒、热启动远 ## 测试 -`packages/test-support/remote-mock/tests/` 覆盖规则、流、日志与载体面;`packages/test-support/client-runtime/tests/` 下的 `assembly-` 系列 spec 覆盖在真 bundle 与临时安装上的 roster 读取器、模块加载、含折叠的代理、并行客户端启动、实例局部的模块与 Connection 重载,以及 jsdom 与纯 Node 下的 `TestClient`。七条改造后的 spec 使用本档。`packages/client/ui-settings-general/tests/` 下,shell 与 apply 两条起整个 `web` roster;apply 从 mock 应答的 Host settings 文档读它的中文文案,并为 off-loopback 分支重配 jsdom 页面 URL。`packages/api/session-controller/tests/` 下,Session、queue-store、pending-submission 三条在 gateway 依赖锥上经 `remote.` 代理走 roster 的真 Connection 驱动对象(`$stream` 的重试循环仍是 Gateway 客户端自己的),client-apply 起插件的依赖锥,把 Remote 事件作为 `$events` 上的 emit 帧投递。`packages/api/workspace-controller/tests/` 下,transport 的 apply 用例起插件锥、手工构造流与 controller 的用例起 gateway 锥,因为进了 roster 的插件会共用 follow 端点。每个包在 `tests/remote/` 保有自己的默认响应与帧构造。fixture 测试包含预期的断言失败,并独立观察客户端清理完成;settings 重载测试观察注册身份被替换,写入测试断言全部 mutation 参数。teardown 失败测试先执行真实树清理,再报告注入的失败,并观察 `$events` 流的取消状态。 +`packages/test-support/remote-mock/tests/` 覆盖规则、流、日志与载体面;`packages/test-support/client-runtime/tests/` 下的 `assembly-` 系列 spec 覆盖在真 bundle 与临时安装上的 roster 读取器、模块加载、含折叠的代理、并行客户端启动、实例局部的模块与 Connection 重载,以及 jsdom 与纯 Node 下的 `TestClient`。八条改造后的 spec 使用本档。`packages/client/ui-settings-general/tests/` 下,shell 与 apply 两条起整个 `web` roster;apply 从 mock 应答的 Host settings 文档读它的中文文案,并为 off-loopback 分支重配 jsdom 页面 URL。`packages/client/ui-message-feedback/tests/` 下,对象层 spec 直接传入 RemoteMock 的 `messageFeedback` 命名空间。`packages/api/session-controller/tests/` 下,Session、queue-store、pending-submission 三条在 gateway 依赖锥上经 `remote.` 代理走 roster 的真 Connection 驱动对象(`$stream` 的重试循环仍是 Gateway 客户端自己的),client-apply 起插件的依赖锥,把 Remote 事件作为 `$events` 上的 emit 帧投递。`packages/api/workspace-controller/tests/` 下,transport 的 apply 用例起插件锥、手工构造流与 controller 的用例起 gateway 锥,因为进了 roster 的插件会共用 follow 端点。每个包在 `tests/remote/` 保有自己的默认响应与帧构造。fixture 测试包含预期的断言失败,并独立观察客户端清理完成;settings 重载测试观察注册身份被替换,写入测试断言全部 mutation 参数。teardown 失败测试先执行真实树清理,再报告注入的失败,并观察 `$events` 流的取消状态。 diff --git a/packages/client/ui-message-feedback/package.json b/packages/client/ui-message-feedback/package.json index 051353d43f..08b42a5819 100644 --- a/packages/client/ui-message-feedback/package.json +++ b/packages/client/ui-message-feedback/package.json @@ -55,6 +55,7 @@ "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-message-feedback": "workspace:^", + "@deepseek-ai/dsh-remote-mock": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/cordis": "workspace:^", diff --git a/packages/client/ui-message-feedback/src/client/controller.ts b/packages/client/ui-message-feedback/src/client/controller.ts index 9a47b63e73..f50b248d99 100644 --- a/packages/client/ui-message-feedback/src/client/controller.ts +++ b/packages/client/ui-message-feedback/src/client/controller.ts @@ -7,9 +7,8 @@ * @module @deepseek-ai/dsh-client-ui-message-feedback/client/controller */ -import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots' -import type { MessageId } from '@deepseek-ai/dsh-api-remotes/client' +import type { ClientRemote, MessageId } from '@deepseek-ai/dsh-api-remotes/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { FeedbackRecord } from '@deepseek-ai/dsh-command-feedback/types' import type { @@ -95,11 +94,11 @@ export class MessageFeedbackController implements HostObservable { - const carried = await this.ctx.remote.messageFeedback.put({ + const carried = await this.remote.put({ sessionId: this.sessionId, messageId, rating, @@ -217,7 +216,7 @@ export class MessageFeedbackController implements HostObservable { - const carried = await this.ctx.remote.messageFeedback.delete({ + const carried = await this.remote.delete({ sessionId: this.sessionId, messageId, ifVersion: observed.version, @@ -240,7 +239,7 @@ export class MessageFeedbackController implements HostObservable { - const carried = await this.ctx.remote.messageFeedback.list({ sessionId: this.sessionId }) + const carried = await this.remote.list({ sessionId: this.sessionId }) if (this.disposed) return OK if (!carried.ok) { this.publish({ status: 'error', items: this.view.items, error: carried.error.message }) diff --git a/packages/client/ui-message-feedback/src/client/surface.ts b/packages/client/ui-message-feedback/src/client/surface.ts index b54d04d659..3006350ce9 100644 --- a/packages/client/ui-message-feedback/src/client/surface.ts +++ b/packages/client/ui-message-feedback/src/client/surface.ts @@ -24,7 +24,7 @@ export class FeedbackSurface { * @param sessionId - Session owning the transcript and the remark. */ constructor(private readonly ctx: ClientContext, private readonly sessionId: SessionId) { - this.feedback = new MessageFeedbackController(ctx, sessionId) + this.feedback = new MessageFeedbackController(ctx.remote.messageFeedback, sessionId) this.dialog = new FeedbackDialogController((target, entry) => target.kind === 'message' ? this.feedback.rate(target.messageId, target.rating, entry) : this.recordSession(entry)) diff --git a/packages/client/ui-message-feedback/tests/controller.client.spec.ts b/packages/client/ui-message-feedback/tests/controller.client.spec.ts index 5fe01b2f58..54ed84bd8e 100644 --- a/packages/client/ui-message-feedback/tests/controller.client.spec.ts +++ b/packages/client/ui-message-feedback/tests/controller.client.spec.ts @@ -5,8 +5,8 @@ * reconciles from the authoritative item carried by the reply, mutations * serialize per Session, and a disposed controller stops publishing. */ -import { describe, expect, it, vi } from 'vitest' -import type { Context as ClientContext } from '@deepseek-ai/cordis' +import { RemoteMock, ok } from '@deepseek-ai/dsh-remote-mock' +import { beforeEach, describe, expect, it, vi } from 'vitest' import type { MessageId, SessionId } from '@deepseek-ai/dsh-api-remotes/client' import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import type { @@ -31,49 +31,20 @@ function item(overrides: Partial = {}): MessageFeedbackItem } } -/** A recording fake Remote whose per-method answers are scripted per call. */ -type Script = { - list?: (request: unknown) => Promise - put?: (request: unknown) => Promise - delete?: (request: unknown) => Promise -} +let mock: RemoteMock -/** - * A recording fake context. Scripts return the *business* result; this wraps it - * in the carrier envelope the generated face uses, so specs stay readable. A - * script may also return an already-enveloped `{ok:false,error:RemoteError}` to - * exercise a carrier failure. - */ -function fakeRemote(script: Script = {}) { - const calls: { method: string; request: unknown }[] = [] - const isCarrier = (v: unknown): boolean => - typeof v === 'object' && v !== null && 'ok' in v && v.ok === false - && 'error' in v && 'details' in ((v as { error: object }).error ?? {}) - const record = (method: 'list' | 'put' | 'delete', real: Script[keyof Script], fallback: unknown) => - (request: never): Promise => { - calls.push({ method, request }) - const business = real === undefined ? Promise.resolve(fallback) : real(request) - return business.then(v => (isCarrier(v) ? v : { ok: true, value: v })) as Promise - } - const ctx = { - remote: { - messageFeedback: { - list: record('list', script.list, { ok: true, value: { items: [] } }), - put: record('put', script.put, { ok: true, value: item() }), - delete: record('delete', script.delete, { ok: true, value: { absent: true } }), - }, - }, - } as unknown as ClientContext - return { ctx, calls } -} +beforeEach(() => { + mock = RemoteMock.create() + mock.remote.messageFeedback.list.mockResolvedValue(ok({ ok: true, value: { items: [] } })) + mock.remote.messageFeedback.put.mockResolvedValue(ok({ ok: true, value: item() })) + mock.remote.messageFeedback.delete.mockResolvedValue(ok({ ok: true, value: { absent: true } })) +}) describe('MessageFeedbackController', () => { it('seeds the view from one list read and keys items by message id', async () => { const seeded = item({ note: 'good' }) - const { ctx, calls } = fakeRemote({ - list: () => Promise.resolve({ ok: true, value: { items: [seeded] } }), - }) - const controller = new MessageFeedbackController(ctx, SESSION) + mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({ ok: true, value: { items: [seeded] } })) + const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION) expect(controller.getSnapshot().status).toBe('cold') expect(await controller.ensure()).toEqual({ ok: true }) @@ -81,73 +52,68 @@ describe('MessageFeedbackController', () => { const view = controller.getSnapshot() expect(view.status).toBe('ready') expect(view.items.get(MSG)).toEqual(seeded) - expect(calls).toEqual([{ method: 'list', request: { sessionId: SESSION } }]) + expect(mock.remote.messageFeedback.list).toHaveBeenCalledExactlyOnceWith({ sessionId: SESSION }) }) it('collapses concurrent loads onto one in-flight read', async () => { - const { ctx, calls } = fakeRemote() - const controller = new MessageFeedbackController(ctx, SESSION) + const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION) await Promise.all([controller.ensure(), controller.ensure(), controller.refresh()]) - expect(calls.filter(call => call.method === 'list')).toHaveLength(1) + expect(mock.remote.messageFeedback.list).toHaveBeenCalledTimes(1) }) it('sends ifVersion null for a first rating and the observed version afterwards', async () => { const first = item({ version: version('v1') }) const second = item({ version: version('v2'), rating: 'negative' }) - const { ctx, calls } = fakeRemote({ - put: request => Promise.resolve({ - ok: true, - value: (request as { rating: string }).rating === 'positive' ? first : second, - }), - }) - const controller = new MessageFeedbackController(ctx, SESSION) + mock.remote.messageFeedback.put.mockImplementation(request => Promise.resolve(ok({ + ok: true, + value: request.rating === 'positive' ? first : second, + }))) + const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION) expect(await controller.rate(MSG, 'positive')).toEqual({ ok: true }) expect(await controller.rate(MSG, 'negative')).toEqual({ ok: true }) - const puts = calls.filter(call => call.method === 'put').map(call => call.request) + const puts = mock.remote.messageFeedback.put.mock.calls.map(([request]) => request) expect(puts[0]).toMatchObject({ messageId: MSG, rating: 'positive', ifVersion: null }) expect(puts[1]).toMatchObject({ messageId: MSG, rating: 'negative', ifVersion: version('v1') }) expect(controller.getSnapshot().items.get(MSG)).toEqual(second) }) it('forwards the entry as note and category and omits absent members', async () => { - const { ctx, calls } = fakeRemote() - const controller = new MessageFeedbackController(ctx, SESSION) + const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION) await controller.rate(MSG, 'negative', { text: 'helpful', category: 'task-result' }) await controller.rate(OTHER, 'negative') - const puts = calls.filter(call => call.method === 'put').map(call => call.request as Record) + const puts = mock.remote.messageFeedback.put.mock.calls.map(([request]) => request) expect(puts[0]).toMatchObject({ note: 'helpful', category: 'task-result' }) expect(puts[1]).not.toHaveProperty('note') expect(puts[1]).not.toHaveProperty('category') }) it('retract deletes a matching rating and ignores an opposite judgment', async () => { - const { ctx, calls } = fakeRemote({ - list: () => Promise.resolve({ ok: true, value: { items: [item({ note: 'stored', category: 'other' })] } }), - }) - const controller = new MessageFeedbackController(ctx, SESSION) + mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({ + ok: true, value: { items: [item({ note: 'stored', category: 'other' })] }, + })) + const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION) expect(await controller.retract(MSG, 'negative')).toEqual({ ok: true }) expect(await controller.retract(MSG, 'positive')).toEqual({ ok: true }) - expect(calls.filter(call => call.method === 'put')).toHaveLength(0) - expect(calls.filter(call => call.method === 'delete')[0]?.request) - .toEqual({ sessionId: SESSION, messageId: MSG, ifVersion: version('v1') }) + expect(mock.remote.messageFeedback.put).not.toHaveBeenCalled() + expect(mock.remote.messageFeedback.delete).toHaveBeenCalledExactlyOnceWith({ + sessionId: SESSION, messageId: MSG, ifVersion: version('v1'), + }) }) it('never turns a queued stale retraction into a bare rating put', async () => { const existing = item({ rating: 'positive', version: version('v1') }) const replacement = item({ rating: 'negative', version: version('v2') }) - const { ctx, calls } = fakeRemote({ - list: () => Promise.resolve({ ok: true, value: { items: [existing] } }), - put: () => Promise.resolve({ ok: true, value: replacement }), - }) - const controller = new MessageFeedbackController(ctx, SESSION) + mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({ ok: true, value: { items: [existing] } })) + mock.remote.messageFeedback.put.mockResolvedValueOnce(ok({ ok: true, value: replacement })) + const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION) await controller.ensure() const replaced = controller.rate(MSG, 'negative') @@ -155,20 +121,18 @@ describe('MessageFeedbackController', () => { await expect(replaced).resolves.toEqual({ ok: true }) await expect(staleRetraction).resolves.toEqual({ ok: true }) - expect(calls.filter(call => call.method === 'put')).toHaveLength(1) - expect(calls.filter(call => call.method === 'delete')).toHaveLength(0) + expect(mock.remote.messageFeedback.put).toHaveBeenCalledTimes(1) + expect(mock.remote.messageFeedback.delete).not.toHaveBeenCalled() expect(controller.getSnapshot().items.get(MSG)).toEqual(replacement) }) it('reconciles a version conflict from the authoritative item without refetching', async () => { const authoritative = item({ version: version('v9'), rating: 'negative', note: 'changed elsewhere' }) - const { ctx, calls } = fakeRemote({ - put: () => Promise.resolve({ - ok: false, - error: { code: 'version-conflict', current: authoritative }, - }), - }) - const controller = new MessageFeedbackController(ctx, SESSION) + mock.remote.messageFeedback.put.mockResolvedValueOnce(ok({ + ok: false, + error: { code: 'version-conflict', current: authoritative }, + })) + const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION) expect(await controller.rate(MSG, 'positive')).toEqual({ ok: false, @@ -176,18 +140,16 @@ describe('MessageFeedbackController', () => { }) expect(controller.getSnapshot().items.get(MSG)).toEqual(authoritative) - expect(calls.filter(call => call.method === 'list')).toHaveLength(1) + expect(mock.remote.messageFeedback.list).toHaveBeenCalledTimes(1) }) it('drops the local item when a conflict reports the feedback is gone', async () => { - const { ctx } = fakeRemote({ - list: () => Promise.resolve({ ok: true, value: { items: [item()] } }), - delete: () => Promise.resolve({ - ok: false, - error: { code: 'version-conflict', current: null }, - }), - }) - const controller = new MessageFeedbackController(ctx, SESSION) + mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({ ok: true, value: { items: [item()] } })) + mock.remote.messageFeedback.delete.mockResolvedValueOnce(ok({ + ok: false, + error: { code: 'version-conflict', current: null }, + })) + const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION) await controller.ensure() expect(await controller.retract(MSG, 'positive')).toMatchObject({ ok: false, error: { code: 'version-conflict' } }) @@ -195,16 +157,17 @@ describe('MessageFeedbackController', () => { }) it('deletes with the observed version and removes the item on success', async () => { - const { ctx, calls } = fakeRemote({ - list: () => Promise.resolve({ ok: true, value: { items: [item({ version: version('v7') })] } }), - }) - const controller = new MessageFeedbackController(ctx, SESSION) + mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({ + ok: true, value: { items: [item({ version: version('v7') })] }, + })) + const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION) await controller.ensure() expect(await controller.retract(MSG, 'positive')).toEqual({ ok: true }) - expect(calls.filter(call => call.method === 'delete')[0]?.request) - .toEqual({ sessionId: SESSION, messageId: MSG, ifVersion: version('v7') }) + expect(mock.remote.messageFeedback.delete).toHaveBeenCalledExactlyOnceWith({ + sessionId: SESSION, messageId: MSG, ifVersion: version('v7'), + }) expect(controller.getSnapshot().items.has(MSG)).toBe(false) }) @@ -213,32 +176,30 @@ describe('MessageFeedbackController', () => { let overlapped = false const versions = [version('v1'), version('v2')] let index = 0 - const { ctx, calls } = fakeRemote({ - put: async () => { - inFlight += 1 - if (inFlight > 1) overlapped = true - await Promise.resolve() - inFlight -= 1 - const next = versions[index] ?? version('vN') - index += 1 - return { ok: true, value: item({ version: next }) } - }, + mock.remote.messageFeedback.put.mockImplementation(async () => { + inFlight += 1 + if (inFlight > 1) overlapped = true + await Promise.resolve() + inFlight -= 1 + const next = versions[index] ?? version('vN') + index += 1 + return ok({ ok: true, value: item({ version: next }) }) }) - const controller = new MessageFeedbackController(ctx, SESSION) + const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION) await Promise.all([controller.rate(MSG, 'positive'), controller.rate(MSG, 'negative')]) expect(overlapped).toBe(false) - const puts = calls.filter(call => call.method === 'put').map(call => call.request as Record) + const puts = mock.remote.messageFeedback.put.mock.calls.map(([request]) => request) expect(puts[0]?.ifVersion).toBeNull() expect(puts[1]?.ifVersion).toBe(version('v1')) }) it('publishes an error status when the list read is rejected by the Host', async () => { - const { ctx } = fakeRemote({ - list: () => Promise.resolve({ ok: false, error: { code: 'session-not-found', sessionId: SESSION } }), - }) - const controller = new MessageFeedbackController(ctx, SESSION) + mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({ + ok: false, error: { code: 'session-not-found', sessionId: SESSION }, + })) + const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION) expect(await controller.ensure()).toMatchObject({ ok: false, error: { code: 'session-not-found' } }) expect(controller.getSnapshot()).toMatchObject({ @@ -248,8 +209,7 @@ describe('MessageFeedbackController', () => { }) it('notifies subscribers on publication and stops after unsubscribe', async () => { - const { ctx } = fakeRemote() - const controller = new MessageFeedbackController(ctx, SESSION) + const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION) const listener = vi.fn() const unsubscribe = controller.subscribe(listener) @@ -263,8 +223,7 @@ describe('MessageFeedbackController', () => { }) it('contains a throwing subscriber at the observable boundary', async () => { - const { ctx } = fakeRemote() - const controller = new MessageFeedbackController(ctx, SESSION) + const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION) const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) controller.subscribe(() => { throw new Error('subscriber exploded') }) const healthy = vi.fn() @@ -278,17 +237,16 @@ describe('MessageFeedbackController', () => { }) it('refuses mutations and stops publishing once disposed', async () => { - const { ctx, calls } = fakeRemote() - const controller = new MessageFeedbackController(ctx, SESSION) + const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION) await controller.ensure() const listener = vi.fn() controller.subscribe(listener) controller.dispose() - const before = calls.length + const puts = mock.remote.messageFeedback.put.mock.calls.length expect(await controller.rate(MSG, 'positive')).toMatchObject({ ok: false, error: { code: 'disposed' } }) - expect(calls).toHaveLength(before) + expect(mock.remote.messageFeedback.put).toHaveBeenCalledTimes(puts) expect(listener).not.toHaveBeenCalled() }) @@ -300,20 +258,20 @@ describe('MessageFeedbackController', () => { ['note-too-large', 'the note is too long'], ] as const for (const [code, message] of codes) { - const { ctx } = fakeRemote({ - list: () => Promise.resolve({ ok: false, error: { code, sessionId: SESSION } } as never), - }) - const controller = new MessageFeedbackController(ctx, SESSION) + mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({ + ok: false, error: { code, sessionId: SESSION }, + } as never)) + const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION) expect(await controller.ensure()).toMatchObject({ ok: false, error: { code } }) expect(controller.getSnapshot().error).toBe(message) } }) it('falls back to the raw code for an unrecognized failure', async () => { - const { ctx } = fakeRemote({ - list: () => Promise.resolve({ ok: false, error: { code: 'brand-new-code' } } as never), - }) - const controller = new MessageFeedbackController(ctx, SESSION) + mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({ + ok: false, error: { code: 'brand-new-code' }, + } as never)) + const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION) expect(await controller.ensure()).toMatchObject({ ok: false, error: { code: 'brand-new-code' } }) expect(controller.getSnapshot().error).toBe('brand-new-code') @@ -322,13 +280,11 @@ describe('MessageFeedbackController', () => { it('publishes nothing when the list settles after disposal', async () => { let release = (): void => {} const gate = new Promise((resolve) => { release = resolve }) - const { ctx } = fakeRemote({ - list: async () => { - await gate - return { ok: true, value: { items: [item()] } } - }, + mock.remote.messageFeedback.list.mockImplementationOnce(async () => { + await gate + return ok({ ok: true, value: { items: [item()] } }) }) - const controller = new MessageFeedbackController(ctx, SESSION) + const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION) const pending = controller.ensure() const listener = vi.fn() controller.subscribe(listener) @@ -342,30 +298,28 @@ describe('MessageFeedbackController', () => { }) it('propagates a failed load to a queued mutation without calling the wire', async () => { - const { ctx, calls } = fakeRemote({ - list: () => Promise.resolve({ ok: false, error: { code: 'session-not-found', sessionId: SESSION } }), - }) - const controller = new MessageFeedbackController(ctx, SESSION) + mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({ + ok: false, error: { code: 'session-not-found', sessionId: SESSION }, + })) + const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION) expect(await controller.rate(MSG, 'positive')).toMatchObject({ ok: false, error: { code: 'session-not-found' }, }) - expect(calls.filter(call => call.method === 'put')).toHaveLength(0) + expect(mock.remote.messageFeedback.put).not.toHaveBeenCalled() }) it('keeps a later mutation running after an earlier one settles as a failure', async () => { let first = true - const { ctx } = fakeRemote({ - put: () => { - if (first) { - first = false - return Promise.resolve({ ok: false, error: new RemoteError('gateway/internal', 'first blew up', {}) }) - } - return Promise.resolve({ ok: true, value: item({ rating: 'negative' }) }) - }, + mock.remote.messageFeedback.put.mockImplementation(() => { + if (first) { + first = false + return Promise.resolve({ ok: false as const, error: new RemoteError('gateway/internal', 'first blew up', {}) }) + } + return Promise.resolve(ok({ ok: true, value: item({ rating: 'negative' }) })) }) - const controller = new MessageFeedbackController(ctx, SESSION) + const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION) const [a, b] = await Promise.all([ controller.rate(MSG, 'positive'), @@ -383,14 +337,17 @@ describe('MessageFeedbackController', () => { // fiber unloads, and its authoritative item must not be published. let release = (): void => {} const gate = new Promise((resolve) => { release = resolve }) - const { ctx } = fakeRemote({ - list: () => Promise.resolve({ ok: true, value: { items: [item({ version: version('v1') })] } }), - put: async () => { - await gate - return { ok: false, error: { code: 'version-conflict', current: item({ version: version('v2'), rating: 'negative' }) } } - }, + mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({ + ok: true, value: { items: [item({ version: version('v1') })] }, + })) + mock.remote.messageFeedback.put.mockImplementationOnce(async () => { + await gate + return ok({ + ok: false, + error: { code: 'version-conflict', current: item({ version: version('v2'), rating: 'negative' }) }, + }) }) - const controller = new MessageFeedbackController(ctx, SESSION) + const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION) await controller.ensure() const listener = vi.fn() controller.subscribe(listener) @@ -407,14 +364,12 @@ describe('MessageFeedbackController', () => { it('drops a delete conflict reconciliation once disposed mid-flight', async () => { let release = (): void => {} const gate = new Promise((resolve) => { release = resolve }) - const { ctx } = fakeRemote({ - list: () => Promise.resolve({ ok: true, value: { items: [item()] } }), - delete: async () => { - await gate - return { ok: false, error: { code: 'version-conflict', current: null } } - }, + mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({ ok: true, value: { items: [item()] } })) + mock.remote.messageFeedback.delete.mockImplementationOnce(async () => { + await gate + return ok({ ok: false, error: { code: 'version-conflict', current: null } }) }) - const controller = new MessageFeedbackController(ctx, SESSION) + const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION) await controller.ensure() const pending = controller.retract(MSG, 'positive') @@ -430,11 +385,11 @@ describe('MessageFeedbackController', () => { it('leaves the local item untouched when a rating fails for a non-conflict reason', async () => { const existing = item({ version: version('v3'), rating: 'positive' }) - const { ctx } = fakeRemote({ - list: () => Promise.resolve({ ok: true, value: { items: [existing] } }), - put: () => Promise.resolve({ ok: false, error: { code: 'note-too-large', maxBytes: 8, actualBytes: 9 } }), - }) - const controller = new MessageFeedbackController(ctx, SESSION) + mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({ ok: true, value: { items: [existing] } })) + mock.remote.messageFeedback.put.mockResolvedValueOnce(ok({ + ok: false, error: { code: 'note-too-large', maxBytes: 8, actualBytes: 9 }, + })) + const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION) await controller.ensure() expect(await controller.rate(MSG, 'negative', { text: 'far too long' })).toMatchObject({ @@ -446,11 +401,11 @@ describe('MessageFeedbackController', () => { it('leaves the local item untouched when a delete fails for a non-conflict reason', async () => { const existing = item({ version: version('v4') }) - const { ctx } = fakeRemote({ - list: () => Promise.resolve({ ok: true, value: { items: [existing] } }), - delete: () => Promise.resolve({ ok: false, error: { code: 'session-not-found', sessionId: SESSION } }), - }) - const controller = new MessageFeedbackController(ctx, SESSION) + mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({ ok: true, value: { items: [existing] } })) + mock.remote.messageFeedback.delete.mockResolvedValueOnce(ok({ + ok: false, error: { code: 'session-not-found', sessionId: SESSION }, + })) + const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION) await controller.ensure() expect(await controller.retract(MSG, 'positive')).toMatchObject({ @@ -466,19 +421,17 @@ describe('MessageFeedbackController', () => { const order: string[] = [] let releasePut = (): void => {} const putGate = new Promise((r) => { releasePut = r }) - const { ctx } = fakeRemote({ - list: () => { - order.push('list') - return Promise.resolve({ ok: true, value: { items: [item({ version: version('v1') })] } }) - }, - put: async () => { - order.push('put:start') - await putGate - order.push('put:end') - return { ok: true, value: item({ version: version('v9'), rating: 'negative' }) } - }, + mock.remote.messageFeedback.list.mockImplementation(() => { + order.push('list') + return Promise.resolve(ok({ ok: true, value: { items: [item({ version: version('v1') })] } })) }) - const controller = new MessageFeedbackController(ctx, SESSION) + mock.remote.messageFeedback.put.mockImplementationOnce(async () => { + order.push('put:start') + await putGate + order.push('put:end') + return ok({ ok: true, value: item({ version: version('v9'), rating: 'negative' }) }) + }) + const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION) await controller.ensure() const rating = controller.rate(MSG, 'negative') @@ -498,14 +451,12 @@ describe('MessageFeedbackController', () => { const gate = new Promise((r) => { release = r }) let started = (): void => {} const listStarted = new Promise((r) => { started = r }) - const { ctx, calls } = fakeRemote({ - list: async () => { - started() - await gate - return { ok: true, value: { items: [] } } - }, + mock.remote.messageFeedback.list.mockImplementationOnce(async () => { + started() + await gate + return ok({ ok: true, value: { items: [] } }) }) - const controller = new MessageFeedbackController(ctx, SESSION) + const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION) const pending = controller.rate(MSG, 'positive') await listStarted @@ -513,19 +464,17 @@ describe('MessageFeedbackController', () => { release() expect(await pending).toMatchObject({ ok: false, error: { code: 'disposed' } }) - expect(calls.filter(c => c.method === 'put')).toHaveLength(0) + expect(mock.remote.messageFeedback.put).not.toHaveBeenCalled() }) it('renders a carrier failure from the Remote envelope', async () => { // The generated face folds transport faults into ok:false with a // RemoteFailure, so the controller reads them as values, not rejections. - const { ctx } = fakeRemote({ - list: () => Promise.resolve({ - ok: false, - error: new RemoteError('gateway/internal', 'socket closed', {}), - }), + mock.remote.messageFeedback.list.mockResolvedValueOnce({ + ok: false, + error: new RemoteError('gateway/internal', 'socket closed', {}), }) - const controller = new MessageFeedbackController(ctx, SESSION) + const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION) expect(await controller.ensure()).toEqual({ ok: false, @@ -535,13 +484,11 @@ describe('MessageFeedbackController', () => { }) it('renders a carrier failure on a mutation without touching the view', async () => { - const { ctx } = fakeRemote({ - put: () => Promise.resolve({ - ok: false, - error: new RemoteError('gateway/internal', 'socket closed', {}), - }), + mock.remote.messageFeedback.put.mockResolvedValueOnce({ + ok: false, + error: new RemoteError('gateway/internal', 'socket closed', {}), }) - const controller = new MessageFeedbackController(ctx, SESSION) + const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION) expect(await controller.rate(MSG, 'positive')).toEqual({ ok: false, @@ -551,14 +498,12 @@ describe('MessageFeedbackController', () => { }) it('renders a carrier failure on a delete', async () => { - const { ctx } = fakeRemote({ - list: () => Promise.resolve({ ok: true, value: { items: [item()] } }), - delete: () => Promise.resolve({ - ok: false, - error: new RemoteError('gateway/internal', 'socket closed', {}), - }), + mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({ ok: true, value: { items: [item()] } })) + mock.remote.messageFeedback.delete.mockResolvedValueOnce({ + ok: false, + error: new RemoteError('gateway/internal', 'socket closed', {}), }) - const controller = new MessageFeedbackController(ctx, SESSION) + const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION) await controller.ensure() expect(await controller.retract(MSG, 'positive')).toMatchObject({ ok: false, error: { code: 'gateway/internal' } }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 197c8b792e..40a5e7b7eb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3127,6 +3127,9 @@ importers: '@deepseek-ai/dsh-message-feedback': specifier: workspace:^ version: link:../../feedback/message-feedback + '@deepseek-ai/dsh-remote-mock': + specifier: workspace:^ + version: link:../../test-support/remote-mock '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session From c1504a2aa6df3f733e4b454df0cd4718f2f3f1d6 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 12 Sep 2026 01:40:39 +0800 Subject: [PATCH 59/79] fix(client-modules): resolve the owning Loader instance --- ...-09-06-client-assembly-test-line.i18n.yaml | 4 ++-- .../2026-09-06-client-assembly-test-line.md | 6 ++++-- ...2026-09-06-client-assembly-test-line.zh.md | 6 ++++-- packages/client/connection/README.i18n.yaml | 4 ++-- packages/client/connection/README.md | 2 +- packages/client/connection/README.zh.md | 2 +- .../client/connection/src/client/index.ts | 2 +- packages/client/modules/src/client/index.ts | 5 +++-- .../modules/tests/loader.client.spec.ts | 8 +++++-- .../src/assembly/test-client.ts | 6 +++++- .../assembly-test-client-node.client.spec.ts | 8 ++----- .../tests/assembly-test-client.client.spec.ts | 21 ++++++++++++------- 12 files changed, 44 insertions(+), 30 deletions(-) diff --git a/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.i18n.yaml b/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.i18n.yaml index b4898e5114..fc53e0bdcb 100644 --- a/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.md -2026-09-06-client-assembly-test-line.md: f3b43ec9a4b08ac7b3a9fc802136ae916da9eb65 -2026-09-06-client-assembly-test-line.zh.md: 95445c584e7484080610da2347a968f5202f14f8 +2026-09-06-client-assembly-test-line.md: 9778ed7fea930dadeae7d786d750f53a0a387f23 +2026-09-06-client-assembly-test-line.zh.md: 944008e493a63de278543ae5c941af95943e0f41 diff --git a/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.md b/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.md index f3b43ec9a4..9778ed7fea 100644 --- a/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.md +++ b/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.md @@ -18,7 +18,7 @@ A whole-client tier lives in `@deepseek-ai/dsh-client-test-runtime` under the de **The roster is read from the bundles, never copied.** `bundleRoster(bundles)` parses each bundle's `dsh.bundle.patch` with the include plugin's own YAML dialect (`entryListSchema`, which carries `!!js`) and composes the layers with its `applyEntryPatches`, then keeps every enabled row whose package declares `dsh.client.platform === 'web'`, carrying that declaration's `inject` and `immediately`. `webApp` is the `web` profile's roster (`dsh-base`, then `dsh-web-app`), computed at import. A spec names what it tests and derives the rest: `webApp.closure([row])` keeps a row plus its transitive `inject` cone; `pick` and `without` exist for deliberate cuts. The test runtime stays a Client-face package: it imports no Host module, uses no dynamic import for this, and its client-face `types` adds `node` beside `client-build-environment` so the reader can use `node:fs`. `closure` treats the shell's static platform modules (`PLATFORM_MODULES`) as satisfied without a row. -**The production boot path runs unchanged.** `TestClient.start(plan, mock)` loads each roster row's `/client` module, binds the Connection row to `installConnection(ctx, { transport: { rpc: mock.rpc } })`, registers the resulting factories through the production module facade's `pendingQueue`, boots through `bootClient`, optionally mounts through `mountClient`, and waits for `connected`. `reload(name)` rebuilds a Loader entry through client-hmr's exported `tearDownEntryFiber`; `unload(name)` removes it; `dispose()` tears down and then fails the test on any endpoint that had no rule. jsdom lacks `EventSource` and `ResizeObserver`; `start` reference-counts inert stand-ins only where the global is absent. Module systems, Connection carriers, Remote mocks, reloads, and disposal are instance-owned, so clients start concurrently and do not coordinate through page globals. +**The production boot path runs with one Connection substitution.** `TestClient.start(plan, mock)` loads each roster row's `/client` module, replaces the Connection row's page-global adapter with `installConnection(ctx, { transport: { rpc: mock.rpc } })`, registers the resulting factories through the production module facade's `pendingQueue`, boots through `bootClient`, optionally mounts through `mountClient`, and waits for `connected`. `reload(name)` rebuilds a Loader entry through client-hmr's exported `tearDownEntryFiber`; `unload(name)` removes it; `dispose()` tears down and then fails the test on any endpoint that had no rule. jsdom lacks `EventSource` and `ResizeObserver`; `start` reference-counts inert stand-ins only where the global is absent. Module systems, Connection carriers, Remote mocks, reloads, and disposal are instance-owned, so clients start concurrently and do not coordinate through page globals. The modules plugin reads the module system from its own `ctx.loader.internal` when it activates. Reloading the bootstrap row republishes that same instance without invalidating its bootstrap code, while another client's Loader and `ctx.modules` remain independent. @@ -52,6 +52,8 @@ The modules plugin reads the module system from its own `ctx.loader.internal` wh **A mock module standing in for the Gateway client.** Rejected: the mock must not interfere with Gateway internals; passing it to the production Connection installer keeps retry, folding, and stream semantics real. +**A worker-level boot turn around a page-global Connection carrier.** Rejected: clients would share mutable process state, and every start or Connection reload would need serialization to install the acting client's carrier. Instance-local Connection installation lets independent plugin trees start and reload concurrently. + **A second typed Gateway implementation with an `Api` generic, envelope and error classes, and a fixtures directory.** Rejected: it duplicates Gateway declarations and encoding. The mock derives method types from the generated namespace map and declares only unary or stream behavior at runtime. **Proxies passing unary rejections through unchanged.** Rejected: product code never awaits a Remote rejection because the generated client folds carrier throws, so an unmatched endpoint produced unhandled rejections; folding through the exported helpers restores the client's face. @@ -89,4 +91,4 @@ Product facts the tier surfaced and leaves as they are: ## Testing -`packages/test-support/remote-mock/tests/` covers rules, streams, the log, and the carrier face; the `assembly-` specs under `packages/test-support/client-runtime/tests/` cover the roster reader on the real bundles and on a scratch installation, module loading, the proxies including their fold, parallel client startup, instance-local module and Connection reload, and `TestClient` under jsdom and plain Node. Eight converted specs use the tier. In `packages/client/ui-settings-general/tests/`, the shell and apply specs boot the whole `web` roster; the apply spec reads its Chinese copy from the Host settings document the mock answers and reconfigures the jsdom page URL for the off-loopback branch. In `packages/client/ui-message-feedback/tests/`, object-layer specs pass the `messageFeedback` RemoteMock namespace directly. In `packages/api/session-controller/tests/`, the Session, queue-store, and pending-submission specs drive their objects over the roster's real Connection through the `remote.` proxies, with the Gateway client's own `$stream` retry loop, over the gateway's dependency cone, and the client-apply spec boots the plugin's dependency cone, delivering Remote events as emit frames on `$events`. In `packages/api/workspace-controller/tests/`, the transport spec boots the plugin's cone for apply cases and the gateway cone for hand-built stream and controller cases, since a rostered plugin would share the follow endpoint. Each package keeps a `tests/remote/` module with its default responses and frame builders. The fixture tests include an expected assertion failure and independently observe completed client cleanup; settings reload tests observe replaced registration identities, and write tests assert every mutation argument. Teardown-failure tests execute the real tree disposer before reporting the injected failure and observe the `$events` stream's cancelled state. +`packages/test-support/remote-mock/tests/` covers rules, streams, the log, and the carrier face; the `assembly-` specs under `packages/test-support/client-runtime/tests/` cover the roster reader on the real bundles and on a scratch installation, module loading, the proxies including their fold, parallel client startup, instance-local module and Connection reload, and `TestClient` under jsdom and plain Node. Seven converted specs use the whole-client tier. In `packages/client/ui-settings-general/tests/`, the shell and apply specs boot the whole `web` roster; the apply spec reads its Chinese copy from the Host settings document the mock answers and reconfigures the jsdom page URL for the off-loopback branch. In `packages/client/ui-message-feedback/tests/`, object-layer specs pass the `messageFeedback` RemoteMock namespace directly. In `packages/api/session-controller/tests/`, the Session, queue-store, and pending-submission specs drive their objects over the roster's real Connection through the `remote.` proxies, with the Gateway client's own `$stream` retry loop, over the gateway's dependency cone, and the client-apply spec boots the plugin's dependency cone, delivering Remote events as emit frames on `$events`. In `packages/api/workspace-controller/tests/`, the transport spec boots the plugin's cone for apply cases and the gateway cone for hand-built stream and controller cases, since a rostered plugin would share the follow endpoint. Each package keeps a `tests/remote/` module with its default responses and frame builders. The fixture tests include an expected assertion failure and independently observe completed client cleanup; settings reload tests observe replaced registration identities, and write tests assert every mutation argument. Teardown-failure tests execute the real tree disposer before reporting the injected failure and observe the `$events` stream's cancelled state. diff --git a/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.zh.md b/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.zh.md index 95445c584e..944008e493 100644 --- a/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.zh.md +++ b/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.zh.md @@ -18,7 +18,7 @@ API 客户端 spec 用一个可编程的 Remote 面假件驱动对象。这个 **roster 从 bundle 现读,绝不拷贝。** `bundleRoster(bundles)` 用 include 插件自己的 YAML 方言(带 `!!js` 的 `entryListSchema`)解析每个 bundle 的 `dsh.bundle.patch`,用它的 `applyEntryPatches` 合成各层,再保留每个未禁用且其包声明 `dsh.client.platform === 'web'` 的行,带上该声明的 `inject` 与 `immediately`。`webApp` 是 `web` profile 的 roster(先 `dsh-base`、再 `dsh-web-app`),import 时算出。spec 点名它要测的东西,其余推导:`webApp.closure([row])` 保留一行及其传递 `inject` 锥;`pick` 与 `without` 留给刻意裁剪。测试运行时仍是 Client 面的包:它不 import 任何 Host 模块,此处不用动态 import,其 client 面的 `types` 在 `client-build-environment` 之外加了 `node`,好让读取器使用 `node:fs`。`closure` 把 shell 静态种入的平台模块(`PLATFORM_MODULES`)视为无需行即已满足。 -**生产启动路径原样运行。** `TestClient.start(plan, mock)` 加载每个 roster 行的 `/client` 模块,把 Connection 行绑定到 `installConnection(ctx, { transport: { rpc: mock.rpc } })`,经生产模块 facade 的 `pendingQueue` 登记这些工厂,经 `bootClient` 启动,可选经 `mountClient` 挂载,然后等 `connected`。`reload(name)` 经 client-hmr 导出的 `tearDownEntryFiber` 重建一个 Loader entry;`unload(name)` 移除它;`dispose()` 拆掉一切,然后对任何没有规则的端点让测试失败。jsdom 没有 `EventSource` 与 `ResizeObserver`;`start` 只对全局缺失的惰性替身做引用计数。模块系统、Connection 载体、Remote mock、重载和销毁都归实例所有,因此客户端可以并行启动,不通过页面全局变量协调。 +**生产启动路径只替换 Connection 输入适配。** `TestClient.start(plan, mock)` 加载每个 roster 行的 `/client` 模块,用 `installConnection(ctx, { transport: { rpc: mock.rpc } })` 替换 Connection 行的页面全局适配,经生产模块 facade 的 `pendingQueue` 登记这些工厂,经 `bootClient` 启动,可选经 `mountClient` 挂载,然后等 `connected`。`reload(name)` 经 client-hmr 导出的 `tearDownEntryFiber` 重建一个 Loader entry;`unload(name)` 移除它;`dispose()` 拆掉一切,然后对任何没有规则的端点让测试失败。jsdom 没有 `EventSource` 与 `ResizeObserver`;`start` 只对全局缺失的惰性替身做引用计数。模块系统、Connection 载体、Remote mock、重载和销毁都归实例所有,因此客户端可以并行启动,不通过页面全局变量协调。 modules 插件激活时从自己的 `ctx.loader.internal` 读取模块系统。重载 bootstrap 行会重新发布同一个实例而不让 bootstrap 代码失效,另一个客户端的 Loader 与 `ctx.modules` 不受影响。 @@ -52,6 +52,8 @@ modules 插件激活时从自己的 `ctx.loader.internal` 读取模块系统。 **用一个 mock 模块替代 Gateway 客户端。** 否决:mock 不得干涉 Gateway 内部;把它传给生产 Connection 安装函数会让重试、折叠与流语义都保持真实。 +**用 worker 级启动轮次包住页面全局 Connection 载体。** 否决:客户端会共享可变进程状态,每次启动或 Connection 重载都必须串行安装当事客户端的载体。实例局部的 Connection 安装让独立插件树可以并行启动和重载。 + **第二套带类型的 Gateway 实现,包含 `Api` 泛型、信封与错误类以及 fixtures 目录。** 否决:它重复 Gateway 声明与编解码。mock 从生成的命名空间映射派生方法类型,运行时只声明一元或流行为。 **代理把一元拒绝原样直传。** 否决:产品代码从不等待 Remote 的 reject,因为生成客户端会折叠载体抛错,于是没匹配的端点造成未处理的拒绝;经导出的辅助函数折叠恢复了客户端的面。 @@ -89,4 +91,4 @@ spec 起的是真插件:整个 `web` roster 冷启动约五秒、热启动远 ## 测试 -`packages/test-support/remote-mock/tests/` 覆盖规则、流、日志与载体面;`packages/test-support/client-runtime/tests/` 下的 `assembly-` 系列 spec 覆盖在真 bundle 与临时安装上的 roster 读取器、模块加载、含折叠的代理、并行客户端启动、实例局部的模块与 Connection 重载,以及 jsdom 与纯 Node 下的 `TestClient`。八条改造后的 spec 使用本档。`packages/client/ui-settings-general/tests/` 下,shell 与 apply 两条起整个 `web` roster;apply 从 mock 应答的 Host settings 文档读它的中文文案,并为 off-loopback 分支重配 jsdom 页面 URL。`packages/client/ui-message-feedback/tests/` 下,对象层 spec 直接传入 RemoteMock 的 `messageFeedback` 命名空间。`packages/api/session-controller/tests/` 下,Session、queue-store、pending-submission 三条在 gateway 依赖锥上经 `remote.` 代理走 roster 的真 Connection 驱动对象(`$stream` 的重试循环仍是 Gateway 客户端自己的),client-apply 起插件的依赖锥,把 Remote 事件作为 `$events` 上的 emit 帧投递。`packages/api/workspace-controller/tests/` 下,transport 的 apply 用例起插件锥、手工构造流与 controller 的用例起 gateway 锥,因为进了 roster 的插件会共用 follow 端点。每个包在 `tests/remote/` 保有自己的默认响应与帧构造。fixture 测试包含预期的断言失败,并独立观察客户端清理完成;settings 重载测试观察注册身份被替换,写入测试断言全部 mutation 参数。teardown 失败测试先执行真实树清理,再报告注入的失败,并观察 `$events` 流的取消状态。 +`packages/test-support/remote-mock/tests/` 覆盖规则、流、日志与载体面;`packages/test-support/client-runtime/tests/` 下的 `assembly-` 系列 spec 覆盖在真 bundle 与临时安装上的 roster 读取器、模块加载、含折叠的代理、并行客户端启动、实例局部的模块与 Connection 重载,以及 jsdom 与纯 Node 下的 `TestClient`。七条改造后的 spec 使用整机客户端档。`packages/client/ui-settings-general/tests/` 下,shell 与 apply 两条起整个 `web` roster;apply 从 mock 应答的 Host settings 文档读它的中文文案,并为 off-loopback 分支重配 jsdom 页面 URL。`packages/client/ui-message-feedback/tests/` 下,对象层 spec 直接传入 RemoteMock 的 `messageFeedback` 命名空间。`packages/api/session-controller/tests/` 下,Session、queue-store、pending-submission 三条在 gateway 依赖锥上经 `remote.` 代理走 roster 的真 Connection 驱动对象(`$stream` 的重试循环仍是 Gateway 客户端自己的),client-apply 起插件的依赖锥,把 Remote 事件作为 `$events` 上的 emit 帧投递。`packages/api/workspace-controller/tests/` 下,transport 的 apply 用例起插件锥、手工构造流与 controller 的用例起 gateway 锥,因为进了 roster 的插件会共用 follow 端点。每个包在 `tests/remote/` 保有自己的默认响应与帧构造。fixture 测试包含预期的断言失败,并独立观察客户端清理完成;settings 重载测试观察注册身份被替换,写入测试断言全部 mutation 参数。teardown 失败测试先执行真实树清理,再报告注入的失败,并观察 `$events` 流的取消状态。 diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index de8fb6c9a1..756a10b3c6 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/connection/README.md -README.md: e86d73ba24ee52ceea618ae79fd43445ed085083 -README.zh.md: daecaee091bf98a665eddb91211512bc886cea7b +README.md: 0010f93a7fa06055756b7f07467eb01a685cfe39 +README.zh.md: 8db4ef1f558be022af54c3b7c617124b8649e9d9 diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index e86d73ba24..0010f93a7f 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -25,7 +25,7 @@ The package carries browser-to-Host Remote calls, exact Fetch responses, and con ## Use this package -The browser uses HTTP POST for Remote unary calls. API Gateway owns the `/api/remote.mux` WebSocket and its logical streams; shell-owned compositions provide equivalent Remote streams through `connection.rpc.open` without opening a WebSocket. The browser plugin reads the page transport, recovery settings, and location, then delegates to `installConnection(ctx, options)`; non-page compositions call the same installer with an explicit carrier. Each invocation creates one Context-owned service, so several Client trees can use different carriers in one realm. The Host half always provides the carrier-neutral RPC and exact `GET`/`HEAD`/`POST` route registries. When a Web carrier is present it also owns the sole `/api` route, Fetch bridge, browser authentication, and Host/Origin checks; a shell-owned carrier dispatches the shared Fetch handler directly. Each exact route declares buffered or streaming request-body handling before the bridge reads any bytes. Typert Gateway claims generated Remote endpoints, feature packages register non-JSON responses such as Session-log downloads and raw file uploads, and unclaimed requests return 404. Loopback hostname classification remains package-internal to the browser-facing Client state. Browser raw-body transfer is provided by [`dsh-client-file-upload`](../file-upload/README.md). +The browser uses HTTP POST for Remote unary calls. API Gateway owns the `/api/remote.mux` WebSocket and its logical streams; shell-owned compositions provide equivalent Remote streams through `connection.rpc.open` without opening a WebSocket. The browser plugin reads the page transport, recovery settings, and location, then delegates to `installConnection(ctx, options)`. A composition that owns its carrier may call the same installer directly; the whole-client test tier does so. Each invocation creates one Context-owned service, so several Client trees can use different carriers in one realm. The Host half always provides the carrier-neutral RPC and exact `GET`/`HEAD`/`POST` route registries. When a Web carrier is present it also owns the sole `/api` route, Fetch bridge, browser authentication, and Host/Origin checks; a shell-owned carrier dispatches the shared Fetch handler directly. Each exact route declares buffered or streaming request-body handling before the bridge reads any bytes. Typert Gateway claims generated Remote endpoints, feature packages register non-JSON responses such as Session-log downloads and raw file uploads, and unclaimed requests return 404. Loopback hostname classification remains package-internal to the browser-facing Client state. Browser raw-body transfer is provided by [`dsh-client-file-upload`](../file-upload/README.md). The browser fixture follows the [Session Controller fork semantics](../../api/session-controller/README.md#use-this-package). diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index daecaee091..8db4ef1f55 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -浏览器通过 HTTP POST 执行 Remote 一元调用;API Gateway 自己拥有 `/api/remote.mux` WebSocket 及其逻辑流。由 shell 持有的组合通过 `connection.rpc.open` 提供等价的 Remote 流,不打开 WebSocket。浏览器插件读取页面 transport、恢复设置与 location,再委托 `installConnection(ctx, options)`;非页面组合以显式载体调用同一个安装函数。每次调用都会创建一个归所属 Context 的服务,因此同一 realm 中的多棵 Client 树可以使用不同载体。Host half 始终提供与载体无关的 RPC 注册表和精确 `GET`/`HEAD`/`POST` 路由注册表。存在 Web 载体时,它还持有唯一 `/api` route、Fetch bridge、浏览器认证与 Host/Origin 校验;由 shell 持有的载体则直接分派共享 Fetch handler。每条精确路由会在 bridge 读取任何字节前声明缓冲或流式请求体处理方式。Typert Gateway 认领生成的 Remote endpoint,功能包注册 Session 日志下载、原始文件上传等非 JSON 响应,未认领的请求返回 404。Loopback hostname 判定只供浏览器侧当前页面状态使用,留在包内。浏览器原始请求体传输由 [`dsh-client-file-upload`](../file-upload/README.zh.md) 提供。 +浏览器通过 HTTP POST 执行 Remote 一元调用;API Gateway 自己拥有 `/api/remote.mux` WebSocket 及其逻辑流。由 shell 持有的组合通过 `connection.rpc.open` 提供等价的 Remote 流,不打开 WebSocket。浏览器插件读取页面 transport、恢复设置与 location,再委托 `installConnection(ctx, options)`。持有自身载体的组合可以直接调用同一个安装函数;整机客户端测试档就是这一消费者。每次调用都会创建一个归所属 Context 的服务,因此同一 realm 中的多棵 Client 树可以使用不同载体。Host half 始终提供与载体无关的 RPC 注册表和精确 `GET`/`HEAD`/`POST` 路由注册表。存在 Web 载体时,它还持有唯一 `/api` route、Fetch bridge、浏览器认证与 Host/Origin 校验;由 shell 持有的载体则直接分派共享 Fetch handler。每条精确路由会在 bridge 读取任何字节前声明缓冲或流式请求体处理方式。Typert Gateway 认领生成的 Remote endpoint,功能包注册 Session 日志下载、原始文件上传等非 JSON 响应,未认领的请求返回 404。Loopback hostname 判定只供浏览器侧当前页面状态使用,留在包内。浏览器原始请求体传输由 [`dsh-client-file-upload`](../file-upload/README.zh.md) 提供。 浏览器 fixture 遵循 [Session Controller 的 fork 语义](../../api/session-controller/README.zh.md#use-this-package)。 diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index de95588852..a134400044 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -121,7 +121,7 @@ export interface ConnectionLocation { export interface ConnectionInstallOptions { /** Explicit physical carrier; omit for the browser HTTP + WebSocket carrier. */ readonly transport?: ClientTransportHooks - /** Resolved reconnect timing; omitted fields use controller defaults. */ + /** Reconnect timing overrides; omitted fields use controller defaults. */ readonly recovery?: ConnectionRecoveryConfig /** Page location; omit for a non-browser composition. */ readonly location?: ConnectionLocation diff --git a/packages/client/modules/src/client/index.ts b/packages/client/modules/src/client/index.ts index db6d74bfe6..f99ee7caff 100644 --- a/packages/client/modules/src/client/index.ts +++ b/packages/client/modules/src/client/index.ts @@ -11,6 +11,7 @@ * @module @deepseek-ai/dsh-client-modules/client */ import type { Context } from '@deepseek-ai/cordis' +import type { Loader } from '@deepseek-ai/cordis-plugin-loader' import { ClientModuleSystem } from './system.ts' import { parseBootManifest } from './manifest.ts' import type { @@ -55,8 +56,8 @@ export const inject = ['loader'] * @param ctx - client root context. */ export function apply(ctx: Context): void { - const loader = ctx.get('loader') as { readonly internal?: unknown } | undefined - const modules = loader?.internal as ClientModuleLoader | undefined + const loader: Loader = ctx.loader + const modules = loader.internal as unknown as ClientModuleLoader | undefined if (modules?.version !== 'client') { throw new Error('client-modules: the Loader has no client module system') } diff --git a/packages/client/modules/tests/loader.client.spec.ts b/packages/client/modules/tests/loader.client.spec.ts index 12ebd8ea96..71ae8c4973 100644 --- a/packages/client/modules/tests/loader.client.spec.ts +++ b/packages/client/modules/tests/loader.client.spec.ts @@ -123,8 +123,12 @@ function bench( } describe('Cordis plugin face', () => { - it('rejects activation before the shell installs a client Loader internal', () => { - expect(() => { apply(new Context()) }).toThrow('the Loader has no client module system') + it('rejects a Loader whose internal is absent or not a client module system', () => { + for (const internal of [undefined, { version: 'worker' }]) { + const ctx = new Context() + ctx.provide('loader', { internal } as never) + expect(() => { apply(ctx) }).toThrow('the Loader has no client module system') + } }) }) diff --git a/packages/test-support/client-runtime/src/assembly/test-client.ts b/packages/test-support/client-runtime/src/assembly/test-client.ts index 12b58e7b5a..678286feef 100644 --- a/packages/test-support/client-runtime/src/assembly/test-client.ts +++ b/packages/test-support/client-runtime/src/assembly/test-client.ts @@ -162,7 +162,11 @@ export class TestClient { * Load the roster's modules, bind this client's mock to its Connection row, * hold the jsdom shims, and boot through `bootClient` over the synthesized * boot graph; afterwards optionally mount and wait for the connection. The - * `@deepseek-ai/dsh-api-remotes` row is + * bound row replaces only the page-global input adapter: both paths call + * `installConnection`, while this path supplies the mock carrier, uses + * default recovery timings, and captures the current page location once for + * later reloads. A caller-provided Connection row remains unchanged and owns + * its readiness behavior. The `@deepseek-ai/dsh-api-remotes` row is * dropped from the roster: its generated Remote clients exist only in built * `lib/`, and the `remote.` services the roster injects (plus the * namespaces the mock has rules for at this point) are provided as diff --git a/packages/test-support/client-runtime/tests/assembly-test-client-node.client.spec.ts b/packages/test-support/client-runtime/tests/assembly-test-client-node.client.spec.ts index 3fa9aa305b..748b6d3eab 100644 --- a/packages/test-support/client-runtime/tests/assembly-test-client-node.client.spec.ts +++ b/packages/test-support/client-runtime/tests/assembly-test-client-node.client.spec.ts @@ -42,7 +42,6 @@ describe('TestClient (node environment)', () => { const failing = { apply(): void { throw new Error('apply boom') } } await expect(TestClient.start({ roster: TYPERT_ONLY, provide: { '@deepseek-ai/dsh-typert-registry': failing } }, RemoteMock.create())) .rejects.toThrow(/apply boom|typert-registry/) - expect(globals.__DSH_TRANSPORT__).toBeUndefined() const mock = RemoteMock.create().load(remoteDefaultResponses) const client = await TestClient.start({ roster: API_ROSTER }, mock) onTestFinished(() => client.dispose()) @@ -53,7 +52,6 @@ describe('TestClient (node environment)', () => { const roster = webApp.closure(['@deepseek-ai/dsh-api-remotes']) await expect(TestClient.start({ roster, provide: { '@deepseek-ai/dsh-api-remotes': { apply() {} } } }, RemoteMock.create())) .rejects.toThrow('@deepseek-ai/dsh-api-remotes cannot be provided; its remote. services are the tier\'s proxies') - expect(globals.__DSH_TRANSPORT__).toBeUndefined() }) it('reports unmatched requests alongside a failed teardown instead of hiding them', async () => { @@ -84,16 +82,14 @@ describe('TestClient (node environment)', () => { await first }) - it('refuses to mount without a DOM without touching the page transport', async () => { + it('refuses to mount without a DOM', async () => { await expect(TestClient.start({ roster: API_ROSTER }, RemoteMock.create(), { mount: true })) .rejects.toThrow('mount requires a DOM') - expect(globals.__DSH_TRANSPORT__).toBeUndefined() }) - it('fails loud without touching the page transport when the roster cannot provide a connection', async () => { + it('fails loud when the roster cannot provide a connection', async () => { await expect(TestClient.start({ roster: TYPERT_ONLY }, RemoteMock.create())) .rejects.toThrow('provides no `connection` service') - expect(globals.__DSH_TRANSPORT__).toBeUndefined() }) it('skips readiness on request and leaves a pre-existing page transport untouched', async () => { diff --git a/packages/test-support/client-runtime/tests/assembly-test-client.client.spec.ts b/packages/test-support/client-runtime/tests/assembly-test-client.client.spec.ts index 33d6a9b12e..9768bf06ee 100644 --- a/packages/test-support/client-runtime/tests/assembly-test-client.client.spec.ts +++ b/packages/test-support/client-runtime/tests/assembly-test-client.client.spec.ts @@ -69,6 +69,7 @@ describe('TestClient (jsdom)', () => { TestClient.start({ roster: API_ROSTER }, mockA), TestClient.start({ roster: API_ROSTER }, mockB), ]) + onTestFinished(() => a.dispose()) onTestFinished(() => b.dispose()) const rename = async (client: TestClient): Promise => (client.ctx as unknown as { remote: { session: { rename(request: unknown): Promise } } }).remote.session.rename({ sessionId: 's', title: 't' }) @@ -92,11 +93,9 @@ describe('TestClient (jsdom)', () => { it('boots two plugin trees concurrently instead of serializing the worker', async () => { let arrivals = 0 const gate = Promise.withResolvers() - const overlap = Promise.withResolvers() const probe: ClientPluginModule = { async apply() { arrivals += 1 - if (arrivals === 2) overlap.resolve(undefined) await gate.promise }, } @@ -106,17 +105,24 @@ describe('TestClient (jsdom)', () => { ]) const first = TestClient.start({ roster, provide: { [PARALLEL_PROBE]: probe } }, RemoteMock.create(), { awaitConnected: false }) const second = TestClient.start({ roster, provide: { [PARALLEL_PROBE]: probe } }, RemoteMock.create(), { awaitConnected: false }) + const starts = Promise.allSettled([first, second]) let overlapFailure: unknown try { await vi.waitFor(() => { expect(arrivals).toBe(2) }, { timeout: 5_000 }) - await overlap.promise } catch (error) { overlapFailure = error } finally { gate.resolve(undefined) } - const clients = await Promise.all([first, second]) - for (const client of clients) onTestFinished(() => client.dispose()) + const results = await starts + for (const result of results) { + if (result.status === 'fulfilled') onTestFinished(() => result.value.dispose()) + } + const rejected = results.find(result => result.status === 'rejected') + if (rejected?.status === 'rejected') { + const reason: unknown = rejected.reason + throw reason + } if (overlapFailure !== undefined) throw overlapFailure }, COLD_BOOT_TIMEOUT_MS) @@ -129,6 +135,8 @@ describe('TestClient (jsdom)', () => { const modulesA = a.ctx.modules const modulesB = b.ctx.modules expect(modulesA).not.toBe(modulesB) + expect(modulesA).toBe(a.ctx.loader.internal) + expect(modulesB).toBe(b.ctx.loader.internal) await a.reload(MODULES) expect(a.ctx.modules).toBe(modulesA) expect(b.ctx.modules).toBe(modulesB) @@ -146,7 +154,6 @@ describe('TestClient (jsdom)', () => { await expect(TestClient.start({ roster: API_ROSTER }, RemoteMock.create().load(remoteDefaultResponses), { mount: true })) .rejects.toThrow('mount requested, but the roster provides no `uiRenderer`') expect(document.body.childElementCount).toBe(before) - expect('__DSH_TRANSPORT__' in globalThis).toBe(false) }) it('creates no mount element when the roster cannot be loaded', async () => { @@ -154,7 +161,6 @@ describe('TestClient (jsdom)', () => { const before = document.body.childElementCount await expect(TestClient.start({ roster }, RemoteMock.create(), { mount: true })).rejects.toThrow() expect(document.body.childElementCount).toBe(before) - expect('__DSH_TRANSPORT__' in globalThis).toBe(false) }) it('mounts into a caller-supplied element and leaves it in place on dispose', async () => { @@ -235,7 +241,6 @@ describe('TestClient (jsdom)', () => { const mock = RemoteMock.create().stream('$events', openStream([])) await expect(TestClient.start({ roster }, mock, { connectTimeoutMs: 300 })) .rejects.toThrow(/connection state is \S+ after 300ms; unmatched: \[unary workspace\/follow\]; streams: \[.*\$events \(open\).*\]/) - expect('__DSH_TRANSPORT__' in globalThis).toBe(false) expect(globals.EventSource).toBeUndefined() }) }) From 9b091919e2fd90af59bd6a37d358331d9af6933c Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 12 Sep 2026 01:21:30 +0800 Subject: [PATCH 60/79] test(session-controller): migrate remaining Remote fixtures --- ...-09-06-client-assembly-test-line.i18n.yaml | 4 +- .../2026-09-06-client-assembly-test-line.md | 4 +- ...2026-09-06-client-assembly-test-line.zh.md | 4 +- .../tests/fake-api.client.ts | 476 ------------------ .../tests/manager.client.spec.ts | 426 ++++++++-------- .../tests/projection-store.client.spec.ts | 77 +-- .../tests/remote/history.client.ts | 2 +- .../tests/remote/session.client.ts | 24 +- .../tests/sessions-service.client.spec.ts | 470 +++++++++-------- 9 files changed, 517 insertions(+), 970 deletions(-) delete mode 100644 packages/api/session-controller/tests/fake-api.client.ts diff --git a/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.i18n.yaml b/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.i18n.yaml index fc53e0bdcb..d65b652543 100644 --- a/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.md -2026-09-06-client-assembly-test-line.md: 9778ed7fea930dadeae7d786d750f53a0a387f23 -2026-09-06-client-assembly-test-line.zh.md: 944008e493a63de278543ae5c941af95943e0f41 +2026-09-06-client-assembly-test-line.md: d3ffb01302a60f7b664f6b225648180d2ec50408 +2026-09-06-client-assembly-test-line.zh.md: 66ecf460e65046eb356b0e35d0c497c6e5774e9c diff --git a/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.md b/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.md index 9778ed7fea..d3ffb01302 100644 --- a/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.md +++ b/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.md @@ -26,7 +26,7 @@ The modules plugin reads the module system from its own `ctx.loader.internal` wh **Native mocks own response configuration and call assertions.** Tests use `mock.remote..` with `mockResolvedValue`, `mockResolvedValueOnce`, `mockReturnValueOnce`, or `mockImplementation`; the generated API supplies the signatures. Each mock instance owns its native response queue. Reusable tables register only default values or positional handlers, and each endpoint retains only its latest default. Stateful callbacks and deferred promises belong to individual tests. `ok` builds the success envelope. Streams require explicit declarations and can receive scripts over the opening args and a handle (`push`, `end`, `fail`); a declaration without a script produces a stream miss, while undeclared endpoints default to unary calls. Values are not validated. `mock.streams` controls scripted streams and exposes readiness/drain waits; `mock.log` records carrier calls (`pending`, `answered`, `failed`), scripted-stream state, first-argument `requests(endpoint?)`, and unmatched requests. `RemoteMock.create()` answers `$events` with a ready frame so the client can connect. -**Vitest owns each test's mock and client lifetime.** `createClientTest(plan, options)` adds native `mock`, `remote`, and `start` fixtures. The mock is fresh and carries the default responses; `remote` is its namespace proxy, and explicit `start()` leaves startup responses configurable and shares one startup promise within the test. Teardown waits for startup, disposes the successful client even after an assertion failure, checks missing responses, and rejects later starts. Callers await startup failures. Independently owned clients still use `TestClient.start`. Scenario data configures native mocks directly; returning a mutation response and updating subsequent describe responses remain separate actions. +**Vitest owns each test's mock and client lifetime.** `createClientTest(plan, options)` adds native `mock`, `remote`, and `start` fixtures. The mock is fresh and carries the default responses; `remote` is its namespace proxy, and explicit `start()` leaves startup responses configurable and shares one startup promise within the test. Object-layer specs use `remote` directly when they do not need the Connection; cases requiring Gateway `$stream` call `start()`. Teardown waits for startup, disposes the successful client even after an assertion failure, checks missing responses, and rejects later starts. Callers await startup failures. Independently owned clients still use `TestClient.start`. Scenario data configures native mocks directly; returning a mutation response and updating subsequent describe responses remain separate actions. **`remoteDefaultResponses` holds default responses for the boot-time Remote endpoints.** The table lists exactly the endpoints the `web` roster calls while booting and rendering with no sessions, no workspaces, and default settings, each row commented with its caller. A spec layers its own `RemoteTable` on top; a new boot-time call fails the spec at `dispose()`. @@ -91,4 +91,4 @@ Product facts the tier surfaced and leaves as they are: ## Testing -`packages/test-support/remote-mock/tests/` covers rules, streams, the log, and the carrier face; the `assembly-` specs under `packages/test-support/client-runtime/tests/` cover the roster reader on the real bundles and on a scratch installation, module loading, the proxies including their fold, parallel client startup, instance-local module and Connection reload, and `TestClient` under jsdom and plain Node. Seven converted specs use the whole-client tier. In `packages/client/ui-settings-general/tests/`, the shell and apply specs boot the whole `web` roster; the apply spec reads its Chinese copy from the Host settings document the mock answers and reconfigures the jsdom page URL for the off-loopback branch. In `packages/client/ui-message-feedback/tests/`, object-layer specs pass the `messageFeedback` RemoteMock namespace directly. In `packages/api/session-controller/tests/`, the Session, queue-store, and pending-submission specs drive their objects over the roster's real Connection through the `remote.` proxies, with the Gateway client's own `$stream` retry loop, over the gateway's dependency cone, and the client-apply spec boots the plugin's dependency cone, delivering Remote events as emit frames on `$events`. In `packages/api/workspace-controller/tests/`, the transport spec boots the plugin's cone for apply cases and the gateway cone for hand-built stream and controller cases, since a rostered plugin would share the follow endpoint. Each package keeps a `tests/remote/` module with its default responses and frame builders. The fixture tests include an expected assertion failure and independently observe completed client cleanup; settings reload tests observe replaced registration identities, and write tests assert every mutation argument. Teardown-failure tests execute the real tree disposer before reporting the injected failure and observe the `$events` stream's cancelled state. +`packages/test-support/remote-mock/tests/` covers rules, streams, the log, and the carrier face; the `assembly-` specs under `packages/test-support/client-runtime/tests/` cover the roster reader on the real bundles and on a scratch installation, module loading, the proxies including their fold, parallel client startup, instance-local module and Connection reload, and `TestClient` under jsdom and plain Node. In `packages/client/ui-settings-general/tests/`, the shell and apply specs boot the whole `web` roster; the apply spec reads its Chinese copy from the Host settings document the mock answers and reconfigures the jsdom page URL for the off-loopback branch. In `packages/client/ui-message-feedback/tests/`, object-layer specs pass the `messageFeedback` RemoteMock namespace directly. In `packages/api/session-controller/tests/`, Session, queue-store, pending-submission, projection tail-page, sessions-service, and the manager cases that exercise streams start the Gateway dependency cone; the other manager cases and projection-store's manager-routing cases use the same per-test Remote fixtures without starting a client; and client-apply boots the plugin's dependency cone, delivering Remote events as emit frames on `$events`. In `packages/api/workspace-controller/tests/`, the transport spec boots the plugin's cone for apply cases and the gateway cone for hand-built stream and controller cases, since a rostered plugin would share the follow endpoint. Each package keeps a `tests/remote/` module with its default responses and frame builders. The fixture tests include an expected assertion failure and independently observe completed client cleanup; settings reload tests observe replaced registration identities, and write tests assert every mutation argument. Teardown-failure tests execute the real tree disposer before reporting the injected failure and observe the `$events` stream's cancelled state. diff --git a/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.zh.md b/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.zh.md index 944008e493..66ecf460e6 100644 --- a/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.zh.md +++ b/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.zh.md @@ -26,7 +26,7 @@ modules 插件激活时从自己的 `ctx.loader.internal` 读取模块系统。 **原生 mock 负责响应配置和调用断言。** 测试通过 `mock.remote..` 使用 `mockResolvedValue`、`mockResolvedValueOnce`、`mockReturnValueOnce` 或 `mockImplementation`,签名由生成的 API 提供。每个 mock 实例独立持有原生响应队列。可复用的表只登记默认值或位置参数 handler,每个端点仅保留最新默认响应。有状态回调和 deferred promise 归各测试所有。`ok` 构造成功信封。流需要显式声明,可提供接收打开参数与句柄(`push`、`end`、`fail`)的脚本;无脚本的声明产生流漏配,未声明端点默认走一元。值不校验。`mock.streams` 控制脚本流并提供打开/排空等待;`mock.log` 记录载体调用(`pending`、`answered`、`failed`)、脚本流状态、首参数 `requests(endpoint?)` 和未匹配请求。`RemoteMock.create()` 为 `$events` 应答 ready 帧,让客户端可以连接。 -**Vitest 拥有每条测试的 mock 和客户端生命周期。** `createClientTest(plan, options)` 增加原生 `mock`、`remote` 与 `start` fixture。mock 每次新建并携带默认响应;`remote` 是它的命名空间 Proxy,显式 `start()` 留出配置启动期应答的时机,同一测试共用一个启动 Promise。收尾等待启动,即使断言失败也销毁成功创建的客户端、检查漏配,并拒绝后续启动。启动错误由调用方 await 观察。分别拥有多个客户端时仍用 `TestClient.start`。场景数据直接配置原生 mock;返回 mutation 应答与更新后续 describe 应答仍是两个独立操作。 +**Vitest 拥有每条测试的 mock 和客户端生命周期。** `createClientTest(plan, options)` 增加原生 `mock`、`remote` 与 `start` fixture。mock 每次新建并携带默认响应;`remote` 是它的命名空间 Proxy,显式 `start()` 留出配置启动期应答的时机,同一测试共用一个启动 Promise。不需要 Connection 的对象层 spec 直接使用 `remote`;需要 Gateway `$stream` 的用例调用 `start()`。收尾等待启动,即使断言失败也销毁成功创建的客户端、检查漏配,并拒绝后续启动。启动错误由调用方 await 观察。分别拥有多个客户端时仍用 `TestClient.start`。场景数据直接配置原生 mock;返回 mutation 应答与更新后续 describe 应答仍是两个独立操作。 **`remoteDefaultResponses` 是启动期 Remote 端点的默认响应。** 这张表恰好列出 `web` roster 在没有 session、没有 workspace、默认设置下启动并渲染时会打的端点,每行注明调用方。spec 在其上叠加自己的 `RemoteTable`;新的启动期调用会在 `dispose()` 时让 spec 失败。 @@ -91,4 +91,4 @@ spec 起的是真插件:整个 `web` roster 冷启动约五秒、热启动远 ## 测试 -`packages/test-support/remote-mock/tests/` 覆盖规则、流、日志与载体面;`packages/test-support/client-runtime/tests/` 下的 `assembly-` 系列 spec 覆盖在真 bundle 与临时安装上的 roster 读取器、模块加载、含折叠的代理、并行客户端启动、实例局部的模块与 Connection 重载,以及 jsdom 与纯 Node 下的 `TestClient`。七条改造后的 spec 使用整机客户端档。`packages/client/ui-settings-general/tests/` 下,shell 与 apply 两条起整个 `web` roster;apply 从 mock 应答的 Host settings 文档读它的中文文案,并为 off-loopback 分支重配 jsdom 页面 URL。`packages/client/ui-message-feedback/tests/` 下,对象层 spec 直接传入 RemoteMock 的 `messageFeedback` 命名空间。`packages/api/session-controller/tests/` 下,Session、queue-store、pending-submission 三条在 gateway 依赖锥上经 `remote.` 代理走 roster 的真 Connection 驱动对象(`$stream` 的重试循环仍是 Gateway 客户端自己的),client-apply 起插件的依赖锥,把 Remote 事件作为 `$events` 上的 emit 帧投递。`packages/api/workspace-controller/tests/` 下,transport 的 apply 用例起插件锥、手工构造流与 controller 的用例起 gateway 锥,因为进了 roster 的插件会共用 follow 端点。每个包在 `tests/remote/` 保有自己的默认响应与帧构造。fixture 测试包含预期的断言失败,并独立观察客户端清理完成;settings 重载测试观察注册身份被替换,写入测试断言全部 mutation 参数。teardown 失败测试先执行真实树清理,再报告注入的失败,并观察 `$events` 流的取消状态。 +`packages/test-support/remote-mock/tests/` 覆盖规则、流、日志与载体面;`packages/test-support/client-runtime/tests/` 下的 `assembly-` 系列 spec 覆盖在真 bundle 与临时安装上的 roster 读取器、模块加载、含折叠的代理、并行客户端启动、实例局部的模块与 Connection 重载,以及 jsdom 与纯 Node 下的 `TestClient`。`packages/client/ui-settings-general/tests/` 下,shell 与 apply 两条起整个 `web` roster;apply 从 mock 应答的 Host settings 文档读它的中文文案,并为 off-loopback 分支重配 jsdom 页面 URL。`packages/client/ui-message-feedback/tests/` 下,对象层 spec 直接传入 RemoteMock 的 `messageFeedback` 命名空间。`packages/api/session-controller/tests/` 下,Session、queue-store、pending-submission、projection tail-page、sessions-service 以及验证流的 manager 用例启动 Gateway 依赖锥;其余 manager 用例与 projection-store 的 manager-routing 用例直接使用同一组逐测试 Remote fixture,不启动客户端;client-apply 则启动插件的依赖锥,把 Remote 事件作为 `$events` 上的 emit 帧投递。`packages/api/workspace-controller/tests/` 下,transport 的 apply 用例起插件锥、手工构造流与 controller 的用例起 gateway 锥,因为进了 roster 的插件会共用 follow 端点。每个包在 `tests/remote/` 保有自己的默认响应与帧构造。fixture 测试包含预期的断言失败,并独立观察客户端清理完成;settings 重载测试观察注册身份被替换,写入测试断言全部 mutation 参数。teardown 失败测试先执行真实树清理,再报告注入的失败,并观察 `$events` 流的取消状态。 diff --git a/packages/api/session-controller/tests/fake-api.client.ts b/packages/api/session-controller/tests/fake-api.client.ts deleted file mode 100644 index 50f702c5fa..0000000000 --- a/packages/api/session-controller/tests/fake-api.client.ts +++ /dev/null @@ -1,476 +0,0 @@ -// Test-local programmable Remote fake (NOT the fixture: fixture is a demo -// data source on a real clock; behavior tests need per-case responses and -// deferred-controlled timing). Session streams are hand pumps: pushFollow/pushControl. -import type { - MessageId, - SessionId, SessionSearchItem, - SubagentCatalog, SubagentInterruptReceipt, SubagentPromptReceipt, - WorkspaceId, WorkspaceView, -} from '@deepseek-ai/dsh-api-remotes/client' -import type { - SessionAddress, - SessionAssistantStreamBaseline, - SessionControlBaseline, - SessionControlFrame, - SessionFollowFrame, - SessionFollowRequest, - SessionPage, - SessionPageRequest, - SessionProjectionBaseline, - SessionSelectModelRequest, - SessionSelectModelValue, -} from '@deepseek-ai/dsh-api-session-controller/types' -import type { WorkspaceRemote } from '@deepseek-ai/dsh-api-workspace-controller/client' -import type { WorkspaceFollowFrame } from '@deepseek-ai/dsh-api-workspace-controller/types' -import type { RemoteFailure, RemoteResult } from '@deepseek-ai/dsh-typert-protocol' -import { - RemoteStream, - type RemoteStreamOptions, -} from '@deepseek-ai/dsh-api-gateway/client' -import type { SessionRemotes } from '../src/client/sessions/remotes.ts' -import { followSnapshot, pageThrough } from './remote/history.client.ts' - -const AVAILABLE_STREAM_CONNECTION = { - generation: { - getSnapshot: () => ({ id: 1, host: { home: '/h' } }), - subscribe: () => () => {}, - }, -} - -/** Programmable-default workspace row (branded id, ISO-ish times). */ -function fakeWorkspace(id: string, over: Partial = {}): WorkspaceView { - return { - workspaceId: id as WorkspaceId, - path: '/f/ws', - title: 'ws', - sessionIds: [], - createdAt: '2026-01-01T00:00:00.000Z', - updatedAt: '2026-01-01T00:00:00.000Z', - ...over, - } -} - -function addressSessionId(address: SessionAddress): SessionId { - return address.kind === 'session' ? address.sessionId : address.childSessionId -} - -export interface Deferred { - promise: Promise - resolve(value: T): void - reject(error: unknown): void -} - -/** Test-held settlement: the case decides when an RPC lands (history-pending injections etc.). */ -export function deferred(): Deferred { - let resolve!: (value: T) => void - let reject!: (error: unknown) => void - const promise = new Promise((res, rej) => { - resolve = res - reject = rej - }) - return { promise, resolve, reject } -} - -/** - * Successful generated Remote result for programmable domain fakes. - * @param value - the value the Host answers with. - * @returns the success branch of a Remote result. - */ -export function ok(value: T): RemoteResult { - return { ok: true, value } -} - -/** - * Failed generated Remote result carrying the owner's declared failure. - * @param error - the owner-declared failure. - * @returns the failure branch of a Remote result. - */ -export function err(error: RemoteFailure): RemoteResult { - return { ok: false, error } -} - -type ValueStreamItem = - | { kind: 'frame'; value: F; delivered?: () => void } - | { kind: 'end' } - | { kind: 'fail'; error: unknown } - -interface ValueStreamConn { - feed(item: ValueStreamItem): void -} - -interface OpenValueStream { - readonly values: AsyncGenerator - dispose(): void -} - -/** - * Commands Remote double: the generated face delivers the carrier's outcome, so - * a test that programs nothing sees an empty catalog and an unmatched line. - * @returns the Remote namespaces the session cluster calls. - */ -export type RuntimeRemotes = SessionRemotes & { readonly workspace: WorkspaceRemote } - -export function fakeRemote(api = new FakeApiClient()): RuntimeRemotes { - return api.sessionRemotes() -} - -export class FakeApiClient { - /** Chronological call record: [method, payload]. */ - readonly calls: { method: string; payload: unknown }[] = [] - /** Session ids in physical follow-generation opening order. */ - readonly followStarts: SessionId[] = [] - - // Programmable slots (defaults answer OK-empty); reassign per case. - onList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ items: [] })) - onSearch: (payload: unknown) => Promise> = - () => Promise.resolve(ok({ items: [], hasMore: false })) - onCreate: (payload: unknown) => Promise> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId })) - onSelectModel: (payload: SessionSelectModelRequest) => Promise> = - payload => Promise.resolve(ok({ - selected: { - provider: payload.provider, - model: payload.model, - ...(payload.reasoningEffort === undefined - ? {} - : { reasoningEffort: payload.reasoningEffort }), - }, - })) - onRename: (payload: unknown) => Promise> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 })) - onFork: (payload: unknown) => Promise> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId })) - onHistory: (payload: { sessionId: SessionId; throughSeq?: number; beforeSeq?: number; maxMessages?: number }) - => Promise> = - () => Promise.resolve(ok({ records: [], hasMore: false })) - - onPrompt: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) - onAttachment: (payload: unknown) => Promise> = - () => Promise.resolve(ok({ attachment: { attachmentId: 'a' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, data: 'AA==' })) - onUpdateQueue: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) - onCancel: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) - onOpenWorkspacePath: (payload: unknown) => Promise> = - () => Promise.resolve(ok({ opened: true as const })) - - private readonly followConns = new Map[]>() - private readonly controlConns: ValueStreamConn[] = [] - private readonly workspaceConns: ValueStreamConn[] = [] - /** Optional Host opening cursor override for stale-page and reconnect tests. */ - followCursor: number | undefined - controlBaseline: SessionControlBaseline = { - queues: {}, - jobs: {}, - projections: {}, - } - assistantStreamBaseline: SessionAssistantStreamBaseline = { - revision: 0, - } - workspaceBaseline: Extract['value'] = { - items: [], - archivedSessionIds: [], - } - lastSearchSignal: AbortSignal | undefined - - onSubagentList: (payload: unknown) => Promise> - = () => Promise.resolve(ok({ entries: [], parentAvailable: true })) - onSubagentPrompt: (payload: unknown) => Promise> - = () => Promise.resolve(ok({ messageId: 'fake-message' as MessageId })) - - onSubagentInterrupt: (payload: unknown) => Promise> - = () => Promise.resolve(ok({ accepted: true as const })) - - onWorkspaceCreate: (payload: unknown) => Promise> = - () => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws'), created: true })) - - onWorkspaceRename: (payload: unknown) => Promise> = - () => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') })) - - onWorkspaceDelete: (payload: unknown) => Promise> = - () => Promise.resolve(ok({ deleted: true })) - - onWorkspaceInsertBefore: (payload: unknown) => Promise> = - () => Promise.resolve(ok({ workspaceIds: [] })) - - onWorkspaceInsertSessionBefore: (payload: unknown) => Promise> = - () => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') })) - - onWorkspaceArchiveSession: (payload: unknown) => Promise> = - payload => Promise.resolve(ok({ archivedSessionIds: [(payload as { sessionId: SessionId }).sessionId] })) - - onWorkspaceUnarchiveSession: (payload: unknown) => Promise> = - () => Promise.resolve(ok({ archivedSessionIds: [] })) - - /** Remote namespaces bound to this fake's programmable unary slots and stream pumps. */ - sessionRemotes(): RuntimeRemotes { - return { - $stream: (options: RemoteStreamOptions) => ( - new RemoteStream(AVAILABLE_STREAM_CONNECTION, options) - ), - commands: { - execute: () => Promise.resolve({ ok: true, value: undefined }), - }, - session: { - canOpenWorkspacePath: () => Promise.resolve(ok(true)), - list: payload => this.record('session.list', payload, this.onList(payload)), - modelCatalog: () => Promise.resolve({ - ok: true, - value: { - default: { provider: 'fixture', model: 'fixture' }, - routableProviders: [], - groups: [], - failures: [], - }, - }), - search: (payload, signal) => { - this.lastSearchSignal = signal - return this.record('session.search', payload, this.onSearch(payload)) - }, - create: payload => this.record('session.create', payload, this.onCreate(payload)), - selectModel: payload => this.record( - 'session.selectModel', - payload, - this.onSelectModel(payload), - ), - rename: payload => this.record('session.rename', payload, this.onRename(payload)), - fork: payload => this.record('session.fork', payload, this.onFork(payload)), - prompt: payload => this.record('session.prompt', payload, this.onPrompt(payload)), - attachment: payload => this.record('session.attachment', payload, this.onAttachment(payload)), - updateQueue: payload => this.record('session.updateQueue', payload, this.onUpdateQueue(payload)), - cancel: payload => this.record('session.cancel', payload, this.onCancel(payload)), - openWorkspacePath: payload => this.record( - 'session.openWorkspacePath', - payload, - this.onOpenWorkspacePath(payload), - ), - page: request => this.page(request), - follow: (request, signal) => this.openFollow(request, signal), - control: signal => this.openControl(signal), - }, - subagents: { - list: parentSessionId => this.record( - 'subagents.list', - parentSessionId, - this.onSubagentList(parentSessionId), - ), - prompt: request => this.record('subagents.prompt', request, this.onSubagentPrompt(request)), - interruptByParent: (childSessionId, parentSessionId, mode) => this.record( - 'subagents.interruptByParent', - { childSessionId, parentSessionId, mode }, - this.onSubagentInterrupt({ childSessionId, parentSessionId, mode }), - ), - }, - workspace: { - create: payload => this.record('workspace.create', payload, this.onWorkspaceCreate(payload)), - rename: payload => this.record('workspace.rename', payload, this.onWorkspaceRename(payload)), - delete: payload => this.record('workspace.delete', payload, this.onWorkspaceDelete(payload)), - insertBefore: payload => this.record( - 'workspace.insertBefore', - payload, - this.onWorkspaceInsertBefore(payload), - ), - insertSessionBefore: payload => this.record( - 'workspace.insertSessionBefore', - payload, - this.onWorkspaceInsertSessionBefore(payload), - ), - archiveSession: payload => this.record( - 'workspace.archiveSession', - payload, - this.onWorkspaceArchiveSession(payload), - ), - unarchiveSession: payload => this.record( - 'workspace.unarchiveSession', - payload, - this.onWorkspaceUnarchiveSession(payload), - ), - follow: signal => this.openWorkspace(signal), - }, - } - } - - /** Push one live Session event to every follower of that Session. */ - async pushFollow( - sessionId: SessionId, - frame: Exclude, - ): Promise { - await Promise.all([...(this.followConns.get(sessionId) ?? [])].map(conn => new Promise((resolve) => { - conn.feed({ kind: 'frame', value: frame, delivered: resolve }) - }))) - } - - /** Push one Host-wide control update. */ - pushControl(frame: Exclude): void { - for (const conn of [...this.controlConns]) conn.feed({ kind: 'frame', value: frame }) - } - - /** Push one Workspace projection increment. */ - pushWorkspace(frame: Exclude): void { - for (const conn of [...this.workspaceConns]) conn.feed({ kind: 'frame', value: frame }) - } - - /** End (clean close) or fail (throw) every open stream — reconnect-path material. */ - endStreams(): void { - for (const conns of this.followConns.values()) { - for (const conn of [...conns]) conn.feed({ kind: 'end' }) - } - for (const conn of [...this.controlConns]) conn.feed({ kind: 'end' }) - for (const conn of [...this.workspaceConns]) conn.feed({ kind: 'end' }) - } - - failStreams(error: unknown): void { - for (const conns of this.followConns.values()) { - for (const conn of [...conns]) conn.feed({ kind: 'fail', error }) - } - for (const conn of [...this.controlConns]) conn.feed({ kind: 'fail', error }) - for (const conn of [...this.workspaceConns]) conn.feed({ kind: 'fail', error }) - } - - callsOf(method: string): unknown[] { - return this.calls.filter(c => c.method === method).map(c => c.payload) - } - - /** Number of currently attached journal generations for one Session. */ - activeFollows(sessionId: SessionId): number { - return this.followConns.get(sessionId)?.length ?? 0 - } - - private record(method: string, payload: unknown, response: Promise): Promise { - this.calls.push({ method, payload }) - return response - } - - private page(request: SessionPageRequest): Promise> { - return this.fetchPage(request) - } - - private async fetchPage( - request: SessionPageRequest, - response?: Promise>, - ): Promise> { - const sessionId = addressSessionId(request.address) - const payload = request.address.kind === 'session' - ? { - sessionId, - throughSeq: request.throughSeq, - ...request.beforeSeq === undefined ? {} : { beforeSeq: request.beforeSeq }, - ...request.maxMessages === undefined ? {} : { maxMessages: request.maxMessages }, - } - : { - parentSessionId: request.address.parentSessionId, - childSessionId: request.address.childSessionId, - mode: request.address.mode, - throughSeq: request.throughSeq, - ...request.beforeSeq === undefined ? {} : { beforeSeq: request.beforeSeq }, - ...request.maxMessages === undefined ? {} : { maxMessages: request.maxMessages }, - } - const method = request.address.kind === 'session' ? 'session.history' : 'subagent.history' - const result = await this.record(method, payload, response ?? this.onHistory({ - sessionId, - throughSeq: request.throughSeq, - ...request.beforeSeq === undefined ? {} : { beforeSeq: request.beforeSeq }, - ...request.maxMessages === undefined ? {} : { maxMessages: request.maxMessages }, - })) - if (!result.ok) return result - return { - ok: true, - value: pageThrough(result.value, request.throughSeq), - } - } - - private async *openFollow( - request: SessionFollowRequest, - signal: AbortSignal = new AbortController().signal, - ): AsyncGenerator { - const sessionId = addressSessionId(request.address) - this.followStarts.push(sessionId) - this.calls.push({ method: 'session.follow', payload: request }) - const conns = this.followConns.get(sessionId) ?? [] - if (!this.followConns.has(sessionId)) this.followConns.set(sessionId, conns) - const stream = this.openValueStream(conns, signal) - try { - const response = await this.onHistory({ - sessionId, - maxMessages: request.maxMessages ?? 50, - }) - if (!response.ok) throw response.error - const page = response.value - yield followSnapshot(page, request, this.followCursor, this.assistantStreamBaseline) - yield* stream.values - } finally { - stream.dispose() - } - } - - private async *openControl( - signal: AbortSignal = new AbortController().signal, - ): AsyncGenerator { - const stream = this.openValueStream(this.controlConns, signal) - try { - yield { type: 'baseline', value: this.controlBaseline } - yield* stream.values - } finally { - stream.dispose() - } - } - - private async *openWorkspace( - signal: AbortSignal = new AbortController().signal, - ): AsyncGenerator { - const stream = this.openValueStream(this.workspaceConns, signal) - try { - yield { type: 'baseline', value: this.workspaceBaseline } - yield* stream.values - } finally { - stream.dispose() - } - } - - private openValueStream( - registry: ValueStreamConn[], - signal: AbortSignal, - ): OpenValueStream { - const inbox: ValueStreamItem[] = [] - let wake: (() => void) | null = null - let inFlightDelivered: (() => void) | undefined - let disposed = false - const conn: ValueStreamConn = { - feed: (item) => { - inbox.push(item) - wake?.() - }, - } - registry.push(conn) - const dispose = (): void => { - if (disposed) return - disposed = true - inFlightDelivered?.() - for (const item of inbox) { - if (item.kind === 'frame') item.delivered?.() - } - const index = registry.indexOf(conn) - if (index >= 0) registry.splice(index, 1) - wake?.() - } - const values = (async function* (): AsyncGenerator { - try { - while (!signal.aborted && !disposed) { - while (inbox.length > 0) { - const item = inbox.shift() as ValueStreamItem - if (item.kind === 'end') return - if (item.kind === 'fail') throw item.error - inFlightDelivered = item.delivered - yield item.value - inFlightDelivered?.() - inFlightDelivered = undefined - } - await new Promise((resolve) => { - wake = resolve - signal.addEventListener('abort', () => { resolve() }, { once: true }) - }) - wake = null - } - } finally { - dispose() - } - })() - return { values, dispose } - } - -} diff --git a/packages/api/session-controller/tests/manager.client.spec.ts b/packages/api/session-controller/tests/manager.client.spec.ts index 2608887137..f6a7393964 100644 --- a/packages/api/session-controller/tests/manager.client.spec.ts +++ b/packages/api/session-controller/tests/manager.client.spec.ts @@ -3,18 +3,27 @@ * frame routing, and control baselines for uninstantiated sessions. */ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, vi } from 'vitest' import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' import { SessionSeq } from '@deepseek-ai/dsh-session/types' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import type { SessionControlFrame } from '@deepseek-ai/dsh-api-session-controller/types' +import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' +import { ok, type RemoteMock } from '@deepseek-ai/dsh-remote-mock' +import { + createClientTest, type ClientTestFixtures, webApp, +} from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts' import type {} from '@deepseek-ai/dsh-session-title/client' import { SessionManager } from '../src/client/sessions/manager.ts' -import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.client.ts' +import type { SessionRemotes } from '../src/client/sessions/remotes.ts' import { entries, plainTurn } from './event-script.client.ts' +import { FOLLOW, err, followScript, sessionWorld } from './remote/session.client.ts' const S1 = 'fk-m1' as SessionId const S2 = 'fk-m2' as SessionId +/** A SessionManager's Remote methods use the same native mocks as an assembled client. */ +const API_ROSTER = webApp.closure(['@deepseek-ai/dsh-api-gateway']) +const it = createClientTest({ roster: API_ROSTER }) type SummaryOver = Partial<{ updatedAt: number @@ -29,16 +38,21 @@ function summary(sessionId: SessionId, over: SummaryOver = {}) { return { sessionId, updatedAt: 100, running: false, blank: false, ...over } } -function makeManager(): SessionManager { - const api = new FakeApiClient() - return new SessionManager(fakeRemote(api)) +function makeManager( + mock: RemoteMock, + remote: ClientTestFixtures['remote'], + restoredSelection?: SessionId, + restoredAddress?: SubagentAddress, +): SessionManager { + mock.load(sessionWorld) + // Cases using this helper never open a Session, so they do not need the broader Client Remote's $stream member. + return new SessionManager(remote as unknown as SessionRemotes, restoredSelection, restoredAddress) } describe('SessionManager instances', () => { - it('lazily builds one resident instance per id and syncs the running bit from the list', async () => { - const api = new FakeApiClient() - api.onList = () => Promise.resolve(ok({ items: [summary(S1, { running: true })] as never[] })) - const manager = new SessionManager(fakeRemote(api)) + it('lazily builds one resident instance per id and syncs the running bit from the list', async ({ mock, remote }) => { + remote.session.list.mockResolvedValue(ok({ items: [summary(S1, { running: true })] as never[] })) + const manager = makeManager(mock, remote) await manager.refreshList() const session = manager.get(S1) expect(manager.get(S1)).toBe(session) // resident: same instance forever @@ -48,97 +62,90 @@ describe('SessionManager instances', () => { }) describe('list lifecycle', () => { - it('single-flights refreshList and preserves the Host baseline order', async () => { - const api = new FakeApiClient() - const gate = deferred>>() - api.onList = () => gate.promise - const manager = new SessionManager(fakeRemote(api)) + it('single-flights refreshList and preserves the Host baseline order', async ({ mock, remote }) => { + const gate = Promise.withResolvers>>() + remote.session.list.mockReturnValue(gate.promise) + const manager = makeManager(mock, remote) const first = manager.refreshList() const second = manager.refreshList() expect(manager.getListSnapshot().state).toBe('loading') gate.resolve(ok({ items: [summary(S2, { updatedAt: 200 }), summary(S1)] as never[] })) await Promise.all([first, second]) - expect(api.callsOf('session.list')).toHaveLength(1) + expect(remote.session.list).toHaveBeenCalledOnce() const snapshot = manager.getListSnapshot() expect(snapshot.state).toBe('idle') expect(snapshot.items.map(i => i.sessionId)).toEqual([S2, S1]) }) - it('replays incremental frames over hydration and never batch-reorders established ids', async () => { - const api = new FakeApiClient() - const first = deferred>>() - api.onList = () => first.promise - const manager = new SessionManager(fakeRemote(api)) + it('replays incremental frames over hydration and never batch-reorders established ids', async ({ mock, remote }) => { + const first = Promise.withResolvers>>() + remote.session.list.mockReturnValue(first.promise) + const manager = makeManager(mock, remote) const hydration = manager.refreshList() manager.handleSessionAdded(summary(S2, { blank: true })) first.resolve(ok({ items: [summary(S1)] as never[] })) await hydration expect(manager.getListSnapshot().items.map(item => item.sessionId)).toEqual([S2, S1]) - api.onList = () => Promise.resolve(ok({ + remote.session.list.mockResolvedValue(ok({ items: [summary(S1, { updatedAt: 900 }), summary(S2, { updatedAt: 800 })] as never[], })) await manager.refreshList() expect(manager.getListSnapshot().items.map(item => item.sessionId)).toEqual([S2, S1]) }) - it('advances list activity from the filtered Host notification', async () => { - const api = new FakeApiClient() - api.onList = () => Promise.resolve(ok({ items: [summary(S1)] as never[] })) - const manager = new SessionManager(fakeRemote(api)) + it('advances list activity from the filtered Host notification', async ({ mock, remote }) => { + remote.session.list.mockResolvedValue(ok({ items: [summary(S1)] as never[] })) + const manager = makeManager(mock, remote) await manager.refreshList() manager.handleSessionActivity(S1, 500) expect(manager.getListSnapshot().items[0]?.updatedAt).toBe(500) }) - it('keeps the error in the list snapshot on failure', async () => { - const api = new FakeApiClient() - api.onList = () => Promise.resolve(err(new RemoteError('gateway/internal', 'boom', {}))) - const manager = new SessionManager(fakeRemote(api)) + it('keeps the error in the list snapshot on failure', async ({ mock, remote }) => { + remote.session.list.mockResolvedValue(err(new RemoteError('gateway/internal', 'boom', {}))) + const manager = makeManager(mock, remote) await manager.refreshList() expect(manager.getListSnapshot()).toMatchObject({ state: 'error', error: { code: 'gateway/internal' } }) // A failed pull does not step the arrival phase: still pending. expect(manager.getListSnapshot().phase).toBe('pending') }) - it('phase steps pending → ready on the first successful pull and never returns', async () => { - const api = new FakeApiClient() - const manager = new SessionManager(fakeRemote(api)) + it('phase steps pending → ready on the first successful pull and never returns', async ({ mock, remote }) => { + const manager = makeManager(mock, remote) expect(manager.getListSnapshot().phase).toBe('pending') await manager.refreshList() expect(manager.getListSnapshot().phase).toBe('ready') // Sticky across later failures: the pull-activity axis reports the error, // the arrival phase holds. - api.onList = () => Promise.resolve(err(new RemoteError('gateway/internal', 'down', {}))) + remote.session.list.mockResolvedValue(err(new RemoteError('gateway/internal', 'down', {}))) await manager.refreshList() expect(manager.getListSnapshot()).toMatchObject({ state: 'error', phase: 'ready' }) // And across an empty re-pull (empty-with-ready = truly no sessions). - api.onList = () => Promise.resolve(ok({ items: [] as never[] })) + remote.session.list.mockResolvedValue(ok({ items: [] as never[] })) await manager.refreshList() expect(manager.getListSnapshot()).toMatchObject({ state: 'idle', phase: 'ready' }) expect(manager.getListSnapshot().items).toEqual([]) }) - it('merges create into the list immediately without waiting for a refresh', async () => { - const api = new FakeApiClient() - api.onCreate = () => Promise.resolve(ok({ sessionId: S2 })) - const manager = new SessionManager(fakeRemote(api)) + it('merges create into the list immediately without waiting for a refresh', async ({ mock, remote }) => { + remote.session.create.mockResolvedValue(ok({ sessionId: S2 })) + const manager = makeManager(mock, remote) const result = await manager.create() expect(result).toMatchObject({ ok: true, value: { sessionId: S2 } }) expect(manager.getListSnapshot().items.map(i => i.sessionId)).toEqual([S2]) }) - it('retains title projections before list arrival, keeps last-wins by seq, and clears them on removal', async () => { - const api = new FakeApiClient() - const manager = new SessionManager(fakeRemote(api)) + it('retains title projections before list arrival, keeps last-wins by seq, and clears them on removal', async ({ mock, remote }) => { + const manager = makeManager(mock, remote) const titleFrame = (title: string, seq: number) => { manager.handleControlFrame({ type: 'projection', sessionId: S1, key: 'title', value: title, seq }) } titleFrame('Newest', 4) titleFrame('Stale', 3) titleFrame('Equal', 4) - api.onList = () => Promise.resolve(ok({ + remote.session.list.mockResolvedValue(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[], })) await manager.refreshList() @@ -153,14 +160,13 @@ describe('list lifecycle', () => { expect(manager.getListSnapshot().items.find(item => item.sessionId === S1)?.title).toBeUndefined() }) - it('seeds cold titles from the list rows\' projections block under higher-seq-wins', async () => { - const api = new FakeApiClient() - const manager = new SessionManager(fakeRemote(api)) + it('seeds cold titles from the list rows\' projections block under higher-seq-wins', async ({ mock, remote }) => { + const manager = makeManager(mock, remote) // A push frame landed before the list (S2's title is newer than the block's cut). manager.handleControlFrame({ type: 'projection', sessionId: S2, key: 'title', value: 'Pushed', seq: 9, }) - api.onList = () => Promise.resolve(ok({ + remote.session.list.mockResolvedValue(ok({ items: [ { ...summary(S1), projections: { asOfSeq: 4, values: { title: 'Cold cached' } } }, { ...summary(S2, { updatedAt: 200 }), projections: { asOfSeq: 5, values: { title: 'List stale' } } }, @@ -174,10 +180,9 @@ describe('list lifecycle', () => { expect(items.find(item => item.sessionId === S2)?.title).toBe('Pushed') }) - it('drops a projection row beyond the subscription baseline before accepting its durable replay', async () => { - const api = new FakeApiClient() - api.onList = () => Promise.resolve(ok({ items: [summary(S1)] as never[] })) - const manager = new SessionManager(fakeRemote(api)) + it('drops a projection row beyond the subscription baseline before accepting its durable replay', async ({ mock, remote }) => { + remote.session.list.mockResolvedValue(ok({ items: [summary(S1)] as never[] })) + const manager = makeManager(mock, remote) await manager.refreshList() const frame = (payload: SessionControlFrame) => { manager.handleControlFrame(payload) } frame({ type: 'projection', sessionId: S1, key: 'title', value: 'Unflushed', seq: 4 }) @@ -209,13 +214,12 @@ describe('list lifecycle', () => { }) describe('search', () => { - it('returns bounded Host results and forwards the caller signal', async () => { - const api = new FakeApiClient() - api.onSearch = () => Promise.resolve(ok({ + it('returns bounded Host results and forwards the caller signal', async ({ mock, remote }) => { + remote.session.search.mockResolvedValue(ok({ items: [{ sessionId: S1, snippet: 'matching excerpt' }], hasMore: true, })) - const manager = new SessionManager(fakeRemote(api)) + const manager = makeManager(mock, remote) const signal = new AbortController().signal await expect(manager.search('exact phrase', signal)).resolves.toEqual({ @@ -225,29 +229,26 @@ describe('search', () => { hasMore: true, }, }) - expect(api.callsOf('session.search')).toEqual([{ query: 'exact phrase' }]) - expect(api.lastSearchSignal).toBe(signal) + expect(remote.session.search).toHaveBeenCalledWith({ query: 'exact phrase' }, signal) }) - it('preserves business errors and propagates a non-Remote throw', async () => { - const api = new FakeApiClient() - const manager = new SessionManager(fakeRemote(api)) - api.onSearch = () => Promise.resolve(err(new RemoteError('gateway/internal', 'index unavailable', {}))) + it('preserves business errors and propagates a non-Remote throw', async ({ mock, remote }) => { + const manager = makeManager(mock, remote) + remote.session.search.mockResolvedValue(err(new RemoteError('gateway/internal', 'index unavailable', {}))) const signal = new AbortController().signal await expect(manager.search('first', signal)).resolves.toMatchObject({ ok: false, error: { code: 'gateway/internal', message: 'index unavailable' }, }) - api.onSearch = () => Promise.reject(new Error('wire down')) + remote.session.search.mockRejectedValue(new Error('wire down')) await expect(manager.search('second', signal)).rejects.toThrow('wire down') }) }) describe('Host Remote event routing', () => { - it('adds/removes/flips sessions and keeps removed instances resident', async () => { - const api = new FakeApiClient() - const manager = new SessionManager(fakeRemote(api)) + it('adds/removes/flips sessions and keeps removed instances resident', async ({ mock, remote }) => { + const manager = makeManager(mock, remote) manager.handleSessionAdded(summary(S1, { blank: true })) manager.handleSessionAdded(summary(S1, { blank: true })) // dup: ignored expect(manager.getListSnapshot().items).toHaveLength(1) @@ -268,20 +269,21 @@ describe('Host Remote event routing', () => { }) describe('subagent catalogs', () => { - it('keeps a catalog-discovered child address across ordinary selection and status frames', async () => { - const api = new FakeApiClient() - api.onList = () => Promise.resolve(ok({ items: [ + it('keeps a catalog-discovered child address across ordinary selection and status frames', async ({ mock, remote, start }) => { + remote.session.list.mockResolvedValue(ok({ items: [ summary(S1), summary(S2, { parentSessionId: S1, origin: 'subagent' }), ] as never[] })) - api.onSubagentList = () => Promise.resolve(ok({ + remote.subagents.list.mockResolvedValue(ok({ entries: [{ kind: 'child', id: S2, mode: 'continuable', label: 'worker', activity: 'running', hasChildren: false, }] as never[], parentAvailable: true, })) - const manager = new SessionManager(fakeRemote(api)) + mock.load(sessionWorld) + const client = await start() + const manager = new SessionManager(client.ctx.remote) await manager.refreshList() await manager.refreshSubagents(S1) manager.selectSubagent({ parentSessionId: S1, childSessionId: S2, mode: 'continuable' }) @@ -305,7 +307,7 @@ describe('subagent catalogs', () => { }) await manager.get(S2).open() await manager.get(S2).prompt([{ type: 'text', text: 'continue' }], 'queue') - expect(api.callsOf('session.follow')).toEqual([ + expect(remote.session.follow.mock.calls.map(([request]) => request)).toEqual([ { address: { kind: 'subagent', parentSessionId: S1, childSessionId: S2, mode: 'continuable', @@ -314,8 +316,8 @@ describe('subagent catalogs', () => { maxMessages: 50, }, ]) - expect(api.callsOf('subagent.history')).toEqual([]) - expect(api.callsOf('subagents.prompt')).toEqual([ + expect(remote.session.page).not.toHaveBeenCalled() + expect(remote.subagents.prompt.mock.calls.map(([request]) => request)).toEqual([ { requestId: expect.any(String) as unknown as string, parentSessionId: S1, childSessionId: S2, @@ -325,14 +327,13 @@ describe('subagent catalogs', () => { clientTimeZone: new Intl.DateTimeFormat().resolvedOptions().timeZone, }, ]) - expect(api.callsOf('session.history')).toEqual([]) - expect(api.callsOf('session.prompt')).toEqual([]) - const listCalls = api.callsOf('subagents.list').length + expect(remote.session.prompt).not.toHaveBeenCalled() + const listCalls = remote.subagents.list.mock.calls.length manager.handleSessionStatus(S2, false) expect(manager.getListSnapshot().subagentsByParent[S1]?.entries[0]).toMatchObject({ kind: 'child', id: S2, activity: 'inactive', }) - expect(api.callsOf('subagents.list')).toHaveLength(listCalls) + expect(remote.subagents.list).toHaveBeenCalledTimes(listCalls) manager.handleSessionRemoved(S2) expect(manager.getListSnapshot().items.find(item => item.sessionId === S2)).toMatchObject({ @@ -346,33 +347,31 @@ describe('subagent catalogs', () => { }) }) - it('refetches debounced membership only while the parent catalog is open', async () => { + it('refetches debounced membership only while the parent catalog is open', async ({ mock, remote }) => { vi.useFakeTimers() try { - const api = new FakeApiClient() - const manager = new SessionManager(fakeRemote(api)) + const manager = makeManager(mock, remote) await manager.refreshSubagents(S1) manager.setSubagentCatalogOpen(S1, true) await Promise.resolve() - const baseline = api.callsOf('subagents.list').length + const baseline = remote.subagents.list.mock.calls.length manager.handleSessionAdded(summary(S2, { parentSessionId: S1 })) manager.handleSessionAdded(summary('fk-m3' as SessionId, { parentSessionId: S1 })) await vi.advanceTimersByTimeAsync(50) - expect(api.callsOf('subagents.list')).toHaveLength(baseline + 1) + expect(remote.subagents.list).toHaveBeenCalledTimes(baseline + 1) manager.setSubagentCatalogOpen(S1, false) manager.handleSessionAdded(summary('fk-m4' as SessionId, { parentSessionId: S1 })) await vi.advanceTimersByTimeAsync(50) - expect(api.callsOf('subagents.list')).toHaveLength(baseline + 1) + expect(remote.subagents.list).toHaveBeenCalledTimes(baseline + 1) } finally { vi.useRealTimers() } }) - it('marks a loaded parent row expandable only for a direct subagent publication', async () => { - const api = new FakeApiClient() + it('marks a loaded parent row expandable only for a direct subagent publication', async ({ mock, remote }) => { const root = 'fk-root' as SessionId - api.onSubagentList = () => Promise.resolve(ok({ + remote.subagents.list.mockResolvedValue(ok({ entries: [ { kind: 'child', id: S1, mode: 'continuable', label: 'parent', @@ -385,7 +384,7 @@ describe('subagent catalogs', () => { ] as never[], parentAvailable: true, })) - const manager = new SessionManager(fakeRemote(api)) + const manager = makeManager(mock, remote) await manager.refreshSubagents(root) manager.handleSessionAdded(summary('fk-grandchild' as SessionId, { @@ -399,12 +398,11 @@ describe('subagent catalogs', () => { ]) }) - it('preserves a live expandability hint across only the older in-flight catalog response', async () => { - const api = new FakeApiClient() + it('preserves a live expandability hint across only the older in-flight catalog response', async ({ mock, remote }) => { const root = 'fk-root' as SessionId - const response = deferred>>() - api.onSubagentList = () => response.promise - const manager = new SessionManager(fakeRemote(api)) + const response = Promise.withResolvers>>() + remote.subagents.list.mockReturnValue(response.promise) + const manager = makeManager(mock, remote) const refresh = manager.refreshSubagents(root) manager.handleSessionAdded(summary('fk-grandchild' as SessionId, { @@ -423,7 +421,7 @@ describe('subagent catalogs', () => { { kind: 'child', id: S1, hasChildren: true }, ]) - api.onSubagentList = () => Promise.resolve(ok({ + remote.subagents.list.mockResolvedValue(ok({ entries: [{ kind: 'child', id: S1, mode: 'continuable', label: 'parent', activity: 'inactive', hasChildren: false, @@ -436,12 +434,11 @@ describe('subagent catalogs', () => { ]) }) - it('replays status frames over an older in-flight catalog response', async () => { - const api = new FakeApiClient() + it('replays status frames over an older in-flight catalog response', async ({ mock, remote }) => { const root = 'fk-root' as SessionId - const response = deferred>>() - api.onSubagentList = () => response.promise - const manager = new SessionManager(fakeRemote(api)) + const response = Promise.withResolvers>>() + remote.subagents.list.mockReturnValue(response.promise) + const manager = makeManager(mock, remote) const refresh = manager.refreshSubagents(root) manager.handleSessionStatus(S1, false) @@ -467,16 +464,15 @@ describe('subagent catalogs', () => { ]) }) - it('marks a detached catalog child inactive without requiring a selected address', async () => { - const api = new FakeApiClient() - api.onSubagentList = () => Promise.resolve(ok({ + it('marks a detached catalog child inactive without requiring a selected address', async ({ mock, remote }) => { + remote.subagents.list.mockResolvedValue(ok({ entries: [{ kind: 'child', id: S2, mode: 'continuable', label: 'worker', activity: 'running', hasChildren: false, }] as never[], parentAvailable: true, })) - const manager = new SessionManager(fakeRemote(api)) + const manager = makeManager(mock, remote) await manager.refreshSubagents(S1) manager.handleSessionRemoved(S2) @@ -486,31 +482,29 @@ describe('subagent catalogs', () => { ]) }) - it('coalesces overlapping catalog reads without scheduling a trailing pull', async () => { - const api = new FakeApiClient() + it('coalesces overlapping catalog reads without scheduling a trailing pull', async ({ mock, remote }) => { const root = 'fk-root' as SessionId - const first = deferred>>() - api.onSubagentList = () => first.promise - const manager = new SessionManager(fakeRemote(api)) + const first = Promise.withResolvers>>() + remote.subagents.list.mockReturnValue(first.promise) + const manager = makeManager(mock, remote) const refresh = manager.refreshSubagents(root) expect(manager.refreshSubagents(root)).toBe(refresh) - api.onSubagentList = () => Promise.resolve(ok({ entries: [], parentAvailable: true })) + remote.subagents.list.mockResolvedValue(ok({ entries: [], parentAvailable: true })) first.resolve(ok({ entries: [], parentAvailable: true })) await refresh - expect(api.callsOf('subagents.list')).toHaveLength(1) + expect(remote.subagents.list).toHaveBeenCalledOnce() }) - it('runs one trailing catalog refresh for a membership change coalesced into an in-flight pull', async () => { + it('runs one trailing catalog refresh for a membership change coalesced into an in-flight pull', async ({ mock, remote }) => { vi.useFakeTimers() try { - const api = new FakeApiClient() const root = 'fk-root' as SessionId - const first = deferred>>() - const second = deferred>>() - api.onSubagentList = () => first.promise - const manager = new SessionManager(fakeRemote(api), root) + const first = Promise.withResolvers>>() + const second = Promise.withResolvers>>() + remote.subagents.list.mockReturnValue(first.promise) + const manager = makeManager(mock, remote, root) const refresh = manager.refreshSubagents(root) manager.setSubagentCatalogOpen(root, true) @@ -520,7 +514,7 @@ describe('subagent catalogs', () => { // queue one trailing pull carrying the change. manager.handleSessionAdded(summary(S2, { parentSessionId: root })) await vi.advanceTimersByTimeAsync(50) - api.onSubagentList = () => second.promise + remote.subagents.list.mockReturnValueOnce(second.promise) first.resolve(ok({ entries: [{ kind: 'child', id: S1, mode: 'continuable', label: 'older', @@ -547,7 +541,7 @@ describe('subagent catalogs', () => { // The Remote face resolves one microtask after the response settles. await vi.advanceTimersByTimeAsync(0) - expect(api.callsOf('subagents.list')).toHaveLength(2) + expect(remote.subagents.list).toHaveBeenCalledTimes(2) expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([ { kind: 'child', id: S1, label: 'older' }, { kind: 'child', id: S2, label: 'new child' }, @@ -557,16 +551,15 @@ describe('subagent catalogs', () => { } }) - it('keeps removal invalidation across a stale success and failed trailing pull', async () => { - const api = new FakeApiClient() + it('keeps removal invalidation across a stale success and failed trailing pull', async ({ mock, remote }) => { const root = 'fk-root' as SessionId const child = () => ({ kind: 'child' as const, id: S2, mode: 'continuable' as const, label: 'worker', activity: 'inactive' as const, hasChildren: false, }) - const first = deferred>>() - api.onSubagentList = () => first.promise - const manager = new SessionManager(fakeRemote(api)) + const first = Promise.withResolvers>>() + remote.subagents.list.mockReturnValue(first.promise) + const manager = makeManager(mock, remote) const refresh = manager.refreshSubagents(root) first.resolve(ok({ entries: [child()] as never[], parentAvailable: true })) await refresh @@ -574,12 +567,12 @@ describe('subagent catalogs', () => { // The removal lands while a second pull is in flight: the invalidation // must survive the pre-removal ok response, so one trailing pull runs. - const mid = deferred>>() - api.onSubagentList = () => mid.promise + const mid = Promise.withResolvers>>() + remote.subagents.list.mockReturnValueOnce(mid.promise) const midRefresh = manager.refreshSubagents(root) manager.handleSessionRemoved(root) - const trailing = deferred>>() - api.onSubagentList = () => trailing.promise + const trailing = Promise.withResolvers>>() + remote.subagents.list.mockReturnValueOnce(trailing.promise) mid.resolve(ok({ entries: [child()] as never[], parentAvailable: true })) await midRefresh expect(manager.getListSnapshot().subagentsByParent[root]?.parentAvailable).toBe(false) @@ -593,23 +586,22 @@ describe('subagent catalogs', () => { }) }) - const rootCalls = api.callsOf('subagents.list').filter(call => call === root) + const rootCalls = remote.subagents.list.mock.calls.filter(([call]) => call === root) expect(rootCalls).toHaveLength(3) expect(manager.getListSnapshot().subagentsByParent[root]?.parentAvailable).toBe(false) expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: false }) }) - it('invalidates catalog availability when the owning parent is removed', async () => { - const api = new FakeApiClient() + it('invalidates catalog availability when the owning parent is removed', async ({ mock, remote }) => { const root = 'fk-root' as SessionId - api.onSubagentList = () => Promise.resolve(ok({ + remote.subagents.list.mockResolvedValue(ok({ entries: [{ kind: 'child', id: S2, mode: 'continuable', label: 'worker', activity: 'inactive', hasChildren: false, }] as never[], parentAvailable: true, })) - const manager = new SessionManager(fakeRemote(api)) + const manager = makeManager(mock, remote) await manager.refreshSubagents(root) manager.selectSubagent({ parentSessionId: root, childSessionId: S2, mode: 'continuable' }) expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: true }) @@ -622,56 +614,51 @@ describe('subagent catalogs', () => { }) describe('remaining branches', () => { - it('refreshList propagates a non-Remote throw', async () => { - const api = new FakeApiClient() - api.onList = () => Promise.reject(new Error('list wire down')) - const manager = new SessionManager(fakeRemote(api)) + it('refreshList propagates a non-Remote throw', async ({ mock, remote }) => { + remote.session.list.mockRejectedValue(new Error('list wire down')) + const manager = makeManager(mock, remote) await expect(manager.refreshList()).rejects.toThrow('list wire down') }) - it('refreshList pushes running bits down to already-instantiated sessions', async () => { - const api = new FakeApiClient() - const manager = new SessionManager(fakeRemote(api)) + it('refreshList pushes running bits down to already-instantiated sessions', async ({ mock, remote }) => { + const manager = makeManager(mock, remote) const session = manager.get(S1) - api.onList = () => Promise.resolve(ok({ items: [summary(S1, { running: true })] as never[] })) + remote.session.list.mockResolvedValue(ok({ items: [summary(S1, { running: true })] as never[] })) await manager.refreshList() expect(session.getSnapshot().running).toBe(true) }) - it('create passes cwd and a preallocated id, folds transport throws, and deduplicates the echo', async () => { - const api = new FakeApiClient() - api.onCreate = () => Promise.resolve(ok({ sessionId: S1 })) - const manager = new SessionManager(fakeRemote(api)) + it('create passes cwd and a preallocated id, folds transport throws, and deduplicates the echo', async ({ mock, remote }) => { + remote.session.create.mockResolvedValue(ok({ sessionId: S1 })) + const manager = makeManager(mock, remote) await manager.create({ cwd: '/tmp/w', sessionId: S1 }) - expect(api.callsOf('session.create')).toEqual([{ cwd: '/tmp/w', sessionId: S1 }]) + expect(remote.session.create).toHaveBeenCalledWith({ cwd: '/tmp/w', sessionId: S1 }) expect(manager.getListSnapshot().items[0]).toMatchObject({ sessionId: S1, cwd: '/tmp/w' }) await manager.create({ cwd: '/tmp/w' }) // same id returned: no duplicate row expect(manager.getListSnapshot().items).toHaveLength(1) - api.onCreate = () => Promise.reject(new Error('create wire down')) + remote.session.create.mockRejectedValue(new Error('create wire down')) await expect(manager.create()).rejects.toThrow('create wire down') // Business error passes through untouched. - api.onCreate = () => Promise.resolve(err(new RemoteError('gateway/internal', 'no', {}))) + remote.session.create.mockResolvedValue(err(new RemoteError('gateway/internal', 'no', {}))) expect(await manager.create()).toMatchObject({ ok: false }) }) - it('publishes a real Ungrouped summary from workspace-attach-failed', async () => { - const api = new FakeApiClient() - api.onCreate = () => Promise.resolve(err(new RemoteError('session/workspace-attach-failed', 'published but unattached', { + it('publishes a real Ungrouped summary from workspace-attach-failed', async ({ mock, remote }) => { + remote.session.create.mockResolvedValue(err(new RemoteError('session/workspace-attach-failed', 'published but unattached', { sessionId: S1, workspaceId: 'w1', }))) - const manager = new SessionManager(fakeRemote(api)) + const manager = makeManager(mock, remote) const result = await manager.create({ workspaceId: 'w1' as never, sessionId: S1 }) expect(result).toMatchObject({ ok: false, error: { code: 'session/workspace-attach-failed' } }) expect(manager.getListSnapshot().items).toEqual([expect.objectContaining({ sessionId: S1 })]) expect(manager.getListSnapshot().items[0]).not.toHaveProperty('cwd') }) - it('reconciles a fork child published before workspace attachment fails', async () => { - const api = new FakeApiClient() - api.onFork = () => Promise.resolve(err(new RemoteError('session/workspace-attach-failed', 'forked but unattached', { + it('reconciles a fork child published before workspace attachment fails', async ({ mock, remote }) => { + remote.session.fork.mockResolvedValue(err(new RemoteError('session/workspace-attach-failed', 'forked but unattached', { sessionId: S2, workspaceId: 'w1', }))) - const manager = new SessionManager(fakeRemote(api)) + const manager = makeManager(mock, remote) const result = await manager.fork({ sessionId: S1 }) expect(result).toMatchObject({ ok: false, error: { code: 'session/workspace-attach-failed' } }) expect(manager.getListSnapshot().items).toEqual([expect.objectContaining({ @@ -681,10 +668,9 @@ describe('remaining branches', () => { })]) }) - it('reconciles a preallocated id after an ordinary transport failure', async () => { - const api = new FakeApiClient() - api.onCreate = () => Promise.reject(new Error('response lost')) - const manager = new SessionManager(fakeRemote(api)) + it('reconciles a preallocated id after an ordinary transport failure', async ({ mock, remote }) => { + remote.session.create.mockRejectedValue(new Error('response lost')) + const manager = makeManager(mock, remote) await expect(manager.create({ workspaceId: 'w1' as never, sessionId: S1 })) .rejects.toThrow('response lost') expect(manager.getListSnapshot().items).toEqual([]) @@ -697,9 +683,8 @@ describe('remaining branches', () => { expect(manager.getListSnapshot().items).toHaveLength(1) }) - it('subscribe notifies on list changes and stops after unsubscribe', async () => { - const api = new FakeApiClient() - const manager = new SessionManager(fakeRemote(api)) + it('subscribe notifies on list changes and stops after unsubscribe', async ({ mock, remote }) => { + const manager = makeManager(mock, remote) let notified = 0 const unsubscribe = manager.subscribe(() => { notified++ }) await manager.refreshList() @@ -712,17 +697,15 @@ describe('remaining branches', () => { expect(notified).toBe(seen) }) - it('ignores Host status and error events for sessions without an instance', () => { - const api = new FakeApiClient() - const manager = new SessionManager(fakeRemote(api)) + it('ignores Host status and error events for sessions without an instance', ({ mock, remote }) => { + const manager = makeManager(mock, remote) manager.handleSessionStatus(S2, true) manager.handleSessionError(S2, '无实例') }) - it('keeps list-entry identity for unchanged rows across an unrelated list change', async () => { - const api = new FakeApiClient() - api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] })) - const manager = new SessionManager(fakeRemote(api)) + it('keeps list-entry identity for unchanged rows across an unrelated list change', async ({ mock, remote }) => { + remote.session.list.mockResolvedValue(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] })) + const manager = makeManager(mock, remote) await manager.refreshList() const before = manager.getListSnapshot() manager.handleSessionStatus(S2, true) @@ -736,9 +719,8 @@ describe('remaining branches', () => { expect(manager.getListSnapshot().items).toBe(after.items) }) - it('carries parentSessionId from the added event into the lineage row', () => { - const api = new FakeApiClient() - const manager = new SessionManager(fakeRemote(api)) + it('carries parentSessionId from the added event into the lineage row', ({ mock, remote }) => { + const manager = makeManager(mock, remote) manager.handleSessionAdded(summary(S1, { blank: true })) manager.handleSessionAdded(summary(S2, { blank: true, parentSessionId: S1, origin: 'subagent', @@ -751,34 +733,34 @@ describe('remaining branches', () => { }) describe('connected generation', () => { - it('refreshes query baselines without rebuilding independently resumed Session sources', async () => { - const api = new FakeApiClient() - api.onHistory = () => Promise.resolve(ok({ + it('refreshes query baselines without rebuilding independently resumed Session sources', async ({ mock, remote, start }) => { + mock.stream(FOLLOW, followScript(ok({ records: entries(plainTurn(SessionSeq(0), 0, 'a', 'b')) as never[], hasMore: false, modelSelection: { provider: 'deepseek-official', model: 'deepseek-chat' }, - })) - const manager = new SessionManager(fakeRemote(api)) + }))) + const client = await start() + const manager = new SessionManager(client.ctx.remote) const openedSession = manager.get(S1) await openedSession.open() manager.get(S2) // instantiated but never opened - const historyCallsBefore = api.callsOf('session.history').length + const historyCallsBefore = remote.session.page.mock.calls.length manager.handleConnected() await vi.waitFor(() => { - expect(api.callsOf('session.list').length).toBe(1) + expect(remote.session.list).toHaveBeenCalledOnce() }) - expect(api.callsOf('session.history')).toHaveLength(historyCallsBefore) + expect(remote.session.follow).toHaveBeenCalledOnce() + expect(remote.session.page).toHaveBeenCalledTimes(historyCallsBefore) }) - it('retains the durable parent address and refreshes its catalogs across reconnect', async () => { - const api = new FakeApiClient() + it('retains the durable parent address and refreshes its catalogs across reconnect', async ({ mock, remote }) => { const address = { parentSessionId: S1, childSessionId: S2, mode: 'continuable' as const, } - const parent = deferred>>() - const child = deferred>>() - api.onSubagentList = payload => (payload === S1 ? parent.promise : child.promise) - const manager = new SessionManager(fakeRemote(api), S2, address) + const parent = Promise.withResolvers>>() + const child = Promise.withResolvers>>() + remote.subagents.list.mockImplementation(payload => (payload === S1 ? parent.promise : child.promise)) + const manager = makeManager(mock, remote, S2, address) manager.handleConnected() expect(manager.get(S2).getSnapshot().subagent).toEqual({ address }) @@ -786,10 +768,10 @@ describe('connected generation', () => { child.resolve(ok({ entries: [], parentAvailable: true })) await vi.waitFor(() => { - expect(api.callsOf('session.list')).toHaveLength(1) + expect(remote.session.list).toHaveBeenCalledOnce() }) await vi.waitFor(() => { - expect(api.callsOf('subagents.list')).toEqual([S1, S2]) + expect(remote.subagents.list.mock.calls.map(([parentSessionId]) => parentSessionId)).toEqual([S1, S2]) }) expect(manager.get(S2).getSnapshot().subagent).toEqual({ address, @@ -809,8 +791,8 @@ describe('completed reminder', () => { const entry = (manager: SessionManager, sessionId: SessionId) => manager.getListSnapshot().items.find(item => item.sessionId === sessionId) - it('arms on a running→idle flip of a non-selected session and clears on select', () => { - const manager = makeManager() + it('arms on a running→idle flip of a non-selected session and clears on select', ({ mock, remote }) => { + const manager = makeManager(mock, remote) added(manager, S1) added(manager, S2) manager.select(S1) @@ -823,8 +805,8 @@ describe('completed reminder', () => { expect(entry(manager, S2)?.completed).toBe(false) }) - it('never arms for the session being watched and re-arms after a switch-away re-run', () => { - const manager = makeManager() + it('never arms for the session being watched and re-arms after a switch-away re-run', ({ mock, remote }) => { + const manager = makeManager(mock, remote) added(manager, S1) added(manager, S2) manager.select(S2) @@ -838,8 +820,8 @@ describe('completed reminder', () => { expect(entry(manager, S2)?.completed).toBe(true) }) - it('a re-run disarms the reminder while running and re-arms on its completion', () => { - const manager = makeManager() + it('a re-run disarms the reminder while running and re-arms on its completion', ({ mock, remote }) => { + const manager = makeManager(mock, remote) added(manager, S1) added(manager, S2) manager.select(S1) @@ -853,8 +835,8 @@ describe('completed reminder', () => { expect(entry(manager, S2)?.completed).toBe(true) }) - it('session-removed drops the reminder and a re-add starts clean', () => { - const manager = makeManager() + it('session-removed drops the reminder and a re-add starts clean', ({ mock, remote }) => { + const manager = makeManager(mock, remote) added(manager, S1) added(manager, S2) manager.select(S1) @@ -867,35 +849,32 @@ describe('completed reminder', () => { expect(entry(manager, S2)?.completed).toBe(false) }) - it('a list refresh carrying the running→idle transition arms the reminder', async () => { - const api = new FakeApiClient() - api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200, running: true })] as never[] })) - const manager = new SessionManager(fakeRemote(api)) + it('a list refresh carrying the running→idle transition arms the reminder', async ({ mock, remote }) => { + remote.session.list.mockResolvedValue(ok({ items: [summary(S1), summary(S2, { updatedAt: 200, running: true })] as never[] })) + const manager = makeManager(mock, remote) await manager.refreshList() manager.select(S1) expect(entry(manager, S2)?.completed).toBe(false) - api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200, running: false })] as never[] })) + remote.session.list.mockResolvedValue(ok({ items: [summary(S1), summary(S2, { updatedAt: 200, running: false })] as never[] })) await manager.refreshList() expect(entry(manager, S2)?.completed).toBe(true) }) - it('never arms for sessions already idle at first observation', async () => { - const api = new FakeApiClient() - api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] })) - const manager = new SessionManager(fakeRemote(api)) + it('never arms for sessions already idle at first observation', async ({ mock, remote }) => { + remote.session.list.mockResolvedValue(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] })) + const manager = makeManager(mock, remote) await manager.refreshList() manager.select(S1) expect(entry(manager, S2)?.completed).toBe(false) - api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 201 })] as never[] })) + remote.session.list.mockResolvedValue(ok({ items: [summary(S1), summary(S2, { updatedAt: 201 })] as never[] })) await manager.refreshList() expect(entry(manager, S2)?.completed).toBe(false) }) - it('arms a completion that happened during an in-flight first pull (baseline running, replayed idle)', async () => { - const api = new FakeApiClient() - const gate = deferred>>() - api.onList = () => gate.promise - const manager = new SessionManager(fakeRemote(api)) + it('arms a completion that happened during an in-flight first pull (baseline running, replayed idle)', async ({ mock, remote }) => { + const gate = Promise.withResolvers>>() + remote.session.list.mockReturnValue(gate.promise) + const manager = makeManager(mock, remote) const refresh = manager.refreshList() // The session finishes while the first pull is still in flight; the pull // response recorded it as running at pull time. @@ -905,11 +884,10 @@ describe('completed reminder', () => { expect(entry(manager, S2)?.completed).toBe(true) }) - it('arms when a session ran and completed entirely between in-flight mutations (baseline idle)', async () => { - const api = new FakeApiClient() - const gate = deferred>>() - api.onList = () => gate.promise - const manager = new SessionManager(fakeRemote(api)) + it('arms when a session ran and completed entirely between in-flight mutations (baseline idle)', async ({ mock, remote }) => { + const gate = Promise.withResolvers>>() + remote.session.list.mockReturnValue(gate.promise) + const manager = makeManager(mock, remote) const refresh = manager.refreshList() // The unknown session starts and finishes while the first pull is in // flight; the pull-time baseline recorded it idle, so the running→idle @@ -933,8 +911,8 @@ describe('background-job mirror', () => { type: 'jobs', sessionId, jobs: jobs as never, }) - it('mirrors the whole set last-wins, keyed per session, with no Session instance needed', () => { - const manager = makeManager() + it('mirrors the whole set last-wins, keyed per session, with no Session instance needed', ({ mock, remote }) => { + const manager = makeManager(mock, remote) manager.handleControlFrame(tasksFrame(S1, [view()])) manager.handleControlFrame(tasksFrame(S2, [view({ id: 'pwsh-1', label: 'other' })])) const first = manager.getListSnapshot().jobsBySession @@ -946,16 +924,16 @@ describe('background-job mirror', () => { expect(manager.getListSnapshot().jobsBySession[S1]).toEqual([view({ status: 'completed' })]) }) - it('stores an emptied set as an absent key so absence and [] read alike', () => { - const manager = makeManager() + it('stores an emptied set as an absent key so absence and [] read alike', ({ mock, remote }) => { + const manager = makeManager(mock, remote) manager.handleControlFrame(tasksFrame(S1, [view()])) expect(S1 in manager.getListSnapshot().jobsBySession).toBe(true) manager.handleControlFrame(tasksFrame(S1, [])) expect(S1 in manager.getListSnapshot().jobsBySession).toBe(false) }) - it('clears the mirror when the next control baseline has no jobs', () => { - const manager = makeManager() + it('clears the mirror when the next control baseline has no jobs', ({ mock, remote }) => { + const manager = makeManager(mock, remote) manager.handleControlFrame(tasksFrame(S1, [view()])) manager.handleControlFrame({ type: 'baseline', @@ -964,16 +942,16 @@ describe('background-job mirror', () => { expect(S1 in manager.getListSnapshot().jobsBySession).toBe(false) }) - it('drops the rows when the session is removed, whichever stream lands first', () => { - const manager = makeManager() + it('drops the rows when the session is removed, whichever stream lands first', ({ mock, remote }) => { + const manager = makeManager(mock, remote) manager.handleSessionAdded(summary(S1, { blank: true })) manager.handleControlFrame(tasksFrame(S1, [view()])) manager.handleSessionRemoved(S1) expect(S1 in manager.getListSnapshot().jobsBySession).toBe(false) }) - it('notifies list subscribers so an open header re-renders without a poll', async () => { - const manager = makeManager() + it('notifies list subscribers so an open header re-renders without a poll', async ({ mock, remote }) => { + const manager = makeManager(mock, remote) const seen = vi.fn() manager.subscribe(seen) manager.handleControlFrame(tasksFrame(S1, [view()])) diff --git a/packages/api/session-controller/tests/projection-store.client.spec.ts b/packages/api/session-controller/tests/projection-store.client.spec.ts index 74f37c57cd..b9c651bc76 100644 --- a/packages/api/session-controller/tests/projection-store.client.spec.ts +++ b/packages/api/session-controller/tests/projection-store.client.spec.ts @@ -7,14 +7,19 @@ * seeding, control-stream projection routing pre- and post-instantiation, the * list rows' title projection). */ -import { describe, expect, it } from 'vitest' +import { describe, expect } from 'vitest' import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' import { SessionSeq } from '@deepseek-ai/dsh-session/types' +import { ok, type RemoteMock } from '@deepseek-ai/dsh-remote-mock' +import { + createClientTest, type ClientTestFixtures, webApp, +} from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts' import { ProjectionValueStore } from '../src/client/sessions/projection-store.ts' -import { Session } from '../src/client/sessions/session.ts' import { SessionManager } from '../src/client/sessions/manager.ts' -import { FakeApiClient, fakeRemote, ok } from './fake-api.client.ts' +import type { SessionRemotes } from '../src/client/sessions/remotes.ts' import { entries, plainTurn } from './event-script.client.ts' +import { sessionBench } from './remote/bench.client.ts' +import { FOLLOW, followScript, sessionWorld } from './remote/session.client.ts' // Test-domain keys merged into the projection map (the Service Definition package's // pure-type outlet), the same way domain host plugins merge theirs. @@ -25,6 +30,15 @@ declare module '@deepseek-ai/dsh-session-projection/types' { } const SID = 'fk-s1' as SessionId +/** A Session talks through the Gateway client; its dependency cone is the Typert registry and the Connection. */ +const API_ROSTER = webApp.closure(['@deepseek-ai/dsh-api-gateway']) +const it = createClientTest({ roster: API_ROSTER }) + +function makeManager(mock: RemoteMock, remote: ClientTestFixtures['remote']): SessionManager { + mock.load(sessionWorld) + // Manager-routing cases never open a Session, so they do not need the broader Client Remote's $stream member. + return new SessionManager(remote as unknown as SessionRemotes) +} describe('Session projection value semantics', () => { it('reads undefined until a value lands (capability absence)', () => { @@ -102,34 +116,31 @@ describe('Session projection value semantics', () => { }) describe('Session tail-page seeding', () => { - it('seeds the store from a history response carrying a projections block', async () => { - const api = new FakeApiClient() - const session = new Session(SID, fakeRemote(api)) - api.onHistory = () => Promise.resolve(ok({ + it('seeds the store from a history response carrying a projections block', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + mock.stream(FOLLOW, followScript(ok({ records: entries(plainTurn(SessionSeq(0), 0, '问', '答')) as never[], hasMore: false, projections: { asOfSeq: 5, values: { 'test/marks': { marks: ['from-baseline'] } } }, - } as never)) + } as never))) await session.open() expect(session.projections.get('test/marks')).toEqual({ marks: ['from-baseline'] }) }) - it('a resync serving a stale block keeps the newer pushed value (seq rule end to end)', async () => { - const api = new FakeApiClient() - const session = new Session(SID, fakeRemote(api)) - api.onHistory = () => Promise.resolve(ok({ + it('a resync serving a stale block keeps the newer pushed value (seq rule end to end)', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + mock.stream(FOLLOW, followScript(ok({ records: entries(plainTurn(SessionSeq(0), 0, 'a', 'b')) as never[], hasMore: false, projections: { asOfSeq: 5, values: { 'test/marks': { marks: ['baseline'] } } }, - } as never)) + } as never))) await session.open() session.projections.apply('test/marks', { marks: ['pushed-9'] }, SessionSeq(9)) await session.resync() expect(session.projections.get('test/marks')).toEqual({ marks: ['pushed-9'] }) }) - it('treats a blockless response as no reset: pushed values survive', async () => { - const api = new FakeApiClient() - const session = new Session(SID, fakeRemote(api)) - api.onHistory = () => Promise.resolve(ok({ records: entries(plainTurn(SessionSeq(0), 0, 'a', 'b')) as never[], hasMore: false })) + it('treats a blockless response as no reset: pushed values survive', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + mock.stream(FOLLOW, followScript(ok({ records: entries(plainTurn(SessionSeq(0), 0, 'a', 'b')) as never[], hasMore: false }))) await session.open() session.projections.apply('test/marks', { marks: ['pushed'] }, SessionSeq(9)) await session.resync() @@ -140,9 +151,8 @@ describe('Session tail-page seeding', () => { describe('manager frame routing', () => { const sid = (s: string): SessionId => s as SessionId - it('lands projection frames before instantiation and the Session adopts the same store', async () => { - const api = new FakeApiClient() - const manager = new SessionManager(fakeRemote(api)) + it('lands projection frames before instantiation and the Session adopts the same store', ({ mock, remote }) => { + const manager = makeManager(mock, remote) manager.handleControlFrame({ type: 'projection', sessionId: sid('s1'), key: 'test/marks', value: { marks: ['early'] }, seq: 7, }) @@ -155,12 +165,11 @@ describe('manager frame routing', () => { expect(session.projections.get('test/marks')).toEqual({ marks: ['later'] }) }) - it('projects the title key into list rows and truncates phantom rows on the control baseline', async () => { - const api = new FakeApiClient() - const manager = new SessionManager(fakeRemote(api)) - api.onList = () => Promise.resolve(ok({ + it('projects the title key into list rows and truncates phantom rows on the control baseline', async ({ mock, remote }) => { + const manager = makeManager(mock, remote) + remote.session.list.mockResolvedValue(ok({ items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }], - }) as never) + })) await manager.refreshList() manager.handleControlFrame({ type: 'projection', sessionId: sid('s1'), key: 'title', value: 'Projected title', seq: 4, @@ -180,10 +189,9 @@ describe('manager frame routing', () => { expect(manager.getListSnapshot().items[0]?.title).toBeUndefined() }) - it('projects every retained value into list rows with stable snapshot identity', async () => { - const api = new FakeApiClient() - const manager = new SessionManager(fakeRemote(api)) - api.onList = () => Promise.resolve(ok({ + it('projects every retained value into list rows with stable snapshot identity', async ({ mock, remote }) => { + const manager = makeManager(mock, remote) + remote.session.list.mockResolvedValue(ok({ items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false, projections: { @@ -191,7 +199,7 @@ describe('manager frame routing', () => { values: { 'test/marks': { marks: ['baseline'] } }, }, }], - }) as never) + })) await manager.refreshList() const baseline = manager.getListSnapshot().items[0]?.projectionValues expect(baseline).toEqual({ 'test/marks': { marks: ['baseline'] } }) @@ -207,12 +215,11 @@ describe('manager frame routing', () => { expect(manager.getListSnapshot().items[0]?.projectionValues).not.toBe(baseline) }) - it('drops the projection store with the removed session', async () => { - const api = new FakeApiClient() - const manager = new SessionManager(fakeRemote(api)) - api.onList = () => Promise.resolve(ok({ + it('drops the projection store with the removed session', async ({ mock, remote }) => { + const manager = makeManager(mock, remote) + remote.session.list.mockResolvedValue(ok({ items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }], - }) as never) + })) await manager.refreshList() manager.handleControlFrame({ type: 'projection', sessionId: sid('s1'), key: 'title', value: 'Doomed', seq: 4, diff --git a/packages/api/session-controller/tests/remote/history.client.ts b/packages/api/session-controller/tests/remote/history.client.ts index 3a194f4e99..e954f89a2b 100644 --- a/packages/api/session-controller/tests/remote/history.client.ts +++ b/packages/api/session-controller/tests/remote/history.client.ts @@ -1,4 +1,4 @@ -/** Pure history responses shared by the local Remote fake and assembled Session tests. */ +/** Pure history response builders for assembled Session tests. */ import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session/types' import type { SessionAssistantStreamBaseline, SessionFollowFrame, SessionFollowRequest, SessionPage, SessionProjectionBaseline, diff --git a/packages/api/session-controller/tests/remote/session.client.ts b/packages/api/session-controller/tests/remote/session.client.ts index d2e0b3c135..e35b845f2e 100644 --- a/packages/api/session-controller/tests/remote/session.client.ts +++ b/packages/api/session-controller/tests/remote/session.client.ts @@ -1,6 +1,6 @@ /** * The Remote side of one Session under test: default answers for every - * `session/*` and `subagents/*` endpoint a `Session` calls, builders for + * `session/*` and `subagents/*` endpoint a `Session` or its manager calls, builders for * the two history-shaped answers, the `session/follow` opening snapshot and * the `session/page` page, both derived from event lists the way the Host * derives them from its log, and builders for the `session/control` queue @@ -13,8 +13,8 @@ import { ok, type RemoteMock, type RemoteTable, type StreamScript, type UnaryRul import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' import type { RemoteFailure, RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import type { - SessionControlFrame, SessionFollowFrame, SessionFollowRequest, SessionPage, SessionPageRequest, - SessionQueuedItem, SessionRequestId, + SessionAssistantStreamBaseline, SessionControlFrame, SessionFollowFrame, SessionFollowRequest, + SessionPage, SessionPageRequest, SessionQueuedItem, SessionRequestId, } from '../../src/types.ts' import { entries, historyValue } from '../event-script.client.ts' import { followSnapshot, pageThrough } from './history.client.ts' @@ -73,10 +73,16 @@ export async function pushEvent(mock: RemoteMock, event: SessionEvent): Promise< * then open for pushes. A failed answer fails the stream with its error; a * rejected one fails it with the rejection. * @param history - history answer or a function of the follow request. - * @param options - `cursor` overrides the snapshot cursor. + * @param options - snapshot cursor and initial Assistant stream state; the latter may be read per opening. * @returns the script. */ -export function followScript(history: HistorySource, options: { cursor?: number } = {}): StreamScript { +export function followScript( + history: HistorySource, + options: { + cursor?: number + assistantStream?: SessionAssistantStreamBaseline | (() => SessionAssistantStreamBaseline) + } = {}, +): StreamScript { return async ([request], stream) => { const follow = request as SessionFollowRequest const result = await answer(history, follow) @@ -84,7 +90,10 @@ export function followScript(history: HistorySource, optio stream.fail(result.error) return } - stream.push(followSnapshot(result.value, follow, options.cursor)) + const assistantStream = typeof options.assistantStream === 'function' + ? options.assistantStream() + : options.assistantStream + stream.push(followSnapshot(result.value, follow, options.cursor, assistantStream)) } } @@ -167,7 +176,7 @@ export function queueFrame( return { type: 'queue', sessionId, items: items.map(queueItem) } } -/** Default answers: every command accepted, an empty history, one attachment of one zero byte. */ +/** Default answers: every command accepted, empty history and subagent catalog, one attachment of one zero byte. */ export const sessionWorld: RemoteTable = { unary: { 'session/prompt': ok({ accepted: true }), @@ -179,6 +188,7 @@ export const sessionWorld: RemoteTable = { data: 'AA==', }), 'session/page': pageRule(ok({ records: [], hasMore: false })), + 'subagents/list': ok({ entries: [], parentAvailable: true }), 'subagents/prompt': ok({ messageId: 'fake-message' }), 'subagents/interruptByParent': ok({ accepted: true }), }, diff --git a/packages/api/session-controller/tests/sessions-service.client.spec.ts b/packages/api/session-controller/tests/sessions-service.client.spec.ts index 81de34198f..f0327c6220 100644 --- a/packages/api/session-controller/tests/sessions-service.client.spec.ts +++ b/packages/api/session-controller/tests/sessions-service.client.spec.ts @@ -7,39 +7,50 @@ * projection, create. */ import { Context } from '@deepseek-ai/cordis' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, vi } from 'vitest' import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import { LlmAttemptId } from '@deepseek-ai/dsh-llm' import { RemoteStreamCarrierError } from '@deepseek-ai/dsh-api-gateway/client' import { SESSION_FORMAT_VERSION, SessionSeq } from '@deepseek-ai/dsh-session/types' +import { ok, type RemoteMock } from '@deepseek-ai/dsh-remote-mock' +import { createClientTest, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts' import { ClientSessions, SessionCreateError } from '../src/client/sessions/service.ts' import { scopeOf } from '../src/client/scope.ts' -import type { SessionFollowFrame } from '../src/types.ts' -import { - FakeApiClient, - deferred, - err, - fakeRemote, - ok, - type RuntimeRemotes, -} from './fake-api.client.ts' +import type { + SessionAssistantStreamBaseline, SessionFollowFrame, SessionFollowRequest, +} from '../src/types.ts' +import { FOLLOW, err, followScript, sessionWorld } from './remote/session.client.ts' const sid = (s: string): SessionId => s as SessionId +/** ClientSessions uses the Gateway client for stream supervision and the native Remote mocks for responses. */ +const API_ROSTER = webApp.closure(['@deepseek-ai/dsh-api-gateway']) interface Bench { ctx: Context - api: FakeApiClient + mock: RemoteMock svc: ClientSessions } -function bench(configureRemote?: (remote: RuntimeRemotes) => RuntimeRemotes): Bench { - const ctx = new Context() - const api = new FakeApiClient() - const remote = fakeRemote(api) - const svc = new ClientSessions(ctx, configureRemote?.(remote) ?? remote) - return { ctx, api, svc } -} +type BenchFactory = () => Bench + +const it = createClientTest({ roster: API_ROSTER }).extend<{ bench: BenchFactory }>({ + bench: async ({ mock, start }, use) => { + mock.load(sessionWorld) + const client = await start() + const contexts: Context[] = [] + try { + await use(() => { + const ctx = new Context() + contexts.push(ctx) + const svc = new ClientSessions(ctx, client.ctx.remote) + return { ctx, mock, svc } + }) + } finally { + await Promise.all(contexts.map(ctx => ctx.fiber.dispose())) + } + }, +}) /** Refresh the manager list from programmable rows and flush the microtask batch. */ type FeedRow = { @@ -53,7 +64,7 @@ type FeedRow = { } async function feedList(b: Bench, rows: FeedRow[]): Promise { - b.api.onList = () => Promise.resolve(ok({ + b.mock.remote.session.list.mockResolvedValue(ok({ items: rows.map(r => ({ sessionId: sid(r.id), updatedAt: 1, running: r.running ?? false, blank: r.blank ?? false, ...(r.cwd !== undefined ? { cwd: r.cwd } : {}), @@ -69,7 +80,7 @@ async function feedList(b: Bench, rows: FeedRow[]): Promise { } describe('list store projection', () => { - it('projects durable titles separately from cwd/id display fallbacks and parent links', async () => { + it('projects durable titles separately from cwd/id display fallbacks and parent links', async ({ bench }) => { const b = bench() b.svc.handleControlFrame({ type: 'projection', sessionId: sid('s1'), key: 'title', value: 'Durable title', seq: 2, @@ -87,7 +98,7 @@ describe('list store projection', () => { expect(state.byId[sid('s2')]?.title).toBeUndefined() }) - it('reprojects a blank session from the generic agent-preset projection', async () => { + it('reprojects a blank session from the generic agent-preset projection', async ({ bench }) => { const b = bench() await feedList(b, [{ id: 's1', blank: true, projections: { agentPreset: 'standard' } }]) expect(b.svc.list.getSnapshot().byId[sid('s1')]?.projectionValues?.agentPreset).toBe('standard') @@ -100,7 +111,7 @@ describe('list store projection', () => { expect(b.svc.list.getSnapshot().byId[sid('s1')]?.projectionValues?.agentPreset).toBe('minimal') }) - it('reflects live increments (host stream via manager) into the store', async () => { + it('reflects live increments (host stream via manager) into the store', async ({ bench }) => { const b = bench() await feedList(b, [{ id: 's1' }]) b.svc.handleSessionAdded({ @@ -112,15 +123,16 @@ describe('list store projection', () => { }) describe('search', () => { - it('delegates transient content search without changing the list snapshot', async () => { + it('delegates transient content search without changing the list snapshot', async ({ bench }) => { const b = bench() await feedList(b, [{ id: 's1' }]) const before = b.svc.list.getSnapshot() - b.api.onSearch = () => Promise.resolve(ok({ + b.mock.remote.session.search.mockResolvedValue(ok({ items: [{ sessionId: sid('s1'), snippet: 'matching excerpt' }], hasMore: false, })) const signal = new AbortController().signal + const call = vi.spyOn(b.mock.rpc, 'call') await expect(b.svc.search('needle', signal)).resolves.toEqual({ ok: true, @@ -129,13 +141,13 @@ describe('search', () => { hasMore: false, }, }) - expect(b.api.lastSearchSignal).toBe(signal) + expect(call.mock.calls.find(([, endpoint]) => endpoint === 'session/search')?.[3]).toBe(signal) expect(b.svc.list.getSnapshot()).toBe(before) }) }) describe('scope tree', () => { - it('publishes transient Assistant chunks and the named durable v2 settlement through one event source', async () => { + it('publishes transient Assistant chunks and the named durable v2 settlement through one event source', async ({ bench }) => { const b = bench() await feedList(b, [{ id: 's1' }]) b.svc.open(sid('s1')) @@ -168,14 +180,15 @@ describe('scope tree', () => { publications.push(binding.eventSource.getSnapshot().entries.map(entry => entry.event.type)) }) - await b.api.pushFollow(sid('s1'), { + b.mock.streams.push(FOLLOW, { type: 'assistant-stream', frame: { type: 'start', attemptId, revision: 1, startedAfterSeq: -1, turn: 1, step: 1, }, }) - await b.api.pushFollow(sid('s1'), { + await b.mock.streams.drained(FOLLOW) + b.mock.streams.push(FOLLOW, { type: 'assistant-stream', frame: { type: 'chunk', attemptId, revision: 2, index: 0, @@ -183,20 +196,23 @@ describe('scope tree', () => { chunk: { type: 'text-delta', index: 0, text: 'live' }, }, }) + await b.mock.streams.drained(FOLLOW) await vi.waitFor(() => { expect(binding.eventSource.getSnapshot().entries).toHaveLength(1) }) - await b.api.pushFollow(sid('s1'), durableMessage) + b.mock.streams.push(FOLLOW, durableMessage) + await b.mock.streams.drained(FOLLOW) await Promise.resolve() expect(binding.eventSource.getSnapshot().entries).toHaveLength(1) - await b.api.pushFollow(sid('s1'), { + b.mock.streams.push(FOLLOW, { type: 'assistant-stream', frame: { type: 'end', attemptId, revision: 3, index: 1, outcome: { kind: 'committed', eventType: 'assistant/message', seq: 0 }, }, }) + await b.mock.streams.drained(FOLLOW) await vi.waitFor(() => { expect(binding.eventSource.getSnapshot().entries).toHaveLength(1) }) @@ -208,12 +224,11 @@ describe('scope tree', () => { dispose() }) - it('replaces an active assistant baseline on reconnect without duplicate chunks', async () => { + it('replaces an active assistant baseline on reconnect without duplicate chunks', async ({ bench }) => { const b = bench() const attemptId = LlmAttemptId('reconnect-attempt') let records: never[] = [] - b.api.onHistory = () => Promise.resolve(ok({ records, hasMore: false })) - b.api.assistantStreamBaseline = { + let assistantStreamBaseline: SessionAssistantStreamBaseline = { revision: 2, activeAttempt: { attemptId, startedAfterSeq: -1, turn: 1, step: 1, @@ -221,6 +236,10 @@ describe('scope tree', () => { stream: [{ type: 'text-chunks', time0: 1, index: 0, dt: [], texts: ['a'] }], }, } + b.mock.stream(FOLLOW, followScript( + () => ok({ records, hasMore: false }), + { assistantStream: () => assistantStreamBaseline }, + )) await feedList(b, [{ id: 's1' }]) b.svc.open(sid('s1')) const binding = b.svc.binding(sid('s1')) @@ -230,7 +249,7 @@ describe('scope tree', () => { }) records = [] - b.api.assistantStreamBaseline = { + assistantStreamBaseline = { revision: 3, activeAttempt: { attemptId, startedAfterSeq: -1, turn: 1, step: 1, @@ -238,9 +257,9 @@ describe('scope tree', () => { stream: [{ type: 'text-chunks', time0: 1, index: 0, dt: [1], texts: ['a', 'b'] }], }, } - b.api.failStreams(new RemoteStreamCarrierError('lost')) + b.mock.streams.fail(FOLLOW, new RemoteStreamCarrierError('lost')) await vi.waitFor(() => { - expect(b.api.followStarts.filter(id => id === sid('s1'))).toHaveLength(2) + expect(b.mock.log.requests(FOLLOW)).toHaveLength(2) expect(binding.eventSource.getSnapshot().entries).toHaveLength(2) }) @@ -251,7 +270,7 @@ describe('scope tree', () => { ))).toEqual(['a', 'b']) }) - it('stages a post-opening assistant settlement behind its exact active attempt', async () => { + it('stages a post-opening assistant settlement behind its exact active attempt', async ({ bench }) => { const b = bench() const attemptId = LlmAttemptId('reconnect-settlement-attempt') const priorMessage = { @@ -290,11 +309,11 @@ describe('scope tree', () => { surfaceOp: 'append' as const, }, } - b.api.onHistory = () => Promise.resolve(ok({ + const history = ok({ records: [priorMessage] as never[], hasMore: false, - })) - b.api.assistantStreamBaseline = { + }) + const assistantStreamBaseline: SessionAssistantStreamBaseline = { revision: 2, activeAttempt: { attemptId, @@ -305,6 +324,7 @@ describe('scope tree', () => { stream: currentMessage.event.data.stream, }, } + b.mock.stream(FOLLOW, followScript(history, { assistantStream: assistantStreamBaseline })) await feedList(b, [{ id: 's1' }]) b.svc.open(sid('s1')) const binding = b.svc.binding(sid('s1')) @@ -317,18 +337,20 @@ describe('scope tree', () => { .toEqual(['assistant/message', 'assistant/live-chunk']) expect(binding.eventSource.getSnapshot().entries[0]?.event).toBe(priorMessage.event) - await b.api.pushFollow(sid('s1'), currentMessage) + b.mock.streams.push(FOLLOW, currentMessage) + await b.mock.streams.drained(FOLLOW) await Promise.resolve() expect(binding.eventSource.getSnapshot().entries.map(entry => entry.event.type)) .toEqual(['assistant/message', 'assistant/live-chunk']) - await b.api.pushFollow(sid('s1'), { + b.mock.streams.push(FOLLOW, { type: 'assistant-stream', frame: { type: 'end', attemptId, revision: 3, index: 1, outcome: { kind: 'committed', eventType: 'assistant/message', seq: 1 }, }, }) + await b.mock.streams.drained(FOLLOW) await vi.waitFor(() => { expect(binding.eventSource.getSnapshot().entries.map(entry => entry.event.type)) .toEqual(['assistant/message', 'assistant/message']) @@ -338,7 +360,7 @@ describe('scope tree', () => { }) }) - it('replaces an invalid settlement with the authoritative post-end baseline', async () => { + it('replaces an invalid settlement with the authoritative post-end baseline', async ({ bench }) => { const b = bench() const attemptId = LlmAttemptId('reconnect-end-index-attempt') const prior = { @@ -364,11 +386,7 @@ describe('scope tree', () => { }, } let records = [prior] as never[] - b.api.onHistory = () => Promise.resolve(ok({ - records, - hasMore: false, - })) - b.api.assistantStreamBaseline = { + let assistantStreamBaseline: SessionAssistantStreamBaseline = { revision: 2, activeAttempt: { attemptId, @@ -379,6 +397,10 @@ describe('scope tree', () => { stream: message.event.data.stream, }, } + b.mock.stream(FOLLOW, followScript( + () => ok({ records, hasMore: false }), + { assistantStream: () => assistantStreamBaseline }, + )) await feedList(b, [{ id: 's1' }]) b.svc.open(sid('s1')) const binding = b.svc.binding(sid('s1')) @@ -389,29 +411,31 @@ describe('scope tree', () => { }) const openingRevision = binding.eventSource.getSnapshot().revision - await b.api.pushFollow(sid('s1'), message) + b.mock.streams.push(FOLLOW, message) + await b.mock.streams.drained(FOLLOW) await Promise.resolve() expect(binding.eventSource.getSnapshot().entries.map(entry => entry.event.type)) .toEqual(['turn/start', 'assistant/live-chunk']) records = [prior, message] as never[] - b.api.assistantStreamBaseline = { revision: 3 } - await b.api.pushFollow(sid('s1'), { + assistantStreamBaseline = { revision: 3 } + b.mock.streams.push(FOLLOW, { type: 'assistant-stream', frame: { type: 'end', attemptId, revision: 3, index: 0, outcome: { kind: 'committed', eventType: 'assistant/message', seq: 0 }, }, }) + await b.mock.streams.drained(FOLLOW) await vi.waitFor(() => { - expect(b.api.followStarts.filter(id => id === sid('s1'))).toHaveLength(2) - expect(b.api.activeFollows(sid('s1'))).toBe(1) + expect(b.mock.log.requests(FOLLOW)).toHaveLength(2) + expect(b.mock.log.streams(FOLLOW).filter(stream => stream.state === 'open')).toHaveLength(1) expect(binding.eventSource.getSnapshot().revision).toBeGreaterThan(openingRevision) expect(binding.eventSource.getSnapshot().entries.map(entry => entry.event.type)) .toEqual(['turn/start', 'assistant/message']) }) }) - it('retains a Host-addressed scope until the first Session baseline owns pruning', async () => { + it('retains a Host-addressed scope until the first Session baseline owns pruning', async ({ bench }) => { const b = bench() const scoped = b.svc.resolveAgentScope(sid('s-early')) expect(scopeOf(scoped)).toBe('s-early') @@ -427,7 +451,7 @@ describe('scope tree', () => { expect(b.svc.scope(sid('s-early'))).toBeUndefined() }) - it('mints lazily on first resolution, tags the ctx, and keeps binding identity stable', async () => { + it('mints lazily on first resolution, tags the ctx, and keeps binding identity stable', async ({ bench }) => { const b = bench() await feedList(b, [{ id: 's1' }]) expect(b.svc.scope(sid('unknown'))).toBeUndefined() @@ -442,7 +466,7 @@ describe('scope tree', () => { expect(binding?.ctx).toBe(scoped) }) - it('tears down an off-stage removed session but defers the staged one until the stage moves', async () => { + it('tears down an off-stage removed session but defers the staged one until the stage moves', async ({ bench }) => { const b = bench() await feedList(b, [{ id: 's1' }, { id: 's2' }]) const ctx1 = b.svc.scope(sid('s1')) @@ -460,7 +484,7 @@ describe('scope tree', () => { expect(b.svc.scope(sid('s1'))).toBeUndefined() }) - it('keeps the scope when the session merely stops running (frozen ≠ removed)', async () => { + it('keeps the scope when the session merely stops running (frozen ≠ removed)', async ({ bench }) => { const b = bench() await feedList(b, [{ id: 's1', running: true }]) const scoped = b.svc.scope(sid('s1')) @@ -468,7 +492,7 @@ describe('scope tree', () => { expect(b.svc.scope(sid('s1'))).toBe(scoped) }) - it('cancels a deferred teardown when the id reappears in the list', async () => { + it('cancels a deferred teardown when the id reappears in the list', async ({ bench }) => { const b = bench() await feedList(b, [{ id: 's1' }]) const scoped = b.svc.scope(sid('s1')) @@ -479,13 +503,17 @@ describe('scope tree', () => { expect(b.svc.scope(sid('s1'))).toBe(scoped) }) - it('closes an opened journal when its removed scope drops', async () => { + it('closes an opened journal when its removed scope drops', async ({ bench }) => { const b = bench() + const follows = () => b.mock.log.streams(FOLLOW).filter(({ args }) => { + const request = args[0] as SessionFollowRequest + return request.address.kind === 'session' && request.address.sessionId === sid('s1') + }) await feedList(b, [{ id: 's1' }]) b.svc.open(sid('s1')) const session = b.svc.binding(sid('s1'))?.session if (session === undefined) throw new Error('expected the selected Session binding') - await vi.waitFor(() => { expect(b.api.activeFollows(sid('s1'))).toBe(1) }) + await vi.waitFor(() => { expect(follows().filter(stream => stream.state === 'open')).toHaveLength(1) }) const notified = vi.fn() session.subscribe(notified) @@ -493,20 +521,24 @@ describe('scope tree', () => { await feedList(b, [{ id: 's2' }]) b.svc.open(sid('s2')) - await vi.waitFor(() => { expect(b.api.activeFollows(sid('s1'))).toBe(0) }) + await vi.waitFor(() => { expect(follows().filter(stream => stream.state === 'open')).toHaveLength(0) }) notified.mockClear() - await b.api.pushFollow(sid('s1'), { + expect(b.mock.streams.push(FOLLOW, { type: 'event', event: { seq: 0, timestamp: 0, type: 'turn/start', data: { turn: 0 } } as never, - }) + }, ([request]) => { + const address = (request as SessionFollowRequest).address + return address.kind === 'session' && address.sessionId === sid('s1') + })).toBe(0) + await b.mock.streams.drained(FOLLOW) await Promise.resolve() - expect(b.api.followStarts.filter(id => id === sid('s1'))).toHaveLength(1) + expect(follows()).toHaveLength(1) expect(notified).not.toHaveBeenCalled() }) }) describe('Agent scope disposal lifecycle', () => { - it('root disposal runs Agent scope effects', async () => { + it('root disposal runs Agent scope effects', async ({ bench }) => { const b = bench() const readiness = b.ctx.plugin(() => undefined) await readiness @@ -525,59 +557,54 @@ describe('Agent scope disposal lifecycle', () => { expect(b.svc.sessionOf(scoped)).toBeUndefined() }) - it('root disposal waits for an opened Session source to finish closing', async () => { - const closeGate = deferred() + it('root disposal waits for an opened Session source to finish closing', async ({ bench }) => { + const closeGate = Promise.withResolvers() const abortObserved = vi.fn() let followSignal: AbortSignal | undefined - const b = bench(remote => ({ - ...remote, - session: { - ...remote.session, - follow: (request, signal) => { - if (signal === undefined) throw new Error('fixture requires a signal') - followSignal = signal - let opened = false - return { - [Symbol.asyncIterator]: () => ({ - next: () => { - if (!opened) { - opened = true - return Promise.resolve({ - done: false, - value: { - type: 'snapshot', - header: { - version: SESSION_FORMAT_VERSION, - id: request.address.kind === 'session' - ? request.address.sessionId - : request.address.childSessionId, - createdAt: 0, - isSeeded: false, - }, - cursor: -1, - records: [], - hasMore: false, - projections: { asOfSeq: -1, values: {} }, - assistantStream: { revision: 0 }, - } as const, - }) - } - return new Promise((_resolve, reject) => { - signal.addEventListener('abort', () => { - abortObserved() - void closeGate.promise.then(() => { - reject(signal.reason instanceof Error - ? signal.reason - : new Error(String(signal.reason))) - }) - }, { once: true }) + const b = bench() + b.mock.remote.session.follow.mockImplementation((request, signal) => { + if (signal === undefined) throw new Error('fixture requires a signal') + followSignal = signal + let opened = false + return { + [Symbol.asyncIterator]: () => ({ + next: () => { + if (!opened) { + opened = true + return Promise.resolve({ + done: false, + value: { + type: 'snapshot', + header: { + version: SESSION_FORMAT_VERSION, + id: request.address.kind === 'session' + ? request.address.sessionId + : request.address.childSessionId, + createdAt: 0, + isSeeded: false, + }, + cursor: -1, + records: [], + hasMore: false, + projections: { asOfSeq: -1, values: {} }, + assistantStream: { revision: 0 }, + } as const, + }) + } + return new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => { + abortObserved() + void closeGate.promise.then(() => { + reject(signal.reason instanceof Error + ? signal.reason + : new Error(String(signal.reason))) }) - }, - }), - } - }, - }, - })) + }, { once: true }) + }) + }, + }), + } + }) const readiness = b.ctx.plugin(() => undefined) await readiness await feedList(b, [{ id: 's1' }]) @@ -599,55 +626,50 @@ describe('Agent scope disposal lifecycle', () => { expect(settled).toHaveBeenCalledOnce() }) - it('root disposal joins every Session drop already started by pruning under load', async () => { - const closeGates = new Map>>() + it('root disposal joins every Session drop already started by pruning under load', async ({ bench }) => { + const closeGates = new Map>() const aborted = new Set() - const b = bench(remote => ({ - ...remote, - session: { - ...remote.session, - follow: (request, signal) => { - if (signal === undefined) throw new Error('fixture requires a signal') - const sessionId = request.address.kind === 'session' - ? request.address.sessionId - : request.address.childSessionId - const closeGate = deferred() - closeGates.set(sessionId, closeGate) - let opened = false - return { - [Symbol.asyncIterator]: () => ({ - next: () => { - if (!opened) { - opened = true - return Promise.resolve({ - done: false, - value: { - type: 'snapshot', - header: { version: SESSION_FORMAT_VERSION, id: sessionId, createdAt: 0, isSeeded: false }, - cursor: -1, - records: [], - hasMore: false, - projections: { asOfSeq: -1, values: {} }, - assistantStream: { revision: 0 }, - } as const, - }) - } - return new Promise>((_resolve, reject) => { - signal.addEventListener('abort', () => { - aborted.add(sessionId) - void closeGate.promise.then(() => { - reject(signal.reason instanceof Error - ? signal.reason - : new Error(String(signal.reason))) - }) - }, { once: true }) + const b = bench() + b.mock.remote.session.follow.mockImplementation((request, signal) => { + if (signal === undefined) throw new Error('fixture requires a signal') + const sessionId = request.address.kind === 'session' + ? request.address.sessionId + : request.address.childSessionId + const closeGate = Promise.withResolvers() + closeGates.set(sessionId, closeGate) + let opened = false + return { + [Symbol.asyncIterator]: () => ({ + next: () => { + if (!opened) { + opened = true + return Promise.resolve({ + done: false, + value: { + type: 'snapshot', + header: { version: SESSION_FORMAT_VERSION, id: sessionId, createdAt: 0, isSeeded: false }, + cursor: -1, + records: [], + hasMore: false, + projections: { asOfSeq: -1, values: {} }, + assistantStream: { revision: 0 }, + } as const, + }) + } + return new Promise>((_resolve, reject) => { + signal.addEventListener('abort', () => { + aborted.add(sessionId) + void closeGate.promise.then(() => { + reject(signal.reason instanceof Error + ? signal.reason + : new Error(String(signal.reason))) }) - }, - }), - } - }, - }, - })) + }, { once: true }) + }) + }, + }), + } + }) const readiness = b.ctx.plugin(() => undefined) await readiness const sessionIds = Array.from({ length: 24 }, (_, index) => sid(`load-${String(index)}`)) @@ -691,7 +713,7 @@ describe('Agent scope disposal lifecycle', () => { describe('current selection (migrated from ui-layout, arbitrated into the list snapshot)', () => { afterEach(() => { vi.unstubAllGlobals() }) - it('open() writes list.current; unknown ids fail loud', async () => { + it('open() writes list.current; unknown ids fail loud', async ({ bench }) => { const b = bench() await feedList(b, [{ id: 's1' }]) expect(b.svc.list.getSnapshot().current).toBeUndefined() @@ -701,7 +723,7 @@ describe('current selection (migrated from ui-layout, arbitrated into the list s expect(b.svc.list.getSnapshot().current).toBe('s1') // failed open leaves the selection alone }) - it('clear() blanks list.current and the persisted selection', async () => { + it('clear() blanks list.current and the persisted selection', async ({ bench }) => { const storage = new Map() vi.stubGlobal('localStorage', { getItem: (k: string) => storage.get(k) ?? null, @@ -721,7 +743,7 @@ describe('current selection (migrated from ui-layout, arbitrated into the list s expect(again.svc.list.getSnapshot().current).toBeUndefined() }) - it('masks (not destroys) the selection while its session is off the list', async () => { + it('masks (not destroys) the selection while its session is off the list', async ({ bench }) => { const b = bench() await feedList(b, [{ id: 's1' }, { id: 's2' }]) b.svc.open(sid('s1')) @@ -731,7 +753,7 @@ describe('current selection (migrated from ui-layout, arbitrated into the list s expect(b.svc.list.getSnapshot().current).toBe('s1') }) - it('persists the selection under dsh.sessions.current and rehydrates it into a fresh service', async () => { + it('persists the selection under dsh.sessions.current and rehydrates it into a fresh service', async ({ bench }) => { const storage = new Map() vi.stubGlobal('localStorage', { getItem: (k: string) => storage.get(k) ?? null, @@ -749,7 +771,7 @@ describe('current selection (migrated from ui-layout, arbitrated into the list s }) describe('binding and stage lifecycle', () => { - it('binding() is pure resolution: no staging, no deferred sweep', async () => { + it('binding() is pure resolution: no staging, no deferred sweep', async ({ bench }) => { const b = bench() await feedList(b, [{ id: 's1' }, { id: 's2' }]) b.svc.open(sid('s1')) // staged @@ -758,10 +780,13 @@ describe('binding and stage lifecycle', () => { expect(b.svc.scope(sid('s1'))).toBeDefined() }) - it('staging (current write) opens the session event window; resolution and re-staging do not re-pull', async () => { + it('staging (current write) opens the session event window; resolution and re-staging do not re-pull', async ({ bench }) => { const b = bench() await feedList(b, [{ id: 's1' }, { id: 's2' }]) - const followStarts = () => b.api.followStarts.map(String) + const followStarts = () => b.mock.log.requests(FOLLOW).map((request) => { + const address = (request as SessionFollowRequest).address + return String(address.kind === 'session' ? address.sessionId : address.childSessionId) + }) // Resolution is addressing, not staging: no window pull. b.svc.scope(sid('s1')) b.svc.binding(sid('s1')) @@ -780,7 +805,7 @@ describe('binding and stage lifecycle', () => { }) }) - it('startup restore: a persisted selection validated by the first projection opens its window unprompted', async () => { + it('startup restore: a persisted selection validated by the first projection opens its window unprompted', async ({ bench }) => { const storage = new Map([ ['dsh.sessions.current', JSON.stringify({ sessionId: 's1' })], ]) @@ -790,10 +815,13 @@ describe('binding and stage lifecycle', () => { }) try { const b = bench() - expect(b.api.followStarts).toEqual([]) + expect(b.mock.log.requests(FOLLOW)).toEqual([]) await feedList(b, [{ id: 's1' }]) // projection validates the persisted id → current lands → stage follows await vi.waitFor(() => { - expect(b.api.followStarts.map(String)).toEqual(['s1']) + expect(b.mock.log.requests(FOLLOW).map((request) => { + const address = (request as SessionFollowRequest).address + return String(address.kind === 'session' ? address.sessionId : address.childSessionId) + })).toEqual(['s1']) }) } finally { vi.unstubAllGlobals() @@ -802,10 +830,10 @@ describe('binding and stage lifecycle', () => { }) describe('catalog-addressed navigation', () => { - it('uses catalog labels for a listed addressed route', async () => { + it('uses catalog labels for a listed addressed route', async ({ bench }) => { const b = bench() - b.api.onSubagentList = (payload) => { - const parentSessionId = payload as SessionId + b.mock.remote.subagents.list.mockImplementation((payload) => { + const parentSessionId = payload if (parentSessionId === sid('root')) { return Promise.resolve(ok({ entries: [{ @@ -825,7 +853,7 @@ describe('catalog-addressed navigation', () => { })) } return Promise.resolve(ok({ entries: [], parentAvailable: false })) - } + }) await feedList(b, [ { id: 'root' }, { id: 'child', cwd: '/summary-child', parentId: 'root', origin: 'subagent' }, @@ -841,10 +869,10 @@ describe('catalog-addressed navigation', () => { expect(b.svc.list.getSnapshot().byId[sid('grandchild')]?.displayTitle).toBe('Grandchild') }) - it('projects a directly opened descendant route without retaining ancestor scopes or addresses', async () => { + it('projects a directly opened descendant route without retaining ancestor scopes or addresses', async ({ bench }) => { const b = bench() - b.api.onSubagentList = (payload) => { - const parentSessionId = payload as SessionId + b.mock.remote.subagents.list.mockImplementation((payload) => { + const parentSessionId = payload if (parentSessionId === sid('root')) { return Promise.resolve(ok({ entries: [{ @@ -864,7 +892,7 @@ describe('catalog-addressed navigation', () => { })) } return Promise.resolve(ok({ entries: [], parentAvailable: false })) - } + }) await feedList(b, [{ id: 'root' }]) await b.svc.refreshSubagents(sid('root')) await b.svc.refreshSubagents(sid('child')) @@ -888,12 +916,12 @@ describe('catalog-addressed navigation', () => { }) describe('create', () => { - it('passes a preallocated id and preserves it on ordinary failure', async () => { + it('passes a preallocated id and preserves it on ordinary failure', async ({ bench }) => { const b = bench() - b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('fresh') })) + b.mock.remote.session.create.mockResolvedValue(ok({ sessionId: sid('fresh') })) await expect(b.svc.create({ cwd: '/w', sessionId: sid('fresh') })).resolves.toBe('fresh') - expect(b.api.callsOf('session.create')).toEqual([{ cwd: '/w', sessionId: 'fresh' }]) - b.api.onCreate = () => Promise.resolve(err(new RemoteError('gateway/internal', '爆了', {}))) + expect(b.mock.remote.session.create).toHaveBeenCalledWith({ cwd: '/w', sessionId: 'fresh' }) + b.mock.remote.session.create.mockResolvedValue(err(new RemoteError('gateway/internal', '爆了', {}))) const failure = await b.svc.create({ sessionId: sid('candidate') }).catch((error: unknown) => error) expect(failure).toBeInstanceOf(SessionCreateError) expect(failure).toMatchObject({ @@ -902,9 +930,9 @@ describe('create', () => { }) }) - it('resolves with the session already listed and binding-resolvable (no flush wait)', async () => { + it('resolves with the session already listed and binding-resolvable (no flush wait)', async ({ bench }) => { const b = bench() - b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('born') })) + b.mock.remote.session.create.mockResolvedValue(ok({ sessionId: sid('born') })) const born = await b.svc.create({ workspaceId: 'ws' as never }) // Synchronously after resolution — the draft hand-off contract: the // create echo IS the entity entering the client's view (blank row + @@ -914,9 +942,9 @@ describe('create', () => { expect(b.svc.scope(born)).toBeDefined() }) - it('lists the published id after Workspace attachment fails (publication precedes attachment)', async () => { + it('lists the published id after Workspace attachment fails (publication precedes attachment)', async ({ bench }) => { const b = bench() - b.api.onCreate = () => Promise.resolve(err(new RemoteError( + b.mock.remote.session.create.mockResolvedValue(err(new RemoteError( 'session/workspace-attach-failed', 'ledger unavailable', { sessionId: sid('published'), workspaceId: 'ws' }, @@ -936,29 +964,29 @@ describe('create', () => { }) describe('fork', () => { - it.each([ + it.for([ ['Roadmap', 'Roadmap (1)'], ['Roadmap (1)', 'Roadmap (2)'], ['计划(1)', '计划(2)'], ['计划 (9)', '计划 (10)'], - ])('increments the durable title %j after the child is published', async (sourceTitle, childTitle) => { + ] as const)('increments the durable title %j after the child is published', async ([sourceTitle, childTitle], { bench }) => { const b = bench() b.svc.handleControlFrame({ type: 'projection', sessionId: sid('source'), key: 'title', value: sourceTitle, seq: 2, }) await feedList(b, [{ id: 'source', cwd: '/work' }]) - b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child') })) - b.api.onRename = (payload) => { + b.mock.remote.session.fork.mockResolvedValue(ok({ sessionId: sid('child') })) + b.mock.remote.session.rename.mockImplementation((payload) => { const { title } = payload as { title: string } return Promise.resolve(ok({ title, seq: 3 })) - } + }) await expect(b.svc.fork({ sessionId: sid('source'), atSeq: 7, increaseTitle: true, })).resolves.toBe('child') - expect(b.api.callsOf('session.fork')).toEqual([{ sessionId: 'source', atSeq: 7 }]) - expect(b.api.callsOf('session.rename')).toEqual([{ sessionId: 'child', title: childTitle }]) + expect(b.mock.remote.session.fork).toHaveBeenCalledWith({ sessionId: 'source', atSeq: 7 }) + expect(b.mock.remote.session.rename).toHaveBeenCalledWith({ sessionId: 'child', title: childTitle }) await Promise.resolve() expect(b.svc.list.getSnapshot().byId[sid('child')]).toMatchObject({ title: childTitle, @@ -967,37 +995,37 @@ describe('fork', () => { }) }) - it('floors a fractional anchor to the real event seq the wire accepts', async () => { + it('floors a fractional anchor to the real event seq the wire accepts', async ({ bench }) => { const b = bench() await feedList(b, [{ id: 'source', cwd: '/work' }]) - b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child') })) + b.mock.remote.session.fork.mockResolvedValue(ok({ sessionId: sid('child') })) // The frozen node of an interrupted turn carries turnEnd.seq - 0.9. await expect(b.svc.fork({ sessionId: sid('source'), atSeq: 41.1 })).resolves.toBe('child') - expect(b.api.callsOf('session.fork')).toEqual([{ sessionId: 'source', atSeq: 41 }]) + expect(b.mock.remote.session.fork).toHaveBeenCalledWith({ sessionId: 'source', atSeq: 41 }) }) - it('does not rename without the title policy or a durable source title', async () => { + it('does not rename without the title policy or a durable source title', async ({ bench }) => { const b = bench() await feedList(b, [{ id: 'source', cwd: '/work' }]) - b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child') })) + b.mock.remote.session.fork.mockResolvedValue(ok({ sessionId: sid('child') })) await expect(b.svc.fork({ sessionId: sid('source'), increaseTitle: true })).resolves.toBe('child') - expect(b.api.callsOf('session.rename')).toEqual([]) + expect(b.mock.remote.session.rename).not.toHaveBeenCalled() - b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child-2') })) + b.mock.remote.session.fork.mockResolvedValue(ok({ sessionId: sid('child-2') })) await expect(b.svc.fork({ sessionId: sid('source') })).resolves.toBe('child-2') - expect(b.api.callsOf('session.rename')).toEqual([]) + expect(b.mock.remote.session.rename).not.toHaveBeenCalled() }) - it('rejects when child rename fails while keeping the published child addressable', async () => { + it('rejects when child rename fails while keeping the published child addressable', async ({ bench }) => { const b = bench() b.svc.handleControlFrame({ type: 'projection', sessionId: sid('source'), key: 'title', value: 'Roadmap', seq: 2, }) await feedList(b, [{ id: 'source' }]) - b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child') })) - b.api.onRename = () => Promise.resolve(err(new RemoteError('session/title-invalid', 'rejected', { sessionId: sid('child') }))) + b.mock.remote.session.fork.mockResolvedValue(ok({ sessionId: sid('child') })) + b.mock.remote.session.rename.mockResolvedValue(err(new RemoteError('session/title-invalid', 'rejected', { sessionId: sid('child') }))) await expect(b.svc.fork({ sessionId: sid('source'), increaseTitle: true })) .rejects.toThrow('fork child rename failed: session/title-invalid: rejected') @@ -1006,7 +1034,7 @@ describe('fork', () => { }) describe('scope lifecycle rides the list mirror (entity parity: no client-side pre-birth)', () => { - it('a session-added frame births the row (blank) and makes the scope resolvable; removal prunes it', async () => { + it('a session-added frame births the row (blank) and makes the scope resolvable; removal prunes it', async ({ bench }) => { const b = bench() await feedList(b, []) expect(b.svc.scope(sid('s-new'))).toBeUndefined() // not in view: no scope, no exceptions @@ -1024,7 +1052,7 @@ describe('scope lifecycle rides the list mirror (entity parity: no client-side p }) describe('blank mirror', () => { - it('flips blank=false from the running:true status frame (cross-client conversion)', async () => { + it('flips blank=false from the running:true status frame (cross-client conversion)', async ({ bench }) => { const b = bench() await feedList(b, [{ id: 's1', blank: true }]) expect(b.svc.list.getSnapshot().byId[sid('s1')]).toMatchObject({ blank: true }) @@ -1035,13 +1063,13 @@ describe('blank mirror', () => { expect(b.svc.binding(sid('s1'))?.session.getSnapshot().blank).toBe(false) }) - it('flips blank=false on prompt ACCEPTANCE, not on the attempt', async () => { + it('flips blank=false on prompt ACCEPTANCE, not on the attempt', async ({ bench }) => { const b = bench() await feedList(b, [{ id: 's1', blank: true, cwd: '/w/a' }]) const session = b.svc.binding(sid('s1'))!.session expect(session.getSnapshot().blank).toBe(true) - const gate = deferred>>() - b.api.onPrompt = () => gate.promise + const gate = Promise.withResolvers>>() + b.mock.remote.session.prompt.mockReturnValue(gate.promise) const send = session.prompt([{ type: 'text', text: 'hi' }], 'queue') // In flight: still blank (the flip point is the success response, which // proves the user message reached the host log). @@ -1053,11 +1081,11 @@ describe('blank mirror', () => { expect(b.svc.list.getSnapshot().byId[sid('s1')]).toMatchObject({ blank: false }) }) - it('keeps a rejected first prompt blank: hidden and still reusable', async () => { + it('keeps a rejected first prompt blank: hidden and still reusable', async ({ bench }) => { const b = bench() await feedList(b, [{ id: 's1', blank: true, cwd: '/w/a' }]) const session = b.svc.binding(sid('s1'))!.session - b.api.onPrompt = () => Promise.resolve(err(new RemoteError('gateway/internal', 'agent busy', {}))) + b.mock.remote.session.prompt.mockResolvedValue(err(new RemoteError('gateway/internal', 'agent busy', {}))) const result = await session.prompt([{ type: 'text', text: 'hi' }], 'queue') expect(result.ok).toBe(false) // No flip on failure: local stays aligned with the host authority @@ -1067,7 +1095,7 @@ describe('blank mirror', () => { expect(b.svc.list.getSnapshot().byId[sid('s1')]).toMatchObject({ blank: true }) }) - it('takes session-added blank=true as the hidden birth and list blank as reconnect authority', async () => { + it('takes session-added blank=true as the hidden birth and list blank as reconnect authority', async ({ bench }) => { const b = bench() await feedList(b, []) b.svc.handleSessionAdded({ @@ -1080,7 +1108,7 @@ describe('blank mirror', () => { expect(b.svc.list.getSnapshot().byId[sid('s-new')]).toMatchObject({ blank: false }) }) - it('never re-blanks: a stale blank=true summary cannot hide an engaged session', async () => { + it('never re-blanks: a stale blank=true summary cannot hide an engaged session', async ({ bench }) => { const b = bench() await feedList(b, [{ id: 's1', blank: true }]) const session = b.svc.binding(sid('s1'))!.session @@ -1094,7 +1122,7 @@ describe('blank mirror', () => { }) describe('coverage tails (branch duals)', () => { - it('displayTitleOf falls back to the id for empty and separator-only cwd', async () => { + it('displayTitleOf falls back to the id for empty and separator-only cwd', async ({ bench }) => { const b = bench() await feedList(b, [{ id: 'no-base', cwd: '///' }, { id: 'empty-cwd', cwd: '' }]) const { byId } = b.svc.list.getSnapshot() @@ -1103,7 +1131,7 @@ describe('coverage tails (branch duals)', () => { expect(byId[sid('no-base')]?.title).toBeUndefined() }) - it('binding for an unknown session returns undefined and leaves the staged scope intact', async () => { + it('binding for an unknown session returns undefined and leaves the staged scope intact', async ({ bench }) => { const b = bench() await feedList(b, [{ id: 's1' }]) b.svc.open(sid('s1')) @@ -1113,20 +1141,20 @@ describe('coverage tails (branch duals)', () => { expect(b.svc.scope(sid('s1'))).toBeDefined() }) - it('a masked current gap holds the stage (no teardown, no re-open) until the stage moves', async () => { + it('a masked current gap holds the stage (no teardown, no re-open) until the stage moves', async ({ bench }) => { const b = bench() await feedList(b, [{ id: 's1' }]) b.svc.open(sid('s1')) - await vi.waitFor(() => { expect(b.api.followStarts).toHaveLength(1) }) + await vi.waitFor(() => { expect(b.mock.log.requests(FOLLOW)).toHaveLength(1) }) await feedList(b, []) // removed while staged: current masks to undefined, stage holds → deferred expect(b.svc.scope(sid('s1'))).toBeDefined() // Resurfacing re-projects current = s1: same stage occupant, no second pull. await feedList(b, [{ id: 's1' }]) - expect(b.api.followStarts).toHaveLength(1) + expect(b.mock.log.requests(FOLLOW)).toHaveLength(1) expect(b.svc.list.getSnapshot().current).toBe('s1') }) - it('sweep hits both deferral edges: staged-id skip and an already-vacated scope record', async () => { + it('sweep hits both deferral edges: staged-id skip and an already-vacated scope record', async ({ bench }) => { const b = bench() await feedList(b, [{ id: 'a' }, { id: 'b' }]) b.svc.scope(sid('a')) From eb2cb668a54476cc8b46433db4e16b8451d3c336 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:46:10 +0800 Subject: [PATCH 61/79] test(web): run fixture browser cases through Host --- .../stress-tests/reasoning-chunks.stress.ts | 164 +++++++++++++----- apps/web/tests/goal-bar.e2e.ts | 31 ++-- apps/web/tests/goal-bar.overlay.yml | 7 +- vitest.web-stress.config.ts | 4 +- 4 files changed, 143 insertions(+), 63 deletions(-) diff --git a/apps/web/stress-tests/reasoning-chunks.stress.ts b/apps/web/stress-tests/reasoning-chunks.stress.ts index 2eb1aa29ef..7f4969b456 100644 --- a/apps/web/stress-tests/reasoning-chunks.stress.ts +++ b/apps/web/stress-tests/reasoning-chunks.stress.ts @@ -1,22 +1,29 @@ /** * Opt-in browser stress reproduction for reasoning-stream renderer stalls. - * The fixture emits 100,000 individual chunks through the normal async - * carrier; the test measures event-loop and scheduled-interaction delay while - * the assembled React surface keeps a collapsed Think row live. + * A test-owned model adapter emits 100,000 individual chunks through the real + * Host, Gateway, and browser carriers; the test measures event-loop and + * scheduled-interaction delay while the assembled React surface keeps a + * collapsed Think row live. */ import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { expect, it, onTestFailed } from 'vitest' +import { LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm' +import type {} from '@deepseek-ai/dsh-agent-default-model' import { launchWebScaffold, watchConsole, type WebScaffold } from '../tests/scaffold.ts' -import { newEnglishPage, saveFailureShot } from '../tests/support.ts' +import { + connectFreshWorkspace, newEnglishPage, saveFailureShot, writeComposerDraft, +} from '../tests/support.ts' const CHUNK_COUNT = 100_000 const CHUNKS_PER_INTERVAL = 128 const CHUNK_INTERVAL_MS = 16 const MAIN_THREAD_DELAY_BUDGET_MS = 250 +const PROVIDER = 'reasoning-stress-test' +const MODEL = 'reasoning-storm' +const MARKER = `REASONING_STRESS_COMPLETE:${String(CHUNK_COUNT)}` interface ReasoningChunkStormState { - sessionId: string chunkCount: number chunksPerInterval: number intervalMs: number @@ -25,6 +32,86 @@ interface ReasoningChunkStormState { emitting: boolean } +/** Externally paced model stream whose counters are observed outside the browser. */ +class ReasoningStressAdapter extends LlmAdapter { + private finishStream!: () => void + private resolveStarted!: () => void + private resolveCompleted!: () => void + private releaseBatch: (() => void) | undefined + private resolveBatch: (() => void) | undefined + private batchInFlight = false + private finishRequested = false + private readonly finishGate = new Promise((resolve) => { this.finishStream = resolve }) + readonly started = new Promise((resolve) => { this.resolveStarted = resolve }) + readonly completed = new Promise((resolve) => { this.resolveCompleted = resolve }) + private emitted = 0 + private emitting = false + + finish(): void { + this.finishRequested = true + this.releaseBatch?.() + this.finishStream() + } + + async emitNextBatch(): Promise { + if (!this.emitting || this.releaseBatch === undefined) { + throw new Error('reasoning stress adapter has no batch awaiting release') + } + if (this.batchInFlight) throw new Error('reasoning stress adapter accepts one batch release at a time') + this.batchInFlight = true + const delivered = new Promise((resolve) => { this.resolveBatch = resolve }) + const release = this.releaseBatch + this.releaseBatch = undefined + release() + await delivered + } + + state(): ReasoningChunkStormState { + return { + chunkCount: CHUNK_COUNT, + chunksPerInterval: CHUNKS_PER_INTERVAL, + intervalMs: CHUNK_INTERVAL_MS, + emitted: this.emitted, + marker: MARKER, + emitting: this.emitting, + } + } + + override async *stream(options: GenerateOptions): AsyncIterable { + if (this.emitting || this.emitted !== 0) throw new Error('reasoning stress adapter accepts one model call') + this.emitting = true + const parts: string[] = [] + yield { type: 'block-start', index: 0, blockType: 'reasoning' } + while (this.emitted < CHUNK_COUNT) { + await new Promise((resolve) => { + this.releaseBatch = resolve + if (this.emitted === 0) this.resolveStarted() + if (this.finishRequested) resolve() + }) + if (this.finishRequested) break + options.signal?.throwIfAborted() + const end = Math.min(this.emitted + CHUNKS_PER_INTERVAL, CHUNK_COUNT) + while (this.emitted < end) { + const text = this.emitted === CHUNK_COUNT - 1 + ? `\n${MARKER}` + : this.emitted % 64 === 63 ? '推理\n' : '推理' + parts.push(text) + yield { type: 'reasoning-delta', index: 0, text } + this.emitted += 1 + } + this.batchInFlight = false + this.resolveBatch?.() + this.resolveBatch = undefined + } + this.emitting = false + if (this.emitted === CHUNK_COUNT) this.resolveCompleted() + await this.finishGate + options.signal?.throwIfAborted() + yield { type: 'block-end', index: 0, block: { type: 'reasoning', text: parts.join('') } } + yield { type: 'finish', reason: { kind: 'stop' } } + } +} + interface StressProbe { intervalId: number intervalMs: number @@ -36,10 +123,6 @@ interface StressProbe { } interface StressWindow extends Window { - __fxTiming?: { - startReasoningChunkStorm(id: string, chunkCount: number, chunksPerInterval: number, intervalMs: number): string - reasoningChunkStormState(): ReasoningChunkStormState | null - } __reasoningStressProbe?: StressProbe } @@ -47,23 +130,22 @@ it('keeps the browser responsive while rendering 100,000 reasoning chunks', asyn let scaffold: WebScaffold | undefined let browser: Browser | undefined let page: Page | undefined + const adapter = new ReasoningStressAdapter() try { scaffold = await launchWebScaffold() + scaffold.ctx.effect( + () => scaffold!.ctx.llm.registerAdapter([PROVIDER], adapter), + 'reasoning stress adapter', + ) + await scaffold.ctx.agentDefaultModel.saveSelection({ provider: PROVIDER, model: MODEL }) browser = await chromium.launch({ headless: process.env.DSH_WEB_STRESS_HEADFUL !== '1' }) page = await newEnglishPage(browser) const activePage = page - await activePage.addInitScript(() => { - localStorage.setItem('dsh.sessions.current', JSON.stringify({ sessionId: 'fx-alpha' })) - }) const tripwire = watchConsole(activePage) onTestFailed(() => saveFailureShot(activePage, 'web-stress-reasoning-chunks')) - await activePage.goto(`${scaffold.baseUrl}?fixture`, { waitUntil: 'load' }) + await activePage.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await activePage.waitForSelector('[class*="frame"]', { timeout: 30_000 }) - // Fixture settings deliberately reject writes, so its welcome notice - // cannot acknowledge. Hide only that test overlay; the assembled chat - // tree beneath it remains mounted and exercises the production renderer. - await activePage.addStyleTag({ content: '[class*="onboardingOverlay"] { display: none !important; }' }) - await activePage.locator('[data-sample="bash"]').first().waitFor({ timeout: 30_000 }) + await connectFreshWorkspace(activePage, scaffold.workspaceCwd) await activePage.evaluate(() => { const intervalMs = 50 @@ -92,45 +174,42 @@ it('keeps the browser responsive while rendering 100,000 reasoning chunks', asyn ;(window as StressWindow).__reasoningStressProbe = probe }) - const marker = await activePage.evaluate(({ chunkCount, chunksPerInterval, intervalMs }) => { - const hooks = (window as StressWindow).__fxTiming - if (hooks === undefined) throw new Error('reasoning stress fixture hooks unavailable') - return hooks.startReasoningChunkStorm('fx-alpha', chunkCount, chunksPerInterval, intervalMs) - }, { - chunkCount: CHUNK_COUNT, - chunksPerInterval: CHUNKS_PER_INTERVAL, - intervalMs: CHUNK_INTERVAL_MS, - }) + const settled = scaffold.whenTurnSettled(540_000) + const input = activePage.locator('[data-composer-input]').first() + await writeComposerDraft(activePage, input, `Render ${String(CHUNK_COUNT)} reasoning chunks.`) + await input.press('Enter') + await adapter.started + await adapter.emitNextBatch() const liveThink = activePage.locator('[data-variant="think"][data-state="running"]').last() await liveThink.waitFor({ timeout: 60_000 }) - await expect.poll(async () => await activePage.evaluate(() => { - const hooks = (window as StressWindow).__fxTiming - return hooks?.reasoningChunkStormState()?.emitted ?? 0 - }), { timeout: 540_000, interval: 100 }).toBe(CHUNK_COUNT) - await expect.poll(() => liveThink.textContent(), { timeout: 60_000, interval: 100 }).toContain(marker) + await activePage.evaluate(intervalMs => new Promise((resolve) => { + window.setTimeout(resolve, intervalMs) + }), CHUNK_INTERVAL_MS) + while (adapter.state().emitted < CHUNK_COUNT) { + await adapter.emitNextBatch() + await activePage.evaluate(intervalMs => new Promise((resolve) => { + window.setTimeout(resolve, intervalMs) + }), CHUNK_INTERVAL_MS) + } + await adapter.completed + await expect.poll(() => liveThink.textContent(), { timeout: 60_000, interval: 100 }).toContain(MARKER) - const report = await activePage.evaluate(() => { + const browserReport = await activePage.evaluate(() => { const win = window as StressWindow const probe = win.__reasoningStressProbe - const state = win.__fxTiming?.reasoningChunkStormState() - if (probe === undefined || state === undefined || state === null) { - throw new Error('reasoning stress metrics unavailable') - } + if (probe === undefined) throw new Error('reasoning stress metrics unavailable') window.clearInterval(probe.intervalId) const interactionDelayMs = probe.interactionHandledAt === null ? null : probe.interactionHandledAt - probe.interactionDueAt return { - chunkCount: state.chunkCount, - chunksPerInterval: state.chunksPerInterval, - intervalMs: state.intervalMs, - emitted: state.emitted, maxMainThreadDelayMs: Math.max(0, probe.maxDelayMs), interactionDelayMs, heartbeatSamples: probe.samples, } }) + const report = { ...adapter.state(), ...browserReport } process.stdout.write(`reasoning-chunk stress report: ${JSON.stringify(report)}\n`) expect(report).toMatchObject({ @@ -146,7 +225,10 @@ it('keeps the browser responsive while rendering 100,000 reasoning chunks', asyn expect(interactionDelayMs, JSON.stringify(report)).toBeLessThan(MAIN_THREAD_DELAY_BUDGET_MS) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) + adapter.finish() + await settled } finally { + adapter.finish() await browser?.close() await scaffold?.close() } diff --git a/apps/web/tests/goal-bar.e2e.ts b/apps/web/tests/goal-bar.e2e.ts index e1d77f0cb6..177a16d8a4 100644 --- a/apps/web/tests/goal-bar.e2e.ts +++ b/apps/web/tests/goal-bar.e2e.ts @@ -1,18 +1,19 @@ -// Keyless assembled-browser coverage for the goal bar over the shipped Web -// bundles and the fixture Connection RPC. The command creates a real projected -// goal in the fixture session; the golden pins the active strip, while the -// clear gesture proves the acknowledged tombstone leaves neither stale chrome -// nor a duplicate-mutation error. +// Keyless browser coverage for the goal bar over the shipped Web composition. +// The command creates a real projected goal in a real Host session. The +// goldens pin the active and disarmed strips, while the clear gesture proves +// the acknowledged tombstone leaves neither stale chrome nor a +// duplicate-mutation error. import { fileURLToPath } from 'node:url' import { join } from 'node:path' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import type {} from '@deepseek-ai/dsh-goal' import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { newEnglishPage, saveFailureShot } from './support.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/goal-bar', import.meta.url)) const ACTIVE_EXPECTED = join(SNAPSHOT_DIR, 'active.expected.md') @@ -27,14 +28,13 @@ describe('web e2e: goal bar clear convergence', () => { let tripwire: ReturnType beforeAll(async () => { - scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY, welcomeNoticePending: true }) + scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY }) browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) - const login = await page.context().request.get(scaffold.authenticatedUrl, { maxRedirects: 0 }) - expect(login.status()).toBe(303) - await page.goto(`${scaffold.baseUrl}?fixture`, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspace(page, scaffold.workspaceCwd) }, 120_000) afterAll(async () => { @@ -44,8 +44,6 @@ describe('web e2e: goal bar clear convergence', () => { it('renders one active goal and clears it without exposing a stale error', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-goal-bar-clear')) - // Startup reuses the fixture workspace's blank session, keeping this - // command independent of alpha's running replay and pending question. const input = page.locator('[data-composer-input][data-placeholder="Describe what you want to build, / commands, @ files or sessions"]') await input.waitFor({ timeout: 10_000 }) await input.fill('/goal guard rapid clear clicks') @@ -53,15 +51,16 @@ describe('web e2e: goal bar clear convergence', () => { const bar = page.locator('[data-goal-bar]') await bar.waitFor({ timeout: 10_000 }) - await expect.poll(() => bar.getByRole('button', { name: 'Pause goal' }).count(), { + const pause = bar.getByRole('button', { name: 'Pause goal' }) + await expect.poll(() => pause.count(), { timeout: 10_000, }).toBe(1) const snapshot = await captureStableAria(page, '[data-goal-bar]', scaffold.workspaceCwd) await compareOrRefreshGolden(ACTIVE_EXPECTED, snapshot, MODE) - await page.evaluate(() => { - (globalThis as unknown as { __fxTiming?: { disarmOnlyGoal(): void } }).__fxTiming?.disarmOnlyGoal() - }) + const agents = scaffold.ctx.agents.list() + expect(agents).toHaveLength(1) + scaffold.ctx.goals.disarm(agents[0]!) await expect.poll(() => bar.getByRole('button', { name: 'Resume goal' }).count(), { timeout: 10_000, }).toBe(1) diff --git a/apps/web/tests/goal-bar.overlay.yml b/apps/web/tests/goal-bar.overlay.yml index 4e9e382bb8..7e7fb66cf5 100644 --- a/apps/web/tests/goal-bar.overlay.yml +++ b/apps/web/tests/goal-bar.overlay.yml @@ -1,5 +1,4 @@ -# The client-side FixtureApiClient intentionally rejects settings traffic, so -# this goal-only scenario omits the settings shell and the onboarding steps it -# would mount. Onboarding owns separate assembled-browser coverage. -- id: ui-settings-general +# This scenario owns Goal bar actions, not autonomous Goal rounds. Keeping the +# driver out prevents an active Goal from issuing an unrelated model request. +- id: goal-round-driver disabled: true diff --git a/vitest.web-stress.config.ts b/vitest.web-stress.config.ts index 94bda7e117..6da0bd1315 100644 --- a/vitest.web-stress.config.ts +++ b/vitest.web-stress.config.ts @@ -1,10 +1,10 @@ import tsconfigPaths from 'vite-tsconfig-paths' import { defineConfig } from 'vitest/config' -import { vitestExecArgv } from './vitest.shared.ts' +import { standardDecoratorPlugin, vitestExecArgv } from './vitest.shared.ts' /** Opt-in browser performance lane; no default Vitest config includes *.stress.ts. */ export default defineConfig({ - plugins: [tsconfigPaths({ projects: ['./tsconfig.base.json'] })], + plugins: [tsconfigPaths({ projects: ['./tsconfig.base.json'] }), standardDecoratorPlugin()], test: { execArgv: vitestExecArgv, include: ['apps/web/stress-tests/**/*.stress.ts'], From 5541ae0d2d89b5b1d8866963378974d7c3dcda0c Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:46:37 +0800 Subject: [PATCH 62/79] refactor(client): replace browser fixture with RemoteMock --- apps/web/package.json | 2 + apps/web/tests/assembled-boot.ts | 42 +- apps/web/tests/assembled-remote.ts | 376 ++ apps/web/tests/built-boot.expected.e2e.ts | 6 +- .../command-image-envelope.expected.e2e.ts | 2 +- .../fixtures/assembled-remote.fixture.json | 5859 +++++++++++++++++ apps/web/tests/image-display.expected.e2e.ts | 6 +- .../tests/max-tokens-notice.expected.e2e.ts | 2 +- apps/web/tests/replay-round-trip.e2e.ts | 2 +- apps/web/tests/search-card.expected.e2e.ts | 2 +- apps/web/tests/submission-echo.e2e.ts | 2 +- apps/web/tests/todo-row.expected.e2e.ts | 2 +- .../trajectory-image-display.expected.e2e.ts | 2 +- apps/web/tsconfig.json | 6 + packages/api/gateway/package.json | 1 + .../api/gateway/tests/gateway.client.spec.ts | 18 +- packages/client/connection/README.i18n.yaml | 4 +- packages/client/connection/README.md | 2 - packages/client/connection/README.zh.md | 2 - packages/client/connection/package.json | 10 +- .../client/connection/src/client/fixture.ts | 4053 ------------ .../client/connection/src/client/index.ts | 8 +- .../tests/client-apply.client.spec.ts | 82 +- .../tests/fixture-commands.client.spec.ts | 185 - .../connection/tests/fixture.client.spec.ts | 1825 ----- .../tests/generation.client.spec.ts | 6 +- .../client/connection/tsconfig.client.json | 16 - packages/client/file-upload/README.i18n.yaml | 4 +- packages/client/file-upload/README.md | 6 +- packages/client/file-upload/README.zh.md | 6 +- .../client/file-upload/src/client/contract.ts | 2 +- .../client/file-upload/src/client/runtime.ts | 19 +- .../tests/file-upload.client.spec.ts | 26 +- .../tests/frontend-static.spec.ts | 2 +- .../session-query-sqlite/src/index.ts | 2 - pnpm-lock.yaml | 27 +- vitest.config.ts | 1 - 37 files changed, 6358 insertions(+), 6260 deletions(-) create mode 100644 apps/web/tests/assembled-remote.ts create mode 100644 apps/web/tests/fixtures/assembled-remote.fixture.json delete mode 100644 packages/client/connection/src/client/fixture.ts delete mode 100644 packages/client/connection/tests/fixture-commands.client.spec.ts delete mode 100644 packages/client/connection/tests/fixture.client.spec.ts diff --git a/apps/web/package.json b/apps/web/package.json index 456b264094..6d4575aaf5 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -33,6 +33,7 @@ "@deepseek-ai/cordis-plugin-group": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-dockkit": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", @@ -41,6 +42,7 @@ "@deepseek-ai/dsh-pwsh-local": "workspace:^", "@deepseek-ai/dsh-experimental-webworker-packer": "workspace:^", "@deepseek-ai/dsh-experimental-webworker-runtime": "workspace:^", + "@deepseek-ai/dsh-remote-mock": "workspace:^", "@types/node": "^22.0.0", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", diff --git a/apps/web/tests/assembled-boot.ts b/apps/web/tests/assembled-boot.ts index a18893d82b..90acb73bec 100644 --- a/apps/web/tests/assembled-boot.ts +++ b/apps/web/tests/assembled-boot.ts @@ -1,6 +1,6 @@ // Shared scaffolding for the assembled-jsdom snapshots: the real built // workspace `lib/client.js` artifacts booted through AppWebEntry's -// ModuleLoader path (loadBundle) against the keyless fixture Connection RPC +// ModuleLoader path (loadBundle) against a test-owned RemoteMock carrier // transport. Every file that mounts this graph needs the same boot entry list, // the same bundle map, the same jsdom globals, and the same mount call, and // differs only in what it asserts afterwards, so the scaffolding lives here. @@ -15,7 +15,11 @@ import { act, cleanup } from '@testing-library/react' import { afterEach, beforeEach, vi } from 'vitest' import { bootInjections, orderByModuleGraph } from '@deepseek-ai/dsh-client-modules' import type { ClientModuleLoaderTarget, WebBootEntry, WebBootGraph } from '@deepseek-ai/dsh-client-modules/client' +import type { RemoteMock } from '@deepseek-ai/dsh-remote-mock' import { AppWebEntry } from '@deepseek-ai/dsh-client-web' +import { + createAssembledRemote, type AssembledRemote, type AssembledRemoteOptions, +} from './assembled-remote.ts' interface AssembledPlugin extends WebBootEntry { /** Absolute path to the built client artifact declared by this package. */ @@ -25,6 +29,8 @@ interface AssembledPlugin extends WebBootEntry { interface AssembledBootOptions { /** Package ids omitted from this mounted composition. */ readonly exclude?: readonly string[] + /** Remote answers owned by this assembled case. */ + readonly remote?: AssembledRemoteOptions } interface ClientPackageManifest { @@ -174,6 +180,7 @@ function bundleTable(graph: WebBootGraph, plugins: readonly AssembledPlugin[]): interface FixtureWindow extends Window { __DSH_BOOT__?: WebBootGraph __ModuleLoader__?: ClientModuleLoaderTarget + __DSH_TRANSPORT__?: { readonly rpc: RemoteMock['rpc'] } } class ResizeObserverStub { @@ -189,6 +196,7 @@ class EventSourceStub { const win = window as FixtureWindow let unmount: (() => Promise) | undefined +let mountedRemote: RemoteMock | undefined /** * Register the per-test jsdom setup and teardown the assembled boot needs: @@ -214,9 +222,8 @@ export function installAssembledBootEnv(): void { beforeEach(() => { localStorage.clear() // The locale service derives its provisional locale from the browser and - // takes an explicit choice only from Host settings, which this lane's - // fixture transport does not serve; pinning the navigator is what selects - // English here. + // takes an explicit choice only from Host settings. This scenario serves no + // locale setting, so pinning the navigator selects English. Object.defineProperty(navigator, 'languages', { value: ['en-US'], configurable: true }) Object.defineProperty(navigator, 'language', { value: 'en-US', configurable: true }) document.title = 'DeepSeek Harness' @@ -228,11 +235,23 @@ export function installAssembledBootEnv(): void { }) afterEach(async () => { - await act(async () => { await unmount?.() }) + const failures: unknown[] = [] + try { + await act(async () => { await unmount?.() }) + } catch (error) { + failures.push(error) + } + try { + mountedRemote?.assertNoUnmatched() + } catch (error) { + failures.push(error) + } unmount = undefined + mountedRemote = undefined cleanup() delete win.__DSH_BOOT__ delete win.__ModuleLoader__ + delete win.__DSH_TRANSPORT__ document.body.innerHTML = '' document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() }) document.title = '' @@ -243,19 +262,23 @@ export function installAssembledBootEnv(): void { delete ownNavigator.languages delete ownNavigator.language vi.unstubAllGlobals() + if (failures.length > 0) throw new AggregateError(failures, 'assembled boot teardown failed') }) } /** - * Mount the assembled application on the fixture transport; the teardown + * Mount the assembled application on an isolated RemoteMock transport; the teardown * registered by installAssembledBootEnv disposes it. - * @param search - fixture query string used to select deterministic host behavior. * @param options - composition changes applied to this mount. + * @returns the test-owned RemoteMock world. */ -export function mountAssembledApp(search = '?fixture', options: AssembledBootOptions = {}): void { +export function mountAssembledApp(options: AssembledBootOptions = {}): AssembledRemote { const excluded = new Set(options.exclude) const plugins = PLUGINS.filter(plugin => !excluded.has(plugin.id)) - history.replaceState(null, '', `/${search}`) + const remote = createAssembledRemote(options.remote) + mountedRemote = remote.mock + win.__DSH_TRANSPORT__ = { rpc: remote.mock.rpc } + history.replaceState(null, '', '/') const root = document.createElement('div') root.id = 'root' document.body.appendChild(root) @@ -281,6 +304,7 @@ export function mountAssembledApp(search = '?fixture', options: AssembledBootOpt void entry.run() unmount = () => entry.dispose() }) + return remote } /** diff --git a/apps/web/tests/assembled-remote.ts b/apps/web/tests/assembled-remote.ts new file mode 100644 index 0000000000..b0b44f9db1 --- /dev/null +++ b/apps/web/tests/assembled-remote.ts @@ -0,0 +1,376 @@ +/** RemoteMock scenario for built-client tests that do not own a Host. */ + +import { readFileSync } from 'node:fs' +import { randomUUID } from 'node:crypto' +import { join } from 'node:path' +import { ok, RemoteMock } from '@deepseek-ai/dsh-remote-mock' +import { remoteDefaultResponses } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/remote-default-responses.ts' + +interface SessionSummary { + readonly sessionId: string + updatedAt: number + running: boolean + blank: boolean + readonly parentSessionId?: string + readonly origin?: 'subagent' + readonly cwd?: string + readonly projections?: { + readonly asOfSeq: number + readonly values: Readonly> + } +} + +interface WorkspaceView { + readonly workspaceId: string + readonly path: string + readonly title: string + sessionIds: string[] + readonly createdAt: string + updatedAt: string +} + +interface EventRecord { + readonly type: 'event' + readonly event: { + readonly seq: number + readonly time: number + readonly type: string + readonly data: unknown + readonly surfaceOp?: string + } +} + +interface FollowSnapshot { + readonly type: 'snapshot' + readonly header: Readonly> + readonly cursor: number + readonly records: readonly EventRecord[] + readonly hasMore: boolean + readonly projections: Readonly> + readonly assistantStream?: Readonly> +} + +interface ControlBaseline { + readonly type: 'baseline' + readonly value: { + readonly queues: Readonly> + readonly jobs: Readonly> + readonly approvals: readonly unknown[] + readonly questions: readonly unknown[] + readonly projections: Readonly> + }>> + } +} + +interface CapturedFixture { + readonly sessionList: { readonly ok: true; readonly value: { readonly items: readonly SessionSummary[] } } + readonly settingsDescribe: unknown + readonly credentialsDescribe: unknown + readonly modelCatalog: unknown + readonly agentPresets: unknown + readonly commands: unknown + readonly workspace: { + readonly type: 'baseline' + readonly value: { + readonly items: readonly WorkspaceView[] + readonly archivedSessionIds: readonly string[] + } + } + readonly control: ControlBaseline + readonly remoteEvents: readonly unknown[] + readonly follow: FollowSnapshot + readonly attachment: unknown +} + +export interface AssembledRemoteOptions { + /** Return the fixture's image-dimension admission error from Session prompt. */ + readonly rejectPrompt?: boolean +} + +export interface AssembledRemote { + readonly mock: RemoteMock +} + +const fixtureSource = readFileSync( + join(process.cwd(), 'apps/web/tests/fixtures/assembled-remote.fixture.json'), + 'utf8', +) + +/** Create one isolated RemoteMock world for an assembled built-client case. */ +export function createAssembledRemote(options: AssembledRemoteOptions = {}): AssembledRemote { + const fixture = JSON.parse(fixtureSource) as CapturedFixture + const sessions = [...structuredClone(fixture.sessionList.value.items)] + const workspaces = [...structuredClone(fixture.workspace.value.items)] + const records = new Map([[ + 'fx-alpha', + structuredClone(fixture.follow.records) as EventRecord[], + ]]) + const attachments = new Map([['fixture:image', structuredClone(fixture.attachment)]]) + let nextSession = 1 + + const mock = RemoteMock.create({ host: { home: '/home/fixture' } }).load(remoteDefaultResponses) + mock.load({ + unary: { + 'settings/describe': structuredClone(fixture.settingsDescribe), + 'credentials/describe': structuredClone(fixture.credentialsDescribe), + 'session/modelCatalog': structuredClone(fixture.modelCatalog), + 'agentPresets/list': structuredClone(fixture.agentPresets), + 'commands/list': structuredClone(fixture.commands), + 'settings/canOpenAgentPresetDirectory': ok(true), + 'settings/openSettingsDocument': ok({ opened: true }), + 'settings/openAgentPresetDirectory': ok({ opened: true }), + 'subagents/list': ok({ entries: [], parentAvailable: true }), + 'skills/list': ok({ skills: [] }), + 'session/canOpenWorkspacePath': ok(true), + 'session/openWorkspacePath': ok({ opened: true }), + 'session/updateQueue': { + ok: false, + error: { + code: 'session/queue-item-not-found', + message: 'assembled fixture has no pending queue item', + details: {}, + }, + }, + 'session/cancel': ok({ accepted: true }), + }, + }) + + mock.stream('$events', (_args, stream) => { + for (const frame of fixture.remoteEvents) stream.push(structuredClone(frame)) + }) + mock.stream('session/control', (_args, stream) => { + stream.push(structuredClone(fixture.control)) + }) + mock.stream('workspace/follow', (_args, stream) => { + stream.push({ + type: 'baseline', + value: { + items: structuredClone(workspaces), + archivedSessionIds: structuredClone(fixture.workspace.value.archivedSessionIds), + }, + }) + }) + mock.stream('session/follow', ([args], stream) => { + const request = recordValue(args, 'request') + const sessionId = followedSessionId(request) + if (sessionId === 'fx-alpha') { + stream.push(structuredClone(fixture.follow)) + return + } + const summary = sessions.find(candidate => candidate.sessionId === sessionId) + if (summary === undefined) throw new Error(`assembled fixture: no Session ${sessionId}`) + const sessionRecords = records.get(sessionId) ?? [] + stream.push({ + type: 'snapshot', + header: { + version: 3, + id: sessionId, + createdAt: summary.updatedAt, + cwd: summary.cwd, + isSeeded: false, + }, + cursor: sessionRecords.at(-1)?.event.seq ?? -1, + records: structuredClone(sessionRecords), + hasMore: false, + projections: structuredClone(fixture.control.value.projections['fx-gamma'] ?? { + asOfSeq: -1, + values: {}, + }), + ...isAssistantStreamRequested(request) ? { assistantStream: { revision: 0 } } : {}, + }) + }) + + mock.unary('$events/result', (result: unknown) => { + const eventId = recordString(result, 'eventId') + mock.streams.push('$events', { type: 'cancel', eventId }) + return ok(undefined) + }) + mock.unary('session/list', () => ok({ items: structuredClone(sessions) })) + mock.unary('workspace/create', (request: unknown) => { + const path = recordString(recordValue(request, 'request'), 'path') + const existing = workspaces.find(workspace => workspace.path === path) + if (existing !== undefined) return ok({ workspace: structuredClone(existing), created: false }) + const now = new Date().toISOString() + const workspace = { + workspaceId: `fx-ws-${String(workspaces.length + 1)}`, + path, + title: path.split('/').filter(Boolean).at(-1) ?? path, + sessionIds: [], + createdAt: now, + updatedAt: now, + } + workspaces.unshift(workspace) + mock.streams.push('workspace/follow', { type: 'upsert', workspace: structuredClone(workspace) }) + return ok({ workspace: structuredClone(workspace), created: true }) + }) + mock.unary('session/create', (request: unknown) => { + request = recordValue(request, 'request') + const requestedId = optionalRecordString(request, 'sessionId') + const sessionId = requestedId ?? `fx-${String(nextSession++)}` + const existing = sessions.find(candidate => candidate.sessionId === sessionId) + if (existing !== undefined) return ok({ sessionId }) + const workspaceId = optionalRecordString(request, 'workspaceId') + const workspace = workspaces.find(candidate => candidate.workspaceId === workspaceId) + const cwd = workspace?.path ?? optionalRecordString(request, 'cwd') ?? '/tmp/fixture' + const summary: SessionSummary = { + sessionId, + updatedAt: Date.now(), + running: false, + blank: true, + cwd, + projections: structuredClone(fixture.control.value.projections['fx-gamma'] ?? { + asOfSeq: -1, + values: {}, + }), + } + sessions.push(summary) + records.set(sessionId, []) + if (workspace !== undefined && !workspace.sessionIds.includes(sessionId)) { + workspace.sessionIds = [sessionId, ...workspace.sessionIds] + workspace.updatedAt = new Date().toISOString() + mock.streams.push('workspace/follow', { + type: 'upsert', + workspace: structuredClone(workspace), + }) + } + mock.streams.push('$events', { type: 'emit', event: 'api-session/added', args: [structuredClone(summary)] }) + return ok({ sessionId }) + }) + mock.unary('session/attachment', (request: unknown) => { + request = recordValue(request, 'request') + const attachmentId = recordString(request, 'attachmentId') + return attachments.get(attachmentId) ?? { + ok: false, + error: { + code: 'session/attachment-invalid', + message: `assembled fixture attachment ${attachmentId} is missing`, + details: { reason: 'ATTACHMENT_NOT_FOUND' }, + }, + } + }) + mock.unary('session/prompt', (request: unknown) => { + request = recordValue(request, 'request') + if (options.rejectPrompt === true) { + return { + ok: false, + error: { + code: 'session/attachment-invalid', + message: 'assembled fixture: image side exceeds the deployment limit', + details: { reason: 'IMAGE_DIMENSION_TOO_LARGE' }, + }, + } + } + const sessionId = recordString(request, 'sessionId') + const requestId = recordString(request, 'requestId') + const sessionRecords = records.get(sessionId) ?? [] + const content = recordArray(request, 'content').map((part) => { + if (!isRecord(part) || part.type !== 'image') return part + const attachmentId = `assembled:${randomUUID()}` + const data = recordString(part, 'data') + const attachment = { + attachmentId, + mediaType: recordString(part, 'mediaType'), + bytes: Math.max(1, Math.floor(data.length * 3 / 4)), + width: 160, + height: 90, + ...optionalRecordString(part, 'name') === undefined + ? {} + : { name: optionalRecordString(part, 'name') }, + } + attachments.set(attachmentId, ok({ attachment, data })) + return { type: 'image', attachment } + }) + const turn = sessionRecords.length + const turnEvent = eventOf(sessionRecords.length, 'turn/start', { turn }) + const userEvent = eventOf(sessionRecords.length + 1, 'user/message', { + content, + source: { kind: 'user', rpcId: requestId }, + role: 'user', + id: randomUUID(), + }, 'append') + sessionRecords.push(turnEvent, userEvent) + records.set(sessionId, sessionRecords) + mock.streams.push('session/follow', turnEvent, follows(sessionId)) + mock.streams.push('session/follow', userEvent, follows(sessionId)) + return ok({ accepted: true }) + }) + mock.unary('commands/execute', (args: unknown) => { + const line = optionalRecordString(args, 'line') ?? '' + const name = /^\/(\S+)/u.exec(line.trim())?.[1] + if (name === undefined || !['compact', 'echo', 'goal', 'permission', 'plan'].includes(name)) { + return ok(undefined) + } + return ok({ + commandId: `assembled-command-${randomUUID()}`, + result: { + kind: 'success', + ...name === 'echo' ? { text: line.replace(/^\/echo\s*/u, '') } : {}, + }, + }) + }) + + return { mock } +} + +function eventOf( + seq: number, + type: string, + data: unknown, + surfaceOp?: string, +): EventRecord { + return { + type: 'event', + event: { + seq, + time: Date.now(), + type, + data, + ...(surfaceOp === undefined ? {} : { surfaceOp }), + }, + } +} + +function follows(sessionId: string): (args: readonly unknown[]) => boolean { + return ([args]) => followedSessionId(recordValue(args, 'request')) === sessionId +} + +function followedSessionId(value: unknown): string { + if (!isRecord(value) || !isRecord(value.address)) throw new TypeError('assembled fixture follow request is invalid') + return value.address.kind === 'session' + ? recordString(value.address, 'sessionId') + : recordString(value.address, 'childSessionId') +} + +function isAssistantStreamRequested(value: unknown): boolean { + return isRecord(value) && value.assistantStream === true +} + +function recordString(value: unknown, key: string): string { + const selected = isRecord(value) ? value[key] : undefined + if (typeof selected !== 'string') throw new TypeError(`assembled fixture ${key} must be a string`) + return selected +} + +function optionalRecordString(value: unknown, key: string): string | undefined { + const selected = isRecord(value) ? value[key] : undefined + if (selected === undefined) return undefined + if (typeof selected !== 'string') throw new TypeError(`assembled fixture ${key} must be a string`) + return selected +} + +function recordArray(value: unknown, key: string): readonly unknown[] { + const selected = isRecord(value) ? value[key] : undefined + if (!Array.isArray(selected)) throw new TypeError(`assembled fixture ${key} must be an array`) + return selected +} + +function recordValue(value: unknown, key: string): unknown { + if (!isRecord(value) || !(key in value)) throw new TypeError(`assembled fixture ${key} is missing`) + return value[key] +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} diff --git a/apps/web/tests/built-boot.expected.e2e.ts b/apps/web/tests/built-boot.expected.e2e.ts index e30ac22630..17a741dac4 100644 --- a/apps/web/tests/built-boot.expected.e2e.ts +++ b/apps/web/tests/built-boot.expected.e2e.ts @@ -4,7 +4,7 @@ // reach a surface only the built bundles expose; this one asserts that the // graph assembles at all — staged activation across the immediately tier and // the inject layers, per-plugin CSS injection, and a rendered journey reaching -// chat content from the keyless fixture Connection RPC. +// chat content from the keyless RemoteMock scenario. // // Component behavior remains owned by per-package suites (SlotTestRuntime // benches over src). This smoke additionally pins the resident interaction @@ -92,7 +92,7 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn expect(waitingRow.querySelector('[data-state="ongoing"]')).toBeNull() within(waitingRow).getByText('Waiting for answer') - // Opening a session reaches chat content through the fixture transport. + // Opening a session reaches chat content through the RemoteMock transport. fireEvent.click(waitingTitle) await waitFor(() => { expect(document.querySelector('[data-sample="bash"]')).not.toBeNull() @@ -162,7 +162,7 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn }) it('boots without ui-chat and does not select another conversation view implicitly', async () => { - mountAssembledApp('?fixture', { exclude: ['@deepseek-ai/dsh-client-ui-chat'] }) + mountAssembledApp({ exclude: ['@deepseek-ai/dsh-client-ui-chat'] }) const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) const boot = Reflect.get(window, '__DSH_BOOT__') as { entries: Array<{ id: string }> } | undefined diff --git a/apps/web/tests/command-image-envelope.expected.e2e.ts b/apps/web/tests/command-image-envelope.expected.e2e.ts index 565eaa3524..ef288f5818 100644 --- a/apps/web/tests/command-image-envelope.expected.e2e.ts +++ b/apps/web/tests/command-image-envelope.expected.e2e.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom // The command attachment envelope over the BUILT client graph (real -// bundles via AppWebEntry, keyless fixture Connection RPC): an enter +// bundles via AppWebEntry, keyless RemoteMock transport): an enter // 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 diff --git a/apps/web/tests/fixtures/assembled-remote.fixture.json b/apps/web/tests/fixtures/assembled-remote.fixture.json new file mode 100644 index 0000000000..34d3112dfe --- /dev/null +++ b/apps/web/tests/fixtures/assembled-remote.fixture.json @@ -0,0 +1,5859 @@ +{ + "sessionList": { + "ok": true, + "value": { + "items": [ + { + "sessionId": "fx-alpha", + "updatedAt": 1789268875874, + "running": true, + "blank": false, + "cwd": "/tmp/fixture" + }, + { + "sessionId": "fx-beta", + "updatedAt": 1789268815874, + "running": false, + "blank": false, + "parentSessionId": "fx-alpha", + "cwd": "/tmp/fixture" + }, + { + "sessionId": "fx-gamma", + "updatedAt": 1789268755874, + "running": false, + "blank": false, + "cwd": "/tmp/fixture" + } + ] + } + }, + "settingsDescribe": { + "ok": true, + "value": { + "writable": true, + "hasDocument": true, + "namespaces": [ + { + "ns": "llm-deepseek", + "schema": {}, + "value": { + "apiKeyEnv": "DEEPSEEK_API_KEY" + }, + "applies": "live", + "secrets": [ + { + "path": [ + "apiKey" + ], + "set": false + } + ], + "revision": 0 + } + ] + } + }, + "credentialsDescribe": { + "ok": true, + "value": { + "DEEPSEEK_API_KEY": { + "configured": true, + "source": "file", + "writable": true + } + } + }, + "modelCatalog": { + "ok": true, + "value": { + "default": { + "provider": "deepseek-official", + "model": "deepseek-v4-flash" + }, + "routableProviders": [ + "deepseek-official", + "openai", + "acme-gateway" + ], + "groups": [ + { + "id": "deepseek-official", + "name": "DeepSeek", + "models": [ + { + "id": "deepseek-v4-flash", + "name": "DeepSeek-V4-Flash", + "description": "快速响应", + "reasoning": { + "efforts": [ + { + "id": "off", + "name": "Off" + }, + { + "id": "high", + "name": "High" + }, + { + "id": "max", + "name": "Max" + } + ], + "defaultEffort": "high" + } + }, + { + "id": "deepseek-v4-pro", + "name": "DeepSeek-V4-Pro", + "description": "复杂任务", + "reasoning": { + "efforts": [ + { + "id": "off", + "name": "Off" + }, + { + "id": "high", + "name": "High" + }, + { + "id": "max", + "name": "Max" + } + ], + "defaultEffort": "high" + } + } + ] + }, + { + "id": "openai", + "name": "OpenAI", + "models": [ + { + "id": "gpt-5", + "name": "GPT-5", + "reasoning": { + "efforts": [ + { + "id": "off", + "name": "Off" + }, + { + "id": "medium", + "name": "Medium" + }, + { + "id": "high", + "name": "High" + }, + { + "id": "max", + "name": "Max" + } + ], + "defaultEffort": "medium" + } + } + ] + } + ], + "failures": [] + } + }, + "agentPresets": { + "ok": true, + "value": { + "presets": [ + { + "id": "standard", + "trust": "system", + "isDefault": true + }, + { + "id": "minimal", + "trust": "system", + "isDefault": false + }, + { + "id": "my-agent", + "trust": "user", + "isDefault": false + } + ], + "authorable": true, + "modeSelectionEnabled": true + } + }, + "commands": { + "ok": true, + "value": [ + { + "name": "compact", + "description": "fixture:压缩当前会话上下文" + }, + { + "name": "echo", + "description": "fixture:回显参数", + "input": { + "hint": "text to echo" + } + }, + { + "definitionId": "@deepseek-ai/dsh-command-goal", + "name": "goal", + "description": "Set or view the goal for a long-running task", + "input": { + "hint": "", + "attachments": true + } + }, + { + "definitionId": "@deepseek-ai/dsh-permission-presets", + "name": "permission", + "description": "Switch the permission preset (sandbox mode + approval policy)", + "input": { + "hint": "" + } + }, + { + "definitionId": "@deepseek-ai/dsh-plan-mode", + "name": "plan", + "description": "Enter or leave plan mode", + "input": { + "hint": "[off|message]", + "attachments": true + } + } + ] + }, + "workspace": { + "type": "baseline", + "value": { + "items": [ + { + "workspaceId": "fx-ws-fixture", + "path": "/tmp/fixture", + "title": "fixture", + "sessionIds": [ + "fx-alpha", + "fx-beta", + "fx-gamma" + ], + "createdAt": "2026-09-13T03:02:55.884Z", + "updatedAt": "2026-09-13T03:02:55.884Z" + }, + { + "workspaceId": "fx-ws-home", + "path": "/home/fixture/Documents/project", + "title": "project", + "sessionIds": [], + "createdAt": "2026-09-13T03:02:55.884Z", + "updatedAt": "2026-09-13T03:02:55.884Z" + } + ], + "archivedSessionIds": [] + } + }, + "control": { + "type": "baseline", + "value": { + "queues": { + "fx-alpha": [], + "fx-beta": [], + "fx-gamma": [] + }, + "jobs": { + "fx-alpha": [], + "fx-beta": [], + "fx-gamma": [] + }, + "approvals": [], + "questions": [], + "projections": { + "fx-alpha": { + "asOfSeq": 552, + "values": { + "modelSelection": { + "lastUsed": null, + "next": null + }, + "title": "Fixture 历史会话", + "todos": [ + { + "content": "梳理需求", + "status": "completed" + }, + { + "content": "实现 fixture 样本", + "status": "in_progress" + }, + { + "content": "跑后台构建", + "status": "in_progress" + }, + { + "content": "浏览器验收", + "status": "pending" + } + ], + "permissions": { + "options": [ + { + "value": "workspace-write", + "name": "workspace-write", + "description": "Write inside the workspace and permitted temporary directories; wider retries require approval." + }, + { + "value": "danger-full-access", + "name": "danger-full-access", + "description": "Full file access without approval prompts." + } + ], + "currentValue": "workspace-write" + }, + "plan": { + "active": false, + "pending": false + }, + "goal": null, + "tokenUsage": { + "uncachedInputTokens": 1914, + "outputTokens": 708, + "cacheReadTokens": 6880, + "cacheWriteTokens": 32 + }, + "contextPressure": { + "pressureTokens": 104, + "contextWindow": 128000 + }, + "contextBreakdown": { + "systemTokens": 17, + "toolsTokens": 0, + "messageTokens": 4427 + }, + "sessionStats": { + "turns": 75, + "steps": 87, + "llmMs": 69600, + "toolMs": 24800, + "ttftMs": 0, + "ttftSteps": 0, + "decodeMs": 0, + "decodeTokens": 0 + }, + "imageLimits": { + "maxImageBytes": 5242880, + "maxImagesPerMessage": 20, + "maxMessageImageBytes": 104857600, + "maxImagePixels": 40000000, + "maxImageDimension": 2000, + "mediaTypes": [ + "image/png", + "image/jpeg", + "image/webp", + "image/gif" + ] + } + } + }, + "fx-beta": { + "asOfSeq": -1, + "values": { + "modelSelection": { + "lastUsed": null, + "next": null + }, + "todos": null, + "permissions": { + "options": [ + { + "value": "workspace-write", + "name": "workspace-write", + "description": "Write inside the workspace and permitted temporary directories; wider retries require approval." + }, + { + "value": "danger-full-access", + "name": "danger-full-access", + "description": "Full file access without approval prompts." + } + ], + "currentValue": "workspace-write" + }, + "plan": { + "active": false, + "pending": false + }, + "goal": null, + "tokenUsage": { + "uncachedInputTokens": 0, + "outputTokens": 0, + "cacheReadTokens": 0, + "cacheWriteTokens": 0 + }, + "contextPressure": {}, + "contextBreakdown": { + "systemTokens": 0, + "toolsTokens": 0, + "messageTokens": 0 + }, + "sessionStats": { + "turns": 0, + "steps": 0, + "llmMs": 0, + "toolMs": 0, + "ttftMs": 0, + "ttftSteps": 0, + "decodeMs": 0, + "decodeTokens": 0 + }, + "imageLimits": { + "maxImageBytes": 5242880, + "maxImagesPerMessage": 20, + "maxMessageImageBytes": 104857600, + "maxImagePixels": 40000000, + "maxImageDimension": 2000, + "mediaTypes": [ + "image/png", + "image/jpeg", + "image/webp", + "image/gif" + ] + } + } + }, + "fx-gamma": { + "asOfSeq": -1, + "values": { + "modelSelection": { + "lastUsed": null, + "next": null + }, + "todos": null, + "permissions": { + "options": [ + { + "value": "workspace-write", + "name": "workspace-write", + "description": "Write inside the workspace and permitted temporary directories; wider retries require approval." + }, + { + "value": "danger-full-access", + "name": "danger-full-access", + "description": "Full file access without approval prompts." + } + ], + "currentValue": "workspace-write" + }, + "plan": { + "active": false, + "pending": false + }, + "goal": null, + "tokenUsage": { + "uncachedInputTokens": 0, + "outputTokens": 0, + "cacheReadTokens": 0, + "cacheWriteTokens": 0 + }, + "contextPressure": {}, + "contextBreakdown": { + "systemTokens": 0, + "toolsTokens": 0, + "messageTokens": 0 + }, + "sessionStats": { + "turns": 0, + "steps": 0, + "llmMs": 0, + "toolMs": 0, + "ttftMs": 0, + "ttftSteps": 0, + "decodeMs": 0, + "decodeTokens": 0 + }, + "imageLimits": { + "maxImageBytes": 5242880, + "maxImagesPerMessage": 20, + "maxMessageImageBytes": 104857600, + "maxImagePixels": 40000000, + "maxImageDimension": 2000, + "mediaTypes": [ + "image/png", + "image/jpeg", + "image/webp", + "image/gif" + ] + } + } + } + } + } + }, + "remoteEvents": [ + { + "type": "ready", + "clientId": "36b204ff-b72f-4720-b58a-c86bebca8c42", + "host": { + "home": "/home/fixture" + } + }, + { + "type": "waterfall", + "event": "approval/request", + "eventId": "fx-interaction-approval", + "agentId": "fx-alpha", + "request": { + "toolName": "dangerous_tool", + "reason": "fixture 常驻审批(可答:批准/拒绝后消失)" + } + }, + { + "type": "waterfall", + "event": "user-questions/request", + "eventId": "fx-interaction-question", + "agentId": "fx-alpha", + "request": { + "questions": [ + { + "id": "harness-profile", + "header": "偏好", + "question": "你现在更想招哪类 Agent/Harness 候选人?", + "options": [ + { + "label": "工程落地型 (Recommended)", + "description": "更看重能直接做 runtime、tool executor、sandbox、trace 和线上问题排查。" + }, + { + "label": "研究潜力型", + "description": "更看重 Agent 理解、训练评测思路和长期成长空间。" + }, + { + "label": "均衡型", + "description": "同时要求工程能力和 Agent 认知,但可能筛选门槛更高。" + } + ] + }, + { + "id": "work-mode", + "header": "方式", + "question": "你希望候选人优先展示哪种工作方式?", + "options": [ + { + "label": "先做小型原型 (Recommended)", + "description": "用可运行结果尽快验证关键假设。" + }, + { + "label": "先写完整设计", + "description": "先收敛边界、协议和风险,再开始实现。" + } + ] + }, + { + "id": "signals", + "header": "信号", + "question": "哪些面试信号最重要?", + "detail": "按当前招聘目标选择;跳过则视为不设偏好。", + "multiSelect": true, + "options": [ + { + "label": "系统设计" + }, + { + "label": "代码质量" + }, + { + "label": "Agent 产品判断" + } + ] + } + ] + } + } + ], + "follow": { + "type": "snapshot", + "header": { + "version": 3, + "id": "fx-alpha", + "createdAt": 1789268875874, + "cwd": "/tmp/fixture", + "isSeeded": false + }, + "cursor": 552, + "records": [ + { + "type": "event", + "event": { + "seq": 365, + "time": 1789265568675, + "type": "turn/start", + "data": { + "turn": 51 + } + } + }, + { + "type": "event", + "event": { + "seq": 366, + "time": 1789265569475, + "type": "user/message", + "surfaceOp": "append", + "data": { + "content": [ + { + "type": "text", + "text": "问题 51:fixture 历史消息,用于翻页与渲染验收。" + } + ], + "source": { + "kind": "user" + }, + "role": "user", + "id": "781d1628-a401-4577-9608-fad94469115f" + } + } + }, + { + "type": "event", + "event": { + "seq": 367, + "time": 1789265570275, + "type": "step/start", + "data": { + "turn": 51, + "step": 0 + } + } + }, + { + "type": "event", + "event": { + "seq": 368, + "time": 1789265571075, + "type": "assistant/message", + "surfaceOp": "append", + "data": { + "turn": 51, + "step": 0, + "message": { + "role": "assistant", + "content": [ + { + "type": "text", + "text": "回答 51:这是 fixture 生成的历史回复正文。" + } + ], + "source": { + "kind": "model", + "provider": "fixture", + "model": "fx-1" + }, + "id": "41db22fd-d174-446f-8d4f-dd0769071b92" + }, + "usage": { + "inputTokens": 21, + "outputTokens": 8, + "cacheReadTokens": 80, + "cacheWriteTokens": 0 + }, + "stream": [ + { + "type": "chunk", + "time": 1789265571075, + "chunk": { + "type": "block-start", + "index": 0, + "blockType": "text" + } + }, + { + "type": "chunk", + "time": 1789265571075, + "chunk": { + "type": "block-end", + "index": 0, + "block": { + "type": "text", + "text": "回答 51:这是 fixture 生成的历史回复正文。" + } + } + }, + { + "type": "chunk", + "time": 1789265571075, + "chunk": { + "type": "usage", + "usage": { + "inputTokens": 21, + "outputTokens": 8, + "cacheReadTokens": 80, + "cacheWriteTokens": 0 + } + } + }, + { + "type": "chunk", + "time": 1789265571075, + "chunk": { + "type": "finish", + "reason": { + "kind": "stop" + } + } + } + ] + } + } + }, + { + "type": "event", + "event": { + "seq": 369, + "time": 1789265571875, + "type": "step/end", + "data": { + "turn": 51, + "step": 0 + } + } + }, + { + "type": "event", + "event": { + "seq": 370, + "time": 1789265572675, + "type": "turn/end", + "data": { + "turn": 51, + "reason": { + "kind": "completed" + } + } + } + }, + { + "type": "event", + "event": { + "seq": 371, + "time": 1789265573475, + "type": "turn/start", + "data": { + "turn": 52 + } + } + }, + { + "type": "event", + "event": { + "seq": 372, + "time": 1789265574275, + "type": "user/message", + "surfaceOp": "append", + "data": { + "content": [ + { + "type": "text", + "text": "问题 52:fixture 历史消息,用于翻页与渲染验收。" + } + ], + "source": { + "kind": "user" + }, + "role": "user", + "id": "ca2e9326-9086-477e-b80d-32005b85cbc6" + } + } + }, + { + "type": "event", + "event": { + "seq": 373, + "time": 1789265575075, + "type": "step/start", + "data": { + "turn": 52, + "step": 0 + } + } + }, + { + "type": "event", + "event": { + "seq": 374, + "time": 1789265575875, + "type": "assistant/message", + "surfaceOp": "append", + "data": { + "turn": 52, + "step": 0, + "message": { + "role": "assistant", + "content": [ + { + "type": "reasoning", + "text": "思考过程 52:这是一段可折叠的 reasoning 内容。" + }, + { + "type": "text", + "text": "回答 52:这是 fixture 生成的历史回复正文。" + }, + { + "type": "tool-call", + "id": "fx-call-52", + "name": "echo", + "arguments": "{\"text\":\"turn 52\"}" + } + ], + "source": { + "kind": "model", + "provider": "fixture", + "model": "fx-1" + }, + "id": "38c0ab81-0fc5-401d-a876-f0d1e766de03" + }, + "usage": { + "inputTokens": 22, + "outputTokens": 8, + "cacheReadTokens": 80, + "cacheWriteTokens": 0 + }, + "stream": [ + { + "type": "chunk", + "time": 1789265575875, + "chunk": { + "type": "block-start", + "index": 0, + "blockType": "reasoning" + } + }, + { + "type": "chunk", + "time": 1789265575875, + "chunk": { + "type": "block-end", + "index": 0, + "block": { + "type": "reasoning", + "text": "思考过程 52:这是一段可折叠的 reasoning 内容。" + } + } + }, + { + "type": "chunk", + "time": 1789265575875, + "chunk": { + "type": "block-start", + "index": 1, + "blockType": "text" + } + }, + { + "type": "chunk", + "time": 1789265575875, + "chunk": { + "type": "block-end", + "index": 1, + "block": { + "type": "text", + "text": "回答 52:这是 fixture 生成的历史回复正文。" + } + } + }, + { + "type": "chunk", + "time": 1789265575875, + "chunk": { + "type": "block-start", + "index": 2, + "blockType": "tool-call" + } + }, + { + "type": "chunk", + "time": 1789265575875, + "chunk": { + "type": "block-end", + "index": 2, + "block": { + "type": "tool-call", + "id": "fx-call-52", + "name": "echo", + "arguments": "{\"text\":\"turn 52\"}" + } + } + }, + { + "type": "chunk", + "time": 1789265575875, + "chunk": { + "type": "usage", + "usage": { + "inputTokens": 22, + "outputTokens": 8, + "cacheReadTokens": 80, + "cacheWriteTokens": 0 + } + } + }, + { + "type": "chunk", + "time": 1789265575875, + "chunk": { + "type": "finish", + "reason": { + "kind": "stop" + } + } + } + ] + } + } + }, + { + "type": "event", + "event": { + "seq": 375, + "time": 1789265576675, + "type": "tool/call", + "data": { + "turn": 52, + "step": 0, + "callId": "fx-call-52", + "name": "echo", + "arguments": "{\"text\":\"turn 52\"}" + } + } + }, + { + "type": "event", + "event": { + "seq": 376, + "time": 1789265577475, + "type": "tool/result", + "surfaceOp": "append", + "data": { + "turn": 52, + "step": 0, + "message": { + "source": { + "kind": "tool", + "callId": "fx-call-52" + }, + "content": [ + { + "type": "tool-result", + "toolCallId": "fx-call-52", + "content": [ + { + "type": "text", + "text": "ECHO: TURN 52" + } + ], + "isError": false + } + ], + "role": "user", + "id": "e9bbfe6c-96f3-44b6-b65c-89997783be1e" + } + } + } + }, + { + "type": "event", + "event": { + "seq": 377, + "time": 1789265578275, + "type": "step/end", + "data": { + "turn": 52, + "step": 0 + } + } + }, + { + "type": "event", + "event": { + "seq": 378, + "time": 1789265579075, + "type": "step/start", + "data": { + "turn": 52, + "step": 1 + } + } + }, + { + "type": "event", + "event": { + "seq": 379, + "time": 1789265579875, + "type": "assistant/message", + "surfaceOp": "append", + "data": { + "turn": 52, + "step": 1, + "message": { + "role": "assistant", + "content": [ + { + "type": "text", + "text": "工具结果已消化(turn 52)。" + } + ], + "source": { + "kind": "model", + "provider": "fixture", + "model": "fx-1" + }, + "id": "891ea3be-04ca-4adc-a1b5-70c311b5b0fd" + }, + "usage": { + "inputTokens": 22, + "outputTokens": 9, + "cacheReadTokens": 80, + "cacheWriteTokens": 0 + }, + "stream": [ + { + "type": "chunk", + "time": 1789265579875, + "chunk": { + "type": "block-start", + "index": 0, + "blockType": "text" + } + }, + { + "type": "chunk", + "time": 1789265579875, + "chunk": { + "type": "block-end", + "index": 0, + "block": { + "type": "text", + "text": "工具结果已消化(turn 52)。" + } + } + }, + { + "type": "chunk", + "time": 1789265579875, + "chunk": { + "type": "usage", + "usage": { + "inputTokens": 22, + "outputTokens": 9, + "cacheReadTokens": 80, + "cacheWriteTokens": 0 + } + } + }, + { + "type": "chunk", + "time": 1789265579875, + "chunk": { + "type": "finish", + "reason": { + "kind": "stop" + } + } + } + ] + } + } + }, + { + "type": "event", + "event": { + "seq": 380, + "time": 1789265580675, + "type": "step/end", + "data": { + "turn": 52, + "step": 1 + } + } + }, + { + "type": "event", + "event": { + "seq": 381, + "time": 1789265581475, + "type": "turn/end", + "data": { + "turn": 52, + "reason": { + "kind": "completed" + } + } + } + }, + { + "type": "event", + "event": { + "seq": 382, + "time": 1789265582275, + "type": "turn/start", + "data": { + "turn": 53 + } + } + }, + { + "type": "event", + "event": { + "seq": 383, + "time": 1789265583075, + "type": "user/message", + "surfaceOp": "append", + "data": { + "content": [ + { + "type": "text", + "text": "问题 53:fixture 历史消息,用于翻页与渲染验收。" + } + ], + "source": { + "kind": "user" + }, + "role": "user", + "id": "c26142ac-056b-47cb-bf77-6e554e565484" + } + } + }, + { + "type": "event", + "event": { + "seq": 384, + "time": 1789265583875, + "type": "step/start", + "data": { + "turn": 53, + "step": 0 + } + } + }, + { + "type": "event", + "event": { + "seq": 385, + "time": 1789265584675, + "type": "assistant/message", + "surfaceOp": "append", + "data": { + "turn": 53, + "step": 0, + "message": { + "role": "assistant", + "content": [ + { + "type": "text", + "text": "回答 53:这是 fixture 生成的历史回复正文。" + } + ], + "source": { + "kind": "model", + "provider": "fixture", + "model": "fx-1" + }, + "id": "cc41f0c0-2a74-4bc9-b5a2-f02f4c455569" + }, + "usage": { + "inputTokens": 23, + "outputTokens": 8, + "cacheReadTokens": 80, + "cacheWriteTokens": 0 + }, + "stream": [ + { + "type": "chunk", + "time": 1789265584675, + "chunk": { + "type": "block-start", + "index": 0, + "blockType": "text" + } + }, + { + "type": "chunk", + "time": 1789265584675, + "chunk": { + "type": "block-end", + "index": 0, + "block": { + "type": "text", + "text": "回答 53:这是 fixture 生成的历史回复正文。" + } + } + }, + { + "type": "chunk", + "time": 1789265584675, + "chunk": { + "type": "usage", + "usage": { + "inputTokens": 23, + "outputTokens": 8, + "cacheReadTokens": 80, + "cacheWriteTokens": 0 + } + } + }, + { + "type": "chunk", + "time": 1789265584675, + "chunk": { + "type": "finish", + "reason": { + "kind": "stop" + } + } + } + ] + } + } + }, + { + "type": "event", + "event": { + "seq": 386, + "time": 1789265585475, + "type": "step/end", + "data": { + "turn": 53, + "step": 0 + } + } + }, + { + "type": "event", + "event": { + "seq": 387, + "time": 1789265586275, + "type": "turn/end", + "data": { + "turn": 53, + "reason": { + "kind": "completed" + } + } + } + }, + { + "type": "event", + "event": { + "seq": 388, + "time": 1789265587075, + "type": "turn/start", + "data": { + "turn": 54 + } + } + }, + { + "type": "event", + "event": { + "seq": 389, + "time": 1789265587875, + "type": "user/message", + "surfaceOp": "append", + "data": { + "content": [ + { + "type": "text", + "text": "问题 54:fixture 历史消息,用于翻页与渲染验收。" + } + ], + "source": { + "kind": "user" + }, + "role": "user", + "id": "eadaefc3-d83e-471e-ad64-8df6ae40c1eb" + } + } + }, + { + "type": "event", + "event": { + "seq": 390, + "time": 1789265588675, + "type": "step/start", + "data": { + "turn": 54, + "step": 0 + } + } + }, + { + "type": "event", + "event": { + "seq": 391, + "time": 1789265589475, + "type": "assistant/message", + "surfaceOp": "append", + "data": { + "turn": 54, + "step": 0, + "message": { + "role": "assistant", + "content": [ + { + "type": "text", + "text": "回答 54:这是 fixture 生成的历史回复正文。" + } + ], + "source": { + "kind": "model", + "provider": "fixture", + "model": "fx-1" + }, + "id": "b8f5e883-ce1f-42ef-8106-dfa130620801" + }, + "usage": { + "inputTokens": 24, + "outputTokens": 8, + "cacheReadTokens": 80, + "cacheWriteTokens": 0 + }, + "stream": [ + { + "type": "chunk", + "time": 1789265589475, + "chunk": { + "type": "block-start", + "index": 0, + "blockType": "text" + } + }, + { + "type": "chunk", + "time": 1789265589475, + "chunk": { + "type": "block-end", + "index": 0, + "block": { + "type": "text", + "text": "回答 54:这是 fixture 生成的历史回复正文。" + } + } + }, + { + "type": "chunk", + "time": 1789265589475, + "chunk": { + "type": "usage", + "usage": { + "inputTokens": 24, + "outputTokens": 8, + "cacheReadTokens": 80, + "cacheWriteTokens": 0 + } + } + }, + { + "type": "chunk", + "time": 1789265589475, + "chunk": { + "type": "finish", + "reason": { + "kind": "stop" + } + } + } + ] + } + } + }, + { + "type": "event", + "event": { + "seq": 392, + "time": 1789265590275, + "type": "step/end", + "data": { + "turn": 54, + "step": 0 + } + } + }, + { + "type": "event", + "event": { + "seq": 393, + "time": 1789265591075, + "type": "turn/end", + "data": { + "turn": 54, + "reason": { + "kind": "completed" + } + } + } + }, + { + "type": "event", + "event": { + "seq": 394, + "time": 1789265591875, + "type": "turn/start", + "data": { + "turn": 55 + } + } + }, + { + "type": "event", + "event": { + "seq": 395, + "time": 1789265592675, + "type": "user/message", + "surfaceOp": "append", + "data": { + "content": [ + { + "type": "text", + "text": "问题 55:fixture 历史消息,用于翻页与渲染验收。" + } + ], + "source": { + "kind": "user" + }, + "role": "user", + "id": "116535cf-1160-4aff-bd8f-f89e4fc6c41f" + } + } + }, + { + "type": "event", + "event": { + "seq": 396, + "time": 1789265593475, + "type": "step/start", + "data": { + "turn": 55, + "step": 0 + } + } + }, + { + "type": "event", + "event": { + "seq": 397, + "time": 1789265594275, + "type": "assistant/message", + "surfaceOp": "append", + "data": { + "turn": 55, + "step": 0, + "message": { + "role": "assistant", + "content": [ + { + "type": "reasoning", + "text": "思考过程 55:这是一段可折叠的 reasoning 内容。" + }, + { + "type": "text", + "text": "回答 55:这是 fixture 生成的历史回复正文。" + } + ], + "source": { + "kind": "model", + "provider": "fixture", + "model": "fx-1" + }, + "id": "6a80e9b4-ca99-40ef-8de8-facbc8c27b76" + }, + "usage": { + "inputTokens": 20, + "outputTokens": 8, + "cacheReadTokens": 80, + "cacheWriteTokens": 0 + }, + "stream": [ + { + "type": "chunk", + "time": 1789265594275, + "chunk": { + "type": "block-start", + "index": 0, + "blockType": "reasoning" + } + }, + { + "type": "chunk", + "time": 1789265594275, + "chunk": { + "type": "block-end", + "index": 0, + "block": { + "type": "reasoning", + "text": "思考过程 55:这是一段可折叠的 reasoning 内容。" + } + } + }, + { + "type": "chunk", + "time": 1789265594275, + "chunk": { + "type": "block-start", + "index": 1, + "blockType": "text" + } + }, + { + "type": "chunk", + "time": 1789265594275, + "chunk": { + "type": "block-end", + "index": 1, + "block": { + "type": "text", + "text": "回答 55:这是 fixture 生成的历史回复正文。" + } + } + }, + { + "type": "chunk", + "time": 1789265594275, + "chunk": { + "type": "usage", + "usage": { + "inputTokens": 20, + "outputTokens": 8, + "cacheReadTokens": 80, + "cacheWriteTokens": 0 + } + } + }, + { + "type": "chunk", + "time": 1789265594275, + "chunk": { + "type": "finish", + "reason": { + "kind": "stop" + } + } + } + ] + } + } + }, + { + "type": "event", + "event": { + "seq": 398, + "time": 1789265595075, + "type": "step/end", + "data": { + "turn": 55, + "step": 0 + } + } + }, + { + "type": "event", + "event": { + "seq": 399, + "time": 1789265595875, + "type": "turn/end", + "data": { + "turn": 55, + "reason": { + "kind": "completed" + } + } + } + }, + { + "type": "event", + "event": { + "seq": 400, + "time": 1789265596675, + "type": "turn/start", + "data": { + "turn": 56 + } + } + }, + { + "type": "event", + "event": { + "seq": 401, + "time": 1789265597475, + "type": "user/message", + "surfaceOp": "append", + "data": { + "content": [ + { + "type": "text", + "text": "问题 56:fixture 历史消息,用于翻页与渲染验收。" + } + ], + "source": { + "kind": "user" + }, + "role": "user", + "id": "093791f8-cb5b-4106-853d-306ed532feec" + } + } + }, + { + "type": "event", + "event": { + "seq": 402, + "time": 1789265598275, + "type": "step/start", + "data": { + "turn": 56, + "step": 0 + } + } + }, + { + "type": "event", + "event": { + "seq": 403, + "time": 1789265599075, + "type": "assistant/message", + "surfaceOp": "append", + "data": { + "turn": 56, + "step": 0, + "message": { + "role": "assistant", + "content": [ + { + "type": "text", + "text": "回答 56:这是 fixture 生成的历史回复正文。" + } + ], + "source": { + "kind": "model", + "provider": "fixture", + "model": "fx-1" + }, + "id": "bc0ebb05-d212-4f30-a3eb-c4c209ff7103" + }, + "usage": { + "inputTokens": 21, + "outputTokens": 8, + "cacheReadTokens": 80, + "cacheWriteTokens": 0 + }, + "stream": [ + { + "type": "chunk", + "time": 1789265599075, + "chunk": { + "type": "block-start", + "index": 0, + "blockType": "text" + } + }, + { + "type": "chunk", + "time": 1789265599075, + "chunk": { + "type": "block-end", + "index": 0, + "block": { + "type": "text", + "text": "回答 56:这是 fixture 生成的历史回复正文。" + } + } + }, + { + "type": "chunk", + "time": 1789265599075, + "chunk": { + "type": "usage", + "usage": { + "inputTokens": 21, + "outputTokens": 8, + "cacheReadTokens": 80, + "cacheWriteTokens": 0 + } + } + }, + { + "type": "chunk", + "time": 1789265599075, + "chunk": { + "type": "finish", + "reason": { + "kind": "stop" + } + } + } + ] + } + } + }, + { + "type": "event", + "event": { + "seq": 404, + "time": 1789265599875, + "type": "step/end", + "data": { + "turn": 56, + "step": 0 + } + } + }, + { + "type": "event", + "event": { + "seq": 405, + "time": 1789265600675, + "type": "turn/end", + "data": { + "turn": 56, + "reason": { + "kind": "completed" + } + } + } + }, + { + "type": "event", + "event": { + "seq": 406, + "time": 1789265601475, + "type": "turn/start", + "data": { + "turn": 57 + } + } + }, + { + "type": "event", + "event": { + "seq": 407, + "time": 1789265602275, + "type": "user/message", + "surfaceOp": "append", + "data": { + "content": [ + { + "type": "text", + "text": "问题 57:fixture 历史消息,用于翻页与渲染验收。" + } + ], + "source": { + "kind": "user" + }, + "role": "user", + "id": "4196af12-5f06-4244-a83e-281b0e2e2565" + } + } + }, + { + "type": "event", + "event": { + "seq": 408, + "time": 1789265603075, + "type": "step/start", + "data": { + "turn": 57, + "step": 0 + } + } + }, + { + "type": "event", + "event": { + "seq": 409, + "time": 1789265603875, + "type": "assistant/message", + "surfaceOp": "append", + "data": { + "turn": 57, + "step": 0, + "message": { + "role": "assistant", + "content": [ + { + "type": "text", + "text": "回答 57:这是 fixture 生成的历史回复正文。" + }, + { + "type": "tool-call", + "id": "fx-call-57", + "name": "echo", + "arguments": "{\"text\":\"turn 57\"}" + } + ], + "source": { + "kind": "model", + "provider": "fixture", + "model": "fx-1" + }, + "id": "dc114329-e3f4-43a4-bbd7-3524203a99e9" + }, + "usage": { + "inputTokens": 22, + "outputTokens": 8, + "cacheReadTokens": 80, + "cacheWriteTokens": 0 + }, + "stream": [ + { + "type": "chunk", + "time": 1789265603875, + "chunk": { + "type": "block-start", + "index": 0, + "blockType": "text" + } + }, + { + "type": "chunk", + "time": 1789265603875, + "chunk": { + "type": "block-end", + "index": 0, + "block": { + "type": "text", + "text": "回答 57:这是 fixture 生成的历史回复正文。" + } + } + }, + { + "type": "chunk", + "time": 1789265603875, + "chunk": { + "type": "block-start", + "index": 1, + "blockType": "tool-call" + } + }, + { + "type": "chunk", + "time": 1789265603875, + "chunk": { + "type": "block-end", + "index": 1, + "block": { + "type": "tool-call", + "id": "fx-call-57", + "name": "echo", + "arguments": "{\"text\":\"turn 57\"}" + } + } + }, + { + "type": "chunk", + "time": 1789265603875, + "chunk": { + "type": "usage", + "usage": { + "inputTokens": 22, + "outputTokens": 8, + "cacheReadTokens": 80, + "cacheWriteTokens": 0 + } + } + }, + { + "type": "chunk", + "time": 1789265603875, + "chunk": { + "type": "finish", + "reason": { + "kind": "stop" + } + } + } + ] + } + } + }, + { + "type": "event", + "event": { + "seq": 410, + "time": 1789265604675, + "type": "tool/call", + "data": { + "turn": 57, + "step": 0, + "callId": "fx-call-57", + "name": "echo", + "arguments": "{\"text\":\"turn 57\"}" + } + } + }, + { + "type": "event", + "event": { + "seq": 411, + "time": 1789265605475, + "type": "tool/result", + "surfaceOp": "append", + "data": { + "turn": 57, + "step": 0, + "message": { + "source": { + "kind": "tool", + "callId": "fx-call-57" + }, + "content": [ + { + "type": "tool-result", + "toolCallId": "fx-call-57", + "content": [ + { + "type": "text", + "text": "ECHO: TURN 57" + } + ], + "isError": false + } + ], + "role": "user", + "id": "652931ce-6eb0-4400-8173-9735b82fdf8e" + } + } + } + }, + { + "type": "event", + "event": { + "seq": 412, + "time": 1789265606275, + "type": "step/end", + "data": { + "turn": 57, + "step": 0 + } + } + }, + { + "type": "event", + "event": { + "seq": 413, + "time": 1789265607075, + "type": "step/start", + "data": { + "turn": 57, + "step": 1 + } + } + }, + { + "type": "event", + "event": { + "seq": 414, + "time": 1789265607875, + "type": "assistant/message", + "surfaceOp": "append", + "data": { + "turn": 57, + "step": 1, + "message": { + "role": "assistant", + "content": [ + { + "type": "text", + "text": "工具结果已消化(turn 57)。" + } + ], + "source": { + "kind": "model", + "provider": "fixture", + "model": "fx-1" + }, + "id": "02030b82-e0d9-4e8f-a17c-bfeaa78a74d0" + }, + "usage": { + "inputTokens": 22, + "outputTokens": 9, + "cacheReadTokens": 80, + "cacheWriteTokens": 0 + }, + "stream": [ + { + "type": "chunk", + "time": 1789265607875, + "chunk": { + "type": "block-start", + "index": 0, + "blockType": "text" + } + }, + { + "type": "chunk", + "time": 1789265607875, + "chunk": { + "type": "block-end", + "index": 0, + "block": { + "type": "text", + "text": "工具结果已消化(turn 57)。" + } + } + }, + { + "type": "chunk", + "time": 1789265607875, + "chunk": { + "type": "usage", + "usage": { + "inputTokens": 22, + "outputTokens": 9, + "cacheReadTokens": 80, + "cacheWriteTokens": 0 + } + } + }, + { + "type": "chunk", + "time": 1789265607875, + "chunk": { + "type": "finish", + "reason": { + "kind": "stop" + } + } + } + ] + } + } + }, + { + "type": "event", + "event": { + "seq": 415, + "time": 1789265608675, + "type": "step/end", + "data": { + "turn": 57, + "step": 1 + } + } + }, + { + "type": "event", + "event": { + "seq": 416, + "time": 1789265609475, + "type": "turn/end", + "data": { + "turn": 57, + "reason": { + "kind": "completed" + } + } + } + }, + { + "type": "event", + "event": { + "seq": 417, + "time": 1789265610275, + "type": "turn/start", + "data": { + "turn": 58 + } + } + }, + { + "type": "event", + "event": { + "seq": 418, + "time": 1789265611075, + "type": "user/message", + "surfaceOp": "append", + "data": { + "content": [ + { + "type": "text", + "text": "问题 58:fixture 历史消息,用于翻页与渲染验收。" + } + ], + "source": { + "kind": "user" + }, + "role": "user", + "id": "d08478d2-30ac-49fa-b3ca-f71e8d4517ee" + } + } + }, + { + "type": "event", + "event": { + "seq": 419, + "time": 1789265611875, + "type": "user/message", + "surfaceOp": "append", + "data": { + "content": [ + { + "type": "text", + "text": "[fixture] 上下文注入(turn 58)" + } + ], + "source": { + "kind": "plugin", + "plugin": "fixture" + }, + "role": "user", + "id": "cbb94cfa-48f7-453b-9825-e61432572399" + } + } + }, + { + "type": "event", + "event": { + "seq": 420, + "time": 1789265612675, + "type": "step/start", + "data": { + "turn": 58, + "step": 0 + } + } + }, + { + "type": "event", + "event": { + "seq": 421, + "time": 1789265613475, + "type": "assistant/message", + "surfaceOp": "append", + "data": { + "turn": 58, + "step": 0, + "message": { + "role": "assistant", + "content": [ + { + "type": "reasoning", + "text": "思考过程 58:这是一段可折叠的 reasoning 内容。" + }, + { + "type": "text", + "text": "回答 58:这是 fixture 生成的历史回复正文。" + } + ], + "source": { + "kind": "model", + "provider": "fixture", + "model": "fx-1" + }, + "id": "271e5020-8deb-418e-a4c2-d6609f31aa60" + }, + "usage": { + "inputTokens": 23, + "outputTokens": 8, + "cacheReadTokens": 80, + "cacheWriteTokens": 0 + }, + "stream": [ + { + "type": "chunk", + "time": 1789265613475, + "chunk": { + "type": "block-start", + "index": 0, + "blockType": "reasoning" + } + }, + { + "type": "chunk", + "time": 1789265613475, + "chunk": { + "type": "block-end", + "index": 0, + "block": { + "type": "reasoning", + "text": "思考过程 58:这是一段可折叠的 reasoning 内容。" + } + } + }, + { + "type": "chunk", + "time": 1789265613475, + "chunk": { + "type": "block-start", + "index": 1, + "blockType": "text" + } + }, + { + "type": "chunk", + "time": 1789265613475, + "chunk": { + "type": "block-end", + "index": 1, + "block": { + "type": "text", + "text": "回答 58:这是 fixture 生成的历史回复正文。" + } + } + }, + { + "type": "chunk", + "time": 1789265613475, + "chunk": { + "type": "usage", + "usage": { + "inputTokens": 23, + "outputTokens": 8, + "cacheReadTokens": 80, + "cacheWriteTokens": 0 + } + } + }, + { + "type": "chunk", + "time": 1789265613475, + "chunk": { + "type": "finish", + "reason": { + "kind": "stop" + } + } + } + ] + } + } + }, + { + "type": "event", + "event": { + "seq": 422, + "time": 1789265614275, + "type": "step/end", + "data": { + "turn": 58, + "step": 0 + } + } + }, + { + "type": "event", + "event": { + "seq": 423, + "time": 1789265615075, + "type": "turn/end", + "data": { + "turn": 58, + "reason": { + "kind": "completed" + } + } + } + }, + { + "type": "event", + "event": { + "seq": 424, + "time": 1789265615875, + "type": "turn/start", + "data": { + "turn": 59 + } + } + }, + { + "type": "event", + "event": { + "seq": 425, + "time": 1789265616675, + "type": "user/message", + "surfaceOp": "append", + "data": { + "content": [ + { + "type": "text", + "text": "用户字面量:# 不渲染 `code` [link](https://example.com)" + } + ], + "source": { + "kind": "user" + }, + "role": "user", + "id": "14861e74-05a9-4e2b-b98a-c907999daa42" + } + } + }, + { + "type": "event", + "event": { + "seq": 426, + "time": 1789265617475, + "type": "step/start", + "data": { + "turn": 59, + "step": 0 + } + } + }, + { + "type": "event", + "event": { + "seq": 427, + "time": 1789265618275, + "type": "assistant/message", + "surfaceOp": "append", + "data": { + "turn": 59, + "step": 0, + "message": { + "role": "assistant", + "content": [ + { + "type": "text", + "text": "# Markdown fixture\n\nAssistant output renders **strong text**, *emphasis*, and `inline code`.\n\n- first item\n - nested item\n\n| Area | State |\n| --- | --- |\n| history | rendered |\n| streaming | stable |\n\n[DeepSeek](https://www.deepseek.com)\n\n```ts\nconst markdown = true\n```" + } + ], + "source": { + "kind": "model", + "provider": "fixture", + "model": "fx-1" + }, + "id": "07a55bdb-ff08-40b3-8922-0422a2f04a87" + }, + "usage": { + "inputTokens": 24, + "outputTokens": 8, + "cacheReadTokens": 80, + "cacheWriteTokens": 0 + }, + "stream": [ + { + "type": "chunk", + "time": 1789265618275, + "chunk": { + "type": "block-start", + "index": 0, + "blockType": "text" + } + }, + { + "type": "chunk", + "time": 1789265618275, + "chunk": { + "type": "block-end", + "index": 0, + "block": { + "type": "text", + "text": "# Markdown fixture\n\nAssistant output renders **strong text**, *emphasis*, and `inline code`.\n\n- first item\n - nested item\n\n| Area | State |\n| --- | --- |\n| history | rendered |\n| streaming | stable |\n\n[DeepSeek](https://www.deepseek.com)\n\n```ts\nconst markdown = true\n```" + } + } + }, + { + "type": "chunk", + "time": 1789265618275, + "chunk": { + "type": "usage", + "usage": { + "inputTokens": 24, + "outputTokens": 8, + "cacheReadTokens": 80, + "cacheWriteTokens": 0 + } + } + }, + { + "type": "chunk", + "time": 1789265618275, + "chunk": { + "type": "finish", + "reason": { + "kind": "stop" + } + } + } + ] + } + } + }, + { + "type": "event", + "event": { + "seq": 428, + "time": 1789265619075, + "type": "step/end", + "data": { + "turn": 59, + "step": 0 + } + } + }, + { + "type": "event", + "event": { + "seq": 429, + "time": 1789265619875, + "type": "turn/end", + "data": { + "turn": 59, + "reason": { + "kind": "completed" + } + } + } + }, + { + "type": "event", + "event": { + "seq": 430, + "time": 1789265620675, + "type": "turn/start", + "data": { + "turn": 60 + } + } + }, + { + "type": "event", + "event": { + "seq": 431, + "time": 1789265621475, + "type": "user/message", + "surfaceOp": "append", + "data": { + "content": [ + { + "type": "text", + "text": "问题 60:bash 样本。" + } + ], + "source": { + "kind": "user" + }, + "role": "user", + "id": "de206a9a-a621-435f-88f0-9613ff152eb5" + } + } + }, + { + "type": "event", + "event": { + "seq": 432, + "time": 1789265622275, + "type": "step/start", + "data": { + "turn": 60, + "step": 0 + } + } + }, + { + "type": "event", + "event": { + "seq": 433, + "time": 1789265623075, + "type": "assistant/message", + "surfaceOp": "append", + "data": { + "turn": 60, + "step": 0, + "message": { + "role": "assistant", + "content": [ + { + "type": "tool-call", + "id": "fx-call-60", + "name": "bash", + "arguments": "{\"command\":\"ls -la\\necho done\",\"description\":\"fixture 终端样本\",\"workdir\":\"/tmp/fixture\"}" + } + ], + "source": { + "kind": "model", + "provider": "fixture", + "model": "fx-1" + }, + "id": "4d338006-4348-4002-8a03-c730802f6f51" + }, + "usage": { + "inputTokens": 20, + "outputTokens": 8, + "cacheReadTokens": 80, + "cacheWriteTokens": 4 + }, + "stream": [ + { + "type": "chunk", + "time": 1789265623075, + "chunk": { + "type": "block-start", + "index": 0, + "blockType": "tool-call" + } + }, + { + "type": "chunk", + "time": 1789265623075, + "chunk": { + "type": "block-end", + "index": 0, + "block": { + "type": "tool-call", + "id": "fx-call-60", + "name": "bash", + "arguments": "{\"command\":\"ls -la\\necho done\",\"description\":\"fixture 终端样本\",\"workdir\":\"/tmp/fixture\"}" + } + } + }, + { + "type": "chunk", + "time": 1789265623075, + "chunk": { + "type": "usage", + "usage": { + "inputTokens": 20, + "outputTokens": 8, + "cacheReadTokens": 80, + "cacheWriteTokens": 4 + } + } + }, + { + "type": "chunk", + "time": 1789265623075, + "chunk": { + "type": "finish", + "reason": { + "kind": "stop" + } + } + } + ] + } + } + }, + { + "type": "event", + "event": { + "seq": 434, + "time": 1789265623875, + "type": "tool/call", + "data": { + "turn": 60, + "step": 0, + "callId": "fx-call-60", + "name": "bash", + "arguments": "{\"command\":\"ls -la\\necho done\",\"description\":\"fixture 终端样本\",\"workdir\":\"/tmp/fixture\"}" + } + } + }, + { + "type": "event", + "event": { + "seq": 435, + "time": 1789265624675, + "type": "tool/result", + "surfaceOp": "append", + "data": { + "turn": 60, + "step": 0, + "message": { + "source": { + "kind": "tool", + "callId": "fx-call-60" + }, + "content": [ + { + "type": "tool-result", + "toolCallId": "fx-call-60", + "content": [ + { + "type": "text", + "text": "total 2\ndrwxr-xr-x fixture\n-rw-r--r-- demo.txt" + } + ], + "isError": false + } + ], + "role": "user", + "id": "aa4c1898-7bec-4831-8ba1-0700f4ae897e" + } + } + } + }, + { + "type": "event", + "event": { + "seq": 436, + "time": 1789265625475, + "type": "step/end", + "data": { + "turn": 60, + "step": 0 + } + } + }, + { + "type": "event", + "event": { + "seq": 437, + "time": 1789265626275, + "type": "turn/end", + "data": { + "turn": 60, + "reason": { + "kind": "completed" + } + } + } + }, + { + "type": "event", + "event": { + "seq": 438, + "time": 1789265627075, + "type": "turn/start", + "data": { + "turn": 61 + } + } + }, + { + "type": "event", + "event": { + "seq": 439, + "time": 1789265627875, + "type": "user/message", + "surfaceOp": "append", + "data": { + "content": [ + { + "type": "text", + "text": "问题 61:write 样本。" + } + ], + "source": { + "kind": "user" + }, + "role": "user", + "id": "6fd7a2c3-2ead-41a0-b954-21883b4bc03d" + } + } + }, + { + "type": "event", + "event": { + "seq": 440, + "time": 1789265628675, + "type": "step/start", + "data": { + "turn": 61, + "step": 0 + } + } + }, + { + "type": "event", + "event": { + "seq": 441, + "time": 1789265629475, + "type": "assistant/message", + "surfaceOp": "append", + "data": { + "turn": 61, + "step": 0, + "message": { + "role": "assistant", + "content": [ + { + "type": "tool-call", + "id": "fx-call-61", + "name": "write", + "arguments": "{\"file_path\":\"notes/demo.txt\",\"content\":\"hello fixture\\n\"}" + } + ], + "source": { + "kind": "model", + "provider": "fixture", + "model": "fx-1" + }, + "id": "a3c1b7d4-200f-49b7-b550-bbc5ad98dbc9" + }, + "usage": { + "inputTokens": 21, + "outputTokens": 8, + "cacheReadTokens": 80, + "cacheWriteTokens": 0 + }, + "stream": [ + { + "type": "chunk", + "time": 1789265629475, + "chunk": { + "type": "block-start", + "index": 0, + "blockType": "tool-call" + } + }, + { + "type": "chunk", + "time": 1789265629475, + "chunk": { + "type": "block-end", + "index": 0, + "block": { + "type": "tool-call", + "id": "fx-call-61", + "name": "write", + "arguments": "{\"file_path\":\"notes/demo.txt\",\"content\":\"hello fixture\\n\"}" + } + } + }, + { + "type": "chunk", + "time": 1789265629475, + "chunk": { + "type": "usage", + "usage": { + "inputTokens": 21, + "outputTokens": 8, + "cacheReadTokens": 80, + "cacheWriteTokens": 0 + } + } + }, + { + "type": "chunk", + "time": 1789265629475, + "chunk": { + "type": "finish", + "reason": { + "kind": "stop" + } + } + } + ] + } + } + }, + { + "type": "event", + "event": { + "seq": 442, + "time": 1789265630275, + "type": "tool/call", + "data": { + "turn": 61, + "step": 0, + "callId": "fx-call-61", + "name": "write", + "arguments": "{\"file_path\":\"notes/demo.txt\",\"content\":\"hello fixture\\n\"}" + } + } + }, + { + "type": "event", + "event": { + "seq": 443, + "time": 1789265631075, + "type": "tool/result", + "surfaceOp": "append", + "data": { + "turn": 61, + "step": 0, + "message": { + "source": { + "kind": "tool", + "callId": "fx-call-61" + }, + "content": [ + { + "type": "tool-result", + "toolCallId": "fx-call-61", + "content": [ + { + "type": "text", + "text": "wrote notes/demo.txt" + } + ], + "isError": false + } + ], + "role": "user", + "id": "4eedd943-773b-43d3-a104-e13550015670" + }, + "meta": { + "diffs": [ + { + "path": "notes/demo.txt", + "oldText": null, + "newText": "hello fixture\n" + } + ] + } + } + } + }, + { + "type": "event", + "event": { + "seq": 444, + "time": 1789265631875, + "type": "step/end", + "data": { + "turn": 61, + "step": 0 + } + } + }, + { + "type": "event", + "event": { + "seq": 445, + "time": 1789265632675, + "type": "turn/end", + "data": { + "turn": 61, + "reason": { + "kind": "completed" + } + } + } + }, + { + "type": "event", + "event": { + "seq": 446, + "time": 1789265633475, + "type": "turn/start", + "data": { + "turn": 62 + } + } + }, + { + "type": "event", + "event": { + "seq": 447, + "time": 1789265634275, + "type": "user/message", + "surfaceOp": "append", + "data": { + "content": [ + { + "type": "text", + "text": "问题 62:edit 样本。" + } + ], + "source": { + "kind": "user" + }, + "role": "user", + "id": "8f4e6682-2963-4daa-a912-84ff053ac32e" + } + } + }, + { + "type": "event", + "event": { + "seq": 448, + "time": 1789265635075, + "type": "step/start", + "data": { + "turn": 62, + "step": 0 + } + } + }, + { + "type": "event", + "event": { + "seq": 449, + "time": 1789265635875, + "type": "assistant/message", + "surfaceOp": "append", + "data": { + "turn": 62, + "step": 0, + "message": { + "role": "assistant", + "content": [ + { + "type": "tool-call", + "id": "fx-call-62", + "name": "edit", + "arguments": "{\"file_path\":\"notes/demo.txt\",\"old_string\":\"hello\",\"new_string\":\"hello fixture\"}" + } + ], + "source": { + "kind": "model", + "provider": "fixture", + "model": "fx-1" + }, + "id": "f1b3b777-d4ec-4b33-80ac-fa4e9d87885a" + }, + "usage": { + "inputTokens": 22, + "outputTokens": 8, + "cacheReadTokens": 80, + "cacheWriteTokens": 0 + }, + "stream": [ + { + "type": "chunk", + "time": 1789265635875, + "chunk": { + "type": "block-start", + "index": 0, + "blockType": "tool-call" + } + }, + { + "type": "chunk", + "time": 1789265635875, + "chunk": { + "type": "block-end", + "index": 0, + "block": { + "type": "tool-call", + "id": "fx-call-62", + "name": "edit", + "arguments": "{\"file_path\":\"notes/demo.txt\",\"old_string\":\"hello\",\"new_string\":\"hello fixture\"}" + } + } + }, + { + "type": "chunk", + "time": 1789265635875, + "chunk": { + "type": "usage", + "usage": { + "inputTokens": 22, + "outputTokens": 8, + "cacheReadTokens": 80, + "cacheWriteTokens": 0 + } + } + }, + { + "type": "chunk", + "time": 1789265635875, + "chunk": { + "type": "finish", + "reason": { + "kind": "stop" + } + } + } + ] + } + } + }, + { + "type": "event", + "event": { + "seq": 450, + "time": 1789265636675, + "type": "tool/call", + "data": { + "turn": 62, + "step": 0, + "callId": "fx-call-62", + "name": "edit", + "arguments": "{\"file_path\":\"notes/demo.txt\",\"old_string\":\"hello\",\"new_string\":\"hello fixture\"}" + } + } + }, + { + "type": "event", + "event": { + "seq": 451, + "time": 1789265637475, + "type": "tool/result", + "surfaceOp": "append", + "data": { + "turn": 62, + "step": 0, + "message": { + "source": { + "kind": "tool", + "callId": "fx-call-62" + }, + "content": [ + { + "type": "tool-result", + "toolCallId": "fx-call-62", + "content": [ + { + "type": "text", + "text": "已编辑" + } + ], + "isError": false + } + ], + "role": "user", + "id": "37d72542-2cbc-4821-8897-329da97fe462" + }, + "meta": { + "diffs": [ + { + "path": "notes/demo.txt", + "oldText": "hello", + "newText": "hello fixture" + } + ] + } + } + } + }, + { + "type": "event", + "event": { + "seq": 452, + "time": 1789265638275, + "type": "step/end", + "data": { + "turn": 62, + "step": 0 + } + } + }, + { + "type": "event", + "event": { + "seq": 453, + "time": 1789265639075, + "type": "turn/end", + "data": { + "turn": 62, + "reason": { + "kind": "completed" + } + } + } + }, + { + "type": "event", + "event": { + "seq": 454, + "time": 1789265639875, + "type": "turn/start", + "data": { + "turn": 63 + } + } + }, + { + "type": "event", + "event": { + "seq": 455, + "time": 1789265640675, + "type": "user/message", + "surfaceOp": "append", + "data": { + "content": [ + { + "type": "text", + "text": "问题 63:write 样本。" + } + ], + "source": { + "kind": "user" + }, + "role": "user", + "id": "8e09ed22-fb26-4b3a-8ef5-1e2ac363e0f8" + } + } + }, + { + "type": "event", + "event": { + "seq": 456, + "time": 1789265641475, + "type": "step/start", + "data": { + "turn": 63, + "step": 0 + } + } + }, + { + "type": "event", + "event": { + "seq": 457, + "time": 1789265642275, + "type": "assistant/message", + "surfaceOp": "append", + "data": { + "turn": 63, + "step": 0, + "message": { + "role": "assistant", + "content": [ + { + "type": "tool-call", + "id": "fx-call-63", + "name": "write", + "arguments": "{\"file_path\":\"notes/new-demo.txt\",\"content\":\"hello fixture\\n\"}" + } + ], + "source": { + "kind": "model", + "provider": "fixture", + "model": "fx-1" + }, + "id": "65f7aa11-c436-4b13-8715-63437a194d29" + }, + "usage": { + "inputTokens": 23, + "outputTokens": 8, + "cacheReadTokens": 80, + "cacheWriteTokens": 0 + }, + "stream": [ + { + "type": "chunk", + "time": 1789265642275, + "chunk": { + "type": "block-start", + "index": 0, + "blockType": "tool-call" + } + }, + { + "type": "chunk", + "time": 1789265642275, + "chunk": { + "type": "block-end", + "index": 0, + "block": { + "type": "tool-call", + "id": "fx-call-63", + "name": "write", + "arguments": "{\"file_path\":\"notes/new-demo.txt\",\"content\":\"hello fixture\\n\"}" + } + } + }, + { + "type": "chunk", + "time": 1789265642275, + "chunk": { + "type": "usage", + "usage": { + "inputTokens": 23, + "outputTokens": 8, + "cacheReadTokens": 80, + "cacheWriteTokens": 0 + } + } + }, + { + "type": "chunk", + "time": 1789265642275, + "chunk": { + "type": "finish", + "reason": { + "kind": "stop" + } + } + } + ] + } + } + }, + { + "type": "event", + "event": { + "seq": 458, + "time": 1789265643075, + "type": "tool/call", + "data": { + "turn": 63, + "step": 0, + "callId": "fx-call-63", + "name": "write", + "arguments": "{\"file_path\":\"notes/new-demo.txt\",\"content\":\"hello fixture\\n\"}" + } + } + }, + { + "type": "event", + "event": { + "seq": 459, + "time": 1789265643875, + "type": "tool/result", + "surfaceOp": "append", + "data": { + "turn": 63, + "step": 0, + "message": { + "source": { + "kind": "tool", + "callId": "fx-call-63" + }, + "content": [ + { + "type": "tool-result", + "toolCallId": "fx-call-63", + "content": [ + { + "type": "text", + "text": "已写入" + } + ], + "isError": false + } + ], + "role": "user", + "id": "0d0c76cf-f5b2-4f77-97cb-b0b66a67d135" + }, + "meta": { + "diffs": [ + { + "path": "notes/new-demo.txt", + "oldText": null, + "newText": "hello fixture\n" + } + ] + } + } + } + }, + { + "type": "event", + "event": { + "seq": 460, + "time": 1789265644675, + "type": "step/end", + "data": { + "turn": 63, + "step": 0 + } + } + }, + { + "type": "event", + "event": { + "seq": 461, + "time": 1789265645475, + "type": "turn/end", + "data": { + "turn": 63, + "reason": { + "kind": "completed" + } + } + } + }, + { + "type": "event", + "event": { + "seq": 462, + "time": 1789265646275, + "type": "turn/start", + "data": { + "turn": 64 + } + } + }, + { + "type": "event", + "event": { + "seq": 463, + "time": 1789265647075, + "type": "user/message", + "surfaceOp": "append", + "data": { + "content": [ + { + "type": "text", + "text": "问题 64:edit 样本。" + } + ], + "source": { + "kind": "user" + }, + "role": "user", + "id": "99fcaad3-6e35-4e4f-a359-f97c673c72a2" + } + } + }, + { + "type": "event", + "event": { + "seq": 464, + "time": 1789265647875, + "type": "step/start", + "data": { + "turn": 64, + "step": 0 + } + } + }, + { + "type": "event", + "event": { + "seq": 465, + "time": 1789265648675, + "type": "assistant/message", + "surfaceOp": "append", + "data": { + "turn": 64, + "step": 0, + "message": { + "role": "assistant", + "content": [ + { + "type": "tool-call", + "id": "fx-call-64", + "name": "edit", + "arguments": "{\"file_path\":\"src/config.ts\",\"old_string\":\"const timeout = 30\",\"new_string\":\"const timeout = 60\"}" + } + ], + "source": { + "kind": "model", + "provider": "fixture", + "model": "fx-1" + }, + "id": "e0ae34f7-02f8-4093-a01b-e92d87fcbc3c" + }, + "usage": { + "inputTokens": 24, + "outputTokens": 8, + "cacheReadTokens": 80, + "cacheWriteTokens": 0 + }, + "stream": [ + { + "type": "chunk", + "time": 1789265648675, + "chunk": { + "type": "block-start", + "index": 0, + "blockType": "tool-call" + } + }, + { + "type": "chunk", + "time": 1789265648675, + "chunk": { + "type": "block-end", + "index": 0, + "block": { + "type": "tool-call", + "id": "fx-call-64", + "name": "edit", + "arguments": "{\"file_path\":\"src/config.ts\",\"old_string\":\"const timeout = 30\",\"new_string\":\"const timeout = 60\"}" + } + } + }, + { + "type": "chunk", + "time": 1789265648675, + "chunk": { + "type": "usage", + "usage": { + "inputTokens": 24, + "outputTokens": 8, + "cacheReadTokens": 80, + "cacheWriteTokens": 0 + } + } + }, + { + "type": "chunk", + "time": 1789265648675, + "chunk": { + "type": "finish", + "reason": { + "kind": "stop" + } + } + } + ] + } + } + }, + { + "type": "event", + "event": { + "seq": 466, + "time": 1789265649475, + "type": "tool/call", + "data": { + "turn": 64, + "step": 0, + "callId": "fx-call-64", + "name": "edit", + "arguments": "{\"file_path\":\"src/config.ts\",\"old_string\":\"const timeout = 30\",\"new_string\":\"const timeout = 60\"}" + } + } + }, + { + "type": "event", + "event": { + "seq": 467, + "time": 1789265650275, + "type": "tool/result", + "surfaceOp": "append", + "data": { + "turn": 64, + "step": 0, + "message": { + "source": { + "kind": "tool", + "callId": "fx-call-64" + }, + "content": [ + { + "type": "tool-result", + "toolCallId": "fx-call-64", + "content": [ + { + "type": "text", + "text": "已编辑" + } + ], + "isError": false + } + ], + "role": "user", + "id": "11a489c7-466c-45ba-a5de-228fecd455f2" + }, + "meta": { + "diffs": [ + { + "path": "src/config.ts", + "oldText": "const timeout = 30", + "newText": "const timeout = 60" + }, + { + "path": "src/config.ts", + "oldText": "retries: 1", + "newText": "retries: 3" + } + ] + } + } + } + }, + { + "type": "event", + "event": { + "seq": 468, + "time": 1789265651075, + "type": "step/end", + "data": { + "turn": 64, + "step": 0 + } + } + }, + { + "type": "event", + "event": { + "seq": 469, + "time": 1789265651875, + "type": "turn/end", + "data": { + "turn": 64, + "reason": { + "kind": "completed" + } + } + } + }, + { + "type": "event", + "event": { + "seq": 470, + "time": 1789265652675, + "type": "turn/start", + "data": { + "turn": 65 + } + } + }, + { + "type": "event", + "event": { + "seq": 471, + "time": 1789265653475, + "type": "user/message", + "surfaceOp": "append", + "data": { + "content": [ + { + "type": "text", + "text": "问题 65:run_code 样本。" + } + ], + "source": { + "kind": "user" + }, + "role": "user", + "id": "350becdf-2087-439b-be5e-cb19abf1dfff" + } + } + }, + { + "type": "event", + "event": { + "seq": 472, + "time": 1789265654275, + "type": "step/start", + "data": { + "turn": 65, + "step": 0 + } + } + }, + { + "type": "event", + "event": { + "seq": 473, + "time": 1789265655075, + "type": "assistant/message", + "surfaceOp": "append", + "data": { + "turn": 65, + "step": 0, + "message": { + "role": "assistant", + "content": [ + { + "type": "tool-call", + "id": "fx-call-65", + "name": "run_code", + "arguments": "{\"code\":\"const listing = await tools.bash({ command: \\\"ls notes\\\", description: \\\"List notes\\\" })\\nconst demo = await tools.read({ file_path: \\\"notes/demo.txt\\\" })\\nawait tools.read({ file_path: \\\"notes/missing.txt\\\" }).catch(() => \\\"tolerated\\\")\\nreturn { listing, demo }\",\"description\":\"Read the notes files and summarize\"}" + } + ], + "source": { + "kind": "model", + "provider": "fixture", + "model": "fx-1" + }, + "id": "4af251a1-bd9f-4e7a-a7e8-9d4ac6f0f8e5" + }, + "usage": { + "inputTokens": 20, + "outputTokens": 8, + "cacheReadTokens": 80, + "cacheWriteTokens": 0 + }, + "stream": [ + { + "type": "chunk", + "time": 1789265655075, + "chunk": { + "type": "block-start", + "index": 0, + "blockType": "tool-call" + } + }, + { + "type": "chunk", + "time": 1789265655075, + "chunk": { + "type": "block-end", + "index": 0, + "block": { + "type": "tool-call", + "id": "fx-call-65", + "name": "run_code", + "arguments": "{\"code\":\"const listing = await tools.bash({ command: \\\"ls notes\\\", description: \\\"List notes\\\" })\\nconst demo = await tools.read({ file_path: \\\"notes/demo.txt\\\" })\\nawait tools.read({ file_path: \\\"notes/missing.txt\\\" }).catch(() => \\\"tolerated\\\")\\nreturn { listing, demo }\",\"description\":\"Read the notes files and summarize\"}" + } + } + }, + { + "type": "chunk", + "time": 1789265655075, + "chunk": { + "type": "usage", + "usage": { + "inputTokens": 20, + "outputTokens": 8, + "cacheReadTokens": 80, + "cacheWriteTokens": 0 + } + } + }, + { + "type": "chunk", + "time": 1789265655075, + "chunk": { + "type": "finish", + "reason": { + "kind": "stop" + } + } + } + ] + } + } + }, + { + "type": "event", + "event": { + "seq": 474, + "time": 1789265655875, + "type": "tool/call", + "data": { + "turn": 65, + "step": 0, + "callId": "fx-call-65", + "name": "run_code", + "arguments": "{\"code\":\"const listing = await tools.bash({ command: \\\"ls notes\\\", description: \\\"List notes\\\" })\\nconst demo = await tools.read({ file_path: \\\"notes/demo.txt\\\" })\\nawait tools.read({ file_path: \\\"notes/missing.txt\\\" }).catch(() => \\\"tolerated\\\")\\nreturn { listing, demo }\",\"description\":\"Read the notes files and summarize\"}" + } + } + }, + { + "type": "event", + "event": { + "seq": 475, + "time": 1789265656675, + "type": "tool/ptc-dispatch-start", + "data": { + "rootCallId": "fx-call-65", + "parentCallId": "fx-call-65", + "subCallId": "fx-call-65:ptc:1", + "name": "bash", + "arguments": { + "command": "ls notes", + "description": "List notes" + } + } + } + }, + { + "type": "event", + "event": { + "seq": 476, + "time": 1789265657475, + "type": "tool/ptc-dispatch", + "data": { + "rootCallId": "fx-call-65", + "parentCallId": "fx-call-65", + "subCallId": "fx-call-65:ptc:1", + "name": "bash", + "arguments": { + "command": "ls notes", + "description": "List notes" + }, + "isError": false, + "content": [ + { + "type": "text", + "text": "demo.txt\nnew-demo.txt" + } + ] + } + } + }, + { + "type": "event", + "event": { + "seq": 477, + "time": 1789265658275, + "type": "tool/ptc-dispatch-start", + "data": { + "rootCallId": "fx-call-65", + "parentCallId": "fx-call-65", + "subCallId": "fx-call-65:ptc:2", + "name": "read", + "arguments": { + "file_path": "notes/demo.txt" + } + } + } + }, + { + "type": "event", + "event": { + "seq": 478, + "time": 1789265659075, + "type": "tool/ptc-dispatch", + "data": { + "rootCallId": "fx-call-65", + "parentCallId": "fx-call-65", + "subCallId": "fx-call-65:ptc:2", + "name": "read", + "arguments": { + "file_path": "notes/demo.txt" + }, + "isError": false, + "content": [ + { + "type": "text", + "text": "hello fixture\n" + } + ] + } + } + }, + { + "type": "event", + "event": { + "seq": 479, + "time": 1789265659875, + "type": "tool/ptc-dispatch-start", + "data": { + "rootCallId": "fx-call-65", + "parentCallId": "fx-call-65", + "subCallId": "fx-call-65:ptc:3", + "name": "read", + "arguments": { + "file_path": "notes/missing.txt" + } + } + } + }, + { + "type": "event", + "event": { + "seq": 480, + "time": 1789265660675, + "type": "tool/ptc-dispatch", + "data": { + "rootCallId": "fx-call-65", + "parentCallId": "fx-call-65", + "subCallId": "fx-call-65:ptc:3", + "name": "read", + "arguments": { + "file_path": "notes/missing.txt" + }, + "isError": true, + "content": [ + { + "type": "text", + "text": "Error: ENOENT: notes/missing.txt not found" + } + ] + } + } + }, + { + "type": "event", + "event": { + "seq": 481, + "time": 1789265661475, + "type": "tool/result", + "surfaceOp": "append", + "data": { + "turn": 65, + "step": 0, + "message": { + "source": { + "kind": "tool", + "callId": "fx-call-65" + }, + "content": [ + { + "type": "tool-result", + "toolCallId": "fx-call-65", + "content": [ + { + "type": "text", + "text": "{\"listing\":\"demo.txt\\nnew-demo.txt\",\"demo\":\"hello fixture\\n\"}" + } + ], + "isError": false + } + ], + "role": "user", + "id": "ca689e1a-ebfb-4465-b187-43c3490abb9b" + } + } + } + }, + { + "type": "event", + "event": { + "seq": 482, + "time": 1789265662275, + "type": "step/end", + "data": { + "turn": 65, + "step": 0 + } + } + }, + { + "type": "event", + "event": { + "seq": 483, + "time": 1789265663075, + "type": "turn/end", + "data": { + "turn": 65, + "reason": { + "kind": "completed" + } + } + } + }, + { + "type": "event", + "event": { + "seq": 484, + "time": 1789265663875, + "type": "turn/start", + "data": { + "turn": 66 + } + } + }, + { + "type": "event", + "event": { + "seq": 485, + "time": 1789265664675, + "type": "user/message", + "surfaceOp": "append", + "data": { + "content": [ + { + "type": "text", + "text": "问题 66:bash 样本。" + } + ], + "source": { + "kind": "user" + }, + "role": "user", + "id": "4a5bb1d5-3698-407a-bde1-5f8acd048eab" + } + } + }, + { + "type": "event", + "event": { + "seq": 486, + "time": 1789265665475, + "type": "step/start", + "data": { + "turn": 66, + "step": 0 + } + } + }, + { + "type": "event", + "event": { + "seq": 487, + "time": 1789265666275, + "type": "assistant/message", + "surfaceOp": "append", + "data": { + "turn": 66, + "step": 0, + "message": { + "role": "assistant", + "content": [ + { + "type": "tool-call", + "id": "fx-call-66", + "name": "bash", + "arguments": "{\"command\":\"pnpm run check\",\"description\":\"fixture 终端样本\",\"workdir\":\"/tmp/fixture/deep/nested\"}" + } + ], + "source": { + "kind": "model", + "provider": "fixture", + "model": "fx-1" + }, + "id": "68d3d448-1cac-44fd-8a6d-5446684ac98a" + }, + "usage": { + "inputTokens": 21, + "outputTokens": 8, + "cacheReadTokens": 80, + "cacheWriteTokens": 0 + }, + "stream": [ + { + "type": "chunk", + "time": 1789265666275, + "chunk": { + "type": "block-start", + "index": 0, + "blockType": "tool-call" + } + }, + { + "type": "chunk", + "time": 1789265666275, + "chunk": { + "type": "block-end", + "index": 0, + "block": { + "type": "tool-call", + "id": "fx-call-66", + "name": "bash", + "arguments": "{\"command\":\"pnpm run check\",\"description\":\"fixture 终端样本\",\"workdir\":\"/tmp/fixture/deep/nested\"}" + } + } + }, + { + "type": "chunk", + "time": 1789265666275, + "chunk": { + "type": "usage", + "usage": { + "inputTokens": 21, + "outputTokens": 8, + "cacheReadTokens": 80, + "cacheWriteTokens": 0 + } + } + }, + { + "type": "chunk", + "time": 1789265666275, + "chunk": { + "type": "finish", + "reason": { + "kind": "stop" + } + } + } + ] + } + } + }, + { + "type": "event", + "event": { + "seq": 488, + "time": 1789265667075, + "type": "tool/call", + "data": { + "turn": 66, + "step": 0, + "callId": "fx-call-66", + "name": "bash", + "arguments": "{\"command\":\"pnpm run check\",\"description\":\"fixture 终端样本\",\"workdir\":\"/tmp/fixture/deep/nested\"}" + } + } + }, + { + "type": "event", + "event": { + "seq": 489, + "time": 1789265667875, + "type": "tool/result", + "surfaceOp": "append", + "data": { + "turn": 66, + "step": 0, + "message": { + "source": { + "kind": "tool", + "callId": "fx-call-66" + }, + "content": [ + { + "type": "tool-result", + "toolCallId": "fx-call-66", + "content": [ + { + "type": "text", + "text": "\u001b[1mRunning 4 checks\u001b[0m\n\u001b[32m✓\u001b[0m typecheck 1.82s\n\u001b[32m✓\u001b[0m lint 0.94s\n\u001b[32m✓\u001b[0m duplication 2.10s\n\u001b[31m✗\u001b[0m unit 8.41s\n\n\u001b[90mpackages/client/ui-primitives/tests/terminal-block.client.spec.tsx\u001b[0m\n \u001b[31mFAIL\u001b[0m caps output at the configured line budget\n expected 16 lines, received 24\n\nNAME LINES BRANCHES FUNCTIONS UNCOVERED\nTerminalBlock.tsx 100% 100% 100% -\nansi.ts 100% 100% 100% -\nclipboard.ts 100% 100% 100% -\nCodeBlock.tsx 98.4% 96.2% 100% 41-43\nhighlight.ts 100% 100% 100% -\nPill.tsx 100% 100% 100% -\nStateDot.tsx 100% 100% 100% -\nmarkdown/Markdown.tsx 100% 100% 100% -\n\n\u001b[31m1 of 4 checks failed\u001b[0m\n[exit code: 1]" + } + ], + "isError": false + } + ], + "role": "user", + "id": "0da2afe6-5036-4d36-b9f3-ff562585507f" + } + } + } + }, + { + "type": "event", + "event": { + "seq": 490, + "time": 1789265668675, + "type": "step/end", + "data": { + "turn": 66, + "step": 0 + } + } + }, + { + "type": "event", + "event": { + "seq": 491, + "time": 1789265669475, + "type": "turn/end", + "data": { + "turn": 66, + "reason": { + "kind": "completed" + } + } + } + }, + { + "type": "event", + "event": { + "seq": 492, + "time": 1789265670275, + "type": "turn/start", + "data": { + "turn": 67 + } + } + }, + { + "type": "event", + "event": { + "seq": 493, + "time": 1789265671075, + "type": "user/message", + "surfaceOp": "append", + "data": { + "content": [ + { + "type": "text", + "text": "问题 67:grep 样本。" + } + ], + "source": { + "kind": "user" + }, + "role": "user", + "id": "8dab4a18-bae1-492c-a43a-8337ae80abc3" + } + } + }, + { + "type": "event", + "event": { + "seq": 494, + "time": 1789265671875, + "type": "step/start", + "data": { + "turn": 67, + "step": 0 + } + } + }, + { + "type": "event", + "event": { + "seq": 495, + "time": 1789265672675, + "type": "assistant/message", + "surfaceOp": "append", + "data": { + "turn": 67, + "step": 0, + "message": { + "role": "assistant", + "content": [ + { + "type": "tool-call", + "id": "fx-call-67", + "name": "grep", + "arguments": "{\"pattern\":\"SEARCH_MAX_LINES\",\"path\":\"packages/client\"}" + } + ], + "source": { + "kind": "model", + "provider": "fixture", + "model": "fx-1" + }, + "id": "53469dfd-6af1-4eb2-9273-53ea475b6e94" + }, + "usage": { + "inputTokens": 22, + "outputTokens": 8, + "cacheReadTokens": 80, + "cacheWriteTokens": 0 + }, + "stream": [ + { + "type": "chunk", + "time": 1789265672675, + "chunk": { + "type": "block-start", + "index": 0, + "blockType": "tool-call" + } + }, + { + "type": "chunk", + "time": 1789265672675, + "chunk": { + "type": "block-end", + "index": 0, + "block": { + "type": "tool-call", + "id": "fx-call-67", + "name": "grep", + "arguments": "{\"pattern\":\"SEARCH_MAX_LINES\",\"path\":\"packages/client\"}" + } + } + }, + { + "type": "chunk", + "time": 1789265672675, + "chunk": { + "type": "usage", + "usage": { + "inputTokens": 22, + "outputTokens": 8, + "cacheReadTokens": 80, + "cacheWriteTokens": 0 + } + } + }, + { + "type": "chunk", + "time": 1789265672675, + "chunk": { + "type": "finish", + "reason": { + "kind": "stop" + } + } + } + ] + } + } + }, + { + "type": "event", + "event": { + "seq": 496, + "time": 1789265673475, + "type": "tool/call", + "data": { + "turn": 67, + "step": 0, + "callId": "fx-call-67", + "name": "grep", + "arguments": "{\"pattern\":\"SEARCH_MAX_LINES\",\"path\":\"packages/client\"}" + } + } + }, + { + "type": "event", + "event": { + "seq": 497, + "time": 1789265674275, + "type": "tool/result", + "surfaceOp": "append", + "data": { + "turn": 67, + "step": 0, + "message": { + "source": { + "kind": "tool", + "callId": "fx-call-67" + }, + "content": [ + { + "type": "tool-result", + "toolCallId": "fx-call-67", + "content": [ + { + "type": "text", + "text": "Found 9 of 42 matches\n\npackages/client/ui-primitives/src/SearchBlock.tsx\nLine 16: export const DEFAULT_SEARCH_MAX_LINES = 16\nLine 138: export function SearchBlock(props: SearchBlockProps) {\nLine 141: const [collapsed, setCollapsed] = useState>(() => new Set())\npackages/client/ui-tool/src/client/tool/models/search-card-model.ts\nLine 45: export const CHAT_SEARCH_MAX_LINES = 8\nLine 130: export function searchCardModel(block: ToolCallBlock): SearchCardModel | null {\npackages/client/ui-tool/src/client/tool/toolviews/search-row.tsx\nLine 34: export function SearchRow({ toolName, block, inspect, t }: SearchRowProps) {\nLine 36: const search = searchCardModel(block)\nLine 56: search={search}\nLine 78: yield ctx.slots.register({ name: 'tool.call.toolview', key: 'grep', locale: NS }, SearchRow)\n\n(Full grep result stored at: fixture://spill/grep-66. Read it to see every match.)" + } + ], + "isError": false + } + ], + "role": "user", + "id": "9a3df388-356c-4440-b0bf-b09e0dda0387" + }, + "meta": { + "shape": "matches", + "files": [ + { + "path": "packages/client/ui-primitives/src/SearchBlock.tsx", + "matches": [ + { + "lineNumber": 16, + "line": "export const DEFAULT_SEARCH_MAX_LINES = 16" + }, + { + "lineNumber": 138, + "line": "export function SearchBlock(props: SearchBlockProps) {" + }, + { + "lineNumber": 141, + "line": " const [collapsed, setCollapsed] = useState>(() => new Set())" + } + ] + }, + { + "path": "packages/client/ui-tool/src/client/tool/models/search-card-model.ts", + "matches": [ + { + "lineNumber": 45, + "line": "export const CHAT_SEARCH_MAX_LINES = 8" + }, + { + "lineNumber": 130, + "line": "export function searchCardModel(block: ToolCallBlock): SearchCardModel | null {" + } + ] + }, + { + "path": "packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx", + "matches": [ + { + "lineNumber": 34, + "line": "export function SearchRow({ toolName, block, inspect, t }: SearchRowProps) {" + }, + { + "lineNumber": 36, + "line": " const search = searchCardModel(block)" + }, + { + "lineNumber": 56, + "line": " search={search}" + }, + { + "lineNumber": 78, + "line": " yield ctx.slots.register({ name: 'tool.call.toolview', key: 'grep', locale: NS }, SearchRow)" + } + ] + } + ], + "truncated": true, + "total": 42 + } + } + } + }, + { + "type": "event", + "event": { + "seq": 498, + "time": 1789265675075, + "type": "step/end", + "data": { + "turn": 67, + "step": 0 + } + } + }, + { + "type": "event", + "event": { + "seq": 499, + "time": 1789265675875, + "type": "turn/end", + "data": { + "turn": 67, + "reason": { + "kind": "completed" + } + } + } + }, + { + "type": "event", + "event": { + "seq": 500, + "time": 1789265676675, + "type": "turn/start", + "data": { + "turn": 68 + } + } + }, + { + "type": "event", + "event": { + "seq": 501, + "time": 1789265677475, + "type": "user/message", + "surfaceOp": "append", + "data": { + "content": [ + { + "type": "text", + "text": "问题 68:glob 样本。" + } + ], + "source": { + "kind": "user" + }, + "role": "user", + "id": "728f42ae-723b-485f-bf63-11b28892a89d" + } + } + }, + { + "type": "event", + "event": { + "seq": 502, + "time": 1789265678275, + "type": "step/start", + "data": { + "turn": 68, + "step": 0 + } + } + }, + { + "type": "event", + "event": { + "seq": 503, + "time": 1789265679075, + "type": "assistant/message", + "surfaceOp": "append", + "data": { + "turn": 68, + "step": 0, + "message": { + "role": "assistant", + "content": [ + { + "type": "tool-call", + "id": "fx-call-68", + "name": "glob", + "arguments": "{\"pattern\":\"**/SearchBlock*\",\"path\":\"packages/client\"}" + } + ], + "source": { + "kind": "model", + "provider": "fixture", + "model": "fx-1" + }, + "id": "b6c6950d-d67b-45eb-a718-1a4fdb241e05" + }, + "usage": { + "inputTokens": 23, + "outputTokens": 8, + "cacheReadTokens": 80, + "cacheWriteTokens": 0 + }, + "stream": [ + { + "type": "chunk", + "time": 1789265679075, + "chunk": { + "type": "block-start", + "index": 0, + "blockType": "tool-call" + } + }, + { + "type": "chunk", + "time": 1789265679075, + "chunk": { + "type": "block-end", + "index": 0, + "block": { + "type": "tool-call", + "id": "fx-call-68", + "name": "glob", + "arguments": "{\"pattern\":\"**/SearchBlock*\",\"path\":\"packages/client\"}" + } + } + }, + { + "type": "chunk", + "time": 1789265679075, + "chunk": { + "type": "usage", + "usage": { + "inputTokens": 23, + "outputTokens": 8, + "cacheReadTokens": 80, + "cacheWriteTokens": 0 + } + } + }, + { + "type": "chunk", + "time": 1789265679075, + "chunk": { + "type": "finish", + "reason": { + "kind": "stop" + } + } + } + ] + } + } + }, + { + "type": "event", + "event": { + "seq": 504, + "time": 1789265679875, + "type": "tool/call", + "data": { + "turn": 68, + "step": 0, + "callId": "fx-call-68", + "name": "glob", + "arguments": "{\"pattern\":\"**/SearchBlock*\",\"path\":\"packages/client\"}" + } + } + }, + { + "type": "event", + "event": { + "seq": 505, + "time": 1789265680675, + "type": "tool/result", + "surfaceOp": "append", + "data": { + "turn": 68, + "step": 0, + "message": { + "source": { + "kind": "tool", + "callId": "fx-call-68" + }, + "content": [ + { + "type": "tool-result", + "toolCallId": "fx-call-68", + "content": [ + { + "type": "text", + "text": "packages/client/ui-primitives/src/SearchBlock.tsx\npackages/client/ui-primitives/src/SearchBlock.module.css\npackages/client/ui-tool/src/client/tool/models/search-card-model.ts\npackages/client/ui-tool/src/client/tool/toolviews/search-row.tsx\npackages/client/ui-tool/tests/search-card.client.spec.tsx\n\n(Showing 5 of 23 paths. Full sorted result stored at: fixture://spill/glob-67. Read it to see every path.)" + } + ], + "isError": false + } + ], + "role": "user", + "id": "e6d88943-d180-472b-9c1b-d59a21a50ab9" + }, + "meta": { + "shape": "paths", + "paths": [ + "packages/client/ui-primitives/src/SearchBlock.tsx", + "packages/client/ui-primitives/src/SearchBlock.module.css", + "packages/client/ui-tool/src/client/tool/models/search-card-model.ts", + "packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx", + "packages/client/ui-tool/tests/search-card.client.spec.tsx" + ], + "truncated": true, + "total": 23 + } + } + } + }, + { + "type": "event", + "event": { + "seq": 506, + "time": 1789265681475, + "type": "step/end", + "data": { + "turn": 68, + "step": 0 + } + } + }, + { + "type": "event", + "event": { + "seq": 507, + "time": 1789265682275, + "type": "turn/end", + "data": { + "turn": 68, + "reason": { + "kind": "completed" + } + } + } + }, + { + "type": "event", + "event": { + "seq": 508, + "time": 1789265683075, + "type": "turn/start", + "data": { + "turn": 69 + } + } + }, + { + "type": "event", + "event": { + "seq": 509, + "time": 1789265683875, + "type": "user/message", + "surfaceOp": "append", + "data": { + "content": [ + { + "type": "text", + "text": "问题 69:read 样本。" + } + ], + "source": { + "kind": "user" + }, + "role": "user", + "id": "f0564417-a09b-4e3a-8dd3-c0bd41604c23" + } + } + }, + { + "type": "event", + "event": { + "seq": 510, + "time": 1789265684675, + "type": "step/start", + "data": { + "turn": 69, + "step": 0 + } + } + }, + { + "type": "event", + "event": { + "seq": 511, + "time": 1789265685475, + "type": "assistant/message", + "surfaceOp": "append", + "data": { + "turn": 69, + "step": 0, + "message": { + "role": "assistant", + "content": [ + { + "type": "tool-call", + "id": "fx-call-69", + "name": "read", + "arguments": "{\"file_path\":\"packages/client/ui-primitives/src/ReadBlock.tsx\",\"offset\":41}" + } + ], + "source": { + "kind": "model", + "provider": "fixture", + "model": "fx-1" + }, + "id": "bfbff068-730d-4cf8-b647-6086e170221a" + }, + "usage": { + "inputTokens": 24, + "outputTokens": 8, + "cacheReadTokens": 80, + "cacheWriteTokens": 0 + }, + "stream": [ + { + "type": "chunk", + "time": 1789265685475, + "chunk": { + "type": "block-start", + "index": 0, + "blockType": "tool-call" + } + }, + { + "type": "chunk", + "time": 1789265685475, + "chunk": { + "type": "block-end", + "index": 0, + "block": { + "type": "tool-call", + "id": "fx-call-69", + "name": "read", + "arguments": "{\"file_path\":\"packages/client/ui-primitives/src/ReadBlock.tsx\",\"offset\":41}" + } + } + }, + { + "type": "chunk", + "time": 1789265685475, + "chunk": { + "type": "usage", + "usage": { + "inputTokens": 24, + "outputTokens": 8, + "cacheReadTokens": 80, + "cacheWriteTokens": 0 + } + } + }, + { + "type": "chunk", + "time": 1789265685475, + "chunk": { + "type": "finish", + "reason": { + "kind": "stop" + } + } + } + ] + } + } + }, + { + "type": "event", + "event": { + "seq": 512, + "time": 1789265686275, + "type": "tool/call", + "data": { + "turn": 69, + "step": 0, + "callId": "fx-call-69", + "name": "read", + "arguments": "{\"file_path\":\"packages/client/ui-primitives/src/ReadBlock.tsx\",\"offset\":41}" + } + } + }, + { + "type": "event", + "event": { + "seq": 513, + "time": 1789265687075, + "type": "tool/result", + "surfaceOp": "append", + "data": { + "turn": 69, + "step": 0, + "message": { + "source": { + "kind": "tool", + "callId": "fx-call-69" + }, + "content": [ + { + "type": "tool-result", + "toolCallId": "fx-call-69", + "content": [ + { + "type": "text", + "text": "packages/client/ui-primitives/src/ReadBlock.tsx\nfile\n\n41: export interface ReadBlockProps {\n42: label?: string | undefined\n43: lines: readonly ReadBlockLine[]\n44: totalLines: number\n45: lang?: string | undefined\n46: maxLines?: number | undefined\n47: className?: string | undefined\n48: }\n49: \n50: // A windowed read keeps the file line numbers in the gutter.\n51: const marker = \"fixture read sample\"\n\n(Showing lines 41-51 of 180. Use offset=52 to continue.)\n" + } + ], + "isError": false + } + ], + "role": "user", + "id": "937c02dd-63d8-4106-9310-a9df2055f0e9" + }, + "meta": { + "path": "packages/client/ui-primitives/src/ReadBlock.tsx", + "offset": 41, + "lines": [ + { + "number": 41, + "text": "export interface ReadBlockProps {" + }, + { + "number": 42, + "text": " label?: string | undefined" + }, + { + "number": 43, + "text": " lines: readonly ReadBlockLine[]" + }, + { + "number": 44, + "text": " totalLines: number" + }, + { + "number": 45, + "text": " lang?: string | undefined" + }, + { + "number": 46, + "text": " maxLines?: number | undefined" + }, + { + "number": 47, + "text": " className?: string | undefined" + }, + { + "number": 48, + "text": "}" + }, + { + "number": 49, + "text": "" + }, + { + "number": 50, + "text": "// A windowed read keeps the file line numbers in the gutter." + }, + { + "number": 51, + "text": "const marker = \"fixture read sample\"" + } + ], + "totalLines": 180, + "lang": "ts" + } + } + } + }, + { + "type": "event", + "event": { + "seq": 514, + "time": 1789265687875, + "type": "step/end", + "data": { + "turn": 69, + "step": 0 + } + } + }, + { + "type": "event", + "event": { + "seq": 515, + "time": 1789265688675, + "type": "turn/end", + "data": { + "turn": 69, + "reason": { + "kind": "completed" + } + } + } + }, + { + "type": "event", + "event": { + "seq": 516, + "time": 1789265689475, + "type": "turn/start", + "data": { + "turn": 70 + } + } + }, + { + "type": "event", + "event": { + "seq": 517, + "time": 1789265690275, + "type": "user/message", + "surfaceOp": "append", + "data": { + "content": [ + { + "type": "text", + "text": "问题 70:web_search 样本。" + } + ], + "source": { + "kind": "user" + }, + "role": "user", + "id": "c7ba3fc7-e437-4d77-b212-e766b1ca1c47" + } + } + }, + { + "type": "event", + "event": { + "seq": 518, + "time": 1789265691075, + "type": "step/start", + "data": { + "turn": 70, + "step": 0 + } + } + }, + { + "type": "event", + "event": { + "seq": 519, + "time": 1789265691875, + "type": "assistant/message", + "surfaceOp": "append", + "data": { + "turn": 70, + "step": 0, + "message": { + "role": "assistant", + "content": [ + { + "type": "tool-call", + "id": "fx-call-70", + "name": "web_search", + "arguments": "{\"queries\":[\"deepseek harness architecture\"]}" + } + ], + "source": { + "kind": "model", + "provider": "fixture", + "model": "fx-1" + }, + "id": "70f9aaef-f006-4b53-986d-631458679ef6" + }, + "usage": { + "inputTokens": 20, + "outputTokens": 8, + "cacheReadTokens": 80, + "cacheWriteTokens": 4 + }, + "stream": [ + { + "type": "chunk", + "time": 1789265691875, + "chunk": { + "type": "block-start", + "index": 0, + "blockType": "tool-call" + } + }, + { + "type": "chunk", + "time": 1789265691875, + "chunk": { + "type": "block-end", + "index": 0, + "block": { + "type": "tool-call", + "id": "fx-call-70", + "name": "web_search", + "arguments": "{\"queries\":[\"deepseek harness architecture\"]}" + } + } + }, + { + "type": "chunk", + "time": 1789265691875, + "chunk": { + "type": "usage", + "usage": { + "inputTokens": 20, + "outputTokens": 8, + "cacheReadTokens": 80, + "cacheWriteTokens": 4 + } + } + }, + { + "type": "chunk", + "time": 1789265691875, + "chunk": { + "type": "finish", + "reason": { + "kind": "stop" + } + } + } + ] + } + } + }, + { + "type": "event", + "event": { + "seq": 520, + "time": 1789265692675, + "type": "tool/call", + "data": { + "turn": 70, + "step": 0, + "callId": "fx-call-70", + "name": "web_search", + "arguments": "{\"queries\":[\"deepseek harness architecture\"]}" + } + } + }, + { + "type": "event", + "event": { + "seq": 521, + "time": 1789265693475, + "type": "tool/result", + "surfaceOp": "append", + "data": { + "turn": 70, + "step": 0, + "message": { + "source": { + "kind": "tool", + "callId": "fx-call-70" + }, + "content": [ + { + "type": "tool-result", + "toolCallId": "fx-call-70", + "content": [ + { + "type": "text", + "text": "Search results for deepseek harness architecture." + } + ], + "isError": false + } + ], + "role": "user", + "id": "c2ddb04d-74c9-4a2f-83f6-47a48fc6fbc2" + }, + "meta": { + "answer": "DeepSeek Harness is a plugin-based agent harness on vendored Cordis where **every capability is a plugin**.", + "sources": [ + { + "url": "https://github.com/deepseek-ai/deepseek-harness", + "title": "DeepSeek Harness — plugin-based agent harness", + "snippet": "Everything is a plugin: session, tools, agent-loop, and LLM adapters all mount on the same Cordis context.", + "publishedAt": "2026-07-01" + }, + { + "url": "https://www.deepseek.com/blog/harness-architecture", + "snippet": "The capability-seam pattern splits each capability into interface, implementation, and consumer packages." + }, + { + "url": "https://docs.deepseek.com/harness/plugins", + "title": "Writing a harness plugin", + "publishedAt": "2026-06-15" + } + ], + "truncated": true + } + } + } + }, + { + "type": "event", + "event": { + "seq": 522, + "time": 1789265694275, + "type": "step/end", + "data": { + "turn": 70, + "step": 0 + } + } + }, + { + "type": "event", + "event": { + "seq": 523, + "time": 1789265695075, + "type": "turn/end", + "data": { + "turn": 70, + "reason": { + "kind": "completed" + } + } + } + }, + { + "type": "event", + "event": { + "seq": 524, + "time": 1789265695875, + "type": "turn/start", + "data": { + "turn": 71 + } + } + }, + { + "type": "event", + "event": { + "seq": 525, + "time": 1789265696675, + "type": "user/message", + "surfaceOp": "append", + "data": { + "content": [ + { + "type": "text", + "text": "问题 71:web_fetch 样本。" + } + ], + "source": { + "kind": "user" + }, + "role": "user", + "id": "b164bd50-e038-4ada-a92e-42b483efab9a" + } + } + }, + { + "type": "event", + "event": { + "seq": 526, + "time": 1789265697475, + "type": "step/start", + "data": { + "turn": 71, + "step": 0 + } + } + }, + { + "type": "event", + "event": { + "seq": 527, + "time": 1789265698275, + "type": "assistant/message", + "surfaceOp": "append", + "data": { + "turn": 71, + "step": 0, + "message": { + "role": "assistant", + "content": [ + { + "type": "tool-call", + "id": "fx-call-71", + "name": "web_fetch", + "arguments": "{\"url\":\"https://www.deepseek.com/blog/harness-architecture\"}" + } + ], + "source": { + "kind": "model", + "provider": "fixture", + "model": "fx-1" + }, + "id": "72a35941-d5a4-44ec-9b4d-f14303cf165b" + }, + "usage": { + "inputTokens": 21, + "outputTokens": 8, + "cacheReadTokens": 80, + "cacheWriteTokens": 0 + }, + "stream": [ + { + "type": "chunk", + "time": 1789265698275, + "chunk": { + "type": "block-start", + "index": 0, + "blockType": "tool-call" + } + }, + { + "type": "chunk", + "time": 1789265698275, + "chunk": { + "type": "block-end", + "index": 0, + "block": { + "type": "tool-call", + "id": "fx-call-71", + "name": "web_fetch", + "arguments": "{\"url\":\"https://www.deepseek.com/blog/harness-architecture\"}" + } + } + }, + { + "type": "chunk", + "time": 1789265698275, + "chunk": { + "type": "usage", + "usage": { + "inputTokens": 21, + "outputTokens": 8, + "cacheReadTokens": 80, + "cacheWriteTokens": 0 + } + } + }, + { + "type": "chunk", + "time": 1789265698275, + "chunk": { + "type": "finish", + "reason": { + "kind": "stop" + } + } + } + ] + } + } + }, + { + "type": "event", + "event": { + "seq": 528, + "time": 1789265699075, + "type": "tool/call", + "data": { + "turn": 71, + "step": 0, + "callId": "fx-call-71", + "name": "web_fetch", + "arguments": "{\"url\":\"https://www.deepseek.com/blog/harness-architecture\"}" + } + } + }, + { + "type": "event", + "event": { + "seq": 529, + "time": 1789265699875, + "type": "tool/result", + "surfaceOp": "append", + "data": { + "turn": 71, + "step": 0, + "message": { + "source": { + "kind": "tool", + "callId": "fx-call-71" + }, + "content": [ + { + "type": "tool-result", + "toolCallId": "fx-call-71", + "content": [ + { + "type": "text", + "text": "# Harness architecture\n\nEverything is a plugin." + } + ], + "isError": false + } + ], + "role": "user", + "id": "57cf5cd3-3daf-4e2c-bb16-f8695bc2ab8c" + }, + "meta": { + "url": "https://www.deepseek.com/blog/harness-architecture", + "statusCode": 200, + "truncated": false + } + } + } + }, + { + "type": "event", + "event": { + "seq": 530, + "time": 1789265700675, + "type": "step/end", + "data": { + "turn": 71, + "step": 0 + } + } + }, + { + "type": "event", + "event": { + "seq": 531, + "time": 1789265701475, + "type": "turn/end", + "data": { + "turn": 71, + "reason": { + "kind": "completed" + } + } + } + }, + { + "type": "event", + "event": { + "seq": 532, + "time": 1789265702275, + "type": "turn/start", + "data": { + "turn": 72 + } + } + }, + { + "type": "event", + "event": { + "seq": 533, + "time": 1789265703075, + "type": "user/message", + "surfaceOp": "append", + "data": { + "content": [ + { + "type": "text", + "text": "问题 72:请完整列出全部一百条条目。" + } + ], + "source": { + "kind": "user" + }, + "role": "user", + "id": "35a4608a-bd98-46a2-af6a-233b28470390" + } + } + }, + { + "type": "event", + "event": { + "seq": 534, + "time": 1789265703875, + "type": "step/start", + "data": { + "turn": 72, + "step": 0 + } + } + }, + { + "type": "event", + "event": { + "seq": 535, + "time": 1789265704675, + "type": "assistant/message", + "surfaceOp": "append", + "data": { + "turn": 72, + "step": 0, + "message": { + "role": "assistant", + "content": [ + { + "type": "text", + "text": "条目 1:第一条。条目 2:第二条。条目 3:这一条写到一半被" + } + ], + "source": { + "kind": "model", + "provider": "fixture", + "model": "fx-1" + }, + "id": "006a595a-cd1e-4137-af5c-e36dc98f354e" + }, + "usage": { + "inputTokens": 22, + "outputTokens": 8, + "cacheReadTokens": 80, + "cacheWriteTokens": 0 + }, + "stream": [ + { + "type": "chunk", + "time": 1789265704675, + "chunk": { + "type": "block-start", + "index": 0, + "blockType": "text" + } + }, + { + "type": "chunk", + "time": 1789265704675, + "chunk": { + "type": "block-end", + "index": 0, + "block": { + "type": "text", + "text": "条目 1:第一条。条目 2:第二条。条目 3:这一条写到一半被" + } + } + }, + { + "type": "chunk", + "time": 1789265704675, + "chunk": { + "type": "usage", + "usage": { + "inputTokens": 22, + "outputTokens": 8, + "cacheReadTokens": 80, + "cacheWriteTokens": 0 + } + } + }, + { + "type": "chunk", + "time": 1789265704675, + "chunk": { + "type": "finish", + "reason": { + "kind": "stop" + } + } + } + ] + } + } + }, + { + "type": "event", + "event": { + "seq": 536, + "time": 1789265705475, + "type": "step/end", + "data": { + "turn": 72, + "step": 0 + } + } + }, + { + "type": "event", + "event": { + "seq": 537, + "time": 1789265706275, + "type": "turn/end", + "data": { + "turn": 72, + "reason": { + "kind": "max-tokens" + } + } + } + }, + { + "type": "event", + "event": { + "seq": 538, + "time": 1789265707075, + "type": "turn/start", + "data": { + "turn": 73 + } + } + }, + { + "type": "event", + "event": { + "seq": 539, + "time": 1789265707875, + "type": "user/message", + "surfaceOp": "append", + "data": { + "content": [ + { + "type": "image", + "attachment": { + "attachmentId": "fixture:image", + "mediaType": "image/png", + "bytes": 247, + "width": 160, + "height": 90, + "name": "fixture-image.png" + } + }, + { + "type": "text", + "text": "历史用户图片" + } + ], + "source": { + "kind": "user" + }, + "role": "user", + "id": "f7aa4d0e-5b76-4ccd-a566-150a227b0c27" + } + } + }, + { + "type": "event", + "event": { + "seq": 540, + "time": 1789265708675, + "type": "step/start", + "data": { + "turn": 73, + "step": 0 + } + } + }, + { + "type": "event", + "event": { + "seq": 541, + "time": 1789265709475, + "type": "assistant/message", + "surfaceOp": "append", + "data": { + "turn": 73, + "step": 0, + "message": { + "role": "assistant", + "content": [ + { + "type": "text", + "text": "结构化模型图片:" + }, + { + "type": "image", + "attachment": { + "attachmentId": "fixture:image", + "mediaType": "image/png", + "bytes": 247, + "width": 160, + "height": 90, + "name": "fixture-image.png" + } + } + ], + "source": { + "kind": "model", + "provider": "fixture", + "model": "fx-vision" + }, + "id": "35080466-4027-4eba-95b8-538f1447867b" + }, + "usage": { + "inputTokens": 23, + "outputTokens": 8, + "cacheReadTokens": 80, + "cacheWriteTokens": 0 + }, + "stream": [ + { + "type": "chunk", + "time": 1789265709475, + "chunk": { + "type": "block-start", + "index": 0, + "blockType": "text" + } + }, + { + "type": "chunk", + "time": 1789265709475, + "chunk": { + "type": "block-end", + "index": 0, + "block": { + "type": "text", + "text": "结构化模型图片:" + } + } + }, + { + "type": "chunk", + "time": 1789265709475, + "chunk": { + "type": "block-start", + "index": 1, + "blockType": "image" + } + }, + { + "type": "chunk", + "time": 1789265709475, + "chunk": { + "type": "block-end", + "index": 1, + "block": { + "type": "image", + "attachment": { + "attachmentId": "fixture:image", + "mediaType": "image/png", + "bytes": 247, + "width": 160, + "height": 90, + "name": "fixture-image.png" + } + } + } + }, + { + "type": "chunk", + "time": 1789265709475, + "chunk": { + "type": "usage", + "usage": { + "inputTokens": 23, + "outputTokens": 8, + "cacheReadTokens": 80, + "cacheWriteTokens": 0 + } + } + }, + { + "type": "chunk", + "time": 1789265709475, + "chunk": { + "type": "finish", + "reason": { + "kind": "stop" + } + } + } + ] + } + } + }, + { + "type": "event", + "event": { + "seq": 542, + "time": 1789265710275, + "type": "step/end", + "data": { + "turn": 73, + "step": 0 + } + } + }, + { + "type": "event", + "event": { + "seq": 543, + "time": 1789265711075, + "type": "turn/end", + "data": { + "turn": 73, + "reason": { + "kind": "completed" + } + } + } + }, + { + "type": "event", + "event": { + "seq": 544, + "time": 1789265711875, + "type": "turn/start", + "data": { + "turn": 74 + } + } + }, + { + "type": "event", + "event": { + "seq": 545, + "time": 1789265712675, + "type": "user/message", + "surfaceOp": "append", + "data": { + "content": [ + { + "type": "text", + "text": "问题 74:todo_write 样本。" + } + ], + "source": { + "kind": "user" + }, + "role": "user", + "id": "df61981f-bcff-4984-9d9e-de8754da7bc8" + } + } + }, + { + "type": "event", + "event": { + "seq": 546, + "time": 1789265713475, + "type": "step/start", + "data": { + "turn": 74, + "step": 0 + } + } + }, + { + "type": "event", + "event": { + "seq": 547, + "time": 1789265714275, + "type": "assistant/message", + "surfaceOp": "append", + "data": { + "turn": 74, + "step": 0, + "message": { + "role": "assistant", + "content": [ + { + "type": "tool-call", + "id": "fx-call-74", + "name": "todo_write", + "arguments": "{\"todos\":[{\"content\":\"梳理需求\",\"status\":\"completed\"},{\"content\":\"实现 fixture 样本\",\"status\":\"in_progress\"},{\"content\":\"跑后台构建\",\"status\":\"in_progress\"},{\"content\":\"浏览器验收\",\"status\":\"pending\"}]}" + } + ], + "source": { + "kind": "model", + "provider": "fixture", + "model": "fx-1" + }, + "id": "c346330a-d740-45f6-9cbe-fb7b20d9f741" + }, + "usage": { + "inputTokens": 24, + "outputTokens": 8, + "cacheReadTokens": 80, + "cacheWriteTokens": 0 + }, + "stream": [ + { + "type": "chunk", + "time": 1789265714275, + "chunk": { + "type": "block-start", + "index": 0, + "blockType": "tool-call" + } + }, + { + "type": "chunk", + "time": 1789265714275, + "chunk": { + "type": "block-end", + "index": 0, + "block": { + "type": "tool-call", + "id": "fx-call-74", + "name": "todo_write", + "arguments": "{\"todos\":[{\"content\":\"梳理需求\",\"status\":\"completed\"},{\"content\":\"实现 fixture 样本\",\"status\":\"in_progress\"},{\"content\":\"跑后台构建\",\"status\":\"in_progress\"},{\"content\":\"浏览器验收\",\"status\":\"pending\"}]}" + } + } + }, + { + "type": "chunk", + "time": 1789265714275, + "chunk": { + "type": "usage", + "usage": { + "inputTokens": 24, + "outputTokens": 8, + "cacheReadTokens": 80, + "cacheWriteTokens": 0 + } + } + }, + { + "type": "chunk", + "time": 1789265714275, + "chunk": { + "type": "finish", + "reason": { + "kind": "stop" + } + } + } + ] + } + } + }, + { + "type": "event", + "event": { + "seq": 548, + "time": 1789265715075, + "type": "tool/call", + "data": { + "turn": 74, + "step": 0, + "callId": "fx-call-74", + "name": "todo_write", + "arguments": "{\"todos\":[{\"content\":\"梳理需求\",\"status\":\"completed\"},{\"content\":\"实现 fixture 样本\",\"status\":\"in_progress\"},{\"content\":\"跑后台构建\",\"status\":\"in_progress\"},{\"content\":\"浏览器验收\",\"status\":\"pending\"}]}" + } + } + }, + { + "type": "event", + "event": { + "type": "todo/write", + "time": 1789265715475, + "data": { + "todos": [ + { + "content": "梳理需求", + "status": "completed" + }, + { + "content": "实现 fixture 样本", + "status": "in_progress" + }, + { + "content": "跑后台构建", + "status": "in_progress" + }, + { + "content": "浏览器验收", + "status": "pending" + } + ] + }, + "seq": 549 + } + }, + { + "type": "event", + "event": { + "seq": 550, + "time": 1789265715875, + "type": "tool/result", + "surfaceOp": "append", + "data": { + "turn": 74, + "step": 0, + "message": { + "source": { + "kind": "tool", + "callId": "fx-call-74" + }, + "content": [ + { + "type": "tool-result", + "toolCallId": "fx-call-74", + "content": [ + { + "type": "text", + "text": "Updated todo list: 1 pending, 2 in progress, 1 completed." + } + ], + "isError": false + } + ], + "role": "user", + "id": "499968fd-a83f-45ad-80fe-b1d73651ff13" + } + } + } + }, + { + "type": "event", + "event": { + "seq": 551, + "time": 1789265716675, + "type": "step/end", + "data": { + "turn": 74, + "step": 0 + } + } + }, + { + "type": "event", + "event": { + "seq": 552, + "time": 1789265717475, + "type": "turn/end", + "data": { + "turn": 74, + "reason": { + "kind": "completed" + } + } + } + } + ], + "hasMore": true, + "projections": { + "asOfSeq": 552, + "values": { + "modelSelection": { + "lastUsed": null, + "next": null + }, + "title": "Fixture 历史会话", + "todos": [ + { + "content": "梳理需求", + "status": "completed" + }, + { + "content": "实现 fixture 样本", + "status": "in_progress" + }, + { + "content": "跑后台构建", + "status": "in_progress" + }, + { + "content": "浏览器验收", + "status": "pending" + } + ], + "permissions": { + "options": [ + { + "value": "workspace-write", + "name": "workspace-write", + "description": "Write inside the workspace and permitted temporary directories; wider retries require approval." + }, + { + "value": "danger-full-access", + "name": "danger-full-access", + "description": "Full file access without approval prompts." + } + ], + "currentValue": "workspace-write" + }, + "plan": { + "active": false, + "pending": false + }, + "goal": null, + "tokenUsage": { + "uncachedInputTokens": 1914, + "outputTokens": 708, + "cacheReadTokens": 6880, + "cacheWriteTokens": 32 + }, + "contextPressure": { + "pressureTokens": 104, + "contextWindow": 128000 + }, + "contextBreakdown": { + "systemTokens": 17, + "toolsTokens": 0, + "messageTokens": 4427 + }, + "sessionStats": { + "turns": 75, + "steps": 87, + "llmMs": 69600, + "toolMs": 24800, + "ttftMs": 0, + "ttftSteps": 0, + "decodeMs": 0, + "decodeTokens": 0 + }, + "imageLimits": { + "maxImageBytes": 5242880, + "maxImagesPerMessage": 20, + "maxMessageImageBytes": 104857600, + "maxImagePixels": 40000000, + "maxImageDimension": 2000, + "mediaTypes": [ + "image/png", + "image/jpeg", + "image/webp", + "image/gif" + ] + } + } + }, + "assistantStream": { + "revision": 0 + } + }, + "attachment": { + "ok": true, + "value": { + "attachment": { + "attachmentId": "fixture:image", + "mediaType": "image/png", + "bytes": 247, + "width": 160, + "height": 90, + "name": "fixture-image.png" + }, + "data": "iVBORw0KGgoAAAANSUhEUgAAAKAAAABaCAYAAAA/xl1SAAAAvklEQVR42u3SMQ0AAAjAMIyhELM4AAe8PD1qYFlk9cCXEAEDYkAwIAYEA2JAMCAGBANiQDAgBgQDYkAwIAYEA2JAMCAGBANiQDAgBgQDYkAwIAYEA2JAMCAGxIBCYEAMCAbEgGBADAgGxIBgQAwIBsSAYEAMCAbEgGBADAgGxIBgQAwIBsSAYEAMCAbEgGBADAgGxIAYEAyIAcGAGBAMiAHBgBgQDIgBwYAYEAyIAcGAGBAMiAHBgBgQDIgB4bYWLb6pnOb1xAAAAABJRU5ErkJggg==" + } + } +} diff --git a/apps/web/tests/image-display.expected.e2e.ts b/apps/web/tests/image-display.expected.e2e.ts index e35486abfe..adbb742d0b 100644 --- a/apps/web/tests/image-display.expected.e2e.ts +++ b/apps/web/tests/image-display.expected.e2e.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom // Multimodal image surfaces over the BUILT client graph (the ptc-fixture -// idiom: real bundles via AppWebEntry, keyless fixture Connection RPC). +// idiom: real bundles via AppWebEntry, keyless RemoteMock transport). // Opens the fixture history session whose turn 73 carries an image in BOTH a // user message and an assistant message, and pins the product surfaces: the // history ImageGallery loading real fixture bytes through the authorized @@ -169,7 +169,7 @@ it('accepts a whole-page drop under the limits-labeled overlay and refuses an ov // A file drag anywhere over the page raises the full-viewport overlay whose // desc line carries the projected limits — copy that can only render after - // the imageLimits projection crossed the real fixture transport. + // the imageLimits projection crossed the RemoteMock transport. const image = new File([new Uint8Array([137, 80, 78, 71])], 'dropped.png', { type: 'image/png' }) const dataTransfer = { types: ['Files'], files: [image], dropEffect: 'none' } fireEvent.dragEnter(document.body, { dataTransfer }) @@ -207,7 +207,7 @@ it('accepts a whole-page drop under the limits-labeled overlay and refuses an ov }) it('renders a host dimension rejection with the projected 2000px limit', async () => { - mountAssembledApp('?fixture&fixturePrompt=reject') + mountAssembledApp({ remote: { rejectPrompt: true } }) const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) const start = tree.querySelector('button[aria-label="New session in fixture"]') diff --git a/apps/web/tests/max-tokens-notice.expected.e2e.ts b/apps/web/tests/max-tokens-notice.expected.e2e.ts index 5634315a32..38d23ee815 100644 --- a/apps/web/tests/max-tokens-notice.expected.e2e.ts +++ b/apps/web/tests/max-tokens-notice.expected.e2e.ts @@ -1,7 +1,7 @@ // @vitest-environment jsdom // Assembled max-tokens snapshot: boots the real built `packages/client/*/lib/ // client.js` bundles through AppWebEntry's ModuleLoader path against the -// keyless fixture Connection RPC, opens the fixture session, and pins the +// keyless RemoteMock transport, opens the fixture session, and pins the // surface its max-tokens turn (72) reaches — the turn-end notice row that a // provider output-cap truncation must render instead of ending silently. // diff --git a/apps/web/tests/replay-round-trip.e2e.ts b/apps/web/tests/replay-round-trip.e2e.ts index db60b35753..fce61a7bb3 100644 --- a/apps/web/tests/replay-round-trip.e2e.ts +++ b/apps/web/tests/replay-round-trip.e2e.ts @@ -213,7 +213,7 @@ describe('web e2e: fresh round trip through the real assembly', () => { it.skipIf(MODE === 'record')('expands and collapses the reasoning fold from its click target', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-round-trip-think')) // Interaction over the REAL wire-delivered transcript (the fixture-client - // tier pins the same gesture against the fixture Connection RPC; this one runs on + // tier pins the same gesture against RemoteMock; this one runs on // follow-stream-fed state). Runs after the golden capture so the committed // aria surface stays the untouched settled state. await expandTurnProcesses(page) diff --git a/apps/web/tests/search-card.expected.e2e.ts b/apps/web/tests/search-card.expected.e2e.ts index b54d14e8e7..181a6448cb 100644 --- a/apps/web/tests/search-card.expected.e2e.ts +++ b/apps/web/tests/search-card.expected.e2e.ts @@ -1,7 +1,7 @@ // @vitest-environment jsdom // Assembled search-card snapshot: boots the real built workspace client bundles // through AppWebEntry's ModuleLoader path against the keyless -// fixture Connection RPC (no API key, no model round), opens the fixture +// RemoteMock transport (no API key, no model round), opens the fixture // session, and pins the search card the `grep` turn (fixture turn 67) renders in // the assembled application. The built-boot smoke proves the graph boots but // intentionally carries no behavior assertions; this is the assembled-output check diff --git a/apps/web/tests/submission-echo.e2e.ts b/apps/web/tests/submission-echo.e2e.ts index 7693f3dc87..239c2c86e0 100644 --- a/apps/web/tests/submission-echo.e2e.ts +++ b/apps/web/tests/submission-echo.e2e.ts @@ -1,5 +1,5 @@ // @vitest-environment jsdom -// Local submission echo over the BUILT client graph (keyless fixture Connection RPC +// Local submission echo over the BUILT client graph (keyless RemoteMock // transport): a text-plus-image send paints its echo bubble synchronously on // the submit keystroke — before serialization, transport, or the fixture's // durable admission — with the composer already cleared and editable, and the diff --git a/apps/web/tests/todo-row.expected.e2e.ts b/apps/web/tests/todo-row.expected.e2e.ts index 21fa70d9bf..8bc45f4dfb 100644 --- a/apps/web/tests/todo-row.expected.e2e.ts +++ b/apps/web/tests/todo-row.expected.e2e.ts @@ -1,7 +1,7 @@ // @vitest-environment jsdom // Assembled todo snapshot: boots the real built `packages/client/*/lib/ // client.js` bundles through AppWebEntry's ModuleLoader path against the -// keyless fixture Connection RPC, opens the fixture session, and pins the +// keyless RemoteMock transport, opens the fixture session, and pins the // two surfaces the fixture's parallel plan (turn 74, two items `in_progress`) // reaches — the `todo_write` tool row and the dock's plan strip. // diff --git a/apps/web/tests/trajectory-image-display.expected.e2e.ts b/apps/web/tests/trajectory-image-display.expected.e2e.ts index 315d3baa1e..855e798e55 100644 --- a/apps/web/tests/trajectory-image-display.expected.e2e.ts +++ b/apps/web/tests/trajectory-image-display.expected.e2e.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom // Trajectory image surfaces over the BUILT client graph (the ptc-fixture -// idiom: real bundles via AppWebEntry, keyless fixture Connection RPC). +// idiom: real bundles via AppWebEntry, keyless RemoteMock transport). // Opens the fixture history session whose turn 73 carries an image in BOTH a // user message and an assistant message, and pins the Trajectory surfaces: // selecting the ledger record renders the shared ui-attachment gallery from diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 606d227b8f..0504de6821 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -135,6 +135,12 @@ }, { "path": "../../packages/client/modules" + }, + { + "path": "../../packages/test-support/client-runtime" + }, + { + "path": "../../packages/test-support/remote-mock" } ] } diff --git a/packages/api/gateway/package.json b/packages/api/gateway/package.json index 985db89e9e..b84b7acb4e 100644 --- a/packages/api/gateway/package.json +++ b/packages/api/gateway/package.json @@ -64,6 +64,7 @@ "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", + "@deepseek-ai/dsh-remote-mock": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^", "@deepseek-ai/dsh-util-crypto": "workspace:^", "@types/ws": "^8.18.1", diff --git a/packages/api/gateway/tests/gateway.client.spec.ts b/packages/api/gateway/tests/gateway.client.spec.ts index 7fb0ec8bdc..eea7b53343 100644 --- a/packages/api/gateway/tests/gateway.client.spec.ts +++ b/packages/api/gateway/tests/gateway.client.spec.ts @@ -3,8 +3,10 @@ import { Context, Service } from '@deepseek-ai/cordis' import type { Fiber } from '@deepseek-ai/cordis' import { describe, expect, expectTypeOf, it, vi } from 'vitest' import { z } from 'zod' +import { RemoteMock } from '@deepseek-ai/dsh-remote-mock' import { apply as applyConnection, + type ClientTransportHooks, type ConnectionGeneration, type ConnectionGenerationSource, type ConnectionHandle, @@ -1975,11 +1977,17 @@ describe('Client Typert API', () => { }) }) - it('publishes the Fixture Host facts after Remote events report ready', async () => { + it('publishes injected Host facts after Remote events report ready', async () => { const locationDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'location') + const transportDescriptor = Object.getOwnPropertyDescriptor(globalThis, '__DSH_TRANSPORT__') + const mock = RemoteMock.create({ host: { home: '/home/mock' } }) Object.defineProperty(globalThis, 'location', { configurable: true, - value: { hostname: '127.0.0.1', search: '?fixture' }, + value: { hostname: '127.0.0.1', search: '' }, + }) + Object.defineProperty(globalThis, '__DSH_TRANSPORT__', { + configurable: true, + value: { rpc: mock.rpc } satisfies ClientTransportHooks, }) const ctx = new Context() try { @@ -1987,15 +1995,17 @@ describe('Client Typert API', () => { await ctx.plugin({ inject: [], apply: applyConnection }) await ctx.plugin({ inject, apply }) const connection = ctx.get('connection') as ConnectionHandle | undefined - if (connection === undefined) throw new Error('fixture Connection service is unavailable') + if (connection === undefined) throw new Error('injected Connection service is unavailable') await vi.waitFor(() => { - expect(connection.generation.getSnapshot()?.host.home).toBe('/home/fixture') + expect(connection.generation.getSnapshot()?.host.home).toBe('/home/mock') }) } finally { await ctx.fiber.dispose() if (locationDescriptor === undefined) Reflect.deleteProperty(globalThis, 'location') else Object.defineProperty(globalThis, 'location', locationDescriptor) + if (transportDescriptor === undefined) Reflect.deleteProperty(globalThis, '__DSH_TRANSPORT__') + else Object.defineProperty(globalThis, '__DSH_TRANSPORT__', transportDescriptor) } }) diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index 756a10b3c6..7e91c669e0 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/connection/README.md -README.md: 0010f93a7fa06055756b7f07467eb01a685cfe39 -README.zh.md: 8db4ef1f558be022af54c3b7c617124b8649e9d9 +README.md: c8c2b2f6edb0ce43a07a664bcc2b3c9ece047b7b +README.zh.md: 2c25c0d3520fd7e5d3eeeec98c41cc1d3d4d25d8 diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index 0010f93a7f..c8c2b2f6ed 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -27,8 +27,6 @@ The package carries browser-to-Host Remote calls, exact Fetch responses, and con The browser uses HTTP POST for Remote unary calls. API Gateway owns the `/api/remote.mux` WebSocket and its logical streams; shell-owned compositions provide equivalent Remote streams through `connection.rpc.open` without opening a WebSocket. The browser plugin reads the page transport, recovery settings, and location, then delegates to `installConnection(ctx, options)`. A composition that owns its carrier may call the same installer directly; the whole-client test tier does so. Each invocation creates one Context-owned service, so several Client trees can use different carriers in one realm. The Host half always provides the carrier-neutral RPC and exact `GET`/`HEAD`/`POST` route registries. When a Web carrier is present it also owns the sole `/api` route, Fetch bridge, browser authentication, and Host/Origin checks; a shell-owned carrier dispatches the shared Fetch handler directly. Each exact route declares buffered or streaming request-body handling before the bridge reads any bytes. Typert Gateway claims generated Remote endpoints, feature packages register non-JSON responses such as Session-log downloads and raw file uploads, and unclaimed requests return 404. Loopback hostname classification remains package-internal to the browser-facing Client state. Browser raw-body transfer is provided by [`dsh-client-file-upload`](../file-upload/README.md). -The browser fixture follows the [Session Controller fork semantics](../../api/session-controller/README.md#use-this-package). - ----- diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index 8db4ef1f55..2c25c0d352 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -27,8 +27,6 @@ kind: "package-reference" 浏览器通过 HTTP POST 执行 Remote 一元调用;API Gateway 自己拥有 `/api/remote.mux` WebSocket 及其逻辑流。由 shell 持有的组合通过 `connection.rpc.open` 提供等价的 Remote 流,不打开 WebSocket。浏览器插件读取页面 transport、恢复设置与 location,再委托 `installConnection(ctx, options)`。持有自身载体的组合可以直接调用同一个安装函数;整机客户端测试档就是这一消费者。每次调用都会创建一个归所属 Context 的服务,因此同一 realm 中的多棵 Client 树可以使用不同载体。Host half 始终提供与载体无关的 RPC 注册表和精确 `GET`/`HEAD`/`POST` 路由注册表。存在 Web 载体时,它还持有唯一 `/api` route、Fetch bridge、浏览器认证与 Host/Origin 校验;由 shell 持有的载体则直接分派共享 Fetch handler。每条精确路由会在 bridge 读取任何字节前声明缓冲或流式请求体处理方式。Typert Gateway 认领生成的 Remote endpoint,功能包注册 Session 日志下载、原始文件上传等非 JSON 响应,未认领的请求返回 404。Loopback hostname 判定只供浏览器侧当前页面状态使用,留在包内。浏览器原始请求体传输由 [`dsh-client-file-upload`](../file-upload/README.zh.md) 提供。 -浏览器 fixture 遵循 [Session Controller 的 fork 语义](../../api/session-controller/README.zh.md#use-this-package)。 - ----- diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index 4732dcdd71..913a7bf56e 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-connection", - "description": "Authenticated RPC transport, generation lifecycle, and browser fixture", + "description": "Authenticated RPC transport and generation lifecycle", "version": "0.1.5-rc.2", "publishConfig": { "access": "public" @@ -48,16 +48,10 @@ }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/dsh-commands": "workspace:^", - "@deepseek-ai/dsh-host-directory-picker": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-settings": "workspace:^", - "@deepseek-ai/dsh-tool-todo": "workspace:^", - "@deepseek-ai/dsh-util-values": "workspace:^" + "@deepseek-ai/dsh-session": "workspace:^" } } diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts deleted file mode 100644 index b270122a4a..0000000000 --- a/packages/client/connection/src/client/fixture.ts +++ /dev/null @@ -1,4053 +0,0 @@ -// Standalone browser fixture for UI development without a server. - -import { - createAssistantMessage, - createSystemMessage, - createToolResultMessage, - createUserMessage, -} from '@deepseek-ai/dsh-llm/message' -import { brandString } from '@deepseek-ai/dsh-brand' -import type { MessageId, ToolCallId } from '@deepseek-ai/dsh-llm/brand' -import type { - AssistantMessage, - ContentBlock, - MessageSource, - StreamChunk, - TokenUsage, - ToolResultMessage, - UserMessage, -} from '@deepseek-ai/dsh-llm' -import { LlmAttemptId } from '@deepseek-ai/dsh-llm/brand' -import { - AssistantStreamAccumulator, - expandAssistantStream, - type AssistantStreamRecord, -} from '@deepseek-ai/dsh-llm/assistant-stream' -import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' -import type { - SessionEvent, - SessionId, - SessionSeqCursor, -} from '@deepseek-ai/dsh-session/types' -import { SESSION_FORMAT_VERSION, SessionSeq } from '@deepseek-ai/dsh-session/types' -import type { JsonValue } from '@deepseek-ai/dsh-util-values' -import type { TodoItem } from '@deepseek-ai/dsh-tool-todo/client' -// Type-only: the brand constructor is host-side; the fixture casts at its -// wire-fabrication boundary (the schema layer's one-cast-point posture). -import type { CommandDefinitionId, CommandId } from '@deepseek-ai/dsh-commands/brand' -import type { CommandDescriptor, CommandExecution, CommandResult } from '@deepseek-ai/dsh-commands/types' -import type { CredentialInfo } from '@deepseek-ai/dsh-credentials/types' -import type { DirectoryListing as FixtureDirectoryListing } from '@deepseek-ai/dsh-host-directory-picker/types' -import type { SettingsDescribeValue, SettingsNamespaceView } from '@deepseek-ai/dsh-settings/types' -import { deriveEventMessage, foldSurface } from '@deepseek-ai/dsh-session/surface' -import type { RpcResult } from './api.ts' -import { randomUuid } from './random-uuid.ts' -import type { - ClientConnectionRpc, ConnectionRpcFailure, ConnectionRpcResult, -} from '../rpc.ts' - -const FIXTURE_SESSION_SEARCH_RESULT_LIMIT = 20 - -interface ModelSelection { - readonly provider: string - readonly model: string - readonly reasoningEffort?: string -} - -interface ModelProviderGroup { - readonly id: string - readonly name: string - readonly models: readonly { - readonly id: string - readonly name: string - readonly description?: string - readonly reasoning?: { - readonly efforts: readonly { readonly id: string; readonly name: string; readonly description?: string }[] - readonly defaultEffort?: string - } - }[] -} - -/* jscpd:ignore-start -- The standalone fixture mirrors host timing without importing a target implementation. */ -function isFixtureTokenDelta(chunk: StreamChunk): boolean { - switch (chunk.type) { - case 'text-delta': - case 'reasoning-delta': - return chunk.text !== '' - case 'tool-call-delta': - return chunk.argumentsDelta !== '' || chunk.name !== undefined - default: - return false - } -} -/* jscpd:ignore-end */ - -interface FixtureSessionSummary { - readonly sessionId: SessionId - updatedAt: number - running: boolean - blank: boolean - readonly parentSessionId?: SessionId - readonly origin?: 'subagent' - readonly cwd?: string - readonly agentPreset?: string - readonly projections?: FixtureProjectionsBlock -} - -interface FixtureProjectionsBlock { - readonly asOfSeq: number - readonly values: Readonly> -} - -interface FixtureHistoryEntry { - readonly type: 'event' - readonly event: SessionEvent -} - -type FixtureHistoryRecord = FixtureHistoryEntry - -type FixtureSessionAddress = - | { readonly kind: 'session'; readonly sessionId: SessionId } - | { - readonly kind: 'subagent' - readonly parentSessionId: SessionId - readonly childSessionId: SessionId - readonly mode: 'one-shot' | 'continuable' - } - -interface FixtureFollowRequest { - readonly address: FixtureSessionAddress - readonly maxMessages?: number - readonly assistantStream?: true -} - -interface FixturePageRequest { - readonly address: FixtureSessionAddress - readonly throughSeq: number - readonly beforeSeq?: number - readonly maxMessages?: number -} - -interface FixtureSessionWireHeader { - readonly version: number - readonly id: SessionId - readonly createdAt: number - readonly cwd?: string - readonly parentSession?: SessionId - readonly isSeeded: boolean - readonly origin?: 'subagent' - readonly delegationDepth?: number - readonly agentPreset?: string -} - -type FixtureFollowFrame = - | { - readonly type: 'snapshot' - readonly header: FixtureSessionWireHeader - readonly cursor: number - readonly records: readonly FixtureHistoryRecord[] - readonly hasMore: boolean - readonly projections: FixtureProjectionsBlock - readonly assistantStream?: FixtureAssistantStreamBaseline - } - | FixtureHistoryEntry - | { readonly type: 'assistant-stream'; readonly frame: FixtureAssistantStreamFrame } - -type FixtureFollowEventFrame = Exclude - -interface FixtureAssistantStreamBaseline { - readonly revision: number - readonly activeAttempt?: { - readonly attemptId: ReturnType - readonly startedAfterSeq: number - readonly turn: number - readonly step: number - readonly nextIndex: number - readonly stream: readonly AssistantStreamRecord[] - } -} - -/** Assistant frame emitted by the standalone fixture; tests pin it to the controller wire type. */ -export type FixtureAssistantStreamFrame = - | { - readonly type: 'start' - readonly attemptId: ReturnType - readonly revision: number - readonly startedAfterSeq: SessionSeqCursor - readonly turn: number - readonly step: number - } - | { - readonly type: 'chunk' - readonly attemptId: ReturnType - readonly revision: number - readonly index: number - readonly time: number - readonly chunk: JsonValue - } - | { - readonly type: 'end' - readonly attemptId: ReturnType - readonly revision: number - readonly index: number - readonly outcome: - | { - readonly kind: 'committed' - readonly eventType: 'assistant/message' | 'assistant/attempt' - readonly seq: number - } - | { readonly kind: 'abandoned' } - } - -interface FixtureRemoteEventNotificationFrame { - readonly type: 'emit' - readonly event: string - readonly args: readonly unknown[] -} - -interface FixtureRemoteEventInvocationFrame { - readonly type: 'waterfall' - readonly event: string - readonly eventId: string - readonly agentId: SessionId - readonly request: Readonly> -} - -interface FixtureRemoteEventCancellationFrame { - readonly type: 'cancel' - readonly eventId: string -} - -type FixtureRemoteEventFrame = - | FixtureRemoteEventNotificationFrame - | FixtureRemoteEventInvocationFrame - | FixtureRemoteEventCancellationFrame - -interface FixtureRemoteEventResult { - readonly clientId: string - readonly eventId: string - readonly outcome: - | { readonly kind: 'next' } - | { readonly kind: 'result'; readonly value?: unknown } - | { - readonly kind: 'rejected' - readonly error: { - readonly name: string - readonly message: string - readonly code?: string - readonly details?: unknown - } - } -} - -interface FixtureRemoteEventReadyFrame { - readonly type: 'ready' - readonly clientId: string - readonly host: { readonly home: string } -} - -interface FixtureProjectionFrame { - readonly type: 'projection' - readonly sessionId: SessionId - readonly key: string - readonly value: unknown - readonly seq: number -} - -interface FixtureQuestionItem { - readonly id: string - readonly header?: string - readonly question: string - readonly detail?: string - readonly multiSelect?: boolean - readonly options?: readonly { readonly label: string; readonly description?: string }[] -} - -type FixtureControlFrame = - | { - readonly type: 'baseline' - readonly value: { - readonly queues: Readonly> - readonly jobs: Readonly> - readonly approvals: readonly never[] - readonly questions: readonly never[] - readonly projections: Readonly> - } - } - | FixtureProjectionFrame - -type FixturePromptPart = - | { readonly type: 'text'; readonly text: string } - | { - readonly type: 'image' - readonly mediaType: ImageAttachmentRef['mediaType'] - readonly data: string - readonly name?: string - } - -interface FixtureSessionApi { - list(request: { readonly cursor?: string }): Promise> - search( - request: { readonly query: string }, - signal: AbortSignal, - ): Promise> - create(request: { - readonly workspaceId?: WorkspaceId - readonly cwd?: string - readonly sessionId?: SessionId - readonly agentPreset?: string - }): Promise> - rename(request: { readonly sessionId: SessionId; readonly title: string }): Promise> - fork(request: { readonly sessionId: SessionId; readonly atSeq?: number }): Promise> - history(request: { - readonly sessionId: SessionId - readonly throughSeq?: number - readonly beforeSeq?: number - readonly maxMessages?: number - }): Promise> - selectModel(request: { - readonly sessionId: SessionId - readonly provider: string - readonly model: string - readonly reasoningEffort?: string - }): Promise> - prompt(request: { - readonly requestId: string - readonly sessionId: SessionId - readonly mode: 'queue' | 'steer' - readonly content: readonly FixturePromptPart[] - readonly clientTimeZone?: string - }): Promise> - attachment(request: { - readonly sessionId: SessionId - readonly attachmentId: AttachmentIdType - }): Promise> - updateQueue(request: { - readonly sessionId: SessionId - readonly itemId: MessageId - readonly action: unknown - }): Promise> - cancel(request: { readonly sessionId: SessionId }): Promise> -} - -type WorkspaceId = string & { readonly __fixtureWorkspaceId: 'WorkspaceId' } - -interface WorkspaceView { - readonly workspaceId: WorkspaceId - readonly path: string - readonly title: string - readonly sessionIds: readonly SessionId[] - readonly createdAt: string - readonly updatedAt: string -} - -interface WorkspaceCreateRequest { readonly path: string } -interface WorkspaceCreateValue { readonly workspace: WorkspaceView; readonly created: boolean } -interface WorkspaceRenameRequest { readonly workspaceId: WorkspaceId; readonly title: string } -interface WorkspaceValue { readonly workspace: WorkspaceView } -interface WorkspaceDeleteRequest { readonly workspaceId: WorkspaceId } -interface WorkspaceDeleteValue { readonly deleted: true } -interface WorkspaceInsertBeforeRequest { - readonly workspaceId: WorkspaceId - readonly beforeWorkspaceId?: WorkspaceId -} -interface WorkspaceOrderValue { readonly workspaceIds: readonly WorkspaceId[] } -interface WorkspaceInsertSessionBeforeRequest { - readonly workspaceId: WorkspaceId - readonly sessionId: SessionId - readonly beforeSessionId?: SessionId -} -interface WorkspaceArchiveSessionRequest { readonly sessionId: SessionId } -interface WorkspaceUnarchiveSessionRequest { readonly sessionId: SessionId } -interface WorkspaceArchiveValue { readonly archivedSessionIds: readonly SessionId[] } - -type WorkspaceFollowFrame = - | { - readonly type: 'baseline' - readonly value: { - readonly items: readonly WorkspaceView[] - readonly archivedSessionIds: readonly SessionId[] - } - } - | { readonly type: 'upsert'; readonly workspace: WorkspaceView } - | { readonly type: 'remove'; readonly workspaceId: WorkspaceId } - | { readonly type: 'order'; readonly workspaceIds: readonly WorkspaceId[] } - | { readonly type: 'archived'; readonly archivedSessionIds: readonly SessionId[] } - -interface FixtureWorkspaceApi { - create(request: WorkspaceCreateRequest): Promise> - rename(request: WorkspaceRenameRequest): Promise> - delete(request: WorkspaceDeleteRequest): Promise> - insertBefore(request: WorkspaceInsertBeforeRequest): Promise> - insertSessionBefore(request: WorkspaceInsertSessionBeforeRequest): Promise> - archiveSession(request: WorkspaceArchiveSessionRequest): Promise> - unarchiveSession(request: WorkspaceUnarchiveSessionRequest): Promise> -} - -interface FixtureWorkspace { - workspaceId: WorkspaceId - path: string - title: string - sessionIds: SessionId[] - createdAt: string - updatedAt: string -} - -function text(t: string): ContentBlock[] { - return [{ type: 'text', text: t }] -} - -function userMessage(content: ContentBlock[], source: MessageSource = { kind: 'user' }): UserMessage { - return createUserMessage({ content, source }) -} - -function assistantMessage(content: ContentBlock[], model = 'fx-1'): AssistantMessage { - return createAssistantMessage({ - content, - source: { provider: 'fixture', model }, - }) -} - -function toolResultMessage(callId: string, content: ContentBlock[], isError: boolean): ToolResultMessage { - return createToolResultMessage({ callId: brandString(callId), content, isError }) -} - -const MARKDOWN_FIXTURE = [ - '# Markdown fixture', - '', - 'Assistant output renders **strong text**, *emphasis*, and `inline code`.', - '', - '- first item', - ' - nested item', - '', - '| Area | State |', - '| --- | --- |', - '| history | rendered |', - '| streaming | stable |', - '', - '[DeepSeek](https://www.deepseek.com)', - '', - '```ts', - 'const markdown = true', - '```', -].join('\n') - -const USER_MARKDOWN_LITERAL = '用户字面量:# 不渲染 `code` [link](https://example.com)' - -/** - * SGR wrapper for the terminal output sample below: authoring the escapes as - * `\u001b` keeps literal control bytes out of this source file. - * @param code - the SGR parameter (an ANSI color or attribute number). - * @param body - the text the attribute applies to. - * @returns the body wrapped in the attribute and a reset. - */ -function sgr(code: number, body: string): string { - return `\u001b[${code}m${body}\u001b[0m` -} - -/** - * Terminal output sample for fixture turn 66, authored to carry every feature - * the terminal card draws that turn 60's two prompt rows cannot reach: - * basic-16 SGR foreground runs (green, red, bright-black) that must resolve to - * `--dsw-*` tokens, a bold run, column-aligned table rows that must scroll - * rather than fold, more than DEFAULT_TERMINAL_MAX_LINES (16) lines so the - * height cap collapses the middle. This constant is the visible body; the call - * site appends the shell result's `[exit code: N]` marker so Client derivation - * can consume it into the terminal status pill. - */ -const TERMINAL_OUTPUT_FIXTURE = [ - sgr(1, 'Running 4 checks'), - `${sgr(32, '\u2713')} typecheck 1.82s`, - `${sgr(32, '\u2713')} lint 0.94s`, - `${sgr(32, '\u2713')} duplication 2.10s`, - `${sgr(31, '\u2717')} unit 8.41s`, - '', - sgr(90, 'packages/client/ui-primitives/tests/terminal-block.client.spec.tsx'), - ` ${sgr(31, 'FAIL')} caps output at the configured line budget`, - ' expected 16 lines, received 24', - '', - 'NAME LINES BRANCHES FUNCTIONS UNCOVERED', - 'TerminalBlock.tsx 100% 100% 100% -', - 'ansi.ts 100% 100% 100% -', - 'clipboard.ts 100% 100% 100% -', - 'CodeBlock.tsx 98.4% 96.2% 100% 41-43', - 'highlight.ts 100% 100% 100% -', - 'Pill.tsx 100% 100% 100% -', - 'StateDot.tsx 100% 100% 100% -', - 'markdown/Markdown.tsx 100% 100% 100% -', - '', - sgr(31, '1 of 4 checks failed'), -].join('\n') - -/** - * Structured grep metadata for the search sample (turn 67). `truncated` with a - * larger `total` than the retained match count exercises the search card's - * capped indicator; the file with more than CHAT_SEARCH_MAX_LINES rows - * exercises its head/tail height cap. - */ -const SEARCH_MATCHES_FIXTURE: { path: string; matches: { lineNumber: number; line: string }[] }[] = [ - { - path: 'packages/client/ui-primitives/src/SearchBlock.tsx', - matches: [ - { lineNumber: 16, line: 'export const DEFAULT_SEARCH_MAX_LINES = 16' }, - { lineNumber: 138, line: 'export function SearchBlock(props: SearchBlockProps) {' }, - { lineNumber: 141, line: ' const [collapsed, setCollapsed] = useState>(() => new Set())' }, - ], - }, - { - path: 'packages/client/ui-tool/src/client/tool/models/search-card-model.ts', - matches: [ - { lineNumber: 45, line: 'export const CHAT_SEARCH_MAX_LINES = 8' }, - { lineNumber: 130, line: 'export function searchCardModel(block: ToolCallBlock): SearchCardModel | null {' }, - ], - }, - { - path: 'packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx', - matches: [ - { lineNumber: 34, line: 'export function SearchRow({ toolName, block, inspect, t }: SearchRowProps) {' }, - { lineNumber: 36, line: ' const search = searchCardModel(block)' }, - { lineNumber: 56, line: ' search={search}' }, - { lineNumber: 78, line: " yield ctx.slots.register({ name: 'tool.call.toolview', key: 'grep', locale: NS }, SearchRow)" }, - ], - }, -] - -const SEARCH_MATCHES_TEXT = [ - 'Found 9 of 42 matches', - '', - ...SEARCH_MATCHES_FIXTURE.map(file => - [file.path, ...file.matches.map(m => `Line ${m.lineNumber}: ${m.line}`)].join('\n')), - '', - '(Full grep result stored at: fixture://spill/grep-66. Read it to see every match.)', -].join('\n') - -const SEARCH_PATHS_FIXTURE = [ - 'packages/client/ui-primitives/src/SearchBlock.tsx', - 'packages/client/ui-primitives/src/SearchBlock.module.css', - 'packages/client/ui-tool/src/client/tool/models/search-card-model.ts', - 'packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx', - 'packages/client/ui-tool/tests/search-card.client.spec.tsx', -] - -const SEARCH_PATHS_TEXT = [ - ...SEARCH_PATHS_FIXTURE, - '', - '(Showing 5 of 23 paths. Full sorted result stored at: fixture://spill/glob-67. Read it to see every path.)', -].join('\n') - -const READ_SAMPLE_FIRST_LINE = 41 -const READ_SAMPLE_SOURCE = [ - 'export interface ReadBlockProps {', - ' label?: string | undefined', - ' lines: readonly ReadBlockLine[]', - ' totalLines: number', - ' lang?: string | undefined', - ' maxLines?: number | undefined', - ' className?: string | undefined', - '}', - '', - '// A windowed read keeps the file line numbers in the gutter.', - 'const marker = "fixture read sample"', -] -const READ_SAMPLE_LINES = READ_SAMPLE_SOURCE.map((text, index) => ({ number: READ_SAMPLE_FIRST_LINE + index, text })) -const READ_SAMPLE_PATH = 'packages/client/ui-primitives/src/ReadBlock.tsx' -const READ_SAMPLE_TOTAL = 180 -const READ_SAMPLE_LAST_LINE = READ_SAMPLE_FIRST_LINE + READ_SAMPLE_SOURCE.length - 1 -const READ_SAMPLE_TEXT = [ - `${READ_SAMPLE_PATH}`, - 'file', - '', - ...READ_SAMPLE_SOURCE.map((text, index) => `${READ_SAMPLE_FIRST_LINE + index}: ${text}`), - '', - `(Showing lines ${READ_SAMPLE_FIRST_LINE}-${READ_SAMPLE_LAST_LINE} of ${READ_SAMPLE_TOTAL}. Use offset=${READ_SAMPLE_LAST_LINE + 1} to continue.)`, - '', -].join('\n') - -/** - * The `web_search` result metadata for the web-search turn. The sources cover a - * titled source with a snippet and date, a hostname-label fallback, and a - * titled source without a snippet; `truncated` exercises the capped indicator. - */ -const WEB_SEARCH_META = { - answer: 'DeepSeek Harness is a plugin-based agent harness on vendored Cordis where **every capability is a plugin**.', - sources: [ - { - url: 'https://github.com/deepseek-ai/deepseek-harness', - title: 'DeepSeek Harness — plugin-based agent harness', - snippet: 'Everything is a plugin: session, tools, agent-loop, and LLM adapters all mount on the same Cordis context.', - publishedAt: '2026-07-01', - }, - { - url: 'https://www.deepseek.com/blog/harness-architecture', - snippet: 'The capability-seam pattern splits each capability into interface, implementation, and consumer packages.', - }, - { - url: 'https://docs.deepseek.com/harness/plugins', - title: 'Writing a harness plugin', - publishedAt: '2026-06-15', - }, - ], - truncated: true, -} satisfies JsonValue - -/** The `web_fetch` result metadata for the web-fetch turn. */ -const WEB_FETCH_META = { - url: 'https://www.deepseek.com/blog/harness-architecture', - statusCode: 200, - truncated: false, -} satisfies JsonValue - -const DEEPSEEK_REASONING = { - efforts: [ - { id: 'off', name: 'Off' }, - { id: 'high', name: 'High' }, - { id: 'max', name: 'Max' }, - ], - defaultEffort: 'high', -} - -const OPENAI_REASONING = { - efforts: [ - { id: 'off', name: 'Off' }, - { id: 'medium', name: 'Medium' }, - { id: 'high', name: 'High' }, - { id: 'max', name: 'Max' }, - ], - defaultEffort: 'medium', -} - -/** Catalog served by `session/modelCatalog` (fresh copies per call). */ -function fixtureModelGroups(): ModelProviderGroup[] { - return [ - { - id: 'deepseek-official', - name: 'DeepSeek', - models: [ - { - id: 'deepseek-v4-flash', - name: 'DeepSeek-V4-Flash', - description: '快速响应', - reasoning: DEEPSEEK_REASONING, - }, - { - id: 'deepseek-v4-pro', - name: 'DeepSeek-V4-Pro', - description: '复杂任务', - reasoning: DEEPSEEK_REASONING, - }, - ], - }, - { - id: 'openai', - name: 'OpenAI', - models: [{ id: 'gpt-5', name: 'GPT-5', reasoning: OPENAI_REASONING }], - }, - ] -} - -function sid(id: string): SessionId { - return id as SessionId -} - -const FIXTURE_IMAGE_DATA = 'iVBORw0KGgoAAAANSUhEUgAAAKAAAABaCAYAAAA/xl1SAAAAvklEQVR42u3SMQ0AAAjAMIyhELM4AAe8PD1qYFlk9cCXEAEDYkAwIAYEA2JAMCAGBANiQDAgBgQDYkAwIAYEA2JAMCAGBANiQDAgBgQDYkAwIAYEA2JAMCAGxIBCYEAMCAbEgGBADAgGxIBgQAwIBsSAYEAMCAbEgGBADAgGxIBgQAwIBsSAYEAMCAbEgGBADAgGxIAYEAyIAcGAGBAMiAHBgBgQDIgBwYAYEAyIAcGAGBAMiAHBgBgQDIgB4bYWLb6pnOb1xAAAAABJRU5ErkJggg==' -const FIXTURE_IMAGE_REF: ImageAttachmentRef = { - attachmentId: 'fixture:image' as AttachmentIdType, - mediaType: 'image/png', - bytes: 247, - width: 160, - height: 90, - name: 'fixture-image.png', -} - -/** Deterministic provider billing attached to fixture assistant messages. */ -function fixtureUsage(turn: number, step: number): TokenUsage { - return { - inputTokens: 20 + turn % 5, - outputTokens: 8 + step, - cacheReadTokens: turn === 0 ? 0 : 80, - cacheWriteTokens: turn % 10 === 0 ? 4 : 0, - } -} - -/** Build a lossless settled stream for static fixture messages. */ -function fixtureSettledStream( - message: AssistantMessage, - usage: TokenUsage, - time: number, -): AssistantStreamRecord[] { - const stream: AssistantStreamRecord[] = [] - for (const [index, block] of message.content.entries()) { - stream.push( - { type: 'chunk', time, chunk: { type: 'block-start', index, blockType: block.type } }, - { type: 'chunk', time, chunk: { type: 'block-end', index, block } }, - ) - } - stream.push( - { type: 'chunk', time, chunk: { type: 'usage', usage } }, - { type: 'chunk', time, chunk: { type: 'finish', reason: { kind: 'stop' } } }, - ) - return stream -} - -/** Rendered system prompt of the fx-alpha history: surface node 0. */ -const FIXTURE_SYSTEM_PROMPT = '你是 DeepSeek Harness 的 fixture 助手。用简洁的中文回答,并在需要时调用工具。' - -/** fx-alpha history script: 75 turns (~150+ messages -> 4 pages at PAGE_MESSAGES=50), - * mixing reasoning blocks / tool call+result / context. */ -function buildAlphaLog(): SessionEvent[] { - const events: Record[] = [] - let time = Date.now() - 3_600_000 - const push = (e: Record): number => { - const seq = events.length - const data = e['data'] as Record | undefined - const nextTime = time + 800 - const authored = e['type'] === 'assistant/message' && data !== undefined - ? { - ...e, - data: { - ...data, - usage: fixtureUsage(data['turn'] as number, data['step'] as number), - stream: fixtureSettledStream( - data['message'] as AssistantMessage, - fixtureUsage(data['turn'] as number, data['step'] as number), - nextTime, - ), - }, - } - : e - events.push({ seq, time: (time = nextTime), ...authored }) - return seq - } - // Completed fixture requests retain the route capacity recorded with them. - push({ - type: 'request/context', - data: { provider: 'deepseek-official', model: 'deepseek-v4-flash', contextWindow: 128_000 }, - }) - for (let turn = 0; turn < 60; turn++) { - push({ type: 'turn/start', data: { turn } }) - // The rendered system prompt is surface node 0, ahead of the first user message. - if (turn === 0) { - push({ - type: 'system/message', surfaceOp: 'append', - data: { turn, step: 0, message: createSystemMessage(FIXTURE_SYSTEM_PROMPT, '@deepseek-ai/dsh-system-prompt') }, - }) - } - const userSeq = push({ - type: 'user/message', surfaceOp: 'append', - data: userMessage(text(turn === 59 ? USER_MARKDOWN_LITERAL : `问题 ${turn}:fixture 历史消息,用于翻页与渲染验收。`)), - }) - if (turn === 0) { - push({ - type: 'session/title', - data: { title: 'Fixture 历史会话', messageSeqs: [userSeq], source: { kind: 'fallback' } }, - }) - } - if (turn % 9 === 4) { - push({ type: 'user/message', surfaceOp: 'append', data: userMessage(text(`[fixture] 上下文注入(turn ${turn})`), { kind: 'plugin', plugin: 'fixture' }) }) - } - push({ type: 'step/start', data: { turn, step: 0 } }) - const withTool = turn % 5 === 2 - const withReasoning = turn % 3 === 1 - const blocks: ContentBlock[] = [] - if (withReasoning) blocks.push({ type: 'reasoning', text: `思考过程 ${turn}:这是一段可折叠的 reasoning 内容。` }) - blocks.push({ type: 'text', text: turn === 59 ? MARKDOWN_FIXTURE : `回答 ${turn}:这是 fixture 生成的历史回复正文。` }) - if (withTool) { - const callId = `fx-call-${turn}` - blocks.push({ type: 'tool-call', id: callId, name: 'echo', arguments: `{"text":"turn ${turn}"}` } as ContentBlock) - push({ type: 'assistant/message', surfaceOp: 'append', data: { turn, step: 0, message: assistantMessage(blocks) } }) - push({ type: 'tool/call', data: { turn, step: 0, callId, name: 'echo', arguments: `{"text":"turn ${turn}"}` } }) - push({ type: 'tool/result', surfaceOp: 'append', data: { turn, step: 0, message: toolResultMessage(callId, text(`ECHO: TURN ${turn}`), turn % 25 === 12) } }) - push({ type: 'step/end', data: { turn, step: 0 } }) - push({ type: 'step/start', data: { turn, step: 1 } }) - push({ type: 'assistant/message', surfaceOp: 'append', data: { turn, step: 1, message: assistantMessage(text(`工具结果已消化(turn ${turn})。`)) } }) - push({ type: 'step/end', data: { turn, step: 1 } }) - } else { - push({ type: 'assistant/message', surfaceOp: 'append', data: { turn, step: 0, message: assistantMessage(blocks) } }) - push({ type: 'step/end', data: { turn, step: 0 } }) - } - push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } }) - } - // The structured samples use real first-party names and result metadata so - // the fixture follows the same event-to-card path as a persisted Session. - // `echo` above remains the unknown-tool fallback. - const toolTurn = ( - turn: number, - name: string, - args: string, - resultText: string, - resultMeta?: JsonValue, - ): void => { - const callId = `fx-call-${turn}` - push({ type: 'turn/start', data: { turn } }) - push({ type: 'user/message', surfaceOp: 'append', data: userMessage(text(`问题 ${turn}:${name} 样本。`)) }) - push({ type: 'step/start', data: { turn, step: 0 } }) - push({ - type: 'assistant/message', surfaceOp: 'append', - data: { turn, step: 0, message: assistantMessage([{ type: 'tool-call', id: callId, name, arguments: args } as ContentBlock]) }, - }) - push({ type: 'tool/call', data: { turn, step: 0, callId, name, arguments: args } }) - push({ - type: 'tool/result', - surfaceOp: 'append', - data: { - turn, - step: 0, - message: toolResultMessage(callId, text(resultText), false), - ...resultMeta === undefined ? {} : { meta: resultMeta }, - }, - }) - push({ type: 'step/end', data: { turn, step: 0 } }) - push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } }) - } - // A two-line command, so the fixture covers the terminal card's one-row-per- - // command-line prompt (and that the card still marks the call exactly once). - toolTurn( - 60, - 'bash', - '{"command":"ls -la\\necho done","description":"fixture 终端样本","workdir":"/tmp/fixture"}', - 'total 2\ndrwxr-xr-x fixture\n-rw-r--r-- demo.txt', - ) - toolTurn( - 61, - 'write', - '{"file_path":"notes/demo.txt","content":"hello fixture\\n"}', - 'wrote notes/demo.txt', - { diffs: [{ path: 'notes/demo.txt', oldText: null, newText: 'hello fixture\n' }] }, - ) - toolTurn( - 62, - 'edit', - '{"file_path":"notes/demo.txt","old_string":"hello","new_string":"hello fixture"}', - '已编辑', - { diffs: [{ path: 'notes/demo.txt', oldText: 'hello', newText: 'hello fixture' }] }, - ) - toolTurn( - 63, - 'write', - '{"file_path":"notes/new-demo.txt","content":"hello fixture\\n"}', - '已写入', - { diffs: [{ path: 'notes/new-demo.txt', oldText: null, newText: 'hello fixture\n' }] }, - ) - // Turn 64: a multi-hunk edit — two scattered replacements in one file. Named - // `edit` so it lands on the keyed FileMutationRow (the resident diff card the - // single-hunk turn 62 also uses). Its result metadata carries two scattered - // hunks under one path header, so the card draws the first hunk, a `⋯` gap, - // then the second (the same-file - // second-hunk arm turns 62/63 cannot reach). - toolTurn( - 64, - 'edit', - '{"file_path":"src/config.ts","old_string":"const timeout = 30","new_string":"const timeout = 60"}', - '已编辑', - { - diffs: [ - { path: 'src/config.ts', oldText: 'const timeout = 30', newText: 'const timeout = 60' }, - { path: 'src/config.ts', oldText: 'retries: 1', newText: 'retries: 3' }, - ], - }, - ) - // Turn 65: one run_code turn with three logged sub-dispatches — the Code - // Mode acceptance surface (parent code row + nested native-identical rows, - // including an isError sub-call and a bash sub-call that must hit the same - // keyed registration a top-level bash row uses). - { - const turn = 65 - const callId = `fx-call-${turn}` - const program = 'const listing = await tools.bash({ command: "ls notes", description: "List notes" })\n' - + 'const demo = await tools.read({ file_path: "notes/demo.txt" })\n' - + 'await tools.read({ file_path: "notes/missing.txt" }).catch(() => "tolerated")\n' - + 'return { listing, demo }' - const args = JSON.stringify({ code: program, description: 'Read the notes files and summarize' }) - push({ type: 'turn/start', data: { turn } }) - push({ type: 'user/message', surfaceOp: 'append', data: userMessage(text(`问题 ${turn}:run_code 样本。`)) }) - push({ type: 'step/start', data: { turn, step: 0 } }) - push({ - type: 'assistant/message', surfaceOp: 'append', - data: { turn, step: 0, message: assistantMessage([{ type: 'tool-call', id: callId, name: 'run_code', arguments: args } as ContentBlock]) }, - }) - push({ type: 'tool/call', data: { turn, step: 0, callId, name: 'run_code', arguments: args } }) - const dispatchPair = (n: number, name: string, dispatchArgs: Record, resultText: string, isError = false): void => { - push({ - type: 'tool/ptc-dispatch-start', - data: { rootCallId: callId, parentCallId: callId, subCallId: `${callId}:ptc:${n}`, name, arguments: dispatchArgs }, - }) - push({ - type: 'tool/ptc-dispatch', - data: { - rootCallId: callId, parentCallId: callId, subCallId: `${callId}:ptc:${n}`, name, - arguments: dispatchArgs, isError, content: [{ type: 'text', text: resultText }], - }, - }) - } - dispatchPair(1, 'bash', { command: 'ls notes', description: 'List notes' }, 'demo.txt\nnew-demo.txt') - dispatchPair(2, 'read', { file_path: 'notes/demo.txt' }, 'hello fixture\n') - dispatchPair(3, 'read', { file_path: 'notes/missing.txt' }, 'Error: ENOENT: notes/missing.txt not found', true) - push({ - type: 'tool/result', surfaceOp: 'append', - data: { turn, step: 0, message: toolResultMessage(callId, text('{"listing":"demo.txt\\nnew-demo.txt","demo":"hello fixture\\n"}'), false) }, - }) - push({ type: 'step/end', data: { turn, step: 0 } }) - push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } }) - } - // Turn 74: todo_write sample — the TodoRow toolview in the flow plus the - // todo/write snapshot event feeding the TodoPanel plan strip. Two items are - // in_progress: this fixture chooses the parallel policy, so both surfaces - // must render a parallel plan rather than the first active item alone. - const fixtureTodos = [ - { content: '梳理需求', status: 'completed' }, - { content: '实现 fixture 样本', status: 'in_progress' }, - { content: '跑后台构建', status: 'in_progress' }, - { content: '浏览器验收', status: 'pending' }, - ] - // Turn 66: the terminal sample turn 60's two clean prompt rows cannot cover — - // ANSI SGR coloring, output past the terminal card's height cap, a nested cwd - // whose prompt label is its last segment, and a non-zero exit. The raw result - // includes an `[exit code: N]` marker below; Client - // derivation consumes it into the status pill before rendering the body. - // - // Ordered BEFORE the todo turn deliberately: the standing plan retires at the - // next `turn/start`, so a turn appended after it would leave the dock's plan - // strip empty and take the todo surfaces' own coverage with it. - toolTurn( - 66, - 'bash', - '{"command":"pnpm run check","description":"fixture 终端样本","workdir":"/tmp/fixture/deep/nested"}', - `${TERMINAL_OUTPUT_FIXTURE}\n[exit code: 1]`, - ) - - // Turns 67-68 carry the search card's two metadata variants: grouped matches - // and a flat path list, both truncated with a larger pre-cap total. Both use - // the keyed SearchRow registration. They stay before the todo turn for the - // same standing-plan reason as the bash turn. - toolTurn( - 67, - 'grep', - '{"pattern":"SEARCH_MAX_LINES","path":"packages/client"}', - SEARCH_MATCHES_TEXT, - { shape: 'matches', files: SEARCH_MATCHES_FIXTURE, truncated: true, total: 42 }, - ) - toolTurn( - 68, - 'glob', - '{"pattern":"**/SearchBlock*","path":"packages/client"}', - SEARCH_PATHS_TEXT, - { shape: 'paths', paths: SEARCH_PATHS_FIXTURE, truncated: true, total: 23 }, - ) - - // Turn 69: the read sample — a WINDOW past an offset so the card draws file - // line numbers starting above 1 and a "showing N of M" note (the window is - // shorter than READ_SAMPLE_TOTAL), with a `ts` language hint the shiki path - // highlights. Named `read`, so it exercises the keyed ReadRow registration. - // The run_code sub-dispatches above cover nested read calls without result - // metadata; this top-level result carries the structured window. - toolTurn( - 69, - 'read', - `{"file_path":${JSON.stringify(READ_SAMPLE_PATH)},"offset":${READ_SAMPLE_FIRST_LINE}}`, - READ_SAMPLE_TEXT, - { - path: READ_SAMPLE_PATH, - offset: READ_SAMPLE_FIRST_LINE, - lines: READ_SAMPLE_LINES, - totalLines: READ_SAMPLE_TOTAL, - lang: 'ts', - }, - ) - - // Turns 70-71 carry the web tools' result metadata. They stay before the todo - // turn because a later turn/start retires the standing plan projection. - toolTurn( - 70, - 'web_search', - '{"queries":["deepseek harness architecture"]}', - 'Search results for deepseek harness architecture.', - WEB_SEARCH_META, - ) - toolTurn( - 71, - 'web_fetch', - '{"url":"https://www.deepseek.com/blog/harness-architecture"}', - '# Harness architecture\n\nEverything is a plugin.', - WEB_FETCH_META, - ) - - // Turn 72: max-tokens sample — the provider ends the turn at its output cap - // mid-sentence, so the chat flow must render the turn-max-tokens notice - // instead of ending silently. Ordered before the todo turn for the same - // standing-plan reason the bash turn is. - push({ type: 'turn/start', data: { turn: 72 } }) - push({ type: 'user/message', surfaceOp: 'append', data: userMessage(text('问题 72:请完整列出全部一百条条目。')) }) - push({ type: 'step/start', data: { turn: 72, step: 0 } }) - push({ - type: 'assistant/message', - surfaceOp: 'append', - data: { turn: 72, step: 0, message: assistantMessage(text('条目 1:第一条。条目 2:第二条。条目 3:这一条写到一半被')) }, - }) - push({ type: 'step/end', data: { turn: 72, step: 0 } }) - push({ type: 'turn/end', data: { turn: 72, reason: { kind: 'max-tokens' } } }) - - // Turn 73: user and assistant images share one durable fixture object. - // The todo turn remains last so its standing projection stays visible. - push({ type: 'turn/start', data: { turn: 73 } }) - push({ - type: 'user/message', - surfaceOp: 'append', - data: userMessage([{ type: 'image', attachment: FIXTURE_IMAGE_REF }, ...text('历史用户图片')]), - }) - push({ type: 'step/start', data: { turn: 73, step: 0 } }) - push({ - type: 'assistant/message', - surfaceOp: 'append', - data: { - turn: 73, - step: 0, - message: assistantMessage( - [...text('结构化模型图片:'), { type: 'image', attachment: FIXTURE_IMAGE_REF }], - 'fx-vision', - ), - }, - }) - push({ type: 'step/end', data: { turn: 73, step: 0 } }) - push({ type: 'turn/end', data: { turn: 73, reason: { kind: 'completed' } } }) - - const todoArgs = JSON.stringify({ todos: fixtureTodos }) - toolTurn(74, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 2 in progress, 1 completed.') - // The real tool appends the snapshot mid-execution — between tool/call and - // tool/result — so the fixture reproduces that exact ordering (the last - // toolTurn events run ... tool/call, tool/result, step/end, turn/end). - const callIndex = events.length - 4 - const callTime = events[callIndex]?.time as number - events.splice(callIndex + 1, 0, { type: 'todo/write', time: callTime + 400, data: { todos: fixtureTodos } }) - events.forEach((e, i) => { e.seq = i }) - return events as unknown as SessionEvent[] -} - -/** - * Fixture parallel of the plan unit's lifecycle fold. The paired - * `command/done` retains successful plan selections and drops failures; - * `plan/mode` commits one. `wanted` is exposed for the prompt boundary (the - * fixture's step/start parallel). - */ -function foldPlan(log: readonly SessionEvent[]): { active: boolean; pending: boolean; wanted: boolean | null } { - let active = false - let wanted: boolean | null = null - let running: { commandId: unknown; wanted: boolean } | null = null - for (const event of log) { - const item = event as unknown as { type: string; data?: Record } - if (item.type === 'command/run' && item.data?.['name'] === 'plan') { - const args = item.data['args'] - if (typeof args !== 'string') continue - running = { commandId: item.data['commandId'], wanted: args.trim() !== 'off' } - } else if (item.type === 'command/done' - && item.data !== undefined - && running !== null - && item.data['commandId'] === running.commandId) { - wanted = item.data['kind'] === 'success' && running.wanted !== active ? running.wanted : null - running = null - } else if (item.type === 'plan/mode') { - active = item.data?.['active'] === true - wanted = null - } - } - const selected = running?.wanted ?? wanted - return { active, pending: selected !== null && selected !== active, wanted: selected } -} - -/** The plan projection's wire view over the full log. */ -function planViewOf(log: readonly SessionEvent[]): { active: boolean; pending: boolean } { - const plan = foldPlan(log) - return { active: plan.active, pending: plan.pending } -} - -/** Fixture parallel of the host's projection units: whole current values per key over the full log. */ -/** Fixture preset table (the host PermissionPresetService defaults). */ -const PERMISSION_PRESETS: Record = { - 'workspace-write': { sandbox: 'workspace-write', approval: 'ask', description: 'Write inside the workspace and permitted temporary directories; wider retries require approval.' }, - 'danger-full-access': { sandbox: 'danger-full-access', approval: 'never', description: 'Full file access without approval prompts.' }, -} - -/** Host permissions-unit parallel: fold the three knob events, derive the select over the fixture defaults. */ -function permissionSelectOf( - log: readonly SessionEvent[], -): { options: { value: string; name: string; description?: string }[]; currentValue: string } { - let preset: string | null = null - let sandbox = 'workspace-write' - let approval = 'ask' - for (const event of log) { - const item = event as { type: string; data: Record } - if (item.type === 'permission/preset') preset = item.data['preset'] as string - else if (item.type === 'sandbox/mode') sandbox = item.data['mode'] as string - else if (item.type === 'approval/policy') approval = item.data['policy'] as string - } - const matches = (spec: { sandbox: string; approval: string }): boolean => spec.sandbox === sandbox && spec.approval === approval - let currentValue = 'custom' - const folded = preset === null ? undefined : PERMISSION_PRESETS[preset] - if (preset !== null && folded !== undefined && matches(folded)) { - currentValue = preset - } else { - for (const [name, spec] of Object.entries(PERMISSION_PRESETS)) { - if (matches(spec)) { currentValue = name; break } - } - } - return { - options: [ - ...Object.entries(PERMISSION_PRESETS).map(([value, spec]) => ({ value, name: value, description: spec.description })), - ...currentValue === 'custom' ? [{ value: 'custom', name: 'Custom', description: 'Current sandbox and approval settings do not match a preset.' }] : [], - ], - currentValue, - } -} - -interface FixtureTokenUsageProjection { - uncachedInputTokens: number - outputTokens: number - cacheReadTokens: number - cacheWriteTokens: number -} - -interface FixtureUsageSample { - turn: number - step: number - usage: TokenUsage -} - -/** Read one provider usage sample from either durable carrier. */ -function usageSampleOf(event: SessionEvent): FixtureUsageSample | undefined { - if (event.type !== 'assistant/message' && event.type !== 'assistant/attempt') return undefined - let usage = event.type === 'assistant/message' ? event.data.usage : undefined - for (const member of expandAssistantStream(event.data.stream)) { - if (member.chunk.type === 'usage') usage = member.chunk.usage - } - return usage === undefined - ? undefined - : { turn: event.data.turn, step: event.data.step, usage } -} - -/** Fixture parallel of token-meter's last-sample-replacing usage projection. */ -function tokenUsageOf(log: readonly SessionEvent[]): FixtureTokenUsageProjection { - const totals: FixtureTokenUsageProjection = { - uncachedInputTokens: 0, - outputTokens: 0, - cacheReadTokens: 0, - cacheWriteTokens: 0, - } - let last: { - turn: number - step: number - buckets: FixtureTokenUsageProjection - } | null = null - for (const event of log) { - const sample = usageSampleOf(event) - if (sample === undefined) continue - const buckets: FixtureTokenUsageProjection = { - uncachedInputTokens: sample.usage.inputTokens, - outputTokens: sample.usage.outputTokens, - cacheReadTokens: sample.usage.cacheReadTokens ?? 0, - cacheWriteTokens: sample.usage.cacheWriteTokens ?? 0, - } - const previous = last?.turn === sample.turn && last.step === sample.step - ? last.buckets - : undefined - totals.uncachedInputTokens += buckets.uncachedInputTokens - (previous?.uncachedInputTokens ?? 0) - totals.outputTokens += buckets.outputTokens - (previous?.outputTokens ?? 0) - totals.cacheReadTokens += buckets.cacheReadTokens - (previous?.cacheReadTokens ?? 0) - totals.cacheWriteTokens += buckets.cacheWriteTokens - (previous?.cacheWriteTokens ?? 0) - last = { turn: sample.turn, step: sample.step, buckets } - } - return totals -} - -/** Fixture parallel of session-stats' whole-log counting and wall-time fold. */ -function sessionStatsOf(log: readonly SessionEvent[]): { - turns: number - steps: number - llmMs: number - toolMs: number - ttftMs: number - ttftSteps: number - decodeMs: number - decodeTokens: number -} { - const value = { turns: 0, steps: 0, llmMs: 0, toolMs: 0, ttftMs: 0, ttftSteps: 0, decodeMs: 0, decodeTokens: 0 } - let lastTurn: number | null = null - let openStep: { turn: number; step: number; startTime: number; firstTokenTime: number | null } | null = null - const pendingCalls = new Map() - for (const event of log) { - switch (event.type) { - case 'step/start': - openStep = { turn: event.data.turn, step: event.data.step, startTime: event.time, firstTokenTime: null } - break - case 'assistant/attempt': { - if (openStep === null || openStep.turn !== event.data.turn || openStep.step !== event.data.step) break - const first = expandAssistantStream(event.data.stream) - .find(member => isFixtureTokenDelta(member.chunk))?.time - if (openStep.firstTokenTime === null && first !== undefined) openStep.firstTokenTime = first - break - } - case 'assistant/message': { - if (openStep === null || openStep.turn !== event.data.turn || openStep.step !== event.data.step) break - const first = expandAssistantStream(event.data.stream) - .find(member => isFixtureTokenDelta(member.chunk))?.time - if (openStep.firstTokenTime === null && first !== undefined) openStep.firstTokenTime = first - value.llmMs += Math.max(0, event.time - openStep.startTime) - if (openStep.firstTokenTime !== null) { - value.ttftMs += Math.max(0, openStep.firstTokenTime - openStep.startTime) - value.ttftSteps += 1 - const outputTokens = event.data.usage?.outputTokens - if (typeof outputTokens === 'number' && Number.isFinite(outputTokens) && outputTokens >= 0) { - value.decodeMs += Math.max(0, event.time - openStep.firstTokenTime) - value.decodeTokens += outputTokens - } - } - openStep = null - break - } - case 'tool/call': - pendingCalls.set(event.data.callId, event.time) - break - case 'tool/result': { - const callId = event.data.message.source.callId - const dispatched = pendingCalls.get(callId) - if (dispatched === undefined) break - pendingCalls.delete(callId) - value.toolMs += Math.max(0, event.time - dispatched) - break - } - case 'step/end': - if (event.data.turn !== lastTurn) { - value.turns += 1 - lastTurn = event.data.turn - } - value.steps += 1 - openStep = null - break - case 'turn/end': - pendingCalls.clear() - break - default: - break - } - } - return value -} - -interface FixtureRequestContext { - provider: string - model: string - contextWindow?: number -} - -interface FixtureContextBreakdownProjection { - systemTokens: number - toolsTokens: number - messageTokens: number -} - -/** Fixed token-meter heuristic constants mirrored by this client-only fixture. */ -const CHARS_PER_TOKEN = 4 -const BLOCK_OVERHEAD = 4 -const ROLE_OVERHEAD = 4 - -/** Price fixture content with token-meter's fixed-density heuristic. */ -function estimateFixtureContent(blocks: readonly ContentBlock[]): number { - const densityPrice = (value: string): number => Math.ceil(value.length / CHARS_PER_TOKEN) - return blocks.reduce((tokens, block) => { - if (block.type === 'text' || block.type === 'reasoning') { - return tokens + densityPrice(block.text) + BLOCK_OVERHEAD - } - if (block.type === 'tool-call') { - return tokens + densityPrice(block.name) + densityPrice(block.arguments) + BLOCK_OVERHEAD - } - // ContentBlockMap is merge-extensible: this client graph sees only the - // base four members, but fixture turns do carry extended blocks at - // runtime, so the structural JSON fallback below is live code. - if (block.type === 'tool-result') { - return tokens + estimateFixtureContent(block.content) + BLOCK_OVERHEAD - } - return tokens + densityPrice(JSON.stringify(block)) + BLOCK_OVERHEAD - }, 0) -} - -/** - * Fixture parallel of token-meter's heuristic context-composition projection. - * The system prompt is the system-role surface node; it prices as text plus - * role framing with no block overhead and stays out of the message figure. - */ -function contextBreakdownOf(log: readonly SessionEvent[]): FixtureContextBreakdownProjection { - const headerEvent = log.findLast(event => event.type === 'request/header') - const header = headerEvent === undefined - ? undefined - : headerEvent.data.header - let systemTokens = 0 - let messageTokens = 0 - for (const seq of foldSurface(log).nodes) { - const event = log[seq] - if (event === undefined) continue - const message = deriveEventMessage(event) - if (message === null) continue - if (message.role === 'system') { - const characters = message.content.reduce( - (total, block) => total + (block.type === 'text' ? block.text.length : JSON.stringify(block).length), - 0, - ) - systemTokens = Math.ceil(characters / CHARS_PER_TOKEN) + ROLE_OVERHEAD - continue - } - messageTokens += estimateFixtureContent(message.content) + ROLE_OVERHEAD - } - return { - systemTokens, - toolsTokens: header?.tools === undefined || header.tools.length === 0 - ? 0 - : Math.ceil(JSON.stringify(header.tools).length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD, - messageTokens, - } -} - -/** Latest log-only route context, or undefined before any request ran. */ -function lastRequestContext( - log: readonly SessionEvent[], -): FixtureRequestContext | undefined { - const event = log.findLast(item => (item as { type: string }).type === 'request/context') - return event === undefined - ? undefined - : (event as unknown as { data: FixtureRequestContext }).data -} - -/** - * Fixture parallel of token-meter's request-pressure projection: the last - * provider-reported prompt size paired with the last recorded capacity. The - * two need not come from one request — see the token-meter README. The host's - * `projectedTokens` is deliberately absent: reproducing it would mean - * reimplementing the estimator client-side, and every consumer falls back to - * the bare sample, so a fixture-driven view simply lags a compaction the way - * the projection did before that field existed. - */ -function contextPressureOf( - log: readonly SessionEvent[], -): { pressureTokens?: number; contextWindow?: number } { - let pressureTokens: number | undefined - for (const event of log) { - const sample = usageSampleOf(event) - if (sample === undefined) continue - pressureTokens = sample.usage.inputTokens - + (sample.usage.cacheReadTokens ?? 0) - + (sample.usage.cacheWriteTokens ?? 0) - } - const contextWindow = lastRequestContext(log)?.contextWindow - return { - ...pressureTokens === undefined ? {} : { pressureTokens }, - ...contextWindow === undefined ? {} : { contextWindow }, - } -} - -function projectionValuesOf(log: readonly SessionEvent[]): Record { - const values: Record = {} - values['modelSelection'] = modelSelectionProjectionOf(log) - const titleEvent = log.findLast(item => (item as { type: string }).type === 'session/title') - if (titleEvent !== undefined) { - values['title'] = (titleEvent as unknown as { data: { title: string } }).data.title - } - // Always present (tool-todo unit composed): null when no plan stands. - values['todos'] = backscanTodos(log) ?? null - // Always present (permission service composed): the whole select. - values['permissions'] = permissionSelectOf(log) - // Always present (plan-mode unit composed): the {active, pending} view. - values['plan'] = planViewOf(log) - // Always present (GoalService unit composed): null before create / after clear. - values['goal'] = backscanGoal(log) - // Always present (token-meter composed): full-log provider billing. - values['tokenUsage'] = tokenUsageOf(log) - // Always present (token-meter composed): last request pressure and capacity. - values['contextPressure'] = contextPressureOf(log) - // Always present (token-meter composed): heuristic request composition. - values['contextBreakdown'] = contextBreakdownOf(log) - // Always present (session-stats unit composed): whole-log turn/step counts. - values['sessionStats'] = sessionStatsOf(log) - // Always present (attachment service composed): the deployment image - // limits, constant per boot (mirrors the attachment-local defaults). - // Deliberate host divergence: the real gateway never pushes an imageLimits - // change frame (constant unit), but the fixture's uniform baseline replay - // frames every key here, incidentally exercising higher-seq-wins. - values['imageLimits'] = { - maxImageBytes: 5 * 1024 * 1024, - maxImagesPerMessage: 20, - maxMessageImageBytes: 100 * 1024 * 1024, - maxImagePixels: 40_000_000, - maxImageDimension: 2000, - mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'], - } - return values -} - -function modelSelectionProjectionOf(log: readonly SessionEvent[]): { - lastUsed: ModelSelection | null - next: ModelSelection | null -} { - let lastUsed: ModelSelection | null = null - let pending: ModelSelection | null = null - for (const event of log) { - if ((event as { type: string }).type === 'model/selection') { - pending = (event as unknown as { data: ModelSelection }).data - continue - } - if (event.type !== 'request/header') continue - lastUsed = { - provider: event.data.header.config.provider, - model: event.data.header.config.model, - ...(event.data.header.config.reasoningEffort === undefined - ? {} - : { reasoningEffort: event.data.header.config.reasoningEffort }), - } - if (sameModelSelection(pending, lastUsed)) pending = null - } - return { lastUsed, next: pending ?? lastUsed } -} - -function sameModelSelection(left: ModelSelection | null, right: ModelSelection | null): boolean { - return left === right || (left !== null && right !== null - && left.provider === right.provider - && left.model === right.model - && left.reasoningEffort === right.reasoningEffort) -} - -/** Host parallel: emit one Session control projection frame per key advanced by the event. */ -function projectionFramesOf( - id: SessionId, - log: readonly SessionEvent[], - event: SessionEvent, -): FixtureProjectionFrame[] { - const type = (event as { type: string }).type - const frames: FixtureProjectionFrame[] = [] - if (type === 'model/selection' || type === 'request/header') { - frames.push({ - type: 'projection', - sessionId: id, - key: 'modelSelection', - value: modelSelectionProjectionOf(log), - seq: event.seq, - }) - } - // One usage sample advances both token-meter units. - if (usageSampleOf(event) !== undefined) { - frames.push( - { type: 'projection', sessionId: id, key: 'tokenUsage', value: tokenUsageOf(log), seq: event.seq }, - { type: 'projection', sessionId: id, key: 'contextPressure', value: contextPressureOf(log), seq: event.seq }, - ) - } - if (type === 'request/context') { - frames.push({ - type: 'projection', - sessionId: id, - key: 'contextPressure', - value: contextPressureOf(log), - seq: event.seq, - }) - } - if (type === 'request/header' - || type === 'system/message' - || type === 'user/message' - || type === 'assistant/message' - || type === 'tool/result') { - frames.push({ - type: 'projection', - sessionId: id, - key: 'contextBreakdown', - value: contextBreakdownOf(log), - seq: event.seq, - }) - } - // The stats fold's view advances on message assembly and tool settlement - // (wall times) and on step close (counts). - if (type === 'assistant/message' || type === 'tool/result' || type === 'step/end') { - frames.push({ - type: 'projection', - sessionId: id, - key: 'sessionStats', - value: sessionStatsOf(log), - seq: event.seq, - }) - } - if (frames.length > 0) return frames - if (type === 'session/title') { - const values = projectionValuesOf(log) - /* v8 ignore next -- the advancing title event is in the log, so the key is present. */ - if (!Object.hasOwn(values, 'title')) return [] - return [{ type: 'projection', sessionId: id, key: 'title', value: values['title'], seq: event.seq }] - } - // The goal domain's own durable change advances its projection. - if (type === 'goal/change') { - return [{ type: 'projection', sessionId: id, key: 'goal', value: backscanGoal(log), seq: event.seq }] - } - // Standing-plan fold: writes replace the list; turn/start clears it (null). - if (type === 'todo/write' || type === 'turn/start') { - return [{ - type: 'projection', - sessionId: id, - key: 'todos', - value: backscanTodos(log) ?? null, - seq: event.seq, - }] - } - // Knob fold: any of the three whole-value knob events advances the select. - if (type === 'permission/preset' || type === 'sandbox/mode' || type === 'approval/policy') { - return [{ - type: 'projection', - sessionId: id, - key: 'permissions', - value: permissionSelectOf(log), - seq: event.seq, - }] - } - // The plan unit advances on its two folded event kinds when the command - // lifecycle contains the input that represents a plan selection. - const commandData = event as unknown as { data: { name?: string; args?: unknown } } - if (type === 'plan/mode' || (type === 'command/run' - && commandData.data.name === 'plan' && typeof commandData.data.args === 'string')) { - return [{ - type: 'projection', - sessionId: id, - key: 'plan', - value: planViewOf(log), - seq: event.seq, - }] - } - return [] -} - -/** - * Message-boundary paging mirrors the Host contract: count `maxMessages` - * backwards from the end and cut at a turn/start boundary. - */ -function pageOf( - log: readonly SessionEvent[], - beforeSeq: number | undefined, - maxMessages: number, -): { records: FixtureHistoryRecord[]; hasMore: boolean } { - const end = beforeSeq === undefined ? log.length : Math.max(0, Math.min(beforeSeq, log.length)) - let start = 0 - let messages = 0 - for (let i = end - 1; i >= 0; i--) { - const event = log[i] - /* v8 ignore next -- dense-array guard: log seqs are array indexes, i stays within [0, end). */ - if (event === undefined) break - if (event.type === 'user/message' || event.type === 'assistant/message') messages++ - if (event.type === 'turn/start' && messages >= maxMessages) { - start = i - break - } - } - const records = log.slice(start, end).map((event): FixtureHistoryRecord => ({ type: 'event', event })) - return { records, hasMore: start > 0 } -} - -/** Fixture mirror of host session-scoped attachment authorization. */ -function logReferencesAttachment(log: readonly SessionEvent[], attachmentId: string): boolean { - const visit = (value: unknown): boolean => { - if (Array.isArray(value)) return value.some(visit) - if (typeof value !== 'object' || value === null) return false - const record = value as Record - if (record.attachmentId === attachmentId) return true - return Object.values(record).some(visit) - } - return log.some(event => visit(event.data)) -} - -/** Fixture mirror of first-party message extraction used by session-query. */ -function searchBlockText(block: ContentBlock): string[] { - switch (block.type) { - case 'text': - return [block.text] - case 'reasoning': - return [] - case 'tool-call': - return [block.name, block.arguments] - case 'tool-result': - return block.content.flatMap(searchBlockText) - default: - return [] - } -} - -/** One current-surface user/assistant document, if searchable. */ -function searchEventText(event: SessionEvent): string { - const content = event.type === 'user/message' - ? event.data.content - : event.type === 'assistant/message' - ? event.data.message.content - : undefined - if (content === undefined) return '' - return content.flatMap(searchBlockText).map(part => part.trim()).filter(Boolean).join('\n') -} - -interface FixtureSearchToken { - value: string - /** Inclusive code-point offset in the whitespace-normalized display text. */ - start: number - /** Exclusive code-point offset in the whitespace-normalized display text. */ - end: number -} - -/** - * Browser-safe approximation of SQLite FTS5 unicode61 token boundaries. - * Keeping phrase matching token-based prevents the development fixture from - * promising arbitrary within-token substring behavior that production lacks. - */ -function searchTokenSpans(value: string): { text: string; tokens: FixtureSearchToken[] } { - const text = value.replace(/\s+/gu, ' ').trim() - const characters = Array.from(text) - const tokens: FixtureSearchToken[] = [] - let start: number | undefined - let raw = '' - const flush = (end: number): void => { - if (start !== undefined) { - const folded = raw.normalize('NFD').replace(/\p{M}+/gu, '').toLowerCase() - if (folded !== '') tokens.push({ value: folded, start, end }) - } - start = undefined - raw = '' - } - for (let index = 0; index < characters.length; index++) { - const character = characters[index] as string - const tokenBase = character.normalize('NFD').replace(/\p{M}+/gu, '') - if (tokenBase === '') { - if (start !== undefined) raw += character - continue - } - if (/^[\p{L}\p{N}\p{Co}]+$/u.test(tokenBase)) { - start ??= index - raw += character - } else { - flush(index) - } - } - flush(characters.length) - return { text, tokens } -} - -interface FixturePhraseMatch { - count: number - start: number - end: number -} - -/** Count exact contiguous token-phrase occurrences and retain the first display span. */ -function phraseMatch(document: readonly FixtureSearchToken[], phrase: readonly string[]): FixturePhraseMatch { - if (phrase.length === 0 || phrase.length > document.length) return { count: 0, start: 0, end: 0 } - let count = 0 - let firstStart = 0 - let firstEnd = 0 - for (let start = 0; start <= document.length - phrase.length; start++) { - if (!phrase.every((token, offset) => document[start + offset]?.value === token)) continue - count++ - if (count === 1) { - firstStart = document[start]?.start ?? 0 - firstEnd = document[start + phrase.length - 1]?.end ?? firstStart - } - } - return { count, start: firstStart, end: firstEnd } -} - -/** Match-centered fixture excerpt, bounded by Unicode code points for the sidebar. */ -function searchSnippet(value: string, matchStart: number, matchEnd: number): string { - const characters = Array.from(value) - if (characters.length <= 120) return value - const boundedStart = Math.min(Math.max(0, matchStart), characters.length - 1) - const boundedEnd = Math.min( - characters.length, - Math.max(boundedStart + 1, matchEnd), - ) - const center = Math.floor((boundedStart + boundedEnd) / 2) - let start = Math.min( - characters.length - 118, - Math.max(0, center - Math.floor(118 / 2)), - ) - let end = start + 118 - if (start === 0) { - end = 119 - } else if (end === characters.length) { - start = characters.length - 119 - } - return `${start > 0 ? '…' : ''}${characters.slice(start, end).join('')}${end < characters.length ? '…' : ''}` -} - -interface FixtureSearchCandidate { - sessionId: SessionId - seq: number - time: number - text: string - matchCount: number - matchStart: number - matchEnd: number - documentLength: number -} - -/** Mirrors `packages/session-query/session-query-sqlite/src/index.ts`; update both together. */ -function compareSearchCandidates(a: FixtureSearchCandidate, b: FixtureSearchCandidate): number { - if (a.matchCount !== b.matchCount) return b.matchCount - a.matchCount - if (a.documentLength !== b.documentLength) return a.documentLength - b.documentLength - if (a.time !== b.time) return b.time - a.time - if (a.sessionId !== b.sessionId) return a.sessionId < b.sessionId ? -1 : 1 - return b.seq - a.seq -} - -/** - * Current plan projection over the full log (host parallel: latest todo/write - * with no later turn/start; a new turn retires the previous plan). - */ -function backscanTodos(log: readonly SessionEvent[]): TodoItem[] | undefined { - for (let i = log.length - 1; i >= 0; i--) { - const event = log[i] - if (event === undefined) continue - if (event.type === 'turn/start') return undefined - if (event.type === 'todo/write') return event.data.todos - } - return undefined -} - -/** Fixture-local mirror of the goal projection value (dsh-goal's GoalProjection shape). */ -interface FxGoalProjection { - goal: { - id: string - revision: number - objective: string - phase: 'active' | 'paused' | 'blocked' | 'complete' - maxGoalRounds: number - } - roundsStarted: number - createdAt: number - updatedAt: number -} - -/** One durable goal change. */ -type FxGoalChange = - | { kind: 'goal/change'; version: 1; operation: 'clear'; cleared: { id: string; revision: number }; clearedAt: number } - | { - kind: 'goal/change' - version: 1 - operation: 'create' | 'edit' | 'pause' | 'resume' | 'complete' - goal: FxGoalProjection['goal'] - roundsStarted: number - createdAt: number - updatedAt: number - } - -/** - * Current goal projection over the full log (host parallel: the GoalService - * unit's last-wins fold of goal/change whole values; clear returns null). - */ -function backscanGoal(log: readonly SessionEvent[]): FxGoalProjection | null { - for (let i = log.length - 1; i >= 0; i--) { - const event = log[i] as unknown as { - type: string - data?: FxGoalChange - } | undefined - if (event === undefined || event.type !== 'goal/change' || event.data === undefined) continue - const change = event.data - if (change.operation === 'clear') return null - return { goal: change.goal, roundsStarted: change.roundsStarted, createdAt: change.createdAt, updatedAt: change.updatedAt } - } - return null -} - -interface StreamConn { - push(value: Value): void -} - -interface ReasoningChunkStormState { - sessionId: string - chunkCount: number - chunksPerInterval: number - intervalMs: number - emitted: number - marker: string - emitting: boolean -} - -/** Deterministic fixture branches used by keyless Web assembly tests. */ -export interface FixtureOptions { - /** Start with no real Workspace or Session. */ - empty?: boolean - /** Reject every prompt before appending its user event. */ - rejectPrompt?: boolean - /** Publish the Session but fail its Workspace account write. */ - failWorkspaceAttach?: boolean - /** Publish and frame the Session, then throw instead of returning create. */ - dropSessionCreateResponse?: boolean - /** Order of the two successful create frames. */ - createFrameOrder?: 'session-first' | 'workspace-first' - /** Announce one Agent write to `notes/demo.txt` shortly after a `workspaceFiles/changes` stream opens. */ - fileChanges?: boolean -} - -/** File observation payload, mirrored so this Client fixture names no Host package. */ -type FixtureWorkspaceFileChange = - | { readonly absolutePath: string; readonly version: string } - | { readonly absolutePath: string; readonly absent: true } - -/** Host subscription acknowledgement followed by file observations. */ -type FixtureWorkspaceFileWatchFrame = - | { readonly kind: 'ready' } - | { readonly kind: 'change'; readonly change: FixtureWorkspaceFileChange } - -/** Inbox pump shared by both stream generators (FrameQueue pattern: ONE abort listener hung - * outside the loop — a per-iteration {once:true} listener never fires for non-final rounds and - * piles up for the stream's lifetime). breakNow force-ends the stream without the - * client's signal (timing hook: simulated connection loss). */ -class FxInbox implements StreamConn { - private readonly inbox: Value[] = [] - private wake: (() => void) | null = null - private broken = false - - push(value: Value): void { - this.inbox.push(value) - this.wake?.() - } - - breakNow(): void { - this.broken = true - this.wake?.() - } - - /** Read through a method: breakNow()/abort flip state across yields, so narrowing from the loop condition must not stick. */ - private isLive(signal: AbortSignal): boolean { - return !signal.aborted && !this.broken - } - - async *drain(signal: AbortSignal): AsyncGenerator { - const onAbort = (): void => this.wake?.() - signal.addEventListener('abort', onAbort) - try { - while (this.isLive(signal)) { - while (this.inbox.length > 0) yield this.inbox.shift() as Value - if (!this.isLive(signal)) break - await new Promise((resolve) => { - this.wake = resolve - }) - this.wake = null - } - } finally { - signal.removeEventListener('abort', onAbort) - } - } -} - -/** Fixture RPC face over one in-memory state graph. */ -export interface FixtureWorld { - /** Generic Remote caller for the endpoints business services own. */ - readonly rpc: ClientConnectionRpc -} - -/** - * Build the fixture RPC face over one in-memory state graph. - * @param options - fixture branches for empty state and failure timing. - * @returns the Remote RPC face. - */ -export function createFixtureFaces(options: FixtureOptions = {}): FixtureWorld { - return createFixtureWorld(options) -} - -/** Build the fixture's Remote RPC face over one state graph. */ -function createFixtureWorld(options: FixtureOptions): FixtureWorld { - // The resident fixture sessions all carry history, so none of them is blank. - const sessions: FixtureSessionSummary[] = options.empty ? [] : [ - { sessionId: sid('fx-alpha'), updatedAt: Date.now(), running: true, blank: false, cwd: '/tmp/fixture' }, - { sessionId: sid('fx-beta'), updatedAt: Date.now() - 60_000, running: false, blank: false, parentSessionId: sid('fx-alpha'), cwd: '/tmp/fixture' }, - { sessionId: sid('fx-gamma'), updatedAt: Date.now() - 120_000, running: false, blank: false, cwd: '/tmp/fixture' }, - ] - const logs = new Map([[sid('fx-alpha'), buildAlphaLog()]]) - const goalActivations = new Map() - const modelSelections = new Map(sessions.map(session => [ - session.sessionId, - { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - ])) - const attachments = new Map([[ - String(FIXTURE_IMAGE_REF.attachmentId), - { attachment: FIXTURE_IMAGE_REF, data: FIXTURE_IMAGE_DATA }, - ]]) - /** Credential store double: set/unset flip the describe badge, values never read back. */ - const fixtureCredentials = new Map([ - // The assembled fixture represents an already-configured shipped - // DeepSeek route so unrelated GUI journeys do not enter first-run setup. - ['DEEPSEEK_API_KEY', true], - ]) - - /** Canonical fixture implementation of the generated Settings Remote contract. */ - const settingsRemotes = { - // Only the resolved DeepSeek address needed by first-run readiness is - // represented here. Fixture-backed journeys do not open its Models editor; - // real schema-driven forms ride the HTTP transport. - describe(): RpcResult { - return { - ok: true, - value: { - writable: true, - hasDocument: true, - namespaces: [{ - ns: 'llm-deepseek', - schema: {}, - value: { apiKeyEnv: 'DEEPSEEK_API_KEY' }, - applies: 'live', - secrets: [{ path: ['apiKey'], set: false }], - revision: 0, - }], - }, - } - }, - update(ns: string): ConnectionRpcResult { - return { - ok: false, - error: { - code: 'settings/rejected', - message: 'fixture: the minimal readiness settings descriptor is read-only', - details: { ns }, - }, - } - }, - replace(ns: string): ConnectionRpcResult { - return { - ok: false, - error: { - code: 'settings/rejected', - message: 'fixture: the minimal readiness settings descriptor is read-only', - details: { ns }, - }, - } - }, - mutate(ns: string): ConnectionRpcResult { - // A Remote failure code is free-form, unlike the unary error vocabulary. - return { - ok: false, - error: { - code: 'settings/rejected', - message: 'fixture: no settings namespaces are registered', - details: { ns }, - }, - } - }, - openSettingsDocument(): RpcResult<{ opened: true }> { - return { ok: true, value: { opened: true } } - }, - openAgentPresetDirectory(agentPreset: string): RpcResult< - { opened: true } | { opened: false; path: string } - > { - const existing = fixturePresets.get(agentPreset) - if (existing === undefined || existing.trust === 'system') { - return { - ok: false, - error: { - code: 'agent-preset/read-only', - message: `agent preset "${agentPreset}" ships with the deployment`, - details: { agentPreset, reason: 'it ships with the deployment' }, - }, - } - } - return { ok: true, value: { opened: true } } - }, - } - - const credentialRemotes = { - describe(refs: readonly string[]): RpcResult> { - return { - ok: true, - value: Object.fromEntries(refs.map(ref => [ref, { - configured: fixtureCredentials.has(ref), - ...fixtureCredentials.has(ref) ? { source: 'file' } : {}, - writable: true, - }])), - } - }, - set(ref: string): RpcResult { - fixtureCredentials.set(ref, true) - return { ok: true, value: undefined } - }, - unset(ref: string): RpcResult { - fixtureCredentials.delete(ref) - return { ok: true, value: undefined } - }, - } - - /** - * Preset compositions the fixture serves. Held as state rather than - * constants so the settings editor's save and delete are exercisable: the - * roster a GUI journey sees after writing is the text it wrote. - */ - const fixturePresets = new Map([ - ['standard', { trust: 'system', content: "- id: tool-bash\n name: '@deepseek-ai/dsh-tool-bash'\n" }], - ['minimal', { trust: 'system', content: "- id: tool-web-search\n name: '@deepseek-ai/dsh-tool-web-search'\n" }], - ['my-agent', { trust: 'user', content: "- id: tool-read\n name: '@deepseek-ai/dsh-tool-read'\n" }], - ]) - let fixtureDefaultPreset = 'standard' - const nextTurn = new Map([[sid('fx-alpha'), 75]]) - let nextSession = 1 - // Workspace entities mirroring the host registry: the fixture sessions all - // live under one workspace, whose account carries them in attach order. - const wid = (raw: string): WorkspaceId => raw as WorkspaceId - const fixtureEpoch = new Date(Date.now() - 300_000).toISOString() - const FIXTURE_HOME = '/home/fixture' - const workspaces: FixtureWorkspace[] = options.empty ? [] : [{ - workspaceId: wid('fx-ws-fixture'), - path: '/tmp/fixture', - title: 'fixture', - sessionIds: [sid('fx-alpha'), sid('fx-beta'), sid('fx-gamma')], - createdAt: fixtureEpoch, - updatedAt: fixtureEpoch, - }, { - workspaceId: wid('fx-ws-home'), - path: `${FIXTURE_HOME}/Documents/project`, - title: 'project', - sessionIds: [], - createdAt: fixtureEpoch, - updatedAt: fixtureEpoch, - }] - let nextWorkspace = 1 - // Registry-global archive set mirroring the host: archived sessions keep - // their workspace accounting slot and only grouping surfaces hide them. - const archivedSessionIds: SessionId[] = [] - const workspaceSnapshot = (workspace: FixtureWorkspace): WorkspaceView => ({ - ...workspace, - sessionIds: [...workspace.sessionIds], - }) - const workspaceBaseline = (): Extract => ({ - type: 'baseline', - value: { - items: workspaces.map(workspaceSnapshot), - archivedSessionIds: [...archivedSessionIds], - }, - }) - - // In-memory browse tree behind the fixture's `browse` picker capability — - // deterministic content mirroring the design mock so assembled Web tests - // and snapshots can walk it. Leaves are materialized lazily: a child listed - // by its parent lists as empty until something is created inside it. - const directoryTree = new Map([ - ['/', ['home']], - ['/home', ['fixture']], - [FIXTURE_HOME, ['Documents', 'Downloads', '.config']], - [`${FIXTURE_HOME}/Documents`, [ - 'project', 'deepseek-iOS', 'deepseek-android', 'deepseek-platform', - 'deepseek-web', 'deepseek-harness', 'deepseek-app', 'deepseek-landing-blog', - ]], - ]) - const childrenOf = (path: string): string[] | undefined => { - const known = directoryTree.get(path) - if (known !== undefined) return known - const parent = path.slice(0, path.lastIndexOf('/')) || '/' - const name = path.slice(path.lastIndexOf('/') + 1) - return directoryTree.get(parent)?.includes(name) === true ? [] : undefined - } - const crumbsOf = (path: string): { name: string; path: string; hidden: boolean }[] => { - const crumbs = [{ name: '/', path: '/', hidden: false }] - let acc = '' - for (const segment of path.split('/').filter(Boolean)) { - acc += `/${segment}` - crumbs.push({ name: segment, path: acc, hidden: false }) - } - return crumbs - } - /** Resident waterfalls retain their event ids across Remote Event generations. */ - const pendingApprovalEventId = 'fx-interaction-approval' - let approvalPending = !options.empty - const pendingQuestionEventId = 'fx-interaction-question' - let questionPending = !options.empty - const fixtureQuestions: readonly FixtureQuestionItem[] = [ - { - id: 'harness-profile', - header: '偏好', - question: '你现在更想招哪类 Agent/Harness 候选人?', - options: [ - { label: '工程落地型 (Recommended)', description: '更看重能直接做 runtime、tool executor、sandbox、trace 和线上问题排查。' }, - { label: '研究潜力型', description: '更看重 Agent 理解、训练评测思路和长期成长空间。' }, - { label: '均衡型', description: '同时要求工程能力和 Agent 认知,但可能筛选门槛更高。' }, - ], - }, - { - id: 'work-mode', - header: '方式', - question: '你希望候选人优先展示哪种工作方式?', - options: [ - { label: '先做小型原型 (Recommended)', description: '用可运行结果尽快验证关键假设。' }, - { label: '先写完整设计', description: '先收敛边界、协议和风险,再开始实现。' }, - ], - }, - { - id: 'signals', - header: '信号', - question: '哪些面试信号最重要?', - detail: '按当前招聘目标选择;跳过则视为不设偏好。', - multiSelect: true, - options: [ - { label: '系统设计' }, - { label: '代码质量' }, - { label: 'Agent 产品判断' }, - ], - }, - ] - - const controlConns = new Set>() - const followConns = new Map>>() - interface FixtureAttemptState { - readonly attemptId: ReturnType - readonly startedAfterSeq: number - readonly turn: number - readonly step: number - readonly stream: AssistantStreamAccumulator - index: number - } - const activeAttempts = new Map() - const assistantRevisions = new Map() - const workspaceConns = new Set>() - const remoteEventConns = new Map>() - const emitControl = (frame: FixtureControlFrame): void => { - for (const conn of controlConns) conn.push(frame) - } - const emitWorkspace = (frame: Exclude): void => { - for (const conn of workspaceConns) conn.push(frame) - } - const emitRemote = (event: string, args: readonly unknown[]): void => { - for (const conn of remoteEventConns.values()) conn.push({ type: 'emit', event, args }) - } - const emitRemoteFrame = (frame: FixtureRemoteEventFrame): void => { - for (const conn of remoteEventConns.values()) conn.push(frame) - } - const emitFollow = (sessionId: SessionId, entry: FixtureHistoryEntry): void => { - for (const conn of followConns.get(sessionId) ?? []) conn.push(entry) - } - const emitAssistant = (sessionId: SessionId, frame: FixtureAssistantStreamFrame): void => { - for (const conn of followConns.get(sessionId) ?? []) conn.push({ type: 'assistant-stream', frame }) - } - const nextAssistantRevision = (sessionId: SessionId): number => { - const revision = (assistantRevisions.get(sessionId) ?? 0) + 1 - assistantRevisions.set(sessionId, revision) - return revision - } - const beginAssistant = (sessionId: SessionId, turn: number, step: number): FixtureAttemptState => { - const attemptId = LlmAttemptId(`${sessionId}:fixture:${String(nextAssistantRevision(sessionId))}`) - const lastSeq = logOf(sessionId).length - 1 - const startedAfterSeq = lastSeq < 0 ? -1 : SessionSeq(lastSeq) - const attempt = { - attemptId, startedAfterSeq, turn, step, - stream: new AssistantStreamAccumulator(), index: 0, - } - activeAttempts.set(sessionId, attempt) - emitAssistant(sessionId, { - type: 'start', attemptId, revision: assistantRevisions.get(sessionId) as number, - startedAfterSeq, turn, step, - }) - return attempt - } - const pushAssistant = (sessionId: SessionId, chunk: StreamChunk): void => { - const attempt = activeAttempts.get(sessionId) - if (attempt === undefined) throw new Error(`fixture: no active Assistant attempt for ${sessionId}`) - const timed = attempt.stream.push({ time: Date.now(), chunk }) - emitAssistant(sessionId, { - type: 'chunk', attemptId: attempt.attemptId, revision: nextAssistantRevision(sessionId), - index: attempt.index++, time: timed.time, chunk: timed.chunk as unknown as JsonValue, - }) - } - const commitAssistant = (sessionId: SessionId, event: SessionEvent): void => { - const attempt = activeAttempts.get(sessionId) - if (attempt === undefined) throw new Error(`fixture: no active Assistant attempt for ${sessionId}`) - activeAttempts.delete(sessionId) - emitAssistant(sessionId, { - type: 'end', attemptId: attempt.attemptId, revision: nextAssistantRevision(sessionId), - index: attempt.index, - outcome: { - kind: 'committed', - eventType: event.type === 'assistant/message' ? 'assistant/message' : 'assistant/attempt', - seq: event.seq, - }, - }) - } - - function sessionOk(value: T): Promise> { - return Promise.resolve({ ok: true, value }) - } - - function sessionErr(error: ConnectionRpcFailure): Promise> { - return Promise.resolve({ ok: false, error }) - } - - const summaryOf = (id: SessionId): FixtureSessionSummary | undefined => sessions.find(s => s.sessionId === id) - const requireRemoteSession = ( - request: { readonly sessionId: SessionId }, - ): Promise> | undefined => { - if (summaryOf(request.sessionId) !== undefined) return undefined - return sessionErr({ - code: 'session/not-found', - message: `no session ${request.sessionId}`, - details: { sessionId: request.sessionId }, - }) - } - const setRunning = (id: SessionId, running: boolean): void => { - const summary = summaryOf(id) - if (summary === undefined || summary.running === running) return - summary.running = running - emitRemote('api-session/status', [id, running]) - } - const logOf = (id: SessionId): SessionEvent[] => { - let log = logs.get(id) - if (log === undefined) { - log = [] - logs.set(id, log) - } - return log - } - const append = (id: SessionId, e: Record): SessionEvent => { - const log = logOf(id) - const event = { seq: SessionSeq(log.length), time: Date.now(), ...e } as unknown as SessionEvent - log.push(event) - emitFollow(id, { type: 'event', event }) - // Host eager-drive parallel: a unit-advancing event pushes its finished value. - for (const frame of projectionFramesOf(id, log, event)) emitControl(frame) - if (event.type === 'user/message' && event.data.source.kind === 'user') { - const summary = summaryOf(id) - if (summary !== undefined) summary.updatedAt = event.time - emitRemote('api-session/activity', [id, event.time]) - } - return event - } - - /** Append one durable goal/change (host GoalService parallel). */ - const appendGoalChange = (id: SessionId, change: FxGoalChange): FxGoalProjection => { - const log = logOf(id) - append(id, { - type: 'goal/change', - data: change, - }) - return backscanGoal(log) as FxGoalProjection - } - - /** Update process-local goal activation and publish the forwarded edge. */ - const setGoalActivation = (id: SessionId, activation: 'armed' | 'disarmed'): void => { - const current = backscanGoal(logOf(id)) - if (current === null) { - if (!goalActivations.delete(id)) return - emitRemote('goal/activation-changed', [{ sessionId: id }]) - return - } - const previous = goalActivations.get(id) - goalActivations.set(id, activation) - if (previous === activation) return - emitRemote('goal/activation-changed', [{ - sessionId: id, - goal: { id: current.goal.id, revision: current.goal.revision, activation }, - }]) - } - - type FxGoalRef = { id: string; revision: number } - type FxGoalView = FxGoalProjection['goal'] & { - roundsStarted: number - createdAt: number - updatedAt: number - activation: 'armed' | 'disarmed' - } - - const goalFailure = (message: string): RpcResult => ({ - ok: false, - error: { code: 'gateway/internal', message, details: {} }, - }) - - const requireGoalSession = (id: SessionId): RpcResult | undefined => ( - summaryOf(id) === undefined - ? { ok: false, error: { code: 'session/not-found', message: `no session ${id}`, details: { sessionId: id } } } - : undefined - ) - - /** Canonical fixture implementation of the generated Commands Remote contract. */ - const commandRemotes = { - list(id: SessionId): RpcResult { - const missing = requireGoalSession(id) - if (missing !== undefined) return missing - return { - ok: true, - value: [ - { name: 'compact', description: 'fixture:压缩当前会话上下文' }, - { name: 'echo', description: 'fixture:回显参数', input: { hint: 'text to echo' } }, - { definitionId: brandString('@deepseek-ai/dsh-command-goal'), name: 'goal', description: 'Set or view the goal for a long-running task', input: { hint: '', attachments: true } }, - { definitionId: brandString('@deepseek-ai/dsh-permission-presets'), name: 'permission', description: 'Switch the permission preset (sandbox mode + approval policy)', input: { hint: '' } }, - { definitionId: brandString('@deepseek-ai/dsh-plan-mode'), name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]', attachments: true } }, - ], - } - }, - execute(id: SessionId, line: string, attachments: readonly unknown[] = []): RpcResult { - const missing = requireGoalSession(id) - if (missing !== undefined) return missing - // Structured split mirroring the Host parser: name + verbatim rawInput - // (separator whitespace included) — the run payload carries no line. - const match = /^\/(\S+)((?:\s.*)?)$/.exec(line.trim()) - const name = match?.[1] - const args = match?.[2] ?? '' - // Mirror the Host image policy AFTER command resolution, matching the - // executor's order (an unknown name answers undefined and logs no - // lifecycle): the declaration rejection covers every known command - // without `input.attachments`, and the two producer grammar rejections cover - // the declaring commands' control-only lines. The fixture stores no - // bytes, so an accepted batch is acknowledged and dropped. - const known = ['permission', 'goal', 'compact', 'echo', 'plan'] - if (attachments.length > 0 && name !== undefined && known.includes(name)) { - const rejection = name !== 'goal' && name !== 'plan' - ? `/${name} does not accept attachments` - : name === 'goal' && args.trim() === '' - ? 'Attachments only accompany a goal objective: /goal or /goal edit .' - : name === 'plan' && args.trim() === 'off' - ? 'Attachments cannot accompany /plan off.' - : undefined - if (rejection !== undefined) { - const commandId = `fx-cmd-${logOf(id).length}` as CommandId - append(id, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }) - const result: CommandResult = { kind: 'error', text: rejection } - append(id, { type: 'command/done', data: { commandId, ...result } }) - return { ok: true, value: { commandId, result } } - } - } - if (name === 'permission') { - const preset = args.trim() - const commandId = `fx-cmd-${logOf(id).length}` as CommandId - append(id, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }) - const spec = PERMISSION_PRESETS[preset] - let result: CommandResult - if (preset === '') { - const current = permissionSelectOf(logOf(id)).currentValue - result = { kind: 'success', text: `current preset ${current} (available: ${Object.keys(PERMISSION_PRESETS).join(', ')})` } - } else if (spec === undefined) { - result = { kind: 'error', text: `unknown preset "${preset}" (available: ${Object.keys(PERMISSION_PRESETS).join(', ')})` } - } else { - if (permissionSelectOf(logOf(id)).currentValue !== preset) append(id, { type: 'permission/preset', data: { preset } }) - append(id, { type: 'sandbox/mode', data: { mode: spec.sandbox } }) - append(id, { type: 'approval/policy', data: { policy: spec.approval } }) - result = { kind: 'success', text: `preset ${preset}` } - } - append(id, { type: 'command/done', data: { commandId, ...result } }) - return { ok: true, value: { commandId, result } } - } - if (name === 'goal') { - const commandId = `fx-cmd-${logOf(id).length}` as CommandId - append(id, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }) - const objective = args.trim() - const current = backscanGoal(logOf(id)) - let text: string - if (objective === '') { - text = current === null ? 'No goal is set. Usage: /goal ' : `Current goal: ${current.goal.objective}` - } else if (current !== null && current.goal.phase !== 'complete') { - text = `A goal already exists (${current.goal.objective}). Clear it first.` - } else { - const created = appendGoalChange(id, { - kind: 'goal/change', version: 1, operation: 'create', - goal: { id: `fx-goal-${logOf(id).length}`, revision: 1, objective, phase: 'active', maxGoalRounds: 256 }, - roundsStarted: 0, createdAt: Date.now(), updatedAt: Date.now(), - }) - setGoalActivation(id, 'armed') - text = `Goal created: ${created.goal.objective}` - } - const result: CommandResult = { kind: 'success', text } - append(id, { type: 'command/done', data: { commandId, ...result } }) - return { ok: true, value: { commandId, result } } - } - const running = summaryOf(id)?.running === true - const outcomes: Record = { - compact: 'fixture:已压缩(假动作)', - echo: args.trim(), - plan: args.trim() === 'off' - ? (running ? 'Leaving plan mode (applies from the next step).' : 'Plan mode off.') - : (running - ? 'Entering plan mode (applies from the next step). Use /plan off to leave.' - : 'Plan mode on. Use /plan off to leave.'), - } - const text = name === undefined ? undefined : outcomes[name] - if (name === undefined || text === undefined) return { ok: true, value: undefined } - const commandId = `fx-cmd-${logOf(id).length}` as CommandId - append(id, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }) - if (name === 'plan' && !running) { - const plan = foldPlan(logOf(id)) - if (plan.wanted !== null && plan.wanted !== plan.active) { - append(id, { type: 'plan/mode', data: { active: plan.wanted } }) - } - } - const result: CommandResult = { kind: 'success', ...text === '' ? {} : { text } } - append(id, { type: 'command/done', data: { commandId, ...result } }) - return { ok: true, value: { commandId, result } } - }, - } - - const goalView = (id: SessionId, projection: FxGoalProjection): FxGoalView => ({ - ...projection.goal, - roundsStarted: projection.roundsStarted, - createdAt: projection.createdAt, - updatedAt: projection.updatedAt, - activation: goalActivations.get(id) ?? (projection.goal.phase === 'active' ? 'armed' : 'disarmed'), - }) - - /** Canonical fixture implementation of the generated Goal Remote contract. */ - /** Canonical fixture implementation of the generated reference-discovery Remote contracts. */ - const referenceRemotes = { - files(id: SessionId, query: string): RpcResult<{ path: string; kind: 'file' | 'directory' }[]> { - const missing = requireGoalSession(id) - if (missing !== undefined) return missing - const needle = query.toLocaleLowerCase() - const items = [ - { path: 'notes', kind: 'directory' as const }, - { path: 'README.md', kind: 'file' as const }, - { path: 'notes/demo.txt', kind: 'file' as const }, - ].filter(item => item.path.toLocaleLowerCase().includes(needle)) - return { ok: true, value: items } - }, - sessions(id: SessionId, query: string): RpcResult<{ - sessionId: SessionId - label: string - cwd?: string - createdAt: number - mention: string - }[]> { - const missing = requireGoalSession(id) - if (missing !== undefined) return missing - const needle = query.toLocaleLowerCase() - const value = sessions - .filter(item => item.sessionId !== id) - .filter(item => String(item.sessionId).toLocaleLowerCase().includes(needle) - || item.cwd?.toLocaleLowerCase().includes(needle) === true) - .map((item) => { - const label = item.sessionId === sid('fx-beta') ? 'Fixture child session' : String(item.sessionId) - const encoded = btoa(JSON.stringify(item.sessionId)) - .replaceAll('+', '-') - .replaceAll('/', '_') - .replace(/=+$/u, '') - return { - sessionId: item.sessionId, - label, - ...item.cwd === undefined ? {} : { cwd: item.cwd }, - createdAt: item.updatedAt, - mention: `@[${label}](dsh-session:${encoded})`, - } - }) - return { ok: true, value } - }, - } - - /** - * Workspace text reads under `?fixture`. - * - * The sample content is deliberately more than one shape: the panel's states - * (text, oversized, unreadable) are only demonstrable if the fixture can - * produce each of them, and a preview that can only ever succeed hides its - * own failure rendering. - */ - // The Session workspace the replayed conversation writes into: `list` walks a - // fixed tree under it so the file-tree tab has directories, files, one entry - // of neither kind, and one cut listing to draw. - const WORKSPACE_FILES_ROOT = '/tmp/fixture' - type FixtureWorkspaceEntry = { name: string; type: 'file' | 'directory' | 'other'; size?: number } - const workspaceFileTree = new Map([ - ['', [ - { name: '.gitignore', type: 'file', size: 24 }, - { name: 'dev.sock', type: 'other' }, - { name: 'notes', type: 'directory' }, - { name: 'package.json', type: 'file', size: 512 }, - { name: 'README.md', type: 'file', size: 640 }, - { name: 'src', type: 'directory' }, - ]], - ['notes', [ - { name: 'demo.txt', type: 'file', size: 14 }, - { name: 'new-demo.txt', type: 'file', size: 14 }, - ]], - ['src', [ - { name: 'config.ts', type: 'file', size: 211 }, - { name: 'index.ts', type: 'file', size: 88 }, - { name: 'lib', type: 'directory' }, - ]], - ['src/lib', Array.from({ length: 24 }, (_, index) => ({ - name: `module-${String(index + 1).padStart(2, '0')}.ts`, - type: 'file' as const, - size: 96 + index, - }))], - ]) - /** Resolve a `list` argument to its workspace-relative path, or undefined when it leaves the root. */ - const workspaceFilePath = (path: string): string | undefined => { - const segments: string[] = [] - for (const segment of (path.startsWith('/') ? path : `${WORKSPACE_FILES_ROOT}/${path}`).split('/')) { - if (segment === '' || segment === '.') continue - if (segment === '..') { - segments.pop() - continue - } - segments.push(segment) - } - const absolute = `/${segments.join('/')}` - if (absolute !== WORKSPACE_FILES_ROOT && !absolute.startsWith(`${WORKSPACE_FILES_ROOT}/`)) return undefined - return absolute.slice(WORKSPACE_FILES_ROOT.length + 1) - } - // Page cap mirrored from the Host default so an over-limit request fails here too. - const WORKSPACE_FILE_PAGE_LINES = 5000 - /** - * Sample text for any readable path: a heading plus two lines of copy. The - * replayed conversation's `demo` files, and any `huge` path, run past two - * default pages so paging can be exercised without a real workspace. - */ - const workspaceFileLines = (path: string): string[] => { - const name = path.slice(path.lastIndexOf('/') + 1) - const head = [`# ${name}`, '', 'fixture 模式下的示例文本,用于验收侧栏的文本预览。', '真实构建从工作区读取同名文件。'] - return path.includes('demo') || path.includes('huge') - ? [...head, ...Array.from({ length: 12_000 }, (_, index) => `第 ${index + 5} 行:用于验收分页与滚动的长文本样本。`)] - : head - } - const workspaceFileRemotes = { - list(path: string): ConnectionRpcResult<{ - path: string - entries: readonly FixtureWorkspaceEntry[] - truncated: boolean - }> { - if (path.length === 0) { - return { ok: false, error: { code: 'gateway/bad-request', message: 'path is required', details: {} } } - } - const relative = workspaceFilePath(path) - if (relative === undefined) { - return { - ok: false, - error: { code: 'workspace-file/outside-workspace', message: `${path} is outside the workspace`, details: { path } }, - } - } - const entries = workspaceFileTree.get(relative) - if (entries === undefined) { - const cut = relative.lastIndexOf('/') - const name = relative.slice(cut + 1) - const sibling = workspaceFileTree.get(cut === -1 ? '' : relative.slice(0, cut))?.find(entry => entry.name === name) - if (sibling === undefined) { - return { ok: false, error: { code: 'workspace-file/not-found', message: `no entry at ${path}`, details: { path } } } - } - return { - ok: false, - error: { - code: 'workspace-file/not-directory', - message: `${path} is a ${sibling.type}`, - details: { path, kind: sibling.type === 'file' ? 'file' : 'other' }, - }, - } - } - return { ok: true, value: { path: relative, entries, truncated: relative === 'src/lib' } } - }, - read(path: string, range: { offset?: number; limit?: number }): ConnectionRpcResult<{ - absolutePath: string - version: string - bytes: number - offset: number - text: string - lines: number - eof: boolean - }> { - const located = workspaceFileRemotes.stat(path) - if (!located.ok) return located - const offset = range.offset ?? 1 - const limit = range.limit ?? WORKSPACE_FILE_PAGE_LINES - if (!Number.isInteger(offset) || offset < 1 || !Number.isInteger(limit) || limit < 1 || limit > WORKSPACE_FILE_PAGE_LINES) { - return { ok: false, error: { code: 'gateway/bad-request', message: 'offset and limit must be positive integers within the page cap', details: {} } } - } - const lines = workspaceFileLines(path) - const page = lines.slice(offset - 1, offset - 1 + limit) - return { - ok: true, - value: { - ...located.value, - offset, - text: page.join('\n'), - lines: page.length, - eof: offset - 1 + limit >= lines.length, - }, - } - }, - stat(path: string): ConnectionRpcResult<{ absolutePath: string; version: string; bytes: number }> { - if (path.length === 0) { - return { ok: false, error: { code: 'gateway/bad-request', message: 'path is required', details: {} } } - } - if (path.endsWith('.png') || path.endsWith('.bin')) { - return { - ok: false, - error: { code: 'workspace-file/not-text', message: `${path} is not UTF-8 text`, details: { path } }, - } - } - return { - ok: true, - value: { - absolutePath: path.startsWith('/') ? path : `/${path}`, - version: 'fx-v1', - bytes: new TextEncoder().encode(workspaceFileLines(path).join('\n')).byteLength, - }, - } - }, - } - - /** - * Canonical fixture implementation of the generated Directory Picker Remote - * contract. The pick is deterministic — the keyless lanes drive the full - * pick-then-adopt path without an OS chooser — over the same design-mock - * tree the browse primitives serve. - */ - const directoryPickerRemotes = { - pick(): ConnectionRpcResult { - return { ok: true, value: `${FIXTURE_HOME}/Documents/project` } - }, - list(path?: string): ConnectionRpcResult { - const target = path ?? FIXTURE_HOME - const children = childrenOf(target) - if (children === undefined) { - return { - ok: false, - error: { code: 'directory-picker/unreadable', message: `cannot list ${target}: not in the fixture tree`, details: { path: target } }, - } - } - return { - ok: true, - value: { - path: target, - home: FIXTURE_HOME, - crumbs: crumbsOf(target), - entries: [...children].sort((a, b) => a.localeCompare(b)) - .map(name => ({ name, path: target === '/' ? `/${name}` : `${target}/${name}`, hidden: name.startsWith('.') })), - // The fixture tree is tiny; no level ever reaches a backend bound. - truncated: false, - }, - } - }, - createDirectory(parent: string, name: string): ConnectionRpcResult { - const children = childrenOf(parent) - if (children === undefined) { - return { ok: false, error: { code: 'directory-picker/create-failed', message: `missing parent ${parent}`, details: { path: parent } } } - } - // Same root special case as list's entry paths: a plain join under '/' - // would mint '//name' and fork the tree's identity. - const target = parent === '/' ? `/${name}` : `${parent}/${name}` - if (children.includes(name)) { - return { ok: false, error: { code: 'directory-picker/exists', message: `${target} already exists`, details: { path: target } } } - } - directoryTree.set(parent, [...children, name]) - directoryTree.set(target, []) - return { ok: true, value: target } - }, - } - - const goalRemotes = { - get(id: SessionId): RpcResult { - const missing = requireGoalSession(id) - if (missing !== undefined) return missing - const current = backscanGoal(logOf(id)) - return { ok: true, value: current === null ? undefined : goalView(id, current) } - }, - create(id: SessionId, request: { objective: string; maxGoalRounds?: number }): RpcResult<{ ref: FxGoalRef }> { - const missing = requireGoalSession(id) - if (missing !== undefined) return missing - const current = backscanGoal(logOf(id)) - if (current !== null && current.goal.phase !== 'complete') { - return goalFailure(`goal "${current.goal.id}" already exists`) - } - const now = Date.now() - const projection = appendGoalChange(id, { - kind: 'goal/change', version: 1, operation: 'create', - goal: { - id: `fx-goal-${logOf(id).length}`, - revision: 1, - objective: request.objective, - phase: 'active', - maxGoalRounds: request.maxGoalRounds ?? 256, - }, - roundsStarted: 0, createdAt: now, updatedAt: now, - }) - setGoalActivation(id, 'armed') - return { ok: true, value: { ref: { id: projection.goal.id, revision: projection.goal.revision } } } - }, - edit(id: SessionId, ref: FxGoalRef, request: { objective?: string; maxGoalRounds?: number }): RpcResult { - return mutateGoal(id, ref, current => ({ - ...current.goal, - revision: current.goal.revision + 1, - ...request.objective === undefined ? {} : { objective: request.objective }, - ...request.maxGoalRounds === undefined ? {} : { maxGoalRounds: request.maxGoalRounds }, - })) - }, - pause(id: SessionId, ref: FxGoalRef): RpcResult { - return mutateGoal(id, ref, current => ( - current.goal.phase === 'active' - ? { ...current.goal, revision: current.goal.revision + 1, phase: 'paused' } - : undefined - )) - }, - resume(id: SessionId, ref: FxGoalRef): RpcResult { - return mutateGoal(id, ref, current => ( - current.goal.phase === 'paused' || current.goal.phase === 'blocked' || current.goal.phase === 'active' - ? { ...current.goal, revision: current.goal.revision + 1, phase: 'active' } - : undefined - )) - }, - complete(id: SessionId, ref: FxGoalRef): RpcResult { - return mutateGoal(id, ref, current => ( - current.goal.phase === 'complete' - ? undefined - : { ...current.goal, revision: current.goal.revision + 1, phase: 'complete' } - )) - }, - clear(id: SessionId, ref: FxGoalRef): RpcResult { - const resolved = resolveGoal(id, ref) - if (!resolved.ok) return resolved - const current = resolved.value - const tombstone = { id: current.goal.id, revision: current.goal.revision + 1 } - appendGoalChange(id, { - kind: 'goal/change', version: 1, operation: 'clear', cleared: tombstone, clearedAt: Date.now(), - }) - setGoalActivation(id, 'disarmed') - return { ok: true, value: tombstone } - }, - } - - /** Resolve one current goal revision for a canonical Remote mutation. */ - function resolveGoal(id: SessionId, ref: FxGoalRef): RpcResult { - const missing = requireGoalSession(id) - if (missing !== undefined) return missing - const current = backscanGoal(logOf(id)) - if (current === null || current.goal.id !== ref.id || current.goal.revision !== ref.revision) { - return goalFailure('stale or missing goal revision') - } - return { ok: true, value: current } - } - - /** Shared CAS mutation path behind the canonical Remote verbs. */ - function mutateGoal( - id: SessionId, - ref: FxGoalRef, - next: (current: FxGoalProjection) => FxGoalProjection['goal'] | undefined, - ): RpcResult { - const resolved = resolveGoal(id, ref) - if (!resolved.ok) return resolved - const current = resolved.value - const goal = next(current) - if (goal === undefined) { - return goalFailure(`invalid goal transition from "${current.goal.phase}"`) - } - const currentActivation = goalActivations.get(id) - ?? (current.goal.phase === 'active' ? 'armed' : 'disarmed') - const activation = goal.phase === 'active' - ? current.goal.phase === 'active' ? currentActivation : 'armed' - : 'disarmed' - const projection = appendGoalChange(id, { - kind: 'goal/change', version: 1, - operation: goal.phase === current.goal.phase ? 'edit' : goal.phase === 'paused' ? 'pause' : goal.phase === 'active' ? 'resume' : 'complete', - goal, roundsStarted: current.roundsStarted, createdAt: current.createdAt, updatedAt: Date.now(), - }) - setGoalActivation(id, activation) - return { ok: true, value: goalView(id, projection) } - } - - /** Canonical fixture implementation of the generated AgentPresets Remote contract. */ - const presetRemotes = { - // Both trusts appear, because a surface must present a locally authored - // preset differently from one the deployment vetted. - list(): RpcResult<{ - presets: { id: string; trust: 'system' | 'user'; isDefault: boolean }[] - authorable: boolean - modeSelectionEnabled: boolean - }> { - return { - ok: true, - value: { - presets: [...fixturePresets].map(([id, preset]) => ({ - id, - trust: preset.trust, - isDefault: id === fixtureDefaultPreset, - })), - authorable: true, - modeSelectionEnabled: true, - }, - } - }, - select(_id: SessionId, agentPreset: string): RpcResult { - fixtureDefaultPreset = agentPreset - return { ok: true, value: agentPreset } - }, - read(agentPreset: string): RpcResult<{ agentPreset: string; trust: 'system' | 'user'; content: string }> { - const preset = fixturePresets.get(agentPreset) - if (preset === undefined) { - return { - ok: false, - error: { - code: 'agent-preset/not-found', - message: `unknown agent preset "${agentPreset}"`, - details: { agentPreset, available: [...fixturePresets.keys()] }, - }, - } - } - return { ok: true, value: { agentPreset, trust: preset.trust, content: preset.content } } - }, - copy(from: string, id: string): RpcResult { - const source = fixturePresets.get(from) - if (source === undefined) { - return { - ok: false, - error: { - code: 'agent-preset/not-found', - message: `unknown agent preset "${from}"`, - details: { agentPreset: from, available: [...fixturePresets.keys()] }, - }, - } - } - if (fixturePresets.has(id)) { - return { - ok: false, - error: { - code: 'agent-preset/invalid', - message: `agent preset "${id}" already exists`, - details: { agentPreset: id, reason: 'already exists' }, - }, - } - } - fixturePresets.set(id, { trust: 'user', content: source.content }) - return { ok: true, value: undefined } - }, - deletePreset(id: string): RpcResult { - if (fixturePresets.get(id)?.trust === 'system') { - return { - ok: false, - error: { - code: 'agent-preset/read-only', - message: `agent preset "${id}" ships with the deployment`, - details: { agentPreset: id, reason: 'it ships with the deployment' }, - }, - } - } - fixturePresets.delete(id) - return { ok: true, value: undefined } - }, - } - - /** At most one in-flight replay per session; cancel clears it. */ - const replays = new Map; finish(aborted: boolean): void }>() - - /** History transit delay; the page snapshot is taken at request time. */ - let historyDelayMs = 0 - /** One-shot history failure (timing hook: a pre-disconnect history request already doomed when reconnect lands). */ - let failNextHistory = false - /** Force-enders for currently open stream generators (timing hook: simulated connection loss). */ - const streamBreakers = new Set<() => void>() - /** Retry scenarios opened by timing hooks and completed in a later browser assertion phase. */ - const retryScenarios = new Map() - /** The single opt-in browser stress producer; normal fixture journeys never start it. */ - let activeReasoningChunkStorm: ReasoningChunkStormState | null = null - - // Browser-only timing hooks for slow history, lost frames, and reconnects. - const timingHooks = { - setHistoryDelay(ms: number): void { - historyDelayMs = ms - }, - /** Fail the NEXT history call (after its transit delay) with a transport-level throw. */ - failNextHistory(): void { - failNextHistory = true - }, - /** Log append plus follow-stream delivery (the normal live path). */ - appendUser(id: string, msg: string): void { - append(sid(id), { type: 'user/message', surfaceOp: 'append', data: userMessage(text(msg)) }) - }, - /** Append a later durable title revision through the normal raw-event + control-frame path. */ - appendTitle(id: string, title: string): void { - const log = logOf(sid(id)) - const messageSeqs = log.filter(event => event.type === 'user/message').map(event => event.seq) - append(sid(id), { type: 'session/title', data: { title, messageSeqs, source: { kind: 'provider', provider: 'fixture' } } }) - }, - /** Disarm the single active goal without a durable phase change. */ - disarmOnlyGoal(): void { - const active = [...logs.entries()].filter(([, log]) => { - const current = backscanGoal(log) - return current?.goal.phase === 'active' - }) - if (active.length !== 1) { - throw new Error(`fixture: expected one active goal, found ${String(active.length)}`) - } - const [session] = active - if (session === undefined) return - setGoalActivation(session[0], 'disarmed') - }, - /** Start an externally paced reasoning stream for the opt-in browser stress lane. */ - startReasoningChunkStorm( - id: string, - chunkCount: number, - chunksPerInterval: number, - intervalMs: number, - ): string { - if (!Number.isSafeInteger(chunkCount) || chunkCount < 1) { - throw new Error('fixture: reasoning chunk count must be a positive safe integer') - } - if (!Number.isSafeInteger(chunksPerInterval) || chunksPerInterval < 1) { - throw new Error('fixture: reasoning chunks per interval must be a positive safe integer') - } - if (!Number.isSafeInteger(intervalMs) || intervalMs < 1) { - throw new Error('fixture: reasoning interval must be a positive safe integer') - } - if (activeReasoningChunkStorm?.emitting === true) { - throw new Error('fixture: reasoning chunk storm already running') - } - - const sessionId = sid(id) - const log = logOf(sessionId) - let turn = nextTurn.get(sessionId) ?? 0 - for (const event of log) { - const candidate = (event as unknown as { data?: { turn?: unknown } }).data?.turn - if (typeof candidate === 'number') turn = Math.max(turn, candidate + 1) - } - nextTurn.set(sessionId, turn + 1) - const marker = `REASONING_STRESS_COMPLETE:${String(turn)}:${String(chunkCount)}` - const state: ReasoningChunkStormState = { - sessionId: id, - chunkCount, - chunksPerInterval, - intervalMs, - emitted: 0, - marker, - emitting: true, - } - activeReasoningChunkStorm = state - - setRunning(sessionId, true) - append(sessionId, { type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } }) - append(sessionId, { - type: 'user/message', surfaceOp: 'append', - data: userMessage(text(`Reasoning chunk stress: ${String(chunkCount)} chunks.`)), - }) - append(sessionId, { type: 'step/start', data: { turn, step: 0 } }) - beginAssistant(sessionId, turn, 0) - pushAssistant(sessionId, { type: 'block-start', index: 0, blockType: 'reasoning' }) - - const startedAt = Date.now() - const pump = (): void => { - const elapsedIntervals = Math.floor((Date.now() - startedAt) / intervalMs) + 1 - const due = Math.max(state.emitted + chunksPerInterval, elapsedIntervals * chunksPerInterval) - const end = Math.min(due, chunkCount) - for (let index = state.emitted; index < end; index++) { - const chunkText = index === chunkCount - 1 - ? `\n${marker}` - : index % 64 === 63 ? '推理\n' : '推理' - pushAssistant(sessionId, { type: 'reasoning-delta', index: 0, text: chunkText }) - } - state.emitted = end - if (end < chunkCount) { - setTimeout(pump, intervalMs) - } else { - state.emitting = false - } - } - setTimeout(pump, 0) - return marker - }, - /** Return a copy so browser probes cannot mutate the active producer. */ - reasoningChunkStormState(): ReasoningChunkStormState | null { - return activeReasoningChunkStorm === null ? null : { ...activeReasoningChunkStorm } - }, - /** Open one failed model step whose partial remains visible until llm/retry arrives. */ - beginModelRetry(id: string): void { - const sessionId = sid(id) - const turn = nextTurn.get(sessionId) ?? 0 - nextTurn.set(sessionId, turn + 1) - retryScenarios.set(sessionId, { turn, stepStarted: true }) - setRunning(sessionId, true) - append(sessionId, { type: 'turn/start', data: { turn } }) - append(sessionId, { type: 'user/message', surfaceOp: 'append', data: { content: text('请重试这个请求'), source: { kind: 'user' } } }) - append(sessionId, { type: 'step/start', data: { turn, step: 1 } }) - beginAssistant(sessionId, turn, 1) - pushAssistant(sessionId, { type: 'block-start', index: 0, blockType: 'text' }) - pushAssistant(sessionId, { type: 'text-delta', index: 0, text: '应撤回的半截回复' }) - }, - /** Record one retry decision; the next attempt remains in the same step. */ - scheduleModelRetry(id: string, retry = 1, delayMs = 450): void { - const sessionId = sid(id) - const scenario = retryScenarios.get(sessionId) - if (scenario === undefined) throw new Error(`fixture: no model retry scenario for ${id}`) - if (!scenario.stepStarted) { - beginAssistant(sessionId, scenario.turn, 1) - pushAssistant(sessionId, { type: 'block-start', index: 0, blockType: 'text' }) - pushAssistant(sessionId, { type: 'text-delta', index: 0, text: `第 ${String(retry)} 次应撤回的回复` }) - scenario.stepStarted = true - } - const failure = { code: 'TRANSPORT', message: '连接被重置' } - pushAssistant(sessionId, { type: 'finish', reason: { kind: 'error', failure } }) - const attempt = activeAttempts.get(sessionId) as FixtureAttemptState - const attemptEvent = append(sessionId, { - type: 'assistant/attempt', data: { turn: scenario.turn, step: 1, stream: attempt.stream.snapshot() }, - }) - commitAssistant(sessionId, attemptEvent) - append(sessionId, { - type: 'llm/retry', - data: { - turn: scenario.turn, step: 1, - provider: 'fixture', mode: 'normal', policyKey: 'fixture-normal', - retry, maxRetries: 2, delayMs, failure, - }, - }) - scenario.stepStarted = false - }, - /** Record one retry decision, then cancel its source turn before the retry starts. */ - cancelModelRetryDuringBackoff(id: string, delayMs = 450): void { - const sessionId = sid(id) - const scenario = retryScenarios.get(sessionId) - if (scenario === undefined) throw new Error(`fixture: no model retry scenario for ${id}`) - const failure = { code: 'TRANSPORT', message: '连接被重置' } - const active = activeAttempts.get(sessionId) - if (active !== undefined) { - pushAssistant(sessionId, { type: 'finish', reason: { kind: 'error', failure } }) - const attemptEvent = append(sessionId, { - type: 'assistant/attempt', data: { turn: scenario.turn, step: 1, stream: active.stream.snapshot() }, - }) - commitAssistant(sessionId, attemptEvent) - } - append(sessionId, { - type: 'llm/retry', - data: { - turn: scenario.turn, step: 1, - provider: 'fixture', mode: 'normal', policyKey: 'fixture-normal', - retry: 1, maxRetries: 2, delayMs, failure, - }, - }) - append(sessionId, { type: 'step/end', data: { turn: scenario.turn, step: 1 } }) - append(sessionId, { type: 'turn/end', data: { turn: scenario.turn, reason: { kind: 'aborted', reason: { kind: 'user' } }, - } }) - retryScenarios.delete(sessionId) - setRunning(sessionId, false) - }, - /** Finish the timing-hook retry with a finalized response in the open step. */ - completeModelRetry(id: string): void { - const sessionId = sid(id) - const scenario = retryScenarios.get(sessionId) - if (scenario === undefined) throw new Error(`fixture: no model retry scenario for ${id}`) - retryScenarios.delete(sessionId) - const completed = '重试后的完整回复' - beginAssistant(sessionId, scenario.turn, 1) - pushAssistant(sessionId, { type: 'block-start', index: 0, blockType: 'text' }) - pushAssistant(sessionId, { type: 'text-delta', index: 0, text: completed }) - pushAssistant(sessionId, { type: 'block-end', index: 0, block: { type: 'text', text: completed } }) - pushAssistant(sessionId, { type: 'finish', reason: { kind: 'stop' } }) - const attempt = activeAttempts.get(sessionId) as FixtureAttemptState - const message = append(sessionId, { - type: 'assistant/message', - surfaceOp: 'append', - data: { - turn: scenario.turn, - step: 1, - message: assistantMessage(text(completed)), - stream: attempt.stream.snapshot(), - }, - }) - commitAssistant(sessionId, message) - append(sessionId, { type: 'step/end', data: { turn: scenario.turn, step: 1 } }) - append(sessionId, { type: 'turn/end', data: { turn: scenario.turn, reason: { kind: 'completed' } } }) - setRunning(sessionId, false) - }, - /** Log append without follow delivery: a frame lost in transit that page repair must recover. */ - appendSilent(id: string, msg: string): void { - const log = logOf(sid(id)) - log.push({ type: 'user/message', surfaceOp: 'append', seq: SessionSeq(log.length), time: Date.now(), data: userMessage(text(msg)) } as unknown as SessionEvent) - }, - /** End every open stream generator (client sees both streams close -> reconnect + resync path). */ - breakStreams(): void { - for (const breakNow of [...streamBreakers]) breakNow() - }, - } - ;(globalThis as Record).__fxTiming = timingHooks - - /** Prompt replay: chunk typewriter (80ms/frame) -> assistant/message finalize -> turn/end + running flip. */ - const startReply = (id: SessionId, turn: number, replyText: string): void => { - const step = 0 - append(id, { type: 'step/start', data: { turn, step } }) - beginAssistant(id, turn, step) - pushAssistant(id, { type: 'block-start', index: 0, blockType: 'text' }) - /* v8 ignore next -- the ?? arm needs a null match, but every fixture reply is non-empty. */ - const pieces = replyText.match(/[\s\S]{1,6}/gu) ?? [replyText] - let i = 0 - const finish = (aborted: boolean): void => { - replays.delete(id) - const done = pieces.slice(0, i).join('') - pushAssistant(id, { type: 'block-end', index: 0, block: { type: 'text', text: done } }) - pushAssistant(id, { type: 'usage', usage: fixtureUsage(turn, step) }) - if (!aborted) pushAssistant(id, { type: 'finish', reason: { kind: 'stop' } }) - const attempt = activeAttempts.get(id) as FixtureAttemptState - const message = append(id, { - type: 'assistant/message', - surfaceOp: 'append', - data: { - turn, - step, - message: assistantMessage(text(done)), - stream: attempt.stream.snapshot(), - usage: fixtureUsage(turn, step), - ...(aborted ? { interrupted: true } : {}), - }, - }) - commitAssistant(id, message) - append(id, { type: 'step/end', data: { turn, step } }) - append(id, { type: 'turn/end', data: { - turn, - reason: aborted ? { kind: 'aborted', reason: { kind: 'user' } } : { kind: 'completed' }, - } }) - setRunning(id, false) - } - const tick = (): void => { - const piece = pieces[i] - if (piece === undefined) { - finish(false) - return - } - i++ - pushAssistant(id, { type: 'text-delta', index: 0, text: piece }) - replays.set(id, { timer: setTimeout(tick, 80), finish }) - } - replays.set(id, { timer: setTimeout(tick, 80), finish }) - } - - const sessionApi: FixtureSessionApi = { - list: _request => sessionOk({ items: [...sessions].sort((a, b) => b.updatedAt - a.updatedAt) }), - search: (request, signal) => { - if (signal.aborted) { - return sessionErr({ - code: 'gateway/cancelled', - message: 'fixture session search was aborted', - details: {}, - }) - } - const query = searchTokenSpans(request.query).tokens.map(token => token.value) - const matches = sessions.flatMap((summary) => { - const log = logs.get(summary.sessionId) ?? [] - const current = new Set(foldSurface(log).nodes) - const best = log.flatMap((event): FixtureSearchCandidate[] => { - if (!current.has(event.seq)) return [] - const eventText = searchEventText(event) - const document = searchTokenSpans(eventText) - const match = phraseMatch(document.tokens, query) - if (match.count === 0) return [] - return [{ - sessionId: summary.sessionId, - seq: event.seq, - time: event.time, - text: document.text, - matchCount: match.count, - matchStart: match.start, - matchEnd: match.end, - documentLength: Array.from(eventText).length, - }] - }).sort(compareSearchCandidates)[0] - return best === undefined ? [] : [best] - }).sort(compareSearchCandidates) - return sessionOk({ - items: matches.slice(0, FIXTURE_SESSION_SEARCH_RESULT_LIMIT).map(match => ({ - sessionId: match.sessionId, - snippet: searchSnippet(match.text, match.matchStart, match.matchEnd), - })), - hasMore: matches.length > FIXTURE_SESSION_SEARCH_RESULT_LIMIT, - }) - }, - create: async (request) => { - const workspace = request.workspaceId === undefined - ? undefined - : workspaces.find(w => w.workspaceId === request.workspaceId) - if (request.workspaceId !== undefined && workspace === undefined) { - return sessionErr({ - code: 'workspace/not-found', - message: `no workspace ${request.workspaceId}`, - details: { workspaceId: request.workspaceId }, - }) - } - const cwd = workspace?.path ?? request.cwd ?? '/tmp/fixture' - const requestedId = request.sessionId - const attachWorkspace = (sessionId: SessionId): void => { - /* v8 ignore next -- callers enter only when a target Workspace exists. */ - if (workspace === undefined || workspace.sessionIds.includes(sessionId)) return - workspace.sessionIds = [sessionId, ...workspace.sessionIds] - workspace.updatedAt = new Date().toISOString() - emitWorkspace({ type: 'upsert', workspace: workspaceSnapshot(workspace) }) - } - const attachFailure = ( - sessionId: SessionId, - workspaceId: WorkspaceId, - ): Promise> => sessionErr({ - code: 'session/workspace-attach-failed' as const, - message: `fixture rejected Workspace attachment for ${sessionId}`, - details: { sessionId, workspaceId }, - }) - if (requestedId !== undefined) { - const existing = summaryOf(requestedId) - if (existing !== undefined) { - if (existing.cwd !== cwd) { - return sessionErr({ - code: 'session/conflict', - message: `session ${requestedId} already uses ${existing.cwd ?? 'no cwd'}`, - details: { sessionId: requestedId, requestedCwd: cwd, ...existing.cwd === undefined ? {} : { existingCwd: existing.cwd } }, - }) - } - if (workspace !== undefined && !workspace.sessionIds.includes(requestedId)) { - if (options.failWorkspaceAttach) return attachFailure(requestedId, workspace.workspaceId) - attachWorkspace(requestedId) - } - return sessionOk({ sessionId: requestedId }) - } - } - const created: FixtureSessionSummary = { - sessionId: requestedId ?? sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, blank: true, cwd, - } - sessions.push(created) - modelSelections.set(created.sessionId, { provider: 'deepseek-official', model: 'deepseek-v4-flash' }) - const emitSession = (): void => { - emitRemote('api-session/added', [created]) - } - if (workspace !== undefined && options.failWorkspaceAttach) { - emitSession() - return attachFailure(created.sessionId, workspace.workspaceId) - } - if (workspace !== undefined && options.createFrameOrder === 'workspace-first') { - attachWorkspace(created.sessionId) - emitSession() - } else { - emitSession() - if (workspace !== undefined) attachWorkspace(created.sessionId) - } - if (options.dropSessionCreateResponse) throw new Error('fixture: dropped session.create response after publication') - return sessionOk({ sessionId: created.sessionId }) - }, - rename: (request) => { - const missing = requireRemoteSession(request) - if (missing !== undefined) return missing - const { sessionId, title } = request - const normalized = title.trim().replace(/\s+/g, ' ') - if (normalized.length === 0) { - return sessionErr({ - code: 'session/title-invalid', - message: 'session title must contain visible characters', - details: { sessionId }, - }) - } - // The append emits the durable event and its control projection frame; - // the unary response settles the caller first. - append(sessionId, { - type: 'session/title', - data: { title: normalized, messageSeqs: [], source: { kind: 'user' } }, - }) - const appended = logOf(sessionId).at(-1) as SessionEvent - return sessionOk({ title: normalized, seq: appended.seq }) - }, - fork: (request) => { - const { sessionId, atSeq } = request - const source = summaryOf(sessionId) - if (source === undefined) { - return sessionErr({ - code: 'session/not-found', - message: `no session ${sessionId}`, - details: { sessionId }, - }) - } - const log = logs.get(sessionId) ?? [] - const lastSeq = log.at(-1)?.seq ?? -1 - const anchoredBoundary = atSeq === undefined - ? undefined - : log.find(e => e.type === 'turn/end' && e.seq >= atSeq) - const boundary = anchoredBoundary - ?? (atSeq === undefined || atSeq > lastSeq - ? log.findLast(e => e.type === 'turn/end') - : undefined) - if (boundary === undefined) { - return sessionErr({ - code: 'session/fork-unavailable', - message: atSeq !== undefined && atSeq <= lastSeq - ? `session ${sessionId} has not completed the turn containing event ${String(atSeq)}` - : `session ${sessionId} has no completed turn`, - details: { sessionId }, - }) - } - const cut = boundary.seq + 1 - const child: FixtureSessionSummary = { - sessionId: sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, blank: false, - parentSessionId: sessionId, - ...source.cwd === undefined ? {} : { cwd: source.cwd }, - } - logs.set(child.sessionId, log.slice(0, cut)) - sessions.push(child) - emitRemote('api-session/added', [child]) - const workspace = workspaces.find(w => w.sessionIds.includes(sessionId)) - if (workspace !== undefined) { - workspace.sessionIds = [child.sessionId, ...workspace.sessionIds] - workspace.updatedAt = new Date().toISOString() - emitWorkspace({ type: 'upsert', workspace: workspaceSnapshot(workspace) }) - } - return sessionOk({ sessionId: child.sessionId }) - }, - history: async (request) => { - const log = logs.get(request.sessionId) ?? [] - const throughSeq = request.throughSeq ?? log.length - 1 - const boundedLog = log.slice(0, throughSeq + 1) - // Snapshot at request time, then deliver after the transit delay. - const page = pageOf(boundedLog, request.beforeSeq, request.maxMessages ?? 50) - const doomed = failNextHistory - failNextHistory = false - const delay = historyDelayMs - if (delay > 0) await new Promise(resolve => setTimeout(resolve, delay)) - if (doomed) throw new Error('fixture: simulated history transport failure') - return sessionOk(page) - }, - selectModel: (request) => { - const selected: ModelSelection = { - provider: request.provider, - model: request.model, - ...request.reasoningEffort === undefined - ? {} - : { reasoningEffort: request.reasoningEffort }, - } - append(request.sessionId, { type: 'model/selection', data: selected }) - modelSelections.set(request.sessionId, selected) - return sessionOk({ selected }) - }, - prompt: (request) => { - const { sessionId: id, mode, content } = request - const summary = summaryOf(id) - if (summary === undefined) { - return sessionErr({ code: 'session/not-found', message: `no session ${id}`, details: { sessionId: id } }) - } - if (options.rejectPrompt) { - if (content.some(block => block.type === 'image')) { - return sessionErr({ - code: 'session/attachment-invalid', - message: 'fixture: image side exceeds the deployment limit', - details: { reason: 'IMAGE_DIMENSION_TOO_LARGE' }, - }) - } - return sessionErr({ - code: 'session/agent-busy', - message: 'fixture: prompt rejected before acceptance', - details: { reason: 'fixture-prompt-rejection' }, - }) - } - summary.updatedAt = Date.now() - // First accepted prompt appends events: the summary stops being blank. - summary.blank = false - const userText = content.map(b => (b.type === 'text' ? b.text : '')).join('') - const durable: ContentBlock[] = content.map((block) => { - if (block.type === 'text') return block - const attachment: ImageAttachmentRef = { - attachmentId: `fixture:${randomUuid()}` as AttachmentIdType, - mediaType: block.mediaType, - bytes: Math.max( - 1, - Math.floor(block.data.length * 3 / 4) - - (block.data.endsWith('==') ? 2 : block.data.endsWith('=') ? 1 : 0), - ), - width: 160, - height: 90, - ...block.name === undefined ? {} : { name: block.name }, - } - attachments.set(String(attachment.attachmentId), { attachment, data: block.data }) - return { type: 'image', attachment } - }) - // The host echoes the prompt's requestId as the user source's rpcId; - // the Session object retires its local submission echo on it. The - // user-rpc source member is declared by dsh-api-session-controller, - // which this standalone fixture does not import — hence the assertion. - const promptSource = { kind: 'user', rpcId: request.requestId } as MessageSource - if (mode === 'steer' && replays.has(id)) { - // Steering: the durable user/message lands inside the current turn; the replay continues. - append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(durable, promptSource) }) - return sessionOk({ accepted: true as const }) - } - const turn = nextTurn.get(id) ?? 0 - nextTurn.set(id, turn + 1) - setRunning(id, true) - append(id, { type: 'turn/start', data: { turn } }) - // Boundary flush parallel (the host's step/start observer): an outstanding - // /plan selection commits as plan/mode inside the opened turn. - const plan = foldPlan(logOf(id)) - if (plan.wanted !== null && plan.wanted !== plan.active) { - append(id, { type: 'plan/mode', data: { active: plan.wanted } }) - } - append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(durable, promptSource) }) - // Capacity parallel of the host token-meter's request/context record: - // log-only, appended inside the open turn, and deduplicated against the - // route already recorded (the fixture never varies contextWindow). - const selection = modelSelections.get(id) ?? { provider: 'deepseek', model: 'deepseek-v4-flash' } - const previousHeader = logOf(id).findLast(event => event.type === 'request/header') - const previousSelection = previousHeader?.type === 'request/header' - ? { - provider: previousHeader.data.header.config.provider, - model: previousHeader.data.header.config.model, - ...(previousHeader.data.header.config.reasoningEffort === undefined - ? {} - : { reasoningEffort: previousHeader.data.header.config.reasoningEffort }), - } - : null - if (!sameModelSelection(previousSelection, selection)) { - append(id, { - type: 'request/header', - data: { - header: { config: selection }, - reason: previousHeader === undefined ? 'initial' : 'change', - }, - }) - } - if (lastRequestContext(logOf(id))?.model !== selection.model) { - append(id, { - type: 'request/context', - data: { provider: selection.provider, model: selection.model, contextWindow: 128_000 }, - }) - } - startReply( - id, - turn, - userText === 'render markdown' - ? MARKDOWN_FIXTURE - : userText === 'report model' - ? (() => { - const selection = modelSelections.get(id) - return `当前模型:${selection?.provider ?? 'unknown'}/${selection?.model ?? 'unknown'}` - + (selection?.reasoningEffort === undefined ? '' : ` · 推理等级:${selection.reasoningEffort}`) - })() - : `回声:${userText}。这是 fixture 的流式回复,用于验证打字机增长与定稿切换。`, - ) - return sessionOk({ accepted: true as const }) - }, - attachment: (request) => { - const stored = attachments.get(String(request.attachmentId)) - if (stored === undefined) { - return sessionErr({ - code: 'session/attachment-invalid', - message: 'fixture attachment missing', - details: { reason: 'ATTACHMENT_NOT_FOUND' }, - }) - } - if (!logReferencesAttachment( - logs.get(request.sessionId) ?? [], - String(request.attachmentId), - )) { - return sessionErr({ - code: 'session/attachment-invalid', - message: 'fixture attachment is not referenced by this session', - details: { reason: 'ATTACHMENT_NOT_REFERENCED' }, - }) - } - return sessionOk(stored) - }, - updateQueue: request => sessionErr({ - code: 'session/queue-item-not-found', - message: 'fixture has no pending queue item', - details: { itemId: request.itemId }, - }), - cancel: (request) => { - const replay = replays.get(request.sessionId) - if (replay !== undefined) { - clearTimeout(replay.timer) - replay.finish(true) - } else { - setRunning(request.sessionId, false) - } - return sessionOk({ accepted: true as const }) - }, - } - - const controlBaseline = (): Extract => { - const queues: Record = {} - const jobs: Record = {} - const projections: Record = {} - for (const summary of sessions) { - queues[summary.sessionId] = [] - jobs[summary.sessionId] = [] - const log = logs.get(summary.sessionId) ?? [] - projections[summary.sessionId] = { - asOfSeq: log.length - 1, - values: projectionValuesOf(log), - } - } - return { - type: 'baseline', - value: { - queues, - jobs, - approvals: [], - questions: [], - projections, - }, - } - } - - const approvalInvocation = (): FixtureRemoteEventInvocationFrame => ({ - type: 'waterfall', - event: 'approval/request', - eventId: pendingApprovalEventId, - agentId: sid('fx-alpha'), - request: { - toolName: 'dangerous_tool', - reason: 'fixture 常驻审批(可答:批准/拒绝后消失)', - }, - }) - - const questionInvocation = (): FixtureRemoteEventInvocationFrame => ({ - type: 'waterfall', - event: 'user-questions/request', - eventId: pendingQuestionEventId, - agentId: sid('fx-alpha'), - request: { - questions: fixtureQuestions, - }, - }) - - async function* openControl(signal: AbortSignal): AsyncGenerator { - signal.throwIfAborted() - const conn = new FxInbox() - controlConns.add(conn) - const breakNow = (): void => { conn.breakNow() } - streamBreakers.add(breakNow) - try { - yield controlBaseline() - yield* conn.drain(signal) - } finally { - streamBreakers.delete(breakNow) - controlConns.delete(conn) - } - } - - async function* openWorkspace(signal: AbortSignal): AsyncGenerator { - signal.throwIfAborted() - const conn = new FxInbox() - workspaceConns.add(conn) - const breakNow = (): void => { conn.breakNow() } - streamBreakers.add(breakNow) - try { - yield workspaceBaseline() - yield* conn.drain(signal) - } finally { - streamBreakers.delete(breakNow) - workspaceConns.delete(conn) - } - } - - async function* openWorkspaceFileChanges(signal: AbortSignal): AsyncGenerator { - signal.throwIfAborted() - const conn = new FxInbox() - const breakNow = (): void => { conn.breakNow() } - streamBreakers.add(breakNow) - // Opt-in only: an unprompted frame would age every preview of demo.txt - // into its changed state on a timer the assembled snapshots cannot see. - const announce = options.fileChanges - ? setTimeout(() => { - conn.push({ kind: 'change', change: { absolutePath: `${WORKSPACE_FILES_ROOT}/notes/demo.txt`, version: 'fx-demo-v2' } }) - }, 1000) - : undefined - try { - yield { kind: 'ready' } - yield* conn.drain(signal) - } finally { - if (announce !== undefined) clearTimeout(announce) - streamBreakers.delete(breakNow) - } - } - - async function* openRemoteEvents( - signal: AbortSignal, - ): AsyncGenerator { - signal.throwIfAborted() - const clientId = randomUuid() - const conn = new FxInbox() - remoteEventConns.set(clientId, conn) - // Periodic material for the RPC-panel acceptance: flip fx-gamma every 5s. - // fx-gamma only; the conversation replay owns fx-alpha's running state. - const timer = setInterval(() => { - const gamma = summaryOf(sid('fx-gamma')) - /* v8 ignore next -- the fixture never removes fx-gamma. */ - if (gamma !== undefined) setRunning(gamma.sessionId, !gamma.running) - }, 5000) - try { - yield { type: 'ready', clientId, host: { home: FIXTURE_HOME } } - if (approvalPending) yield approvalInvocation() - if (questionPending) yield questionInvocation() - yield* conn.drain(signal) - } finally { - clearInterval(timer) - remoteEventConns.delete(clientId) - } - } - - async function* openFollow( - request: FixtureFollowRequest, - signal: AbortSignal, - ): AsyncGenerator { - signal.throwIfAborted() - const sessionId = request.address.kind === 'session' - ? request.address.sessionId - : request.address.childSessionId - if (summaryOf(sessionId) === undefined) throw new Error(`fixture: no session ${sessionId}`) - const conn = new FxInbox() - let conns = followConns.get(sessionId) - if (conns === undefined) { - conns = new Set() - followConns.set(sessionId, conns) - } - conns.add(conn) - const breakNow = (): void => { conn.breakNow() } - streamBreakers.add(breakNow) - const snapshot = [...logOf(sessionId)] - const cursor = snapshot.at(-1)?.seq ?? -1 - const summary = summaryOf(sessionId) - /* v8 ignore next -- existence was checked before the stream registered. */ - if (summary === undefined) throw new Error(`fixture: no session ${sessionId}`) - const initial = pageOf(snapshot, undefined, request.maxMessages ?? 50) - let nextSeq = cursor + 1 - try { - yield { - type: 'snapshot', - header: { - version: SESSION_FORMAT_VERSION, - id: sessionId, - createdAt: summary.updatedAt, - ...(summary.cwd === undefined ? {} : { cwd: summary.cwd }), - ...(summary.parentSessionId === undefined ? {} : { parentSession: summary.parentSessionId }), - isSeeded: summary.parentSessionId !== undefined, - ...(summary.origin === undefined ? {} : { origin: summary.origin }), - ...(summary.agentPreset === undefined ? {} : { agentPreset: summary.agentPreset }), - }, - cursor, - records: initial.records, - hasMore: initial.hasMore, - projections: { asOfSeq: cursor, values: projectionValuesOf(snapshot) }, - ...(request.assistantStream === true ? { - assistantStream: { - revision: assistantRevisions.get(sessionId) ?? 0, - ...(activeAttempts.get(sessionId) === undefined - ? {} - : { activeAttempt: { - attemptId: (activeAttempts.get(sessionId) as FixtureAttemptState).attemptId, - startedAfterSeq: (activeAttempts.get(sessionId) as FixtureAttemptState).startedAfterSeq, - turn: (activeAttempts.get(sessionId) as FixtureAttemptState).turn, - step: (activeAttempts.get(sessionId) as FixtureAttemptState).step, - nextIndex: (activeAttempts.get(sessionId) as FixtureAttemptState).index, - stream: (activeAttempts.get(sessionId) as FixtureAttemptState).stream.snapshot(), - } }), - }, - } : {}), - } - for await (const frame of conn.drain(signal)) { - if (frame.type === 'assistant-stream') { - yield frame - continue - } - if (frame.event.seq < nextSeq) continue - if (frame.event.seq !== nextSeq) { - throw new Error(`fixture: session event stream skipped seq ${String(nextSeq)}`) - } - nextSeq++ - yield frame - } - } finally { - streamBreakers.delete(breakNow) - conns.delete(conn) - if (conns.size === 0) followConns.delete(sessionId) - } - } - - const answerRemoteEvent = (result: FixtureRemoteEventResult): ConnectionRpcResult => { - if (!remoteEventConns.has(result.clientId)) { - return { - ok: false, - error: { - code: 'gateway/invocation-unavailable', - message: 'fixture Remote event result identifies no active event stream', - details: {}, - }, - } - } - if (result.eventId === pendingApprovalEventId) { - if (!approvalPending) return { ok: true, value: undefined } - approvalPending = false - } else if (result.eventId === pendingQuestionEventId) { - if (!questionPending) return { ok: true, value: undefined } - questionPending = false - } else { - return { ok: true, value: undefined } - } - emitRemoteFrame({ type: 'cancel', eventId: result.eventId }) - return { ok: true, value: undefined } - } - - const workspaceApi: FixtureWorkspaceApi = { - create: (request) => { - const existing = workspaces.find(workspace => workspace.path === request.path) - if (existing !== undefined) { - return sessionOk({ workspace: workspaceSnapshot(existing), created: false }) - } - const now = new Date().toISOString() - const created: FixtureWorkspace = { - workspaceId: wid(`fx-ws-${nextWorkspace++}`), - path: request.path, - title: request.path.split('/').filter(Boolean).at(-1) ?? request.path, - sessionIds: [], - createdAt: now, - updatedAt: now, - } - workspaces.unshift(created) - const workspace = workspaceSnapshot(created) - emitWorkspace({ type: 'upsert', workspace }) - return sessionOk({ workspace, created: true }) - }, - rename: (request) => { - const workspace = workspaces.find(candidate => candidate.workspaceId === request.workspaceId) - if (workspace === undefined) { - return sessionErr({ - code: 'workspace/not-found', - message: `no workspace ${request.workspaceId}`, - details: { workspaceId: request.workspaceId }, - }) - } - const title = request.title.trim() - if (title === '') { - return sessionErr({ - code: 'gateway/bad-request', - message: 'Workspace rename requires a non-blank title', - details: {}, - }) - } - if (title !== workspace.title) { - if (workspaces.some(candidate => candidate.workspaceId !== request.workspaceId && candidate.title === title)) { - return sessionErr({ - code: 'workspace/name-conflict', - message: `workspace name '${title}' is already in use`, - details: { name: title }, - }) - } - workspace.title = title - workspace.updatedAt = new Date().toISOString() - emitWorkspace({ type: 'upsert', workspace: workspaceSnapshot(workspace) }) - } - return sessionOk({ workspace: workspaceSnapshot(workspace) }) - }, - delete: (request) => { - const index = workspaces.findIndex(workspace => workspace.workspaceId === request.workspaceId) - if (index === -1) { - return sessionErr({ - code: 'workspace/not-found', - message: `no workspace ${request.workspaceId}`, - details: { workspaceId: request.workspaceId }, - }) - } - workspaces.splice(index, 1) - emitWorkspace({ type: 'remove', workspaceId: request.workspaceId }) - return sessionOk({ deleted: true }) - }, - insertBefore: (request) => { - const source = workspaces.findIndex(workspace => workspace.workspaceId === request.workspaceId) - const anchor = request.beforeWorkspaceId === undefined - ? workspaces.length - : workspaces.findIndex(workspace => workspace.workspaceId === request.beforeWorkspaceId) - const missing = source === -1 - ? request.workspaceId - : anchor === -1 - ? request.beforeWorkspaceId - : undefined - if (missing !== undefined) { - return sessionErr({ - code: 'workspace/not-found', - message: `no workspace ${missing}`, - details: { workspaceId: missing }, - }) - } - if (request.beforeWorkspaceId !== request.workspaceId) { - const previousOrder = workspaces.map(workspace => workspace.workspaceId) - const [workspace] = workspaces.splice(source, 1) - /* v8 ignore next -- source was resolved from the same array immediately above. */ - if (workspace === undefined) throw new Error(`fixture lost workspace ${request.workspaceId}`) - const at = request.beforeWorkspaceId === undefined - ? workspaces.length - : workspaces.findIndex(candidate => candidate.workspaceId === request.beforeWorkspaceId) - workspaces.splice(at, 0, workspace) - if (workspaces.some((candidate, index) => candidate.workspaceId !== previousOrder[index])) { - emitWorkspace({ - type: 'order', - workspaceIds: workspaces.map(candidate => candidate.workspaceId), - }) - } - } - return sessionOk({ workspaceIds: workspaces.map(candidate => candidate.workspaceId) }) - }, - insertSessionBefore: (request) => { - const workspace = workspaces.find(candidate => candidate.workspaceId === request.workspaceId) - if (workspace === undefined) { - return sessionErr({ - code: 'workspace/not-found', - message: `no workspace ${request.workspaceId}`, - details: { workspaceId: request.workspaceId }, - }) - } - if (!workspace.sessionIds.includes(request.sessionId) - || (request.beforeSessionId !== undefined && !workspace.sessionIds.includes(request.beforeSessionId))) { - return sessionErr({ - code: 'workspace/move-invalid', - message: `session or anchor is not accounted by workspace ${request.workspaceId}`, - details: { - workspaceId: request.workspaceId, - sessionId: request.sessionId, - ...request.beforeSessionId === undefined ? {} : { beforeSessionId: request.beforeSessionId }, - }, - }) - } - const without = workspace.sessionIds.filter(id => id !== request.sessionId) - const at = request.beforeSessionId === undefined ? without.length : without.indexOf(request.beforeSessionId) - const sessionIds = [...without.slice(0, at), request.sessionId, ...without.slice(at)] - if (!sessionIds.every((id, index) => id === workspace.sessionIds[index])) { - workspace.sessionIds = sessionIds - workspace.updatedAt = new Date().toISOString() - emitWorkspace({ type: 'upsert', workspace: workspaceSnapshot(workspace) }) - } - return sessionOk({ workspace: workspaceSnapshot(workspace) }) - }, - archiveSession: (request) => { - if (summaryOf(request.sessionId) === undefined) { - return sessionErr({ - code: 'session/not-found', - message: `no session ${request.sessionId}`, - details: { sessionId: request.sessionId }, - }) - } - if (!archivedSessionIds.includes(request.sessionId)) { - archivedSessionIds.push(request.sessionId) - emitWorkspace({ type: 'archived', archivedSessionIds: [...archivedSessionIds] }) - } - return sessionOk({ archivedSessionIds: [...archivedSessionIds] }) - }, - unarchiveSession: (request) => { - if (archivedSessionIds.includes(request.sessionId)) { - archivedSessionIds.splice(archivedSessionIds.indexOf(request.sessionId), 1) - emitWorkspace({ type: 'archived', archivedSessionIds: [...archivedSessionIds] }) - } - return sessionOk({ archivedSessionIds: [...archivedSessionIds] }) - }, - } - - const rpc: ClientConnectionRpc = { - call(channel, endpoint, payload, signal) { - if (channel !== '/api') { - return Promise.reject(new Error(`fixture connection RPC channel ${JSON.stringify(channel)} is unavailable`)) - } - const args = (payload as { - args: Readonly<{ - agentId: SessionId - line?: string - query?: string - path?: string - range?: { offset?: number; limit?: number } - name?: string - images?: readonly unknown[] - // A goal ref and a credential reference name share this wire field name. - ref?: string | { id: string; revision: number } - refs?: readonly string[] - value?: string - ns?: string - settingsNs?: string - agentPreset?: string - from?: string - id?: string - request?: unknown - _request?: unknown - }> - }).args - const sessionId = args.agentId - const callSignal = signal ?? new AbortController().signal - const request = args.request - switch (endpoint) { - case 'commands/list': return Promise.resolve(commandRemotes.list(sessionId)) - case 'commands/execute': return Promise.resolve(commandRemotes.execute(sessionId, args.line as string, args.images ?? [])) - case 'fileReferences/list': return Promise.resolve(referenceRemotes.files(sessionId, args.query ?? '')) - case 'sessionReferenceResolver/candidates': return Promise.resolve(referenceRemotes.sessions(sessionId, args.query ?? '')) - case 'directoryPicker/pick': return Promise.resolve(directoryPickerRemotes.pick()) - case 'directoryPicker/list': return Promise.resolve(directoryPickerRemotes.list(args.path)) - case 'directoryPicker/createDirectory': - return Promise.resolve(directoryPickerRemotes.createDirectory(args.path ?? '', args.name ?? '')) - case 'goals/get': return Promise.resolve(goalRemotes.get(sessionId)) - case 'goals/create': return Promise.resolve(goalRemotes.create(sessionId, { - objective: (request as { objective?: string } | undefined)?.objective as string, - ...(request as { maxGoalRounds?: number } | undefined)?.maxGoalRounds === undefined - ? {} - : { maxGoalRounds: (request as { maxGoalRounds: number }).maxGoalRounds }, - })) - case 'goals/edit': return Promise.resolve(goalRemotes.edit( - sessionId, - args.ref as FxGoalRef, - request as { objective?: string; maxGoalRounds?: number }, - )) - case 'goals/pause': return Promise.resolve(goalRemotes.pause(sessionId, args.ref as FxGoalRef)) - case 'goals/resume': return Promise.resolve(goalRemotes.resume(sessionId, args.ref as FxGoalRef)) - case 'goals/complete': return Promise.resolve(goalRemotes.complete(sessionId, args.ref as FxGoalRef)) - case 'goals/clear': return Promise.resolve(goalRemotes.clear(sessionId, args.ref as FxGoalRef)) - case 'agentPresets/list': return Promise.resolve(presetRemotes.list()) - case 'agentPresets/select': return Promise.resolve(presetRemotes.select(sessionId, args.agentPreset as string)) - case 'agentPresets/read': return Promise.resolve(presetRemotes.read(args.agentPreset as string)) - case 'agentPresets/copy': return Promise.resolve(presetRemotes.copy(args.from as string, args.id as string)) - case 'agentPresets/deletePreset': return Promise.resolve(presetRemotes.deletePreset(args.id as string)) - case 'subagents/list': return Promise.resolve({ - ok: true, - value: { entries: [], parentAvailable: true }, - }) - case 'subagents/prompt': return Promise.resolve({ - ok: true, - value: { - messageId: `fixture-message-${(request as { childSessionId: SessionId }).childSessionId}`, - }, - }) - case 'subagents/interruptByParent': return Promise.resolve({ ok: true, value: { accepted: true } }) - case 'credentials/describe': return Promise.resolve(credentialRemotes.describe(args.refs ?? [])) - case 'credentials/set': return Promise.resolve(credentialRemotes.set(args.ref as string)) - case 'credentials/unset': return Promise.resolve(credentialRemotes.unset(args.ref as string)) - case 'settings/describe': return Promise.resolve(settingsRemotes.describe()) - case 'settings/canOpenAgentPresetDirectory': return Promise.resolve({ ok: true, value: true }) - case 'settings/openSettingsDocument': return Promise.resolve(settingsRemotes.openSettingsDocument()) - case 'settings/openAgentPresetDirectory': return Promise.resolve( - settingsRemotes.openAgentPresetDirectory(args.agentPreset as string), - ) - case 'skills/list': { - const skillRequest = request as { readonly sessionId: SessionId } - const missing = requireRemoteSession(skillRequest) - if (missing !== undefined) return missing - return sessionOk({ - skills: [ - { name: 'fixture-demo', description: 'fixture 技能样本', whenToUse: '仅供 UI 目录渲染验收', modelInvocable: true }, - { name: 'fixture-user-only', description: 'fixture 仅用户技能样本', modelInvocable: false }, - ], - }) - } - case 'session/openWorkspacePath': { - return sessionOk({ opened: true as const }) - } - case 'workspaceFiles/read': { - return Promise.resolve(workspaceFileRemotes.read(args.path ?? '', args.range ?? {})) - } - case 'workspaceFiles/stat': { - return Promise.resolve(workspaceFileRemotes.stat(args.path ?? '')) - } - case 'workspaceFiles/list': { - return Promise.resolve(workspaceFileRemotes.list(args.path ?? '')) - } - case 'session/canOpenWorkspacePath': return Promise.resolve({ ok: true, value: true }) - case 'session/modelCatalog': return Promise.resolve({ - ok: true, - value: { - default: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - routableProviders: ['deepseek-official', 'openai', 'acme-gateway'], - groups: fixtureModelGroups(), - failures: [], - }, - }) - case 'llm/listProviders': return Promise.resolve({ - ok: true, - value: [ - { id: 'deepseek-official', name: 'DeepSeek' }, - { id: 'openai', name: 'openai' }, - { id: 'acme-gateway', name: 'Acme Gateway' }, - ], - }) - case 'llm/listConfigurableProviders': return Promise.resolve({ - ok: true, - value: [ - { provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [] }, - { provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], declared: false }, - { provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'], declared: false }, - { provider: 'acme-gateway', displayName: 'Acme Gateway', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'acme-gateway'], declared: true }, - ], - }) - // The fixture endpoint is imaginary, so interrogation answers the - // catalog it already serves without a network request. - case 'llm/discoverModels': return Promise.resolve({ - ok: true, - value: fixtureModelGroups().flatMap(group => group.models.map(model => ({ id: model.id, name: model.name }))), - }) - case 'settings/update': return Promise.resolve(settingsRemotes.update(args.ns as string)) - case 'settings/replace': return Promise.resolve(settingsRemotes.replace(args.ns as string)) - case 'settings/mutate': return Promise.resolve(settingsRemotes.mutate(args.ns as string)) - case 'session/list': return sessionApi.list( - args._request as Parameters[0], - ) - case 'session/search': return sessionApi.search( - request as Parameters[0], - callSignal, - ) - case 'session/create': return sessionApi.create( - request as Parameters[0], - ) - case 'session/selectModel': return sessionApi.selectModel( - request as Parameters[0], - ) - case 'session/rename': return sessionApi.rename( - request as Parameters[0], - ) - case 'session/fork': return sessionApi.fork( - request as Parameters[0], - ) - case 'session/prompt': return sessionApi.prompt( - request as Parameters[0], - ) - case 'session/attachment': return sessionApi.attachment( - request as Parameters[0], - ) - case 'session/updateQueue': return sessionApi.updateQueue( - request as Parameters[0], - ) - case 'session/cancel': return sessionApi.cancel( - request as Parameters[0], - ) - case 'session/page': { - const page = request as FixturePageRequest - const pageSessionId = page.address.kind === 'session' - ? page.address.sessionId - : page.address.childSessionId - return sessionApi.history({ - sessionId: pageSessionId, - throughSeq: page.throughSeq, - ...page.beforeSeq === undefined ? {} : { beforeSeq: page.beforeSeq }, - ...page.maxMessages === undefined ? {} : { maxMessages: page.maxMessages }, - }) - } - case '$events/result': return Promise.resolve(answerRemoteEvent(args as unknown as FixtureRemoteEventResult)) - case 'workspace/create': return workspaceApi.create(request as WorkspaceCreateRequest) - case 'workspace/rename': return workspaceApi.rename(request as WorkspaceRenameRequest) - case 'workspace/delete': return workspaceApi.delete(request as WorkspaceDeleteRequest) - case 'workspace/insertBefore': return workspaceApi.insertBefore(request as WorkspaceInsertBeforeRequest) - case 'workspace/insertSessionBefore': return workspaceApi.insertSessionBefore( - request as WorkspaceInsertSessionBeforeRequest, - ) - case 'workspace/archiveSession': return workspaceApi.archiveSession(request as WorkspaceArchiveSessionRequest) - case 'workspace/unarchiveSession': return workspaceApi.unarchiveSession( - request as WorkspaceUnarchiveSessionRequest, - ) - default: - return Promise.reject(new Error(`fixture connection RPC endpoint ${JSON.stringify(endpoint)} is unavailable`)) - } - }, - open(channel, endpoint, payload, signal) { - if (channel !== '/api') { - throw new Error(`fixture connection RPC channel ${JSON.stringify(channel)} is unavailable`) - } - const args = (payload as { args: Readonly<{ request?: unknown }> }).args - switch (endpoint) { - case '$events': return openRemoteEvents(signal) - case 'session/control': return openControl(signal) - case 'session/follow': return openFollow(args.request as FixtureFollowRequest, signal) - case 'workspace/follow': return openWorkspace(signal) - case 'workspaceFiles/changes': return openWorkspaceFileChanges(signal) - default: - throw new Error(`fixture connection stream endpoint ${JSON.stringify(endpoint)} is unavailable`) - } - }, - } - return { rpc } -} - -/** - * Build the browser fixture transport from the current page's query switches. - * @returns an in-memory Connection RPC transport. - */ -export function createFixtureConnectionRpc(): ClientConnectionRpc { - return createFixtureWorld(fixtureOptionsFromLocation()).rpc -} - -/** Browser query mapping; direct unit callers pass FixtureOptions explicitly. */ -function fixtureOptionsFromLocation(): FixtureOptions { - if (typeof location === 'undefined') return {} - const query = new URLSearchParams(location.search) - return { - empty: query.get('fixture') === 'empty', - rejectPrompt: query.get('fixturePrompt') === 'reject', - failWorkspaceAttach: query.get('fixtureAttach') === 'fail', - dropSessionCreateResponse: query.get('fixtureSessionCreate') === 'drop-response', - createFrameOrder: query.get('fixtureFrames') === 'workspace-first' ? 'workspace-first' : 'session-first', - fileChanges: query.get('fixtureFileChanges') === 'demo', - } -} diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index a134400044..4497ca05de 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -8,7 +8,6 @@ import { type ConnectionSinks, type ConnectionState, } from './connection.ts' -import { createFixtureConnectionRpc } from './fixture.ts' import { createWebConnectionRpc, type RpcFetch, type RpcStreamOpen } from './rpc.ts' import { isLoopbackHostname } from '../loopback-hostname.ts' import type { ClientConnectionRpc } from '../rpc.ts' @@ -111,10 +110,9 @@ interface ClientTransportGlobal { __DSH_CONNECTION_RECOVERY__?: unknown } -/** Browser location fields used to select fixture mode and loopback authority. */ +/** Browser location fields used to classify loopback authority. */ export interface ConnectionLocation { readonly hostname: string - readonly search: string } /** Instance-local inputs for installing a Connection service. */ @@ -204,11 +202,9 @@ function watchBrowserNetwork(controller: ConnectionController): () => void { */ export function installConnection(ctx: Context, options: ConnectionInstallOptions = {}): void { const pageLocation = options.location - const fixture = pageLocation !== undefined && new URLSearchParams(pageLocation.search).has('fixture') - const fixtureRpc = fixture ? createFixtureConnectionRpc() : undefined const transport = options.transport const recovery = options.recovery ?? {} - const rpc = fixtureRpc ?? transport?.rpc ?? createWebConnectionRpc(transport?.fetch, transport?.openStream) + const rpc = transport?.rpc ?? createWebConnectionRpc(transport?.fetch, transport?.openStream) let generationSource: ConnectionGenerationSource | undefined let owner: ConnectionOwner | undefined let generationId = 0 diff --git a/packages/client/connection/tests/client-apply.client.spec.ts b/packages/client/connection/tests/client-apply.client.spec.ts index 3025767fd5..084f037135 100644 --- a/packages/client/connection/tests/client-apply.client.spec.ts +++ b/packages/client/connection/tests/client-apply.client.spec.ts @@ -1,6 +1,6 @@ /** - * Connection plugin browser-half apply: ctx.connection handle mounting, mode - * selection off the page URL, and single-consumer connection-loop ownership. + * Connection plugin browser-half apply: ctx.connection handle mounting, + * explicit carrier selection, and single-consumer connection-loop ownership. */ import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -15,7 +15,7 @@ import { } from '../src/client/index.ts' type Win = { - location?: { hostname: string; search: string; origin?: string } + location?: { hostname: string; origin?: string } __DSH_TRANSPORT__?: ClientTransportHooks } @@ -124,25 +124,18 @@ describe('connection client apply', () => { }) it('mounts ctx.connection and identifies a loopback page', async () => { - ;(globalThis as Win).location = { hostname: 'localhost', search: '' } + ;(globalThis as Win).location = { hostname: 'localhost' } const handle = await mount() expect(handle.isLoopback).toBe(true) }) - it('selects the fixture RPC transport under ?fixture', async () => { - ;(globalThis as Win).location = { hostname: '127.0.0.1', search: '?fixture' } - const handle = await mount() - await expect(handle.rpc.call('/api', 'settings/describe', { args: {} })) - .resolves.toMatchObject({ ok: true }) - }) - it('reports non-loopback page authority through the connection handle', async () => { - ;(globalThis as Win).location = { hostname: '192.0.2.20', search: '' } + ;(globalThis as Win).location = { hostname: '192.0.2.20' } expect((await mount()).isLoopback).toBe(false) }) it('requires one generation source and ignores a stale source disposer', async () => { - ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } + ;(globalThis as Win).location = { hostname: 'localhost' } const handle = await mount() const first = new GenerationProbe() const second = new GenerationProbe() @@ -165,7 +158,7 @@ describe('connection client apply', () => { }) it('start() hands out one loop, rejects a second consumer, and stop() aborts the generation', async () => { - ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } + ;(globalThis as Win).location = { hostname: 'localhost' } const handle = await mount() installGeneration(handle) const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) @@ -192,7 +185,7 @@ describe('connection client apply', () => { }) it('does not notify state subscribers when a pre-ready loop stops', async () => { - ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } + ;(globalThis as Win).location = { hostname: 'localhost' } const handle = await mount() handle.registerGenerationSource(signal => new Promise((resolve) => { signal.addEventListener('abort', () => { resolve() }, { once: true }) @@ -209,7 +202,7 @@ describe('connection client apply', () => { }) it('allows a replacement owner and ignores the previous owner handle', async () => { - ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } + ;(globalThis as Win).location = { hostname: 'localhost' } const handle = await mount() const generation = installGeneration(handle) @@ -232,7 +225,7 @@ describe('connection client apply', () => { }) it('lets the connection service force only its current owner to reconnect', async () => { - ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } + ;(globalThis as Win).location = { hostname: 'localhost' } const handle = await mount() installGeneration(handle) const requested = vi.fn() @@ -259,7 +252,7 @@ describe('connection client apply', () => { it('ignores a non-browser window shim without navigator state', async () => { vi.stubGlobal('window', new EventTarget()) - ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } + ;(globalThis as Win).location = { hostname: 'localhost' } const handle = await mount() installGeneration(handle) const loop = handle.start({}) @@ -276,7 +269,7 @@ describe('connection client apply', () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) const browser = new BrowserNetworkProbe() vi.stubGlobal('window', browser) - ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } + ;(globalThis as Win).location = { hostname: 'localhost' } const handle = await mount() let calls = 0 const source: ConnectionGenerationSource = (signal, ready) => new Promise((resolve) => { @@ -320,7 +313,7 @@ describe('connection client apply', () => { }) it('does not announce a generation synchronously stopped by a generation subscriber', async () => { - ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } + ;(globalThis as Win).location = { hostname: 'localhost' } const handle = await mount() installGeneration(handle) const owner: { loop?: ReturnType } = {} @@ -344,7 +337,7 @@ describe('connection client apply', () => { }) it('retracts the generation while connecting and publishes the next generation', async () => { - ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } + ;(globalThis as Win).location = { hostname: 'localhost' } const handle = await mount() const generation = installGeneration(handle) const generations: Array = [] @@ -377,7 +370,7 @@ describe('connection client apply', () => { }) it('publishes connection state directly on the service and isolates subscribers', async () => { - ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } + ;(globalThis as Win).location = { hostname: 'localhost' } const handle = await mount() const generation = installGeneration(handle) const snapshots: Array = [] @@ -415,7 +408,7 @@ describe('connection client apply', () => { }) it('does not announce disconnection after a generation subscriber stops the loop', async () => { - ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } + ;(globalThis as Win).location = { hostname: 'localhost' } const handle = await mount() const generation = installGeneration(handle) const owner: { loop?: ReturnType } = {} @@ -448,7 +441,7 @@ describe('connection client apply', () => { }) it('carries RPC calls without requiring secure-context randomUUID', async () => { - ;(globalThis as Win).location = { hostname: 'localhost', search: '' } + ;(globalThis as Win).location = { hostname: 'localhost' } vi.stubGlobal('crypto', { getRandomValues(bytes: Uint8Array) { return bytes.fill(0) @@ -486,7 +479,7 @@ describe('connection client apply', () => { }) it('uses an already decoded rpc carrier from the transport hooks instead of the HTTP caller', async () => { - ;(globalThis as Win).location = { hostname: 'preview.example', search: '' } + ;(globalThis as Win).location = { hostname: 'preview.example' } const rpc: ClientConnectionRpc = { call: vi.fn(async (_channel: string, endpoint: string, payload: unknown) => ({ ok: true as const, value: { endpoint, payload } })), open: vi.fn((_channel: string, endpoint: string) => (async function *(): AsyncGenerator { yield endpoint })()), @@ -500,7 +493,7 @@ describe('connection client apply', () => { }) it('exposes a worker-local Gateway stream through connection.rpc.open', async () => { - ;(globalThis as Win).location = { hostname: 'preview.example', search: '' } + ;(globalThis as Win).location = { hostname: 'preview.example' } const openStream = vi.fn>( (endpoint, payload, signal) => (async function *(): AsyncGenerator { signal.throwIfAborted() @@ -538,7 +531,7 @@ describe('connection client apply', () => { it('validates generic RPC transport failures, correlation, and targets', async () => { ;(globalThis as Win).location = { - hostname: 'harness.example', search: '', origin: 'https://harness.example', + hostname: 'harness.example', origin: 'https://harness.example', } const handle = await mount() const original = globalThis.fetch @@ -552,7 +545,7 @@ describe('connection client apply', () => { expect.objectContaining({ signal: abort.signal }), ) - ;(globalThis as Win).location = { hostname: 'localhost', search: '', origin: 'null' } + ;(globalThis as Win).location = { hostname: 'localhost', origin: 'null' } globalThis.fetch = vi.fn().mockResolvedValue(Response.json({ type: 'server-response', rpcId: 'different-rpc', @@ -624,37 +617,4 @@ describe('connection client apply', () => { } }) - it('carries Goal Remotes over the client-only fixture state', async () => { - ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } - const handle = await mount() - const created = await handle.rpc.call('/api', 'goals/create', { - args: { agentId: 'fx-alpha', request: { objective: 'fixture remote' } }, - }) - expect(created).toMatchObject({ ok: true, value: { ref: { revision: 1 } } }) - if (!created.ok) throw new Error('fixture Goal create failed') - const ref = (created.value as { ref: { id: string; revision: number } }).ref - const edited = await handle.rpc.call('/api', 'goals/edit', { - args: { agentId: 'fx-alpha', ref, request: { objective: 'edited fixture remote' } }, - }) - expect(edited).toMatchObject({ ok: true, value: { objective: 'edited fixture remote', revision: 2 } }) - const editedRef = { id: ref.id, revision: 2 } - const paused = await handle.rpc.call('/api', 'goals/pause', { - args: { agentId: 'fx-alpha', ref: editedRef }, - }) - expect(paused).toMatchObject({ ok: true, value: { phase: 'paused', activation: 'disarmed', revision: 3 } }) - const resumed = await handle.rpc.call('/api', 'goals/resume', { - args: { agentId: 'fx-alpha', ref: { id: ref.id, revision: 3 } }, - }) - expect(resumed).toMatchObject({ ok: true, value: { phase: 'active', activation: 'armed', revision: 4 } }) - const completed = await handle.rpc.call('/api', 'goals/complete', { - args: { agentId: 'fx-alpha', ref: { id: ref.id, revision: 4 } }, - }) - expect(completed).toMatchObject({ ok: true, value: { phase: 'complete', activation: 'disarmed', revision: 5 } }) - await expect(handle.rpc.call('/api', 'goals/clear', { - args: { agentId: 'fx-alpha', ref: { id: ref.id, revision: 5 } }, - })).resolves.toEqual({ ok: true, value: { id: ref.id, revision: 6 } }) - await expect(handle.rpc.call('/other', 'goals/create', {})).rejects.toThrow(/channel.*unavailable/) - await expect(handle.rpc.call('/api', 'unknown/read', { args: { agentId: 'fx-alpha' } })) - .rejects.toThrow(/endpoint.*unavailable/) - }) }) diff --git a/packages/client/connection/tests/fixture-commands.client.spec.ts b/packages/client/connection/tests/fixture-commands.client.spec.ts deleted file mode 100644 index 103a9d9266..0000000000 --- a/packages/client/connection/tests/fixture-commands.client.spec.ts +++ /dev/null @@ -1,185 +0,0 @@ -/** - * Fixture commands/skills domains: session-addressed catalogs, execute - * parse/dispatch and its logged lifecycle pair, and skills/list Session resolution. - */ -import { describe, expect, it } from 'vitest' -import type { SessionId } from '../src/client/api.ts' -import { createFixtureFaces } from '../src/client/fixture.ts' - -/** Drive one commands Remote endpoint against the fixture state graph. */ -async function callRemote( - rpc: ReturnType['rpc'], - endpoint: string, - args: Record, -): Promise { - const result = await rpc.call('/api', endpoint, { args }) - if (!result.ok) throw new Error(`${endpoint} failed: ${result.error.code}`) - return result.value as T -} - -const sid = (id: string): SessionId => id as SessionId - -describe('createFixtureApi commands/skills', () => { - it('serves the addressed session catalog', async () => { - const { rpc } = createFixtureFaces() - const commands = await callRemote<{ name: string; input?: { hint: string; attachments?: boolean } }[]>( - rpc, 'commands/list', { agentId: sid('fx-alpha') }) - expect(commands.map(c => c.name)).toEqual(['compact', 'echo', 'goal', 'permission', 'plan']) - // input hint rides only the commands declaring it. - const echo = commands.find(c => c.name === 'echo') - expect(echo?.input?.hint).toBeTruthy() - expect(commands.find(c => c.name === 'compact')?.input).toBeUndefined() - // Image acceptance is declared per descriptor; only goal and plan carry it. - expect(commands.filter(c => c.input?.attachments === true).map(c => c.name)).toEqual(['goal', 'plan']) - }) - - it('rejects a catalog request for an unknown session', async () => { - const { rpc } = createFixtureFaces() - const result = await rpc.call('/api', 'commands/list', { args: { agentId: sid('fx-nope') } }) - expect(result).toMatchObject({ ok: false, error: { code: 'session/not-found' } }) - }) - - it('executes a known command line: pure admission plus a followed lifecycle pair', async () => { - const { rpc } = createFixtureFaces() - const frames: unknown[] = [] - const abort = new AbortController() - const stream = rpc.open?.('/api', 'session/follow', { - args: { request: { address: { kind: 'session', sessionId: sid('fx-alpha') } } }, - }, abort.signal) - if (stream === undefined) throw new Error('fixture session follow stream is unavailable') - const pump = (async () => { - for await (const frame of stream) { - frames.push(frame) - if (frames.filter(f => (f as { type: string }).type === 'event').length >= 2) abort.abort() - } - })() - const execution = await callRemote<{ commandId: string } | undefined>( - rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/echo hello world' }) - expect(execution?.commandId).toBeTruthy() - await pump - const events = frames - .filter((f): f is { type: string; event: { type: string; data: Record } } => (f as { type: string }).type === 'event') - .map(f => f.event) - expect(events).toMatchObject([ - { type: 'command/run', data: { name: 'echo', args: ' hello world', source: { kind: 'user' } } }, - { type: 'command/done', data: { kind: 'success', text: 'hello world' } }, - ]) - expect(events[0]?.data.commandId).toBe(events[1]?.data.commandId) - }) - - it('addresses execute to the session; an unknown session errs', async () => { - const { rpc } = createFixtureFaces() - const hit = await callRemote<{ commandId: string } | undefined>( - rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/goal ship' }) - expect(hit?.commandId).toBeTruthy() - - const missing = await rpc.call('/api', 'commands/execute', { - args: { agentId: sid('fx-nope'), line: '/goal ship' }, - }) - expect(missing).toMatchObject({ ok: false, error: { code: 'session/not-found' } }) - }) - - it('refuses an image-carrying execute for a non-declaring command with a logged error pair', async () => { - const { rpc } = createFixtureFaces() - const frames: unknown[] = [] - const abort = new AbortController() - const stream = rpc.open?.('/api', 'session/follow', { - args: { request: { address: { kind: 'session', sessionId: sid('fx-alpha') } } }, - }, abort.signal) - if (stream === undefined) throw new Error('fixture session follow stream is unavailable') - const pump = (async () => { - for await (const frame of stream) { - frames.push(frame) - if (frames.filter(f => (f as { type: string }).type === 'event').length >= 2) abort.abort() - } - })() - const png = { mediaType: 'image/png', data: 'AA==' } - const refused = await callRemote<{ commandId: string; result: { kind: string; text?: string } } | undefined>( - rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/echo hi', images: [png] }) - expect(refused?.commandId).toBeTruthy() - expect(refused?.result).toEqual({ kind: 'error', text: '/echo does not accept attachments' }) - await pump - const events = frames - .filter((f): f is { type: string; event: { type: string; data: Record } } => (f as { type: string }).type === 'event') - .map(f => f.event) - expect(events).toMatchObject([ - { type: 'command/run', data: { name: 'echo', args: ' hi', source: { kind: 'user' } } }, - { type: 'command/done', data: { kind: 'error', text: '/echo does not accept attachments' } }, - ]) - }) - - it('a declaring command accepts an image-carrying execute', async () => { - const { rpc } = createFixtureFaces() - const png = { mediaType: 'image/png', data: 'AA==' } - const accepted = await callRemote<{ result: { kind: string } } | undefined>( - rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/goal ship it', images: [png] }) - expect(accepted?.result.kind).toBe('success') - const planMessage = await callRemote<{ result: { kind: string } } | undefined>( - rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/plan sketch the layout', images: [png] }) - expect(planMessage?.result.kind).toBe('success') - const imageOnlyPlan = await callRemote<{ result: { kind: string } } | undefined>( - rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/plan', images: [png] }) - expect(imageOnlyPlan?.result.kind).toBe('success') - }) - - it('mirrors the producer grammar rejections for control-only declaring lines', async () => { - const { rpc } = createFixtureFaces() - const png = { mediaType: 'image/png', data: 'AA==' } - const bareGoal = await callRemote<{ result: { kind: string; text?: string } } | undefined>( - rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/goal', images: [png] }) - expect(bareGoal?.result).toEqual({ - kind: 'error', - text: 'Attachments only accompany a goal objective: /goal or /goal edit .', - }) - const refused = await callRemote<{ result: { kind: string; text?: string } } | undefined>( - rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/plan off', images: [png] }) - expect(refused?.result).toEqual({ - kind: 'error', - text: 'Attachments cannot accompany /plan off.', - }) - }) - - it('answers no execution for an unknown name even when images accompany it', async () => { - const { rpc } = createFixtureFaces() - const png = { mediaType: 'image/png', data: 'AA==' } - expect(await callRemote(rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/nope', images: [png] })) - .toBeUndefined() - }) - - it('answers no execution for unknown names and non-command lines', async () => { - const { rpc } = createFixtureFaces() - for (const line of ['/nope', 'plain text', '/']) { - // Absence is the whole answer: nothing matched, so no lifecycle id exists. - expect(await callRemote(rpc, 'commands/execute', { agentId: sid('fx-alpha'), line })) - .toBeUndefined() - } - }) - - it('serves the skill catalog for the addressed session and rejects unknown sessions', async () => { - const { rpc } = createFixtureFaces() - const skills = await callRemote<{ skills: Array<{ name: string }> }>( - rpc, 'skills/list', { request: { sessionId: sid('fx-alpha') } }, - ) - expect(skills.skills[0]?.name).toBe('fixture-demo') - - const missingSession = await rpc.call('/api', 'skills/list', { - args: { request: { sessionId: sid('fx-nope') } }, - }) - expect(missingSession).toMatchObject({ ok: false, error: { code: 'session/not-found' } }) - }) -}) - -describe('fixture Connection command/skill dispatch', () => { - it('routes the Remote command and skill rows through one state graph', async () => { - const { rpc } = createFixtureFaces() - const commands = await callRemote<{ name: string }[]>(rpc, 'commands/list', { agentId: sid('fx-alpha') }) - expect(commands.length).toBeGreaterThan(0) - const executed = await callRemote<{ commandId: string } | undefined>( - rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/compact' }) - expect(executed?.commandId).toBeTruthy() - const skills = await callRemote<{ skills: unknown[] }>( - rpc, 'skills/list', { request: { sessionId: sid('fx-alpha') } }, - ) - expect(skills.skills.length).toBeGreaterThan(0) - }) -}) diff --git a/packages/client/connection/tests/fixture.client.spec.ts b/packages/client/connection/tests/fixture.client.spec.ts deleted file mode 100644 index efaa35cc16..0000000000 --- a/packages/client/connection/tests/fixture.client.spec.ts +++ /dev/null @@ -1,1825 +0,0 @@ -import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest' -import type { - RpcRequest, - RpcResponse, - RpcResult, - SessionEvent, - SessionId, - StreamChunk, -} from '../src/client/api.ts' -import { RpcId } from '../src/client/api.ts' -import { - createFixtureConnectionRpc, - createFixtureFaces, - type FixtureAssistantStreamFrame, - type FixtureOptions, -} from '../src/client/fixture.ts' -import type { - ClientConnectionRpc, ConnectionRpcResult, -} from '../src/rpc.ts' -import type { DirectoryListing } from '@deepseek-ai/dsh-host-directory-picker/types' -import type { - ModelCatalog, ModelSelection, SessionAssistantStreamFrame, -} from '@deepseek-ai/dsh-api-session-controller/types' - -const sid = (id: string): SessionId => id as SessionId -type WorkspaceId = string & { readonly __fixtureWorkspaceId: 'WorkspaceId' } -const req =

(payload: P): RpcRequest

=> ({ rpcId: RpcId(`t-${Math.abs(Math.sin(reqCount++)).toString(36).slice(2, 10)}`), payload }) -let reqCount = 0 - -interface FixtureSessionSummary { - sessionId: SessionId - updatedAt: number - running: boolean - blank: boolean - parentSessionId?: SessionId - origin?: 'subagent' - cwd?: string - agentPreset?: string -} - -interface FixtureHistoryEntry { - readonly type: 'event' - readonly event: SessionEvent -} - -type FixtureHistoryRecord = FixtureHistoryEntry - -interface FixturePage { - readonly records: readonly FixtureHistoryRecord[] - readonly hasMore: boolean -} - -function historyEvents(records: readonly FixtureHistoryRecord[]): SessionEvent[] { - return records.map(record => record.event) -} - -function isReasoningDeltaChunk( - value: unknown, -): value is Extract { - if (typeof value !== 'object' || value === null) return false - const record = value as { readonly type?: unknown; readonly text?: unknown } - return record.type === 'reasoning-delta' && typeof record.text === 'string' -} - -type FixtureFollowFrame = - | { - readonly type: 'snapshot' - readonly cursor: number - readonly records: readonly FixtureHistoryRecord[] - readonly hasMore: boolean - readonly projections: { - readonly asOfSeq: number - readonly values: Readonly> - } - readonly assistantStream?: { - readonly revision: number - readonly activeAttempt?: { - readonly attemptId: string - readonly startedAfterSeq: number - readonly turn: number - readonly step: number - readonly nextIndex: number - readonly stream: readonly unknown[] - } - } - } - | FixtureHistoryEntry - | { readonly type: 'assistant-stream'; readonly frame: FixtureAssistantStreamFrame } - -type FixtureControlFrame = - | { - readonly type: 'baseline' - readonly value: { - readonly queues: Readonly> - readonly jobs: Readonly> - readonly approvals: readonly unknown[] - readonly questions: readonly unknown[] - readonly projections: Readonly> - }>> - } - } - | { - readonly type: 'projection' - readonly sessionId: SessionId - readonly key: string - readonly value: unknown - readonly seq: number - } - -interface FixtureSessionRequests { - list: { readonly cursor?: string } - search: { readonly query: string } - create: { - readonly workspaceId?: WorkspaceId - readonly cwd?: string - readonly sessionId?: SessionId - readonly agentPreset?: string - } - history: { - readonly sessionId: SessionId - readonly beforeSeq?: number - readonly maxMessages?: number - } - selectModel: { - readonly sessionId: SessionId - readonly provider: string - readonly model: string - readonly reasoningEffort?: string - } - prompt: { - readonly sessionId: SessionId - readonly mode: 'queue' | 'steer' - readonly content: readonly ({ readonly type: 'text'; readonly text: string } | { - readonly type: 'image' - readonly mediaType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif' - readonly data: string - readonly name?: string - })[] - } - cancel: { readonly sessionId: SessionId } - rename: { readonly sessionId: SessionId; readonly title: string } -} - -interface FixtureSessionValues { - list: { readonly items: FixtureSessionSummary[] } - search: { readonly items: readonly { readonly sessionId: SessionId; readonly snippet: string }[]; readonly hasMore: boolean } - create: { readonly sessionId: SessionId } - history: FixturePage - selectModel: { readonly selected: ModelSelection } - prompt: { readonly accepted: true } - cancel: Record - rename: { readonly title: string; readonly seq: number } -} - -type FixtureSessionApi = { - [K in keyof FixtureSessionRequests]: ( - request: RpcRequest, - signal?: AbortSignal, - ) => Promise> -} - -type FixtureSessionClient = { - [K in keyof FixtureSessionRequests]: ( - request: FixtureSessionRequests[K], - signal?: AbortSignal, - ) => Promise> -} - -interface FixtureSessionRemote { - modelCatalog(): Promise> - follow(sessionId: SessionId, signal: AbortSignal): AsyncIterable - control(signal: AbortSignal): AsyncIterable -} - -interface FixtureWorkspaceView { - readonly workspaceId: WorkspaceId - readonly path: string - readonly title: string - readonly sessionIds: readonly SessionId[] - readonly createdAt: string - readonly updatedAt: string -} - -interface FixtureWorkspaceRequests { - create: { readonly path: string } - rename: { readonly workspaceId: WorkspaceId; readonly title: string } - delete: { readonly workspaceId: WorkspaceId } - insertBefore: { readonly workspaceId: WorkspaceId; readonly beforeWorkspaceId?: WorkspaceId } - insertSessionBefore: { - readonly workspaceId: WorkspaceId - readonly sessionId: SessionId - readonly beforeSessionId?: SessionId - } - archiveSession: { readonly sessionId: SessionId } - unarchiveSession: { readonly sessionId: SessionId } -} - -interface FixtureWorkspaceValues { - create: { readonly workspace: FixtureWorkspaceView; readonly created: boolean } - rename: { readonly workspace: FixtureWorkspaceView } - delete: { readonly deleted: true } - insertBefore: { readonly workspaceIds: readonly WorkspaceId[] } - insertSessionBefore: { readonly workspace: FixtureWorkspaceView } - archiveSession: { readonly archivedSessionIds: readonly SessionId[] } - unarchiveSession: { readonly archivedSessionIds: readonly SessionId[] } -} - -type FixtureWorkspaceApi = { - [K in keyof FixtureWorkspaceRequests]: ( - request: RpcRequest, - signal?: AbortSignal, - ) => Promise> -} - -type FixtureWorkspaceClient = { - [K in keyof FixtureWorkspaceRequests]: ( - request: FixtureWorkspaceRequests[K], - signal?: AbortSignal, - ) => Promise> -} - -type FixtureWorkspaceFrame = - | { - readonly type: 'baseline' - readonly value: { - readonly items: readonly FixtureWorkspaceView[] - readonly archivedSessionIds: readonly SessionId[] - } - } - | { readonly type: 'upsert'; readonly workspace: FixtureWorkspaceView } - | { readonly type: 'remove'; readonly workspaceId: WorkspaceId } - | { readonly type: 'order'; readonly workspaceIds: readonly WorkspaceId[] } - | { readonly type: 'archived'; readonly archivedSessionIds: readonly SessionId[] } - -interface FixtureWorkspaceRemote { - follow(signal: AbortSignal): AsyncIterable -} - -interface FixtureRemoteEventNotificationFrame { - readonly type: 'emit' - readonly event: string - readonly args: readonly unknown[] -} - -interface FixtureRemoteEventRequestFrame { - readonly type: 'waterfall' - readonly event: string - readonly eventId: string - readonly agentId: SessionId - readonly request: Readonly> -} - -interface FixtureRemoteEventCancellationFrame { - readonly type: 'cancel' - readonly eventId: string -} - -type FixtureRemoteEventFrame = - | FixtureRemoteEventNotificationFrame - | FixtureRemoteEventRequestFrame - | FixtureRemoteEventCancellationFrame - -interface FixtureRemoteEventResult { - readonly clientId: string - readonly eventId: string - readonly outcome: - | { readonly kind: 'next' } - | { readonly kind: 'result'; readonly value?: unknown } - | { - readonly kind: 'rejected' - readonly error: { - readonly name: string - readonly message: string - readonly code?: string - readonly details?: unknown - } - } -} - -interface FixtureRemoteEventStream extends AsyncIterable { - readonly clientId: Promise -} - -type FixtureTestApi = { - /** The directory-picking Remote namespace as the fixture serves it. */ - readonly directoryPickerRemote: { - pick: () => Promise> - list: (path?: string) => Promise> - createDirectory: (path: string, name: string) => Promise> - } - readonly sessions: FixtureSessionApi - readonly sessionRemote: FixtureSessionRemote - readonly workspace: FixtureWorkspaceApi - readonly workspaceRemote: FixtureWorkspaceRemote - readonly credentialRemote: FixtureCredentialRemote - readonly settingsRemote: FixtureSettingsRemote - readonly remoteEvents: (signal: AbortSignal) => FixtureRemoteEventStream - readonly answerRemoteEvent: (result: FixtureRemoteEventResult) => Promise -} - -/** Keep existing fixture assertions compact while driving only the new Session Remote endpoints. */ -function createFixtureApi(options: FixtureOptions = {}): FixtureTestApi { - const { rpc } = createFixtureFaces(options) - return { - directoryPickerRemote: { - pick: () => rpc.call('/api', 'directoryPicker/pick', { args: {} }) as - Promise>, - list: (path?: string) => rpc.call('/api', 'directoryPicker/list', { args: { path } }) as - Promise>, - createDirectory: (path: string, name: string) => - rpc.call('/api', 'directoryPicker/createDirectory', { args: { path, name } }) as - Promise>, - }, - sessions: createSessionApi(rpc), - sessionRemote: createSessionRemote(rpc), - workspace: createWorkspaceApi(rpc), - workspaceRemote: createWorkspaceRemote(rpc), - credentialRemote: createCredentialRemote(rpc), - settingsRemote: createSettingsRemote(rpc), - remoteEvents: (signal: AbortSignal) => openFixtureRemoteEvents(rpc, signal), - answerRemoteEvent: (result: FixtureRemoteEventResult) => - rpc.call('/api', '$events/result', { args: result }), - } -} - -/** The fixture's Credentials Remote endpoints over the shared RPC carrier. */ -interface FixtureCredentialRemote { - describe(refs: readonly string[]): Promise> - set(ref: string, value: string): Promise> - unset(ref: string): Promise> -} - -/** The settings Remote reads the fixture serves, addressed like the credential half. */ -interface FixtureSettingsRemote { - describe(): Promise> - update(ns: string, patch: unknown, expectedRevision?: number): Promise> - replace(ns: string, section: unknown, expectedRevision?: number): Promise> -} - -function createSettingsRemote(rpc: ClientConnectionRpc): FixtureSettingsRemote { - return { - describe: () => rpc.call('/api', 'settings/describe', { args: {} }), - update: (ns, patch, expectedRevision) => rpc.call('/api', 'settings/update', { - args: { ns, patch, expectedRevision }, - }), - replace: (ns, section, expectedRevision) => rpc.call('/api', 'settings/replace', { - args: { ns, section, expectedRevision }, - }), - } -} - -function createCredentialRemote(rpc: ClientConnectionRpc): FixtureCredentialRemote { - return { - describe: refs => rpc.call('/api', 'credentials/describe', { args: { refs } }), - set: (ref, value) => rpc.call('/api', 'credentials/set', { args: { ref, value } }), - unset: ref => rpc.call('/api', 'credentials/unset', { args: { ref } }), - } -} - -function openFixtureRemoteEvents( - rpc: ClientConnectionRpc, - signal: AbortSignal, -): FixtureRemoteEventStream { - const ready = Promise.withResolvers() - const source = (async function* (): AsyncGenerator { - const stream = rpc.open?.('/api', '$events', { args: {} }, signal) - if (stream === undefined) throw new Error('fixture forwarded-event stream is unavailable') - let opened = false - for await (const value of stream) { - if (!opened) { - expect(value).toMatchObject({ type: 'ready' }) - const clientId: unknown = Reflect.get(value as object, 'clientId') - if (typeof clientId !== 'string') throw new Error('fixture forwarded-event stream omitted its Client id') - ready.resolve(clientId) - opened = true - continue - } - yield value as FixtureRemoteEventFrame - } - })() - return Object.assign(source, { clientId: ready.promise }) -} - -function createSessionApi(rpc: ClientConnectionRpc): FixtureSessionApi { - const call = async ( - endpoint: K, - request: RpcRequest, - signal?: AbortSignal, - ): Promise> => { - const page = endpoint === 'history' - ? request.payload as FixtureSessionRequests['history'] - : undefined - const args = endpoint === 'list' - ? { _request: request.payload } - : endpoint === 'history' - ? { - request: { - address: { kind: 'session', sessionId: page?.sessionId }, - ...page?.beforeSeq === undefined ? {} : { beforeSeq: page.beforeSeq }, - ...page?.maxMessages === undefined ? {} : { maxMessages: page.maxMessages }, - }, - } - : { request: request.payload } - const remoteEndpoint = endpoint === 'history' ? 'page' : endpoint - const result = await rpc.call('/api', `session/${remoteEndpoint}`, { args }, signal) - return { - rpcId: request.rpcId, - result: result as unknown as RpcResult, - } - } - return { - list: (request, signal) => call('list', request, signal), - search: (request, signal) => call('search', request, signal), - create: (request, signal) => call('create', request, signal), - history: (request, signal) => call('history', request, signal), - selectModel: (request, signal) => call('selectModel', request, signal), - prompt: (request, signal) => call('prompt', request, signal), - cancel: (request, signal) => call('cancel', request, signal), - rename: (request, signal) => call('rename', request, signal), - } -} - -function createSessionClient(rpc: ClientConnectionRpc): FixtureSessionClient { - const api = createSessionApi(rpc) - return { - list: (request, signal) => api.list(req(request), signal), - search: (request, signal) => api.search(req(request), signal), - create: (request, signal) => api.create(req(request), signal), - history: (request, signal) => api.history(req(request), signal), - selectModel: (request, signal) => api.selectModel(req(request), signal), - prompt: (request, signal) => api.prompt(req(request), signal), - cancel: (request, signal) => api.cancel(req(request), signal), - rename: (request, signal) => api.rename(req(request), signal), - } -} - -function createSessionRemote(rpc: ClientConnectionRpc): FixtureSessionRemote { - const open = (endpoint: string, args: object, signal: AbortSignal): AsyncIterable => { - const stream = rpc.open?.('/api', endpoint, { args }, signal) - if (stream === undefined) throw new Error(`fixture ${endpoint} stream is unavailable`) - return stream as AsyncIterable - } - return { - modelCatalog: () => rpc.call('/api', 'session/modelCatalog', { args: {} }) as - Promise>, - follow: (sessionId, signal) => open('session/follow', { - request: { address: { kind: 'session', sessionId }, assistantStream: true }, - }, signal), - control: signal => open('session/control', {}, signal), - } -} - -function createWorkspaceApi(rpc: ClientConnectionRpc): FixtureWorkspaceApi { - const call = async ( - endpoint: K, - request: RpcRequest, - signal?: AbortSignal, - ): Promise> => { - const result = await rpc.call('/api', `workspace/${endpoint}`, { - args: { request: request.payload }, - }, signal) - return { - rpcId: request.rpcId, - result: result as unknown as RpcResult, - } - } - return { - create: (request, signal) => call('create', request, signal), - rename: (request, signal) => call('rename', request, signal), - delete: (request, signal) => call('delete', request, signal), - insertBefore: (request, signal) => call('insertBefore', request, signal), - insertSessionBefore: (request, signal) => call('insertSessionBefore', request, signal), - archiveSession: (request, signal) => call('archiveSession', request, signal), - unarchiveSession: (request, signal) => call('unarchiveSession', request, signal), - } -} - -function createWorkspaceClient(rpc: ClientConnectionRpc): FixtureWorkspaceClient { - const api = createWorkspaceApi(rpc) - return { - create: (request, signal) => api.create(req(request), signal), - rename: (request, signal) => api.rename(req(request), signal), - delete: (request, signal) => api.delete(req(request), signal), - insertBefore: (request, signal) => api.insertBefore(req(request), signal), - insertSessionBefore: (request, signal) => api.insertSessionBefore(req(request), signal), - archiveSession: (request, signal) => api.archiveSession(req(request), signal), - unarchiveSession: (request, signal) => api.unarchiveSession(req(request), signal), - } -} - -function createWorkspaceRemote(rpc: ClientConnectionRpc): FixtureWorkspaceRemote { - return { - follow(signal) { - const stream = rpc.open?.('/api', 'workspace/follow', { args: {} }, signal) - if (stream === undefined) throw new Error('fixture workspace/follow stream is unavailable') - return stream as AsyncIterable - }, - } -} - -interface TimingHooks { - setHistoryDelay(ms: number): void - failNextHistory(): void - appendUser(id: string, msg: string): void - appendTitle(id: string, title: string): void - disarmOnlyGoal(): void - startReasoningChunkStorm(id: string, chunkCount: number, chunksPerInterval: number, intervalMs: number): string - reasoningChunkStormState(): { - sessionId: string - chunkCount: number - chunksPerInterval: number - intervalMs: number - emitted: number - marker: string - emitting: boolean - } | null - beginModelRetry(id: string): void - scheduleModelRetry(id: string, retry?: number, delayMs?: number): void - cancelModelRetryDuringBackoff(id: string, delayMs?: number): void - completeModelRetry(id: string): void - appendSilent(id: string, msg: string): void - breakStreams(): void -} -const timing = (): TimingHooks => (globalThis as Record).__fxTiming as TimingHooks - -/** Collect value-stream frames until the predicate or a soft cap; abort ends the stream. */ -async function collectValues(stream: AsyncIterable, abort: AbortController, done: (frames: F[]) => boolean): Promise { - const frames: F[] = [] - for await (const frame of stream) { - frames.push(frame) - if (done(frames) || frames.length > 500) { - abort.abort() - break - } - } - return frames -} - -async function readControlBaseline(remote: FixtureSessionRemote): Promise> { - const abort = new AbortController() - for await (const frame of remote.control(abort.signal)) { - if (frame.type !== 'baseline') continue - abort.abort() - return frame - } - throw new Error('fixture control baseline missing') -} - -function isRemoteEventRequest(frame: FixtureRemoteEventFrame): frame is FixtureRemoteEventRequestFrame { - return frame.type === 'waterfall' -} - -function isRemoteEventCancellation(frame: FixtureRemoteEventFrame): frame is FixtureRemoteEventCancellationFrame { - return frame.type === 'cancel' -} - -async function readResidentRemoteEvents( - api: FixtureTestApi, - count: number, -): Promise { - const abort = new AbortController() - const frames = await collectValues( - api.remoteEvents(abort.signal), - abort, - seen => seen.filter(isRemoteEventRequest).length >= count, - ) - return frames.filter(isRemoteEventRequest) -} - -async function nextRemoteEvent( - iterator: AsyncIterator, - predicate: (frame: FixtureRemoteEventFrame) => boolean, -): Promise { - for (;;) { - const item = await iterator.next() - if (item.done) throw new Error('fixture Remote Event stream ended before the expected frame') - if (predicate(item.value)) return item.value - } -} - -async function readOpeningCursor(remote: FixtureSessionRemote, sessionId: SessionId): Promise { - const abort = new AbortController() - for await (const frame of remote.follow(sessionId, abort.signal)) { - if (frame.type !== 'snapshot') continue - abort.abort() - return frame.cursor - } - throw new Error('fixture follow opening cursor missing') -} - -async function readWorkspaceBaseline( - remote: FixtureWorkspaceRemote, -): Promise['value']> { - const abort = new AbortController() - for await (const frame of remote.follow(abort.signal)) { - if (frame.type !== 'baseline') continue - abort.abort() - return frame.value - } - throw new Error('fixture Workspace baseline missing') -} - -describe('createFixtureApi', () => { - it('keeps its Assistant frame vocabulary identical to the controller wire', () => { - expectTypeOf().toEqualTypeOf() - }) - - it('serves the session list sorted by updatedAt desc and echoes rpcIds on every unary', async () => { - const api = createFixtureApi() - const request = req({}) - const response = await api.sessions.list(request) - expect(response.rpcId).toBe(request.rpcId) - if (!response.result.ok) throw new Error('list failed') - expect(response.result.value.items.map(s => s.sessionId)).toEqual(['fx-alpha', 'fx-beta', 'fx-gamma']) - expect(response.result.value.items[1]?.parentSessionId).toBe('fx-alpha') // lineage material - }) - - it('returns an empty Assistant baseline when Session follow opts in', async () => { - const api = createFixtureApi() - const abort = new AbortController() - const iterator = api.sessionRemote.follow(sid('fx-alpha'), abort.signal)[Symbol.asyncIterator]() - try { - const opening = await iterator.next() - if (opening.done || opening.value.type !== 'snapshot') throw new Error('follow opening snapshot missing') - expect(opening.value.assistantStream).toEqual({ revision: 0 }) - } finally { - abort.abort() - await iterator.return?.() - } - }) - - it('searches current message text with literal unicode61-style token phrases', async () => { - const api = createFixtureApi() - const signal = new AbortController().signal - const phrase = await api.sessions.search(req({ query: 'FIXTURE 历史消息' }), signal) - expect(phrase.result).toMatchObject({ - ok: true, - value: { - items: [{ sessionId: 'fx-alpha' }], - hasMore: false, - }, - }) - if (!phrase.result.ok) throw new Error('search failed') - expect(phrase.result.value.items[0]?.snippet).toContain('fixture 历史消息') - - timing().appendUser( - 'fx-alpha', - `${'leading context '.repeat(20)}late café token${' trailing context'.repeat(20)}`, - ) - const late = await api.sessions.search(req({ query: 'LATE CAFE TOKEN' }), signal) - if (!late.result.ok) throw new Error('late search failed') - const lateSnippet = late.result.value.items[0]?.snippet ?? '' - expect(lateSnippet).toContain('late café token') - expect(lateSnippet.startsWith('…')).toBe(true) - expect(lateSnippet.endsWith('…')).toBe(true) - expect(Array.from(lateSnippet).length).toBeLessThanOrEqual(120) - - timing().appendUser('fx-alpha', 'Greek final sigma: ος') - const finalSigma = await api.sessions.search(req({ query: 'ΟΣ' }), signal) - if (!finalSigma.result.ok) throw new Error('final sigma search failed') - expect(finalSigma.result.value.items[0]?.snippet).toContain('ος') - - const substring = await api.sessions.search(req({ query: 'ixtur' }), signal) - expect(substring.result).toEqual({ - ok: true, - value: { items: [], hasMore: false }, - }) - const punctuationOnly = await api.sessions.search(req({ query: '*' }), signal) - expect(punctuationOnly.result).toEqual({ - ok: true, - value: { items: [], hasMore: false }, - }) - const reasoningOnly = await api.sessions.search(req({ query: '思考过程' }), signal) - expect(reasoningOnly.result).toEqual({ - ok: true, - value: { items: [], hasMore: false }, - }) - - const aborted = new AbortController() - aborted.abort() - await expect(api.sessions.search(req({ query: 'fixture' }), aborted.signal)) - .resolves.toMatchObject({ result: { ok: false, error: { code: 'gateway/cancelled' } } }) - }) - - it('pages history backwards on message-boundary cuts with seq-contiguous stitching', async () => { - const api = createFixtureApi() - const tail = await api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 10 })) - if (!tail.result.ok) throw new Error('history failed') - const tailPage = tail.result.value - expect(tailPage.hasMore).toBe(true) - const tailEvents = historyEvents(tailPage.records) - expect(tailEvents[0]?.type).toBe('turn/start') // cut lands on a turn boundary - const boundary = tailEvents[0]?.seq ?? 0 - expect(boundary).toBeGreaterThan(0) - const older = await api.sessions.history(req({ sessionId: sid('fx-alpha'), beforeSeq: boundary, maxMessages: 10 })) - if (!older.result.ok) throw new Error('older failed') - const olderTail = historyEvents(older.result.value.records).at(-1) - expect((olderTail?.seq ?? -1) + 1).toBe(boundary) // pages stitch with no hole/overlap - // Out-of-range beforeSeq clamps instead of exploding. - const clamped = await api.sessions.history(req({ sessionId: sid('fx-alpha'), beforeSeq: -5, maxMessages: 10 })) - if (!clamped.result.ok) throw new Error('clamped failed') - expect(clamped.result.value.records).toEqual([]) - // Unknown session: empty page, not an error (history of a bare id). - const empty = await api.sessions.history(req({ sessionId: sid('no-such'), maxMessages: 10 })) - if (!empty.result.ok) throw new Error('empty failed') - expect(empty.result.value).toEqual({ records: [], hasMore: false }) - }) - - it('serves raw history entries with replayable tool-result metadata', async () => { - const api = createFixtureApi() - const response = await api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 200 })) - if (!response.result.ok) throw new Error('history failed') - - const records = response.result.value.records - const results = historyEvents(records) - .filter(event => event.type === 'tool/result') - - expect(results.find(event => event.data.turn === 64)).toMatchObject({ - data: { - meta: { - diffs: [ - { path: 'src/config.ts', oldText: 'const timeout = 30', newText: 'const timeout = 60' }, - { path: 'src/config.ts', oldText: 'retries: 1', newText: 'retries: 3' }, - ], - }, - }, - }) - expect(results.find(event => event.data.turn === 67)).toMatchObject({ - data: { meta: { shape: 'matches', truncated: true, total: 42 } }, - }) - expect(results.find(event => event.data.turn === 69)).toMatchObject({ - data: { meta: { path: 'packages/client/ui-primitives/src/ReadBlock.tsx', offset: 41, totalLines: 180 } }, - }) - const webSearch = results.find(event => event.data.turn === 70) - expect(webSearch).toHaveProperty('data.meta.truncated', true) - expect(webSearch).toHaveProperty('data.meta.sources', expect.arrayContaining([ - expect.objectContaining({ url: 'https://github.com/deepseek-ai/deepseek-harness' }), - ])) - expect(results.find(event => event.data.turn === 71)).toMatchObject({ - data: { meta: { url: 'https://www.deepseek.com/blog/harness-architecture', statusCode: 200 } }, - }) - const terminal = results.find(event => event.data.turn === 66) - expect(terminal).toHaveProperty('data.message.content.0.content.0.type', 'text') - expect(terminal).toHaveProperty( - 'data.message.content.0.content.0.text', - expect.stringContaining('\n[exit code: 1]'), - ) - }) - - it('serves grouped models and keeps a selection for later history and fixture requests', async () => { - const api = createFixtureApi() - const sessionId = sid('fx-alpha') - const catalog = await api.sessionRemote.modelCatalog() - if (!catalog.ok) throw new Error('models failed') - expect(catalog.value.groups.map(group => group.name)).toEqual(['DeepSeek', 'OpenAI']) - expect(catalog.value.groups[0]?.models.map(model => model.id)) - .toEqual(['deepseek-v4-flash', 'deepseek-v4-pro']) - - const selected = await api.sessions.selectModel(req({ - sessionId, - provider: 'openai', - model: 'gpt-5', - })) - if (!selected.result.ok) throw new Error('selection failed') - expect(selected.result.value.selected).toEqual({ provider: 'openai', model: 'gpt-5' }) - const history = await api.sessions.history(req({ sessionId })) - if (!history.result.ok) throw new Error('history failed') - - const prompt = await api.sessions.prompt(req({ - sessionId, - mode: 'queue', - content: [{ type: 'text', text: 'report model' }], - })) - expect(prompt.result.ok).toBe(true) - await new Promise(resolve => setTimeout(resolve, 600)) - const after = await api.sessions.history(req({ sessionId })) - if (!after.result.ok) throw new Error('history failed') - expect(JSON.stringify(after.result.value.records)).toContain('openai/gpt-5') - }) - - it('serves configured DeepSeek readiness and keeps credential values write-only', async () => { - const api = createFixtureApi() - const settings = await api.settingsRemote.describe() - if (!settings.ok) throw new Error('settings describe failed') - expect((settings.value as { namespaces: unknown[] }).namespaces).toMatchObject([{ - ns: 'llm-deepseek', - value: { apiKeyEnv: 'DEEPSEEK_API_KEY' }, - secrets: [{ path: ['apiKey'], set: false }], - }]) - for (const result of [ - await api.settingsRemote.update('llm-deepseek', {}, undefined), - await api.settingsRemote.replace('llm-deepseek', {}, undefined), - ]) { - expect(result).toMatchObject({ - ok: false, - error: { code: 'settings/rejected', message: 'fixture: the minimal readiness settings descriptor is read-only' }, - }) - } - - const describe = async (refs: readonly string[]): Promise> => { - const result = await api.credentialRemote.describe(refs) - if (!result.ok) throw new Error('credential describe failed') - return result.value as Record - } - expect(await describe(['DEEPSEEK_API_KEY', 'TEST_API_KEY'])).toEqual({ - DEEPSEEK_API_KEY: { configured: true, source: 'file', writable: true }, - TEST_API_KEY: { configured: false, writable: true }, - }) - await api.credentialRemote.set('TEST_API_KEY', 'write-only-fixture-secret') - expect((await describe(['TEST_API_KEY'])).TEST_API_KEY).toEqual({ - configured: true, - source: 'file', - writable: true, - }) - await api.credentialRemote.unset('TEST_API_KEY') - expect((await describe(['TEST_API_KEY'])).TEST_API_KEY).toEqual({ configured: false, writable: true }) - }) - - it('emits the todo/write snapshot at the real tool boundary: between tool/call and tool/result, timestamps monotonic', async () => { - const api = createFixtureApi() - const tail = await api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 10 })) - if (!tail.result.ok) throw new Error('history failed') - const events = historyEvents(tail.result.value.records) - const todoAt = events.findIndex(e => e.type === 'todo/write') - expect(todoAt).toBeGreaterThan(0) - // Production ordering (the tool appends mid-execution): call → snapshot → result. - expect(events[todoAt - 1]?.type).toBe('tool/call') - expect(events[todoAt + 1]?.type).toBe('tool/result') - const times = events.slice(todoAt - 1, todoAt + 2).map(e => e.time) - expect(times[0]).toBeLessThanOrEqual(times[1] ?? 0) - expect(times[1]).toBeLessThanOrEqual(times[2] ?? 0) - // The sample is a parallel plan: this fixture chooses the parallel policy, - // so the surfaces fed from here face more than one active item. - const snapshot = events[todoAt] as { data: { todos: { status: string }[] } } - expect(snapshot.data.todos.filter(t => t.status === 'in_progress')).toHaveLength(2) - }) - - it('create adds a session and announces it through the Host Remote event stream', async () => { - const api = createFixtureApi() - const abort = new AbortController() - const seen: FixtureRemoteEventNotificationFrame[] = [] - const consuming = (async () => { - for await (const frame of api.remoteEvents(abort.signal)) { - if (frame.type !== 'emit' || frame.event !== 'api-session/added') continue - seen.push(frame) - abort.abort() - break - } - })() - await new Promise(resolve => setTimeout(resolve, 10)) // let the stream register - const created = await api.sessions.create(req({})) - if (!created.result.ok) throw new Error('create failed') - await consuming - if (!created.result.ok) throw new Error('create failed') - const createdId = created.result.value.sessionId - expect(seen).toHaveLength(1) - const added = seen[0] - expect(added).toMatchObject({ - event: 'api-session/added', - args: [{ sessionId: createdId, blank: true, cwd: '/tmp/fixture' }], - }) - const list = await api.sessions.list(req({})) - if (!list.result.ok) throw new Error('list failed') - expect(list.result.value.items.some(s => s.sessionId === createdId)).toBe(true) - }) - - it('prompt replays a full streamed turn and cancel mid-replay freezes with (已中断)', async () => { - const api = createFixtureApi() - const created = await api.sessions.create(req({})) - if (!created.result.ok) throw new Error('create failed') - const id = created.result.value.sessionId - const followAbort = new AbortController() - const controlAbort = new AbortController() - const controlFrames: FixtureControlFrame[] = [] - const followPromise = collectValues( - api.sessionRemote.follow(id, followAbort.signal), - followAbort, - frames => frames.some(frame => frame.type === 'event' && frame.event.type === 'turn/end'), - ) - const controlPromise = (async () => { - for await (const frame of api.sessionRemote.control(controlAbort.signal)) controlFrames.push(frame) - })() - await new Promise(resolve => setTimeout(resolve, 10)) - // Unknown session → session/not-found with the id echoed in details. - const missing = await api.sessions.prompt(req({ sessionId: sid('ghost'), mode: 'queue' as const, content: [{ type: 'text' as const, text: 'x' }] })) - expect(missing.result).toMatchObject({ ok: false, error: { code: 'session/not-found', details: { sessionId: 'ghost' } } }) - // Real prompt: replay starts (running flips true), cancel freezes it. - const accepted = await api.sessions.prompt(req({ sessionId: id, mode: 'queue' as const, content: [{ type: 'text' as const, text: 'render markdown' }] })) - expect(accepted.result).toMatchObject({ ok: true, value: { accepted: true } }) - await new Promise(resolve => setTimeout(resolve, 120)) // a couple of typewriter ticks - await api.sessions.cancel(req({ sessionId: id })) - const frames = await followPromise - const types = frames.flatMap(frame => frame.type === 'event' ? [frame.event.type] : []) - const assistantFrames = frames.flatMap(frame => frame.type === 'assistant-stream' ? [frame.frame] : []) - expect(types).toContain('turn/start') - expect(types).toContain('user/message') - expect(types).toContain('assistant/message') - expect(assistantFrames.some(frame => frame.type === 'chunk')).toBe(true) - const committed = assistantFrames.find(frame => frame.type === 'end' - && frame.outcome.kind === 'committed' - && frame.outcome.eventType === 'assistant/message') - expect(committed).toMatchObject({ - type: 'end', - index: assistantFrames.filter(frame => frame.type === 'chunk' - && frame.attemptId === committed?.attemptId).length, - }) - expect(types.at(-1)).toBe('turn/end') - // Capacity is durable log state, not a transient frame: the prompt path - // records request/context and the projection carries it to the client. - expect(types).toContain('request/context') - await vi.waitFor(() => { - expect(controlFrames.some(frame => - frame.type === 'projection' - && frame.key === 'contextBreakdown' - && (frame.value as { messageTokens?: number }).messageTokens! > 0)).toBe(true) - }) - expect(controlFrames.some(frame => - frame.type === 'projection' - && frame.key === 'tokenUsage' - && (frame.value as { outputTokens?: number }).outputTokens === 8)).toBe(true) - expect(controlFrames.some(frame => - frame.type === 'projection' - && frame.key === 'contextPressure' - && (frame.value as { contextWindow?: number }).contextWindow === 128_000)).toBe(true) - const finalize = frames.find(frame => frame.type === 'event' && frame.event.type === 'assistant/message') - if (finalize?.type !== 'event') throw new Error('assistant final event missing') - if (finalize.event.type !== 'assistant/message') throw new Error('assistant final event has wrong type') - expect(finalize.event.data.interrupted).toBe(true) - expect(finalize.event.data.stream.length).toBeGreaterThan(0) - controlAbort.abort() - await controlPromise - // Idle cancel: no replay in flight, must not explode; running flips false. - const idleCancel = await api.sessions.cancel(req({ sessionId: id })) - expect(idleCancel.result).toMatchObject({ ok: true }) - }) - - it('steer during a replay lands a user/message inside the current turn and the replay continues', async () => { - const api = createFixtureApi() - const created = await api.sessions.create(req({})) - if (!created.result.ok) throw new Error('create failed') - const id = created.result.value.sessionId - const abort = new AbortController() - const framesPromise = collectValues(api.sessionRemote.follow(id, abort.signal), abort, - frames => frames.some(frame => frame.type === 'event' && frame.event.type === 'turn/end')) - await new Promise(resolve => setTimeout(resolve, 10)) - await api.sessions.prompt(req({ sessionId: id, mode: 'queue' as const, content: [{ type: 'text' as const, text: '短' }] })) - await api.sessions.prompt(req({ sessionId: id, mode: 'steer' as const, content: [{ type: 'text' as const, text: '插话' }] })) - const frames = await framesPromise - const types = frames.flatMap(frame => frame.type === 'event' ? [frame.event.type] : []) - expect(JSON.stringify(frames)).toContain('插话') - expect(types.at(-1)).toBe('turn/end') // steer did not restart the turn - }) - - it('control replays projections while resident Remote Events retain ids across reconnects', async () => { - const api = createFixtureApi() - const first = await readControlBaseline(api.sessionRemote) - const second = await readControlBaseline(api.sessionRemote) - expect(first.value.approvals).toEqual([]) - expect(first.value.questions).toEqual([]) - const alpha = first.value.projections['fx-alpha'] - expect(alpha?.asOfSeq).toBeGreaterThan(0) - expect(alpha?.values).toMatchObject({ - title: 'Fixture 历史会话', - plan: { active: false, pending: false }, - goal: null, - imageLimits: { maxImagesPerMessage: 20, maxImageBytes: 5 * 1024 * 1024 }, - }) - const breakdown = alpha?.values['contextBreakdown'] as { systemTokens: number; messageTokens: number } - expect(breakdown.messageTokens).toBeGreaterThan(0) - // The seeded system/message at surface node 0 prices the system figure. - expect(breakdown.systemTokens).toBeGreaterThan(0) - expect((alpha?.values['sessionStats'] as { steps: number }).steps).toBeGreaterThan(0) - expect(second.value.projections['fx-alpha']).toEqual(alpha) - - const firstEvents = await readResidentRemoteEvents(api, 2) - const secondEvents = await readResidentRemoteEvents(api, 2) - const firstApproval = firstEvents.find(frame => frame.event === 'approval/request') - const firstQuestion = firstEvents.find(frame => frame.event === 'user-questions/request') - const secondApproval = secondEvents.find(frame => frame.event === 'approval/request') - const secondQuestion = secondEvents.find(frame => frame.event === 'user-questions/request') - expect(firstApproval).toMatchObject({ - type: 'waterfall', - request: { toolName: 'dangerous_tool' }, - agentId: 'fx-alpha', - }) - expect(firstQuestion).toMatchObject({ - type: 'waterfall', - agentId: 'fx-alpha', - }) - expect(Array.isArray(firstQuestion?.request.questions)).toBe(true) - expect(secondApproval?.eventId).toBe(firstApproval?.eventId) - expect(secondQuestion?.eventId).toBe(firstQuestion?.eventId) - expect(await readOpeningCursor(api.sessionRemote, sid('fx-alpha'))).toBeGreaterThan(0) - }) - - it('steer with no replay in flight falls through to a fresh queued turn; non-text blocks stringify empty', async () => { - const api = createFixtureApi() - const created = await api.sessions.create(req({})) - if (!created.result.ok) throw new Error('create failed') - const abort = new AbortController() - const framesPromise = collectValues( - api.sessionRemote.follow(created.result.value.sessionId, abort.signal), - abort, - frames => frames.some(frame => frame.type === 'event' && frame.event.type === 'turn/end'), - ) - await new Promise(resolve => setTimeout(resolve, 10)) - // steer while idle + a non-text content block (covers the '' arm of the text join). - await api.sessions.prompt(req({ - sessionId: created.result.value.sessionId, mode: 'steer' as const, - content: [{ type: 'text' as const, text: '短' }, { type: 'image', data: 'x' } as never], - })) - const frames = await framesPromise - const types = frames.flatMap(frame => frame.type === 'event' ? [frame.event.type] : []) - expect(types[0]).toBe('turn/start') // idle steer degraded to a queued turn, not an in-turn insert - }) - - it('gamma interval flip emits a Remote status event and its empty follow source opens at -1', async () => { - vi.useFakeTimers() - try { - const api = createFixtureApi() - const abort = new AbortController() - const hostSeen: FixtureRemoteEventFrame[] = [] - const consuming = (async () => { - for await (const frame of api.remoteEvents(abort.signal)) hostSeen.push(frame) - })() - await vi.advanceTimersByTimeAsync(5001) // interval fires: fx-gamma flips running=true (no log exists) - expect(hostSeen).toContainEqual({ - type: 'emit', - event: 'api-session/status', - args: [sid('fx-gamma'), true], - }) - expect(await readOpeningCursor(api.sessionRemote, sid('fx-gamma'))).toBe(-1) - abort.abort() - await vi.advanceTimersByTimeAsync(10) - await consuming - } finally { - vi.useRealTimers() - } - }) - - it('answers a resident question through its Remote Event id and stops replaying it', async () => { - const api = createFixtureApi() - const abort = new AbortController() - const stream = api.remoteEvents(abort.signal) - const iterator = stream[Symbol.asyncIterator]() - const question = await nextRemoteEvent( - iterator, - frame => isRemoteEventRequest(frame) && frame.event === 'user-questions/request', - ) - if (!isRemoteEventRequest(question)) throw new Error('fixture question Remote Event missing') - const clientId = await stream.clientId - await expect(api.answerRemoteEvent({ - clientId, - eventId: 'unrelated', - outcome: { kind: 'result', value: {} }, - })).resolves.toEqual({ ok: true, value: undefined }) - await expect(api.answerRemoteEvent({ - clientId, - eventId: question.eventId, - outcome: { kind: 'result', value: { answers: {} } }, - })).resolves.toEqual({ ok: true, value: undefined }) - const cancelled = await nextRemoteEvent( - iterator, - frame => isRemoteEventCancellation(frame) && frame.eventId === question.eventId, - ) - expect(cancelled).toEqual({ type: 'cancel', eventId: question.eventId }) - abort.abort() - await iterator.return?.() - - await expect(api.answerRemoteEvent({ - clientId, - eventId: question.eventId, - outcome: { kind: 'result', value: { answers: {} } }, - })).resolves.toMatchObject({ ok: false, error: { code: 'gateway/invocation-unavailable' } }) - const remaining = await readResidentRemoteEvents(api, 1) - expect(remaining.map(frame => frame.event)).toEqual(['approval/request']) - - const cancelledApi = createFixtureApi() - const cancelAbort = new AbortController() - const cancelStream = cancelledApi.remoteEvents(cancelAbort.signal) - const cancelIterator = cancelStream[Symbol.asyncIterator]() - const cancelQuestion = await nextRemoteEvent( - cancelIterator, - frame => isRemoteEventRequest(frame) && frame.event === 'user-questions/request', - ) - if (!isRemoteEventRequest(cancelQuestion)) throw new Error('fixture cancellation question missing') - await expect(cancelledApi.answerRemoteEvent({ - clientId: await cancelStream.clientId, - eventId: cancelQuestion.eventId, - outcome: { - kind: 'rejected', - error: { name: 'UserQuestionError', message: 'skip', code: 'ASK_CANCELLED' }, - }, - })).resolves.toEqual({ ok: true, value: undefined }) - cancelAbort.abort() - await cancelIterator.return?.() - const afterCancellation = await readResidentRemoteEvents(cancelledApi, 1) - expect(afterCancellation.map(frame => frame.event)).toEqual(['approval/request']) - }) - - it('answers a resident approval and broadcasts cancellation to its active delivery', async () => { - const api = createFixtureApi() - const abort = new AbortController() - const stream = api.remoteEvents(abort.signal) - const iterator = stream[Symbol.asyncIterator]() - const approval = await nextRemoteEvent( - iterator, - frame => isRemoteEventRequest(frame) && frame.event === 'approval/request', - ) - if (!isRemoteEventRequest(approval)) throw new Error('fixture approval Remote Event missing') - - await expect(api.answerRemoteEvent({ - clientId: await stream.clientId, - eventId: approval.eventId, - outcome: { kind: 'result', value: 'allowed-once' }, - })).resolves.toEqual({ ok: true, value: undefined }) - const cancelled = await nextRemoteEvent( - iterator, - frame => isRemoteEventCancellation(frame) && frame.eventId === approval.eventId, - ) - expect(cancelled).toEqual({ type: 'cancel', eventId: approval.eventId }) - abort.abort() - await iterator.return?.() - - await expect(api.answerRemoteEvent({ - clientId: await stream.clientId, - eventId: approval.eventId, - outcome: { kind: 'next' }, - })).resolves.toMatchObject({ ok: false, error: { code: 'gateway/invocation-unavailable' } }) - const remaining = await readResidentRemoteEvents(api, 1) - expect(remaining.map(frame => frame.event)).toEqual(['user-questions/request']) - }) - - it('createDirectory under the root mints /name whose listing and crumbs share the identity', async () => { - const api = createFixtureApi() - const created = await api.directoryPickerRemote.createDirectory('/', 'srv') - if (!created.ok) throw new Error('create failed') - expect(created.value).toBe('/srv') - const listed = await api.directoryPickerRemote.list('/srv') - if (!listed.ok) throw new Error('list failed') - expect(listed.value.crumbs).toEqual([ - { name: '/', path: '/', hidden: false }, - { name: 'srv', path: '/srv', hidden: false }, - ]) - const root = await api.directoryPickerRemote.list('/') - if (!root.ok) throw new Error('root list failed') - expect(root.value.entries).toContainEqual({ name: 'srv', path: '/srv', hidden: false }) - }) - - it('workspace/follow serves the resident baseline and create reuses on path collision', async () => { - const api = createFixtureApi() - const baseline = await readWorkspaceBaseline(api.workspaceRemote) - expect(baseline.items).toEqual([ - expect.objectContaining({ - workspaceId: 'fx-ws-fixture', path: '/tmp/fixture', title: 'fixture', - sessionIds: ['fx-alpha', 'fx-beta', 'fx-gamma'], - }), - expect.objectContaining({ - workspaceId: 'fx-ws-home', path: '/home/fixture/Documents/project', title: 'project', - sessionIds: [], - }), - ]) - // path collision → the existing entity comes back, created:false, no frame. - const reused = await api.workspace.create(req({ path: '/tmp/fixture' })) - if (!reused.result.ok) throw new Error('reuse failed') - expect(reused.result.value).toMatchObject({ created: false, workspace: { workspaceId: 'fx-ws-fixture' } }) - }) - - it('workspace.create on a fresh path mints a new entity and pushes an upsert', async () => { - const api = createFixtureApi() - const abort = new AbortController() - const consuming = collectValues( - api.workspaceRemote.follow(abort.signal), - abort, - frames => frames.some(frame => frame.type === 'upsert' - && frame.workspace.path === '/tmp/fixture-workspaces/nova'), - ) - await new Promise(resolve => setTimeout(resolve, 10)) - const created = await api.workspace.create(req({ path: '/tmp/fixture-workspaces/nova' })) - if (!created.result.ok) throw new Error('create failed') - expect(created.result.value.created).toBe(true) - expect(created.result.value.workspace).toMatchObject({ - path: '/tmp/fixture-workspaces/nova', title: 'nova', sessionIds: [], - }) - const frames = await consuming - expect(frames.at(-1)).toEqual({ type: 'upsert', workspace: created.result.value.workspace }) - // A basename-less path serves as its own title. - const rootPath = await api.workspace.create(req({ path: '/' })) - if (!rootPath.result.ok) throw new Error('rootPath failed') - expect(rootPath.result.value.workspace.title).toBe('/') - }) - - it('workspace.rename covers not-found, conflict, no-op, and the changed frame', async () => { - const api = createFixtureApi() - const abort = new AbortController() - const consuming = collectValues( - api.workspaceRemote.follow(abort.signal), - abort, - frames => frames.filter(frame => frame.type === 'upsert').length >= 2, - ) - await new Promise(resolve => setTimeout(resolve, 10)) - const wsid = 'fx-ws-fixture' as WorkspaceId - const missing = await api.workspace.rename(req({ workspaceId: 'fx-ws-void' as WorkspaceId, title: 'x' })) - expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace/not-found', details: { workspaceId: 'fx-ws-void' } } }) - - await api.workspace.create(req({ path: '/tmp/fixture-workspaces/occupied' })) - const conflict = await api.workspace.rename(req({ workspaceId: wsid, title: ' occupied ' })) - expect(conflict.result).toMatchObject({ ok: false, error: { code: 'workspace/name-conflict', details: { name: 'occupied' } } }) - - const noop = await api.workspace.rename(req({ workspaceId: wsid, title: ' fixture ' })) - if (!noop.result.ok) throw new Error('no-op rename failed') - expect(noop.result.value.workspace.title).toBe('fixture') - - const renamed = await api.workspace.rename(req({ workspaceId: wsid, title: 'renamed' })) - if (!renamed.result.ok) throw new Error('rename failed') - expect(renamed.result.value.workspace.title).toBe('renamed') - const frames = await consuming - // Only the create and the effective rename emit frames; the no-op stays silent. - const upserts = frames.filter(frame => frame.type === 'upsert') - expect(upserts).toHaveLength(2) - expect(upserts[1]).toMatchObject({ workspace: { workspaceId: wsid, title: 'renamed' } }) - }) - - it('session.rename covers not-found, blank title, and the accepted append + title frame', async () => { - const api = createFixtureApi() - const followAbort = new AbortController() - const controlAbort = new AbortController() - const followPromise = collectValues( - api.sessionRemote.follow(sid('fx-alpha'), followAbort.signal), - followAbort, - frames => frames.some(frame => frame.type === 'event' - && (frame.event as { type: string }).type === 'session/title'), - ) - const controlPromise = collectValues( - api.sessionRemote.control(controlAbort.signal), - controlAbort, - frames => frames.some(frame => frame.type === 'projection' && frame.key === 'title' && frame.value === '重命名'), - ) - await new Promise(resolve => setTimeout(resolve, 10)) - - const missing = await api.sessions.rename(req({ sessionId: sid('fx-void'), title: 'x' })) - expect(missing.result).toMatchObject({ ok: false, error: { code: 'session/not-found', details: { sessionId: 'fx-void' } } }) - - const blank = await api.sessions.rename(req({ sessionId: sid('fx-alpha'), title: ' ' })) - expect(blank.result).toMatchObject({ ok: false, error: { code: 'session/title-invalid', details: { sessionId: 'fx-alpha' } } }) - - const renamed = await api.sessions.rename(req({ sessionId: sid('fx-alpha'), title: ' 重命名 ' })) - if (!renamed.result.ok) throw new Error('rename failed') - expect(renamed.result.value.title).toBe('重命名') - const acceptedSeq = renamed.result.value.seq - // The response seq addresses the appended title event (the client plane - // has no session/title in its event union — titles ride the projection — - // so the event is located by seq and its payload checked structurally). - const history = await api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 100 })) - if (!history.result.ok) throw new Error('history failed') - const appended = historyEvents(history.result.value.records).find(event => event.seq === acceptedSeq) - expect(appended).toMatchObject({ - type: 'session/title', - data: { title: '重命名', messageSeqs: [], source: { kind: 'user' } }, - }) - const followed = await followPromise - expect(followed.some(frame => frame.type === 'event' - && frame.event.seq === acceptedSeq - && (frame.event as { readonly type: string }).type === 'session/title')).toBe(true) - const frames = await controlPromise - const titleFrames = frames.filter(frame => - frame.type === 'projection' - && frame.key === 'title' - && frame.sessionId === sid('fx-alpha') - && frame.value === '重命名') - expect(titleFrames).toHaveLength(1) - expect(titleFrames[0]).toMatchObject({ seq: acceptedSeq }) - }) - - it('workspace.insertSessionBefore moves, appends, no-ops, and rejects invalid ids', async () => { - const api = createFixtureApi() - const wsid = 'fx-ws-fixture' as WorkspaceId - const missing = await api.workspace.insertSessionBefore(req({ workspaceId: 'fx-ws-void' as WorkspaceId, sessionId: sid('fx-alpha') })) - expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace/not-found' } }) - const ghost = await api.workspace.insertSessionBefore(req({ workspaceId: wsid, sessionId: sid('fx-ghost') })) - expect(ghost.result).toMatchObject({ ok: false, error: { code: 'workspace/move-invalid', details: { sessionId: 'fx-ghost' } } }) - const badAnchor = await api.workspace.insertSessionBefore(req({ workspaceId: wsid, sessionId: sid('fx-alpha'), beforeSessionId: sid('fx-ghost') })) - expect(badAnchor.result).toMatchObject({ ok: false, error: { code: 'workspace/move-invalid', details: { beforeSessionId: 'fx-ghost' } } }) - - const moved = await api.workspace.insertSessionBefore(req({ workspaceId: wsid, sessionId: sid('fx-gamma'), beforeSessionId: sid('fx-beta') })) - if (!moved.result.ok) throw new Error('move failed') - expect(moved.result.value.workspace.sessionIds).toEqual(['fx-alpha', 'fx-gamma', 'fx-beta']) - const appended = await api.workspace.insertSessionBefore(req({ workspaceId: wsid, sessionId: sid('fx-alpha') })) - if (!appended.result.ok) throw new Error('append failed') - expect(appended.result.value.workspace.sessionIds).toEqual(['fx-gamma', 'fx-beta', 'fx-alpha']) - const before = appended.result.value.workspace.updatedAt - const noop = await api.workspace.insertSessionBefore(req({ workspaceId: wsid, sessionId: sid('fx-alpha') })) - if (!noop.result.ok) throw new Error('no-op move failed') - expect(noop.result.value.workspace.sessionIds).toEqual(['fx-gamma', 'fx-beta', 'fx-alpha']) - expect(noop.result.value.workspace.updatedAt).toBe(before) - }) - - it('workspace.delete removes only the Workspace row and emits the removal frame', async () => { - const api = createFixtureApi() - const abort = new AbortController() - const consuming = collectValues( - api.workspaceRemote.follow(abort.signal), - abort, - frames => frames.some(frame => frame.type === 'remove'), - ) - await new Promise(resolve => setTimeout(resolve, 10)) - const missing = await api.workspace.delete(req({ workspaceId: 'fx-ws-void' as WorkspaceId })) - expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace/not-found' } }) - const deleted = await api.workspace.delete(req({ workspaceId: 'fx-ws-fixture' as WorkspaceId })) - expect(deleted.result).toEqual({ ok: true, value: { deleted: true } }) - const frames = await consuming - expect(frames.at(-1)).toEqual({ type: 'remove', workspaceId: 'fx-ws-fixture' }) - const baseline = await readWorkspaceBaseline(api.workspaceRemote) - expect(baseline.items.some(workspace => workspace.workspaceId === 'fx-ws-fixture')).toBe(false) - const sessions = await api.sessions.list(req({})) - if (!sessions.result.ok) throw new Error('session list failed') - expect(sessions.result.value.items.map(session => session.sessionId)).toContain('fx-alpha') - }) - - it('session.create({workspaceId}) lands on the account and unknown ids error', async () => { - const api = createFixtureApi() - const hostAbort = new AbortController() - const workspaceAbort = new AbortController() - const seen: FixtureRemoteEventNotificationFrame[] = [] - const consuming = (async () => { - for await (const frame of api.remoteEvents(hostAbort.signal)) { - if (frame.type !== 'emit' || frame.event !== 'api-session/added') continue - seen.push(frame) - hostAbort.abort() - break - } - })() - const workspaceFrames = collectValues( - api.workspaceRemote.follow(workspaceAbort.signal), - workspaceAbort, - frames => frames.some(frame => frame.type === 'upsert' - && frame.workspace.sessionIds.length === 4), - ) - await new Promise(resolve => setTimeout(resolve, 10)) - const missing = await api.sessions.create(req({ workspaceId: 'fx-ws-void' as WorkspaceId })) - expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace/not-found', details: { workspaceId: 'fx-ws-void' } } }) - const created = await api.sessions.create(req({ workspaceId: 'fx-ws-fixture' as WorkspaceId })) - if (!created.result.ok) throw new Error('create failed') - const id = created.result.value.sessionId - await consuming - const added = seen[0] - expect(added).toMatchObject({ - event: 'api-session/added', - args: [{ sessionId: id, blank: true, cwd: '/tmp/fixture' }], - }) - expect((await workspaceFrames).at(-1)).toMatchObject({ - type: 'upsert', - workspace: { - workspaceId: 'fx-ws-fixture', - sessionIds: [id, 'fx-alpha', 'fx-beta', 'fx-gamma'], - }, - }) - }) - - it('supports an empty baseline, preallocated ids, independent streams, and idempotent retry', async () => { - const api = createFixtureApi({ empty: true, createFrameOrder: 'workspace-first' }) - const initialSessions = await api.sessions.list(req({})) - expect(initialSessions.result).toMatchObject({ ok: true, value: { items: [] } }) - expect(await readWorkspaceBaseline(api.workspaceRemote)).toEqual({ - items: [], - archivedSessionIds: [], - }) - - const made = await api.workspace.create(req({ path: '/tmp/fixture-workspaces/nova' })) - if (!made.result.ok) throw new Error('workspace create failed') - const hostAbort = new AbortController() - const workspaceAbort = new AbortController() - const hostFrames = collectValues( - api.remoteEvents(hostAbort.signal), - hostAbort, - frames => frames.length === 1, - ) - const workspaceFrames = collectValues( - api.workspaceRemote.follow(workspaceAbort.signal), - workspaceAbort, - frames => frames.some(frame => frame.type === 'upsert' - && frame.workspace.sessionIds.includes(sid('fx-preallocated'))), - ) - await new Promise(resolve => setTimeout(resolve, 10)) - const preallocated = sid('fx-preallocated') - const created = await api.sessions.create(req({ - workspaceId: made.result.value.workspace.workspaceId, - sessionId: preallocated, - })) - expect(created.result).toEqual({ ok: true, value: { sessionId: preallocated } }) - expect((await workspaceFrames).at(-1)).toMatchObject({ - type: 'upsert', workspace: { sessionIds: [preallocated] }, - }) - const added = (await hostFrames)[0] - expect(added).toMatchObject({ - event: 'api-session/added', - args: [{ - sessionId: preallocated, - blank: true, - cwd: made.result.value.workspace.path, - }], - }) - - const retried = await api.sessions.create(req({ - workspaceId: made.result.value.workspace.workspaceId, - sessionId: preallocated, - })) - expect(retried.result).toEqual({ ok: true, value: { sessionId: preallocated } }) - const listed = await api.sessions.list(req({})) - if (!listed.result.ok) throw new Error('session list failed') - expect(listed.result.value.items.filter(item => item.sessionId === preallocated)).toHaveLength(1) - - const conflict = await api.sessions.create(req({ sessionId: preallocated, cwd: '/elsewhere' })) - expect(conflict.result).toMatchObject({ - ok: false, - error: { code: 'session/conflict', details: { sessionId: preallocated, requestedCwd: '/elsewhere' } }, - }) - }) - - it('attaches an existing ungrouped Session to a matching Workspace', async () => { - const api = createFixtureApi() - const sessionId = sid('fx-existing-ungrouped') - await expect(api.sessions.create(req({ sessionId, cwd: '/tmp/fixture' }))).resolves.toMatchObject({ - result: { ok: true, value: { sessionId } }, - }) - - await expect(api.sessions.create(req({ - sessionId, - workspaceId: 'fx-ws-fixture' as WorkspaceId, - }))).resolves.toMatchObject({ result: { ok: true, value: { sessionId } } }) - - const workspaces = await readWorkspaceBaseline(api.workspaceRemote) - expect(workspaces.items[0]?.sessionIds).toContain(sessionId) - }) - - it('reports a conflict without an existing cwd detail for an unrecorded cwd', async () => { - const api = createFixtureApi() - const listed = await api.sessions.list(req({})) - if (!listed.result.ok) throw new Error('session list failed') - const existing = listed.result.value.items.find(item => item.sessionId === sid('fx-alpha')) - if (existing === undefined) throw new Error('fixture Session missing') - delete existing.cwd - - const conflict = await api.sessions.create(req({ sessionId: existing.sessionId })) - expect(conflict.result).toEqual({ - ok: false, - error: { - code: 'session/conflict', - message: `session ${existing.sessionId} already uses no cwd`, - details: { sessionId: existing.sessionId, requestedCwd: '/tmp/fixture' }, - }, - }) - }) - - it('publishes an ungrouped Session when Workspace attachment fails', async () => { - const api = createFixtureApi({ failWorkspaceAttach: true }) - const sessionId = sid('fx-partial') - const created = await api.sessions.create(req({ - workspaceId: 'fx-ws-fixture' as WorkspaceId, - sessionId, - })) - expect(created.result).toMatchObject({ - ok: false, - error: { code: 'session/workspace-attach-failed', details: { sessionId, workspaceId: 'fx-ws-fixture' } }, - }) - const listed = await api.sessions.list(req({})) - const workspaces = await readWorkspaceBaseline(api.workspaceRemote) - if (!listed.result.ok) throw new Error('list failed') - expect(listed.result.value.items.filter(item => item.sessionId === sessionId)).toHaveLength(1) - expect(workspaces.items[0]?.sessionIds).not.toContain(sessionId) - - const retried = await api.sessions.create(req({ - workspaceId: 'fx-ws-fixture' as WorkspaceId, - sessionId, - })) - expect(retried.result).toMatchObject({ ok: false, error: { code: 'session/workspace-attach-failed' } }) - const afterRetry = await api.sessions.list(req({})) - if (!afterRetry.result.ok) throw new Error('list failed') - expect(afterRetry.result.value.items.filter(item => item.sessionId === sessionId)).toHaveLength(1) - }) - - it('reconciles a dropped create response and can reject a prompt before acceptance', async () => { - const sessionId = sid('fx-lost-response') - const dropped = createFixtureApi({ dropSessionCreateResponse: true }) - await expect(Promise.resolve().then(() => dropped.sessions.create(req({ - workspaceId: 'fx-ws-fixture' as WorkspaceId, - sessionId, - })))).rejects.toThrow(/dropped session\.create response/) - const listed = await dropped.sessions.list(req({})) - const workspaces = await readWorkspaceBaseline(dropped.workspaceRemote) - if (!listed.result.ok) throw new Error('list failed') - expect(listed.result.value.items.some(item => item.sessionId === sessionId)).toBe(true) - expect(workspaces.items[0]?.sessionIds).toContain(sessionId) - await expect(dropped.sessions.create(req({ - workspaceId: 'fx-ws-fixture' as WorkspaceId, - sessionId, - }))).resolves.toMatchObject({ result: { ok: true, value: { sessionId } } }) - - const rejecting = createFixtureApi({ empty: true, rejectPrompt: true }) - const real = await rejecting.sessions.create(req({ sessionId: sid('fx-rejected') })) - if (!real.result.ok) throw new Error('session create failed') - const prompt = await rejecting.sessions.prompt(req({ - sessionId: real.result.value.sessionId, - mode: 'queue' as const, - content: [{ type: 'text' as const, text: 'keep me' }], - })) - expect(prompt.result).toMatchObject({ ok: false, error: { code: 'session/agent-busy' } }) - const imagePrompt = await rejecting.sessions.prompt(req({ - sessionId: real.result.value.sessionId, - mode: 'queue' as const, - content: [{ type: 'image' as const, mediaType: 'image/png' as const, data: 'iVBORw0KGgo=' }], - })) - expect(imagePrompt.result).toMatchObject({ - ok: false, - error: { code: 'session/attachment-invalid', details: { reason: 'IMAGE_DIMENSION_TOO_LARGE' } }, - }) - }) - - it('timing hooks: history delay + one-shot failure, silent append, and breakStreams end open generators', async () => { - const api = createFixtureApi() - const hooks = timing() - // One-shot transport failure after transit delay. - hooks.setHistoryDelay(5) - hooks.failNextHistory() - await expect(api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 5 }))).rejects.toThrow(/simulated history transport failure/) - hooks.setHistoryDelay(0) - // The failure was one-shot: the next call succeeds. - const ok = await api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 5 })) - expect(ok.result.ok).toBe(true) - // A durable append without a live frame creates a detectable seq gap. - const gapAbort = new AbortController() - const gapIterator = api.sessionRemote.follow(sid('fx-alpha'), gapAbort.signal)[Symbol.asyncIterator]() - const opening = await gapIterator.next() - if (opening.done || opening.value.type !== 'snapshot') throw new Error('follow opening snapshot missing') - hooks.appendSilent('fx-alpha', '静默丢帧') - hooks.appendUser('fx-alpha', '正常直播') - await expect(gapIterator.next()).rejects.toThrow(/stream skipped seq/) - - // Reopening replaces the window with a complete snapshot containing both durable events. - const followAbort = new AbortController() - const controlAbort = new AbortController() - const followed: FixtureFollowFrame[] = [] - const controlled: FixtureControlFrame[] = [] - const following = (async () => { - for await (const frame of api.sessionRemote.follow(sid('fx-alpha'), followAbort.signal)) { - followed.push(frame) - } - })() - const controlling = (async () => { - for await (const frame of api.sessionRemote.control(controlAbort.signal)) controlled.push(frame) - })() - await new Promise(resolve => setTimeout(resolve, 10)) - await vi.waitFor(() => { - const snapshot = followed.find(frame => frame.type === 'snapshot') - const events = snapshot === undefined ? [] : historyEvents(snapshot.records) - expect(events.some(event => JSON.stringify(event.data).includes('静默丢帧'))).toBe(true) - expect(events.some(event => JSON.stringify(event.data).includes('正常直播'))).toBe(true) - }) - hooks.appendTitle('fx-alpha', 'Fixture 修订标题') - hooks.beginModelRetry('fx-alpha') - hooks.scheduleModelRetry('fx-alpha') - hooks.completeModelRetry('fx-alpha') - hooks.beginModelRetry('fx-alpha') - hooks.cancelModelRetryDuringBackoff('fx-alpha') - await vi.waitFor(() => { - expect(followed.some(frame => frame.type === 'event' && (frame.event as { type: string }).type === 'llm/retry')).toBe(true) - expect(followed.some(frame => frame.type === 'event' && JSON.stringify(frame.event.data).includes('重试后的完整回复'))).toBe(true) - expect(followed.some(frame => frame.type === 'event' - && frame.event.type === 'turn/end' - && frame.event.data.reason.kind === 'aborted')).toBe(true) - expect(controlled.some(frame => frame.type === 'projection' - && frame.key === 'title' - && frame.value === 'Fixture 修订标题')).toBe(true) - }) - expect(followed.some(frame => frame.type === 'event' && (frame.event as { type: string }).type === 'session/title')).toBe(true) - // Paging and resumed follow agree on the recovered durable event. - const repull = await api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 5 })) - if (!repull.result.ok) throw new Error('repull failed') - expect(JSON.stringify(repull.result.value.records)).toContain('静默丢帧') - // breakStreams force-ends follow and control without client aborts. - await new Promise(resolve => setTimeout(resolve, 10)) - hooks.breakStreams() - await following - await controlling - expect(followAbort.signal.aborted).toBe(false) - expect(controlAbort.signal.aborted).toBe(false) - }) - - it('paces the opt-in reasoning stress hook from an external interval', async () => { - vi.useFakeTimers() - vi.setSystemTime(0) - const api = createFixtureApi() - const hooks = timing() - expect(hooks.reasoningChunkStormState()).toBeNull() - expect(() => hooks.startReasoningChunkStorm('fx-alpha', 0, 1, 16)).toThrow(/chunk count/) - expect(() => hooks.startReasoningChunkStorm('fx-alpha', 1, 0, 16)).toThrow(/chunks per interval/) - expect(() => hooks.startReasoningChunkStorm('fx-alpha', 1, 1, 0)).toThrow(/reasoning interval/) - const abort = new AbortController() - try { - const streamed = collectValues(api.sessionRemote.follow(sid('fx-alpha'), abort.signal), abort, frames => frames.some(frame => ( - frame.type === 'assistant-stream' - && frame.frame.type === 'chunk' - && isReasoningDeltaChunk(frame.frame.chunk) - && frame.frame.chunk.text.includes('REASONING_STRESS_COMPLETE') - ))) - const marker = hooks.startReasoningChunkStorm('fx-alpha', 3, 2, 16) - expect(() => hooks.startReasoningChunkStorm('fx-alpha', 1, 1, 16)).toThrow(/already running/) - expect(hooks.reasoningChunkStormState()).toMatchObject({ emitted: 0, emitting: true, marker }) - - await vi.advanceTimersByTimeAsync(0) - expect(hooks.reasoningChunkStormState()).toMatchObject({ emitted: 2, emitting: true }) - await vi.advanceTimersByTimeAsync(16) - expect(hooks.reasoningChunkStormState()).toEqual({ - sessionId: 'fx-alpha', chunkCount: 3, chunksPerInterval: 2, intervalMs: 16, - emitted: 3, marker, emitting: false, - }) - - const frames = await streamed - const deltas = frames.flatMap(frame => ( - frame.type === 'assistant-stream' - && frame.frame.type === 'chunk' - && isReasoningDeltaChunk(frame.frame.chunk) - ? [frame.frame.chunk.text] - : [] - )) - expect(deltas).toEqual(['推理', '推理', `\n${marker}`]) - } finally { - abort.abort() - vi.useRealTimers() - } - }) -}) - -describe('fixture Connection RPC', () => { - afterEach(() => { - vi.restoreAllMocks() - vi.unstubAllGlobals() - }) - - it.each(['turn-end', 'omitted', 'past-end'] as const)('forks through the completed turn for a %s anchor', async (anchor) => { - vi.stubGlobal('__fxTiming', (globalThis as Record).__fxTiming) - const { rpc } = createFixtureFaces() - const sessions = createSessionClient(rpc) - const sourceId = sid('fx-alpha') - const initial = await sessions.history({ sessionId: sourceId, maxMessages: 1000 }) - if (!initial.result.ok) throw new Error('source history failed') - const events = historyEvents(initial.result.value.records) - const boundary = events.findLast(event => event.type === 'turn/end') - if (boundary === undefined) throw new Error('source has no completed turn') - expect(initial.result.value.hasMore).toBe(false) - expect((await sessions.rename({ sessionId: sourceId, title: 'Title after the selected turn' })).result.ok).toBe(true) - const source = await sessions.history({ sessionId: sourceId, maxMessages: 1000 }) - if (!source.result.ok) throw new Error('source history after rename failed') - const fork = await rpc.call('/api', 'session/fork', { - args: { request: { - sessionId: sourceId, - ...(anchor === 'omitted' ? {} : { atSeq: anchor === 'turn-end' ? boundary.seq : events.length + 100 }), - } }, - }) - if (!fork.ok) throw new Error('fork failed') - const childId = (fork.value as { sessionId: SessionId }).sessionId - const child = await sessions.history({ sessionId: childId, maxMessages: 1000 }) - if (!child.result.ok) throw new Error('child history failed') - expect(child.result.value.hasMore).toBe(false) - expect(historyEvents(child.result.value.records)).toEqual(events.slice(0, boundary.seq + 1)) - expect(await sessions.history({ sessionId: sourceId, maxMessages: 1000 })).toMatchObject({ result: source.result }) - }) - - it('covers the migrated Remote dispatch table', async () => { - const rpc = createFixtureConnectionRpc() - const sessions = createSessionClient(rpc) - const workspaces = createWorkspaceClient(rpc) - expect((await sessions.search( - { query: 'fixture' }, - new AbortController().signal, - )).result.ok).toBe(true) - const created = await sessions.create({}) - if (!created.result.ok) throw new Error('create failed') - const id = created.result.value.sessionId - expect((await sessions.history({ sessionId: id })).result.ok).toBe(true) - expect((await sessions.prompt({ sessionId: id, mode: 'queue', content: [{ type: 'text', text: '嗨' }] })).result.ok).toBe(true) - expect((await sessions.cancel({ sessionId: id })).result.ok).toBe(true) - expect((await readWorkspaceBaseline(createWorkspaceRemote(rpc))).items).not.toHaveLength(0) - const workspace = await workspaces.create({ path: '/tmp/fixture-workspaces/via-client' }) - if (!workspace.result.ok) throw new Error('workspace create failed') - expect(workspace.result.value.workspace.title).toBe('via-client') - const wsid = workspace.result.value.workspace.workspaceId - const renamed = await workspaces.rename({ workspaceId: wsid, title: 'via-client-2' }) - if (!renamed.result.ok) throw new Error('workspace rename failed') - expect(renamed.result.value.workspace.title).toBe('via-client-2') - const attached = await sessions.create({ workspaceId: wsid }) - if (!attached.result.ok) throw new Error('attached create failed') - const moved = await workspaces.insertSessionBefore({ workspaceId: wsid, sessionId: attached.result.value.sessionId }) - if (!moved.result.ok) throw new Error('workspace move failed') - expect(moved.result.value.workspace.sessionIds).toEqual([attached.result.value.sessionId]) - const archived = await workspaces.archiveSession({ sessionId: attached.result.value.sessionId }) - if (!archived.result.ok) throw new Error('workspace archive failed') - expect(archived.result.value.archivedSessionIds).toEqual([attached.result.value.sessionId]) - const unarchived = await workspaces.unarchiveSession({ sessionId: attached.result.value.sessionId }) - if (!unarchived.result.ok) throw new Error('workspace unarchive failed') - expect(unarchived.result.value.archivedSessionIds).toEqual([]) - // A second unarchive of the same id leaves the set unchanged. - const repeated = await workspaces.unarchiveSession({ sessionId: attached.result.value.sessionId }) - if (!repeated.result.ok) throw new Error('repeated workspace unarchive failed') - expect(repeated.result.value.archivedSessionIds).toEqual([]) - }) - - it('folds the goal lifecycle over the Goal Remotes', async () => { - const rpc = createFixtureConnectionRpc() - const sessions = createSessionClient(rpc) - const created = await sessions.create({}) - if (!created.result.ok) throw new Error('create failed') - const id = created.result.value.sessionId - const goal = (endpoint: string, args: Record) => - rpc.call('/api', endpoint, { args: { agentId: id, ...args } }) - - // create → edit → pause → resume → complete → clear; each mutation advances the CAS - // revision by one (state rides the projection frames). - const goalCreated = await goal('goals/create', { request: { objective: 'ship it' } }) - if (!goalCreated.ok) throw new Error('goal create failed') - const { id: goalId, revision } = (goalCreated.value as { ref: { id: string; revision: number } }).ref - expect(revision).toBe(1) - const ref = (at: number) => ({ id: goalId, revision: at }) - expect((await goal('goals/edit', { ref: ref(1), request: { objective: 'ship it v2' } })).ok).toBe(true) - expect(await goal('goals/get', {})).toMatchObject({ - ok: true, value: { objective: 'ship it v2', revision: 2, activation: 'armed' }, - }) - timing().disarmOnlyGoal() - expect(await goal('goals/get', {})).toMatchObject({ - ok: true, value: { objective: 'ship it v2', revision: 2, activation: 'disarmed' }, - }) - expect((await goal('goals/pause', { ref: ref(2) })).ok).toBe(true) - expect((await goal('goals/resume', { ref: ref(3) })).ok).toBe(true) - // A stale ref loses the CAS check. - expect((await goal('goals/pause', { ref: ref(1) })).ok).toBe(false) - expect((await goal('goals/complete', { ref: ref(4) })).ok).toBe(true) - // complete → complete is an invalid transition. - expect((await goal('goals/complete', { ref: ref(5) })).ok).toBe(false) - expect(await goal('goals/clear', { ref: ref(5) })).toEqual({ ok: true, value: ref(6) }) - - const goalHistory = await sessions.history({ sessionId: id }) - if (!goalHistory.result.ok) throw new Error('goal history failed') - const goalEvents = historyEvents(goalHistory.result.value.records).map(event => event as unknown as { - type: string - data: { - operation?: string - source?: { kind?: string; round?: number } - } - }) - const goalChanges = goalEvents.filter(event => event.type === 'goal/change') - expect(goalChanges.map(event => event.data.operation)) - .toEqual(['create', 'edit', 'pause', 'resume', 'complete', 'clear']) - expect(goalEvents.some(event => event.type === 'user/message' - && event.data.source?.kind === 'goal' && event.data.source.round === 0)).toBe(false) - }) - - it('maps empty, prompt-reject, and workspace-first query scenarios', async () => { - vi.stubGlobal('location', { - search: '?fixture=empty&fixturePrompt=reject&fixtureFrames=workspace-first', - }) - const rpc = createFixtureConnectionRpc() - const sessions = createSessionClient(rpc) - const workspaces = createWorkspaceClient(rpc) - const workspaceRemote = createWorkspaceRemote(rpc) - await expect(sessions.list({})).resolves.toMatchObject({ result: { ok: true, value: { items: [] } } }) - const made = await workspaces.create({ path: '/tmp/fixture-workspaces/query-workspace' }) - if (!made.result.ok) throw new Error('workspace create failed') - const hostAbort = new AbortController() - const workspaceAbort = new AbortController() - const hostFrames = collectValues( - openFixtureRemoteEvents(rpc, hostAbort.signal), - hostAbort, - frames => frames.length === 1, - ) - const workspaceFrames = collectValues( - workspaceRemote.follow(workspaceAbort.signal), - workspaceAbort, - frames => frames.some(frame => frame.type === 'upsert' - && frame.workspace.sessionIds.includes(sid('fx-query-session'))), - ) - await new Promise(resolve => setTimeout(resolve, 10)) - const sessionId = sid('fx-query-session') - const created = await sessions.create({ - workspaceId: made.result.value.workspace.workspaceId, - sessionId, - }) - expect(created.result).toMatchObject({ ok: true, value: { sessionId } }) - expect((await workspaceFrames).at(-1)).toMatchObject({ - type: 'upsert', - workspace: { sessionIds: [sessionId] }, - }) - expect((await hostFrames)[0]).toMatchObject({ - event: 'api-session/added', - }) - const rejected = await sessions.prompt({ - sessionId, - mode: 'queue', - content: [{ type: 'text', text: 'retain' }], - }) - expect(rejected.result).toMatchObject({ ok: false, error: { code: 'session/agent-busy' } }) - }) - - it('maps attach-failure and dropped-response query scenarios', async () => { - vi.stubGlobal('location', { search: '?fixture&fixtureAttach=fail' }) - const partial = createFixtureConnectionRpc() - const partialResult = await createSessionClient(partial).create({ - workspaceId: 'fx-ws-fixture' as WorkspaceId, - sessionId: sid('fx-query-partial'), - }) - expect(partialResult.result).toMatchObject({ - ok: false, - error: { code: 'session/workspace-attach-failed', details: { sessionId: 'fx-query-partial' } }, - }) - - vi.stubGlobal('location', { search: '?fixture&fixtureSessionCreate=drop-response' }) - const dropped = createFixtureConnectionRpc() - await expect(createSessionClient(dropped).create({ - workspaceId: 'fx-ws-fixture' as WorkspaceId, - sessionId: sid('fx-query-dropped'), - })).rejects.toThrow(/dropped session\.create response/) - }) - -}) diff --git a/packages/client/connection/tests/generation.client.spec.ts b/packages/client/connection/tests/generation.client.spec.ts index d2b365de4b..48a6dc5e7b 100644 --- a/packages/client/connection/tests/generation.client.spec.ts +++ b/packages/client/connection/tests/generation.client.spec.ts @@ -7,7 +7,7 @@ import { } from '../src/client/index.ts' type BrowserGlobal = { - location?: { hostname: string; search: string } + location?: { hostname: string } } const contexts = new Set() @@ -20,12 +20,12 @@ afterEach(async () => { }) async function mount(): Promise { - ;(globalThis as BrowserGlobal).location = { hostname: 'localhost', search: '?fixture' } + ;(globalThis as BrowserGlobal).location = { hostname: 'localhost' } const ctx = new Context() contexts.add(ctx) await ctx.plugin({ apply, inject: [] }) const connection = ctx.get('connection') as ConnectionHandle | undefined - if (connection === undefined) throw new Error('fixture did not provide Connection') + if (connection === undefined) throw new Error('plugin did not provide Connection') return connection } diff --git a/packages/client/connection/tsconfig.client.json b/packages/client/connection/tsconfig.client.json index 4266a1defc..ffc18472c2 100644 --- a/packages/client/connection/tsconfig.client.json +++ b/packages/client/connection/tsconfig.client.json @@ -9,7 +9,6 @@ "src/api-path.ts", "src/client/api.ts", "src/client/connection.ts", - "src/client/fixture.ts", "src/client/index.ts", "src/client/random-uuid.ts", "src/client/rpc.ts", @@ -30,24 +29,9 @@ { "path": "../../core/session" }, - { - "path": "../../todo/tool-todo" - }, - { - "path": "../../core/tools" - }, { "path": "../../credentials/credentials" }, - { - "path": "../../settings/settings" - }, - { - "path": "../../host/directory-picker" - }, - { - "path": "../../interaction/commands" - }, { "path": "../../llm/llm" }, diff --git a/packages/client/file-upload/README.i18n.yaml b/packages/client/file-upload/README.i18n.yaml index e6b4cb5fcb..4422bb94d6 100644 --- a/packages/client/file-upload/README.i18n.yaml +++ b/packages/client/file-upload/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/file-upload/README.md -README.md: 2c19e1a564d7b58226e6ccd3cb5b7c81e1b1c14e -README.zh.md: d1553d40d953dd6378a954c43a9b7a36b7334953 +README.md: e0c0d7637da0b426ebb5b4a0c6cb47d12784e0ed +README.zh.md: abdf965694f7ae62dd05a0192ffbc16e8678ec7d diff --git a/packages/client/file-upload/README.md b/packages/client/file-upload/README.md index 2c19e1a564..e0c0d7637d 100644 --- a/packages/client/file-upload/README.md +++ b/packages/client/file-upload/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -This package lets browser features store a `Blob`, exact bytes, or a `ReadableStream` for one Session and receive an opaque receipt for a later prompt. Served pages send Blob and stream bodies without aggregating their bytes on the page thread; pages whose Host runs in another execution context supply a Fetch-shaped carrier before Cordis boots. Callers can observe consumed bytes and cancel an active operation. A stream body is consumed once and transfers ownership when it crosses a Worker boundary. The standalone `?fixture` page uses the generated Remote for replayable Blob and exact-byte inputs. +This package lets browser features store a `Blob`, exact bytes, or a `ReadableStream` for one Session and receive an opaque receipt for a later prompt. Served pages send Blob and stream bodies without aggregating their bytes on the page thread; pages whose Host runs in another execution context supply a Fetch-shaped carrier before Cordis boots. Callers can observe consumed bytes and cancel an active operation. A stream body is consumed once and transfers ownership when it crosses a Worker boundary. Exact bytes use the generated Remote. ## Table of Contents @@ -32,7 +32,7 @@ Mount the package before a consumer that injects `fileUpload`, then call `ctx.fi name: '@deepseek-ai/dsh-client-file-upload' ``` -The package has no Cordis configuration fields. A `Blob` uses XMLHttpRequest inside a dedicated Worker so the service can report browser upload progress, including the total when the browser provides it. A `ReadableStream` transfers to that Worker and feeds Fetch incrementally; progress reports consumed bytes without a total. An `AbortSignal` terminates the dedicated Worker or reaches a page-owned carrier. Exact bytes and fixture Blob inputs use the generated Remote. +The package has no Cordis configuration fields. A `Blob` uses XMLHttpRequest inside a dedicated Worker so the service can report browser upload progress, including the total when the browser provides it. A `ReadableStream` transfers to that Worker and feeds Fetch incrementally; progress reports consumed bytes without a total. An `AbortSignal` terminates the dedicated Worker or reaches a page-owned carrier. Exact bytes use the generated Remote. ----- @@ -42,7 +42,7 @@ The package has no Cordis configuration fields. A `Blob` uses XMLHttpRequest ins

Implementation internals — click to expand -The Client plugin provides `ctx.fileUpload`. Its `upload()` method receives the owning Session identity, assembles the raw route request, and invokes the generated Remote fallback for replayable inputs. The provider reads the optional pre-Cordis `__DSH_FILE_UPLOAD__` hook once. Without a hook, each non-fixture raw request owns a short-lived Worker and releases it after completion, failure, or cancellation. With the hook, the service sends the body through the page-owned Fetch carrier; the Web Worker runtime transfers stream bodies through its request frame and exposes them to the Host HTTP bridge as backpressured chunks. +The Client plugin provides `ctx.fileUpload`. Its `upload()` method receives the owning Session identity, assembles the raw route request, and invokes the generated Remote for exact bytes. The provider reads the optional pre-Cordis `__DSH_FILE_UPLOAD__` hook once. Without a hook, each Blob or stream request owns a short-lived Worker and releases it after completion, failure, or cancellation. With the hook, the service sends the body through the page-owned Fetch carrier; the Web Worker runtime transfers stream bodies through its request frame and exposes them to the Host HTTP bridge as backpressured chunks. The Host plugin provides `ctx.fileUploads`. It owns the authenticated streaming route, encoded Remote fallback, command receipt resolver, and staged-receipt lifecycle; encoded admission, attachment-error recognition, and byte storage stay behind `ctx.attachments`. Receipt tables use the receiving Agent's Session object as their key. The Session Controller registers the resolver that can resume a cold ordinary Agent and consumes receipts during prompt admission. Prompt delivery holds each receipt binding in a disposable transaction: disposal restores the previous binding until successful delivery commits it, and queue or history observation then retires the committed receipt. diff --git a/packages/client/file-upload/README.zh.md b/packages/client/file-upload/README.zh.md index d1553d40d9..abdf965694 100644 --- a/packages/client/file-upload/README.zh.md +++ b/packages/client/file-upload/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -本包让浏览器功能为一个 Session 存储 `Blob`、精确字节或 `ReadableStream`,并取得供后续 prompt 使用的不透明凭证。普通服务页面发送 Blob 和 stream 请求体时,不会在页面线程聚合全部字节;Host 位于其他执行上下文中的页面会在 Cordis 启动前提供 Fetch 形式的载体。调用方可以观察已消费字节并取消活动操作。stream 请求体只能消费一次,跨 Worker 边界时会转移所有权。独立的 `?fixture` 页面通过生成的 Remote 处理可重放的 Blob 与精确字节输入。 +本包让浏览器功能为一个 Session 存储 `Blob`、精确字节或 `ReadableStream`,并取得供后续 prompt 使用的不透明凭证。普通服务页面发送 Blob 和 stream 请求体时,不会在页面线程聚合全部字节;Host 位于其他执行上下文中的页面会在 Cordis 启动前提供 Fetch 形式的载体。调用方可以观察已消费字节并取消活动操作。stream 请求体只能消费一次,跨 Worker 边界时会转移所有权。精确字节使用生成的 Remote。 ## 目录 @@ -32,7 +32,7 @@ kind: "package-reference" name: '@deepseek-ai/dsh-client-file-upload' ``` -本包没有 Cordis 配置字段。`Blob` 在专用 Worker 内通过 XMLHttpRequest 发送,因此服务可以报告浏览器上传进度,并在浏览器提供总量时一并报告。`ReadableStream` 会转移给该 Worker,再增量传入 Fetch;进度只报告已消费字节,不包含总量。`AbortSignal` 会终止专用 Worker,或传递给页面自己提供的载体。精确字节与 fixture Blob 输入使用生成的 Remote。 +本包没有 Cordis 配置字段。`Blob` 在专用 Worker 内通过 XMLHttpRequest 发送,因此服务可以报告浏览器上传进度,并在浏览器提供总量时一并报告。`ReadableStream` 会转移给该 Worker,再增量传入 Fetch;进度只报告已消费字节,不包含总量。`AbortSignal` 会终止专用 Worker,或传递给页面自己提供的载体。精确字节使用生成的 Remote。 ----- @@ -42,7 +42,7 @@ kind: "package-reference"
实现细节——点击展开 -Client 插件提供 `ctx.fileUpload`。其 `upload()` 方法接收所属 Session 标识,组装原始路由请求,并为可重放输入调用生成的 Remote 兜底。提供方只读取一次可选的 Cordis 启动前 `__DSH_FILE_UPLOAD__` 钩子。没有该钩子时,每个非 fixture 原始请求拥有一个短期 Worker,并在完成、失败或取消后释放。存在该钩子时,服务通过页面自己提供的 Fetch 载体发送请求体;Web Worker runtime 会通过请求帧转移 stream 请求体,再以带背压的分片形式交给 Host HTTP bridge。 +Client 插件提供 `ctx.fileUpload`。其 `upload()` 方法接收所属 Session 标识,组装原始路由请求,并为精确字节调用生成的 Remote。提供方只读取一次可选的 Cordis 启动前 `__DSH_FILE_UPLOAD__` 钩子。没有该钩子时,每个 Blob 或 stream 请求拥有一个短期 Worker,并在完成、失败或取消后释放。存在该钩子时,服务通过页面自己提供的 Fetch 载体发送请求体;Web Worker runtime 会通过请求帧转移 stream 请求体,再以带背压的分片形式交给 Host HTTP bridge。 Host 插件提供 `ctx.fileUploads`。它拥有经过认证的流式路由、编码 Remote 兜底、命令凭证解析器与暂存凭证生命周期;编码准入、附件错误识别与字节存储仍由 `ctx.attachments` 提供。凭证表以接收方 Agent 的 Session 对象为键。Session Controller 注册可恢复休眠普通 Agent 的解析器,并在 prompt 准入时消费凭证。Prompt 投递通过可释放事务持有每个凭证绑定。成功投递提交事务前,释放会恢复原绑定;提交后,队列或历史观察会退休该凭证。 diff --git a/packages/client/file-upload/src/client/contract.ts b/packages/client/file-upload/src/client/contract.ts index 998a71b2c3..dfe2245ff2 100644 --- a/packages/client/file-upload/src/client/contract.ts +++ b/packages/client/file-upload/src/client/contract.ts @@ -17,7 +17,7 @@ export interface FileUploadService { readonly available: boolean /** * Store one file for a Session. Blob and stream bodies use - * the background carrier; exact bytes and fixture fallbacks use Remote. + * the background carrier; exact bytes use Remote. * @param sessionId - Session that owns the staged receipt. * @param data - browser Blob, exact bytes, or a one-shot byte stream. * @param name - optional display name. diff --git a/packages/client/file-upload/src/client/runtime.ts b/packages/client/file-upload/src/client/runtime.ts index a54e31121c..90076cbedd 100644 --- a/packages/client/file-upload/src/client/runtime.ts +++ b/packages/client/file-upload/src/client/runtime.ts @@ -160,14 +160,13 @@ interface FileUploadTransport { /** Cordis service that owns one background carrier per upload operation. */ export class FileUploadRuntime extends Service implements FileUploadService { - readonly available: boolean + readonly available = true private readonly transport: FileUploadTransport /** @param ctx - providing Client context. */ constructor(ctx: Context) { super(ctx, 'fileUpload') const hook = (globalThis as ClientFileUploadGlobal).__DSH_FILE_UPLOAD__ - this.available = hook !== undefined || !isFixturePage() this.transport = hook === undefined ? workerTransport() : customTransport(hook.fetch) } @@ -177,7 +176,6 @@ export class FileUploadRuntime extends Service implements FileUploadService { * @returns the response status and text body. */ post(request: FileUploadRequest): Promise { - if (!this.available) return Promise.reject(new Error('background upload is unavailable in fixture mode')) return this.transport.post(request) } @@ -197,7 +195,7 @@ export class FileUploadRuntime extends Service implements FileUploadService { signal?: AbortSignal, onProgress?: (progress: { readonly loaded: number; readonly total?: number }) => void, ): Promise> { - if (!(data instanceof Uint8Array) && this.available) { + if (!(data instanceof Uint8Array)) { const query = new URLSearchParams({ sessionId }) if (name !== undefined) query.set('name', name) const response = await this.post({ @@ -212,14 +210,10 @@ export class FileUploadRuntime extends Service implements FileUploadService { } return parseFileUploadResult(response.body) } - if (!(data instanceof Uint8Array) && !(data instanceof Blob)) { - throw new Error('stream file upload requires a background carrier') - } - const bytes = data instanceof Uint8Array ? data : new Uint8Array(await data.arrayBuffer()) return (this.ctx as FileUploadRemoteContext).remote.fileUploads.upload( sessionId, { - data: bytesToBase64(bytes), + data: bytesToBase64(data), ...(name === undefined ? {} : { name }), }, signal, @@ -311,13 +305,6 @@ function resolveUrl(path: string): URL { return new URL(path, origin === undefined || origin === 'null' ? 'http://dsh.internal' : origin) } -function isFixturePage(): boolean { - const pageLocation = Reflect.get(globalThis, 'location') as unknown - return typeof pageLocation === 'object' && pageLocation !== null - && 'search' in pageLocation && typeof pageLocation.search === 'string' - && new URLSearchParams(pageLocation.search).has('fixture') -} - function parseFileUploadResult(body: string): RemoteResult { const value = JSON.parse(body) as unknown if (!isRecord(value) || typeof value.ok !== 'boolean') { diff --git a/packages/client/file-upload/tests/file-upload.client.spec.ts b/packages/client/file-upload/tests/file-upload.client.spec.ts index 66b27b102a..c6e28d54c0 100644 --- a/packages/client/file-upload/tests/file-upload.client.spec.ts +++ b/packages/client/file-upload/tests/file-upload.client.spec.ts @@ -252,17 +252,6 @@ describe('file upload service', () => { await fiber.dispose() }) - it('leaves the fixture on its generated Remote fallback', async () => { - vi.stubGlobal('location', { origin: 'https://fixture.test', search: '?fixture' }) - const ctx = new Context() - const fiber = ctx.plugin(FileUploadRuntime) - await fiber - expect(ctx.fileUpload.available).toBe(false) - await expect((ctx.fileUpload as FileUploadRuntime).post({ path: '/upload', body: new Blob() })) - .rejects.toThrow('background upload is unavailable in fixture mode') - await fiber.dispose() - }) - it('fails loud when a served browser has no Worker implementation', async () => { vi.stubGlobal('Worker', undefined) const ctx = new Context() @@ -438,8 +427,7 @@ describe('Session-addressed file upload', () => { await fiber.dispose() }) - it('uses the direct Remote fallback for exact bytes and fixture Blob bodies', async () => { - vi.stubGlobal('location', { origin: 'https://fixture.test', search: '?fixture' }) + it('uses the direct Remote fallback for exact bytes', async () => { const remote = vi.fn(() => Promise.resolve({ ok: true, value: { @@ -450,23 +438,13 @@ describe('Session-addressed file upload', () => { const { fiber, service } = await scopedService({ remote }) await expect(service.upload(SESSION_ID, Uint8Array.of(0, 0, 0), 'bytes.bin')) .resolves.toMatchObject({ ok: true }) - await expect(service.upload(SESSION_ID, new Blob([Uint8Array.of(1)]))) - .resolves.toMatchObject({ ok: true }) expect(remote.mock.calls).toEqual([ [SESSION_ID, { data: 'AAAA', name: 'bytes.bin' }, undefined], - [SESSION_ID, { data: 'AQ==' }, undefined], ]) await fiber.dispose() }) - it('rejects an unavailable stream and malformed background results', async () => { - vi.stubGlobal('location', { origin: 'https://fixture.test', search: '?fixture' }) - const fixture = await scopedService() - const stream = new ReadableStream({ start(controller) { controller.close() } }) - await expect(fixture.service.upload(SESSION_ID, stream)) - .rejects.toThrow('stream file upload requires a background carrier') - await fixture.fiber.dispose() - + it('rejects malformed background results', async () => { vi.stubGlobal('location', { origin: 'https://preview.test' }) ;(globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ = { fetch: () => Promise.resolve(new Response(null, { status: 413 })), diff --git a/packages/host/frontend-static/tests/frontend-static.spec.ts b/packages/host/frontend-static/tests/frontend-static.spec.ts index 26034e7d6f..2a38750c06 100644 --- a/packages/host/frontend-static/tests/frontend-static.spec.ts +++ b/packages/host/frontend-static/tests/frontend-static.spec.ts @@ -141,7 +141,7 @@ describe('real Loader composition', () => { // Only the root and index path render index.html through registered taps. const untap = server.tapIndex(html => html.replace('', '')) - for (const path of ['/', '/index.html', '/?fixture']) { + for (const path of ['/', '/index.html', '/?view=test']) { const got = await request(port, path, authenticated()) expect(got.status).toBe(200) expect(got.type).toBe('text/html; charset=utf-8') diff --git a/packages/session-query/session-query-sqlite/src/index.ts b/packages/session-query/session-query-sqlite/src/index.ts index e0b24b3c39..7c95afa28d 100644 --- a/packages/session-query/session-query-sqlite/src/index.ts +++ b/packages/session-query/session-query-sqlite/src/index.ts @@ -662,8 +662,6 @@ export class SqliteSessionQueryEngine extends SessionQueryEngine { offset, ] assertPortableBindingCount(bindings.length) - // The browser fixture mirrors these rank keys in - // `packages/client/connection/src/client/fixture.ts`; update both together. return this._requireDb().prepare(` ${selected.sql}, filtered AS ( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 40a5e7b7eb..037ce8b38b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -604,6 +604,9 @@ importers: '@deepseek-ai/dsh-client-store': specifier: workspace:^ version: link:../../packages/client/store + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../../packages/test-support/client-runtime '@deepseek-ai/dsh-client-ui-dockkit': specifier: workspace:^ version: link:../../packages/client/ui-dockkit @@ -634,6 +637,9 @@ importers: '@deepseek-ai/dsh-pwsh-local': specifier: workspace:^ version: link:../../packages/shell/pwsh-local + '@deepseek-ai/dsh-remote-mock': + specifier: workspace:^ + version: link:../../packages/test-support/remote-mock '@types/node': specifier: ^22.0.0 version: 22.20.0 @@ -878,6 +884,9 @@ importers: '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ version: link:../../host/webserver + '@deepseek-ai/dsh-remote-mock': + specifier: workspace:^ + version: link:../../test-support/remote-mock '@deepseek-ai/dsh-typert-registry': specifier: workspace:^ version: link:../../typert/registry @@ -2056,21 +2065,12 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-api-session-controller': - specifier: workspace:^ - version: link:../../api/session-controller '@deepseek-ai/dsh-attachment': specifier: workspace:^ version: link:../../attachment/attachment '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand - '@deepseek-ai/dsh-commands': - specifier: workspace:^ - version: link:../../interaction/commands - '@deepseek-ai/dsh-host-directory-picker': - specifier: workspace:^ - version: link:../../host/directory-picker '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ version: link:../../host/webserver @@ -2080,15 +2080,6 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session - '@deepseek-ai/dsh-settings': - specifier: workspace:^ - version: link:../../settings/settings - '@deepseek-ai/dsh-tool-todo': - specifier: workspace:^ - version: link:../../todo/tool-todo - '@deepseek-ai/dsh-util-values': - specifier: workspace:^ - version: link:../../util/values packages/client/file-upload: dependencies: diff --git a/vitest.config.ts b/vitest.config.ts index 20c6e149ad..cd6752424c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -305,7 +305,6 @@ export default defineConfig({ 'packages/experimental/client-ui-agent-team/src/client/index.ts', // Slash/command/input round: per-file gaps deferred with the same // client-lane debt. TODO(gui): cover and remove with the lane above. - 'packages/client/connection/src/client/fixture.ts', 'packages/client/ui-commands/src/index.ts', 'packages/client/ui-skill/src/index.ts', 'packages/client/ui-input-trigger/src/index.ts', From 566db0b6cb234de1350e398d8c0ac1dd5e2b9502 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:47:17 +0800 Subject: [PATCH 63/79] docs(testing): retire browser fixture references --- ...ojected-token-usage-and-request-context.i18n.yaml | 4 ++-- ...7-29-projected-token-usage-and-request-context.md | 2 +- ...9-projected-token-usage-and-request-context.zh.md | 2 +- .../2026-07-31-goal-owned-durable-events.i18n.yaml | 4 ++-- .../2026-07-31-goal-owned-durable-events.md | 2 +- .../2026-07-31-goal-owned-durable-events.zh.md | 2 +- ...-08-23-client-derived-tool-presentation.i18n.yaml | 4 ++-- .../2026-08-23-client-derived-tool-presentation.md | 6 +++--- ...2026-08-23-client-derived-tool-presentation.zh.md | 6 +++--- .../process/2026-07-20-gui-testing-system.i18n.yaml | 4 ++-- .../process/2026-07-20-gui-testing-system.md | 10 +++++----- .../process/2026-07-20-gui-testing-system.zh.md | 10 +++++----- .../2026-07-24-web-gui-browser-e2e-lane.i18n.yaml | 4 ++-- .../testing/2026-07-24-web-gui-browser-e2e-lane.md | 4 ++-- .../2026-07-24-web-gui-browser-e2e-lane.zh.md | 4 ++-- ...3-opt-in-reasoning-chunk-browser-stress.i18n.yaml | 4 ++-- ...26-08-03-opt-in-reasoning-chunk-browser-stress.md | 12 ++++++------ ...08-03-opt-in-reasoning-chunk-browser-stress.zh.md | 12 ++++++------ .../2026-09-06-client-assembly-test-line.i18n.yaml | 4 ++-- .../testing/2026-09-06-client-assembly-test-line.md | 8 ++++++-- .../2026-09-06-client-assembly-test-line.zh.md | 8 ++++++-- 21 files changed, 62 insertions(+), 54 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.i18n.yaml index 892e3a8fc0..a5d41c6bca 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md -2026-07-29-projected-token-usage-and-request-context.md: f96257243bef91ff6a73418231de5e777d8edb2e -2026-07-29-projected-token-usage-and-request-context.zh.md: 7365d5d816f9f9b324f3e3d3b4db0d3346851bdf +2026-07-29-projected-token-usage-and-request-context.md: 10056ac8ef85562035d4fc4591e90aec603a7dd8 +2026-07-29-projected-token-usage-and-request-context.zh.md: 0e4cd68791f0ee65dd700b64493be3c33b78a920 diff --git a/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md index f96257243b..10056ac8ef 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md +++ b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md @@ -58,4 +58,4 @@ Token totals stay stable across pagination, compaction, replay, restart, and rec Occupancy is approximate in the ways documented above. It is available immediately after restore or reconnect, since both fields are durable, at the cost of describing the last recorded request rather than an exact current boundary. -Each session log gains one small `request/context` record per route or advertised-capacity change. Token-meter is the canonical owner of durable usage semantics, including retry-attempt separation in the cumulative projection and the reusable exact attempt/Turn fold; Web Chat only selects a complete loaded Turn and renders the fold result. The TUI retains its live per-step map because it does not mount the generic projection seam, and the standalone browser fixture mirrors the unit. Connection and API Gateway carry no token-specific code, own no per-session metrics cache, and perform no measurement. The browser keeps two generic projection values and no connection-local telemetry; streaming text deltas do not force the stats line to recompute or churn layout-observer subscriptions. +Each session log gains one small `request/context` record per route or advertised-capacity change. Token-meter is the canonical owner of durable usage semantics, including retry-attempt separation in the cumulative projection and the reusable exact attempt/Turn fold; Web Chat only selects a complete loaded Turn and renders the fold result. The TUI retains its live per-step map because it does not mount the generic projection seam, and the assembled RemoteMock scenario supplies the same projection values. Connection and API Gateway carry no token-specific code, own no per-session metrics cache, and perform no measurement. The browser keeps two generic projection values and no connection-local telemetry; streaming text deltas do not force the stats line to recompute or churn layout-observer subscriptions. diff --git a/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.zh.md b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.zh.md index 7365d5d816..0e4cd68791 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.zh.md @@ -58,4 +58,4 @@ token 总量在分页、压缩、回放、重启和重连期间保持稳定, 占用率在上文记录的意义上是近似值。由于两个字段都是持久的,它在恢复或重连后立即可用;代价是它描述的是最后一条已记录的请求,而不是精确的当前边界。 -每个会话日志会为每次路由或已公布容量变化增加一条小型 `request/context` 记录。token-meter 是持久用量语义的正典所有方,包括累计投影中的重试 attempt 分离,以及可复用的精确 attempt/Turn fold;Web Chat 只选择已完整加载的 Turn 并渲染 fold 结果。TUI 未挂载通用投影 seam,因此保留自己的实时逐步骤 map,而独立浏览器 fixture(测试前置数据)会镜像该单元。Connection 与 API Gateway 不携带任何 token 专用代码,不拥有逐会话指标缓存,也不执行测量。浏览器只保留两个通用投影值,不保留连接本地的遥测数据;流式文本增量不会迫使统计行重新计算或反复替换布局 observer 订阅。 +每个会话日志会为每次路由或已公布容量变化增加一条小型 `request/context` 记录。token-meter 是持久用量语义的正典所有方,包括累计投影中的重试 attempt 分离,以及可复用的精确 attempt/Turn fold;Web Chat 只选择已完整加载的 Turn 并渲染 fold 结果。TUI 未挂载通用投影 seam,因此保留自己的实时逐步骤 map,而组装 RemoteMock 场景提供相同投影值。Connection 与 API Gateway 不携带任何 token 专用代码,不拥有逐会话指标缓存,也不执行测量。浏览器只保留两个通用投影值,不保留连接本地的遥测数据;流式文本增量不会迫使统计行重新计算或反复替换布局 observer 订阅。 diff --git a/.agents/notes/implemented/architecture/2026-07-31-goal-owned-durable-events.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-31-goal-owned-durable-events.i18n.yaml index dde6b822da..aecb82c585 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-goal-owned-durable-events.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-31-goal-owned-durable-events.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-31-goal-owned-durable-events.md -2026-07-31-goal-owned-durable-events.md: 8f7d3b74a83e063fbfc5a530f9e51a2a3eea8197 -2026-07-31-goal-owned-durable-events.zh.md: 8ee0099209c9c98a88549073b33c98ddb4b12bbd +2026-07-31-goal-owned-durable-events.md: 0d3e632ba275f38e48fdf1f9ee6747f4bc460aab +2026-07-31-goal-owned-durable-events.zh.md: fd29a6f36eed42f5ba26a46a32acb165d1e42873 diff --git a/.agents/notes/implemented/architecture/2026-07-31-goal-owned-durable-events.md b/.agents/notes/implemented/architecture/2026-07-31-goal-owned-durable-events.md index 8f7d3b74a8..0d3e632ba2 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-goal-owned-durable-events.md +++ b/.agents/notes/implemented/architecture/2026-07-31-goal-owned-durable-events.md @@ -30,4 +30,4 @@ The domain does not automatically project each mutation into model input. Goal t Goal state is independent of inbox placement and admission. Replay has one mutation path, projections advance directly on `goal/change`, and continuation messages carry only round attribution. The model does not receive a mutation-only `` message; model-visible state appears through goal tools and scheduled continuation prompts. Direct session writers remain trusted and can append malformed changes, which the strict fold and invariant companion reject. -Focused goal, goal-round-driver, command, TUI, and client-fixture tests pin durable replay, positive-round accounting, inbox independence, projection updates, and restored-session behavior. The keyless process test inspects the persisted `goal/change` event and verifies that creation alone starts no continuation round. +Focused goal, goal-round-driver, command, TUI, and Client RemoteMock tests pin durable replay, positive-round accounting, inbox independence, projection updates, and restored-session behavior. The keyless process test inspects the persisted `goal/change` event and verifies that creation alone starts no continuation round. diff --git a/.agents/notes/implemented/architecture/2026-07-31-goal-owned-durable-events.zh.md b/.agents/notes/implemented/architecture/2026-07-31-goal-owned-durable-events.zh.md index 8ee0099209..fd29a6f36e 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-goal-owned-durable-events.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-31-goal-owned-durable-events.zh.md @@ -30,4 +30,4 @@ Goal 领域需要持久状态,但不需要拥有待处理的模型输入。继 Goal 状态不依赖 inbox 放置与准入。回放只有一条变更路径,投影直接由 `goal/change` 推进,继续执行消息只携带 Round 归属。模型不会收到仅用于变更的 `` 消息;模型可见状态来自 goal 工具与已调度的继续执行提示词。直接写入会话的写入方仍受信任,并且可以追加畸形变更;严格折叠与 invariant 配套模块会拒绝这些变更。 -聚焦的 goal、goal-round-driver、command、TUI 与 client fixture(测试前置数据)测试固定持久回放、正数 Round 计数、inbox 独立性、投影更新和恢复会话行为。无密钥进程测试检查持久的 `goal/change` 事件,并验证仅创建 goal 不会启动继续执行 Round。 +聚焦的 goal、goal-round-driver、command、TUI 与 Client RemoteMock 测试固定持久回放、正数 Round 计数、inbox 独立性、投影更新和恢复会话行为。无密钥进程测试检查持久的 `goal/change` 事件,并验证仅创建 goal 不会启动继续执行 Round。 diff --git a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml index 0e53dfc6d6..fff8e98bc5 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md -2026-08-23-client-derived-tool-presentation.md: 895618099c6669058d23fc0a58335a90d5fbe4ca -2026-08-23-client-derived-tool-presentation.zh.md: 38572f17c41ea9b68dc8c8c8cdc26a74d9dbb757 +2026-08-23-client-derived-tool-presentation.md: 703960cb24f90eb678fda381f929efb3f94bbd28 +2026-08-23-client-derived-tool-presentation.zh.md: 7f161379553f3aca33c72c2edf93e114d27eb7f9 diff --git a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md index 895618099c..703960cb24 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md +++ b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md @@ -387,7 +387,7 @@ This change does not add a general tool-side-effect registry. The ability for a ## Fixtures and Test Data -The Client fixture deletes its handwritten `presentCall()`, `presentResult()`, `viewFor()`, and fixture tool-view types. It continues producing the same raw calls, result content, and result metadata as a real log. +The assembled RemoteMock scenario contains no handwritten `presentCall()`, `presentResult()`, `viewFor()`, or tool-view types. It supplies the same raw calls, result content, and result metadata as a real log. | Fixture | Raw facts that must remain | |---|---| @@ -398,7 +398,7 @@ The Client fixture deletes its handwritten `presentCall()`, `presentResult()`, ` | web | result metadata sources/answer or url/statusCode/truncated | | generic/custom | name, argsRaw, content, and error | -The fixture does not import Host tool packages to compute page presentation and retains no presenter mirror. The same raw fixture continues to drive jsdom, built Web snapshots, and the `?fixture` browser path. +The scenario does not import Host tool packages to compute page presentation and retains no presenter mirror. The same raw scenario drives built Web snapshots under jsdom; real-Host browser cases independently cover the network path. ## Presentation-Equivalence Matrix @@ -566,7 +566,7 @@ Changes to this decision use `dsh-pre-push-checks` to select commands for the fi - ui-chat and ui-trajectory Tool Definition tests; - ui-tool terminal, diff, read, search, web, row, tree, and details tests; - ui-deliverables produced-file tests; -- connection fixture and Client runtime tests; +- assembled RemoteMock and Client runtime tests; - affected Host and Client TypeScript faces; - lint and duplication; - per-file 100% coverage for affected source files; diff --git a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md index 38572f17c4..7f16137955 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md @@ -387,7 +387,7 @@ Deliverables Definition 按 callId 观察 root `tool/call` 与成功 `tool/resul ## Fixture 与测试数据 -Client fixture 删除手写 `presentCall()`、`presentResult()`、`viewFor()` 与 fixture tool-view 类型。它继续产生与真实日志相同的 raw call、result content 和 result meta。 +组装 RemoteMock 场景不包含手写 `presentCall()`、`presentResult()`、`viewFor()` 或 tool-view 类型。它提供与真实日志相同的 raw call、result content 和 result meta。 | Fixture | 必须保留的原始事实 | |---|---| @@ -398,7 +398,7 @@ Client fixture 删除手写 `presentCall()`、`presentResult()`、`viewFor()` | web | result meta 的 sources/answer 或 url/statusCode/truncated | | generic/custom | name、argsRaw、content、error | -fixture 不导入 Host 工具包来计算页面展示,也不保留 presenter 镜像。同一 raw fixture 继续驱动 jsdom、built Web snapshot 与 `?fixture` 浏览器路径。 +该场景不导入 Host 工具包来计算页面展示,也不保留 presenter 镜像。同一 raw 场景在 jsdom 下驱动 built Web snapshot;真实 Host 浏览器用例独立覆盖网络路径。 ## 展示等价矩阵 @@ -566,7 +566,7 @@ Host registry 允许不同 scope 为同一 tool name 提供不同定义;Sessio - ui-chat 与 ui-trajectory Tool Definition 测试; - ui-tool terminal、diff、read、search、web、row、tree 与 details 测试; - ui-deliverables produced-files 测试; -- connection fixture 与 Client runtime 测试; +- 组装 RemoteMock 与 Client runtime 测试; - 受影响 Host/Client TypeScript face; - lint 与 duplication; - 受影响源文件 per-file 100% coverage; diff --git a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml index 2bfae42d7b..8d3f060672 100644 --- a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-20-gui-testing-system.md -2026-07-20-gui-testing-system.md: fa38b6e57fc0ff48f2604b000f6b9131897f2e4c -2026-07-20-gui-testing-system.zh.md: 06ff9b81aad0d13abf51b2db093670d5e0ca57fb +2026-07-20-gui-testing-system.md: 3fdb9a596dd18861ea8b765da129656942778f96 +2026-07-20-gui-testing-system.zh.md: e49d5a2f848e7f4847c7761ee0a487ce6fd3220e diff --git a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.md b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.md index fa38b6e57f..3fdb9a596d 100644 --- a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.md +++ b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.md @@ -20,12 +20,12 @@ Cut along the architecture's natural test hooks into three tiers, bottom-up: |---|---|---|---| | 1 Protocol isomorphism | Generated Typert Remote descriptors + `ApiGateway` + the Connection RPC carrier (arguments / results / errors / streams / cancellation) | **The full chain at the isomorphic point**: gateway host/client suites validate descriptor codecs and Remote dispatch in process; Connection host suites exercise the same `/api` carrier framing and trust checks without a browser | `packages/api/gateway/tests/`, `packages/client/connection/tests/` | | 2 Object-layer orchestration | `Session`/`SessionManager`/`ConnectionController` (state machines and timing: stitching / dedup / paging / optimistic draft clearing / pendingBuffers / reconnect / backoff) | **The "event sequence in → snapshot out" golden path**: programmable fakes + deferreds controlling timing + fake timers controlling backoff | `packages/client/{runtime,connection}/tests/` | -| 3 Assembled presentation | Built artifacts × the real client loader and plugin composition | App-owned semantic snapshots boot all eight built client plugins under jsdom for deterministic cross-plugin state changes; bare Playwright smoke separately proves the real browser/carrier boundary, with real-host cases self-skipping without a key; the keyless browser e2e lane disables the shipped model-adapter row and replays recorded session fixtures through `dsh-llm-replay` in the real in-process web assembly against conversation aria goldens ([web e2e lane](../testing/2026-07-24-web-gui-browser-e2e-lane.md), [required CI gate](../testing/2026-07-30-web-browser-snapshot-ci-gate.md)) | `apps/web/tests/*.snapshot.ts`, `apps/web/tests/smoke-{fixture,real}.e2e.ts`, `apps/web/tests/{replay-round-trip,seeded-history}.e2e.ts` | +| 3 Assembled presentation | Built artifacts × the real client loader and plugin composition | App-owned semantic snapshots boot the built client graph under jsdom with a test-owned `RemoteMock`; Playwright cases separately exercise the real browser and Host carriers, with recorded model sessions replayed through `dsh-llm-replay` ([whole-client tier](../testing/2026-09-06-client-assembly-test-line.md), [web e2e lane](../testing/2026-07-24-web-gui-browser-e2e-lane.md)) | `apps/web/tests/*.expected.e2e.ts`, `apps/web/tests/*.e2e.ts`, `apps/web/tests/*.snapshot.ts` | Inter-tier discipline: **each tier tests its own layer, upper tiers never re-test lower ones** — an app semantic snapshot pins only user-visible projection across the assembled plugin boundary, while Playwright smoke proves browser and carrier liveness; wire semantics belong to tier 1 and data semantics to tier 2. Pure-function layers (lineage/partial/notifier/transcript-adapter) are tested directly with zero fakes in the same package's tests/ alongside tier 2. - **Host and client source** are under the repo-wide per-file 100% coverage gate except the narrow browser-grade exclusions annotated in `vitest.config.ts`; component suites use per-file jsdom pragmas and Testing Library without changing Node suites. -- **App-owned semantic snapshots** read built client bundles, execute them through the real loader, and drive only deterministic fixture hooks. They own stable visible state such as sidebar labels, breadcrumbs, and `document.title`, not CSS pixels or lower-layer state-machine details. +- **App-owned semantic snapshots** read built client bundles, execute them through the real loader, and drive deterministic RemoteMock scenarios. They own stable visible state such as sidebar labels, breadcrumbs, and `document.title`, not CSS pixels or lower-layer state-machine details. ## Lane map @@ -33,7 +33,7 @@ Inter-tier discipline: **each tier tests its own layer, upper tiers never re-tes |---|---|---|---| | Baseline | `pnpm run test:gui` | Tier 1+2 vitest (`packages/client packages/host`), seconds-fast, no browser, no server | Casually, after touching any GUI source | | Semantic snapshot | `DSH_EXAMPLE_MODE=lib pnpm run test:snapshot` | Keyless assembled-application semantics plus the repo's transport-specific expected outputs | After a human-visible GUI change; before delivery | -| Browser end-to-end | `pnpm run test:web` | Rebuilds the front-end dist first, then runs the tier-3 browser set: the two-level smoke (fixture level + real-host level self-skip) plus the keyless replayed e2e scenarios (`DSH_SNAPSHOT=record`/`refresh` re-record fixtures / rewrite goldens) | After touching the build surface/boot/carriage; before delivery | +| Browser end-to-end | `pnpm run test:web` | Rebuilds the front-end dist first, then runs built-client RemoteMock cases and real-Host browser scenarios, including keyless recorded-session replay (`DSH_SNAPSHOT=record`/`refresh` re-record fixtures / rewrite goldens) | After touching the build surface/boot/carriage; before delivery | | Browser expected-output gate | `DSH_SNAPSHOT=replay pnpm run test:web:built` | Reuses CI-built artifacts and compares every committed browser golden without writing | Every Linux pull request | | Gate | `pnpm run test:coverage` | The repo-wide gate (host and client GUI packages included, except annotated browser-grade exclusions) | The PR window | @@ -42,7 +42,7 @@ Inter-tier discipline: **each tier tests its own layer, upper tiers never re-tes ## Anti-regression discipline - **Every bug fix pins an assertion**: a browser-visible bug is pinned into its owning browser spec (smoke or e2e scenario); a data-layer bug is pinned into the matching spec (precedent: the res-close misjudgment pinned in the webserver bridge suite — pure Node, reproduces in seconds, no longer needs the 12s browser sentinel as the only defense). -- **All-green on fixture is not done, the real wire must pass too**: what the fixture short-circuits is exactly the wire carriage chain (node:http bridge close semantics, real network timing); both empirically confirmed bugs hid there. Changes touching connection/bridge/handler/SSE must run the browser lane (`pnpm run test:web`) — its keyless e2e scenarios drive the real HTTP/SSE carriage, and the with-key real-host smoke remains the live-model complement. +- **All-green on RemoteMock is not done, the real wire must pass too**: a decoded in-process carrier deliberately omits the HTTP/WebSocket chain and its network timing. Changes touching connection, bridge, handler, or streaming transport must run the browser lane (`pnpm run test:web`), whose keyless e2e scenarios drive the real carrier; the with-key real-Host smoke remains the live-model complement. - The code-on-disk-is-the-answer reconciliation workflow: when a behavior change lands and turns existing cases red, reconcile on the spot (fix the test or fix the code, with the RFC/contract as arbiter); no red left hanging. ## Consequences @@ -55,6 +55,6 @@ Each lane tests its own tier: touching any GUI source gets seconds-fast `test:gu |---|---| | Single e2e (everything through the browser) | Browser startup is seconds × N slower and timing is uncontrollable; wire/object-layer invariants can be fully asserted in milliseconds in node env | | Migrating the verify scripts to vitest | An ordered script shares one browser session; splitting the cases either formalizes it (sequential + shared page) or re-runs the preamble × N; streaming PASS/FAIL output is exactly the agent's locating interface | -| Reusing FixtureApiClient in tests | The demo script runs on a real clock, tests need deferred hand-controlled timing — orthogonal purposes; forced reuse chains the tests to the demo's rhythm | +| Keeping a production client fixture for tests | It couples shipped code to scenario data and a query-selected transport; RemoteMock and real-Host tests own the two required tiers directly | | A standalone vitest config for GUI packages (once designed as vitest.gui.config.ts) | Package-level tests/ are already scanned by the root include; `vitest run packages/client packages/host` path filtering is the tight loop — zero new config | | Deferring hooks/component-layer unit tests | jsdom remains the coverage mainline because it gives fast per-file component behavior; the required browser replay gate complements it at the assembled tier rather than replacing it ([CI gate decision](../testing/2026-07-30-web-browser-snapshot-ci-gate.md)) | diff --git a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md index 06ff9b81aa..e49d5a2f84 100644 --- a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md +++ b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md @@ -20,12 +20,12 @@ GUI 栈需要考虑多种应用形态,同应用形态内的不同运行环境 |---|---|---|---| | 1 协议同构层 | 生成的 Typert Remote 描述符 + `ApiGateway` + Connection RPC 承载(参数/结果/错误/流/取消) | **同构点全链**:gateway host/client 套件在进程内验证描述符 codec 与 Remote 分派;Connection host 套件不经浏览器即可运行相同的 `/api` 承载帧与信任检查 | `packages/api/gateway/tests/`、`packages/client/connection/tests/` | | 2 对象层编排 | `Session`/`SessionManager`/`ConnectionController`(状态机与时序:缝合/去重/翻页/乐观清稿/pendingBuffers/重连/退避) | **「事件序列进→快照出」黄金路径**:可编程假体 + deferred 控时序 + fake timers 控退避 | `packages/client/{runtime,connection}/tests/` | -| 3 组装呈现层 | 构建产物 × 真实 client loader 与插件组合 | 归应用所有的语义快照会在 jsdom 下启动全部 8 个已构建的 client 插件,以确定性方式驱动跨插件状态变化;另有最简 Playwright 冒烟测试负责验证真实浏览器/承载层边界,真 host 用例在无密钥时自行跳过;无密钥浏览器 e2e 车道会禁用交付配置中的模型适配器行,并通过 `dsh-llm-replay` 在真实进程内 web 组装中回放录制的会话 fixture(测试前置数据),与会话区 aria 预期输出比对([web e2e 车道](../testing/2026-07-24-web-gui-browser-e2e-lane.zh.md)、[必需 CI 门禁](../testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md)) | `apps/web/tests/*.snapshot.ts`、`apps/web/tests/smoke-{fixture,real}.e2e.ts`、`apps/web/tests/{replay-round-trip,seeded-history}.e2e.ts` | +| 3 组装呈现层 | 构建产物 × 真实 client loader 与插件组合 | 归应用所有的语义快照在 jsdom 下用测试持有的 `RemoteMock` 启动构建后 Client 图;Playwright 用例分别验证真实浏览器与 Host 载体,并通过 `dsh-llm-replay` 回放已录制的模型会话([整机客户端测试档](../testing/2026-09-06-client-assembly-test-line.zh.md)、[web e2e 车道](../testing/2026-07-24-web-gui-browser-e2e-lane.zh.md)) | `apps/web/tests/*.expected.e2e.ts`、`apps/web/tests/*.e2e.ts`、`apps/web/tests/*.snapshot.ts` | 层间纪律:**各层各测各的,上层不重测下层**:应用语义快照只固定组装后插件边界上的用户可见投影,Playwright 冒烟测试负责验证浏览器与承载层是否存活;wire 语义归 1 层,数据语义归 2 层。纯函数层(lineage/partial/notifier/transcript-adapter)随 2 层同包 tests/ 零假体直测。 - **host 与 client 源码**均纳入全仓 per-file 100% 覆盖率门禁,仅排除 `vitest.config.ts` 中带注释的少量浏览器级例外;组件套件通过逐文件 jsdom pragma 和 Testing Library 运行,不会改变 Node 套件。 -- **归应用所有的语义快照**读取已构建的 client bundle,通过真实 loader 执行它们,并且只驱动确定性的 fixture 钩子。它们负责固定侧边栏标签、面包屑和 `document.title` 等稳定可见状态,而不固定 CSS 像素或下层状态机细节。 +- **归应用所有的语义快照**读取已构建的 client bundle,通过真实 loader 执行它们,并驱动确定性的 RemoteMock 场景。它们负责固定侧边栏标签、面包屑和 `document.title` 等稳定可见状态,而不固定 CSS 像素或下层状态机细节。 ## 车道地图 @@ -33,7 +33,7 @@ GUI 栈需要考虑多种应用形态,同应用形态内的不同运行环境 |---|---|---|---| | 基础 | `pnpm run test:gui` | 1+2 层 vitest(`packages/client packages/host`),秒级、无浏览器、无 server | 改 GUI 任意源码后随手跑 | | 语义快照 | `DSH_EXAMPLE_MODE=lib pnpm run test:snapshot` | 无需密钥的组装应用语义,以及仓库按传输形态划分的预期输出 | 用户可见的 GUI 变更后;交付前 | -| 浏览器端到端 | `pnpm run test:web` | 先重建前端 dist,再跑 3 层浏览器全集:双级冒烟测试(fixture 级 + 真 host 级 self-skip)加上无密钥回放 e2e 场景(`DSH_SNAPSHOT=record`/`refresh` 重录 fixture / 重写期望输出) | 改构建面/boot/承载后;交付前 | +| 浏览器端到端 | `pnpm run test:web` | 先重建前端 dist,再运行 built-client RemoteMock 用例与真实 Host 浏览器场景,其中包括无密钥的录制会话回放(`DSH_SNAPSHOT=record`/`refresh` 重录 fixture/重写预期输出) | 改构建面/boot/承载后;交付前 | | 浏览器预期输出门禁 | `DSH_SNAPSHOT=replay pnpm run test:web:built` | 复用 CI 构建的产物,并在不写入的情况下比较每份已提交的浏览器预期输出 | 每个 Linux 拉取请求 | | 门禁 | `pnpm run test:coverage` | 全仓门禁(host 与 client GUI 包均纳入,仅排除带注释的浏览器级例外) | PR(Pull Request)窗口 | @@ -42,7 +42,7 @@ GUI 栈需要考虑多种应用形态,同应用形态内的不同运行环境 ## 防回归纪律 - **修一个 bug 钉一条断言**:浏览器可见的 bug 钉进所属浏览器 spec(冒烟测试或 e2e 场景);数据层 bug 钉进对应 spec(先例:res-close 误判钉在 webserver 桥 suite——纯 Node 秒级复现,不再需要 12s 浏览器哨兵作唯一防线)。 -- **fixture 全绿不算完,真 wire 也要过**:fixture 短路的恰是 wire 承载链(node:http 桥 close 语义、真网络时序),两次实证 bug 都藏在那里。改动触及连接/桥/handler/SSE 的,浏览器车道(`pnpm run test:web`)必跑——其无密钥 e2e 场景驱动真实 HTTP/SSE 承载,带密钥的真 host 冒烟测试仍是真模型侧的补充。 +- **RemoteMock 全绿不算完,真 wire 也要过**:已解码的进程内载体会刻意绕过 HTTP/WebSocket 链及其网络时序。改动触及 connection、bridge、handler 或流式 transport 时必须运行浏览器车道(`pnpm run test:web`),由其中的无密钥 e2e 场景驱动真实载体;带密钥的真实 Host 冒烟仍是真模型侧的补充。 - 落盘代码即答案的对表工作流:行为改动落盘打红既有用例时,当场对表校准(改测试还是改代码以 RFC/约定为裁),不留悬红。 ## Consequences @@ -55,6 +55,6 @@ GUI 栈需要考虑多种应用形态,同应用形态内的不同运行环境 |---|---| | 单一 e2e(全走浏览器) | 浏览器起步秒级×N 倍慢+时序不可控;wire/对象层不变量在 node env 可毫秒级全断言 | | verify 脚本迁 vitest | 有序脚本共享浏览器会话,拆 case 要么形式化(sequential+共享 page)要么重走前置×N;PASS/FAIL 流式输出正是 agent(智能体)定位接口 | -| 测试复用 FixtureApiClient | 演示脚本走真实时钟,测试需要 deferred 手控时序——用途正交,硬复用把测试绑死在演示节奏上 | +| 为测试保留生产 Client fixture | 它把交付代码耦合到场景数据和 query 选择的 transport;RemoteMock 与真实 Host 测试分别直接持有两个所需层级 | | GUI 包独立 vitest config(曾设计 vitest.gui.config.ts) | 包级 tests/ 本就被根 include 扫到,`vitest run packages/client packages/host` 路径过滤即窄循环——零新 config | | 钩子/组件层暂缓单测 | jsdom 仍是覆盖率主线,因为它能快速验证逐文件组件行为;必需的浏览器回放门禁在组装层与之互补,而非取代它([CI 门禁决策](../testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md)) | diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml index 5e7ef2db63..dbe621f214 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md -2026-07-24-web-gui-browser-e2e-lane.md: 8276ed1982a7472ba1b825837a933058c32bf840 -2026-07-24-web-gui-browser-e2e-lane.zh.md: 3a7e5ea5547cf5584a2ad3f61b90c91326379de5 +2026-07-24-web-gui-browser-e2e-lane.md: b08d99a7d750bdeabac3dcbb7007e922d32dbdcd +2026-07-24-web-gui-browser-e2e-lane.zh.md: fbb92a094bbd14042d33e5ebd20d2d3ffd490d43 diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md index 8276ed1982..b08d99a7d7 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md @@ -6,7 +6,7 @@ English | [中文](2026-07-24-web-gui-browser-e2e-lane.zh.md) ## Problem -The web GUI ships as a real assembled chain — chromium page → client plugin bundles → HTTP unary RPC + two SSE streams → `toFetchHandler`/apiproxy → the host agent loop, tools, and JSONL persistence — and no test exercised that chain keylessly and deterministically. The [GUI testing system](../process/2026-07-20-gui-testing-system.md) covers tier 1 (wire isomorphism in node), tier 2 (object-layer state machines), and tier-3 smokes, but the keyless smoke drives `FixtureApiClient` — no host, no wire, no agent loop — while the full-chain smoke needs `DEEPSEEK_API_KEY` and a live model, so it is nondeterministic and self-skips in keyless CI. The snapshot philosophy of [docs/testing.md](../../../../docs/testing.md) — record once with a key, replay forever keyless, refresh on format churn — already covers the ACP, headless `stream-json`, and TUI transcript surfaces; the web surface was the one assembled product shape without it. The gap is exactly where the two confirmed GUI P0s hid: the wire carriage chain the fixture client short-circuits. +The web GUI ships as a real assembled chain — chromium page → client plugin bundles → HTTP unary RPC + two SSE streams → `toFetchHandler`/apiproxy → the host agent loop, tools, and JSONL persistence — and no test exercised that chain keylessly and deterministically. The [GUI testing system](../process/2026-07-20-gui-testing-system.md) covers tier 1 (wire isomorphism in node), tier 2 (object-layer state machines), and tier-3 smokes, but the former keyless smoke drove a standalone client fixture — no host, no wire, no agent loop — while the full-chain smoke needed `DEEPSEEK_API_KEY` and a live model, so it was nondeterministic and self-skipped in keyless CI. The snapshot philosophy of [docs/testing.md](../../../../docs/testing.md) — record once with a key, replay forever keyless, refresh on format churn — already covered the ACP, headless `stream-json`, and TUI transcript surfaces; the web surface was the one assembled product shape without it. The gap is exactly where the two confirmed GUI P0s hid: the wire carriage chain the standalone fixture short-circuited. ## Decision @@ -64,7 +64,7 @@ Surveyed AI-chat/agent web UIs and mocking layers (LibreChat, vercel/ai-chatbot **Mock HTTP provider at `DEEPSEEK_BASE_URL`.** Rejected as the lane's mechanism (kept for the one existing workspace-probe smoke): fixtures become hand-authored OpenAI SSE byte scripts, a second fixture format that drifts from the session-log format the rest of the repo records and replays; the adapter's real HTTP path is with-key e2e's job. -**Growing the `?fixture` client.** Rejected: tier separation — `FixtureApiClient` exists to test the client shell without a server; everything below the client API boundary stays untested by construction. +**Keeping or growing a `?fixture` client.** Rejected: client-only assembly tests inject `RemoteMock`, while behavior that depends on a Host runs through the real scaffold. A production query switch would retain a second application backend and still leave everything below the Client API untested. **Placeholder `DEEPSEEK_API_KEY` + replay interception instead of disabling the adapter row.** Rejected despite zero composition change and two in-tree precedents: it satisfies `llm-deepseek`'s fail-loud key check with a lie and leaves a dead adapter mounted-but-intercepted; the disabled row (the ACP overlay's move) is honest keylessness and fails loud at the earliest resolvable point. diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md index 3a7e5ea554..fbb92a094b 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bundle → HTTP 单次 RPC + 两条 SSE(Server-Sent Events)流 → `toFetchHandler`/apiproxy → host 端的 agent loop(智能体循环)、工具与 JSONL 持久化——却没有任何测试无密钥且确定性地检验这条链。[GUI 测试体系](../process/2026-07-20-gui-testing-system.zh.md)覆盖第 1 层(Node 中的协议同构)、第 2 层(对象层状态机)与第 3 层冒烟测试,但无密钥冒烟驱动的是 `FixtureApiClient`——没有 host、没有 wire、没有 agent loop——而全链路冒烟需要 `DEEPSEEK_API_KEY` 和真实模型,因此不确定、在无密钥 CI 中自行跳过。[docs/testing.md](../../../../docs/testing.zh.md) 的快照哲学——带密钥录制一次、永久无密钥回放、格式变动时刷新——已覆盖 ACP(Agent Client Protocol)、headless `stream-json` 与 TUI 三个 transcript(文本记录)表面;web 表面是唯一没有这层保障的组装形态。而缺口恰恰是两起已实证 GUI P0 藏身之处:fixture(测试前置数据)客户端短路掉的 wire 承载链。 +Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bundle → HTTP 单次 RPC + 两条 SSE(Server-Sent Events)流 → `toFetchHandler`/apiproxy → host 端的 agent loop(智能体循环)、工具与 JSONL 持久化——却没有任何测试无密钥且确定性地检验这条链。[GUI 测试体系](../process/2026-07-20-gui-testing-system.zh.md)覆盖第 1 层(Node 中的协议同构)、第 2 层(对象层状态机)与第 3 层冒烟测试,但原有无密钥冒烟驱动的是独立 Client fixture——没有 host、没有 wire、没有 agent loop——而全链路冒烟需要 `DEEPSEEK_API_KEY` 和真实模型,因此不确定、在无密钥 CI 中自行跳过。[docs/testing.md](../../../../docs/testing.zh.md) 的快照哲学——带密钥录制一次、永久无密钥回放、格式变动时刷新——已覆盖 ACP(Agent Client Protocol)、headless `stream-json` 与 TUI 三个 transcript(文本记录)表面;web 表面是唯一没有这层保障的组装形态。而缺口恰恰是两起已实证 GUI P0 藏身之处:独立 fixture 短路掉的 wire 承载链。 ## 决策 @@ -64,7 +64,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu **`DEEPSEEK_BASE_URL` 处的 mock HTTP 提供方。** 作为本车道机制已否决(仅保留给既有的工作区探针冒烟):fixture 会变成手写的 OpenAI SSE 字节脚本,一种与仓库其余部分录制回放的会话日志格式渐行渐远的第二 fixture 格式;适配器的真实 HTTP 路径归带密钥 e2e 管。 -**扩展 `?fixture` 客户端。** 已否决:分层纪律——`FixtureApiClient` 的存在意义就是脱离服务器测试客户端 shell;client API 边界以下按构造即失测。 +**保留或扩展 `?fixture` 客户端。** 已否决:仅客户端的组装测试注入 `RemoteMock`,依赖 Host 行为的测试则运行真实 scaffold。生产 query 开关会保留第二套应用后端,而且 Client API 以下仍然按构造失测。 **用占位 `DEEPSEEK_API_KEY` + 回放拦截替代禁用适配器行。** 尽管零组合改动且树内有两处先例仍被否决:它用谎言满足 `llm-deepseek` 的快速失败密钥检查,还留下一个挂载却被拦截的死适配器;禁用行(ACP overlay 的同款做法)是诚实的无密钥,并在最早可解析点快速失败。 diff --git a/.agents/notes/implemented/testing/2026-08-03-opt-in-reasoning-chunk-browser-stress.i18n.yaml b/.agents/notes/implemented/testing/2026-08-03-opt-in-reasoning-chunk-browser-stress.i18n.yaml index c345ae674c..10cf18647b 100644 --- a/.agents/notes/implemented/testing/2026-08-03-opt-in-reasoning-chunk-browser-stress.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-08-03-opt-in-reasoning-chunk-browser-stress.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-08-03-opt-in-reasoning-chunk-browser-stress.md -2026-08-03-opt-in-reasoning-chunk-browser-stress.md: bc5d31d80e5fb8aebcd9988dcb1f051d113cbf63 -2026-08-03-opt-in-reasoning-chunk-browser-stress.zh.md: 105c8d69c37a18fd18aed3cc9f6f716e38b952d7 +2026-08-03-opt-in-reasoning-chunk-browser-stress.md: 21d92fa329549b1369ced6b7d92f8099fbe8e7ec +2026-08-03-opt-in-reasoning-chunk-browser-stress.zh.md: f7345543e84fa2a4bc2ff57cd8157e303e79747c diff --git a/.agents/notes/implemented/testing/2026-08-03-opt-in-reasoning-chunk-browser-stress.md b/.agents/notes/implemented/testing/2026-08-03-opt-in-reasoning-chunk-browser-stress.md index bc5d31d80e..21d92fa329 100644 --- a/.agents/notes/implemented/testing/2026-08-03-opt-in-reasoning-chunk-browser-stress.md +++ b/.agents/notes/implemented/testing/2026-08-03-opt-in-reasoning-chunk-browser-stress.md @@ -8,7 +8,7 @@ English | [中文](2026-08-03-opt-in-reasoning-chunk-browser-stress.zh.md) Long reasoning streams continuously produce large numbers of process-local `assistant/live-chunk` updates before one durable settlement. Each update must remain ordered and be folded into the Assistant Definition to preserve live completeness, while the settlement embeds the exact stream for replay; React, however, needs only the current accumulated result, not every intermediate state within one browser frame. -Each `yield` in an async stream can create a new microtask boundary, so `Notifier.markDirty()` backed only by microtask batching degrades into rebuilding a `ConversationSnapshot`, notifying `useSyncExternalStore`, and running a React render for every chunk. Even with the live Think row collapsed, 100,000 reasoning chunks can overwhelm the main thread with reconciliation, commit, and layout work. The performance boundary must sit between session ingestion and React publication; it cannot hide the problem by slowing the producer or discarding raw events. +Each `yield` in an async stream can create a new microtask boundary, so `Notifier.markDirty()` backed only by microtask batching degrades into rebuilding a `ConversationSnapshot`, notifying `useSyncExternalStore`, and running a React render for every chunk. Even with the live Think row collapsed, 100,000 reasoning chunks can overwhelm the main thread with reconciliation, commit, and layout work. The performance boundary must sit between session ingestion and React publication; the test must preserve every raw event and distinguish one-batch rendering cost from an unbounded transport backlog. ## Decision @@ -18,9 +18,9 @@ The Session Controller appends every Client-only live chunk to its event source, Keeping the live Think row horizontally pinned to the end of the accumulated text is purely visual alignment and does not require synchronous layout reads on every React commit. An in-component scheduler coalesces consecutive requests into one update every three frames, reads `scrollWidth` and `clientWidth` from the latest DOM, and updates `scrollLeft` directly to the latest position; the fixed visual cadence keeps summary changes readable without allowing browser smooth-scroll animations to accumulate. This throttling applies only to Think's horizontal summary and does not delay Chat body scrolling, history-prepend anchoring, or user-triggered `scrollIntoView`. -`pnpm run test:web:stress` remains keyless, opt-in browser performance evidence. The deterministic `?fixture` session emits 100,000 `reasoning-delta` events at a cadence independent of painting, and a terminal marker proves that the events cross production session reduction and reach the live Think row; a 50-millisecond heartbeat and a pre-scheduled DOM event measure main-thread stalls and interaction latency, respectively, with a 250-millisecond budget for identifying clear regressions. `DSH_WEB_STRESS_HEADFUL=1` lets developers profile the same scenario in a visible browser with the Performance panel. The stress lane is evidence for manual performance diagnosis and fix acceptance, not a default CI gate or a substitute for deterministic scheduling unit tests. +`pnpm run test:web:stress` remains keyless, opt-in browser performance evidence. A test-owned model adapter emits 100,000 `reasoning-delta` chunks through the real Host, Gateway, WebSocket, Session reduction, and live Think row. Each 128-chunk batch waits for one browser timer turn before the next batch, so the 250-millisecond heartbeat and pre-scheduled DOM event measure one bounded ingestion/render interval instead of accumulated socket backlog. A terminal marker proves that all chunks reached the UI. `DSH_WEB_STRESS_HEADFUL=1` lets developers profile the same scenario in a visible browser with the Performance panel. The stress lane is evidence for manual performance diagnosis and fix acceptance, not a default CI gate or a substitute for deterministic scheduling unit tests. -Focused tests pin `Notifier`'s per-frame coalescing, structural-event preemption, invalidated callbacks, and no-rAF fallback, and prove at the `Session` layer that a frame publishes the latest accumulated text only once and that finalization is not followed by a duplicate notification from a stale frame callback. Small fixture unit tests continue to pin input validation, external arrival pacing, concurrency rejection, exact event count, and terminal-marker delivery without bringing the 100,000-chunk workload into the default test suites. +Focused tests pin `Notifier`'s per-frame coalescing, structural-event preemption, invalidated callbacks, and no-rAF fallback, and prove at the `Session` layer that a frame publishes the latest accumulated text only once and that finalization is not followed by a duplicate notification from a stale frame callback. The opt-in browser case owns adapter pacing, exact event count, and terminal-marker delivery without bringing the 100,000-chunk workload into the default test suites. ## Alternatives considered @@ -30,9 +30,9 @@ Focused tests pin `Notifier`'s per-frame coalescing, structural-event preemption **Microtask batching alone.** Rejected: consecutive asynchronous `yield` operations can drain the microtask queue between adjacent chunks, making microtask batching approximate one notification per chunk. -**Pacing the test producer by animation frames.** Rejected: the producer would slow whenever rendering slowed, giving the page implicit backpressure absent from a real network stream and masking main-thread starvation. +**Sending the former browser-local rate through the WebSocket without an acknowledgment.** Rejected: queued transport work accumulates faster than a model can produce it and turns the result into a socket-backlog measurement. A browser timer acknowledgment bounds each batch while the heartbeat still detects a batch that starves the page. -**A live model or recorded HTTP byte stream.** Rejected: live models are nondeterministic, and an HTTP/SSE recording would not improve the target assertion. The in-memory fixture preserves individual asynchronous session events, production client reduction, and the React rendering path while controlling the workload and arrival cadence. +**A live external model or recorded HTTP byte stream.** Rejected: an external model is nondeterministic, and an HTTP recording would add a second fixture format without improving the target assertion. The test-owned adapter preserves individual chunks through the real Host and browser transport while controlling the workload. ## Consequences @@ -40,4 +40,4 @@ The publication rate of streaming `ConversationSnapshot` objects is bounded by t Horizontal layout reads and writes for the collapsed Think summary run at most once every three frames, and each update moves the summary directly to the latest position; React still commits accumulated snapshots normally, and the summary returns to the first line at finalization. This local visual policy does not change the immediacy of body scrolling or user interactions. -The browser stress lane continues to provide a responsiveness signal from the real assembled application and an entry point for visible profiling, but hardware and scheduling differences make it suitable only as explicit performance evidence. Deterministic focused tests guard publication counts, accumulated content, and preemption order, while the default test lanes remain fast. +The browser stress lane continues to provide a responsiveness signal from the real assembled application and carrier plus an entry point for visible profiling, but hardware and scheduling differences make it suitable only as explicit performance evidence. Deterministic focused tests guard publication counts, accumulated content, and preemption order, while the default test lanes remain fast. diff --git a/.agents/notes/implemented/testing/2026-08-03-opt-in-reasoning-chunk-browser-stress.zh.md b/.agents/notes/implemented/testing/2026-08-03-opt-in-reasoning-chunk-browser-stress.zh.md index 105c8d69c3..f7345543e8 100644 --- a/.agents/notes/implemented/testing/2026-08-03-opt-in-reasoning-chunk-browser-stress.zh.md +++ b/.agents/notes/implemented/testing/2026-08-03-opt-in-reasoning-chunk-browser-stress.zh.md @@ -8,7 +8,7 @@ Status: implemented 长 reasoning stream 会在一个持久 settlement 前连续产生大量进程本地 `assistant/live-chunk` update。每个 update 都必须保持有序并折叠进 Assistant Definition,以保留实时完整性;settlement 则嵌入精确 stream 供 replay。React 只需要看到当前累计结果,不需要观察同一浏览器帧内每个中间态。 -异步流的每次 `yield` 都可能形成新的微任务边界,因此仅靠微任务合批的 `Notifier.markDirty()` 会退化为每个分片重建一次 `ConversationSnapshot`、通知一次 `useSyncExternalStore` 并运行一次 React render。即使实时 Think 行保持折叠,100,000 个推理分片仍会让协调、提交和布局工作压住主线程。性能边界必须位于会话接收与 React 发布之间,不能通过减慢生产方或丢弃原始事件来掩盖问题。 +异步流的每次 `yield` 都可能形成新的微任务边界,因此仅靠微任务合批的 `Notifier.markDirty()` 会退化为每个分片重建一次 `ConversationSnapshot`、通知一次 `useSyncExternalStore` 并运行一次 React render。即使实时 Think 行保持折叠,100,000 个推理分片仍会让协调、提交和布局工作压住主线程。性能边界必须位于会话接收与 React 发布之间;测试必须保留每个原始事件,并区分单批渲染成本与无限累积的 transport backlog。 ## 决策 @@ -18,9 +18,9 @@ Session Controller 把每个 Client-only live chunk 追加到 event source,Con 实时 Think 行对累计文本的横向跟尾属于纯视觉对齐,不需要在每次 React 提交中同步读取布局。组件内调度器将连续请求合并为每三帧一次,从最新 DOM 读取 `scrollWidth` 和 `clientWidth` 并将 `scrollLeft` 直接更新到最新位置;固定的视觉节奏让摘要变化可读,又不会积压浏览器平滑滚动动画。该节流只作用于 Think 的横向摘要,不延迟 Chat 正文滚动、历史 prepend 锚定或用户触发的 `scrollIntoView`。 -`pnpm run test:web:stress` 保留为无密钥、需显式启用的浏览器性能证据。确定性的 `?fixture` 会话以独立于绘制的节奏发出 100,000 个 `reasoning-delta`,结尾标记证明事件经过生产会话归并并到达实时 Think 行;50 毫秒心跳和预先调度的 DOM 事件分别测量主线程停顿与交互延迟,250 毫秒预算用于识别明显回归。`DSH_WEB_STRESS_HEADFUL=1` 允许开发者在可见浏览器中使用 Performance 面板分析同一场景。该压力车道是手动性能诊断与修复验收的证据,不是默认 CI 门禁,也不替代确定性的调度单元测试。 +`pnpm run test:web:stress` 保留为无密钥、需显式启用的浏览器性能证据。测试持有的模型 adapter 通过真实 Host、Gateway、WebSocket、Session 归并和实时 Think 行发出 100,000 个 `reasoning-delta` chunk。每批 128 个 chunk 会等待一次浏览器 timer 轮次后再发下一批,因此 250 毫秒心跳与预先调度的 DOM 事件测量的是一个有界接收/渲染区间,而不是累积的 socket backlog。结尾标记证明所有 chunk 都已到达 UI。`DSH_WEB_STRESS_HEADFUL=1` 允许开发者在可见浏览器中使用 Performance 面板分析同一场景。该压力车道是手动性能诊断与修复验收的证据,不是默认 CI 门禁,也不替代确定性的调度单元测试。 -聚焦测试固定 `Notifier` 的逐帧合并、结构事件抢占、失效回调和无 rAF 回退,并在 `Session` 层证明一帧只发布一次最新累计文本且定稿不会被旧帧回调重复通知。fixture(测试前置数据)的小型单元测试继续固定输入校验、外部到达节奏、并发拒绝、精确事件数和结尾标记交付,无需把 100,000 分片工作负载带入默认测试套件。 +聚焦测试固定 `Notifier` 的逐帧合并、结构事件抢占、失效回调和无 rAF 回退,并在 `Session` 层证明一帧只发布一次最新累计文本且定稿不会被旧帧回调重复通知。按需启用的浏览器用例持有 adapter 节奏、精确事件数和结尾标记交付,不把 100,000 分片工作负载带入默认测试套件。 ## 曾考虑的替代方案 @@ -30,9 +30,9 @@ Session Controller 把每个 Client-only live chunk 追加到 event source,Con **只使用微任务合批。** 不予采纳:连续异步 `yield` 会在相邻分片间排空微任务队列,使微任务合批近似退化为每个分片通知一次。 -**按动画帧控制测试生产方节奏。** 不予采纳:生产方会在渲染变慢时同步减速,使页面获得真实网络流不存在的隐式背压,并掩盖主线程饥饿。 +**不等待确认就把原有浏览器本地速率发入 WebSocket。** 不予采纳:transport 工作的排队速度会超过模型可能达到的产出速度,使结果变成 socket backlog 测量。浏览器 timer 确认限制每批工作量,同时 heartbeat 仍能发现一批数据使页面饥饿。 -**真实模型或录制的 HTTP 字节流。** 不予采纳:实时模型不具确定性,HTTP/SSE(Server-Sent Events)录制也不会改进目标断言。内存 fixture 保留逐个异步会话事件、生产客户端归并和 React 渲染路径,同时控制工作负载与到达节奏。 +**真实外部模型或录制的 HTTP 字节流。** 不予采纳:外部模型不具确定性,HTTP 录制还会增加第二种 fixture 格式,却不会改进目标断言。测试持有的 adapter 让每个 chunk 经过真实 Host 与浏览器 transport,同时控制工作负载。 ## 后果 @@ -40,4 +40,4 @@ Session Controller 把每个 Client-only live chunk 追加到 event source,Con 折叠 Think 摘要的横向布局读写最多每三帧执行一次,并直接追上该时刻的最新位置;React 仍按累计快照正常提交,定稿时摘要恢复到首行。该局部视觉策略不会改变正文滚动和用户交互的即时性。 -浏览器压力车道继续提供真实组装应用上的响应性信号和可见 profiling 入口,但硬件与调度差异使其只适合作为显式性能证据。确定性的 focused tests 负责守住发布次数、累计内容与抢占顺序,默认测试车道保持快速。 +浏览器压力车道继续提供真实组装应用与载体上的响应性信号和可见 profiling 入口,但硬件与调度差异使其只适合作为显式性能证据。确定性的 focused tests 负责守住发布次数、累计内容与抢占顺序,默认测试车道保持快速。 diff --git a/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.i18n.yaml b/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.i18n.yaml index d65b652543..1e0850bf40 100644 --- a/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.md -2026-09-06-client-assembly-test-line.md: d3ffb01302a60f7b664f6b225648180d2ec50408 -2026-09-06-client-assembly-test-line.zh.md: 66ecf460e65046eb356b0e35d0c497c6e5774e9c +2026-09-06-client-assembly-test-line.md: d2515c0689b9ea39bf14e4ce653a26d9a81ebfb8 +2026-09-06-client-assembly-test-line.zh.md: 90b5768db4307c551b895d7101324f329f42c955 diff --git a/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.md b/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.md index d3ffb01302..d2515c0689 100644 --- a/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.md +++ b/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.md @@ -30,11 +30,13 @@ The modules plugin reads the module system from its own `ctx.loader.internal` wh **`remoteDefaultResponses` holds default responses for the boot-time Remote endpoints.** The table lists exactly the endpoints the `web` roster calls while booting and rendering with no sessions, no workspaces, and default settings, each row commented with its caller. A spec layers its own `RemoteTable` on top; a new boot-time call fails the spec at `dispose()`. +App-owned built-bundle tests install a fresh `RemoteMock` through `__DSH_TRANSPORT__` before `AppWebEntry` starts. Their scenario data stays under `apps/web/tests`; the production Connection package contains no scenario dataset or query-selected test transport. Browser cases that need Host behavior use `launchWebScaffold()` and the real HTTP/WebSocket path. + `mock.remote` uses native `@vitest/spy.fn` functions shared by direct callers and Connection dispatch. `MockedRemote` applies Vitest's deep mock type transformation to the entire generated namespace map; an empty map weakens only this proxy to `any`. Production `Context` and Remote declarations remain strict, with no namespace-specific type copies or compiler flags. The [proxy typing guidance](../../../../packages/test-support/remote-mock/README.md#remote-proxy) requires build-backed local type checking even when unbuilt tests pass. ## Product exports added for the tier -- `client/connection`: `ClientTransportHooks.rpc?` publishes the already decoded carrier the `?fixture` path used internally; `fetch` becomes optional. `installConnection(ctx, options)` installs the same production service from instance-local transport, recovery, and location inputs. +- `client/connection`: `ClientTransportHooks.rpc?` accepts an already decoded carrier owned by an in-process composition; `fetch` is optional. `installConnection(ctx, options)` installs the same production service from instance-local transport, recovery, and location inputs. - `client/hmr`: `tearDownEntryFiber(entry)` is the registry-first fiber teardown `reload` already performed. - `client/modules`: `parseDshClient` and `exactPackageSpecifier` are exported from the client face and shared by the Host and roster reader. Test factories use the existing registration queue. The roster-to-boot-graph synthesis has only test consumers and lives in the tier. - `client/web`: `bootClient` and `mountClient` are extracted from `AppWebEntry.run()`, which now calls them. @@ -52,6 +54,8 @@ The modules plugin reads the module system from its own `ctx.loader.internal` wh **A mock module standing in for the Gateway client.** Rejected: the mock must not interfere with Gateway internals; passing it to the production Connection installer keeps retry, folding, and stream semantics real. +**A query-selected fixture transport and scenario dataset in the production Connection package.** Rejected: it couples shipped code to mutable application-test data and makes a URL flag a hidden transport selector. Client-only assembled tests inject `RemoteMock`; browser tests that own Host behavior use the real Host scaffold. + **A worker-level boot turn around a page-global Connection carrier.** Rejected: clients would share mutable process state, and every start or Connection reload would need serialization to install the acting client's carrier. Instance-local Connection installation lets independent plugin trees start and reload concurrently. **A second typed Gateway implementation with an `Api` generic, envelope and error classes, and a fixtures directory.** Rejected: it duplicates Gateway declarations and encoding. The mock derives method types from the generated namespace map and declares only unary or stream behavior at runtime. @@ -91,4 +95,4 @@ Product facts the tier surfaced and leaves as they are: ## Testing -`packages/test-support/remote-mock/tests/` covers rules, streams, the log, and the carrier face; the `assembly-` specs under `packages/test-support/client-runtime/tests/` cover the roster reader on the real bundles and on a scratch installation, module loading, the proxies including their fold, parallel client startup, instance-local module and Connection reload, and `TestClient` under jsdom and plain Node. In `packages/client/ui-settings-general/tests/`, the shell and apply specs boot the whole `web` roster; the apply spec reads its Chinese copy from the Host settings document the mock answers and reconfigures the jsdom page URL for the off-loopback branch. In `packages/client/ui-message-feedback/tests/`, object-layer specs pass the `messageFeedback` RemoteMock namespace directly. In `packages/api/session-controller/tests/`, Session, queue-store, pending-submission, projection tail-page, sessions-service, and the manager cases that exercise streams start the Gateway dependency cone; the other manager cases and projection-store's manager-routing cases use the same per-test Remote fixtures without starting a client; and client-apply boots the plugin's dependency cone, delivering Remote events as emit frames on `$events`. In `packages/api/workspace-controller/tests/`, the transport spec boots the plugin's cone for apply cases and the gateway cone for hand-built stream and controller cases, since a rostered plugin would share the follow endpoint. Each package keeps a `tests/remote/` module with its default responses and frame builders. The fixture tests include an expected assertion failure and independently observe completed client cleanup; settings reload tests observe replaced registration identities, and write tests assert every mutation argument. Teardown-failure tests execute the real tree disposer before reporting the injected failure and observe the `$events` stream's cancelled state. +`packages/test-support/remote-mock/tests/` covers rules, streams, the log, and the carrier face; the `assembly-` specs under `packages/test-support/client-runtime/tests/` cover the roster reader on the real bundles and on a scratch installation, module loading, the proxies including their fold, parallel client startup, instance-local module and Connection reload, and `TestClient` under jsdom and plain Node. App-owned built-client specs under `apps/web/tests/` inject a fresh RemoteMock and keep their stateful scenario data beside those specs. In `packages/client/ui-settings-general/tests/`, the shell and apply specs boot the whole `web` roster; the apply spec reads its Chinese copy from the Host settings document the mock answers and reconfigures the jsdom page URL for the off-loopback branch. In `packages/client/ui-message-feedback/tests/`, object-layer specs pass the `messageFeedback` RemoteMock namespace directly. In `packages/api/session-controller/tests/`, Session, queue-store, pending-submission, projection tail-page, sessions-service, and the manager cases that exercise streams start the Gateway dependency cone; the other manager cases and projection-store's manager-routing cases use the same per-test Remote fixtures without starting a client; and client-apply boots the plugin's dependency cone, delivering Remote events as emit frames on `$events`. In `packages/api/workspace-controller/tests/`, the transport spec boots the plugin's cone for apply cases and the gateway cone for hand-built stream and controller cases, since a rostered plugin would share the follow endpoint. Each package keeps a `tests/remote/` module with its default responses and frame builders. The fixture tests include an expected assertion failure and independently observe completed client cleanup; settings reload tests observe replaced registration identities, and write tests assert every mutation argument. Teardown-failure tests execute the real tree disposer before reporting the injected failure and observe the `$events` stream's cancelled state. diff --git a/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.zh.md b/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.zh.md index 66ecf460e6..90b5768db4 100644 --- a/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.zh.md +++ b/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.zh.md @@ -30,11 +30,13 @@ modules 插件激活时从自己的 `ctx.loader.internal` 读取模块系统。 **`remoteDefaultResponses` 是启动期 Remote 端点的默认响应。** 这张表恰好列出 `web` roster 在没有 session、没有 workspace、默认设置下启动并渲染时会打的端点,每行注明调用方。spec 在其上叠加自己的 `RemoteTable`;新的启动期调用会在 `dispose()` 时让 spec 失败。 +归应用所有的 built-bundle 测试会在 `AppWebEntry` 启动前通过 `__DSH_TRANSPORT__` 安装一份新的 `RemoteMock`。场景数据留在 `apps/web/tests` 下;生产 Connection 包不包含场景数据集或由 query 选择的测试 transport。需要 Host 行为的浏览器用例使用 `launchWebScaffold()` 和真实 HTTP/WebSocket 路径。 + `mock.remote` 使用直接调用方与 Connection 分发共用的原生 `@vitest/spy.fn` 函数。`MockedRemote` 对完整生成的命名空间映射应用 Vitest 深层 mock 类型转换;映射为空时仅这个 Proxy 弱化为 `any`。生产 `Context` 与 Remote 声明保持严格,不需要命名空间专属类型副本或编译器 Flag。[Proxy 类型指引](../../../../packages/test-support/remote-mock/README.zh.md#remote-proxy)要求即使无构建测试通过,本地也必须执行构建后的类型检查。 ## 为本档新增的产品导出 -- `client/connection`:`ClientTransportHooks.rpc?` 公开 `?fixture` 路径内部已在用的已解码载体;`fetch` 变为可选。`installConnection(ctx, options)` 从实例局部的 transport、recovery 与 location 输入安装同一个生产服务。 +- `client/connection`:`ClientTransportHooks.rpc?` 接受由进程内组合持有的已解码载体;`fetch` 可选。`installConnection(ctx, options)` 从实例局部的 transport、recovery 与 location 输入安装同一个生产服务。 - `client/hmr`:`tearDownEntryFiber(entry)` 就是 `reload` 本来执行的 registry 先行的 fiber 拆除。 - `client/modules`:`parseDshClient` 与 `exactPackageSpecifier` 从 client 面导出,由 Host 和 roster 读取器共用。测试工厂使用已有注册队列。roster 行到 boot graph 的合成只有测试消费者,放在本档里。 - `client/web`:`bootClient` 与 `mountClient` 从 `AppWebEntry.run()` 抽出,后者现在调用它们。 @@ -52,6 +54,8 @@ modules 插件激活时从自己的 `ctx.loader.internal` 读取模块系统。 **用一个 mock 模块替代 Gateway 客户端。** 否决:mock 不得干涉 Gateway 内部;把它传给生产 Connection 安装函数会让重试、折叠与流语义都保持真实。 +**在生产 Connection 包中保留由 query 选择的 fixture transport 与场景数据集。** 否决:这会把交付代码耦合到易变的应用测试数据,并让 URL 标记成为隐藏的 transport 选择器。仅客户端的组装测试注入 `RemoteMock`;拥有 Host 行为的浏览器测试使用真实 Host scaffold。 + **用 worker 级启动轮次包住页面全局 Connection 载体。** 否决:客户端会共享可变进程状态,每次启动或 Connection 重载都必须串行安装当事客户端的载体。实例局部的 Connection 安装让独立插件树可以并行启动和重载。 **第二套带类型的 Gateway 实现,包含 `Api` 泛型、信封与错误类以及 fixtures 目录。** 否决:它重复 Gateway 声明与编解码。mock 从生成的命名空间映射派生方法类型,运行时只声明一元或流行为。 @@ -91,4 +95,4 @@ spec 起的是真插件:整个 `web` roster 冷启动约五秒、热启动远 ## 测试 -`packages/test-support/remote-mock/tests/` 覆盖规则、流、日志与载体面;`packages/test-support/client-runtime/tests/` 下的 `assembly-` 系列 spec 覆盖在真 bundle 与临时安装上的 roster 读取器、模块加载、含折叠的代理、并行客户端启动、实例局部的模块与 Connection 重载,以及 jsdom 与纯 Node 下的 `TestClient`。`packages/client/ui-settings-general/tests/` 下,shell 与 apply 两条起整个 `web` roster;apply 从 mock 应答的 Host settings 文档读它的中文文案,并为 off-loopback 分支重配 jsdom 页面 URL。`packages/client/ui-message-feedback/tests/` 下,对象层 spec 直接传入 RemoteMock 的 `messageFeedback` 命名空间。`packages/api/session-controller/tests/` 下,Session、queue-store、pending-submission、projection tail-page、sessions-service 以及验证流的 manager 用例启动 Gateway 依赖锥;其余 manager 用例与 projection-store 的 manager-routing 用例直接使用同一组逐测试 Remote fixture,不启动客户端;client-apply 则启动插件的依赖锥,把 Remote 事件作为 `$events` 上的 emit 帧投递。`packages/api/workspace-controller/tests/` 下,transport 的 apply 用例起插件锥、手工构造流与 controller 的用例起 gateway 锥,因为进了 roster 的插件会共用 follow 端点。每个包在 `tests/remote/` 保有自己的默认响应与帧构造。fixture 测试包含预期的断言失败,并独立观察客户端清理完成;settings 重载测试观察注册身份被替换,写入测试断言全部 mutation 参数。teardown 失败测试先执行真实树清理,再报告注入的失败,并观察 `$events` 流的取消状态。 +`packages/test-support/remote-mock/tests/` 覆盖规则、流、日志与载体面;`packages/test-support/client-runtime/tests/` 下的 `assembly-` 系列 spec 覆盖在真 bundle 与临时安装上的 roster 读取器、模块加载、含折叠的代理、并行客户端启动、实例局部的模块与 Connection 重载,以及 jsdom 与纯 Node 下的 `TestClient`。`apps/web/tests/` 下归应用所有的 built-client spec 注入一份新的 RemoteMock,并将有状态场景数据留在这些 spec 旁边。`packages/client/ui-settings-general/tests/` 下,shell 与 apply 两条起整个 `web` roster;apply 从 mock 应答的 Host settings 文档读它的中文文案,并为 off-loopback 分支重配 jsdom 页面 URL。`packages/client/ui-message-feedback/tests/` 下,对象层 spec 直接传入 RemoteMock 的 `messageFeedback` 命名空间。`packages/api/session-controller/tests/` 下,Session、queue-store、pending-submission、projection tail-page、sessions-service 以及验证流的 manager 用例启动 Gateway 依赖锥;其余 manager 用例与 projection-store 的 manager-routing 用例直接使用同一组逐测试 Remote fixture,不启动客户端;client-apply 则启动插件的依赖锥,把 Remote 事件作为 `$events` 上的 emit 帧投递。`packages/api/workspace-controller/tests/` 下,transport 的 apply 用例起插件锥、手工构造流与 controller 的用例起 gateway 锥,因为进了 roster 的插件会共用 follow 端点。每个包在 `tests/remote/` 保有自己的默认响应与帧构造。fixture 测试包含预期的断言失败,并独立观察客户端清理完成;settings 重载测试观察注册身份被替换,写入测试断言全部 mutation 参数。teardown 失败测试先执行真实树清理,再报告注入的失败,并观察 `$events` 流的取消状态。 From 7e331d72eddf548d6303937ea1c7b2e77291b7cb Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:54:31 +0800 Subject: [PATCH 64/79] test(file-upload): cover unnamed byte uploads --- packages/client/file-upload/tests/file-upload.client.spec.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/client/file-upload/tests/file-upload.client.spec.ts b/packages/client/file-upload/tests/file-upload.client.spec.ts index c6e28d54c0..ac9e63f499 100644 --- a/packages/client/file-upload/tests/file-upload.client.spec.ts +++ b/packages/client/file-upload/tests/file-upload.client.spec.ts @@ -438,8 +438,11 @@ describe('Session-addressed file upload', () => { const { fiber, service } = await scopedService({ remote }) await expect(service.upload(SESSION_ID, Uint8Array.of(0, 0, 0), 'bytes.bin')) .resolves.toMatchObject({ ok: true }) + await expect(service.upload(SESSION_ID, Uint8Array.of(1))) + .resolves.toMatchObject({ ok: true }) expect(remote.mock.calls).toEqual([ [SESSION_ID, { data: 'AAAA', name: 'bytes.bin' }, undefined], + [SESSION_ID, { data: 'AQ==' }, undefined], ]) await fiber.dispose() }) From c0e8252afee27e43f2e6d0cc09511398da92300c Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 13 Sep 2026 22:24:43 +0800 Subject: [PATCH 65/79] refactor(client): remove fixture-only API residue --- packages/client/connection/src/client/api.ts | 4 ++-- packages/client/file-upload/src/client/contract.ts | 2 -- packages/client/file-upload/src/client/runtime.ts | 1 - .../client/ui-conversation/tests/apply-inject.client.spec.tsx | 1 - .../tests/service-orchestration.client.spec.ts | 1 - packages/test-support/client-runtime/README.i18n.yaml | 4 ++-- packages/test-support/client-runtime/README.md | 2 +- packages/test-support/client-runtime/README.zh.md | 2 +- .../test-support/client-runtime/src/assembly/test-client.ts | 4 ++-- packages/test-support/client-runtime/src/index.ts | 3 --- .../test-support/client-runtime/tests/helpers.client.spec.tsx | 1 - 11 files changed, 8 insertions(+), 17 deletions(-) diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts index 41e384c662..a3de74c4fe 100644 --- a/packages/client/connection/src/client/api.ts +++ b/packages/client/connection/src/client/api.ts @@ -16,8 +16,8 @@ export type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm/types' import type { RpcResponse, RpcResult } from '../rpc.ts' /** - * Return the business result carried by a narrow fixture response. - * @param response - fixture response to unwrap. + * Return the business result carried by an RPC response. + * @param response - RPC response to unwrap. * @returns the response's business result. */ export function resultOf(response: RpcResponse): RpcResult { diff --git a/packages/client/file-upload/src/client/contract.ts b/packages/client/file-upload/src/client/contract.ts index dfe2245ff2..8bf1145193 100644 --- a/packages/client/file-upload/src/client/contract.ts +++ b/packages/client/file-upload/src/client/contract.ts @@ -13,8 +13,6 @@ export interface FileUploadProgress { /** Browser upload service addressed by one Session identity. */ export interface FileUploadService { - /** Whether this page has a Host-backed background upload carrier. */ - readonly available: boolean /** * Store one file for a Session. Blob and stream bodies use * the background carrier; exact bytes use Remote. diff --git a/packages/client/file-upload/src/client/runtime.ts b/packages/client/file-upload/src/client/runtime.ts index 90076cbedd..6601da4878 100644 --- a/packages/client/file-upload/src/client/runtime.ts +++ b/packages/client/file-upload/src/client/runtime.ts @@ -160,7 +160,6 @@ interface FileUploadTransport { /** Cordis service that owns one background carrier per upload operation. */ export class FileUploadRuntime extends Service implements FileUploadService { - readonly available = true private readonly transport: FileUploadTransport /** @param ctx - providing Client context. */ diff --git a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx index a35bf956d0..678985ba88 100644 --- a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx @@ -42,7 +42,6 @@ async function bench() { }, })) const uploads = new Map Promise>([[ROOT, rootUpload]]) - runtime.fileUpload.available = true runtime.fileUpload.upload = (sessionId: SessionId, ...args: unknown[]) => { const upload = uploads.get(sessionId) if (upload === undefined) throw new Error('test file upload has no Session fixture') diff --git a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts index 5e0521f0da..1c30f577a1 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts @@ -17,7 +17,6 @@ import { zh } from '../src/client/locales.ts' async function bench(maxConcurrentFileUploads = 2) { const runtime = await SlotTestRuntime.create() - runtime.fileUpload.available = true runtime.fileUpload.upload = (sessionId: SessionId, ...args: unknown[]) => { const session = runtime.sessions.behavior(sessionId) as { uploadFile?: (...input: unknown[]) => Promise diff --git a/packages/test-support/client-runtime/README.i18n.yaml b/packages/test-support/client-runtime/README.i18n.yaml index e2ce3b25fe..962229c870 100644 --- a/packages/test-support/client-runtime/README.i18n.yaml +++ b/packages/test-support/client-runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/test-support/client-runtime/README.md -README.md: 64a686f08ec2778de941bedfb70b3a1de2d542b9 -README.zh.md: a9c85ca3b281f62dc57bffdaf705ea3b33e84941 +README.md: 014bcbf8bc0ed9be596e2a20c07f57c80aaaf7dc +README.zh.md: a14a5281c58a5ea4a8a49e1a826397ae91e5f115 diff --git a/packages/test-support/client-runtime/README.md b/packages/test-support/client-runtime/README.md index 64a686f08e..014bcbf8bc 100644 --- a/packages/test-support/client-runtime/README.md +++ b/packages/test-support/client-runtime/README.md @@ -40,7 +40,7 @@ expect(view.container).toMatchSnapshot() await runtime.dispose() ``` -`mount` prechecks required services and fails loud when one is missing — `provide(name, value)` supplies an extra service first. The runtime provides an unavailable `fileUpload` stub so assemblies can mount; replace `runtime.fileUpload.upload` before mounting when a test exercises upload behavior. `storeOf(key, scopeKey)` returns the live store instance the renderer hands a slot's component for identity and action-driven-write assertions. +`mount` prechecks required services and fails loud when one is missing — `provide(name, value)` supplies an extra service first. The runtime provides a `fileUpload` stub that rejects every call until a suite replaces `runtime.fileUpload.upload`. `storeOf(key, scopeKey)` returns the live store instance the renderer hands a slot's component for identity and action-driven-write assertions. The optional render options select a keyed entry with `entryKey` or a list item with `only`; `view.update(owner)` retains that selection. `runtime.panelInfo` supplies the default `usePanelInfo` source with no global panel selected. Release it with `releasePanelInfoSource()` before mounting the production Layout owner. `dispose()` releases both default Workspace and panel-info root sources; early release is idempotent and does not remove replacement owners. diff --git a/packages/test-support/client-runtime/README.zh.md b/packages/test-support/client-runtime/README.zh.md index a9c85ca3b2..a14a5281c5 100644 --- a/packages/test-support/client-runtime/README.zh.md +++ b/packages/test-support/client-runtime/README.zh.md @@ -40,7 +40,7 @@ expect(view.container).toMatchSnapshot() await runtime.dispose() ``` -`mount` 会预检必需服务,缺失时自明报错——先用 `provide(name, value)` 提供额外服务。运行时会提供不可用的 `fileUpload` 替身,使装配可以挂载;测试上传行为时,需要在挂载前替换 `runtime.fileUpload.upload`。`storeOf(key, scopeKey)` 返回渲染器交给 slot 组件的实时存储实例,用于身份与动作驱动写入断言。 +`mount` 会预检必需服务,缺失时自明报错——先用 `provide(name, value)` 提供额外服务。运行时提供的 `fileUpload` 替身会拒绝每次调用,直到测试套件替换 `runtime.fileUpload.upload`。`storeOf(key, scopeKey)` 返回渲染器交给 slot 组件的实时存储实例,用于身份与动作驱动写入断言。 可选渲染参数通过 `entryKey` 选择 keyed 条目,或通过 `only` 选择 list 条目;`view.update(owner)` 保留该选择。`runtime.panelInfo` 提供默认的 `usePanelInfo` 数据源,初始不选中全局面板。挂载生产 Layout 所有者之前,先调用 `releasePanelInfoSource()` 释放该数据源。`dispose()` 同时释放默认的工作区与面板信息根数据源;提前释放是幂等的,不会移除替代它们的所有者。 diff --git a/packages/test-support/client-runtime/src/assembly/test-client.ts b/packages/test-support/client-runtime/src/assembly/test-client.ts index 678286feef..ecadd61fea 100644 --- a/packages/test-support/client-runtime/src/assembly/test-client.ts +++ b/packages/test-support/client-runtime/src/assembly/test-client.ts @@ -164,7 +164,7 @@ export class TestClient { * boot graph; afterwards optionally mount and wait for the connection. The * bound row replaces only the page-global input adapter: both paths call * `installConnection`, while this path supplies the mock carrier, uses - * default recovery timings, and captures the current page location once for + * default recovery timings, and captures the current page hostname once for * later reloads. A caller-provided Connection row remains unchanged and owns * its readiness behavior. The `@deepseek-ai/dsh-api-remotes` row is * dropped from the roster: its generated Remote clients exist only in built @@ -194,7 +194,7 @@ export class TestClient { const ctx = new Context() const pageLocation = typeof location === 'undefined' ? undefined - : { hostname: location.hostname, search: location.search } + : { hostname: location.hostname } let mountPoint: MountPoint = { element: undefined, owned: false } let release: (() => void) | undefined const restore = (): void => { diff --git a/packages/test-support/client-runtime/src/index.ts b/packages/test-support/client-runtime/src/index.ts index 8472514901..34614b0205 100644 --- a/packages/test-support/client-runtime/src/index.ts +++ b/packages/test-support/client-runtime/src/index.ts @@ -113,8 +113,6 @@ export interface FeatureHandle { /** Mutable fail-loud file-upload stub installed by {@link SlotTestRuntime}. */ export interface TestFileUpload { - /** Availability reported to the feature under test. */ - available: boolean /** Test-supplied upload behavior; the default rejects every call. */ upload: (sessionId: SessionId, ...args: unknown[]) => Promise } @@ -245,7 +243,6 @@ export class SlotTestRuntime { this.sessions = new TestSessions(this.stabilizer, ctx) this.workspaces = new TestWorkspaces(this.stabilizer) this.fileUpload = { - available: false, upload: () => Promise.reject(new Error('client test runtime: file upload is not stubbed')), } ctx.provide('sessions', this.sessions) diff --git a/packages/test-support/client-runtime/tests/helpers.client.spec.tsx b/packages/test-support/client-runtime/tests/helpers.client.spec.tsx index 389c1a0d90..f96849d9e4 100644 --- a/packages/test-support/client-runtime/tests/helpers.client.spec.tsx +++ b/packages/test-support/client-runtime/tests/helpers.client.spec.tsx @@ -105,7 +105,6 @@ describe('fixture helpers', () => { it('rejects an upload until a suite replaces the default stub', async () => { const runtime = await SlotTestRuntime.create() - expect(runtime.fileUpload.available).toBe(false) await expect(runtime.fileUpload.upload('fixture-session' as SessionId)).rejects.toThrow('file upload is not stubbed') await runtime.dispose() }) From e179c4ae14a5f9493b7bbc8ef7da89d9af6f79f0 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 13 Sep 2026 22:28:09 +0800 Subject: [PATCH 66/79] test(client): preserve migrated fixture semantics --- apps/web/tests/assembled-remote.spec.ts | 44 +++++++++++++++++++ apps/web/tests/assembled-remote.ts | 43 +++++++++++++----- .../tests/manager.client.spec.ts | 10 +++-- .../tests/projection-store.client.spec.ts | 4 +- .../tests/sessions-service.client.spec.ts | 12 ++--- 5 files changed, 92 insertions(+), 21 deletions(-) create mode 100644 apps/web/tests/assembled-remote.spec.ts diff --git a/apps/web/tests/assembled-remote.spec.ts b/apps/web/tests/assembled-remote.spec.ts new file mode 100644 index 0000000000..6e5ade036e --- /dev/null +++ b/apps/web/tests/assembled-remote.spec.ts @@ -0,0 +1,44 @@ +/** Stateful behavior of the app-owned RemoteMock scenario. */ + +import { expect, it } from 'vitest' +import { createAssembledRemote } from './assembled-remote.ts' + +it('advances turns and updates the Session summary after accepted prompts', async () => { + const { mock } = createAssembledRemote() + await mock.dispatch('session/create', [{ request: { cwd: '/work' } }]) + await mock.dispatch('session/prompt', [{ + request: { sessionId: 'fx-1', requestId: 'request-1', mode: 'queue', content: [] }, + }]) + await mock.dispatch('session/prompt', [{ + request: { sessionId: 'fx-1', requestId: 'request-2', mode: 'queue', content: [] }, + }]) + + const listed = await mock.dispatch('session/list', []) as { + readonly ok: true + readonly value: { + readonly items: readonly { readonly sessionId: string; readonly blank: boolean; readonly running: boolean }[] + } + } + expect(listed.ok).toBe(true) + expect(listed.value.items.find(item => item.sessionId === 'fx-1')).toMatchObject({ + blank: false, + running: true, + }) + + const iterator = mock.open('session/follow', [{ + request: { address: { kind: 'session', sessionId: 'fx-1' } }, + }], new AbortController().signal)[Symbol.asyncIterator]() + await expect(iterator.next()).resolves.toMatchObject({ + done: false, + value: { + records: [ + { event: { type: 'turn/start', data: { turn: 0 } } }, + { event: { type: 'user/message' } }, + { event: { type: 'turn/start', data: { turn: 1 } } }, + { event: { type: 'user/message' } }, + ], + }, + }) + await iterator.return?.() + mock.assertNoUnmatched() +}) diff --git a/apps/web/tests/assembled-remote.ts b/apps/web/tests/assembled-remote.ts index b0b44f9db1..fd84fb468c 100644 --- a/apps/web/tests/assembled-remote.ts +++ b/apps/web/tests/assembled-remote.ts @@ -1,4 +1,8 @@ -/** RemoteMock scenario for built-client tests that do not own a Host. */ +/** + * RemoteMock scenario for built-client tests that do not own a Host. + * The adjacent JSON is maintained with this module when Remote responses or + * the current Session header version change. + */ import { readFileSync } from 'node:fs' import { randomUUID } from 'node:crypto' @@ -107,10 +111,23 @@ export function createAssembledRemote(options: AssembledRemoteOptions = {}): Ass 'fx-alpha', structuredClone(fixture.follow.records) as EventRecord[], ]]) + const nextTurns = new Map([...records].map(([sessionId, sessionRecords]) => { + let next = 0 + for (const { event } of sessionRecords) { + if (event.type !== 'turn/start' || !isRecord(event.data)) continue + const turn = event.data['turn'] + if (typeof turn === 'number') next = Math.max(next, turn + 1) + } + return [sessionId, next] as const + })) const attachments = new Map([['fixture:image', structuredClone(fixture.attachment)]]) + const blankSessionProjections = fixture.control.value.projections['fx-gamma'] + if (blankSessionProjections === undefined) { + throw new Error('assembled fixture: blank Session projections missing') + } let nextSession = 1 - const mock = RemoteMock.create({ host: { home: '/home/fixture' } }).load(remoteDefaultResponses) + const mock = RemoteMock.create().load(remoteDefaultResponses) mock.load({ unary: { 'settings/describe': structuredClone(fixture.settingsDescribe), @@ -174,10 +191,7 @@ export function createAssembledRemote(options: AssembledRemoteOptions = {}): Ass cursor: sessionRecords.at(-1)?.event.seq ?? -1, records: structuredClone(sessionRecords), hasMore: false, - projections: structuredClone(fixture.control.value.projections['fx-gamma'] ?? { - asOfSeq: -1, - values: {}, - }), + projections: structuredClone(blankSessionProjections), ...isAssistantStreamRequested(request) ? { assistantStream: { revision: 0 } } : {}, }) }) @@ -220,13 +234,11 @@ export function createAssembledRemote(options: AssembledRemoteOptions = {}): Ass running: false, blank: true, cwd, - projections: structuredClone(fixture.control.value.projections['fx-gamma'] ?? { - asOfSeq: -1, - values: {}, - }), + projections: structuredClone(blankSessionProjections), } sessions.push(summary) records.set(sessionId, []) + nextTurns.set(sessionId, 0) if (workspace !== undefined && !workspace.sessionIds.includes(sessionId)) { workspace.sessionIds = [sessionId, ...workspace.sessionIds] workspace.updatedAt = new Date().toISOString() @@ -265,6 +277,8 @@ export function createAssembledRemote(options: AssembledRemoteOptions = {}): Ass const sessionId = recordString(request, 'sessionId') const requestId = recordString(request, 'requestId') const sessionRecords = records.get(sessionId) ?? [] + const summary = sessions.find(candidate => candidate.sessionId === sessionId) + if (summary === undefined) throw new Error(`assembled fixture: no Session ${sessionId}`) const content = recordArray(request, 'content').map((part) => { if (!isRecord(part) || part.type !== 'image') return part const attachmentId = `assembled:${randomUUID()}` @@ -282,7 +296,14 @@ export function createAssembledRemote(options: AssembledRemoteOptions = {}): Ass attachments.set(attachmentId, ok({ attachment, data })) return { type: 'image', attachment } }) - const turn = sessionRecords.length + const turn = nextTurns.get(sessionId) ?? 0 + nextTurns.set(sessionId, turn + 1) + summary.updatedAt = Date.now() + summary.blank = false + if (!summary.running) { + summary.running = true + mock.streams.push('$events', { type: 'emit', event: 'api-session/status', args: [sessionId, true] }) + } const turnEvent = eventOf(sessionRecords.length, 'turn/start', { turn }) const userEvent = eventOf(sessionRecords.length + 1, 'user/message', { content, diff --git a/packages/api/session-controller/tests/manager.client.spec.ts b/packages/api/session-controller/tests/manager.client.spec.ts index f6a7393964..e6ffa43850 100644 --- a/packages/api/session-controller/tests/manager.client.spec.ts +++ b/packages/api/session-controller/tests/manager.client.spec.ts @@ -21,9 +21,11 @@ import { FOLLOW, err, followScript, sessionWorld } from './remote/session.client const S1 = 'fk-m1' as SessionId const S2 = 'fk-m2' as SessionId -/** A SessionManager's Remote methods use the same native mocks as an assembled client. */ +/** Gateway Client cone used by the subagent-catalog and connected-generation cases. */ const API_ROSTER = webApp.closure(['@deepseek-ai/dsh-api-gateway']) const it = createClientTest({ roster: API_ROSTER }) +/** The first client boot pays the cold module transform of the api cone. */ +const COLD_BOOT_TIMEOUT_MS = 60_000 type SummaryOver = Partial<{ updatedAt: number @@ -229,7 +231,7 @@ describe('search', () => { hasMore: true, }, }) - expect(remote.session.search).toHaveBeenCalledWith({ query: 'exact phrase' }, signal) + expect(remote.session.search).toHaveBeenCalledExactlyOnceWith({ query: 'exact phrase' }, signal) }) it('preserves business errors and propagates a non-Remote throw', async ({ mock, remote }) => { @@ -345,7 +347,7 @@ describe('subagent catalogs', () => { address: { parentSessionId: S1, childSessionId: S2, mode: 'continuable' }, }, }) - }) + }, COLD_BOOT_TIMEOUT_MS) it('refetches debounced membership only while the parent catalog is open', async ({ mock, remote }) => { vi.useFakeTimers() @@ -632,7 +634,7 @@ describe('remaining branches', () => { remote.session.create.mockResolvedValue(ok({ sessionId: S1 })) const manager = makeManager(mock, remote) await manager.create({ cwd: '/tmp/w', sessionId: S1 }) - expect(remote.session.create).toHaveBeenCalledWith({ cwd: '/tmp/w', sessionId: S1 }) + expect(remote.session.create).toHaveBeenCalledExactlyOnceWith({ cwd: '/tmp/w', sessionId: S1 }) expect(manager.getListSnapshot().items[0]).toMatchObject({ sessionId: S1, cwd: '/tmp/w' }) await manager.create({ cwd: '/tmp/w' }) // same id returned: no duplicate row expect(manager.getListSnapshot().items).toHaveLength(1) diff --git a/packages/api/session-controller/tests/projection-store.client.spec.ts b/packages/api/session-controller/tests/projection-store.client.spec.ts index b9c651bc76..b5a04e659c 100644 --- a/packages/api/session-controller/tests/projection-store.client.spec.ts +++ b/packages/api/session-controller/tests/projection-store.client.spec.ts @@ -33,6 +33,8 @@ const SID = 'fk-s1' as SessionId /** A Session talks through the Gateway client; its dependency cone is the Typert registry and the Connection. */ const API_ROSTER = webApp.closure(['@deepseek-ai/dsh-api-gateway']) const it = createClientTest({ roster: API_ROSTER }) +/** The first client boot pays the cold module transform of the api cone. */ +const COLD_BOOT_TIMEOUT_MS = 60_000 function makeManager(mock: RemoteMock, remote: ClientTestFixtures['remote']): SessionManager { mock.load(sessionWorld) @@ -124,7 +126,7 @@ describe('Session tail-page seeding', () => { } as never))) await session.open() expect(session.projections.get('test/marks')).toEqual({ marks: ['from-baseline'] }) - }) + }, COLD_BOOT_TIMEOUT_MS) it('a resync serving a stale block keeps the newer pushed value (seq rule end to end)', async ({ mock, start }) => { const session = await sessionBench(mock, start, SID) diff --git a/packages/api/session-controller/tests/sessions-service.client.spec.ts b/packages/api/session-controller/tests/sessions-service.client.spec.ts index f0327c6220..f49116158b 100644 --- a/packages/api/session-controller/tests/sessions-service.client.spec.ts +++ b/packages/api/session-controller/tests/sessions-service.client.spec.ts @@ -25,6 +25,8 @@ import { FOLLOW, err, followScript, sessionWorld } from './remote/session.client const sid = (s: string): SessionId => s as SessionId /** ClientSessions uses the Gateway client for stream supervision and the native Remote mocks for responses. */ const API_ROSTER = webApp.closure(['@deepseek-ai/dsh-api-gateway']) +/** The first client boot pays the cold module transform of the api cone. */ +const COLD_BOOT_TIMEOUT_MS = 60_000 interface Bench { ctx: Context @@ -96,7 +98,7 @@ describe('list store projection', () => { displayTitle: 's2', parentId: 's1', origin: 'subagent', running: true, }) expect(state.byId[sid('s2')]?.title).toBeUndefined() - }) + }, COLD_BOOT_TIMEOUT_MS) it('reprojects a blank session from the generic agent-preset projection', async ({ bench }) => { const b = bench() @@ -920,7 +922,7 @@ describe('create', () => { const b = bench() b.mock.remote.session.create.mockResolvedValue(ok({ sessionId: sid('fresh') })) await expect(b.svc.create({ cwd: '/w', sessionId: sid('fresh') })).resolves.toBe('fresh') - expect(b.mock.remote.session.create).toHaveBeenCalledWith({ cwd: '/w', sessionId: 'fresh' }) + expect(b.mock.remote.session.create).toHaveBeenCalledExactlyOnceWith({ cwd: '/w', sessionId: 'fresh' }) b.mock.remote.session.create.mockResolvedValue(err(new RemoteError('gateway/internal', '爆了', {}))) const failure = await b.svc.create({ sessionId: sid('candidate') }).catch((error: unknown) => error) expect(failure).toBeInstanceOf(SessionCreateError) @@ -985,8 +987,8 @@ describe('fork', () => { sessionId: sid('source'), atSeq: 7, increaseTitle: true, })).resolves.toBe('child') - expect(b.mock.remote.session.fork).toHaveBeenCalledWith({ sessionId: 'source', atSeq: 7 }) - expect(b.mock.remote.session.rename).toHaveBeenCalledWith({ sessionId: 'child', title: childTitle }) + expect(b.mock.remote.session.fork).toHaveBeenCalledExactlyOnceWith({ sessionId: 'source', atSeq: 7 }) + expect(b.mock.remote.session.rename).toHaveBeenCalledExactlyOnceWith({ sessionId: 'child', title: childTitle }) await Promise.resolve() expect(b.svc.list.getSnapshot().byId[sid('child')]).toMatchObject({ title: childTitle, @@ -1003,7 +1005,7 @@ describe('fork', () => { // The frozen node of an interrupted turn carries turnEnd.seq - 0.9. await expect(b.svc.fork({ sessionId: sid('source'), atSeq: 41.1 })).resolves.toBe('child') - expect(b.mock.remote.session.fork).toHaveBeenCalledWith({ sessionId: 'source', atSeq: 41 }) + expect(b.mock.remote.session.fork).toHaveBeenCalledExactlyOnceWith({ sessionId: 'source', atSeq: 41 }) }) it('does not rename without the title policy or a durable source title', async ({ bench }) => { From f258024b4d909836dfaf2d6024dc80a013f7308f Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 13 Sep 2026 22:31:18 +0800 Subject: [PATCH 67/79] docs(testing): correct fixture migration guidance --- ...2026-08-03-opt-in-reasoning-chunk-browser-stress.i18n.yaml | 4 ++-- .../2026-08-03-opt-in-reasoning-chunk-browser-stress.md | 4 +++- .../2026-08-03-opt-in-reasoning-chunk-browser-stress.zh.md | 4 +++- apps/web/tests/README.i18n.yaml | 4 ++-- apps/web/tests/README.md | 2 +- apps/web/tests/README.zh.md | 2 +- 6 files changed, 12 insertions(+), 8 deletions(-) diff --git a/.agents/notes/implemented/testing/2026-08-03-opt-in-reasoning-chunk-browser-stress.i18n.yaml b/.agents/notes/implemented/testing/2026-08-03-opt-in-reasoning-chunk-browser-stress.i18n.yaml index 10cf18647b..e8edd6c33e 100644 --- a/.agents/notes/implemented/testing/2026-08-03-opt-in-reasoning-chunk-browser-stress.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-08-03-opt-in-reasoning-chunk-browser-stress.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-08-03-opt-in-reasoning-chunk-browser-stress.md -2026-08-03-opt-in-reasoning-chunk-browser-stress.md: 21d92fa329549b1369ced6b7d92f8099fbe8e7ec -2026-08-03-opt-in-reasoning-chunk-browser-stress.zh.md: f7345543e84fa2a4bc2ff57cd8157e303e79747c +2026-08-03-opt-in-reasoning-chunk-browser-stress.md: 5b679ce2682743531bd3ff48621cbde462a335aa +2026-08-03-opt-in-reasoning-chunk-browser-stress.zh.md: 3d730a4505351be968c71092c65a9b9c6b69818c diff --git a/.agents/notes/implemented/testing/2026-08-03-opt-in-reasoning-chunk-browser-stress.md b/.agents/notes/implemented/testing/2026-08-03-opt-in-reasoning-chunk-browser-stress.md index 21d92fa329..5b679ce268 100644 --- a/.agents/notes/implemented/testing/2026-08-03-opt-in-reasoning-chunk-browser-stress.md +++ b/.agents/notes/implemented/testing/2026-08-03-opt-in-reasoning-chunk-browser-stress.md @@ -18,7 +18,7 @@ The Session Controller appends every Client-only live chunk to its event source, Keeping the live Think row horizontally pinned to the end of the accumulated text is purely visual alignment and does not require synchronous layout reads on every React commit. An in-component scheduler coalesces consecutive requests into one update every three frames, reads `scrollWidth` and `clientWidth` from the latest DOM, and updates `scrollLeft` directly to the latest position; the fixed visual cadence keeps summary changes readable without allowing browser smooth-scroll animations to accumulate. This throttling applies only to Think's horizontal summary and does not delay Chat body scrolling, history-prepend anchoring, or user-triggered `scrollIntoView`. -`pnpm run test:web:stress` remains keyless, opt-in browser performance evidence. A test-owned model adapter emits 100,000 `reasoning-delta` chunks through the real Host, Gateway, WebSocket, Session reduction, and live Think row. Each 128-chunk batch waits for one browser timer turn before the next batch, so the 250-millisecond heartbeat and pre-scheduled DOM event measure one bounded ingestion/render interval instead of accumulated socket backlog. A terminal marker proves that all chunks reached the UI. `DSH_WEB_STRESS_HEADFUL=1` lets developers profile the same scenario in a visible browser with the Performance panel. The stress lane is evidence for manual performance diagnosis and fix acceptance, not a default CI gate or a substitute for deterministic scheduling unit tests. +`pnpm run test:web:stress` remains keyless, opt-in browser performance evidence. A test-owned model adapter emits 100,000 `reasoning-delta` chunks through the real Host, Gateway, WebSocket, Session reduction, and live Think row. Each 128-chunk batch waits for one browser timer turn before the next batch, so a 50-millisecond heartbeat and a pre-scheduled DOM event measure one bounded ingestion/render interval instead of accumulated socket backlog against a 250-millisecond budget. A terminal marker proves that all chunks reached the UI. `DSH_WEB_STRESS_HEADFUL=1` lets developers profile the same scenario in a visible browser with the Performance panel. The stress lane is evidence for manual performance diagnosis and fix acceptance, not a default CI gate or a substitute for deterministic scheduling unit tests. Focused tests pin `Notifier`'s per-frame coalescing, structural-event preemption, invalidated callbacks, and no-rAF fallback, and prove at the `Session` layer that a frame publishes the latest accumulated text only once and that finalization is not followed by a duplicate notification from a stale frame callback. The opt-in browser case owns adapter pacing, exact event count, and terminal-marker delivery without bringing the 100,000-chunk workload into the default test suites. @@ -30,6 +30,8 @@ Focused tests pin `Notifier`'s per-frame coalescing, structural-event preemption **Microtask batching alone.** Rejected: consecutive asynchronous `yield` operations can drain the microtask queue between adjacent chunks, making microtask batching approximate one notification per chunk. +**Pacing the test producer by animation frames.** Rejected: the producer would slow whenever rendering slowed, giving the page implicit backpressure absent from a real network stream and masking main-thread starvation. + **Sending the former browser-local rate through the WebSocket without an acknowledgment.** Rejected: queued transport work accumulates faster than a model can produce it and turns the result into a socket-backlog measurement. A browser timer acknowledgment bounds each batch while the heartbeat still detects a batch that starves the page. **A live external model or recorded HTTP byte stream.** Rejected: an external model is nondeterministic, and an HTTP recording would add a second fixture format without improving the target assertion. The test-owned adapter preserves individual chunks through the real Host and browser transport while controlling the workload. diff --git a/.agents/notes/implemented/testing/2026-08-03-opt-in-reasoning-chunk-browser-stress.zh.md b/.agents/notes/implemented/testing/2026-08-03-opt-in-reasoning-chunk-browser-stress.zh.md index f7345543e8..3d730a4505 100644 --- a/.agents/notes/implemented/testing/2026-08-03-opt-in-reasoning-chunk-browser-stress.zh.md +++ b/.agents/notes/implemented/testing/2026-08-03-opt-in-reasoning-chunk-browser-stress.zh.md @@ -18,7 +18,7 @@ Session Controller 把每个 Client-only live chunk 追加到 event source,Con 实时 Think 行对累计文本的横向跟尾属于纯视觉对齐,不需要在每次 React 提交中同步读取布局。组件内调度器将连续请求合并为每三帧一次,从最新 DOM 读取 `scrollWidth` 和 `clientWidth` 并将 `scrollLeft` 直接更新到最新位置;固定的视觉节奏让摘要变化可读,又不会积压浏览器平滑滚动动画。该节流只作用于 Think 的横向摘要,不延迟 Chat 正文滚动、历史 prepend 锚定或用户触发的 `scrollIntoView`。 -`pnpm run test:web:stress` 保留为无密钥、需显式启用的浏览器性能证据。测试持有的模型 adapter 通过真实 Host、Gateway、WebSocket、Session 归并和实时 Think 行发出 100,000 个 `reasoning-delta` chunk。每批 128 个 chunk 会等待一次浏览器 timer 轮次后再发下一批,因此 250 毫秒心跳与预先调度的 DOM 事件测量的是一个有界接收/渲染区间,而不是累积的 socket backlog。结尾标记证明所有 chunk 都已到达 UI。`DSH_WEB_STRESS_HEADFUL=1` 允许开发者在可见浏览器中使用 Performance 面板分析同一场景。该压力车道是手动性能诊断与修复验收的证据,不是默认 CI 门禁,也不替代确定性的调度单元测试。 +`pnpm run test:web:stress` 保留为无密钥、需显式启用的浏览器性能证据。测试持有的模型 adapter 通过真实 Host、Gateway、WebSocket、Session 归并和实时 Think 行发出 100,000 个 `reasoning-delta` chunk。每批 128 个 chunk 会等待一次浏览器 timer 轮次后再发下一批,因此 50 毫秒心跳与预先调度的 DOM 事件按 250 毫秒预算测量一个有界接收/渲染区间,而不是累积的 socket backlog。结尾标记证明所有 chunk 都已到达 UI。`DSH_WEB_STRESS_HEADFUL=1` 允许开发者在可见浏览器中使用 Performance 面板分析同一场景。该压力车道是手动性能诊断与修复验收的证据,不是默认 CI 门禁,也不替代确定性的调度单元测试。 聚焦测试固定 `Notifier` 的逐帧合并、结构事件抢占、失效回调和无 rAF 回退,并在 `Session` 层证明一帧只发布一次最新累计文本且定稿不会被旧帧回调重复通知。按需启用的浏览器用例持有 adapter 节奏、精确事件数和结尾标记交付,不把 100,000 分片工作负载带入默认测试套件。 @@ -30,6 +30,8 @@ Session Controller 把每个 Client-only live chunk 追加到 event source,Con **只使用微任务合批。** 不予采纳:连续异步 `yield` 会在相邻分片间排空微任务队列,使微任务合批近似退化为每个分片通知一次。 +**按动画帧控制测试生产方节奏。** 不予采纳:生产方会在渲染变慢时同步减速,使页面获得真实网络流不存在的隐式背压,并掩盖主线程饥饿。 + **不等待确认就把原有浏览器本地速率发入 WebSocket。** 不予采纳:transport 工作的排队速度会超过模型可能达到的产出速度,使结果变成 socket backlog 测量。浏览器 timer 确认限制每批工作量,同时 heartbeat 仍能发现一批数据使页面饥饿。 **真实外部模型或录制的 HTTP 字节流。** 不予采纳:外部模型不具确定性,HTTP 录制还会增加第二种 fixture 格式,却不会改进目标断言。测试持有的 adapter 让每个 chunk 经过真实 Host 与浏览器 transport,同时控制工作负载。 diff --git a/apps/web/tests/README.i18n.yaml b/apps/web/tests/README.i18n.yaml index 4a06bf19ed..cf707e6cb3 100644 --- a/apps/web/tests/README.i18n.yaml +++ b/apps/web/tests/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/web/tests/README.md -README.md: 883d93d746afa6bac0b67f2c2d6bc6c03b31553a -README.zh.md: 7a232329b890a669240272aada3a5bafff9efd99 +README.md: b2fa27bb7a60ed78309bf81e77b9cd5217bbee2a +README.zh.md: 29ae2e86018257a4fc2379ea2563c8b5e5e9036f diff --git a/apps/web/tests/README.md b/apps/web/tests/README.md index 883d93d746..b2fa27bb7a 100644 --- a/apps/web/tests/README.md +++ b/apps/web/tests/README.md @@ -18,6 +18,6 @@ Importing a Client package — a value or a type — pulls its whole TypeScript When a scenario needs a Client-owned constant or pure function, mirror it here instead, next to the commented-out import that names the source module. A drift then surfaces as a missed selector or a stale mirrored value — a loud failure, never a silent pass. `scaffold.ts` follows this rule for the welcome-notice namespace, acknowledgement field, version, and asserted Chinese copy. -One kind of Client import stands. `assembled-boot.ts` drives the shell itself, so it imports `AppWebEntry` from `@deepseek-ai/dsh-client-web` and the boot-manifest type from `@deepseek-ai/dsh-client-modules/client`: booting the real shell is what that harness is for, and both packages are already in the Host graph. The chat scenarios mirror `conversationContextKey` in `support.ts` instead of importing its Client owner. +The built-client harness is the exception. `assembled-boot.ts` imports `AppWebEntry`, the boot-manifest type, and `RemoteMock`; `assembled-remote.ts` imports the Client test runtime's default responses and `RemoteMock`. These packages are explicit project references for booting the real shell against a test-owned carrier. The chat scenarios mirror `conversationContextKey` in `support.ts` instead of importing its Client owner. Nothing mechanically enforces this rule; keep it in review. diff --git a/apps/web/tests/README.zh.md b/apps/web/tests/README.zh.md index 7a232329b8..29ae2e8601 100644 --- a/apps/web/tests/README.zh.md +++ b/apps/web/tests/README.zh.md @@ -18,6 +18,6 @@ import 一个 Client 包——无论值还是类型——都会把它整个 Type 当某个场景需要 Client 持有的常量或纯函数时,改为在此处镜像一份,并紧挨着一条注释掉的 import 点明源模块。这样漂移会表现为选择器未命中或镜像值陈旧——是响亮的失败,绝不会是静默通过。`scaffold.ts` 按此规则镜像 welcome-notice 的 namespace、确认字段、版本和被断言的中文文案。 -有一类 Client import 是长期成立的。`assembled-boot.ts` 驱动 shell 本身,因此它从 `@deepseek-ai/dsh-client-web` import `AppWebEntry`、从 `@deepseek-ai/dsh-client-modules/client` import boot manifest(元数据清单)类型:启动真实 shell 正是该 harness 的用途,且这两个包本来就在 Host 图中。chat 场景则在 `support.ts` 中镜像 `conversationContextKey`,而不 import 其 Client owner。 +built-client harness 是例外。`assembled-boot.ts` import `AppWebEntry`、boot manifest(元数据清单)类型与 `RemoteMock`;`assembled-remote.ts` import Client test runtime 的默认响应与 `RemoteMock`。这些包是显式的工程引用,用于通过测试持有的 carrier 启动真实 shell。chat 场景仍在 `support.ts` 中镜像 `conversationContextKey`,而不 import 其 Client owner。 没有任何机制强制这条规则;靠 review 守住它。 From 27d52a4edb2a2c5073e7b09f3a9425deb1e13bdf Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 14 Sep 2026 10:21:54 +0800 Subject: [PATCH 68/79] Update image tests for split adapters and durable offload failures --- .../tests/image-offload.spec.ts | 2 +- .../tests/messages/adapter.spec.ts | 6 ++--- .../llm-deepseek/tests/messages/files.spec.ts | 8 +++---- .../tests/messages/serialize.spec.ts | 23 ++++++++++++------- 4 files changed, 23 insertions(+), 16 deletions(-) diff --git a/packages/compaction/compaction-image-offload/tests/image-offload.spec.ts b/packages/compaction/compaction-image-offload/tests/image-offload.spec.ts index d0130034c9..59ecd36568 100644 --- a/packages/compaction/compaction-image-offload/tests/image-offload.spec.ts +++ b/packages/compaction/compaction-image-offload/tests/image-offload.spec.ts @@ -10,7 +10,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import BasicCompactionEngine from '@deepseek-ai/dsh-compaction-basic' import TokenMeter from '@deepseek-ai/dsh-token-meter' import { ImageVariantId } from '@deepseek-ai/dsh-attachment' -import { serializeRequestWithImages } from '@deepseek-ai/dsh-llm-deepseek/src/serialize.ts' +import { serializeRequestWithImages } from '@deepseek-ai/dsh-llm-deepseek/src/protocols/chat-completions/serialize.ts' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { createAssistantMessage, createToolResultMessage, createUserMessage, IMAGE_OFFLOAD_REQUIRED_CODE, LlmAdapter, LlmError, ToolCallId } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' diff --git a/packages/llm/llm-deepseek/tests/messages/adapter.spec.ts b/packages/llm/llm-deepseek/tests/messages/adapter.spec.ts index c88c9b2d18..5ecae3f9e8 100644 --- a/packages/llm/llm-deepseek/tests/messages/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/messages/adapter.spec.ts @@ -173,10 +173,10 @@ describe('Cordis provider composition', () => { const model = 'deepseek-v4-flash-vision-exp' const price = () => ctx.llm.imageRequestPricing('deepseek-official', model)! const dummy = { attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), width: 1, height: 1, bytes: 3, mediaType: 'image/png' as const } - expect(price().priceImages([dummy])[0]?.text).toBeDefined() + expect(price().priceImages([{ type: 'image', attachment: dummy }])[0]?.text).toBeDefined() await ctx.plugin(LocalAttachments, { dshHome: home }) const attachment = await ctx.attachments.saveImage({ data: await readFile(new URL('fixtures/red.png', import.meta.url)), mediaType: 'image/png' }) - expect(price().priceImages([attachment])[0]?.text).not.toContain('/mounted/image.png') + expect(price().priceImages([{ type: 'image', attachment }])[0]?.text).not.toContain('/mounted/image.png') class MappedFiles extends Service { constructor(context: Context) { super(context, 'fs') } processPathFromHostPath(_path: string) { return '/mounted/image.png' } @@ -185,7 +185,7 @@ describe('Cordis provider composition', () => { const message = user() await chunks(ctx.llm.stream(options({ model, messages: [{ ...message, content: [...message.content, { type: 'image', attachment }] }] }))) expect(JSON.stringify(http.requests[0]?.body)).toContain('/mounted/image.png') - expect(price().priceImages([attachment])[0]?.text).toContain('/mounted/image.png') + expect(price().priceImages([{ type: 'image', attachment }])[0]?.text).toContain('/mounted/image.png') }) async function boot(...args: Parameters) { diff --git a/packages/llm/llm-deepseek/tests/messages/files.spec.ts b/packages/llm/llm-deepseek/tests/messages/files.spec.ts index b07b16b20f..1b5ee31941 100644 --- a/packages/llm/llm-deepseek/tests/messages/files.spec.ts +++ b/packages/llm/llm-deepseek/tests/messages/files.spec.ts @@ -123,7 +123,7 @@ describe('Messages Files requests', () => { await chunks(h.adapter.stream(original)) expect(body(fetchImpl.mock.calls[0]?.[1]).match(/"file_id"/gu)).toHaveLength(2) h.ensureUploaded.mockRejectedValueOnce(new Error('offline')) - await expect(chunks(h.adapter.stream(original))).rejects.toMatchObject({ code: 'IMAGE_OFFLOAD_REQUIRED', offloadImages: 1 }) + await expect(chunks(h.adapter.stream(original))).rejects.toMatchObject({ failure: { code: 'IMAGE_OFFLOAD_REQUIRED', offloadImages: 1 } }) expect(fetchImpl).toHaveBeenCalledTimes(1) h.ensureUploaded.mockRejectedValueOnce(new Error('offline')) await chunks(h.adapter.stream(options({ model, messages: [{ ...user(), content: [ @@ -138,7 +138,7 @@ describe('Messages Files requests', () => { it('reads and prices only the retained occurrences selected by the logged offload', async () => { vi.stubGlobal('fetch', async () => success()) const h = harness({ maxRequestFilesBytes: 3, imageOffloadByteQuantum: 1, maxImagesPerRequest: 2, imageOffloadCountQuantum: 1 }) - const images = [{ type: 'image' as const, attachment: ref, offloaded: true }, { type: 'image' as const, attachment: second }] + const images = [{ type: 'image' as const, attachment: ref, offloaded: true as const }, { type: 'image' as const, attachment: second }] await chunks(h.adapter.stream(options({ model, messages: [{ ...user(), content: images }] }))) expect(h.readImageRequest).toHaveBeenCalledExactlyOnceWith(second, expect.anything(), expect.any(AbortSignal)) expect(h.adapter.imageRequestPricing('deepseek-official', model).priceImages(images).map(image => image.visualTokens)).toEqual([0, expect.any(Number)]) @@ -153,7 +153,7 @@ describe('Messages Files requests', () => { const h = harness(config) const original = request([ref, ref]) const saved = JSON.stringify(original.messages) - await expect(chunks(h.adapter.stream(original))).rejects.toMatchObject({ code: 'IMAGE_OFFLOAD_REQUIRED', offloadImages: 1 }) + await expect(chunks(h.adapter.stream(original))).rejects.toMatchObject({ failure: { code: 'IMAGE_OFFLOAD_REQUIRED', offloadImages: 1 } }) expect(h.readImageRequest).toHaveBeenCalledTimes(1) expect(h.ensureUploaded).not.toHaveBeenCalled() expect(fetchImpl).not.toHaveBeenCalled() @@ -163,7 +163,7 @@ describe('Messages Files requests', () => { it('uses exact prepared bytes when the durable reference fits the Files budget', async () => { const h = harness({ maxRequestFilesBytes: 3, imageOffloadByteQuantum: 1, imageOffloadCountQuantum: 1 }) h.readImageRequest.mockResolvedValue({ ...version(ref), bytes: 4, data: Uint8Array.of(1, 2, 3, 4) }) - await expect(chunks(h.adapter.stream(request()))).rejects.toMatchObject({ code: 'IMAGE_OFFLOAD_REQUIRED', offloadImages: 1 }) + await expect(chunks(h.adapter.stream(request()))).rejects.toMatchObject({ failure: { code: 'IMAGE_OFFLOAD_REQUIRED', offloadImages: 1 } }) expect(h.ensureUploaded).not.toHaveBeenCalled() }) diff --git a/packages/llm/llm-deepseek/tests/messages/serialize.spec.ts b/packages/llm/llm-deepseek/tests/messages/serialize.spec.ts index 347030f16d..ada2e7207b 100644 --- a/packages/llm/llm-deepseek/tests/messages/serialize.spec.ts +++ b/packages/llm/llm-deepseek/tests/messages/serialize.spec.ts @@ -1,7 +1,7 @@ /** Request conversion and durable replay validation. */ import { describe, expect, it, vi } from 'vitest' import { createAssistantMessage, createMessage, createSystemMessage, createToolResultMessage, ReasoningEffortId, ToolCallId } from '@deepseek-ai/dsh-llm' -import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, GenerateOptions, ImageBlock, Message } from '@deepseek-ai/dsh-llm' import { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment' import type { AttachmentStore, ImageAttachmentRef, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' import { resolveAdapterOptions } from '../../src/config.ts' @@ -248,7 +248,7 @@ describe('validated configuration', () => { describe('Messages images', () => { const ref: ImageAttachmentRef = { attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), mediaType: 'image/png', width: 1, height: 1, bytes: 3 } - const image: ContentBlock = { type: 'image', attachment: ref } + const image: ImageBlock = { type: 'image', attachment: ref } const version: RequestImageAttachment = { attachment: ref, variantId: ImageVariantId(`sha256:${'b'.repeat(64)}`), mediaType: 'image/png', bytes: 3, data: Uint8Array.of(1, 2, 3), width: 1, height: 1, depth: 'uchar', space: 'srgb', hasAlpha: false } const access = () => ({ readonlyPath: '/workspace/image.png' }) const model = 'deepseek-v4-flash-vision-exp' @@ -264,23 +264,30 @@ describe('Messages images', () => { { type: 'text', text: expect.stringContaining('/workspace/image.png') as string }, { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AQID' } }, { type: 'text' }, { type: 'image' }, ] }) - expect(imagePricing(connection, model, access).priceImages([ref])[0]?.visualTokens).toBeGreaterThan(0) - expect(imagePricing(connection, MODEL, access).priceImages([ref])[0]?.visualTokens).toBe(0) + expect(imagePricing(connection, model, access).priceImages([image])[0]?.visualTokens).toBeGreaterThan(0) + expect(imagePricing(connection, MODEL, access).priceImages([image])[0]?.visualTokens).toBe(0) }) - it('offloads an oldest prefix using exact encoded bytes and preserves durable references', async () => { + it('requires logged offload at exact encoded bytes and preserves durable references', async () => { const config = resolveAdapterOptions({ maxInlineRequestImageBytes: 4, inlineImageOffloadByteQuantum: 1, maxImagesPerRequest: 2, imageOffloadCountQuantum: 1, }) const history = [result('a', [image, image])] const prepared = await prepareImages(history, config, model, attachments, access, signal) expect(prepared.messages[0]?.content[0]).toMatchObject({ content: [image, image] }) - expect(inlineImages(prepared.messages, prepared.versions, config, access)[0]?.content[0]).toMatchObject({ content: [{ type: 'text' }, { type: 'image' }] }) + expect(() => inlineImages(prepared.messages, prepared.versions, config)).toThrow(expect.objectContaining({ + failure: expect.objectContaining({ code: 'IMAGE_OFFLOAD_REQUIRED', offloadImages: 1 }) as unknown, + })) + const offloaded: ImageBlock = { ...image, offloaded: true } + const retry = await prepareImages([result('a', [offloaded, image])], config, model, attachments, access, signal) + expect(inlineImages(retry.messages, retry.versions, config)[0]?.content[0]).toMatchObject({ content: [{ type: 'text' }, { type: 'image' }] }) expect(history[0]?.content[0]).toMatchObject({ content: [image, image] }) - expect(imagePricing(config, model, access).priceImages([ref, ref]).map(entry => entry.visualTokens)) + expect(imagePricing(config, model, access).priceImages([image, image]).map(entry => entry.visualTokens)) .toEqual([expect.any(Number), expect.any(Number)]) const large = { readImageRequest: async () => ({ ...version, bytes: 30, data: new Uint8Array(30) }) } as unknown as AttachmentStore const exact = await prepareImages([result('a', [image])], config, model, large, access, signal) - expect(inlineImages(exact.messages, exact.versions, config, access)[0]?.content[0]).toMatchObject({ content: [{ type: 'text' }] }) + expect(() => inlineImages(exact.messages, exact.versions, config)).toThrow(expect.objectContaining({ + failure: expect.objectContaining({ code: 'IMAGE_OFFLOAD_REQUIRED', offloadImages: 1 }) as unknown, + })) }) it('rejects unsupported roles and unavailable image capabilities before HTTP', async () => { const history = [result('a', [image])] From 2b1dabec86b4283180e160486beb71b931a4dc83 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 14 Sep 2026 10:25:54 +0800 Subject: [PATCH 69/79] Refresh merged SDK recordings and persistence documentation --- .../2026-09-14-image-offload.i18n.yaml | 4 +- .../2026-09-14-image-offload.md | 2 +- .../2026-09-14-image-offload.zh.md | 2 +- .../historical-formats/README.i18n.yaml | 4 +- .../historical-formats/README.md | 2 +- .../historical-formats/README.zh.md | 2 +- packages/core/session/src/surface.ts | 2 +- .../advanced/result.json | 236 +++++++++--------- .../advanced/session.v3.jsonl | 35 +-- 9 files changed, 146 insertions(+), 143 deletions(-) diff --git a/docs/persistence-changes/2026-09-14-image-offload.i18n.yaml b/docs/persistence-changes/2026-09-14-image-offload.i18n.yaml index 036debd4f4..11fc8092c4 100644 --- a/docs/persistence-changes/2026-09-14-image-offload.i18n.yaml +++ b/docs/persistence-changes/2026-09-14-image-offload.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/persistence-changes/2026-09-14-image-offload.md -2026-09-14-image-offload.md: 55922a030a80f5a6d6be0191b78762ed5b4793d9 -2026-09-14-image-offload.zh.md: 33f322b15fa98b9d06f3cc29493844aceaeb5a26 +2026-09-14-image-offload.md: 1d4f47f5a25cc9dd39ad16f111af7f30d906a297 +2026-09-14-image-offload.zh.md: b184b32e96d04a425eda6faf9f5d50f815a9c32a diff --git a/docs/persistence-changes/2026-09-14-image-offload.md b/docs/persistence-changes/2026-09-14-image-offload.md index 55922a030a..1d4f47f5a2 100644 --- a/docs/persistence-changes/2026-09-14-image-offload.md +++ b/docs/persistence-changes/2026-09-14-image-offload.md @@ -88,7 +88,7 @@ Existing logs remain readable. The optional offloaded image field leaves unmarke ## Verification -Session and image-offload tests cover event validation, immutable message projection, missing interpreters, restore, and retry. TypeScript image snapshots and the Python advanced SDK recording cover the event through shipped profiles. Validation of the merged branch is pending. +Session and image-offload tests cover event validation, immutable message projection, missing interpreters, restore, and retry. The focused 1,144-test run and two TypeScript image snapshots pass. The Python advanced SDK recording was refreshed through the built dsh profile and includes the standalone image/offload event. ## Dev Note diff --git a/docs/persistence-changes/2026-09-14-image-offload.zh.md b/docs/persistence-changes/2026-09-14-image-offload.zh.md index 33f322b15f..b184b32e96 100644 --- a/docs/persistence-changes/2026-09-14-image-offload.zh.md +++ b/docs/persistence-changes/2026-09-14-image-offload.zh.md @@ -88,7 +88,7 @@ changes: ## 验证 -Session 和图片省略测试覆盖事件校验、不可变消息投影、缺少处理器、恢复和重试。TypeScript 图片快照与 Python advanced SDK 录制通过发布的 profile 覆盖该事件。合并后分支的验证尚未运行。 +Session 和图片省略测试覆盖事件校验、不可变消息投影、缺少处理器、恢复和重试。所选的 1,144 项测试和两项 TypeScript 图片快照通过。Python advanced SDK 录制已通过构建后的 dsh profile 刷新,包含独立的 image/offload 事件。 ## 开发备注 diff --git a/docs/persistence-changes/historical-formats/README.i18n.yaml b/docs/persistence-changes/historical-formats/README.i18n.yaml index b404f89378..1e25621ff8 100644 --- a/docs/persistence-changes/historical-formats/README.i18n.yaml +++ b/docs/persistence-changes/historical-formats/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/persistence-changes/historical-formats/README.md -README.md: 8dc2caf4409e693c8b68c4e49db67b6c3234c238 -README.zh.md: 36095f9872ed6a2bd72d348be280f6d4534aeea7 +README.md: 35fa4cec9c960e8d0e047c42e35dad83992ac8d0 +README.zh.md: 7dd2af55d1d68c108d678e6e0849f7d679da929d diff --git a/docs/persistence-changes/historical-formats/README.md b/docs/persistence-changes/historical-formats/README.md index 8dc2caf440..35fa4cec9c 100644 --- a/docs/persistence-changes/historical-formats/README.md +++ b/docs/persistence-changes/historical-formats/README.md @@ -31,7 +31,7 @@ The index is generated from validated snapshots and the current writer constant. | 0 | `dsh-v0.1.2-rc.1` | [V0](v0.md) | [JSON](v0.schema.json) | 54 / 415 | | 1 | PR #3349 | [V1](v1.md) | [JSON](v1.schema.json) | 54 / 415 | | 2 | `dsh-v0.1.3-alpha.2` | [V2](v2.md) | [JSON](v2.schema.json) | 56 / 435 | -| 3 | Current checkout | [Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json) | 59 / 463 | +| 3 | Current checkout | [Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json) | 60 / 468 | diff --git a/docs/persistence-changes/historical-formats/README.zh.md b/docs/persistence-changes/historical-formats/README.zh.md index 36095f9872..7dd2af55d1 100644 --- a/docs/persistence-changes/historical-formats/README.zh.md +++ b/docs/persistence-changes/historical-formats/README.zh.md @@ -31,7 +31,7 @@ description: "查找从 V0 到工作区写入器版本的每个 Session 格式 | 0 | `dsh-v0.1.2-rc.1` | [V0](v0.zh.md) | [JSON](v0.schema.json) | 54 / 415 | | 1 | PR #3349 | [V1](v1.zh.md) | [JSON](v1.schema.json) | 54 / 415 | | 2 | `dsh-v0.1.3-alpha.2` | [V2](v2.zh.md) | [JSON](v2.schema.json) | 56 / 435 | -| 3 | 当前工作树 | [当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json) | 59 / 463 | +| 3 | 当前工作树 | [当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json) | 60 / 468 | diff --git a/packages/core/session/src/surface.ts b/packages/core/session/src/surface.ts index decf002dd9..9bc3d32a8c 100644 --- a/packages/core/session/src/surface.ts +++ b/packages/core/session/src/surface.ts @@ -539,7 +539,7 @@ function applySurfacePlan( * @param events - session events in contiguous seq order. * @param projections - pure interpreters for plugin-owned message changes; required definitions must be supplied. * @returns detached current sequences and replacement history. - * @throws when an interpreter is missing or an event violates its projection, surface metadata, provenance, or replacement rules. + * @throws when an interpreter is missing or an event violates its projection, surface metadata, source attribution, or replacement rules. */ export function foldSurface(events: readonly SessionEvent[], projections: readonly SessionMessageProjection[] = []): SurfaceFoldResult { const state = createFoldState() diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/result.json b/scripts/snapshots/python-sdk-single-exe/advanced/result.json index 30bc62a79f..c6dbc30ad6 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/result.json +++ b/scripts/snapshots/python-sdk-single-exe/advanced/result.json @@ -1131,7 +1131,7 @@ }, { "type": "tool/call", - "seq": 37, + "seq": 41, "time": 0, "data": { "turn": 1, @@ -1143,7 +1143,7 @@ }, { "type": "tool/result", - "seq": 38, + "seq": 42, "time": 0, "data": { "turn": 1, @@ -1176,13 +1176,13 @@ } }, "sourceEventSeqs": [ - 37 + 41 ], "surfaceOp": "append" }, { "type": "step/end", - "seq": 39, + "seq": 43, "time": 0, "data": { "turn": 1, @@ -1191,7 +1191,7 @@ }, { "type": "step/start", - "seq": 40, + "seq": 44, "time": 0, "data": { "turn": 1, @@ -1200,17 +1200,17 @@ }, { "type": "session-log-deepseek/delivery-accepted", - "seq": 41, + "seq": 45, "time": 0, "data": { "sessionId": "{{parent}}", "sessionFormatVersion": 3, - "throughSeq": 40 + "throughSeq": 44 } }, { "type": "assistant/message", - "seq": 42, + "seq": 46, "time": 0, "data": { "turn": 1, @@ -1334,7 +1334,7 @@ }, { "type": "tool/call", - "seq": 43, + "seq": 47, "time": 0, "data": { "turn": 1, @@ -1346,7 +1346,7 @@ }, { "type": "tool/ptc-dispatch-start", - "seq": 44, + "seq": 48, "time": 0, "data": { "rootCallId": "advanced-denied-ptc", @@ -1360,7 +1360,7 @@ }, { "type": "tool/ptc-dispatch", - "seq": 45, + "seq": 49, "time": 0, "data": { "rootCallId": "advanced-denied-ptc", @@ -1386,7 +1386,7 @@ }, { "type": "tool/result", - "seq": 46, + "seq": 50, "time": 0, "data": { "turn": 1, @@ -1414,13 +1414,13 @@ } }, "sourceEventSeqs": [ - 43 + 47 ], "surfaceOp": "append" }, { "type": "step/end", - "seq": 47, + "seq": 51, "time": 0, "data": { "turn": 1, @@ -1429,7 +1429,7 @@ }, { "type": "step/start", - "seq": 48, + "seq": 52, "time": 0, "data": { "turn": 1, @@ -1438,17 +1438,17 @@ }, { "type": "session-log-deepseek/delivery-accepted", - "seq": 49, + "seq": 53, "time": 0, "data": { "sessionId": "{{parent}}", "sessionFormatVersion": 3, - "throughSeq": 48 + "throughSeq": 52 } }, { "type": "assistant/message", - "seq": 50, + "seq": 54, "time": 0, "data": { "turn": 1, @@ -1572,7 +1572,7 @@ }, { "type": "tool/call", - "seq": 51, + "seq": 55, "time": 0, "data": { "turn": 1, @@ -1584,7 +1584,7 @@ }, { "type": "subagent/catalog", - "seq": 52, + "seq": 56, "time": 0, "data": { "version": 0, @@ -1596,7 +1596,7 @@ }, { "type": "tool/result", - "seq": 53, + "seq": 57, "time": 0, "data": { "turn": 1, @@ -1624,13 +1624,13 @@ } }, "sourceEventSeqs": [ - 51 + 55 ], "surfaceOp": "append" }, { "type": "step/end", - "seq": 54, + "seq": 58, "time": 0, "data": { "turn": 1, @@ -1639,7 +1639,7 @@ }, { "type": "step/start", - "seq": 55, + "seq": 59, "time": 0, "data": { "turn": 1, @@ -1648,17 +1648,17 @@ }, { "type": "session-log-deepseek/delivery-accepted", - "seq": 56, + "seq": 60, "time": 0, "data": { "sessionId": "{{parent}}", "sessionFormatVersion": 3, - "throughSeq": 55 + "throughSeq": 59 } }, { "type": "assistant/message", - "seq": 57, + "seq": 61, "time": 0, "data": { "turn": 1, @@ -1782,7 +1782,7 @@ }, { "type": "tool/call", - "seq": 58, + "seq": 62, "time": 0, "data": { "turn": 1, @@ -1794,7 +1794,7 @@ }, { "type": "tool-workflow/run-start", - "seq": 59, + "seq": 63, "time": 0, "data": { "runId": "{{workflow-run}}", @@ -1803,7 +1803,7 @@ }, { "type": "subagent/catalog", - "seq": 60, + "seq": 64, "time": 0, "data": { "version": 0, @@ -1814,7 +1814,7 @@ }, { "type": "tool-workflow/agent-start", - "seq": 61, + "seq": 65, "time": 0, "data": { "runId": "{{workflow-run}}", @@ -1826,7 +1826,7 @@ }, { "type": "tool-workflow/agent-end", - "seq": 62, + "seq": 66, "time": 0, "data": { "runId": "{{workflow-run}}", @@ -1836,7 +1836,7 @@ }, { "type": "tool-workflow/run-end", - "seq": 63, + "seq": 67, "time": 0, "data": { "runId": "{{workflow-run}}", @@ -1845,7 +1845,7 @@ }, { "type": "tool/result", - "seq": 64, + "seq": 68, "time": 0, "data": { "turn": 1, @@ -1873,13 +1873,13 @@ } }, "sourceEventSeqs": [ - 58 + 62 ], "surfaceOp": "append" }, { "type": "step/end", - "seq": 65, + "seq": 69, "time": 0, "data": { "turn": 1, @@ -1888,7 +1888,7 @@ }, { "type": "step/start", - "seq": 66, + "seq": 70, "time": 0, "data": { "turn": 1, @@ -1897,17 +1897,17 @@ }, { "type": "session-log-deepseek/delivery-accepted", - "seq": 67, + "seq": 71, "time": 0, "data": { "sessionId": "{{parent}}", "sessionFormatVersion": 3, - "throughSeq": 66 + "throughSeq": 70 } }, { "type": "assistant/message", - "seq": 68, + "seq": 72, "time": 0, "data": { "turn": 1, @@ -2031,7 +2031,7 @@ }, { "type": "tool/call", - "seq": 69, + "seq": 73, "time": 0, "data": { "turn": 1, @@ -2043,7 +2043,7 @@ }, { "type": "tool/result", - "seq": 70, + "seq": 74, "time": 0, "data": { "turn": 1, @@ -2071,13 +2071,13 @@ } }, "sourceEventSeqs": [ - 69 + 73 ], "surfaceOp": "append" }, { "type": "step/end", - "seq": 71, + "seq": 75, "time": 0, "data": { "turn": 1, @@ -2086,7 +2086,7 @@ }, { "type": "step/start", - "seq": 72, + "seq": 76, "time": 0, "data": { "turn": 1, @@ -2095,7 +2095,7 @@ }, { "type": "system/message", - "seq": 73, + "seq": 77, "time": 0, "data": { "turn": 1, @@ -2126,7 +2126,7 @@ }, { "type": "request/header", - "seq": 74, + "seq": 78, "time": 0, "data": { "header": { @@ -2162,17 +2162,17 @@ }, { "type": "session-log-deepseek/delivery-accepted", - "seq": 75, + "seq": 79, "time": 0, "data": { "sessionId": "{{parent}}", "sessionFormatVersion": 3, - "throughSeq": 74 + "throughSeq": 78 } }, { "type": "assistant/message", - "seq": 76, + "seq": 80, "time": 0, "data": { "turn": 1, @@ -2280,7 +2280,7 @@ }, { "type": "step/end", - "seq": 77, + "seq": 81, "time": 0, "data": { "turn": 1, @@ -2289,7 +2289,7 @@ }, { "type": "command/run", - "seq": 78, + "seq": 82, "time": 0, "data": { "commandId": "{{command:1}}", @@ -2301,7 +2301,7 @@ }, { "type": "feedback/record", - "seq": 79, + "seq": 83, "time": 0, "data": { "text": "The session needs a clearer explanation." @@ -2309,7 +2309,7 @@ }, { "type": "command/done", - "seq": 80, + "seq": 84, "time": 0, "data": { "commandId": "{{command:1}}", @@ -2319,7 +2319,7 @@ }, { "type": "feedback/record", - "seq": 81, + "seq": 85, "time": 0, "data": { "category": "other" @@ -2327,7 +2327,7 @@ }, { "type": "feedback/message-put", - "seq": 82, + "seq": 86, "time": 0, "data": { "sessionId": "{{parent}}", @@ -2344,7 +2344,7 @@ }, { "type": "feedback/message-put", - "seq": 83, + "seq": 87, "time": 0, "data": { "sessionId": "{{parent}}", @@ -2360,7 +2360,7 @@ }, { "type": "feedback/message-delete", - "seq": 84, + "seq": 88, "time": 0, "data": { "sessionId": "{{parent}}", @@ -2369,7 +2369,7 @@ }, { "type": "turn/end", - "seq": 85, + "seq": 89, "time": 0, "data": { "turn": 1, @@ -3748,7 +3748,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/call", - "seq": 37, + "seq": 41, "time": 0, "data": { "turn": 1, @@ -3766,7 +3766,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/result", - "seq": 38, + "seq": 42, "time": 0, "data": { "turn": 1, @@ -3799,7 +3799,7 @@ } }, "sourceEventSeqs": [ - 37 + 41 ], "surfaceOp": "append" } @@ -3811,7 +3811,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/end", - "seq": 39, + "seq": 43, "time": 0, "data": { "turn": 1, @@ -3826,7 +3826,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/start", - "seq": 40, + "seq": 44, "time": 0, "data": { "turn": 1, @@ -3841,12 +3841,12 @@ "sessionId": "{{parent}}", "event": { "type": "session-log-deepseek/delivery-accepted", - "seq": 41, + "seq": 45, "time": 0, "data": { "sessionId": "{{parent}}", "sessionFormatVersion": 3, - "throughSeq": 40 + "throughSeq": 44 } } } @@ -3857,7 +3857,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/message", - "seq": 42, + "seq": 46, "time": 0, "data": { "turn": 1, @@ -3987,7 +3987,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/call", - "seq": 43, + "seq": 47, "time": 0, "data": { "turn": 1, @@ -4005,7 +4005,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/ptc-dispatch-start", - "seq": 44, + "seq": 48, "time": 0, "data": { "rootCallId": "advanced-denied-ptc", @@ -4025,7 +4025,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/ptc-dispatch", - "seq": 45, + "seq": 49, "time": 0, "data": { "rootCallId": "advanced-denied-ptc", @@ -4057,7 +4057,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/result", - "seq": 46, + "seq": 50, "time": 0, "data": { "turn": 1, @@ -4085,7 +4085,7 @@ } }, "sourceEventSeqs": [ - 43 + 47 ], "surfaceOp": "append" } @@ -4097,7 +4097,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/end", - "seq": 47, + "seq": 51, "time": 0, "data": { "turn": 1, @@ -4112,7 +4112,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/start", - "seq": 48, + "seq": 52, "time": 0, "data": { "turn": 1, @@ -4127,12 +4127,12 @@ "sessionId": "{{parent}}", "event": { "type": "session-log-deepseek/delivery-accepted", - "seq": 49, + "seq": 53, "time": 0, "data": { "sessionId": "{{parent}}", "sessionFormatVersion": 3, - "throughSeq": 48 + "throughSeq": 52 } } } @@ -4143,7 +4143,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/message", - "seq": 50, + "seq": 54, "time": 0, "data": { "turn": 1, @@ -4273,7 +4273,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/call", - "seq": 51, + "seq": 55, "time": 0, "data": { "turn": 1, @@ -4366,7 +4366,7 @@ "sessionId": "{{parent}}", "event": { "type": "subagent/catalog", - "seq": 52, + "seq": 56, "time": 0, "data": { "version": 0, @@ -4775,7 +4775,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/result", - "seq": 53, + "seq": 57, "time": 0, "data": { "turn": 1, @@ -4803,7 +4803,7 @@ } }, "sourceEventSeqs": [ - 51 + 55 ], "surfaceOp": "append" } @@ -4815,7 +4815,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/end", - "seq": 54, + "seq": 58, "time": 0, "data": { "turn": 1, @@ -4830,7 +4830,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/start", - "seq": 55, + "seq": 59, "time": 0, "data": { "turn": 1, @@ -4845,12 +4845,12 @@ "sessionId": "{{parent}}", "event": { "type": "session-log-deepseek/delivery-accepted", - "seq": 56, + "seq": 60, "time": 0, "data": { "sessionId": "{{parent}}", "sessionFormatVersion": 3, - "throughSeq": 55 + "throughSeq": 59 } } } @@ -4861,7 +4861,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/message", - "seq": 57, + "seq": 61, "time": 0, "data": { "turn": 1, @@ -4991,7 +4991,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/call", - "seq": 58, + "seq": 62, "time": 0, "data": { "turn": 1, @@ -5009,7 +5009,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool-workflow/run-start", - "seq": 59, + "seq": 63, "time": 0, "data": { "runId": "{{workflow-run}}", @@ -5099,7 +5099,7 @@ "sessionId": "{{parent}}", "event": { "type": "subagent/catalog", - "seq": 60, + "seq": 64, "time": 0, "data": { "version": 0, @@ -5116,7 +5116,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool-workflow/agent-start", - "seq": 61, + "seq": 65, "time": 0, "data": { "runId": "{{workflow-run}}", @@ -5524,7 +5524,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool-workflow/agent-end", - "seq": 62, + "seq": 66, "time": 0, "data": { "runId": "{{workflow-run}}", @@ -5540,7 +5540,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool-workflow/run-end", - "seq": 63, + "seq": 67, "time": 0, "data": { "runId": "{{workflow-run}}", @@ -5555,7 +5555,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/result", - "seq": 64, + "seq": 68, "time": 0, "data": { "turn": 1, @@ -5583,7 +5583,7 @@ } }, "sourceEventSeqs": [ - 58 + 62 ], "surfaceOp": "append" } @@ -5595,7 +5595,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/end", - "seq": 65, + "seq": 69, "time": 0, "data": { "turn": 1, @@ -5610,7 +5610,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/start", - "seq": 66, + "seq": 70, "time": 0, "data": { "turn": 1, @@ -5625,12 +5625,12 @@ "sessionId": "{{parent}}", "event": { "type": "session-log-deepseek/delivery-accepted", - "seq": 67, + "seq": 71, "time": 0, "data": { "sessionId": "{{parent}}", "sessionFormatVersion": 3, - "throughSeq": 66 + "throughSeq": 70 } } } @@ -5641,7 +5641,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/message", - "seq": 68, + "seq": 72, "time": 0, "data": { "turn": 1, @@ -5771,7 +5771,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/call", - "seq": 69, + "seq": 73, "time": 0, "data": { "turn": 1, @@ -5789,7 +5789,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/result", - "seq": 70, + "seq": 74, "time": 0, "data": { "turn": 1, @@ -5817,7 +5817,7 @@ } }, "sourceEventSeqs": [ - 69 + 73 ], "surfaceOp": "append" } @@ -5829,7 +5829,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/end", - "seq": 71, + "seq": 75, "time": 0, "data": { "turn": 1, @@ -5844,7 +5844,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/start", - "seq": 72, + "seq": 76, "time": 0, "data": { "turn": 1, @@ -5859,7 +5859,7 @@ "sessionId": "{{parent}}", "event": { "type": "system/message", - "seq": 73, + "seq": 77, "time": 0, "data": { "turn": 1, @@ -5896,7 +5896,7 @@ "sessionId": "{{parent}}", "event": { "type": "request/header", - "seq": 74, + "seq": 78, "time": 0, "data": { "header": { @@ -5938,12 +5938,12 @@ "sessionId": "{{parent}}", "event": { "type": "session-log-deepseek/delivery-accepted", - "seq": 75, + "seq": 79, "time": 0, "data": { "sessionId": "{{parent}}", "sessionFormatVersion": 3, - "throughSeq": 74 + "throughSeq": 78 } } } @@ -5954,7 +5954,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/message", - "seq": 76, + "seq": 80, "time": 0, "data": { "turn": 1, @@ -6068,7 +6068,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/end", - "seq": 77, + "seq": 81, "time": 0, "data": { "turn": 1, @@ -6083,7 +6083,7 @@ "sessionId": "{{parent}}", "event": { "type": "command/run", - "seq": 78, + "seq": 82, "time": 0, "data": { "commandId": "{{command:1}}", @@ -6101,7 +6101,7 @@ "sessionId": "{{parent}}", "event": { "type": "feedback/record", - "seq": 79, + "seq": 83, "time": 0, "data": { "text": "The session needs a clearer explanation." @@ -6115,7 +6115,7 @@ "sessionId": "{{parent}}", "event": { "type": "command/done", - "seq": 80, + "seq": 84, "time": 0, "data": { "commandId": "{{command:1}}", @@ -6131,7 +6131,7 @@ "sessionId": "{{parent}}", "event": { "type": "feedback/record", - "seq": 81, + "seq": 85, "time": 0, "data": { "category": "other" @@ -6145,7 +6145,7 @@ "sessionId": "{{parent}}", "event": { "type": "feedback/message-put", - "seq": 82, + "seq": 86, "time": 0, "data": { "sessionId": "{{parent}}", @@ -6168,7 +6168,7 @@ "sessionId": "{{parent}}", "event": { "type": "feedback/message-put", - "seq": 83, + "seq": 87, "time": 0, "data": { "sessionId": "{{parent}}", @@ -6190,7 +6190,7 @@ "sessionId": "{{parent}}", "event": { "type": "feedback/message-delete", - "seq": 84, + "seq": 88, "time": 0, "data": { "sessionId": "{{parent}}", @@ -6205,7 +6205,7 @@ "sessionId": "{{parent}}", "event": { "type": "turn/end", - "seq": 85, + "seq": 89, "time": 0, "data": { "turn": 1, diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/session.v3.jsonl b/scripts/snapshots/python-sdk-single-exe/advanced/session.v3.jsonl index f85218ff3d..bac23522da 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/session.v3.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/advanced/session.v3.jsonl @@ -13,13 +13,16 @@ {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["cordis_define","cordis_inspect_list","cordis_inspect_query","cordis_inspect_self","cordis_run","cordis_stop","cordis_undefine","job_kill","job_list","job_output","run_code","subagent","workflow"]},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"smoke-model","contextWindow":1000000}} {"type":"session/title","data":{"title":"Run the advanced packaged-runtime snapsh","messageSeqs":[8],"source":{"kind":"fallback"}}} -{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","sessionFormatVersion":3,"throughSeq":12}} +{"type":"assistant/attempt","data":{"turn":1,"step":1,"stream":[{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"code":"IMAGE_OFFLOAD_REQUIRED","message":"authored image budget failure","offloadImages":1}}}}]}} +{"type":"image/offload","data":{"targets":[{"seq":10,"imageIndexes":[0]}]}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["cordis_define","cordis_inspect_list","cordis_inspect_query","cordis_inspect_self","cordis_run","cordis_stop","cordis_undefine","job_kill","job_list","job_output","run_code","subagent","workflow"]},"reason":"series"}} +{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","sessionFormatVersion":3,"throughSeq":16}} {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-define","name":"cordis_define","arguments":"{\"plugin\": {\"kind\": \"new\", \"idPrefix\": \"snap\"}, \"name\": \"Snapshot Double\", \"purpose\": \"Expose a deterministic doubling tool for executable snapshot verification.\", \"code\": {\"host\": \"return (ctx) => {\\n ctx.on('tools/pre-execute', (exec, next) => {\\n if (exec.name !== 'snapshot_double' || exec.arguments.value !== -1) return next()\\n return {\\n kind: 'deny',\\n reason: 'Auto review rejected tool \\\"snapshot_double\\\"; its body was not executed',\\n info: {\\n name: 'AutoReviewDeniedError', code: 'AUTO_REVIEW_DENIED',\\n reason: ' transport raw\\\\r\\\\nreason ',\\n },\\n }\\n })\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"tool-call"}]}},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"advanced-define","name":"cordis_define","args":[""]},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"advanced-define","args":["{\"plugin\": {\"kind\": \"new\", \"idPrefix\": \"snap\"}, \"name\": \"Snapshot Double\", \"purpose\": \"Expose a deterministic doubling tool for executable snapshot verification.\", \"code\": {\"host\": \"return (ctx) => {\\n ctx.on('tools/pre-execute', (exec, next) => {\\n if (exec.name !== 'snapshot_double' || exec.arguments.value !== -1) return next()\\n return {\\n kind: 'deny',\\n reason: 'Auto review rejected tool \\\"snapshot_double\\\"; its body was not executed',\\n info: {\\n name: 'AutoReviewDeniedError', code: 'AUTO_REVIEW_DENIED',\\n reason: ' transport raw\\\\r\\\\nreason ',\\n },\\n }\\n })\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-define","name":"cordis_define","arguments":"{\"plugin\": {\"kind\": \"new\", \"idPrefix\": \"snap\"}, \"name\": \"Snapshot Double\", \"purpose\": \"Expose a deterministic doubling tool for executable snapshot verification.\", \"code\": {\"host\": \"return (ctx) => {\\n ctx.on('tools/pre-execute', (exec, next) => {\\n if (exec.name !== 'snapshot_double' || exec.arguments.value !== -1) return next()\\n return {\\n kind: 'deny',\\n reason: 'Auto review rejected tool \\\"snapshot_double\\\"; its body was not executed',\\n info: {\\n name: 'AutoReviewDeniedError', code: 'AUTO_REVIEW_DENIED',\\n reason: ' transport raw\\\\r\\\\nreason ',\\n },\\n }\\n })\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"tool-call"}]}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"advanced-define","name":"cordis_define","arguments":"{\"plugin\": {\"kind\": \"new\", \"idPrefix\": \"snap\"}, \"name\": \"Snapshot Double\", \"purpose\": \"Expose a deterministic doubling tool for executable snapshot verification.\", \"code\": {\"host\": \"return (ctx) => {\\n ctx.on('tools/pre-execute', (exec, next) => {\\n if (exec.name !== 'snapshot_double' || exec.arguments.value !== -1) return next()\\n return {\\n kind: 'deny',\\n reason: 'Auto review rejected tool \\\"snapshot_double\\\"; its body was not executed',\\n info: {\\n name: 'AutoReviewDeniedError', code: 'AUTO_REVIEW_DENIED',\\n reason: ' transport raw\\\\r\\\\nreason ',\\n },\\n }\\n })\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}}"}} -{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"advanced-define"},"content":[{"type":"tool-result","toolCallId":"advanced-define","content":[{"type":"text","text":"Defined snap-1/pkg-1 (Snapshot Double); it is not running yet. Use cordis_run to activate this Package."}],"isError":false}],"role":"user","id":"{{messageId}}"},"meta":{"pluginId":"snap-1","packageId":"pkg-1"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"advanced-define"},"content":[{"type":"tool-result","toolCallId":"advanced-define","content":[{"type":"text","text":"Defined snap-1/pkg-1 (Snapshot Double); it is not running yet. Use cordis_run to activate this Package."}],"isError":false}],"role":"user","id":"{{messageId}}"},"meta":{"pluginId":"snap-1","packageId":"pkg-1"}},"sourceEventSeqs":[19],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} -{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","sessionFormatVersion":3,"throughSeq":18}} +{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","sessionFormatVersion":3,"throughSeq":22}} {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-run","name":"cordis_run","arguments":"{\"pluginId\": \"snap-1\", \"packageId\": \"pkg-1\", \"mode\": \"run\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"tool-call"}]}},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"advanced-run","name":"cordis_run","args":[""]},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"advanced-run","args":["{\"pluginId\": \"snap-1\", \"packageId\": \"pkg-1\", \"mode\": \"run\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-run","name":"cordis_run","arguments":"{\"pluginId\": \"snap-1\", \"packageId\": \"pkg-1\", \"mode\": \"run\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"tool-call"}]}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":2,"callId":"advanced-run","name":"cordis_run","arguments":"{\"pluginId\": \"snap-1\", \"packageId\": \"pkg-1\", \"mode\": \"run\"}"}} {"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-run"},"content":[{"type":"tool-result","toolCallId":"advanced-run","content":[{"type":"text","text":"snap-1/pkg-1 is running (run-1)."}],"isError":false}],"role":"user","id":"{{messageId}}"},"meta":{"pluginId":"snap-1","packageId":"pkg-1","pluginRunId":"run-1"}},"sourceEventSeqs":[25],"surfaceOp":"append"} @@ -27,7 +30,7 @@ {"type":"step/start","data":{"turn":1,"step":3}} {"type":"system/message","data":{"turn":1,"step":3,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{messageId}}"}},"sourceEventSeqs":[7],"surfaceOp":{"op":"replace","startSeq":7,"endSeq":7}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["cordis_define","cordis_inspect_list","cordis_inspect_query","cordis_inspect_self","cordis_run","cordis_stop","cordis_undefine","job_kill","job_list","job_output","run_code","snapshot_double","subagent","workflow"]},"reason":"change","startsSeries":true}} -{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","sessionFormatVersion":3,"throughSeq":26}} +{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","sessionFormatVersion":3,"throughSeq":30}} {"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"tool-call"}]}},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"advanced-code","name":"run_code","args":[""]},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"advanced-code","args":["{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"tool-call"}]}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":3,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}} {"type":"tool/ptc-dispatch-start","data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:ptc:1","name":"snapshot_double","arguments":{"value":21}}} @@ -35,28 +38,28 @@ {"type":"tool/result","data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"advanced-code"},"content":[{"type":"tool-result","toolCallId":"advanced-code","content":[{"type":"text","text":"42"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[33],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":3}} {"type":"step/start","data":{"turn":1,"step":4}} -{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","sessionFormatVersion":3,"throughSeq":34}} +{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","sessionFormatVersion":3,"throughSeq":38}} {"type":"assistant/message","data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-denied-native","name":"snapshot_double","arguments":"{\"value\": -1}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"tool-call"}]}},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"advanced-denied-native","name":"snapshot_double","args":[""]},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"advanced-denied-native","args":["{\"value\": -1}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-denied-native","name":"snapshot_double","arguments":"{\"value\": -1}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"tool-call"}]}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":4,"callId":"advanced-denied-native","name":"snapshot_double","arguments":"{\"value\": -1}"}} -{"type":"tool/result","data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"advanced-denied-native"},"content":[{"type":"tool-result","toolCallId":"advanced-denied-native","content":[{"type":"text","text":"Error: Auto review rejected tool \"snapshot_double\"; its body was not executed"}],"isError":true}],"role":"user","id":"{{messageId}}"},"error":{"name":"AutoReviewDeniedError","code":"AUTO_REVIEW_DENIED","reason":" transport raw\r\nreason "}},"sourceEventSeqs":[37],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"advanced-denied-native"},"content":[{"type":"tool-result","toolCallId":"advanced-denied-native","content":[{"type":"text","text":"Error: Auto review rejected tool \"snapshot_double\"; its body was not executed"}],"isError":true}],"role":"user","id":"{{messageId}}"},"error":{"name":"AutoReviewDeniedError","code":"AUTO_REVIEW_DENIED","reason":" transport raw\r\nreason "}},"sourceEventSeqs":[41],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":4}} {"type":"step/start","data":{"turn":1,"step":5}} -{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","sessionFormatVersion":3,"throughSeq":40}} +{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","sessionFormatVersion":3,"throughSeq":44}} {"type":"assistant/message","data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-denied-ptc","name":"run_code","arguments":"{\"code\": \"try { await tools.snapshot_double({ value: -1 }) } catch (error) { return error.message }\", \"description\": \"Catch a structured inner tool denial\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"tool-call"}]}},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"advanced-denied-ptc","name":"run_code","args":[""]},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"advanced-denied-ptc","args":["{\"code\": \"try { await tools.snapshot_double({ value: -1 }) } catch (error) { return error.message }\", \"description\": \"Catch a structured inner tool denial\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-denied-ptc","name":"run_code","arguments":"{\"code\": \"try { await tools.snapshot_double({ value: -1 }) } catch (error) { return error.message }\", \"description\": \"Catch a structured inner tool denial\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"tool-call"}]}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":5,"callId":"advanced-denied-ptc","name":"run_code","arguments":"{\"code\": \"try { await tools.snapshot_double({ value: -1 }) } catch (error) { return error.message }\", \"description\": \"Catch a structured inner tool denial\"}"}} {"type":"tool/ptc-dispatch-start","data":{"rootCallId":"advanced-denied-ptc","parentCallId":"advanced-denied-ptc","subCallId":"advanced-denied-ptc:ptc:1","name":"snapshot_double","arguments":{"value":-1}}} {"type":"tool/ptc-dispatch","data":{"rootCallId":"advanced-denied-ptc","parentCallId":"advanced-denied-ptc","subCallId":"advanced-denied-ptc:ptc:1","name":"snapshot_double","arguments":{"value":-1},"isError":true,"error":{"name":"AutoReviewDeniedError","code":"AUTO_REVIEW_DENIED","reason":" transport raw\r\nreason "},"content":[{"type":"text","text":"Error: Auto review rejected tool \"snapshot_double\"; its body was not executed"}]}} -{"type":"tool/result","data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"advanced-denied-ptc"},"content":[{"type":"tool-result","toolCallId":"advanced-denied-ptc","content":[{"type":"text","text":"Auto review rejected tool \"snapshot_double\"; its body was not executed"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[43],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"advanced-denied-ptc"},"content":[{"type":"tool-result","toolCallId":"advanced-denied-ptc","content":[{"type":"text","text":"Auto review rejected tool \"snapshot_double\"; its body was not executed"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[47],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":5}} {"type":"step/start","data":{"turn":1,"step":6}} -{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","sessionFormatVersion":3,"throughSeq":48}} +{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","sessionFormatVersion":3,"throughSeq":52}} {"type":"assistant/message","data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"tool-call"}]}},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"advanced-direct-child","name":"subagent","args":[""]},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"advanced-direct-child","args":["{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"tool-call"}]}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":6,"callId":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}} {"type":"subagent/catalog","data":{"version":0,"childId":"{{child-1}}","childCreatedAt":0,"mode":"one-shot","label":"Check direct child"}} -{"type":"tool/result","data":{"turn":1,"step":6,"message":{"source":{"kind":"tool","callId":"advanced-direct-child"},"content":[{"type":"tool-result","toolCallId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[51],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":6,"message":{"source":{"kind":"tool","callId":"advanced-direct-child"},"content":[{"type":"tool-result","toolCallId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[55],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":6}} {"type":"step/start","data":{"turn":1,"step":7}} -{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","sessionFormatVersion":3,"throughSeq":55}} +{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","sessionFormatVersion":3,"throughSeq":59}} {"type":"assistant/message","data":{"turn":1,"step":7,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"tool-call"}]}},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"advanced-workflow","name":"workflow","args":[""]},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"advanced-workflow","args":["{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"tool-call"}]}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":7,"callId":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}} {"type":"tool-workflow/run-start","data":{"runId":"{{workflow-run}}","name":"advanced-exe-snapshot"}} @@ -64,18 +67,18 @@ {"type":"tool-workflow/agent-start","data":{"runId":"{{workflow-run}}","seq":1,"label":"workflow-child","phase":"Delegate","childId":"{{child-2}}"}} {"type":"tool-workflow/agent-end","data":{"runId":"{{workflow-run}}","seq":1,"outcome":"completed"}} {"type":"tool-workflow/run-end","data":{"runId":"{{workflow-run}}","stopReason":"completed"}} -{"type":"tool/result","data":{"turn":1,"step":7,"message":{"source":{"kind":"tool","callId":"advanced-workflow"},"content":[{"type":"tool-result","toolCallId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-exe-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[58],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":7,"message":{"source":{"kind":"tool","callId":"advanced-workflow"},"content":[{"type":"tool-result","toolCallId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-exe-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[62],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":7}} {"type":"step/start","data":{"turn":1,"step":8}} -{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","sessionFormatVersion":3,"throughSeq":66}} +{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","sessionFormatVersion":3,"throughSeq":70}} {"type":"assistant/message","data":{"turn":1,"step":8,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-undefine","name":"cordis_undefine","arguments":"{\"pluginId\": \"snap-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"tool-call"}]}},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"advanced-undefine","name":"cordis_undefine","args":[""]},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"advanced-undefine","args":["{\"pluginId\": \"snap-1\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-undefine","name":"cordis_undefine","arguments":"{\"pluginId\": \"snap-1\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"tool-call"}]}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":8,"callId":"advanced-undefine","name":"cordis_undefine","arguments":"{\"pluginId\": \"snap-1\"}"}} -{"type":"tool/result","data":{"turn":1,"step":8,"message":{"source":{"kind":"tool","callId":"advanced-undefine"},"content":[{"type":"tool-result","toolCallId":"advanced-undefine","content":[{"type":"text","text":"Removed dynamic Plugin snap-1 and all of its Packages."}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[69],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":8,"message":{"source":{"kind":"tool","callId":"advanced-undefine"},"content":[{"type":"tool-result","toolCallId":"advanced-undefine","content":[{"type":"text","text":"Removed dynamic Plugin snap-1 and all of its Packages."}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[73],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":8}} {"type":"step/start","data":{"turn":1,"step":9}} -{"type":"system/message","data":{"turn":1,"step":9,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{messageId}}"}},"sourceEventSeqs":[25],"surfaceOp":{"op":"replace","startSeq":25,"endSeq":25}} +{"type":"system/message","data":{"turn":1,"step":9,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{messageId}}"}},"sourceEventSeqs":[29],"surfaceOp":{"op":"replace","startSeq":29,"endSeq":29}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["cordis_define","cordis_inspect_list","cordis_inspect_query","cordis_inspect_self","cordis_run","cordis_stop","cordis_undefine","job_kill","job_list","job_output","run_code","subagent","workflow"]},"reason":"change","startsSeries":true}} -{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","sessionFormatVersion":3,"throughSeq":74}} +{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","sessionFormatVersion":3,"throughSeq":78}} {"type":"assistant/message","data":{"turn":1,"step":9,"message":{"role":"assistant","content":[{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"text"}]}},"id":"{{message:1}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["ADVANCED_EXECUTABLE_OK"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"text"}]}}}]},"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":9}} {"type":"command/run","data":{"commandId":"{{command:1}}","name":"feedback","source":{"kind":"user"}}} From 33d89aee7745f462263bd5be532fe802431ece71 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 14 Sep 2026 10:59:46 +0800 Subject: [PATCH 70/79] feat(web): choose and remember terminal shells --- .../2026-09-09-web-sidebar-terminal.i18n.yaml | 4 +- .../2026-09-09-web-sidebar-terminal.md | 4 +- .../2026-09-09-web-sidebar-terminal.zh.md | 4 +- .../sidebar-terminal/selection.expected.md | 5 ++ .../tests/fixtures/sidebar-terminal.patch.yml | 1 + apps/web/tests/sidebar-terminal.e2e.ts | 34 +++++++ docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 2 + docs/config-catalog.zh.md | 2 + docs/subsystems/workspace.i18n.yaml | 4 +- docs/subsystems/workspace.md | 8 ++ docs/subsystems/workspace.zh.md | 8 ++ .../api/terminal-controller/README.i18n.yaml | 4 +- packages/api/terminal-controller/README.md | 7 +- packages/api/terminal-controller/README.zh.md | 7 +- packages/api/terminal-controller/package.json | 1 + .../terminal-controller/src/client/model.ts | 56 +++++++++--- .../src/client/shell-preference.ts | 20 +++++ packages/api/terminal-controller/src/index.ts | 26 +++++- .../api/terminal-controller/src/shells.ts | 31 ++++++- packages/api/terminal-controller/src/types.ts | 2 + .../tests/controller.spec.ts | 39 +++++++- .../tests/model.client.spec.ts | 6 ++ .../tests/recovery.client.spec.ts | 88 +++++++++++++++++-- .../terminal-controller/tsconfig.client.json | 1 + .../ui-sidebar-terminal/README.i18n.yaml | 4 +- packages/client/ui-sidebar-terminal/README.md | 6 +- .../client/ui-sidebar-terminal/README.zh.md | 4 +- .../src/client/TerminalBody.module.css | 3 + .../src/client/TerminalBody.tsx | 11 ++- .../ui-sidebar-terminal/src/client/locales.ts | 2 + .../tests/terminal-body.client.spec.tsx | 14 +++ .../extensions/tool-cordis/src/api-catalog.ts | 8 +- packages/ssh/ssh/src/helper-processes.ts | 13 +++ packages/ssh/ssh/src/helper.ts | 18 +++- packages/ssh/ssh/src/schemas.ts | 4 +- .../ssh/ssh/tests/helper-processes.spec.ts | 9 +- packages/ssh/ssh/tests/helper-runtime.spec.ts | 3 + packages/ssh/ssh/tests/live.e2e.ts | 2 +- .../ssh/ssh/tests/stream-capability.spec.ts | 4 +- .../ssh/ssh/tests/stream-lifetime.spec.ts | 2 +- packages/ssh/subprocess-ssh/README.i18n.yaml | 4 +- packages/ssh/subprocess-ssh/README.md | 2 + packages/ssh/subprocess-ssh/README.zh.md | 2 + packages/ssh/subprocess-ssh/src/index.ts | 28 ++++-- .../subprocess-ssh/tests/lifecycle.spec.ts | 2 +- .../tests/shell-discovery.spec.ts | 58 ++++++++++++ .../tests/terminal-behavior.spec.ts | 8 +- scripts/gen-cordis-catalog.ts | 1 + scripts/package-dependency-policy.ts | 1 + .../cordis-inspect-jsdoc/session.v3.jsonl | 2 +- 51 files changed, 514 insertions(+), 69 deletions(-) create mode 100644 apps/web/tests/expected/sidebar-terminal/selection.expected.md create mode 100644 packages/api/terminal-controller/src/client/shell-preference.ts create mode 100644 packages/ssh/subprocess-ssh/tests/shell-discovery.spec.ts diff --git a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml index 4151a21804..2a4d90b89c 100644 --- a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md -2026-09-09-web-sidebar-terminal.md: d95968712e7f99b8e83d020273bb02818b4b2824 -2026-09-09-web-sidebar-terminal.zh.md: 2b8ed2fa1317dce510e01d840acd3b99f40eaccf +2026-09-09-web-sidebar-terminal.md: f922e8c889fea0fef1fafcd729b1a5489a8b0c42 +2026-09-09-web-sidebar-terminal.zh.md: e53c71e3f6c307e1afe2fc9f1f9561599ba41829 diff --git a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md index d95968712e..f922e8c889 100644 --- a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md +++ b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md @@ -10,7 +10,7 @@ Web users need an interactive shell beside a Session to inspect the workspace an ## Decision -`api-terminal-controller` owns user terminals by Session and exposes the `terminal` Remote namespace. `ui-sidebar-terminal` registers native right-sidebar tabs, xterm.js rendering and FitAddon sizing. A new terminal starts the execution environment's default shell immediately. The terminal type declares independent instances, so ordinary page deduplication cannot collapse separate processes when opening or docking tabs. The existing sidebar controls open additional tabs; double-clicking a tab title renames its terminal. Terminal processes use the composed subprocess provider and Session sandbox policy. Shell resolution occurs only during creation; reading limits and reconnecting an existing process do not depend on the default executable remaining available. Interactive shell configuration supplies Tab completion and optional inline suggestions. +`api-terminal-controller` owns user terminals by Session and exposes the `terminal` Remote namespace. `ui-sidebar-terminal` registers native right-sidebar tabs, xterm.js rendering and FitAddon sizing. A new terminal offers installed shells and waits for Start. Host discovery verifies the configured candidates, with the execution default first; creation accepts only a currently discovered path. The browser remembers the last successful shell path in origin-scoped localStorage and falls back to the current default if that path is unavailable. The terminal type declares independent instances, so ordinary page deduplication cannot collapse separate processes when opening or docking tabs. The existing sidebar controls open additional tabs; double-clicking a tab title renames its terminal. Terminal processes use the composed subprocess provider and Session sandbox policy. Shell resolution occurs during discovery and creation; reading limits and reconnecting an existing process do not depend on the default executable remaining available. Interactive shell configuration supplies Tab completion and optional inline suggestions. Close and replacement remove the tab synchronously and run process cleanup in the background. The Client first records the unfinished close request under a terminal-specific localStorage key; success removes it, and startup retries requests that remain. A cleanup failure produces a lightweight notification with a retry action without reopening the tab. Independent keys prevent another window from overwriting unrelated cleanup requests. Collapse, tab/Session switching, floating, fullscreen and browser disconnection preserve the process. Component cleanup and `TabDomain.signal` only detach browser work because the same lifetime can end during plugin reload. Failed process cleanup retains ownership, including failures after allocation but before create publication. Session owner disposal and Host plugin disposal also clean up terminals. A definitive missing-Session response retires its saved close request because the Session owns process cleanup; transport failures remain retryable. Client plugin disposal awaits every detached stream so a replacement plugin does not inherit unfinished Client cleanup. @@ -22,7 +22,7 @@ The latest attachment controls input and dimensions; other attachments remain re ## Alternatives considered -**Add shell selection and terminal-specific creation controls.** Opening a terminal uses the execution environment's default shell, and the sidebar already owns tab creation and titles. Another picker, process list, header and add control would duplicate navigation around the terminal screen. +**Persist complete shell profiles in the browser.** Only the selected path is a user preference. Arguments and availability belong to Host discovery; persisting them would allow stale profiles to bypass current execution configuration. The sidebar continues to own the terminal list and tab controls. **Keep the tab visible until process cleanup finishes.** A slow or failed termination would delay the user's close action. Saving the cleanup intent allows immediate removal while preserving failure reporting and retry. diff --git a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md index 2b8ed2fa13..e53c71e3f6 100644 --- a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md +++ b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md @@ -10,7 +10,7 @@ Web 用户需要在 Session 旁使用交互式 shell 检查工作区和运行命 ## 决定 -`api-terminal-controller` 按 Session 管理用户终端并提供 `terminal` Remote namespace。`ui-sidebar-terminal` 注册原生右侧栏标签页,使用 xterm.js 渲染和 FitAddon 测量尺寸。新终端立即启动执行环境的默认 shell。终端类型声明独立实例,因此打开或停靠标签页时,普通页面的去重规则不会合并不同进程。已有侧栏控件负责打开更多标签页,双击标签页标题可重命名终端。终端进程使用组合的 subprocess provider 和 Session sandbox policy。shell 只在创建时解析;读取限制和重新连接已有进程不依赖默认可执行文件仍然可用。交互式 shell 配置提供 Tab 补全和可选的内联建议。 +`api-terminal-controller` 按 Session 管理用户终端并提供 `terminal` Remote namespace。`ui-sidebar-terminal` 注册原生右侧栏标签页,使用 xterm.js 渲染和 FitAddon 测量尺寸。新终端提供已安装 shell 的选择并等待用户启动。Host 探测会验证配置的候选,并把执行环境默认项放在首位;创建只接受当前探测返回的路径。浏览器在当前站点 localStorage 中记住上次成功启动的 shell 路径,该路径不可用时回到当前默认项。终端类型声明独立实例,因此打开或停靠标签页时,普通页面的去重规则不会合并不同进程。已有侧栏控件负责打开更多标签页,双击标签页标题可重命名终端。终端进程使用组合的 subprocess provider 和 Session sandbox policy。shell 在探测和创建时解析;读取限制和重新连接已有进程不依赖默认可执行文件仍然可用。交互式 shell 配置提供 Tab 补全和可选的内联建议。 关闭和替换会同步移除标签页,并在后台清理进程。Client 先以终端独立的 localStorage key 保存未完成的关闭请求;成功后删除,启动时重试剩余请求。清理失败时显示带重试操作的轻量通知,不重新打开标签页。独立 key 避免其他窗口覆盖无关的清理请求。折叠、切换标签页或 Session、浮动、全屏和浏览器断线均保留进程。组件清理和 `TabDomain.signal` 只停止浏览器工作,因为插件重新加载也会结束这些生命周期。进程清理失败时保留所有权,包括分配完成但 create 尚未发布时的失败。Session owner 和 Host 插件卸载也会清理终端。 明确的 Session 不存在响应会清除已保存的关闭请求,因为进程清理由 Session 负责;传输失败仍可重试。Client 插件卸载等待所有断开的流结束,避免替换插件继承未完成的 Client 清理。 @@ -22,7 +22,7 @@ Web 用户需要在 Session 旁使用交互式 shell 检查工作区和运行命 ## 考虑过的替代方案 -**增加 shell 选择和终端专用创建控件。** 打开终端使用执行环境的默认 shell,侧栏已经负责标签页创建与标题。额外的选择器、进程列表、标题栏和添加控件会在终端屏幕周围重复导航功能。 +**在浏览器保存完整 shell profile。** 用户偏好只包含选中的路径。参数和可用性由 Host 探测决定;持久化这些信息会让过期 profile 绕过当前执行配置。终端列表和标签页控件仍由侧栏负责。 **进程清理完成前保留标签页。** 缓慢或失败的终止会拖延用户关闭操作。保存清理意图后,可以立即移除标签页,同时保留错误反馈与重试。 diff --git a/apps/web/tests/expected/sidebar-terminal/selection.expected.md b/apps/web/tests/expected/sidebar-terminal/selection.expected.md new file mode 100644 index 0000000000..5b64f44658 --- /dev/null +++ b/apps/web/tests/expected/sidebar-terminal/selection.expected.md @@ -0,0 +1,5 @@ +- text: Shell +- combobox "Shell": + - option "bash — /bin/bash" [selected] + - option "sh — /bin/sh" +- button "Start terminal" diff --git a/apps/web/tests/fixtures/sidebar-terminal.patch.yml b/apps/web/tests/fixtures/sidebar-terminal.patch.yml index cc5d4b94f3..8b96197c79 100644 --- a/apps/web/tests/fixtures/sidebar-terminal.patch.yml +++ b/apps/web/tests/fixtures/sidebar-terminal.patch.yml @@ -1,6 +1,7 @@ - id: terminal-controller config: maxTerminals: 2 + shellCandidates: [/bin/bash, /bin/sh] shell: path: /bin/bash name: bash diff --git a/apps/web/tests/sidebar-terminal.e2e.ts b/apps/web/tests/sidebar-terminal.e2e.ts index 73030f48be..072801c58d 100644 --- a/apps/web/tests/sidebar-terminal.e2e.ts +++ b/apps/web/tests/sidebar-terminal.e2e.ts @@ -19,6 +19,7 @@ async function openTerminal(page: Page, waitForShell = true): Promise { const entry = page.locator('[data-sidebar-right-guide-entry="terminal"]') if (!await entry.isVisible()) await page.locator('[data-dockkit-add-tab]').click() await entry.click() + await page.getByRole('button', { name: 'Start terminal', exact: true }).click() if (waitForShell) await expect.poll(async () => await page.locator('.xterm-rows:visible').innerText()).toContain('bash-') } @@ -157,6 +158,39 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => { expect(tripwire.pageErrors).toEqual([]) }) + it('offers installed shells, remembers the choice after reload, and restores without a picker', async () => { + onTestFailed(() => saveFailureShot(page, 'sidebar-terminal-shell-choice')) + await page.locator('[data-sidebar-right-expand]').click() + await page.locator('[data-sidebar-right-guide-entry="terminal"]').click() + const selector = page.getByRole('combobox', { name: 'Shell', exact: true }) + await selector.waitFor() + expect(await selector.inputValue()).toBe('/bin/bash') + expect(handles).toHaveLength(0) + await compareOrRefreshGolden(fileURLToPath(new URL('./expected/sidebar-terminal/selection.expected.md', import.meta.url)), + await page.locator('[data-sidebar-terminal]').ariaSnapshot(), webSnapshotMode()) + await selector.selectOption('/bin/sh') + await page.screenshot({ path: `${shots}/shell-choice.png`, fullPage: true }) + await page.getByRole('button', { name: 'Start terminal', exact: true }).click() + await command(page, "printf 'CHOSEN_SHELL:%s\\n' \"$0\"") + const screen = page.locator('.xterm-rows:visible') + await expect.poll(() => screen.innerText()).toContain('CHOSEN_SHELL:/bin/sh') + expect(await page.evaluate(() => localStorage.getItem('dsh.terminal.shell'))).toBe('/bin/sh') + const retained = processIdentity(0) + await page.reload({ waitUntil: 'load' }) + await page.locator('.xterm-helper-textarea:visible').waitFor() + expect(await selector.count()).toBe(0) + expect(alive(retained)).toBe(true) + await page.locator('[data-dockkit-add-tab]').click() + await page.locator('[data-sidebar-right-guide-entry="terminal"]').click() + await selector.waitFor() + expect(await selector.inputValue()).toBe('/bin/sh') + expect(handles).toHaveLength(1) + await page.getByRole('button', { name: 'Start terminal', exact: true }).click() + await expect.poll(() => handles.length).toBe(2) + expect(scaffold.ctx.terminalController.list(scaffold.ctx.agents.list()[0]!.id).map(info => info.shell.path)).toEqual(['/bin/sh', '/bin/sh']) + expect(tripwire.pageErrors).toEqual([]) + }) + it('explains that exited terminals count toward the quota and permits creation after closing one', async () => { onTestFailed(() => saveFailureShot(page, 'sidebar-terminal-quota')) const terminals = () => scaffold.ctx.terminalController.list(scaffold.ctx.agents.list()[0]!.id) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 4100624182..70f495f129 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 5c550cc5d36213da9ad5705d756f4a1ef163ee16 -config-catalog.zh.md: 06a98df3e00100025c668534d2b2f223efa11bdb +config-catalog.md: a812da090af38b958b6bd1afffd97d0377c963ea +config-catalog.zh.md: 0bf9e3b49a07782e9a647be08046440d34cd95ff diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 5c550cc5d3..a812da090a 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -247,6 +247,8 @@ export interface Config { /** Arguments passed to the interactive shell. */ args: string[] } | undefined + /** Executable names or paths checked for the new-terminal shell selector. */ + readonly shellCandidates: string[] /** Maximum retained terminals and pending allocations per Session. */ readonly maxTerminals: number /** Maximum terminal width in columns. */ diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 06a98df3e0..0bf9e3b49a 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -249,6 +249,8 @@ export interface Config { /** Arguments passed to the interactive shell. */ args: string[] } | undefined + /** Executable names or paths checked for the new-terminal shell selector. */ + readonly shellCandidates: string[] /** Maximum retained terminals and pending allocations per Session. */ readonly maxTerminals: number /** Maximum terminal width in columns. */ diff --git a/docs/subsystems/workspace.i18n.yaml b/docs/subsystems/workspace.i18n.yaml index ea153ba123..b9004c4754 100644 --- a/docs/subsystems/workspace.i18n.yaml +++ b/docs/subsystems/workspace.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/workspace.md -workspace.md: ee5486800c2b0f01ab3cedde10e1b5ca30968950 -workspace.zh.md: 34d0e6f1cc2b00ee2ae8dd737b45d6a20061c107 +workspace.md: 77e5dd39afb6d2840751d6d56a7a50f9fea2adaa +workspace.zh.md: ad226e34b4fb508e9ce68e90349848d28632d559 diff --git a/docs/subsystems/workspace.md b/docs/subsystems/workspace.md index ee5486800c..77e5dd39af 100644 --- a/docs/subsystems/workspace.md +++ b/docs/subsystems/workspace.md @@ -198,6 +198,14 @@ Typed Remote control of transient Session-owned terminal processes. */ @Remote environment(agent: Agent, signal: AbortSignal): TerminalEnvironment +/** + * Discover installed shells in the Session's execution environment. + * @param agent - Session owner supplied by the Gateway. + * @param signal - request cancellation. + * @returns verified profiles, with the configured or system default first. + */ +@Remote shells(agent: Agent, signal: AbortSignal): Promise + /** * List retained terminals without resolving or activating an Agent. * @param sessionId - displayed Session identity, including offline history. diff --git a/docs/subsystems/workspace.zh.md b/docs/subsystems/workspace.zh.md index 34d0e6f1cc..ad226e34b4 100644 --- a/docs/subsystems/workspace.zh.md +++ b/docs/subsystems/workspace.zh.md @@ -198,6 +198,14 @@ Typed Remote control of transient Session-owned terminal processes. */ @Remote environment(agent: Agent, signal: AbortSignal): TerminalEnvironment +/** + * Discover installed shells in the Session's execution environment. + * @param agent - Session owner supplied by the Gateway. + * @param signal - request cancellation. + * @returns verified profiles, with the configured or system default first. + */ +@Remote shells(agent: Agent, signal: AbortSignal): Promise + /** * List retained terminals without resolving or activating an Agent. * @param sessionId - displayed Session identity, including offline history. diff --git a/packages/api/terminal-controller/README.i18n.yaml b/packages/api/terminal-controller/README.i18n.yaml index b3c9756b81..618b771218 100644 --- a/packages/api/terminal-controller/README.i18n.yaml +++ b/packages/api/terminal-controller/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/terminal-controller/README.md -README.md: e6ffceff8cd3c16ad7a6d61697dec870bba0fe14 -README.zh.md: 609493cfc0fa127473538bc6ce79e6083f6d8703 +README.md: a24cb6e572782b59b83b836f0f477358abadf347 +README.zh.md: b979a7913e3b7fbcd1330ed7459eea9423348941 diff --git a/packages/api/terminal-controller/README.md b/packages/api/terminal-controller/README.md index e6ffceff8c..a24cb6e572 100644 --- a/packages/api/terminal-controller/README.md +++ b/packages/api/terminal-controller/README.md @@ -25,13 +25,14 @@ Open the execution environment's default shell in a Session workspace from the W ## Use this package -The Web bundle mounts this package with the subprocess provider, sandbox policy and Typert Gateway. `remote.terminal` exposes `environment`, `list`, `create`, `follow`, `write`, `resize`, `rename` and `close`; each operation is scoped by Session identity. Listing reads retained Host terminals directly, so viewing an offline Session neither activates an Agent nor produces a recovery error. +The Web bundle mounts this package with the subprocess provider, sandbox policy and Typert Gateway. `remote.terminal` exposes `environment`, `shells`, `list`, `create`, `follow`, `write`, `resize`, `rename` and `close`; each operation is scoped by Session identity. Listing reads retained Host terminals directly, so viewing an offline Session neither activates an Agent nor produces a recovery error. -New terminals use the execution environment's declared default shell. Only when the provider omits that default does resolution use `/bin/sh` on POSIX or `cmd.exe` on Windows. An optional `shell` profile overrides that choice with executable `path`, display `name` and `args` (default `[]`). The provider verifies the executable during creation; resolution failure is reported without trying another shell. Environment lookup returns the working directory and limits without resolving a shell, so an unavailable default does not prevent reattaching to an existing process. Automatic POSIX profiles start interactively, and PowerShell uses `-NoLogo`, so completion and startup configuration remain shell-owned. The Session workspace supplies the initial directory; its sandbox policy also applies to the terminal. +Shell discovery lists the execution environment's declared default shell first. Only when the provider omits that default does resolution use `/bin/sh` on POSIX or `cmd.exe` on Windows. An optional `shell` profile overrides that choice with executable `path`, display `name` and `args` (default `[]`). The selector also probes `shellCandidates` through the execution provider and omits only confirmed lookup misses. Creation accepts a discovered `shellPath` and verifies it again; resolution or transport failure is reported without launching a different shell. Environment lookup returns the working directory and limits without resolving a shell, so an unavailable default does not prevent reattaching to an existing process. Automatic POSIX profiles start interactively, and PowerShell uses `-NoLogo`, so completion and startup configuration remain shell-owned. The Session workspace supplies the initial directory; its sandbox policy also applies to the terminal. | Configuration | Default | Meaning | |---|---|---| | `shell` | omitted | Use the execution environment's default shell, or one explicit profile | +| `shellCandidates` | `zsh`, `bash`, `fish`, `sh`, `ksh`, `tcsh`, `csh`, `pwsh`, `powershell`, `cmd`, `nu` | Additional executable names or paths offered when installed | | `maxTerminals` | `8` | Retained terminals and pending allocations per Session | | `maxCols`, `maxRows` | `500`, `200` | Maximum PTY dimensions | | `scrollback` | `1000` | Retained screen history rows | @@ -51,6 +52,8 @@ The latest attachment owns input and resize. Detachment releases input control w Client views keep the association between sidebar tabs and terminal identities in memory. Recovery queries the Host for retained terminals; a new view may create a process, while a recovered view reports a missing target without creating a replacement. The Client model acknowledges screen writes after the browser emulator processes them, serializes input and ignores stale attachment responses. Client-owned errors carry locale keys. Plugin disposal awaits active and previously detached output streams without closing Host processes. +New views wait for explicit shell selection and start. The last successful shell path is stored under `dsh.terminal.shell` in origin-scoped localStorage. A saved path is preselected only while Host discovery still lists it; otherwise the current default is selected. Storage failures do not prevent startup. Restoring existing terminals neither reads this preference nor discovers shells. + Closing saves an unfinished cleanup request before releasing the tab, then awaits Host cleanup in the background. A failure exposes a retry notification. Each request has its own terminal-ID localStorage key and is removed after successful cleanup or a definitive `session/not-found` response; startup retries saved requests. Transport failures retain the request. This stores cleanup intent, not sidebar layout, open-tab mappings, selected tabs or process PIDs. If browser storage is unavailable, cleanup remains usable in memory but cannot be recovered after reload.
diff --git a/packages/api/terminal-controller/README.zh.md b/packages/api/terminal-controller/README.zh.md index 609493cfc0..b979a7913e 100644 --- a/packages/api/terminal-controller/README.zh.md +++ b/packages/api/terminal-controller/README.zh.md @@ -25,13 +25,14 @@ kind: "package-reference" ## 使用此包 -Web bundle 将此包与 subprocess provider、sandbox policy 和 Typert Gateway 一起挂载。`remote.terminal` 提供 `environment`、`list`、`create`、`follow`、`write`、`resize`、`rename` 和 `close`;每个操作均按 Session 标识限定范围。列表直接读取 Host 保留的终端,因此查看离线 Session 不会激活 Agent,也不会产生恢复错误。 +Web bundle 将此包与 subprocess provider、sandbox policy 和 Typert Gateway 一起挂载。`remote.terminal` 提供 `environment`、`shells`、`list`、`create`、`follow`、`write`、`resize`、`rename` 和 `close`;每个操作均按 Session 标识限定范围。列表直接读取 Host 保留的终端,因此查看离线 Session 不会激活 Agent,也不会产生恢复错误。 -新终端使用执行环境声明的默认 shell。仅当 provider 未声明默认值时,才在 POSIX 使用 `/bin/sh`,在 Windows 使用 `cmd.exe`。可选的 `shell` profile 通过可执行路径 `path`、显示名称 `name` 和参数 `args`(默认 `[]`)覆盖这一选择。provider 在创建时验证可执行文件;解析失败会直接报告,不尝试其他 shell。环境查询只返回工作目录和限制,不解析 shell,因此默认 shell 不可用时仍可重新连接已有进程。POSIX 自动 profile 以交互模式启动,PowerShell 使用 `-NoLogo`,补全和启动配置仍由 shell 提供。初始目录来自 Session 工作区,终端遵循同一 sandbox policy。 +Shell 探测结果首先列出执行环境声明的默认 shell。仅当 provider 未声明默认值时,才在 POSIX 使用 `/bin/sh`,在 Windows 使用 `cmd.exe`。可选的 `shell` profile 通过可执行路径 `path`、显示名称 `name` 和参数 `args`(默认 `[]`)覆盖这一选择。选择器还会通过执行 provider 探测 `shellCandidates`,仅省略确定未找到的候选。创建请求接受探测返回的 `shellPath` 并再次验证;解析或传输失败会直接报告,不启动其他 shell。环境查询只返回工作目录和限制,不解析 shell,因此默认 shell 不可用时仍可重新连接已有进程。POSIX 自动 profile 以交互模式启动,PowerShell 使用 `-NoLogo`,补全和启动配置仍由 shell 提供。初始目录来自 Session 工作区,终端遵循同一 sandbox policy。 | 配置 | 默认值 | 含义 | |---|---|---| | `shell` | 省略 | 使用执行环境默认 shell,或指定一个 profile | +| `shellCandidates` | `zsh`、`bash`、`fish`、`sh`、`ksh`、`tcsh`、`csh`、`pwsh`、`powershell`、`cmd`、`nu` | 已安装时供用户选择的额外可执行名称或路径 | | `maxTerminals` | `8` | 每个 Session 保留的终端和创建请求上限 | | `maxCols`、`maxRows` | `500`、`200` | PTY 最大尺寸 | | `scrollback` | `1000` | 屏幕历史行数 | @@ -51,6 +52,8 @@ Host 通过 `ctx.subprocess.spawnTerminal` 创建 `TERM=xterm-256color` 的终 Client 视图只在内存中关联侧栏标签页与终端标识。恢复操作查询 Host 保留的终端;新视图可以创建进程,恢复视图在目标缺失时显示错误,不创建替代进程。Client 模型在浏览器完成屏幕解析后确认帧,按序发送输入,并忽略旧连接迟到的响应。 Client 自产错误携带本地化键。插件卸载等待活跃及先前断开的输出流结束,不关闭 Host 进程。 +新视图等待用户选择 shell 并启动。上次成功启动的 shell 路径保存在当前站点 localStorage 的 `dsh.terminal.shell` 中。仅当 Host 探测仍列出该路径时才预选它,否则选择当前默认项。存储失败不影响启动。恢复已有终端既不读取这一偏好,也不探测 shell。 + 关闭时先保存未完成的清理请求并释放标签页,再在后台等待 Host 清理。失败时提供重试通知。每个请求使用独立的终端 ID localStorage key,清理成功或收到明确的 `session/not-found` 响应后删除;启动时重试已保存的请求。传输失败时保留请求。保存的是清理意图,不是侧栏布局、打开标签页映射、选中标签页或进程 PID。浏览器存储不可用时,内存中的清理仍可工作,但刷新后无法恢复该请求。
diff --git a/packages/api/terminal-controller/package.json b/packages/api/terminal-controller/package.json index 46e22a9708..0504a7dfcc 100644 --- a/packages/api/terminal-controller/package.json +++ b/packages/api/terminal-controller/package.json @@ -62,6 +62,7 @@ "zod": "^4.4.3" }, "peerDependencies": { + "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { diff --git a/packages/api/terminal-controller/src/client/model.ts b/packages/api/terminal-controller/src/client/model.ts index 7c19a4cac8..1150718f1f 100644 --- a/packages/api/terminal-controller/src/client/model.ts +++ b/packages/api/terminal-controller/src/client/model.ts @@ -1,4 +1,5 @@ /** React-free browser terminal state and reconnecting Remote-stream ownership. */ +import { preferredShell, rememberShell } from './shell-preference.ts' import { randomUUID } from '@deepseek-ai/dsh-util-crypto' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' import { RemoteStreamCarrierError, type ClientRemote, type RemoteStream } from '@deepseek-ai/dsh-api-gateway/client' @@ -6,7 +7,7 @@ import { RemoteError, remoteErrorOf, type RemoteResult } from '@deepseek-ai/dsh- import type {} from '@deepseek-ai/dsh-api-terminal-controller/remote' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { - TerminalAttachmentId, TerminalEnvironment, TerminalFrame, + TerminalShell, TerminalAttachmentId, TerminalEnvironment, TerminalFrame, WebTerminalId, WebTerminalInfo, } from '../types.ts' @@ -35,7 +36,9 @@ export interface TerminalRenderFrame { /** Observable state of one sidebar occurrence. */ export interface TerminalViewState { - readonly phase: 'idle' | 'loading' | 'creating' | 'connecting' | 'connected' | 'disconnected' | 'closing' | 'closed' | 'failed' + readonly phase: 'idle' | 'loading' | 'selecting' | 'creating' | 'connecting' | 'connected' | 'disconnected' | 'closing' | 'closed' | 'failed' + readonly shells?: readonly TerminalShell[] | undefined + readonly selectedShell?: string | undefined readonly environment?: TerminalEnvironment | undefined readonly title?: string | undefined readonly info?: WebTerminalInfo | undefined @@ -78,7 +81,7 @@ export class TerminalView { ) {} /** - * Attach the DOM lifetime, starting the default shell or reconnecting the saved process. + * Attach the DOM lifetime, loading shell choices or reconnecting the saved process. * @returns a detach callback that leaves the terminal process alive. */ mount(): () => void { @@ -97,6 +100,7 @@ export class TerminalView { * @returns after environment lookup and creation or recovery settle. */ refresh(): Promise { + if (this.creation !== undefined) return this.creation if (this.loading !== undefined) return this.loading if (this.closing !== undefined || this.lifetime.signal.aborted) return Promise.resolve() this.patch({ phase: 'loading', error: undefined, issue: undefined }) @@ -104,24 +108,56 @@ export class TerminalView { const [environment, available] = await Promise.all([ this.remote.environment(this.sessionId, this.lifetime.signal), this.remote.list(this.sessionId), ]) - if (this.lifetime.signal.aborted || this.closing !== undefined) return + if (this.stopped()) return this.patch({ environment: valueOf(environment) }) const info = valueOf(available).find(item => item.id === this.id) if (info !== undefined) this.adopt(info) - else if (this.createWhenMissing) await this.create(valueOf(environment)) + else if (this.createWhenMissing) { + const shells = valueOf(await this.remote.shells(this.sessionId, this.lifetime.signal)) + if (this.stopped()) return + const previous = this.state.getSnapshot().selectedShell ?? preferredShell() + const selectedShell = shells.find(shell => shell.path === previous)?.path ?? shells[0]?.path + this.patch({ phase: 'selecting', shells, selectedShell }) + } else throw new TerminalViewError('missingTerminal') })().catch((error: unknown) => { this.fail(error) }).finally(() => { this.loading = undefined }) return this.loading } - private async create(environment: TerminalEnvironment): Promise { + /** + * Select one verified shell before starting the terminal. + * @param path - executable path offered by Host discovery. + */ + selectShell(path: string): void { + const state = this.state.getSnapshot() + if (state.phase === 'selecting' && state.shells?.some(shell => shell.path === path)) this.patch({ selectedShell: path }) + } + + /** + * Start the selected shell once; failed attempts retain their selection for retry. + * @returns after allocation settles; failures are exposed in observable state. + */ + start(): Promise { + if (this.creation !== undefined) return this.creation + const state = this.state.getSnapshot() + if (state.phase !== 'selecting' || state.environment === undefined || state.selectedShell === undefined + || this.closing !== undefined || this.lifetime.signal.aborted) return Promise.resolve() + return this.create(state.environment, state.selectedShell) + } + + private stopped(): boolean { return this.lifetime.signal.aborted || this.closing !== undefined } + + private async create(environment: TerminalEnvironment, shellPath: string): Promise { this.patch({ phase: 'creating', error: undefined, issue: undefined }) this.creation = (async () => { const info = valueOf(await this.remote.create(this.sessionId, { - id: this.id, cols: Math.min(80, environment.maxCols), rows: Math.min(24, environment.maxRows), + id: this.id, shellPath, cols: Math.min(80, environment.maxCols), rows: Math.min(24, environment.maxRows), }, this.lifetime.signal)) - if (!this.lifetime.signal.aborted) this.adopt(info) - })().finally(() => { this.creation = undefined }) + if (!this.lifetime.signal.aborted) { + rememberShell(info.shell.path) + this.adopt(info) + } + })().catch((error: unknown) => { this.fail(error) }).finally(() => { this.creation = undefined }) await this.creation } @@ -221,7 +257,7 @@ export class TerminalView { this.detach() this.closing = (async () => { // Even a refused or lost creation response may leave an allocation to close. - await this.creation?.catch(() => {}) + await this.creation valueOf(await this.remote.close(this.sessionId, this.id)) this.detach() this.patch({ phase: 'closed', writable: false }) diff --git a/packages/api/terminal-controller/src/client/shell-preference.ts b/packages/api/terminal-controller/src/client/shell-preference.ts new file mode 100644 index 0000000000..044a41d845 --- /dev/null +++ b/packages/api/terminal-controller/src/client/shell-preference.ts @@ -0,0 +1,20 @@ +/** Browser-local shell preference; Host discovery decides whether the saved path is usable. */ +const KEY = 'dsh.terminal.shell' + +/** + * Read the browser preference. + * @returns the last successfully started shell path, or null when storage is unavailable. + */ +export function preferredShell(): string | null { + try { return typeof localStorage === 'undefined' ? null : localStorage.getItem(KEY) } + catch (_storageUnavailable) { return null } +} + +/** + * Remember a successful shell launch without making storage a startup dependency. + * @param path - verified executable path returned by the Host. + */ +export function rememberShell(path: string): void { + try { if (typeof localStorage !== 'undefined') localStorage.setItem(KEY, path) } + catch (_storageUnavailable) { /* Private browsing or quota failure leaves this launch usable. */ } +} diff --git a/packages/api/terminal-controller/src/index.ts b/packages/api/terminal-controller/src/index.ts index 7b854476b7..920982897e 100644 --- a/packages/api/terminal-controller/src/index.ts +++ b/packages/api/terminal-controller/src/index.ts @@ -8,10 +8,10 @@ import type {} from '@deepseek-ai/dsh-sandbox-policy' import type {} from '@deepseek-ai/dsh-sandbox' import type {} from '@deepseek-ai/dsh-session-projection' import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' -import { resolveShell } from './shells.ts' +import { discoverShells, resolveShell } from './shells.ts' import { BrowserTerminal } from './terminal.ts' import type { - TerminalAttachmentId, TerminalCreateRequest, TerminalEnvironment, TerminalFrame, + TerminalShell, TerminalAttachmentId, TerminalCreateRequest, TerminalEnvironment, TerminalFrame, WebTerminalId, WebTerminalInfo, } from './types.ts' @@ -35,6 +35,8 @@ export interface Config { /** Arguments passed to the interactive shell. */ args: string[] } | undefined + /** Executable names or paths checked for the new-terminal shell selector. */ + readonly shellCandidates: string[] /** Maximum retained terminals and pending allocations per Session. */ readonly maxTerminals: number /** Maximum terminal width in columns. */ @@ -67,6 +69,7 @@ export class TerminalController extends TypertRemoteService { shell: z.union([z.object({ path: z.string().required(), name: z.string().required(), args: z.array(z.string()).default([]), }), z.const(undefined)]), + shellCandidates: z.array(z.string().min(1)).default(['zsh', 'bash', 'fish', 'sh', 'ksh', 'tcsh', 'csh', 'pwsh', 'powershell', 'cmd', 'nu']), maxTerminals: z.number().step(1).min(1).default(8), maxCols: z.number().step(1).min(2).default(500), maxRows: z.number().step(1).min(1).default(200), @@ -117,6 +120,18 @@ export class TerminalController extends TypertRemoteService { maxRows: this.config.maxRows, scrollback: this.config.scrollback } } + /** + * Discover installed shells in the Session's execution environment. + * @param agent - Session owner supplied by the Gateway. + * @param signal - request cancellation. + * @returns verified profiles, with the configured or system default first. + */ + @Remote + shells(agent: Agent, signal: AbortSignal): Promise { + signal.throwIfAborted() + return discoverShells(this.execution(agent).subprocess, this.config.shell, this.config.shellCandidates, signal) + } + /** * List retained terminals without resolving or activating an Agent. * @param sessionId - displayed Session identity, including offline history. @@ -301,13 +316,16 @@ export class TerminalController extends TypertRemoteService { private async spawn(agent: Agent, owner: OwnedSession, request: TerminalCreateRequest, signal: AbortSignal): Promise { const environment = this.environment(agent, signal) const { subprocess, sandboxPolicy } = this.execution(agent) - const shell = await resolveShell(subprocess, this.config.shell, signal) + const shell = request.shellPath === undefined + ? await resolveShell(subprocess, this.config.shell, signal) + : (await this.shells(agent, signal)).find(candidate => candidate.path === request.shellPath) + if (shell === undefined) throw new Error('Selected shell is not available in this execution environment') const policy = sandboxPolicy.resolve({ session: agent.session }) let argv = [shell.path, ...shell.args] if (policy.mode !== 'danger-full-access') { const sandbox = agent.ctx.get('sandbox') if (sandbox === undefined) throw new Error('The Session sandbox mode requires an execution sandbox provider') - argv = sandbox.confine(argv, { ...policy, mode: policy.mode }).argv + argv = (await sandbox.confine(argv, { ...policy, mode: policy.mode }, signal)).argv } const handle = await subprocess.spawnTerminal({ argv, cwd: environment.cwd, cols: request.cols, rows: request.rows, diff --git a/packages/api/terminal-controller/src/shells.ts b/packages/api/terminal-controller/src/shells.ts index 284a46f319..c8010c38c4 100644 --- a/packages/api/terminal-controller/src/shells.ts +++ b/packages/api/terminal-controller/src/shells.ts @@ -1,5 +1,5 @@ /** Shell selection and executable verification use the target execution provider. */ -import type { SubprocessRuntime } from '@deepseek-ai/dsh-subprocess' +import { SubprocessExecutableNotFoundError, type SubprocessRuntime } from '@deepseek-ai/dsh-subprocess' import type { TerminalShell } from './types.ts' /** @@ -26,3 +26,32 @@ function profile(path: string): TerminalShell { const kind = name.toLowerCase().replace(/\.exe$/u, '') return { path, name, args: kind === 'cmd' ? [] : kind === 'pwsh' || kind === 'powershell' ? ['-NoLogo'] : ['-i'] } } + +/** + * List verified candidates after the configured or environment-default shell. + * @param subprocess - target execution provider. + * @param configured - optional default profile. + * @param candidates - executable names or paths permitted for shell selection. + * @param signal - discovery cancellation. + * @returns unique installed shells, with the default first; transport failures reject. + */ +export async function discoverShells( + subprocess: SubprocessRuntime, configured: TerminalShell | undefined, + candidates: readonly string[], signal: AbortSignal, +): Promise { + const preferred = await resolveShell(subprocess, configured, signal) + const found = await Promise.all(candidates.map(async (candidate) => { + try { return await resolveShell(subprocess, profile(candidate), signal) } + catch (error) { + if (error instanceof SubprocessExecutableNotFoundError) return undefined + throw error + } + })) + const shells = new Map() + for (const shell of [preferred, ...found]) { + if (shell === undefined) continue + const key = shell.path.includes('\\') ? shell.path.toLowerCase() : shell.path + if (!shells.has(key)) shells.set(key, shell) + } + return [...shells.values()] +} diff --git a/packages/api/terminal-controller/src/types.ts b/packages/api/terminal-controller/src/types.ts index b474c9ad47..fac1f4363c 100644 --- a/packages/api/terminal-controller/src/types.ts +++ b/packages/api/terminal-controller/src/types.ts @@ -49,6 +49,8 @@ export interface WebTerminalInfo { /** Create is idempotent for an open identity; closed identities cannot be recreated. */ export interface TerminalCreateRequest { + /** A path returned by shell discovery; absent selects the execution default. */ + readonly shellPath?: string readonly id: WebTerminalId readonly cols: number readonly rows: number diff --git a/packages/api/terminal-controller/tests/controller.spec.ts b/packages/api/terminal-controller/tests/controller.spec.ts index 619750f9ff..a42ac6c44e 100644 --- a/packages/api/terminal-controller/tests/controller.spec.ts +++ b/packages/api/terminal-controller/tests/controller.spec.ts @@ -16,7 +16,7 @@ import type { TerminalAttachmentId, WebTerminalId } from '../src/types.ts' const roots: Context[] = [] afterEach(async () => { await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose())) }) -const config: Config = { shell: { path: '/bin/bash', name: 'bash', args: ['--noprofile', '--norc', '-i'] }, maxTerminals: 2, maxCols: 200, maxRows: 100, scrollback: 100, maxBufferedBytes: 100_000, maxInputBytes: 1000, disposeGraceMs: 100 } +const config: Config = { shellCandidates: ['zsh', 'bash', 'sh'], shell: { path: '/bin/bash', name: 'bash', args: ['--noprofile', '--norc', '-i'] }, maxTerminals: 2, maxCols: 200, maxRows: 100, scrollback: 100, maxBufferedBytes: 100_000, maxInputBytes: 1000, disposeGraceMs: 100 } const id = 'test-terminal' as WebTerminalId const request = { id, cols: 80, rows: 24 } const signal = (): AbortSignal => new AbortController().signal @@ -288,7 +288,7 @@ describe('TerminalController', () => { const confine = vi.fn((argv: readonly string[]) => ({ argv: ['sandbox-runner', ...argv] })) ctx.provide('sandbox', { confine } as never) await controller.create(agent, request, signal()) - expect(confine).toHaveBeenCalledWith(['/bin/bash', '--noprofile', '--norc', '-i'], policy) + expect(confine).toHaveBeenCalledWith(['/bin/bash', '--noprofile', '--norc', '-i'], policy, expect.any(AbortSignal)) expect(subprocess.spawnTerminal).toHaveBeenCalledWith(expect.objectContaining({ argv: ['sandbox-runner', '/bin/bash', '--noprofile', '--norc', '-i'], env: { DSH_SESSION_ID: agent.id }, graceMs: 100 })) }) @@ -506,3 +506,38 @@ it.skipIf(process.platform === 'win32')('runs a real interactive shell with comp await rm(cwd, { recursive: true, force: true }) } }) + +it('discovers installed shells once per path, preserves default arguments, and refuses unlisted paths', async () => { + const h = fixture({ shellCandidates: ['bash', 'zsh', 'missing'] }) + h.subprocess.resolveExecutable.mockImplementation(async (path) => { + if (path === 'missing') throw new SubprocessExecutableNotFoundError('absent') + return path.startsWith('/') ? path : `/bin/${path}` + }) + const shells = await h.controller.shells(h.agent, signal()) + expect(shells).toEqual([config.shell, { path: '/bin/zsh', name: 'zsh', args: ['-i'] }]) + expect(h.subprocess.spawnTerminal).not.toHaveBeenCalled() + await expect(h.controller.create(h.agent, { ...request, shellPath: '/bin/unlisted' }, signal())).rejects.toThrow('Selected shell is not available') + expect(h.subprocess.spawnTerminal).not.toHaveBeenCalled() + const created = await h.controller.create(h.agent, { ...request, shellPath: '/bin/zsh' }, signal()) + expect(created.shell.path).toBe('/bin/zsh') + expect(h.subprocess.spawnTerminal).toHaveBeenCalledWith(expect.objectContaining({ argv: ['/bin/zsh', '-i'] })) + h.subprocess.resolveExecutable.mockRejectedValue(new Error('SSH disconnected')) + await expect(h.controller.shells(h.agent, signal())).rejects.toThrow('SSH disconnected') + expect(await h.controller.create(h.agent, { ...request, shellPath: '/bin/bash' }, signal())).toBe(created) +}) + +it('propagates optional-shell discovery transport errors and cancellation', async () => { + const h = fixture({ shellCandidates: ['fish'] }) + h.subprocess.resolveExecutable.mockImplementation(async (path) => { + if (path === 'fish') throw new Error('lookup transport failed') + return path + }) + await expect(h.controller.shells(h.agent, signal())).rejects.toThrow('lookup transport failed') + const cancelled = AbortSignal.abort(new Error('cancelled discovery')) + expect(() => h.controller.shells(h.agent, cancelled)).toThrow('cancelled discovery') +}) + +it('deduplicates Windows executable paths regardless of letter case', async () => { + const h = fixture({ shell: { path: 'C:\\Windows\\cmd.exe', name: 'Command Prompt', args: [] }, shellCandidates: ['c:\\windows\\cmd.exe'] }) + expect(await h.controller.shells(h.agent, signal())).toEqual([{ path: 'C:\\Windows\\cmd.exe', name: 'Command Prompt', args: [] }]) +}) diff --git a/packages/api/terminal-controller/tests/model.client.spec.ts b/packages/api/terminal-controller/tests/model.client.spec.ts index 4ce6fd6b8c..ac1a7243f9 100644 --- a/packages/api/terminal-controller/tests/model.client.spec.ts +++ b/packages/api/terminal-controller/tests/model.client.spec.ts @@ -31,6 +31,7 @@ function failure(message: string): RemoteResult { function fixture(prepareStream?: (stream: RemoteStream) => void) { const remote: TerminalRemote = { + shells: vi.fn(async () => success([info.shell])), environment: vi.fn(async () => success(environment)), list: vi.fn(async () => success([])), create: vi.fn(async (_sessionId, request) => success({ ...info, id: request.id })), close: vi.fn(async () => success(undefined)), rename: vi.fn(async () => success(undefined)), @@ -61,6 +62,7 @@ function fixture(prepareStream?: (stream: RemoteStream) => void) { async function mount(model: TerminalView) { const detach = model.mount() await model.refresh() + await model.start() return detach } @@ -434,6 +436,7 @@ it('classifies a discovery carrier failure as disconnected and supports explicit expect(model.state.getSnapshot()).toMatchObject({ phase: 'disconnected', error: 'offline' }) expect(remote.create).not.toHaveBeenCalled() await model.refresh() + await model.start() expect(model.state.getSnapshot().info?.id).toBe(info.id) expect(remote.create).toHaveBeenCalledOnce() }) @@ -446,12 +449,14 @@ it('retains state when disposal overtakes successful allocation or a failed disc if (operation === 'creation') vi.mocked(remote.create).mockReturnValueOnce(creation.promise) else vi.mocked(remote.environment).mockReturnValueOnce(discovery.promise) const loading = model.refresh() + const starting = operation === 'creation' ? loading.then(() => model.start()) : undefined if (operation === 'creation') await expect.poll(() => remote.create).toHaveBeenCalledOnce() await model.dispose() const before = model.state.getSnapshot() if (operation === 'creation') creation.resolve(success(info)) else discovery.reject(new Error('late discovery failure')) await loading + await starting expect(model.state.getSnapshot()).toBe(before) expect(remote.follow).not.toHaveBeenCalled() } @@ -555,6 +560,7 @@ it('exposes a localized quota error and clears it after a successful retry', asy const { model, remote } = fixture() vi.mocked(remote.create).mockResolvedValueOnce({ ok: false, error: new RemoteError('terminal/limit-reached', 'Session terminal limit reached', { limit: 8 }) }) await model.refresh() + await model.start() expect(model.state.getSnapshot()).toMatchObject({ phase: 'failed', issue: 'terminalLimit' }) await connected(model) expect(model.state.getSnapshot()).toMatchObject({ phase: 'connected', issue: undefined, error: undefined }) diff --git a/packages/api/terminal-controller/tests/recovery.client.spec.ts b/packages/api/terminal-controller/tests/recovery.client.spec.ts index 28db262070..d58a9e26ba 100644 --- a/packages/api/terminal-controller/tests/recovery.client.spec.ts +++ b/packages/api/terminal-controller/tests/recovery.client.spec.ts @@ -28,6 +28,7 @@ function storage() { function fixture() { const remote: TerminalRemote = { + shells: vi.fn(async () => success([info.shell])), environment: vi.fn(async () => success(environment)), list: vi.fn(async () => success([])), create: vi.fn(async (_session, request) => success({ ...info, id: request.id })), close: vi.fn(async () => success(undefined)), rename: vi.fn(async () => success(undefined)), @@ -58,7 +59,7 @@ function fixture() { return { remote, view, service } } -it('starts the default shell once across overlapping mounts and retries, with no shell choice in the request', async () => { +it('waits for shell selection and deduplicates overlapping starts', async () => { const h = fixture() const model = h.view() const creation = Promise.withResolvers>() @@ -66,10 +67,15 @@ it('starts the default shell once across overlapping mounts and retries, with no model.mount() const loading = model.refresh() expect(model.refresh()).toBe(loading) - await expect.poll(() => vi.mocked(h.remote.create).mock.calls.length).toBe(1) - expect(vi.mocked(h.remote.create).mock.calls[0]?.[1]).toEqual({ id: info.id, cols: 80, rows: 24 }) - creation.resolve(success(info)) await loading + expect(h.remote.create).not.toHaveBeenCalled() + const starting = model.start() + const remounting = model.refresh() + void model.start() + expect(vi.mocked(h.remote.create).mock.calls[0]?.[1]).toEqual({ id: info.id, shellPath: info.shell.path, cols: 80, rows: 24 }) + creation.resolve(success(info)) + await starting + await remounting await expect.poll(() => model.state.getSnapshot().writable).toBe(true) expect(h.remote.create).toHaveBeenCalledOnce() }) @@ -105,8 +111,10 @@ it('retries lost create acknowledgements with the saved id and closes after a re const model = h.view() vi.mocked(h.remote.create).mockResolvedValueOnce(failure('response lost')) await model.refresh() + await model.start() expect(model.state.getSnapshot().error).toBe('response lost') await model.refresh() + await model.start() expect(vi.mocked(h.remote.create).mock.calls.map(call => call[1].id)).toEqual([info.id, info.id]) await model.close() expect(h.remote.close).toHaveBeenCalledWith(sessionId, info.id) @@ -118,12 +126,15 @@ it('waits for an in-flight creation while close detaches immediately and prevent const creation = Promise.withResolvers>() vi.mocked(h.remote.create).mockReturnValueOnce(creation.promise) const unmount = model.mount() + await model.refresh() + const starting = model.start() await expect.poll(() => vi.mocked(h.remote.create).mock.calls.length).toBe(1) const closing = model.close() expect(model.close()).toBe(closing) unmount() expect(h.remote.close).not.toHaveBeenCalled() creation.resolve(failure('cancelled allocation')) + await starting await closing expect(h.remote.close).toHaveBeenCalledWith(sessionId, info.id) expect(h.remote.follow).not.toHaveBeenCalled() @@ -323,7 +334,7 @@ it('saves close intents without persisting any active terminal or sidebar state' const pending = Promise.withResolvers>() vi.mocked(h.remote.close).mockReturnValueOnce(pending.promise) service.close(sessionId, 'new-tab') - const request = { sessionId, id: model.id, title: info.title } + const request = { sessionId, id: model.id, title: 'new-tab' } expect([...data.entries()]).toEqual([[`dsh.terminal.close.v1.${model.id}`, JSON.stringify(request)]]) pending.resolve(success(undefined)) await expect.poll(() => data.size).toBe(0) @@ -414,6 +425,8 @@ it('waits for both active and detached stream finalizers during plugin disposal }) const model = service.view(sessionId, 'tab') model.mount() + await model.refresh() + await model.start() await expect.poll(() => model.state.getSnapshot().writable).toBe(true) model.connect() await started[0]!.promise @@ -431,3 +444,68 @@ it('waits for both active and detached stream finalizers during plugin disposal expect(disposed).toBe(true) expect(h.remote.close).not.toHaveBeenCalled() }) + +it('remembers a successful shell choice for new views and falls back when that shell is absent', async () => { + const data = storage() + const h = fixture() + const alternate = { name: 'bash', path: '/bin/bash', args: ['-i'] } + vi.mocked(h.remote.shells).mockResolvedValue(success([info.shell, alternate])) + vi.mocked(h.remote.create).mockImplementation(async (_sessionId, request) => success({ ...info, shell: alternate, id: request.id })) + const first = h.view() + await first.start() + first.selectShell(alternate.path) + await first.refresh() + expect(first.state.getSnapshot()).toMatchObject({ phase: 'selecting', selectedShell: info.shell.path }) + first.selectShell('/not-listed') + expect(first.state.getSnapshot().selectedShell).toBe(info.shell.path) + first.selectShell(alternate.path) + expect(data.size).toBe(0) + await first.start() + expect(data.get('dsh.terminal.shell')).toBe(alternate.path) + expect(h.remote.create).toHaveBeenCalledWith(sessionId, expect.objectContaining({ shellPath: alternate.path }), expect.any(AbortSignal)) + const second = h.view() + await second.refresh() + expect(second.state.getSnapshot().selectedShell).toBe(alternate.path) + await second.dispose() + await second.start() + vi.mocked(h.remote.shells).mockResolvedValue(success([info.shell])) + const third = h.view() + await third.refresh() + expect(third.state.getSnapshot().selectedShell).toBe(info.shell.path) + expect(h.remote.create).toHaveBeenCalledOnce() +}) + +it('retains a selection across a failed launch without remembering the failed shell', async () => { + const data = storage() + const h = fixture() + vi.mocked(h.remote.create).mockResolvedValueOnce(failure('shell disappeared')) + const model = h.view() + await model.refresh() + await model.start() + expect(data.size).toBe(0) + await model.refresh() + expect(model.state.getSnapshot().selectedShell).toBe(info.shell.path) + await model.close() + await model.start() + expect(h.remote.create).toHaveBeenCalledOnce() +}) + +it('keeps launch usable when browser storage is denied and stops late shell discovery after close', async () => { + vi.stubGlobal('localStorage', undefined) + Object.defineProperty(globalThis, 'localStorage', { configurable: true, get() { throw new Error('denied') } }) + const h = fixture() + const model = h.view() + await model.refresh() + await model.start() + expect(model.state.getSnapshot().info).toBeDefined() + const delayed = h.view() + const shells = Promise.withResolvers>>() + vi.mocked(h.remote.shells).mockReturnValueOnce(shells.promise) + const loading = delayed.refresh() + await expect.poll(() => h.remote.shells).toHaveBeenCalledTimes(2) + await delayed.close() + shells.resolve(success([info.shell])) + await loading + expect(delayed.state.getSnapshot().phase).toBe('closed') + expect(h.remote.create).toHaveBeenCalledOnce() +}) diff --git a/packages/api/terminal-controller/tsconfig.client.json b/packages/api/terminal-controller/tsconfig.client.json index 4b78263b57..3f2a5ca277 100644 --- a/packages/api/terminal-controller/tsconfig.client.json +++ b/packages/api/terminal-controller/tsconfig.client.json @@ -8,6 +8,7 @@ "files": [ "src/client/index.ts", "src/client/model.ts", + "src/client/shell-preference.ts", "src/types.ts", "src/client/close-requests.ts" ], diff --git a/packages/client/ui-sidebar-terminal/README.i18n.yaml b/packages/client/ui-sidebar-terminal/README.i18n.yaml index 769eb9afc0..8981de4d59 100644 --- a/packages/client/ui-sidebar-terminal/README.i18n.yaml +++ b/packages/client/ui-sidebar-terminal/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-sidebar-terminal/README.md -README.md: 2dba3ea839f89f505286fa6b977aef3fb0e45ab0 -README.zh.md: 63d483a37fcb9b913c20e47251aaaba3e0e21ffd +README.md: f20648687bce864604a93915ea52e26990f15db4 +README.zh.md: c9f3bc8f285ff4d7dfd38104337c7466f69bca5c diff --git a/packages/client/ui-sidebar-terminal/README.md b/packages/client/ui-sidebar-terminal/README.md index 2dba3ea839..f20648687b 100644 --- a/packages/client/ui-sidebar-terminal/README.md +++ b/packages/client/ui-sidebar-terminal/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Open the execution environment's default shell beside a conversation without leaving the Web app. Rename terminals in their tabs and recover retained processes after reloading the page. Collapse the sidebar to keep commands running; close a terminal tab to request process termination. Tab completion follows the shell configuration. +Choose an installed shell beside a conversation without leaving the Web app. Rename terminals in their tabs and recover retained processes after reloading the page. Collapse the sidebar to keep commands running; close a terminal tab to request process termination. Tab completion follows the shell configuration. ## Table of Contents @@ -25,7 +25,7 @@ Open the execution environment's default shell beside a conversation without lea ## Use this package -Open the right sidebar and select **New terminal** on the Start page; first select **New tab** when another content tab is open. The default shell starts immediately. Use the sidebar's **New tab** control to open more terminals. +Open the right sidebar and select **New terminal** on the Start page; first select **New tab** when another content tab is open. Choose an installed **Shell** and select **Start terminal**. The last successfully started shell is preselected from localStorage; first use or an unavailable saved choice selects the current default. Use the sidebar's **New tab** control to open more terminals. Double-click the terminal's tab title to rename it. **Take control** makes the current attachment writable when another page owns input. A failed connection offers **Reconnect**. An exited shell remains visible with its exit code and never restarts automatically. Exited terminals count toward the Session limit; close unused tabs when the limit is reached. @@ -65,7 +65,7 @@ None; terminal output travels only between the browser and Host. -- The default shell or native PTY startup can fail. The tab reports the failure without launching a different shell. +- Shell discovery or native PTY startup can fail. The tab reports the failure without launching a different shell. - Completion menus and inline suggestions depend on shell configuration. The Web UI adds no independent completion engine. - Terminal history is bounded. The feature does not send terminal output to the Agent, provide split terminal panes inside a tab, or restore processes after Host restart. diff --git a/packages/client/ui-sidebar-terminal/README.zh.md b/packages/client/ui-sidebar-terminal/README.zh.md index 63d483a37f..c9f3bc8f28 100644 --- a/packages/client/ui-sidebar-terminal/README.zh.md +++ b/packages/client/ui-sidebar-terminal/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -在对话旁打开执行环境的默认 shell,无需离开 Web 应用。在标签页上重命名终端,并在刷新页面后恢复保留的进程。折叠侧栏让命令继续运行,关闭终端标签页则请求结束进程。Tab 补全使用 shell 的配置。 +在对话旁选择已安装的 shell,无需离开 Web 应用。在标签页上重命名终端,并在刷新页面后恢复保留的进程。折叠侧栏让命令继续运行,关闭终端标签页则请求结束进程。Tab 补全使用 shell 的配置。 ## 目录 @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用此包 -展开右侧栏,在开始页选择**新建终端**;已有其他标签页时,先选择**新标签页**。默认 shell 会立即启动。通过侧栏的**新标签页**控件打开更多终端。 +展开右侧栏,在开始页选择**新建终端**;已有其他标签页时,先选择**新标签页**。选择已安装的 **Shell**,再点击**启动终端**。localStorage 会记住上次成功启动的 shell 并预选;首次使用或已保存选项不可用时,预选当前默认项。通过侧栏的**新标签页**控件打开更多终端。 双击终端标签页标题即可重命名。另一页面持有输入权时,**接管输入**让当前连接可写。连接失败时可以**重新连接**。shell 退出后仍显示屏幕和退出码,不自动重启。 已退出的终端计入 Session 数量上限;达到上限时请关闭不用的标签页。 diff --git a/packages/client/ui-sidebar-terminal/src/client/TerminalBody.module.css b/packages/client/ui-sidebar-terminal/src/client/TerminalBody.module.css index 817d30bef0..aabba1b3c1 100644 --- a/packages/client/ui-sidebar-terminal/src/client/TerminalBody.module.css +++ b/packages/client/ui-sidebar-terminal/src/client/TerminalBody.module.css @@ -7,3 +7,6 @@ .root button { padding: 6px 10px; border: 0.5px solid var(--dsw-alias-border-l3); border-radius: 6px; color: inherit; background: transparent; cursor: pointer; } .root button:hover { background: var(--dsw-alias-bg-l2); } .error { margin: 0; padding: 10px 12px; overflow-wrap: anywhere; color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-l2); } +.launch { display: flex; align-items: end; flex-wrap: wrap; gap: 8px; padding: 12px; } +.shellLabel { display: flex; flex: 1; flex-direction: column; gap: 6px; min-width: 0; } +.shellLabel select { min-width: 0; width: 100%; padding: 6px 10px; border: 0.5px solid var(--dsw-alias-border-l3); border-radius: 6px; color: inherit; font: inherit; background: var(--dsw-alias-bg-l1); } diff --git a/packages/client/ui-sidebar-terminal/src/client/TerminalBody.tsx b/packages/client/ui-sidebar-terminal/src/client/TerminalBody.tsx index 9a14a55a14..af404b1e69 100644 --- a/packages/client/ui-sidebar-terminal/src/client/TerminalBody.tsx +++ b/packages/client/ui-sidebar-terminal/src/client/TerminalBody.tsx @@ -13,7 +13,7 @@ import css from './TerminalBody.module.css' export type TerminalBodyProps = PropsRuntime<'sidebar.right.pane.tab'> & PropsLocale<'sidebarTerminal'> & InjectFace /** - * Mount an automatically started or restored terminal. + * Offer shell selection for a new terminal or mount a restored screen. * @param props - sidebar occurrence, model lookup and translated copy. * @returns the terminal screen and any pending or exceptional state. */ @@ -34,6 +34,15 @@ export function TerminalBody({ useTabInfo, useTerminal, view, t }: TerminalBodyP const readOnly = state.phase === 'connected' && state.info?.state === 'running' && !state.writable return (
+ {state.phase === 'selecting' &&
{ event.preventDefault(); void model.start() }}> + + +
} {(status !== undefined || retry || readOnly) &&
{status} {readOnly && <>{t('readonly')} } diff --git a/packages/client/ui-sidebar-terminal/src/client/locales.ts b/packages/client/ui-sidebar-terminal/src/client/locales.ts index ebda63a422..51afac8349 100644 --- a/packages/client/ui-sidebar-terminal/src/client/locales.ts +++ b/packages/client/ui-sidebar-terminal/src/client/locales.ts @@ -10,6 +10,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Simplified Chinese terminal copy. */ export const zh = { recoveryFailed: '恢复终端失败:{message}', retryRecovery: '重试恢复终端', + shell: 'Shell', start: '启动终端', title: '终端', new: '新建终端', loading: '正在读取终端环境…', creating: '正在启动…', connecting: '正在连接…', disconnected: '连接已断开。', reconnect: '重新连接', readonly: '此页面当前只读。', control: '接管输入', @@ -26,6 +27,7 @@ export const zh = { /** English terminal copy. */ export const en = { recoveryFailed: 'Terminal recovery failed: {message}', retryRecovery: 'Retry terminal recovery', + shell: 'Shell', start: 'Start terminal', title: 'Terminal', new: 'New terminal', loading: 'Reading terminal environment…', creating: 'Starting…', connecting: 'Connecting…', disconnected: 'Disconnected.', reconnect: 'Reconnect', readonly: 'This view is read-only.', control: 'Take control', diff --git a/packages/client/ui-sidebar-terminal/tests/terminal-body.client.spec.tsx b/packages/client/ui-sidebar-terminal/tests/terminal-body.client.spec.tsx index c20a5e7920..d8d80751be 100644 --- a/packages/client/ui-sidebar-terminal/tests/terminal-body.client.spec.tsx +++ b/packages/client/ui-sidebar-terminal/tests/terminal-body.client.spec.tsx @@ -65,6 +65,7 @@ function mount(initial: TerminalViewState | undefined = idle, dictionary = en) { let visible = true const detach = vi.fn() const model = { + start: vi.fn(async () => {}), selectShell: vi.fn(), mount: vi.fn(() => detach), refresh: vi.fn(async () => {}), rename: vi.fn(async () => {}), connect: vi.fn(), write: vi.fn(), resize: vi.fn(), acknowledge: vi.fn(), } @@ -309,3 +310,16 @@ it.each([en, zh])('translates known terminal failures while retaining unknown Ho h.update({ ...idle, phase: 'failed', error: 'Host permission denied' }) expect(h.view.getByRole('alert').textContent).toContain('Host permission denied') }) + +it('lets the user select an installed shell and start it before rendering a screen', () => { + const other = { path: '/bin/zsh', name: 'zsh', args: ['-i'] } + const h = mount({ phase: 'selecting', writable: false, environment, shells: [info.shell, other], selectedShell: other.path }) + expect(h.view.getByRole('combobox', { name: 'Shell' })).toHaveProperty('value', other.path) + expect(fake.terminals).toHaveLength(0) + fireEvent.change(h.view.getByRole('combobox'), { target: { value: info.shell.path } }) + expect(h.model.selectShell).toHaveBeenCalledWith(info.shell.path) + fireEvent.click(h.view.getByRole('button', { name: 'Start terminal' })) + expect(h.model.start).toHaveBeenCalledOnce() + h.update({ phase: 'selecting', writable: false, environment }) + expect(h.view.getByRole('button', { name: 'Start terminal' })).toHaveProperty('disabled', true) +}) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 896af518c4..94eb145f2c 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -2519,6 +2519,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'agent', description: 'Session owner supplied by the Gateway.' }, { name: 'signal', description: 'request cancellation.' }], returns: 'the Session workspace directory and terminal limits.', }, + { + signature: '@Remote shells(agent: Agent, signal: AbortSignal): Promise', + description: 'Discover installed shells in the Session\'s execution environment.', + parameters: [{ name: 'agent', description: 'Session owner supplied by the Gateway.' }, { name: 'signal', description: 'request cancellation.' }], + returns: 'verified profiles, with the configured or system default first.', + }, { signature: '@Remote list(sessionId: SessionId): WebTerminalInfo[]', description: 'List retained terminals without resolving or activating an Agent.', @@ -6101,7 +6107,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'TerminalCreateRequest', - declaration: 'export interface TerminalCreateRequest {\n readonly id: WebTerminalId;\n readonly cols: number;\n readonly rows: number;\n}', + declaration: 'export interface TerminalCreateRequest {\n readonly shellPath?: string;\n readonly id: WebTerminalId;\n readonly cols: number;\n readonly rows: number;\n}', }, { name: 'TerminalEnvironment', diff --git a/packages/ssh/ssh/src/helper-processes.ts b/packages/ssh/ssh/src/helper-processes.ts index e52945378b..c672a2deba 100644 --- a/packages/ssh/ssh/src/helper-processes.ts +++ b/packages/ssh/ssh/src/helper-processes.ts @@ -331,6 +331,19 @@ export class RemoteProcesses { return terminal.signalForeground(z.enum(['SIGINT', 'SIGTERM', 'SIGKILL', 'SIGTSTP', 'SIGHUP']).parse(value)) } + /** + * Resize an allocated terminal without replacing its process. + * @param id - terminal reservation. + * @param cols - positive terminal width. + * @param rows - positive terminal height. + * @returns after the local provider accepts the dimensions. + */ + async resizeTerminal(id: SshProcessId, cols: number, rows: number): Promise { + const terminal = this.record(id).terminal + if (terminal === undefined) throw new Error('SSH handle does not own a terminal') + await terminal.resize(cols, rows) + } + /** Stop every owned process on lease expiry or disconnect. */ async close(): Promise { if (this.closing) return diff --git a/packages/ssh/ssh/src/helper.ts b/packages/ssh/ssh/src/helper.ts index 61c1c82404..519d68a4a2 100644 --- a/packages/ssh/ssh/src/helper.ts +++ b/packages/ssh/ssh/src/helper.ts @@ -6,13 +6,14 @@ import type { Readable, Writable } from 'node:stream' import { Context } from '@deepseek-ai/cordis' import { FsError, type FsTarget, type FsWriteIntent, type FsVersion } from '@deepseek-ai/dsh-fs' import { SandboxedFileSystem } from '@deepseek-ai/dsh-fs-sandbox' +import { SubprocessExecutableNotFoundError } from '@deepseek-ai/dsh-subprocess' import { LocalSubprocessRuntime } from '@deepseek-ai/dsh-subprocess-local' import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { SessionProjectionRegistry } from '@deepseek-ai/dsh-session-projection' import type { SandboxExecutionPolicy, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import { z } from 'zod' -import { SshRpcPeer, SSH_MAX_PROCESS_HANDLES, SSH_MAX_TEXT_STREAMS, SSH_PROTOCOL_VERSION } from './protocol.ts' +import { SshRpcPeer, RemoteOperationError, SSH_MAX_PROCESS_HANDLES, SSH_MAX_TEXT_STREAMS, SSH_PROTOCOL_VERSION } from './protocol.ts' import { RemoteProcesses } from './helper-processes.ts' import { editSchema, environmentSchema, intentSchema, policySchema, processIdSchema, remotePath, targetSchema, textStreamIdSchema } from './schemas.ts' import type { SshTextStreamId } from './schemas.ts' @@ -114,6 +115,14 @@ export async function runSshHelper(transport: HelperTransport): Promise { if (method === 'process.done') return processes.done(processIdRequest.parse(raw).id) if (method === 'process.wait') return processes.wait(processIdRequest.parse(raw).id, signal) if (method === 'process.terminate') { await processes.terminate(processIdRequest.parse(raw).id); return null } + if (method === 'terminal.environment') { object.parse(raw); return ctx.subprocess.terminalEnvironment(signal) } + if (method === 'terminal.resize') { + const input = z.object({ + id: processIdSchema, cols: z.number().int().positive(), rows: z.number().int().positive(), + }).strict().parse(raw) + await processes.resizeTerminal(input.id, input.cols, input.rows) + return null + } if (method === 'terminal.write' || method === 'terminal.inspect' || method === 'terminal.signal') { const input = z.object({ id: processIdSchema, value: z.string().optional() }).strict().parse(raw) return processes.terminal(input.id, method === 'terminal.write' ? 'write' : method === 'terminal.inspect' ? 'inspect' : 'signal', input.value) @@ -123,7 +132,12 @@ export async function runSshHelper(transport: HelperTransport): Promise { const env = input.env === undefined ? undefined : Object.fromEntries( Object.entries(input.env).filter((entry): entry is [string, string] => entry[1] !== null), ) - return ctx.subprocess.resolveExecutable(input.command, env, signal) + try { + return await ctx.subprocess.resolveExecutable(input.command, env, signal) + } catch (error) { + if (error instanceof SubprocessExecutableNotFoundError) throw new RemoteOperationError(error.message, 'SUBPROCESS_EXECUTABLE_NOT_FOUND') + throw error + } } if (method === 'sandbox') { const input = z.object({ argv: z.array(z.string()).min(1), policy: policySchema }).strict().parse(raw) diff --git a/packages/ssh/ssh/src/schemas.ts b/packages/ssh/ssh/src/schemas.ts index 23b754067e..1b7db7dc92 100644 --- a/packages/ssh/ssh/src/schemas.ts +++ b/packages/ssh/ssh/src/schemas.ts @@ -48,7 +48,9 @@ export const spawnSchema = z.object({ stderr: z.union([z.literal('pipe'), z.literal('inherit'), collection]), control: z.literal('pipe').optional(), }).strict().optional(), - terminal: z.object({ rows: z.number().int().positive(), cols: z.number().int().positive() }).strict().optional(), + terminal: z.object({ + terminalType: z.string().min(1), rows: z.number().int().positive(), cols: z.number().int().positive(), + }).strict().optional(), }).strict().refine(value => (value.stdio === undefined) !== (value.terminal === undefined), 'select ordinary or terminal execution') /** Connection handshake binds sockets and workspace to one helper process. */ export const helloSchema = z.object({ protocol: z.literal(1), hash: z.string().regex(/^[0-9a-f]{64}$/), platform: z.enum(['linux', 'darwin']), nodeVersion: z.string(), node: remotePath, root: remotePath, workspace: remotePath, bootstrapHash: z.string().regex(/^[0-9a-f]{64}$/).optional() }).strict() diff --git a/packages/ssh/ssh/tests/helper-processes.spec.ts b/packages/ssh/ssh/tests/helper-processes.spec.ts index a7b42bd9d8..c94ffb7c8a 100644 --- a/packages/ssh/ssh/tests/helper-processes.spec.ts +++ b/packages/ssh/ssh/tests/helper-processes.spec.ts @@ -19,7 +19,7 @@ vi.mock('node:fs/promises', async (original) => { const outcome = { exitCode: 0, signal: null } const ordinaryRequest = { argv: ['target'], cwd: '/tmp', graceMs: 20, stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' } } -const terminalRequest = { argv: ['target'], cwd: '/tmp', graceMs: 20, terminal: { rows: 24, cols: 80 } } +const terminalRequest = { argv: ['target'], cwd: '/tmp', graceMs: 20, terminal: { terminalType: 'dumb', rows: 24, cols: 80 } } async function harness() { const root = await mkdtemp('/tmp/dsh-ssh-owner-') @@ -76,10 +76,12 @@ function terminal() { const inspectForeground = vi.fn(async () => undefined) const signalForeground = vi.fn(async () => 42) const terminate = vi.fn(async () => { output.end(); completion.resolve(outcome) }) + const resize = vi.fn(async (_cols: number, _rows: number) => {}) const handle: SubprocessTerminalHandle = { + resize, pid: 42, output, done: completion.promise, write, inspectForeground, signalForeground, terminate, } - return { handle, completion, output, write, inspectForeground, signalForeground, terminate } + return { handle, completion, output, write, resize, inspectForeground, signalForeground, terminate } } describe.skipIf(process.platform === 'win32')('SSH helper process settlement', () => { @@ -99,6 +101,8 @@ describe.skipIf(process.platform === 'win32')('SSH helper process settlement', ( await expect(test.owner.start(run.id)).rejects.toThrow('already requested') await test.owner.terminal(run.id, 'write', 'input') expect(child.write).toHaveBeenCalledWith('input') + await test.owner.resizeTerminal(run.id, 120, 40) + expect(child.resize).toHaveBeenCalledWith(120, 40) expect(await test.owner.terminal(run.id, 'inspect')).toBeNull() child.inspectForeground.mockResolvedValueOnce({ processGroupId: 42, inputWaiting: true }) expect(await test.owner.terminal(run.id, 'inspect')).toEqual({ processGroupId: 42, inputWaiting: true }) @@ -144,6 +148,7 @@ describe.skipIf(process.platform === 'win32')('SSH helper process settlement', ( const run = await test.prepare({ ...ordinaryRequest, env: { VALUE: 'kept', REMOVED: null }, stdio: { ...ordinaryRequest.stdio, stdin: 'pipe' } }) await expect(test.owner.done(run.id)).rejects.toThrow('has not started') await expect(test.owner.wait(run.id)).rejects.toThrow('was not started') + await expect(test.owner.resizeTerminal(run.id, 80, 24)).rejects.toThrow('does not own a terminal') const bytes: Buffer[] = [] child.stdin.on('data', (chunk: Buffer) => { bytes.push(Buffer.from(chunk)) }) const ended = once(child.stdin, 'end') diff --git a/packages/ssh/ssh/tests/helper-runtime.spec.ts b/packages/ssh/ssh/tests/helper-runtime.spec.ts index 1e4aa21070..3336986a8e 100644 --- a/packages/ssh/ssh/tests/helper-runtime.spec.ts +++ b/packages/ssh/ssh/tests/helper-runtime.spec.ts @@ -93,6 +93,9 @@ describe.skipIf(process.platform === 'win32')('SSH helper runtime', () => { await expect(test.client.request('sandbox', { argv: ['true'], policy: { mode: 'danger-full-access', workspaceRoot: test.root } }, z.unknown())).rejects.toThrow('does not need') expect(await test.client.request('executable', { command: process.execPath, env: { REMOVED: null } }, z.string())).toBe(process.execPath) expect(await test.client.request('executable', { command: process.execPath }, z.string())).toBe(process.execPath) + expect(await test.client.request('terminal.environment', {}, z.unknown())).toMatchObject({ platform: 'posix' }) + await expect(test.client.request('executable', { command: `${test.root}/absent` }, z.string())).rejects.toMatchObject({ code: 'SUBPROCESS_EXECUTABLE_NOT_FOUND' }) + await expect(test.client.request('executable', { command: './relative' }, z.string())).rejects.toThrow('relative') const wrapped = await test.client.request('sandbox', { argv: ['true'], policy: policy(test.root) }, z.looseObject({ argv: z.array(z.string()), enforcement: z.enum(['full', 'partial']) })) expect(wrapped.argv).toEqual(['confined', 'true']) expect(confine).toHaveBeenCalledWith(['true'], policy(test.root), expect.any(AbortSignal)) diff --git a/packages/ssh/ssh/tests/live.e2e.ts b/packages/ssh/ssh/tests/live.e2e.ts index 045ea794d6..6fd71de3c5 100644 --- a/packages/ssh/ssh/tests/live.e2e.ts +++ b/packages/ssh/ssh/tests/live.e2e.ts @@ -191,7 +191,7 @@ describe.skipIf(!enabled)('POSIX SSH runtime acceptance', () => { const test = await setup() try { const terminal = await test.ctx.subprocess.spawnTerminal({ - argv: ['/bin/bash', '--noprofile', '--norc', '-i'], cwd: test.root, rows: 24, cols: 80, graceMs: 500, + terminalType: 'dumb', argv: ['/bin/bash', '--noprofile', '--norc', '-i'], cwd: test.root, rows: 24, cols: 80, graceMs: 500, }) const output: string[] = [] const received = Promise.withResolvers() diff --git a/packages/ssh/ssh/tests/stream-capability.spec.ts b/packages/ssh/ssh/tests/stream-capability.spec.ts index fc1ca16187..9729a9f50d 100644 --- a/packages/ssh/ssh/tests/stream-capability.spec.ts +++ b/packages/ssh/ssh/tests/stream-capability.spec.ts @@ -21,7 +21,7 @@ describe.skipIf(process.platform === 'win32')('SSH stream capabilities', () => { const owner = new RemoteProcesses(new Context(), root, 1, 5000) let stream: Socket | undefined try { - const prepared = await owner.prepare({ argv: ['true'], cwd: root, graceMs: 100, terminal: { rows: 24, cols: 80 } }) + const prepared = await owner.prepare({ argv: ['true'], cwd: root, graceMs: 100, terminal: { terminalType: 'dumb', rows: 24, cols: 80 } }) const endpoint = prepared.streams.terminal! const raw = createConnection(endpoint.path) await once(raw, 'connect') @@ -40,7 +40,7 @@ describe.skipIf(process.platform === 'win32')('SSH stream capabilities', () => { const owner = new RemoteProcesses(new Context(), root, 1, 5000) const sockets: Socket[] = [] try { - const prepared = await owner.prepare({ argv: ['true'], cwd: root, graceMs: 100, terminal: { rows: 24, cols: 80 } }) + const prepared = await owner.prepare({ argv: ['true'], cwd: root, graceMs: 100, terminal: { terminalType: 'dumb', rows: 24, cols: 80 } }) const endpoint = prepared.streams.terminal! const raw = [createConnection(endpoint.path), createConnection(endpoint.path)] await Promise.all(raw.map(socket => once(socket, 'connect'))) diff --git a/packages/ssh/ssh/tests/stream-lifetime.spec.ts b/packages/ssh/ssh/tests/stream-lifetime.spec.ts index 9f88a2224e..2feeb1d52e 100644 --- a/packages/ssh/ssh/tests/stream-lifetime.spec.ts +++ b/packages/ssh/ssh/tests/stream-lifetime.spec.ts @@ -12,7 +12,7 @@ describe('SSH TLS cancellation ownership', () => { const root = await mkdtemp('/tmp/dsh-ssh-tls-life-') const owner = new RemoteProcesses(new Context(), root, 1, 5000) try { - const prepared = await owner.prepare({ argv: ['true'], cwd: root, graceMs: 100, terminal: { rows: 24, cols: 80 } }) + const prepared = await owner.prepare({ argv: ['true'], cwd: root, graceMs: 100, terminal: { terminalType: 'dumb', rows: 24, cols: 80 } }) const endpoint = prepared.streams.terminal! const raw = createConnection({ path: endpoint.path, allowHalfOpen: true }) raw.on('error', () => {}) diff --git a/packages/ssh/subprocess-ssh/README.i18n.yaml b/packages/ssh/subprocess-ssh/README.i18n.yaml index df7872748e..4b47b611a5 100644 --- a/packages/ssh/subprocess-ssh/README.i18n.yaml +++ b/packages/ssh/subprocess-ssh/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/ssh/subprocess-ssh/README.md -README.md: ea647cd15b97ae281e145c5ac3afbf1e4cc4740c -README.zh.md: 455da9cade52540eb69581ddc3ec858553c444a5 +README.md: 15afa74f041e9944036c451dacaaedb53ecd9ff9 +README.zh.md: 3417f184fbcb702edeadf3b510663b1a9a58192f diff --git a/packages/ssh/subprocess-ssh/README.md b/packages/ssh/subprocess-ssh/README.md index ea647cd15b..15afa74f04 100644 --- a/packages/ssh/subprocess-ssh/README.md +++ b/packages/ssh/subprocess-ssh/README.md @@ -33,6 +33,8 @@ A direct exit reported by `done` does not prove managed-range quiescence; `waitF ----- +Terminal shell facts and executable verification come from the remote helper. Lookup misses remain distinct from SSH failures. PTY creation forwards the caller's `terminalType`, and `resize()` updates the remote terminal without replacing its process. + ## Understand the implementation diff --git a/packages/ssh/subprocess-ssh/README.zh.md b/packages/ssh/subprocess-ssh/README.zh.md index 455da9cade..3417f184fb 100644 --- a/packages/ssh/subprocess-ssh/README.zh.md +++ b/packages/ssh/subprocess-ssh/README.zh.md @@ -33,6 +33,8 @@ kind: "package-reference" ----- +终端 shell 信息与可执行文件验证来自远端 helper。查找未命中与 SSH 故障分别报告。PTY 创建传递调用方的 `terminalType`,`resize()` 更新远端终端尺寸,不替换进程。 + ## 理解实现 diff --git a/packages/ssh/subprocess-ssh/src/index.ts b/packages/ssh/subprocess-ssh/src/index.ts index da69932102..ea2ba64907 100644 --- a/packages/ssh/subprocess-ssh/src/index.ts +++ b/packages/ssh/subprocess-ssh/src/index.ts @@ -2,13 +2,13 @@ import { Duplex, PassThrough, type Readable, type Writable } from 'node:stream' import type { Socket } from 'node:net' import { Context } from '@deepseek-ai/cordis' -import { SubprocessRuntime } from '@deepseek-ai/dsh-subprocess' -import type { SubprocessCollectedOutputs, SubprocessHandle, SubprocessOutcome, SubprocessOutputMode, SubprocessSpawnSpec, SubprocessTerminalHandle, SubprocessTerminalSignal, SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import { SubprocessRuntime, SubprocessExecutableNotFoundError } from '@deepseek-ai/dsh-subprocess' +import type { SubprocessCollectedOutputs, SubprocessHandle, SubprocessOutcome, SubprocessOutputMode, SubprocessSpawnSpec, SubprocessTerminalHandle, SubprocessTerminalEnvironment, SubprocessTerminalSignal, SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess' import { OutputCollector } from '@deepseek-ai/dsh-subprocess-local/output' import type { SshConnection } from '@deepseek-ai/dsh-ssh' import { doneSchema, foregroundSchema, outputSnapshotFrameLimit, outputSnapshotSchema, preparedSchema, remotePath, streamEndpointSchema } from '@deepseek-ai/dsh-ssh/schemas' import type { SshProcessId } from '@deepseek-ai/dsh-ssh/schemas' -import { SshRpcPeer } from '@deepseek-ai/dsh-ssh/protocol' +import { SshRpcPeer, RemoteOperationError } from '@deepseek-ai/dsh-ssh/protocol' import { z } from 'zod' function environment(env?: NodeJS.ProcessEnv): Record | undefined { @@ -252,8 +252,23 @@ export class SshSubprocessRuntime extends SubprocessRuntime { }) } - override resolveExecutable(command: string, env?: Readonly>, signal?: AbortSignal): Promise { - return this.ctx.ssh.request('executable', { command, env }, remotePath, signal) + override async resolveExecutable(command: string, env?: Readonly>, signal?: AbortSignal): Promise { + try { + return await this.ctx.ssh.request('executable', { command, env }, remotePath, signal) + } catch (error) { + if (error instanceof RemoteOperationError && error.code === 'SUBPROCESS_EXECUTABLE_NOT_FOUND') { + throw new SubprocessExecutableNotFoundError(error.message, { cause: error }) + } + throw error + } + } + + override terminalEnvironment(signal?: AbortSignal): Promise { + return this.ctx.ssh.request('terminal.environment', {}, z.object({ + platform: z.enum(['posix', 'windows']), defaultShell: z.string().optional(), + }).strict().transform(value => ({ platform: value.platform, + ...(value.defaultShell === undefined ? {} : { defaultShell: value.defaultShell }), + })), signal) } override spawn(spec: SubprocessSpawnSpec): SubprocessHandle { @@ -279,7 +294,7 @@ export class SshSubprocessRuntime extends SubprocessRuntime { const ssh = this.ctx.ssh const prepared = await ssh.request('process.prepare', { argv: spec.argv, cwd: spec.cwd, env: environment(spec.env), graceMs: spec.graceMs, - terminal: { rows: spec.rows, cols: spec.cols }, + terminal: { rows: spec.rows, cols: spec.cols, terminalType: spec.terminalType }, }, preparedSchema, signal) const id = prepared.id let socket: Socket | undefined @@ -298,6 +313,7 @@ export class SshSubprocessRuntime extends SubprocessRuntime { const abort = (): void => { void handle.terminate().catch(() => { void ssh.dispose().catch(() => {}) }) } const handle: SubprocessTerminalHandle = { pid: started.pid, output, done, + resize: async (cols, rows) => { await ssh.request('terminal.resize', { id, cols, rows }, z.null()) }, write: async (data) => { await ssh.request('terminal.write', { id, value: data }, z.null()) }, inspectForeground: async () => await ssh.request('terminal.inspect', { id }, foregroundSchema) ?? undefined, signalForeground: (signal: SubprocessTerminalSignal) => ssh.request('terminal.signal', { id, value: signal }, z.number().int().positive()), diff --git a/packages/ssh/subprocess-ssh/tests/lifecycle.spec.ts b/packages/ssh/subprocess-ssh/tests/lifecycle.spec.ts index 8f62ee1cea..4444a7e5eb 100644 --- a/packages/ssh/subprocess-ssh/tests/lifecycle.spec.ts +++ b/packages/ssh/subprocess-ssh/tests/lifecycle.spec.ts @@ -5,7 +5,7 @@ import { describe, expect, it, vi } from 'vitest' import { SshSubprocessRuntime } from '../src/index.ts' const id = 'a7b17d9c-5ebf-40d8-9e82-3d1ddae8517b' -const spec = { argv: ['bash'], cwd: '/workspace', rows: 24, cols: 80, graceMs: 100 } +const spec = { terminalType: 'dumb', argv: ['bash'], cwd: '/workspace', rows: 24, cols: 80, graceMs: 100 } describe('SSH terminal allocation lifecycle', () => { it.each(['prepare', 'connect', 'start'])('joins a pending %s before completing provider disposal', async (stage) => { diff --git a/packages/ssh/subprocess-ssh/tests/shell-discovery.spec.ts b/packages/ssh/subprocess-ssh/tests/shell-discovery.spec.ts new file mode 100644 index 0000000000..7af2872b28 --- /dev/null +++ b/packages/ssh/subprocess-ssh/tests/shell-discovery.spec.ts @@ -0,0 +1,58 @@ +/** Shell facts and lookup failures retain remote execution semantics. */ +import { Context } from '@deepseek-ai/cordis' +import { SubprocessExecutableNotFoundError } from '@deepseek-ai/dsh-subprocess' +import { RemoteOperationError } from '@deepseek-ai/dsh-ssh/protocol' +import { expect, it, vi } from 'vitest' +import { z } from 'zod' +import { SshSubprocessRuntime } from '../src/index.ts' + +it('validates remote shell facts and distinguishes a lookup miss from transport failure', async () => { + const ctx = new Context() + let facts: unknown = { platform: 'posix', defaultShell: '/remote/bin/zsh' } + let failure: Error | undefined + const request = vi.fn(async (method: string, _params: unknown, schema: z.ZodType): Promise => { + if (failure !== undefined) throw failure + return schema.parse(method === 'terminal.environment' ? facts : '/remote/bin/bash') + }) + ctx.provide('ssh', { request } as never) + const runtime = new SshSubprocessRuntime(ctx) + const signal = new AbortController().signal + try { + expect(await runtime.terminalEnvironment(signal)).toEqual(facts) + expect(request).toHaveBeenCalledWith('terminal.environment', {}, expect.anything(), signal) + facts = { platform: 'posix' } + expect(await runtime.terminalEnvironment()).toEqual(facts) + facts = { platform: 'untrusted' } + await expect(runtime.terminalEnvironment()).rejects.toBeInstanceOf(z.ZodError) + expect(await runtime.resolveExecutable('bash', undefined, signal)).toBe('/remote/bin/bash') + failure = new RemoteOperationError('not installed', 'SUBPROCESS_EXECUTABLE_NOT_FOUND') + await expect(runtime.resolveExecutable('absent')).rejects.toBeInstanceOf(SubprocessExecutableNotFoundError) + failure = new RemoteOperationError('permission', 'EACCES') + await expect(runtime.resolveExecutable('bash')).rejects.toBe(failure) + failure = new Error('SSH disconnected') + await expect(runtime.resolveExecutable('bash')).rejects.toBe(failure) + } finally { await ctx.fiber.dispose() } +}) + +it.skipIf(process.platform === 'win32')('queries the real helper and resizes its PTY without replacing the shell', async () => { + const { createHelperHarness } = await import('../../ssh/tests/fixtures/helper.ts') + const helper = await createHelperHarness() + const ctx = new Context() + ctx.provide('ssh', helper.connection as never) + const runtime = new SshSubprocessRuntime(ctx) + try { + expect(await runtime.terminalEnvironment()).toMatchObject({ platform: 'posix' }) + await expect(runtime.resolveExecutable(`${helper.root}/absent`)).rejects.toBeInstanceOf(SubprocessExecutableNotFoundError) + const terminal = await runtime.spawnTerminal({ argv: ['/bin/sh', '-i'], cwd: helper.root, cols: 80, rows: 24, terminalType: 'xterm-256color', graceMs: 100 }) + let output = '' + terminal.output.on('data', (chunk: Buffer) => { output += chunk.toString() }) + await terminal.resize(120, 40) + await terminal.write('printf "REMOTE_TERM:%s\\n" "$TERM"; stty size\n') + await expect.poll(() => output).toContain('REMOTE_TERM:xterm-256color') + await expect.poll(() => output).toMatch(/40\s+120/u) + await terminal.terminate() + await terminal.done + } finally { + try { await ctx.fiber.dispose() } finally { await helper.close() } + } +}) diff --git a/packages/ssh/subprocess-ssh/tests/terminal-behavior.spec.ts b/packages/ssh/subprocess-ssh/tests/terminal-behavior.spec.ts index b29902f6e4..7ec4e4b15c 100644 --- a/packages/ssh/subprocess-ssh/tests/terminal-behavior.spec.ts +++ b/packages/ssh/subprocess-ssh/tests/terminal-behavior.spec.ts @@ -8,7 +8,7 @@ import { z } from 'zod' import { SshSubprocessRuntime } from '../src/index.ts' const id = 'aa910b47-e7d7-467b-8421-3331569dd02b' -const spec: SubprocessTerminalSpawnSpec = { argv: ['bash'], cwd: '/remote/workspace', rows: 24, cols: 80, graceMs: 100 } +const spec: SubprocessTerminalSpawnSpec = { argv: ['bash'], cwd: '/remote/workspace', terminalType: 'dumb', rows: 24, cols: 80, graceMs: 100 } const completion = { outcome: { exitCode: 0, signal: null }, spills: {}, collected: {} } async function setup(options: { @@ -42,7 +42,7 @@ async function setup(options: { if (options.terminateFailure !== undefined) throw options.terminateFailure finished.resolve(completion) value = null - } else if (method === 'terminal.write') value = null + } else if (method === 'terminal.write' || method === 'terminal.resize') value = null else if (method === 'terminal.inspect') value = foreground else if (method === 'terminal.signal') value = 321 else throw new Error(`Unexpected terminal request ${method}`) @@ -89,13 +89,15 @@ describe('SSH terminal behavior', () => { test.remote.write('terminal output') expect(String((await data)[0])).toBe('terminal output') await handle.write('input\n') + await handle.resize(120, 40) + expect(test.calls.find(call => call.method === 'terminal.resize')?.params).toEqual({ id, cols: 120, rows: 40 }) expect(await handle.inspectForeground()).toBeUndefined() test.setForeground({ processGroupId: 321, inputWaiting: true }) expect(await handle.inspectForeground()).toEqual({ processGroupId: 321, inputWaiting: true }) expect(await handle.signalForeground('SIGINT')).toBe(321) expect(test.calls.find(call => call.method === 'process.prepare')?.params).toEqual({ argv: ['bash'], cwd: spec.cwd, env: { KEEP: 'value' }, graceMs: 100, - terminal: { rows: 24, cols: 80 }, + terminal: { terminalType: 'dumb', rows: 24, cols: 80 }, }) expect(test.calls.find(call => call.method === 'terminal.write')?.params).toEqual({ id, value: 'input\n' }) expect(test.calls.find(call => call.method === 'terminal.signal')?.params).toEqual({ id, value: 'SIGINT' }) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index e1566714c2..88b7f478a6 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -787,6 +787,7 @@ export const TYPE_LINK_EXEMPTIONS: Readonly> = { WorkspaceFileRange: 'Host workspace file endpoint contract is owned by packages/api/workspace-files/README.md', WorkspaceFileStat: 'Host workspace file endpoint contract is owned by packages/api/workspace-files/README.md', WorkspaceFileText: 'Host workspace file endpoint contract is owned by packages/api/workspace-files/README.md', + TerminalShell: 'Browser terminal shell profiles are owned by packages/api/terminal-controller/README.md', TerminalEnvironment: 'Browser terminal environment fields are owned by packages/api/terminal-controller/README.md', WebTerminalInfo: 'Browser terminal metadata is owned by packages/api/terminal-controller/README.md', TerminalCreateRequest: 'Browser terminal allocation fields are owned by packages/api/terminal-controller/README.md', diff --git a/scripts/package-dependency-policy.ts b/scripts/package-dependency-policy.ts index fa1db54c75..835c252675 100644 --- a/scripts/package-dependency-policy.ts +++ b/scripts/package-dependency-policy.ts @@ -54,6 +54,7 @@ const SAFE_HOST_DEPENDENCY_EXPORTS = { /** Runtime exports that require every consumer to resolve the provider's shared peer instance. */ const PEER_REQUIRED_HOST_EXPORTS = { + '@deepseek-ai/dsh-subprocess': ['SubprocessExecutableNotFoundError'], '@deepseek-ai/dsh-scope': ['carrierKeyOf', 'scopeOf', 'scopeTarget'], '@deepseek-ai/dsh-session': ['SESSION_FORMAT_VERSION'], '@deepseek-ai/dsh-session-persistence': ['SessionPersistenceNotFoundError'], diff --git a/snapshots/session/cordis-inspect-jsdoc/session.v3.jsonl b/snapshots/session/cordis-inspect-jsdoc/session.v3.jsonl index 0c58c12c6e..a184b87d82 100644 --- a/snapshots/session/cordis-inspect-jsdoc/session.v3.jsonl +++ b/snapshots/session/cordis-inspect-jsdoc/session.v3.jsonl @@ -19,7 +19,7 @@ {"type":"step/start","data":{"turn":1,"step":2}} {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-subprocess-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"subprocess\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1788778146017,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1788778146017,"index":0,"dt":[],"id":"inspect-subprocess-api","name":"cordis_inspect_query","args":["{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"subprocess\"}}"]},{"type":"chunk","time":1788778146017,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"inspect-subprocess-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"subprocess\"}}"}}},{"type":"chunk","time":1788778146017,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1788778146017,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":2,"callId":"inspect-subprocess-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"subprocess\"}}"}} -{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"inspect-subprocess-api"},"content":[{"type":"tool-result","toolCallId":"inspect-subprocess-api","content":[{"type":"text","text":"{\n \"platform\": \"host\",\n \"provider\": \"Service\",\n \"method\": \"listService\",\n \"data\": {\n \"mode\": \"service\",\n \"service\": {\n \"key\": \"subprocess\",\n \"description\": \"Abstract subprocess service. Subclass, implement spawn, and load the subclass as a plugin — it registers as `ctx.subprocess` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior).\\n\\nImplementations must honor these semantics:\\n\\n- Executable paths belong to one execution world shared with the mounted filesystem provider.\\n- spawn returns a live handle synchronously. Target identity remains provider-private; `done` resolves with the spawned command's exit facts and may reject for spawn or provider failures.\\n- Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another's output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here.\\n- SubprocessHandle.terminate (and the spec's abort signal) starts the provider's documented procedure against its managed range. SubprocessHandle.waitForExit observes that same range so a consumer-owned teardown ladder can hold each tier on real quiescence; each provider documents its signalling and observability limits.\\n- Disposal of the service terminates all still-running managed processes and awaits their exit.\\n- spawnTerminal owns terminal allocation, text transport, foreground groups, signalling, and whole-session quiescence behind one awaited termination method; readiness and persistent-shell policy stay in the PTY consumer. Its output stream ends after queued terminal output when the top-level process exits.\",\n \"access\": {\n \"optional\": {\n \"expression\": \"ctx.get(\\\"subprocess\\\")\",\n \"requiresUndefinedCheck\": true\n },\n \"hardDependency\": {\n \"inject\": [\n \"subprocess\"\n ],\n \"expression\": \"ctx.subprocess\"\n }\n },\n \"methods\": [\n {\n \"signature\": \"abstract resolveExecutable( command: string, env?: Readonly>, signal?: AbortSignal, ): Promise\",\n \"description\": \"Resolve one configured executable in this provider's execution world. Absolute paths are verified; bare names use the provider's scrubbed PATH plus explicit environment overrides. Relative paths containing separators are rejected: the resolution base is undefined, so providers fail loud instead of guessing.\",\n \"parameters\": [\n {\n \"name\": \"command\",\n \"description\": \"absolute executable path or bare PATH name.\"\n },\n {\n \"name\": \"env\",\n \"description\": \"explicit environment entries used for lookup.\"\n },\n {\n \"name\": \"signal\",\n \"description\": \"aborts remote or local lookup.\"\n }\n ],\n \"returns\": \"a canonical executable path.\"\n },\n {\n \"signature\": \"abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle\",\n \"description\": \"Start one managed child process from a fully-specified spec; this seam applies no defaults.\",\n \"parameters\": [\n {\n \"name\": \"spec\",\n \"description\": \"argv, directory, stdio dispositions, grace, cancellation, and environment.\"\n }\n ],\n \"returns\": \"the live process handle (streams/readers, signalling, outcome promise).\",\n \"throws\": [\n \"synchronously when pre-aborted or when argv, cwd, environment, or grace is invalid before handle creation.\"\n ]\n },\n {\n \"signature\": \"abstract spawnTerminal(spec: SubprocessTerminalSpawnSpec): Promise\",\n \"description\": \"Allocate a real terminal and start one owned process session. This is the only non-pipe process primitive: implementations own terminal byte I/O, foreground groups, signals, and whole-session quiescence.\",\n \"parameters\": [\n {\n \"name\": \"spec\",\n \"description\": \"fully specified argv, cwd, environment, dimensions, grace, and allocation cancellation.\"\n }\n ],\n \"returns\": \"the live terminal handle after allocation succeeds.\"\n }\n ]\n },\n \"referencedTypes\": [\n {\n \"name\": \"SubprocessCollect\",\n \"declaration\": \"export interface SubprocessCollect {\\n maxBytes: number;\\n spill?: {\\n maxBytes: number;\\n };\\n}\"\n },\n {\n \"name\": \"SubprocessCollectedOutputs\",\n \"declaration\": \"export interface SubprocessCollectedOutputs {\\n readonly stdout?: SubprocessOutputReader;\\n readonly stderr?: SubprocessOutputReader;\\n}\"\n },\n {\n \"name\": \"SubprocessHandle\",\n \"declaration\": \"export interface SubprocessHandle {\\n readonly stdin: Writable | undefined;\\n readonly stdout: Readable | undefined;\\n readonly stderr: Readable | undefined;\\n readonly control: Duplex | undefined;\\n readonly collected: SubprocessCollectedOutputs;\\n readonly done: Promise;\\n terminate(): void;\\n waitForExit(signal?: AbortSignal): Promise;\\n}\"\n },\n {\n \"name\": \"SubprocessOutcome\",\n \"declaration\": \"export interface SubprocessOutcome {\\n exitCode: number | null;\\n signal: NodeJS.Signals | null;\\n}\"\n },\n {\n \"name\": \"SubprocessOutputMode\",\n \"declaration\": \"export type SubprocessOutputMode = 'pipe' | 'inherit' | SubprocessCollect;\"\n },\n {\n \"name\": \"SubprocessOutputRead\",\n \"declaration\": \"export interface SubprocessOutputRead {\\n text: string;\\n nextOffset: number;\\n lossy: boolean;\\n spillPath?: string;\\n}\"\n },\n {\n \"name\": \"SubprocessOutputReader\",\n \"declaration\": \"export interface SubprocessOutputReader {\\n readFrom(fromByte: number): SubprocessOutputRead;\\n}\"\n },\n {\n \"name\": \"SubprocessSpawnSpec\",\n \"declaration\": \"export interface SubprocessSpawnSpec {\\n argv: readonly string[];\\n cwd: string;\\n stdio: SubprocessStdio;\\n graceMs: number;\\n signal?: AbortSignal | undefined;\\n env?: NodeJS.ProcessEnv | undefined;\\n}\"\n },\n {\n \"name\": \"SubprocessStdinMode\",\n \"declaration\": \"export type SubprocessStdinMode = 'ignore' | 'pipe' | {\\n readonly data: string;\\n};\"\n },\n {\n \"name\": \"SubprocessStdio\",\n \"declaration\": \"export interface SubprocessStdio {\\n stdin: SubprocessStdinMode;\\n stdout: SubprocessOutputMode;\\n stderr: SubprocessOutputMode;\\n control?: 'pipe';\\n}\"\n },\n {\n \"name\": \"SubprocessTerminalForeground\",\n \"declaration\": \"export interface SubprocessTerminalForeground {\\n processGroupId: number;\\n inputWaiting: boolean;\\n}\"\n },\n {\n \"name\": \"SubprocessTerminalHandle\",\n \"declaration\": \"export interface SubprocessTerminalHandle {\\n readonly pid: number;\\n readonly output: Readable;\\n readonly done: Promise;\\n write(data: string): Promise;\\n inspectForeground(): Promise;\\n signalForeground(signal: SubprocessTerminalSignal): Promise;\\n terminate(): Promise;\\n}\"\n },\n {\n \"name\": \"SubprocessTerminalSignal\",\n \"declaration\": \"export type SubprocessTerminalSignal = 'SIGINT' | 'SIGTERM' | 'SIGKILL' | 'SIGTSTP' | 'SIGHUP';\"\n },\n {\n \"name\": \"SubprocessTerminalSpawnSpec\",\n \"declaration\": \"export interface SubprocessTerminalSpawnSpec {\\n argv: readonly string[];\\n cwd: string;\\n env?: Record | undefined;\\n rows: number;\\n cols: number;\\n graceMs: number;\\n signal?: AbortSignal | undefined;\\n}\"\n }\n ]\n }\n}"}],"isError":false}],"role":"user","id":"{{message:7}}"}},"sourceEventSeqs":[19],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"inspect-subprocess-api"},"content":[{"type":"tool-result","toolCallId":"inspect-subprocess-api","content":[{"type":"text","text":"{\n \"platform\": \"host\",\n \"provider\": \"Service\",\n \"method\": \"listService\",\n \"data\": {\n \"mode\": \"service\",\n \"service\": {\n \"key\": \"subprocess\",\n \"description\": \"Abstract subprocess service. Subclass, implement spawn, and load the subclass as a plugin — it registers as `ctx.subprocess` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior).\\n\\nImplementations must honor these semantics:\\n\\n- Executable paths belong to one execution world shared with the mounted filesystem provider.\\n- spawn returns a live handle synchronously. Target identity remains provider-private; `done` resolves with the spawned command's exit facts and may reject for spawn or provider failures.\\n- Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another's output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here.\\n- SubprocessHandle.terminate (and the spec's abort signal) starts the provider's documented procedure against its managed range. SubprocessHandle.waitForExit observes that same range so a consumer-owned teardown ladder can hold each tier on real quiescence; each provider documents its signalling and observability limits.\\n- Disposal of the service terminates all still-running managed processes and awaits their exit.\\n- spawnTerminal owns terminal allocation, text transport, foreground groups, signalling, and whole-session quiescence behind one awaited termination method; readiness and persistent-shell policy stay in the PTY consumer. Its output stream ends after queued terminal output when the top-level process exits.\",\n \"access\": {\n \"optional\": {\n \"expression\": \"ctx.get(\\\"subprocess\\\")\",\n \"requiresUndefinedCheck\": true\n },\n \"hardDependency\": {\n \"inject\": [\n \"subprocess\"\n ],\n \"expression\": \"ctx.subprocess\"\n }\n },\n \"methods\": [\n {\n \"signature\": \"abstract resolveExecutable( command: string, env?: Readonly>, signal?: AbortSignal, ): Promise\",\n \"description\": \"Resolve one configured executable in this provider's execution world. Absolute paths are verified; bare names use the provider's scrubbed PATH plus explicit environment overrides. Relative paths containing separators are rejected: the resolution base is undefined, so providers fail loud instead of guessing.\",\n \"parameters\": [\n {\n \"name\": \"command\",\n \"description\": \"absolute executable path or bare PATH name.\"\n },\n {\n \"name\": \"env\",\n \"description\": \"explicit environment entries used for lookup.\"\n },\n {\n \"name\": \"signal\",\n \"description\": \"aborts remote or local lookup.\"\n }\n ],\n \"returns\": \"a canonical executable path.\"\n },\n {\n \"signature\": \"abstract terminalEnvironment(signal?: AbortSignal): Promise\",\n \"description\": \"Inspect shell-selection facts in the provider's execution environment.\",\n \"parameters\": [\n {\n \"name\": \"signal\",\n \"description\": \"cancellation of remote environment inspection.\"\n }\n ],\n \"returns\": \"platform and preferred shell; executable lookup and allocation remain separate operations.\"\n },\n {\n \"signature\": \"abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle\",\n \"description\": \"Start one managed child process from a fully-specified spec; this seam applies no defaults.\",\n \"parameters\": [\n {\n \"name\": \"spec\",\n \"description\": \"argv, directory, stdio dispositions, grace, cancellation, and environment.\"\n }\n ],\n \"returns\": \"the live process handle (streams/readers, signalling, outcome promise).\",\n \"throws\": [\n \"synchronously when pre-aborted or when argv, cwd, environment, or grace is invalid before handle creation.\"\n ]\n },\n {\n \"signature\": \"abstract spawnTerminal(spec: SubprocessTerminalSpawnSpec): Promise\",\n \"description\": \"Allocate a real terminal and start one owned process session. This is the only non-pipe process primitive: implementations own terminal byte I/O, foreground groups, signals, and whole-session quiescence.\",\n \"parameters\": [\n {\n \"name\": \"spec\",\n \"description\": \"fully specified argv, cwd, environment, dimensions, grace, and allocation cancellation.\"\n }\n ],\n \"returns\": \"the live terminal handle after allocation succeeds.\"\n }\n ]\n },\n \"referencedTypes\": [\n {\n \"name\": \"SubprocessCollect\",\n \"declaration\": \"export interface SubprocessCollect {\\n maxBytes: number;\\n spill?: {\\n maxBytes: number;\\n };\\n}\"\n },\n {\n \"name\": \"SubprocessCollectedOutputs\",\n \"declaration\": \"export interface SubprocessCollectedOutputs {\\n readonly stdout?: SubprocessOutputReader;\\n readonly stderr?: SubprocessOutputReader;\\n}\"\n },\n {\n \"name\": \"SubprocessHandle\",\n \"declaration\": \"export interface SubprocessHandle {\\n readonly stdin: Writable | undefined;\\n readonly stdout: Readable | undefined;\\n readonly stderr: Readable | undefined;\\n readonly control: Duplex | undefined;\\n readonly collected: SubprocessCollectedOutputs;\\n readonly done: Promise;\\n terminate(): void;\\n waitForExit(signal?: AbortSignal): Promise;\\n}\"\n },\n {\n \"name\": \"SubprocessOutcome\",\n \"declaration\": \"export interface SubprocessOutcome {\\n exitCode: number | null;\\n signal: NodeJS.Signals | null;\\n}\"\n },\n {\n \"name\": \"SubprocessOutputMode\",\n \"declaration\": \"export type SubprocessOutputMode = 'pipe' | 'inherit' | SubprocessCollect;\"\n },\n {\n \"name\": \"SubprocessOutputRead\",\n \"declaration\": \"export interface SubprocessOutputRead {\\n text: string;\\n nextOffset: number;\\n lossy: boolean;\\n spillPath?: string;\\n}\"\n },\n {\n \"name\": \"SubprocessOutputReader\",\n \"declaration\": \"export interface SubprocessOutputReader {\\n readFrom(fromByte: number): SubprocessOutputRead;\\n}\"\n },\n {\n \"name\": \"SubprocessSpawnSpec\",\n \"declaration\": \"export interface SubprocessSpawnSpec {\\n argv: readonly string[];\\n cwd: string;\\n stdio: SubprocessStdio;\\n graceMs: number;\\n signal?: AbortSignal | undefined;\\n env?: NodeJS.ProcessEnv | undefined;\\n}\"\n },\n {\n \"name\": \"SubprocessStdinMode\",\n \"declaration\": \"export type SubprocessStdinMode = 'ignore' | 'pipe' | {\\n readonly data: string;\\n};\"\n },\n {\n \"name\": \"SubprocessStdio\",\n \"declaration\": \"export interface SubprocessStdio {\\n stdin: SubprocessStdinMode;\\n stdout: SubprocessOutputMode;\\n stderr: SubprocessOutputMode;\\n control?: 'pipe';\\n}\"\n },\n {\n \"name\": \"SubprocessTerminalEnvironment\",\n \"declaration\": \"export interface SubprocessTerminalEnvironment {\\n platform: 'posix' | 'windows';\\n defaultShell?: string;\\n}\"\n },\n {\n \"name\": \"SubprocessTerminalForeground\",\n \"declaration\": \"export interface SubprocessTerminalForeground {\\n processGroupId: number;\\n inputWaiting: boolean;\\n}\"\n },\n {\n \"name\": \"SubprocessTerminalHandle\",\n \"declaration\": \"export interface SubprocessTerminalHandle {\\n readonly pid: number;\\n readonly output: Readable;\\n readonly done: Promise;\\n write(data: string): Promise;\\n resize(cols: number, rows: number): Promise;\\n inspectForeground(): Promise;\\n signalForeground(signal: SubprocessTerminalSignal): Promise;\\n terminate(): Promise;\\n}\"\n },\n {\n \"name\": \"SubprocessTerminalSignal\",\n \"declaration\": \"export type SubprocessTerminalSignal = 'SIGINT' | 'SIGTERM' | 'SIGKILL' | 'SIGTSTP' | 'SIGHUP';\"\n },\n {\n \"name\": \"SubprocessTerminalSpawnSpec\",\n \"declaration\": \"export interface SubprocessTerminalSpawnSpec {\\n argv: readonly string[];\\n cwd: string;\\n env?: Record | undefined;\\n rows: number;\\n cols: number;\\n terminalType: string;\\n graceMs: number;\\n signal?: AbortSignal | undefined;\\n}\"\n }\n ]\n }\n}"}],"isError":false}],"role":"user","id":"{{message:7}}"}},"sourceEventSeqs":[19],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} {"type":"step/start","data":{"turn":1,"step":3}} {"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"CORDIS_INSPECT_JSDOC_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:8}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1788778146037,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1788778146037,"index":0,"dt":[],"texts":["CORDIS_INSPECT_JSDOC_OK"]},{"type":"chunk","time":1788778146037,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CORDIS_INSPECT_JSDOC_OK"}}},{"type":"chunk","time":1788778146037,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1788778146037,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} From 37e5d8c4aac1ac5c97c9ac61382faf9c17568f63 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 14 Sep 2026 11:02:53 +0800 Subject: [PATCH 71/79] fix(web): remember shell choices before terminal startup --- .../2026-09-09-web-sidebar-terminal.i18n.yaml | 4 ++-- .../feature/2026-09-09-web-sidebar-terminal.md | 2 +- .../feature/2026-09-09-web-sidebar-terminal.zh.md | 2 +- apps/web/tests/sidebar-terminal.e2e.ts | 1 + packages/api/terminal-controller/README.i18n.yaml | 4 ++-- packages/api/terminal-controller/README.md | 2 +- packages/api/terminal-controller/README.zh.md | 2 +- packages/api/terminal-controller/src/client/model.ts | 9 ++++++--- .../src/client/shell-preference.ts | 6 +++--- .../tests/recovery.client.spec.ts | 12 ++++++++---- packages/client/ui-sidebar-terminal/README.i18n.yaml | 4 ++-- packages/client/ui-sidebar-terminal/README.md | 2 +- packages/client/ui-sidebar-terminal/README.zh.md | 2 +- 13 files changed, 30 insertions(+), 22 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml index 2a4d90b89c..21ad4429b4 100644 --- a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md -2026-09-09-web-sidebar-terminal.md: f922e8c889fea0fef1fafcd729b1a5489a8b0c42 -2026-09-09-web-sidebar-terminal.zh.md: e53c71e3f6c307e1afe2fc9f1f9561599ba41829 +2026-09-09-web-sidebar-terminal.md: ce1162a84d3f96336cbb217dbdfe62d12ac23280 +2026-09-09-web-sidebar-terminal.zh.md: 51e0f374b5fde389323868e56fbd86d3bc5720b3 diff --git a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md index f922e8c889..ce1162a84d 100644 --- a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md +++ b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md @@ -10,7 +10,7 @@ Web users need an interactive shell beside a Session to inspect the workspace an ## Decision -`api-terminal-controller` owns user terminals by Session and exposes the `terminal` Remote namespace. `ui-sidebar-terminal` registers native right-sidebar tabs, xterm.js rendering and FitAddon sizing. A new terminal offers installed shells and waits for Start. Host discovery verifies the configured candidates, with the execution default first; creation accepts only a currently discovered path. The browser remembers the last successful shell path in origin-scoped localStorage and falls back to the current default if that path is unavailable. The terminal type declares independent instances, so ordinary page deduplication cannot collapse separate processes when opening or docking tabs. The existing sidebar controls open additional tabs; double-clicking a tab title renames its terminal. Terminal processes use the composed subprocess provider and Session sandbox policy. Shell resolution occurs during discovery and creation; reading limits and reconnecting an existing process do not depend on the default executable remaining available. Interactive shell configuration supplies Tab completion and optional inline suggestions. +`api-terminal-controller` owns user terminals by Session and exposes the `terminal` Remote namespace. `ui-sidebar-terminal` registers native right-sidebar tabs, xterm.js rendering and FitAddon sizing. A new terminal offers installed shells and waits for Start. Host discovery verifies the configured candidates, with the execution default first; creation accepts only a currently discovered path. The browser remembers the last selected shell path in origin-scoped localStorage and falls back to the current default if that path is unavailable. The terminal type declares independent instances, so ordinary page deduplication cannot collapse separate processes when opening or docking tabs. The existing sidebar controls open additional tabs; double-clicking a tab title renames its terminal. Terminal processes use the composed subprocess provider and Session sandbox policy. Shell resolution occurs during discovery and creation; reading limits and reconnecting an existing process do not depend on the default executable remaining available. Interactive shell configuration supplies Tab completion and optional inline suggestions. Close and replacement remove the tab synchronously and run process cleanup in the background. The Client first records the unfinished close request under a terminal-specific localStorage key; success removes it, and startup retries requests that remain. A cleanup failure produces a lightweight notification with a retry action without reopening the tab. Independent keys prevent another window from overwriting unrelated cleanup requests. Collapse, tab/Session switching, floating, fullscreen and browser disconnection preserve the process. Component cleanup and `TabDomain.signal` only detach browser work because the same lifetime can end during plugin reload. Failed process cleanup retains ownership, including failures after allocation but before create publication. Session owner disposal and Host plugin disposal also clean up terminals. A definitive missing-Session response retires its saved close request because the Session owns process cleanup; transport failures remain retryable. Client plugin disposal awaits every detached stream so a replacement plugin does not inherit unfinished Client cleanup. diff --git a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md index e53c71e3f6..51e0f374b5 100644 --- a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md +++ b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md @@ -10,7 +10,7 @@ Web 用户需要在 Session 旁使用交互式 shell 检查工作区和运行命 ## 决定 -`api-terminal-controller` 按 Session 管理用户终端并提供 `terminal` Remote namespace。`ui-sidebar-terminal` 注册原生右侧栏标签页,使用 xterm.js 渲染和 FitAddon 测量尺寸。新终端提供已安装 shell 的选择并等待用户启动。Host 探测会验证配置的候选,并把执行环境默认项放在首位;创建只接受当前探测返回的路径。浏览器在当前站点 localStorage 中记住上次成功启动的 shell 路径,该路径不可用时回到当前默认项。终端类型声明独立实例,因此打开或停靠标签页时,普通页面的去重规则不会合并不同进程。已有侧栏控件负责打开更多标签页,双击标签页标题可重命名终端。终端进程使用组合的 subprocess provider 和 Session sandbox policy。shell 在探测和创建时解析;读取限制和重新连接已有进程不依赖默认可执行文件仍然可用。交互式 shell 配置提供 Tab 补全和可选的内联建议。 +`api-terminal-controller` 按 Session 管理用户终端并提供 `terminal` Remote namespace。`ui-sidebar-terminal` 注册原生右侧栏标签页,使用 xterm.js 渲染和 FitAddon 测量尺寸。新终端提供已安装 shell 的选择并等待用户启动。Host 探测会验证配置的候选,并把执行环境默认项放在首位;创建只接受当前探测返回的路径。浏览器在当前站点 localStorage 中记住上次选择的 shell 路径,该路径不可用时回到当前默认项。终端类型声明独立实例,因此打开或停靠标签页时,普通页面的去重规则不会合并不同进程。已有侧栏控件负责打开更多标签页,双击标签页标题可重命名终端。终端进程使用组合的 subprocess provider 和 Session sandbox policy。shell 在探测和创建时解析;读取限制和重新连接已有进程不依赖默认可执行文件仍然可用。交互式 shell 配置提供 Tab 补全和可选的内联建议。 关闭和替换会同步移除标签页,并在后台清理进程。Client 先以终端独立的 localStorage key 保存未完成的关闭请求;成功后删除,启动时重试剩余请求。清理失败时显示带重试操作的轻量通知,不重新打开标签页。独立 key 避免其他窗口覆盖无关的清理请求。折叠、切换标签页或 Session、浮动、全屏和浏览器断线均保留进程。组件清理和 `TabDomain.signal` 只停止浏览器工作,因为插件重新加载也会结束这些生命周期。进程清理失败时保留所有权,包括分配完成但 create 尚未发布时的失败。Session owner 和 Host 插件卸载也会清理终端。 明确的 Session 不存在响应会清除已保存的关闭请求,因为进程清理由 Session 负责;传输失败仍可重试。Client 插件卸载等待所有断开的流结束,避免替换插件继承未完成的 Client 清理。 diff --git a/apps/web/tests/sidebar-terminal.e2e.ts b/apps/web/tests/sidebar-terminal.e2e.ts index 072801c58d..4a721047e4 100644 --- a/apps/web/tests/sidebar-terminal.e2e.ts +++ b/apps/web/tests/sidebar-terminal.e2e.ts @@ -169,6 +169,7 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => { await compareOrRefreshGolden(fileURLToPath(new URL('./expected/sidebar-terminal/selection.expected.md', import.meta.url)), await page.locator('[data-sidebar-terminal]').ariaSnapshot(), webSnapshotMode()) await selector.selectOption('/bin/sh') + expect(await page.evaluate(() => localStorage.getItem('dsh.terminal.shell'))).toBe('/bin/sh') await page.screenshot({ path: `${shots}/shell-choice.png`, fullPage: true }) await page.getByRole('button', { name: 'Start terminal', exact: true }).click() await command(page, "printf 'CHOSEN_SHELL:%s\\n' \"$0\"") diff --git a/packages/api/terminal-controller/README.i18n.yaml b/packages/api/terminal-controller/README.i18n.yaml index 618b771218..0a1f8343a9 100644 --- a/packages/api/terminal-controller/README.i18n.yaml +++ b/packages/api/terminal-controller/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/terminal-controller/README.md -README.md: a24cb6e572782b59b83b836f0f477358abadf347 -README.zh.md: b979a7913e3b7fbcd1330ed7459eea9423348941 +README.md: c302a2b7269bdb6a76d0c6056d5851a84a876e9a +README.zh.md: dfc50e30f3a35b70c40bb76f9832cbd9548ba2cf diff --git a/packages/api/terminal-controller/README.md b/packages/api/terminal-controller/README.md index a24cb6e572..c302a2b726 100644 --- a/packages/api/terminal-controller/README.md +++ b/packages/api/terminal-controller/README.md @@ -52,7 +52,7 @@ The latest attachment owns input and resize. Detachment releases input control w Client views keep the association between sidebar tabs and terminal identities in memory. Recovery queries the Host for retained terminals; a new view may create a process, while a recovered view reports a missing target without creating a replacement. The Client model acknowledges screen writes after the browser emulator processes them, serializes input and ignores stale attachment responses. Client-owned errors carry locale keys. Plugin disposal awaits active and previously detached output streams without closing Host processes. -New views wait for explicit shell selection and start. The last successful shell path is stored under `dsh.terminal.shell` in origin-scoped localStorage. A saved path is preselected only while Host discovery still lists it; otherwise the current default is selected. Storage failures do not prevent startup. Restoring existing terminals neither reads this preference nor discovers shells. +New views wait for explicit shell selection and start. The last selected shell path is stored under `dsh.terminal.shell` in origin-scoped localStorage. A saved path is preselected only while Host discovery still lists it; otherwise the current default is selected. Storage failures do not prevent startup. Restoring existing terminals neither reads this preference nor discovers shells. Closing saves an unfinished cleanup request before releasing the tab, then awaits Host cleanup in the background. A failure exposes a retry notification. Each request has its own terminal-ID localStorage key and is removed after successful cleanup or a definitive `session/not-found` response; startup retries saved requests. Transport failures retain the request. This stores cleanup intent, not sidebar layout, open-tab mappings, selected tabs or process PIDs. If browser storage is unavailable, cleanup remains usable in memory but cannot be recovered after reload. diff --git a/packages/api/terminal-controller/README.zh.md b/packages/api/terminal-controller/README.zh.md index b979a7913e..dfc50e30f3 100644 --- a/packages/api/terminal-controller/README.zh.md +++ b/packages/api/terminal-controller/README.zh.md @@ -52,7 +52,7 @@ Host 通过 `ctx.subprocess.spawnTerminal` 创建 `TERM=xterm-256color` 的终 Client 视图只在内存中关联侧栏标签页与终端标识。恢复操作查询 Host 保留的终端;新视图可以创建进程,恢复视图在目标缺失时显示错误,不创建替代进程。Client 模型在浏览器完成屏幕解析后确认帧,按序发送输入,并忽略旧连接迟到的响应。 Client 自产错误携带本地化键。插件卸载等待活跃及先前断开的输出流结束,不关闭 Host 进程。 -新视图等待用户选择 shell 并启动。上次成功启动的 shell 路径保存在当前站点 localStorage 的 `dsh.terminal.shell` 中。仅当 Host 探测仍列出该路径时才预选它,否则选择当前默认项。存储失败不影响启动。恢复已有终端既不读取这一偏好,也不探测 shell。 +新视图等待用户选择 shell 并启动。上次选择的 shell 路径保存在当前站点 localStorage 的 `dsh.terminal.shell` 中。仅当 Host 探测仍列出该路径时才预选它,否则选择当前默认项。存储失败不影响启动。恢复已有终端既不读取这一偏好,也不探测 shell。 关闭时先保存未完成的清理请求并释放标签页,再在后台等待 Host 清理。失败时提供重试通知。每个请求使用独立的终端 ID localStorage key,清理成功或收到明确的 `session/not-found` 响应后删除;启动时重试已保存的请求。传输失败时保留请求。保存的是清理意图,不是侧栏布局、打开标签页映射、选中标签页或进程 PID。浏览器存储不可用时,内存中的清理仍可工作,但刷新后无法恢复该请求。 diff --git a/packages/api/terminal-controller/src/client/model.ts b/packages/api/terminal-controller/src/client/model.ts index 1150718f1f..f6c1a350ad 100644 --- a/packages/api/terminal-controller/src/client/model.ts +++ b/packages/api/terminal-controller/src/client/model.ts @@ -125,12 +125,15 @@ export class TerminalView { } /** - * Select one verified shell before starting the terminal. + * Select and remember one verified shell before starting the terminal. * @param path - executable path offered by Host discovery. */ selectShell(path: string): void { const state = this.state.getSnapshot() - if (state.phase === 'selecting' && state.shells?.some(shell => shell.path === path)) this.patch({ selectedShell: path }) + if (state.phase === 'selecting' && state.shells?.some(shell => shell.path === path)) { + this.patch({ selectedShell: path }) + rememberShell(path) + } } /** @@ -142,6 +145,7 @@ export class TerminalView { const state = this.state.getSnapshot() if (state.phase !== 'selecting' || state.environment === undefined || state.selectedShell === undefined || this.closing !== undefined || this.lifetime.signal.aborted) return Promise.resolve() + rememberShell(state.selectedShell) return this.create(state.environment, state.selectedShell) } @@ -154,7 +158,6 @@ export class TerminalView { id: this.id, shellPath, cols: Math.min(80, environment.maxCols), rows: Math.min(24, environment.maxRows), }, this.lifetime.signal)) if (!this.lifetime.signal.aborted) { - rememberShell(info.shell.path) this.adopt(info) } })().catch((error: unknown) => { this.fail(error) }).finally(() => { this.creation = undefined }) diff --git a/packages/api/terminal-controller/src/client/shell-preference.ts b/packages/api/terminal-controller/src/client/shell-preference.ts index 044a41d845..8e8a461591 100644 --- a/packages/api/terminal-controller/src/client/shell-preference.ts +++ b/packages/api/terminal-controller/src/client/shell-preference.ts @@ -3,7 +3,7 @@ const KEY = 'dsh.terminal.shell' /** * Read the browser preference. - * @returns the last successfully started shell path, or null when storage is unavailable. + * @returns the last selected shell path, or null when storage is unavailable. */ export function preferredShell(): string | null { try { return typeof localStorage === 'undefined' ? null : localStorage.getItem(KEY) } @@ -11,8 +11,8 @@ export function preferredShell(): string | null { } /** - * Remember a successful shell launch without making storage a startup dependency. - * @param path - verified executable path returned by the Host. + * Remember the selected shell without making storage a startup dependency. + * @param path - verified executable path offered by the Host. */ export function rememberShell(path: string): void { try { if (typeof localStorage !== 'undefined') localStorage.setItem(KEY, path) } diff --git a/packages/api/terminal-controller/tests/recovery.client.spec.ts b/packages/api/terminal-controller/tests/recovery.client.spec.ts index d58a9e26ba..3839e2024a 100644 --- a/packages/api/terminal-controller/tests/recovery.client.spec.ts +++ b/packages/api/terminal-controller/tests/recovery.client.spec.ts @@ -445,7 +445,7 @@ it('waits for both active and detached stream finalizers during plugin disposal expect(h.remote.close).not.toHaveBeenCalled() }) -it('remembers a successful shell choice for new views and falls back when that shell is absent', async () => { +it('remembers a shell choice immediately for new views and falls back when that shell is absent', async () => { const data = storage() const h = fixture() const alternate = { name: 'bash', path: '/bin/bash', args: ['-i'] } @@ -459,7 +459,11 @@ it('remembers a successful shell choice for new views and falls back when that s first.selectShell('/not-listed') expect(first.state.getSnapshot().selectedShell).toBe(info.shell.path) first.selectShell(alternate.path) - expect(data.size).toBe(0) + expect(data.get('dsh.terminal.shell')).toBe(alternate.path) + const beforeLaunch = h.view() + await beforeLaunch.refresh() + expect(beforeLaunch.state.getSnapshot().selectedShell).toBe(alternate.path) + expect(h.remote.create).not.toHaveBeenCalled() await first.start() expect(data.get('dsh.terminal.shell')).toBe(alternate.path) expect(h.remote.create).toHaveBeenCalledWith(sessionId, expect.objectContaining({ shellPath: alternate.path }), expect.any(AbortSignal)) @@ -475,14 +479,14 @@ it('remembers a successful shell choice for new views and falls back when that s expect(h.remote.create).toHaveBeenCalledOnce() }) -it('retains a selection across a failed launch without remembering the failed shell', async () => { +it('retains the last selection across a failed launch', async () => { const data = storage() const h = fixture() vi.mocked(h.remote.create).mockResolvedValueOnce(failure('shell disappeared')) const model = h.view() await model.refresh() await model.start() - expect(data.size).toBe(0) + expect(data.get('dsh.terminal.shell')).toBe(info.shell.path) await model.refresh() expect(model.state.getSnapshot().selectedShell).toBe(info.shell.path) await model.close() diff --git a/packages/client/ui-sidebar-terminal/README.i18n.yaml b/packages/client/ui-sidebar-terminal/README.i18n.yaml index 8981de4d59..4a97230d15 100644 --- a/packages/client/ui-sidebar-terminal/README.i18n.yaml +++ b/packages/client/ui-sidebar-terminal/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-sidebar-terminal/README.md -README.md: f20648687bce864604a93915ea52e26990f15db4 -README.zh.md: c9f3bc8f285ff4d7dfd38104337c7466f69bca5c +README.md: d36e42ccea3a4b419231e39c1c6e8d5b3bf754aa +README.zh.md: 1493828388e82c24d5d3a95a8a2e1dc379f152cf diff --git a/packages/client/ui-sidebar-terminal/README.md b/packages/client/ui-sidebar-terminal/README.md index f20648687b..d36e42ccea 100644 --- a/packages/client/ui-sidebar-terminal/README.md +++ b/packages/client/ui-sidebar-terminal/README.md @@ -25,7 +25,7 @@ Choose an installed shell beside a conversation without leaving the Web app. Ren ## Use this package -Open the right sidebar and select **New terminal** on the Start page; first select **New tab** when another content tab is open. Choose an installed **Shell** and select **Start terminal**. The last successfully started shell is preselected from localStorage; first use or an unavailable saved choice selects the current default. Use the sidebar's **New tab** control to open more terminals. +Open the right sidebar and select **New terminal** on the Start page; first select **New tab** when another content tab is open. Choose an installed **Shell** and select **Start terminal**. The last selected shell is preselected from localStorage; first use or an unavailable saved choice selects the current default. Use the sidebar's **New tab** control to open more terminals. Double-click the terminal's tab title to rename it. **Take control** makes the current attachment writable when another page owns input. A failed connection offers **Reconnect**. An exited shell remains visible with its exit code and never restarts automatically. Exited terminals count toward the Session limit; close unused tabs when the limit is reached. diff --git a/packages/client/ui-sidebar-terminal/README.zh.md b/packages/client/ui-sidebar-terminal/README.zh.md index c9f3bc8f28..1493828388 100644 --- a/packages/client/ui-sidebar-terminal/README.zh.md +++ b/packages/client/ui-sidebar-terminal/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用此包 -展开右侧栏,在开始页选择**新建终端**;已有其他标签页时,先选择**新标签页**。选择已安装的 **Shell**,再点击**启动终端**。localStorage 会记住上次成功启动的 shell 并预选;首次使用或已保存选项不可用时,预选当前默认项。通过侧栏的**新标签页**控件打开更多终端。 +展开右侧栏,在开始页选择**新建终端**;已有其他标签页时,先选择**新标签页**。选择已安装的 **Shell**,再点击**启动终端**。localStorage 会记住上次选择的 shell 并预选;首次使用或已保存选项不可用时,预选当前默认项。通过侧栏的**新标签页**控件打开更多终端。 双击终端标签页标题即可重命名。另一页面持有输入权时,**接管输入**让当前连接可写。连接失败时可以**重新连接**。shell 退出后仍显示屏幕和退出码,不自动重启。 已退出的终端计入 Session 数量上限;达到上限时请关闭不用的标签页。 From 3219a2d8e125aa7f8f04071f23f77b8879cc3bf5 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 14 Sep 2026 11:10:18 +0800 Subject: [PATCH 72/79] docs(api): refresh terminal dependency graph --- docs/module-graph.i18n.yaml | 4 ++-- docs/module-graph.md | 3 ++- docs/module-graph.zh.md | 3 ++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 3fbca05c5a..2ad16b0afa 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: a6371ef88bc8c49fe04e9ba3e2d3096b57ed2bcc -module-graph.zh.md: 38acfd45af4eed482ccb61ac4657d13beff316a3 +module-graph.md: 64cae46975a6ab586c034bbeee2783b49eec1825 +module-graph.zh.md: af71106dd91a778f12e912593d27907bd1db000b diff --git a/docs/module-graph.md b/docs/module-graph.md index a6371ef88b..64cae46975 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -427,6 +427,7 @@ flowchart TD pkg_web_search_perplexity --> pkg_launch_environment pkg_web_search_perplexity --> pkg_web pkg_api_remotes --> pkg_scope + pkg_api_terminal_controller --> pkg_subprocess pkg_attachment_local --> pkg_attachment pkg_attachment_local --> pkg_home_paths pkg_client_file_upload --> pkg_scope @@ -1260,7 +1261,6 @@ flowchart TD | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | `llm` | — | | [`llm`](../packages/llm/llm) | `llm` | — | | [`api-gateway`](../packages/api/gateway) | `api` | — | -| [`api-terminal-controller`](../packages/api/terminal-controller) | `api` | — | | [`api-workspace-files`](../packages/api/workspace-files) | `api` | — | | [`cmdline`](../packages/boot/cmdline) | `boot` | — | | [`acp-app`](../packages/bundle/acp-app) | `bundle` | — | @@ -1364,6 +1364,7 @@ flowchart TD | [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | | [`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) | +| [`api-terminal-controller`](../packages/api/terminal-controller) | `api` | [`subprocess`](../packages/subprocess/subprocess) | | [`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) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 38acfd45af..af71106dd9 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -429,6 +429,7 @@ flowchart TD pkg_web_search_perplexity --> pkg_launch_environment pkg_web_search_perplexity --> pkg_web pkg_api_remotes --> pkg_scope + pkg_api_terminal_controller --> pkg_subprocess pkg_attachment_local --> pkg_attachment pkg_attachment_local --> pkg_home_paths pkg_client_file_upload --> pkg_scope @@ -1262,7 +1263,6 @@ flowchart TD | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | `llm` | — | | [`llm`](../packages/llm/llm) | `llm` | — | | [`api-gateway`](../packages/api/gateway) | `api` | — | -| [`api-terminal-controller`](../packages/api/terminal-controller) | `api` | — | | [`api-workspace-files`](../packages/api/workspace-files) | `api` | — | | [`cmdline`](../packages/boot/cmdline) | `boot` | — | | [`acp-app`](../packages/bundle/acp-app) | `bundle` | — | @@ -1366,6 +1366,7 @@ flowchart TD | [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | | [`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) | +| [`api-terminal-controller`](../packages/api/terminal-controller) | `api` | [`subprocess`](../packages/subprocess/subprocess) | | [`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) | From 55a17d2e576961997ab8a3e72ef6626f80524108 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Mon, 14 Sep 2026 11:46:02 +0800 Subject: [PATCH 73/79] fix(ui): keep markdown table height stable on hover --- .../ui-primitives/src/markdown/MarkdownText.module.css | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css index d29c50c99a..7650bb3718 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css @@ -198,10 +198,11 @@ scrollbar STYLES (neither hover-conditioned `::-webkit-scrollbar*` rules nor a :hover `scrollbar-color` change reaches the painted bar), so the toggle is `overflow-x` itself — a layout change repaints reliably. The - resting padding matches the themed bar's height, so on an overflowing - table the appearing bar exactly replaces it and nothing below shifts. + resting padding matches the themed bar's height. `scroll` reserves that + height even when the table fits, so removing the padding cannot shift + the content below it. Wheel and trackpad scrolling need the pointer over the table, which is - already the hover that re-enables `auto`. */ + already the hover that re-enables scrolling. */ .tableScroll:global(.md-table-wide) { overflow-x: hidden; padding-bottom: var(--dsh-scrollbar-width, 8px); @@ -209,7 +210,7 @@ .tableScroll:global(.md-table-wide):hover, .tableScroll:global(.md-table-wide):focus-visible { - overflow-x: auto; + overflow-x: scroll; padding-bottom: 0; } From 6d81d0fdbd51841b62b45077d5b554fd1a65bbd2 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Mon, 14 Sep 2026 11:47:13 +0800 Subject: [PATCH 74/79] test(web): update table hover scrollbar expectation --- apps/web/tests/markdown-wide-table.e2e.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/tests/markdown-wide-table.e2e.ts b/apps/web/tests/markdown-wide-table.e2e.ts index 09f980ab5c..afae437885 100644 --- a/apps/web/tests/markdown-wide-table.e2e.ts +++ b/apps/web/tests/markdown-wide-table.e2e.ts @@ -386,7 +386,7 @@ describe('web e2e: markdown tables fill the column, wide ones break out and scro // Resting hidden overflow keeps the scroll position reachable and intact. expect(await wide.evaluate(element => element.scrollLeft)).toBeGreaterThanOrEqual(0) await wide.hover() - await expect.poll(overflowState, { timeout: 5_000 }).toBe('auto 0px') + await expect.poll(overflowState, { timeout: 5_000 }).toBe('scroll 0px') // Pointer leaves: the bar rests hidden again. await page.mouse.move(4, 4) await expect.poll(overflowState, { timeout: 5_000 }).toBe('hidden 8px') From 11b211478684e9923cc6eade66fdc82300b0289b Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 14 Sep 2026 12:00:29 +0800 Subject: [PATCH 75/79] fix(web): align terminal launcher controls and guide appearance --- .../sidebar-terminal/selection.expected.md | 4 +- .../sidebar-terminal/shell-menu.expected.md | 5 +++ apps/web/tests/sidebar-terminal.e2e.ts | 17 ++++--- .../ui-sidebar-terminal/README.i18n.yaml | 4 +- packages/client/ui-sidebar-terminal/README.md | 4 +- .../client/ui-sidebar-terminal/README.zh.md | 4 +- .../src/client/TerminalBody.module.css | 10 +++-- .../src/client/TerminalBody.tsx | 44 ++++++++++++++----- .../src/client/TerminalIcon.tsx | 20 ++++++++- .../ui-sidebar-terminal/src/client/index.ts | 4 +- .../ui-sidebar-terminal/src/client/locales.ts | 4 +- .../tests/apply.client.spec.ts | 8 +++- .../tests/terminal-body.client.spec.tsx | 12 ++++- 13 files changed, 103 insertions(+), 37 deletions(-) create mode 100644 apps/web/tests/expected/sidebar-terminal/shell-menu.expected.md diff --git a/apps/web/tests/expected/sidebar-terminal/selection.expected.md b/apps/web/tests/expected/sidebar-terminal/selection.expected.md index 5b64f44658..21961c3c4d 100644 --- a/apps/web/tests/expected/sidebar-terminal/selection.expected.md +++ b/apps/web/tests/expected/sidebar-terminal/selection.expected.md @@ -1,5 +1,3 @@ - text: Shell -- combobox "Shell": - - option "bash — /bin/bash" [selected] - - option "sh — /bin/sh" +- button "Shell": bash — /bin/bash - button "Start terminal" diff --git a/apps/web/tests/expected/sidebar-terminal/shell-menu.expected.md b/apps/web/tests/expected/sidebar-terminal/shell-menu.expected.md new file mode 100644 index 0000000000..50be37e43b --- /dev/null +++ b/apps/web/tests/expected/sidebar-terminal/shell-menu.expected.md @@ -0,0 +1,5 @@ +- menu: + - menuitem "bash — /bin/bash": + - text: bash — /bin/bash + - img + - menuitem "sh — /bin/sh" diff --git a/apps/web/tests/sidebar-terminal.e2e.ts b/apps/web/tests/sidebar-terminal.e2e.ts index 4a721047e4..ba1ee4ca94 100644 --- a/apps/web/tests/sidebar-terminal.e2e.ts +++ b/apps/web/tests/sidebar-terminal.e2e.ts @@ -161,14 +161,21 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => { it('offers installed shells, remembers the choice after reload, and restores without a picker', async () => { onTestFailed(() => saveFailureShot(page, 'sidebar-terminal-shell-choice')) await page.locator('[data-sidebar-right-expand]').click() - await page.locator('[data-sidebar-right-guide-entry="terminal"]').click() - const selector = page.getByRole('combobox', { name: 'Shell', exact: true }) + const entry = page.locator('[data-sidebar-right-guide-entry="terminal"]') + expect(await entry.innerText()).toBe('New terminal\nRun commands in the Session workspace') + await page.locator('[data-sidebar-right-guide]').screenshot({ path: `${shots}/terminal-guide.png`, animations: 'disabled' }) + await entry.click() + const selector = page.getByRole('button', { name: 'Shell', exact: true }) await selector.waitFor() - expect(await selector.inputValue()).toBe('/bin/bash') + expect(await selector.innerText()).toContain('bash — /bin/bash') expect(handles).toHaveLength(0) await compareOrRefreshGolden(fileURLToPath(new URL('./expected/sidebar-terminal/selection.expected.md', import.meta.url)), await page.locator('[data-sidebar-terminal]').ariaSnapshot(), webSnapshotMode()) - await selector.selectOption('/bin/sh') + await selector.click() + await compareOrRefreshGolden(fileURLToPath(new URL('./expected/sidebar-terminal/shell-menu.expected.md', import.meta.url)), + await page.getByRole('menu').ariaSnapshot(), webSnapshotMode()) + await page.screenshot({ path: `${shots}/shell-menu.png`, fullPage: true }) + await page.getByRole('menuitem', { name: 'sh — /bin/sh', exact: true }).click() expect(await page.evaluate(() => localStorage.getItem('dsh.terminal.shell'))).toBe('/bin/sh') await page.screenshot({ path: `${shots}/shell-choice.png`, fullPage: true }) await page.getByRole('button', { name: 'Start terminal', exact: true }).click() @@ -184,7 +191,7 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => { await page.locator('[data-dockkit-add-tab]').click() await page.locator('[data-sidebar-right-guide-entry="terminal"]').click() await selector.waitFor() - expect(await selector.inputValue()).toBe('/bin/sh') + expect(await selector.innerText()).toContain('sh — /bin/sh') expect(handles).toHaveLength(1) await page.getByRole('button', { name: 'Start terminal', exact: true }).click() await expect.poll(() => handles.length).toBe(2) diff --git a/packages/client/ui-sidebar-terminal/README.i18n.yaml b/packages/client/ui-sidebar-terminal/README.i18n.yaml index 4a97230d15..ad7c48a193 100644 --- a/packages/client/ui-sidebar-terminal/README.i18n.yaml +++ b/packages/client/ui-sidebar-terminal/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-sidebar-terminal/README.md -README.md: d36e42ccea3a4b419231e39c1c6e8d5b3bf754aa -README.zh.md: 1493828388e82c24d5d3a95a8a2e1dc379f152cf +README.md: dec4c0a88649b74e90afa23ef07d3f11a9dc67cb +README.zh.md: a10cde602ad4edfd13652a93535c4f5d7fd26b52 diff --git a/packages/client/ui-sidebar-terminal/README.md b/packages/client/ui-sidebar-terminal/README.md index d36e42ccea..dec4c0a886 100644 --- a/packages/client/ui-sidebar-terminal/README.md +++ b/packages/client/ui-sidebar-terminal/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Choose an installed shell beside a conversation without leaving the Web app. Rename terminals in their tabs and recover retained processes after reloading the page. Collapse the sidebar to keep commands running; close a terminal tab to request process termination. Tab completion follows the shell configuration. +Choose an installed shell from the right sidebar's Start page to run commands in the Session workspace. Rename terminals in their tabs and recover retained processes after reloading the page. Collapse the sidebar to keep commands running; close a terminal tab to request process termination. Tab completion follows the shell configuration. ## Table of Contents @@ -39,7 +39,7 @@ Displaying a Session after a page reload reopens its retained Host terminals as
Implementation internals — click to expand -This plugin registers the `terminal` type and body/title seats with the right sidebar. The React-free terminal model belongs to `api-terminal-controller`; keyed framework hooks expose its state. xterm.js and FitAddon render the screen and measure the viewport. The body reserves an 8px gap below the tab strip within the pane height. Input, including Tab and control characters, travels unchanged to the PTY. +This plugin registers the `terminal` type and body/title seats with the right sidebar. The guide uses a compact dark terminal card, while tab titles retain the line glyph. The React-free terminal model belongs to `api-terminal-controller`; keyed framework hooks expose its state. `ui-primitives` Menu and Button provide the shell picker and startup controls, including keyboard navigation and the selected-item marker. xterm.js and FitAddon render the screen and measure the viewport. The body reserves an 8px gap below the tab strip within the pane height. Input, including Tab and control characters, travels unchanged to the PTY. A Session header contribution queries Host terminals and opens recovery tabs. Their navigation parameters carry `terminalId` only within the current page; a recovered view cannot allocate a replacement process. The sidebar's close handler schedules cleanup through the [terminal controller](../../api/terminal-controller/README.md#understand-the-implementation) and returns synchronously. Browser component cleanup and the tab's abort signal only detach browser work. diff --git a/packages/client/ui-sidebar-terminal/README.zh.md b/packages/client/ui-sidebar-terminal/README.zh.md index 1493828388..a10cde602a 100644 --- a/packages/client/ui-sidebar-terminal/README.zh.md +++ b/packages/client/ui-sidebar-terminal/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -在对话旁选择已安装的 shell,无需离开 Web 应用。在标签页上重命名终端,并在刷新页面后恢复保留的进程。折叠侧栏让命令继续运行,关闭终端标签页则请求结束进程。Tab 补全使用 shell 的配置。 +从右侧栏开始页选择已安装的 shell,在会话工作区运行命令。在标签页上重命名终端,并在刷新页面后恢复保留的进程。折叠侧栏让命令继续运行,关闭终端标签页则请求结束进程。Tab 补全使用 shell 的配置。 ## 目录 @@ -39,7 +39,7 @@ kind: "package-reference"
实现细节 -插件向右侧栏注册 `terminal` 类型及正文和标题 seat。无 React 依赖的终端模型属于 `api-terminal-controller`,通过框架 keyed hooks 暴露状态。xterm.js 与 FitAddon 负责屏幕渲染和视口测量。正文在面板高度内为页签条下方预留 8px 间距。输入原样传到 PTY,包括 Tab 和控制字符。 +插件向右侧栏注册 `terminal` 类型及正文和标题 seat。开始页入口使用紧凑的黑底终端图标,页签标题保留线条图标。无 React 依赖的终端模型属于 `api-terminal-controller`,通过框架 keyed hooks 暴露状态。`ui-primitives` 的 Menu 与 Button 提供 shell 选择和启动控件,支持键盘导航与选中标记。xterm.js 与 FitAddon 负责屏幕渲染和视口测量。正文在面板高度内为页签条下方预留 8px 间距。输入原样传到 PTY,包括 Tab 和控制字符。 Session header contribution 查询 Host 终端并打开恢复标签页。导航参数中的 `terminalId` 只在当前页面内保留;恢复视图不能分配替代进程。侧栏关闭 handler 通过[终端 controller](../../api/terminal-controller/README.zh.md#understand-the-implementation)安排清理并同步返回。浏览器组件清理和 tab 的 abort signal 只停止浏览器工作。 diff --git a/packages/client/ui-sidebar-terminal/src/client/TerminalBody.module.css b/packages/client/ui-sidebar-terminal/src/client/TerminalBody.module.css index aabba1b3c1..80fa425b11 100644 --- a/packages/client/ui-sidebar-terminal/src/client/TerminalBody.module.css +++ b/packages/client/ui-sidebar-terminal/src/client/TerminalBody.module.css @@ -4,9 +4,11 @@ .screen { flex: 1; min-height: 0; min-width: 0; overflow: hidden; padding: 8px; background: #17191d; } .screen :global(.xterm) { height: 100%; } .status { display: flex; align-items: center; gap: 8px; padding: 5px 12px; background: var(--dsw-alias-bg-l2); } -.root button { padding: 6px 10px; border: 0.5px solid var(--dsw-alias-border-l3); border-radius: 6px; color: inherit; background: transparent; cursor: pointer; } -.root button:hover { background: var(--dsw-alias-bg-l2); } +.status button { padding: 6px 10px; border: 0.5px solid var(--dsw-alias-border-l3); border-radius: 6px; color: inherit; background: transparent; cursor: pointer; } +.status button:hover { background: var(--dsw-alias-bg-l2); } .error { margin: 0; padding: 10px 12px; overflow-wrap: anywhere; color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-l2); } .launch { display: flex; align-items: end; flex-wrap: wrap; gap: 8px; padding: 12px; } -.shellLabel { display: flex; flex: 1; flex-direction: column; gap: 6px; min-width: 0; } -.shellLabel select { min-width: 0; width: 100%; padding: 6px 10px; border: 0.5px solid var(--dsw-alias-border-l3); border-radius: 6px; color: inherit; font: inherit; background: var(--dsw-alias-bg-l1); } +.shellField { display: flex; flex: 1; flex-direction: column; gap: 6px; min-width: 0; } +.shellMenu { width: 100%; min-width: 0; } +.shellTrigger { width: 100%; min-width: 0; justify-content: space-between; gap: 8px; } +.shellValue { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } diff --git a/packages/client/ui-sidebar-terminal/src/client/TerminalBody.tsx b/packages/client/ui-sidebar-terminal/src/client/TerminalBody.tsx index af404b1e69..d9adf4fae2 100644 --- a/packages/client/ui-sidebar-terminal/src/client/TerminalBody.tsx +++ b/packages/client/ui-sidebar-terminal/src/client/TerminalBody.tsx @@ -1,5 +1,6 @@ /** Sidebar terminal screen and connection recovery. */ -import { useEffect, useLayoutEffect, useRef, type ReactNode } from 'react' +import { useEffect, useId, useLayoutEffect, useRef, useState, type ReactNode } from 'react' +import { Button, Menu, IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' import { Terminal } from '@xterm/xterm' import { FitAddon } from '@xterm/addon-fit' import type { TerminalViewState, TerminalView } from '@deepseek-ai/dsh-api-terminal-controller/client' @@ -34,15 +35,7 @@ export function TerminalBody({ useTabInfo, useTerminal, view, t }: TerminalBodyP const readOnly = state.phase === 'connected' && state.info?.state === 'running' && !state.writable return (
- {state.phase === 'selecting' &&
{ event.preventDefault(); void model.start() }}> - - -
} + {state.phase === 'selecting' && } {(status !== undefined || retry || readOnly) &&
{status} {readOnly && <>{t('readonly')} } @@ -56,6 +49,37 @@ export function TerminalBody({ useTabInfo, useTerminal, view, t }: TerminalBodyP ) } +function TerminalLauncher({ state, model, t }: { + state: TerminalViewState + model: TerminalView + t: TerminalBodyProps['t'] +}): ReactNode { + const [open, setOpen] = useState(false) + const selectionId = useId() + const selected = state.shells?.find(shell => shell.path === state.selectedShell) + return
{ event.preventDefault(); void model.start() }}> +
+ {t('shell')} + ({ id: shell.path, label: `${shell.name} — ${shell.path}` })) ?? []} + selectedId={state.selectedShell} + onClose={() => { setOpen(false) }} + onSelect={(path) => { model.selectShell(path); setOpen(false) }} + anchor={} + /> +
+ +
+} + /* oxlint-disable typescript/no-non-null-assertion -- React sets the DOM ref, then these effects initialize and use the emulator. */ function TerminalScreen({ state, model, visible, label }: { state: TerminalViewState diff --git a/packages/client/ui-sidebar-terminal/src/client/TerminalIcon.tsx b/packages/client/ui-sidebar-terminal/src/client/TerminalIcon.tsx index fc9d995c10..df5525a08d 100644 --- a/packages/client/ui-sidebar-terminal/src/client/TerminalIcon.tsx +++ b/packages/client/ui-sidebar-terminal/src/client/TerminalIcon.tsx @@ -1,9 +1,25 @@ -/** Terminal prompt glyph shared by the guide and tab title. */ +/** Terminal glyphs for the sidebar guide and tab title. */ import type { ReactNode } from 'react' +import type { IconProps } from '@deepseek-ai/dsh-client-ui-primitives' -/** Render the terminal's decorative prompt icon. @returns a sixteen-pixel glyph. */ +/** + * Render the tab title's terminal prompt in the surrounding text color. + * @returns a decorative sixteen-pixel line glyph. + */ export function TerminalIcon(): ReactNode { return } + +/** + * Render the guide's terminal card within the folder glyph's painted bounds. + * @param props - canvas size and layout class supplied by the guide. + * @returns a dark rounded terminal card with a white prompt. + */ +export function TerminalGuideIcon({ size = 26, className }: IconProps): ReactNode { + return +} diff --git a/packages/client/ui-sidebar-terminal/src/client/index.ts b/packages/client/ui-sidebar-terminal/src/client/index.ts index 0ba160121c..cf71fb2f87 100644 --- a/packages/client/ui-sidebar-terminal/src/client/index.ts +++ b/packages/client/ui-sidebar-terminal/src/client/index.ts @@ -8,7 +8,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-session/client' -import { TerminalIcon } from './TerminalIcon.tsx' +import { TerminalGuideIcon } from './TerminalIcon.tsx' import { TerminalBody } from './TerminalBody.tsx' import { TerminalTitle } from './TerminalTitle.tsx' import { TerminalRecovery, type TerminalRecoveryInjected } from './TerminalRecovery.tsx' @@ -38,7 +38,7 @@ export function apply(ctx: Context): void { ctx.effect(() => ctx.locale.register(namespace, { zh, en }), 'ui-sidebar-terminal.copy') ctx.effect(() => ctx.sidebarRightTabs.register({ id, kind: 'terminal', multiple: true, priority: 'builtin', title: () => t('title'), - guide: [{ order: 20, title: () => t('new'), icon: TerminalIcon }], + guide: [{ order: 20, title: () => t('new'), description: () => t('description'), icon: TerminalGuideIcon }], }), 'ui-sidebar-terminal.type') ctx.effect(() => ctx.sidebarRight.registerCloseHandler('terminal', (sessionId, tab) => { ctx.webTerminals.close(sessionId, tab.id, terminalId(sessionId, tab.id)) diff --git a/packages/client/ui-sidebar-terminal/src/client/locales.ts b/packages/client/ui-sidebar-terminal/src/client/locales.ts index 51afac8349..d6ee5a5d17 100644 --- a/packages/client/ui-sidebar-terminal/src/client/locales.ts +++ b/packages/client/ui-sidebar-terminal/src/client/locales.ts @@ -10,7 +10,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Simplified Chinese terminal copy. */ export const zh = { recoveryFailed: '恢复终端失败:{message}', retryRecovery: '重试恢复终端', - shell: 'Shell', start: '启动终端', + shell: 'Shell', start: '启动终端', description: '在会话工作区运行命令', title: '终端', new: '新建终端', loading: '正在读取终端环境…', creating: '正在启动…', connecting: '正在连接…', disconnected: '连接已断开。', reconnect: '重新连接', readonly: '此页面当前只读。', control: '接管输入', @@ -27,7 +27,7 @@ export const zh = { /** English terminal copy. */ export const en = { recoveryFailed: 'Terminal recovery failed: {message}', retryRecovery: 'Retry terminal recovery', - shell: 'Shell', start: 'Start terminal', + shell: 'Shell', start: 'Start terminal', description: 'Run commands in the Session workspace', title: 'Terminal', new: 'New terminal', loading: 'Reading terminal environment…', creating: 'Starting…', connecting: 'Connecting…', disconnected: 'Disconnected.', reconnect: 'Reconnect', readonly: 'This view is read-only.', control: 'Take control', diff --git a/packages/client/ui-sidebar-terminal/tests/apply.client.spec.ts b/packages/client/ui-sidebar-terminal/tests/apply.client.spec.ts index 70d7f1d473..dbbd4fa040 100644 --- a/packages/client/ui-sidebar-terminal/tests/apply.client.spec.ts +++ b/packages/client/ui-sidebar-terminal/tests/apply.client.spec.ts @@ -1,5 +1,7 @@ // @vitest-environment jsdom /** Terminal type, copy, seats and explicit cleanup follow the plugin lifetime. */ +import { createElement } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' import { Context } from '@deepseek-ai/cordis' import { expect, it, vi } from 'vitest' import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -69,7 +71,11 @@ it('registers terminal views, recovery and cleanup, then releases every contribu try { const definition = h.tabs.get('terminal')! expect(definition.title('sidebar://terminal')).toBe('title') - expect(definition.guide?.map(entry => [entry.order, entry.title()])).toEqual([[20, 'new']]) + expect(definition.guide?.map(entry => [entry.order, entry.title(), entry.description?.()])).toEqual([[20, 'new', 'description']]) + const Icon = definition.guide?.[0]?.icon + if (Icon === undefined) throw new Error('Terminal guide icon was not registered') + expect(renderToStaticMarkup(createElement(Icon, { size: 22 }))).toContain('width="22"') + expect(renderToStaticMarkup(createElement(Icon))).toContain('width="26"') expect(definition.multiple).toBe(true) expect(h.dictionaries.get('sidebarTerminal')).toEqual({ en, zh }) expect(h.entries.map(entry => [entry.name, entry.component, entry.locale])).toEqual([ diff --git a/packages/client/ui-sidebar-terminal/tests/terminal-body.client.spec.tsx b/packages/client/ui-sidebar-terminal/tests/terminal-body.client.spec.tsx index d8d80751be..7070b43df9 100644 --- a/packages/client/ui-sidebar-terminal/tests/terminal-body.client.spec.tsx +++ b/packages/client/ui-sidebar-terminal/tests/terminal-body.client.spec.tsx @@ -218,6 +218,7 @@ it.each(titleSurfaces)('shows saved and attached terminal names in the $name', ( title.rerender(chip()) expect(title.container.textContent).toBe('bash') expect(title.container.querySelector('svg')?.getAttribute('aria-hidden')).toBe('true') + expect(title.container.querySelector('svg rect')).toBeNull() }) it.each(titleSurfaces)('renames from the $name pointer target, preserving dragging and isolating editor shortcuts', (surface) => { @@ -314,9 +315,16 @@ it.each([en, zh])('translates known terminal failures while retaining unknown Ho it('lets the user select an installed shell and start it before rendering a screen', () => { const other = { path: '/bin/zsh', name: 'zsh', args: ['-i'] } const h = mount({ phase: 'selecting', writable: false, environment, shells: [info.shell, other], selectedShell: other.path }) - expect(h.view.getByRole('combobox', { name: 'Shell' })).toHaveProperty('value', other.path) + const selector = h.view.getByRole('button', { name: 'Shell' }) + expect(selector.textContent).toContain(other.path) expect(fake.terminals).toHaveLength(0) - fireEvent.change(h.view.getByRole('combobox'), { target: { value: info.shell.path } }) + fireEvent.click(selector) + expect(h.view.getByRole('menu')).toBeTruthy() + fireEvent.keyDown(document, { key: 'Escape' }) + expect(h.view.queryByRole('menu')).toBeNull() + fireEvent.click(selector) + fireEvent.click(h.view.getByRole('menuitem', { name: `bash — ${info.shell.path}` })) + expect(h.view.queryByRole('menu')).toBeNull() expect(h.model.selectShell).toHaveBeenCalledWith(info.shell.path) fireEvent.click(h.view.getByRole('button', { name: 'Start terminal' })) expect(h.model.start).toHaveBeenCalledOnce() From 0a0563cc9b0cb55634b9f85d75343548e562ed3d Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 14 Sep 2026 12:13:26 +0800 Subject: [PATCH 76/79] test(web): mock retained terminals in assembled fixtures --- apps/web/tests/assembled-remote.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/tests/assembled-remote.ts b/apps/web/tests/assembled-remote.ts index fd84fb468c..69739dc7a7 100644 --- a/apps/web/tests/assembled-remote.ts +++ b/apps/web/tests/assembled-remote.ts @@ -2,6 +2,7 @@ * RemoteMock scenario for built-client tests that do not own a Host. * The adjacent JSON is maintained with this module when Remote responses or * the current Session header version change. + * Fixture Sessions have no retained Host terminals to restore. */ import { readFileSync } from 'node:fs' @@ -139,6 +140,7 @@ export function createAssembledRemote(options: AssembledRemoteOptions = {}): Ass 'settings/openSettingsDocument': ok({ opened: true }), 'settings/openAgentPresetDirectory': ok({ opened: true }), 'subagents/list': ok({ entries: [], parentAvailable: true }), + 'terminal/list': ok([]), 'skills/list': ok({ skills: [] }), 'session/canOpenWorkspacePath': ok(true), 'session/openWorkspacePath': ok({ opened: true }), From 8ab3579e063ff08d02c3c4e52ad4c4c20495d35c Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Mon, 14 Sep 2026 13:34:00 +0800 Subject: [PATCH 77/79] test(web): guard fitting table geometry across interaction --- apps/web/tests/markdown-wide-table.e2e.ts | 46 +++++++++++++++++-- .../src/markdown/MarkdownText.module.css | 6 +-- 2 files changed, 45 insertions(+), 7 deletions(-) diff --git a/apps/web/tests/markdown-wide-table.e2e.ts b/apps/web/tests/markdown-wide-table.e2e.ts index afae437885..cf7cdaabb5 100644 --- a/apps/web/tests/markdown-wide-table.e2e.ts +++ b/apps/web/tests/markdown-wide-table.e2e.ts @@ -56,6 +56,7 @@ const TAIL_MARKER = 'MWT_TABLES_DONE' const FILL_MARKER = 'MWT_FILL_C1' const WIDE_MARKER = 'MWT_WIDE_C01' const LONG_CELL_MARKER = 'MWT_LONGCELL_F1' +const SHORT_MARKER = 'MWT_SHORT_C1' const MARKERS = [FILL_MARKER, WIDE_MARKER, LONG_CELL_MARKER] /** Golden-facing names, in {@link MARKERS} order. */ const TABLE_NAMES = ['fill', 'wide', 'long-cell'] @@ -76,13 +77,20 @@ const SENTENCE = 'This cell carries one full sentence so the unwrapped table is const LONG_TOKEN = 'workspace/deepseek-harness/packages/client/ui-primitives/src/markdown/render.tsx/'.repeat(3) const CJK_SENTENCE = '这个单元格包含一段较长的中文说明,用来验证长内容在窄列宽下按最小可读宽度换行而不是把列压缩到无法阅读。' -/** The assistant markdown: one 3-column fill, one 12-column wide, one long-cell table. */ +/** The assistant markdown includes fitting and overflowing wide tables. */ function tablesMarkdown(): string { const wideHeader = [WIDE_MARKER, ...Array.from({ length: 11 }, (_, i) => `C${String(i + 2).padStart(2, '0')}`)] const wideRow = (row: number): string[] => Array.from({ length: 12 }, (_, i) => `v${String(row)}${String(i + 1).padStart(2, '0')}`) return [ - 'Three markdown tables exercise the wide-table layout rules.', + 'Markdown tables exercise the wide-table layout rules.', + '', + `| ${SHORT_MARKER} | C2 | C3 | C4 |`, + '| --- | --- | --- | --- |', + '| 1 | 2 | 3 | 4 |', + '| 5 | 6 | 7 | 8 |', + '', + 'The paragraph after the short table stays in place.', '', `| ${FILL_MARKER} | Current approach | Proposed approach |`, '| --- | --- | --- |', @@ -103,7 +111,7 @@ function tablesMarkdown(): string { ].join('\n') } -/** Build one closed, invariant-checked session fixture carrying the three tables. */ +/** Build one closed, invariant-checked session fixture carrying the tables. */ function wideTableFixture(): string { const session = Session.create(SessionId('markdown-wide-table-source')) const eventTimeOrigin = new Date().setHours(12, 0, 0, 0) @@ -257,7 +265,8 @@ describe('web e2e: markdown tables fill the column, wide ones break out and scro beforeAll(async () => { scaffold = await launchWebScaffold({}) await seedSession(scaffold, wideTableFixture(), SEED_ID) - browser = await chromium.launch() + // The geometry assertions include the space occupied by native scrollbars. + browser = await chromium.launch({ ignoreDefaultArgs: ['--hide-scrollbars'] }) page = await newEnglishPage(browser) tripwire = watchConsole(page) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) @@ -393,6 +402,35 @@ describe('web e2e: markdown tables fill the column, wide ones break out and scro expect(tripwire.pageErrors).toEqual([]) }, 120_000) + it('keeps a fitting wide table and its following paragraph stationary during interaction', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-short-table-height')) + await settleAt(1680) + const short = page.locator('[class*="tableScroll"]', { hasText: SHORT_MARKER }) + await short.evaluate(element => element.scrollIntoView({ block: 'center', behavior: 'instant' })) + await page.mouse.move(4, 4) + await short.evaluate(element => element.blur()) + expect(await short.evaluate(element => element.classList.contains('md-table-wide'))).toBe(true) + expect(await short.evaluate(element => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1) + const position = () => short.evaluate((element) => { + const following = element.nextElementSibling + if (following === null) throw new Error('short table has no following paragraph') + return { + height: element.getBoundingClientRect().height, + followingTop: following.getBoundingClientRect().top, + } + }) + const resting = await position() + await short.hover() + await expect.poll(position).toEqual(resting) + await page.mouse.move(4, 4) + await short.focus() + expect(await short.evaluate(element => document.activeElement === element)).toBe(true) + await expect.poll(position).toEqual(resting) + await short.evaluate(element => element.blur()) + await expect.poll(position).toEqual(resting) + expect(tripwire.pageErrors).toEqual([]) + }, 120_000) + it('keeps the fill/scroll relations under page zoom', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-wide-table-zoom')) await sweep() diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css index 7650bb3718..652affcf85 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css @@ -198,9 +198,9 @@ scrollbar STYLES (neither hover-conditioned `::-webkit-scrollbar*` rules nor a :hover `scrollbar-color` change reaches the painted bar), so the toggle is `overflow-x` itself — a layout change repaints reliably. The - resting padding matches the themed bar's height. `scroll` reserves that - height even when the table fits, so removing the padding cannot shift - the content below it. + resting padding matches the themed bar's height on the WebKit scrollbar + path. There, `scroll` reserves that height even when the table fits. + Other engines and overlay scrollbars may reserve a different height. Wheel and trackpad scrolling need the pointer over the table, which is already the hover that re-enables scrolling. */ .tableScroll:global(.md-table-wide) { From 1119fa254466ea3b5f490e48fc8b2d1f840c5164 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 14 Sep 2026 13:50:23 +0800 Subject: [PATCH 78/79] test(web): refresh the Auto-review trajectory golden for PTC summaries The trajectory ledger now summarizes a recorded run_code call with the program description instead of its raw JSON arguments, and marks it with the code-inspection icon. Refresh the Auto-review denial golden so the replayed ARIA expectation matches the shipped rendering. --- snapshots/web/auto-review-denial/ui.expected.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/snapshots/web/auto-review-denial/ui.expected.md b/snapshots/web/auto-review-denial/ui.expected.md index c430cf3f33..b849cc3a7b 100644 --- a/snapshots/web/auto-review-denial/ui.expected.md +++ b/snapshots/web/auto-review-denial/ui.expected.md @@ -121,9 +121,11 @@ - 'button "Request #2"' - text: ASSISTANT - cell "(tool call only)" - - 'row "TOOL, run_code {\"code\":\"try { await tools.bash({command: \"echo hidden-input-ptc\"}) } catch (error) { return \"denial caught\" }\",\"description\":\"Catch an Auto-denied inner call\"}"': + - row "TOOL, run_code Catch an Auto-denied inner call": - cell "TOOL" - - 'cell "run_code{\"code\":\"try { await tools.bash({command: \"echo hidden-input-ptc\"}) } catch (error) { return \"denial caught\" }\",\"description\":\"Catch an Auto-denied inner call\"} → denial caught"' + - cell "run_codeCatch an Auto-denied inner call → denial caught": + - img + - text: run_codeCatch an Auto-denied inner call → denial caught - 'row "SUBTOOL, bash {\"command\":\"echo hidden-input-ptc\"}" [selected]': - cell "SUBTOOL" - 'cell "bash{\"command\":\"echo hidden-input-ptc\"} → AUTO_REVIEW_DENIED"' From f0463cf80ddc3c60594f6c7a7c119d17605c4d5e Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Mon, 14 Sep 2026 13:53:09 +0800 Subject: [PATCH 79/79] fix(test): use block bodies for void browser callbacks --- apps/web/tests/markdown-wide-table.e2e.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/tests/markdown-wide-table.e2e.ts b/apps/web/tests/markdown-wide-table.e2e.ts index cf7cdaabb5..45d889c6d4 100644 --- a/apps/web/tests/markdown-wide-table.e2e.ts +++ b/apps/web/tests/markdown-wide-table.e2e.ts @@ -406,9 +406,9 @@ describe('web e2e: markdown tables fill the column, wide ones break out and scro onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-short-table-height')) await settleAt(1680) const short = page.locator('[class*="tableScroll"]', { hasText: SHORT_MARKER }) - await short.evaluate(element => element.scrollIntoView({ block: 'center', behavior: 'instant' })) + await short.evaluate((element) => { element.scrollIntoView({ block: 'center', behavior: 'instant' }) }) await page.mouse.move(4, 4) - await short.evaluate(element => element.blur()) + await short.evaluate((element) => { element.blur() }) expect(await short.evaluate(element => element.classList.contains('md-table-wide'))).toBe(true) expect(await short.evaluate(element => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1) const position = () => short.evaluate((element) => { @@ -426,7 +426,7 @@ describe('web e2e: markdown tables fill the column, wide ones break out and scro await short.focus() expect(await short.evaluate(element => document.activeElement === element)).toBe(true) await expect.poll(position).toEqual(resting) - await short.evaluate(element => element.blur()) + await short.evaluate((element) => { element.blur() }) await expect.poll(position).toEqual(resting) expect(tripwire.pageErrors).toEqual([]) }, 120_000)