diff --git a/.agents/notes/implemented/feature/2026-07-30-web-read-card.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-read-card.i18n.yaml new file mode 100644 index 0000000000..abefa88679 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-web-read-card.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-read-card.md +2026-07-30-web-read-card.md: 1fb3d61a113d26f6daf023fc791f3638055b5be0 +2026-07-30-web-read-card.zh.md: 946bcca95bcc9bb50beb1ef22e77e4a21728b538 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-read-card.md b/.agents/notes/implemented/feature/2026-07-30-web-read-card.md new file mode 100644 index 0000000000..1fb3d61a11 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-web-read-card.md @@ -0,0 +1,49 @@ +# Agent Note: Read card — the read tool's structured line window reaches the client + +Status: implemented + +English | [中文](2026-07-30-web-read-card.zh.md) + +## Problem + +The `read` tool returns a canonical output object `{ path, offset, lines: [{ number, text }], totalLines }`, but its presentation collapsed that structure. `presentCall` declared a `GenericCallView` (`kind: 'read'`, a follow-along location) and `presentResult` returned a `GenericResultView` whose only content was the model-facing text with its `file` envelope stripped. A UI receiving that view saw one flattened text block: the line numbers were baked into the text as `N: ` prefixes, the file's language was unknown, and `totalLines` was gone. There was no way for a capable client to render a read the way it renders a diff — a line-numbered, syntax-highlighted code view with the line-number gutter separate from the content. + +The structured data cannot be recovered downstream. A tool result on the wire carries only the model-facing `ContentBlock[]` (the rendered text) plus an opaque `meta`; the canonical output object stays in the tool and never reaches the client or the session log. So a client that wants the line array, the total, and a language hint cannot parse them back out of the `N: text` text — the tool has to project them onto a channel that persists. + +## Decision + +Add a fourth `card` tag, `read`, to the [render-intent union](../architecture/2026-07-02-tool-render-intent-union.md) — result-side only. `ToolResultView` gains `ReadResultView { card: 'read'; title?; path; lines: ReadFileLine[]; totalLines; lang?; content? }`; `ReadFileLine { number; text }` is the shared line unit. `ToolCallView` is untouched: the pending state stays a `GenericCallView` (`kind: 'read'`) because a call carries no file content until `execute` returns, so there is nothing structured to show at call time. This diverges from the bash terminal card, which tags both sides — a terminal call already carries its command and cwd at call time, a read call carries neither content nor total, so tagging the call side would add an empty variant. + +The read tool projects the structured window through `output.presentationMeta`, the same persisted channel write/edit use for their applied-diff hunks ([canonical tool output contract](../architecture/2026-07-20-canonical-tool-output-contract.md)). `presentationMeta` runs once for a top-level surface call, returns `{ path, offset, lines, totalLines, lang? }` as JSON the session validates and stores on the result's `meta`, and `presentResult` narrows that meta back into the `ReadResultView` on both live and replay paths. `offset` (the 1-based first line the window requested) rides along because a byte cap below the first selected line yields an empty `lines` array with a positive `totalLines`; without the persisted `offset` a replayed card of such a window could not report where it starts or where a continuation resumes, and the last-line and re-parse fallbacks are both lossy. Without this channel the line array and total would be unreachable: the raw output object is not on the wire, and re-parsing the `N: text` text is lossy and fragile against the truncation footer. + +`presentResult` returns `undefined` — the generic fallback — whenever the meta is absent or malformed (`readMetaFromMeta` narrows it defensively, so a replay of an older logged result never throws), whenever the result is an error, and whenever the single text block is not the read envelope. A pre-card logged result — a valid read envelope with no persisted `meta`, recorded before this card existed — takes that same `undefined` path deliberately: the client falls back to the raw `result.content`, so it shows the enveloped `//` text rather than the envelope-stripped generic card the old presenter returned. This is the accepted degradation under the [pre-release stance](../../../../AGENTS.md#pre-release-stance-foundation-over-blast-radius): reject the old on-disk format rather than add an envelope-stripping compatibility branch, since this PR re-records every published fixture and the session format promises no backward compatibility. On the success path `presentResult` carries `content` (the envelope-stripped text) alongside the structured fields, so a UI without the read capability, including the current TUI, renders the file text through the generic/default card arm exactly as before. The TUI's `renderBody` switch (`packages/ui/tui/src/components/transcript.ts`) is not `assertNever`-exhaustive: `terminal` and `diff` have arms and everything else falls through to the generic arm, which reads `view.content`. That default arm alone was not enough: `render()` sets `genericContent` — and with it the dim-Markdown `dimBody` treatment — on a separate gate that was `card === 'generic'` only, so a `read` card would have kept the text but lost its dim styling. The gate now admits `card: 'read'` too, taking `content` down the same dim-Markdown path, so a read renders in the TUI exactly as it did before the read card existed. Beyond that one gate the TUI needs no read-specific code. + +### Language hint derivation + +`langFromPath` (in `read-render.ts`) maps a file extension to a syntax-highlighting language id through a small fixed table (`LANG_BY_EXTENSION`) covering common source, config, and markup extensions. It reads the extension after the last path segment and last dot, is case-insensitive, and returns `undefined` for a dotfile (`.gitignore`), an extensionless name (`/etc/hosts`), a trailing dot, and any unknown extension — the card then omits `lang` and a UI renders plain text. The table is not a tunable: it is a display hint a UI may ignore, not a deployment-varying choice, and an unknown extension degrades to plain text rather than failing. It is deliberately small rather than an exhaustive language registry; extending it is a one-line table addition. + +## Alternatives considered + +**Re-parse the `N: text` model-facing text in `presentResult`.** Rejected: the structured line array would have to be reconstructed by splitting each line on the first `: `, which is ambiguous (a line whose own text contains `: `), loses the exact `totalLines` (the footer only states it in some branches), and breaks the moment the render format changes. `presentationMeta` carries the already-structured data with no re-parse. + +**Tag the call side too (`ReadCallView`), mirroring the terminal card's both-sides symmetry.** Rejected: a read call has no content, no line array, and no total until it executes — a call-side read card would be an empty variant duplicating what `GenericCallView` (`kind: 'read'`, follow-along location) already expresses. The terminal card tags both sides because a terminal call genuinely carries call-time data (command, cwd); a read call does not. + +**Put the structured window in a new service or a side channel instead of `meta`.** Rejected: `meta` is the established persisted presentation channel (write/edit's applied diffs ride it), it replays for free with the session log, and it needs no new plumbing. A service would reinvent persistence and replay that the event log already provides. + +**A merge-extensible union instead of a closed tag.** Rejected for the same reason the [render-intent union](../architecture/2026-07-02-tool-render-intent-union.md) closed: a new card needs consuming code to render it, so a variant a consumer silently drops is worse than a compile error. Adding `read` to the closed union is the sanctioned way to extend it — each consumer that switches on `card` keeps compiling because the new member falls through its generic default, and a consumer that wants the rich view adds its own arm. + +## Consequences + +`ToolResultView` has a fourth member. Every consumer that switches on `card` keeps compiling: the TUI and the current Web client route an unknown card to their generic path, and the read card carries `content` so that path shows the file text. The Web frontend that renders the line-numbered, syntax-highlighted view from `lines`/`lang`/`totalLines` is a separate follow-up PR; this PR is the backend that makes the data reachable. Until that lands, a read renders exactly as it did before (the generic text card) everywhere. + +The read tool now computes `presentationMeta` for every top-level read, a small per-call projection (a `lines.map` and one `langFromPath` call) on data already in hand. The meta is persisted with the session log, so a read result is slightly larger on disk — the line array it already rendered as text, now also structured. + +## Testing + +`packages/fs/tool-fs/tests/read-render.spec.ts` unit-tests `langFromPath` (known extensions case-insensitively, extension read after the last segment and last dot, and the `undefined` cases: dotfile, extensionless, trailing dot, unknown) and `readMetaFromMeta` (a well-formed narrow with and without `lang`, and every rejection: non-object, array, missing or wrong-typed `path`/`totalLines`/`lines`, a malformed line entry, a non-string `lang`, and — because the function narrows the opaque persisted `meta` boundary — the semantically invalid paths a well-typed replayed JSON can still carry: an `offset` that is not a 1-based integer, a first line `number` below `offset`, a line `number` that is not a 1-based integer (`0`, `1.5`, `NaN`, `Infinity`), a `totalLines` that is not a non-negative integer (`-1`, `1.5`, `NaN`), and lines whose numbers duplicate, decrease, or exceed `totalLines`; it also narrows an empty window at a positive `offset` (a byte cap below the first selected line). `packages/fs/tool-fs/tests/tools.spec.ts` pins the tool wiring: `execute` attaches the structured window (with and without a `lang` hint) as `meta`, `presentResult` narrows it into a `card: 'read'` view carrying the envelope-stripped `content`, and the decline paths (error result, non-single-text content, malformed envelope with valid meta, and valid envelope with absent or malformed meta) all fall back to `undefined`. Both changed source files hold per-file 100% coverage. This PR carries the snapshot evidence for the persisted meta and the extended union, not for a new rendered view: the re-recorded ACP session fixtures (`fs-read`, `fs-read-window`, `fs-edit`, `fs-policy-reject`, `fs-write-overwrite`, `parallel-tool-calls`, `workspace-context`, `workspace-edit`) pin the persisted read `meta` (with `{{cwd}}`-tokenized paths), and `cordis-inspect-jsdoc` pins the four-member `ToolResultView` union. The keyless snapshot and assembled-application transcript for the rendered read card belong to the follow-up Web PR that consumes the view, since this PR adds no new product-user-visible rendering — the TUI routes the read card through its existing generic dim-Markdown fallback (`transcript.ts` treats `card: 'read'` like `card: 'generic'`), so its output is unchanged. The `apps/cli` `parallel-file-reads` terminal golden (`apps/cli/tests/snapshots/parallel-file-reads/terminal.expected.txt`) pins exactly that: a real replay executes the read tool, renders it through the new `card: 'read'` gate, and the golden's dim-Markdown rows are byte-for-byte what a generic read produced before this card existed. + +## Related + +- [Tagged render-intent union for tool-call presentation](../architecture/2026-07-02-tool-render-intent-union.md) — the `card`-tagged vocabulary this extends with the `read` result arm. +- [Canonical tool output contract](../architecture/2026-07-20-canonical-tool-output-contract.md) — owns the `presentationMeta` persisted channel this projects the read window onto. +- [Web terminal card](2026-07-28-web-terminal-card.md) — the precedent for a client consuming a structured card; the read card follows the same producer pattern, result-side only. diff --git a/.agents/notes/implemented/feature/2026-07-30-web-read-card.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-read-card.zh.md new file mode 100644 index 0000000000..946bcca95b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-web-read-card.zh.md @@ -0,0 +1,49 @@ +# Agent Note: Read card — the read tool's structured line window reaches the client + +Status: implemented + +[English](2026-07-30-web-read-card.md) | 中文 + +## Problem + +`read` 工具返回规范化输出对象 `{ path, offset, lines: [{ number, text }], totalLines }`,但它的展示层把这个结构压平了。`presentCall` 声明为 `GenericCallView`(`kind: 'read'`,一个跟随定位),`presentResult` 返回 `GenericResultView`,其唯一内容是剥掉 `file` 信封后的面向模型文本。收到该视图的 UI 只看到一个压平的文本块:行号以 `N: ` 前缀烘焙进文本、文件语言未知、`totalLines` 丢失。capable 客户端无法像渲染 diff 那样渲染一次 read——即带行号、语法高亮、行号槽与内容分离的代码视图。 + +结构化数据在下游无法恢复。线上(wire)的工具结果只携带面向模型的 `ContentBlock[]`(已渲染文本)加上一个不透明的 `meta`;规范化输出对象留在工具内,从不到达客户端或会话日志。因此想要行数组、总数和语言提示的客户端无法从 `N: text` 文本里解析回它们——工具必须把它们投影到一个会持久化的通道上。 + +## Decision + +给[渲染意图 union](../architecture/2026-07-02-tool-render-intent-union.md) 新增第四个 `card` 标签 `read`——仅在结果侧。`ToolResultView` 增加 `ReadResultView { card: 'read'; title?; path; lines: ReadFileLine[]; totalLines; lang?; content? }`;`ReadFileLine { number; text }` 是共享的行单元。`ToolCallView` 不动:待定状态仍是 `GenericCallView`(`kind: 'read'`),因为一次调用在 `execute` 返回前不携带文件内容,调用时没有可展示的结构。这与 bash 终端 card 不同——终端 card 两侧都打标签,因为终端调用在调用时已携带命令和 cwd,而 read 调用既无内容也无总数,给调用侧打标签只会新增一个空变体。 + +read 工具通过 `output.presentationMeta` 投影结构化窗口,这与 write/edit 用来投影其应用 diff hunk 的持久化通道相同([规范化工具输出契约](../architecture/2026-07-20-canonical-tool-output-contract.md))。`presentationMeta` 对一次顶层 surface 调用运行一次,返回 `{ path, offset, lines, totalLines, lang? }` 作为会话校验并存储在结果 `meta` 上的 JSON,`presentResult` 在 live 和回放路径上都把该 meta 收窄回 `ReadResultView`。`offset`(窗口请求的 1-based 起始行)一并携带,是因为当字节上限低于首个选中行时,窗口会返回空的 `lines` 数组而 `totalLines` 为正;没有持久化的 `offset`,这类窗口的回放 card 就无法报告它从哪行开始、或续读应从哪行继续,而末行推断与文本重解析两种兜底都有损。没有这个通道,行数组和总数就无法触及:原始输出对象不在线上,而重新解析 `N: text` 文本既有损又对截断脚注脆弱。 + +`presentResult` 在以下情况返回 `undefined`——即 generic 回退:meta 缺失或畸形(`readMetaFromMeta` 防御性收窄它,因此回放旧的已记录结果永不抛错)、结果是错误、以及单个文本块不是 read 信封。本 card 出现之前记录的结果——信封合法但无持久化 `meta`——有意走同一条 `undefined` 路径:客户端回退到原始 `result.content`,因此显示带 `//` 信封的原文,而非旧展示器返回的剥信封 generic card。这是 [pre-release 立场](../../../../AGENTS.md#pre-release-stance-foundation-over-blast-radius)下接受的降级:拒绝旧的磁盘格式,而非加一个剥信封的兼容分支——本 PR 已重录全部已发布 fixtures,且 session 格式不承诺向后兼容。在成功路径上,`presentResult` 在结构化字段之外携带 `content`(剥信封后的文本),因此不具备 read 能力的 UI(包括当前的 TUI)通过 generic/default card 分支渲染文件文本,与之前完全一致。TUI 的 `renderBody` switch(`packages/ui/tui/src/components/transcript.ts`)不是 `assertNever` 穷尽的:`terminal` 和 `diff` 有分支,其余都落入 generic 分支,该分支读取 `view.content`。仅有该默认分支还不够:`render()` 在一个独立门控上设置 `genericContent`(连同 dim-Markdown 的 `dimBody` 处理),该门控原先只判 `card === 'generic'`,因此 `read` card 虽保留文本却会丢失 dim 样式。现在该门控也接纳 `card: 'read'`,让 `content` 走同一条 dim-Markdown 路径,因此 read 在 TUI 中的渲染与 read card 出现之前完全一致。除这一处门控外,TUI 无需 read 专属代码。 + +### 语言提示推导 + +`langFromPath`(在 `read-render.ts` 中)通过一张固定小表(`LANG_BY_EXTENSION`,覆盖常见源码、配置、标记扩展名)把文件扩展名映射到语法高亮语言 id。它读取最后一个路径段与最后一个点之后的扩展名,大小写不敏感,并对以下情况返回 `undefined`:dotfile(`.gitignore`)、无扩展名(`/etc/hosts`)、结尾的点、以及任何未知扩展名——此时 card 省略 `lang`,UI 渲染纯文本。该表不是可调项(tunable):它是 UI 可忽略的展示提示,而非随部署变化的选择,未知扩展名降级为纯文本而非失败。它有意保持小规模而非穷尽的语言注册表;扩展它是一行表项新增。 + +## Alternatives considered + +**在 `presentResult` 中重新解析 `N: text` 面向模型文本。** 已否决:结构化行数组将不得不通过按第一个 `: ` 切分每行来重建,这既有歧义(某行文本自身含 `: `),又丢失精确的 `totalLines`(脚注只在部分分支中陈述它),并在渲染格式变化时立即失效。`presentationMeta` 携带已经结构化的数据,无需重新解析。 + +**调用侧也打标签(`ReadCallView`),镜像终端 card 的两侧对称。** 已否决:read 调用在执行前没有内容、没有行数组、没有总数——调用侧 read card 会是一个空变体,重复 `GenericCallView`(`kind: 'read'`,跟随定位)已经表达的东西。终端 card 两侧都打标签是因为终端调用确实携带调用时数据(命令、cwd);read 调用没有。 + +**把结构化窗口放进新服务或旁路通道而非 `meta`。** 已否决:`meta` 是既有的持久化展示通道(write/edit 的应用 diff 就搭它),它随会话日志免费回放,无需新接线。服务会重新发明事件日志已提供的持久化与回放。 + +**用 merge-extensible union 而非封闭标签。** 出于[渲染意图 union](../architecture/2026-07-02-tool-render-intent-union.md) 封闭的相同理由否决:新 card 需要消费代码来渲染它,因此被消费者静默丢弃的变体比编译错误更糟。把 `read` 加入封闭 union 是扩展它的许可方式——每个在 `card` 上 switch 的消费者都继续编译,因为新成员落入其 generic default,而想要富视图的消费者新增自己的分支。 + +## Consequences + +`ToolResultView` 多了第四个成员。每个在 `card` 上 switch 的消费者都继续编译:TUI 和当前 Web 客户端把未知 card 路由到其 generic 路径,而 read card 携带 `content` 使该路径显示文件文本。从 `lines`/`lang`/`totalLines` 渲染带行号、语法高亮视图的 Web 前端是单独的后续 PR;本 PR 是让数据可触及的后端。在它落地前,read 在各处的渲染与之前完全一致(generic 文本 card)。 + +read 工具现在为每次顶层 read 计算 `presentationMeta`,这是对已在手数据的一次小投影(一次 `lines.map` 和一次 `langFromPath` 调用)。meta 随会话日志持久化,因此 read 结果在磁盘上略大——它已渲染为文本的行数组,现在也以结构化形式存在。 + +## Testing + +`packages/fs/tool-fs/tests/read-render.spec.ts` 单测 `langFromPath`(已知扩展名的大小写不敏感、扩展名在最后一段与最后一个点之后读取、以及 `undefined` 各情况:dotfile、无扩展名、结尾的点、未知)与 `readMetaFromMeta`(含与不含 `lang` 的良构收窄,以及每种拒绝:非对象、数组、缺失或类型错误的 `path`/`totalLines`/`lines`、畸形行项、非字符串 `lang`,以及——因为该函数收窄持久化的 opaque `meta` 边界——良构类型的回放 JSON 仍可能携带的语义无效路径:不是 1-based 整数的 `offset`、小于 `offset` 的首行 `number`、不是 1-based 整数的行 `number`(`0`、`1.5`、`NaN`、`Infinity`)、不是非负整数的 `totalLines`(`-1`、`1.5`、`NaN`)、以及行号重复、递减或超过 `totalLines` 的情况;并且收窄正 `offset` 处的空窗口(字节上限低于首个选中行))。`packages/fs/tool-fs/tests/tools.spec.ts` 固定工具接线:`execute` 把结构化窗口(含与不含 `lang` 提示)作为 `meta` 附上、`presentResult` 把它收窄为携带剥信封 `content` 的 `card: 'read'` 视图、以及各拒绝路径(错误结果、非单文本内容、meta 有效但信封畸形、信封有效但 meta 缺失或畸形)都回退到 `undefined`。两个改动的源文件保持逐文件 100% 覆盖率。本 PR 携带的是持久化 meta 与扩展后联合类型的快照证据,而非新渲染视图的证据:重录的 ACP session fixtures(`fs-read`、`fs-read-window`、`fs-edit`、`fs-policy-reject`、`fs-write-overwrite`、`parallel-tool-calls`、`workspace-context`、`workspace-edit`)钉住持久化的读取 `meta`(含 `{{cwd}}` 令牌化路径),`cordis-inspect-jsdoc` 钉住四成员的 `ToolResultView` 联合类型。已渲染读取 card 的 keyless 快照与组装应用 transcript 属于消费该视图的后续 Web PR,因为本 PR 不新增任何面向产品用户可见的渲染——TUI 通过其现有的通用 dim-Markdown 回退路由读取 card(`transcript.ts` 把 `card: 'read'` 当作 `card: 'generic'` 处理),因此其输出保持不变。`apps/cli` 的 `parallel-file-reads` 终端 golden(`apps/cli/tests/snapshots/parallel-file-reads/terminal.expected.txt`)正钉住这一点:一次真实回放执行 read 工具、经新的 `card: 'read'` 门渲染,golden 的 dim-Markdown 行与本 card 出现前 generic read 所产出的逐字节一致。 + +## Related + +- [Tagged render-intent union for tool-call presentation](../architecture/2026-07-02-tool-render-intent-union.md) —— 本 Note 以 `read` 结果分支扩展的 `card` 标签词汇。 +- [Canonical tool output contract](../architecture/2026-07-20-canonical-tool-output-contract.md) —— 拥有本 Note 用来投影 read 窗口的 `presentationMeta` 持久化通道。 +- [Web terminal card](2026-07-28-web-terminal-card.md) —— 客户端消费结构化 card 的先例;read card 遵循相同的生产者模式,仅结果侧。 diff --git a/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.i18n.yaml new file mode 100644 index 0000000000..cca8f8a597 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-session-archive-global-set.md +2026-07-31-session-archive-global-set.md: fab99a405a6f8264c36453473327e32905bac9c8 +2026-07-31-session-archive-global-set.zh.md: e33f3b5272a6d8fc90cfad247ba21d4d10afb045 diff --git a/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.md b/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.md new file mode 100644 index 0000000000..fab99a405a --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.md @@ -0,0 +1,33 @@ +# Agent Note: Session archive (registry-global set) + +Status: implemented + +English | [中文](2026-07-31-session-archive-global-set.zh.md) + +## Problem + +The session row menu in the sidebar workspace browser carried a purely visual "Delete session" placeholder (no handler). The product decision is **archive**, not delete: the session log and its workspace accounting stay untouched; the session merely disappears from every grouping surface (workspace groups, Ungrouped, search, the flat list). The archive record needs a home: an Ungrouped session belongs to no workspace entity, so a per-workspace field cannot carry it. + +## Decision + +**The archive set is a new field on the workspace domain's global singleton (`workspaceDomainState.archivedSessionIds`), layered over workspace accounting; display filtering converges entirely in the client's `tree.ts` derivation layer; the wire surface uses the full-snapshot posture.** + +- Storage: `archivedSessionIds: z.array(sessionId).default([])`, domain version stays 2 — a purely additive field; pre-field media parse to an empty set through the schema default, no migration code. An archived session keeps its `sessionIds` slot (a future unarchive restores its position), so the set never touches the one-owner accounting invariant. +- Registry: `ctx.workspace.archiveSession(id)` rides `enqueueOperation`, serialized with create/delete; a session neither live nor persisted throws `WorkspaceUnknownSessionError`; an already archived id neither writes nor emits. The `archivedSessionIds` getter exposes the read-only set. +- RPC: `workspace.archiveSession({sessionId}) → {archivedSessionIds}` (answers the full updated set); the `workspace.list` response carries the set as the reconnect baseline; a new host frame `host/archived-sessions-changed` pushes the full snapshot after every durable change (same posture as `host/workspace-changed`, emitted from the `domain/changed` global-put branch by set comparison). Unknown sessions reuse the `session-not-found` error code. +- Client runtime: `WorkspaceListState.archivedSessionIds` (a `readonly SessionId[]` in Host order, reference replaced only on membership change — public snapshot state stays in the store engine's plain-data vocabulary since immer drafts reject Sets without the MapSet plugin; membership lookups build a transient Set in the derivation, the expandedProjects pattern); the list baseline, the unary echo, and the changed frame each install the complete set. the projection sweep clears the current selection whenever it lands in the archive set, returning to the New Session view (user decision: archiving the open session sends the main view back to the hero) — one rule covering the local unary echo, another tab's changed frame, and a reconnect baseline restoring a selection archived while this client was away; a frame or echo landing during an in-flight `workspace.list` also shields the newer set from the stale baseline. +- UI: the `delete` menu row (visual-only) becomes `archive` (label "Archive session", non-danger styling, no confirmation dialog — a non-destructive action whose worst misfire is list hiding); filtering is one extra arm in `tree.ts`'s `sessionVisible` predicate, with `deriveGroups`/`deriveFlat` taking an `archived` set parameter so all four surfaces (group loop, stray bucket, search, flat) share one source. + +## Alternatives considered + +**Per-workspace archivedSessionIds (the original phrasing).** Rejected: Ungrouped sessions have no home; the user switched to global. + +**An archived flag on SessionSummary (session.list layer).** Rejected: it joins a workspace-domain fact into the sessions-domain projection, summaries have no incremental frame so a separate notification would still be needed — cross-domain coupling outweighs the saving. + +**Host-side filtering in `workspaceView`/the `sessionIds` getter.** Rejected: archiving ≠ changing accounting, and filtering the projection muddles the two concepts; a future restore surface also needs the client to see full accounting. + +**Incremental frames (single archived/removed rows).** Rejected: the set is tiny and changes rarely; full snapshots spare the client merge logic and dedup state and match the existing workspace-changed posture. + +## Consequences + +Archived sessions have no viewing or unarchive surface yet (this iteration's scope; recorded as a README Known Limitation); data and accounting slots stay intact, so a future restore is one UI surface plus one inverse RPC. The `workspace.list` response shape change is a pre-release direct edit (no compatibility layer). The workspace-management e2e pins the full chain (archive → row disappears → still hidden after reload, log still present); domain tests pin idempotence, unknown-id rejection, restart recovery, and the pre-field media default upgrade. diff --git a/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.zh.md b/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.zh.md new file mode 100644 index 0000000000..e33f3b5272 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.zh.md @@ -0,0 +1,33 @@ +# Agent Note: Session 归档(注册表级全局集合) + +状态:implemented + +[English](2026-07-31-session-archive-global-set.md) | 中文 + +## 问题 + +Sidebar workspace 浏览区的 session 行菜单里,「Delete session」一直是纯视觉占位(无 handler)。产品口径定为**归档**而非删除:session 日志与 workspace 记账都不动,只把该 session 从所有分组视图(workspace 分组、Ungrouped、搜索、平铺列表)里隐藏。归档记录需要一个落点:Ungrouped 的 session 不属于任何 workspace 实体,per-workspace 字段放不下它。 + +## 决策 + +**归档集合是 workspace domain 全局单例(`workspaceDomainState.archivedSessionIds`)上的一个新字段,覆盖在 workspace 记账之上;显示过滤全部收敛在 client 的 `tree.ts` 派生层;wire 面走全快照姿态。** + +- 存储:`archivedSessionIds: z.array(sessionId).default([])`,domain version 保持 2——纯增量字段,旧介质经 schema default 解析为空集合,无迁移代码。被归档的 session 保留其 `sessionIds` 席位(未来取消归档恢复原位置),因此与「一个 session 只被一个 workspace 记账」不变式零纠缠。 +- Registry:`ctx.workspace.archiveSession(id)` 走 `enqueueOperation` 与 create/delete 串行;未知 session(实时与持久化都查不到)抛 `WorkspaceUnknownSessionError`;已归档 id 不写盘不发事件。`archivedSessionIds` getter 暴露只读集合。 +- RPC:`workspace.archiveSession({sessionId}) → {archivedSessionIds}`(应答完整更新后集合);`workspace.list` 响应携带集合作为重连基线;新 host 帧 `host/archived-sessions-changed` 在每次持久变更后推完整快照(与 `host/workspace-changed` 同姿态,从 `domain/changed` 的 global put 分支比对推帧)。未知 session 复用错误码 `session-not-found`。 +- client runtime:`WorkspaceListState.archivedSessionIds`(按 Host 顺序的 `readonly SessionId[]`,成员不变不换引用——公有快照状态保持 store 引擎的纯数据词汇:immer draft 不开 MapSet 插件就不接受 Set;membership 查询在派生函数内自建临时 Set,与 expandedProjects 同款);list 基线、unary 回声、changed 帧三路都整体替换安装。投影层在当前 selection 落入归档集合时统一清空回 New Session 视图(用户拍板:归档当前打开的 session 主视图回 hero)——一条规则同时覆盖本地 unary 回声、其他标签页的 changed 帧、以及重连基线恢复出一个离线期间被归档的 selection;帧/回声落在 in-flight `workspace.list` 期间时还会屏蔽旧基线对新集合的回滚。 +- UI:菜单项 `delete`(visual-only)改为 `archive`(label「Archive session」,非 danger 样式,无确认对话框——非破坏性操作,误触后果只是列表隐藏);过滤实现为 `tree.ts` 的 `sessionVisible` 判据加一档,`deriveGroups`/`deriveFlat` 增加 `archived` 集合入参,四个视图(分组循环、stray 兜底、搜索、平铺)同源生效。 + +## 已考虑的替代方案 + +**per-workspace archivedSessionIds(最初表述)。** 否决:Ungrouped session 无落点;用户改口全局。 + +**SessionSummary 打 archived 标(session.list 层)。** 否决:要把 workspace domain 事实 join 进 sessions domain 投影,summary 无增量帧还得另发通知,跨域耦合大于收益。 + +**host 侧在 `workspaceView`/`sessionIds` getter 过滤。** 否决:归档 ≠ 改记账,投影过滤会把两个概念搅浑;未来恢复入口也需要 client 拿到全量记账。 + +**增量帧(archived/removed 单条)。** 否决:集合极小、变更频率低,全快照免去 client 侧合并逻辑与去重状态,与 workspace-changed 现有姿态一致。 + +## 后果 + +归档后 UI 无查看/取消归档入口(本期口径,README Known Limitation 记账);数据与席位完好,后续加恢复面只是 UI + 一个逆向 RPC。`workspace.list` 响应形状变化是 pre-release 直改(无兼容层)。e2e(workspace-management)钉住了「归档→行消失→reload 后仍隐藏、日志仍在」的全链路;domain 层测试钉住幂等、未知 id 拒绝、跨重启恢复与旧介质默认升级。 diff --git a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md index 7024719a3a..dc4cbca241 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md @@ -16,7 +16,7 @@ - treeitem "workspace 1 session" [expanded]: - img - text: workspace 1 session - - treeitem "New Session now" [selected] + - treeitem "New Session" [selected] - button "Settings": - img - text: Settings diff --git a/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md index 15bee7afe4..7f62e80503 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md @@ -16,7 +16,7 @@ - treeitem "workspace 1 session" [expanded]: - img - text: workspace 1 session - - treeitem "New Session now" [selected] + - treeitem "New Session" [selected] - button "Settings": - img - text: Settings diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index e6a7f31f18..48483e512d 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/apps/web/tests/workspace-management.e2e.ts @@ -1,10 +1,13 @@ // Web e2e scenarios: workspace management — the create-by-name dialog, the // rename round trip over the real wire (workspace.rename RPC + durable // registry), duplicate-name pre-check, the flat "In one list" view with its -// persisted group-by preference, and the session hover card. Zero model -// calls: workspace.create/rename are host RPCs with no model involvement, -// and the one session row the flat/hover scenarios need comes from a seeded -// fixture (the seeded-history seed reused verbatim — no new recording). +// persisted group-by preference, the session hover card, and the session +// archive round trip (row menu → workspace.archiveSession RPC → durable +// global set → row hidden across reload). Zero model calls: +// workspace.create/rename/archiveSession are host RPCs with no model +// involvement, and the one session row the flat/hover/archive scenarios need +// comes from a seeded fixture (the seeded-history seed reused verbatim — no +// new recording). import { mkdir, readFile, stat, writeFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { join } from 'node:path' @@ -413,6 +416,55 @@ describe('web e2e: workspace management (create / rename / flat view / hover car expect(tripwire.pageErrors).toEqual([]) }, 60_000) + it('archives the seeded session from its row menu, hiding it durably across reload', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-archive')) + // The seeded session lives under Ungrouped (expanded by the hover-card + // test's gesture; converge again for order independence). + const ungroupedRow = page.getByText('Ungrouped', { exact: true }).locator('..').locator('..') + const ungroupedSection = ungroupedRow.locator('..') + await expect.poll(async () => { + if (await ungroupedRow.getAttribute('aria-expanded') !== 'true') { + await page.getByText('Ungrouped', { exact: true }).click() + await page.waitForTimeout(50) + } + return await ungroupedRow.getAttribute('aria-expanded') + }, { timeout: 5_000 }).toBe('true') + // Anchor on session rows (the rows carrying a session actions button), + // not a positional index, and assert the single-stray assumption loudly + // so a fixture gaining a second stray fails here instead of archiving + // the wrong row. CSS attribute match, not getByRole: the button is + // display:none until its row hovers, and role queries skip hidden nodes. + const sessionRows = ungroupedSection.locator('[role="treeitem"]') + .filter({ has: page.locator('button[aria-label^="Session actions for "]') }) + await expect.poll(() => sessionRows.count(), { timeout: 10_000 }).toBe(1) + const sessionRow = sessionRows.first() + const rowTitle = await sessionRow.locator('[class*="title"]').innerText() + // Row menu: hover reveals the actions button; Archive session commits + // without a confirmation dialog (non-destructive: log + accounting stay). + await sessionRow.hover() + await sessionRow.getByRole('button', { name: `Session actions for ${rowTitle}` }).click() + await page.getByRole('menuitem', { name: 'Archive session' }).click() + // The row disappears on the archive-set echo; with no other visible + // stray, the whole Ungrouped bucket withdraws. + await expect.poll(() => page.getByText(rowTitle, { exact: true }).count(), { timeout: 10_000 }).toBe(0) + await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 10_000 }).toBe(0) + // Durable on the host: the registry-global set carries the id while the + // session log itself stays in persistence untouched. + expect([...scaffold.ctx.workspace.archivedSessionIds]).toEqual([SessionId(SEED_ID)]) + expect((await scaffold.ctx.sessionPersistence.list()).map(header => header.id)).toContain(SessionId(SEED_ID)) + // Reload: the hidden state is rebuilt from the workspace.list baseline. + const warningStart = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + acknowledgeReloadConnectionLoss(tripwire, warningStart) + await expect.poll(() => page.getByText('Workspaces', { exact: true }).count(), { timeout: 15_000 }).toBe(1) + // The archived row must not resurface (the Ungrouped bucket itself may + // reappear if selection restore lands on another stray — not this test's + // concern). + expect(await page.getByText(rowTitle, { exact: true }).count()).toBe(0) + expect(tripwire.pageErrors).toEqual([]) + }, 90_000) + it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => { expect(tripwire.warnings).toEqual([]) // The directory-browser aria golden is this spec's one owned artifact; diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 1b4db6a593..d4e29f31c1 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1992,7 +1992,7 @@ export interface Config { export type ToolPresentationMode = 'native' | 'code' | 'both' ``` -Source: [`packages/core/tools/src/index.ts:582`](../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:584`](../packages/core/tools/src/index.ts) ## `@deepseek-ai/dsh-tui` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index df6e2d9d1d..b8946e6574 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -938,7 +938,7 @@ A tool was registered or unregistered, or a scoped restriction changed (the avai 'tools/change'(): void ``` -Source: [`packages/core/tools/src/index.ts:160`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:162`](../../packages/core/tools/src/index.ts) ### `tools/code-dispatch-log` — waterfall @@ -962,7 +962,7 @@ Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bri Types: [CodeDispatchLog](../core-data-structures/tools.md) · [ContentBlock](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [ToolRegistry](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:142`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:144`](../../packages/core/tools/src/index.ts) ### `tools/execute` — waterfall @@ -984,7 +984,7 @@ Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns a nor Types: [Scoped](../core-data-structures/scope.md) · [ToolDispatchExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:117`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:119`](../../packages/core/tools/src/index.ts) ### `tools/post-execute` — waterfall @@ -1007,7 +1007,7 @@ Accept, replace, enrich, or block a normalized dispatch result. `next()` accepts Types: [PostToolDecision](../core-data-structures/tools.md) · [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:129`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:131`](../../packages/core/tools/src/index.ts) ### `tools/pre-execute` — waterfall @@ -1028,7 +1028,7 @@ Allow, deny, or ask before dispatch. `next()` delegates to allow; missing approv Types: [PreToolDecision](../core-data-structures/tools.md) · [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:106`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:108`](../../packages/core/tools/src/index.ts) ### `tools/result` — emit @@ -1047,7 +1047,7 @@ Observe the frozen, lossless-JSON final outcome. Listener failures are contained Types: [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:150`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:152`](../../packages/core/tools/src/index.ts) ## `workflow/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 4bb2ad0678..d8102eefb5 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2333,7 +2333,7 @@ async execute(exec: ToolExecutionInput): Promise Types: [ScopeKey](../core-data-structures/scope.md) · [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionMode](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolGuard](../core-data-structures/tools.md) · [ToolRestriction](../core-data-structures/tools.md) · [ToolSchema](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:704`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:706`](../../packages/core/tools/src/index.ts) ## `ctx.tui` — `TuiExtensionService` (abstract seam) @@ -2564,6 +2564,15 @@ list(): Workspace[] */ delete(id: WorkspaceId): Promise +/** + * Archive one session durably. The session must exist (live or in session + * persistence); its workspace accounting — or lack of one — is irrelevant. + * An already archived id resolves without writing. + * @param sessionId - The session to archive. + * @returns resolution after durability. + */ +archiveSession(sessionId: SessionId): Promise + /** * Resolve by canonical directory path without creating or mutating a * workspace. A missing path rejects during `realpath`; an existing unowned @@ -2574,7 +2583,9 @@ delete(id: WorkspaceId): Promise async resolveByPath(path: string): Promise ``` -Source: [`packages/workspace/workspace/src/index.ts:78`](../../packages/workspace/workspace/src/index.ts) +Types: [SessionId](../core-data-structures/core.md) + +Source: [`packages/workspace/workspace/src/index.ts:92`](../../packages/workspace/workspace/src/index.ts) ## Inherited `ctx` members (cordis core + loader/hmr/timer) diff --git a/docs/core-data-structures/tools.i18n.yaml b/docs/core-data-structures/tools.i18n.yaml index a6cf2bae68..e99ea06597 100644 --- a/docs/core-data-structures/tools.i18n.yaml +++ b/docs/core-data-structures/tools.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/core-data-structures/tools.md -tools.md: 3c94f1093001e8c65365cc5baa50c1393d53b3ee -tools.zh.md: 7a6aad81c4cfe83be8625411e4313d0c36018821 +tools.md: 62acd00a3afe50c90b2cab0bb0f170ab82852a4f +tools.zh.md: 1ec638d6062d6496aebc019e531126343d6ead28 diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md index 3c94f10930..62acd00a3a 100644 --- a/docs/core-data-structures/tools.md +++ b/docs/core-data-structures/tools.md @@ -447,8 +447,8 @@ type ObjectJsonSchema = JsonSchemaNode & { type: 'object' } How a tool wants its call shown in a UI (an editor tool-call card, a CLI log line), provider-neutral so a tool describes itself without depending on any client protocol. `presentCall`/`presentResult` return a **`card`-tagged render intent** — a discriminated union a UI bridge switches on: - `ToolCallView` (pending): `{ card: 'generic', title, kind?, rawInput?, content?, locations? }` (the default card; `locations` is `{ path, line? }[]` files the call reads/modifies, for editor follow-along), `{ card: 'terminal', title, description?, cwd? }` (a shell command → a terminal card), or `{ card: 'diff', title, diffs, locations? }` (a file create/modify → an inline diff card; `diffs` is `{ path, oldText, newText }[]`, `oldText: null` for a new file). -- `ToolResultView` (completed): `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }` (the captured run output + exit; a capable UI shows an exit-status pill, while another may derive a fenced ` ```console ` fallback), `{ card: 'diff', title?, diffs }` (a completed file mutation → the change to show, typically the applied hunks with context lines computed from the before/after content, or a whole-file diff when there is no before-image), or `{ card: 'web', kind: 'search' | 'fetch', title?, … }` (a completed web retrieval; `kind: 'search'` carries the structured `sources`/`answer?`/`truncated`, `kind: 'fetch'` carries `url`/`statusCode`/`truncated`, and a UI without the `web` capability falls back to the raw result content — the body is not duplicated into the view). Completed views replace pending views, so mutation tools return a diff result even when it duplicates the call-time snippet. +- `ToolResultView` (completed): `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }` (the captured run output + exit; a capable UI shows an exit-status pill, while another may derive a fenced ` ```console ` fallback), `{ card: 'diff', title?, diffs }` (a completed file mutation → the change to show, typically the applied hunks with context lines computed from the before/after content, or a whole-file diff when there is no before-image), `{ card: 'read', title?, path, offset, lines, totalLines, lang?, content? }` (a completed file read → a line-numbered, optionally syntax-highlighted code view; `offset` is the 1-based first line the window requested, kept even when `lines` is empty; `lang` is a language hint from the extension, and `content` is the envelope-stripped text a UI without read support falls back to), or `{ card: 'web', kind: 'search' | 'fetch', title?, … }` (a completed web retrieval; `kind: 'search'` carries the structured `sources`/`answer?`/`truncated`, `kind: 'fetch'` carries `url`/`statusCode`/`truncated`, and a UI without the `web` capability falls back to the raw result content — the body is not duplicated into the view). Completed views replace pending views, so mutation tools return a diff result even when it duplicates the call-time snippet. -`ToolCallKind` (`'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other'`) picks an icon on a generic card. `FileLocation` (`{ path, line? }`) and `FileDiff` (`{ path, oldText, newText }`) are the shared file-card vocabulary. The design is pinned in [the render-intent-union Agent Note](../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md); the TUI and host/client runtime project this neutral vocabulary into their own views. +`ToolCallKind` (`'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other'`) picks an icon on a generic card. `FileLocation` (`{ path, line? }`), `FileDiff` (`{ path, oldText, newText }`), and `ReadFileLine` (`{ number, text }`, one 1-based numbered line of a read window) are the shared file-card vocabulary. The design is pinned in [the render-intent-union Agent Note](../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md); the TUI and host/client runtime project this neutral vocabulary into their own views. The full presentation field docs live in [`packages/core/tools/src/presentation.ts`](../../packages/core/tools/src/presentation.ts). The `bash` schema and executor are on [bash.md](bash.md); generic background controls are on [tasks.md](tasks.md). diff --git a/docs/core-data-structures/tools.zh.md b/docs/core-data-structures/tools.zh.md index 7a6aad81c4..1ec638d606 100644 --- a/docs/core-data-structures/tools.zh.md +++ b/docs/core-data-structures/tools.zh.md @@ -447,8 +447,8 @@ type ObjectJsonSchema = JsonSchemaNode & { type: 'object' } 工具希望其调用在 UI 中如何呈现(编辑器工具调用卡片、CLI(命令行界面)日志行),提供方无关,使工具在不依赖任何客户端协议的情况下描述自身。`presentCall`/`presentResult` 返回一个 **`card` 标签的渲染意图**——一个可辨识联合类型,UI 桥接层据此分发: - `ToolCallView`(待执行):`{ card: 'generic', title, kind?, rawInput?, content?, locations? }`(默认卡片;`locations` 是 `{ path, line? }[]`,表示调用读取/修改的文件,供编辑器跟随)、`{ card: 'terminal', title, description?, cwd? }`(shell 命令→终端卡片)、或 `{ card: 'diff', title, diffs, locations? }`(文件创建/修改→行内 diff 卡片;`diffs` 是 `{ path, oldText, newText }[]`,新文件时 `oldText: null`)。 -- `ToolResultView`(已完成):`{ card: 'generic', title?, content? }`、`{ card: 'terminal', title?, output?, exitCode?, signal? }`(捕获的运行输出 + 退出状态;有能力的 UI 显示退出状态标签,其他 UI 可以派生围栏 ` ```console ` 回退)、`{ card: 'diff', title?, diffs }`(已完成的文件变更→要展示的变更,通常是从变更前后内容计算出带上下文行的已应用 hunk,或在没有前像时的整文件 diff)、或 `{ card: 'web', kind: 'search' | 'fetch', title?, … }`(已完成的 web 检索;`kind: 'search'` 携带结构化的 `sources`/`answer?`/`truncated`,`kind: 'fetch'` 携带 `url`/`statusCode`/`truncated`,不具备 `web` 能力的 UI 回退到原始结果内容——正文不会重复进视图)。已完成视图会替换待执行视图,因此变更工具即使与调用时的片段重复也要返回 diff 结果。 +- `ToolResultView`(已完成):`{ card: 'generic', title?, content? }`、`{ card: 'terminal', title?, output?, exitCode?, signal? }`(捕获的运行输出 + 退出状态;有能力的 UI 显示退出状态标签,其他 UI 可以派生围栏 ` ```console ` 回退)、`{ card: 'diff', title?, diffs }`(已完成的文件变更→要展示的变更,通常是从变更前后内容计算出带上下文行的已应用 hunk,或在没有前像时的整文件 diff)、`{ card: 'read', title?, path, offset, lines, totalLines, lang?, content? }`(已完成的文件读取→带行号、可选语法高亮的代码视图;`offset` 是窗口请求的 1-based 起始行,即使 `lines` 为空也保留;`lang` 是从扩展名推得的语言提示,`content` 是无读取能力的 UI 回退时使用的去信封文本)、或 `{ card: 'web', kind: 'search' | 'fetch', title?, … }`(已完成的 web 检索;`kind: 'search'` 携带结构化的 `sources`/`answer?`/`truncated`,`kind: 'fetch'` 携带 `url`/`statusCode`/`truncated`,不具备 `web` 能力的 UI 回退到原始结果内容——正文不会重复进视图)。已完成视图会替换待执行视图,因此变更工具即使与调用时的片段重复也要返回 diff 结果。 -`ToolCallKind`(`'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other'`)用于为通用卡片选择图标。`FileLocation`(`{ path, line? }`)与 `FileDiff`(`{ path, oldText, newText }`)是共享的文件卡片词汇。该设计由[渲染意图联合类型 Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md)固定;TUI 和 host/client 运行时将这套中性词汇投影为各自的视图。 +`ToolCallKind`(`'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other'`)用于为通用卡片选择图标。`FileLocation`(`{ path, line? }`)、`FileDiff`(`{ path, oldText, newText }`)与 `ReadFileLine`(`{ number, text }`,读取窗口中一行带 1-based 行号的内容)是共享的文件卡片词汇。该设计由[渲染意图联合类型 Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md)固定;TUI 和 host/client 运行时将这套中性词汇投影为各自的视图。 完整的展示字段文档见 [`packages/core/tools/src/presentation.ts`](../../packages/core/tools/src/presentation.ts)。`bash` schema 与执行器见 [bash.md](bash.md);通用后台控制见 [tasks.md](tasks.md)。 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 090a790c05..360213037b 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -48,12 +48,12 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `telemetry/record` | `waterfall` | [`packages/telemetry/session-telemetry/src/index.ts:41`](../packages/telemetry/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/telemetry/session-telemetry) (`waterfall`) | - | -| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:160`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | -| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:142`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | -| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:117`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`timeout-policy`](../packages/timeout/timeout-policy) | -| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:129`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search), [`workspace-context`](../packages/context/workspace-context) | -| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:106`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-tasks`](../packages/tasks/tool-tasks) | -| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:150`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | +| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:162`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | +| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:144`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | +| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:119`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`timeout-policy`](../packages/timeout/timeout-policy) | +| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:131`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search), [`workspace-context`](../packages/context/workspace-context) | +| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:108`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-tasks`](../packages/tasks/tool-tasks) | +| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:152`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:81`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:70`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:91`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index bce928c659..b464616e19 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -11,7 +11,7 @@ {"type":"assistant/chunk","seq":9,"time":1783951000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":10,"time":1784449176722,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"1ac37046-d1c0-4ef6-9ea9-963e4b46d1cf"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":1784449176722,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n reserveTurnAdmission(): (() => void) | undefined;\n updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n adapterDefaults?: LlmCallConfigAdapterDefaults;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export type InboxAction = {\n readonly kind: 'edit';\n readonly content: ContentBlock[];\n } | {\n readonly kind: 'remove';\n };\n export type InboxActionResult = 'applied' | 'not-found';\n export type InboxItemId = Branded<'InboxItemId'>;\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmCallConfigAdapterDefaults {\n reasoningEffort?: true;\n maxTokens?: true;\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': {\n turn: number;\n message: UserMessage;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'session/end-seed': Record;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | WebResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n retry: {\n kind: 'retry';\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }\n export interface WebFetchResultView {\n card: 'web';\n kind: 'fetch';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n }\n export type WebResultView = WebSearchResultView | WebFetchResultView;\n export interface WebSearchResultView {\n card: 'web';\n kind: 'search';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n }\n export interface WebSource {\n url: string;\n title?: string;\n snippet?: string;\n publishedAt?: string;\n }"}],"isError":false}],"role":"user","id":"1c43b8df-aae8-42e7-8253-5b275edc09bc"}},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n reserveTurnAdmission(): (() => void) | undefined;\n updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n adapterDefaults?: LlmCallConfigAdapterDefaults;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export type InboxAction = {\n readonly kind: 'edit';\n readonly content: ContentBlock[];\n } | {\n readonly kind: 'remove';\n };\n export type InboxActionResult = 'applied' | 'not-found';\n export type InboxItemId = Branded<'InboxItemId'>;\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmCallConfigAdapterDefaults {\n reasoningEffort?: true;\n maxTokens?: true;\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReadFileLine {\n number: number;\n text: string;\n }\n export interface ReadResultView {\n card: 'read';\n title?: string;\n path: string;\n offset: number;\n lines: ReadFileLine[];\n totalLines: number;\n lang?: string;\n content?: ContentBlock[];\n }\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': {\n turn: number;\n message: UserMessage;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'session/end-seed': Record;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | ReadResultView | WebResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n retry: {\n kind: 'retry';\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }\n export interface WebFetchResultView {\n card: 'web';\n kind: 'fetch';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n }\n export type WebResultView = WebSearchResultView | WebFetchResultView;\n export interface WebSearchResultView {\n card: 'web';\n kind: 'search';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n }\n export interface WebSource {\n url: string;\n title?: string;\n snippet?: string;\n publishedAt?: string;\n }"}],"isError":false}],"role":"user","id":"1c43b8df-aae8-42e7-8253-5b275edc09bc"}},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":1784449176732,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":1784449176733,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":1783951000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl b/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl index e4b84dee8c..1420353ae4 100644 --- a/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl @@ -14,7 +14,7 @@ {"type":"assistant/chunk","seq":68,"time":1783352086057,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":69,"time":1783352086059,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to:\n1. Read config.txt in the current directory\n2. Use the edit tool to replace DEBUG with RELEASE\n3. Reply with exactly \"DONE\"\n\nLet me start by reading the file."},{"type":"tool-call","id":"call_00_S6krdIDHoSCGWv7WnXX97617","name":"read","arguments":"{\"file_path\": \"config.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"36ebf262-429c-4398-abbc-a197e2522f1d"},"usage":{"inputTokens":2900,"outputTokens":91,"cacheReadTokens":0,"reasoningTokens":46}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68],"surfaceOp":"append"} {"type":"tool/call","seq":70,"time":1783352086059,"data":{"turn":1,"step":1,"callId":"call_00_S6krdIDHoSCGWv7WnXX97617","name":"read","arguments":"{\"file_path\": \"config.txt\"}"}} -{"type":"tool/result","seq":71,"time":1783352086065,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_S6krdIDHoSCGWv7WnXX97617"},"content":[{"type":"tool-result","toolCallId":"call_00_S6krdIDHoSCGWv7WnXX97617","content":[{"type":"text","text":"{{cwd}}/config.txt\nfile\n\n1: mode=DEBUG\n2: level=info\n\n(End of file - total 2 lines)\n"}],"isError":false}],"role":"user","id":"1c3ce978-55ee-4337-a586-084a77ed44e7"}},"sourceEventSeqs":[70],"surfaceOp":"append"} +{"type":"tool/result","seq":71,"time":1783352086065,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_S6krdIDHoSCGWv7WnXX97617"},"content":[{"type":"tool-result","toolCallId":"call_00_S6krdIDHoSCGWv7WnXX97617","content":[{"type":"text","text":"{{cwd}}/config.txt\nfile\n\n1: mode=DEBUG\n2: level=info\n\n(End of file - total 2 lines)\n"}],"isError":false}],"role":"user","id":"1c3ce978-55ee-4337-a586-084a77ed44e7"},"meta":{"path":"{{cwd}}/config.txt","offset":1,"lines":[{"number":1,"text":"mode=DEBUG"},{"number":2,"text":"level=info"}],"totalLines":2}},"sourceEventSeqs":[70],"surfaceOp":"append"} {"type":"step/end","seq":72,"time":1783352086065,"data":{"turn":1,"step":1}} {"type":"step/start","seq":73,"time":1783352086066,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":74,"time":1783352086901,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl b/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl index 7ff933ab4b..f58a07edfe 100644 --- a/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl @@ -27,7 +27,7 @@ {"type":"assistant/chunk","seq":143,"time":1783611705572,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":144,"time":1783611705573,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The edit tool requires reading the file first. The user said \"Do not read the file first\" but the tool's own constraints require reading. I need to follow the tool requirements. Let me read the file first."},{"type":"tool-call","id":"call_00_tfr0gdIclW92oIEgi7NF0891","name":"read","arguments":"{\"file_path\": \"settings.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"de588e3c-b10c-4eee-93a5-26e9a665dcbc"},"usage":{"inputTokens":235,"outputTokens":89,"cacheReadTokens":3072,"reasoningTokens":44}},"sourceEventSeqs":[82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143],"surfaceOp":"append"} {"type":"tool/call","seq":145,"time":1783611705573,"data":{"turn":1,"step":2,"callId":"call_00_tfr0gdIclW92oIEgi7NF0891","name":"read","arguments":"{\"file_path\": \"settings.txt\"}"}} -{"type":"tool/result","seq":146,"time":1783611705579,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_tfr0gdIclW92oIEgi7NF0891"},"content":[{"type":"tool-result","toolCallId":"call_00_tfr0gdIclW92oIEgi7NF0891","content":[{"type":"text","text":"{{cwd}}/settings.txt\nfile\n\n1: color: blue\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"fade9382-14c7-47e3-8da4-286008d7e9b8"}},"sourceEventSeqs":[145],"surfaceOp":"append"} +{"type":"tool/result","seq":146,"time":1783611705579,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_tfr0gdIclW92oIEgi7NF0891"},"content":[{"type":"tool-result","toolCallId":"call_00_tfr0gdIclW92oIEgi7NF0891","content":[{"type":"text","text":"{{cwd}}/settings.txt\nfile\n\n1: color: blue\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"fade9382-14c7-47e3-8da4-286008d7e9b8"},"meta":{"path":"{{cwd}}/settings.txt","offset":1,"lines":[{"number":1,"text":"color: blue"}],"totalLines":1}},"sourceEventSeqs":[145],"surfaceOp":"append"} {"type":"step/end","seq":147,"time":1783611705579,"data":{"turn":1,"step":2}} {"type":"step/start","seq":148,"time":1783611705579,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":149,"time":1783611706200,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl b/examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl index 58cb8c5f34..33b0a2754f 100644 --- a/examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl @@ -14,7 +14,7 @@ {"type":"assistant/chunk","seq":90,"time":1783352101346,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":91,"time":1783352101348,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to use the read tool with offset 5 and limit 4 to read lines 5 through 8 of big.txt in the current directory. Then reply with exactly the single word DONE.\n\nLet me first check the current directory, then read the file."},{"type":"tool-call","id":"call_00_GIZwZS9a7vhWTFCIc7Z35497","name":"read","arguments":"{\"file_path\": \"big.txt\", \"offset\": 5, \"limit\": 4}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"5620412c-8fae-4d17-aac4-0801f3b02461"},"usage":{"inputTokens":2894,"outputTokens":133,"cacheReadTokens":0,"reasoningTokens":56}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90],"surfaceOp":"append"} {"type":"tool/call","seq":92,"time":1783352101348,"data":{"turn":1,"step":1,"callId":"call_00_GIZwZS9a7vhWTFCIc7Z35497","name":"read","arguments":"{\"file_path\": \"big.txt\", \"offset\": 5, \"limit\": 4}"}} -{"type":"tool/result","seq":93,"time":1783352101353,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_GIZwZS9a7vhWTFCIc7Z35497"},"content":[{"type":"tool-result","toolCallId":"call_00_GIZwZS9a7vhWTFCIc7Z35497","content":[{"type":"text","text":"{{cwd}}/big.txt\nfile\n\n5: line five\n6: line six\n7: line seven\n8: line eight\n\n(Showing lines 5-8 of 10. Use offset=9 to continue.)\n"}],"isError":false}],"role":"user","id":"02513672-93cb-4f70-9ee7-ad19542a5f6b"}},"sourceEventSeqs":[92],"surfaceOp":"append"} +{"type":"tool/result","seq":93,"time":1783352101353,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_GIZwZS9a7vhWTFCIc7Z35497"},"content":[{"type":"tool-result","toolCallId":"call_00_GIZwZS9a7vhWTFCIc7Z35497","content":[{"type":"text","text":"{{cwd}}/big.txt\nfile\n\n5: line five\n6: line six\n7: line seven\n8: line eight\n\n(Showing lines 5-8 of 10. Use offset=9 to continue.)\n"}],"isError":false}],"role":"user","id":"02513672-93cb-4f70-9ee7-ad19542a5f6b"},"meta":{"path":"{{cwd}}/big.txt","offset":5,"lines":[{"number":5,"text":"line five"},{"number":6,"text":"line six"},{"number":7,"text":"line seven"},{"number":8,"text":"line eight"}],"totalLines":10}},"sourceEventSeqs":[92],"surfaceOp":"append"} {"type":"step/end","seq":94,"time":1783352101353,"data":{"turn":1,"step":1}} {"type":"step/start","seq":95,"time":1783352101354,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":96,"time":1783352102021,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-read/session.jsonl b/examples/acp-agent/tests/snapshots/fs-read/session.jsonl index 70c0f679bd..9d16f66531 100644 --- a/examples/acp-agent/tests/snapshots/fs-read/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-read/session.jsonl @@ -14,7 +14,7 @@ {"type":"assistant/chunk","seq":52,"time":1783352073705,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":53,"time":1783352073708,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to read the file greeting.txt using the read tool (not bash), then reply with exactly the single word \"DONE\"."},{"type":"tool-call","id":"call_00_hHPZCcivsIkXAGS9jTGy8417","name":"read","arguments":"{\"file_path\": \"greeting.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"5452254c-4843-458c-9732-12fe8b7c1468"},"usage":{"inputTokens":2882,"outputTokens":75,"cacheReadTokens":0,"reasoningTokens":29}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52],"surfaceOp":"append"} {"type":"tool/call","seq":54,"time":1783352073709,"data":{"turn":1,"step":1,"callId":"call_00_hHPZCcivsIkXAGS9jTGy8417","name":"read","arguments":"{\"file_path\": \"greeting.txt\"}"}} -{"type":"tool/result","seq":55,"time":1783352073717,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_hHPZCcivsIkXAGS9jTGy8417"},"content":[{"type":"tool-result","toolCallId":"call_00_hHPZCcivsIkXAGS9jTGy8417","content":[{"type":"text","text":"{{cwd}}/greeting.txt\nfile\n\n1: hello\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"6dd015cf-8c8b-4fd3-a1b2-fa243d67d8e9"}},"sourceEventSeqs":[54],"surfaceOp":"append"} +{"type":"tool/result","seq":55,"time":1783352073717,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_hHPZCcivsIkXAGS9jTGy8417"},"content":[{"type":"tool-result","toolCallId":"call_00_hHPZCcivsIkXAGS9jTGy8417","content":[{"type":"text","text":"{{cwd}}/greeting.txt\nfile\n\n1: hello\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"6dd015cf-8c8b-4fd3-a1b2-fa243d67d8e9"},"meta":{"path":"{{cwd}}/greeting.txt","offset":1,"lines":[{"number":1,"text":"hello"}],"totalLines":1}},"sourceEventSeqs":[54],"surfaceOp":"append"} {"type":"step/end","seq":56,"time":1783352073718,"data":{"turn":1,"step":1}} {"type":"step/start","seq":57,"time":1783352073719,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":58,"time":1783352074666,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl b/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl index e836616e19..4fdc8bae84 100644 --- a/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl @@ -14,7 +14,7 @@ {"type":"assistant/chunk","seq":64,"time":1783352093614,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":65,"time":1783352093617,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to:\n1. Read data.txt using the read tool\n2. Replace its entire contents with exactly \"replaced\" using the write tool\n3. Reply with exactly \"DONE\""},{"type":"tool-call","id":"call_00_n4eRJuGoxNR07svgNtk82243","name":"read","arguments":"{\"file_path\": \"data.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"00272d0c-8ed0-436a-8d10-4a7091447dfe"},"usage":{"inputTokens":2899,"outputTokens":87,"cacheReadTokens":0,"reasoningTokens":42}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64],"surfaceOp":"append"} {"type":"tool/call","seq":66,"time":1783352093617,"data":{"turn":1,"step":1,"callId":"call_00_n4eRJuGoxNR07svgNtk82243","name":"read","arguments":"{\"file_path\": \"data.txt\"}"}} -{"type":"tool/result","seq":67,"time":1783352093624,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_n4eRJuGoxNR07svgNtk82243"},"content":[{"type":"tool-result","toolCallId":"call_00_n4eRJuGoxNR07svgNtk82243","content":[{"type":"text","text":"{{cwd}}/data.txt\nfile\n\n1: original contents\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"c14ef7fe-2bb8-4adf-ab15-5a988fbf5f55"}},"sourceEventSeqs":[66],"surfaceOp":"append"} +{"type":"tool/result","seq":67,"time":1783352093624,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_n4eRJuGoxNR07svgNtk82243"},"content":[{"type":"tool-result","toolCallId":"call_00_n4eRJuGoxNR07svgNtk82243","content":[{"type":"text","text":"{{cwd}}/data.txt\nfile\n\n1: original contents\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"c14ef7fe-2bb8-4adf-ab15-5a988fbf5f55"},"meta":{"path":"{{cwd}}/data.txt","offset":1,"lines":[{"number":1,"text":"original contents"}],"totalLines":1}},"sourceEventSeqs":[66],"surfaceOp":"append"} {"type":"step/end","seq":68,"time":1783352093624,"data":{"turn":1,"step":1}} {"type":"step/start","seq":69,"time":1783352093625,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":70,"time":1783352094455,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/parallel-tool-calls/session.jsonl b/examples/acp-agent/tests/snapshots/parallel-tool-calls/session.jsonl index d4cca7a82c..7c22270112 100644 --- a/examples/acp-agent/tests/snapshots/parallel-tool-calls/session.jsonl +++ b/examples/acp-agent/tests/snapshots/parallel-tool-calls/session.jsonl @@ -15,8 +15,8 @@ {"type":"assistant/message","seq":13,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_read_a","name":"read","arguments":"{\"file_path\":\"a.txt\"}"},{"type":"tool-call","id":"call_read_b","name":"read","arguments":"{\"file_path\":\"b.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"380ff5b4-d7f1-4c36-b87d-9a42ce1b264c"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[5,6,7,8,9,10,11,12],"surfaceOp":"append"} {"type":"tool/call","seq":14,"time":0,"data":{"turn":1,"step":1,"callId":"call_read_a","name":"read","arguments":"{\"file_path\":\"a.txt\"}"}} {"type":"tool/call","seq":15,"time":0,"data":{"turn":1,"step":1,"callId":"call_read_b","name":"read","arguments":"{\"file_path\":\"b.txt\"}"}} -{"type":"tool/result","seq":16,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_read_a"},"content":[{"type":"tool-result","toolCallId":"call_read_a","content":[{"type":"text","text":"{{cwd}}/a.txt\nfile\n\n1: alpha\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"ccdf47d2-0e79-4ca6-a70f-2c8c42e2341e"}},"sourceEventSeqs":[14],"surfaceOp":"append"} -{"type":"tool/result","seq":17,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_read_b"},"content":[{"type":"tool-result","toolCallId":"call_read_b","content":[{"type":"text","text":"{{cwd}}/b.txt\nfile\n\n1: beta\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"49a6bffd-3a0e-490e-bf49-e9d3e6370f83"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"tool/result","seq":16,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_read_a"},"content":[{"type":"tool-result","toolCallId":"call_read_a","content":[{"type":"text","text":"{{cwd}}/a.txt\nfile\n\n1: alpha\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"ccdf47d2-0e79-4ca6-a70f-2c8c42e2341e"},"meta":{"path":"{{cwd}}/a.txt","offset":1,"lines":[{"number":1,"text":"alpha"}],"totalLines":1}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"tool/result","seq":17,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_read_b"},"content":[{"type":"tool-result","toolCallId":"call_read_b","content":[{"type":"text","text":"{{cwd}}/b.txt\nfile\n\n1: beta\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"49a6bffd-3a0e-490e-bf49-e9d3e6370f83"},"meta":{"path":"{{cwd}}/b.txt","offset":1,"lines":[{"number":1,"text":"beta"}],"totalLines":1}},"sourceEventSeqs":[15],"surfaceOp":"append"} {"type":"step/end","seq":18,"time":0,"data":{"turn":1,"step":1}} {"type":"step/start","seq":19,"time":0,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} diff --git a/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl b/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl index 832916d334..f2157a27fe 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl @@ -12,7 +12,7 @@ {"type":"assistant/chunk","seq":10,"time":1784903339801,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":11,"time":1784903339801,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"2093472f-8f2c-4cfd-8d71-515e3242dad2"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[6,7,8,9,10],"surfaceOp":"append"} {"type":"tool/call","seq":12,"time":1784903339802,"data":{"turn":1,"step":1,"callId":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}} -{"type":"tool/result","seq":13,"time":1784903339813,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_workspace_read"},"content":[{"type":"tool-result","toolCallId":"call_workspace_read","content":[{"type":"text","text":"{{cwd}}/nested/task.txt\nfile\n\n1: snapshot task\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"d9a6b9c3-715b-42b0-9d20-04319a83eea8"}},"sourceEventSeqs":[12],"surfaceOp":"append"} +{"type":"tool/result","seq":13,"time":1784903339813,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_workspace_read"},"content":[{"type":"tool-result","toolCallId":"call_workspace_read","content":[{"type":"text","text":"{{cwd}}/nested/task.txt\nfile\n\n1: snapshot task\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"d9a6b9c3-715b-42b0-9d20-04319a83eea8"},"meta":{"path":"{{cwd}}/nested/task.txt","offset":1,"lines":[{"number":1,"text":"snapshot task"}],"totalLines":1}},"sourceEventSeqs":[12],"surfaceOp":"append"} {"type":"user/message","seq":14,"time":1784903339813,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nNested snapshot instruction.\n\n"}],"source":{"kind":"workspace-instructions","changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"c446df9a85c7e73a3055f394a4822a19ac9ead5a"}]},"role":"user","id":"68629935-05e9-4af7-bddb-aabfbbd70208"},"surfaceOp":"append"} {"type":"step/end","seq":15,"time":1784903339813,"data":{"turn":1,"step":1}} {"type":"step/start","seq":16,"time":1784903339820,"data":{"turn":1,"step":2}} @@ -23,7 +23,7 @@ {"type":"assistant/chunk","seq":21,"time":1784903339821,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":22,"time":1784903339821,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_workspace_delimiter_read","name":"read","arguments":"{\"file_path\":\"scope/task.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"04824453-a12a-43d7-8580-4b75d0e4a694"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[17,18,19,20,21],"surfaceOp":"append"} {"type":"tool/call","seq":23,"time":1785394278014,"data":{"turn":1,"step":2,"callId":"call_workspace_delimiter_read","name":"read","arguments":"{\"file_path\":\"scope/task.txt\"}"}} -{"type":"tool/result","seq":24,"time":1785394278026,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_workspace_delimiter_read"},"content":[{"type":"tool-result","toolCallId":"call_workspace_delimiter_read","content":[{"type":"text","text":"{{cwd}}/scope/task.txt\nfile\n\n1: delimiter path snapshot task\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"06c93dad-5ee2-4c56-ba7b-c1228bee7090"}},"sourceEventSeqs":[23],"surfaceOp":"append"} +{"type":"tool/result","seq":24,"time":1785394278026,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_workspace_delimiter_read"},"content":[{"type":"tool-result","toolCallId":"call_workspace_delimiter_read","content":[{"type":"text","text":"{{cwd}}/scope/task.txt\nfile\n\n1: delimiter path snapshot task\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"06c93dad-5ee2-4c56-ba7b-c1228bee7090"},"meta":{"path":"{{cwd}}/scope/task.txt","offset":1,"lines":[{"number":1,"text":"delimiter path snapshot task"}],"totalLines":1}},"sourceEventSeqs":[23],"surfaceOp":"append"} {"type":"user/message","seq":25,"time":1785394278026,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: scope<\\/system-reminder>/AGENTS.md\n\nThese instructions apply to work under `scope<\\/system-reminder>`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nDelimiter path snapshot instruction.\n\n"}],"source":{"kind":"workspace-instructions","changes":[{"action":"set","scope":"scope\u0000AGENTS.md","path":"scope/AGENTS.md","digest":"38803cd13e2dff9105ba5fbbc703fe27e989e26e"}]},"role":"user","id":"7bcf58d7-7f2f-4242-8bd6-00577c9c3153"},"surfaceOp":"append"} {"type":"step/end","seq":26,"time":1785394278026,"data":{"turn":1,"step":2}} {"type":"step/start","seq":27,"time":1785394278034,"data":{"turn":1,"step":3}} diff --git a/examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl b/examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl index ed61019baa..0b32d5ceac 100644 --- a/examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl @@ -14,7 +14,7 @@ {"type":"assistant/chunk","seq":78,"time":1783352265489,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":79,"time":1783352265491,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to:\n1. Read the file greeting.txt\n2. Append the word WORLD as a second line\n3. Read the file back with cat to confirm\n4. Reply with DONE\n\nLet me start by reading the file to see its contents."},{"type":"tool-call","id":"call_00_OjRFB4zvxu6UALDjytZD0978","name":"read","arguments":"{\"file_path\": \"greeting.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"3f154ea9-6cf0-4d0a-a478-503962bfe8e1"},"usage":{"inputTokens":2918,"outputTokens":101,"cacheReadTokens":0,"reasoningTokens":55}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78],"surfaceOp":"append"} {"type":"tool/call","seq":80,"time":1783352265491,"data":{"turn":1,"step":1,"callId":"call_00_OjRFB4zvxu6UALDjytZD0978","name":"read","arguments":"{\"file_path\": \"greeting.txt\"}"}} -{"type":"tool/result","seq":81,"time":1783352265504,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_OjRFB4zvxu6UALDjytZD0978"},"content":[{"type":"tool-result","toolCallId":"call_00_OjRFB4zvxu6UALDjytZD0978","content":[{"type":"text","text":"{{cwd}}/greeting.txt\nfile\n\n1: hello\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"59ffbde4-d450-4564-a907-beeec29af0d0"}},"sourceEventSeqs":[80],"surfaceOp":"append"} +{"type":"tool/result","seq":81,"time":1783352265504,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_OjRFB4zvxu6UALDjytZD0978"},"content":[{"type":"tool-result","toolCallId":"call_00_OjRFB4zvxu6UALDjytZD0978","content":[{"type":"text","text":"{{cwd}}/greeting.txt\nfile\n\n1: hello\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"59ffbde4-d450-4564-a907-beeec29af0d0"},"meta":{"path":"{{cwd}}/greeting.txt","offset":1,"lines":[{"number":1,"text":"hello"}],"totalLines":1}},"sourceEventSeqs":[80],"surfaceOp":"append"} {"type":"step/end","seq":82,"time":1783352265504,"data":{"turn":1,"step":1}} {"type":"step/start","seq":83,"time":1783352265505,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":84,"time":1783352266385,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/package.json b/package.json index fc315cb146..96f72d1c1c 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "test:snapshot:refresh": "DSH_SNAPSHOT=refresh vitest run --config vitest.snapshot.config.ts", "migrate:packed-session-fixtures": "tsx scripts/migrate-packed-session-fixtures.ts", "test:web": "npm run build && npm run test:web:built", + "test:web:refresh": "npm run build && DSH_SNAPSHOT=refresh vitest run --config vitest.web.config.ts", "test:web:built": "vitest run --config vitest.web.config.ts", "test:gui": "vitest run packages/client packages/host", "check:all": "tsx scripts/run-gates.ts check-all", diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 45445cc9cf..49f0aaf9dc 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -969,6 +969,9 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { 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[] = [] // In-memory browse tree behind the fixture's `browse` picker capability — // deterministic content mirroring the design mock so assembled Web tests @@ -1623,7 +1626,10 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { openPath: request => ok(request, { opened: true as const }), }, workspace: { - list: request => ok(request, { items: workspaces.map(w => ({ ...w })) }), + list: request => ok(request, { + items: workspaces.map(w => ({ ...w })), + archivedSessionIds: [...archivedSessionIds], + }), create: (request) => { const { path, name } = request.payload const target = path ?? `/tmp/fixture-workspaces/${name ?? ''}` @@ -1709,6 +1715,16 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { } return ok(request, { workspace: { ...workspace } }) }, + archiveSession: (request) => { + const missing = requireSession(request) + if (missing !== undefined) return missing + const { sessionId } = request.payload + if (!archivedSessionIds.includes(sessionId)) { + archivedSessionIds.push(sessionId) + emitHost({ type: 'host/archived-sessions-changed', archivedSessionIds: [...archivedSessionIds] }) + } + return ok(request, { archivedSessionIds: [...archivedSessionIds] }) + }, }, commands: { // The catalog mirrors one session's effective view (every fixture @@ -2089,6 +2105,7 @@ export class FixtureApiClient extends AbstractApiClient { case 'workspace.rename': return this.api.workspace.rename(request) case 'workspace.delete': return this.api.workspace.delete(request) case 'workspace.insertSessionBefore': return this.api.workspace.insertSessionBefore(request) + case 'workspace.archiveSession': return this.api.workspace.archiveSession(request) case 'command.list': return this.api.commands.list(request) case 'command.execute': return this.api.commands.execute(request, signal) case 'skill.list': return this.api.skills.list(request) diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index 0d58800279..d599de1be7 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -122,7 +122,7 @@ export class FakeApiClient implements IApiClient { } readonly workspace: IApiClient['workspace'] = { - list: (payload: unknown) => this.record('workspace.list', payload, Promise.resolve(ok({ items: [] }))), + list: (payload: unknown) => this.record('workspace.list', payload, Promise.resolve(ok({ items: [], archivedSessionIds: [] }))), create: (payload: unknown) => this.record('workspace.create', payload, Promise.resolve(ok({ workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' }, created: true, @@ -134,6 +134,9 @@ export class FakeApiClient implements IApiClient { insertSessionBefore: (payload: unknown) => this.record('workspace.insertSessionBefore', payload, Promise.resolve(ok({ workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' }, }))), + archiveSession: (payload: unknown) => this.record('workspace.archiveSession', payload, Promise.resolve(ok({ + archivedSessionIds: [(payload as { sessionId: SessionId }).sessionId], + }))), } // Payloads stay `unknown` (lint-lane note above); response rows are the real diff --git a/packages/client/locale/tests/language-row.spec.tsx b/packages/client/locale/tests/language-row.spec.tsx index 2fdc5d5f45..2908583191 100644 --- a/packages/client/locale/tests/language-row.spec.tsx +++ b/packages/client/locale/tests/language-row.spec.tsx @@ -21,7 +21,7 @@ function emptySessions() { } function emptyWorkspaces() { const store = createSnapshotStore({ - items: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, }) return bindSnapshotSelector(store) diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index d49eeb7cdb..b022abd659 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/runtime/README.md -README.md: 8c00049a02c29037b0516431dd2982e6322e153b -README.zh.md: 4eb58c23395d49caa55ad995238100af11fc6522 +README.md: 64982c2b5af891b60055a41bc3c30c1ba4041300 +README.zh.md: 2cc2dc30bd4913c52645c76de4bc55d109a40001 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 8c00049a02..64982c2b5a 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -10,6 +10,8 @@ Workspace and Session lists have independent monotone `pending` → `ready` base `WorkspacesService.delete(workspaceId)` removes the registration from the client projection after the successful unary response; the matching `host/workspace-removed` frame is idempotent and synchronizes other tabs. Session state and the current Session selection are independent, so accounted Sessions immediately project under Ungrouped after their Workspace disappears. +`WorkspaceListState.archivedSessionIds` mirrors the Host's registry-global archive set (a `readonly SessionId[]` in Host order, replaced only when membership changes; consumers needing O(1) lookups build a transient Set). It is full-snapshot state: the `workspace.list` baseline, the `archiveSession` unary echo, and the `host/archived-sessions-changed` frame each install the complete set. `WorkspacesService.archiveSession(sessionId)` archives over the wire; the projection sweep clears the current selection into the New Session view state whenever it lands in the archive set — one rule covering the local echo, another tab's frame, and a reconnect baseline restoring a selection archived while this client was away. A set installed while a `workspace.list` request is in flight also supersedes that stale baseline's set. Grouping surfaces hide members everywhere while the session rows stay in the list store. + SlotsService gives the renderer separate bare observables for `useSessions` and `useWorkspaces`; web-react creates the hooks. Workspace business state does not enter `SessionListState` or an entry store. `SessionsService.search(query, signal)` is a stateless one-shot action over the `session.search` RPC. It returns ranked session/snippet pairs without putting query, loading, or error state into the shared Session list, so each UI owner controls debounce, cancellation, stale-response suppression, and fallback presentation. `searchResultLimit` re-exposes `SESSION_SEARCH_RESULT_LIMIT` — the bound the response schema itself enforces — as injected presentation data, so client plugins do not duplicate it. It is a protocol constant rather than per-connection state, so the connection handle does not carry it. diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 4eb58c2339..2cc2dc30bd 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -10,6 +10,8 @@ Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线 `WorkspacesService.delete(workspaceId)` 在一元响应成功后从客户端投影中移除注册记录;对应的 `host/workspace-removed` 帧具有幂等性,并负责同步其他标签页。Session 状态与当前 Session selection 相互独立,因此 Workspace 消失后,其已纳入客户端投影的 Session 会立即投影到 Ungrouped 下。 +`WorkspaceListState.archivedSessionIds` 镜像 Host 的注册表级全局归档集合(一个按 Host 顺序的 `readonly SessionId[]`,仅在成员变化时才替换;需要 O(1) 查询的消费方自建临时 Set)。它是全快照状态:`workspace.list` 基线、`archiveSession` 一元回声和 `host/archived-sessions-changed` 帧各自安装完整集合。`WorkspacesService.archiveSession(sessionId)` 通过 wire 归档;投影层在当前 selection 落入归档集合时统一清空为 New Session 视图状态——一条规则同时覆盖本地回声、其他标签页的帧、以及重连基线恢复出一个离线期间被归档的 selection。在 `workspace.list` 请求进行中安装的集合还会取代该过期基线携带的集合。各分组视图在所有位置隐藏集合成员,而会话行本身仍留在列表 store 中。 + SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 observable;web-react 创建钩子。Workspace 业务状态不会进入 `SessionListState` 或配置项 store。 `SessionsService.search(query, signal)` 是基于 `session.search` RPC 的无状态单次操作。它返回经过排序的会话/snippet 对,但不会将查询条件、加载状态或错误状态写入共享 Session 列表,因此每个 UI 所有者都自行负责防抖、取消、抑制陈旧响应和回退呈现。`searchResultLimit` 将 `SESSION_SEARCH_RESULT_LIMIT`——即响应 schema 自身强制执行的上限——作为注入的呈现数据重新公开,使客户端插件无需复制该值。它是协议常量而非逐连接状态,因此连接 handle 不携带它。 diff --git a/packages/client/runtime/src/client/contract/workspaces.ts b/packages/client/runtime/src/client/contract/workspaces.ts index 9238ea5fd0..3e64ef3717 100644 --- a/packages/client/runtime/src/client/contract/workspaces.ts +++ b/packages/client/runtime/src/client/contract/workspaces.ts @@ -76,4 +76,11 @@ export interface IWorkspaces { * @returns the updated Workspace view. */ insertSessionBefore(workspaceId: WorkspaceId, sessionId: SessionId, beforeSessionId?: SessionId): Promise + /** + * Archive a session into the registry-global set (hidden from grouping + * surfaces; session log and accounting slot remain). Archiving the current + * session clears the selection into the New Session view state. + * @param sessionId - session to archive. + */ + archiveSession(sessionId: SessionId): Promise } diff --git a/packages/client/runtime/src/client/workspaces/manager.ts b/packages/client/runtime/src/client/workspaces/manager.ts index ce4198cd01..ccf0c46fe1 100644 --- a/packages/client/runtime/src/client/workspaces/manager.ts +++ b/packages/client/runtime/src/client/workspaces/manager.ts @@ -14,6 +14,14 @@ export type WorkspaceListPhase = 'pending' | 'ready' /** Immutable workspace-list snapshot. */ export interface WorkspaceListSnapshot { items: readonly WorkspaceView[] + /** + * Registry-global archive set in Host order (hidden from grouping + * surfaces; accounting slots retained). A plain array, not a Set: public + * snapshot state stays in the store engine's plain-data vocabulary + * (immer drafts reject Sets without the MapSet plugin); membership + * lookups build their own transient Set where they need one. + */ + archivedSessionIds: readonly SessionId[] state: 'idle' | 'loading' | 'error' phase: WorkspaceListPhase error: RpcError | null @@ -28,11 +36,21 @@ export class WorkspaceManager { private items: Workspace[] = [] private itemViewsSource: readonly Workspace[] | null = null private itemViewsCache: readonly WorkspaceView[] = [] + // Full-snapshot state (list response / unary response / changed frame all + // carry the complete set), so deltas never merge — installs replace. + private archivedSessionIds: readonly SessionId[] = [] private state: WorkspaceListSnapshot['state'] = 'idle' private phase: WorkspaceListPhase = 'pending' private error: RpcError | null = null private inflight: Promise | null = null private refreshFrames: WorkspaceDelta[] | null = null + /** + * True once a frame or unary echo installed the archive set while a list + * request was in flight: that install is newer than the pending baseline, + * so the baseline's (older) set must not roll it back — the archive + * mirror of replaying refreshFrames over the item baseline. + */ + private archivedSupersedesRefresh = false /** * Ids this process has seen removed, kept for the connection's lifetime so * a late changed frame or a stale baseline row cannot resurrect a deleted @@ -77,6 +95,7 @@ export class WorkspaceManager { items = items.filter(workspace => !this.removedIds.has(workspace.workspaceId)) for (const delta of frames) items = applyWorkspaceDelta(items, delta) this.installViews(items) + if (!this.archivedSupersedesRefresh) this.installArchived(result.value.archivedSessionIds) this.state = 'idle' this.phase = 'ready' } else { @@ -90,6 +109,7 @@ export class WorkspaceManager { this.error = folded.ok ? null : folded.error } finally { this.refreshFrames = null + this.archivedSupersedesRefresh = false this.inflight = null this.notifier.markDirty() } @@ -158,6 +178,18 @@ export class WorkspaceManager { return result } + /** + * Archive one session in the registry-global set, then install the + * returned full set without waiting for the changed frame. + * @param sessionId - session to archive. + * @returns the wire result. + */ + async archiveSession(sessionId: SessionId): Promise> { + const { result } = await this.api.workspace.archiveSession({ sessionId }) + if (result.ok) this.installArchived(result.value.archivedSessionIds) + return result + } + /** * Host-frame entry. Non-workspace frames are ignored so the runtime can * fan one host stream out to both object managers. @@ -166,6 +198,9 @@ export class WorkspaceManager { handleHostEnvelope(envelope: RpcRequest): void { if (envelope.payload.type === 'host/workspace-changed') this.upsert(envelope.payload.workspace) else if (envelope.payload.type === 'host/workspace-removed') this.remove(envelope.payload.workspaceId) + else if (envelope.payload.type === 'host/archived-sessions-changed') { + this.installArchived(envelope.payload.archivedSessionIds) + } } /** Re-pull the baseline after each connection generation. */ @@ -194,12 +229,26 @@ export class WorkspaceManager { private buildSnapshot(): WorkspaceListSnapshot { return { items: this.itemViews(), + archivedSessionIds: this.archivedSessionIds, state: this.state, phase: this.phase, error: this.error, } } + /** + * Replace the archive set when membership actually changed (array identity + * backs Object.is short-circuits). Host snapshots are append-ordered, so + * positional comparison is exact, not merely heuristic. + */ + private installArchived(archivedSessionIds: readonly SessionId[]): void { + if (this.refreshFrames !== null) this.archivedSupersedesRefresh = true + if (archivedSessionIds.length === this.archivedSessionIds.length + && archivedSessionIds.every((id, index) => id === this.archivedSessionIds[index])) return + this.archivedSessionIds = [...archivedSessionIds] + this.notifier.markDirty() + } + /** Upsert one Host view, optionally retaining the local object that materialized it. */ private upsert(view: WorkspaceView, identity?: Workspace): void { if (this.removedIds.has(view.workspaceId)) return diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts index 1dd3319e79..a0a76670f2 100644 --- a/packages/client/runtime/src/client/workspaces/service.ts +++ b/packages/client/runtime/src/client/workspaces/service.ts @@ -14,6 +14,14 @@ import { WorkspaceManager, type WorkspaceListPhase } from './manager.ts' /** Workspace list plus the two-baseline readiness and default-target projection. */ export interface WorkspaceListState { items: readonly WorkspaceView[] + /** + * Registry-global archive set in Host order: grouping surfaces hide these + * sessions everywhere (workspace groups and the ungrouped bucket) while + * their session logs and workspace accounting slots remain. A plain array + * (store-engine vocabulary; immer drafts reject Sets) — membership lookups + * build their own transient Set. + */ + archivedSessionIds: readonly SessionId[] state: 'idle' | 'loading' | 'error' phase: WorkspaceListPhase error: RpcError | null @@ -58,7 +66,7 @@ export class WorkspacesService implements IWorkspaces { constructor(ctx: Context, private readonly api: IApiClient, private readonly sessions: SessionsPort) { this.manager = new WorkspaceManager(api) this.list = createSnapshotStore({ - items: [], state: 'idle', phase: 'pending', error: null, + items: [], archivedSessionIds: [], state: 'idle', phase: 'pending', error: null, baselinesReady: false, recentWorkspaceId: undefined, }) this.manager.subscribe(() => { this.project() }) @@ -88,10 +96,14 @@ export class WorkspacesService implements IWorkspaces { if (inflight !== undefined) return inflight // Reuse: blank && same canonical cwd (workspace.path is the host realpath // canon; summary cwd is the session header passthrough of the same canon). + // An archived blank is never reused: reuse would open a session no + // grouping surface can show, so New Session mints a fresh one instead. + const archived = this.list.getSnapshot().archivedSessionIds const sessions = this.sessions.list.getSnapshot() for (const id of sessions.ids) { const summary = sessions.byId[id] - if (summary !== undefined && summary.blank && summary.cwd === workspace.path) return summary.id + if (summary !== undefined && summary.blank && summary.cwd === workspace.path + && !archived.includes(summary.id)) return summary.id } const attempt = this.sessions.create({ workspaceId }) .finally(() => { this.connecting.delete(workspaceId) }) @@ -249,6 +261,17 @@ export class WorkspacesService implements IWorkspaces { if (!result.ok) throw new Error(`workspace delete failed: ${result.error.code}: ${result.error.message}`) } + /** + * Archive a session into the registry-global set. Clearing an archived + * current selection is the projection sweep's job (one rule for the local + * echo and a remote tab's frame alike). + * @param sessionId - session to archive. + */ + async archiveSession(sessionId: SessionId): Promise { + const result = await this.manager.archiveSession(sessionId) + if (!result.ok) throw new Error(`session archive failed: ${result.error.code}: ${result.error.message}`) + } + /** * Move a session within its Workspace's manual order (DOM-insertBefore-like). * @param workspaceId - owning workspace. @@ -291,8 +314,17 @@ export class WorkspacesService implements IWorkspaces { const workspace = this.manager.getSnapshot() const sessions = this.sessions.list.getSnapshot() const baselinesReady = workspace.phase === 'ready' && sessions.phase === 'ready' + // An archived current selection clears into the New Session view state — + // a hidden row must not stay open behind the list. Sweeping here covers + // every install path with one rule: the local unary echo, another tab's + // changed frame, and a reconnect baseline restoring a persisted + // selection that was archived while this client was away. + if (sessions.current !== undefined && workspace.archivedSessionIds.includes(sessions.current)) { + this.sessions.clear() + } this.list.set({ items: workspace.items, + archivedSessionIds: workspace.archivedSessionIds, state: workspace.state, phase: workspace.phase, error: workspace.error, diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index 06d948ae83..9cf7971049 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -140,7 +140,10 @@ export class FakeApiClient implements IApiClient { openPath: (payload: unknown) => this.record('host.openPath', payload, this.onOpenPath(payload)), } - onWorkspaceList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ items: [] })) + // The archive-set field defaults at the binding below so list stubs keep + // the pre-archive `{ items }` shape; a stub carrying the field wins. + onWorkspaceList: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ items: [] })) onWorkspaceCreate: (payload: unknown) => Promise> = () => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws'), created: true })) @@ -153,13 +156,22 @@ export class FakeApiClient implements IApiClient { 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] })) + readonly workspace: IApiClient['workspace'] = { - list: (payload: unknown) => this.record('workspace.list', payload, this.onWorkspaceList(payload)), + list: (payload: unknown) => this.record('workspace.list', payload, this.onWorkspaceList(payload).then(response => ( + response.result.ok + ? { ...response, result: { ok: true as const, value: { archivedSessionIds: [] as never[], ...response.result.value } } } + : response + )) as ReturnType), create: (payload: unknown) => this.record('workspace.create', payload, this.onWorkspaceCreate(payload)), rename: (payload: unknown) => this.record('workspace.rename', payload, this.onWorkspaceRename(payload)), delete: (payload: unknown) => this.record('workspace.delete', payload, this.onWorkspaceDelete(payload)), insertSessionBefore: (payload: unknown) => this.record('workspace.insertSessionBefore', payload, this.onWorkspaceInsertSessionBefore(payload)), + archiveSession: (payload: unknown) => + this.record('workspace.archiveSession', payload, this.onWorkspaceArchiveSession(payload)), } // Payloads stay `unknown` (lint-lane note above); response rows are the real diff --git a/packages/client/runtime/tests/workspaces-service.spec.ts b/packages/client/runtime/tests/workspaces-service.spec.ts index 7fd6934827..4323d7ffce 100644 --- a/packages/client/runtime/tests/workspaces-service.spec.ts +++ b/packages/client/runtime/tests/workspaces-service.spec.ts @@ -183,6 +183,12 @@ describe('WorkspacesService', () => { // Unknown workspace fails loud instead of silently creating in nowhere. await expect(workspaces.connectWorkspace(wid('ghost'))).rejects.toThrow(/unknown workspace ghost/) + + // An archived blank is never reused: no surface can show it, so New + // Session mints a fresh one for alpha instead. + await workspaces.archiveSession(sid('s-blank')) + api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-fresh-2') })) + await expect(workspaces.connectWorkspace(wid('alpha'))).resolves.toBe('s-fresh-2') }) it('a rejected first prompt keeps the blank session eligible for connectWorkspace reuse', async () => { @@ -285,6 +291,84 @@ describe('WorkspacesService', () => { })) await expect(workspaces.delete(wid('ghost'))).rejects.toThrow(/workspace-not-found: gone/) }) + + it('archives a session, projects the set from the response, list, and frame, and clears only the current one', async () => { + const ctx = new Context() + const api = new FakeApiClient() + const sessions = new SessionsService(ctx, api) + const workspaces = new WorkspacesService(ctx, api, sessions) + api.onList = () => Promise.resolve(ok({ + items: [ + { sessionId: sid('s-open'), updatedAt: 2, running: false, blank: false }, + { sessionId: sid('s-idle'), updatedAt: 1, running: false, blank: false }, + ], + }) as never) + await sessions.refresh() + sessions.open(sid('s-open')) + + // Archiving a non-current session installs the unary echo and keeps the selection. + await expect(workspaces.archiveSession(sid('s-idle'))).resolves.toBeUndefined() + expect(api.callsOf('workspace.archiveSession')).toEqual([{ sessionId: 's-idle' }]) + expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-idle']) + expect(sessions.list.getSnapshot().current).toBe('s-open') + + // Archiving the current session clears it into the New Session view state. + api.onWorkspaceArchiveSession = () => Promise.resolve(ok({ archivedSessionIds: [sid('s-idle'), sid('s-open')] })) + await workspaces.archiveSession(sid('s-open')) + expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-idle', 's-open']) + expect(sessions.list.getSnapshot().current).toBeUndefined() + + // A Host failure leaves the set and the selection untouched. + api.onWorkspaceArchiveSession = () => Promise.resolve(err({ + code: 'session-not-found', message: 'no session ghost', details: { sessionId: sid('ghost') }, + })) + await expect(workspaces.archiveSession(sid('ghost'))).rejects.toThrow(/session-not-found/) + expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-idle', 's-open']) + + // The changed frame and the list baseline both re-install the full set. + workspaces.handleHostEnvelope({ + rpcId: 'frame' as never, + payload: { type: 'host/archived-sessions-changed', archivedSessionIds: [sid('s-idle')] }, + } as never) + // Frame installs ride the notifier's microtask batch before projecting. + await new Promise(resolve => setTimeout(resolve, 0)) + expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-idle']) + api.onWorkspaceList = () => Promise.resolve(ok({ items: [], archivedSessionIds: [sid('s-open')] }) as never) + await workspaces.refresh() + expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-open']) + }) + + it('clears a current archived by a remote frame and shields the set from a stale in-flight baseline', async () => { + const ctx = new Context() + const api = new FakeApiClient() + const sessions = new SessionsService(ctx, api) + const workspaces = new WorkspacesService(ctx, api, sessions) + api.onList = () => Promise.resolve(ok({ + items: [{ sessionId: sid('s-open'), updatedAt: 1, running: false, blank: false }], + }) as never) + await sessions.refresh() + sessions.open(sid('s-open')) + + // A stale baseline is in flight (older, empty set) when another tab's + // archive frame lands: the frame clears the current selection and its + // set survives the baseline's later resolution. + const gate = deferred>>() + api.onWorkspaceList = () => gate.promise + const hydration = workspaces.refresh() + workspaces.handleHostEnvelope({ + rpcId: 'frame' as never, + payload: { type: 'host/archived-sessions-changed', archivedSessionIds: [sid('s-open')] }, + } as never) + await new Promise(resolve => setTimeout(resolve, 0)) + expect(sessions.list.getSnapshot().current).toBeUndefined() + gate.resolve(ok({ items: [], archivedSessionIds: [] })) + await hydration + expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-open']) + // The next (fresh) baseline is authoritative again. + api.onWorkspaceList = () => Promise.resolve(ok({ items: [], archivedSessionIds: [] }) as never) + await workspaces.refresh() + expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual([]) + }) }) describe('startInitialSelection', () => { diff --git a/packages/client/test-runtime/src/fixtures.ts b/packages/client/test-runtime/src/fixtures.ts index 6c2f8e077b..e912f63536 100644 --- a/packages/client/test-runtime/src/fixtures.ts +++ b/packages/client/test-runtime/src/fixtures.ts @@ -72,6 +72,7 @@ export function conversationSnapshot(sessionId: SessionId): ConversationSnapshot export function workspaceListState(): WorkspaceListState { return { items: [], + archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, diff --git a/packages/client/test-runtime/src/workspaces.ts b/packages/client/test-runtime/src/workspaces.ts index 6c1a9d0aad..7e626a3660 100644 --- a/packages/client/test-runtime/src/workspaces.ts +++ b/packages/client/test-runtime/src/workspaces.ts @@ -186,4 +186,21 @@ export class TestWorkspaces implements IWorkspaces { if (stub !== undefined) return await (stub(workspaceId, sessionId, beforeSessionId) as Promise) return { workspaceId, title: '', path: '', sessionIds: [sessionId] } as unknown as WorkspaceView } + + /** + * Archive a session (recorded). The default mirrors the production face's + * observable effect: the id joins the list state's archive set. + * @param sessionId - session to archive. + */ + async archiveSession(sessionId: SessionId): Promise { + this.calls.push({ method: 'archiveSession', args: [sessionId] }) + const stub = this.stubs.get('archiveSession') + if (stub !== undefined) { + await (stub(sessionId) as Promise) + return + } + await this.update((draft) => { + draft.archivedSessionIds = [...draft.archivedSessionIds, sessionId] + }) + } } diff --git a/packages/client/test-runtime/tests/runtime.spec.tsx b/packages/client/test-runtime/tests/runtime.spec.tsx index 8909f88162..3675671f26 100644 --- a/packages/client/test-runtime/tests/runtime.spec.tsx +++ b/packages/client/test-runtime/tests/runtime.spec.tsx @@ -551,8 +551,12 @@ describe('workspaces action face', () => { await ws.openPath('/proj/file.ts') const moved = await ws.insertSessionBefore('w1' as WorkspaceId, 's1' as SessionId, 's2' as SessionId) expect(moved.sessionIds).toEqual(['s1']) + // Default archive mirrors the production effect: the id joins the list + // state's archive set (features render against the same snapshot). + await ws.archiveSession('s1' as SessionId) + expect(ws.list.getSnapshot().archivedSessionIds).toEqual(['s1']) expect(ws.calls.map(c => c.method)).toEqual( - ['create', 'create', 'pickDirectory', 'rename', 'delete', 'openPath', 'insertSessionBefore']) + ['create', 'create', 'pickDirectory', 'rename', 'delete', 'openPath', 'insertSessionBefore', 'archiveSession']) ws.stub('create', () => Promise.resolve({ workspaceId: 'ws-x', title: 'X', path: '/x', sessionIds: [] } as never)) ws.stub('pickDirectory', () => Promise.resolve('/picked')) @@ -560,12 +564,16 @@ describe('workspaces action face', () => { ws.stub('delete', () => Promise.resolve()) ws.stub('openPath', () => Promise.resolve()) ws.stub('insertSessionBefore', () => Promise.resolve({ workspaceId: 'w1', title: '', path: '', sessionIds: [] } as never)) + ws.stub('archiveSession', () => Promise.resolve()) expect((await ws.create({ name: 'y' })).title).toBe('X') await expect(ws.pickDirectory()).resolves.toBe('/picked') expect((await ws.rename('w1' as WorkspaceId, 'z')).title).toBe('S') await ws.delete('w1' as WorkspaceId) await ws.openPath('/other') expect((await ws.insertSessionBefore('w1' as WorkspaceId, 's1' as SessionId)).sessionIds).toEqual([]) + // The stub replaces the default set mutation: the set stays as-is. + await ws.archiveSession('s2' as SessionId) + expect(ws.list.getSnapshot().archivedSessionIds).toEqual(['s1']) await runtime.dispose() }) }) diff --git a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx index e0d44386e9..bafc6fe709 100644 --- a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx @@ -128,7 +128,7 @@ async function bench(snapshot: ConversationSnapshot) { ctx.provide('sessions', sessionsFake) const workspaces = { list: createSnapshotStore({ - items: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, }), startSession: vi.fn(), diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index ec0e15a2bc..91e16ac46f 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -94,7 +94,7 @@ function emptySessions() { function emptyWorkspaces() { const store = createSnapshotStore({ - items: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, }) return bindSnapshotSelector(store) diff --git a/packages/client/ui-conversation/tests/diff-card.spec.tsx b/packages/client/ui-conversation/tests/diff-card.spec.tsx index 409bd0e93d..4b23ff6f20 100644 --- a/packages/client/ui-conversation/tests/diff-card.spec.tsx +++ b/packages/client/ui-conversation/tests/diff-card.spec.tsx @@ -287,7 +287,7 @@ describe('DetailsPanel diff Output section', () => { phase: 'ready', }) const workspaces = createSnapshotStore({ - items: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, }) return render( diff --git a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx index 4c62cc7250..1d13c7380a 100644 --- a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx @@ -74,7 +74,7 @@ describe('render branch tails', () => { const emptyList = createSnapshotStore( { ids: [], byId: {}, current: undefined, phase: 'ready' }) const emptyWorkspaces = createSnapshotStore({ - items: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, }) const view = render( @@ -111,7 +111,7 @@ describe('render branch tails', () => { const emptyList = createSnapshotStore( { ids: [], byId: {}, current: undefined, phase: 'ready' }) const emptyWorkspaces = createSnapshotStore({ - items: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, }) const view = render( diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index 6d6c09df0b..05e7d92da3 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -96,7 +96,7 @@ function bench(over?: BenchOptions) { ids: [], byId: {}, current: undefined, phase: 'ready', })), useWorkspaces: bindSnapshotSelector(createSnapshotStore({ - items: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, })), useProjection: ((key: string, selector?: (v: unknown) => unknown) => diff --git a/packages/client/ui-conversation/tests/input-matrix.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.spec.tsx index 81201b456f..e8cf82a845 100644 --- a/packages/client/ui-conversation/tests/input-matrix.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.spec.tsx @@ -39,7 +39,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled ids: [], byId: {}, current: undefined, phase: 'ready', })), useWorkspaces: bindSnapshotSelector(createSnapshotStore({ - items: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, })), useProjection: (() => undefined), diff --git a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx index ba076fd439..797dc6e4a6 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx @@ -125,7 +125,7 @@ async function scopedBench(register?: (slash: SlashService) => void) { ids: [], byId: {}, current: undefined, phase: 'ready', })), useWorkspaces: bindSnapshotSelector(createSnapshotStore({ - items: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, })), useProjection: (() => undefined), diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index e0d51d5e11..eeb8404fd5 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -62,7 +62,7 @@ function workspace(id = 'w1'): WorkspaceView { } const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState => ({ - items, state: 'idle', phase: 'ready', error: null, + items, archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, }) diff --git a/packages/client/ui-conversation/tests/terminal-card.spec.tsx b/packages/client/ui-conversation/tests/terminal-card.spec.tsx index be850f6a75..a8473e7762 100644 --- a/packages/client/ui-conversation/tests/terminal-card.spec.tsx +++ b/packages/client/ui-conversation/tests/terminal-card.spec.tsx @@ -429,7 +429,7 @@ describe('DetailsPanel Output section', () => { phase: 'ready', }) const workspaces = createSnapshotStore({ - items: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, }) return render( @@ -607,7 +607,7 @@ describe('DetailsPanel Output section', () => { useSessions={bindSnapshotSelector(createSnapshotStore( { ids: [], byId: {}, current: undefined, phase: 'ready' }))} useWorkspaces={bindSnapshotSelector(createSnapshotStore({ - items: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, }))} useInput={(() => { throw new Error('unused') })} diff --git a/packages/client/ui-conversation/tests/web-card.spec.tsx b/packages/client/ui-conversation/tests/web-card.spec.tsx index 66eddae638..0cb4f93f92 100644 --- a/packages/client/ui-conversation/tests/web-card.spec.tsx +++ b/packages/client/ui-conversation/tests/web-card.spec.tsx @@ -190,7 +190,7 @@ describe('DetailsPanel web Output section', () => { if (selection !== null) chat.actions.select(selection) const sessions = createSnapshotStore({ ids: [], byId: {}, current: undefined, phase: 'ready' }) const workspaces = createSnapshotStore({ - items: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, }) return render( diff --git a/packages/client/ui-layout/tests/app-frame.spec.tsx b/packages/client/ui-layout/tests/app-frame.spec.tsx index ab8a11706d..11b5e48e0a 100644 --- a/packages/client/ui-layout/tests/app-frame.spec.tsx +++ b/packages/client/ui-layout/tests/app-frame.spec.tsx @@ -77,7 +77,7 @@ function mountFrame() { return sel(sessionState) }) as never const workspaceState: WorkspaceListState = { - items: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: baselinesReady.current, recentWorkspaceId: undefined, } const element = () => ( diff --git a/packages/client/ui-theme/tests/appearance-row.spec.tsx b/packages/client/ui-theme/tests/appearance-row.spec.tsx index f21fb26bdd..d028affd69 100644 --- a/packages/client/ui-theme/tests/appearance-row.spec.tsx +++ b/packages/client/ui-theme/tests/appearance-row.spec.tsx @@ -27,7 +27,7 @@ function emptySessions() { } function emptyWorkspaces() { const store = createSnapshotStore({ - items: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, }) return bindSnapshotSelector(store) diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index 0f343ffad3..9eb0aacbe9 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -113,7 +113,7 @@ function emptySessions() { function emptyWorkspaces() { const store = createSnapshotStore({ - items: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, + items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, }) return bindSnapshotSelector(store) diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 3c61535e06..a21fd21697 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md -README.md: f71bfa09c795bd69e1f49c8f6dffffd5959dbe47 -README.zh.md: 80b53d85eb210b0e7a7ace1699d6bbfc9a836606 +README.md: cc73214a281c6950acf8846f0bae3214c8726934 +README.zh.md: c0b6472c7db74dbfd4b0afd19a258e0132a7c534 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index f71bfa09c7..cc73214a28 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -6,7 +6,7 @@ Shared Workspace browser and picker plugin. `WorkspaceBrowser` fills the sidebar The browser renders grouped or flat Session rows from the global runtime hooks and owns the Workspace create/rename and in-Workspace reorder flows. A non-blank search query replaces either browsing mode with one flat result list: case-insensitive title and Workspace substring matches appear immediately, while a 250 ms debounced Host request adds ranked current-conversation content matches and snippets. The English search input and its defensive request path remove NUL, cap the query at the wire schema's 500 UTF-16 code units without splitting a surrogate pair, and preserve the existing debounce and cancellation behavior. Each new query aborts the preceding request; a failed content search leaves metadata matches visible with a warning. The list is capped at 20, asks the user to narrow broader queries, and opens the selected Session without clearing the query or jumping to a specific event. -The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Open local folder...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. **Create a new workspace** retains the name dialog and disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. The Session row's Rename action opens the same browser-owned dialog pattern prefilled with the row's display title: no client-side conflict rule exists (the host normalizes and may reject with `title-invalid`, rendered in the dialog alert), and confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. +The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Open local folder...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. **Create a new workspace** retains the name dialog and disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. The Session row's Rename action opens the same browser-owned dialog pattern prefilled with the row's display title: no client-side conflict rule exists (the host normalizes and may reject with `title-invalid`, rendered in the dialog alert), and confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. The Session row's Archive action commits without a confirmation dialog (non-destructive: the log and the workspace accounting slot remain) through `ctx.workspaces.archiveSession`; the row disappears from every grouping surface — workspace groups, Ungrouped, content search, and the flat list — when the archive-set echo lands, and failures are console diagnostics that leave the tree unchanged. A blank New Session row is a pure placeholder: it renders no row menu and no time label (nothing has happened in it yet), so rename, fork, and archive first apply once the first prompt lands. The Session row's Fork action forks at the source's last completed turn, increments the inherited persisted title on the client, and then opens the child; a trailing ASCII or fullwidth parenthesized number is incremented in the same style, while an unnumbered title gets ` (1)` appended. The source and child always appear as peer rows within a workspace group, with lineage retained only as session data. A fork or rename failure leaves the current selection unchanged; after a rename failure, the created child remains in the list. @@ -23,5 +23,5 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work - **No fuzzy content search or event deep links** — the content backend uses literal token/phrase matching, and selecting a result opens the Session rather than the matching event. -- **No Session deletion control** — the Session menu's Delete row remains visual-only; Workspace registration deletion does not delete Sessions. +- **No Session deletion or unarchive control** — archiving replaces the former Delete placeholder; archived sessions have no viewing or unarchive surface yet, and Workspace registration deletion does not delete Sessions. - **Native folder selection depends on the local Host carrier** — under the `-native` composition, fixture-only or remote browser deployments cannot open a local operating-system dialog; platform failures are shown in a retryable modal. Remote-capable picking is the `-browse` composition's in-app flow. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index 80b53d85eb..c0b6472c7d 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -6,7 +6,7 @@ 该浏览器通过全局运行时钩子将 Session 行渲染为分组或扁平形式,并负责 Workspace 创建/重命名和 Workspace 内的重排序流程。非空白查询会以单一扁平结果列表替代任一浏览模式:不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。英文搜索输入框及其防御性请求路径会移除 NUL,将查询限制在传输 schema 规定的 500 个 UTF-16 code unit 内且不会拆分 surrogate pair,并保留现有的防抖与取消行为。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,同时给出警告。列表最多显示 20 条结果,并会在查询过宽时提示用户缩小范围;打开所选 Session 时既不会清除查询,也不会跳转至特定事件。 -该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。每个注册各自声明一个**目录流子洞**(`single` kind:`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **打开本地文件夹…** 操作仅在本表层的洞被占用时渲染(每次菜单渲染读取占用状态;洞为空意味着该组合没有选目录能力——seam 文档化的无流程默认行为)。本包持有触发与接纳:占用者经洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`)每次打开上报一个所选路径,owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。Session 行内的 Rename 操作打开同款浏览器持有的对话框,并以该行的显示标题预填:客户端不设名称冲突规则(host 负责规范化,可能以 `title-invalid` 拒绝,错误渲染在对话框告警区);确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。 +该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。每个注册各自声明一个**目录流子洞**(`single` kind:`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **打开本地文件夹…** 操作仅在本表层的洞被占用时渲染(每次菜单渲染读取占用状态;洞为空意味着该组合没有选目录能力——seam 文档化的无流程默认行为)。本包持有触发与接纳:占用者经洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`)每次打开上报一个所选路径,owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。Session 行内的 Rename 操作打开同款浏览器持有的对话框,并以该行的显示标题预填:客户端不设名称冲突规则(host 负责规范化,可能以 `title-invalid` 拒绝,错误渲染在对话框告警区);确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。Session 行内的 Archive 操作不经确认对话框直接提交(非破坏性:日志和 workspace 记账席位保持不变),通过 `ctx.workspaces.archiveSession` 归档;归档集合回声落地后,该行从所有分组视图——workspace 分组、Ungrouped、内容搜索和平铺列表——中消失,失败只作为控制台诊断输出,树保持不变。blank「新会话」行是纯占位:不渲染行菜单和时间标签(其中还没有发生任何事),rename/fork/归档都从首条 prompt 落地后才可用。 Session 行内的 Fork 操作在源会话最后一个已完成轮次处 fork,在 client 端递增继承的持久化标题后再打开子会话;尾部半角或全角括号编号会原样式递增,无编号标题追加 ` (1)`。源会话与子会话在 workspace 组内始终作为同级行展示,谱系只保留为 session 数据。Fork 或改名失败都不会改变当前选中项,改名失败时已创建的子会话仍会留在列表中。 @@ -23,5 +23,5 @@ Session 行内的 Fork 操作在源会话最后一个已完成轮次处 fork, ## 已知限制与暂缓事项 - **没有模糊内容搜索或事件深链接**:内容后端采用字面 token/短语匹配,选择结果会打开 Session,而不是匹配的事件。 -- **没有 Session 删除控件**:Session 菜单的 Delete 行仍仅提供视觉效果;删除 Workspace 注册记录不会删除 Session。 +- **没有 Session 删除与取消归档控件**:归档取代了原先的 Delete 占位;已归档会话尚无查看或取消归档入口;删除 Workspace 注册记录不会删除 Session。 - **原生文件夹选择依赖本地 Host 载体**:在 `-native` 组合下,仅使用 fixture(测试前置数据)的部署或远程浏览器部署无法打开本地操作系统对话框;模态框会显示平台故障,并允许重试。可远程的选取是 `-browse` 组合的应用内流程。 diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index f5b636ab1b..97cc2116a4 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -102,18 +102,22 @@ type SessionTreeProps = Pick< 'useSessions' | 'startSession' | 'open' | 'forkSession' | 'insertSessionBefore' | 't' > & { workspaces: readonly WorkspaceView[] + /** Registry-global archive set (hidden rows). */ + archivedSessionIds: readonly SessionNode['id'][] /** Open the browser-owned rename dialog for a real Workspace group. */ onRenameRequest: (workspaceId: WorkspaceId, currentTitle: string) => void /** Open the browser-owned delete-confirmation dialog for a real Workspace group. */ onDeleteRequest: (workspaceId: WorkspaceId, currentTitle: string) => void /** Open the browser-owned session rename dialog. */ onSessionRename: (sessionId: SessionNode['id'], currentTitle: string) => void + /** Archive a session (row menu action; the row disappears on the state echo). */ + onSessionArchive: (sessionId: SessionNode['id']) => void } /** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */ function SessionTree({ - useSessions, startSession, open, forkSession, workspaces, - onRenameRequest, onDeleteRequest, onSessionRename, insertSessionBefore, t, + useSessions, startSession, open, forkSession, workspaces, archivedSessionIds, + onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive, insertSessionBefore, t, }: SessionTreeProps) { const list = useSessions(s => s) const current = list.current @@ -129,8 +133,8 @@ function SessionTree({ setExpandedProjects(l => (l.includes(currentGroup) ? l : [...l, currentGroup])) }, [current, currentGroup]) const groups = useMemo( - () => deriveGroups(list, workspaces, { expandedProjects }), - [list, workspaces, expandedProjects], + () => deriveGroups(list, workspaces, archivedSessionIds, { expandedProjects }), + [list, workspaces, archivedSessionIds, expandedProjects], ) const now = Date.now() @@ -209,6 +213,7 @@ function SessionTree({ onOpen={open} onRename={onSessionRename} onFork={forkSession} + onArchive={onSessionArchive} drag={dragProps} t={t} /> @@ -223,9 +228,11 @@ function SessionTree({ } /** The flat "In one list" body: every session a top-level row, newest-first. */ -function FlatList({ useSessions, open, forkSession, onSessionRename, t }: Pick) { +function FlatList({ useSessions, open, forkSession, onSessionRename, onSessionArchive, archivedSessionIds, t }: Pick< + SessionTreeProps, 'useSessions' | 'open' | 'forkSession' | 'onSessionRename' | 'onSessionArchive' | 'archivedSessionIds' | 't' +>) { const list = useSessions(s => s) - const rows = useMemo(() => deriveFlat(list), [list]) + const rows = useMemo(() => deriveFlat(list, archivedSessionIds), [list, archivedSessionIds]) const now = Date.now() return (
@@ -242,6 +249,7 @@ function FlatList({ useSessions, open, forkSession, onSessionRename, t }: Pick ))} @@ -263,12 +271,14 @@ function SearchResults({ useSessions, open, workspaces, + archivedSessionIds, query, remote, resultLimit, t, }: Pick & { workspaces: readonly WorkspaceView[] + archivedSessionIds: readonly SessionNode['id'][] query: string remote: RemoteSearchState resultLimit: number @@ -278,8 +288,8 @@ function SearchResults({ ? remote : { query, status: 'loading' as const, items: [], hasMore: false } const results = useMemo( - () => deriveSearchResults(list, workspaces, query, currentRemote, resultLimit), - [list, workspaces, query, currentRemote, resultLimit], + () => deriveSearchResults(list, workspaces, query, archivedSessionIds, currentRemote, resultLimit), + [list, workspaces, query, archivedSessionIds, currentRemote, resultLimit], ) const pending = currentRemote.status === 'loading' const failed = currentRemote.status === 'error' @@ -337,6 +347,7 @@ export function WorkspaceBrowser({ forkSession, renameWorkspace, deleteWorkspace, + archiveSession, insertSessionBefore, createWorkspace, searchSessions, @@ -346,6 +357,7 @@ export function WorkspaceBrowser({ t, }: WorkspaceBrowserProps) { const workspaces = useWorkspaces(state => state.items) + const archivedSessionIds = useWorkspaces(state => state.archivedSessionIds) const groupBy = useStore(s => s.groupBy) // The query outlives the tree and the input (both wide-only) so collapsing // does not silently drop an in-progress filter. @@ -475,6 +487,16 @@ export function WorkspaceBrowser({ setSessionRenameError(null) } + // Archive is dialog-free: not destructive (the log and the accounting slot + // remain), so the menu action commits directly; the row disappears when the + // archive-set echo lands. Failures are non-fatal console diagnostics, the + // same posture as reorder rejections. + const onSessionArchive = (sessionId: SessionNode['id']) => { + archiveSession(sessionId).catch((reason: unknown) => { + console.warn('session archive rejected:', reason) + }) + } + // Delete dialog is separate from the row so a successful removal can // unmount that row without tearing down the in-flight confirmation state. const [deleteTarget, setDeleteTarget] = useState<{ workspaceId: WorkspaceId; title: string } | null>(null) @@ -597,6 +619,7 @@ export function WorkspaceBrowser({ useSessions={useSessions} open={open} workspaces={workspaces} + archivedSessionIds={archivedSessionIds} query={normalizedQuery} remote={remoteSearch} resultLimit={searchResultLimit} @@ -607,15 +630,18 @@ export function WorkspaceBrowser({ ? ( ) : ( Promise /** Delete only a Host Workspace registration; directory and Session logs remain. */ deleteWorkspace: (workspaceId: WorkspaceId) => Promise + /** + * Archive a Session into the registry-global set: hidden from grouping + * surfaces, log and accounting slot retained. Archiving the current + * session clears the selection into the New Session view state. + */ + archiveSession: (sessionId: SessionId) => Promise /** * Reorder a session inside its Workspace account (DOM-insertBefore * semantics: omitted anchor appends to the end). The view refreshes from diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index 7dd8db5419..59574beed3 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -92,6 +92,7 @@ export function apply(ctx: ClientContext): void { }, renameWorkspace: async (workspaceId, title) => { await ctx.workspaces.rename(workspaceId, title) }, deleteWorkspace: async (workspaceId) => { await ctx.workspaces.delete(workspaceId) }, + archiveSession: async (sessionId) => { await ctx.workspaces.archiveSession(sessionId) }, insertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => { await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId) }, diff --git a/packages/client/ui-workspace/src/client/locales.ts b/packages/client/ui-workspace/src/client/locales.ts index 3d8a5d657f..1ecc244329 100644 --- a/packages/client/ui-workspace/src/client/locales.ts +++ b/packages/client/ui-workspace/src/client/locales.ts @@ -45,7 +45,7 @@ export const zh = { 'delete.desc': '将把“{name}”从工作区列表中移除。文件夹与会话记录会保留,其会话将显示在“未分组”下。', 'delete.pending': '正在删除工作区…', 'menu.fork': '分叉会话', - 'menu.deleteSession': '删除会话', + 'menu.archiveSession': '归档会话', 'sessions.count.one': '{n} 个会话', 'sessions.count.other': '{n} 个会话', 'actions.workspace.aria': '工作区“{name}”的操作', @@ -108,7 +108,7 @@ export const en = { 'delete.desc': 'This removes “{name}” from the workspace list. The folder and session logs will be kept. Its sessions will appear under Ungrouped.', 'delete.pending': 'Deleting workspace…', 'menu.fork': 'Fork session', - 'menu.deleteSession': 'Delete session', + 'menu.archiveSession': 'Archive session', 'sessions.count.one': '{n} session', 'sessions.count.other': '{n} sessions', 'actions.workspace.aria': 'Workspace actions for {name}', diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index 03634dbe1a..77583140c1 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -2,14 +2,14 @@ * Workspace browser tree row components (figma Cell set 14:3080): pure presentational — * all data and callbacks arrive via props. Hover swaps (folder->chevron, * time->ellipsis, action buttons) are CSS-only. Row ... menus are visual-only - * except workspace Rename/Delete and session Rename/Fork; the session and - * workspace hover cards are suppressed while a menu is open. + * except workspace Rename/Delete and session Rename/Fork/Archive; the session + * and workspace hover cards are suppressed while a menu is open. */ import { useState } from 'react' import clsx from 'clsx' import { - HoverCard, IconBranchOutline16, IconEditOutline16, IconEllipsisOutline16, - IconFolderClose16, IconFolderOpen16, IconPlusOutline16, + HoverCard, IconBranchOutline16, IconDownloadOutline16, IconEditOutline16, + IconEllipsisOutline16, IconFolderClose16, IconFolderOpen16, IconPlusOutline16, IconTrashOutline16, IconTriangleRightFill14, Menu, StateDot, } from '@deepseek-ai/dsh-client-ui-primitives' import type { WorkspaceBrowserProps } from '../contract/slots.ts' @@ -175,7 +175,9 @@ function SessionHoverContent({ node, now, t }: { node: SessionNode; now: number; return (
{displayTitle(node, t)}
-
{hoverTimeLabel(node.updatedAt, now, t)}
+ {/* Same placeholder rule as the row's trailing cell: no timestamp + before the first prompt. */} + {!node.blank &&
{hoverTimeLabel(node.updatedAt, now, t)}
}
{node.running ? t('status.running') : t('status.idle')} @@ -243,7 +245,7 @@ function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' | return e.clientY < rect.top + rect.height / 2 ? 'before' : 'after' } -export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork, drag, t }: { +export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork, onArchive, drag, t }: { node: SessionNode currentId: string | undefined now: number @@ -252,6 +254,8 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork onRename: (id: SessionNode['id'], currentTitle: string) => void /** Fork a session at its last completed turn (row menu action). */ onFork: (id: SessionNode['id']) => void + /** Archive this session (row menu action; commits without a dialog). */ + onArchive: (id: SessionNode['id']) => void /** Present only on draggable rows (workspace-group sessions outside search). */ drag?: RowDragProps | undefined t: RowTranslate @@ -260,10 +264,13 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork const title = displayTitle(node, t) const selected = node.id === currentId const [menuOpen, setMenuOpen] = useState(false) + // Archive replaces the former Delete placeholder: it hides the row through + // the registry-global archive set and never touches the session log, so it + // is not styled as destructive and needs no confirmation dialog. const sessionMenuItems = [ { id: 'rename', label: t('rename'), icon: }, { id: 'fork', label: t('menu.fork'), icon: }, - { id: 'delete', label: t('menu.deleteSession'), icon: , danger: true }, + { id: 'archive', label: t('menu.archiveSession'), icon: }, ] // Figma session cell: pad 8, status slot 16, then a 4px title gap. const ownRow = ( @@ -301,31 +308,38 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork > {row.running && } {title} - {timeLabel(row.updatedAt, now, t)} - - { setMenuOpen(false) }} - items={sessionMenuItems} - onSelect={(id) => { - setMenuOpen(false) - if (id === 'rename') onRename(node.id, row.title) - if (id === 'fork') onFork(node.id) // delete stays visual-only. - }} - portal - closeOnPointerLeave - anchor={( - - )} - /> - + {/* A blank New Session row is a provisional placeholder: nothing has + happened in it yet, so a "now" timestamp and the row verbs + (rename/fork/archive) would all act on content that does not + exist — both trailing cells stay off until the first prompt. */} + {!row.blank && {timeLabel(row.updatedAt, now, t)}} + {!row.blank && ( + + { setMenuOpen(false) }} + items={sessionMenuItems} + onSelect={(id) => { + setMenuOpen(false) + if (id === 'rename') onRename(node.id, row.title) + if (id === 'fork') onFork(node.id) + if (id === 'archive') onArchive(node.id) + }} + portal + closeOnPointerLeave + anchor={( + + )} + /> + + )}
) return ( diff --git a/packages/client/ui-workspace/src/client/tree.ts b/packages/client/ui-workspace/src/client/tree.ts index aed01c21cb..7ed7e684d1 100644 --- a/packages/client/ui-workspace/src/client/tree.ts +++ b/packages/client/ui-workspace/src/client/tree.ts @@ -90,9 +90,13 @@ function byRecency(a: SessionSummary, b: SessionSummary): number { return a.id < b.id ? -1 : 1 } -/** Ordinary sessions are visible; among blank sessions, only the current one is visible. */ -function sessionVisible(session: SessionSummary, current: SessionId | undefined): boolean { - return !session.blank || session.id === current +/** + * Ordinary sessions are visible; among blank sessions, only the current one + * is visible; archived sessions are visible nowhere (their accounting slots + * remain, so unarchiving restores position). + */ +function sessionVisible(session: SessionSummary, current: SessionId | undefined, archived: ReadonlySet): boolean { + return !archived.has(session.id) && (!session.blank || session.id === current) } /** @@ -126,7 +130,11 @@ function buildGroup( * order, with members resolved from sessionIds in their stored order. Sessions * outside every Workspace trail in the recency-ordered Ungrouped bucket. */ -function groupByWorkspace(list: SessionListState, workspaces: readonly WorkspaceView[]): Group[] { +function groupByWorkspace( + list: SessionListState, + workspaces: readonly WorkspaceView[], + archived: ReadonlySet, +): Group[] { const groups: Group[] = [] const accounted = new Set() for (const workspace of workspaces) { @@ -135,7 +143,7 @@ function groupByWorkspace(list: SessionListState, workspaces: readonly Workspace const summary = list.byId[id] if (summary === undefined) continue // account may lead the list pull; the row appears when the summary lands accounted.add(id) - if (!sessionVisible(summary, list.current)) continue + if (!sessionVisible(summary, list.current, archived)) continue members.push(summary) } groups.push(buildGroup( @@ -146,7 +154,7 @@ function groupByWorkspace(list: SessionListState, workspaces: readonly Workspace const stray = list.ids .map(id => list.byId[id]) .filter((s): s is SessionSummary => - s !== undefined && !accounted.has(s.id) && sessionVisible(s, list.current)) + s !== undefined && !accounted.has(s.id) && sessionVisible(s, list.current, archived)) if (stray.length > 0) { groups.push(buildGroup(UNGROUPED_KEY, undefined, undefined, undefined, UNGROUPED_LABEL, stray, 'recency')) } @@ -168,25 +176,29 @@ function sessionNode(s: SessionSummary): SessionNode { * * Every group shows; sessions populate under expanded groups, preserving * Host account order. Blank sessions are excluded except for the selected - * provisional New Session row. Content search lives outside this derivation + * provisional New Session row; archived sessions are excluded everywhere. + * Content search lives outside this derivation * (see {@link deriveSearchResults}). * @param list - sessions list snapshot (`current` feeds containsCurrent). * @param workspaces - real workspaces in stable Host order. + * @param archivedSessionIds - registry-global archive set. * @param view - local expansion arrays. * @returns group sections in render order. */ export function deriveGroups( list: SessionListState, workspaces: readonly WorkspaceView[], + archivedSessionIds: readonly SessionId[], view: TreeView, ): GroupNode[] { + const archived = new Set(archivedSessionIds) const expandedProjects = new Set(view.expandedProjects) const currentGroup = list.current === undefined ? undefined : (workspaces.find(w => w.sessionIds.includes(list.current as SessionId))?.workspaceId as string | undefined) ?? UNGROUPED_KEY const groups: GroupNode[] = [] - for (const g of groupByWorkspace(list, workspaces)) { + for (const g of groupByWorkspace(list, workspaces, archived)) { const expanded = expandedProjects.has(g.key) groups.push({ key: g.key, @@ -209,13 +221,15 @@ export function deriveGroups( * no parent/child adjacency. Content search lives outside this derivation * (see {@link deriveSearchResults}). * @param list - sessions list snapshot. + * @param archivedSessionIds - registry-global archive set. * @returns flat rows in render order. */ -export function deriveFlat(list: SessionListState): SessionNode[] { +export function deriveFlat(list: SessionListState, archivedSessionIds: readonly SessionId[]): SessionNode[] { + const archived = new Set(archivedSessionIds) const rows: SessionSummary[] = [] for (const id of list.ids) { const s = list.byId[id] - if (s === undefined || !sessionVisible(s, list.current)) continue + if (s === undefined || !sessionVisible(s, list.current, archived)) continue rows.push(s) } rows.sort(byRecency) @@ -238,6 +252,7 @@ export interface RelativeTime { * @param list - session metadata authority. * @param workspaces - Workspace membership and display labels. * @param query - caller text; surrounding whitespace is ignored. + * @param archivedSessionIds - registry-global archive set (members never match). * @param content - ranked Host content-search page. * @param limit - protocol-owned maximum merged row count. * @returns bounded deduplicated flat rows and a refine-query hint bit. @@ -246,11 +261,13 @@ export function deriveSearchResults( list: SessionListState, workspaces: readonly WorkspaceView[], query: string, + archivedSessionIds: readonly SessionId[], content: { items: readonly SessionSearchResultItem[]; hasMore: boolean }, limit: number, ): SearchResultSet { const q = query.trim().toLowerCase() if (q === '') return { items: [], hasMore: false } + const archived = new Set(archivedSessionIds) const workspaceBySession = new Map() for (const workspace of workspaces) { @@ -270,7 +287,7 @@ export function deriveSearchResults( const summary = list.byId[id] // Blank placeholders never match a query (their canonical title displays // localized, so matching it would tie search to one language). - if (summary === undefined || summary.blank || !sessionVisible(summary, list.current)) continue + if (summary === undefined || summary.blank || !sessionVisible(summary, list.current, archived)) continue if ( sessionTitle(summary).toLowerCase().includes(q) || labelOf(summary).toLowerCase().includes(q) @@ -290,7 +307,7 @@ export function deriveSearchResults( for (const summary of local) include(summary) for (const item of content.items) { const summary = list.byId[item.sessionId] - if (summary !== undefined && !summary.blank && sessionVisible(summary, list.current)) include(summary) + if (summary !== undefined && !summary.blank && sessionVisible(summary, list.current, archived)) include(summary) } return { diff --git a/packages/client/ui-workspace/tests/rows.spec.tsx b/packages/client/ui-workspace/tests/rows.spec.tsx index 59fe95fe0c..c0b4959b17 100644 --- a/packages/client/ui-workspace/tests/rows.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.spec.tsx @@ -88,7 +88,7 @@ describe('workspace browser rows', () => { const onOpen = vi.fn() render( , + onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />, ) const row = screen.getByRole('treeitem') @@ -157,18 +157,43 @@ describe('workspace browser rows', () => { expect(screen.queryByRole('button', { name: /工作区/ })).toBeNull() }) - it('session row menu opens without opening the session and dispatches rename and fork', () => { + it('blank New Session rows carry no menu, no time label, and no hover-card time', () => { + vi.useFakeTimers() + try { + const node: SessionNode = { + id: sid('s-blank'), title: 'ignored', blank: true, running: false, updatedAt: 0, + } + render() + // The placeholder has no content yet: no row verbs, no "now" stamp. + expect(screen.queryByRole('button', { name: /会话.*的操作/ })).toBeNull() + expect(screen.queryByText('刚刚')).toBeNull() + // The hover card keeps title + status but drops the timestamp line. + const wrapper = screen.getByRole('treeitem').parentElement as HTMLElement + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.getAllByText('新会话').length).toBeGreaterThanOrEqual(2) + expect(screen.getByText('空闲')).toBeTruthy() + expect(screen.queryByText('刚刚')).toBeNull() + } finally { + vi.useRealTimers() + } + }) + + it('session row menu opens without opening the session and dispatches rename, fork, and archive', () => { const onOpen = vi.fn() const onRename = vi.fn() const onFork = vi.fn() + const onArchive = vi.fn() const node: SessionNode = { id: sid('s1'), title: 'One', blank: false, running: false, updatedAt: 0, } render() + onRename={onRename} onFork={onFork} onArchive={onArchive} t={t} />) fireEvent.click(screen.getByRole('button', { name: '会话“One”的操作' })) expect(onOpen).not.toHaveBeenCalled() - expect(screen.getByRole('menuitem', { name: '删除会话' }).className).toMatch(/danger/) + // Archive is not destructive (log and accounting slot remain): no danger styling. + expect(screen.getByRole('menuitem', { name: '归档会话' }).className).not.toMatch(/danger/) // Rename dispatches with the current display title (dialog prefill). fireEvent.click(screen.getByRole('menuitem', { name: '重命名' })) expect(screen.queryByRole('menu')).toBeNull() @@ -177,10 +202,12 @@ describe('workspace browser rows', () => { fireEvent.click(screen.getByRole('button', { name: '会话“One”的操作' })) fireEvent.click(screen.getByRole('menuitem', { name: '分叉会话' })) expect(onFork).toHaveBeenCalledWith(node.id) - // Delete stays visual-only. + // Archive dispatches without opening the session. fireEvent.click(screen.getByRole('button', { name: '会话“One”的操作' })) - fireEvent.click(screen.getByRole('menuitem', { name: '删除会话' })) + fireEvent.click(screen.getByRole('menuitem', { name: '归档会话' })) + expect(onArchive).toHaveBeenCalledWith(node.id) expect(onRename).toHaveBeenCalledOnce() + expect(onOpen).not.toHaveBeenCalled() // Escape closes without selecting (Menu onClose path). fireEvent.click(screen.getByRole('button', { name: '会话“One”的操作' })) fireEvent.keyDown(document, { key: 'Escape' }) @@ -194,7 +221,7 @@ describe('workspace browser rows', () => { id: sid('s1'), title: 'Hovered', blank: false, running: true, updatedAt: 0, } render() + onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />) const wrapper = screen.getByRole('treeitem').parentElement as HTMLElement fireEvent.pointerEnter(wrapper) act(() => { vi.advanceTimersByTime(500) }) @@ -220,7 +247,7 @@ describe('workspace browser rows', () => { id: sid('s1'), title: 'Quiet', blank: false, running: false, updatedAt: 0, } render() + onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />) fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement) act(() => { vi.advanceTimersByTime(500) }) expect(screen.getByText('空闲')).toBeTruthy() @@ -237,7 +264,7 @@ describe('workspace browser rows', () => { const inactive = dragProps() const { rerender } = render( , + onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} drag={inactive} t={t} />, ) const row = screen.getByRole('treeitem') stubRect(row) @@ -255,7 +282,7 @@ describe('workspace browser rows', () => { const active = dragProps({ active: true, marker: 'before' }) rerender( , + onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} drag={active} t={t} />, ) stubRect(screen.getByRole('treeitem')) // Top half hovers/drops 'before'; bottom half 'after' (row mid = 117). @@ -269,7 +296,7 @@ describe('workspace browser rows', () => { const after = dragProps({ active: true, marker: 'after' }) rerender( , + onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} drag={after} t={t} />, ) expect(screen.getByRole('treeitem').className).toMatch(/dropAfter/) }) diff --git a/packages/client/ui-workspace/tests/tree.spec.ts b/packages/client/ui-workspace/tests/tree.spec.ts index 8fb750a4ad..7476de29f5 100644 --- a/packages/client/ui-workspace/tests/tree.spec.ts +++ b/packages/client/ui-workspace/tests/tree.spec.ts @@ -26,19 +26,21 @@ const workspace = (id: string, sessionIds: string[], title = id): WorkspaceView const view = (expandedProjects: readonly string[] = []) => ({ expandedProjects, }) +const noArchive: readonly SessionId[] = [] +const archived = (...ids: string[]): readonly SessionId[] => ids.map(sid) describe('deriveGroups', () => { it('keeps Host Workspace and sessionIds order without Client recency sorting', () => { const sessions = list(summary('newer', 20), summary('older', 10)) const workspaces = [workspace('first', ['older', 'newer']), workspace('empty', [])] - const groups = deriveGroups(sessions, workspaces, view(['first'])) + const groups = deriveGroups(sessions, workspaces, noArchive, view(['first'])) expect(groups.map(group => group.key)).toEqual(['first', 'empty']) expect(groups[0]!.sessions.map(session => session.id)).toEqual([sid('older'), sid('newer')]) }) it('puts only real unaccounted Sessions in the trailing Ungrouped group', () => { const sessions = list(summary('owned', 1, '/projects/first'), summary('loose', 9, '/other')) - const groups = deriveGroups(sessions, [workspace('first', ['owned'])], view([UNGROUPED_KEY])) + const groups = deriveGroups(sessions, [workspace('first', ['owned'])], noArchive, view([UNGROUPED_KEY])) expect(groups.map(group => group.key)).toEqual(['first', UNGROUPED_KEY]) expect(groups[1]!.sessions.map(session => session.id)).toEqual([sid('loose')]) }) @@ -52,7 +54,7 @@ describe('deriveGroups', () => { current: currentBlank.id, } const groups = deriveGroups( - sessions, [workspace('first', ['shown', 'current-blank', 'stale-blank'])], view(['first']), + sessions, [workspace('first', ['shown', 'current-blank', 'stale-blank'])], noArchive, view(['first']), ) expect(groups[0]!.sessions.map(session => session.id)).toEqual([real.id, currentBlank.id]) const blankNode = groups[0]!.sessions.find(session => session.id === currentBlank.id)! @@ -63,7 +65,7 @@ describe('deriveGroups', () => { expect(groups[0]!.sessions.find(session => session.id === real.id)!.blank).toBe(false) expect(groups[0]!.sessionCount).toBe(2) // A non-current blank stray never surfaces an Ungrouped bucket either. - const strayGroups = deriveGroups(list({ ...summary('stray', 2), blank: true }), [workspace('first', [])], view()) + const strayGroups = deriveGroups(list({ ...summary('stray', 2), blank: true }), [workspace('first', [])], noArchive, view()) expect(strayGroups.map(group => group.key)).toEqual(['first']) }) @@ -80,6 +82,7 @@ describe('deriveGroups', () => { const groups = deriveGroups( list(parent, oldChild, newChild, tieB, tieA, self, orphan, cycleA, cycleB), [], + noArchive, { expandedProjects: [UNGROUPED_KEY] }, ) @@ -90,7 +93,7 @@ describe('deriveGroups', () => { ]) // Equal timestamps use ids as a deterministic tiebreak in either input order. - expect(deriveGroups(list(summary('tie-a', 1), summary('tie-b', 1)), [], view([UNGROUPED_KEY]))[0]! + expect(deriveGroups(list(summary('tie-a', 1), summary('tie-b', 1)), [], noArchive, view([UNGROUPED_KEY]))[0]! .sessions.map(node => node.id)).toEqual([sid('tie-a'), sid('tie-b')]) }) @@ -100,17 +103,32 @@ describe('deriveGroups', () => { ids: [sid('present')], byId: { [sid('present')]: summary('present', 1) }, } - const groups = deriveGroups(partial, [workspace('project', ['missing', 'present'])], view(['project'])) + const groups = deriveGroups(partial, [workspace('project', ['missing', 'present'])], noArchive, view(['project'])) expect(groups[0]!.sessions.map(node => node.id)).toEqual([sid('present')]) }) + it('hides archived sessions from workspace groups and Ungrouped', () => { + const kept = summary('kept', 1, '/projects/first') + const gone = summary('gone', 2, '/projects/first') + const looseGone = summary('loose-gone', 3, '/other') + const sessions = list(kept, gone, looseGone) + const groups = deriveGroups( + sessions, [workspace('first', ['kept', 'gone'])], archived('gone', 'loose-gone'), view(['first', UNGROUPED_KEY]), + ) + // The archived member drops from its group AND the archived stray never + // surfaces an Ungrouped bucket; counts follow the visible rows. + expect(groups.map(group => group.key)).toEqual(['first']) + expect(groups[0]!.sessions.map(node => node.id)).toEqual([kept.id]) + expect(groups[0]!.sessionCount).toBe(1) + }) + it('marks selected Workspace and Ungrouped sessions without relying on an Intent', () => { const owned = summary('owned', 1) const loose = summary('loose', 2) const ws = workspace('project', ['owned']) - const ownedGroups = deriveGroups({ ...list(owned, loose), current: owned.id }, [ws], view()) + const ownedGroups = deriveGroups({ ...list(owned, loose), current: owned.id }, [ws], noArchive, view()) expect(ownedGroups.find(group => group.key === 'project')!.containsCurrent).toBe(true) - const looseGroups = deriveGroups({ ...list(owned, loose), current: loose.id }, [ws], view()) + const looseGroups = deriveGroups({ ...list(owned, loose), current: loose.id }, [ws], noArchive, view()) expect(looseGroups.find(group => group.key === UNGROUPED_KEY)!.containsCurrent).toBe(true) }) }) @@ -121,13 +139,13 @@ describe('deriveFlat', () => { const child = { ...summary('child', 30), parentId: parent.id } const tieB = summary('tie-b', 20) const tieA = summary('tie-a', 20) - const rows = deriveFlat(list(parent, child, tieB, tieA)) + const rows = deriveFlat(list(parent, child, tieB, tieA), noArchive) expect(rows.map(row => row.id)).toEqual([sid('child'), sid('tie-a'), sid('tie-b'), sid('parent')]) }) it('tolerates ids whose summary has not landed yet', () => { const partial: SessionListState = { ...list(summary('present', 1)), ids: [sid('ghost'), sid('present')] } - expect(deriveFlat(partial).map(row => row.id)).toEqual([sid('present')]) + expect(deriveFlat(partial, noArchive).map(row => row.id)).toEqual([sid('present')]) }) it('shows only the current blank session and excludes blanks from search', () => { @@ -137,11 +155,35 @@ describe('deriveFlat', () => { ...list(summary('real', 1), currentBlank, staleBlank), current: currentBlank.id, } - const rows = deriveFlat(sessions) + const rows = deriveFlat(sessions, noArchive) expect(rows.map(row => row.id)).toEqual([currentBlank.id, sid('real')]) expect(rows.map(row => row.title)).toEqual(['New Session', 'real']) expect(rows.map(row => row.blank)).toEqual([true, false]) }) + + it('hides archived sessions in flat mode', () => { + const kept = summary('kept', 1) + const gone = summary('gone', 2) + expect(deriveFlat(list(kept, gone), archived('gone')).map(row => row.id)).toEqual([kept.id]) + }) +}) + +describe('deriveSearchResults archive filtering', () => { + it('archived sessions never match — not by title and not via a backend content hit', () => { + const hit = summary('hit', 2) + hit.displayTitle = 'Needle row' + const gone = summary('gone', 1) + gone.displayTitle = 'Needle archived' + const result = deriveSearchResults( + list(hit, gone), + [], + 'needle', + archived('gone'), + { items: [{ sessionId: gone.id, snippet: 'needle body' }], hasMore: false }, + 10, + ) + expect(result.items.map(item => item.id)).toEqual([hit.id]) + }) }) describe('deriveSearchResults', () => { @@ -160,6 +202,7 @@ describe('deriveSearchResults', () => { workspace('duplicate-owner', ['title-hit'], 'Ignored duplicate owner'), ], ' NEEDLE ', + noArchive, { items: [ { sessionId: contentHit.id, snippet: 'body needle excerpt' }, @@ -212,6 +255,7 @@ describe('deriveSearchResults', () => { sessions, [workspace('first', ['opaque-current', 'new session stale'])], 'new session', + noArchive, { items: [ { sessionId: staleBlank.id, snippet: 'stale body' }, @@ -234,6 +278,7 @@ describe('deriveSearchResults', () => { list(...rows), [], 'needle', + noArchive, { items: [], hasMore: false }, 3, ) @@ -244,12 +289,13 @@ describe('deriveSearchResults', () => { list(summary('body', 1)), [], 'needle', + noArchive, { items: [{ sessionId: sid('body'), snippet: 'needle' }], hasMore: true }, 3, ) expect(backendMore.items).toHaveLength(1) expect(backendMore.hasMore).toBe(true) - expect(deriveSearchResults(list(), [], ' ', { items: [], hasMore: true }, 3)) + expect(deriveSearchResults(list(), [], ' ', noArchive, { items: [], hasMore: true }, 3)) .toEqual({ items: [], hasMore: false }) }) }) diff --git a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx index 7dc3f7239b..e5c89ce0de 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx @@ -35,8 +35,8 @@ const workspace = (id: string, sessionIds: string[], title = id): WorkspaceView workspaceId: wid(id), path: `/projects/${id}`, title, sessionIds: sessionIds.map(sid), createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', }) -const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState => ({ - items, state: 'idle', phase: 'ready', error: null, baselinesReady: true, +const workspaceState = (items: readonly WorkspaceView[], archivedSessionIds: readonly SessionId[] = []): WorkspaceListState => ({ + items, archivedSessionIds, state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: items[0]?.workspaceId, }) function hook(snapshot: T) { @@ -68,6 +68,7 @@ function mount(overrides: Partial = {}) { forkSession: vi.fn(), renameWorkspace: vi.fn(async () => {}), deleteWorkspace: vi.fn(async () => {}), + archiveSession: vi.fn(async () => {}), insertSessionBefore: vi.fn(async () => {}), createWorkspace: vi.fn(async () => workspace('created', [])), useDirectoryFlow: bindSnapshotSelector({ getSnapshot: () => true, subscribe: () => () => {} }), @@ -135,6 +136,50 @@ describe('WorkspaceBrowser', () => { expect(screen.queryByText('alpha-s')).toBeNull() }) + it('archives a session from the row menu and hides archived rows in both modes', async () => { + const archiveSession = vi.fn(async () => {}) + const b = mount({ + useSessions: hook(sessionState([summary('kept-s', 2), summary('gone-s', 1)])), + useWorkspaces: hook(workspaceState([workspace('alpha', ['kept-s', 'gone-s'])])), + archiveSession, + }) + fireEvent.click(screen.getByText('alpha')) + fireEvent.click(screen.getByRole('button', { name: '会话“gone-s”的操作' })) + fireEvent.click(screen.getByRole('menuitem', { name: '归档会话' })) + expect(archiveSession).toHaveBeenCalledWith(sid('gone-s')) + + // The archive-set echo hides the row in grouped mode (count included) and flat mode. + rerender(b, { useWorkspaces: hook(workspaceState([workspace('alpha', ['kept-s', 'gone-s'])], [sid('gone-s')])) }) + expect(screen.queryByText('gone-s')).toBeNull() + expect(screen.getByText('1 个会话')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: '分组方式' })) + fireEvent.click(screen.getByRole('menuitem', { name: '单列表' })) + expect(screen.getByText('kept-s')).toBeTruthy() + expect(screen.queryByText('gone-s')).toBeNull() + }) + + it('logs and keeps the tree when the archive call rejects', async () => { + const rejection = new Error('archive exploded') + const archiveSession = vi.fn(async () => { throw rejection }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + mount({ + useSessions: hook(sessionState([summary('alpha-s', 1)])), + useWorkspaces: hook(workspaceState([workspace('alpha', ['alpha-s'])])), + archiveSession, + }) + fireEvent.click(screen.getByText('alpha')) + fireEvent.click(screen.getByRole('button', { name: '会话“alpha-s”的操作' })) + fireEvent.click(screen.getByRole('menuitem', { name: '归档会话' })) + await Promise.resolve() + await Promise.resolve() + expect(warn).toHaveBeenCalledWith('session archive rejected:', rejection) + expect(screen.getByText('alpha-s')).toBeTruthy() + } finally { + warn.mockRestore() + } + }) + it('renders a fork child as a top-level row without a session twist', () => { const parent = summary('parent-s', 2) const child = { ...summary('child-s', 1), parentId: parent.id } diff --git a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx index 3a1bb8e2b2..b2d1479d17 100644 --- a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx @@ -32,7 +32,7 @@ const sessions: SessionListState = { ids: [], byId: {}, current: undefined, phase: 'ready', } const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState => ({ - items, state: 'idle', phase: 'ready', error: null, baselinesReady: true, + items, archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: items[0]?.workspaceId, }) function anchor(): { current: HTMLElement } { diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 9120a6364c..bceef94db3 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1166,6 +1166,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'delete(id: WorkspaceId): Promise', jsDoc: '/**\n * Delete one workspace registration while retaining its directory and every\n * session log. The durable order is updated before the table deletion; a\n * failed table write restores the prior order and keeps the entity\n * published. Unknown ids are an idempotent no-op for domain callers.\n * @param id - Workspace registration to remove.\n * @returns `true` when a record was deleted, `false` when it was unknown.\n */', }, + { + signature: 'archiveSession(sessionId: SessionId): Promise', + jsDoc: '/**\n * Archive one session durably. The session must exist (live or in session\n * persistence); its workspace accounting — or lack of one — is irrelevant.\n * An already archived id resolves without writing.\n * @param sessionId - The session to archive.\n * @returns resolution after durability.\n */', + }, { signature: 'async resolveByPath(path: string): Promise', jsDoc: '/**\n * Resolve by canonical directory path without creating or mutating a\n * workspace. A missing path rejects during `realpath`; an existing unowned\n * directory returns `undefined`.\n * @param path - Existing directory path in any spelling.\n * @returns the workspace owning the canonical path, when one exists.\n */', @@ -2215,6 +2219,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'PtyWaitReason', declaration: 'export type PtyWaitReason = \'stdin_read\' | \'inferred_idle\' | \'timeout\' | \'session_exit\';', }, + { + name: 'ReadFileLine', + declaration: 'export interface ReadFileLine {\n number: number;\n text: string;\n}', + }, + { + name: 'ReadResultView', + declaration: 'export interface ReadResultView {\n card: \'read\';\n title?: string;\n path: string;\n offset: number;\n lines: ReadFileLine[];\n totalLines: number;\n lang?: string;\n content?: ContentBlock[];\n}', + }, { name: 'ReasoningBlock', declaration: 'export interface ReasoningBlock {\n type: \'reasoning\';\n text: string;\n}', @@ -2853,7 +2865,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ToolResultView', - declaration: 'export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | WebResultView;', + declaration: 'export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | ReadResultView | WebResultView;', }, { name: 'ToolRunContext', diff --git a/packages/core/tools/README.i18n.yaml b/packages/core/tools/README.i18n.yaml index e888160b0f..064f3f9e1f 100644 --- a/packages/core/tools/README.i18n.yaml +++ b/packages/core/tools/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/tools/README.md -README.md: e7f395f8c1d6417db856e590f5267cf6887e4d12 -README.zh.md: acb4c047bf86e36c828882ff751d4be1f627f99e +README.md: dcce455f9551318f3871e3df84c29789078fef7c +README.zh.md: 63eaa2e0b66797c74a1d4845c29ca970b63f5f99 diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index e7f395f8c1..dcce455f95 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -108,7 +108,7 @@ Optional `isConcurrencySafe(args)` receives typed, softly validated arguments. E Tools optionally own pure `presentCall()` and `presentResult()` render intents, so UIs do not special-case tool names: - Call views are `{ card: 'generic', title, kind?, rawInput?, content?, locations? }`, `{ card: 'terminal', title, description?, cwd? }`, or `{ card: 'diff', title, diffs, locations? }`. -- Result views are `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }`, `{ card: 'diff', title?, diffs }`, or `{ card: 'web', kind: 'search' | 'fetch', title?, … }` (a completed web retrieval; the `kind` arms carry the structured search sources or the fetch summary, and a UI without the `web` capability falls back to the raw result content). +- Result views are `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }`, `{ card: 'diff', title?, diffs }`, `{ card: 'read', title?, path, offset, lines, totalLines, lang?, content? }` (a completed file read → a line-numbered, optionally syntax-highlighted code view; `offset` is the 1-based first line the window requested, kept even when `lines` is empty; `lines` is `{ number, text }[]` keeping each file line number, and `content` is the envelope-stripped text a UI without read support falls back to), or `{ card: 'web', kind: 'search' | 'fetch', title?, … }` (a completed web retrieval; the `kind` arms carry the structured search sources or the fetch summary, and a UI without the `web` capability falls back to the raw result content). Returning `undefined` selects generic fallback. Presenters depend only on their arguments and the durable result because UIs call them during live streaming and log replay. `output.presentationMeta(args, value)` derives JSON metadata for direct surface calls; that metadata persists with `tool/result` and returns to `presentResult`, while the canonical value itself remains execution-local and is never replayed. Nested Code dispatches do not compute metadata. `defineTool` soft-validates older logged arguments and falls back instead of crashing replay. `dsh-tool-bash` and `dsh-tool-fs` are the reference implementations; the [canonical-output Agent Note](../../../.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md) owns the value/presentation split and the [render-intent Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md) owns card vocabulary. diff --git a/packages/core/tools/README.zh.md b/packages/core/tools/README.zh.md index acb4c047bf..63eaa2e0b6 100644 --- a/packages/core/tools/README.zh.md +++ b/packages/core/tools/README.zh.md @@ -108,7 +108,7 @@ ctx.tools.register(defineTool({ 工具可以选择拥有纯 `presentCall()` 和 `presentResult()` 呈现意图,使 UI 无需特殊处理工具名称: - 调用视图为 `{ card: 'generic', title, kind?, rawInput?, content?, locations? }`、`{ card: 'terminal', title, description?, cwd? }` 或 `{ card: 'diff', title, diffs, locations? }`。 -- 结果视图为 `{ card: 'generic', title?, content? }`、`{ card: 'terminal', title?, output?, exitCode?, signal? }`、`{ card: 'diff', title?, diffs }` 或 `{ card: 'web', kind: 'search' | 'fetch', title?, … }`(已完成的 web 检索;`kind` 各分支携带结构化的搜索来源或抓取摘要,不具备 `web` 能力的 UI 回退到原始结果内容)。 +- 结果视图为 `{ card: 'generic', title?, content? }`、`{ card: 'terminal', title?, output?, exitCode?, signal? }`、`{ card: 'diff', title?, diffs }`、`{ card: 'read', title?, path, offset, lines, totalLines, lang?, content? }`(已完成的文件读取→带行号、可选语法高亮的代码视图;`offset` 是窗口请求的 1-based 起始行,即使 `lines` 为空也保留;`lines` 是 `{ number, text }[]`,保留每一行的文件行号,`content` 是无读取能力的 UI 回退时使用的去信封文本)或 `{ card: 'web', kind: 'search' | 'fetch', title?, … }`(已完成的 web 检索;`kind` 各分支携带结构化的搜索来源或抓取摘要,不具备 `web` 能力的 UI 回退到原始结果内容)。 返回 `undefined` 会选择通用回退。呈现器只依赖其参数和持久结果,因为 UI 会在实时流式输出和日志回放期间调用它们。`output.presentationMeta(args, value)` 为直接接口调用派生 JSON 元数据;该元数据随 `tool/result` 持久化并传回 `presentResult`,而规范值本身仍只存在于执行局部,绝不会回放。嵌套 Code 分发不会计算元数据。`defineTool` 会软验证较旧的日志参数并回退,而不会使回放崩溃。`dsh-tool-bash` 与 `dsh-tool-fs` 是参考实现;[规范输出 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md) 规定值/呈现拆分,[呈现意图 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md) 规定卡片词汇。 diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index e825a1a0dc..60dafed720 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -74,6 +74,7 @@ export type { ToolCallKind, FileLocation, FileDiff, + ReadFileLine, ToolCallView, GenericCallView, TerminalCallView, @@ -82,6 +83,7 @@ export type { GenericResultView, TerminalResultView, DiffResultView, + ReadResultView, WebResultView, WebSearchResultView, WebFetchResultView, diff --git a/packages/core/tools/src/presentation.ts b/packages/core/tools/src/presentation.ts index d1e7d552b5..b2b24554c4 100644 --- a/packages/core/tools/src/presentation.ts +++ b/packages/core/tools/src/presentation.ts @@ -117,6 +117,18 @@ export interface DiffCallView { locations?: FileLocation[] } +/** + * One numbered line of a file, the unit a {@link ReadResultView} carries so a + * capable UI can render a syntax-highlighted, line-numbered code view. `number` + * is the 1-based line number in the file (a window past `offset` keeps the file's + * own numbering, not a 1-based re-count); `text` is the line without its trailing + * newline, already truncated to the read tool's per-line cap. + */ +export interface ReadFileLine { + number: number + text: string +} + /** * How a tool wants the COMPLETED call shown — the *result* state, after `execute` * returns. A `card`-tagged union mirroring {@link ToolCallView}: a UI switches on @@ -125,7 +137,7 @@ export interface DiffCallView { * `ToolDefinition.presentResult`; omitting the method keeps the pending * title and renders the raw result content. */ -export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | WebResultView +export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | ReadResultView | WebResultView /** * The default completed card: an optional replacement title and reformatted @@ -177,6 +189,47 @@ export interface DiffResultView { diffs: FileDiff[] } +/** + * A completed file read rendered as a line-numbered, optionally syntax-highlighted + * code view by a capable UI. Set by a tool whose call reads file text (e.g. + * `read`); the pending state stays a {@link GenericCallView} (`kind: 'read'`) + * because a call carries no content until `execute` returns. The structured + * `lines`/`path`/`lang`/`totalLines` fields cannot be reconstructed from the + * model-facing result text alone, so the read tool projects them through its + * `output.presentationMeta` (persisted with the session log) and `presentResult` + * narrows that metadata back into this view on live and replay paths alike. A UI + * without the read capability falls back to `content` (the model-facing text with + * its envelope stripped), so this view degrades to the generic text card. + */ +export interface ReadResultView { + card: 'read' + /** Replacement title for the completed call. Omit to keep the pending-state title. */ + title?: string + /** The read file's path (the model-facing path; the bridge relativizes it). */ + path: string + /** + * The 1-based first line the window requested, preserved even when `lines` is + * empty (a byte cap below the first selected line yields an empty window) so a + * UI knows where the window starts and where a continuation resumes. + */ + offset: number + /** The returned window's lines, in file order, each keeping its file line number. */ + lines: ReadFileLine[] + /** Exact total line count in the file, so a UI can show a "showing N of M" affordance. */ + totalLines: number + /** + * A syntax-highlighting language hint derived from the file extension (e.g. + * `ts`, `py`), or omitted when the extension maps to no known language so a UI + * renders the lines as plain text. + */ + lang?: string + /** + * The model-facing result content with its envelope stripped, for a UI without + * the read capability. Omit to let such a UI render the raw result content. + */ + content?: ContentBlock[] +} + /** * One citeable source in a completed {@link WebSearchResultView}, the faithful * projection of one web-search source. The presentation projection of `dsh-web`'s diff --git a/packages/fs/tool-fs/README.i18n.yaml b/packages/fs/tool-fs/README.i18n.yaml index a3ebe97bc4..65c6b65268 100644 --- a/packages/fs/tool-fs/README.i18n.yaml +++ b/packages/fs/tool-fs/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/fs/tool-fs/README.md -README.md: 4ff9b043525e8e7a0b59e3d91410951d88bb9a69 -README.zh.md: ce93e10072d74ce268273aa472bfbb3f34f46259 +README.md: c00b59fed06249e6d9479c4a809cdf7d78f93239 +README.zh.md: f90fbb36391c1388ab0f6836daa2a9061d046be6 diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md index 4ff9b04352..c00b59fed0 100644 --- a/packages/fs/tool-fs/README.md +++ b/packages/fs/tool-fs/README.md @@ -34,7 +34,7 @@ All keys are optional; the defaults are the shipped read caps. Field names are snake_case to match Claude Code and existing harness tool schemas. -Canonical successes are `read` → `{ path, offset, lines: [{ number, text }], totalLines }`, `write` → `{ path, operation: 'create' | 'update', before: string | null, after }`, and `edit` → `{ path, before, after }`. Native renderers preserve the line-numbered read and mutation acknowledgements below. Write/edit derive replayable diff-card metadata from these values; the values themselves are execution-local and are not added to `tool/result`. +Canonical successes are `read` → `{ path, offset, lines: [{ number, text }], totalLines }`, `write` → `{ path, operation: 'create' | 'update', before: string | null, after }`, and `edit` → `{ path, before, after }`. Native renderers preserve the line-numbered read and mutation acknowledgements below. `write`/`edit` derive replayable diff-card metadata, and `read` derives a replayable read-card window `{ path, offset, lines, totalLines, lang? }`, from these canonical values; the canonical values themselves are execution-local and are not added to `tool/result`, only the derived presentation metadata is persisted. ## The tool is the executor; policy is an event gate diff --git a/packages/fs/tool-fs/README.zh.md b/packages/fs/tool-fs/README.zh.md index ce93e10072..f90fbb3639 100644 --- a/packages/fs/tool-fs/README.zh.md +++ b/packages/fs/tool-fs/README.zh.md @@ -34,7 +34,7 @@ await ctx.plugin(ToolFs) // this package — re 字段名使用 snake_case,与 Claude Code 和现有 harness 工具 schema 一致。 -规范成功值分别为:`read` → `{ path, offset, lines: [{ number, text }], totalLines }`,`write` → `{ path, operation: 'create' | 'update', before: string | null, after }`,`edit` → `{ path, before, after }`。原生渲染器会保留下方带行号的读取结果和变更确认。写入/编辑从这些值派生可回放的 diff 卡片元数据;这些值本身仅限于本次执行,不会添加到 `tool/result`。 +规范成功值分别为:`read` → `{ path, offset, lines: [{ number, text }], totalLines }`,`write` → `{ path, operation: 'create' | 'update', before: string | null, after }`,`edit` → `{ path, before, after }`。原生渲染器会保留下方带行号的读取结果和变更确认。`write`/`edit` 从这些规范值派生可回放的 diff 卡片元数据,`read` 派生可回放的读取卡片窗口 `{ path, offset, lines, totalLines, lang? }`;规范值本身仅限于本次执行,不会添加到 `tool/result`,只有派生出的呈现元数据会被持久化。 ## 工具就是执行器;策略是事件门禁 diff --git a/packages/fs/tool-fs/src/read-render.ts b/packages/fs/tool-fs/src/read-render.ts index 7e581bb22c..19b6c0b1e7 100644 --- a/packages/fs/tool-fs/src/read-render.ts +++ b/packages/fs/tool-fs/src/read-render.ts @@ -168,3 +168,105 @@ export function formatReadOutput(displayPath: string, outcome: FileReadOutcome): ${body} ` } + +/** + * Lowercased file-extension to syntax-highlighting language hint. Keys are the + * extension without its dot; a UI treats an absent key as plain text. The map is + * intentionally small — common source, config, and markup extensions a + * line-numbered code view benefits from highlighting — not an exhaustive registry. + */ +const LANG_BY_EXTENSION: Readonly> = { + ts: 'ts', tsx: 'tsx', mts: 'ts', cts: 'ts', + js: 'js', jsx: 'jsx', mjs: 'js', cjs: 'js', + json: 'json', jsonc: 'json', + py: 'py', rb: 'rb', go: 'go', rs: 'rs', java: 'java', + c: 'c', h: 'c', cc: 'cpp', cpp: 'cpp', hpp: 'cpp', cxx: 'cpp', + cs: 'cs', kt: 'kotlin', swift: 'swift', php: 'php', + sh: 'sh', bash: 'sh', zsh: 'sh', + yaml: 'yaml', yml: 'yaml', toml: 'toml', ini: 'ini', + md: 'md', markdown: 'md', mdx: 'mdx', + html: 'html', htm: 'html', css: 'css', scss: 'scss', less: 'less', + sql: 'sql', xml: 'xml', lua: 'lua', +} + +/** + * Derive a syntax-highlighting language hint from a read path's file extension. + * Pure and case-insensitive on the extension; a dotfile with no extension + * (`.gitignore`) and an unknown extension both yield `undefined`. + * @param path - the model-facing path the read reported. + * @returns the language hint for {@link LANG_BY_EXTENSION}, or `undefined` when the extension maps to none. + */ +export function langFromPath(path: string): string | undefined { + const base = path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1) + const dot = base.lastIndexOf('.') + // A leading dot is a dotfile (no extension), not an empty extension. + if (dot <= 0) return undefined + const ext = base.slice(dot + 1).toLowerCase() + // Own-property check only: a filename whose extension is an Object.prototype + // key (`foo.constructor`, `foo.__proto__`) must map to no language, not to the + // inherited member — otherwise a function would reach `lang` and fail the + // tool-output JSON validation. + return Object.hasOwn(LANG_BY_EXTENSION, ext) ? LANG_BY_EXTENSION[ext] : undefined +} + +/** + * The `read` tool's private `tool/result` `meta` payload: the structured + * line-numbered window a capable UI renders as a code view. Attached opaquely (as + * `unknown`) on the tool result and persisted with the session log — it must be + * JSON-serializable (the session validates this at `append`), so `presentResult` + * reproduces the read card on replay when the raw structured output is no longer + * on the wire. The producing tool owns and narrows this opaque shape. + */ +export interface FsReadMeta { + /** The read file's model-facing path. */ + path: string + /** The 1-based first line the window requested, kept even when `lines` is empty. */ + offset: number + /** The returned window's lines, each keeping its file line number. */ + lines: FileTextLine[] + /** Exact total line count in the file. */ + totalLines: number + /** Syntax-highlighting language hint from the extension, or omitted for plain text. */ + lang?: string +} + +/** + * Whether `value` is a valid {@link FileTextLine} (defensive narrowing from + * opaque `meta`). `number` must be a 1-based integer line number, since a card + * rendered from a zero, fractional, or non-finite line number would violate the + * 1-based numbering contract the read window promises. + */ +function isFileTextLine(value: unknown): value is FileTextLine { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false + const { number, text } = value as Record + return typeof number === 'number' && Number.isInteger(number) && number >= 1 && typeof text === 'string' +} + +/** + * Narrow opaque live or replayed result metadata to a structured read window. + * Malformed metadata returns `undefined` so presentation can fall back to the + * generic text card instead of throwing during replay. Beyond shape, the + * semantic contract of a read window is enforced against replayed JSON that is + * well-typed but out of range: `offset` must be a 1-based integer, `totalLines` + * must be a non-negative integer, each line number must be a 1-based integer no + * less than `offset`, the line numbers must strictly increase, and no line number + * may exceed `totalLines`. Any violation declines to the generic fallback rather + * than emitting a card that misnumbers or overcounts. + * @param meta - result metadata. + * @returns the validated read window, or `undefined` for absent, malformed, or semantically invalid data. + */ +export function readMetaFromMeta(meta: unknown): FsReadMeta | undefined { + if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) return undefined + const { path, offset, lines, totalLines, lang } = meta as Record + if (typeof path !== 'string' || typeof totalLines !== 'number' || typeof offset !== 'number') return undefined + if (!Number.isInteger(offset) || offset < 1) return undefined + if (!Number.isInteger(totalLines) || totalLines < 0) return undefined + if (!Array.isArray(lines) || !lines.every(isFileTextLine)) return undefined + if (lang !== undefined && typeof lang !== 'string') return undefined + let previous = offset - 1 + for (const { number } of lines) { + if (number <= previous || number > totalLines) return undefined + previous = number + } + return { path, offset, lines, totalLines, ...lang === undefined ? {} : { lang } } +} diff --git a/packages/fs/tool-fs/src/read.ts b/packages/fs/tool-fs/src/read.ts index 05e1b41ae2..a92fdcaad8 100644 --- a/packages/fs/tool-fs/src/read.ts +++ b/packages/fs/tool-fs/src/read.ts @@ -6,11 +6,11 @@ import type { Context } from 'cordis' import { defineTool } from '@deepseek-ai/dsh-tools' -import type { GenericCallView, GenericResultView, ToolResult } from '@deepseek-ai/dsh-tools' +import type { GenericCallView, ReadResultView, ToolResult } from '@deepseek-ai/dsh-tools' import { FsError } from '@deepseek-ai/dsh-fs' import type {} from '@deepseek-ai/dsh-fs' import type {} from '@deepseek-ai/dsh-system-prompt' -import { buildWindow, formatReadOutput } from './read-render.ts' +import { buildWindow, formatReadOutput, langFromPath, readMetaFromMeta } from './read-render.ts' import { sessionResolveOptions } from './session-cwd.ts' /** Default and maximum number of lines returned by one `read` call (the `readLimit` config). */ @@ -118,6 +118,19 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void { }), }] }, + // Project the structured window into persisted `meta` so a UI's read card + // survives replay: the raw canonical output object is not on the wire, only + // the model-facing text, from which the line/lang data cannot be recovered. + presentationMeta: (_args, value) => { + const lang = langFromPath(value.path) + return { + path: value.path, + offset: value.offset, + lines: value.lines.map(({ number, text }) => ({ number, text })), + totalLines: value.totalLines, + ...lang === undefined ? {} : { lang }, + } + }, }, // Observation races fail closed because guarded mutations re-check the version in-lock. isConcurrencySafe: () => true, @@ -154,15 +167,32 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void { ctx.emit('fs/observed', target, info.version, exec) return outcome }, - presentResult(_args, result: ToolResult): GenericResultView | undefined { + // Result-time display: a `read` card carrying the structured line window a + // capable UI renders as a line-numbered, syntax-highlighted view. The + // structured data is narrowed from the persisted `meta` (replay-safe); the + // envelope-stripped model-facing text rides along as `content` so a UI without + // the read capability still shows the file text. A malformed or absent meta, + // or a result whose text is not the read envelope, declines to `undefined` + // (the generic fallback), never throwing on replay of obsolete logged output. + presentResult(_args, result: ToolResult): ReadResultView | undefined { if (result.isError) return undefined + const meta = readMetaFromMeta(result.meta) + if (meta === undefined) return undefined const only = result.content.length === 1 ? result.content[0] : undefined const text = only?.type === 'text' ? only.text : undefined if (text === undefined) return undefined // Group 1 always captures (possibly empty) when the envelope matches. const body = /^[^\n]*<\/path>\nfile<\/type>\n\n([\s\S]*)\n<\/content>$/u.exec(text)?.[1] if (body === undefined) return undefined - return { card: 'generic', content: [{ type: 'text', text: body }] } + return { + card: 'read', + path: meta.path, + offset: meta.offset, + lines: meta.lines, + totalLines: meta.totalLines, + ...meta.lang === undefined ? {} : { lang: meta.lang }, + content: [{ type: 'text', text: body }], + } }, // Pure display: a generic card titled by the file with the read window appended (`Read // foo.txt (5 - 8)`), `read` kind (icon), and a follow-along location whose line is the diff --git a/packages/fs/tool-fs/tests/read-render.spec.ts b/packages/fs/tool-fs/tests/read-render.spec.ts index c2afaf002e..cc03a0c8f9 100644 --- a/packages/fs/tool-fs/tests/read-render.spec.ts +++ b/packages/fs/tool-fs/tests/read-render.spec.ts @@ -6,7 +6,7 @@ */ import { describe, expect, it } from 'vitest' -import { buildWindow, READ_MAX_BYTES, READ_MAX_LINE_LENGTH } from '../src/read-render.ts' +import { buildWindow, langFromPath, readMetaFromMeta, READ_MAX_BYTES, READ_MAX_LINE_LENGTH } from '../src/read-render.ts' import type { ReadWindow } from '../src/read-render.ts' const DEFAULT_CAPS = { maxLineLength: READ_MAX_LINE_LENGTH, maxBytes: READ_MAX_BYTES } @@ -116,3 +116,103 @@ describe('buildWindow', () => { }) }) }) + +describe('langFromPath', () => { + it('maps a known extension to its language hint, case-insensitively', () => { + expect(langFromPath('src/a.ts')).toBe('ts') + expect(langFromPath('src/a.TSX')).toBe('tsx') + expect(langFromPath('/abs/module.mjs')).toBe('js') + expect(langFromPath('conf.yml')).toBe('yaml') + expect(langFromPath('README.md')).toBe('md') + }) + + it('reads the extension after the last path segment and last dot', () => { + expect(langFromPath('a.py.bak')).toBeUndefined() + expect(langFromPath('archive.tar.gz')).toBeUndefined() + expect(langFromPath('/dir.py/plain')).toBeUndefined() + expect(langFromPath('C:\\src\\main.rs')).toBe('rs') + }) + + it('returns undefined for a dotfile, an extensionless name, and an unknown extension', () => { + expect(langFromPath('.gitignore')).toBeUndefined() + expect(langFromPath('/etc/hosts')).toBeUndefined() + expect(langFromPath('data.unknownext')).toBeUndefined() + expect(langFromPath('trailingdot.')).toBeUndefined() + }) + + it('returns undefined for a filename whose extension is an Object.prototype key', () => { + // Own-property lookup only: these must not resolve to the inherited member + // (a function/object), which would fail the tool-output JSON validation. + expect(langFromPath('foo.constructor')).toBeUndefined() + expect(langFromPath('foo.__proto__')).toBeUndefined() + expect(langFromPath('foo.toString')).toBeUndefined() + expect(langFromPath('foo.hasOwnProperty')).toBeUndefined() + }) +}) + +describe('readMetaFromMeta', () => { + const good = { path: '/abs/a.ts', offset: 1, lines: [{ number: 1, text: 'x' }], totalLines: 1, lang: 'ts' } + + it('narrows a well-formed read meta, with and without a lang hint', () => { + expect(readMetaFromMeta(good)).toEqual(good) + const noLang = { path: '/abs/a', offset: 1, lines: [], totalLines: 0 } + expect(readMetaFromMeta(noLang)).toEqual(noLang) + }) + + it('narrows an empty window at a positive offset (byte cap below the first selected line)', () => { + const empty = { path: '/abs/a', offset: 5, lines: [], totalLines: 9 } + expect(readMetaFromMeta(empty)).toEqual(empty) + }) + + it('returns undefined for absent, non-object, or array meta', () => { + expect(readMetaFromMeta(undefined)).toBeUndefined() + expect(readMetaFromMeta(null)).toBeUndefined() + expect(readMetaFromMeta('nope')).toBeUndefined() + expect(readMetaFromMeta([good])).toBeUndefined() + }) + + it('returns undefined when a field is missing or the wrong type (defensive narrowing)', () => { + expect(readMetaFromMeta({ ...good, path: 5 })).toBeUndefined() + expect(readMetaFromMeta({ ...good, offset: '1' })).toBeUndefined() + expect(readMetaFromMeta({ ...good, totalLines: '1' })).toBeUndefined() + expect(readMetaFromMeta({ ...good, lines: 'nope' })).toBeUndefined() + expect(readMetaFromMeta({ ...good, lines: [{ number: '1', text: 'x' }] })).toBeUndefined() + expect(readMetaFromMeta({ ...good, lines: [{ number: 1 }] })).toBeUndefined() + expect(readMetaFromMeta({ ...good, lines: [null] })).toBeUndefined() + expect(readMetaFromMeta({ ...good, lang: 5 })).toBeUndefined() + }) + + it('rejects an offset that is not a 1-based integer', () => { + expect(readMetaFromMeta({ ...good, offset: 0 })).toBeUndefined() + expect(readMetaFromMeta({ ...good, offset: 1.5 })).toBeUndefined() + expect(readMetaFromMeta({ ...good, offset: NaN })).toBeUndefined() + expect(readMetaFromMeta({ ...good, offset: Infinity })).toBeUndefined() + }) + + it('rejects a first line number below offset', () => { + expect(readMetaFromMeta({ ...good, offset: 2, lines: [{ number: 1, text: 'x' }], totalLines: 2 })).toBeUndefined() + }) + + it('rejects a line number that is not a 1-based integer', () => { + expect(readMetaFromMeta({ ...good, lines: [{ number: 0, text: 'x' }], totalLines: 1 })).toBeUndefined() + expect(readMetaFromMeta({ ...good, lines: [{ number: 1.5, text: 'x' }], totalLines: 2 })).toBeUndefined() + expect(readMetaFromMeta({ ...good, lines: [{ number: NaN, text: 'x' }], totalLines: 1 })).toBeUndefined() + expect(readMetaFromMeta({ ...good, lines: [{ number: Infinity, text: 'x' }], totalLines: 1 })).toBeUndefined() + }) + + it('rejects a totalLines that is not a non-negative integer', () => { + expect(readMetaFromMeta({ ...good, totalLines: -1 })).toBeUndefined() + expect(readMetaFromMeta({ ...good, totalLines: 1.5 })).toBeUndefined() + expect(readMetaFromMeta({ ...good, totalLines: NaN })).toBeUndefined() + }) + + it('rejects lines that do not strictly increase or exceed totalLines', () => { + const twoLines = { path: '/abs/a', offset: 1, lang: 'ts' } + // Duplicate line numbers. + expect(readMetaFromMeta({ ...twoLines, lines: [{ number: 1, text: 'a' }, { number: 1, text: 'b' }], totalLines: 2 })).toBeUndefined() + // Out-of-order line numbers. + expect(readMetaFromMeta({ ...twoLines, lines: [{ number: 2, text: 'b' }, { number: 1, text: 'a' }], totalLines: 2 })).toBeUndefined() + // A line number past totalLines. + expect(readMetaFromMeta({ ...twoLines, lines: [{ number: 3, text: 'c' }], totalLines: 2 })).toBeUndefined() + }) +}) diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index de844dcaf4..914a1bf7de 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -320,6 +320,39 @@ describe('read tool', () => { expect(text(result)).toContain('Output capped.') }) + it('attaches the structured window as presentation meta, and presentResult narrows it into a read card', async () => { + const { ctx, fs } = await setup() + fs.files.set('key:a.ts', 'const x = 1\nconst y = 2') + const result = await call(ctx, 'read', { file_path: 'a.ts' }) + expect(result.isError).toBe(false) + if (result.isError) throw new Error('expected read success') + // The extension drives the lang hint; the window rides on persisted meta. + expect(result.meta).toEqual({ + path: '/abs/a.ts', + offset: 1, + lines: [{ number: 1, text: 'const x = 1' }, { number: 2, text: 'const y = 2' }], + totalLines: 2, + lang: 'ts', + }) + const view = ctx.tools.get('read')?.presentResult?.({ file_path: 'a.ts' }, result) + expect(view).toEqual({ + card: 'read', + path: '/abs/a.ts', + offset: 1, + lines: [{ number: 1, text: 'const x = 1' }, { number: 2, text: 'const y = 2' }], + totalLines: 2, + lang: 'ts', + content: [{ type: 'text', text: '1: const x = 1\n2: const y = 2\n\n(End of file - total 2 lines)' }], + }) + }) + + it('omits the lang hint in meta for an extension that maps to no language', async () => { + const { ctx, fs } = await setup() + fs.files.set('key:notes', 'plain') + const result = await call(ctx, 'read', { file_path: 'notes' }) + if (result.isError) throw new Error('expected read success') + expect(result.meta).toEqual({ path: '/abs/notes', offset: 1, lines: [{ number: 1, text: 'plain' }], totalLines: 1 }) + }) }) describe('formatReadOutput footer variants', () => { @@ -450,33 +483,72 @@ describe('tool-owned presentation (pure presentCall)', () => { }) }) - it('read: completed presentation removes the model-facing XML envelope', async () => { - expect(await presentResult('read', { file_path: 'a.txt' }, { - content: [{ type: 'text', text: '/tmp/a.txt\nfile\n\n1: hello\n\n(End of file - total 1 lines)\n' }], + it('read: completed presentation is a read card carrying the structured window with the envelope stripped', async () => { + // The structured line data rides on persisted meta (the raw output object is + // not on the wire); presentResult narrows it and appends the stripped text as + // the no-capability `content` fallback. + const meta = { path: '/tmp/a.ts', offset: 1, lines: [{ number: 1, text: 'hello' }], totalLines: 1, lang: 'ts' } + expect(await presentResult('read', { file_path: 'a.ts' }, { + content: [{ type: 'text', text: '/tmp/a.ts\nfile\n\n1: hello\n\n(End of file - total 1 lines)\n' }], isError: false, + meta, })).toEqual({ - card: 'generic', + card: 'read', + path: '/tmp/a.ts', + offset: 1, + lines: [{ number: 1, text: 'hello' }], + totalLines: 1, + lang: 'ts', content: [{ type: 'text', text: '1: hello\n\n(End of file - total 1 lines)' }], }) - expect(await presentResult('read', { file_path: 'a.txt' }, { + // A window whose extension maps to no language omits `lang` from the card. + expect(await presentResult('read', { file_path: 'notes' }, { + content: [{ type: 'text', text: '/tmp/notes\nfile\n\nbody\n' }], + isError: false, + meta: { path: '/tmp/notes', offset: 1, lines: [{ number: 1, text: 'body' }], totalLines: 1 }, + })).toEqual({ + card: 'read', + path: '/tmp/notes', + offset: 1, + lines: [{ number: 1, text: 'body' }], + totalLines: 1, + content: [{ type: 'text', text: 'body' }], + }) + // Malformed envelope text with valid meta still declines (the fallback text is unavailable). + expect(await presentResult('read', { file_path: 'a.ts' }, { content: [{ type: 'text', text: 'malformed replay' }], isError: false, + meta, + })).toBeUndefined() + // Valid envelope but absent/malformed meta declines to the generic fallback. + expect(await presentResult('read', { file_path: 'a.ts' }, { + content: [{ type: 'text', text: '/tmp/a.ts\nfile\n\n1: hello\n' }], + isError: false, + })).toBeUndefined() + expect(await presentResult('read', { file_path: 'a.ts' }, { + content: [{ type: 'text', text: '/tmp/a.ts\nfile\n\n1: hello\n' }], + isError: false, + meta: { path: '/tmp/a.ts', lines: 'nope', totalLines: 1 }, })).toBeUndefined() }) it('read: completed presentation declines errors and non-single-text content', async () => { const envelope = '/tmp/a.txt\nfile\n\nbody\n' + const meta = { path: '/tmp/a.txt', offset: 1, lines: [{ number: 1, text: 'body' }], totalLines: 1 } expect(await presentResult('read', { file_path: 'a.txt' }, { content: [{ type: 'text', text: envelope }], isError: true, + meta, })).toBeUndefined() expect(await presentResult('read', { file_path: 'a.txt' }, { content: [{ type: 'text', text: envelope }, { type: 'text', text: 'second' }], isError: false, + meta, })).toBeUndefined() expect(await presentResult('read', { file_path: 'a.txt' }, { content: [{ type: 'reasoning', text: envelope }], isError: false, + meta, })).toBeUndefined() }) diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 573790009f..1bd0f19d0b 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: b12c1179a7b64b4b67efa01598e19bffc9f4198c -README.zh.md: 740c01bf46a0df56470fd2b4655e3c21317199bb +README.md: 8f08d90f8a91afc2ff022761d2d83de055df18a0 +README.zh.md: febeba0e601d75cbc69490d29135c206189efe29 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index b12c1179a7..8f08d90f8a 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -22,7 +22,7 @@ Session model routing is a session-domain contract. `session.models` returns the Pending queued input is a live control-plane contract, not session history. The gateway mirrors queued `InboxItem` occurrences from `agent/inbox/*` and broadcasts authoritative `session/queue` snapshots on every queued change and reconnect; pending steering stays outside this Web projection. `session.updateQueue` addresses one `InboxItemId`: edit replaces pending content and remove discards it. A driver claim wins races by retiring the address before admission; a later operation returns `queue-item-not-found`. The operation queries only an attached Agent and never resumes a cold session because process-local inbox identities do not survive restart or disposal. The client never infers retirement from turn or status events. -Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`. +Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. `workspace.archiveSession` adds one session to the registry-global archive set and answers the full updated set; `workspace.list` carries that set as the reconnect baseline and `host/archived-sessions-changed` pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with `session-not-found`. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`. `session.search` is a bounded content-search projection over the sessions visible through `session.list`. The gateway asks the optional `ctx.sessionQuery` service for globally ranked current-surface user, assistant, and steering matches, consumes that stream until it has at most 20 visible session/snippet pairs plus one lookahead, and revalidates every hit against the list-derived authorization set before returning it. Provider pages start at 20 hits; when a first-page request rejects that limit, the gateway probes 10, 5, 2, then 1 and retains the learned size for continuation and stale-generation restarts. Returned snippets contain at most 240 Unicode code points, and the response schema independently enforces that bound at each client boundary. Keeping the authorization set in Host memory avoids SQLite's variable ceiling for large valid corpora without weakening visibility or ranking. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 740c01bf46..febeba0e60 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -22,7 +22,7 @@ 待处理的 queued 输入属于实时控制平面契约,而非会话历史。网关镜像来自 `agent/inbox/*` 的 queued `InboxItem` 入队项,并在每次 queued 变更和重连时广播权威的 `session/queue` 快照;待处理 steering(中途引导)不进入此 Web 投影。`session.updateQueue` 通过 `InboxItemId` 寻址单个项:编辑会替换待处理内容,移除会将其丢弃。驱动器在接纳前退役寻址标识,因此认领会赢得竞态;之后的操作返回 `queue-item-not-found`。该操作只查询当前已挂载的 Agent,绝不恢复冷会话,因为进程本地 inbox 标识无法在重启或资源释放后存活。客户端绝不根据轮次或状态事件推断项已退役。 -Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。 +Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非实时也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。 `session.search` 是以 `session.list` 所列会话为范围的有界内容搜索投影。网关向可选的 `ctx.sessionQuery` 服务请求全局排序后的当前 surface user、assistant 和 steering(中途引导)匹配项,并持续消费该结果流,直到获得至多 20 个可见会话/snippet 对及一个前瞻项;返回前仍会依据从列表推导的授权集合重新校验每个命中。提供方分页初始请求 20 个命中;如果第一页请求因这一上限被拒绝,网关会依次探测 10、5、2、1,并在续传和陈旧世代重启中沿用探测所得的页面大小。返回的 snippet 最多包含 240 个 Unicode 码点,响应 schema 则会在每个客户端边界独立强制执行该上限。将授权集合保留在宿主内存中,可在不削弱可见性或排序的前提下避开有效大型语料库的 SQLite 变量上限。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 205c9912f5..b6365a4a52 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -21,7 +21,7 @@ import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-se import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace' import { workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId, - WorkspaceMoveInvalidError, WorkspaceNameConflictError, + WorkspaceMoveInvalidError, WorkspaceNameConflictError, WorkspaceUnknownSessionError, } from '@deepseek-ai/dsh-workspace' // Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters). import type {} from '@deepseek-ai/dsh-tools' @@ -1582,7 +1582,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro workspace: { list(request) { - return Promise.resolve(ok(request, { items: ctx.workspace.list().map(workspaceView) })) + return Promise.resolve(ok(request, { + items: ctx.workspace.list().map(workspaceView), + archivedSessionIds: [...ctx.workspace.archivedSessionIds], + })) }, // Exactly one of path/name arrives (schema refine). Existing-folder @@ -1698,6 +1701,23 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } return ok(request, { workspace: workspaceView(workspace) }) }, + + async archiveSession(request) { + const { sessionId } = request.payload + try { + await ctx.workspace.archiveSession(sessionId) + } catch (error: unknown) { + // Only the registry's unknown-session rejection is the business + // code; storage/durability failures propagate as internal errors. + if (!(error instanceof WorkspaceUnknownSessionError)) throw error + return err(request, { + code: 'session-not-found', + message: error.message, + details: { sessionId }, + }) + } + return ok(request, { archivedSessionIds: [...ctx.workspace.archivedSessionIds] }) + }, }, host: { @@ -2109,6 +2129,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro const committedWorkspaceIds = new Set( ctx.workspace.list().map(workspace => String(workspace.id)), ) + // Frame-dedup baseline, same posture as committedWorkspaceIds: the + // stream opens against the current set; workspace.list re-baselines + // reconnecting clients, so only later changes need frames. + let archivedSessionIds = ctx.workspace.archivedSessionIds const disposers = [ ctx.on('session/created', (session: Session) => { queue.push(frame({ @@ -2145,6 +2169,14 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro committedWorkspaceIds.add(workspaceId) queue.push(frame({ type: 'host/workspace-changed', workspace: workspaceView(workspace) })) } + if (state.archivedSessionIds.length !== archivedSessionIds.length + || state.archivedSessionIds.some((id, index) => id !== archivedSessionIds[index])) { + archivedSessionIds = state.archivedSessionIds + queue.push(frame({ + type: 'host/archived-sessions-changed', + archivedSessionIds: [...state.archivedSessionIds], + })) + } return } if (change.table !== 'workspaces') return diff --git a/packages/host/apiproxy/src/api/events.schema.ts b/packages/host/apiproxy/src/api/events.schema.ts index 186c189879..ea4b6c892f 100644 --- a/packages/host/apiproxy/src/api/events.schema.ts +++ b/packages/host/apiproxy/src/api/events.schema.ts @@ -71,6 +71,7 @@ export const hostFrameSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('host/agent-error'), sessionId: sessionIdSchema, message: z.string() }), z.object({ type: z.literal('host/workspace-changed'), workspace: workspaceViewSchema }), z.object({ type: z.literal('host/workspace-removed'), workspaceId: workspaceIdSchema }), + z.object({ type: z.literal('host/archived-sessions-changed'), archivedSessionIds: z.array(sessionIdSchema) }), z.object({ type: z.literal('host/commands-changed') }), z.object({ type: z.literal('host/settings-changed'), ns: z.string() }), z.object({ type: z.literal('host/credentials-changed'), ref: z.string() }), diff --git a/packages/host/apiproxy/src/api/events.ts b/packages/host/apiproxy/src/api/events.ts index d678f2f911..d8ee3f6bff 100644 --- a/packages/host/apiproxy/src/api/events.ts +++ b/packages/host/apiproxy/src/api/events.ts @@ -101,7 +101,9 @@ export type MuxFrame = * workspace mutation (create/attach/order change — the client upserts, while * `workspace.list` provides the reconnect baseline); workspace-removed is the * committed registration-deletion increment and never implies directory or - * session-log deletion. + * session-log deletion; archived-sessions-changed pushes the full registry + * archive set after every durable change (same full-snapshot posture as + * workspace-changed — `workspace.list` re-baselines it on reconnect). */ export type HostFrame = | { type: 'host/session-added'; sessionId: SessionId; blank: boolean; parentSessionId?: SessionId; cwd?: string } @@ -110,6 +112,7 @@ export type HostFrame = | { type: 'host/agent-error'; sessionId: SessionId; message: string } | { type: 'host/workspace-changed'; workspace: WorkspaceView } | { type: 'host/workspace-removed'; workspaceId: WorkspaceView['workspaceId'] } + | { type: 'host/archived-sessions-changed'; archivedSessionIds: SessionId[] } /** * The command registry changed (`commands/change` passthrough). Pure * invalidation signal, no payload: clients refetch `command.list` in the diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index f2f112f7cd..88e2c05575 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -42,6 +42,7 @@ export interface RpcMethodMap { 'workspace.rename': WorkspaceApi['rename'] 'workspace.delete': WorkspaceApi['delete'] 'workspace.insertSessionBefore': WorkspaceApi['insertSessionBefore'] + 'workspace.archiveSession': WorkspaceApi['archiveSession'] 'command.list': CommandsApi['list'] 'command.execute': CommandsApi['execute'] 'skill.list': SkillsApi['list'] diff --git a/packages/host/apiproxy/src/api/workspace.schema.ts b/packages/host/apiproxy/src/api/workspace.schema.ts index e16e5339da..20b3038301 100644 --- a/packages/host/apiproxy/src/api/workspace.schema.ts +++ b/packages/host/apiproxy/src/api/workspace.schema.ts @@ -28,6 +28,7 @@ export const workspaceListRequestSchema = z.object({}) satisfies z.ZodType>> /** workspace.create request payload: exactly one of path/name (the contract's create spellings). */ @@ -80,3 +81,13 @@ export const workspaceInsertSessionBeforeRequestSchema = z.object({ export const workspaceInsertSessionBeforeValueSchema = z.object({ workspace: workspaceViewSchema, }) satisfies z.ZodType>> + +/** workspace.archiveSession request payload. */ +export const workspaceArchiveSessionRequestSchema = z.object({ + sessionId: sessionIdSchema, +}) satisfies z.ZodType>> + +/** workspace.archiveSession response value: the full updated archive set. */ +export const workspaceArchiveSessionValueSchema = z.object({ + archivedSessionIds: z.array(sessionIdSchema), +}) satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/workspace.ts b/packages/host/apiproxy/src/api/workspace.ts index ff22d845fb..957c566bbd 100644 --- a/packages/host/apiproxy/src/api/workspace.ts +++ b/packages/host/apiproxy/src/api/workspace.ts @@ -37,8 +37,13 @@ export interface WorkspaceView { /** Workspace-domain unary methods (the map keys workspace.* of RpcMethodMap). */ export interface WorkspaceApi { - /** Lists all workspaces in the registry's durable display order. */ - list(request: RpcRequest<{}>): Promise> + /** + * Lists all workspaces in the registry's durable display order, plus the + * registry-global archive set (the reconnect baseline of + * `host/archived-sessions-changed`). Archived sessions stay in their + * workspace's `sessionIds` account; grouping surfaces hide them. + */ + list(request: RpcRequest<{}>): Promise> /** * Creates (or idempotently resolves) a workspace. Exactly one of `path` / @@ -86,4 +91,15 @@ export interface WorkspaceApi { sessionId: SessionId beforeSessionId?: SessionId }>): Promise> + + /** + * Adds one session to the registry-global archive set: the session + * disappears from every grouping surface but keeps its session log and its + * workspace accounting slot (a future unarchive restores its position). + * Idempotent for an already archived id. A session neither live nor in + * session persistence fails with `session-not-found`. Returns the full + * updated set (same snapshot the changed frame carries). + */ + archiveSession(request: RpcRequest<{ sessionId: SessionId }>): + Promise> } diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 5261658ee0..7b5c1545de 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -31,6 +31,7 @@ import { sessionUpdateQueueValueSchema, } from '../api/sessions.schema.ts' import { + workspaceArchiveSessionValueSchema, workspaceCreateValueSchema, workspaceDeleteValueSchema, workspaceInsertSessionBeforeValueSchema, @@ -98,6 +99,7 @@ export interface IApiClient { rename(payload: RequestPayload<'workspace.rename'>, signal?: AbortSignal): Promise>> delete(payload: RequestPayload<'workspace.delete'>, signal?: AbortSignal): Promise>> insertSessionBefore(payload: RequestPayload<'workspace.insertSessionBefore'>, signal?: AbortSignal): Promise>> + archiveSession(payload: RequestPayload<'workspace.archiveSession'>, signal?: AbortSignal): Promise>> } commands: { list(payload: RequestPayload<'command.list'>, signal?: AbortSignal): Promise>> @@ -163,6 +165,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType this.callUnary('workspace.rename', payload, signal), delete: (payload, signal) => this.callUnary('workspace.delete', payload, signal), insertSessionBefore: (payload, signal) => this.callUnary('workspace.insertSessionBefore', payload, signal), + archiveSession: (payload, signal) => this.callUnary('workspace.archiveSession', payload, signal), } readonly commands: IApiClient['commands'] = { diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index 0cf7625a65..6bc060e969 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -33,6 +33,7 @@ import { hostPickDirectoryRequestSchema, } from '../api/host.schema.ts' import { + workspaceArchiveSessionRequestSchema, workspaceCreateRequestSchema, workspaceDeleteRequestSchema, workspaceInsertSessionBeforeRequestSchema, @@ -96,6 +97,7 @@ const UNARY_ROUTES: UnaryRoutes = { 'workspace.rename': { schema: workspaceRenameRequestSchema, invoke: (api, r) => api.workspace.rename(r) }, 'workspace.delete': { schema: workspaceDeleteRequestSchema, invoke: (api, r) => api.workspace.delete(r) }, 'workspace.insertSessionBefore': { schema: workspaceInsertSessionBeforeRequestSchema, invoke: (api, r) => api.workspace.insertSessionBefore(r) }, + 'workspace.archiveSession': { schema: workspaceArchiveSessionRequestSchema, invoke: (api, r) => api.workspace.archiveSession(r) }, 'command.list': { schema: commandListRequestSchema, invoke: (api, r) => api.commands.list(r) }, 'command.execute': { schema: commandExecuteRequestSchema, invoke: (api, r, signal) => api.commands.execute(r, signal) }, 'skill.list': { schema: skillListRequestSchema, invoke: (api, r) => api.skills.list(r) }, diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index 32264114f5..12d76dac71 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -442,4 +442,44 @@ describe('Host Workspace increments', () => { expect(expectOk(await api.sessions.list(request({}))).items.map(item => item.sessionId)).toContain(sessionId) abort.abort() }) + + it('archives a session into the global set, keeps its accounting, and streams the set once', async () => { + const { api } = await harness() + const workspace = expectOk(await api.workspace.create(request({ name: 'archive-home' }))).workspace + const sessionId = SessionId('session-to-archive') + expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId }))) + expect(expectOk(await api.workspace.list(request({}))).archivedSessionIds).toEqual([]) + + const abort = new AbortController() + const stream: AsyncIterator> = + api.events.host(request({}), abort.signal)[Symbol.asyncIterator]() + const changed = nextHostFrame(stream) + expect(expectOk(await api.workspace.archiveSession(request({ sessionId }))).archivedSessionIds) + .toEqual([sessionId]) + expect(await changed).toMatchObject({ + payload: { type: 'host/archived-sessions-changed', archivedSessionIds: [sessionId] }, + }) + + // Accounting and the session itself are untouched; list re-baselines the set. + const listed = expectOk(await api.workspace.list(request({}))) + expect(listed.archivedSessionIds).toEqual([sessionId]) + expect(listed.items[0]?.sessionIds).toEqual([sessionId]) + expect(expectOk(await api.sessions.list(request({}))).items.map(item => item.sessionId)).toContain(sessionId) + + // The idempotent repeat emits no second frame: the next observed frame is + // the workspace-changed of a later attach, not another archive snapshot. + const after = nextHostFrame(stream) + expect(expectOk(await api.workspace.archiveSession(request({ sessionId }))).archivedSessionIds) + .toEqual([sessionId]) + const otherSession = SessionId('session-after-archive') + expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId: otherSession }))) + expect((await after).payload.type).not.toBe('host/archived-sessions-changed') + + const missing = await api.workspace.archiveSession(request({ sessionId: SessionId('session-ghost') })) + expect(missing.result).toMatchObject({ + ok: false, + error: { code: 'session-not-found', details: { sessionId: 'session-ghost' } }, + }) + abort.abort() + }) }) diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 1a667bf4a6..6307dfe8f9 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -66,11 +66,12 @@ function scriptedApi(overrides: { ...overrides.host, }, workspace: { - list: r => ok(r, { items: [] }), + list: r => ok(r, { items: [], archivedSessionIds: [] }), create: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' }, created: true }), rename: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' } }), delete: r => ok(r, { deleted: true as const }), insertSessionBefore: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' } }), + archiveSession: r => ok(r, { archivedSessionIds: [r.payload.sessionId] }), }, commands: { list: r => ok(r, { commands: [] }), @@ -360,10 +361,12 @@ describe('workspace domain round trip', () => { it('routes both workspace methods through their handler rows and value schemas', async () => { const c = client(scriptedApi()) const list = await c.workspace.list({}) - expect(list.result).toEqual({ ok: true, value: { items: [] } }) + expect(list.result).toEqual({ ok: true, value: { items: [], archivedSessionIds: [] } }) const created = await c.workspace.create({ path: '/t' }) expect(created.result.ok).toBe(true) if (created.result.ok) expect(created.result.value.created).toBe(true) + const archivedResponse = await c.workspace.archiveSession({ sessionId: 's-arch' as never }) + expect(archivedResponse.result).toEqual({ ok: true, value: { archivedSessionIds: ['s-arch'] } }) }) it('rejects a create payload violating the exactly-one refine at the handler', async () => { diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 3a21d6d1ce..ef111afe12 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -122,7 +122,7 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra }, workspace: { async list(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { items: [] } } } + return { rpcId: request.rpcId, result: { ok: true, value: { items: [], archivedSessionIds: [] } } } }, async create(request) { return { @@ -145,6 +145,9 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra result: { ok: true, value: { workspace: { workspaceId: 'w1' as never, path: '/w', title: 'w', sessionIds: [], createdAt: 't', updatedAt: 't' } } }, } }, + async archiveSession(request) { + return { rpcId: request.rpcId, result: { ok: true, value: { archivedSessionIds: [request.payload.sessionId] } } } + }, }, commands: { async list(request) { diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index aa9c46d9ae..f6bd093178 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -20,6 +20,7 @@ import { hostListDirectoryRequestSchema, hostListDirectoryValueSchema, } from '../src/api/host.schema.ts' import { + workspaceArchiveSessionRequestSchema, workspaceArchiveSessionValueSchema, workspaceCreateRequestSchema, workspaceCreateValueSchema, workspaceIdSchema, workspaceDeleteRequestSchema, workspaceDeleteValueSchema, workspaceInsertSessionBeforeRequestSchema, workspaceInsertSessionBeforeValueSchema, @@ -312,7 +313,16 @@ describe('workspace domain schemas', () => { expect(workspaceViewSchema.parse(view).sessionIds).toEqual(['s1']) expect(() => workspaceViewSchema.parse({ ...view, sessionIds: 's1' })).toThrow() expect(workspaceListRequestSchema.parse({})).toEqual({}) - expect(workspaceListValueSchema.parse({ items: [view] }).items).toHaveLength(1) + expect(workspaceListValueSchema.parse({ items: [view], archivedSessionIds: ['s1'] }).items).toHaveLength(1) + expect(() => workspaceListValueSchema.parse({ items: [view] })).toThrow() + }) + + it('archiveSession request/value carry the id and the full updated set', () => { + expect(workspaceArchiveSessionRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1') + expect(() => workspaceArchiveSessionRequestSchema.parse({})).toThrow() + expect(workspaceArchiveSessionValueSchema.parse({ archivedSessionIds: ['s1', 's2'] }).archivedSessionIds) + .toEqual(['s1', 's2']) + expect(() => workspaceArchiveSessionValueSchema.parse({ archivedSessionIds: 's1' })).toThrow() }) it('create requires exactly one of path/name (both refine arms)', () => { diff --git a/packages/ui/tui/src/components/transcript.ts b/packages/ui/tui/src/components/transcript.ts index 8491b98733..c991e85c1f 100644 --- a/packages/ui/tui/src/components/transcript.ts +++ b/packages/ui/tui/src/components/transcript.ts @@ -402,12 +402,15 @@ export class ToolCardComponent implements Component { const glyph = this.result === undefined ? '○' : '●' const rawBody = this.renderBody() const view = this.resultView ?? this.callView - // A generic card's own content, or a web card's fallback to the raw result - // content (the `web` view carries no `content` copy), both render as one dim - // Markdown block below, so links/lists/headings keep the unified dim styling - // rather than reading as bare text. Terminal and diff cards own their body - // styling, so they are excluded (mirrors renderBody's post-terminal/diff fallback). - const markdownContent = view.card === 'generic' + // A generic card's own content, or a read card's `content` fallback (the + // envelope-stripped file text — the TUI has no dedicated read rendering, so a + // read renders exactly as before the read card existed), or a web card's + // fallback to the raw result content (the `web` view carries no `content` + // copy), all render as one dim Markdown block below, so links/lists/headings + // keep the unified dim styling rather than reading as bare text. Terminal and + // diff cards own their body styling, so they are excluded (mirrors + // renderBody's post-terminal/diff fallback). + const markdownContent = view.card === 'generic' || view.card === 'read' ? view.content ?? this.result?.content : view.card === 'web' // A web resultView is only assigned alongside this.result (the result @@ -532,11 +535,12 @@ export class ToolCardComponent implements Component { // rather than under the dim result-output color. return { prelude: [...hunks, footer], lines: [] } } - // The web card carries no `content` copy, so a `web` result view falls back - // to the raw result content here (`view.card === 'generic'` narrows the - // generic union arm; a `web` card takes the same fallback, mirroring the - // `markdownContent` selection in render()). - const content = (view.card === 'generic' ? view.content : undefined) ?? this.result?.content + // A generic or read card carries its own envelope-stripped `content`; a `web` + // card carries no `content` copy and falls back to the raw result content + // here. (Mirrors the `markdownContent` selection in render(); a read card has + // no dedicated TUI rendering, so its `content` takes the same body path, + // keeping read output as it was before the read card existed.) + const content = (view.card === 'generic' || view.card === 'read' ? view.content : undefined) ?? this.result?.content const prelude: string[] = [] const lines: string[] = [] // The presenter title headlines the body now that the header is a fixed diff --git a/packages/workspace/workspace/README.i18n.yaml b/packages/workspace/workspace/README.i18n.yaml index a24e81d977..33f0029093 100644 --- a/packages/workspace/workspace/README.i18n.yaml +++ b/packages/workspace/workspace/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/workspace/workspace/README.md -README.md: bee3e4fcb5dded273f30942ee2e42ee93b839e62 -README.zh.md: 9a052f796fb7cc8756999bdc9b2ce905805730ab +README.md: 11dc8172392e530ab4ea16f1b60473e5befb8089 +README.zh.md: 5c6e3cefe31759df27b8008861505527648ce3c4 diff --git a/packages/workspace/workspace/README.md b/packages/workspace/workspace/README.md index bee3e4fcb5..11dc817239 100644 --- a/packages/workspace/workspace/README.md +++ b/packages/workspace/workspace/README.md @@ -12,7 +12,7 @@ The entity/storage rationale lives in the [domain Agent Note](../../../.agents/n - `ctx.workspace.get(id)` / `list()` / `resolveByPath(path)` — cache-served lookups. `list()` is synchronous and follows durable registry order; `resolveByPath` is async because it applies the same `realpath` canon and rejects a missing path rather than creating it. - `ctx.workspace.delete(id)` — removes only the Workspace registration, its durable order entry, and its session account. Unknown ids return `false`; a removed record returns `true`. The directory, user files, live Sessions, and persisted session logs are never touched, so those Sessions become Ungrouped. A table-write failure restores the prior order and published entity. - `Workspace.attachSession(id)` — validates a live or persisted session header cwd against the workspace path and prepends a new id. Unknown sessions, absent/unresolvable/non-directory cwd values, and mismatches reject without writing. `detachSession` removes only the candidate index entry. -- `ctx.workspace.touchSession(id)` — moves only that validated, accounted session to the front. Ungrouped or filtered sessions are no-ops, and workspace order never changes. +- `ctx.workspace.archiveSession(id)` / `archivedSessionIds` — the registry-global archive set, layered over workspace accounting: an archived session disappears from grouping surfaces but keeps its session log and its `sessionIds` slot, so a future unarchive restores its position. Archiving accepts any live or persisted session (accounted or Ungrouped), resolves without writing for an already archived id, and rejects an unknown id. State written before the field existed parses with an empty set. - `Workspace.sessionIds` — synchronous id-plus-canonical-cwd membership projection in durable candidate order. Missing headers, invalid cwd values, and mismatches are filtered; the next workspace mutation prunes them. A medium indexing one session under two workspaces, claiming one path from two records, or diverging from durable workspace order rejects at startup. - `Workspace.status()` — uncached directory check, `'ok' | 'missing-dir'`; a missing directory never mutates the record. diff --git a/packages/workspace/workspace/README.zh.md b/packages/workspace/workspace/README.zh.md index 9a052f796f..5c6e3cefe3 100644 --- a/packages/workspace/workspace/README.zh.md +++ b/packages/workspace/workspace/README.zh.md @@ -12,7 +12,7 @@ DeepSeek Harness 的 Workspace 实体注册表(`ctx.workspace`):通过领 - `ctx.workspace.get(id)`/`list()`/`resolveByPath(path)`:由缓存提供的查找。`list()` 为同步操作,并遵循持久注册表顺序;`resolveByPath` 为异步操作,因为它采用相同的 `realpath` 规范化方式,并会拒绝缺失路径,而不是创建路径。 - `ctx.workspace.delete(id)`:只移除 Workspace 注册记录、对应的持久顺序条目及会话归属记录。未知 id 返回 `false`,成功移除记录则返回 `true`。目录、用户文件、活跃会话和持久化会话日志绝不受影响,因此相关会话会进入 Ungrouped。表写入失败时会恢复原顺序和此前发布的实体。 - `Workspace.attachSession(id)`:对照 workspace 路径验证实时或已持久化的会话头 cwd,并将新 id 前置。未知会话、缺失/无法解析/非目录的 cwd 值和不匹配情况都会在不写入的前提下被拒绝。`detachSession` 只移除候选索引条目。 -- `ctx.workspace.touchSession(id)`:仅将已验证、已记账的会话移到最前。未分组或被过滤的会话不会触发任何操作,workspace 顺序绝不改变。 +- `ctx.workspace.archiveSession(id)`/`archivedSessionIds`:覆盖在 workspace 记账之上的注册表级全局归档集合:被归档的会话从各分组视图中消失,但其会话日志和 `sessionIds` 席位保持不变,未来取消归档时可恢复原位置。归档接受任何实时或已持久化的会话(无论已记账还是 Ungrouped),对已归档的 id 直接完成而不写入,并拒绝未知 id。在该字段出现之前写入的状态解析为一个空集合。 - `Workspace.sessionIds`:按持久候选顺序提供同步 id 加规范 cwd 成员投影。缺失头部、无效 cwd 值和不匹配情况都被过滤;下一次 workspace 变更会剪除它们。如果同一存储介质将一个会话索引到两个 workspace 下、用两条记录声明同一路径,或偏离持久 workspace 顺序,启动会被拒绝。 - `Workspace.status()`:未缓存的目录检查,返回 `'ok' | 'missing-dir'`;目录缺失绝不会改动记录。 diff --git a/packages/workspace/workspace/src/index.ts b/packages/workspace/workspace/src/index.ts index 5172c63805..5262043b03 100644 --- a/packages/workspace/workspace/src/index.ts +++ b/packages/workspace/workspace/src/index.ts @@ -49,6 +49,20 @@ export class WorkspaceNameConflictError extends Error { } } +/** + * An archiveSession request named a session neither live nor in session + * persistence — a definite miss only; storage faults propagate as themselves. + */ +export class WorkspaceUnknownSessionError extends Error { + /** + * @param sessionId - The unknown session id. + */ + constructor(readonly sessionId: SessionId) { + super(`cannot archive session '${sessionId}': live sessions and session persistence hold no such session`) + this.name = 'WorkspaceUnknownSessionError' + } +} + declare module 'cordis' { interface Context { @@ -181,6 +195,49 @@ export class WorkspaceRegistry extends Service { return this.enqueueOperation(() => this.deleteKnown(id)) } + /** + * The registry-global archive set: sessions hidden from every grouping + * surface. Archiving never touches workspace accounting — an archived + * session keeps its `sessionIds` slot so unarchiving restores its position. + * @returns the archived session ids in archive order. + */ + get archivedSessionIds(): readonly SessionId[] { + return this.requireState().archivedSessionIds + } + + /** + * Archive one session durably. The session must exist (live or in session + * persistence); its workspace accounting — or lack of one — is irrelevant. + * An already archived id resolves without writing. + * @param sessionId - The session to archive. + * @returns resolution after durability. + */ + archiveSession(sessionId: SessionId): Promise { + return this.enqueueOperation(async () => { + // The chain slot serializes against every other registry write, so this + // check-then-write pair cannot interleave with another archive. + if (this.requireState().archivedSessionIds.includes(sessionId)) return + if (!(await this.sessionKnown(sessionId))) { + throw new WorkspaceUnknownSessionError(sessionId) + } + const state = this.requireState() + await this.setState({ ...state, archivedSessionIds: [...state.archivedSessionIds, sessionId] }) + }) + } + + /** + * Whether a session is live, header-indexed, or present in a fresh + * persistence listing. Only a definite miss returns false — a failing + * `sessionPersistence.list()` propagates so storage faults never + * masquerade as an unknown session. + */ + private async sessionKnown(id: SessionId): Promise { + if (this.ctx.get('sessions')?.get(id) !== undefined) return true + if (this.headers.has(id)) return true + await this.indexHeaders(await this.ctx.sessionPersistence.list()) + return this.headers.has(id) + } + /** * Resolve by canonical directory path without creating or mutating a * workspace. A missing path rejects during `realpath`; an existing unowned @@ -245,7 +302,11 @@ export class WorkspaceRegistry extends Service { } try { - await this.setState({ initialized: true, workspaceIds: [id, ...state.workspaceIds] }) + await this.setState({ + initialized: true, + workspaceIds: [id, ...state.workspaceIds], + archivedSessionIds: state.archivedSessionIds, + }) } catch (error) { this.entities.delete(id) try { @@ -276,6 +337,7 @@ export class WorkspaceRegistry extends Service { const nextState = { initialized: true, workspaceIds: state.workspaceIds.filter(workspaceId => workspaceId !== id), + archivedSessionIds: state.archivedSessionIds, } await this.setState({ ...nextState, @@ -329,7 +391,11 @@ export class WorkspaceRegistry extends Service { ) } await this.requireTable().delete(pending.workspaceId) - await this.setState({ initialized: state.initialized, workspaceIds: state.workspaceIds }) + await this.setState({ + initialized: state.initialized, + workspaceIds: state.workspaceIds, + archivedSessionIds: state.archivedSessionIds, + }) } private async bootstrap(headers: readonly SessionHeader[]): Promise { @@ -411,9 +477,9 @@ export class WorkspaceRegistry extends Service { .map(([id]) => id) if (!sameIds(state.workspaceIds, workspaceIds)) { - await this.setState({ initialized: false, workspaceIds }) + await this.setState({ initialized: false, workspaceIds, archivedSessionIds: state.archivedSessionIds }) } - await this.setState({ initialized: true, workspaceIds }) + await this.setState({ initialized: true, workspaceIds, archivedSessionIds: state.archivedSessionIds }) } private validateStoredState(state: WorkspaceDomainState): void { diff --git a/packages/workspace/workspace/src/spec.ts b/packages/workspace/workspace/src/spec.ts index 7b1a6a41d0..ba1b89e39d 100644 --- a/packages/workspace/workspace/src/spec.ts +++ b/packages/workspace/workspace/src/spec.ts @@ -42,11 +42,16 @@ const workspacePendingMutation = z.discriminatedUnion('operation', [ /** * Durable registry state. `initialized` distinguishes a valid empty registry * from one that still needs the header-only history bootstrap; - * `workspaceIds` is the authoritative display order. + * `workspaceIds` is the authoritative display order. `archivedSessionIds` is + * the registry-global archive set layered over workspace accounting: an + * archived session keeps its `sessionIds` slot (unarchiving must restore the + * position), so the set never participates in the one-owner accounting + * invariant. Defaulted so records written before the field parse unchanged. */ export const workspaceDomainState = z.object({ initialized: z.boolean(), workspaceIds: z.array(workspaceId), + archivedSessionIds: z.array(z.string().transform(SessionId)).default([]), pendingMutation: workspacePendingMutation.optional(), }) @@ -64,7 +69,7 @@ export const workspaceDomainSpec = defineDomain({ version: 2, global: { schema: workspaceDomainState, - initial: { initialized: false, workspaceIds: [] }, + initial: { initialized: false, workspaceIds: [], archivedSessionIds: [] }, }, tables: { workspaces: domainTable(workspaceRecord) }, }) diff --git a/packages/workspace/workspace/tests/workspace.spec.ts b/packages/workspace/workspace/tests/workspace.spec.ts index 4576155f3b..ae4567b27e 100644 --- a/packages/workspace/workspace/tests/workspace.spec.ts +++ b/packages/workspace/workspace/tests/workspace.spec.ts @@ -135,9 +135,16 @@ function record(path: string, sessionIds: string[], createdAt = '2026-07-24T00:0 } } +/** + * Media written before archivedSessionIds existed omit the field; keeping the + * fixtures in that shape continuously proves the schema default upgrades them. + */ +type StoredDomainState = Omit + & Partial> + function storedPool( entries: Array<[string, WorkspaceRecord]>, - state: WorkspaceDomainState, + state: StoredDomainState, ): MemoryMediaPool { const pool = new MemoryMediaPool() pool.versions.set('workspace', DOMAIN_VERSION) @@ -185,7 +192,7 @@ describe('WorkspaceRegistry lifecycle and bootstrap', () => { await fiber.await() expect(ctx.workspace.list()).toEqual([]) expect(list).toHaveBeenCalledTimes(1) - expect(storedState(pool)).toEqual({ initialized: true, workspaceIds: [] }) + expect(storedState(pool)).toEqual({ initialized: true, workspaceIds: [], archivedSessionIds: [] }) }) it('bootstraps once from list headers only, in workspace/session createdAt order', async () => { @@ -218,6 +225,7 @@ describe('WorkspaceRegistry lifecycle and bootstrap', () => { expect(storedState(result.pool)).toEqual({ initialized: true, workspaceIds: result.registry.list().map(workspace => workspace.id), + archivedSessionIds: [], }) }) @@ -246,7 +254,7 @@ describe('WorkspaceRegistry lifecycle and bootstrap', () => { const second = await harness({ pool, sessions: [header('late', late, 100)] }) expect(second.list).not.toHaveBeenCalled() expect(second.registry.list()).toEqual([]) - expect(storedState(pool)).toEqual({ initialized: true, workspaceIds: [] }) + expect(storedState(pool)).toEqual({ initialized: true, workspaceIds: [], archivedSessionIds: [] }) }) it('reuses partial records after a bootstrap record write fails', async () => { @@ -476,7 +484,7 @@ describe('WorkspaceRegistry create and lookup', () => { await expect(result.registry.delete(workspace.id)).resolves.toBe(false) expect(result.registry.get(workspace.id)).toBeUndefined() expect(result.registry.list()).toEqual([]) - expect(storedState(result.pool)).toEqual({ initialized: true, workspaceIds: [] }) + expect(storedState(result.pool)).toEqual({ initialized: true, workspaceIds: [], archivedSessionIds: [] }) expect(result.pool.media.get('workspace')!.tables.get('workspaces')!.has(workspace.id)).toBe(false) await expect(realpath(dir)).resolves.toBe(dir) expect(result.list).toHaveBeenCalledTimes(1) @@ -519,6 +527,7 @@ describe('WorkspaceRegistry create and lookup', () => { expect(storedState(pool)).toEqual({ initialized: true, workspaceIds: [], + archivedSessionIds: [], pendingMutation: { operation: 'delete', workspaceId: workspace.id }, }) const reregistered = await first.registry.create(dir) @@ -526,6 +535,7 @@ describe('WorkspaceRegistry create and lookup', () => { expect(storedState(pool)).toEqual({ initialized: true, workspaceIds: [reregistered.id], + archivedSessionIds: [], }) await first.fiber.dispose() @@ -762,7 +772,7 @@ describe('header-validated membership projection', () => { const createRecovery = await harness({ pool: interruptedCreate }) expect(createRecovery.registry.list()).toEqual([]) expect(interruptedCreate.media.get('workspace')!.tables.get('workspaces')!.has(createId)).toBe(false) - expect(storedState(interruptedCreate)).toEqual({ initialized: true, workspaceIds: [] }) + expect(storedState(interruptedCreate)).toEqual({ initialized: true, workspaceIds: [], archivedSessionIds: [] }) const interruptedDelete = storedPool( [[deleteId, record(deleteDir, [])]], @@ -775,7 +785,7 @@ describe('header-validated membership projection', () => { const deleteRecovery = await harness({ pool: interruptedDelete }) expect(deleteRecovery.registry.list()).toEqual([]) expect(interruptedDelete.media.get('workspace')!.tables.get('workspaces')!.has(deleteId)).toBe(false) - expect(storedState(interruptedDelete)).toEqual({ initialized: true, workspaceIds: [] }) + expect(storedState(interruptedDelete)).toEqual({ initialized: true, workspaceIds: [], archivedSessionIds: [] }) const corruptPending = storedPool( [[deleteId, record(deleteDir, [])]], @@ -816,3 +826,74 @@ describe('workspace mutation and status', () => { expect(registry.get(workspace.id)).toBe(workspace) }) }) + +describe('registry-global session archive', () => { + it('archives durably in order, idempotently skips repeats, and leaves accounting untouched', async () => { + const dir = await makeDir('archive-home') + const result = await harness({ sessions: [header('kept', dir, 100), header('gone', dir, 200)] }) + const workspace = result.registry.list()[0]! + expect(result.registry.archivedSessionIds).toEqual([]) + + await result.registry.archiveSession(SessionId('gone')) + expect(result.registry.archivedSessionIds).toEqual(['gone']) + // Archiving is a display-set write: the workspace account keeps the id. + expect(workspace.sessionIds).toContain('gone') + expect(storedState(result.pool).archivedSessionIds).toEqual(['gone']) + const changesAfterFirst = result.changes.filter(change => change.table === '').length + + await result.registry.archiveSession(SessionId('gone')) + expect(result.registry.archivedSessionIds).toEqual(['gone']) + // The idempotent repeat neither rewrites the medium nor emits a change. + expect(result.changes.filter(change => change.table === '').length).toBe(changesAfterFirst) + + await result.registry.archiveSession(SessionId('kept')) + expect(result.registry.archivedSessionIds).toEqual(['gone', 'kept']) + }) + + it('accepts unaccounted and live sessions but rejects unknown ids without writing', async () => { + const dir = await makeDir('archive-strays') + const live = await makeDir('archive-live') + const result = await harness({ + sessions: [header('stray', dir, 100)], + liveSessions: [header('live-only', live, 200)], + }) + await result.registry.archiveSession(SessionId('stray')) + await result.registry.archiveSession(SessionId('live-only')) + expect(result.registry.archivedSessionIds).toEqual(['stray', 'live-only']) + + await expect(result.registry.archiveSession(SessionId('ghost'))) + .rejects.toThrow(/cannot archive session 'ghost'/) + expect(storedState(result.pool).archivedSessionIds).toEqual(['stray', 'live-only']) + }) + + it('propagates a persistence-listing failure instead of reporting an unknown session', async () => { + const result = await harness({ sessions: [] }) + result.list.mockRejectedValueOnce(new Error('persistence backend down')) + // The storage fault is the error — never WorkspaceUnknownSessionError, + // which the API layer would misreport as session-not-found. + await expect(result.registry.archiveSession(SessionId('unlisted'))) + .rejects.toThrow(/persistence backend down/) + expect(storedState(result.pool).archivedSessionIds).toEqual([]) + }) + + it('restores the archive set across restarts and defaults it for pre-field media', async () => { + const dir = await makeDir('archive-restart') + const pool = new MemoryMediaPool() + const first = await harness({ pool, sessions: [header('s1', dir, 100)] }) + await first.registry.archiveSession(SessionId('s1')) + await first.fiber.dispose() + + const second = await harness({ pool, sessions: [header('s1', dir, 100)] }) + expect(second.registry.archivedSessionIds).toEqual(['s1']) + await second.fiber.dispose() + + // A medium written before the field existed parses through the schema default. + const legacyId = WorkspaceId('00000000-0000-4000-8000-00000000000a') + const legacy = storedPool( + [[legacyId, record(dir, [])]], + { initialized: true, workspaceIds: [legacyId] }, + ) + const upgraded = await harness({ pool: legacy }) + expect(upgraded.registry.archivedSessionIds).toEqual([]) + }) +})