diff --git a/.agents/notes/implemented/architecture/2026-07-30-command-row-copy-contract.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-command-row-copy-contract.i18n.yaml new file mode 100644 index 0000000000..330a279b35 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-30-command-row-copy-contract.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-command-row-copy-contract.md +2026-07-30-command-row-copy-contract.md: f6d5199389b3907780c501894e2861e6add85e77 +2026-07-30-command-row-copy-contract.zh.md: 4afaf31640c07e88765060681739f262f322769e diff --git a/.agents/notes/implemented/architecture/2026-07-30-command-row-copy-contract.md b/.agents/notes/implemented/architecture/2026-07-30-command-row-copy-contract.md new file mode 100644 index 0000000000..f6d5199389 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-30-command-row-copy-contract.md @@ -0,0 +1,35 @@ +# Agent Note: Command row copy is split between the row and the handler + +Status: implemented + +English | [中文](2026-07-30-command-row-copy-contract.zh.md) + +## Problem + +The web command row renders `title · summary` from one logged [command lifecycle pair](../../proposed/architecture/2026-07-27-session-projection-and-command-log.md): the title was the dispatched line rebuilt from `command/run` (`/permission workspace-write`) and the summary was `command/done`'s verbatim `text` (`Permission preset: workspace-write.`). Both halves were written without knowing about the other, so the row said the command name twice and its argument twice — the single worst case being the row a user gets for every Access-chip pick. + +## Decision + +The row's two halves have disjoint jobs, and each side is written to its own half alone. + +The row title is the bare command name — no `/`, no arguments. The `/` belongs to the composer's input grammar, not to a settled record, and the argument is not the row's to report: the summary already says what the command did. `GenericCommandCard` keeps the `命令` fallback for a cross-window node whose `command/run` page fell out of the client's window. + +A command handler's settlement `text` therefore never labels its value with the command's own name, because the surface that renders it has already said it. `/permission` returns `preset workspace-write`, bare `current preset workspace-write (available: …)`, and for a bad argument `unknown preset "bogus" (available: …)`. Read as a row this is `permission · preset workspace-write`; read as a standalone line — the TUI appends the same text as a notice — it still states which preset now applies. + +The rule bans the *label*, not the vocabulary. `Permission preset: workspace-write.` lost because `Permission preset:` is a caption for a value whose caption is already the title. A domain noun that happens to contain the command's name is not a caption and stays: `/plan` keeps `Plan mode off.` and `Plan mode on. Use /plan off to leave.` (`plan · Plan mode off.` names the mode, and the tail is an instruction, not an echo), and `/goal` keeps `Goal cleared.`. A handler that finds itself writing ` :` in front of its own value is the case this rule catches. + +The log is unchanged: `command/run` keeps the structured `name`/`args` split, so a richer registered command row can still render arguments from the same node without a second data channel. + +## Alternatives considered + +**Keep the dispatched line as the title and only shorten the settlement text.** The argument would still appear on both sides of the separator (`permission workspace-write · preset workspace-write`), which is the repetition complained about. + +**Drop the settlement text from the collapsed row instead of the arguments.** It inverts the row's value: the outcome is what a durable record is for, and an error text would then have nowhere to land. + +**Have the row strip a leading command name from the settlement text.** Presentation would silently rewrite handler-authored text, and every handler that phrased its outcome differently would defeat the heuristic. + +**Ban the command's name from its settlement text outright, rewriting `/plan` and `/goal` to match.** The broader ban costs more than it buys: `Plan mode off.` and `Goal cleared.` are the clearest sentences those outcomes have, in the row and as standalone TUI notices both, and the shortenings that satisfy a name ban (`off.`, `cleared.`) read as fragments. Captions are the redundancy worth removing. + +## Consequences + +Every command row gets shorter, and the rule scales: a new command's author writes its outcome without knowing which surface renders it, and no surface has to de-duplicate. The cost is that the dispatched arguments leave the collapsed row — while a command is still executing the row shows only its name and `执行中…` — and that the no-caption rule is a convention the reviewer enforces, not a gate. The `/permission` texts are pinned by the permission package's command tests, and the assembled row copy by the [seeded-history](../../../../apps/web/tests/snapshots/seeded-history/command-row.expected.md) web golden, which reaches a real settled command row keylessly because `/permission` runs entirely on the host. diff --git a/.agents/notes/implemented/architecture/2026-07-30-command-row-copy-contract.zh.md b/.agents/notes/implemented/architecture/2026-07-30-command-row-copy-contract.zh.md new file mode 100644 index 0000000000..4afaf31640 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-30-command-row-copy-contract.zh.md @@ -0,0 +1,35 @@ +# Agent Note: Command row copy is split between the row and the handler + +Status: implemented + +[English](2026-07-30-command-row-copy-contract.md) | 中文 + +## Problem + +Web 命令行由一对落库的[命令生命周期事件](../../proposed/architecture/2026-07-27-session-projection-and-command-log.md)渲染出 `标题 · 摘要`:标题是由 `command/run` 重建的分派命令行(`/permission workspace-write`),摘要是 `command/done` 的原样 `text`(`Permission preset: workspace-write.`)。两半各自成文、互不知情,于是一行里命令名出现两次、参数也出现两次——最糟的一例正是用户每次用 Access chip 切换权限时得到的那一行。 + +## Decision + +命令行两半的职责互不重叠,各自只按自己那一半来写。 + +行标题就是裸命令名——没有 `/`,也没有参数。`/` 属于编辑器的输入语法,不属于一条已落定的记录;参数也不该由这一行来报告:摘要已经说清了这条命令做了什么。对于 `command/run` 那一页已滑出客户端窗口的跨窗口节点,`GenericCommandCard` 仍保留 `命令` 兜底标题。 + +因此,命令 handler 的落定 `text` 绝不用命令自身的名字给自己的值加标签——渲染它的界面已经说过一次了。`/permission` 返回 `preset workspace-write`,裸调用时返回 `current preset workspace-write (available: …)`,参数非法时返回 `unknown preset "bogus" (available: …)`。作为一行读是 `permission · preset workspace-write`;作为独立一句读——TUI 把同一段 text 作为通知追加——它依然说明了当下生效的是哪个预设。 + +这条规则禁的是*标签*,不是用词。`Permission preset: workspace-write.` 之所以出局,是因为 `Permission preset:` 是给一个值加的题头,而这个题头正是标题本身。恰好含有命令名的领域名词不是题头,因此保留:`/plan` 仍返回 `Plan mode off.` 与 `Plan mode on. Use /plan off to leave.`(`plan · Plan mode off.` 说的是那个模式,句尾是一条指引,不是回声),`/goal` 仍返回 `Goal cleared.`。真正被这条规则拦下的,是 handler 在自己的值前面写出 `<命令名> <名词>:` 的那一类。 + +日志本身未变:`command/run` 保留结构化的 `name`/`args` 拆分,因此更丰富的已注册命令行仍可从同一个节点渲染参数,无需第二条数据通道。 + +## Alternatives considered + +**保留分派命令行作标题,只缩短落定文案。** 参数仍会出现在分隔点两侧(`permission workspace-write · preset workspace-write`),而这正是被指出的重复。 + +**从折叠行中去掉落定文案,而不是去掉参数。** 这颠倒了这一行的价值:持久记录存在的意义就是结果,而错误文案将无处落脚。 + +**由这一行从落定文案里剥掉开头的命令名。** 呈现层会悄悄改写 handler 写就的文案,而任何换一种措辞表达结果的 handler 都会让这套启发式失效。 + +**彻底禁止命令名出现在自己的落定文案里,并把 `/plan`、`/goal` 一并改写。** 这种更宽的禁令代价大于收益:无论在行上还是作为独立的 TUI 通知,`Plan mode off.` 与 `Goal cleared.` 都是这些结果最清楚的句子,而满足"禁名字"所需的缩写(`off.`、`cleared.`)读起来只是残句。值得去掉的冗余是题头。 + +## Consequences + +每一条命令行都变短了,而且这条规则可扩展:新命令的作者写结果时无需知道由哪个界面渲染,任何界面也都不必再去重。代价是分派参数离开了折叠行——命令仍在执行时,行上只有名字和 `执行中…`——以及"不加题头"这条规则是靠评审执行的约定,而非门禁。`/permission` 的文案由 permission 包的命令测试钉住,装配后的行文案由 [seeded-history](../../../../apps/web/tests/snapshots/seeded-history/command-row.expected.md) web 预期输出钉住:因为 `/permission` 完全在 host 上执行,它能无密钥地抵达一条真实的落定命令行。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.i18n.yaml new file mode 100644 index 0000000000..dbb3928f0c --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.md +2026-07-30-approval-panel-command-cap.md: 941f7eda187f263f2d8af6aa643d493c92a3669b +2026-07-30-approval-panel-command-cap.zh.md: 939a700934f6467947028d988da9a694169e203e diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.md b/.agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.md new file mode 100644 index 0000000000..941f7eda18 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.md @@ -0,0 +1,52 @@ +# Agent Note: The approval takeover shares the composer's text cap + +Status: implemented + +English | [中文](2026-07-30-approval-panel-command-cap.zh.md) + +## Problem + +The approval panel is a composer takeover: while a sandbox escalation waits, it replaces the InputBar in the composer seat with the model's justification, the paired command, and a refuse/allow row. Both texts are unbounded model output, and the card had no height cap. A long command — the realistic shape, since escalation happens on the command the sandbox just denied, and a denied command is often a long inline write — grew the card until the action row left the viewport. The user could read the request and not answer it: the buttons existed, off screen, in a sticky footer that had already used the whole column. + +The InputBar the panel replaces has always been capped (14 lines, then the textarea scrolls), so the takeover was also the one composer state that could grow without limit — the seat's height jumped on election and jumped back on answer. + +## Decision + +The panel's justification and command move into one scroll region (`data-approval-scroll`) capped at the same height as the composer's draft area; the amber strip and the action row sit outside it, so both buttons are in the card at every content length. + +The cap is one value with two consumers, declared as `--dsh-composer-text-max-height: 336px` on `ConversationRoot`'s `.composerSeat` — the composer chain's only shared ancestor, since the fallback InputBar and an elected takeover render as siblings. `InputBar`'s mirror and the panel's scroll region both read it, so the seat cannot cap its two states differently: what the designer asked for ("unify it with the input box's max height") is now a fact of the stylesheet rather than a number repeated in two files. The region is `box-sizing: border-box` so the cap is its outer height, the same box the composer's draft area occupies. + +The region is a tab stop (`tabIndex={0}`, named `role="group"`). Unlike the question composer's scroll body, whose option rows are focusable and pull the container along, this one holds nothing but text: without its own tab stop a keyboard-only user could reach the buttons and never the command's tail, and approve what they could not finish reading. + +The panel's card rebinds `--dsh-scrollbar-thumb{,-hover}` to the l2 pair, as every scrolling surface on an elevated background must ([scrollbar contract](../../../../packages/client/ui-theme/src/styles/scrollbar.css)). + +## Alternatives considered + +**Cap the whole card instead of the text region.** One declaration, no restructuring, and it reads as the literal "same max height as the input box". Rejected because the card holds the strip and the action row: at 336px total the justification and command would get ~250px, less room than the draft they replace, and the numbers would only agree by coincidence of the strip's height. Capping the text region makes both seats top out at the same text height, which is the property that keeps the footer from jumping. + +**Cap against the viewport like the question composer (`min(60vh, 520px)`).** The sibling takeover already does this, so it is the local precedent. Rejected because the designer's request was parity with the InputBar, and the two takeovers are not the same shape: the question composer's scroll content is a list of options the user must compare, which wants as much viewport as it can get, while the approval panel's is one command the user skims before deciding. A viewport-relative cap would also make the seat's height jump on election again, in the other direction. + +**Ellipsize or truncate the command.** No scroll region, no cap, and the buttons stay put. Rejected because the command is the thing being approved: hiding its tail asks the user to consent to text they cannot read. Truncation is also unrecoverable here — the panel is the whole approval UI, so there is no "show more" surface to fall back to. + +**Leave the action row inside the scroll region and cap the region.** Fewer moving parts than pinning the row. Rejected because it reproduces the defect inside the card: the buttons scroll out of the region, and the user has to discover a scrollbar to reach them. + +## Consequences + +- A long command scrolls inside the card and the refuse/allow buttons stay on screen. Measured on the built client at 900x1000 and 900x700: the region reports `scrollHeight` past `clientHeight`, and both buttons stay inside the card and inside the viewport. +- Electing the takeover no longer changes how tall the composer seat can get, so the transcript above it does not reflow by hundreds of pixels when an approval arrives or resolves. +- The InputBar's 14-line cap now resolves through a custom property inherited from `.composerSeat`. Rendering the bar outside that seat would drop the declaration (an unresolved `var()` with no fallback), so a future composer host has to carry the property — which is why it is declared on the shared seat rather than the app root. +- The scenario's recorded command is a 200-token blob, far longer than a round trip needs. That cost is deliberate: the cap is unfalsifiable without content that passes it, and the model compresses any regular payload (the first recording turned "alpha 400 times" into `printf 'alpha %.0s' {1..400}`, a one-line command that proves nothing). + +## Verification + +`apps/web/tests/approval-composer.e2e.ts` drives the real composition: a read-only session, a denied write, the model's escalation retry, and the answer clicked through the panel. The geometry assertion runs on the live panel at two viewport heights and is guarded against holding vacuously — the region must actually be scrolling, and the measured cap must equal the composer's own, which the test reads off the live textarea before sending rather than hardcoding the px value. + +Confirmed both directions against the built client. With the cap reverted, the region reports `scrolls: false` and grows to the command's full height (1798px for the recorded blob at 900x1000, against 336px capped); at 900x700 the card is 680px tall against a 700px viewport and the action row's bottom lands at y=749 — below the fold, the designer's report exactly. With the cap restored the scenario passes in replay. + +Reproducing the off-screen buttons needs a card taller than the scrollport, not merely a tall card. The composer seat is `position: sticky; bottom: 0`, so while the card still fits it stays pinned to the viewport bottom and the buttons remain visible — at 900x1000 the uncapped card ate the whole transcript yet kept its action row on screen. Only once the card outgrows the scrollport does sticky stop being able to hold the bottom edge, and the row goes under. + +The geometry block and the golden are replay-only, so record mode reaches the fixture write instead of aborting on layout. + +The scenario keeps exactly one golden — the waiting panel — and asserts the answered state on the world instead (the decided outcome, the file the escalated command wrote, `DONE`, the panel gone, the composer re-enabled). An answered-transcript golden was recorded first and failed on Linux CI: the denied first attempt renders the OS's own refusal, and that text is platform-specific (`bash: notes.txt: Operation not permitted` on macOS against `bash: line 1: notes.txt: Read-only file system` on Linux). Any scenario whose transcript contains a sandbox-denied command inherits that, so the denial belongs in assertions, never in a golden. + +The panel ships as a client-module bundle: `pnpm run build:web` alone does not pick up a change to `ApprovalPanel.module.css` or a new `data-` hook in `ApprovalPanel.tsx` — the package build must run first, or the browser lane asserts against an older client than the tree. diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.zh.md b/.agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.zh.md new file mode 100644 index 0000000000..939a700934 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.zh.md @@ -0,0 +1,52 @@ +# Agent Note: 审批接管面板与输入框共用同一文本高度上限 + +Status: implemented + +[English](2026-07-30-approval-panel-command-cap.md) | 中文 + +## 问题 + +审批面板是一次 composer 接管:当一次沙箱越权申请处于等待状态时,它在 composer 容器中取代 InputBar,展示模型给出的理由、与之配对的命令,以及一行拒绝/允许按钮。这两段文本都是长度不受限的模型输出,而卡片当时没有任何高度上限。命令一长——而这正是现实中的常见形态,因为越权申请针对的就是沙箱刚刚拒绝的那条命令,而被拒绝的命令往往是一次很长的内联写入——卡片就会一直变高,直到操作按钮行离开视口。用户能读到这次申请,却无法回应它:按钮存在,只是在屏幕之外,位于一个已经占满整列的吸底容器里。 + +被它取代的 InputBar 一直是有上限的(14 行,之后由 textarea 自行滚动),因此这次接管也是 composer 唯一一个可以无限增高的状态——被选中时容器高度骤增,回应之后又骤降。 + +## 决策 + +面板的理由与命令移入同一个滚动区域(`data-approval-scroll`),其高度上限与 composer 的草稿区完全相同;琥珀色状态条与操作按钮行位于该区域之外,因此无论内容多长,两个按钮都留在卡片内。 + +这个上限是一个值、两个消费者,以 `--dsh-composer-text-max-height: 336px` 声明在 `ConversationRoot` 的 `.composerSeat` 上——它是 composer 链唯一的共同祖先,因为兜底的 InputBar 与被选中的接管面板是兄弟节点。`InputBar` 的 mirror 与面板的滚动区域都读取它,于是同一个容器不可能给它的两种状态设出不同上限:设计同学要求的"可以跟输入框最大高度统一",如今是样式表中的一个事实,而不是抄在两个文件里的一个数字。该区域取 `box-sizing: border-box`,因此上限指的是它的外框高度,与 composer 草稿区占据的是同一个盒子。 + +该区域自身是一个 Tab 停靠点(`tabIndex={0}`,带名称的 `role="group"`)。提问 composer 的滚动体不需要这样做——它的选项行本身可聚焦,会把容器一起带过去;而这里除文本之外别无内容:没有自己的停靠点,仅用键盘的用户能走到按钮却走不到命令尾部,于是可能批准了自己没读完的东西。 + +面板卡片把 `--dsh-scrollbar-thumb{,-hover}` 重新绑定到 l2 那一对,这是每一个位于高层表面上的滚动区域都必须做的([滚动条约定](../../../../packages/client/ui-theme/src/styles/scrollbar.css))。 + +## 曾考虑的替代方案 + +**给整张卡片设上限,而不是给文本区域设。** 一条声明,不需要重构结构,而且它读起来就是字面意义上的"与输入框相同的最大高度"。之所以否决:卡片还装着状态条和操作按钮行——总高 336px 时,理由与命令只能分到约 250px,比它们所取代的草稿区更矮,而且两边数字能对上纯属状态条高度的巧合。给文本区域设上限,才能让两种状态在同一文本高度处收住,而这正是让底部不再跳动的那条性质。 + +**像提问 composer 那样按视口设上限(`min(60vh, 520px)`)。** 同为接管面板的兄弟组件已经这么做了,因此这是本地既有先例。之所以否决:设计同学的要求是与 InputBar 对齐,而两个接管面板形态并不相同——提问 composer 的滚动内容是一组需要用户互相比较的选项,能占多少视口就该占多少;审批面板的滚动内容则是一条命令,用户在决定之前扫读即可。按视口设上限还会让容器高度在被选中时再次跳动,只是方向相反。 + +**对命令做省略号或截断处理。** 不需要滚动区域,不需要上限,按钮也不会移位。之所以否决:命令正是被审批的对象,隐去它的尾部等于要求用户为自己读不到的文本背书。在这里截断还是不可恢复的——面板就是审批的全部界面,没有"展开更多"的落脚处。 + +**把操作按钮行留在滚动区域内,只给该区域设上限。** 比把按钮行固定住少动几处。之所以否决:这会把缺陷搬进卡片内部——按钮滚出该区域,用户得先发现有滚动条才能碰到它们。 + +## 后果 + +- 长命令在卡片内滚动,拒绝/允许按钮留在屏幕内。在构建产物客户端上于 900x1000 与 900x700 实测:该区域报告的 `scrollHeight` 超过 `clientHeight`,两个按钮都留在卡片内、也都留在视口内。 +- 选中接管面板不再改变 composer 容器能达到的高度,因此审批到来或解决时,上方的会话流不会有数百像素的重排。 +- InputBar 的 14 行上限现在通过一个自 `.composerSeat` 继承而来的自定义属性解析。把输入栏渲染到该容器之外会丢掉这条声明(一个没有兜底值的未解析 `var()`),因此未来的 composer 宿主必须带上这个属性——这也正是它声明在共享容器上、而不是应用根节点上的原因。 +- 该场景录制的命令是一段 200 个 token 的字符块,远超一次往返所需。这个代价是有意付出的:没有能越过上限的内容,这个上限无法被证伪,而模型会把任何规整的载荷压缩掉(第一次录制时,模型把"alpha 重复 400 次"写成了 `printf 'alpha %.0s' {1..400}`,一条什么也证明不了的单行命令)。 + +## 验证 + +`apps/web/tests/approval-composer.e2e.ts` 驱动的是真实组合:一个只读会话、一次被拒绝的写入、模型的越权重试,以及在面板上点击完成的回应。几何断言在两个视口高度上针对活动面板执行,并有守卫防止它空洞地成立——该区域必须确实处在滚动状态,且实测上限必须等于 composer 自身的上限,后者由测试在发送之前从活动 textarea 上读出,而不是把该像素值写死。 + +在构建产物客户端上双向确认过。撤销上限后,该区域报告 `scrolls: false`,并长到命令的完整高度(900x1000 下,录制的字符块为 1798px,而设上限后为 336px);在 900x700 下卡片高 680px、视口高 700px,操作按钮行底边落在 y=749——正在折叠之下,与设计同学的反馈完全一致。恢复上限后,该场景在回放模式下通过。 + +要复现按钮跑到屏幕外,需要的是比滚动视口更高的卡片,而不只是一张很高的卡片。composer 容器为 `position: sticky; bottom: 0`,因此在卡片尚能容纳时它会一直吸附在视口底部,按钮仍然可见——在 900x1000 下,未设上限的卡片吃掉了整个会话流,却仍把操作按钮行留在屏幕内。只有当卡片长过滚动视口,sticky 才再也无法守住底边,按钮行随之沉入折叠之下。 + +几何断言块与 golden 仅在回放模式下执行,这样录制模式才能走到写入 fixture 那一步,而不是在布局检查处中断。 + +该场景只保留一份 golden —— 等待中的面板;回应之后的状态改为对世界作断言(决策结果、越权命令写出的那个文件、`DONE`、面板消失、输入框重新可用)。最初还录了一份"已回应会话流"的 golden,它在 Linux CI 上失败了:第一次被拒绝的尝试渲染的是操作系统自己的拒绝文本,而这段文本因平台而异(macOS 为 `bash: notes.txt: Operation not permitted`,Linux 为 `bash: line 1: notes.txt: Read-only file system`)。任何会话流中含有被沙箱拒绝命令的场景都会继承这一点,因此这类拒绝只能进断言,绝不能进 golden。 + +该面板以客户端模组包的形式发布:单跑 `pnpm run build:web` 不会带上对 `ApprovalPanel.module.css` 的改动,也不会带上 `ApprovalPanel.tsx` 中新增的 `data-` 钩子——必须先执行包构建,否则浏览器测试通道会对着一个比工作树更旧的客户端做断言。 diff --git a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.i18n.yaml index 47ce2b206e..b5068fb402 100644 --- a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md -2026-07-30-deepseek-onboarding-credential-setup.md: 253800b7d94c80f1809c211ad0b3788b4ae4e07c -2026-07-30-deepseek-onboarding-credential-setup.zh.md: 2dd4de8185d0b6c8c33ad381a1b1aa358b07e872 +2026-07-30-deepseek-onboarding-credential-setup.md: ed53ffe64d3ba27e8746d58ad84d1a4c401f6ce4 +2026-07-30-deepseek-onboarding-credential-setup.zh.md: 340728ab9348e0132954e403f9bdbf561e340247 diff --git a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md index 253800b7d9..ed53ffe64d 100644 --- a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md +++ b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md @@ -10,13 +10,13 @@ The [web configuration plane](../architecture/2026-07-30-web-config-plane.md) ma ## Decision -**One readiness projection owns both Models and onboarding facts.** `ui-models` keeps a single store that joins `llm.providers({})`, redacted `settings.describe({})`, and batched `credentials.describe({refs})`. The onboarding projection selects the `deepseek-official` configurable-provider entry, resolves its `settingsNs` and `settingsPath`, reads the effective `apiKeyEnv`, and evaluates the matching credential descriptor. A configured literal `apiKey` secret sidecar is also ready, so compatibility configuration does not trigger a false prompt; a configured process-environment credential is ready and remains read-only. +**One readiness projection owns both Models and onboarding facts.** `ui-models` keeps a single store that joins `llm.providers({})`, redacted `settings.describe({})`, and batched `credentials.describe({refs})`. The onboarding projection selects the `deepseek-official` configurable-provider entry owned by the `llm-deepseek` namespace and empty settings path, reads the effective `apiKeyEnv`, and evaluates the matching credential descriptor. A live route with the same provider id but no matching configurable-provider declaration is adapter-absent for onboarding. A configured literal `apiKey` secret sidecar is also ready, so compatibility configuration does not trigger a false prompt; a configured process-environment credential is ready and remains read-only. **The settings shell contributes ordering and navigation, not provider policy.** `ui-settings` declares a root-scoped `settings.onboarding` list slot and mounts one ordered step at a time while the current surface is the empty Hero. The active registrant receives `complete()` and a private `openSection(id)` callback; completion transfers ownership to the next entry. `ui-models` registers the DeepSeek step through the same declaration-aware deferred-registration path as its Models section, so plugin load order does not become a contract and independently contributed dialogs cannot stack. The product-wide welcome step that precedes it is owned separately by [the versioned welcome decision](2026-07-30-versioned-gui-welcome-onboarding.md). **The prompt routes to the one credential editor.** A mounted, active adapter with a resolved, writable, unconfigured reference presents one action that opens Settings on Models. The existing DeepSeek setup card there exclusively owns the password input, `credentials.set({ref, value})`, write failures, and post-write refresh; the onboarding overlay never holds or submits a secret. An unavailable settings or credential capability keeps its deployment diagnostic and routes to the same page, while an absent adapter remains skipped because navigation cannot mount a Cordis plugin. -**Unavailable states stay honest.** An absent configurable-provider entry completes the step because navigation cannot repair the composition. A present provider whose settings or credential capability cannot be resolved renders an actionable deployment diagnostic; a failed initial join names the connection problem and leads to the Models retry surface. Configure later completes only this mounted coordinator pass and writes no completion fact. Settings, credential, provider-topology, and connection invalidations all refresh the shared join, so an external credential update completes an open step without a reload. +**Unavailable states do not capture the product.** An absent configurable-provider entry, inactive route, failed initial join, read-only deployment, or unresolved settings or credential capability completes the step without rendering because the onboarding action cannot repair that state. The Models page remains the deployment diagnostic and retry surface. Configure later completes a missing-credential step for the current mounted coordinator pass and writes no completion fact. Settings, credential, provider-topology, and connection invalidations all refresh the shared join, so an external credential update completes an open step without a reload. ## Alternatives considered @@ -30,4 +30,4 @@ The [web configuration plane](../architecture/2026-07-30-web-config-plane.md) ma ## Consequences -The ordered flow leads from the product welcome step to the shipped adapter's existing editor without restarting: a keyless browser test boots the real Web composition under an isolated harness home, acknowledges the welcome notice, follows the DeepSeek step to Models, stores a generated key through that page into the home's `.env`, verifies no key reaches DOM, ARIA, or browser console output, and confirms the running page reports configured. Pure readiness and React tests pin literal, file, process-environment, missing-provider, missing-capability, navigation, cancellation, external-invalidation, and coordinator-transfer behavior. The flow deliberately inherits the configuration plane's documented base limitations rather than adding local secret storage, redaction, or settings replacement workarounds. +The ordered flow leads from the product notice to the shipped adapter's existing editor without restarting: a keyless browser test boots the real Web composition under an isolated harness home, acknowledges the notice, follows the DeepSeek page to Models, stores a generated key through that page into the home's `.env`, verifies no key reaches DOM, ARIA, or browser console output, and confirms the running page reports configured. The full keyless Web replay lane also pins that a non-configurable replay route with the same provider id does not block unrelated journeys. Pure readiness and React tests pin literal, file, process-environment, missing-provider, missing-capability, navigation, cancellation, external-invalidation, and coordinator-transfer behavior. The flow deliberately inherits the configuration plane's documented base limitations rather than adding local secret storage, redaction, or settings replacement workarounds. diff --git a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.zh.md b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.zh.md index 2dd4de8185..340728ab93 100644 --- a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.zh.md @@ -10,13 +10,13 @@ Status: implemented ## 决策 -**Models 与首次使用引导共享同一个就绪状态投影。**`ui-models` 维护一个 store,把 `llm.providers({})`、脱敏后的 `settings.describe({})` 和批量调用的 `credentials.describe({refs})` 联接为同一份状态。首次使用投影选取 `deepseek-official` 可配置提供方条目,解析其 `settingsNs` 与 `settingsPath`,读取生效的 `apiKeyEnv`,并检查对应的凭据描述符。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,也会判定为就绪,兼容配置因此不会误触发浮层;通过进程环境提供的凭据若已配置,同样判定为就绪并保持只读。 +**Models 与首次使用引导共享同一个就绪状态投影。**`ui-models` 维护一个 store,把 `llm.providers({})`、脱敏后的 `settings.describe({})` 和批量调用的 `credentials.describe({refs})` 联接为同一份状态。首次使用投影选取由 `llm-deepseek` namespace 与空 settings path 持有的 `deepseek-official` 可配置提供方条目,读取生效的 `apiKeyEnv`,并检查对应的凭据描述符。同 provider id 但没有匹配可配置提供方声明的存活路由,在首次使用引导中视为适配器缺失。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,也会判定为就绪,兼容配置因此不会误触发页面;通过进程环境提供的凭据若已配置,同样判定为就绪并保持只读。 **设置外壳只贡献排序与导航,不持有提供方策略。** `ui-settings` 声明一个根作用域的 `settings.onboarding` list slot,并在当前界面为空白 Hero 时,每次只挂载一个有序步骤。当前注册方会收到 `complete()` 和私有 `openSection(id)` 回调;完成当前步骤后,所有权转交给下一项。`ui-models` 沿用 Models 分区所使用、感知 slot 声明的延迟注册路径来注册 DeepSeek 步骤,因此插件加载顺序不会成为契约,独立贡献的对话框也无法堆叠。排在它之前的产品级欢迎步骤由[版本化欢迎决策](2026-07-30-versioned-gui-welcome-onboarding.md)单独持有。 -**浮层只负责跳转到唯一的凭据编辑器。**适配器已挂载且处于活跃状态,其引用可解析、可写但尚未配置时,界面会显示一个操作按钮,用于打开「设置」的 Models 分区。该分区已有的 DeepSeek 设置卡片全权负责密码输入框、`credentials.set({ref, value})`、写入失败处理和写入后刷新;首次使用浮层绝不持有或提交 secret。设置或凭据能力不可用时会保留部署诊断,并提供前往同一页面的入口;适配器缺失时仍直接跳过,因为导航无法挂载 Cordis 插件。 +**首次使用页面只负责跳转到唯一的凭据编辑器。**适配器已挂载且处于活跃状态,其引用可解析、可写但尚未配置时,界面会显示一个操作按钮,用于打开「设置」的 Models 分区。该分区已有的 DeepSeek 设置卡片全权负责密码输入框、`credentials.set({ref, value})`、写入失败处理和写入后刷新;首次使用页面绝不持有或提交 secret。 -**不可用状态如实呈现。** 可配置提供方条目缺失时会完成当前步骤,因为导航无法修复当前组合。提供方存在,但设置或凭据能力无法解析时,界面会显示可采取操作的部署诊断;初始联接失败时会明确指出连接问题,并引导前往 Models 的重试界面。「稍后配置」只会完成协调器当前这一次挂载流程,不写入任何完成状态。设置、凭据、提供方拓扑和连接失效事件都会刷新共享联接,因此外部凭据更新无需重新加载页面即可完成已打开的步骤。 +**不可用状态不会占住产品。** 可配置提供方条目缺失、路由不活跃、初始联接失败、部署只读或设置/凭据能力无法解析时,都会直接完成而不渲染该步骤,因为首次使用引导无法修复这些状态。Models 页仍是部署诊断与重试界面。「稍后配置」只会完成协调器当前这一次缺少凭据的步骤,不写入任何完成状态。设置、凭据、提供方拓扑和连接失效事件都会刷新共享联接,因此外部凭据更新无需重新加载页面即可完成已打开的步骤。 ## 曾考虑的替代方案 @@ -30,4 +30,4 @@ Status: implemented ## 后果 -有序流程从产品欢迎步骤开始,无需重启即可引导用户前往随产品提供的适配器已有的编辑器:无密钥浏览器测试在隔离的 harness 家目录下启动真实 Web 组合,确认欢迎通知后依照 DeepSeek 步骤前往 Models,通过该页面把生成的密钥存入该目录的 `.env`,验证密钥未进入 DOM、ARIA 或浏览器控制台输出,并确认运行中的页面报告已配置。纯就绪状态测试与 React 测试固化了字面量凭据、文件凭据、进程环境凭据、提供方缺失、能力缺失、导航、取消、外部失效和协调器移交行为。该流程直接继承配置平面已记录的基础限制,不会另加局部的机密存储、脱敏或设置替换变通方案。 +有序流程从产品声明页开始,无需重启即可引导用户前往随产品提供的适配器已有的编辑器:无密钥浏览器测试在隔离的 harness 家目录下启动真实 Web 组合,确认声明后依照 DeepSeek 页面前往 Models,通过该页面把生成的密钥存入该目录的 `.env`,验证密钥未进入 DOM、ARIA 或浏览器控制台输出,并确认运行中的页面报告已配置。完整的无密钥 Web 回放也固定了同 id 的不可配置回放路由不会阻塞无关流程。纯就绪状态测试与 React 测试固化了字面量凭据、文件凭据、进程环境凭据、提供方缺失、能力缺失、导航、取消、外部失效和协调器移交行为。该流程直接继承配置平面已记录的基础限制,不会另加局部的机密存储、脱敏或设置替换变通方案。 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-result-card.i18n.yaml new file mode 100644 index 0000000000..3b30da4b96 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-web-result-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-result-card.md +2026-07-30-web-result-card.md: deec27832aba2d5d868889f7306cbaef4f0b90b4 +2026-07-30-web-result-card.zh.md: 037e029332fbb665d90860d7e11c2fd117e6eb45 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card.md b/.agents/notes/implemented/feature/2026-07-30-web-result-card.md new file mode 100644 index 0000000000..deec27832a --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card.md @@ -0,0 +1,44 @@ +# Agent Note: Web result card — a structured render intent for web_search and web_fetch + +Status: implemented + +English | [中文](2026-07-30-web-result-card.zh.md) + +## Problem + +The `web_search` and `web_fetch` tools each declared a generic pending card (`presentCall`, `kind: 'search'`/`'fetch'`) but no `presentResult`, so a completed web call reached a UI only as the model-facing render text. For a web frontend that wants to render a citation list or a fetch summary, that text is lossy: `web_search`'s render collapses each source's `title`, `snippet`, and `publishedAt` into one free-text markdown line labelled by title OR hostname (`formatSearchOutput` in `packages/web/tool-web/src/search.ts`), so reparsing the render cannot recover the per-source fields; and `web_fetch`'s render carries `url` and `statusCode` only in a header line. The render-intent contract ([tagged union](../architecture/2026-07-02-tool-render-intent-union.md)) had no arm a web tool could declare to carry a structured result. + +## Decision + +Add one `card: 'web'` result arm to `ToolResultView` (`packages/core/tools/src/presentation.ts`), a union `WebResultView = WebSearchResultView | WebFetchResultView` discriminated by a `kind: 'search' | 'fetch'` field, plus a `WebSource` shape for one citeable source. Both tools now declare `presentResult`. + +One tag with a `kind` discriminant, not two tags. Both calls are web retrieval and a web frontend renders them with one component family (a retrieval card whose body differs by kind), so a shared `card` keeps every card consumer's switch to one added arm and lets the frontend branch on `kind` inside it. Two tags would force every present and future consumer to add two arms for what is one visual family. The `kind` values match the two tools' existing generic call-view `kind`s, so a call and its result read as the same category. + +`presentationMeta` carries what render text cannot. The structured result object a tool returns from `execute` does NOT reach a client over the wire — only the model-facing `render` text and, when declared, the `output.presentationMeta` JSON projected onto the `tool/result` event's `meta` do. For `web_search` the meta is the ONLY faithful route to `{url, title?, snippet?, publishedAt?}`: the render collapses those fields into one lossy free-text line, so a consumer cannot reparse them. For `web_fetch` the meta is a smaller but real gain: `url`/`statusCode` are recoverable from the deterministic `Fetched (HTTP )` header line, but `truncated` is the effective truncation — provider cap, pre-conversion source cut, or the deployment's `fetchMaxOutputChars` output cap — which a client cannot recompute because it does not know that cap. The fetch card and the model-facing text derive `truncated` from one shared `renderFetchOutput(result, maxOutputChars)` helper, so the card never disagrees with the footer the model saw. This mirrors the write/edit diff template (`packages/fs/tool-fs/src/diff.ts`): a `*MetaFromValue` projector feeds `output.presentationMeta`, and a `*MetaFromResult` narrower reads `result.meta` back with a defensive fallback to the generic card. `web_fetch`'s body is already markdown in the result content, so it is not duplicated into meta. + +Neither result view carries a `content` copy. A UI that does not render the structured `web` card falls back to the raw `tool/result` content. The TUI does exactly this: it renders no structured web body, and its transcript renderer routes a `web` view's fallback content through the same dim Markdown path as a generic card's content (`packages/ui/tui/src/components/transcript.ts`, where both `render` and `renderBody` narrow the `generic` arm to `view.content` and give a `web` view the same `this.result?.content` fallback). Copying the result content into the view would duplicate up to `fetchMaxOutputChars` characters on the same delivered frame for no gain (the same rejection the meta section applies to the fetch body), so the views omit it and the fallback path renders the identical text. Each view sets its result-state `title` from the call args (`args.query` / `args.url`) so a window-truncated replay that dropped the call head still has a title, the way write/edit reset title at result time. + +`presentResult` returns `undefined` (the generic card) on an error result and on absent or malformed `meta`, because presentation runs on replay of arbitrary logged results (possibly from an older schema) and must never throw. The narrowers validate every field defensively; an empty source list is valid meta, not malformed. + +## Consequences + +The web frontend consumer is a separate later PR: this PR adds the contract arm and makes the two tools emit it, with no client-side rendering. The one observable change is that the `web_search`/`web_fetch` `tool/result` events now persist a `data.meta` payload (the `web-fetch` keyless snapshot is refreshed accordingly); the model-facing render text and the TUI presentation are unchanged (the TUI falls back to the same result content). The assembled-application transcript snapshot that exercises a `web` card belongs to the consumer PR that renders it, delivered there. Any existing `ToolResultView` consumer that switches exhaustively must add a `web` arm; the TUI does not switch exhaustively and needs none. `apiproxy`'s session schema already accepts any `card` string (`packages/host/apiproxy/src/api/sessions.schema.ts`), so the new view crosses the wire without a schema change. + +A future web tool that wants this card declares `presentResult` returning a `card: 'web'` view with its own `kind`; adding a third `kind` is a union edit plus the frontend's branch, not a new card tag. + +## Alternatives considered + +**Two card tags (`web-search`, `web-fetch`).** Rejected: it doubles the arm count at every card consumer for one visual family, and the two shapes already share enough (a titled retrieval card with fallback content) that a `kind` discriminant expresses the difference without a second tag. + +**Reparse the render text in `presentResult` instead of projecting meta.** Rejected for `web_search`: the render's source list is lossy (title-or-hostname label, snippet and date concatenated into free text), so reparsing cannot faithfully recover the structured fields. `presentationMeta` is the only route that preserves them. + +**Carry the fetch body in meta, or copy the result content into either view.** Rejected: the body is already the model-facing markdown in the result content, and duplicating it into meta or into a view `content` field would double the persisted or delivered payload for no gain; a UI without the `web` capability falls back to the existing result content, which is the same text. + +## Testing + +`packages/web/tool-web/tests/tool-web.spec.ts` covers, per-file to the 100% gate: `searchMetaFromValue`/`fetchMetaFromValue` projection including omission of absent optional fields, and the fetch `truncated` projection agreeing with the render footer both when only the output cap cut the body and when nothing did; `searchMetaFromResult`/`fetchMetaFromResult` narrowing with a round-trip and every malformed-shape rejection (non-object, wrong field types, a malformed source entry) plus the empty-source-list accept; `presentSearchResult`/`presentFetchResult` typed views including the args-derived title, the absence of a `content` copy, the truncated signal, the error-result fallback, and the malformed-meta fallback; and two real-registry executions asserting the tool projects the meta onto `result.meta` and its registered `presentResult` derives the `card: 'web'` view. + +## 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 `web` arm. +- [Web terminal card](2026-07-28-web-terminal-card.md) — the precedent that carried the bash `terminal` render intent to the browser; the web frontend consumer of this arm is its analogue, deferred to a later PR. diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-result-card.zh.md new file mode 100644 index 0000000000..037e029332 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card.zh.md @@ -0,0 +1,44 @@ +# Agent Note: Web result card — a structured render intent for web_search and web_fetch + +Status: implemented + +[English](2026-07-30-web-result-card.md) | 中文 + +## Problem + +`web_search` 与 `web_fetch` 工具各自声明了一个 generic 待定卡片(`presentCall`,`kind: 'search'`/`'fetch'`),但没有 `presentResult`,因此一个已完成的 web 调用抵达 UI 时只剩下面向模型的 render 文本。对于想渲染引用列表或抓取摘要的 web 前端而言,该文本是有损的:`web_search` 的 render 把每个来源的 `title`、`snippet`、`publishedAt` 压进一行以 title 或 hostname 标注的自由文本 markdown(`packages/web/tool-web/src/search.ts` 中的 `formatSearchOutput`),因此重新解析 render 无法恢复各来源字段;`web_fetch` 的 render 也仅在一行 header 里携带 `url` 与 `statusCode`。渲染意图契约([标签联合类型](../architecture/2026-07-02-tool-render-intent-union.md))此前没有一个可供 web 工具声明、用以携带结构化结果的分支。 + +## Decision + +向 `ToolResultView`(`packages/core/tools/src/presentation.ts`)新增一个 `card: 'web'` 结果分支,它是以 `kind: 'search' | 'fetch'` 字段作判别的联合 `WebResultView = WebSearchResultView | WebFetchResultView`,并附一个表示单个可引用来源的 `WebSource` 形状。两个工具现在都声明 `presentResult`。 + +采用一个标签加 `kind` 判别,而非两个标签。两个调用都是 web 检索,web 前端会用同一族组件渲染它们(一个检索卡片,正文按 kind 不同),因此共用一个 `card` 让每个 card 消费者的 switch 只需新增一个分支,并让前端在其内部按 `kind` 分岔。两个标签会迫使当前及未来每个消费者为本属同一视觉族的东西添加两个分支。这两个 `kind` 取值与两个工具既有的 generic 调用视图 `kind` 一致,因此一个调用与它的结果读起来是同一类别。 + +`presentationMeta` 携带 render 文本无法携带的东西。工具从 `execute` 返回的结构化结果对象**不会**经由 wire 抵达客户端——只有面向模型的 `render` 文本,以及(声明时)投影到 `tool/result` 事件 `meta` 上的 `output.presentationMeta` JSON 会。对 `web_search`,meta 是得到 `{url, title?, snippet?, publishedAt?}` 的**唯一**忠实途径:render 把这些字段压进一行有损的自由文本,消费者无法重新解析。对 `web_fetch`,meta 是更小但真实的收益:`url`/`statusCode` 可从确定格式的 `Fetched (HTTP )` header 行还原,但 `truncated` 是有效截断——provider cap、转换前源截断,或部署的 `fetchMaxOutputChars` 输出上限——客户端无法重算,因为它不知道那个上限。抓取卡片与面向模型的文本都从同一个 `renderFetchOutput(result, maxOutputChars)` helper 派生 `truncated`,因此卡片绝不会与模型看到的脚注分叉。这照搬 write/edit 的 diff 模板(`packages/fs/tool-fs/src/diff.ts`):一个 `*MetaFromValue` 投影器喂给 `output.presentationMeta`,一个 `*MetaFromResult` 收窄器读回 `result.meta`,并在失败时防御性回退到 generic 卡片。`web_fetch` 的正文已是结果内容中的 markdown,因此不重复写入 meta。 + +两个结果视图都不携带 `content` 副本。不渲染结构化 `web` 卡片的 UI 回退到原始 `tool/result` 内容。TUI 正是如此:它不渲染结构化的 web 正文,其 transcript 渲染器把 `web` 视图的回退内容与 generic 卡片的内容路由进同一条 dim Markdown 路径(`packages/ui/tui/src/components/transcript.ts` 中 `render` 与 `renderBody` 都把 `generic` 分支收窄为 `view.content`,并给 `web` 视图相同的 `this.result?.content` 回退)。把结果内容复制进视图会在同一投递帧上重复最多 `fetchMaxOutputChars` 个字符却毫无收益(与 meta 一节对抓取正文的否决同理),因此视图省略它,回退路径渲染完全相同的文本。每个视图从调用参数设置其结果期 `title`(`args.query`/`args.url`),因此丢掉了调用头的窗口截断重放仍有标题,与 write/edit 在结果期重设 title 的做法一致。 + +`presentResult` 在错误结果、以及 `meta` 缺失或畸形时返回 `undefined`(即 generic 卡片),因为 presentation 会在对任意已记录结果(可能来自旧 schema)的重放中运行,绝不能抛错。收窄器防御性地校验每个字段;空来源列表是有效 meta,而非畸形。 + +## Consequences + +web 前端消费者是一个独立的后续 PR:本 PR 新增契约分支并让两个工具发出它,不含客户端渲染。唯一可观察的变化是 `web_search`/`web_fetch` 的 `tool/result` 事件现在持久化一个 `data.meta` 载荷(`web-fetch` keyless 快照随之刷新);面向模型的 render 文本与 TUI 呈现不变(TUI 回退到相同的结果内容)。渲染 `web` 卡片的组装应用 transcript 快照属于渲染它的消费者 PR,在那里交付。任何做穷尽 switch 的现有 `ToolResultView` 消费者都必须新增一个 `web` 分支;TUI 并不穷尽 switch,无需新增。`apiproxy` 的会话 schema 已接受任意 `card` 字符串(`packages/host/apiproxy/src/api/sessions.schema.ts`),因此新视图无需 schema 变更即可跨 wire。 + +未来想用此卡片的 web 工具,声明一个返回带自有 `kind` 的 `card: 'web'` 视图的 `presentResult`;新增第三个 `kind` 是一次联合类型编辑加前端的分岔,而非一个新的 card 标签。 + +## Alternatives considered + +**两个 card 标签(`web-search`、`web-fetch`)。** 否决:它在每个 card 消费者处为一个视觉族翻倍分支数,而两个形状已共享得够多(一个带回退内容的带标题检索卡片),`kind` 判别无需第二个标签即可表达差异。 + +**在 `presentResult` 里重新解析 render 文本,而非投影 meta。** 对 `web_search` 否决:render 的来源列表是有损的(title 或 hostname 标签,snippet 与日期拼进自由文本),因此重新解析无法忠实恢复结构化字段。`presentationMeta` 是唯一保留它们的途径。 + +**把抓取正文放进 meta,或把结果内容复制进任一视图。** 否决:正文已是结果内容中面向模型的 markdown,把它复制进 meta 或视图的 `content` 字段会为无收益的目的翻倍持久化或投递载荷;不具备 `web` 能力的 UI 回退到既有的结果内容,那是相同的文本。 + +## Testing + +`packages/web/tool-web/tests/tool-web.spec.ts` 覆盖以下内容,满足按文件 100% 的门禁:`searchMetaFromValue`/`fetchMetaFromValue` 投影,含对缺席可选字段的省略,以及抓取 `truncated` 投影在仅输出上限截断正文时、以及在毫无截断时都与 render 脚注一致;`searchMetaFromResult`/`fetchMetaFromResult` 收窄,含一次往返与每种畸形形状的拒绝(非对象、字段类型错误、畸形来源条目)以及空来源列表的接受;`presentSearchResult`/`presentFetchResult` 类型化视图,含从参数派生的 title、无 `content` 副本、truncated 信号、错误结果回退与畸形 meta 回退;以及两次真实注册表执行,断言工具把 meta 投影到 `result.meta` 上,其注册的 `presentResult` 推导出 `card: 'web'` 视图。 + +## Related + +- [标签化的工具调用渲染意图联合类型](../architecture/2026-07-02-tool-render-intent-union.md) —— 本卡片以 `web` 分支扩展的 `card` 标签词汇表。 +- [Web terminal card](2026-07-28-web-terminal-card.md) —— 把 bash `terminal` 渲染意图带到浏览器的先例;本分支的 web 前端消费者是它的对应物,推迟到后续 PR。 diff --git a/.agents/notes/implemented/simplification/2026-07-30-sidebar-resize-without-visible-pill.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-30-sidebar-resize-without-visible-pill.i18n.yaml new file mode 100644 index 0000000000..76604171be --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-30-sidebar-resize-without-visible-pill.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/simplification/2026-07-30-sidebar-resize-without-visible-pill.md +2026-07-30-sidebar-resize-without-visible-pill.md: cc41898990fa23ff2937140186a8324217911d2e +2026-07-30-sidebar-resize-without-visible-pill.zh.md: 9f1f521df2848b15f5015719bfa6f0e0e9b7be0c diff --git a/.agents/notes/implemented/simplification/2026-07-30-sidebar-resize-without-visible-pill.md b/.agents/notes/implemented/simplification/2026-07-30-sidebar-resize-without-visible-pill.md new file mode 100644 index 0000000000..cc41898990 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-30-sidebar-resize-without-visible-pill.md @@ -0,0 +1,25 @@ +# Agent Note: Sidebar resize without a visible pill + +Status: implemented + +English | [中文](2026-07-30-sidebar-resize-without-visible-pill.zh.md) + +## Problem + +The AppFrame exposed identical floating pills on both column borders. The left pill added unnecessary visual weight beside primary navigation, but the sidebar's resize interaction remains useful. + +## Decision + +AppFrame keeps the sidebar's 8px resize hit strip, `col-resize` cursor, pointer capture, animation-frame throttling, and width updates, but does not generate the sidebar handle's pill pseudo-element. The details boundary retains both its hit strip and floating pill. + +The layout component test continues to pin sidebar dragging and both handles' collapse lifecycle. A keyless browser scenario reads the generated pseudo-elements from the shipped composition and drags the invisible sidebar boundary to prove the interaction remains live. + +## Alternatives considered + +**Remove the sidebar drag interaction with the pill.** Rejected because the requested change is visual; removing a working geometry control would unnecessarily narrow the interaction. + +**Keep the pill but reduce its emphasis.** A smaller or lower-contrast pill still leaves an unwanted object on the sidebar boundary. + +## Consequences + +The sidebar boundary is visually quiet while pointer resizing remains available from the boundary and retains the resize cursor. Unlike the details control, that interaction has no visible pill. diff --git a/.agents/notes/implemented/simplification/2026-07-30-sidebar-resize-without-visible-pill.zh.md b/.agents/notes/implemented/simplification/2026-07-30-sidebar-resize-without-visible-pill.zh.md new file mode 100644 index 0000000000..9f1f521df2 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-30-sidebar-resize-without-visible-pill.zh.md @@ -0,0 +1,25 @@ +# Agent Note: 侧边栏缩放不显示胶囊 + +Status: implemented + +[English](2026-07-30-sidebar-resize-without-visible-pill.md) | 中文 + +## 问题 + +AppFrame 在两个栏位边界都显示相同的浮动胶囊。左侧胶囊在主导航旁增加了不必要的视觉负担,但侧边栏的缩放交互仍有用。 + +## 决策 + +AppFrame 保留侧边栏宽 8px 的缩放命中条带、`col-resize` 光标、指针捕获、动画帧节流和宽度更新,但不再生成侧边栏手柄的胶囊形伪元素。详情栏边界同时保留命中条带和浮动胶囊。 + +布局组件测试继续固定侧边栏拖动行为,以及两个手柄随面板折叠时的生命周期。一个无密钥浏览器场景读取实际交付组合所生成的伪元素,并拖动不可见的侧边栏边界,证明该交互仍然有效。 + +## 曾考虑的替代方案 + +**随胶囊一并移除侧边栏拖动交互。** 不予采纳,因为本次要求只改视觉表现;移除正常工作的几何控制会不必要地缩减交互方式。 + +**保留胶囊,但降低其视觉强调。** 更小或对比度更低的胶囊仍会在侧边栏边界留下一个不需要的物体。 + +## 后果 + +侧边栏边界在视觉上保持简洁,同时仍可在边界处通过指针调整宽度,并保留缩放光标。与详情栏控件不同,该交互没有可见胶囊。 diff --git a/apps/web/tests/approval-composer.e2e.ts b/apps/web/tests/approval-composer.e2e.ts new file mode 100644 index 0000000000..2c6a78d2e5 --- /dev/null +++ b/apps/web/tests/approval-composer.e2e.ts @@ -0,0 +1,181 @@ +// Web e2e scenario: the composer-takeover approval panel under a long +// command. The shipped composition confines bash through the sandbox policy +// and routes its escalation through the approval seam, so a read-only session +// asked to write a file produces a REAL pending approval — the panel renders +// in the browser, the test measures its geometry, answers through it, and the +// escalated command then runs. Replay is deterministic: the denial, the +// escalation retry and its command text arrive from replayed chunks, and the +// answer click is the test's own gesture (the same sanctioned reaction to +// model content as the question composer: the turn cannot complete without it). +// +// Geometry is the point of the scenario. The command is unbounded model text, +// and before the cap a long one grew the card until the refuse/allow buttons +// left the viewport — an approval the user could see and not answer. +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +// Empty type import: carries the approval package's session-event merge, so +// the decided-outcome assertion below type-checks against the real union. +import type {} from '@deepseek-ai/dsh-user-approval' +import { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, + launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/approval-composer', import.meta.url)) +const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') +// The scenario's one golden: the waiting panel. Everything the answered state +// proves is asserted directly — see the world-state block at the end. +const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md') +const MODE = webSnapshotMode() + +// Irreducible payload: the command has to be long enough to pass the card's +// height cap, which is the only shape that reproduces an action row pushed off +// screen. Unrelated tokens, not a repeated word — a repeated word is what the +// model compressed into `printf 'alpha %.0s' {1..400}` while recording, and a +// short command proves nothing here. The formula keeps the source small; the +// model receives the expanded literal it has to put in the command. +const TOKENS = Array.from({ length: 220 }, (_, index) => `tok${((index + 1) * 7919 % 99991).toString(36)}`).join(' ') +const PROMPT = `Write a file named notes.txt in the workspace containing exactly this text on one line: ${TOKENS}. Use one bash command with the literal text inline. Then reply with the single word DONE and stop.` + +/** Draft used to measure the composer's own text cap: enough lines to pass it. */ +const CAP_PROBE = Array.from({ length: 40 }, (_, index) => `line ${index}`).join('\n') + +describe('web e2e: approval takeover keeps its actions reachable', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + const sessionEvents: SessionEvent[] = [] + + beforeAll(async () => { + scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 }) + scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) }) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspace(page) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('caps the long command, answers through the panel, and runs the escalated command', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-approval')) + if (MODE !== 'record') { + expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT]) + } + const input = page.locator('textarea').first() + await input.waitFor({ timeout: 10_000 }) + + // The composer's own text cap, measured on the live textarea before the + // takeover replaces it. The panel's scroll region must stop at the same + // height (the designer's requirement: one cap for the composer seat), and + // measuring it here keeps the assertion free of the px value itself. + await input.fill(CAP_PROBE) + const composerCap = await input.evaluate(el => el.clientHeight) + expect(composerCap).toBeGreaterThan(0) + await input.fill('') + + // Read-only: the mode whose denial the model escalates from. Switched + // through the shipped access-mode chip, not a test-only seam. + await page.locator('[aria-label^="Access mode"]').click() + await page.getByRole('menuitem', { name: 'Read Only' }).click() + await expect.poll( + () => page.locator('[aria-label="Access mode, current: Read Only"]').count(), + { timeout: 15_000 }, + ).toBe(1) + + const settled = scaffold.whenTurnSettled(MODE === 'record' ? 240_000 : 60_000) + await input.fill(PROMPT) + await input.press('Enter') + + // The panel takes over the input area while the tool blocks. Its presence + // is a STABLE waiting state (it stays until answered), so waitFor is + // race-free. + const panel = page.locator('[data-approval-key]') + await panel.waitFor({ timeout: MODE === 'record' ? 180_000 : 60_000 }) + const scroll = panel.locator('[data-approval-scroll]') + await expect.poll(() => scroll.getByText(/tok/).count(), { timeout: 15_000 }).toBeGreaterThan(0) + + if (MODE !== 'record') { + // This golden owns the stable waiting surface; the answered golden below + // owns the resulting transcript. + const snapshot = await captureStableAria(page, '[data-approval-key]', scaffold.workspaceCwd) + await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + + // The regression this scenario exists for: an uncapped card grew with + // the command until the action row left the viewport. Measured at the + // lane baseline and at a short viewport, on the live panel. + const original = page.viewportSize() ?? { width: 1680, height: 1000 } + for (const height of [1000, 700]) { + await page.setViewportSize({ width: 900, height }) + const geometry = await panel.evaluate((root) => { + const region = root.querySelector('[data-approval-scroll]') + const card = region?.parentElement ?? null + // Role/text, not the CSS-module class names: the built client hashes those. + const buttons = [...root.querySelectorAll('button')] + const rows = buttons.map(button => button.getBoundingClientRect()) + return { + buttons: buttons.length, + capped: region === null ? 0 : region.clientHeight, + // A scrolling region proves the cap is genuinely engaged; without + // it every assertion below would hold vacuously. + scrolls: region === null ? false : region.scrollHeight > region.clientHeight, + cardBottom: card === null ? Number.NaN : card.getBoundingClientRect().bottom, + actionsTop: Math.min(...rows.map(rect => rect.top)), + actionsBottom: Math.max(...rows.map(rect => rect.bottom)), + viewport: window.innerHeight, + } + }) + expect(geometry.buttons).toBe(2) + expect(geometry.scrolls).toBe(true) + // One cap for the seat: the panel's text region stops where the + // composer draft does (sub-pixel tolerance for the shared padding). + expect(Math.abs(geometry.capped - composerCap)).toBeLessThan(1) + // Both buttons stay inside the card AND inside the viewport — the + // answerable state the cap exists to guarantee. + expect(geometry.actionsTop).toBeGreaterThan(0) + expect(geometry.actionsBottom).toBeLessThanOrEqual(geometry.viewport) + expect(geometry.actionsBottom).toBeLessThanOrEqual(geometry.cardBottom) + } + await page.setViewportSize(original) + } + + await panel.getByRole('button', { name: '允许一次' }).click() + + const sessionId = await settled + if (MODE === 'record') { + await recordFixture(scaffold, sessionId, FIXTURE) + return + } + // World state: the granted escalation is what let the command run, and the + // panel leaves with the regular composer restored. Asserted on the world + // and the DOM rather than through a transcript golden — the denied first + // attempt renders the OS's own refusal ("Operation not permitted" on + // macOS, "Read-only file system" on Linux), so the answered transcript is + // not a platform-neutral golden surface. + expect(JSON.stringify(sessionEvents.filter(e => e.type === 'approval/decided').at(-1))) + .toContain('allowed-once') + const written = await readFile(join(scaffold.workspaceCwd, 'workspace', 'notes.txt'), 'utf8') + expect(written).toContain(TOKENS.slice(0, 64)) + await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 20_000 }).toBeGreaterThanOrEqual(1) + expect(await page.locator('[data-approval-key]').count()).toBe(0) + await expect.poll(() => page.locator('textarea').first().isEnabled(), { timeout: 10_000 }).toBe(true) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 300_000) + + it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { + await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'ui.expected.md']) + }) +}) diff --git a/apps/web/tests/details-session-lifecycle.e2e.ts b/apps/web/tests/details-session-lifecycle.e2e.ts index 23a14ef445..8105519338 100644 --- a/apps/web/tests/details-session-lifecycle.e2e.ts +++ b/apps/web/tests/details-session-lifecycle.e2e.ts @@ -3,15 +3,19 @@ // unselected states, and closes it only when a different Session takes ownership. import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' +import { join } from 'node:path' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { - acknowledgeReloadConnectionLoss, fixtureUserPrompts, launchWebScaffold, seedSession, watchConsole, - webSnapshotMode, type WebScaffold, + acknowledgeReloadConnectionLoss, assertFixtureInventory, compareOrRefreshGolden, + fixtureUserPrompts, launchWebScaffold, seedSession, watchConsole, webSnapshotMode, + type WebScaffold, } from './scaffold.ts' import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/details-session-lifecycle', import.meta.url)) +const HANDLES_EXPECTED = join(SNAPSHOT_DIR, 'handles.expected.md') const FIXTURE = fileURLToPath(new URL('./snapshots/lifecycle-chrome/session.jsonl', import.meta.url)) const SEED_FIXTURE = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url)) const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.' @@ -25,11 +29,41 @@ async function detailsTrack(page: Page): Promise { }) } +/** First AppFrame grid track in CSS pixels. */ +async function sidebarTrack(page: Page): Promise { + return await appFrame(page).evaluate((element) => { + const tracks = getComputedStyle(element).gridTemplateColumns.split(' ') + return Number.parseFloat(tracks[0] ?? 'NaN') + }) +} + /** AppFrame is the only product element with an inline grid track template. */ function appFrame(page: Page) { return page.locator('[style*="grid-template-columns"]').first() } +/** Render the two boundary affordances without platform-dependent coordinates. */ +async function handleSnapshot(page: Page): Promise { + const handles = await page.locator('[class*="handle"]').evaluateAll(elements => + elements.map(element => ({ + side: element.getAttribute('data-side'), + cursor: getComputedStyle(element).cursor, + pillGenerated: getComputedStyle(element, '::after').content !== 'none', + }))) + return [ + '# AppFrame drag handles', + '', + ...handles.flatMap(handle => [ + `## ${handle.side}`, + '', + '- hit strip present: true', + `- cursor: ${handle.cursor}`, + `- pill generated: ${String(handle.pillGenerated)}`, + '', + ]), + ].join('\n').trimEnd() +} + describe.skipIf(MODE === 'record')('web e2e: details panel follows the current Session lifecycle', () => { let scaffold: WebScaffold let browser: Browser @@ -65,6 +99,18 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0) expect(await page.getByText('详情', { exact: true }).isVisible()).toBe(false) + await compareOrRefreshGolden(HANDLES_EXPECTED, await handleSnapshot(page), MODE) + + const sidebarBefore = await sidebarTrack(page) + const sidebarHandle = page.locator('[data-side="sidebar"]') + const sidebarBox = await sidebarHandle.boundingBox() + expect(sidebarBox).not.toBeNull() + const dragStartX = sidebarBox!.x + sidebarBox!.width / 2 + await page.mouse.move(dragStartX, sidebarBox!.y + 200) + await page.mouse.down() + await page.mouse.move(dragStartX + 70, sidebarBox!.y + 200, { steps: 6 }) + await page.mouse.up() + await expect.poll(() => sidebarTrack(page), { timeout: 5_000 }).toBe(sidebarBefore + 70) const warningStart = tripwire.warnings.length await page.reload({ waitUntil: 'load' }) @@ -101,5 +147,6 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) + await assertFixtureInventory(SNAPSHOT_DIR, ['handles.expected.md']) }, 90_000) }) diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index d6a78c9277..4d7825963a 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -3,7 +3,9 @@ // else covers: sidebar cold listing, the implicit resume/attach inside the // history RPC, history-page tool views, and the client fold of historical // events — with ZERO model calls in replay (no replay fixture; a stray stream -// fails loud on the open llm seam). The seed is a recorded fixture under the +// fails loud on the open llm seam). The cold session also carries the one +// keyless command-row surface: an Access-chip pick runs `/permission` on the +// host, so the settled row's copy has a golden here. The seed is a recorded fixture under the // same record discipline as every other: DSH_SNAPSHOT=record drives the turn // live through the composer (real read tool against seeded workspace files) // and harvests seed.jsonl; replay/refresh seed it cold and only render. @@ -24,6 +26,9 @@ import { newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/seeded-history', import.meta.url)) const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url)) const UI_EXPECTED = fileURLToPath(new URL('./snapshots/seeded-history/ui.expected.md', import.meta.url)) +// The command-row golden: the same conversation after one /permission switch, +// which is the only surface that shows a settled command row's copy. +const COMMAND_ROW_EXPECTED = fileURLToPath(new URL('./snapshots/seeded-history/command-row.expected.md', import.meta.url)) const MODE = webSnapshotMode() const SEED_ID = 'seeded-history-web-e2e' @@ -225,11 +230,33 @@ describe('web e2e: seeded history renders through cold resume', () => { await expect.poll(() => page.getByText('a.txt', { exact: false }).count(), { timeout: 5_000 }).toBeGreaterThan(0) }) + it.skipIf(MODE === 'record')('an Access-chip switch lands one command row: bare name, non-repeating settlement text', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-command-row')) + // The Access chip submits `/permission ` — a host command with no + // model call, so the settled row renders keylessly over this cold history. + // The row copy is the assertion: `permission · preset workspace-write`, + // where neither half repeats the other (the dispatched `/` and its + // argument stay out of the title, and the settlement text never restates + // the command's own name). + await page.getByRole('button', { name: 'Access mode, current: Danger Full Access' }).click() + await page.getByRole('menuitem', { name: 'Workspace Write' }).click() + await page.getByRole('button', { name: 'Access mode, current: Workspace Write' }).waitFor({ timeout: 10_000 }) + // Scoped to the row itself, so unrelated page text that happens to read + // `permission` (a future resident slash menu) cannot satisfy or break it. + const row = page.locator('[data-variant="others"]').filter({ hasText: 'preset workspace-write' }) + await expect.poll(() => row.count(), { timeout: 10_000 }).toBe(1) + expect(await row.getByText('permission', { exact: true }).count()).toBe(1) + expect(await row.getByText('/permission workspace-write', { exact: true }).count()).toBe(0) + const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) + .split(SEED_ID).join('{{seededId}}') + await compareOrRefreshGolden(COMMAND_ROW_EXPECTED, snapshot, MODE) + }, 60_000) + it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => { // No replay fixture was installed and the llm seam is open — any stray // stream would have failed the turn loudly. Cleanliness pins the wire. expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) - await assertFixtureInventory(SNAPSHOT_DIR, ['seed.jsonl', 'ui.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, ['command-row.expected.md', 'seed.jsonl', 'ui.expected.md']) }) }) diff --git a/apps/web/tests/snapshots/approval-composer/session.jsonl b/apps/web/tests/snapshots/approval-composer/session.jsonl new file mode 100644 index 0000000000..b072da5fc4 --- /dev/null +++ b/apps/web/tests/snapshots/approval-composer/session.jsonl @@ -0,0 +1,64 @@ +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785403668101,"cwd":"{{cwd}}/workspace"} +{"type":"command/run","seq":0,"time":1785403668197,"data":{"commandId":"cmd-0ab130cc-1","name":"permission","args":" read-only","source":{"kind":"user"}}} +{"type":"permission/preset","seq":1,"time":1785403668197,"data":{"preset":"read-only"}} +{"type":"sandbox/mode","seq":2,"time":1785403668197,"data":{"mode":"read-only"}} +{"type":"approval/policy","seq":3,"time":1785403668198,"data":{"policy":"ask"}} +{"type":"command/done","seq":4,"time":1785403668198,"data":{"commandId":"cmd-0ab130cc-1","kind":"success","text":"Permission preset: read-only."}} +{"type":"turn/start","seq":5,"time":1785403668212,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}} +{"type":"user/message","seq":6,"time":1785403668212,"data":{"content":[{"type":"text","text":"Write a file named notes.txt in the workspace containing exactly this text on one line: tok63z tokc7y tokibx tokofw tokujv tok10nu tok16rt tok1cvs tok1izr tok1p3q tok1v7p tok21bo tok2a4 tok8e3 tokei2 tokkm1 tokqq0 tokwtz tok12xy tok191x tok1f5w tok1l9v tok1rdu tok1xht tok23ls tok4k8 tokao7 tokgs6 tokmw5 tokt04 tokz43 tok1582 tok1bc1 tok1hg0 tok1njz tok1tny tok1zrx tokqd tok6uc tokcyb tokj2a tokp69 tokva8 tok11e7 tok17i6 tok1dm5 tok1jq4 tok1pu3 tok1vy2 tok2221 tok30h tok94g tokf8f toklce tokrgd tokxkc tok13ob tok19sa tok1fw9 tok1m08 tok1s47 tok1y86 tok24c5 tok5al tokbek tokhij toknmi toktqh tokzug tok15yf tok1c2e tok1i6d tok1oac tok1ueb tok20ia tok1gq tok7kp tokdoo tokjsn tokpwm tokw0l tok124k tok188j tok1eci tok1kgh tok1qkg tok1wof tok22se tok3qu tok9ut tokfys tokm2r toks6q tokyap tok14eo tok1ain tok1gmm tok1mql tok1suk tok1yyj tok252i tok60y tokc4x toki8w tokocv tokugu tok10kt tok16os tok1csr tok1iwq tok1p0p tok1v4o tok218n tok273 tok8b2 tokef1 tokkj0 tokqmz tokwqy tok12ux tok18yw tok1f2v tok1l6u tok1rat tok1xes tok23ir tok4h7 tokal6 tokgp5 tokmt4 toksx3 tokz12 tok1551 tok1b90 tok1hcz tok1ngy tok1tkx tok1zow toknc tok6rb tokcva tokiz9 tokp38 tokv77 tok11b6 tok17f5 tok1dj4 tok1jn3 tok1pr2 tok1vv1 tok21z0 tok2xg tok91f tokf5e tokl9d tokrdc tokxhb tok13la tok19p9 tok1ft8 tok1lx7 tok1s16 tok1y55 tok2494 tok57k tokbbj tokhfi toknjh toktng tokzrf tok15ve tok1bzd tok1i3c tok1o7b tok1uba tok20f9 tok1dp tok7ho tokdln tokjpm tokptl tokvxk tok121j tok185i tok1e9h tok1kdg tok1qhf tok1wle tok22pd tok3nt tok9rs tokfvr toklzq toks3p toky7o tok14bn tok1afm tok1gjl tok1mnk tok1srj tok1yvi tok24zh tok5xx tokc1w toki5v toko9u tokudt tok10hs tok16lr tok1cpq tok1itp tok1oxo tok1v1n tok215m tok242 tok881 tokec0 tokkfz tokqjy tokwnx. Use one bash command with the literal text inline. Then reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"},"role":"user","id":"f8d50240-b224-4d14-a126-63301ca93176"},"surfaceOp":"append"} +{"type":"session/title","seq":7,"time":1785403668213,"data":{"title":"Write a file named notes.txt","messageSeqs":[6],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":8,"time":1785403668214,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":9,"time":1785403668215,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash","reasoningEffort":"high"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":10,"time":1785403669261,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":11,"time0":1785403669262,"data":{"turn":1,"step":1,"index":0,"dt":[110,26,1,0,0,18,0,0,28,0,0,0,0,0,25,1,0,0,24,0,1,23,27,0,0,0,0,31,1],"texts":["The"," user"," wants"," me"," to"," write"," a"," file"," named"," notes",".txt"," with"," a"," specific"," line"," of"," text","."," Let"," me"," do"," this"," with"," a"," single"," bash"," command"," using"," echo","."]}} +{"type":"assistant/chunk","seq":41,"time":1785403669643,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":42,"time0":1785403669643,"data":{"turn":1,"step":1,"index":1,"dt":[32,1,0,0,0,16,1,0,0,0,0,25,0,0,0,0,0,22,0,0,25,0,0,54,1,0,0,0,0,25,0,1,0,30,1,0,0,25,1,0,0,29,0,0,0,0,22,1,0,0,14,0,0,0,53,0,0,1,0,0,0,0,14,1,0,0,0,34,0,0,12,0,0,35,0,1,0,23,1,0,0,0,26,1,0,0,0,17,0,0,0,0,0,29,0,0,0,56,1,0,2,0,0,5,0,0,0,0,29,1,0,18,1,0,30,0,0,1,687,1,0,0,107,0,0,0,0,1,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,2,1,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,79,1,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,1,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,17,0,0,0,56,0,1,0,0,0,0,0,0,0,12,1,0,0,0,33,0,0,0,13,0,0,24,1,0,27,1,0,0,0,24,1,0,0,0,25,0,0,0,0,30,0,0,22,1,0,57,0,0,0,0,1,0,0,19,1,0,0,42,1,0,0,0,3,0,1,0,17,0,0,31,0,0,0,0,21,0,1,0,27,1,0,22,1,0,20,1,0,0,30,0,0,1,0,19,1,0,0,0,43,0,4,0,0,22,1,0,24,0,0,26,1,0,0,0,27,0,0,0,20,0,0,0,0,26,0,0,0,0,24,0,0,0,32,0,1,0,0,14,0,0,30,0,0,1,34,1,0,0,10,0,0,22,0,0,56,1,0,0,0,0,1,0,0,20,1,0,0,20,1,0,25,0,0,48,1,0,0,17,0,0,0,35,0,0,1,0,14,0,31,0,0,1,0,18,1,0,0,26,0,1,0,25,0,0,28,0,0,0,61,1,0,0,0,0,1,0,12,0,0,29,1,0,0,17,1,0,0,0,18,0,0,25,0,0,0,128,1,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,6,0,1,0,60,1,0,0,0,0,0,10,0,0,1321,0,0,174,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,84,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,81,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,1,0,0,0,0,0,0,0,15],"id":"call_00_RFz12ulKTflJvhrwgkQX7978","name":"bash","args":["","{","\"","command","\"",": ","\"","echo"," '","tok","63","z"," tok","c","7","y"," tok","ib","x"," tok","of","w"," tok","uj","v"," tok","10","nu"," tok","16","rt"," tok","1","c","vs"," tok","1","iz","r"," tok","1","p","3","q"," tok","1","v","7","p"," tok","21","bo"," tok","2","a","4"," tok","8","e","3"," to","kei","2"," tok","km","1"," tok","qq","0"," tok","w","tz"," tok","12","xy"," tok","191","x"," tok","1","f","5","w"," tok","1","l","9","v"," tok","1","r","du"," tok","1","x","ht"," tok","23","ls"," tok","4","k","8"," to","ka","o","7"," tok","gs","6"," to","km","w","5"," to","kt","04"," tok","z","43"," tok","158","2"," tok","1","bc","1"," tok","1","hg","0"," tok","1","nj","z"," tok","1","t","ny"," tok","1","z","rx"," tok","qd"," tok","6","uc"," tok","cy","b"," tok","j","2","a"," tok","p","69"," tok","va","8"," tok","11","e","7"," tok","17","i","6"," tok","1","dm","5"," tok","1","j","q","4"," tok","1","pu","3"," tok","1","vy","2"," tok","222","1"," tok","30","h"," tok","94","g"," tok","f","8","f"," to","kl","ce"," tok","rg","d"," tok","x","kc"," tok","13","ob"," tok","19","sa"," tok","1","fw","9"," tok","1","m","08"," tok","1","s","47"," tok","1","y","86"," tok","24","c","5"," tok","5","al"," tok","bek"," to","kh","ij"," to","kn","mi"," to","kt","qh"," tok","zug"," tok","15","y","f"," tok","1","c","2","e"," tok","1","i","6","d"," tok","1","o","ac"," tok","1","ue","b"," tok","20","ia"," tok","1","g","q"," tok","7","kp"," tok","do","o"," tok","js","n"," tok","p","wm"," tok","w","0","l"," tok","124","k"," tok","188","j"," tok","1","e","ci"," tok","1","k","gh"," tok","1","q","kg"," tok","1","wof"," tok","22","se"," tok","3","qu"," tok","9","ut"," tok","f","ys"," to","km","2","r"," to","ks","6","q"," to","ky","ap"," tok","14","eo"," tok","1","ain"," tok","1","g","mm"," tok","1","m","ql"," tok","1","s","uk"," tok","1","yy","j"," tok","252","i"," tok","60","y"," tok","c","4","x"," to","ki","8","w"," tok","oc","v"," tok","ugu"," tok","10","kt"," tok","16","os"," tok","1","cs","r"," tok","1","iw","q"," tok","1","p","0","p"," tok","1","v","4","o"," tok","218","n"," tok","273"," tok","8","b","2"," tok","ef","1"," to","kk","j","0"," tok","qm","z"," tok","w","q","y"," tok","12","ux"," tok","18","yw"," tok","1","f","2","v"," tok","1","l","6","u"," tok","1","rat"," tok","1","xes"," tok","23","ir"," tok","4","h","7"," tok","al","6"," to","kg","p","5"," tok","mt","4"," to","ks","x","3"," tok","z","12"," tok","155","1"," tok","1","b","90"," tok","1","h","cz"," tok","1","ng","y"," tok","1","tk","x"," tok","1","z","ow"," to","kn","c"," tok","6","rb"," tok","c","va"," tok","iz","9"," tok","p","38"," tok","v","77"," tok","11","b","6"," tok","17","f","5"," tok","1","dj","4"," tok","1","jn","3"," tok","1","pr","2"," tok","1","vv","1"," tok","21","z","0"," tok","2","xg"," tok","91","f"," tok","f","5","e"," to","kl","9","d"," tok","rd","c"," tok","x","hb"," tok","13","la"," tok","19","p","9"," tok","1","ft","8"," tok","1","lx","7"," tok","1","s","16"," tok","1","y","55"," tok","249","4"," tok","57","k"," tok","bb","j"," to","kh","fi"," to","kn","jh"," tok","kt","ng"," tok","z","rf"," tok","15","ve"," tok","1","b","zd"," tok","1","i","3","c"," tok","1","o","7","b"," tok","1","uba"," tok","20","f","9"," tok","1","dp"," tok","7","ho"," tok","d","ln"," tok","j","pm"," tok","pt","l"," tok","v","xk"," tok","121","j"," tok","185","i"," tok","1","e","9","h"," tok","1","kd","g"," tok","1","qh","f"," tok","1","w","le"," tok","22","pd"," tok","3","nt"," tok","9","rs"," tok","f","vr"," to","kl","z","q"," to","ks","3","p"," to","ky","7","o"," tok","14","bn"," tok","1","af","m"," tok","1","g","jl"," tok","1","mn","k"," tok","1","sr","j"," tok","1","y","vi"," tok","24","zh"," tok","5","xx"," tok","c","1","w"," to","ki","5","v"," to","ko","9","u"," tok","ud","t"," tok","10","hs"," tok","16","lr"," tok","1","cp","q"," tok","1","it","p"," tok","1","ox","o"," tok","1","v","1","n"," tok","215","m"," tok","242"," tok","881"," tok","ec","0"," tok","f","z"," tok","q","jy"," tok","wn","x","'"," >"," notes",".txt","\"",", ","\"","description","\"",": ","\"","Write"," notes",".txt"," with"," the"," specified"," text","\"","}"]}} +{"type":"assistant/chunk","seq":843,"time":1785403674783,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to write a file named notes.txt with a specific line of text. Let me do this with a single bash command using echo."}}}} +{"type":"assistant/chunk","seq":844,"time":1785403674783,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_RFz12ulKTflJvhrwgkQX7978","name":"bash","arguments":"{\"command\": \"echo 'tok63z tokc7y tokibx tokofw tokujv tok10nu tok16rt tok1cvs tok1izr tok1p3q tok1v7p tok21bo tok2a4 tok8e3 tokei2 tokkm1 tokqq0 tokwtz tok12xy tok191x tok1f5w tok1l9v tok1rdu tok1xht tok23ls tok4k8 tokao7 tokgs6 tokmw5 tokt04 tokz43 tok1582 tok1bc1 tok1hg0 tok1njz tok1tny tok1zrx tokqd tok6uc tokcyb tokj2a tokp69 tokva8 tok11e7 tok17i6 tok1dm5 tok1jq4 tok1pu3 tok1vy2 tok2221 tok30h tok94g tokf8f toklce tokrgd tokxkc tok13ob tok19sa tok1fw9 tok1m08 tok1s47 tok1y86 tok24c5 tok5al tokbek tokhij toknmi toktqh tokzug tok15yf tok1c2e tok1i6d tok1oac tok1ueb tok20ia tok1gq tok7kp tokdoo tokjsn tokpwm tokw0l tok124k tok188j tok1eci tok1kgh tok1qkg tok1wof tok22se tok3qu tok9ut tokfys tokm2r toks6q tokyap tok14eo tok1ain tok1gmm tok1mql tok1suk tok1yyj tok252i tok60y tokc4x toki8w tokocv tokugu tok10kt tok16os tok1csr tok1iwq tok1p0p tok1v4o tok218n tok273 tok8b2 tokef1 tokkj0 tokqmz tokwqy tok12ux tok18yw tok1f2v tok1l6u tok1rat tok1xes tok23ir tok4h7 tokal6 tokgp5 tokmt4 toksx3 tokz12 tok1551 tok1b90 tok1hcz tok1ngy tok1tkx tok1zow toknc tok6rb tokcva tokiz9 tokp38 tokv77 tok11b6 tok17f5 tok1dj4 tok1jn3 tok1pr2 tok1vv1 tok21z0 tok2xg tok91f tokf5e tokl9d tokrdc tokxhb tok13la tok19p9 tok1ft8 tok1lx7 tok1s16 tok1y55 tok2494 tok57k tokbbj tokhfi toknjh tokktng tokzrf tok15ve tok1bzd tok1i3c tok1o7b tok1uba tok20f9 tok1dp tok7ho tokdln tokjpm tokptl tokvxk tok121j tok185i tok1e9h tok1kdg tok1qhf tok1wle tok22pd tok3nt tok9rs tokfvr toklzq toks3p toky7o tok14bn tok1afm tok1gjl tok1mnk tok1srj tok1yvi tok24zh tok5xx tokc1w toki5v toko9u tokudt tok10hs tok16lr tok1cpq tok1itp tok1oxo tok1v1n tok215m tok242 tok881 tokec0 tokfz tokqjy tokwnx' > notes.txt\", \"description\": \"Write notes.txt with the specified text\"}"}}}} +{"type":"assistant/chunk","seq":845,"time":1785403674783,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":65,"outputTokens":872,"cacheReadTokens":5376,"reasoningTokens":30}}}} +{"type":"assistant/chunk","seq":846,"time":1785403674783,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":847,"time":1785403674786,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to write a file named notes.txt with a specific line of text. Let me do this with a single bash command using echo."},{"type":"tool-call","id":"call_00_RFz12ulKTflJvhrwgkQX7978","name":"bash","arguments":"{\"command\": \"echo 'tok63z tokc7y tokibx tokofw tokujv tok10nu tok16rt tok1cvs tok1izr tok1p3q tok1v7p tok21bo tok2a4 tok8e3 tokei2 tokkm1 tokqq0 tokwtz tok12xy tok191x tok1f5w tok1l9v tok1rdu tok1xht tok23ls tok4k8 tokao7 tokgs6 tokmw5 tokt04 tokz43 tok1582 tok1bc1 tok1hg0 tok1njz tok1tny tok1zrx tokqd tok6uc tokcyb tokj2a tokp69 tokva8 tok11e7 tok17i6 tok1dm5 tok1jq4 tok1pu3 tok1vy2 tok2221 tok30h tok94g tokf8f toklce tokrgd tokxkc tok13ob tok19sa tok1fw9 tok1m08 tok1s47 tok1y86 tok24c5 tok5al tokbek tokhij toknmi toktqh tokzug tok15yf tok1c2e tok1i6d tok1oac tok1ueb tok20ia tok1gq tok7kp tokdoo tokjsn tokpwm tokw0l tok124k tok188j tok1eci tok1kgh tok1qkg tok1wof tok22se tok3qu tok9ut tokfys tokm2r toks6q tokyap tok14eo tok1ain tok1gmm tok1mql tok1suk tok1yyj tok252i tok60y tokc4x toki8w tokocv tokugu tok10kt tok16os tok1csr tok1iwq tok1p0p tok1v4o tok218n tok273 tok8b2 tokef1 tokkj0 tokqmz tokwqy tok12ux tok18yw tok1f2v tok1l6u tok1rat tok1xes tok23ir tok4h7 tokal6 tokgp5 tokmt4 toksx3 tokz12 tok1551 tok1b90 tok1hcz tok1ngy tok1tkx tok1zow toknc tok6rb tokcva tokiz9 tokp38 tokv77 tok11b6 tok17f5 tok1dj4 tok1jn3 tok1pr2 tok1vv1 tok21z0 tok2xg tok91f tokf5e tokl9d tokrdc tokxhb tok13la tok19p9 tok1ft8 tok1lx7 tok1s16 tok1y55 tok2494 tok57k tokbbj tokhfi toknjh tokktng tokzrf tok15ve tok1bzd tok1i3c tok1o7b tok1uba tok20f9 tok1dp tok7ho tokdln tokjpm tokptl tokvxk tok121j tok185i tok1e9h tok1kdg tok1qhf tok1wle tok22pd tok3nt tok9rs tokfvr toklzq toks3p toky7o tok14bn tok1afm tok1gjl tok1mnk tok1srj tok1yvi tok24zh tok5xx tokc1w toki5v toko9u tokudt tok10hs tok16lr tok1cpq tok1itp tok1oxo tok1v1n tok215m tok242 tok881 tokec0 tokfz tokqjy tokwnx' > notes.txt\", \"description\": \"Write notes.txt with the specified text\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"d01c80d5-7880-4039-827c-4b35e4d40dac"},"usage":{"inputTokens":65,"outputTokens":872,"cacheReadTokens":5376,"reasoningTokens":30}},"sourceEventSeqs":[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,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,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249,250,251,252,253,254,255,256,257,258,259,260,261,262,263,264,265,266,267,268,269,270,271,272,273,274,275,276,277,278,279,280,281,282,283,284,285,286,287,288,289,290,291,292,293,294,295,296,297,298,299,300,301,302,303,304,305,306,307,308,309,310,311,312,313,314,315,316,317,318,319,320,321,322,323,324,325,326,327,328,329,330,331,332,333,334,335,336,337,338,339,340,341,342,343,344,345,346,347,348,349,350,351,352,353,354,355,356,357,358,359,360,361,362,363,364,365,366,367,368,369,370,371,372,373,374,375,376,377,378,379,380,381,382,383,384,385,386,387,388,389,390,391,392,393,394,395,396,397,398,399,400,401,402,403,404,405,406,407,408,409,410,411,412,413,414,415,416,417,418,419,420,421,422,423,424,425,426,427,428,429,430,431,432,433,434,435,436,437,438,439,440,441,442,443,444,445,446,447,448,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,465,466,467,468,469,470,471,472,473,474,475,476,477,478,479,480,481,482,483,484,485,486,487,488,489,490,491,492,493,494,495,496,497,498,499,500,501,502,503,504,505,506,507,508,509,510,511,512,513,514,515,516,517,518,519,520,521,522,523,524,525,526,527,528,529,530,531,532,533,534,535,536,537,538,539,540,541,542,543,544,545,546,547,548,549,550,551,552,553,554,555,556,557,558,559,560,561,562,563,564,565,566,567,568,569,570,571,572,573,574,575,576,577,578,579,580,581,582,583,584,585,586,587,588,589,590,591,592,593,594,595,596,597,598,599,600,601,602,603,604,605,606,607,608,609,610,611,612,613,614,615,616,617,618,619,620,621,622,623,624,625,626,627,628,629,630,631,632,633,634,635,636,637,638,639,640,641,642,643,644,645,646,647,648,649,650,651,652,653,654,655,656,657,658,659,660,661,662,663,664,665,666,667,668,669,670,671,672,673,674,675,676,677,678,679,680,681,682,683,684,685,686,687,688,689,690,691,692,693,694,695,696,697,698,699,700,701,702,703,704,705,706,707,708,709,710,711,712,713,714,715,716,717,718,719,720,721,722,723,724,725,726,727,728,729,730,731,732,733,734,735,736,737,738,739,740,741,742,743,744,745,746,747,748,749,750,751,752,753,754,755,756,757,758,759,760,761,762,763,764,765,766,767,768,769,770,771,772,773,774,775,776,777,778,779,780,781,782,783,784,785,786,787,788,789,790,791,792,793,794,795,796,797,798,799,800,801,802,803,804,805,806,807,808,809,810,811,812,813,814,815,816,817,818,819,820,821,822,823,824,825,826,827,828,829,830,831,832,833,834,835,836,837,838,839,840,841,842,843,844,845,846],"surfaceOp":"append"} +{"type":"tool/call","seq":848,"time":1785403674787,"data":{"turn":1,"step":1,"callId":"call_00_RFz12ulKTflJvhrwgkQX7978","name":"bash","arguments":"{\"command\": \"echo 'tok63z tokc7y tokibx tokofw tokujv tok10nu tok16rt tok1cvs tok1izr tok1p3q tok1v7p tok21bo tok2a4 tok8e3 tokei2 tokkm1 tokqq0 tokwtz tok12xy tok191x tok1f5w tok1l9v tok1rdu tok1xht tok23ls tok4k8 tokao7 tokgs6 tokmw5 tokt04 tokz43 tok1582 tok1bc1 tok1hg0 tok1njz tok1tny tok1zrx tokqd tok6uc tokcyb tokj2a tokp69 tokva8 tok11e7 tok17i6 tok1dm5 tok1jq4 tok1pu3 tok1vy2 tok2221 tok30h tok94g tokf8f toklce tokrgd tokxkc tok13ob tok19sa tok1fw9 tok1m08 tok1s47 tok1y86 tok24c5 tok5al tokbek tokhij toknmi toktqh tokzug tok15yf tok1c2e tok1i6d tok1oac tok1ueb tok20ia tok1gq tok7kp tokdoo tokjsn tokpwm tokw0l tok124k tok188j tok1eci tok1kgh tok1qkg tok1wof tok22se tok3qu tok9ut tokfys tokm2r toks6q tokyap tok14eo tok1ain tok1gmm tok1mql tok1suk tok1yyj tok252i tok60y tokc4x toki8w tokocv tokugu tok10kt tok16os tok1csr tok1iwq tok1p0p tok1v4o tok218n tok273 tok8b2 tokef1 tokkj0 tokqmz tokwqy tok12ux tok18yw tok1f2v tok1l6u tok1rat tok1xes tok23ir tok4h7 tokal6 tokgp5 tokmt4 toksx3 tokz12 tok1551 tok1b90 tok1hcz tok1ngy tok1tkx tok1zow toknc tok6rb tokcva tokiz9 tokp38 tokv77 tok11b6 tok17f5 tok1dj4 tok1jn3 tok1pr2 tok1vv1 tok21z0 tok2xg tok91f tokf5e tokl9d tokrdc tokxhb tok13la tok19p9 tok1ft8 tok1lx7 tok1s16 tok1y55 tok2494 tok57k tokbbj tokhfi toknjh tokktng tokzrf tok15ve tok1bzd tok1i3c tok1o7b tok1uba tok20f9 tok1dp tok7ho tokdln tokjpm tokptl tokvxk tok121j tok185i tok1e9h tok1kdg tok1qhf tok1wle tok22pd tok3nt tok9rs tokfvr toklzq toks3p toky7o tok14bn tok1afm tok1gjl tok1mnk tok1srj tok1yvi tok24zh tok5xx tokc1w toki5v toko9u tokudt tok10hs tok16lr tok1cpq tok1itp tok1oxo tok1v1n tok215m tok242 tok881 tokec0 tokfz tokqjy tokwnx' > notes.txt\", \"description\": \"Write notes.txt with the specified text\"}"}} +{"type":"tool/result","seq":849,"time":1785403674809,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_RFz12ulKTflJvhrwgkQX7978"},"content":[{"type":"tool-result","toolCallId":"call_00_RFz12ulKTflJvhrwgkQX7978","content":[{"type":"text","text":"[stderr]\nbash: notes.txt: Operation not permitted\n[sandbox: file access denied under read-only mode]\n[sandbox: escalation available — retry this exact command once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]\n[exit code: 1]"}],"isError":false}],"role":"user","id":"b555391f-0a33-4110-9631-4c3d073cb73c"}},"sourceEventSeqs":[848],"surfaceOp":"append"} +{"type":"step/end","seq":850,"time":1785403674809,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":851,"time":1785403674812,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":852,"time":1785403676090,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":853,"time0":1785403676091,"data":{"turn":1,"step":2,"index":0,"dt":[140,23,0,0,0,22,0,27,0,0,0,26,0,0,0,0,0,22,0,0,28,1,0,0,0,0,20,1,21,0,1,0,0,19,1,0,0],"texts":["The"," sand","box"," denied"," the"," file"," write","."," I"," need"," to"," ret","ry"," with"," sand","box","_per","missions"," set"," to"," \"","works","pace","-w","rite","\""," (","the"," narrow","est"," wider"," mode",")"," and"," provide"," a"," justification","."]}} +{"type":"assistant/chunk","seq":891,"time":1785403676518,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":892,"time0":1785403676518,"data":{"turn":1,"step":2,"index":1,"dt":[33,0,1,0,0,19,0,0,0,0,21,0,0,0,17,0,27,1,0,0,22,0,0,0,0,24,0,0,0,23,1,0,0,0,23,0,0,0,30,0,0,0,0,21,0,0,0,55,0,0,0,0,0,0,0,0,36,0,1,0,7,0,0,0,31,1,0,0,19,0,0,0,0,25,0,0,0,0,19,0,0,27,1,0,0,26,0,0,0,0,19,0,0,0,25,0,0,0,0,32,0,0,0,0,0,0,0,1,0,33,0,0,0,0,19,0,0,23,1,0,0,0,20,0,0,15,0,0,33,0,0,0,18,0,0,0,30,0,0,0,0,19,0,0,0,0,0,23,0,0,0,19,0,0,0,26,0,0,25,1,0,0,0,22,0,0,24,0,0,0,28,0,1,22,1,0,0,23,0,0,0,56,0,0,0,0,0,0,0,0,0,0,0,24,1,0,0,0,0,24,1,0,0,0,27,0,0,0,24,0,0,0,0,18,0,0,0,15,0,0,18,0,0,27,1,0,55,0,0,0,1,0,0,0,19,0,0,0,21,0,0,0,50,0,1,0,3,0,0,0,15,1,23,1,0,0,23,1,0,0,19,0,0,22,0,0,27,1,0,0,1,23,1,0,0,0,0,26,0,0,1,0,13,0,0,0,0,42,1,0,0,16,1,0,0,0,0,22,0,0,0,25,0,0,0,0,21,1,0,0,24,0,0,0,44,1,0,5,0,0,0,0,22,0,0,25,0,0,0,29,0,0,22,0,0,0,0,17,1,0,0,43,0,1,0,4,1,0,0,0,23,0,0,0,28,1,0,0,58,0,0,0,0,0,0,14,0,0,0,1,23,1,0,0,22,1,0,0,0,23,1,0,0,0,25,1,0,0,26,1,0,0,0,24,0,0,0,0,51,0,1,0,0,0,0,0,13,0,0,0,21,1,0,0,0,20,0,0,0,0,22,1,0,0,19,0,0,0,0,29,1,0,0,28,0,0,0,0,23,1,0,0,20,0,0,30,0,0,0,26,0,0,0,0,42,1,0,0,0,0,0,0,34,1,0,0,0,14,0,0,0,26,1,0,19,0,0,0,28,1,0,21,0,0,1,21,0,0,0,0,1,17,0,0,0,0,23,0,0,0,20,0,0,0,24,0,0,0,23,0,0,25,1,0,0,22,0,0,0,0,31,1,0,0,11,1,0,22,1,0,48,1,0,0,9,0,1,0,12,1,0,0,21,1,0,0,32,0,0,0,0,15,0,0,1,23,1,0,21,1,0,47,0,1,0,0,3,0,1,0,0,17,1,0,22,1,0,25,0,0,0,22,0,0,0,26,1,0,0,52,0,0,1,0,0,0,0,15,0,1,22,1,0,0,25,0,0,22,0,0,0,23,0,0,24,0,0,0,52,0,0,0,0,0,0,0,52,1,0,0,0,0,0,0,31,0,1,0,0,19,0,0,17,1,0,0,19,0,0,28,0,0,0,0,19,1,0,24,0,0,0,23,0,0,0,0,24,1,0,24,1,0,0,0,0,22,1,0,0,24,1,0,0,22,0,1,0,24,0,1,25,1,0,44,0,0,3,1,0,0,20,1,0,0,24,1,0,0,0,0,19,1,0,0,22,0,1,0,23,0,1,0,25,1,0,0,21,1,0,0,22,1,0,0,23,1,0,26,0,0,0,28,0,0,0,0,18,0,0,0,25,0,0,0,0,29,0,0,0,1,17,1,0,0,0,25,1,0,0,23,0,0,1,27,0,1,0,18,0,0,23,1,0,27,1,0,0,20,1,0,20,1,0,26,0,0,0,19,0,27,0,0,0,23,1,0,0,28,0,0,1,0,15,39,1,0,0,0,0,15,1,0,0,27,0,1,0,13,1,32,0,1,0,0,12],"id":"call_00_Oa8HTzXO89hDxPkAtKSn1634","name":"bash","args":["","{","\"","description","\"",": ","\"","Write"," notes",".txt"," with"," the"," specified"," text","\"",", ","\"","command","\"",": ","\"","echo"," '","tok","63","z"," tok","c","7","y"," tok","ib","x"," tok","of","w"," tok","uj","v"," tok","10","nu"," tok","16","rt"," tok","1","c","vs"," tok","1","iz","r"," tok","1","p","3","q"," tok","1","v","7","p"," tok","21","bo"," tok","2","a","4"," tok","8","e","3"," to","kei","2"," tok","km","1"," tok","qq","0"," tok","w","tz"," tok","12","xy"," tok","191","x"," tok","1","f","5","w"," tok","1","l","9","v"," tok","1","r","du"," tok","1","x","ht"," tok","23","ls"," tok","4","k","8"," to","ka","o","7"," tok","gs","6"," to","km","w","5"," to","kt","04"," tok","z","43"," tok","158","2"," tok","1","bc","1"," tok","1","hg","0"," tok","1","nj","z"," tok","1","t","ny"," tok","1","z","rx"," tok","qd"," tok","6","uc"," tok","cy","b"," tok","j","2","a"," tok","p","69"," tok","va","8"," tok","11","e","7"," tok","17","i","6"," tok","1","dm","5"," tok","1","j","q","4"," tok","1","pu","3"," tok","1","vy","2"," tok","222","1"," tok","30","h"," tok","94","g"," tok","f","8","f"," to","kl","ce"," tok","rg","d"," tok","x","kc"," tok","13","ob"," tok","19","sa"," tok","1","fw","9"," tok","1","m","08"," tok","1","s","47"," tok","1","y","86"," tok","24","c","5"," tok","5","al"," tok","bek"," to","kh","ij"," to","kn","mi"," to","kt","qh"," tok","zug"," tok","15","y","f"," tok","1","c","2","e"," tok","1","i","6","d"," tok","1","o","ac"," tok","1","ue","b"," tok","20","ia"," tok","1","g","q"," tok","7","kp"," tok","do","o"," tok","js","n"," tok","p","wm"," tok","w","0","l"," tok","124","k"," tok","188","j"," tok","1","e","ci"," tok","1","k","gh"," tok","1","q","kg"," tok","1","wof"," tok","22","se"," tok","3","qu"," tok","9","ut"," tok","f","ys"," to","km","2","r"," to","ks","6","q"," to","ky","ap"," tok","14","eo"," tok","1","ain"," tok","1","g","mm"," tok","1","m","ql"," tok","1","s","uk"," tok","1","yy","j"," tok","252","i"," tok","60","y"," tok","c","4","x"," to","ki","8","w"," tok","oc","v"," tok","ugu"," tok","10","kt"," tok","16","os"," tok","1","cs","r"," tok","1","iw","q"," tok","1","p","0","p"," tok","1","v","4","o"," tok","218","n"," tok","273"," tok","8","b","2"," tok","ef","1"," to","kk","j","0"," tok","qm","z"," tok","w","q","y"," tok","12","ux"," tok","18","yw"," tok","1","f","2","v"," tok","1","l","6","u"," tok","1","rat"," tok","1","xes"," tok","23","ir"," tok","4","h","7"," tok","al","6"," to","kg","p","5"," tok","mt","4"," to","ks","x","3"," tok","z","12"," tok","155","1"," tok","1","b","90"," tok","1","h","cz"," tok","1","ng","y"," tok","1","tk","x"," tok","1","z","ow"," to","kn","c"," tok","6","rb"," tok","c","va"," tok","iz","9"," tok","p","38"," tok","v","77"," tok","11","b","6"," tok","17","f","5"," tok","1","dj","4"," tok","1","jn","3"," tok","1","pr","2"," tok","1","vv","1"," tok","21","z","0"," tok","2","xg"," tok","91","f"," tok","f","5","e"," to","kl","9","d"," tok","rd","c"," tok","x","hb"," tok","13","la"," tok","19","p","9"," tok","1","ft","8"," tok","1","lx","7"," tok","1","s","16"," tok","1","y","55"," tok","249","4"," tok","57","k"," tok","bb","j"," to","kh","fi"," to","kn","jh"," tok","kt","ng"," tok","z","rf"," tok","15","ve"," tok","1","b","zd"," tok","1","i","3","c"," tok","1","o","7","b"," tok","1","uba"," tok","20","f","9"," tok","1","dp"," tok","7","ho"," tok","d","ln"," tok","j","pm"," tok","pt","l"," tok","v","xk"," tok","121","j"," tok","185","i"," tok","1","e","9","h"," tok","1","kd","g"," tok","1","qh","f"," tok","1","w","le"," tok","22","pd"," tok","3","nt"," tok","9","rs"," tok","f","vr"," to","kl","z","q"," to","ks","3","p"," to","ky","7","o"," tok","14","bn"," tok","1","af","m"," tok","1","g","jl"," tok","1","mn","k"," tok","1","sr","j"," tok","1","y","vi"," tok","24","zh"," tok","5","xx"," tok","c","1","w"," to","ki","5","v"," to","ko","9","u"," tok","ud","t"," tok","10","hs"," tok","16","lr"," tok","1","cp","q"," tok","1","it","p"," tok","1","ox","o"," tok","1","v","1","n"," tok","215","m"," tok","242"," tok","881"," tok","ec","0"," tok","f","z"," tok","q","jy"," tok","wn","x","'"," >"," notes",".txt","\"",", ","\"","sand","box","_per","missions","\"",": ","\"","works","pace","-w","rite","\"",", ","\"","just","ification","\"",": ","\"","Need"," to"," write"," the"," notes",".txt"," file"," as"," requested"," by"," the"," user",".","\"","}"]}} +{"type":"assistant/chunk","seq":1728,"time":1785403681516,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The sandbox denied the file write. I need to retry with sandbox_permissions set to \"workspace-write\" (the narrowest wider mode) and provide a justification."}}}} +{"type":"assistant/chunk","seq":1729,"time":1785403681516,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_Oa8HTzXO89hDxPkAtKSn1634","name":"bash","arguments":"{\"description\": \"Write notes.txt with the specified text\", \"command\": \"echo 'tok63z tokc7y tokibx tokofw tokujv tok10nu tok16rt tok1cvs tok1izr tok1p3q tok1v7p tok21bo tok2a4 tok8e3 tokei2 tokkm1 tokqq0 tokwtz tok12xy tok191x tok1f5w tok1l9v tok1rdu tok1xht tok23ls tok4k8 tokao7 tokgs6 tokmw5 tokt04 tokz43 tok1582 tok1bc1 tok1hg0 tok1njz tok1tny tok1zrx tokqd tok6uc tokcyb tokj2a tokp69 tokva8 tok11e7 tok17i6 tok1dm5 tok1jq4 tok1pu3 tok1vy2 tok2221 tok30h tok94g tokf8f toklce tokrgd tokxkc tok13ob tok19sa tok1fw9 tok1m08 tok1s47 tok1y86 tok24c5 tok5al tokbek tokhij toknmi toktqh tokzug tok15yf tok1c2e tok1i6d tok1oac tok1ueb tok20ia tok1gq tok7kp tokdoo tokjsn tokpwm tokw0l tok124k tok188j tok1eci tok1kgh tok1qkg tok1wof tok22se tok3qu tok9ut tokfys tokm2r toks6q tokyap tok14eo tok1ain tok1gmm tok1mql tok1suk tok1yyj tok252i tok60y tokc4x toki8w tokocv tokugu tok10kt tok16os tok1csr tok1iwq tok1p0p tok1v4o tok218n tok273 tok8b2 tokef1 tokkj0 tokqmz tokwqy tok12ux tok18yw tok1f2v tok1l6u tok1rat tok1xes tok23ir tok4h7 tokal6 tokgp5 tokmt4 toksx3 tokz12 tok1551 tok1b90 tok1hcz tok1ngy tok1tkx tok1zow toknc tok6rb tokcva tokiz9 tokp38 tokv77 tok11b6 tok17f5 tok1dj4 tok1jn3 tok1pr2 tok1vv1 tok21z0 tok2xg tok91f tokf5e tokl9d tokrdc tokxhb tok13la tok19p9 tok1ft8 tok1lx7 tok1s16 tok1y55 tok2494 tok57k tokbbj tokhfi toknjh tokktng tokzrf tok15ve tok1bzd tok1i3c tok1o7b tok1uba tok20f9 tok1dp tok7ho tokdln tokjpm tokptl tokvxk tok121j tok185i tok1e9h tok1kdg tok1qhf tok1wle tok22pd tok3nt tok9rs tokfvr toklzq toks3p toky7o tok14bn tok1afm tok1gjl tok1mnk tok1srj tok1yvi tok24zh tok5xx tokc1w toki5v toko9u tokudt tok10hs tok16lr tok1cpq tok1itp tok1oxo tok1v1n tok215m tok242 tok881 tokec0 tokfz tokqjy tokwnx' > notes.txt\", \"sandbox_permissions\": \"workspace-write\", \"justification\": \"Need to write the notes.txt file as requested by the user.\"}"}}}} +{"type":"assistant/chunk","seq":1730,"time":1785403681516,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":122,"outputTokens":930,"cacheReadTokens":6272,"reasoningTokens":38}}}} +{"type":"assistant/chunk","seq":1731,"time":1785403681516,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":1732,"time":1785403681517,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The sandbox denied the file write. I need to retry with sandbox_permissions set to \"workspace-write\" (the narrowest wider mode) and provide a justification."},{"type":"tool-call","id":"call_00_Oa8HTzXO89hDxPkAtKSn1634","name":"bash","arguments":"{\"description\": \"Write notes.txt with the specified text\", \"command\": \"echo 'tok63z tokc7y tokibx tokofw tokujv tok10nu tok16rt tok1cvs tok1izr tok1p3q tok1v7p tok21bo tok2a4 tok8e3 tokei2 tokkm1 tokqq0 tokwtz tok12xy tok191x tok1f5w tok1l9v tok1rdu tok1xht tok23ls tok4k8 tokao7 tokgs6 tokmw5 tokt04 tokz43 tok1582 tok1bc1 tok1hg0 tok1njz tok1tny tok1zrx tokqd tok6uc tokcyb tokj2a tokp69 tokva8 tok11e7 tok17i6 tok1dm5 tok1jq4 tok1pu3 tok1vy2 tok2221 tok30h tok94g tokf8f toklce tokrgd tokxkc tok13ob tok19sa tok1fw9 tok1m08 tok1s47 tok1y86 tok24c5 tok5al tokbek tokhij toknmi toktqh tokzug tok15yf tok1c2e tok1i6d tok1oac tok1ueb tok20ia tok1gq tok7kp tokdoo tokjsn tokpwm tokw0l tok124k tok188j tok1eci tok1kgh tok1qkg tok1wof tok22se tok3qu tok9ut tokfys tokm2r toks6q tokyap tok14eo tok1ain tok1gmm tok1mql tok1suk tok1yyj tok252i tok60y tokc4x toki8w tokocv tokugu tok10kt tok16os tok1csr tok1iwq tok1p0p tok1v4o tok218n tok273 tok8b2 tokef1 tokkj0 tokqmz tokwqy tok12ux tok18yw tok1f2v tok1l6u tok1rat tok1xes tok23ir tok4h7 tokal6 tokgp5 tokmt4 toksx3 tokz12 tok1551 tok1b90 tok1hcz tok1ngy tok1tkx tok1zow toknc tok6rb tokcva tokiz9 tokp38 tokv77 tok11b6 tok17f5 tok1dj4 tok1jn3 tok1pr2 tok1vv1 tok21z0 tok2xg tok91f tokf5e tokl9d tokrdc tokxhb tok13la tok19p9 tok1ft8 tok1lx7 tok1s16 tok1y55 tok2494 tok57k tokbbj tokhfi toknjh tokktng tokzrf tok15ve tok1bzd tok1i3c tok1o7b tok1uba tok20f9 tok1dp tok7ho tokdln tokjpm tokptl tokvxk tok121j tok185i tok1e9h tok1kdg tok1qhf tok1wle tok22pd tok3nt tok9rs tokfvr toklzq toks3p toky7o tok14bn tok1afm tok1gjl tok1mnk tok1srj tok1yvi tok24zh tok5xx tokc1w toki5v toko9u tokudt tok10hs tok16lr tok1cpq tok1itp tok1oxo tok1v1n tok215m tok242 tok881 tokec0 tokfz tokqjy tokwnx' > notes.txt\", \"sandbox_permissions\": \"workspace-write\", \"justification\": \"Need to write the notes.txt file as requested by the user.\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"9a0932ab-d9d9-4df3-a362-4c930d4bd7d1"},"usage":{"inputTokens":122,"outputTokens":930,"cacheReadTokens":6272,"reasoningTokens":38}},"sourceEventSeqs":[852,853,854,855,856,857,858,859,860,861,862,863,864,865,866,867,868,869,870,871,872,873,874,875,876,877,878,879,880,881,882,883,884,885,886,887,888,889,890,891,892,893,894,895,896,897,898,899,900,901,902,903,904,905,906,907,908,909,910,911,912,913,914,915,916,917,918,919,920,921,922,923,924,925,926,927,928,929,930,931,932,933,934,935,936,937,938,939,940,941,942,943,944,945,946,947,948,949,950,951,952,953,954,955,956,957,958,959,960,961,962,963,964,965,966,967,968,969,970,971,972,973,974,975,976,977,978,979,980,981,982,983,984,985,986,987,988,989,990,991,992,993,994,995,996,997,998,999,1000,1001,1002,1003,1004,1005,1006,1007,1008,1009,1010,1011,1012,1013,1014,1015,1016,1017,1018,1019,1020,1021,1022,1023,1024,1025,1026,1027,1028,1029,1030,1031,1032,1033,1034,1035,1036,1037,1038,1039,1040,1041,1042,1043,1044,1045,1046,1047,1048,1049,1050,1051,1052,1053,1054,1055,1056,1057,1058,1059,1060,1061,1062,1063,1064,1065,1066,1067,1068,1069,1070,1071,1072,1073,1074,1075,1076,1077,1078,1079,1080,1081,1082,1083,1084,1085,1086,1087,1088,1089,1090,1091,1092,1093,1094,1095,1096,1097,1098,1099,1100,1101,1102,1103,1104,1105,1106,1107,1108,1109,1110,1111,1112,1113,1114,1115,1116,1117,1118,1119,1120,1121,1122,1123,1124,1125,1126,1127,1128,1129,1130,1131,1132,1133,1134,1135,1136,1137,1138,1139,1140,1141,1142,1143,1144,1145,1146,1147,1148,1149,1150,1151,1152,1153,1154,1155,1156,1157,1158,1159,1160,1161,1162,1163,1164,1165,1166,1167,1168,1169,1170,1171,1172,1173,1174,1175,1176,1177,1178,1179,1180,1181,1182,1183,1184,1185,1186,1187,1188,1189,1190,1191,1192,1193,1194,1195,1196,1197,1198,1199,1200,1201,1202,1203,1204,1205,1206,1207,1208,1209,1210,1211,1212,1213,1214,1215,1216,1217,1218,1219,1220,1221,1222,1223,1224,1225,1226,1227,1228,1229,1230,1231,1232,1233,1234,1235,1236,1237,1238,1239,1240,1241,1242,1243,1244,1245,1246,1247,1248,1249,1250,1251,1252,1253,1254,1255,1256,1257,1258,1259,1260,1261,1262,1263,1264,1265,1266,1267,1268,1269,1270,1271,1272,1273,1274,1275,1276,1277,1278,1279,1280,1281,1282,1283,1284,1285,1286,1287,1288,1289,1290,1291,1292,1293,1294,1295,1296,1297,1298,1299,1300,1301,1302,1303,1304,1305,1306,1307,1308,1309,1310,1311,1312,1313,1314,1315,1316,1317,1318,1319,1320,1321,1322,1323,1324,1325,1326,1327,1328,1329,1330,1331,1332,1333,1334,1335,1336,1337,1338,1339,1340,1341,1342,1343,1344,1345,1346,1347,1348,1349,1350,1351,1352,1353,1354,1355,1356,1357,1358,1359,1360,1361,1362,1363,1364,1365,1366,1367,1368,1369,1370,1371,1372,1373,1374,1375,1376,1377,1378,1379,1380,1381,1382,1383,1384,1385,1386,1387,1388,1389,1390,1391,1392,1393,1394,1395,1396,1397,1398,1399,1400,1401,1402,1403,1404,1405,1406,1407,1408,1409,1410,1411,1412,1413,1414,1415,1416,1417,1418,1419,1420,1421,1422,1423,1424,1425,1426,1427,1428,1429,1430,1431,1432,1433,1434,1435,1436,1437,1438,1439,1440,1441,1442,1443,1444,1445,1446,1447,1448,1449,1450,1451,1452,1453,1454,1455,1456,1457,1458,1459,1460,1461,1462,1463,1464,1465,1466,1467,1468,1469,1470,1471,1472,1473,1474,1475,1476,1477,1478,1479,1480,1481,1482,1483,1484,1485,1486,1487,1488,1489,1490,1491,1492,1493,1494,1495,1496,1497,1498,1499,1500,1501,1502,1503,1504,1505,1506,1507,1508,1509,1510,1511,1512,1513,1514,1515,1516,1517,1518,1519,1520,1521,1522,1523,1524,1525,1526,1527,1528,1529,1530,1531,1532,1533,1534,1535,1536,1537,1538,1539,1540,1541,1542,1543,1544,1545,1546,1547,1548,1549,1550,1551,1552,1553,1554,1555,1556,1557,1558,1559,1560,1561,1562,1563,1564,1565,1566,1567,1568,1569,1570,1571,1572,1573,1574,1575,1576,1577,1578,1579,1580,1581,1582,1583,1584,1585,1586,1587,1588,1589,1590,1591,1592,1593,1594,1595,1596,1597,1598,1599,1600,1601,1602,1603,1604,1605,1606,1607,1608,1609,1610,1611,1612,1613,1614,1615,1616,1617,1618,1619,1620,1621,1622,1623,1624,1625,1626,1627,1628,1629,1630,1631,1632,1633,1634,1635,1636,1637,1638,1639,1640,1641,1642,1643,1644,1645,1646,1647,1648,1649,1650,1651,1652,1653,1654,1655,1656,1657,1658,1659,1660,1661,1662,1663,1664,1665,1666,1667,1668,1669,1670,1671,1672,1673,1674,1675,1676,1677,1678,1679,1680,1681,1682,1683,1684,1685,1686,1687,1688,1689,1690,1691,1692,1693,1694,1695,1696,1697,1698,1699,1700,1701,1702,1703,1704,1705,1706,1707,1708,1709,1710,1711,1712,1713,1714,1715,1716,1717,1718,1719,1720,1721,1722,1723,1724,1725,1726,1727,1728,1729,1730,1731],"surfaceOp":"append"} +{"type":"tool/call","seq":1733,"time":1785403681518,"data":{"turn":1,"step":2,"callId":"call_00_Oa8HTzXO89hDxPkAtKSn1634","name":"bash","arguments":"{\"description\": \"Write notes.txt with the specified text\", \"command\": \"echo 'tok63z tokc7y tokibx tokofw tokujv tok10nu tok16rt tok1cvs tok1izr tok1p3q tok1v7p tok21bo tok2a4 tok8e3 tokei2 tokkm1 tokqq0 tokwtz tok12xy tok191x tok1f5w tok1l9v tok1rdu tok1xht tok23ls tok4k8 tokao7 tokgs6 tokmw5 tokt04 tokz43 tok1582 tok1bc1 tok1hg0 tok1njz tok1tny tok1zrx tokqd tok6uc tokcyb tokj2a tokp69 tokva8 tok11e7 tok17i6 tok1dm5 tok1jq4 tok1pu3 tok1vy2 tok2221 tok30h tok94g tokf8f toklce tokrgd tokxkc tok13ob tok19sa tok1fw9 tok1m08 tok1s47 tok1y86 tok24c5 tok5al tokbek tokhij toknmi toktqh tokzug tok15yf tok1c2e tok1i6d tok1oac tok1ueb tok20ia tok1gq tok7kp tokdoo tokjsn tokpwm tokw0l tok124k tok188j tok1eci tok1kgh tok1qkg tok1wof tok22se tok3qu tok9ut tokfys tokm2r toks6q tokyap tok14eo tok1ain tok1gmm tok1mql tok1suk tok1yyj tok252i tok60y tokc4x toki8w tokocv tokugu tok10kt tok16os tok1csr tok1iwq tok1p0p tok1v4o tok218n tok273 tok8b2 tokef1 tokkj0 tokqmz tokwqy tok12ux tok18yw tok1f2v tok1l6u tok1rat tok1xes tok23ir tok4h7 tokal6 tokgp5 tokmt4 toksx3 tokz12 tok1551 tok1b90 tok1hcz tok1ngy tok1tkx tok1zow toknc tok6rb tokcva tokiz9 tokp38 tokv77 tok11b6 tok17f5 tok1dj4 tok1jn3 tok1pr2 tok1vv1 tok21z0 tok2xg tok91f tokf5e tokl9d tokrdc tokxhb tok13la tok19p9 tok1ft8 tok1lx7 tok1s16 tok1y55 tok2494 tok57k tokbbj tokhfi toknjh tokktng tokzrf tok15ve tok1bzd tok1i3c tok1o7b tok1uba tok20f9 tok1dp tok7ho tokdln tokjpm tokptl tokvxk tok121j tok185i tok1e9h tok1kdg tok1qhf tok1wle tok22pd tok3nt tok9rs tokfvr toklzq toks3p toky7o tok14bn tok1afm tok1gjl tok1mnk tok1srj tok1yvi tok24zh tok5xx tokc1w toki5v toko9u tokudt tok10hs tok16lr tok1cpq tok1itp tok1oxo tok1v1n tok215m tok242 tok881 tokec0 tokfz tokqjy tokwnx' > notes.txt\", \"sandbox_permissions\": \"workspace-write\", \"justification\": \"Need to write the notes.txt file as requested by the user.\"}"}} +{"type":"approval/asked","seq":1734,"time":1785403681519,"data":{"id":"c9284e32-66fe-40d1-baba-a4a467d00f0d","toolName":"bash","callId":"call_00_Oa8HTzXO89hDxPkAtKSn1634","reason":"escalate sandbox to workspace-write: Need to write the notes.txt file as requested by the user."}} +{"type":"approval/decided","seq":1735,"time":1785403681598,"data":{"id":"c9284e32-66fe-40d1-baba-a4a467d00f0d","outcome":"allowed-once"}} +{"type":"tool/result","seq":1736,"time":1785403681611,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_Oa8HTzXO89hDxPkAtKSn1634"},"content":[{"type":"tool-result","toolCallId":"call_00_Oa8HTzXO89hDxPkAtKSn1634","content":[{"type":"text","text":"(no output)"}],"isError":false}],"role":"user","id":"7f38d1c8-3dff-440b-be2d-a7c6a0a332cd"}},"sourceEventSeqs":[1733],"surfaceOp":"append"} +{"type":"step/end","seq":1737,"time":1785403681612,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":1738,"time":1785403681613,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":1739,"time":1785403682638,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":1740,"time0":1785403682639,"data":{"turn":1,"step":3,"index":0,"dt":[165,1,0,0,0,0,0,42,1,0,0,0,0],"texts":["The"," file"," was"," written"," successfully","."," Let"," me"," verify"," it"," was"," created"," correctly","."]}} +{"type":"assistant/chunk","seq":1754,"time":1785403682934,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":1755,"time0":1785403682935,"data":{"turn":1,"step":3,"index":1,"dt":[0,0,0,0,0,44,0,0,0,0,0],"id":"call_00_1izt6taEc9Avror1q7UM6022","name":"read","args":["","{","\"","file","_path","\"",": ","\"","notes",".txt","\"","}"]}} +{"type":"assistant/chunk","seq":1767,"time":1785403683035,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The file was written successfully. Let me verify it was created correctly."}}}} +{"type":"assistant/chunk","seq":1768,"time":1785403683035,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_1izt6taEc9Avror1q7UM6022","name":"read","arguments":"{\"file_path\": \"notes.txt\"}"}}}} +{"type":"assistant/chunk","seq":1769,"time":1785403683036,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":42,"outputTokens":59,"cacheReadTokens":7296,"reasoningTokens":14}}}} +{"type":"assistant/chunk","seq":1770,"time":1785403683036,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":1771,"time":1785403683037,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The file was written successfully. Let me verify it was created correctly."},{"type":"tool-call","id":"call_00_1izt6taEc9Avror1q7UM6022","name":"read","arguments":"{\"file_path\": \"notes.txt\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"db47847b-6227-40c8-9afd-45424fde25e1"},"usage":{"inputTokens":42,"outputTokens":59,"cacheReadTokens":7296,"reasoningTokens":14}},"sourceEventSeqs":[1739,1740,1741,1742,1743,1744,1745,1746,1747,1748,1749,1750,1751,1752,1753,1754,1755,1756,1757,1758,1759,1760,1761,1762,1763,1764,1765,1766,1767,1768,1769,1770],"surfaceOp":"append"} +{"type":"tool/call","seq":1772,"time":1785403683037,"data":{"turn":1,"step":3,"callId":"call_00_1izt6taEc9Avror1q7UM6022","name":"read","arguments":"{\"file_path\": \"notes.txt\"}"}} +{"type":"tool/result","seq":1773,"time":1785403683042,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_00_1izt6taEc9Avror1q7UM6022"},"content":[{"type":"tool-result","toolCallId":"call_00_1izt6taEc9Avror1q7UM6022","content":[{"type":"text","text":"{{cwd}}/workspace/notes.txt\nfile\n\n1: tok63z tokc7y tokibx tokofw tokujv tok10nu tok16rt tok1cvs tok1izr tok1p3q tok1v7p tok21bo tok2a4 tok8e3 tokei2 tokkm1 tokqq0 tokwtz tok12xy tok191x tok1f5w tok1l9v tok1rdu tok1xht tok23ls tok4k8 tokao7 tokgs6 tokmw5 tokt04 tokz43 tok1582 tok1bc1 tok1hg0 tok1njz tok1tny tok1zrx tokqd tok6uc tokcyb tokj2a tokp69 tokva8 tok11e7 tok17i6 tok1dm5 tok1jq4 tok1pu3 tok1vy2 tok2221 tok30h tok94g tokf8f toklce tokrgd tokxkc tok13ob tok19sa tok1fw9 tok1m08 tok1s47 tok1y86 tok24c5 tok5al tokbek tokhij toknmi toktqh tokzug tok15yf tok1c2e tok1i6d tok1oac tok1ueb tok20ia tok1gq tok7kp tokdoo tokjsn tokpwm tokw0l tok124k tok188j tok1eci tok1kgh tok1qkg tok1wof tok22se tok3qu tok9ut tokfys tokm2r toks6q tokyap tok14eo tok1ain tok1gmm tok1mql tok1suk tok1yyj tok252i tok60y tokc4x toki8w tokocv tokugu tok10kt tok16os tok1csr tok1iwq tok1p0p tok1v4o tok218n tok273 tok8b2 tokef1 tokkj0 tokqmz tokwqy tok12ux tok18yw tok1f2v tok1l6u tok1rat tok1xes tok23ir tok4h7 tokal6 tokgp5 tokmt4 toksx3 tokz12 tok1551 tok1b90 tok1hcz tok1ngy tok1tkx tok1zow toknc tok6rb tokcva tokiz9 tokp38 tokv77 tok11b6 tok17f5 tok1dj4 tok1jn3 tok1pr2 tok1vv1 tok21z0 tok2xg tok91f tokf5e tokl9d tokrdc tokxhb tok13la tok19p9 tok1ft8 tok1lx7 tok1s16 tok1y55 tok2494 tok57k tokbbj tokhfi toknjh tokktng tokzrf tok15ve tok1bzd tok1i3c tok1o7b tok1uba tok20f9 tok1dp tok7ho tokdln tokjpm tokptl tokvxk tok121j tok185i tok1e9h tok1kdg tok1qhf tok1wle tok22pd tok3nt tok9rs tokfvr toklzq toks3p toky7o tok14bn tok1afm tok1gjl tok1mnk tok1srj tok1yvi tok24zh tok5xx tokc1w toki5v toko9u tokudt tok10hs tok16lr tok1cpq tok1itp tok1oxo tok1v1n tok215m tok242 tok881 tokec0 tokfz tokqjy tokwnx\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"64de91fe-45e9-4bc2-9430-ca8b0d31c2b9"}},"sourceEventSeqs":[1772],"surfaceOp":"append"} +{"type":"step/end","seq":1774,"time":1785403683042,"data":{"turn":1,"step":3}} +{"type":"step/start","seq":1775,"time":1785403683043,"data":{"turn":1,"step":4}} +{"type":"assistant/chunk","seq":1776,"time":1785403684230,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":1777,"time0":1785403684231,"data":{"turn":1,"step":4,"index":0,"dt":[83,30,0,1,0,17,1,0,23,0,25,0,33,0,0,18,0,0,0,0,44,1,0,0,0,0],"texts":["The"," file"," was"," created"," successfully"," with"," the"," exact"," text"," on"," one"," line"," as"," requested","."," Now"," I"," need"," to"," reply"," with"," the"," single"," word"," D","ONE","."]}} +{"type":"assistant/chunk","seq":1804,"time":1785403684514,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":1805,"time":1785403684514,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":1806,"time":1785403684514,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":1807,"time":1785403684514,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The file was created successfully with the exact text on one line as requested. Now I need to reply with the single word DONE."}}}} +{"type":"assistant/chunk","seq":1808,"time":1785403684514,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":1809,"time":1785403684514,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":964,"outputTokens":30,"cacheReadTokens":7296,"reasoningTokens":27}}}} +{"type":"assistant/chunk","seq":1810,"time":1785403684514,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":1811,"time":1785403684515,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The file was created successfully with the exact text on one line as requested. Now I need to reply with the single word DONE."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"af385b17-60fe-435d-838d-4932b6b39bf6"},"usage":{"inputTokens":964,"outputTokens":30,"cacheReadTokens":7296,"reasoningTokens":27}},"sourceEventSeqs":[1776,1777,1778,1779,1780,1781,1782,1783,1784,1785,1786,1787,1788,1789,1790,1791,1792,1793,1794,1795,1796,1797,1798,1799,1800,1801,1802,1803,1804,1805,1806,1807,1808,1809,1810],"surfaceOp":"append"} +{"type":"step/end","seq":1812,"time":1785403684515,"data":{"turn":1,"step":4}} +{"type":"turn/end","seq":1813,"time":1785403684515,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/apps/web/tests/snapshots/approval-composer/ui.expected.md b/apps/web/tests/snapshots/approval-composer/ui.expected.md new file mode 100644 index 0000000000..469ca78790 --- /dev/null +++ b/apps/web/tests/snapshots/approval-composer/ui.expected.md @@ -0,0 +1,4 @@ +- text: 等待审批 +- group "审批详情": "escalate sandbox to workspace-write: Need to write the notes.txt file as requested by the user. echo 'tok63z tokc7y tokibx tokofw tokujv tok10nu tok16rt tok1cvs tok1izr tok1p3q tok1v7p tok21bo tok2a4 tok8e3 tokei2 tokkm1 tokqq0 tokwtz tok12xy tok191x tok1f5w tok1l9v tok1rdu tok1xht tok23ls tok4k8 tokao7 tokgs6 tokmw5 tokt04 tokz43 tok1582 tok1bc1 tok1hg0 tok1njz tok1tny tok1zrx tokqd tok6uc tokcyb tokj2a tokp69 tokva8 tok11e7 tok17i6 tok1dm5 tok1jq4 tok1pu3 tok1vy2 tok2221 tok30h tok94g tokf8f toklce tokrgd tokxkc tok13ob tok19sa tok1fw9 tok1m08 tok1s47 tok1y86 tok24c5 tok5al tokbek tokhij toknmi toktqh tokzug tok15yf tok1c2e tok1i6d tok1oac tok1ueb tok20ia tok1gq tok7kp tokdoo tokjsn tokpwm tokw0l tok124k tok188j tok1eci tok1kgh tok1qkg tok1wof tok22se tok3qu tok9ut tokfys tokm2r toks6q tokyap tok14eo tok1ain tok1gmm tok1mql tok1suk tok1yyj tok252i tok60y tokc4x toki8w tokocv tokugu tok10kt tok16os tok1csr tok1iwq tok1p0p tok1v4o tok218n tok273 tok8b2 tokef1 tokkj0 tokqmz tokwqy tok12ux tok18yw tok1f2v tok1l6u tok1rat tok1xes tok23ir tok4h7 tokal6 tokgp5 tokmt4 toksx3 tokz12 tok1551 tok1b90 tok1hcz tok1ngy tok1tkx tok1zow toknc tok6rb tokcva tokiz9 tokp38 tokv77 tok11b6 tok17f5 tok1dj4 tok1jn3 tok1pr2 tok1vv1 tok21z0 tok2xg tok91f tokf5e tokl9d tokrdc tokxhb tok13la tok19p9 tok1ft8 tok1lx7 tok1s16 tok1y55 tok2494 tok57k tokbbj tokhfi toknjh tokktng tokzrf tok15ve tok1bzd tok1i3c tok1o7b tok1uba tok20f9 tok1dp tok7ho tokdln tokjpm tokptl tokvxk tok121j tok185i tok1e9h tok1kdg tok1qhf tok1wle tok22pd tok3nt tok9rs tokfvr toklzq toks3p toky7o tok14bn tok1afm tok1gjl tok1mnk tok1srj tok1yvi tok24zh tok5xx tokc1w toki5v toko9u tokudt tok10hs tok16lr tok1cpq tok1itp tok1oxo tok1v1n tok215m tok242 tok881 tokec0 tokfz tokqjy tokwnx' > notes.txt" +- button "拒绝" +- button "允许一次" diff --git a/apps/web/tests/snapshots/details-session-lifecycle/handles.expected.md b/apps/web/tests/snapshots/details-session-lifecycle/handles.expected.md new file mode 100644 index 0000000000..aa865c4ad4 --- /dev/null +++ b/apps/web/tests/snapshots/details-session-lifecycle/handles.expected.md @@ -0,0 +1,7 @@ +# AppFrame drag handles + +## sidebar + +- hit strip present: true +- cursor: col-resize +- pill generated: false diff --git a/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md b/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md index 0bdf87d67a..788b8a3233 100644 --- a/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md +++ b/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md @@ -11,7 +11,7 @@ - cell "SYSTEM" - cell "Initial System Prompt" - 'row "USER, NavScenario: first run bash to print exactly NAVIGATION_OK, then read nav-a.md and nav-b.md using two read calls in ONE assistant message, then reply with the single word FIRST_DONE and stop."': - - cell "Turn 1 USER" + - cell "Turn 1 USER": USER - 'cell "NavScenario: first run bash to print exactly NAVIGATION_OK, then read nav-a.md and nav-b.md using two read calls in ONE assistant message, then reply with the single word FIRST_DONE and stop."' - 'row "Request 1, ASSISTANT, The user wants me to follow a specific navigation scenario. Let me: Run bash to print \"NAVIGATION_OK\" Read nav-a.md and nav-b.md in two read calls in ONE message Reply with \"FIRST_DONE\" Let me start with the bash command and the reads."': - 'cell "Request #1 ASSISTANT"': @@ -33,7 +33,7 @@ - text: ASSISTANT - cell "FIRST_DONE" - 'row "USER, Reply in markdown with: a level-2 heading \"Navigation Summary\", a bulleted list of exactly two items, and a fenced code block containing echo WATERFALL. Then stop."': - - cell "Turn 2 USER" + - cell "Turn 2 USER": USER - 'cell "Reply in markdown with: a level-2 heading \"Navigation Summary\", a bulleted list of exactly two items, and a fenced code block containing echo WATERFALL. Then stop."' - row "Request 3, ASSISTANT, Navigation Summary alpha nav beta nav echo WATERFALL": - 'cell "Request #3 ASSISTANT"': diff --git a/apps/web/tests/snapshots/plan-review/approved.expected.md b/apps/web/tests/snapshots/plan-review/approved.expected.md index aca0bc31bb..cb905c0b8b 100644 --- a/apps/web/tests/snapshots/plan-review/approved.expected.md +++ b/apps/web/tests/snapshots/plan-review/approved.expected.md @@ -5,7 +5,7 @@ - tab "Chat" [selected] - tab "Trajectory" - img -- text: "/plan Plan a small change: add a --greeting flag to a CLI. Do not read or write any files. Call exit_plan_mode with a short plan of at most five bullet points. Once the plan is approved, reply with the single word DONE and stop. Plan mode on. Use /plan off to leave. Plan a small change: add a --greeting flag to a CLI. Do not read or write any files. Call exit_plan_mode with a short plan of at most five bullet points. Once the plan is approved, reply with the single word DONE and stop. {{clock}}" +- text: "plan Plan mode on. Use /plan off to leave. Plan a small change: add a --greeting flag to a CLI. Do not read or write any files. Call exit_plan_mode with a short plan of at most five bullet points. Once the plan is approved, reply with the single word DONE and stop. {{clock}}" - button "复制": - img - button "在新对话中分支": diff --git a/apps/web/tests/snapshots/seeded-history/command-row.expected.md b/apps/web/tests/snapshots/seeded-history/command-row.expected.md new file mode 100644 index 0000000000..87ffd9fc55 --- /dev/null +++ b/apps/web/tests/snapshots/seeded-history/command-row.expected.md @@ -0,0 +1,49 @@ +- banner: + - navigation "Session hierarchy": + - button "Use the read tool twice" [disabled] + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. {{clock}}" +- button "复制": + - img +- button "在新对话中分支": + - img +- button "编辑": + - img +- button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel.": + - img + - img + - text: Think The user wants me to read a.txt and b.txt, then reply with "DONE". Let me do both reads in parallel. +- img +- text: Read +- button "a.txt" +- img +- text: Read +- button "b.txt" +- button "Think Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed.": + - img + - img + - text: Think Both files have been read. a.txt contains "alpha" and b.txt contains "beta". I'll now reply with DONE as instructed. +- paragraph: DONE +- button "复制": + - img +- button "在新对话中分支": + - img +- text: {{clock}} +- button "上下文注入": + - img + - img + - text: 上下文注入 +- img +- text: permission preset workspace-write +- textbox "Message the agent" +- button "Add attachment": + - img +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Plan mode off, press to turn on": Plan off +- button "Select model, current deepseek-v4-flash": + - text: deepseek-v4-flash + - img +- button "Send message" [disabled] +- text: 1 turns · 2 steps Tool call {{duration}} Cache hit 98% Input 15.8K tok · Output 135 tok diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 203f659c0c..f5774e1545 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -26,6 +26,7 @@ "tests/scaffold-hermetic.e2e.ts", "tests/live-interactions.e2e.ts", "tests/question-composer.e2e.ts", + "tests/approval-composer.e2e.ts", "tests/plan-review.e2e.ts", "tests/steering.e2e.ts", "tests/navigation-panes.e2e.ts", diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 8f5a77fc9e..e8600f8e55 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1934,7 +1934,7 @@ export interface Config { } ``` -Source: [`packages/web/tool-web/src/index.ts:35`](../packages/web/tool-web/src/index.ts) +Source: [`packages/web/tool-web/src/index.ts:37`](../packages/web/tool-web/src/index.ts) ## `@deepseek-ai/dsh-tool-workflow` @@ -1980,7 +1980,7 @@ export interface Config { export type ToolPresentationMode = 'native' | 'code' | 'both' ``` -Source: [`packages/core/tools/src/index.ts:578`](../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:582`](../packages/core/tools/src/index.ts) ## `@deepseek-ai/dsh-tui` diff --git a/docs/cookbook/adding-a-tool.i18n.yaml b/docs/cookbook/adding-a-tool.i18n.yaml index 423737be39..29ad71c634 100644 --- a/docs/cookbook/adding-a-tool.i18n.yaml +++ b/docs/cookbook/adding-a-tool.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -adding-a-tool.md: d06e3d8e3c7da1f71a55bf9c4f56cd4b2cc03697 -adding-a-tool.zh.md: 53f608eba3b26b124f873990fa13ce1572c0baf2 +# pnpm run verify-translation-pairing --write docs/cookbook/adding-a-tool.md +adding-a-tool.md: a85de0feeeee307ac645f8c2967bb44521d059a8 +adding-a-tool.zh.md: 8e4e6a1128f4f2ad3b4d42c2b88edaf4d8d89af1 diff --git a/docs/cookbook/adding-a-tool.md b/docs/cookbook/adding-a-tool.md index d06e3d8e3c..a85de0feee 100644 --- a/docs/cookbook/adding-a-tool.md +++ b/docs/cookbook/adding-a-tool.md @@ -78,6 +78,7 @@ Both methods return a **`card`-tagged render intent** — pick the card kind tha - `generic` supplies an optional title and content. - `terminal` supplies raw output and optional exit metadata; each UI renders its capable or fallback view. - `diff` supplies applied hunks, often derived by `output.presentationMeta` and carried in persisted `result.meta` so replay reproduces them. Mutation tools keep a diff result because the completed view replaces the pending card. + - `web` supplies a completed web retrieval, discriminated by `kind: 'search' | 'fetch'` (the structured search sources or the fetch summary), derived from `result.meta`; it carries no body copy, so a UI without the `web` capability falls back to the raw result content. (tool-web `web_search`/`web_fetch`.) Hard rules (they bite if broken): diff --git a/docs/cookbook/adding-a-tool.zh.md b/docs/cookbook/adding-a-tool.zh.md index 53f608eba3..8e4e6a1128 100644 --- a/docs/cookbook/adding-a-tool.zh.md +++ b/docs/cookbook/adding-a-tool.zh.md @@ -78,6 +78,7 @@ producer 提供同步的 `cancel`、在资源清理后 settle 且不 reject 的 - `generic` 提供可选的标题和内容。 - `terminal` 提供原始输出和可选的退出元数据;各 UI 根据自身能力渲染对应视图或回退视图。 - `diff` 提供已应用的 hunk,通常由 `output.presentationMeta` 派生并通过持久化的 `result.meta` 携带,使回放能重现它们。变更类工具保留 diff 结果,因为完成后的视图会替换 pending 卡片。 + - `web` 提供已完成的 web 检索,以 `kind: 'search' | 'fetch'` 区分(结构化的搜索来源或抓取摘要),由 `result.meta` 派生;它不携带正文副本,因此不具备 `web` 能力的 UI 回退到原始结果内容。(tool-web `web_search`/`web_fetch`。) 硬性规则(违反会出问题): diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index f821206431..8ccc0f3d87 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:156`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:160`](../../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:138`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:142`](../../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:113`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:117`](../../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:125`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:129`](../../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:102`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:106`](../../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:146`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:150`](../../packages/core/tools/src/index.ts) ## `workflow/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 55d679b8be..541cbb6016 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2313,7 +2313,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:700`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:704`](../../packages/core/tools/src/index.ts) ## `ctx.tui` — `TuiExtensionService` (abstract seam) diff --git a/docs/core-data-structures/tools.i18n.yaml b/docs/core-data-structures/tools.i18n.yaml index fa49f46c59..a6cf2bae68 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: dad7f7421caa94940801407fd4ef7fd936eb05c9 -tools.zh.md: 8386e5870e665e90ee0dbada8cb98084281001a7 +tools.md: 3c94f1093001e8c65365cc5baa50c1393d53b3ee +tools.zh.md: 7a6aad81c4cfe83be8625411e4313d0c36018821 diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md index dad7f7421c..3c94f10930 100644 --- a/docs/core-data-structures/tools.md +++ b/docs/core-data-structures/tools.md @@ -447,7 +447,7 @@ 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), or `{ 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). 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), 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. diff --git a/docs/core-data-structures/tools.zh.md b/docs/core-data-structures/tools.zh.md index 8386e5870e..7a6aad81c4 100644 --- a/docs/core-data-structures/tools.zh.md +++ b/docs/core-data-structures/tools.zh.md @@ -447,7 +447,7 @@ 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)。已完成视图会替换待执行视图,因此变更工具即使与调用时的片段重复也要返回 diff 结果。 +- `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 结果。 `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 运行时将这套中性词汇投影为各自的视图。 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 4690a0ca2d..fd9dac0d37 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:156`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | -| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:138`](../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:113`](../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:125`](../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:102`](../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:146`](../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: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) | | `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 6f94b5801d..5a087badc4 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 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 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 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;\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 }"}],"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 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 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 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":"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/web-fetch/session.jsonl b/examples/acp-agent/tests/snapshots/web-fetch/session.jsonl index eebf49c9b2..af78dc43cf 100644 --- a/examples/acp-agent/tests/snapshots/web-fetch/session.jsonl +++ b/examples/acp-agent/tests/snapshots/web-fetch/session.jsonl @@ -14,7 +14,7 @@ {"type":"assistant/chunk","seq":78,"time":1785078729804,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":79,"time":1785078729807,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to use the web_fetch tool exactly once to fetch http://127.0.0.1:43117/menu.html, then reply with exactly \"DONE\". Let me do that."},{"type":"tool-call","id":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","arguments":"{\"url\": \"http://127.0.0.1:43117/menu.html\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"58db7df1-5331-49ca-b34f-09c59d8d8c85"},"usage":{"inputTokens":5405,"outputTokens":103,"cacheReadTokens":0,"reasoningTokens":44}},"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":1785078729809,"data":{"turn":1,"step":1,"callId":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","arguments":"{\"url\": \"http://127.0.0.1:43117/menu.html\"}"}} -{"type":"tool/result","seq":81,"time":1785078729843,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_sxjOyfDYN07koiE7jiIa5326"},"content":[{"type":"tool-result","toolCallId":"call_00_sxjOyfDYN07koiE7jiIa5326","content":[{"type":"text","text":"Fetched http://127.0.0.1:43117/menu.html (HTTP 200)\n\nMenu\n\n# Café menu\n\nPrices include **service & _tax_** — updated daily.\n\n- Espresso\n- Flat white\n\n| Drink | Price |\n| --- | --- |\n| Espresso | €2 |\n| Flat white | €3 |\n\nSee [today’s specials](https://fixture.invalid/specials)."}],"isError":false}],"role":"user","id":"fca910ec-ed8f-45a9-8dda-1e88cfd41126"}},"sourceEventSeqs":[80],"surfaceOp":"append"} +{"type":"tool/result","seq":81,"time":1785078729843,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_sxjOyfDYN07koiE7jiIa5326"},"content":[{"type":"tool-result","toolCallId":"call_00_sxjOyfDYN07koiE7jiIa5326","content":[{"type":"text","text":"Fetched http://127.0.0.1:43117/menu.html (HTTP 200)\n\nMenu\n\n# Café menu\n\nPrices include **service & _tax_** — updated daily.\n\n- Espresso\n- Flat white\n\n| Drink | Price |\n| --- | --- |\n| Espresso | €2 |\n| Flat white | €3 |\n\nSee [today’s specials](https://fixture.invalid/specials)."}],"isError":false}],"role":"user","id":"fca910ec-ed8f-45a9-8dda-1e88cfd41126"},"meta":{"url":"http://127.0.0.1:43117/menu.html","statusCode":200,"truncated":false}},"sourceEventSeqs":[80],"surfaceOp":"append"} {"type":"step/end","seq":82,"time":1785078729847,"data":{"turn":1,"step":1}} {"type":"step/start","seq":83,"time":1785078729848,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":84,"time":1785078730611,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 31fe2e301d..068fdc8307 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -1392,14 +1392,14 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { const spec = PERMISSION_PRESETS[preset] if (preset === '') { const current = permissionSelectOf(logOf(id)).currentValue - append(id, { type: 'command/done', data: { commandId, kind: 'success', text: `Current permission preset: ${current}. Available: ${Object.keys(PERMISSION_PRESETS).join(', ')}.` } }) + append(id, { type: 'command/done', data: { commandId, kind: 'success', text: `current preset ${current} (available: ${Object.keys(PERMISSION_PRESETS).join(', ')})` } }) } else if (spec === undefined) { - append(id, { type: 'command/done', data: { commandId, kind: 'error', text: `unknown permission preset ${JSON.stringify(preset)} (available: ${Object.keys(PERMISSION_PRESETS).join(', ')})` } }) + append(id, { type: 'command/done', data: { commandId, kind: 'error', text: `unknown preset "${preset}" (available: ${Object.keys(PERMISSION_PRESETS).join(', ')})` } }) } else { if (permissionSelectOf(logOf(id)).currentValue !== preset) append(id, { type: 'permission/preset', data: { preset } }) append(id, { type: 'sandbox/mode', data: { mode: spec.sandbox } }) append(id, { type: 'approval/policy', data: { policy: spec.approval } }) - append(id, { type: 'command/done', data: { commandId, kind: 'success', text: `Permission preset: ${preset}.` } }) + append(id, { type: 'command/done', data: { commandId, kind: 'success', text: `preset ${preset}` } }) } return ok(request, { matched: true as const, commandId }) } diff --git a/packages/client/runtime/src/client/session-history/source.ts b/packages/client/runtime/src/client/session-history/source.ts index 8de11b9da9..4f3e86be6a 100644 --- a/packages/client/runtime/src/client/session-history/source.ts +++ b/packages/client/runtime/src/client/session-history/source.ts @@ -1,12 +1,14 @@ import type { HistoryEntry, IApiClient, MuxFrame, RpcError, SessionId, } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' import type { SessionHistoryFace, SessionHistorySnapshot, } from '../contract/session-history.ts' import { createHistoryInspection } from '../sessions/history.ts' import { Notifier } from '../sessions/notifier.ts' +import { PartialAccumulator } from '../sessions/partial.ts' const HISTORY_PAGE_MESSAGES = 50 @@ -33,6 +35,9 @@ export class SessionHistorySource implements SessionHistoryFace { entries: readonly HistoryEntry[] value: SessionHistorySnapshot['inspection'] } | null = null + private streamPublishToken: object | null = null + private streamBaseInspection: SessionHistorySnapshot['inspection'] | null = null + private streamPartial: PartialAccumulator | null = null private snapshotCache: SessionHistorySnapshot private readonly notifier = new Notifier(() => { this.snapshotCache = this.buildSnapshot() @@ -125,7 +130,7 @@ export class SessionHistorySource implements SessionHistoryFace { if (this.state !== 'cold') { this.state = 'cold' this.error = null - this.notifier.markDirty() + this.publishDirtyNow() } } @@ -143,7 +148,7 @@ export class SessionHistorySource implements SessionHistoryFace { this.hasMore = false this.state = 'cold' this.error = null - this.notifier.markDirty() + this.publishDirtyNow() void this.loadForConsumers() } @@ -155,6 +160,9 @@ export class SessionHistorySource implements SessionHistoryFace { this.openPromise = null this.olderPromise = null this.liveBuffer = [] + this.streamPublishToken = null + this.streamBaseInspection = null + this.streamPartial = null } private open(): Promise { @@ -188,7 +196,7 @@ export class SessionHistorySource implements SessionHistoryFace { private async doOpen(generation: number): Promise { this.state = 'loading' this.error = null - this.notifier.markDirty() + this.publishDirtyNow() try { let { result } = await this.api.sessions.history({ sessionId: this.sessionId, @@ -222,7 +230,7 @@ export class SessionHistorySource implements SessionHistoryFace { /* v8 ignore next -- transportError always returns the error branch. */ this.error = folded.ok ? null : folded.error } finally { - if (generation === this.generation) this.notifier.markDirty() + if (generation === this.generation) this.publishDirtyNow() } } @@ -261,7 +269,7 @@ export class SessionHistorySource implements SessionHistoryFace { const settled = operation.finally(() => { if (this.olderPromise !== settled) return this.olderPromise = null - this.notifier.markDirty() + this.publishDirtyNow() }) this.olderPromise = settled return settled @@ -286,7 +294,7 @@ export class SessionHistorySource implements SessionHistoryFace { const buffered = this.liveBuffer this.liveBuffer = [] for (const entry of buffered) this.appendLive(entry) - this.notifier.markDirty() + this.publishDirtyNow() } private acceptLive(entry: HistoryEntry): void { @@ -301,8 +309,16 @@ export class SessionHistorySource implements SessionHistoryFace { void this.repairGap() return } + if ( + entry.event.type === 'assistant/chunk' + && entry.event.data.chunk.type !== 'usage' + ) { + if (!this.appendIncrementalChunk(entry, entry.event)) return + this.publishStreamDirty() + return + } this.appendLive(entry) - this.notifier.markDirty() + this.publishDirtyNow() } private appendLive(entry: HistoryEntry): void { @@ -311,6 +327,66 @@ export class SessionHistorySource implements SessionHistoryFace { this.entries = [...this.entries, entry] } + /** Append a chunk against the cached finalized projection; false means no visible publish. */ + private appendIncrementalChunk( + entry: HistoryEntry, + event: SessionEvent<'assistant/chunk'>, + ): boolean { + const { turn, step, chunk } = event.data + if (!isVisibleAssistantChunk(chunk.type)) { + const inspection = this.currentInspection() + this.appendLive(entry) + this.inspectionCache = { entries: this.entries, value: inspection } + return false + } + const base = this.streamBaseInspection ?? this.currentInspection() + this.streamBaseInspection = base + if ( + this.streamPartial === null + || this.streamPartial.turn !== turn + || this.streamPartial.step !== step + ) { + const current = base.partial + this.streamPartial = new PartialAccumulator( + turn, + step, + current?.turn === turn && current.step === step ? current.blocks : [], + ) + } + this.streamPartial.push(chunk) + this.appendLive(entry) + this.inspectionCache = { + entries: this.entries, + value: { ...base, partial: this.streamPartial.toPartial() }, + } + return true + } + + /** Coalesce token-stream projection and rendering work to one publish per browser frame. */ + private publishStreamDirty(): void { + if (this.streamPublishToken !== null) return + const token = {} + this.streamPublishToken = token + const publish = () => { + if (this.streamPublishToken !== token) return + this.streamPublishToken = null + this.notifier.markDirty() + } + if (typeof globalThis.requestAnimationFrame === 'function') { + globalThis.requestAnimationFrame(publish) + } else { + queueMicrotask(publish) + } + } + + /** Publish structural changes immediately and invalidate an older scheduled stream publish. */ + private publishDirtyNow(): void { + this.streamPublishToken = null + this.streamBaseInspection = null + this.streamPartial = null + this.notifier.markDirty() + } + private async repairGap(): Promise { if (this.stitching) return this.stitching = true @@ -335,6 +411,16 @@ export class SessionHistorySource implements SessionHistoryFace { } private buildSnapshot(): SessionHistorySnapshot { + return { + state: this.state, + error: this.error, + hasMore: this.hasMore, + inspection: this.currentInspection(), + } + } + + /** Inspection pinned to the source's current immutable entry array. */ + private currentInspection(): SessionHistorySnapshot['inspection'] { if (this.inspectionCache?.entries !== this.entries) { const entries = this.entries this.inspectionCache = { @@ -342,11 +428,14 @@ export class SessionHistorySource implements SessionHistoryFace { value: createHistoryInspection(() => entries), } } - return { - state: this.state, - error: this.error, - hasMore: this.hasMore, - inspection: this.inspectionCache.value, - } + return this.inspectionCache.value } } + +function isVisibleAssistantChunk(type: string): boolean { + return type === 'block-start' + || type === 'text-delta' + || type === 'reasoning-delta' + || type === 'tool-call-delta' + || type === 'block-end' +} diff --git a/packages/client/runtime/src/client/sessions/partial.ts b/packages/client/runtime/src/client/sessions/partial.ts index 189febf253..242232f3c1 100644 --- a/packages/client/runtime/src/client/sessions/partial.ts +++ b/packages/client/runtime/src/client/sessions/partial.ts @@ -13,8 +13,18 @@ export class PartialAccumulator { private changed = true private snapshot: PartialAssistant - constructor(readonly turn: number, readonly step: number) { - this.snapshot = { turn, step, blocks: [] } + /** + * @param turn - Owning agent turn. + * @param step - Owning model step. + * @param initialBlocks - Materialized prefix when accumulation begins after history replay. + */ + constructor( + readonly turn: number, + readonly step: number, + initialBlocks: readonly AssistantBlock[] = [], + ) { + this.blocks = [...initialBlocks] + this.snapshot = { turn, step, blocks: initialBlocks } } /** diff --git a/packages/client/runtime/tests/partial.spec.ts b/packages/client/runtime/tests/partial.spec.ts index 25681ea4cc..c1df190c33 100644 --- a/packages/client/runtime/tests/partial.spec.ts +++ b/packages/client/runtime/tests/partial.spec.ts @@ -41,6 +41,12 @@ describe('PartialAccumulator', () => { expect(acc.toPartial().blocks).toEqual([{ kind: 'reasoning', text: '思考' }]) }) + it('continues from a materialized history prefix', () => { + const acc = new PartialAccumulator(1, 0, [{ kind: 'text', text: '已有' }]) + acc.push(chunk({ type: 'text-delta', index: 0, text: '增量' })) + expect(acc.toPartial().blocks).toEqual([{ kind: 'text', text: '已有增量' }]) + }) + it('folds tool-call deltas: first id pins callId, late name overrides, argsRaw concatenates', () => { const acc = new PartialAccumulator(1, 0) acc.push(chunk({ type: 'tool-call-delta', index: 0, id: 'c1', argumentsDelta: '{"a"' })) diff --git a/packages/client/runtime/tests/session-history-source.spec.ts b/packages/client/runtime/tests/session-history-source.spec.ts index 1375338e90..bcf25cd933 100644 --- a/packages/client/runtime/tests/session-history-source.spec.ts +++ b/packages/client/runtime/tests/session-history-source.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' import { SessionHistorySource } from '../src/client/session-history/source.ts' @@ -7,6 +7,10 @@ import { entries, ev, plainTurn } from './event-script.ts' const SID = 'history-s1' as SessionId +afterEach(() => { + vi.unstubAllGlobals() +}) + function histResponse(events: SessionEvent[], hasMore = false) { return Promise.resolve(ok({ events: entries(events) as never[], hasMore })) } @@ -52,6 +56,71 @@ describe('SessionHistorySource', () => { .toEqual([1, 3, 6]) }) + it('publishes multiple assistant chunks once per browser frame', async () => { + const api = new FakeApiClient() + api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答')) + const source = new SessionHistorySource(SID, api) + await source.loadAll() + const frames: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frames.push(callback) + return frames.length + }) + let notifications = 0 + const unsubscribe = source.subscribe(() => { notifications++ }) + const before = source.getSnapshot().inspection + const finalizedNodes = before.eventNodes + const requests = before.requests + const contexts = before.contexts + + for (const event of [ + ev.chunkStart(6, 1), + ev.chunkText(7, 1, 'stream '), + ev.chunkText(8, 1, 'content'), + ]) { + source.handleMuxFrame({ + type: 'session/event', + sessionId: SID, + event, + }) + } + + expect(frames).toHaveLength(1) + expect(notifications).toBe(0) + frames[0]?.(0) + await Promise.resolve() + + expect(notifications).toBe(1) + const streamed = source.getSnapshot().inspection + expect(streamed.eventNodes).toBe(finalizedNodes) + expect(streamed.requests).toBe(requests) + expect(streamed.contexts).toBe(contexts) + expect(streamed.partial?.blocks).toEqual([ + { kind: 'text', text: 'stream content' }, + ]) + + source.handleMuxFrame({ + type: 'session/event', + sessionId: SID, + event: ev.chunkText(9, 1, ' then final'), + }) + source.handleMuxFrame({ + type: 'session/event', + sessionId: SID, + event: ev.assistant(10, 1, 'stream content then final'), + }) + await Promise.resolve() + + expect(notifications).toBe(2) + const finalized = source.getSnapshot().inspection + expect(finalized.eventNodes).not.toBe(finalizedNodes) + expect(finalized.partial).toBeNull() + frames[1]?.(0) + await Promise.resolve() + expect(notifications).toBe(2) + unsubscribe() + }) + it('stops loading when an older page fails to advance', async () => { const api = new FakeApiClient() api.onHistory = payload => payload.beforeSeq === undefined diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index 46a0e020b9..e62e14bd48 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -135,7 +135,7 @@ describe('live event path', () => { expect(session.getSnapshot().composerPhase).toBe('blank') const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } feed(ev.commandRun(0, 'cmd-perm', 'permission', ' danger-full-access')) - feed(ev.commandDone(1, 'cmd-perm', 'success', 'Permission preset: danger-full-access.')) + feed(ev.commandDone(1, 'cmd-perm', 'success', 'preset danger-full-access')) const snapshot = session.getSnapshot() expect(snapshot.nodes.at(-1)).toMatchObject({ kind: 'command', name: 'permission' }) expect(snapshot.composerPhase).toBe('blank') diff --git a/packages/client/ui-conversation/src/client/chat/GenericCommandCard.tsx b/packages/client/ui-conversation/src/client/chat/GenericCommandCard.tsx index 1dfea5488b..4b42a94c72 100644 --- a/packages/client/ui-conversation/src/client/chat/GenericCommandCard.tsx +++ b/packages/client/ui-conversation/src/client/chat/GenericCommandCard.tsx @@ -1,6 +1,6 @@ // GenericCommandCard: the default command row — a stripped-down -// GenericToolCard rendering the dispatched command line and the settlement -// text. Supplied by the chat view as the keyed commandview slot's render-site +// GenericToolCard rendering the command name and its settlement text. +// Supplied by the chat view as the keyed commandview slot's render-site // fallback (an unregistered command name lands here); registrants may compose // it as a base, feeding the same owner payload through. @@ -20,10 +20,11 @@ export function GenericCommandCard({ node }: CommandRowOwnerProps) { const summary = node.outcome === null ? '执行中…' : text ?? (node.outcome.kind === 'error' ? '命令失败' : '已完成') - // Display line rebuilt from the structured payload (args carries its own - // separator whitespace verbatim); a cross-window node whose run page fell - // out of the window has neither. - const title = node.name === null ? '命令' : `/${node.name}${node.args ?? ''}` + // Title is the bare command name: the row already reads `name · outcome`, + // and the dispatched line's own `/` and arguments only restate what the + // settlement text says (`permission · preset workspace-write`). A + // cross-window node whose run page fell out of the window has no name. + const title = node.name ?? '命令' return (
等待审批
-
+ {/* Tab stop: the region scrolls once the command passes the cap and + holds nothing focusable of its own, so without one a keyboard-only + user cannot reach the command's tail before answering. */} +
{pending.reason ?? `工具 ${pending.toolName} 请求越权执行`}
{command !== undefined &&
{command}
} -
- - -
+
+
+ +
diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index e48d25ab45..01f7ea515c 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -143,6 +143,14 @@ display: flex; flex: none; flex-direction: column; + /* One cap for every scrolling text region a composer seat can hold: the + InputBar draft (figma Input 75:8208 max 14 lines × 24px line) and the + takeover panels' bodies top out at the same height, so electing a + takeover never grows the footer past the card it replaces. Declared on + the seat because it is the chain's only shared ancestor — fallback and + elected overlay are siblings — and custom properties inherit down to + whichever entry is mounted. */ + --dsh-composer-text-max-height: 336px; } /* Active phase: header is ordinary column chrome above the scrollport (not diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css index f0a59942f7..14aebe899b 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -209,7 +209,9 @@ .mirror { visibility: hidden; pointer-events: none; - max-height: 336px; + /* 14-line cap, shared with the composer takeovers (declared on + ConversationRoot .composerSeat). */ + max-height: var(--dsh-composer-text-max-height); overflow: hidden; } diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 59025b6b21..d78b81797d 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -460,10 +460,14 @@ describe('ChatView', () => { name: 'plan', args: '', outcome: { kind: 'success', text: '已进入 plan mode' }, ...over, }) - // Settled success: the command line is the title, the outcome text the summary. - const settled = makeHarness({ nodes: [user(1, 'hi'), command({})] }) + // Settled success: the bare command name is the title, the outcome text + // the summary — neither the dispatched `/` nor its arguments reach the row + // (the settlement text already says what the command did). + const settled = makeHarness({ nodes: [user(1, 'hi'), command({ args: ' now' })] }) const view = render() - expect(view.getByText('/plan')).toBeTruthy() + expect(view.getByText('plan')).toBeTruthy() + expect(view.queryByText('/plan')).toBeNull() + expect(view.queryByText('/plan now')).toBeNull() expect(view.getByText('已进入 plan mode')).toBeTruthy() // Error outcome flips the row state; a text-less error gets the default copy. diff --git a/packages/client/ui-layout/README.i18n.yaml b/packages/client/ui-layout/README.i18n.yaml index 870937c93c..aba4711a24 100644 --- a/packages/client/ui-layout/README.i18n.yaml +++ b/packages/client/ui-layout/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-layout/README.md -README.md: 0e92958c9b088071ab58f7e87e8af68f6c2df68d -README.zh.md: 0fb3b1cd85bbf6e070a2a1dd01b25981e5990df0 +README.md: cb99023e6a9e3364c6f48190cf4a0cd71da2cbba +README.zh.md: 3681b4517670eb92d8f32be2ac62d5852ac745a3 diff --git a/packages/client/ui-layout/README.md b/packages/client/ui-layout/README.md index 0e92958c9b..cb99023e6a 100644 --- a/packages/client/ui-layout/README.md +++ b/packages/client/ui-layout/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Shell plugin: three-column AppFrame (drag handles and concession chain) plus the `ctx.layout` panel-geometry service; it registers into the runtime-owned `root` slot and declares `sidebar`, `conversation`, `details`, and `conversation.empty`. The sidebar is fixed-width (only details shrinks, then auto-closes); a closed sidebar retains a 56px control rail while details closes to zero width. The package also seats the theme presenter: it consumes resolved `ctx.theme` snapshots and projects them onto the document (`html { color-scheme }` for native UA chrome, `body[data-ds-dark-theme]` from the active color scheme, plus the theme's alias tokens as inline variables on body). +Shell plugin: three-column AppFrame (drag handles and concession chain) plus the `ctx.layout` panel-geometry service; it registers into the runtime-owned `root` slot and declares `sidebar`, `conversation`, `details`, and `conversation.empty`. The sidebar resize boundary is an invisible hit strip, while the details boundary retains its floating pill; only details shrinks during concession and then auto-closes. A closed sidebar retains a 56px control rail while details closes to zero width. The package also seats the theme presenter: it consumes resolved `ctx.theme` snapshots and projects them onto the document (`html { color-scheme }` for native UA chrome, `body[data-ds-dark-theme]` from the active color scheme, plus the theme's alias tokens as inline variables on body). AppFrame always mounts the conversation and details columns; a connected Session renders through `SessionProvider`. The transient layout store starts the sidebar at its default width and details closed, and it never reads or writes `localStorage`. Hero and other unselected states also derive a zero rendered details width without changing that stored preference. AppFrame retains the last non-blank Session id across those states: the first Session remains closed, an explicit details action opens the contract default width, returning to the same Session restores its unchanged width, and selecting a different Session closes details before paint. The conversation owner share is empty, while the sidebar owner share contains only `collapsed` and `width`; registrants obtain business data from standard hooks and actions from their own inject faces. diff --git a/packages/client/ui-layout/README.zh.md b/packages/client/ui-layout/README.zh.md index 0fb3b1cd85..3681b45176 100644 --- a/packages/client/ui-layout/README.zh.md +++ b/packages/client/ui-layout/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -外壳插件:三栏 AppFrame(拖动手柄与让步链)加 `ctx.layout` 面板几何服务;它注册到运行时拥有的 `root` slot,并声明 `sidebar`、`conversation`、`details` 和 `conversation.empty`。侧边栏宽度固定(只会收缩详情栏,然后将其自动关闭);关闭的侧边栏仍保留 56px 控制轨道,详情栏则关闭到零宽度。该包还提供主题呈现器:它消费解析后的 `ctx.theme` 快照,并将其投影到 document(用 `html { color-scheme }` 驱动原生 UA 控件,依据当前配色方案设置 `body[data-ds-dark-theme]`,并将主题的别名 token 设为 body 上的内联变量)。 +外壳插件:三栏 AppFrame(拖动手柄与让步链)加 `ctx.layout` 面板几何服务;它注册到运行时拥有的 `root` slot,并声明 `sidebar`、`conversation`、`details` 和 `conversation.empty`。侧边栏的缩放边界是不可见命中条带,详情栏边界则保留其浮动胶囊;让步期间只有详情栏会收缩并随后自动关闭。关闭的侧边栏仍保留 56px 控制轨道,详情栏则关闭到零宽度。该包还提供主题呈现器:它消费解析后的 `ctx.theme` 快照,并将其投影到 document(用 `html { color-scheme }` 驱动原生 UA 控件,依据当前配色方案设置 `body[data-ds-dark-theme]`,并将主题的别名 token 设为 body 上的内联变量)。 AppFrame 始终挂载会话栏和详情栏;已连接 Session 通过 `SessionProvider` 渲染。布局 store 是瞬时状态,侧边栏以默认宽度启动,详情栏则保持关闭,且该 store 从不读写 `localStorage`。hero 和其他未选中状态也会将详情栏的渲染宽度派生为零,但不会改变存储的首选宽度。AppFrame 会跨越这些状态保留最后一个非 blank 会话 id:首个会话保持关闭;显式打开详情栏的操作会使用契约默认宽度;返回同一会话时恢复其未改变的宽度;选择不同会话时,详情栏会在绘制前关闭。会话 owner share 为空,侧边栏 owner share 只包含 `collapsed` 和 `width`;注册方通过标准钩子获取业务数据,并从各自的 inject 表层获取操作。 diff --git a/packages/client/ui-layout/src/client/AppFrame.module.css b/packages/client/ui-layout/src/client/AppFrame.module.css index b805bb178a..ef6c70661d 100644 --- a/packages/client/ui-layout/src/client/AppFrame.module.css +++ b/packages/client/ui-layout/src/client/AppFrame.module.css @@ -49,9 +49,8 @@ } /* Drag handles are frame children (columns clip overflow): an 8px hit strip - centered on the column border via inline left, above column content. The - visible pill (12x32 r10, riding the border at vertical center) is the figma - Handle component; the hit strip stays wider than the pill. */ + centered on the column border via inline left, above column content. Details + adds a visible 12x32 pill at vertical center; sidebar keeps only the hit strip. */ .handle { position: absolute; top: 0; @@ -76,7 +75,7 @@ } } -.handle::after { +.handle[data-side='details']::after { content: ''; position: absolute; top: 50%; @@ -88,23 +87,22 @@ box-sizing: border-box; background: var(--dsw-alias-button-floating-fill); border: 1px solid var(--dsw-alias-border-l2-darkmode-thin); - /* Hover affordance: the pill hides until the pointer is over the owning - column (data-side pairs handle and column), the strip itself, or a drag. */ + /* Hover affordance: the details pill hides until the pointer is over its + column, the strip itself, or a drag. */ opacity: 0; transition: opacity var(--ds-transition-duration-slow) var(--ds-ease-in-out), background var(--ds-transition-duration-slow) var(--ds-ease-in-out); } -.sidebarCol:hover ~ .handle[data-side='sidebar']::after, .detailsCol:hover ~ .handle[data-side='details']::after, -.handle:hover::after, -.handle[data-dragging='true']::after { +.handle[data-side='details']:hover::after, +.handle[data-side='details'][data-dragging='true']::after { opacity: 1; } -.handle:hover::after, -.handle[data-dragging='true']::after { +.handle[data-side='details']:hover::after, +.handle[data-side='details'][data-dragging='true']::after { background: var(--dsw-alias-button-floating-hover); border-color: var(--dsw-alias-border-l3); } diff --git a/packages/client/ui-models/README.i18n.yaml b/packages/client/ui-models/README.i18n.yaml index 3bc6c94ec4..c0e819489c 100644 --- a/packages/client/ui-models/README.i18n.yaml +++ b/packages/client/ui-models/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-models/README.md -README.md: 2f53024df95a79862d5461d3514987a6e8257f9d -README.zh.md: 7c95709933fa29a8fd6ed773696e9657d959f753 +README.md: b438965736c3e765fd6ccd0acb636c076afc4449 +README.zh.md: ba95d15316b81d4dd19e2c38445085b942f48895 diff --git a/packages/client/ui-models/README.md b/packages/client/ui-models/README.md index 2f53024df9..b438965736 100644 --- a/packages/client/ui-models/README.md +++ b/packages/client/ui-models/README.md @@ -6,7 +6,7 @@ Models settings plugin: the provider configuration page and official-DeepSeek co Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), plus `reasoningEffort` (deepseek) or `reasoning` (pi-ai); every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base). -The DeepSeek step projects `deepseek-official` readiness from that same joined snapshot after earlier onboarding steps complete. A configured literal `apiKey` secret sidecar or configured credential reference completes the step without rendering, including a read-only launch-environment credential. A mounted adapter with a missing writable reference shows one action that opens Settings on the Models section, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter is skipped because browser navigation cannot mount Cordis plugins, while an unusable settings or credential capability produces a deployment diagnostic with the same route to Models. +The DeepSeek step projects `deepseek-official` readiness from that same joined snapshot after earlier onboarding pages complete. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured literal `apiKey` secret sidecar or configured credential reference completes the step without rendering, including a read-only launch-environment credential. Only a mounted, active adapter with a missing writable reference shows the page that opens Settings on Models, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering so onboarding cannot block the product; Models remains the diagnostic surface. Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted row. The page only ever holds the REDACTED descriptor, so it names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. Each write carries the `revision` the card opened at, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict` and the card asks the user to reopen instead of replaying its stale snapshot. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling. diff --git a/packages/client/ui-models/README.zh.md b/packages/client/ui-models/README.zh.md index 7c95709933..ba95d15316 100644 --- a/packages/client/ui-models/README.zh.md +++ b/packages/client/ui-models/README.zh.md @@ -6,7 +6,7 @@ 行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),另加 `reasoningEffort`(deepseek)或 `reasoning`(pi-ai);其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base)。 -前序首次使用引导步骤完成后,DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,或凭据引用已配置,该步骤会直接完成而不渲染,其中包括来自启动环境且只读的凭据。适配器已挂载、引用可写但尚未配置时,该步骤只显示一个操作按钮,用于打开「设置」的 Models 分区;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失时直接跳过,因为浏览器导航无法挂载 Cordis 插件;设置或凭据能力不可用时则显示部署诊断,并提供同一个前往 Models 的入口。 +前序首次使用引导页面完成后,DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,或凭据引用已配置,该步骤会直接完成而不渲染,其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置/凭据能力不可用时,该步骤均不渲染并直接完成,以免首次使用引导阻塞产品;Models 页仍是诊断界面。 每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除整行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它点名自己看得见的字段,而不是重建分节:一个它从未收到过的已存字面机密不会被任何 op 提及,也就得以留存。每次写入都携带该卡片打开时的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝,卡片会请用户重新打开,而不是把自己的陈旧快照重放上去。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。 diff --git a/packages/client/ui-models/src/client/DeepSeekOnboardingDialog.module.css b/packages/client/ui-models/src/client/DeepSeekOnboardingDialog.module.css index 6d8b77f8ab..4d72cc076b 100644 --- a/packages/client/ui-models/src/client/DeepSeekOnboardingDialog.module.css +++ b/packages/client/ui-models/src/client/DeepSeekOnboardingDialog.module.css @@ -26,8 +26,7 @@ outline: none; } -.description, -.diagnostic { +.description { max-width: 600px; margin: 22px 0 0; font-size: 17px; @@ -73,15 +72,13 @@ .brand, .title, .description, -.diagnostic, .provider, .actions { animation: credential-enter 280ms cubic-bezier(0.23, 1, 0.32, 1) both; } .title { animation-delay: 40ms; } -.description, -.diagnostic { animation-delay: 80ms; } +.description { animation-delay: 80ms; } .provider { animation-delay: 120ms; } .actions { animation-delay: 160ms; } @@ -101,7 +98,6 @@ .brand, .title, .description, - .diagnostic, .provider, .actions { animation: none; @@ -118,8 +114,7 @@ margin-bottom: 30px; } - .description, - .diagnostic { + .description { font-size: 16px; line-height: 27px; } diff --git a/packages/client/ui-models/src/client/DeepSeekOnboardingDialog.tsx b/packages/client/ui-models/src/client/DeepSeekOnboardingDialog.tsx index 54055d4bf0..9ffcd24947 100644 --- a/packages/client/ui-models/src/client/DeepSeekOnboardingDialog.tsx +++ b/packages/client/ui-models/src/client/DeepSeekOnboardingDialog.tsx @@ -9,7 +9,7 @@ import type { ReactNode } from 'react' import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import { BrandWordmark, Button } from '@deepseek-ai/dsh-client-ui-primitives' import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react' -import type { DeepSeekReadiness, ModelsSettingsState, ModelsSettingsStore } from './store.ts' +import type { ModelsSettingsState, ModelsSettingsStore } from './store.ts' import { deepSeekReadiness } from './store.ts' import type { en } from './locales.ts' import styles from './DeepSeekOnboardingDialog.module.css' @@ -28,35 +28,11 @@ export interface DeepSeekOnboardingInjected { export type DeepSeekOnboardingDialogProps = PropsRuntime<'settings.onboarding'> & DeepSeekOnboardingInjected -type UnavailableReason = Extract['reason'] - /* v8 ignore next 3 -- closed-union defaults only defend future source widening */ function assertNever(_value: never): never { throw new Error('unexpected DeepSeek onboarding state') } -function unavailableDiagnostic( - reason: UnavailableReason, - t: DeepSeekOnboardingInjected['t'], -): string { - switch (reason) { - case 'load-failed': - return t('onboardingLoadFailed') - case 'credentials-unavailable': - return t('onboardingCredentialsUnavailable') - case 'settings-read-only': - case 'credential-read-only': - return t('onboardingReadOnly') - case 'provider-inactive': - case 'settings-unavailable': - case 'credential-ref-unavailable': - return t('onboardingConfigurationUnavailable') - /* v8 ignore next -- every current unavailable reason is handled above */ - default: - return assertNever(reason) - } -} - /** * Prompt a first-run user to open Models while the official adapter exists * and its effective credential is not configured. @@ -74,42 +50,34 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps): }, [controller, state.status]) useEffect(() => { - if (readiness.kind === 'adapter-absent' || readiness.kind === 'configured') complete() + if ( + readiness.kind === 'adapter-absent' + || readiness.kind === 'configured' + || readiness.kind === 'unavailable' + ) complete() }, [complete, readiness.kind]) + useEffect(() => { + if (readiness.kind === 'credential-missing') titleRef.current?.focus() + }, [readiness.kind]) + const openModels = (): void => { complete() openSection('models') } - useEffect(() => { - if (readiness.kind === 'credential-missing' || readiness.kind === 'unavailable') { - titleRef.current?.focus() - } - }, [readiness.kind]) - - let unavailableReason: UnavailableReason | undefined switch (readiness.kind) { case 'loading': case 'adapter-absent': case 'configured': + case 'unavailable': return null case 'credential-missing': - unavailableReason = undefined - break - case 'unavailable': - unavailableReason = readiness.reason break /* v8 ignore next -- every current readiness variant is handled above */ default: return assertNever(readiness) } - const unavailable = unavailableReason !== undefined - const diagnostic = unavailableReason === undefined - ? undefined - : unavailableDiagnostic(unavailableReason, t) - - const title = unavailable ? t('onboardingUnavailableTitle') : t('onboardingTitle') return (
@@ -120,11 +88,9 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps): className={styles['title']} tabIndex={-1} > - {title} + {t('onboardingTitle')} - {unavailable - ?

{diagnostic}

- :

{t('onboardingDescription')}

} +

{t('onboardingDescription')}

DeepSeek deepseek-official @@ -133,11 +99,7 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps): -
diff --git a/packages/client/ui-models/src/client/locales.ts b/packages/client/ui-models/src/client/locales.ts index 503b3aa3dc..44339d588c 100644 --- a/packages/client/ui-models/src/client/locales.ts +++ b/packages/client/ui-models/src/client/locales.ts @@ -32,11 +32,6 @@ export const en = { onboardingDescription: 'Configure the official DeepSeek provider to start building.', onboardingGoToSettings: 'Go to settings', onboardingLater: 'Configure later', - onboardingUnavailableTitle: 'DeepSeek setup is unavailable', - onboardingLoadFailed: 'DeepSeek configuration could not be loaded. Check the connection and try again in Models.', - onboardingCredentialsUnavailable: 'Credential storage is unavailable in this deployment. Check the deployment configuration.', - onboardingReadOnly: 'This deployment does not allow the DeepSeek API key to be changed here. Ask an administrator to provide the credential.', - onboardingConfigurationUnavailable: 'DeepSeek configuration is unavailable in this deployment. Check the deployment composition.', } /** Chinese strings (same keys as {@link en}). */ @@ -71,9 +66,4 @@ export const zh: typeof en = { onboardingDescription: '配置 DeepSeek 官方模型,即可开始使用。', onboardingGoToSettings: '前往配置', onboardingLater: '稍后配置', - onboardingUnavailableTitle: '无法在此配置 DeepSeek', - onboardingLoadFailed: '无法加载 DeepSeek 配置。请检查连接,然后在模型设置中重试。', - onboardingCredentialsUnavailable: '当前部署无法使用凭据存储。请检查部署配置。', - onboardingReadOnly: '当前部署不允许在此修改 DeepSeek API 密钥。请联系管理员提供凭据。', - onboardingConfigurationUnavailable: '当前部署无法使用 DeepSeek 配置。请检查部署组合。', } diff --git a/packages/client/ui-models/src/client/store.ts b/packages/client/ui-models/src/client/store.ts index eda36882ef..282f21fe75 100644 --- a/packages/client/ui-models/src/client/store.ts +++ b/packages/client/ui-models/src/client/store.ts @@ -215,8 +215,8 @@ export type DeepSeekReadiness = /** * Project official-DeepSeek readiness from the provider/settings/credential - * join used by the Models page. A missing directory entry means the adapter - * is not mounted and therefore cannot be repaired by navigating to Models. + * join used by the Models page. A missing official configurable-provider + * declaration means the adapter is not repairable by navigating to Models. * @param state - current shared Models join snapshot. * @returns the onboarding state without reading a parallel fact source. */ @@ -230,7 +230,10 @@ export function deepSeekReadiness(state: ModelsSettingsState): DeepSeekReadiness reason: 'load-failed', } } - const row = state.rows.find(candidate => candidate.entry.provider === 'deepseek-official') + const row = state.rows.find(candidate => + candidate.entry.provider === 'deepseek-official' + && candidate.entry.settingsNs === 'llm-deepseek' + && candidate.entry.settingsPath.length === 0) if (row === undefined) return { kind: 'adapter-absent' } if (!row.entry.active) { return { diff --git a/packages/client/ui-models/tests/onboarding-dialog.spec.tsx b/packages/client/ui-models/tests/onboarding-dialog.spec.tsx index 731fa2406d..07acc89fe4 100644 --- a/packages/client/ui-models/tests/onboarding-dialog.spec.tsx +++ b/packages/client/ui-models/tests/onboarding-dialog.spec.tsx @@ -24,6 +24,7 @@ function fail(message: string): RpcResponse { function harness(options: { provider?: boolean + providerSettingsNs?: string providerActive?: boolean settingsNamespace?: boolean apiKeyEnv?: string | null @@ -32,25 +33,21 @@ function harness(options: { credential?: { source?: string; writable: boolean } describeFailure?: string settingsWritable?: boolean - providersRejectOnce?: boolean + providersReject?: boolean } = {}) { let fileConfigured = false - let rejectProviders = options.providersRejectOnce === true const configured = options.configured ?? (() => fileConfigured) const face = { llm: { providers: () => { - if (rejectProviders) { - rejectProviders = false - return Promise.reject(new Error('provider transport unavailable')) - } + if (options.providersReject === true) return Promise.reject(new Error('provider transport unavailable')) return Promise.resolve(ok({ providers: options.provider === false ? [] : [{ provider: 'deepseek-official', displayName: 'DeepSeek', - settingsNs: 'llm-deepseek', + settingsNs: options.providerSettingsNs ?? 'llm-deepseek', settingsPath: [], active: options.providerActive ?? true, }], @@ -137,45 +134,21 @@ describe('DeepSeekOnboardingDialog', () => { expect(h.openSection).not.toHaveBeenCalled() }) - it('routes an unavailable credential deployment to Models with a diagnostic', async () => { - const h = harness({ describeFailure: 'credentials service is absent' }) - render() - await screen.findByRole('region', { name: en.onboardingUnavailableTitle }) - expect(screen.getByText(en.onboardingCredentialsUnavailable)).toBeTruthy() - fireEvent.click(screen.getByRole('button', { name: en.onboardingGoToSettings })) - expect(h.openSection).toHaveBeenCalledWith('models') - }) - - it('explains read-only credential and settings deployments', async () => { + it('does not block the product when DeepSeek setup is unavailable', async () => { for (const h of [ + harness({ describeFailure: 'credentials service is absent' }), harness({ credential: { writable: false } }), harness({ settingsWritable: false }), - ]) { - const view = render() - await screen.findByRole('region', { name: en.onboardingUnavailableTitle }) - expect(screen.getByText(en.onboardingReadOnly)).toBeTruthy() - view.unmount() - } - }) - - it('distinguishes an initial transport failure from deployment misconfiguration', async () => { - const h = harness({ providersRejectOnce: true }) - render() - await screen.findByRole('region', { name: en.onboardingUnavailableTitle }) - expect(screen.getByText(en.onboardingLoadFailed)).toBeTruthy() - fireEvent.click(screen.getByRole('button', { name: en.onboardingGoToSettings })) - expect(h.openSection).toHaveBeenCalledWith('models') - }) - - it('uses the configuration diagnostic for inactive or unresolvable adapters', async () => { - for (const h of [ + harness({ providersReject: true }), harness({ providerActive: false }), harness({ settingsNamespace: false }), harness({ apiKeyEnv: null }), ]) { const view = render() - await screen.findByRole('region', { name: en.onboardingUnavailableTitle }) - expect(screen.getByText(en.onboardingConfigurationUnavailable)).toBeTruthy() + await act(async () => { await h.controller.load() }) + expect(screen.queryByRole('region')).toBeNull() + await waitFor(() => { expect(h.complete).toHaveBeenCalledOnce() }) + expect(h.openSection).not.toHaveBeenCalled() view.unmount() } }) @@ -183,6 +156,7 @@ describe('DeepSeekOnboardingDialog', () => { it('skips an absent adapter and already-configured literal or environment credentials', async () => { for (const h of [ harness({ provider: false }), + harness({ providerSettingsNs: '' }), harness({ literal: true, describeFailure: 'credential seam absent' }), harness({ configured: () => true, credential: { source: 'env', writable: false } }), ]) { diff --git a/packages/client/ui-models/tests/readiness.spec.ts b/packages/client/ui-models/tests/readiness.spec.ts index d2da587c16..d03cd130f4 100644 --- a/packages/client/ui-models/tests/readiness.spec.ts +++ b/packages/client/ui-models/tests/readiness.spec.ts @@ -41,6 +41,14 @@ describe('deepSeekReadiness', () => { expect(deepSeekReadiness(state({ status: 'idle', rows: [] }))).toEqual({ kind: 'loading' }) expect(deepSeekReadiness(state({ status: 'loading', rows: [] }))).toEqual({ kind: 'loading' }) expect(deepSeekReadiness(state({ rows: [] }))).toEqual({ kind: 'adapter-absent' }) + expect(deepSeekReadiness(state({ + rows: [row({ + entry: { + ...row().entry, + settingsNs: '', + }, + })], + }))).toEqual({ kind: 'adapter-absent' }) }) it('reports a missing writable effective credential', () => { diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css index 3e0e96b838..c7b7948845 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css @@ -16,6 +16,7 @@ flex: 1; min-width: 0; overflow: auto; + container: trajectory-table / inline-size; } .table { @@ -235,6 +236,18 @@ width: 3px; } +.table tbody tr[data-error='true'] .turnRail { + background: color-mix( + in srgb, + var(--dsw-alias-state-error-primary) 22%, + var(--dsw-alias-bg-layer-1) + ); +} + +.table tbody tr[data-error='true'] .selectionRail { + background: var(--dsw-alias-state-error-primary); +} + .table tbody tr[data-turn-start='true'] td { position: relative; overflow: visible; @@ -279,6 +292,10 @@ white-space: nowrap; } +.turnLabelCompact { + display: none; +} + .turnLabelActive { color: color-mix( in srgb, @@ -325,11 +342,66 @@ user-select: none; } +.kindTagIcon { + display: none; + align-items: center; + justify-content: center; + width: 13px; + height: 13px; +} + +.kindTagLabel { + display: inline; +} + .table .kindSlot .message { justify-content: center; width: 100%; } +@container trajectory-table (max-width: 620px) { + .eventColumn { + width: 50px; + } + + .event { + padding-right: 3px !important; + padding-left: 28px !important; + } + + .requestBoundaryControl { + left: 6px; + } + + .kindSlot { + width: 19px; + } + + .kindTag, + .table .kindSlot .message { + justify-content: center; + width: 19px; + padding-right: 0; + padding-left: 0; + } + + .kindTagIcon { + display: inline-flex; + } + + .kindTagLabel { + display: none; + } + + .turnLabelFull { + display: none; + } + + .turnLabelCompact { + display: inline; + } +} + .user { color: var(--dsw-alias-state-business-primary); background: var(--dsw-alias-state-business-tertiary); @@ -576,6 +648,28 @@ color: var(--dsw-alias-state-error-primary); } +.overview dd.error { + color: var(--dsw-alias-state-error-primary); +} + +.details .errorPayload { + color: var(--dsw-alias-state-error-primary); +} + +.details .errorPayload .resultBlockText { + color: inherit; +} + +.details .jsonPayload.errorPayload, +.details .jsonPreview.errorPayload { + --json-tree-property: var(--dsw-alias-state-error-primary); + --json-tree-string: var(--dsw-alias-state-error-primary); + --json-tree-number: var(--dsw-alias-state-error-primary); + --json-tree-keyword: var(--dsw-alias-state-error-primary); + --json-tree-punctuation: var(--dsw-alias-state-error-primary); + --json-tree-icon: var(--dsw-alias-state-error-primary); +} + .details { position: relative; display: flex; diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index 09ad60b427..fb369da242 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -1,9 +1,15 @@ /** Turn-aware trajectory event ledger with a local record inspector. */ -import { useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { CSSProperties, ReactNode } from 'react' import { - extractMarkdownPlainText, IconChevronRightOutline14, JsonTree, MarkdownText, + IconChevronRightOutline14, + IconSettingsOutline16, + IconSparkle16, + IconUserOutline16, + JsonTree, + MarkdownText, + Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' import { structuredPatch } from 'diff' import type { @@ -13,7 +19,7 @@ import type { AssistantMetricDetail, TrajectoryCellKind, TrajectoryCellProps, TrajectorySourceBlock, } from './trajectory-record.ts' import { formatElapsedSeconds } from './trajectory-record.ts' -import type { TrajectoryTurnModel } from './layout.ts' +import { trajectoryPreviewText, type TrajectoryTurnModel } from './layout.ts' import css from './TrajectoryTable.module.css' const KIND_LABEL: Record = { @@ -26,6 +32,77 @@ const KIND_LABEL: Record = { subtool: 'SUBTOOL', } +function ToolWrenchIcon(): ReactNode { + return ( + + ) +} + +function InformationIcon(): ReactNode { + return ( + + ) +} + +function CompactedIcon(): ReactNode { + return ( + + ) +} + +const KIND_ICON: Record = { + system: , + user: , + context: , + compacted: , + message: , + tool: , + subtool: , +} + interface TableRecord { turn: number group: string @@ -225,6 +302,8 @@ export interface TrajectoryTableProps { onSelectedIndexChange?: (index: number | null) => void /** Report a direct user selection from a ledger row. */ onRecordSelect?: (index: number) => void + /** One externally requested record selection; a new object repeats the request. */ + recordSelection?: { readonly index: number } | null /** Clear selection state owned by the ledger host. */ onClearSelection?: () => void /** Turn ids whose rows after the first are folded into a summary. */ @@ -721,13 +800,13 @@ function detailTabs(record: TableRecord): readonly DetailTabItem[] { function recordDisplayText(cell: TrajectoryCellProps): string { if (isToolCallOnly(cell)) return '' + if (cell.text !== '') return cell.text const markdown = cell.kind === 'user' || cell.kind === 'context' ? cell.inputDetail : cell.kind === 'message' ? cell.outputDetail ?? cell.thinkingDetail : undefined - if (!markdown) return cell.text - return extractMarkdownPlainText(markdown).replace(/\s+/g, ' ').trim() + return markdown === undefined ? '' : trajectoryPreviewText(markdown) } function toolCallTextParts( @@ -1043,13 +1122,20 @@ function SystemPromptDiff({ function ToolOutputBlocks({ blocks, + error, preview, }: { blocks: readonly TrajectorySourceBlock[] + error: boolean preview: boolean }) { return ( -
+
value !== undefined).join(' ')} + > {blocks.map((block, index) => ( block.imageSrc !== undefined ? @@ -1222,6 +1308,9 @@ function RecordPayload({ ? 'No payload captured' : 'No result captured' if (!value) return

{missing}

+ const error = direction === 'output' && record.cell.isError === true + const payloadClass = preview ? css.jsonPreview : css.jsonPayload + const payloadClassName = error ? `${payloadClass} ${css.errorPayload}` : payloadClass const json = parseJsonContainer(value) const singleTextResult = direction === 'output' @@ -1232,7 +1321,7 @@ function RecordPayload({ ) } @@ -1245,6 +1334,7 @@ function RecordPayload({ return ( ) @@ -1258,7 +1348,11 @@ function RecordPayload({ ) if (markdown) { return ( -
+
className !== undefined).join(' ')} + >
) @@ -1268,7 +1362,7 @@ function RecordPayload({ ) } @@ -1276,7 +1370,7 @@ function RecordPayload({
 value !== undefined).join(' ')}
     >
@@ -1397,6 +1491,7 @@ export function TrajectoryTable({
   searchMatchIndexes = null,
   onSelectedIndexChange,
   onRecordSelect,
+  recordSelection = null,
   onClearSelection,
   collapsedTurns,
   onToggleTurn,
@@ -1406,15 +1501,16 @@ export function TrajectoryTable({
   const [selectedIndex, setSelectedIndex] = useState(null)
   const [selectedRequest, setSelectedRequest] = useState(null)
   const [activeTab, setActiveTab] = useState('overview')
-  const [thinkingExpanded, setThinkingExpanded] = useState(true)
+  const [thinkingExpanded, setThinkingExpanded] = useState(false)
   const [detailsWidth, setDetailsWidth] = useState(null)
   const [toolRequestOffset, setToolRequestOffset] = useState(null)
   const detailsResizeDrag = useRef(null)
+  const appliedRecordSelection = useRef(null)
   const tabHistory = useRef>(new Set(['overview']))
   useEffect(() => {
     onSelectedIndexChange?.(selectedIndex)
   }, [onSelectedIndexChange, selectedIndex])
-  const allRecords = flattenRecords(turns)
+  const allRecords = useMemo(() => flattenRecords(turns), [turns])
   const requestNumbers = indexRequestNumbers(allRecords, sessionRequestNumbers)
   const records = searchMatchIndexes === null
     ? collapseAssistantRecords(
@@ -1531,7 +1627,7 @@ export function TrajectoryTable({
     onClearSelection?.()
   }
 
-  const selectRecord = (index: number) => {
+  const selectRecord = useCallback((index: number) => {
     const record = allRecords.find(candidate => candidate.cell.index === index)
     onRecordSelect?.(index)
     setSelectedRequest(null)
@@ -1541,7 +1637,15 @@ export function TrajectoryTable({
     const available = new Set(tabs.map(tab => tab.id))
     const recent = [...tabHistory.current].reverse().find(tab => available.has(tab))
     setActiveTab(recent ?? tabs[0]?.id ?? 'overview')
-  }
+  }, [allRecords, onRecordSelect])
+  useEffect(() => {
+    if (
+      recordSelection === null
+      || appliedRecordSelection.current === recordSelection
+    ) return
+    appliedRecordSelection.current = recordSelection
+    selectRecord(recordSelection.index)
+  }, [recordSelection, selectRecord])
 
   const selectRequest = (
     request: SelectedRequest,
@@ -1714,8 +1818,14 @@ export function TrajectoryTable({
                         className={activeTurn === record.turn
                           ? `${css.turnLabel} ${css.turnLabelActive}`
                           : css.turnLabel}
+                        aria-label={`Turn ${record.turn}`}
                       >
-                        Turn {record.turn}
+                        
+                        
                       
                     )}
                     
@@ -1723,24 +1833,33 @@ export function TrajectoryTable({ - - {KIND_LABEL[record.cell.kind]} - + + + + + {KIND_LABEL[record.cell.kind]} + + + )}
@@ -1973,7 +2092,9 @@ export function TrajectoryTable({
Status
-
{statusLabel(selectedRequestState)}
+
+ {statusLabel(selectedRequestState)} +
{selectedRequestInfo?.purpose === 'compaction' && (
@@ -2014,7 +2135,7 @@ export function TrajectoryTable({ {selectedRequestInfo?.error !== undefined && (
Error
-
{selectedRequestInfo.error}
+
{selectedRequestInfo.error}
)} {selectedRequestInfo?.retry !== undefined && ( @@ -2122,7 +2243,9 @@ export function TrajectoryTable({
Status
-
{statusLabel(selectedState)}
+
+ {statusLabel(selectedState)} +
Duration
@@ -2225,7 +2348,9 @@ export function TrajectoryTable({ )}
Status
-
{statusLabel(selectedState)}
+
+ {statusLabel(selectedState)} +
{selected.cell.kind === 'message' && ( diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css index 734b2f3326..402387da48 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css @@ -70,16 +70,29 @@ .lanes { position: absolute; z-index: 2; - inset: 7px 0; + top: 7px; + bottom: 7px; + left: var(--trajectory-domain-left); + width: var(--trajectory-domain-width); } .turnBoundaries { position: absolute; z-index: 3; - inset: 0; + top: 0; + bottom: 0; + left: var(--trajectory-domain-left); + width: var(--trajectory-domain-width); pointer-events: none; } +@media (prefers-reduced-motion: no-preference) { + .lanes[data-animate-viewport='true'], + .turnBoundaries[data-animate-viewport='true'] { + transition: left 180ms ease-out; + } +} + .turnBoundary { position: absolute; top: 0; @@ -133,6 +146,10 @@ ); } +.span[data-error='true'] { + background: var(--dsw-alias-state-error-primary); +} + .span[data-equal-duration='true'] { width: 8px; min-width: 8px; @@ -142,6 +159,18 @@ opacity: 0.2; } +.span[data-hovered='true']:not([data-current='true']) { + z-index: 1; + opacity: 0.78; + box-shadow: + 0 0 0 1px var(--dsw-alias-bg-layer-2), + 0 0 0 2px color-mix( + in srgb, + var(--dsw-alias-state-business-primary) 80%, + transparent + ); +} + .span[data-current='true'] { z-index: 1; opacity: 1; diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx index 8ad1afefc1..89e3b8bb2b 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx @@ -15,12 +15,20 @@ import css from './TrajectoryTimeline.module.css' const MINIMUM_DRAG_PX = 3 const MINIMUM_ZOOM_OPERATIONS = 4 +const EDGE_PAN_ZONE_FRACTION = 0.08 +const EDGE_PAN_STEP_FRACTION = 0.025 +const MAXIMUM_EDGE_PAN_PX = 32 interface FractionRange { start: number end: number } +interface HoverPoint { + fraction: number + recordIndex: number | null +} + /** Props for the fixed full-domain overview above the trajectory ledger. */ export interface TrajectoryTimelineProps { turns: readonly TrajectoryTurnModel[] @@ -30,6 +38,9 @@ export interface TrajectoryTimelineProps { /** Record indexes matching the active ledger search, or null without a query. */ searchMatchIndexes?: ReadonlySet | null onRangeChange: (range: TrajectoryTimeRange | null) => void + /** Select a directly clicked timeline block. */ + onRecordSelect?: (index: number) => void + /** Bring the nearest record into view after clicking timeline whitespace. */ onRecordFocus?: (index: number) => void } @@ -41,11 +52,16 @@ function clampFraction(value: number): number { return Math.min(1, Math.max(0, value)) } -function centeredRange(center: number, width: number): FractionRange { - const clampedWidth = Math.min(1, Math.max(0, width)) +function centeredRange( + center: number, + width: number, + minimum: number, + maximum: number, +): FractionRange { + const clampedWidth = Math.min(maximum - minimum, Math.max(0, width)) const start = Math.min( - Math.max(center - clampedWidth / 2, 0), - 1 - clampedWidth, + Math.max(center - clampedWidth / 2, minimum), + maximum - clampedWidth, ) return { start, end: start + clampedWidth } } @@ -79,6 +95,7 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ selectedIndex = null, searchMatchIndexes = null, onRangeChange, + onRecordSelect, onRecordFocus, }: TrajectoryTimelineProps) { const model = useMemo(() => deriveTrajectoryTimeline(turns, mode), [mode, turns]) @@ -94,10 +111,16 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ )), [turns], ) - const dragRef = useRef<{ pointerId: number; anchor: number; width: number } | null>(null) - const [draft, setDraft] = useState(null) - const [hover, setHover] = useState(null) + const dragRef = useRef<{ + pointerId: number + anchorTime: number + anchorClientX: number + recordIndex: number | null + } | null>(null) + const [draft, setDraft] = useState(null) + const [hover, setHover] = useState(null) const [viewport, setViewport] = useState(null) + const [animateViewport, setAnimateViewport] = useState(false) useEffect(() => { if ( model !== null @@ -109,11 +132,35 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ }, [model, onRangeChange, range]) useEffect(() => { if (model === null) return + setAnimateViewport(false) setViewport(current => current !== null && (current.end < model.start || current.start > model.end) ? null : current) }, [model]) + useEffect(() => { + if (model === null || selectedIndex === null) return + const selectedSpan = model.spans.find(span => span.index === selectedIndex) + if (selectedSpan === undefined) return + setAnimateViewport(true) + setViewport((current) => { + if (current === null) return current + if ( + selectedSpan.end > current.start + && selectedSpan.start < current.end + ) return current + const duration = Math.max(1, current.end - current.start) + const desiredStart = selectedSpan.end <= current.start + ? selectedSpan.start + : selectedSpan.end - duration + const nextStart = Math.min( + Math.max(desiredStart, model.start), + Math.max(model.start, model.end - duration), + ) + if (nextStart === current.start) return current + return { start: nextStart, end: nextStart + duration } + }) + }, [model, selectedIndex]) const fullDuration = Math.max(1, (model?.end ?? 0) - (model?.start ?? 0)) const viewportDuration = Math.min( fullDuration, @@ -127,16 +174,21 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ ) const domainDuration = viewport === null ? fullDuration : viewportDuration const domainStart = viewport === null ? model?.start ?? 0 : viewportStart + const projectedDomainStyle = model === null + ? undefined + : { + '--trajectory-domain-left': + `${-(domainStart - model.start) / domainDuration * 100}%`, + '--trajectory-domain-width': `${fullDuration / domainDuration * 100}%`, + } as CSSProperties const committed = model === null || range === null ? null : rangeFraction(range, domainStart, domainDuration) - const visibleRange = draft ?? committed - const activeRange = draft === null - ? range - : { - start: domainStart + draft.start * domainDuration, - end: domainStart + draft.end * domainDuration, - } + const draftFraction = model === null || draft === null + ? null + : rangeFraction(draft, domainStart, domainDuration) + const visibleRange = draftFraction ?? committed + const activeRange = draft ?? range if (model === null) { return ( @@ -151,9 +203,9 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ ) } - const minimumSelectionFraction = Math.min( - 1, - fullDuration / domainDuration / model.spans.length, + const minimumSelectionDuration = Math.min( + domainDuration, + fullDuration / model.spans.length, ) const fractionAt = (event: PointerEvent): number => { @@ -161,51 +213,107 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ return clampFraction((event.clientX - rect.left) / Math.max(1, rect.width)) } - const commit = (fraction: FractionRange) => { - onRangeChange({ - start: domainStart + fraction.start * domainDuration, - end: domainStart + fraction.end * domainDuration, - }) + const recordIndexAt = (event: PointerEvent): number | null => { + const target = event.target instanceof HTMLElement ? event.target : null + const value = target?.closest('[data-timeline-record-index]') + ?.dataset.timelineRecordIndex + if (value === undefined) return null + const index = Number(value) + return Number.isFinite(index) ? index : null + } + + const commit = (nextRange: TrajectoryTimeRange) => { + onRangeChange(nextRange) } const onPointerDown = (event: PointerEvent) => { if (event.button !== 0) return - const rect = event.currentTarget.getBoundingClientRect() const anchor = fractionAt(event) - setHover(anchor) - dragRef.current = { pointerId: event.pointerId, anchor, width: Math.max(1, rect.width) } + const anchorTime = domainStart + anchor * domainDuration + const recordIndex = recordIndexAt(event) + setHover({ fraction: anchor, recordIndex }) + dragRef.current = { + pointerId: event.pointerId, + anchorTime, + anchorClientX: event.clientX, + recordIndex, + } if (typeof event.currentTarget.setPointerCapture === 'function') { event.currentTarget.setPointerCapture(event.pointerId) } - setDraft({ start: anchor, end: anchor }) + setDraft({ start: anchorTime, end: anchorTime }) } const onPointerMove = (event: PointerEvent) => { const drag = dragRef.current + const rect = event.currentTarget.getBoundingClientRect() const fraction = fractionAt(event) - setHover(fraction) + setHover({ fraction, recordIndex: recordIndexAt(event) }) if (drag === null || drag.pointerId !== event.pointerId) return - setDraft(orderedRange(drag.anchor, fraction)) + let nextDomainStart = domainStart + if (viewport !== null) { + const localX = event.clientX - rect.left + const edgeWidth = Math.min( + MAXIMUM_EDGE_PAN_PX, + Math.max(1, rect.width * EDGE_PAN_ZONE_FRACTION), + ) + const direction = localX < edgeWidth + ? -1 + : localX > rect.width - edgeWidth ? 1 : 0 + if (direction !== 0) { + const edgeDistance = direction < 0 + ? edgeWidth - localX + : localX - (rect.width - edgeWidth) + const strength = clampFraction(edgeDistance / edgeWidth) + const desiredStart = domainStart + + direction * domainDuration * EDGE_PAN_STEP_FRACTION + * Math.max(0.2, strength) + nextDomainStart = Math.min( + Math.max(desiredStart, model.start), + model.end - domainDuration, + ) + if (nextDomainStart !== domainStart) { + setAnimateViewport(false) + setViewport({ + start: nextDomainStart, + end: nextDomainStart + domainDuration, + }) + } + } + } + const pointTime = nextDomainStart + fraction * domainDuration + setDraft(orderedRange(drag.anchorTime, pointTime)) } const onPointerEnd = (event: PointerEvent) => { const drag = dragRef.current if (drag === null || drag.pointerId !== event.pointerId) return - const point = fractionAt(event) - const selected = orderedRange(drag.anchor, point) - setHover(point) + const pointFraction = fractionAt(event) + const pointTime = domainStart + pointFraction * domainDuration + const selected = orderedRange(drag.anchorTime, pointTime) + setHover({ fraction: pointFraction, recordIndex: recordIndexAt(event) }) dragRef.current = null setDraft(null) - const click = (selected.end - selected.start) * drag.width < MINIMUM_DRAG_PX - const committedRange = selected.end - selected.start < minimumSelectionFraction + const click = Math.abs(event.clientX - drag.anchorClientX) < MINIMUM_DRAG_PX + const clickedSpan = click && drag.recordIndex !== null + ? model.spans.find(span => span.index === drag.recordIndex) + : undefined + if (clickedSpan !== undefined) { + onRangeChange(null) + onRecordSelect?.(clickedSpan.index) + return + } + const committedRange = selected.end - selected.start < minimumSelectionDuration ? centeredRange( click ? selected.start : (selected.start + selected.end) / 2, - minimumSelectionFraction, + minimumSelectionDuration, + model.start, + model.end, ) : selected commit(committedRange) if (click) { - const timelinePoint = domainStart + selected.start * domainDuration + const timelinePoint = selected.start const nearest = model.spans.reduce((candidate, span) => { const candidateDistance = timelinePoint < candidate.start ? candidate.start - timelinePoint @@ -233,6 +341,7 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ const onWheel = (event: WheelEvent) => { event.preventDefault() + setAnimateViewport(false) const rect = event.currentTarget.getBoundingClientRect() const anchorFraction = clampFraction((event.clientX - rect.left) / Math.max(1, rect.width)) @@ -278,16 +387,18 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ onWheel={onWheel} onContextMenu={(event) => { event.preventDefault() + setAnimateViewport(false) onRangeChange(null) setViewport(null) }} > - {hover !== null && draft === null && ( + {hover !== null && hover.recordIndex === null && draft === null && (