diff --git a/.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.i18n.yaml b/.agents/notes/archived/architecture/2026-06-15-turn-enclosure-invariant.i18n.yaml similarity index 63% rename from .agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.i18n.yaml rename to .agents/notes/archived/architecture/2026-06-15-turn-enclosure-invariant.i18n.yaml index 46cb7aaa20..9f712421c3 100644 --- a/.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.i18n.yaml +++ b/.agents/notes/archived/architecture/2026-06-15-turn-enclosure-invariant.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 -2026-06-15-turn-enclosure-invariant.md: 6e2abd1716f8efc08c06d5ff8faec38282f2a17f -2026-06-15-turn-enclosure-invariant.zh.md: 0921c2574dc171e887664d8a2ea840a4e81f1531 +2026-06-15-turn-enclosure-invariant.md: f38f5b2600b57ad8f5fc4c8975579e281b5f3a49 +2026-06-15-turn-enclosure-invariant.zh.md: b74ddf3f6b65a7990329a8a903aafe2e4ca41c90 diff --git a/.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md b/.agents/notes/archived/architecture/2026-06-15-turn-enclosure-invariant.md similarity index 99% rename from .agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md rename to .agents/notes/archived/architecture/2026-06-15-turn-enclosure-invariant.md index 6e2abd1716..f38f5b2600 100644 --- a/.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md +++ b/.agents/notes/archived/architecture/2026-06-15-turn-enclosure-invariant.md @@ -1,6 +1,7 @@ # Agent Note: Every session event is enclosed in a turn Status: implemented +Archived: 2026-07-28 English | [中文](2026-06-15-turn-enclosure-invariant.zh.md) diff --git a/.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.zh.md b/.agents/notes/archived/architecture/2026-06-15-turn-enclosure-invariant.zh.md similarity index 99% rename from .agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.zh.md rename to .agents/notes/archived/architecture/2026-06-15-turn-enclosure-invariant.zh.md index 0921c2574d..b74ddf3f6b 100644 --- a/.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.zh.md +++ b/.agents/notes/archived/architecture/2026-06-15-turn-enclosure-invariant.zh.md @@ -1,6 +1,7 @@ # Agent Note: 每个会话事件都封闭在一个轮次内 Status: implemented +Archived: 2026-07-28 [English](2026-06-15-turn-enclosure-invariant.md) | 中文 diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index 78c51b882f..e46d7c34cd 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -7,6 +7,9 @@ "architecture/2026-06-11-tool-schemas-in-prompt-assembly.i18n.yaml": "sha256:37230a2f5b9dbe160b4f36b6906065637846261cbef380c3e2b777cf0d8e9013", "architecture/2026-06-11-tool-schemas-in-prompt-assembly.md": "sha256:6f7b7f15f53f857ccb8477b3fdf65bf2de3a6f96acd93049c03ce4aff1629538", "architecture/2026-06-11-tool-schemas-in-prompt-assembly.zh.md": "sha256:fd5ddfc53f8a1c4afa86599c858c2ad27858166e676754ee6e2ce0881f55cef7", + "architecture/2026-06-15-turn-enclosure-invariant.i18n.yaml": "sha256:7eb471a53b7bef104c57e9343b80d672763f062ecb086b01b318b65b488d3c02", + "architecture/2026-06-15-turn-enclosure-invariant.md": "sha256:afefa3a268c84f26cf5461e08933245352a9e63cff688d3c398c8064a4ac6e85", + "architecture/2026-06-15-turn-enclosure-invariant.zh.md": "sha256:c54fdac980abc922cdc252a8fef59e4bdd7567316c7fbb6f7dbc035e470d95fa", "architecture/2026-06-20-extract-example-app-packages.i18n.yaml": "sha256:d99b612cc1051c86d883d74737c72e921735e7a28e0b5e6351d3870c664bdcc4", "architecture/2026-06-20-extract-example-app-packages.md": "sha256:9c7aca3a1e9a1ccc3729961663bc649b90076e671cae23e3db8203305983ccce", "architecture/2026-06-20-extract-example-app-packages.zh.md": "sha256:19bd50232d9f25d35aa3f9dc72d9af0df457dd0eaca8b982d5aa625e5b95bcff", diff --git a/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.i18n.yaml index bd19eb7b18..eabd18d6a1 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.md -2026-07-24-separate-context-injection-from-turn-execution.md: b74cd6bdc48e795e57d780ab31a907ffe94dd518 -2026-07-24-separate-context-injection-from-turn-execution.zh.md: f2421d2fc7b8c1329dd1349a6fb088407ac5fc75 +2026-07-24-separate-context-injection-from-turn-execution.md: d44ef5afc8c376790192998bcf3069ceb651ae82 +2026-07-24-separate-context-injection-from-turn-execution.zh.md: ba561b587effb278a67844ce4d876da9cd940e94 diff --git a/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.md b/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.md index b74cd6bdc4..d44ef5afc8 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.md +++ b/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.md @@ -32,7 +32,7 @@ Outside that window, `inject()` appends its `user/message` immediately. It does If prompt admission blocks or fails, a caller-staged context-only batch appends immediately without a turn. Steering and context staged beside it remain in the outbox for a later admitted prompt; cancellation or disposal may discard them. Hook-produced `additionalContexts` never materialize because they belong to the rejected admission decision. -The session invariant permits `user/message` between turns while continuing to require turn enclosure for execution events, steering, assistant output, tools, and package-added events by default. Persistence, recovery, resume, fork, and compaction treat a valid out-of-turn `user/message` as committed session history rather than an interrupted or discardable turn tail. +The session invariant permits `user/message` between turns while continuing to require turn enclosure for core execution events, steering, assistant output, and tools. Merge-extensible event relations belong to their declaring plugin rather than a core default. Persistence, recovery, resume, fork, and compaction treat valid between-turn events as committed session history rather than an interrupted or discardable turn tail. ## Extension and caller semantics @@ -42,7 +42,7 @@ Caller-driven injection and hook-produced additional context deliberately have d Cross-session references use that domain composition: TUI prepares the snapshot, then either adds it to the prompt's admission decision outside an acceptance window or injects it beside steering during one. The target log contains two simple messages, so later source mutation cannot change replay and transcript consumers do not need a prompt envelope. This supersedes the attachment mechanism in the [cross-session reference decision](../feature/2026-07-21-cross-session-references.md) while retaining its snapshot and trust-boundary rules. -This decision preserves the caller-owned framing decision from [unwrapped injected content](../simplification/2026-07-20-unwrap-injected-content-envelopes.md), the one-item turn rule from [one send, one turn](../simplification/2026-07-17-one-send-one-turn.md), and narrows the [turn-enclosure decision](2026-06-15-turn-enclosure-invariant.md) so turns enclose execution rather than every session event. +This decision preserves the caller-owned framing decision from [unwrapped injected content](../simplification/2026-07-20-unwrap-injected-content-envelopes.md) and the one-item turn rule from [one send, one turn](../simplification/2026-07-17-one-send-one-turn.md). The later [standalone log-only event decision](../simplification/2026-07-28-remove-synthetic-log-only-turns.md) applies the same execution-only meaning to plugin-owned records. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.zh.md b/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.zh.md index f2421d2fc7..ba561b587e 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.zh.md @@ -32,7 +32,7 @@ agent API 曾用三种相互重叠的方式表示面向模型的补充输入: 如果提示词准入被阻止或失败,调用方暂存的仅含上下文的批次会立即追加,且不产生轮次。steering 及与其一同暂存的上下文会留在 outbox 中,供后续获准提示词使用;取消或 dispose(资源释放)可能丢弃它们。钩子产生的 `additionalContexts` 属于被拒绝的准入决策,因此永远不会落入日志。 -会话不变量允许 `user/message` 位于两个轮次之间,同时继续要求执行事件、steering、助手输出、工具事件以及默认的包扩展事件均受轮次边界约束。持久化、恢复、resume、fork 和压缩会把合法的轮次外 `user/message` 当作已提交会话历史,而不是中断轮次或可丢弃的日志尾部。 +会话不变量允许 `user/message` 位于两个轮次之间,同时继续要求核心执行事件、steering、助手输出和工具事件均受轮次边界约束。可合并扩展事件的关系由声明它们的插件拥有,而不是采用核心默认规则。持久化、恢复、resume、fork 和压缩会把合法的轮次间事件当作已提交会话历史,而不是中断轮次或可丢弃的日志尾部。 ## 扩展点与调用方语义 @@ -42,7 +42,7 @@ agent API 曾用三种相互重叠的方式表示面向模型的补充输入: 跨会话引用采用这种领域组合方式:TUI 先准备快照,然后在接受窗口之外将其加入提示词准入决策,或在窗口期间将其注入到 steering 旁。目标日志包含两条简单消息,因此来源会话后续变化不会改变回放,transcript 消费方也不需要提示词封套。本决策取代[跨会话引用决策](../feature/2026-07-21-cross-session-references.md)中的附件机制,但保留其快照与信任边界规则。 -本决策保留[移除注入内容封套](../simplification/2026-07-20-unwrap-injected-content-envelopes.md)确立的调用方自主管理框架原则,以及[一次 send、一个轮次](../simplification/2026-07-17-one-send-one-turn.md)确立的单条目轮次规则;同时收窄[轮次封闭决策](2026-06-15-turn-enclosure-invariant.md),使轮次约束执行过程,而不是约束所有会话事件。 +本决策保留[移除注入内容封套](../simplification/2026-07-20-unwrap-injected-content-envelopes.md)确立的调用方自主管理框架原则,以及[一次 send、一个轮次](../simplification/2026-07-17-one-send-one-turn.md)确立的单条目轮次规则。后续的[独立纯日志事件决策](../simplification/2026-07-28-remove-synthetic-log-only-turns.md)将同样的「轮次仅表示执行」语义应用于插件所属记录。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.i18n.yaml new file mode 100644 index 0000000000..3df46dedf2 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.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-28-web-conversation-polish-sweep.md +2026-07-28-web-conversation-polish-sweep.md: cae52217d66017509c025a5d8d37b1e1e8173c6a +2026-07-28-web-conversation-polish-sweep.zh.md: d19a5f75937e9ae9f553b2c941594db118fa434e diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.md b/.agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.md new file mode 100644 index 0000000000..cae52217d6 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.md @@ -0,0 +1,37 @@ +# Agent Note: Web conversation UI polish sweep + +Status: implemented + +English | [中文](2026-07-28-web-conversation-polish-sweep.zh.md) + +## Problem + +A design review of the web GUI's conversation surfaces found a batch of presentation defects: portal menus painted one frame at the wrong position before repositioning (visible open jump), the chat column split one tool run into several groups whenever a step message carried only tool-call heads, tool row summaries printed workspace-absolute paths that consumed most of the row, the running-row sweep was implemented as an alpha mask that dimmed the whole row, the hero workspace chip resurrected a deleted workspace's folder name from the session cwd, and the header showed a turns counter nobody asked for next to a 13px title. + +## Decision + +The sweep lands as presentation-layer changes only; nothing enters the session log. + +- **Portal menus pre-render hidden and measure before paint.** The menu list mounts with `visibility: hidden` at (0,0), measures in `useLayoutEffect`, and becomes visible already at its final position. Menus keep 12px viewport clearance with internal scroll; workspace create actions pin in a non-scrolling footer. +- **The chat flow skips assistant nodes that render nothing.** A finalized assistant node whose blocks are only tool-call heads and blank text/reasoning is dropped from the flow derivation, so consecutive tool results merge into one group. Interrupted nodes always render (they carry the 已停止 marker). +- **Tool row summaries relativize workspace-rooted paths.** The session cwd threads through the toolview slot contract (`ToolRowOwnerProps.cwd`) and `toolRowModel` strips it from summaries that start with it; paths outside the workspace stay verbatim. Display-only — args and the log are untouched. +- **The running sweep is a glare-band overlay.** A fixed-width `::after` gradient band animates across the row (the deepsuite ShimmerText pattern), replacing the previous `mask-image` approach, in both ToolRow and the Bash toolview. +- **The hero workspace chip is a selector, not an echo.** With no live selection (cold start, or the workspace was deleted after the list settled) it shows a "Choose workspace" placeholder; the cwd-derived name only bridges the initial list load, and stale pending picks clear when their workspace leaves a ready list. +- **One 16px vertical rhythm.** The chat column gap and in-group tool-row gap are both 16px, replacing the 10px in-group gap plus a negative cross-group margin. +- **Header title reads 14/20 with no turns counter**; StateDot ongoing and the turn tail use a stepped pixel-chase loading language; `body` gets grayscale antialiasing (`-webkit-font-smoothing` and the Firefox macOS equivalent). + +## Alternatives considered + +- **Position menus synchronously from anchor rects before mount.** Rejected: the list's own size is unknown until it lays out, so clamping to the viewport still needs a post-layout measure; measuring a hidden mounted node is the pattern React and Floating UI document. +- **Filter empty assistant messages host-side.** Rejected: the node is real model output that Trajectory and replay must keep; only the chat presentation should skip it, and the web layer is pure presentation by contract. +- **Relativize paths in each tool's presenter.** Rejected: the redundancy is shared by every path-summarizing tool; one display-only pass in `toolRowModel` covers them all and non-chat consumers keep absolute paths. +- **Keep the mask-based sweep.** Rejected: the mask dims the entire row content including state dots, and its exit transition fought the hover icon crossfade; an overlay band composites above the content without touching its alpha. +- **Keep showing the deleted workspace's name in the chip.** Rejected: the chip is the selector for the *next* session; echoing a cwd whose workspace the user just deleted misrepresents the current pick. + +## Consequences + +Chat renders fewer flow items than the snapshot has nodes: anyone counting rendered blocks against nodes must account for skipped render-nothing assistants (the chat-view spec pins this). The path relativization is a prefix check against the session cwd, so a workspace rename mid-session shows absolute paths until the summary re-derives — accepted as display-only staleness. The uniform 16px rhythm retires the tighter 10px tool-run look; a future denser layout would reintroduce a second constant deliberately. The menu pre-render adds one hidden layout pass per open, negligible at menu sizes. + +## Testing + +`chat-view.spec.tsx` pins the render-nothing grouping (including the interrupted exception); `chat-tool-row.spec.tsx` pins cwd relativization inside/outside the workspace and with an empty cwd; `atoms.spec.tsx` and `workspace-picker.spec.tsx` cover the menu and chip states; the full ui-conversation, ui-primitives, and ui-workspace suites pass. diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.zh.md b/.agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.zh.md new file mode 100644 index 0000000000..d19a5f7593 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.zh.md @@ -0,0 +1,37 @@ +# Agent Note: Web conversation UI polish sweep + +Status: implemented + +[English](2026-07-28-web-conversation-polish-sweep.md) | 中文 + +## Problem + +一次针对 web GUI 对话界面的设计评审发现了一批视觉呈现缺陷:portal 菜单在重新定位前会先在错误位置绘制一帧(打开时可见跳动);只要某条步骤消息只携带工具调用头,聊天列就会把一次工具运行拆成好几组;工具行摘要打印以工作区为根的绝对路径,占掉行内大部分空间;运行中行的扫光效果用 alpha 遮罩实现,把整行都压暗;hero 区的工作区 chip 会从会话 cwd 里复现已删除工作区的文件夹名;标题栏还在 13px 的标题旁显示一个没人需要的轮次计数。 + +## Decision + +本次修复全部为纯展示侧改动落地;不会有任何内容进入会话日志。 + +- **Portal 菜单先隐藏预渲染,绘制前完成测量。**菜单列表以 `visibility: hidden` 挂载在 (0,0),在 `useLayoutEffect` 中测量,显示时已处于最终位置。菜单与视口保持 12px 间距并支持内部滚动;工作区创建操作固定在不滚动的页脚区。 +- **聊天流跳过不渲染任何内容的助手节点。**已定稿的助手节点若其块仅含工具调用头和空白的文本/推理(reasoning)内容,就会从流推导中剔除,于是连续的工具结果合并为一组。被中断的节点始终渲染(它们携带「已停止」标记)。 +- **工具行摘要把以工作区为根的路径转为相对路径。**会话 cwd 经由 toolview 插槽契约(`ToolRowOwnerProps.cwd`)逐层传递,`toolRowModel` 从以其开头的摘要中剥去该前缀;工作区之外的路径保持原样。这只影响显示:工具参数与日志均不受影响。 +- **运行中的扫光效果改为高光带叠加层。**一条固定宽度的 `::after` 渐变光带横向扫过整行(即 deepsuite 的 ShimmerText 模式),取代先前的 `mask-image` 方案,ToolRow 与 Bash toolview 两处均已替换。 +- **hero 区的工作区 chip 是选择器,而非回显。**没有有效选中项时(冷启动,或列表稳定后工作区被删除),它显示「Choose workspace」占位文案;由 cwd 推导的名称只用于衔接列表的首次加载,待定选择对应的工作区从已就绪的列表中消失时,该陈旧选择会被清除。 +- **统一为 16px 的纵向节奏。**聊天列间距与分组内工具行间距统一为 16px,取代原先「分组内 10px 间距加跨分组负外边距」的做法。 +- **标题栏标题改为 14/20,去掉轮次计数**;StateDot 的进行中状态与轮次尾部采用逐格推进的像素追逐式加载视觉语言;`body` 启用灰度抗锯齿(`-webkit-font-smoothing` 及 Firefox 在 macOS 上的等价设置)。 + +## Alternatives considered + +- **挂载前根据锚点矩形同步定位菜单。**不予采纳:列表自身尺寸在布局完成前无从得知,向视口内收拢仍然需要布局后测量;对已挂载的隐藏节点做测量正是 React 与 Floating UI 文档记载的模式。 +- **在宿主侧过滤空的助手消息。**不予采纳:该节点是真实的模型输出,Trajectory 与回放都必须保留它;只有聊天展示应当跳过它,且按契约 web 层只负责呈现。 +- **在每个工具各自的展示器中做路径相对化。**不予采纳:这种冗余是所有输出路径摘要的工具共有的;在 `toolRowModel` 里做一次仅影响显示的处理即可覆盖全部工具,非聊天消费方仍拿到绝对路径。 +- **保留基于遮罩的扫光。**不予采纳:遮罩会把包括状态圆点在内的整行内容压暗,其退出过渡还与悬停图标的交叉淡入淡出相互冲突;叠加光带在内容之上合成,完全不触碰内容的 alpha。 +- **让 chip 继续显示已删除工作区的名称。**不予采纳:chip 是为*下一个*会话服务的选择器;用户刚删掉某个工作区,还回显它的 cwd,就是在错误呈现当前的选择。 + +## Consequences + +聊天渲染出的流条目数少于快照中的节点数:凡是拿渲染出的块与节点数对账的人,都必须把被跳过的「不渲染任何内容」的助手节点计算在内(chat-view 规格测试固定了这一点)。路径相对化只是针对会话 cwd 的前缀检查,因此会话中途重命名工作区后,摘要在重新推导前会显示绝对路径,这被接受为仅影响显示的陈旧状态。统一的 16px 节奏淘汰了原先更紧凑的 10px 工具运行外观;将来若要更紧凑的布局,应当有意识地重新引入第二个常量。菜单预渲染让每次打开多一次隐藏布局计算,在菜单的尺寸量级下开销可忽略。 + +## Testing + +`chat-view.spec.tsx` 固定了「不渲染任何内容」节点的分组行为(含被中断节点这一例外);`chat-tool-row.spec.tsx` 固定了工作区内、工作区外以及 cwd 为空时的 cwd 相对化行为;`atoms.spec.tsx` 与 `workspace-picker.spec.tsx` 覆盖菜单与 chip 的各种状态;ui-conversation、ui-primitives 与 ui-workspace 的全量测试套件通过。 diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml index de9949114b..260ea57905 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.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 -2026-06-30-hook-protocol-lib.md: 19a69119befde99417b736edf38923ec6ac5fa7c -2026-06-30-hook-protocol-lib.zh.md: f4950eea2b02e86ed7109f8ebdaab29dd77428d8 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md +2026-06-30-hook-protocol-lib.md: 33ec23dd4fa6aa8b4966bbe6c0ca5697ec83056c +2026-06-30-hook-protocol-lib.zh.md: 8e8c89a4ecca3bea98fb26bc55974765f27f6a11 diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md index 19a69119be..33ec23dd4f 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md @@ -19,7 +19,7 @@ A new `packages/hooks/` group with `hook-protocol` as a pure library. It owns fo - **Execution** — `runHook(bash, hook, options)`. Runs a command hook through the `ctx.bash` seam rather than a bespoke `spawn`: the executor already provides the scrubbed-but-overridable env, process-group kills, and timeout the protocol needs, and `dsh-bash`'s `stdin`/`env` fields (added for exactly this) are the trusted-plugin surface an in-process bridge is allowed to use. It serializes the bridge-built payload to stdin (trailing newline iff CC), honors the hook's `timeoutSec` (else `DEFAULT_HOOK_TIMEOUT_MS`, the 10-minute reference default both dialects share), and never throws (an executor rejection becomes a non-blocking-error `HookOutput`). - **Decode** — `parseHookOutput(exit, stdout, stderr)`, the exit-code + structured-stdout codec, producing a dialect-neutral `HookOutput`. Exit `0` → lenient JSON parse of stdout; exit `2` → blocking error with `stderr` as the reason (surfaced as `decision: 'block'` so no caller needs a separate exit-code branch); other → non-blocking error. Parses the CC structured-stdout fields that have a consumer on some path (`continue`/`stopReason`/`decision`/`hookSpecificOutput.{permissionDecision,additionalContext,updatedInput}`/`systemMessage`); the bridge honors only the subset meaningful for its dialect. Fields with no consumer on any path are not parsed at all (CC's `suppressOutput` — hook stdout never enters a transcript here, so there is nothing to suppress; see [the tighten-hook-protocol-contract Agent Note](../simplification/2026-07-04-tighten-hook-protocol-contract.md)). - **Merge** — `mergeHookOutputs(outputs)`, folding multiple matched hooks into one most-restrictive `MergedHookOutcome`: permission precedence **deny > ask > allow**, halt sticky on the first `continue:false`, block reasons joined `\n\n`, context/system-messages accumulated in order. -- **`hook/*` session events** — `hook/invoked` / `hook/result`, declaration-merged into `SessionEventMap` (log-only, like `compact/*` — NOT `SurfaceEventType`s), with `appendHookInvoked`/`appendHookResult` helpers so the invoked/result pairing and turn-enclosure stay consistent across bridges. `appendHookResult` also owns the durable record's semantics — the decision string (the hook's parsed decision, else `'stop'` on `continue:false`, else `'pass'`) and the 500-character `stderrSummary` truncation derive from the `HookOutput` here, not per-bridge. +- **`hook/*` session events** — `hook/invoked` / `hook/result`, declaration-merged into `SessionEventMap` (log-only, like `compact/*` — NOT `SurfaceEventType`s), with `appendHookInvoked`/`appendHookResult` helpers so the invoked/result pairing and owner-defined execution relation stay consistent across bridges. `appendHookResult` also owns the durable record's semantics — the decision string (the hook's parsed decision, else `'stop'` on `continue:false`, else `'pass'`) and the 500-character `stderrSummary` truncation derive from the `HookOutput` here, not per-bridge. **Per-dialect (the bridge plugins):** building each event's stdin payload (CC's base+per-event field sets vs Codex's snake_case with `turn_id`/`model` extras), the dialect's env + `${CLAUDE_PLUGIN_ROOT}` substitution (CC) vs none (Codex), and mapping the neutral `HookOutput`/`MergedHookOutcome` onto the harness's seam-specific typed Decisions (`PreToolDecision`, `PromptDecision`, `ContinuationDecision`, `PostToolDecision`). diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md index f4950eea2b..8e8c89a4ec 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md @@ -19,7 +19,7 @@ Status: implemented - **执行** — `runHook(bash, hook, options)`。通过 `ctx.bash` seam 而非自建 `spawn` 运行命令钩子:执行器已提供清洗但可覆盖的 env、进程组 kill 和超时,正是协议所需的能力;`dsh-bash` 的 `stdin`/`env` 字段(正是为此添加的)是进程内桥接插件被允许使用的受信插件接口。它将桥接插件构建的 payload 序列化到 stdin(CC 时追加尾部换行),遵守钩子的 `timeoutSec`(否则使用 `DEFAULT_HOOK_TIMEOUT_MS`,即两种方言共享的 10 分钟参考默认值),且从不抛异常(执行器拒绝变为 non-blocking-error 的 `HookOutput`)。 - **解码** — `parseHookOutput(exit, stdout, stderr)`,exit-code + structured-stdout 编解码器,产出方言无关的 `HookOutput`。Exit `0` → 宽松 JSON 解析 stdout;exit `2` → blocking error,`stderr` 为原因(以 `decision: 'block'` 呈现,调用方无需单独处理 exit-code 分支);其他 → non-blocking error。解析 CC structured-stdout 中在某条路径上有消费方的字段(`continue`/`stopReason`/`decision`/`hookSpecificOutput.{permissionDecision,additionalContext,updatedInput}`/`systemMessage`);桥接插件只采纳对其方言有意义的子集。在任何路径上都没有消费方的字段不予解析(CC 的 `suppressOutput`——钩子 stdout 在此处从不进入 transcript(文本记录),因此无需抑制;见 [收紧钩子协议契约 Agent Note](../simplification/2026-07-04-tighten-hook-protocol-contract.md))。 - **合并** — `mergeHookOutputs(outputs)`,将多个匹配钩子的输出折叠为一个最严格的 `MergedHookOutcome`:权限优先级 **deny > ask > allow**,halt 在首个 `continue:false` 时粘滞,阻止原因以 `\n\n` 拼接,context/system-messages 按序累积。 -- **`hook/*` 会话事件** — `hook/invoked` / `hook/result`,通过声明合并进入 `SessionEventMap`(仅日志,如 `compact/*`——不是 `SurfaceEventType`),配有 `appendHookInvoked`/`appendHookResult` 辅助函数,确保 invoked/result 配对与轮次包含关系在各桥接插件间保持一致。`appendHookResult` 还拥有持久化记录的语义:decision 字符串(钩子解析出的 decision,否则 `continue:false` 时为 `'stop'`,否则为 `'pass'`)和 500 字符的 `stderrSummary` 截断均从本库的 `HookOutput` 派生,而非各桥接插件各自实现。 +- **`hook/*` 会话事件** — `hook/invoked` / `hook/result`,通过声明合并进入 `SessionEventMap`(仅日志,如 `compact/*`——不是 `SurfaceEventType`),配有 `appendHookInvoked`/`appendHookResult` 辅助函数,确保 invoked/result 配对与由所有方定义的执行关系在各桥接插件间保持一致。`appendHookResult` 还拥有持久化记录的语义:decision 字符串(钩子解析出的 decision,否则 `continue:false` 时为 `'stop'`,否则为 `'pass'`)和 500 字符的 `stderrSummary` 截断均从本库的 `HookOutput` 派生,而非各桥接插件各自实现。 **方言专属(桥接插件):** 构建每个事件的 stdin payload(CC 的 base+per-event 字段集 vs Codex 的 snake_case 加 `turn_id`/`model` 额外字段)、方言的 env 与 `${CLAUDE_PLUGIN_ROOT}` 替换(CC)vs 无替换(Codex),以及将方言无关的 `HookOutput`/`MergedHookOutcome` 映射为 harness seam 专属的类型化 Decision(`PreToolDecision`、`PromptDecision`、`ContinuationDecision`、`PostToolDecision`)。 diff --git a/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.i18n.yaml index b535ef6081..437bd200dc 100644 --- a/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.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 -2026-06-30-session-store-fork-api.md: 13d411e915b2e34b15a12e632f1a5e047f4aeedc -2026-06-30-session-store-fork-api.zh.md: bcf15eb581af3993ed2d71a2b7dc604faa6e4433 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md +2026-06-30-session-store-fork-api.md: 5342deba8ca879026d32ee1420cb3c0fdf67c500 +2026-06-30-session-store-fork-api.zh.md: 51a3e0ce50aff10a9812c91d24dc6e78a56c43ba diff --git a/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md b/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md index 13d411e915..5342deba8c 100644 --- a/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md +++ b/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md @@ -8,7 +8,7 @@ English | [中文](2026-06-30-session-store-fork-api.zh.md) The event-sourced session log already has the primitive a fork needs: create a new session with a seed event prefix, then derive model history from that seeded log exactly as replay does. That primitive is intentionally low-level: `ctx.sessions.create(id, { seed, meta })` accepts any valid seed, but ordinary live-session branching needs policy around which prefix can be copied, which metadata is stamped on the child, and how errors are classified. -The semantic hazard is the fork boundary. A valid user-visible fork seed must be contiguous and turn-enclosed. Forking inside an active turn would copy an open `turn/start`, possibly an open `step/start`, and possibly dangling tool calls. That violates turn-enclosure and provider-transcript invariants, and it creates a misleading child history that appears to have participated in an unfinished parent turn. The existing [subagent seam](2026-06-21-subagent-capability-seam.md) deliberately solves a different problem: tool-triggered subagent forks usually happen while the parent turn is open, so `dsh-subagent-fork` clips the seed to the parent's last completed-turn prefix. A general session fork should not silently clip; it should either fork the requested boundary or reject it. +The semantic hazard is the fork boundary. A valid user-visible fork seed must be contiguous and end outside an active turn. Forking inside execution would copy an open `turn/start`, possibly an open `step/start`, and possibly dangling tool calls. That violates execution and provider-transcript invariants, and it creates a misleading child history that appears to have participated in an unfinished parent turn. Standalone context and plugin-owned log-only events are stable forkable history after a closed turn. The existing [subagent seam](2026-06-21-subagent-capability-seam.md) deliberately solves a different problem: tool-triggered subagent forks usually happen while the parent turn is open, so `dsh-subagent-fork` clips the seed to the parent's last completed-turn prefix. A general session fork should not silently clip; it should either fork the requested boundary or reject it. ## Decision @@ -24,9 +24,9 @@ class SessionStore extends Service { } ``` -`boundary` is the inclusive source event `seq` to copy through. When omitted, it defaults to the source session's current last event; on an empty source, omitted `boundary` creates an empty child. Fork-specific validation only checks that the requested boundary exists and is a `turn/end`. The selected prefix is then deep-cloned into the child seed. The child inherits the source session's `cwd`, stamps `parentSession` to the source id, and sets `seedLength` to the copied prefix length. When `childSessionId` is omitted, `SessionStore` generates one using its existing id policy. +`boundary` is the inclusive source event `seq` to copy through. When omitted, it defaults to the source session's current last event; on an empty source, omitted `boundary` creates an empty child. Fork-specific validation checks that the requested boundary exists and that the selected prefix's latest turn boundary is not an unmatched `turn/start`. The selected prefix may therefore end at `turn/end` or at a later standalone event, then is deep-cloned into the child seed. The child inherits the source session's `cwd`, stamps `parentSession` to the source id, and sets `seedLength` to the copied prefix length. When `childSessionId` is omitted, `SessionStore` generates one using its existing id policy. -An empty prefix is forkable; any non-empty boundary must be a safe existing sequence at `turn/end`, regardless of reason. Typed errors distinguish missing sources, stale objects, duplicate child ids, and invalid boundaries. Broader log validation and crash repair remain with their existing owners. +An empty prefix is forkable; any non-empty boundary must be a safe existing sequence outside an open turn. Typed errors distinguish missing sources, stale objects, duplicate child ids, invalid boundaries, and prefixes ending during execution. Broader log validation and crash repair remain with their existing owners. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.zh.md b/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.zh.md index bcf15eb581..51a3e0ce50 100644 --- a/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.zh.md @@ -8,7 +8,7 @@ Status: implemented 事件溯源的会话日志已经具备 fork 所需的原语:创建一个带有种子事件前缀的新会话,然后像回放一样从该种子日志推导模型历史。这个原语有意保持底层:`ctx.sessions.create(id, { seed, meta })` 接受任何合法种子,但常规的活跃会话分支需要围绕以下问题制定策略:哪些前缀可以被复制、子会话应打上哪些元数据、以及错误如何分类。 -语义上的风险在于 fork 边界。一个合法的用户可见 fork 种子必须是连续的且封闭在轮次内。如果在一个活跃轮次内部 fork,会复制一个未关闭的 `turn/start`、可能还有一个未关闭的 `step/start`,以及可能悬空的工具调用。这违反了轮次封闭性与提供方 transcript 不变式,并且会创建一段误导性的子历史——看起来子会话参与了父会话中一个尚未完成的轮次。现有的 [subagent seam](2026-06-21-subagent-capability-seam.md) 有意解决的是另一个问题:工具触发的 subagent fork 通常发生在父轮次仍然打开时,因此 `dsh-subagent-fork` 会将种子裁剪到父会话最后一个已完成轮次的前缀。通用的会话 fork 不应静默裁剪;它应当要么在请求的边界处 fork,要么拒绝请求。 +语义上的风险在于 fork 边界。一个合法的用户可见 fork 种子必须连续,并在活跃轮次之外结束。如果在执行过程中 fork,会复制一个未关闭的 `turn/start`、可能还有一个未关闭的 `step/start`,以及可能悬空的工具调用。这违反了执行与提供方 transcript 不变式,并且会创建一段误导性的子历史——看起来子会话参与了父会话中一个尚未完成的轮次。已关闭轮次之后的独立上下文和插件所属纯日志事件是稳定且可 fork 的历史。现有的 [subagent seam](2026-06-21-subagent-capability-seam.md) 有意解决的是另一个问题:工具触发的 subagent fork 通常发生在父轮次仍然打开时,因此 `dsh-subagent-fork` 会将种子裁剪到父会话最后一个已完成轮次的前缀。通用的会话 fork 不应静默裁剪;它应当要么在请求的边界处 fork,要么拒绝请求。 ## 决策 @@ -24,9 +24,9 @@ class SessionStore extends Service { } ``` -`boundary` 是要复制到的源事件 `seq`(含该序号)。省略时默认为源会话当前的最后一个事件;对空源会话省略 `boundary` 则创建一个空的子会话。fork 特有的校验仅检查请求的边界是否存在且为 `turn/end`。选定的前缀随后被深拷贝到子会话的种子中。子会话继承源会话的 `cwd`,将 `parentSession` 设为源会话 id,并将 `seedLength` 设为已复制前缀的长度。省略 `childSessionId` 时,`SessionStore` 使用其现有的 id 策略生成一个。 +`boundary` 是要复制到的源事件 `seq`(含该序号)。省略时默认为源会话当前的最后一个事件;对空源会话省略 `boundary` 则创建一个空的子会话。fork 特有的校验会检查请求的边界存在,并确认所选前缀最近的轮次边界不是未匹配的 `turn/start`。因此,所选前缀可以结束于 `turn/end` 或更晚的独立事件,随后被深拷贝到子会话的种子中。子会话继承源会话的 `cwd`,将 `parentSession` 设为源会话 id,并将 `seedLength` 设为已复制前缀的长度。省略 `childSessionId` 时,`SessionStore` 使用其现有的 id 策略生成一个。 -空前缀可以被 fork;任何非空边界都必须是一个安全的、已存在的、位于 `turn/end` 的序号,无论结束原因为何。类型化的错误区分源缺失、对象陈旧、子 id 重复和边界无效等情况。更广泛的日志校验与崩溃恢复仍由其现有的负责方处理。 +空前缀可以被 fork;任何非空边界都必须是位于开放轮次之外且安全、已存在的序号。类型化的错误区分源缺失、对象陈旧、子 id 重复、边界无效和前缀结束于执行过程中等情况。更广泛的日志校验与崩溃恢复仍由其现有的负责方处理。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml index c00293eb69..952027b6e3 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.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-06-sandbox.md -2026-07-06-sandbox.md: b9c410e4498a565a7414085fddfb74856592da66 -2026-07-06-sandbox.zh.md: 876870a12f1205b4f2cc2c8c13b5e7ec815a1569 +2026-07-06-sandbox.md: c3c61ed4539bcbca359f84f3dcc020e0bd41ae79 +2026-07-06-sandbox.zh.md: 39bf92aa20c697d2ad5f4c0926caeff0a0b60a1d diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.md index b9c410e449..c3c61ed453 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.md @@ -202,5 +202,5 @@ In-repo precedents this design copies or contrasts with: - [The capability-seams Agent Note](../architecture/2026-06-13-capability-seams.md) — the interface/implementation/consumer split and the "don't split preemptively" timing rule the second consumer satisfied. - The `dsh-bash` request/spec split ([the bash vocabulary catalog](../../../../docs/core-data-structures/bash.md)) — the complete `sandboxPolicy` rides its per-call carrier, and the explicit-`resolve()` defaulting convention. - [The approval seam Agent Note](2026-07-06-approval-seam.md) — the channel escalation asks through; its answerer waterfall, audit pair, and one-package rationale are recorded there. -- [Event-sourced sessions](../architecture/2026-06-11-event-sourced-sessions.md) and [the turn-enclosure invariant](../architecture/2026-06-15-turn-enclosure-invariant.md) — the log-as-store foundation the per-session modes fold over, and the commit boundary the anchoring design obeys. +- [Event-sourced sessions](../architecture/2026-06-11-event-sourced-sessions.md) and [standalone log-only events](../simplification/2026-07-28-remove-synthetic-log-only-turns.md) — the log-as-store foundation the per-session modes fold over, and the explicit durability boundary the anchoring design obeys. - [The interception-seams Agent Note](2026-06-30-interception-seams.md) — the `tools/pre-execute` vocabulary the escalation gate deliberately does not reuse (an escalating call has no pre-execute moment of its own). diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md index 876870a12f..39bf92aa20 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md @@ -202,5 +202,5 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层 - [能力 seam Agent Note](../architecture/2026-06-13-capability-seams.md)——接口/实现/消费方拆分与「不要过早拆分」的时机规则(第二个消费方满足了该规则)。 - `dsh-bash` 的 request/spec 拆分([bash 词汇目录](../../../../docs/core-data-structures/bash.md))——完整的 `sandboxPolicy` 搭载其按调用载体,以及显式 `resolve()` 默认约定。 - [批准 seam Agent Note](2026-07-06-approval-seam.md)——升级请求通过的通道;其应答器 waterfall(瀑布式事件)、审计对和单包理由记录在那里。 -- [事件溯源会话](../architecture/2026-06-11-event-sourced-sessions.md)与[轮次封闭不变式](../architecture/2026-06-15-turn-enclosure-invariant.md)——按会话模式 fold 所依赖的日志即存储基础,以及锚定设计遵守的提交边界。 +- [事件溯源会话](../architecture/2026-06-11-event-sourced-sessions.md)与[独立纯日志事件](../simplification/2026-07-28-remove-synthetic-log-only-turns.md)——按会话模式 fold 所依赖的日志即存储基础,以及锚定设计遵守的显式持久性边界。 - [拦截 seam Agent Note](2026-06-30-interception-seams.md)——`tools/pre-execute` 词汇,升级门控刻意不复用它(升级调用没有自己的 pre-execute 时刻)。 diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml index fd047fa625..be4fc8f19f 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.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-16-persistent-pty-sessions.md -2026-07-16-persistent-pty-sessions.md: 55ab262b89526911e16a3fec5dd624fc42fb2222 -2026-07-16-persistent-pty-sessions.zh.md: 525d199298bc071c36444d689368317968186c9f +2026-07-16-persistent-pty-sessions.md: 691ccd4341837d63bb48d27c2a3fb007657fe7ba +2026-07-16-persistent-pty-sessions.zh.md: 86d57f79ee74429542730ebcad60bc9f3c9f15ca diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md index 55ab262b89..691ccd4341 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md @@ -74,7 +74,7 @@ With `run_in_background: true`, `dsh-tool-pty` registers the in-flight send on ` The local backend first recognizes a private OSC prompt marker emitted by its controlled bash startup, then requires printable prompt text after that marker before declaring prompt readiness and runs three bounded fallback tiers. Carrying that state across data callbacks covers macOS delivery where the OSC marker and `PS1` arrive separately; the marker alone can no longer publish an empty MOTD. The marker is removed before output reaches the model and avoids a fixed silence delay for ordinary shell commands on both platforms. Unpublished startup does not accept zero-output silence as readiness; timeout rejects the spawn. If caller cancellation wins during startup, the backend closes the private session and propagates the exact `AbortSignal.reason`; a foreground PGID that is not observable yet cannot replace cancellation with a lookup error. All timings are validated config fields: `pollIntervalMs`, `exactProbeAfterMs`, `idleSilenceMs`, `handoffGraceMs`, and `timeoutMs`. -On Linux, the inspector reads the shell's terminal foreground PGID from `/proc//stat`, enumerates every process and thread in that process group, and probes their current syscalls. A positive Tier 1 result requires an observed stdin wait: direct `read(0)`, a permitted read of a `select`/`pselect6` or `poll`/`ppoll` argument containing fd 0, or an epoll interest list containing fd 0. Unreadable process memory and unrecognized syscalls are misses, never positive guesses. Architecture tables contain only syscall numbers defined by the corresponding Linux UAPI; unsupported architectures skip Tier 1. +On Linux, the inspector reads the shell's terminal foreground PGID from `/proc//stat`, enumerates every process and thread in that process group, and probes their current syscalls. A positive Tier 1 result requires an observed stdin wait: direct `read(0)`, a permitted read of a `select`/`pselect6` or `poll`/`ppoll` argument containing fd 0, or an epoll interest list containing fd 0. A wait already present before terminal input is not post-write readiness: the same PGID must be observed outside that wait before re-entering it, while a changed foreground PGID is new evidence. Unreadable process memory and unrecognized syscalls are misses, never positive guesses. Architecture tables contain only syscall numbers defined by the corresponding Linux UAPI; unsupported architectures skip Tier 1. On macOS there is no exact syscall tier. Output silence returns `inferred_idle` for any foreground process group, including Python and `gdb`; `ps`-derived terminal PGID is used for signaling, not as proof that only the shell can be idle. Pure process-inspector logic is injectable and unit-tested on Linux, while a macOS CI job exercises the real PTY and process-table path. @@ -155,7 +155,7 @@ The package ships concise tool guidance explaining persistent state, owner isola ## Verification -- Per-file coverage pins owner fencing, concurrent reservations, unpublished-spawn cancellation and awaited teardown, sandbox-mode change rejection, retriable lifecycle cleanup, readiness tiers, the configured handoff grace holding the idle fallback past one poll and its rejection below `pollIntervalMs`, sanitizer carry state, complete UTF-8 bounds, task integration, schemas, and exact render intents. +- Per-file coverage pins owner fencing, concurrent reservations, unpublished-spawn cancellation and awaited teardown, sandbox-mode change rejection, retriable lifecycle cleanup, readiness tiers, rejection of pre-write stdin waits, the configured handoff grace holding the idle fallback past one poll and its rejection below `pollIntervalMs`, sanitizer carry state, complete UTF-8 bounds, task integration, schemas, and exact render intents. - Linux process fixtures cover non-leader and non-main-thread stdin waits, zombie quiescence, unreadable process state, supported syscall tables, unsupported architectures, and false-positive rejection; macOS inspector logic is injected into the same unit suite. - Real `node-pty` tests exercise shell state, shared sandbox policy, environment scrubbing, raw-mode foreground `SIGINT` after deliberately delayed child readiness under scenario-owned timing bounds, a TERM-ignoring descendant, and immediate post-disposal quiescence on supported hosts. - A Loader-driven `cordis.yml` test mounts the real three-package composition. ACP and headless snapshots pin the six schemas, bounded results, and errors through opt-in overlays; TUI snapshots pin terminal and generic card presentation. @@ -168,7 +168,7 @@ The package ships concise tool guidance explaining persistent state, owner isola **Idle below Linux Tier 1 is heuristic.** Output silence cannot distinguish a prompt from sleep or network I/O. The typed result preserves uncertainty, and bounded timeout plus task waiting and signaling keep control with the model. -**The exact-versus-inferred boundary is a latency trade, not a solvable race.** Attribution depends on whether the kernel publishes the foreground handoff before or after the silence bound elapses, so any fixed grace is a scheduling bet. `handoffGraceMs` puts that bet in deployment configuration: raising it buys exact `stdin_read` attribution on a slow or loaded host at the cost of interactive return latency after a prompt marker, and lowering it does the reverse. Tests that must not depend on the winner assert the observable behavior — the next send runs — rather than the attribution. +**The exact-versus-inferred boundary is a latency trade, not a solvable race.** Attribution depends on whether the kernel publishes the foreground handoff before or after the silence bound elapses, so any fixed grace is a scheduling bet. `handoffGraceMs` puts that bet in deployment configuration: raising it buys exact `stdin_read` attribution on a slow or loaded host at the cost of interactive return latency after a prompt marker, and lowering it does the reverse. Tests that must not depend on the winner assert child-produced output from the next send, using a token absent from echoed input, rather than the attribution. **Persistent state can drift from the model's belief.** The model may forget its cwd or active REPL. Session summaries and retained output help recovery, but no prompt can make state persistence deterministic. diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md index 525d199298..86d57f79ee 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md @@ -74,7 +74,7 @@ UI 渲染契约精确且不携带位置信息。`terminal_send` 只为前台发 本地后端先识别受控 bash 启动时发出的私有 OSC prompt marker,并且只有在该 marker 后出现可打印的 prompt 文本时才据此声明 prompt 就绪;除此之外,它还运行 3 个有界 fallback 层级。在 data callback 之间保留这项状态,可以适配 macOS 分开交付 OSC marker 与 `PS1` 的情况;单独的 marker 不会发布空 MOTD。marker 在输出到达模型前被移除,使两个平台上的普通 shell 命令都无需固定等待静默阈值。尚未发布的 startup 不会把零输出静默视为就绪;timeout 会拒绝 spawn。若调用方取消在 startup 期间胜出,后端会关闭私有会话并原样抛出 `AbortSignal.reason`;尚不可观察的前台 PGID 不会再用查找错误覆盖取消原因。所有时间参数都是经校验的配置字段:`pollIntervalMs`、`exactProbeAfterMs`、`idleSilenceMs`、`handoffGraceMs` 和 `timeoutMs`。 -在 Linux 上,检查器从 `/proc//stat` 读取 shell 的终端前台 PGID,枚举该进程组中的每个进程与线程,并检查它们当前的 syscall。Tier 1 只有观察到 stdin 等待才返回正结果:直接 `read(0)`、获准读取且含 fd 0 的 `select`/`pselect6` 或 `poll`/`ppoll` 参数,或者含 fd 0 的 epoll interest list。无法读取的进程内存和未识别的 syscall 都是 miss,绝不作为正向猜测。架构表只包含对应 Linux UAPI 定义的 syscall number;不支持的架构跳过 Tier 1。 +在 Linux 上,检查器从 `/proc//stat` 读取 shell 的终端前台 PGID,枚举该进程组中的每个进程与线程,并检查它们当前的 syscall。Tier 1 只有观察到 stdin 等待才返回正结果:直接 `read(0)`、获准读取且含 fd 0 的 `select`/`pselect6` 或 `poll`/`ppoll` 参数,或者含 fd 0 的 epoll interest list。终端输入前就已存在的等待并不代表写入后就绪:必须先观察到同一 PGID 脱离该等待,之后再次进入等待才能使该次 send 完成;前台 PGID 发生变化则构成新的证据。无法读取的进程内存和未识别的 syscall 都是 miss,绝不作为正向猜测。架构表只包含对应 Linux UAPI 定义的 syscall number;不支持的架构跳过 Tier 1。 macOS 没有精确 syscall 层。任何前台进程组输出静默都会返回 `inferred_idle`,包括 Python 和 `gdb`;从 `ps` 推导的终端 PGID 只用于发送信号,不作为「只有 shell 才能 idle」的证明。纯进程检查逻辑可注入并在 Linux 上完成 unit 覆盖率,同时由 macOS CI job 驱动真实 PTY 和进程表路径。 @@ -155,7 +155,7 @@ plugins: ## 验证 -- 每文件覆盖率固定 owner 隔离、并发预留、未发布 spawn 的取消与等待式 teardown、沙箱模式变更拒绝、可重试的生命周期清理、就绪层级、配置化交接宽限把 idle fallback 顶过一次轮询以及低于 `pollIntervalMs` 时的拒绝、sanitizer carry state、完整 UTF-8 结果上限、task 集成、schema 和精确 render intent。 +- 每文件覆盖率固定 owner 隔离、并发预留、未发布 spawn 的取消与等待式 teardown、沙箱模式变更拒绝、可重试的生命周期清理、就绪层级、对写入前 stdin 等待的拒绝、配置化交接宽限把 idle fallback 顶过一次轮询以及低于 `pollIntervalMs` 时的拒绝、sanitizer carry state、完整 UTF-8 结果上限、task 集成、schema 和精确 render intent。 - Linux 进程 fixture 覆盖非 leader 与非主线程的 stdin 等待、僵尸进程静止性、不可读进程状态、受支持的 syscall 表、不支持的架构和误报拒绝;同一单元测试套件通过注入覆盖 macOS 检查器逻辑。 - 真实 `node-pty` 测试在受支持宿主上覆盖 shell 状态、共享沙箱策略、环境清洗、在由场景掌控的时间界限内先有意延迟子进程就绪,再对 raw mode 前台进程发送 `SIGINT`、忽略 `SIGTERM` 的子进程,以及 dispose 返回后立即完全停稳。 - Loader 驱动的 `cordis.yml` 测试挂载真实三包组合。ACP 与 headless 快照通过 opt-in overlay 固定 6 个 schema、有界结果和错误;TUI 快照固定 terminal 与 generic 卡片展示。 @@ -168,7 +168,7 @@ plugins: **Linux Tier 1 之外的 idle 都是启发式结果。**输出静默无法区分 prompt、sleep 和网络 I/O。类型化结果保留不确定性,有界 timeout、task 等待与信号让模型仍能掌握控制权。 -**精确归因与推断归因的边界是延迟取舍,不是可消除的竞态。**归因取决于内核在静默上限到达之前还是之后发布前台交接,因此任何固定宽限都是一次调度上的赌注。`handoffGraceMs` 把这个赌注交给部署配置:调大它可以在慢速或高负载主机上换到精确的 `stdin_read` 归因,代价是见过 prompt marker 之后的交互返回延迟;调小则相反。不应依赖胜负结果的测试断言可观察行为——下一次 send 能正常执行——而不是断言归因路径。 +**精确归因与推断归因的边界是延迟取舍,不是可消除的竞态。**归因取决于内核在静默上限到达之前还是之后发布前台交接,因此任何固定宽限都是一次调度上的赌注。`handoffGraceMs` 把这个赌注交给部署配置:调大它可以在慢速或高负载主机上换到精确的 `stdin_read` 归因,代价是见过 prompt marker 之后的交互返回延迟;调小则相反。不应依赖胜负结果的测试使用不会出现在输入回显中的 token,断言下一次 send 中由子进程产生的输出,而不是断言归因路径。 **持久状态可能偏离模型认知。**模型可能忘记 cwd 或活跃 REPL。会话摘要和保留输出有助恢复,但任何 prompt 都无法让状态持久化变成确定行为。 diff --git a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.i18n.yaml index 2dee4d6261..b09dcbf17d 100644 --- a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.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 -2026-07-21-log-backed-session-titles.md: 4cf238a278a4eec7b894a0fcfb0f2ac464325bb0 -2026-07-21-log-backed-session-titles.zh.md: 933cc14eb245581c128cb18055df726174f953ec +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md +2026-07-21-log-backed-session-titles.md: 1bd58e35ec625fb0b04c0c119ce425ff30a64881 +2026-07-21-log-backed-session-titles.zh.md: 37ec95efbca334f71d19d2bc3e18c22d50d9b5fb diff --git a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md index 4cf238a278..1bd58e35ec 100644 --- a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md +++ b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md @@ -8,7 +8,7 @@ English | [中文](2026-07-21-log-backed-session-titles.zh.md) A session needs a short human-facing title before an editor, terminal, or query consumer can present it usefully. The cheapest implementation can derive one from the first prompt, while higher-quality implementations may call a model over the first prompt or the whole conversation. Those strategies have different latency, cost, routing, and retry behavior, but every consumer needs one durable source of truth. -Session identity metadata is immutable, the event log is the replay and fork boundary, and every event must remain turn-enclosed. A model-generated title often finishes after the main turn closes, so writing it synchronously would delay the agent response while writing it as mutable metadata would bypass ordinary persistence, replay, and lineage semantics. Concurrent prompts, provider HMR, cancellation, and ignored abort signals also make an unfenced background result capable of overwriting a newer title. +Session identity metadata is immutable, and the event log is the replay and fork boundary. A model-generated title often finishes after the main turn closes, so writing it synchronously would delay the agent response while writing it as mutable metadata would bypass ordinary persistence, replay, and lineage semantics. Concurrent prompts, provider HMR, cancellation, and ignored abort signals also make an unfenced background result capable of overwriting a newer title. ## Decision @@ -18,13 +18,13 @@ The [`session-title` capability family](../../../../packages/session-title/READM Every accepted revision is a log-only `session/title` event. Its payload contains normalized non-empty text, the exact eligible human `user/message` seqs used to derive it, and either fallback provenance or the registered provider id plus optional provider/model route. Before an auxiliary title-model dispatch, the shared helper appends a log-only `session/title-llm-request` event containing the title-provider id, exact source seqs, route, system prompt, messages, and output-token cap; a later generation failure leaves the request auditable. The dispatched envelope is deep-frozen to preserve exact agreement with that record but carries no process-local agent-loop request identity, so loop-only reconstruction checks do not compare it with the main conversation header. Validation failures that never reach dispatch create no request event. `foldSessionTitle()` selects the latest title event and adds that event's seq and timestamp as `SessionTitleSnapshot`. Neither event enters `session.surface` or `deriveMessages()`. -The core session package exposes `ctx.sessions.appendOutOfBand()` only for plugin event types whose owners also declaration-merge an `OutOfBandSessionEventMap` marker. An open turn receives the log-only event directly and owns its normal checkpoint. A closed log receives `turn/start → event → turn/end` under the plugin's trigger, followed by an awaited flush. Once the synthetic turn opens, target-append failure still attempts to close and flush it; detach is deferred until the sequence settles. Session titles contribute the source-free `session-title` zero-step trigger and opt both title event types into this seam. No message caused that trigger, so consumers of the merge-extensible `TurnTriggerMap` discriminate `kind` before reading variant fields; goal-round admission, for example, ignores every non-`message` trigger. +The title service appends `session/title` directly after checking its current revision and exact live session; the bundled model helper likewise appends its literal `session/title-llm-request` record before dispatch. Both records may sit between turns without inventing an execution boundary. Persistence observes them eagerly and drains through ordinary checkpoints and lifecycle teardown; title publication does not force a per-event flush. No generic marker, cast, or settlement queue sits between the event owner and `Session.append()`. This is the domain-specific application of the [standalone log-only event decision](../simplification/2026-07-28-remove-synthetic-log-only-turns.md). ### Input and asynchronous timing Only text blocks from human-source `user/message` events are eligible. Empty, control-only, and non-text prompts wait for the next eligible message. The service schedules the first fallback without awaiting it from the prompt path, normalizes whitespace and control sequences, applies the configured word and UTF-8 byte limits without splitting a code point, and records the first message seq. -Automatic provider work starts only after the main loop has a current logged provider/model route. A newly appended `request/header` starts pending work directly; when the header is unchanged, the marked loop-built `llm/stream` request starts it after matching the folded route. Generation then runs independently of the agent response, and a completion joins whichever turn is open at acceptance time or uses the zero-step append path. Explicit `refresh(session, signal?)` materializes any missing fallback and awaits the registered provider; without a provider it returns the fallback. Caller cancellation during fallback flush does not roll back the durable append, but `refresh()` rechecks the signal and rejects instead of returning success. Concurrent refreshes reserve their session-local revision before waiting for fallback durability, so a newer call supersedes an older call before either can invert provider completion order. Automatic work and concurrent refreshes share one session-local in-flight fallback promise, so the first fallback creates only one title event and zero-step turn. All title-capability out-of-band writes share a per-session settlement queue; a replacement model request waits for any earlier title write, while the superseded model call itself remains independently abortable and cannot commit stale output. A title accepted during asynchronous compaction remains log-only, so the compactor's post-summary surface-node check tolerates it; a concurrent surface mutation still invalidates the replacement. +Automatic provider work starts only after the main loop has a current logged provider/model route. A newly appended `request/header` starts pending work directly; when the header is unchanged, the marked loop-built `llm/stream` request starts it after matching the folded route. Generation then runs independently of the agent response, and a completion appends a standalone event without changing turn state. Explicit `refresh(session, signal?)` materializes any missing fallback and awaits the registered provider; without a provider it returns the fallback. Caller cancellation does not roll back an already accepted fallback event, and `refresh()` rechecks the signal before returning success. Concurrent refreshes reserve their session-local revision before provider work, so a newer call aborts and supersedes an older call before either can invert provider completion order. Automatic work and concurrent refreshes share one session-local in-flight fallback promise, so the first fallback creates only one title event. A title accepted during asynchronous compaction remains log-only, so the compactor's post-summary surface-node check tolerates it; a concurrent surface mutation still invalidates the replacement. The first-message provider schedules once when a fresh session first creates its fallback. An automatic failure does not reschedule on later prompts; `refresh()` is the retry path. The all-messages provider schedules after every eligible human prompt and passes all eligible messages through that revision, including seeded history. Its newer revision aborts and supersedes older pending or active work. @@ -34,13 +34,13 @@ The first-message provider schedules once when a fresh session first creates its Model providers require explicit word, CJK-character, input-byte, output-token, and timeout limits. Optional `provider` and `model` overrides are a pair; without them the helper uses the exact route from the logged main request header. Selected messages are framed as JSON under one fixed language-aware instruction. The dispatched `GenerateOptions` carries `purpose: 'session-title'`; the DeepSeek adapter maps that purpose to thinking-disabled and omits reasoning effort so the bounded output is visible title text, while the main conversation keeps its configured thinking mode. The input limit measures the final user prompt, including wrappers, seq fields, and JSON escaping, before the request is logged or dispatched. Oversized input is rejected rather than truncated because truncation would make the recorded source seqs falsely imply complete use. The fused deadline is checked while consuming each stream chunk and after completion, so a successful result returned after timeout cannot be accepted even when an interceptor or adapter ignores abort. -Automatic provider failures are nonfatal warnings and retain the latest title. Explicit refresh failures reject to the caller. Output must be non-empty text with unique ordered seqs drawn from the fixed request; the service normalizes and byte-limits it before durable acceptance. +Automatic provider failures are nonfatal warnings and retain the latest title. Explicit refresh failures reject to the caller. Output must be non-empty text with unique ordered seqs drawn from the fixed request; the service normalizes and byte-limits it before log acceptance. ### Forks and consumers A fork inherits seed title events unchanged, like the rest of its source log. The first-message provider does not automatically retitle a fork. The all-messages provider may append a child-owned revision after a later child prompt, using inherited and new eligible messages. -`ctx.sessionQuery.readTitle()` folds one live-preferred or persisted log without loading titles during `listSessions()`. The TUI uses the latest title as its header subtitle and sets the terminal window title to `` after terminal-safe rendering. The Web host folds the same log state into a validated mux control frame after each attached-session subscription baseline and immediately after forwarding a live raw title event. The browser retains only newer title event seqs even when the control frame precedes list or session-instance creation; sidebar labels, search, breadcrumbs, and the browser title then react to the projected revision. `session.list` remains metadata-only, so a cold persisted session uses the cwd basename or id until opening or resuming it attaches the log. The browser title uses `` only for a selected titled session and otherwise preserves the product title. A synthetic title turn remains a completed durability boundary for the metadata write; consumers reporting agent completion use the core `findLastMessageTurnEnd()` fold so a later title, injection, or other plugin-owned turn cannot replace the preceding message-triggered outcome. +`ctx.sessionQuery.readTitle()` folds one live-preferred or persisted log without loading titles during `listSessions()`. The TUI uses the latest title as its header subtitle and sets the terminal window title to `` after terminal-safe rendering. The Web host folds the same log state into a validated mux control frame after each attached-session subscription baseline and immediately after forwarding a live raw title event. The browser retains only newer title event seqs even when the control frame precedes list or session-instance creation; sidebar labels, search, breadcrumbs, and the browser title then react to the projected revision. `session.list` remains metadata-only, so a cold persisted session uses the cwd basename or id until opening or resuming it attaches the log. The browser title uses `` only for a selected titled session and otherwise preserves the product title. Consumers reporting agent completion use the core `findLastMessageTurnEnd()` fold, so a later between-turn title record cannot replace the preceding message-triggered outcome. ## Alternatives considered @@ -57,6 +57,6 @@ A fork inherits seed title events unchanged, like the rest of its source log. Th - Titles survive JSONL and SQLite persistence, replay, and fork inheritance without a separate mutable record. - Web title delivery stays incremental and log-backed without a title index or persisted-list scan; cold list rows improve after attach. - A fallback appears immediately. Each fresh Web session adds one first-message auxiliary call; other compositions choose whether better titles justify model cost and whether later prompts should retitle a session. -- Auxiliary request records and late accepted titles consume event seqs and may create balanced zero-step turns, so persistence exposes both attempted dispatches and accepted updates even though model history and KV-cache identity do not change. +- Auxiliary request records and late accepted titles consume event seqs without consuming turn numbers, so persistence exposes both attempted dispatches and accepted updates even though model history and KV-cache identity do not change. - One provider and monotonic per-session revisions make disposal, supersession, and stale-result rejection explicit, at the cost of leaving multi-strategy precedence to a composite provider. - Manual rename, deletion, generated-versus-user precedence, search, and list indexing remain outside the capability. diff --git a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.zh.md b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.zh.md index 933cc14eb2..37ec95efbc 100644 --- a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.zh.md @@ -8,7 +8,7 @@ Status: implemented 会话需要一个面向用户的简短标题,编辑器、终端或查询消费方才能有效呈现它。成本最低的实现可以从第一条提示词派生标题,质量更高的实现则可以让模型处理第一条提示词或整个对话。这些策略在延迟、成本、路由和重试行为上各有不同,但所有消费方都需要一个持久的真源。 -会话身份元数据不可变,事件日志是回放和 fork 的边界,而且每个事件都必须包围在轮次内。模型生成的标题往往在主轮次结束后才完成,因此同步写入会延迟 agent(智能体)响应,而作为可变元数据写入则会绕过常规的持久化、回放和沿袭语义。并发提示词、提供方 HMR(热模块替换)、取消以及被忽略的中止信号,还可能让未受版本校验约束的后台结果覆盖更新的标题。 +会话身份元数据不可变,事件日志是回放和 fork 的边界。模型生成的标题往往在主轮次结束后才完成,因此同步写入会延迟 agent(智能体)响应,而作为可变元数据写入则会绕过常规的持久化、回放和沿袭语义。并发提示词、提供方 HMR(热模块替换)、取消以及被忽略的中止信号,还可能让未受版本校验约束的后台结果覆盖更新的标题。 ## 决策 @@ -18,13 +18,13 @@ Status: implemented 每个已接受的修订都是纯日志 `session/title` 事件。其载荷包含规范化后的非空文本、用于派生标题的所有合格且来源为人类的 `user/message` 的准确 seq,以及回退来源信息,或已注册的提供方 id 加可选的提供方和模型路由。辅助标题模型发起调用前,共享辅助组件会追加一个纯日志 `session/title-llm-request` 事件,其载荷包含标题提供方 id、准确的源 seq、路由、系统提示词、消息和输出 token 上限;即使后续生成失败,这次请求仍可审计。发送的请求信封经过深度冻结,以确保其与该记录精确一致,但它有意不携带进程本地的 agent loop(智能体循环)请求身份,因此仅针对 agent loop 的重建检查不会将它与主对话请求头进行比较。未进入调用阶段的验证失败不会创建请求事件。`foldSessionTitle()` 选择最新的标题事件,并将该事件的 seq 和时间戳加入 `SessionTitleSnapshot`。这两类事件都不会进入 `session.surface` 或 `deriveMessages()`。 -核心会话包通过 `ctx.sessions.appendOutOfBand()` 暴露这一接口,但只允许所属插件同时通过声明合并向 `OutOfBandSessionEventMap` 添加标记的插件事件类型使用。开放轮次会直接接收纯日志事件,并负责其常规检查点。已关闭的日志会在该插件的触发器下接收 `turn/start → event → turn/end`,随后等待刷写完成。合成轮次一旦开启,即使目标追加失败,系统仍会尝试将其关闭并刷写;整个序列完成前会延迟 detach。会话标题提供不带消息来源的 `session-title` 零步骤触发器,并让这两类标题事件都使用这一服务边界。该触发器并非由消息引起,因此可合并扩展的 `TurnTriggerMap` 的消费方在读取变体字段前,会先根据 `kind` 判别类型;例如,目标轮次准入会忽略所有非 `message` 触发器。 +标题服务会在检查当前修订和确切的实时会话后,直接追加 `session/title`;随附模型辅助函数同样会在发起调用前追加其字面量 `session/title-llm-request` 记录。两类记录都可以位于轮次之间,而无需虚构执行边界。持久化会尽快观察它们,并通过常规检查点和生命周期 teardown 排空;标题发布不会强制逐事件 flush。事件所有方与 `Session.append()` 之间不存在通用标记、类型断言或结算队列。这是[独立纯日志事件决策](../simplification/2026-07-28-remove-synthetic-log-only-turns.md)在特定领域中的应用。 ### 输入与异步时序 只有人类来源的 `user/message` 事件中的文本块才符合条件。空提示词、仅含控制字符的提示词和非文本提示词会等待下一条合格消息。服务从提示词路径调度首个回退标题而不等待其完成,随后规范化空白和控制序列,应用已配置的单词数和 UTF-8 字节限制且不拆分代码点,并记录第一条消息的 seq。 -仅当主循环存在已记录在日志中的当前提供方/模型路由时,自动提供方工作才会启动。`request/header` 新追加到日志时,会直接启动待执行工作;如果请求头没有变化,则由循环构建并带有标记的 `llm/stream` 请求会先与折叠所得的路由匹配,再启动该工作。随后,生成工作独立于 agent 响应运行;完成结果在被接受时加入当时开放的轮次,否则使用零步骤追加路径。显式调用 `refresh(session, signal?)` 会生成尚缺的回退标题并等待已注册的提供方;没有提供方时则返回回退标题。调用方在回退标题刷写期间取消调用不会回滚这次持久化追加,但 `refresh()` 会重新检查取消信号,并让调用以拒绝结束,而非返回成功。并发刷新会在等待回退标题持久化完成前预留会话本地修订号,因此在任何调用有机会造成提供方完成顺序倒置之前,较新的调用就会取代较早的调用。自动工作与并发刷新在每个会话内共用同一个进行中的回退 promise,因此首次回退只会创建一个标题事件和一个零步骤轮次。会话标题功能产生的所有带外写入在每个会话内共用一个结算队列;接替执行的模型请求会等待任何更早的标题写入完成,而被取代的模型调用本身仍可独立中止,且无法提交陈旧输出。异步压缩(compaction)期间接受的标题仍是纯日志事件,因此压缩器在摘要完成后执行的表层节点检查不会因该标题而失败;并发的表层变更仍会使替换失效。 +仅当主循环存在已记录在日志中的当前提供方/模型路由时,自动提供方工作才会启动。`request/header` 新追加到日志时,会直接启动待执行工作;如果请求头没有变化,则由循环构建并带有标记的 `llm/stream` 请求会先与折叠所得的路由匹配,再启动该工作。随后,生成工作独立于 agent 响应运行;完成结果会追加一个独立事件,而不改变轮次状态。显式调用 `refresh(session, signal?)` 会生成尚缺的回退标题并等待已注册的提供方;没有提供方时则返回回退标题。调用方取消不会回滚已接受的回退事件,`refresh()` 会在返回成功前重新检查信号。并发刷新会在提供方工作之前预留会话本地修订号,因此较新的调用会在任何调用有机会造成提供方完成顺序倒置之前中止并取代较早的调用。自动工作与并发刷新在每个会话内共用同一个进行中的回退 promise,因此首次回退只会创建一个标题事件。异步压缩(compaction)期间接受的标题仍是纯日志事件,因此压缩器在摘要完成后执行的表层节点检查不会因该标题而失败;并发的表层变更仍会使替换失效。 首消息提供方仅在新会话首次创建回退标题时调度一次。自动执行失败后,后续提示词不会重新调度;`refresh()` 是重试路径。全部消息提供方会在每条合格且由人类发出的提示词后调度,并传入截至该修订的所有合格消息,包括预置历史记录。较新的修订会中止并取代更早的待执行或活跃工作。 @@ -34,13 +34,13 @@ Status: implemented 模型提供方必须显式配置单词数、CJK 字符数、输入字节数、输出 token 数和超时限制。可选的 `provider` 和 `model` 覆盖项必须成对提供;两者均未提供时,辅助组件会使用主请求已记录请求头中的准确路由。系统在一条固定且能区分语言的指令下,将选中的消息封装为 JSON。发出的 `GenerateOptions` 携带 `purpose: 'session-title'`;DeepSeek 适配器将该用途映射为禁用思考且省略推理强度设置的请求,使受限输出成为可见的标题文本,而主对话仍沿用已配置的思考模式。输入字节数按最终形成的用户提示词计算,其中包括包装文本、seq 字段和 JSON 转义;系统会在记录请求或发起调用前完成这项检查。过大输入会被拒绝而不是截断,因为截断会让记录的源消息 seq 错误地表示这些消息已被完整使用。系统在消费每个流分片时以及流完成后都会检查融合后的截止时间,因此即使拦截器或适配器忽略中止信号,超时后返回的成功结果也不会被接受。 -自动提供方故障只会发出非致命警告,并保留最新标题。显式刷新失败则会向调用方返回拒绝。输出必须是非空文本,并包含来自固定请求、唯一且有序的 seq;服务会在持久接受前对其进行规范化并施加字节限制。 +自动提供方故障只会发出非致命警告,并保留最新标题。显式刷新失败则会向调用方返回拒绝。输出必须是非空文本,并包含来自固定请求、唯一且有序的 seq;服务会在日志接受前对其进行规范化并施加字节限制。 ### Fork 与消费方 与源日志的其他部分相同,fork 会原样继承作为种子的标题事件。首消息提供方不会自动为 fork 重新生成标题。全部消息提供方可以在子会话出现后续提示词后追加一项归子会话所有的修订,并使用继承的合格消息和新增的合格消息。 -`ctx.sessionQuery.readTitle()` 会折叠一份实时优先或已持久化的日志,而不会在 `listSessions()` 期间加载标题。TUI 使用最新标题作为其标题栏副标题,并在完成终端安全渲染后,将终端窗口标题设置为 ``。Web host 会在每个已附加会话的订阅基线之后,以及转发实时原始标题事件后立即,将同一份日志状态折叠为经过校验的 mux 控制帧。即使控制帧先于列表或会话实例创建抵达,浏览器也只保留标题事件 seq 较新的版本;侧边栏标签、搜索、面包屑和浏览器标题会随投影后的修订更新。`session.list` 仍只包含元数据,因此尚未打开的持久化会话会继续以 cwd 基名或 id 作为回退,直至打开或恢复会话时附加其日志。浏览器仅在选中已有标题的会话时将标题设置为 ``,否则保留产品标题。合成标题轮次本身仍会完成,并作为元数据写入的持久性边界;报告 agent 完成情况的消费方使用核心的 `findLastMessageTurnEnd()` 折叠逻辑,因此后续的标题轮次、注入轮次或其他归插件所有的轮次无法取代此前由消息触发的结果。 +`ctx.sessionQuery.readTitle()` 会折叠一份实时优先或已持久化的日志,而不会在 `listSessions()` 期间加载标题。TUI 使用最新标题作为其标题栏副标题,并在完成终端安全渲染后,将终端窗口标题设置为 ``。Web host 会在每个已附加会话的订阅基线之后,以及转发实时原始标题事件后立即,将同一份日志状态折叠为经过校验的 mux 控制帧。即使控制帧先于列表或会话实例创建抵达,浏览器也只保留标题事件 seq 较新的版本;侧边栏标签、搜索、面包屑和浏览器标题会随投影后的修订更新。`session.list` 仍只包含元数据,因此尚未打开的持久化会话会继续以 cwd 基名或 id 作为回退,直至打开或恢复会话时附加其日志。浏览器仅在选中已有标题的会话时将标题设置为 ``,否则保留产品标题。报告 agent 完成情况的消费方使用核心的 `findLastMessageTurnEnd()` 折叠逻辑,因此后续的轮次间标题记录无法取代此前由消息触发的结果。 ## 考虑过的替代方案 @@ -57,6 +57,6 @@ Status: implemented - 标题可以在 JSONL 和 SQLite 持久化中存续、重放并遵循 fork 继承语义,而无需单独的可变记录。 - Web 标题仍以增量方式从日志交付,无需标题索引或扫描持久化列表;冷启动列表项会在会话附加后改用标题。 - 回退标题会立即出现。每个新建的 Web 会话都会增加一次针对首消息的辅助调用;其他组合可以自行决定更优标题是否值得模型成本,以及后续提示词是否需要重新生成会话标题。 -- 辅助请求记录和延迟接受的标题会占用事件 seq,并可能创建平衡的零步骤轮次,因此持久化会同时呈现尝试发起的调用与已接受的更新,尽管模型历史和 KV 缓存标识保持不变。 +- 辅助请求记录和延迟接受的标题会占用事件 seq,但不会占用轮次编号,因此持久化会同时呈现尝试发起的调用与已接受的更新,尽管模型历史和 KV 缓存标识保持不变。 - 单个提供方和每会话单调递增的修订号让释放、取代和陈旧结果拒绝行为明确可见,但多策略优先级必须由复合提供方负责。 - 手动重命名、删除、生成标题与用户标题的优先级、搜索和列表索引不在此功能范围内。 diff --git a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.i18n.yaml new file mode 100644 index 0000000000..44869d6f05 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.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-28-tool-call-file-open-in-os.md +2026-07-28-tool-call-file-open-in-os.md: a2c9b52507d32c2d851f811f0ecdd878a60b1e1c +2026-07-28-tool-call-file-open-in-os.zh.md: efb4c39503d9de71a9d773bdae7fac4fb2b08ee3 diff --git a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md new file mode 100644 index 0000000000..a2c9b52507 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md @@ -0,0 +1,30 @@ +# Agent Note: Tool-call file open in OS + +Status: implemented + +English | [中文](2026-07-28-tool-call-file-open-in-os.zh.md) + +## Problem + +Chat tool rows treated the whole summary line as a click target that opened the right-hand details panel, with a hover background on the row. For filesystem tools the useful action is opening the mentioned file in the operating system's default application, not inspecting the raw tool payload in a sidebar. + +## Decision + +File-tool path summaries (`read` / `write` / `edit` args carrying `path` or `file_path`) render as hover-underline links with a pointer cursor. Clicking the path calls `host.openPath` through `WorkspacesService.openPath`, resolving relative paths against the session cwd. File-link rows disable args expand (leading icon is inert); whole-row click, row hover fill, and the click-to-open-details gesture are removed from tool rows (including bash and todo registrations). The details panel and its inject surface remain for programmatic selection; rows no longer drive them. + +`host.openPath` is a privileged unary RPC accepted only from loopback, same-origin browser requests (same carrier guard as `host.pickDirectory`). Platform adapters open without a shell: `open` on macOS, PowerShell `Invoke-Item` on Windows, `xdg-open` on Linux. The opener is injectable for tests. URL-only read args (`web_fetch`) are not file links. + +## Alternatives considered + +- Keep row-click details and add a separate file affordance — rejected; the product ask replaces the row gesture with the file link. +- Open files inside an in-app preview — rejected; the ask is the OS default application. +- Reuse `host.pickDirectory`'s timeout exemption — unnecessary; path open hand-off completes quickly under the normal unary deadline. + +## Consequences + +Clicking a file path in a tool row opens that path on the host. Non-file tool rows are inert summaries (expand toggles remain where the row already supported them). Remote or non-loopback clients cannot invoke `host.openPath`. + +## Risks + +- Linux hosts without `xdg-open` fail the RPC; the chat row stays silent while the host returns an internal error. +- Relative paths without a session cwd are forwarded verbatim and may fail on the host. diff --git a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md new file mode 100644 index 0000000000..efb4c39503 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md @@ -0,0 +1,30 @@ +# Agent Note: 在工具调用中用系统应用打开文件 + +Status: implemented + +[English](2026-07-28-tool-call-file-open-in-os.md) | 中文 + +## Problem + +聊天工具行把整行摘要当作点击目标,点击后打开右侧 details 面板,并带有整行悬停背景。对文件系统工具而言,有用的动作是用操作系统默认应用打开所涉文件,而不是在侧栏里查看原始工具载荷。 + +## Decision + +文件工具的路径摘要(`read`/`write`/`edit` 参数中的 `path` 或 `file_path`)渲染为悬停下划线链接并使用 pointer 光标。点击路径会经 `WorkspacesService.openPath` 调用 `host.openPath`,相对路径相对会话 cwd 解析。带文件链接的行关闭参数展开(左侧图标不可点);工具行(含 bash 与 todo 注册)去掉整行点击、整行悬停底色,以及点击打开 details 的手势。details 面板及其 inject 面仍保留供程序化选择;工具行不再驱动它们。 + +`host.openPath` 是特权一元 RPC,仅接受来自回环、同源浏览器请求(与 `host.pickDirectory` 相同的载体守卫)。平台适配器不经 shell 打开:macOS 为 `open`,Windows 为 PowerShell `Invoke-Item`,Linux 为 `xdg-open`。打开器可在测试中注入。仅含 URL 的 read 参数(`web_fetch`)不是文件链接。 + +## Alternatives considered + +- 保留整行点击打开 details,另加文件入口 — 否决;产品要求用文件链接替换整行手势。 +- 在应用内预览文件 — 否决;要求是操作系统默认应用。 +- 复用 `host.pickDirectory` 的超时豁免 — 不必要;打开路径的交接在常规一元截止时间内即可完成。 + +## Consequences + +点击工具行中的文件路径会在宿主上打开该路径。非文件工具行是惰性摘要(行内已有的展开开关仍保留)。远程或非回环客户端无法调用 `host.openPath`。 + +## Risks + +- 没有 `xdg-open` 的 Linux 宿主会使 RPC 失败;聊天行保持静默,宿主返回 internal 错误。 +- 没有会话 cwd 时相对路径会原样转发,可能在宿主侧失败。 diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.i18n.yaml b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.i18n.yaml index cfa2cafeb6..9821b8fea4 100644 --- a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.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 -2026-07-06-parallel-pre-push-gates.md: 0c3311b259a2fcf00deb4eed491c301a0c330186 -2026-07-06-parallel-pre-push-gates.zh.md: 6949237d2e025034162f66950033d3ad6ecf11ea +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md +2026-07-06-parallel-pre-push-gates.md: d86642b7feb82908ec792db0c6a3da403cfc79fd +2026-07-06-parallel-pre-push-gates.zh.md: 0425cf1a01b56604a07be366dd46d3920c5fb487 diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md index 0c3311b259..d86642b7fe 100644 --- a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md +++ b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md @@ -12,12 +12,18 @@ Aggregate jobs such as documentation synchronization hide long sequential chains ## Decision -[scripts/run-gates.ts](../../../../scripts/run-gates.ts) owns the bounded scheduler used by CI, `doc-sync`, and the opt-in `check:all` command. It expands named modes into leaf gates, respects artifact dependencies, buffers attributable output, and accepts `DSH_GATE_CONCURRENCY` when a caller needs a different worker bound. +[scripts/run-gates.ts](../../../../scripts/run-gates.ts) owns the bounded scheduler used by CI, `doc-sync`, and the opt-in `check:all` command. It expands named modes into leaf gates, rejects empty or ambiguous dependency graphs before starting a child, respects artifact dependencies, buffers attributable output, reports exit and signal outcomes independently, and accepts `DSH_GATE_CONCURRENCY` when a caller needs a different worker bound. + +The Node 24 consumer job is one seven-gate mode rather than a shell-owned process pool. Its default worker count equals its gate count while dependencies control readiness: `publint` precedes built-package invariant validation, and snapshot replay, NodeNext type checks, built-bin smokes, and lint wait for that validation. Lint waits because the invariant verifier temporarily stages package views that ESLint must not traverse; source compatibility checks can overlap the validation chain. [scripts/publint-all.ts](../../../../scripts/publint-all.ts) discovers packages from `packages//` and runs `publint` with a worker pool sized from `availableParallelism()`. `DSH_PUBLINT_CONCURRENCY` can cap or raise the worker count for local machines and CI runners with different resource profiles. Results are buffered per package and printed in deterministic package order, so parallel execution does not scramble each package's log block. The per-gate package scripts remain the vocabulary for ad hoc local runs. `hygiene` stays an aggregate `&&` chain, while `doc-sync` owns its member list in the scheduler ([doc-sync through the gate scheduler](../../archived/process/2026-07-21-doc-sync-through-gate-scheduler.md)). +## Verification + +[scripts/run-gates.spec.ts](../../../../scripts/run-gates.spec.ts) rejects invalid graphs before the executor runs, pins the consumer inventory and dependency edges, and exercises signal termination through a real child process. [scripts/publint-all.spec.ts](../../../../scripts/publint-all.spec.ts) rejects a missing public export before downstream artifact consumers run. + ## Alternatives considered - **Keep aggregate jobs serial** — simpler execution but makes wall clock equal the sum of independent checks and repeats command-wrapper startup. @@ -28,6 +34,8 @@ The per-gate package scripts remain the vocabulary for ad hoc local runs. `hygie ## Consequences -Scheduler-backed commands take the slowest dependency chain instead of the sum of independent gates and report the gate that dominates. The cost is a custom scheduler with an explicit mode inventory. +Scheduler-backed commands take the slowest dependency chain instead of the sum of independent gates and report the gate that dominates. Invalid graphs fail before partial execution. The cost is a custom scheduler with an explicit mode inventory. + +The consumer validation chain delays restored-artifact consumers and lint until the shared artifact view is known-good and transient staging is gone; those downstream gates can still overlap one another. `publint-all.ts` is asynchronous and buffers command output instead of inheriting stdio live. The payoff is package-level parallelism with stable output order and one environment variable for resource tuning. diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.zh.md b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.zh.md index 6949237d2e..0425cf1a01 100644 --- a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.zh.md +++ b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.zh.md @@ -12,12 +12,18 @@ Status: implemented ## 决策 -[scripts/run-gates.ts](../../../../scripts/run-gates.ts) 拥有 CI、`doc-sync` 和选择启用的 `check:all` 命令所使用的有界调度器。它将具名模式展开为叶子门禁,遵守产物依赖,缓冲可归因的输出,并在调用方需要不同 worker 上限时接受 `DSH_GATE_CONCURRENCY`。 +[scripts/run-gates.ts](../../../../scripts/run-gates.ts) 拥有 CI、`doc-sync` 和选择启用的 `check:all` 命令所使用的有界调度器。它将具名模式展开为叶子门禁,在启动子进程前拒绝空的或有歧义的依赖图,遵守产物依赖,缓冲可归因的输出,分别报告退出结果与信号结果,并在调用方需要不同 worker 上限时接受 `DSH_GATE_CONCURRENCY`。 + +Node 24 消费方 job 采用单个包含七道门禁的模式,而非由 shell 管理的进程池。其默认 worker 数等于门禁数,但门禁是否就绪由依赖关系控制:`publint` 先于已构建包不变式验证运行,快照回放、NodeNext 类型检查、built-bin 冒烟测试和 lint 则等待该验证完成。lint 之所以等待,是因为不变式验证器会临时暂存包视图,而 ESLint 不得遍历这些视图;源码兼容性检查可以与这条验证链重叠运行。 [scripts/publint-all.ts](../../../../scripts/publint-all.ts) 从 `packages//` 发现包,并以根据 `availableParallelism()` 确定大小的 worker 池运行 `publint`。`DSH_PUBLINT_CONCURRENCY` 可以针对资源配置不同的本地机器和 CI runner 限制或提高 worker 数量。结果按包缓冲,并按确定性的包顺序打印,因此并行执行不会打乱各包的日志块。 各门禁的包脚本仍是临时本地运行所用的词汇。`hygiene` 继续作为聚合 `&&` 链,而 `doc-sync` 在调度器中拥有其成员列表([通过门禁调度器运行 doc-sync](../../archived/process/2026-07-21-doc-sync-through-gate-scheduler.md))。 +## 验证 + +[scripts/run-gates.spec.ts](../../../../scripts/run-gates.spec.ts) 在执行器运行前拒绝无效图,锁定消费方清单和依赖边,并通过真实子进程验证信号终止。[scripts/publint-all.spec.ts](../../../../scripts/publint-all.spec.ts) 在下游产物消费方运行前拒绝缺失的公开导出。 + ## 曾考虑的替代方案 - **保持聚合 job 串行**:执行更简单,但墙钟时间等于各独立检查之和,并重复启动命令包装器。 @@ -28,6 +34,8 @@ Status: implemented ## 后果 -由调度器支持的命令耗时取最慢依赖链,而非各独立门禁之和,并会报告主导耗时的门禁。代价是维护一个具有显式模式清单的定制调度器。 +由调度器支持的命令耗时取最慢依赖链,而非各独立门禁之和,并会报告主导耗时的门禁。无效图会直接失败,不会先执行其中一部分。代价是维护一个具有显式模式清单的定制调度器。 + +这条验证链会让已恢复产物的下游消费方和 lint 延后启动,直至共享产物视图经确认有效且临时暂存已清除;这些下游门禁仍可彼此重叠运行。 `publint-all.ts` 采用异步执行并缓冲命令输出,而不是实时继承 stdio。换来的是具有稳定输出顺序的包级并行,以及用于资源调节的单一环境变量。 diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml index 51073f506e..333d19da04 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md -2026-07-21-serial-cross-platform-ci-reference.md: 9ecd7906d2f28461a8a6f3f2fe485580214b39f3 -2026-07-21-serial-cross-platform-ci-reference.zh.md: ed927012c5b0ba9d43444a129d10d966f5e7d923 +2026-07-21-serial-cross-platform-ci-reference.md: 71b364f72a094f7899a0929eec29f3a16ccc8a93 +2026-07-21-serial-cross-platform-ci-reference.zh.md: 0f324a5c235a2b495186caf0d6cc0490cc5876e3 diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md index 9ecd7906d2..71b364f72a 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md @@ -12,6 +12,8 @@ Encoding the one-minute non-Windows target and three-minute Windows target as jo Reviewers also need a direct answer to a simpler question: what happens when the repository's complete primary Node CI aggregate runs without matrix selection, shard variables, or concurrent gates on each selected hosted operating system? +Real-kernel sandbox proofs require specific hosted operating systems and architectures but do not provide a pull-request merge verdict. Repeating that four-job matrix for every pull request consumes Linux, arm64 Linux, and macOS capacity without satisfying branch protection or contributing to the required aggregate in another workflow. + ## Decision [CI](../../../../.github/workflows/ci.yml) gives pull-request and master-push events complementary responsibilities. Pull requests run consolidated Linux and Windows jobs plus the Node compatibility and Python contracts on standard GitHub-hosted capacity. A push to `master` skips those jobs and runs four explicit references: `serial / linux`, `serial / macos`, and `serial / windows` on standard hosted runners, plus `serial / linux (self-hosted standby)` on the in-house `vm-backup` pool — the hot-standby drill that continuously re-proves the failover target described in the [failover runbook](2026-07-26-ci-failover-runbook.md). They intentionally duplicate their short checkout, runtime setup, and immutable install sequences instead of hiding the operating systems behind a matrix or reusable workflow. `workflow_dispatch` is reserved for runner benchmarks. @@ -22,7 +24,9 @@ Platform ownership remains explicit inside that complete aggregate. `pty-local` The macOS reference runs the ordinary Vitest project in forked processes. Node 24 on macOS arm64 has aborted in its CJS lexer from a worker thread; the process boundary contains that external runtime failure without removing any test from the aggregate, while Linux and Windows retain the lower-overhead thread pool. Repository-owned races are fixed at their observation boundaries: dev bundle polling stages each candidate table, graph, and watch-baseline map before publishing a rescan, and a missing bundle remains dirty until a successful content hash. PTY readiness retains a prompt candidate while polling checks foreground ownership; the ordinary silence bound covers inherited markers from interactive children. Real PTY fixtures assemble synchronization tokens at runtime so the interactive shell's input echo cannot satisfy a child-readiness wait. The live-link package-manager e2e preserves the workflow-prepared Corepack home and pnpm metadata/store caches while isolating the other managers' mutable caches, so it does not discard reusable package-manager state before the install. -Master reference jobs are diagnostic and do not participate in the pull request's required `all checks passed` result. A pull request runs only its required jobs; a master push runs only the serial references. Performance is evaluated from completed hosted-job timestamps and reported as a measurement; it is not encoded as a `timeout-minutes` value. +The standalone [Sandbox](../../../../.github/workflows/sandbox.yml) workflow belongs to the reference side of the same split. Its bwrap, Landlock x64/arm64, and Seatbelt real-kernel matrix runs only after a push to `master`. Those four jobs are diagnostic: they are not branch-protection requirements and do not feed `all checks passed` across workflow files. Pull-request CI still checks sandbox source through its ordinary unit and coverage inventory; the host-kernel and packed-install proofs report after merge. + +Master reference jobs are diagnostic and do not participate in the pull request's required `all checks passed` result. The CI and Sandbox workflows keep their cross-platform references on master pushes. Performance is evaluated from completed hosted-job timestamps and reported as a measurement; it is not encoded as a `timeout-minutes` value. The portable reference uses GitHub's standard `ubuntu-latest`, `macos-latest`, and `windows-2025` labels; `serial / windows` is the one remaining native-Windows job, the complete-kernel oracle behind the Wine-hosted pull-request lane ([Wine lane decision](2026-07-27-wine-windows-gates-experiment.md)). Required pull-request jobs use portable standard capacity under the [required-CI decision](2026-07-23-portable-required-pull-request-ci.md). Higher-core hosted runners remain manual benchmarks because a correctness path must remain runnable without repository-external runner configuration. @@ -31,6 +35,7 @@ The portable reference uses GitHub's standard `ubuntu-latest`, `macos-latest`, a - **Set each timeout equal to its latency target** - rejected because scheduling variance would cancel correct work and suppress the evidence needed to diagnose a regression. - **Trust only the concurrent primary inventory** - rejected because scheduling and validation share implementation assumptions; a serial aggregate is an independent completeness check. - **Run the serial references on every pull request** - rejected because they duplicate complete cross-platform aggregates and add macOS work to every change; the required jobs already execute the blocking Linux and Windows contracts. +- **Run the real-kernel Sandbox matrix on every pull request** - rejected because its four statuses do not participate in branch protection, while repeated installs, Landlock builds, and macOS unit parity consume runner capacity without changing the merge verdict. The master run retains the platform and installed-launcher signal. - **Use one operating-system matrix** - rejected because three named jobs make the reference surface visible without another selection mechanism. - **Run the serial reference on larger runners** - rejected because both required CI and its independent reference must remain runnable when organization-owned pools cannot allocate jobs. @@ -40,6 +45,8 @@ The workflow contains duplicated setup steps and a master reference run can take The reference may expose platform failures that the optimized blocking set does not yet claim to support, especially on Windows. Such a failure is evidence about current cross-platform behavior rather than a reason to weaken or silently skip the aggregate. +A sandbox regression visible only to a real host kernel or the packed Landlock install can merge before the master run reports it. That post-merge detection window is accepted in exchange for removing four non-blocking jobs from every pull request; the default branch retains the complete signal. + The explicit `pty-local` ownership boundary means Windows does not claim coverage for a backend it cannot load, and forked macOS unit workers cost more process startup time. In return, every supported surface has an honest platform oracle, a native runtime abort cannot erase the rest of the unit result, and timing-sensitive observers start from state established before callers can mutate it. Removing strict duration timeouts means a latency regression is observed rather than automatically cancelled. Hosted measurements must therefore accompany performance changes, while the completed logs retain the information needed to optimize the slow lane. diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md index ed927012c5..0f324a5c23 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md @@ -12,6 +12,8 @@ Status: implemented 评审人还需要直接回答一个更简单的问题:在每个选定的托管操作系统上,如果仓库完整的主 Node CI 聚合流程不使用矩阵选择、分片变量或并发门禁,运行结果会怎样? +真实内核沙箱验证需要特定的托管操作系统和架构,但不会产生拉取请求的合并裁决。在每个拉取请求上重复运行这个包含四个作业的矩阵会消耗 Linux、arm64 Linux 和 macOS 容量,却既不能满足分支保护,也无法参与另一工作流中的必需聚合结果。 + ## 决策 [CI](../../../../.github/workflows/ci.yml) 为拉取请求事件与 master 推送事件赋予互补的职责。拉取请求在 GitHub 标准托管容量上运行合并后的 Linux 和 Windows 作业,以及 Node 兼容性与 Python 契约。向 `master` 推送时会跳过这些作业,改为运行四个显式参考作业:在标准托管运行器上的 `serial / linux`、`serial / macos` 和 `serial / windows`,以及在公司自有 `vm-backup` 池上的 `serial / linux (self-hosted standby)`——后者是热备演练,持续验证[故障切换手册](2026-07-26-ci-failover-runbook.md)所描述的切换目标。这些作业有意分别重复简短的代码检出、运行时设置和依赖锁定的安装步骤,不用矩阵或可复用工作流把操作系统差异隐藏起来。`workflow_dispatch` 仅用于运行器基准测试。 @@ -22,7 +24,9 @@ Status: implemented macOS 参考流程使用 fork 进程运行常规 Vitest 项目。macOS arm64 上的 Node 24 曾在工作线程中执行 CJS 词法分析器时异常终止;进程边界能够隔离这一外部运行时故障,且无需从聚合流程中删除任何测试,而 Linux 与 Windows 仍使用开销更低的线程池。仓库自身引入的竞态均在相应的观测边界修复:开发构建产物的轮询逻辑每次发布重新扫描结果前,都会先暂存候选表、候选图和候选监视基线映射;构建产物缺失后会一直保持脏状态,直到成功计算内容哈希。PTY 就绪检测会在轮询检查前台进程组归属期间保留提示符候选项;常规静默时限也适用于交互式子进程继承提示符标记的情况。真实 PTY fixture 会在运行时拼接同步标记,使就绪等待逻辑不会把交互式 shell 的输入回显误判为子进程已就绪。实时链接场景下的包管理器 e2e 会保留由工作流预先准备的 Corepack 主目录、pnpm 元数据缓存和 store 缓存,同时隔离其他包管理器的可变缓存,因此不会在安装前丢弃可复用的包管理器状态。 -master 分支的参考作业仅用于诊断,不参与拉取请求所要求的 `all checks passed` 结果。拉取请求只运行其必需作业;向 master 推送时只运行串行参考作业。系统根据已完成托管作业的时间戳评估性能,并将其报告为测量结果,而不是写成 `timeout-minutes` 值。 +独立的 [Sandbox](../../../../.github/workflows/sandbox.yml) 工作流属于同一职责划分中的参考侧。其 bwrap、Landlock x64/arm64 与 Seatbelt 真实内核矩阵只在向 `master` 推送后运行。这四个作业仅用于诊断:它们既不是分支保护的必需项,也不会跨工作流计入 `all checks passed`。拉取请求 CI 仍通过常规的单元测试与覆盖率清单检查沙箱源码;宿主内核与 packed-install 验证在合并后报告结果。 + +master 分支的参考作业仅用于诊断,不参与拉取请求所要求的 `all checks passed` 结果。CI 与 Sandbox 工作流把跨平台参考流程保留在 master 推送上。系统根据已完成托管作业的时间戳评估性能,并将其报告为测量结果,而不是写成 `timeout-minutes` 值。 可移植的参考流程使用 GitHub 标准的 `ubuntu-latest`、`macos-latest` 和 `windows-2025` 标签;`serial / windows` 是仅存的原生 Windows 作业,是 Wine 托管拉取请求通道背后的完整内核标尺([Wine 通道决策](2026-07-27-wine-windows-gates-experiment.md))。依据[必需 CI 决策](2026-07-23-portable-required-pull-request-ci.md),拉取请求必需作业使用可移植的标准容量。更高核心数的托管运行器仍仅用于手动基准测试,因为正确性路径必须无需仓库外部的运行器配置即可运行。 @@ -31,6 +35,7 @@ master 分支的参考作业仅用于诊断,不参与拉取请求所要求的 - **将每个超时值设为相应延迟目标**:不予采纳,因为调度波动会中止原本正确的执行,并使诊断回归所需的证据无法产生。 - **仅信任并发执行的主门禁清单**:不予采纳,因为调度逻辑与校验逻辑共享实现假设;串行聚合流程是一项独立的完整性检查。 - **在每个拉取请求上运行串行参考作业**:不予采纳,因为这些作业会重复完整的跨平台聚合流程,并为每项改动增加 macOS 工作;必需作业已经执行阻塞性的 Linux 和 Windows 契约。 +- **在每个拉取请求上运行真实内核 Sandbox 矩阵**:不予采纳,因为它的四个状态不参与分支保护,而重复安装、Landlock 构建以及为保持平台一致而运行的 macOS 单元测试会消耗运行器容量,却不会改变合并裁决。master 上的运行保留平台与已安装 launcher 的信号。 - **使用一个操作系统矩阵**:不予采纳,因为三个具名作业无需另一套选择机制,就能让参考流程的构成清晰可见。 - **在大型运行器上运行串行参考流程**:不予采纳,因为当组织自有运行器池无法分配作业时,必需 CI 及其独立参考流程都必须仍可运行。 @@ -40,6 +45,8 @@ master 分支的参考作业仅用于诊断,不参与拉取请求所要求的 参考流程可能暴露某些平台上的故障,而优化后的阻塞门禁集合尚未声明支持这些平台,Windows 尤其如此。这类失败反映了当前的跨平台行为,不应成为削弱或静默跳过该聚合流程的理由。 +仅在真实宿主内核或打包后的 Landlock 安装中可见的沙箱回归,可能在 master 上的运行报告前已经合并。我们接受这个合并后检测窗口,以换取从每个拉取请求中移除四个非阻塞作业;默认分支仍保留完整信号。 + 明确的 `pty-local` 归属边界意味着 Windows 不会声称覆盖一个无法加载的后端,而 macOS 采用 fork 的单元测试工作进程会增加进程启动开销。这些代价换来的是:支持范围内的每项功能都有能够如实反映对应平台行为的判据,原生运行时异常终止不会抹掉其余单元测试结果,各项对时序敏感的观测逻辑也都会以调用方有机会修改状态前已建立的状态作为起点。 移除严格的时长超时后,系统会观测到延迟回归,而不是在发生回归时自动取消运行。因此,性能改动必须附带托管环境测量结果,已完成的日志则保留优化最慢通道所需的信息。 diff --git a/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.i18n.yaml new file mode 100644 index 0000000000..9031dc8eb0 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.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-28-remove-synthetic-log-only-turns.md +2026-07-28-remove-synthetic-log-only-turns.md: af4da00f4fe1d7aebff845cd55053bb5b807c979 +2026-07-28-remove-synthetic-log-only-turns.zh.md: 9d72781d6b7cf396a830790d108f4ff25adc816a diff --git a/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md b/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md new file mode 100644 index 0000000000..af4da00f4f --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md @@ -0,0 +1,43 @@ +# Agent Note: Remove synthetic turns for log-only events + +Status: implemented + +English | [中文](2026-07-28-remove-synthetic-log-only-turns.zh.md) + +## Problem + +The session store exposed `appendOutOfBand()` so a plugin could publish a late log-only event while no agent turn was running. The method wrapped that event in `turn/start` and `turn/end`, then flushed it. This preserved the old rule that every durable event had to live inside a turn, but it made one identifier mean both a model-loop execution and a persistence-only update. + +That rule was introduced when persistence recovery treated the last `turn/end` as the only committed boundary. The persistence scanners now preserve every valid contiguous event, and crash repair reacts only to an actually open turn. Idle context already uses the same capability by appending `user/message` between turns. Retaining synthetic turns for title updates therefore inflated turn counts, produced execution outcomes for work that never ran the model, and let a late metadata write consume the next turn number. + +The generic seam also duplicated domain policy. Its marker map said which plugin events were eligible, while the title capability already owned cancellation, liveness, and stale-result rules. Replacing it with another generic or title-specific append wrapper would preserve the same type indirection for two literal event types. + +## Decision + +`SessionStore.appendOutOfBand()`, `OutOfBandSessionEventMap`, and `OutOfBandSessionEventType` do not exist. A plugin that owns a log-only event appends it through `Session`; when the operation promises durability, it explicitly awaits `ctx.sessions.flush(session)`. No turn is opened solely to obtain that checkpoint. + +Core session invariants continue to enforce core-owned execution relations: turn and step numbering, enclosure of steering, assistant, tool, todo, and request-header events, and same-step tool call/result pairing. Core permits merge-extensible events between turns because only their declaring plugin knows whether they are execution-scoped or standalone. Plugin invariant companions remain responsible for their own event relations. + +The title service appends `session/title` directly after its existing service, revision, cancellation, and live-session checks. The bundled model helper appends its literal `session/title-llm-request` record before dispatch. Persistence observes both through the eager `session/event` path and drains them at ordinary checkpoints and lifecycle teardown; neither append forces a flush merely because it is between turns. A fallback, auxiliary request record, or accepted provider title may therefore appear after `turn/end` and before the next `turn/start`. + +A session fork may end at any stable event position outside an open turn, not only at `turn/end`. This preserves standalone title and context records in a default fork while still rejecting a prefix cut through active execution. + +The historical [universal turn-enclosure decision](../../archived/architecture/2026-06-15-turn-enclosure-invariant.md) remains useful only as the reason the synthetic mechanism was introduced. The [context-injection decision](../architecture/2026-07-24-separate-context-injection-from-turn-execution.md) established the current meaning: one turn represents one model-loop execution. + +## Alternatives considered + +**Keep synthetic zero-step turns.** This preserves a uniform-looking log and reuses `turn/end` as a flush point, but it reports executions that never happened, perturbs turn numbering, and makes every turn consumer filter persistence-only records. Durability already has the independent `session/flush` boundary. + +**Keep a generic core durable-append helper without synthetic turns.** A method that performs `append()` plus `flush()` is small, but its eligibility marker and concurrency promises would still centralize plugin policy in the session store. The event owners already have literal typed append sites, and a caller that truly needs a durability barrier can await the existing `session/flush` operation at that boundary. + +**Store titles as mutable session metadata.** This avoids between-turn events but creates a second mutation, replay, persistence, and fork protocol beside the append-only log. Titles remain replayable latest-wins events instead. + +**Require every plugin event to declare standalone eligibility to core.** This keeps a central allowlist but makes absence mean an execution relation that core cannot verify. Merge-extensible unions already assign semantic ownership to the declaring plugin; its invariant companion is the correct enforcement point. + +## Verification + +Core invariant tests accept an unknown plugin event between turns while continuing to reject built-in execution events there. Hook, compaction, plan-mode, Code Mode dispatch, and approval invariant companions replay existing logs and reject the same execution-scoped events before commit when no turn is open. Session-title service tests pin one direct fallback event under concurrent refresh, detached-session rejection, and newest-revision acceptance. JSONL and SQLite round trips preserve a title appended after `turn/end` through the persistence lifecycle drain, and fork tests retain a standalone log-only tail while rejecting boundaries inside an open turn. A keyless assembled ACP snapshot delays the model-backed title until after `turn/end` and pins one standalone provider title with no synthetic turn. Generated API and type-equivalence catalogs contain no removed symbol. + +## Consequences + +Turn counts and outcomes again describe model-loop executions only. Standalone events consume session seqs, start eager persistence like every other append, and require owners to request an explicit durability barrier only when their operation promises one. Generic plugin mistakes no longer fail under a core default enclosure rule, so each plugin that needs an execution relation must state and test that relation itself. The title capability keeps revision ordering and lifecycle persistence with less core state, no duplicate type seam, and no turn-number collision. diff --git a/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.zh.md b/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.zh.md new file mode 100644 index 0000000000..9d72781d6b --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.zh.md @@ -0,0 +1,43 @@ +# Agent Note: 移除纯日志事件的合成轮次 + +Status: implemented + +[English](2026-07-28-remove-synthetic-log-only-turns.md) | 中文 + +## 问题 + +会话存储曾暴露 `appendOutOfBand()`,让插件可以在没有 agent(智能体)轮次运行时发布延迟到达的纯日志事件。该方法会用 `turn/start` 和 `turn/end` 包住事件,再将其刷写。这保留了「每个持久事件都必须位于轮次内」的旧规则,却让同一个标识符既表示模型循环执行,又表示仅持久化更新。 + +引入该规则时,持久化恢复曾将最后一个 `turn/end` 视为唯一的已提交边界。如今,持久化扫描器会保留每个合法且连续的事件,崩溃修复也只处理确实处于开放状态的轮次。空闲上下文早已采用同一机制,在轮次之间追加 `user/message`。因此,为标题更新保留合成轮次会夸大轮次计数、为从未运行模型的工作产生执行结果,还会让延迟到达的元数据写入占用下一个轮次编号。 + +通用 seam 还重复了领域策略。它的标记映射说明哪些插件事件符合条件,而标题功能本就拥有取消、活跃性和陈旧结果处理规则。改用另一个通用或标题专属追加包装层,仍会为两个字面量事件类型保留同一层类型间接性。 + +## 决策 + +`SessionStore.appendOutOfBand()`、`OutOfBandSessionEventMap` 和 `OutOfBandSessionEventType` 均不再存在。拥有纯日志事件的插件通过 `Session` 追加该事件;当操作承诺持久性时,插件会显式等待 `ctx.sessions.flush(session)`。系统不会仅为获得该检查点而打开轮次。 + +核心会话不变量继续强制核心所属的执行关系:轮次与步骤编号、steering、助手、工具、待办和请求头事件的封闭,以及同一步骤内的工具调用/结果配对。核心允许可合并扩展事件位于轮次之间,因为只有声明它们的插件知道这些事件受执行作用域约束,还是可以独立存在。插件的不变量配套组件仍负责其自身的事件关系。 + +标题服务会在完成既有的服务状态、修订、取消和实时会话检查后,直接追加 `session/title`。随附模型辅助函数会在发起调用前追加其字面量 `session/title-llm-request` 记录。持久化通过尽快处理的 `session/event` 路径观察两者,并在常规检查点与生命周期 teardown 时排空;二者都不会仅因为位于轮次之间就强制 flush。因此,回退标题、辅助请求记录或已接受的提供方标题可以出现在 `turn/end` 之后、下一个 `turn/start` 之前。 + +会话 fork 可以结束于开放轮次之外的任意稳定事件位置,而不限于 `turn/end`。这样,默认 fork 会保留独立标题和上下文记录,同时仍拒绝在活跃执行过程中截断前缀。 + +历史上的[通用轮次封闭决策](../../archived/architecture/2026-06-15-turn-enclosure-invariant.md)如今只适合用于解释为何曾引入合成机制。[上下文注入决策](../architecture/2026-07-24-separate-context-injection-from-turn-execution.md)确立了当前语义:一个轮次表示一次模型循环执行。 + +## 曾考虑的替代方案 + +**保留合成的零步骤轮次。** 这可以让日志在形式上保持统一,并复用 `turn/end` 作为刷写点,但会报告从未发生的执行、扰动轮次编号,还会迫使每个轮次消费方过滤仅持久化记录。持久性已有独立的 `session/flush` 边界。 + +**保留不使用合成轮次的通用核心持久追加辅助函数。** 执行 `append()` 再执行 `flush()` 的方法本身很小,但其准入标记和并发承诺仍会将插件策略集中到会话存储中。事件所有方已经拥有直接、类型化的字面量追加点;真正需要持久性屏障的调用方可以在该边界等待既有的 `session/flush` 操作。 + +**将标题存储为可变会话元数据。** 这可以避免轮次间事件,却会在仅追加日志之外建立第二套变更、回放、持久化和 fork 协议。标题仍采用可回放、后写覆盖的事件。 + +**要求每个插件事件向核心声明可独立存在的资格。** 这可以保留中心准入列表,但会让缺少声明意味着一种核心无法验证的执行关系。可合并扩展联合类型已经将语义所有权赋予声明事件的插件;该插件的不变量配套组件才是正确的强制位置。 + +## 验证 + +核心不变量测试会接受轮次之间的未知插件事件,同时继续拒绝位于该处的内置执行事件。钩子、压缩(compaction)、plan-mode、Code Mode 分发和审批的不变量配套组件会回放既有日志,并在没有开放轮次时,于提交前拒绝相同的执行作用域事件。会话标题服务测试会在并发刷新、会话脱离拒绝和最新修订接受场景下,固定一个直接追加的回退事件。JSONL 和 SQLite 往返测试会通过持久化生命周期排空保留追加在 `turn/end` 之后的标题;fork 测试会保留独立纯日志尾部,同时拒绝位于开放轮次内的边界。一个无密钥、经完整组装的 ACP(Agent Client Protocol)快照会将模型生成的标题延迟到 `turn/end` 之后,并固定一个不含合成轮次的独立提供方标题。生成的 API 和类型等价性目录不含任何已移除符号。 + +## 后果 + +轮次计数和结果重新只描述模型循环执行。独立事件会占用会话 seq,像其他追加一样启动尽快持久化,并且仅当操作承诺持久性时,才要求事件所有方请求显式持久性屏障。通用插件错误不再因核心默认的封闭规则而失败,因此每个需要执行关系的插件都必须自行声明并测试该关系。标题功能保留修订排序和生命周期持久化,同时减少了核心状态,不再重复类型 seam,并消除了轮次编号冲突。 diff --git a/.agents/notes/rejected/simplification/2026-06-20-truncate-interrupted-turns.i18n.yaml b/.agents/notes/rejected/simplification/2026-06-20-truncate-interrupted-turns.i18n.yaml index 98845d7e03..704d2e8227 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-truncate-interrupted-turns.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-06-20-truncate-interrupted-turns.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 -2026-06-20-truncate-interrupted-turns.md: af18618ad4c41af125e37c51b9fd971dd8eae64e -2026-06-20-truncate-interrupted-turns.zh.md: a20d0169f7735aa7a9437c10c958580c00704171 +# pnpm run verify-translation-pairing --write .agents/notes/rejected/simplification/2026-06-20-truncate-interrupted-turns.md +2026-06-20-truncate-interrupted-turns.md: 3c7acf8d673568f851edd52635a28f73d9bf5f6f +2026-06-20-truncate-interrupted-turns.zh.md: 36a3d3f3cbd736c8f9a70fea453a314c0495d6f2 diff --git a/.agents/notes/rejected/simplification/2026-06-20-truncate-interrupted-turns.md b/.agents/notes/rejected/simplification/2026-06-20-truncate-interrupted-turns.md index af18618ad4..3c7acf8d67 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-truncate-interrupted-turns.md +++ b/.agents/notes/rejected/simplification/2026-06-20-truncate-interrupted-turns.md @@ -31,6 +31,6 @@ A crash can lose real work from the final turn: assistant text, tool calls, and ## Related -This is a direct simplification of [session persistence](../../implemented/architecture/2026-06-14-session-persistence.md) and [turn enclosure](../../implemented/architecture/2026-06-15-turn-enclosure-invariant.md). It also removes much of the motivation for durable step boundary events, making [drop durable step boundary events](2026-06-20-drop-durable-step-boundaries.md) smaller. +This is a direct simplification of [session persistence](../../implemented/architecture/2026-06-14-session-persistence.md) and the historical [universal turn-enclosure rule](../../archived/architecture/2026-06-15-turn-enclosure-invariant.md). It also removes much of the motivation for durable step boundary events, making [drop durable step boundary events](2026-06-20-drop-durable-step-boundaries.md) smaller. diff --git a/.agents/notes/rejected/simplification/2026-06-20-truncate-interrupted-turns.zh.md b/.agents/notes/rejected/simplification/2026-06-20-truncate-interrupted-turns.zh.md index a20d0169f7..36a3d3f3cb 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-truncate-interrupted-turns.zh.md +++ b/.agents/notes/rejected/simplification/2026-06-20-truncate-interrupted-turns.zh.md @@ -31,6 +31,6 @@ Status: rejected — 单个轮次可以包含大量真实工作,包括多个 ## 相关 -本提案是对[会话持久化](../../implemented/architecture/2026-06-14-session-persistence.md)与[轮次封闭不变式](../../implemented/architecture/2026-06-15-turn-enclosure-invariant.md)的直接简化。它还移除了持久化步骤边界事件的大部分动机,使[移除持久化步骤边界事件](2026-06-20-drop-durable-step-boundaries.md)的改动更小。 +本提案是对[会话持久化](../../implemented/architecture/2026-06-14-session-persistence.md)与历史上的[通用轮次封闭规则](../../archived/architecture/2026-06-15-turn-enclosure-invariant.md)的直接简化。它还移除了持久化步骤边界事件的大部分动机,使[移除持久化步骤边界事件](2026-06-20-drop-durable-step-boundaries.md)的改动更小。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 577bd92b42..8817fb325d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -240,44 +240,7 @@ jobs: exit "$sandbox_status" - name: Run compatibility, snapshot, and artifact gates - run: | - pnpm run check:ci:lint & - lint_pid=$! - pnpm run check:node-compat & - compat_pid=$! - DSH_EXAMPLE_MODE=lib pnpm run test:snapshot & - snapshot_pid=$! - pnpm run publint & - publint_pid=$! - pnpm run verify-node-next-types & - node_next_pid=$! - pnpm run verify-built-package-invariants & - built_invariants_pid=$! - DSH_EXAMPLE_MODE=lib pnpm exec vitest run --config vitest.e2e.config.ts \ - examples/headless-agent/tests/keyless-smoke.e2e.ts \ - examples/tui-agent/tests/tui-keyless-smoke.e2e.ts \ - packages/examples/cli-demo/tests/built-bin.e2e.ts \ - packages/examples/acp-demo/tests/built-bin.e2e.ts \ - packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts \ - packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts \ - packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts & - built_bin_pid=$! - - final_status=0 - capture_status() { - local child_status=0 - wait "$1" || child_status=$? - if (( final_status == 0 && child_status != 0 )); then - final_status=$child_status - fi - } - for child_pid in \ - "$lint_pid" "$compat_pid" "$snapshot_pid" \ - "$publint_pid" "$node_next_pid" "$built_invariants_pid" "$built_bin_pid" - do - capture_status "$child_pid" - done - exit "$final_status" + run: pnpm run check:ci:consumers node-compat: diff --git a/.github/workflows/sandbox.yml b/.github/workflows/sandbox.yml index 0d14914bfd..36f58cc75b 100644 --- a/.github/workflows/sandbox.yml +++ b/.github/workflows/sandbox.yml @@ -1,15 +1,16 @@ -# Sandbox CI: the keyless real-kernel confinement proofs. A separate workflow -# from ci.yml because the axis is different — these jobs fan out over -# OS×runner (kernel capabilities), not node versions. The Landlock launcher -# arrives from the registry with `pnpm install` (the npm package family +# Sandbox CI: the keyless real-kernel confinement proofs. This master-only +# reference stays outside the pull-request verdict; rationale lives in +# .agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md. +# A separate workflow from ci.yml because the axis is different — these jobs +# fan out over OS×runner (kernel capabilities), not node versions. The Landlock +# launcher arrives from the registry with `pnpm install` (the npm package family # `node-addon-landlock-run`, built and released from its own repository), so # these legs exercise the true consumer path — nothing is compiled here. name: Sandbox on: push: - branches: [main, master] - pull_request: + branches: [master] concurrency: group: ${{ github.workflow }}-${{ github.ref }} diff --git a/apps/web/tests/code-mode-fixture.snapshot.ts b/apps/web/tests/code-mode-fixture.snapshot.ts index f53777fff8..ce5a98b2eb 100644 --- a/apps/web/tests/code-mode-fixture.snapshot.ts +++ b/apps/web/tests/code-mode-fixture.snapshot.ts @@ -5,7 +5,7 @@ // the code-variant parent row titled by the model-authored description, its // three always-visible nested sub-rows (bash through the sample registration, // read through GenericToolCard, the failing read wearing the error state), -// the expanded program body, details-panel resolution of a sub-callId, and +// the expanded program body, inert bash / file-link sub-row gestures, and // the trajectory/waterfall tabs' sub-call cells and timing lanes. import { readFileSync } from 'node:fs' import { join } from 'node:path' @@ -152,7 +152,7 @@ it('renders the fixture run_code turn: code parent row, nested sub-rows, error s `) }) -it('expands the code row into the program body and resolves a sub-row through the details panel', async () => { +it('expands the code row into the program body; sub-row clicks do not open details', async () => { boot() await openFixtureSession() @@ -171,26 +171,28 @@ it('expands the code row into the program body and resolves a sub-row through th } }) - // Sub-row click → details panel resolves the sub-callId with FULL output. + // Tool rows no longer drive the details panel: bash is inert, file paths + // are host-open links (fixture openPath is a no-op success). const nest = document.querySelector('[data-subcalls]') if (nest === null) throw new Error('sub-call nest missing') const bashRow = nest.querySelector('[data-sample="bash-global"]') if (bashRow === null) throw new Error('bash sample sub-row missing') + const fileLink = nest.querySelector('button') + if (fileLink === null) throw new Error('file-path summary link missing on a read sub-row') + const frame = document.querySelector('[data-details-collapsed]') + if (frame === null) throw new Error('app frame missing') + expect(frame.getAttribute('data-details-collapsed')).toBe('true') fireEvent.click(bashRow) - const details = await screen.findByText('Input') - const panel = details.closest('[class*="root"]') - if (panel === null) throw new Error('details panel missing') + expect(frame.getAttribute('data-details-collapsed')).toBe('true') + fireEvent.click(fileLink) + expect(frame.getAttribute('data-details-collapsed')).toBe('true') expect({ - title: visibleText(within(panel as HTMLElement).getByText('bash')), - inputEchoesArgs: visibleText(panel).includes('ls notes'), - outputComplete: visibleText(panel).includes('demo.txt new-demo.txt') - || visibleText(panel).includes('demo.txt\nnew-demo.txt') - || (panel.textContent ?? '').includes('demo.txt\nnew-demo.txt'), + fileLink: visibleText(fileLink), + detailsCollapsed: frame.getAttribute('data-details-collapsed'), }).toMatchInlineSnapshot(` { - "inputEchoesArgs": true, - "outputComplete": true, - "title": "bash", + "detailsCollapsed": "true", + "fileLink": "notes/demo.txt", } `) }) diff --git a/apps/web/tests/code-mode-round.e2e.ts b/apps/web/tests/code-mode-round.e2e.ts index 32c51a2a2a..a7f493e448 100644 --- a/apps/web/tests/code-mode-round.e2e.ts +++ b/apps/web/tests/code-mode-round.e2e.ts @@ -118,19 +118,14 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => { expect(await nest.locator('[data-state="error"]').count()).toBeGreaterThanOrEqual(1) }, 60_000) - it.skipIf(MODE === 'record')('a sub-row click opens the details panel on the sub-call material', async () => { + it.skipIf(MODE === 'record')('a bash sub-row click leaves the details panel collapsed', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-code-mode-details')) const nest = page.locator('[data-subcalls]').first() + const frame = page.locator('[data-details-collapsed], [class*="frame"]').first() + expect(await frame.getAttribute('data-details-collapsed')).not.toBeNull() await nest.locator('[data-sample="bash-global"]').first().click() - // The details column opens (width > 0) and shows the sub-call's complete - // output — the full-content log contract, no truncation marker anywhere. - await page.waitForFunction(() => { - const frame = document.querySelector('[class*="frame"]') - if (frame === null) return false - return Number(getComputedStyle(frame).gridTemplateColumns.split(' ').pop()!.replace('px', '')) > 0 - }, undefined, { timeout: 10_000 }) - await expect.poll(() => page.getByText('CODE_ROUND_OK', { exact: false }).count(), { timeout: 5_000 }) - .toBeGreaterThanOrEqual(1) + // Tool rows no longer open details; the column stays width 0. + await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).not.toBeNull() }) it.skipIf(MODE === 'record')('matches the conversation aria golden with stable anchors', async () => { diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index bbae7363df..a27a61f80f 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -1,12 +1,11 @@ // Web e2e scenarios: navigation & panes — the view tabs (Trajectory / -// Waterfall), the details column, and sidebar search, all over ONE rich -// two-turn seeded fixture rendered purely from the log (the seeded-history -// pattern: zero model calls in replay, so every surface here is the client -// fold + host history RPC, not replay binding). The seed is recorded live -// under the standard discipline: turn 1 produces a bash call plus two -// parallel reads in one assistant message (tool-call density for the -// trajectory/waterfall lanes and a details-capable bash row), turn 2 a -// markdown-rich reply (a second turn so the waterfall has two lanes). +// Waterfall) and sidebar search, all over ONE rich two-turn seeded fixture +// rendered purely from the log (the seeded-history pattern: zero model calls +// in replay, so every surface here is the client fold + host history RPC, +// not replay binding). The seed is recorded live under the standard +// discipline: turn 1 produces a bash call plus two parallel reads in one +// assistant message (tool-call density for the trajectory/waterfall lanes), +// turn 2 a markdown-rich reply (a second turn so the waterfall has two lanes). import { mkdir, readFile, writeFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { join } from 'node:path' @@ -25,7 +24,6 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/navigation-panes', impor const SEED = join(SNAPSHOT_DIR, 'seed.jsonl') const TRAJECTORY_EXPECTED = join(SNAPSHOT_DIR, 'trajectory.expected.md') const WATERFALL_EXPECTED = join(SNAPSHOT_DIR, 'waterfall.expected.md') -const DETAILS_EXPECTED = join(SNAPSHOT_DIR, 'details-open.expected.md') const MODE = webSnapshotMode() const SEED_ID = 'navigation-panes-web-e2e' @@ -155,36 +153,27 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { await compareOrRefreshGolden(WATERFALL_EXPECTED, snapshot, MODE) }, 60_000) - it.skipIf(MODE === 'record')('opens the details column from the bash row and closes it', async () => { + it.skipIf(MODE === 'record')('bash and file-path rows leave the details column collapsed', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-details')) await page.getByRole('tab', { name: 'Chat' }).click() - // The bash toolview row routes its click to openDetails (read rows are - // expand-in-place instead — the seeded-history scenario owns that fold). const bashRow = page.locator('[data-sample="bash-global"]').first() await bashRow.waitFor({ timeout: 15_000 }) - // Open/closed is the frame's collapsed attribute: the column collapses to - // width 0 but its subtree deliberately never unmounts (hidden, not - // absent), so element presence/visibility cannot express the state. const frame = page.locator('[data-details-collapsed], [class*="frame"]').first() expect(await frame.getAttribute('data-details-collapsed')).not.toBeNull() await bashRow.click() - await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 10_000 }).toBeNull() - // The open panel shows the selected call's name, arguments, and durable - // result (NAVIGATION_OK appears in the chat row too, hence >= 2 total). - await expect.poll(() => page.getByText('NAVIGATION_OK', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2) - // Golden of the open panel: tool name header, Input args, Output result. - const snapshot = (await captureStableAria(page, '[class*="detailsCol"]', scaffold.workspaceCwd)) - .split(SEED_ID).join('{{seededId}}') - await compareOrRefreshGolden(DETAILS_EXPECTED, snapshot, MODE) - await page.getByRole('button', { name: '关闭详情' }).click() - await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 10_000 }).not.toBeNull() + await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).not.toBeNull() + // Read summaries are host-open file links; they also must not open details. + const fileLink = page.locator('[data-variant="read"] button').first() + await fileLink.waitFor({ timeout: 10_000 }) + await fileLink.click() + await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).not.toBeNull() }, 60_000) it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => { expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, [ - 'seed.jsonl', 'trajectory.expected.md', 'waterfall.expected.md', 'details-open.expected.md', + 'seed.jsonl', 'trajectory.expected.md', 'waterfall.expected.md', ]) }) }) diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 265bb20d07..0742612e29 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -103,21 +103,19 @@ describe('web e2e: seeded history renders through cold resume', () => { await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) }) - it.skipIf(MODE === 'record')('expands and collapses a tool row rebuilt from the cold log', async () => { + it.skipIf(MODE === 'record')('file-path tool rows rebuilt from the cold log stay details-inert', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-toolrow')) - // Interaction over cold-resumed history: read rows are expand-in-place - // rows (rowExpands routes the click to toggleExpand, not openDetails), so - // the gesture under test is the inline fold over log-rebuilt content. - // Runs after the golden capture; still zero model calls. - const row = page.locator('[data-variant] [data-clickable][role="button"]').first() - await row.waitFor({ timeout: 10_000 }) - expect(await row.getAttribute('aria-expanded')).toBe('false') - await row.click() - await expect.poll(() => row.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('true') - // The expanded body renders the recorded tool result (a.txt's contents). - await expect.poll(() => page.getByText('alpha', { exact: false }).count(), { timeout: 5_000 }).toBeGreaterThan(0) - await row.click() - await expect.poll(() => row.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('false') + // Interaction over cold-resumed history: read summaries are host-open + // file links (not expand-in-place / not details). Runs after the golden + // capture; still zero model calls. + const fileLink = page.locator('[data-variant="read"] button').first() + await fileLink.waitFor({ timeout: 10_000 }) + const frame = page.locator('[data-details-collapsed], [class*="frame"]').first() + expect(await frame.getAttribute('data-details-collapsed')).not.toBeNull() + await fileLink.click() + await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).not.toBeNull() + // Path label survives from the recorded args (a.txt). + await expect.poll(() => page.getByText('a.txt', { exact: false }).count(), { timeout: 5_000 }).toBeGreaterThan(0) }) it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => { diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts index 58db1beafd..47d6ea81f7 100644 --- a/apps/web/tests/smoke-real.e2e.ts +++ b/apps/web/tests/smoke-real.e2e.ts @@ -7,7 +7,7 @@ // // Selector convention: CSS Modules hash as [hash]_[local], so class-substring // selectors are unreliable — anchor on data-* attributes (data-variant / -// data-clickable / data-sample) or visible text. The one [class*=] use below +// data-sample) or visible text. The one [class*=] use below // (frame/handle) rides local names that survive hashing as suffixes; prefer // data-* for anything new. // @@ -448,7 +448,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke await screen(page, '07-back-to-chat') }) - it('5 bash differential rendering: tool row click opens the details column', async () => { + it('5 bash differential rendering: tool row click leaves the details column collapsed', async () => { onTestFailed(() => saveFailureShot(page, 'w5-tool-details')) const input = page.locator('textarea').first() await input.fill('请用 bash 工具运行命令 echo w5marker 然后告诉我结果') @@ -462,13 +462,9 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke await screen(page, '08-bash-round') expect(await detailsTrack(page)).toBe(0) await toolRow.click() - // Selection channel: click writes selection + layout.openDetails. - await page.waitForFunction(() => { - const frame = document.querySelector('[class*="frame"]') - if (frame === null) return false - return Number(getComputedStyle(frame).gridTemplateColumns.split(' ').pop()!.replace('px', '')) > 0 - }, undefined, { timeout: 10_000 }) - await screen(page, '09-details-open') + // Tool rows no longer drive layout.openDetails; the column stays closed. + expect(await detailsTrack(page)).toBe(0) + await screen(page, '09-details-closed') }, 150_000) it('6 sidebar drag widens the column and persists across reload', async () => { diff --git a/apps/web/tests/snapshots/navigation-panes/details-open.expected.md b/apps/web/tests/snapshots/navigation-panes/details-open.expected.md deleted file mode 100644 index d69a95eb2d..0000000000 --- a/apps/web/tests/snapshots/navigation-panes/details-open.expected.md +++ /dev/null @@ -1,5 +0,0 @@ -- text: bash -- button "关闭详情" -- text: Input -- code: "{ \"command\": \"echo NAVIGATION_OK\", \"description\": \"Print NAVIGATION_OK\" }" -- text: Output NAVIGATION_OK diff --git a/apps/web/tests/workspace-flow.snapshot.ts b/apps/web/tests/workspace-flow.snapshot.ts index e1fe183ef1..b33df1b157 100644 --- a/apps/web/tests/workspace-flow.snapshot.ts +++ b/apps/web/tests/workspace-flow.snapshot.ts @@ -165,7 +165,7 @@ it('locks the composer in the New Session view state until a Workspace is chosen sidebar: visibleText(tree), }).toMatchInlineSnapshot(` { - "chip": "New Workspace", + "chip": "Choose workspace", "composerDisabled": true, "headline": "Let's start building", "sendDisabled": true, @@ -232,7 +232,10 @@ it('New Session reuses the Workspace blank session and converts the single visib // New Session resolves through the recent Workspace and reuses its blank // session in place: no locked interlude, no second entity. - fireEvent.click(screen.getByRole('button', { name: 'New session' })) + const newSessionButton = screen.getAllByRole('button', { name: 'New session' }) + .find(button => visibleText(button) === 'New Session') + if (newSessionButton === undefined) throw new Error('New Session button missing') + fireEvent.click(newSessionButton) const composer = await findHeroComposer() await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() }, { timeout: 10_000 }) diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 6b10543048..e56a3f91bb 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: 6a9bfbbeb399bc1d4fb5fa4b5f2cb639547f5a93 -architecture.zh.md: 6f7c59cf8ec24cb5d8527e82aa04ca773535e54c +architecture.md: 054985ac5ea32a44b9daca3c1abfd58dcdc5d897 +architecture.zh.md: 84876faf2ae27069ba8bd026bcfbc56e32f65574 diff --git a/docs/architecture.md b/docs/architecture.md index 6a9bfbbeb3..054985ac5e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -145,7 +145,7 @@ The session log is authoritative. `deriveMessages()` projects model history; raw Durability is a plugin concern. Backends eagerly drain synchronous `session/event` notifications. `session/flush` barriers precede each request and top-level tool dispatch, then follow `turn/end` before another queued turn or idle observation. `SessionPersistence` stores `SessionEvent` directly and metadata in `SessionHeader`; JSONL defaults to checksummed Zstandard, while SQLite shares the contract ([decision](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md)). -`ctx.sessions.appendOutOfBand()` adds plugin-owned log-only events to an open turn or creates a balanced, flushed zero-step turn. `session/title` folds latest-wins with source seqs and provenance; its immediate fallback and sole optional async provider never delay response. Forks inherit titles ([decision](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md)). +Log-only events may sit between turns. Owners append through `Session`, flushing only for durability. `session/title` relies on eager persistence and lifecycle drains. Latest title wins with provenance; fallback and provider work never delays responses. Such records are fork boundaries, so forks inherit titles ([decision](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md)). ### Model Content diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 6f7c59cf8e..84876faf2a 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -145,7 +145,7 @@ idle inject: 持久性由插件负责。后端会尽快排空同步的 `session/event` 通知。`session/flush` 屏障位于每次请求与顶层工具分发之前,并在 `turn/end` 之后、处理另一个已排队轮次或观察到空闲状态之前执行。`SessionPersistence` 直接存储 `SessionEvent`,并将元数据存入 `SessionHeader`;JSONL 默认采用带校验和的 Zstandard,SQLite 遵循同一契约([决策](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md))。 -`ctx.sessions.appendOutOfBand()` 会把插件所属的纯日志事件加入开放轮次,或创建一个平衡且已刷写的零步骤轮次。`session/title` 按后写覆盖方式折叠,并携带源 seq 和来源信息;其即时回退标题和唯一可选异步提供方都不会延迟响应。fork 会继承标题([决策](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md))。 +纯日志事件可以位于轮次之间。事件所有方通过 `Session` 追加,仅为持久性而刷写。`session/title` 依赖尽快持久化与生命周期排空。最新标题按后写覆盖并携带来源信息;回退与提供方工作绝不会延迟响应。这类记录可作为 fork 边界,因此 fork 会继承标题([决策](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md))。 ### 模型内容 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index b89a49529c..dcb4fa69e7 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1149,7 +1149,7 @@ export interface Config { } ``` -Source: [`packages/session-title/session-title/src/index.ts:69`](../packages/session-title/session-title/src/index.ts) +Source: [`packages/session-title/session-title/src/index.ts:67`](../packages/session-title/session-title/src/index.ts) ## `@deepseek-ai/dsh-session-title-all-messages-llm` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 75464938b0..19d7d7765d 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1320,28 +1320,6 @@ announce(session: Session): void */ async flush(session: Session): Promise -/** - * Append one plugin-declared log-only event without borrowing the agent - * loop's lifecycle. An open turn receives the event directly and remains - * responsible for its ordinary checkpoint. A closed log receives one - * zero-step turn around the event, followed by an awaited flush. - * - * Once the synthetic `turn/start` commits, this method always attempts its - * matching `turn/end` and flush, including when the target append fails. - * Detachment requested by an event or flush listener is deferred until that - * sequence settles, so publication cannot switch from a live scoped session - * to an unobserved bare `Session` halfway through the update. - * - * @param session - exact live session that owns the target log. - * @param type - event type opted into {@link OutOfBandSessionEventMap} by its owner. - * @param data - typed JSON payload for the target event. - * @param trigger - plugin-owned turn trigger used only when the log is closed. - * @returns the accepted target event with its assigned sequence and timestamp. - * @throws when the session is detached, another out-of-band append is active, - * event acceptance fails, the synthetic turn cannot close, or flushing fails. - */ -async appendOutOfBand( session: Session, type: T, data: SessionEventMap[T], trigger: TurnTrigger, ): Promise> - /** * Look up a live session. * @param id - the session id to look up. @@ -1356,9 +1334,10 @@ get(id: SessionId): Session | undefined list(): Session[] /** - * Create a live child session from a turn-enclosed prefix of a live source. + * Create a live child session from a stable prefix of a live source. * `boundary` is an inclusive source event seq; omitted means the source's - * current last event. A non-empty selected slice must end at `turn/end`. + * current last event. The selected slice may end with a between-turn event + * but must not end inside an open turn. * * @param source - Live source session object or id. * @param boundary - Inclusive source event seq to fork through; omitted means @@ -1371,9 +1350,9 @@ list(): Session[] fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session ``` -Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [OutOfBandSessionEventType](../core-data-structures/session.md) · [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md) · [SessionEventMap](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) · [TurnTrigger](../core-data-structures/session.md) +Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [Session](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) -Source: [`packages/core/session/src/index.ts:614`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:613`](../../packages/core/session/src/index.ts) ## `ctx.sessionTitle` — `SessionTitleService` @@ -1391,7 +1370,7 @@ get(session: Session): SessionTitleSnapshot | undefined * Explicitly retry the registered provider, or materialize the built-in * fallback when no provider is registered. * @param session - exact live session to refresh. - * @param signal - optional caller cancellation; an in-progress fallback append may finish durably before rejection. + * @param signal - optional caller cancellation. * @returns latest accepted title, or `undefined` when no eligible text exists. */ async refresh(session: Session, signal?: AbortSignal): Promise @@ -1407,7 +1386,7 @@ register(provider: SessionTitleProvider): () => Promise Types: [Session](../core-data-structures/session.md) · [SessionTitleProvider](../core-data-structures/session-title.md) · [SessionTitleSnapshot](../core-data-structures/session-title.md) -Source: [`packages/session-title/session-title/src/index.ts:283`](../../packages/session-title/session-title/src/index.ts) +Source: [`packages/session-title/session-title/src/index.ts:232`](../../packages/session-title/session-title/src/index.ts) ## `ctx.skills` — `SkillService` diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index 63d3cda07b..f0d078c123 100644 --- a/docs/core-data-structures/core.i18n.yaml +++ b/docs/core-data-structures/core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/core.md -core.md: 357712bd197ac2e0661e6bc61a638aa8a4738356 -core.zh.md: dcb7210d37377da99ac2cd68b1ce18fa6e90e0b8 +core.md: 647c7f273183e0890ef191d98ab009ad129db572 +core.zh.md: 8b0f439f09a6e6609dbe69c3056aa74d553a0943 diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 357712bd19..647c7f2731 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -22,7 +22,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t | [scope.md](scope.md) | scoped registration identity, dispatch carriers, and the owned `Scope` context | | [goal.md](goal.md) | persisted goal identity, lifecycle snapshots, activation, change records, and round attribution | | [commands.md](commands.md) | the human-command seam: definitions, adapter discovery, direct invocation, results, and parsing views | -| [session.md](session.md) | the full `SessionEventMap` variant catalog, `TurnTrigger`/`TurnEndReason`, `deriveMessages()`, the turn-enclosure invariant | +| [session.md](session.md) | the full `SessionEventMap` variant catalog, `TurnTrigger`/`TurnEndReason`, `deriveMessages()`, execution enclosure, and standalone events | | [persistence.md](persistence.md) | the durability seam: `SessionPersistence`, JSONL + SQLite backends, `session/flush`, crash recovery, `SessionHeader` | | [session-query.md](session-query.md) | logical records, bounded exact-event reads, relationship traces, semantic filters/documents, and full-text result pages | | [session-title.md](session-title.md) | durable title snapshots, source provenance, and the asynchronous provider contract | @@ -401,7 +401,7 @@ type SessionEvent = { }[T] ``` -The twelve event variants (`turn/start`, `turn/end`, `step/start`, `step/end`, `user/message`, `assistant/chunk`, `assistant/message`, `tool/call`, `tool/result`, `steering/message`, `todo/write`, `request/header`), the `deriveMessages()` projection rules, the `TurnTrigger`/`TurnEndReason` reasons, and the turn-enclosure invariant are on **[session.md](session.md)**. How the log is made durable — the `SessionPersistence` seam, JSONL/SQLite backends, the `session/flush` checkpoint, crash recovery, and `SessionHeader` — is on **[persistence.md](persistence.md)**. +The twelve event variants (`turn/start`, `turn/end`, `step/start`, `step/end`, `user/message`, `assistant/chunk`, `assistant/message`, `tool/call`, `tool/result`, `steering/message`, `todo/write`, `request/header`), the `deriveMessages()` projection rules, the `TurnTrigger`/`TurnEndReason` reasons, and the execution-enclosure and standalone-event rules are on **[session.md](session.md)**. How the log is made durable — the `SessionPersistence` seam, JSONL/SQLite backends, the `session/flush` checkpoint, crash recovery, and `SessionHeader` — is on **[persistence.md](persistence.md)**. ## The agent handle diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index dcb7210d37..8b0f439f09 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -22,7 +22,7 @@ harness 是一个微内核:一个极小的核心加上众多插件。大多数 | [scope.md](scope.md) | 作用域注册标识、dispatch 载体,以及拥有的 `Scope` 上下文 | | [goal.md](goal.md) | 持久 goal 标识、生命周期快照、激活、变更记录与 Round 归属 | | [commands.md](commands.md) | 人类命令 seam:定义、适配器发现、直接调用、结果与解析视图 | -| [session.md](session.md) | 完整的 `SessionEventMap` 变体目录、`TurnTrigger`/`TurnEndReason`、`deriveMessages()`、轮次封闭不变式 | +| [session.md](session.md) | 完整的 `SessionEventMap` 变体目录、`TurnTrigger`/`TurnEndReason`、`deriveMessages()`、执行封闭与独立事件 | | [persistence.md](persistence.md) | 持久性 seam:`SessionPersistence`、JSONL + SQLite 后端、`session/flush`、崩溃恢复、`SessionHeader` | | [session-query.md](session-query.md) | 逻辑记录、有界精确事件读取、关系追踪、语义筛选器/文档与全文检索结果页 | | [session-title.md](session-title.md) | 持久标题快照、来源 provenance 与异步提供方契约 | @@ -407,7 +407,7 @@ type SessionEvent = { }[T] ``` -十二种事件变体(`turn/start`、`turn/end`、`step/start`、`step/end`、`user/message`、`assistant/chunk`、`assistant/message`、`tool/call`、`tool/result`、`steering/message`、`todo/write`、`request/header`)、`deriveMessages()` 投影规则、`TurnTrigger`/`TurnEndReason` 原因以及轮次封闭不变量都在 **[session.md](session.md)** 中。日志如何持久化——`SessionPersistence` seam、JSONL/SQLite 后端、`session/flush` 检查点、崩溃恢复与 `SessionHeader`——则在 **[persistence.md](persistence.md)** 中。 +十二种事件变体(`turn/start`、`turn/end`、`step/start`、`step/end`、`user/message`、`assistant/chunk`、`assistant/message`、`tool/call`、`tool/result`、`steering/message`、`todo/write`、`request/header`)、`deriveMessages()` 投影规则、`TurnTrigger`/`TurnEndReason` 原因以及执行封闭和独立事件规则都在 **[session.md](session.md)** 中。日志如何持久化——`SessionPersistence` seam、JSONL/SQLite 后端、`session/flush` 检查点、崩溃恢复与 `SessionHeader`——则在 **[persistence.md](persistence.md)** 中。 diff --git a/docs/core-data-structures/persistence.i18n.yaml b/docs/core-data-structures/persistence.i18n.yaml index ebb8bfb6f3..ea83035f92 100644 --- a/docs/core-data-structures/persistence.i18n.yaml +++ b/docs/core-data-structures/persistence.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 -persistence.md: 4ec967873e946c8f185f8a8f497f2af4a363474e -persistence.zh.md: 3030ff2fe949cb02385331800d826df227e3d6cd +# pnpm run verify-translation-pairing --write docs/core-data-structures/persistence.md +persistence.md: 5a660e17d6f498213564ca7d68dc4d7a615ba1de +persistence.zh.md: b5477cfc8242f9db47c2c6e40bd63f1b3683ace9 diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md index 4ec967873e..5a660e17d6 100644 --- a/docs/core-data-structures/persistence.md +++ b/docs/core-data-structures/persistence.md @@ -12,7 +12,7 @@ The seam is a textbook [capability seam](../../.agents/notes/implemented/archite ## Crash recovery preserves an interrupted turn -A backend that reloads a log crashed mid-turn finds an open `turn/start` with no `turn/end`. It does **not** truncate — a single turn can be huge in a long-horizon task (many steps, large tool output), and those events were durably appended before the crash. Instead it closes the orphaned turn with a synthetic `turn/end { reason: { kind: 'interrupted' } }`, keeping the log balanced and the turn-enclosure invariant intact. `interrupted` is the one `TurnEndReason` no loop emits (see [session.md](session.md#why-a-turn-ended-turnendreasonmap)). +A backend that reloads a log crashed mid-turn finds an open `turn/start` with no `turn/end`. It does **not** truncate — a single turn can be huge in a long-horizon task (many steps, large tool output), and those events were durably appended before the crash. Instead it closes the orphaned turn with a synthetic `turn/end { reason: { kind: 'interrupted' } }`, keeping the interrupted execution balanced without changing any standalone events before or after it. `interrupted` is the one `TurnEndReason` no loop emits (see [session.md](session.md#why-a-turn-ended-turnendreasonmap)). Repair applies only to cold sessions. For a live id, `SessionPersistence.load(id)` snapshots the in-memory log, waits until that snapshot is durable, and returns it with the stored header only when balanced; an open live turn rejects rather than receiving synthetic interruption boundaries. A coordinator-backed cold load reserves the id across backend reads and repair writes, so concurrent publication of a same-id live session rejects and rolls back. HMR also adopts a live prefix without closing its active turn. diff --git a/docs/core-data-structures/persistence.zh.md b/docs/core-data-structures/persistence.zh.md index 3030ff2fe9..b5477cfc82 100644 --- a/docs/core-data-structures/persistence.zh.md +++ b/docs/core-data-structures/persistence.zh.md @@ -12,7 +12,7 @@ ## 崩溃恢复保留被中断的轮次 -后端重新加载一个在轮次中途崩溃的日志时,会发现一个已打开的 `turn/start` 却没有 `turn/end`。它**不会**截断日志:在长周期任务中,单个轮次可能非常庞大(许多步骤、大量工具输出),而这些事件在崩溃前已被持久追加。后端改为用一个合成的 `turn/end { reason: { kind: 'interrupted' } }` 关闭这个遗留轮次,保持日志平衡与轮次闭合不变式。`interrupted` 是唯一一个不由循环发出的 `TurnEndReason`(见 [session.md](session.md#why-a-turn-ended-turnendreasonmap))。 +后端重新加载一个在轮次中途崩溃的日志时,会发现一个已打开的 `turn/start` 却没有 `turn/end`。它**不会**截断日志:在长周期任务中,单个轮次可能非常庞大(许多步骤、大量工具输出),而这些事件在崩溃前已被持久追加。后端改为用一个合成的 `turn/end { reason: { kind: 'interrupted' } }` 关闭这个遗留轮次,在不改变其前后任何独立事件的情况下配平被中断的执行。`interrupted` 是唯一一个不由循环发出的 `TurnEndReason`(见 [session.md](session.md#why-a-turn-ended-turnendreasonmap))。 修复仅适用于冷会话。对于活跃 id,`SessionPersistence.load(id)` 会对内存日志拍摄快照,等待该快照完成持久化,并且只在日志平衡时连同已存储的 header 返回;若活跃轮次仍未闭合,则拒绝操作,而不是添加合成的中断边界。由协调器管理的冷加载会在后端读取和修复写入期间占用该 id,因此并发发布同 id 的活跃会话会被拒绝并回滚。HMR 也会接管活跃前缀,而不会关闭其中正在进行的轮次。 diff --git a/docs/core-data-structures/session-title.i18n.yaml b/docs/core-data-structures/session-title.i18n.yaml index f31d9016ac..64b3d8f342 100644 --- a/docs/core-data-structures/session-title.i18n.yaml +++ b/docs/core-data-structures/session-title.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 -session-title.md: 6575bda5fdecf2be15ed7c3288efb0efa759ac8a -session-title.zh.md: 66ec567ac6cef32acef9c50ae3e67155097a76b6 +# pnpm run verify-translation-pairing --write docs/core-data-structures/session-title.md +session-title.md: 33efc911c0ca1ae94dc4ded74676e5c32a73bdd5 +session-title.zh.md: a4b95a726d2bc89a13d14f1daa2f825cd5aa91b1 diff --git a/docs/core-data-structures/session-title.md b/docs/core-data-structures/session-title.md index 6575bda5fd..33efc911c0 100644 --- a/docs/core-data-structures/session-title.md +++ b/docs/core-data-structures/session-title.md @@ -114,7 +114,7 @@ interface SessionTitleProviderRequest { ``` ```ts type-equiv -/** Provider output before service-owned normalization and durable acceptance. */ +/** Provider output before service-owned normalization and log acceptance. */ interface SessionTitleProviderResult { /** Proposed title text. */ readonly title: string diff --git a/docs/core-data-structures/session-title.zh.md b/docs/core-data-structures/session-title.zh.md index 66ec567ac6..a4b95a726d 100644 --- a/docs/core-data-structures/session-title.zh.md +++ b/docs/core-data-structures/session-title.zh.md @@ -114,7 +114,7 @@ interface SessionTitleProviderRequest { ``` ```ts type-equiv -/** Provider output before service-owned normalization and durable acceptance. */ +/** Provider output before service-owned normalization and log acceptance. */ interface SessionTitleProviderResult { /** Proposed title text. */ readonly title: string diff --git a/docs/core-data-structures/session.i18n.yaml b/docs/core-data-structures/session.i18n.yaml index a795b0f23f..0f98bd5054 100644 --- a/docs/core-data-structures/session.i18n.yaml +++ b/docs/core-data-structures/session.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/session.md -session.md: 002a074aad0f26ddc86bba4d73ff57c47e2242db -session.zh.md: 94c8c8455f8ae05a3d801a262f5ca05207689d52 +session.md: b6df8893e2baecac4a86ccbc7a9c0f033dc139db +session.zh.md: 48a384e1ba225c7ba7503877388f6da085cae00b diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 002a074aad..b6df8893e2 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -107,20 +107,6 @@ interface SessionEventMap { `UserMessageData` is the durable `content` and `source` base shared by ordinary prompts, injected context, and steering. Live inbox events extend the same shape with an `AgentMessageId`; the loop adds only driver-owned routing state while an item remains pending. -### `OutOfBandSessionEventMap` — narrow late-append opt-in - -`SessionEventMap` membership alone does not authorize an event outside the agent loop's ordinary lifecycle. An event owner declaration-merges the same key into this empty marker map before `ctx.sessions.appendOutOfBand()` accepts it; the derived type additionally excludes every surface event. An accepted update joins an open turn or receives a balanced, flushed zero-step turn. - -```ts type-equiv -/** - * Marker map for plugin-owned log-only events accepted by - * `SessionStore.appendOutOfBand()`. A plugin extends this map with the same key - * it adds to {@link SessionEventMap}; surface and lifecycle events stay - * ineligible unless their owner explicitly opts them into this narrow seam. - */ -interface OutOfBandSessionEventMap {} -``` - ### `TodoItem` — one todo-list entry The unit of the `todo/write` event's whole-list snapshot. Deliberately minimal — a `content` line and a three-state `status` (no id, priority, or `activeForm`): the list is replaced wholesale on every write, so entries need no stable identity. See the [todo_write Agent Note](../../.agents/notes/implemented/feature/2026-06-29-todo-write-tool.md). @@ -465,9 +451,9 @@ Everything else (`turn/*`, `step/*`, plugin-owned `llm/retry`) is structural and `ctx.sessions.create(id, { seed, meta })` is the low-level replay/fork primitive. For ordinary live-session forks, `SessionStore` exposes one policy API: -- `fork(source, boundary?, childSessionId?)` accepts a live `Session` object or live `SessionId`, selects source events through the inclusive `boundary` seq (default: current last event), requires the boundary event to be `turn/end`, then creates a live child session with deep-cloned seed events plus child metadata (`parentSession`, `seedLength`, and inherited `cwd`). +- `fork(source, boundary?, childSessionId?)` accepts a live `Session` object or live `SessionId`, selects source events through the inclusive `boundary` seq (default: current last event), requires the selected prefix to end outside an open turn, then creates a live child session with deep-cloned seed events plus child metadata (`parentSession`, `seedLength`, and inherited `cwd`). -An explicit `boundary` lets callers fork from a previous completed turn even if the source has newer events or an open current turn. The API rejects non-`turn/end` boundaries instead of clipping silently. Broader turn-enclosure sanity stays in the existing `dsh-invariants` plugin and persistence repair path rather than being duplicated in `fork()`. `dsh-subagent-fork` keeps its completed-prefix clipping because tool-time delegation usually starts while the parent turn is open; ordinary session branching should make the requested boundary explicit. +An explicit `boundary` lets callers fork from any stable between-turn position, including a previous `turn/end` or a later standalone log-only event, even if the source has newer events or an open current turn. The API rejects a prefix that ends inside an open turn instead of clipping silently. Broader execution-relation sanity stays in the existing `dsh-invariants` plugin and persistence repair path rather than being duplicated in `fork()`. `dsh-subagent-fork` keeps its completed-prefix clipping because tool-time delegation usually starts while the parent turn is open; ordinary session branching should make the requested boundary explicit. ## What started a turn: `TurnTriggerMap` @@ -526,18 +512,20 @@ interface TurnEndReasonMap { `max-tokens` mirrors the model-call `FinishReason` of the same name: any `max-tokens` step in a turn makes the whole turn end `max-tokens` rather than `completed` (the cut-short fact wins over a later continuation), so a consumer can tell a clean stop from a truncated one — but only over `completed`: the `disposed`/`aborted`/`error` outcomes take precedence. `interrupted` is the one reason no loop emits — it is synthesized by crash recovery (see [persistence.md](persistence.md)). Both maps are merge-extensible. -## The turn-enclosure invariant +## Execution enclosure and standalone events -Every session event lives **inside** a turn (between a `turn/start` and its `turn/end`). The loop appends queued `user/message` events *after* `turn/start`, an idle `agent.inject()` wraps its `user/message` in a one-shot `injection` turn, and `appendOutOfBand()` similarly wraps an eligible log-only event when no turn is open. This makes the turn the single durability/replay boundary: a backend can treat anything after the last `turn/end` as an interrupted-crash tail without risking the loss of legitimately-recorded between-turn context. The optional `dsh-session/invariant` companion enforces it in dev through `ctx.invariants` (a message event outside an open turn throws). See [the turn-enclosure invariant Agent Note](../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md). +A turn encloses one model-loop execution, not the whole session log. Idle injected `user/message` events and plugin-owned log-only events may appear between `turn/end` and the next `turn/start`; they consume event seqs without incrementing turn numbers. Persistence eagerly records every contiguous accepted event, while crash repair closes only a genuinely open trailing turn. A producer that needs a durability barrier explicitly awaits `ctx.sessions.flush(session)`. + +The optional `dsh-session/invariant` companion enforces the relations owned by core: turn and step numbering, execution-event enclosure, and same-step tool call/result pairing. Merge-extensible event relations belong to the plugin that declares them, so core does not reject an unknown event merely because no turn is open. See [the standalone-event decision](../../.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md). ## Plugin-contributed log-only events -A plugin may declaration-merge extra `SessionEventMap` types. These are **log-only**: NOT `SurfaceEventType`s (they carry no `surfaceOp` and contribute nothing to derived history), but, like every event, they must sit inside an open turn. The full per-event enumeration — core and plugin-contributed alike, with payloads and provenance — is the generated [persistence log event catalog](../persistence-catalog.md); the compaction seam's `compact/*` semantics are discussed on [compaction.md](compaction.md). +A plugin may declaration-merge extra `SessionEventMap` types. These are **log-only**: NOT `SurfaceEventType`s (they carry no `surfaceOp` and contribute nothing to derived history). Their owner decides whether they belong to an open execution turn or may stand between turns, and enforces any relation in its own invariant companion. The full per-event enumeration — core and plugin-contributed alike, with payloads and provenance — is the generated [persistence log event catalog](../persistence-catalog.md); the compaction seam's `compact/*` semantics are discussed on [compaction.md](compaction.md). The hook bridges' `hook/invoked` / `hook/result` provenance pairs (from `@deepseek-ai/dsh-hook-protocol`) correlate by `handlerId`. The mid-turn hook points (`PreToolUse`/`PostToolUse`/`Stop`) fire inside the loop's open turn, so their `hook/*` records are turn-enclosed by construction. `SessionStart` and the pre-turn `UserPromptSubmit` admission seam get no `hook/*` record because neither has an open turn to enclose one; allowed context is instead evidenced by its sourced `user/message` (see [the hook-bridges Agent Note](../../.agents/notes/implemented/feature/2026-06-30-hook-bridges.md)). ## Durability contract -What a persistence backend relies on: the durable log persists every event losslessly, **including** `assistant/chunk` — `seq` must stay contiguous, so chunks cannot be filtered out of the canonical log. A backend may choose its own storage encoding for an event batch as long as `load` returns the exact appended events (the JSONL backend's default packed chunk rows are such an encoding — see [persistence.md](persistence.md)). All `event.data` must be JSON-serializable; `Session.append` enforces this at the source (throwing on non-serializable data), so a bad event never enters the log and `session.events` always equals what a backend can persist. Adding an event type that carries non-serializable data, or that breaks the turn/step nesting checked by the session invariant companion, is a breaking change to the on-disk format. +What a persistence backend relies on: the durable log persists every event losslessly, **including** `assistant/chunk` — `seq` must stay contiguous, so chunks cannot be filtered out of the canonical log. A backend may choose its own storage encoding for an event batch as long as `load` returns the exact appended events (the JSONL backend's default packed chunk rows are such an encoding — see [persistence.md](persistence.md)). All `event.data` must be JSON-serializable; `Session.append` enforces this at the source (throwing on non-serializable data), so a bad event never enters the log and `session.events` always equals what a backend can persist. Adding an event type that carries non-serializable data, corrupts core execution nesting, or violates its owner's declared relation is a breaking change to the on-disk format. The backends that consume this contract are on [persistence.md](persistence.md). diff --git a/docs/core-data-structures/session.zh.md b/docs/core-data-structures/session.zh.md index 94c8c8455f..48a384e1ba 100644 --- a/docs/core-data-structures/session.zh.md +++ b/docs/core-data-structures/session.zh.md @@ -107,20 +107,6 @@ interface SessionEventMap { `UserMessageData` 是普通提示词、注入上下文与 steering(中途引导)共享的持久 `content` + `source` 基础形状。实时收件箱事件在同一形状上扩展一个 `AgentMessageId`;条目待处理期间,loop 只额外附加驱动器自有的路由状态。 -### `OutOfBandSessionEventMap`:受限的带外追加显式准入 - -仅属于 `SessionEventMap` 并不表示事件可以脱离 agent loop(智能体循环)的常规生命周期追加。事件所有方必须通过声明合并将同一键加入这个空标记映射,`ctx.sessions.appendOutOfBand()` 才会接受该事件;派生类型还会排除所有 surface 事件。被接受的更新会并入已打开的轮次;如果没有打开的轮次,系统则为它创建一个边界配平且已刷新完成的零步骤轮次。 - -```ts type-equiv -/** - * Marker map for plugin-owned log-only events accepted by - * `SessionStore.appendOutOfBand()`. A plugin extends this map with the same key - * it adds to {@link SessionEventMap}; surface and lifecycle events stay - * ineligible unless their owner explicitly opts them into this narrow seam. - */ -interface OutOfBandSessionEventMap {} -``` - ### `TodoItem`:一条待办项 这是 `todo/write` 事件全量列表快照中的单元。它有意保持精简:一行 `content` 加一个三态 `status`(没有 id、优先级或 `activeForm`);列表在每次写入时整体替换,因此条目无需稳定标识。见 [todo_write Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-06-29-todo-write-tool.md)。 @@ -467,9 +453,9 @@ declare class Session { `ctx.sessions.create(id, { seed, meta })` 是底层的回放/fork 原语。对于普通的活跃会话 fork,`SessionStore` 暴露一个策略 API: -- `fork(source, boundary?, childSessionId?)` 接受一个活跃的 `Session` 对象或活跃的 `SessionId`,选取到 `boundary` seq(含)为止的源事件(默认为当前最后一个事件),要求 boundary 事件必须是 `turn/end`,然后创建一个活跃的子会话,包含深克隆的种子事件和子会话元数据(`parentSession`、`seedLength` 及继承的 `cwd`)。 +- `fork(source, boundary?, childSessionId?)` 接受一个活跃的 `Session` 对象或活跃的 `SessionId`,选取到 `boundary` seq(含)为止的源事件(默认为当前最后一个事件),要求所选前缀结束时没有开放轮次,然后创建一个活跃的子会话,包含深克隆的种子事件和子会话元数据(`parentSession`、`seedLength` 及继承的 `cwd`)。 -显式 `boundary` 允许调用者从之前完成的轮次 fork,即使源会话有更新的事件或正在进行的轮次。API 拒绝非 `turn/end` 的 boundary,而不是静默截断。更广泛的轮次封闭性检查留在既有的 `dsh-invariants` 插件和持久化修复路径中,不在 `fork()` 中重复。`dsh-subagent-fork` 保留其已完成前缀截断逻辑,因为工具时委托通常在父轮次仍然打开时启动;普通的会话分支应显式指定请求的 boundary。 +显式 `boundary` 允许调用者从任意稳定的轮次间位置 fork,包括之前的 `turn/end` 或更晚的独立纯日志事件,即使源会话有更新的事件或正在进行的轮次。API 拒绝结束于开放轮次内的前缀,而不是静默截断。更广泛的执行关系健全性检查留在既有的 `dsh-invariants` 插件和持久化修复路径中,不在 `fork()` 中重复。`dsh-subagent-fork` 保留其已完成前缀截断逻辑,因为工具时委托通常在父轮次仍然打开时启动;普通的会话分支应显式指定请求的 boundary。 ## 轮次的触发原因:`TurnTriggerMap` @@ -530,18 +516,20 @@ interface TurnEndReasonMap { `max-tokens` 与模型调用中同名的 `FinishReason` 对应:只要轮次内有任何步骤以 `max-tokens` 结束,整个轮次就以 `max-tokens` 而不是 `completed` 结束(即使之后继续执行,截断事实仍优先),让消费方能够区分正常停止和截断停止;但它只优先于 `completed`,`disposed`/`aborted`/`error` 结果的优先级更高。`interrupted` 是唯一不会由任何 loop 发出的原因:它由崩溃恢复合成(见 [persistence.md](persistence.md))。两个 map 均可通过合并扩展。 -## 轮次封闭不变式 +## 执行封闭与独立事件 -每个会话事件都位于一个轮次**之内**(在 `turn/start` 和对应的 `turn/end` 之间)。loop 在 `turn/start` *之后*追加已排队的 `user/message` 事件;空闲时的 `agent.inject()` 会用一次性的 `injection` 轮次包住其 `user/message`;没有打开的轮次时,`appendOutOfBand()` 同样会用一个轮次包住符合条件的仅日志事件。这使轮次成为唯一的持久性/回放边界:后端可以将最后一个 `turn/end` 之后的任何内容视为崩溃中断尾部,而不会丢失合法记录在轮次之间的上下文。可选的 `dsh-session/invariant` 配套插件通过 `ctx.invariants` 在开发环境中强制此不变式(消息事件若位于打开的轮次之外便会抛出)。见[轮次封闭不变式 Agent Note](../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)。 +一个轮次包围一次模型循环执行,而不是整个会话日志。空闲注入的 `user/message` 事件和插件所属的纯日志事件可以出现在 `turn/end` 与下一个 `turn/start` 之间;它们占用事件 seq,但不递增轮次编号。持久化会尽快记录每个连续且已接受的事件,而崩溃修复只关闭确实仍处于开放状态的尾部轮次。需要持久性屏障的生产方会显式等待 `ctx.sessions.flush(session)`。 + +可选的 `dsh-session/invariant` 配套插件会强制核心拥有的关系:轮次与步骤编号、执行事件封闭,以及同一步骤内的工具调用/结果配对。可合并扩展事件的关系由声明它的插件拥有,因此核心不会仅因没有开放轮次就拒绝未知事件。见[独立事件决策](../../.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md)。 ## 插件贡献的仅日志事件 -插件可以通过 declaration merging 添加额外的 `SessionEventMap` 类型。这些是**仅日志**事件:不是 `SurfaceEventType`(不携带 `surfaceOp`,不参与派生历史),但与所有事件一样,必须位于一个打开的轮次内。完整的逐事件枚举(核心与插件贡献的,含 payload 与溯源信息)见生成的[持久化日志事件目录](../persistence-catalog.md);压缩 seam 的 `compact/*` 语义在 [compaction.md](compaction.md) 中讨论。 +插件可以通过 declaration merging 添加额外的 `SessionEventMap` 类型。这些是**仅日志**事件:不是 `SurfaceEventType`(不携带 `surfaceOp`,不参与派生历史)。事件所有方决定它们属于一个开放的执行轮次,还是可以独立位于轮次之间,并在自己的不变量配套插件中强制所需关系。完整的逐事件枚举(核心与插件贡献的,含 payload 与溯源信息)见生成的[持久化日志事件目录](../persistence-catalog.md);压缩 seam 的 `compact/*` 语义在 [compaction.md](compaction.md) 中讨论。 钩子桥接层的 `hook/invoked` / `hook/result` 溯源对(来自 `@deepseek-ai/dsh-hook-protocol`)通过 `handlerId` 关联。轮次中间的钩子点(`PreToolUse`/`PostToolUse`/`Stop`)在 loop 已打开的轮次内触发,因此其 `hook/*` 记录天然位于轮次之内。`SessionStart` 与轮次开始前的 `UserPromptSubmit` 准入 seam 都不生成 `hook/*` 记录,因为两者都没有已打开的轮次可容纳该记录;被放行的上下文改由其带来源的 `user/message` 作为持久证据(见[钩子桥接 Agent Note](../../.agents/notes/implemented/feature/2026-06-30-hook-bridges.md))。 ## 持久性契约 -持久化后端依赖的契约如下:持久日志无损保存每个事件,**包括** `assistant/chunk`;`seq` 必须连续,因此不能从规范日志中过滤分片。后端可以为事件批次选择自己的存储编码,只要 `load` 返回与追加时完全一致的事件即可(JSONL 后端默认启用的打包分片行就是此类编码;见 [persistence.md](persistence.md))。所有 `event.data` 都必须可序列化为 JSON;`Session.append` 会从源头强制这一要求(遇到不可序列化数据时抛出),因此错误事件绝不会进入日志,`session.events` 始终与后端可持久化的内容一致。新增携带不可序列化数据的事件类型,或破坏会话不变式配套插件所检查的轮次/步骤嵌套,会构成磁盘格式的破坏性变更。 +持久化后端依赖的契约如下:持久日志无损保存每个事件,**包括** `assistant/chunk`;`seq` 必须连续,因此不能从规范日志中过滤分片。后端可以为事件批次选择自己的存储编码,只要 `load` 返回与追加时完全一致的事件即可(JSONL 后端默认启用的打包分片行就是此类编码;见 [persistence.md](persistence.md))。所有 `event.data` 都必须可序列化为 JSON;`Session.append` 会从源头强制这一要求(遇到不可序列化数据时抛出),因此错误事件绝不会进入日志,`session.events` 始终与后端可持久化的内容一致。新增携带不可序列化数据的事件类型、破坏核心执行嵌套,或违反事件所有方声明的关系,都会构成磁盘格式的破坏性变更。 消费此契约的后端见 [persistence.md](persistence.md)。 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 3a72d5c0f9..e9e5bb6e6a 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -31,9 +31,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/types.ts:169`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:56`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:70`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`user-approval`](../packages/ui/user-approval) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:70`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:80`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:92`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:92`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:102`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) | | `slash/input-begin-command` | `bail` | [`packages/client/ui-slash/src/types.ts:230`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | | `slash/input-consume-token` | `bail` | [`packages/client/ui-slash/src/types.ts:244`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index f4d7357572..8b51586912 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -78,7 +78,7 @@ export type SessionEvent = { }[T] ``` -Sources: [`packages/core/session/src/types.ts:269`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:282`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:311`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:343`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:261`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:268`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:297`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:329`](../packages/core/session/src/types.ts) ## Events @@ -373,7 +373,7 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:34`](../packages/s Types: [SessionTitleEventData](core-data-structures/session-title.md) -Source: [`packages/session-title/session-title/src/index.ts:95`](../packages/session-title/session-title/src/index.ts) +Source: [`packages/session-title/session-title/src/index.ts:88`](../packages/session-title/session-title/src/index.ts) #### `session/title-llm-request` — log-only @@ -384,7 +384,7 @@ Source: [`packages/session-title/session-title/src/index.ts:95`](../packages/ses Types: [SessionTitleLlmRequestEventData](core-data-structures/session-title.md) -Source: [`packages/session-title/session-title-llm/src/index.ts:44`](../packages/session-title/session-title-llm/src/index.ts) +Source: [`packages/session-title/session-title-llm/src/index.ts:43`](../packages/session-title/session-title-llm/src/index.ts) ### `steering/*` @@ -462,7 +462,7 @@ Source: [`packages/core/session/src/types.ts:228`](../packages/core/session/src/ * Log-only: `deriveMessages()` ignores it, so sub-calls never re-enter * model context; persistence and UIs get every call. Appended inside the * parent `run_code`'s execution (the bridge drains in-flight dispatches - * before returning), so the turn-enclosure invariant holds by + * before returning), so its execution-enclosure relation holds by * construction. */ 'tool/code-dispatch': { parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown; isError: boolean; content: ContentBlock[] } diff --git a/examples/acp-agent/session-title.cordis.snapshot.yml b/examples/acp-agent/session-title.cordis.snapshot.yml new file mode 100644 index 0000000000..2226fdf8a7 --- /dev/null +++ b/examples/acp-agent/session-title.cordis.snapshot.yml @@ -0,0 +1,53 @@ +# Keyless session-title composition. Main-agent chunks derive from session.jsonl; +# the auxiliary route consumes replay.override.json with pacing so its accepted +# title commits only after the main turn has closed. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + provider: deepseek + model: deepseek-v4-flash + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: none + workspaceContext: + maxBytes: 65536 + persona: | + You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + + Verify your work by running the code or tests. Keep answers brief and factual. + - insert: + - id: llm-replay-main + name: '@deepseek-ai/dsh-llm-replay' + config: + overrideFile: ./.missing-main-replay-override.json + providers: + - id: deepseek + name: DeepSeek + models: + - id: deepseek-v4-flash + - id: llm-replay-title + name: '@deepseek-ai/dsh-llm-replay' + config: + paceMs: 10 + providers: + - id: title-replay + name: Title replay + models: + - id: title-model + - id: session-title-provider + name: '@deepseek-ai/dsh-session-title-first-message-llm' + config: + targetWords: 5 + targetCjkCharacters: 10 + maxInputBytes: 4096 + maxOutputTokens: 32 + timeoutMs: 5000 + provider: title-replay + model: title-model diff --git a/examples/acp-agent/session-title.cordis.yml b/examples/acp-agent/session-title.cordis.yml new file mode 100644 index 0000000000..09c9e2b919 --- /dev/null +++ b/examples/acp-agent/session-title.cordis.yml @@ -0,0 +1,19 @@ +# Session-title snapshot composition: the optional first-message provider uses +# the ordinary DeepSeek route while the ACP app and every other capability stay +# identical to the base example. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - insert: + - id: session-title-provider + name: '@deepseek-ai/dsh-session-title-first-message-llm' + config: + targetWords: 5 + targetCjkCharacters: 10 + maxInputBytes: 4096 + maxOutputTokens: 32 + timeoutMs: 5000 + provider: deepseek + model: deepseek-v4-flash diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 5d8f4669bb..9987679607 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -39,6 +39,7 @@ const PTY_CONFIG = fileURLToPath(new URL('../pty.cordis.yml', import.meta.url)) const DEPTH_TWO_CONFIG = fileURLToPath(new URL('../depth-two.cordis.yml', import.meta.url)) const SESSION_SANDBOX_ROOT_CONFIG = fileURLToPath(new URL('../session-sandbox-root.cordis.yml', import.meta.url)) const RETRY_CONFIG = fileURLToPath(new URL('../retry.cordis.yml', import.meta.url)) +const SESSION_TITLE_CONFIG = fileURLToPath(new URL('../session-title.cordis.yml', import.meta.url)) const LSP_CONFIG = fileURLToPath(new URL('./lsp.cordis.yml', import.meta.url)) const WEB_CONFIG = fileURLToPath(new URL('../web.cordis.yml', import.meta.url)) const SNAPSHOTS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'snapshots') @@ -75,6 +76,13 @@ const SCENARIOS: Scenario[] = [ // text-turn is the pinned-header scenario: the minimal single text turn. // Its prompt and tool-schema sidecars pin the composed header. { name: 'text-turn', hasModelTurn: true, recorded: true, pinsHeader: true }, + { + name: 'session-title-after-turn', + hasModelTurn: true, + recorded: false, + overridden: true, + configPath: SESSION_TITLE_CONFIG, + }, { name: 'tool-call-turn', hasModelTurn: true, recorded: true }, // Authored from the real PACKED_CHUNKS_SOURCE recording under the ordinary // app composition. The contract below pins decoded equality and all three diff --git a/examples/acp-agent/tests/snapshots/session-title-after-turn/input.json b/examples/acp-agent/tests/snapshots/session-title-after-turn/input.json new file mode 100644 index 0000000000..468835606d --- /dev/null +++ b/examples/acp-agent/tests/snapshots/session-title-after-turn/input.json @@ -0,0 +1,8 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Reply with exactly TITLE_DONE. Do not use tools." }, + { "op": "waitForTitleAfterTurnEnd" } + ] +} diff --git a/examples/acp-agent/tests/snapshots/session-title-after-turn/replay.override.json b/examples/acp-agent/tests/snapshots/session-title-after-turn/replay.override.json new file mode 100644 index 0000000000..65b5cea7be --- /dev/null +++ b/examples/acp-agent/tests/snapshots/session-title-after-turn/replay.override.json @@ -0,0 +1,15 @@ +[ + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "text" }, + { "type": "text-delta", "index": 0, "text": "Late" }, + { "type": "text-delta", "index": 0, "text": " durable" }, + { "type": "text-delta", "index": 0, "text": " session" }, + { "type": "text-delta", "index": 0, "text": " title" }, + { "type": "block-end", "index": 0, "block": { "type": "text", "text": "Late durable session title" } }, + { "type": "usage", "usage": { "inputTokens": 12, "outputTokens": 4 } }, + { "type": "finish", "reason": { "kind": "stop" } } + ] + } +] diff --git a/examples/acp-agent/tests/snapshots/session-title-after-turn/session.jsonl b/examples/acp-agent/tests/snapshots/session-title-after-turn/session.jsonl new file mode 100644 index 0000000000..028f41d511 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/session-title-after-turn/session.jsonl @@ -0,0 +1,16 @@ +{"type":"session","version":0,"id":"session-title-after-turn","createdAt":0,"cwd":"/tmp/session-title-after-turn","delegationDepth":0} +{"type":"turn/start","seq":0,"time":1785222848166,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1785222848166,"data":{"content":[{"type":"text","text":"Reply with exactly TITLE_DONE. Do not use tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":1785222848166,"data":{"title":"Reply with exactly TITLE_DONE. Do","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":3,"time":1785222848199,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":4,"time":1785222848199,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"session/title-llm-request","seq":5,"time":1785222848201,"data":{"titleProvider":"session-title-first-message-llm","messageSeqs":[1],"route":{"provider":"title-replay","model":"title-model"},"system":"Create a concise title for an AI coding-assistant session from the supplied human messages.\nReturn only the title on one line, **in plain text of natural language**, with no quotes, prefix, explanation, Markdown, XML, or terminal control codes. No code is allowed.\nUse the language of the messages.\nAim for about 5 words in non-CJK languages or 10 CJK characters.","messages":[{"role":"user","content":[{"type":"text","text":"Generate the session title from this JSON array of human messages:\n[{\"seq\":1,\"text\":\"Reply with exactly TITLE_DONE. Do not use tools.\"}]"}]}],"maxTokens":32}} +{"type":"assistant/chunk","seq":6,"time":1785222848208,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":7,"time":1785222848208,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"TITLE_DONE"}}} +{"type":"assistant/chunk","seq":8,"time":1785222848208,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"TITLE_DONE"}}}} +{"type":"assistant/chunk","seq":9,"time":1785222848208,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} +{"type":"assistant/chunk","seq":10,"time":1785222848208,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":11,"time":1785222848208,"data":{"turn":1,"step":1,"content":[{"type":"text","text":"TITLE_DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[6,7,8,9,10],"surfaceOp":"append"} +{"type":"step/end","seq":12,"time":1785222848209,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":13,"time":1785222848209,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session/title","seq":14,"time":1785222848209,"data":{"title":"Late durable session title","messageSeqs":[1],"source":{"kind":"provider","provider":"session-title-first-message-llm","model":{"provider":"title-replay","model":"title-model"}}}} diff --git a/examples/acp-agent/tests/snapshots/session-title-after-turn/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/session-title-after-turn/stdout.expected.jsonl new file mode 100644 index 0000000000..651af9e5ce --- /dev/null +++ b/examples/acp-agent/tests/snapshots/session-title-after-turn/stdout.expected.jsonl @@ -0,0 +1,4 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"TITLE_DONE"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/package.json b/package.json index ee9e7fa912..cb6a6270ed 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "check:ci:coverage": "tsx scripts/run-gates.ts ci-coverage", "check:ci:snapshot": "tsx scripts/run-gates.ts ci-snapshot", "check:ci:artifacts": "tsx scripts/run-gates.ts ci-artifacts", + "check:ci:consumers": "tsx scripts/run-gates.ts ci-consumers", "check:ci:windows-blocking": "tsx scripts/run-gates.ts ci-windows-blocking", "check:ci:windows-complete": "tsx scripts/run-gates.ts ci-windows-complete", "check:ci:windows-observational": "tsx scripts/run-gates.ts ci-windows-observational", diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 75923cedab..aa01031997 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -779,6 +779,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { host: { describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions }), pickDirectory: request => ok(request, { path: null }), + openPath: request => ok(request, { opened: true as const }), }, workspace: { list: request => ok(request, { items: workspaces.map(w => ({ ...w })) }), @@ -1030,6 +1031,7 @@ export class FixtureApiClient extends AbstractApiClient { case 'session.cancel': return this.api.sessions.cancel(request) case 'host.describe': return this.api.host.describe(request) case 'host.pickDirectory': return this.api.host.pickDirectory(request, new AbortController().signal) + case 'host.openPath': return this.api.host.openPath(request, new AbortController().signal) case 'workspace.list': return this.api.workspace.list(request) case 'workspace.create': return this.api.workspace.create(request) case 'workspace.rename': return this.api.workspace.rename(request) diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index bc73e0e054..33f6d0cc41 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -26,7 +26,8 @@ export function apply(ctx: Context): void { path: API_PATH, handler: async (req, res) => { const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname - if (pathname === `${API_PATH}/host.pickDirectory` + if ((pathname === `${API_PATH}/host.pickDirectory` + || pathname === `${API_PATH}/host.openPath`) && !isTrustedNativeDialogRequest(req)) { res.writeHead(403) res.end('forbidden') diff --git a/packages/client/connection/src/native-dialog-request.ts b/packages/client/connection/src/native-dialog-request.ts index fe91bbae2d..0eaf09f149 100644 --- a/packages/client/connection/src/native-dialog-request.ts +++ b/packages/client/connection/src/native-dialog-request.ts @@ -1,4 +1,4 @@ -/** Trust check for browser requests that can open an operating-system dialog. */ +/** Trust check for browser requests that can invoke privileged native host actions. */ import type { IncomingHttpHeaders } from 'node:http' diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index 1bb49b19fb..f4058deda1 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -66,6 +66,8 @@ export class FakeApiClient implements IApiClient { () => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 })) onPickDirectory: (payload: unknown) => Promise> = () => Promise.resolve(ok({ path: null })) + onOpenPath: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ opened: true as const })) private readonly muxConns: StreamConn[] = [] private readonly hostConns: StreamConn[] = [] @@ -88,6 +90,7 @@ export class FakeApiClient implements IApiClient { readonly host: IApiClient['host'] = { describe: payload => this.record('host.describe', payload, this.onDescribe(payload)), pickDirectory: payload => this.record('host.pickDirectory', payload, this.onPickDirectory(payload)), + openPath: payload => this.record('host.openPath', payload, this.onOpenPath(payload)), } readonly workspace: IApiClient['workspace'] = { diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index 86af61ba0d..2c90cd8b7a 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -28,22 +28,24 @@ describe('connection node half', () => { expect(routes).toHaveLength(1) expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH }) - let status: number | undefined - let body: unknown - const deniedRequest = { - url: '/api/host.pickDirectory', - headers: { - host: 'harness.example', origin: 'http://harness.example', 'sec-fetch-site': 'same-origin', - }, - socket: { remoteAddress: '192.168.1.8' }, - } as unknown as IncomingMessage - const deniedResponse = { - writeHead(value: number) { status = value; return this }, - end(value?: unknown) { body = value; return this }, - } as unknown as ServerResponse - await routes[0]!.handler(deniedRequest, deniedResponse) - expect(status).toBe(403) - expect(body).toBe('forbidden') + for (const url of ['/api/host.pickDirectory', '/api/host.openPath']) { + let status: number | undefined + let body: unknown + const deniedRequest = { + url, + headers: { + host: 'harness.example', origin: 'http://harness.example', 'sec-fetch-site': 'same-origin', + }, + socket: { remoteAddress: '192.168.1.8' }, + } as unknown as IncomingMessage + const deniedResponse = { + writeHead(value: number) { status = value; return this }, + end(value?: unknown) { body = value; return this }, + } as unknown as ServerResponse + await routes[0]!.handler(deniedRequest, deniedResponse) + expect(status).toBe(403) + expect(body).toBe('forbidden') + } await fiber.dispose() expect(routes).toHaveLength(0) diff --git a/packages/client/locale/src/client/LanguageRow.module.css b/packages/client/locale/src/client/LanguageRow.module.css index f17a67d279..57d975d9b7 100644 --- a/packages/client/locale/src/client/LanguageRow.module.css +++ b/packages/client/locale/src/client/LanguageRow.module.css @@ -42,6 +42,10 @@ cursor: pointer; } +.selector:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + .chevron { flex: none; } diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts index fe345801c6..97f01d0bf1 100644 --- a/packages/client/runtime/src/client/workspaces/service.ts +++ b/packages/client/runtime/src/client/workspaces/service.ts @@ -182,6 +182,17 @@ export class WorkspacesService { return response.result.value.path } + /** + * Open a filesystem path with the Host operating system's default application. + * @param path - absolute or host-resolvable path. + */ + async openPath(path: string): Promise { + const response = await this.api.host.openPath({ path }) + if (!response.result.ok) { + throw new Error(`path open failed: ${response.result.error.message}`) + } + } + /** * Rename a Workspace. * @param workspaceId - target workspace. diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index a5eecd0cf5..dc33e20128 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -84,6 +84,8 @@ export class FakeApiClient implements IApiClient { () => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 })) onPickDirectory: (payload: unknown) => Promise> = () => Promise.resolve(ok({ path: null })) + onOpenPath: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ opened: true as const })) private readonly muxConns: StreamConn[] = [] private readonly hostConns: StreamConn[] = [] @@ -106,6 +108,7 @@ export class FakeApiClient implements IApiClient { readonly host: IApiClient['host'] = { describe: (payload: unknown) => this.record('host.describe', payload, this.onDescribe(payload)), pickDirectory: (payload: unknown) => this.record('host.pickDirectory', payload, this.onPickDirectory(payload)), + openPath: (payload: unknown) => this.record('host.openPath', payload, this.onOpenPath(payload)), } onWorkspaceList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ items: [] })) diff --git a/packages/client/runtime/tests/workspaces-service.spec.ts b/packages/client/runtime/tests/workspaces-service.spec.ts index 5327066651..6768fddff7 100644 --- a/packages/client/runtime/tests/workspaces-service.spec.ts +++ b/packages/client/runtime/tests/workspaces-service.spec.ts @@ -236,6 +236,17 @@ describe('WorkspacesService', () => { expect(api.callsOf('host.pickDirectory')).toEqual([{}, {}]) }) + it('opens a filesystem path through the host without local state', async () => { + const ctx = new Context() + const api = new FakeApiClient() + const sessions = new SessionsService(ctx, api) + const workspaces = new WorkspacesService(ctx, api, sessions) + await expect(workspaces.openPath('/w/alpha/a.ts')).resolves.toBeUndefined() + expect(api.callsOf('host.openPath')).toEqual([{ path: '/w/alpha/a.ts' }]) + api.onOpenPath = () => Promise.resolve(err({ code: 'internal', message: 'boom', details: {} })) + await expect(workspaces.openPath('/missing')).rejects.toThrow(/path open failed/) + }) + it('deletes a Workspace or preserves it when the Host rejects deletion', async () => { const ctx = new Context() const api = new FakeApiClient() diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 2c5e75e877..1fd11f1379 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/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-conversation/README.md -README.md: 658a6d5a3bc3f94a90639ddc730b2195504675cd -README.zh.md: f5510490e5130fb91af72558798aad2a4269b8c0 +README.md: 6cb4952eff2a98f8294c68ab96693a396ae32c5f +README.zh.md: 563fbae996bf19822f8b600489a6168e9c736600 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 658a6d5a3b..6cb4952eff 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -8,9 +8,9 @@ The resident conversation shell survives no-session and session transitions. Wit The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: `), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves. -Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and `Write · ` or `Edit · ` summary while retaining the shared row-to-details interaction. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged), and the details panel resolves a selected sub-call id to its full logged args and complete output. Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering. +Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is a hover-underline link that opens the file with the host OS default application (`host.openPath`, relative paths resolve against the session cwd). Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering. -Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders). +Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openFile`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders). The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`/ 已完成 · ` plus a `+` count of the other active ones in its own non-shrinking span, parsed from its args through `toolviews/plan-summary.ts` `planSummary`, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). Several items may be `in_progress` at once (the tool permits parallel work), so `planSummary` names the first and counts the rest, and deliberately returns the two unjoined: the row ellipsizes its summary text, so a count concatenated onto the end of the task name would be the first thing a narrow row clips. `TodoDock` takes the `'conversation.input.dock'` list slot at `order: -1` — above the queue rows — and is the durable plan strip: it selects `todos` off the session snapshot and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and collapses to a header of title plus `"/ tasks · in progress"` (status glyphs are the figma check / progress / dashed-pending set), so it reports the parallel count without needing a name to truncate. The dock adapter owns the selection so the panel stays a pure function of its props; the persistent list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index f5510490e5..563fbae996 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -8,9 +8,9 @@ 视图环本身就是 slot:会话注册声明 `'conversation.view'` 列表 slot(Session scope),并将其列在 `children` 表中;ConversationRoot 通过 renderSlot share 渲染活跃配置项(`only: `);视图标签页从环账本的注册选项(`id`/`order`/`label`)投影而来。聊天视图是该包自身的环配置项;其他插件(ui-trajectory)通过普通的 `ctx.slots.register` 贡献标签页。先前包内的视图注册表(`registerView`/`ViewEntry`/`ConversationViewMap` 及 chrome 附加表)已退役,逐视图 chrome 则被拆入视图组件自身。 -通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和 `Write · ` 或 `Edit · ` 摘要,同时保留共享的行到详情交互。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行),details 面板则会根据选中的子调用 id 解析出其完整记录的参数与完整输出。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect`、`Mount temporary Plugin` 和 `Unmount temporary Plugin`;mount 行保留 code 变体的可展开源码渲染。 +通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是悬停下划线链接,点击后通过宿主操作系统的默认应用打开文件(`host.openPath`,相对路径相对会话 cwd 解析)。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行)。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect`、`Mount temporary Plugin` 和 `Unmount temporary Plugin`;mount 行保留 code 变体的可展开源码渲染。 -工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openDetails`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall 工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。 +工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openFile`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall 工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。 todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 经 `toolviews/plan-summary.ts` 的 `planSummary` 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`,并把「其余活跃项的数量」`+` 放在自己的不收缩 span 里;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。可以有多个条目同时处于 `in_progress`(工具允许并行工作),因此 `planSummary` 给出第一个活跃条目并计数其余,且刻意不把两者拼成一个字符串:行会对摘要文本做省略号截断,把数量接在任务名末尾时,窄行最先裁掉的正是这个数量。`TodoDock` 以 `order: -1` 占用 `'conversation.input.dock'` 列表 slot(位于队列行之上),是常驻的计划条:它从会话快照中选取 `todos` 并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏,折叠时收成标题加 `"<已完成>/<总数> tasks · in progress"` 的表头(状态图标为 figma 的勾选/进行中/虚线未开始一组),因此它无需一个可被截断的任务名即可报告并行数量。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;常驻列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 62801ca0b8..c5c004cdc4 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -7,6 +7,7 @@ import type { ViewTab } from './contract/views.ts' import type { ChatViewInjected, ComposerBarInjected, ConversationInjected, ConversationSessionInjected, DetailsInjected, } from './contract/slots.ts' +import { resolveToolPath } from './contract/tool-call-model.ts' import { createChatStore } from './stores.ts' import { ConversationService } from './service.ts' import { InputHub } from './input/hub.ts' @@ -167,6 +168,13 @@ export function apply(ctx: Context): void { actions.select(target) layout.openDetails() }, + openFile: (path) => { + const cwd = sessions.list.getSnapshot().byId[sessionId]?.cwd + void workspaces.openPath(resolveToolPath(cwd, path)).catch(() => { + // Host/OS open failures stay silent in the chat row; the native + // app surfaces its own error dialog when the path is unusable. + }) + }, loadOlder: () => { void scoped.loadOlder() }, } }, diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.module.css b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.module.css index b4570af921..9177d721c3 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.module.css +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.module.css @@ -9,18 +9,6 @@ color: var(--dsw-alias-label-primary); } -.pulse { - display: inline-block; - width: 8px; - height: 14px; - background: var(--dsw-alias-state-business-primary); - animation: pulse 1s infinite ease-in-out; -} - -@keyframes pulse { - 50% { opacity: 0.2; } -} - /* Interrupted-turn terminal marker: quiet inline tag, no animation. */ .stopped { align-self: flex-start; diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index 6daf78719e..e3573b14f5 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -2,8 +2,8 @@ // reasoning as the figma Think summary row (expand = indented gray text), // other-block JSON fallback. Tool-call heads are NOT rendered here: the chat // view groups them into tool rows through its keyed toolview slot (figma -// step-summary flow). Shared by finalized nodes and the streaming partial -// (pulse marker). +// step-summary flow). Shared by finalized nodes and the streaming partial; +// the turn-level loading dots live in the chat view's tail, not here. import { memo } from 'react' import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client' @@ -14,7 +14,7 @@ import css from './AssistantMarkdown.module.css' export interface AssistantMarkdownProps { blocks: readonly AssistantBlock[] streaming: boolean - /** Frozen partial of an aborted turn: rendered with a 已停止 marker, no pulse. */ + /** Frozen partial of an aborted turn: rendered with a 已停止 marker. */ interrupted?: boolean | undefined } @@ -28,7 +28,7 @@ function ThinkRow({ text, running }: { text: string; running: boolean }) { return ( } + icon={} title="Think" summary={firstLine(text)} body={text} @@ -58,7 +58,6 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, strea default: return } })} - {streaming && } {interrupted && 已停止} ) diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.module.css b/packages/client/ui-conversation/src/client/chat/ChatView.module.css index 6d75f9a519..fae96e5f6c 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.module.css +++ b/packages/client/ui-conversation/src/client/chat/ChatView.module.css @@ -1,5 +1,6 @@ -/* Chat flow: block gap 16 between narration/bubbles/tool groups (figma); - tool rows inside a group gap 10. Input padding cap rides the skeleton. */ +/* Chat flow: one 16px rhythm everywhere — between blocks (prose <-> tool + runs) via the column gap and between consecutive tool rows via the group + gap. Input padding cap rides the skeleton. */ .root { position: relative; @@ -30,7 +31,7 @@ .toolGroup { display: flex; flex-direction: column; - gap: 10px; + gap: 16px; } .callRow { @@ -51,6 +52,35 @@ border-left: 1px solid var(--dsw-alias-border-l2); } +/* Turn loader: one row of four 2.5px pixels (StateDot blue) chasing left to + right with a stepped trail — flat keyframe holds, no tweening. Phase + offsets come from per-rect animation-delay (index * -250ms) set inline + by the component. */ +.turnDots { + align-self: flex-start; + flex: none; + display: flex; + align-items: center; + /* One message line box: the dots center inside the text line height. */ + height: 26px; + /* Same pin as StateDot: ongoing blue has no alias token (business-primary + is the 500 step, not this 450). */ + color: var(--dsw-static-deepseek-450); +} + +.turnDotCell { + fill: currentColor; + opacity: 0.15; + animation: dsh-turn-dots-chase 1s infinite; +} + +@keyframes dsh-turn-dots-chase { + 0%, 24.9% { opacity: 1; } + 25%, 49.9% { opacity: 0.6; } + 50%, 74.9% { opacity: 0.35; } + 75%, 100% { opacity: 0.15; } +} + .hint { color: var(--dsw-alias-label-tertiary); font-size: 12px; diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index f61c6da6ef..afcd1201c0 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -25,7 +25,6 @@ import type { import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps } from '../contract/slots.ts' -import type { SelectionTarget } from '../contract/views.ts' import { deriveChatFlow, type ChatFlowItem } from './chat-flow.ts' import { AssistantMarkdown } from './AssistantMarkdown.tsx' import { GenericToolCard } from './GenericToolCard.tsx' @@ -36,7 +35,7 @@ import css from './ChatView.module.css' const FOLLOW_THRESHOLD = 24 -type OpenDetails = (target: SelectionTarget) => void +type OpenFile = (path: string) => void /** The declared toolview hole's render share (stable framework binding, passed through memoized rows). */ type RenderToolRow = ChatViewSlotProps['renderSlot'] @@ -49,19 +48,18 @@ type UseConversation = SnapshotSelectorHook * top-level call (same registrations, same fallback), nested by the parent. * A started-but-unsettled sub-call arrives as the RunningToolCall shape and * renders the running state exactly as a native in-flight row. */ -const SubCallRow = memo(function SubCallRow({ renderSlot, node, onOpenDetails, selected }: { +const SubCallRow = memo(function SubCallRow({ renderSlot, node, openFile, selected, cwd }: { renderSlot: RenderToolRow node: CodeSubCall - onOpenDetails: OpenDetails + openFile: OpenFile selected: boolean + cwd: string | undefined }) { const settled = 'kind' in node const toolName = settled ? node.call?.name ?? '' : node.name - const seq = settled ? node.seq : node.time const owner = useMemo(() => ({ - callId: node.callId, toolName, block: node, - openDetails: () => { onOpenDetails({ turnSeq: seq, callId: node.callId, toolName }) }, - }), [node, toolName, seq, onOpenDetails]) + callId: node.callId, toolName, block: node, openFile, cwd, + }), [node, toolName, openFile, cwd]) return (
{renderSlot('conversation.chat.toolview', owner, { @@ -77,25 +75,26 @@ const SubCallRow = memo(function SubCallRow({ renderSlot, node, onOpenDetails, s * GenericToolCard at this render site. A `run_code` call additionally * renders its logged sub-dispatches as always-visible indented rows — * each one the same keyed-slot dispatch as a native top-level call. */ -const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq, onOpenDetails, selected, subCalls, selectedCallId }: { +const CallRow = memo(function CallRow({ + renderSlot, callId, toolName, block, openFile, selected, subCalls, selectedCallId, cwd, +}: { renderSlot: RenderToolRow callId: string toolName: string block: ToolResultNode | RunningToolCall - /** Surface seq for finalized results; the call's turn for running calls. */ - seq: number - onOpenDetails: OpenDetails + openFile: OpenFile selected: boolean /** `run_code` sub-dispatches in dispatch order (reference-stable per * parent; running entries settle in place); undefined for ordinary calls. */ subCalls?: readonly CodeSubCall[] | undefined /** The store's selected callId, matched against sub-rows (undefined when no sub-row here is selected). */ selectedCallId?: string | undefined + /** Session workspace root for path-relative summaries. */ + cwd: string | undefined }) { const owner = useMemo(() => ({ - callId, toolName, block, - openDetails: () => { onOpenDetails({ turnSeq: seq, callId, toolName }) }, - }), [callId, toolName, block, seq, onOpenDetails]) + callId, toolName, block, openFile, cwd, + }), [callId, toolName, block, openFile, cwd]) return (
{renderSlot('conversation.chat.toolview', owner, { @@ -109,8 +108,9 @@ const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq key={node.callId} renderSlot={renderSlot} node={node} - onOpenDetails={onOpenDetails} + openFile={openFile} selected={node.callId === selectedCallId} + cwd={cwd} /> ))}
@@ -119,15 +119,17 @@ const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq ) }) -/** Consecutive tool results as one step-run group (figma VERTICAL gap10). */ -const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails, selectedCallId, codeDispatches }: { +/** Consecutive tool results as one step-run group (uniform 16px rhythm). */ +const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selectedCallId, codeDispatches, cwd }: { renderSlot: RenderToolRow results: readonly ToolResultNode[] - onOpenDetails: OpenDetails + openFile: OpenFile /** Only set when the selected call lives in THIS group, top-level or nested (memo economy). */ selectedCallId: string | undefined /** Sub-dispatch index off the snapshot (map reference is chunk-storm stable). */ codeDispatches: ReadonlyMap + /** Session workspace root for path-relative summaries. */ + cwd: string | undefined }) { return (
@@ -138,17 +140,51 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails, callId={node.callId} toolName={node.call?.name ?? ''} block={node} - seq={node.seq} - onOpenDetails={onOpenDetails} + openFile={openFile} selected={node.callId === selectedCallId} subCalls={codeDispatches.get(node.callId)} selectedCallId={selectedCallId} + cwd={cwd} /> ))}
) }) +/** Turn loader: one row of four 2.5px pixels (half a notch above the StateDot + * 2px cell, same blue) chasing left to right with a stepped trail — flat + * keyframe holds, no tweening, no rotation. Phase offsets come from + * per-rect animation-delay. */ +const LOADER_CELLS = [0, 5, 10, 15] as const + +function TurnDots() { + return ( + /* The wrapper is a 26px line box (message line height) so the loader + occupies one text line and centers the dots inside it. */ + + ) +} + /** The streaming partial, isolated so chunk batches re-render only this tail. * onGrow lets the scroll owner follow content the parent never re-renders for. */ function StreamingTail({ useSession, onGrow }: { @@ -167,8 +203,11 @@ function StreamingTail({ useSession, onGrow }: { * The chat view slot entry: pure component over the composed props (tool rows * render through the declared keyed hole's renderSlot share). */ -export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOlder }: ChatViewSlotProps) { +export function ChatView({ useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder }: ChatViewSlotProps) { const nodes = useSession(s => s.nodes) + // Workspace root off the session list row: path summaries display relative to it. + const cwd = useSessions(s => s.byId[sessionId]?.cwd) + const running = useSession(s => s.running) const runningCalls = useSession(s => s.runningCalls) const codeDispatches = useSession(s => s.codeDispatches) const pending = useSession(s => s.pending) @@ -265,9 +304,10 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl key={item.key} renderSlot={renderSlot} results={item.results} - onOpenDetails={openDetails} + openFile={openFile} selectedCallId={inGroup ? selectedCallId : undefined} codeDispatches={codeDispatches} + cwd={cwd} /> ) } @@ -304,16 +344,19 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl callId={call.callId} toolName={call.name} block={call} - seq={call.turn} - onOpenDetails={openDetails} + openFile={openFile} selected={call.callId === selectedCallId} subCalls={codeDispatches.get(call.callId)} selectedCallId={selectedCallId} + cwd={cwd} /> ))}
)} {pending.map(item => )} + {/* Turn-level loading signal: rides the whole running turn (first-token + wait, tool execution, streaming) so it never flickers per step. */} + {running && } diff --git a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx index 5cb2126f34..7bd4f22b06 100644 --- a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx +++ b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx @@ -13,20 +13,21 @@ import { toolRowModel, type ToolRowVariant } from '../contract/tool-call-model.t import { ToolRow } from './ToolRow.tsx' import { IconSparkle16 } from './IconSparkle16.tsx' -/** Variant leading icons (figma table). */ +/** Variant leading icons (figma table); all glyphs render at 14 inside the 16px leading box. */ const VARIANT_ICONS: Record = { - think: , - search: , - read: , - bash: , - write: , - edit: , - code: , - others: , + think: , + search: , + read: , + bash: , + write: , + edit: , + code: , + others: , } -export function GenericToolCard({ toolName, block, openDetails }: ToolRowOwnerProps) { - const model = toolRowModel(toolName, block) +export function GenericToolCard({ toolName, block, cwd, openFile }: ToolRowOwnerProps) { + const model = toolRowModel(toolName, block, cwd) + const singleFile = model.filePath !== undefined return ( ) } diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css index 5d44a9260e..7e2ed4db21 100644 --- a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css +++ b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css @@ -7,22 +7,47 @@ } .row { + position: relative; /* sweep-glare overlay anchor */ + overflow: hidden; display: flex; align-items: center; height: 24px; min-width: 0; } -.row[data-clickable] { - cursor: pointer; - border-radius: 6px; +/* Running sweep (deepsuite ShimmerText pattern): a fixed-width glare band — + theme background at 60% — glides over the row content from off-left to + off-right, washing glyphs and icon toward the background as it passes. + ease-out with a 10% end hold gives each pass a beat before the next. */ +.root[data-state='running'] .row::after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 300px; + background: linear-gradient( + 90deg, + transparent 0%, + color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%, + transparent 100% + ); + animation: dsh-tool-row-sweep 2.6s ease-out infinite; + pointer-events: none; } -.row[data-clickable]:hover { - background: var(--dsw-alias-interactive-bg-hover); +@keyframes dsh-tool-row-sweep { + 0% { left: -300px; } + 90%, 100% { left: 100%; } +} + +/* Expand-on-row (Think / code): pointer only — no row fill hover. */ +.row[data-expandable] { + cursor: pointer; } .leading { + position: relative; /* .chevronHover overlay anchor */ flex: none; width: 16px; height: 16px; @@ -65,11 +90,36 @@ button.leading { color: var(--dsw-alias-label-secondary); } +/* Hover preview on expandable rows: the idle tool icon crossfades (100ms) + into a down chevron before the row is opened. The chevron overlays the + icon cell absolutely so both can stay mounted for the opacity transition. */ +.iconIdle { + display: inline-flex; + opacity: 1; + transition: opacity 100ms ease; +} + +.chevronHover { + position: absolute; + inset: 0; + margin: auto; + opacity: 0; + transition: opacity 100ms ease; +} + +.row:hover .iconIdle { + opacity: 0; +} + +.row:hover .chevronHover { + opacity: 1; +} + .title { flex: none; font-size: 14px; line-height: 24px; - color: var(--dsw-alias-label-primary-dimmed); + color: var(--dsw-alias-label-secondary); } .sep { @@ -92,6 +142,29 @@ button.leading { color: var(--dsw-alias-label-tertiary); } +/* File-tool path: same geometry as .summary; hover underline + pointer. */ +.fileLink { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin: 0; + padding: 0; + border: none; + background: none; + font: inherit; + text-align: left; + font-size: 14px; + line-height: 24px; + color: var(--dsw-alias-label-tertiary); + cursor: pointer; +} + +.fileLink:hover { + text-decoration: underline; +} + /* Expanded body: pad-left 22 indented gray text, no border, no fill. */ .body { padding: 4px 0 4px 22px; diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx index a81c084b8e..9395813910 100644 --- a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx +++ b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx @@ -1,8 +1,9 @@ // ToolRow: the single-line tool summary row (figma component set 122:9479) — -// 16px leading slot (state dot / tool icon, chevron when expanded) + title + +// 16px leading slot (state dot / tool icon, chevron on hover or expanded) + title + // separator dot + FILL-truncated summary. Expanded body is indented gray text; // no inline output (full results live in the details panel). Expand state is -// component-local view state; row click hands the selection off to the owner. +// component-local view state. File-tool summaries are path links that open +// through the host; the row itself is not a details-panel control. import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react' import clsx from 'clsx' @@ -24,15 +25,20 @@ export interface ToolRowProps { state: ToolRowState /** Makes the row itself the expand control instead of only its leading icon. */ expandOnRowClick?: boolean | undefined - /** Selection handoff (row click), already bound to this call by the owner. */ - onOpenDetails?: (() => void) | undefined + /** + * Filesystem path from tool args; when set with onOpenFile, the summary + * renders as a hover-underline link that opens the host default app. + */ + filePath?: string | undefined + /** Open the path with the host OS default application (already cwd-resolved). */ + onOpenFile?: ((path: string) => void) | undefined } -/** Leading-slot state substitution: the tool icon yields to the state semantic - * (running = blue ring, error = red, interrupted = amber halo; ok = icon). */ +/** Leading-slot state substitution: the tool icon yields to the terminal state + * semantic (error = red, interrupted = amber halo). Running keeps the icon — + * the row sweep (CSS on data-state) carries the in-flight signal. */ function leadingFor(state: ToolRowState, icon: ReactNode): ReactNode { switch (state) { - case 'running': return case 'error': return case 'stopped': return default: return icon @@ -48,10 +54,15 @@ export function ToolRow({ body, state, expandOnRowClick = false, - onOpenDetails, + filePath, + onOpenFile, }: ToolRowProps) { const [expanded, setExpanded] = useState(false) - const expandable = body !== null + // A row that names a single file keeps one interaction (open that path); + // args expand is off whether or not the open callback is wired yet. + const singleFile = filePath !== undefined + const fileLink = singleFile && onOpenFile !== undefined + const expandable = body !== null && !singleFile const open = expanded && expandable const rowExpands = expandable && expandOnRowClick const toggleExpand = () => { @@ -66,15 +77,32 @@ export function ToolRow({ event.preventDefault() toggleExpand() } + const openFile = (event: MouseEvent) => { + event.stopPropagation() + if (filePath !== undefined) onOpenFile?.(filePath) + } + // Expandable rows preview the toggle on hover: the tool icon yields to a + // down chevron (CSS swap on .row:hover); state dots still take precedence. + const collapsedIcon = expandable + ? ( + <> + {icon} + + + ) + : icon + const leading = open + ? + : leadingFor(state, collapsedIcon) return (
{expandable && !rowExpands ? ( @@ -84,18 +112,28 @@ export function ToolRow({ aria-expanded={open} onClick={toggleFromLeading} > - {open ? : leadingFor(state, icon)} + {leading} ) : ( - {open ? : leadingFor(state, icon)} + {leading} )} {title} {!open && ( <> - {summary} + {fileLink ? ( + + ) : ( + {summary} + )} )}
diff --git a/packages/client/ui-conversation/src/client/chat/chat-flow.ts b/packages/client/ui-conversation/src/client/chat/chat-flow.ts index 8f47e334c5..f1ce061af8 100644 --- a/packages/client/ui-conversation/src/client/chat/chat-flow.ts +++ b/packages/client/ui-conversation/src/client/chat/chat-flow.ts @@ -12,6 +12,16 @@ export type ChatFlowItem = | { kind: 'node'; key: string; node: ConversationNode } | { kind: 'tool-group'; key: string; results: readonly ToolResultNode[] } +/** An assistant node that renders nothing: only tool-call heads (rows render + * via the grouping pass) and blank text/reasoning. Skipped by the flow so it + * neither costs column gaps nor splits a tool-row run. Interrupted nodes + * always render (the 已停止 marker). */ +function rendersNothing(node: ConversationNode): boolean { + return node.kind === 'assistant' && node.interrupted !== true + && node.blocks.every(b => b.kind === 'tool-call' + || ((b.kind === 'text' || b.kind === 'reasoning') && b.text.trim() === '')) +} + /** * Group finalized nodes into the step-summary flow. * @param nodes - snapshot nodes (surface order). @@ -21,6 +31,7 @@ export function deriveChatFlow(nodes: readonly ConversationNode[]): ChatFlowItem const items: ChatFlowItem[] = [] let group: ToolResultNode[] | null = null for (const node of nodes) { + if (rendersNothing(node)) continue if (node.kind === 'tool-result') { if (group === null) { group = [node] diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 7a99323826..7aaf570ac7 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -143,8 +143,13 @@ export interface ToolRowOwnerProps { toolName: string /** Frozen call slice: the running call or the settled result node. */ block: ToolCallBlock - /** Open the details panel for this call (session-level facility, supplied by the view). */ - openDetails: () => void + /** Session workspace root; path summaries display relative to it. */ + cwd?: string | undefined + /** + * Open a tool-arg filesystem path with the host OS default application. + * The chat view resolves relative paths against the session cwd. + */ + openFile: (path: string) => void } /** @@ -283,6 +288,11 @@ export type ConversationSessionSlotProps = export interface ChatViewInjected { /** Selection write + details panel opening in one gesture (store action + layout orchestration). */ openDetails: (target: SelectionTarget) => void + /** + * Open a tool-arg filesystem path with the host OS default application + * (relative paths resolve against the session cwd). + */ + openFile: (path: string) => void loadOlder: () => void } @@ -307,6 +317,8 @@ export type DetailsSlotProps = PropsRuntime<'details'> & PropsStore & export interface EmptyWorkspaceOwnerProps { open: boolean anchorRef?: RefObject + /** Currently active workspace (renders a trailing check in the picker list). */ + selectedId?: WorkspaceId | undefined onPick: (workspaceId: WorkspaceId) => void onClose: () => void } diff --git a/packages/client/ui-conversation/src/client/contract/tool-call-model.ts b/packages/client/ui-conversation/src/client/contract/tool-call-model.ts index 5b725df00b..04a8c66d40 100644 --- a/packages/client/ui-conversation/src/client/contract/tool-call-model.ts +++ b/packages/client/ui-conversation/src/client/contract/tool-call-model.ts @@ -62,6 +62,12 @@ export interface ToolRowModel { variant: ToolRowVariant title: string summary: string + /** + * Filesystem path from args (`path` / `file_path`) when the row is a file + * tool; absent for URL reads and non-file tools. The chat view resolves + * relative values against the session cwd before opening. + */ + filePath: string | undefined /** Expanded-body text (pretty args); null = row not expandable. */ body: string | null state: ToolRowState @@ -101,6 +107,14 @@ const SUMMARY_KEYS: Record = { others: [], } +/** Strip the workspace root from workspace-rooted absolute paths (display only). */ +function relativizeToCwd(text: string, cwd: string | undefined): string { + if (cwd === undefined || cwd === '') return text + const root = cwd.replace(/[/\\]+$/, '') + if (text.startsWith(`${root}/`) || text.startsWith(`${root}\\`)) return text.slice(root.length + 1) + return text +} + function deriveSummary(variant: ToolRowVariant, argsRaw: string): string { const parsed = parseArgs(argsRaw) if (typeof parsed !== 'object' || parsed === null) return firstLine(argsRaw) @@ -113,6 +127,35 @@ function deriveSummary(variant: ToolRowVariant, argsRaw: string): string { return firstLine(argsRaw) } +/** Path keys only — never `url` (web_fetch lands on the read variant). */ +const FILE_PATH_KEYS = ['path', 'file_path'] as const + +/** File-tool variants whose summary may be an openable workspace path. */ +const FILE_PATH_VARIANTS: ReadonlySet = new Set(['read', 'write', 'edit']) + +function deriveFilePath(variant: ToolRowVariant, argsRaw: string): string | undefined { + if (!FILE_PATH_VARIANTS.has(variant)) return undefined + const parsed = parseArgs(argsRaw) + if (typeof parsed !== 'object' || parsed === null) return undefined + const picked = pickString(parsed as Record, FILE_PATH_KEYS) + return picked === undefined ? undefined : firstLine(picked) +} + +/** + * Resolve a tool-arg path against the session cwd for host.openPath. + * Absolute POSIX/Windows paths pass through; relative paths join under cwd. + * @param cwd - session working directory (may be absent for ungrouped sessions). + * @param path - path as carried in tool args. + * @returns a host-facing path string. + */ +export function resolveToolPath(cwd: string | undefined, path: string): string { + if (path.startsWith('/') || /^[A-Za-z]:[/\\]/.test(path) || path.startsWith('\\\\')) return path + if (cwd === undefined || cwd === '') return path + const base = cwd.replace(/[/\\]+$/, '') + const rel = path.replace(/^[/\\]+/, '') + return `${base}/${rel}` +} + function deriveBody(variant: ToolRowVariant, argsRaw: string): string | null { if (argsRaw === '') return null const parsed = parseArgs(argsRaw) @@ -130,16 +173,17 @@ function deriveBody(variant: ToolRowVariant, argsRaw: string): string | null { * Derive the full row model from a frozen call slice. * @param toolName - wire tool name (dispatch-supplied; survives windowless results). * @param block - RunningToolCall or ToolResultNode off the snapshot caches. + * @param cwd - session workspace root; workspace-rooted path summaries display relative to it. * @returns the row model. */ -export function toolRowModel(toolName: string, block: ToolCallBlock): ToolRowModel { +export function toolRowModel(toolName: string, block: ToolCallBlock, cwd?: string): ToolRowModel { const variant = classifyTool(toolName) const done = 'kind' in block const argsRaw = (done ? block.call?.argsRaw : block.argsRaw) ?? '' const state: ToolRowState = !done ? 'running' : block.error?.code === 'interrupted' ? 'stopped' : block.isError ? 'error' : 'ok' - const base = argsRaw === '' ? block.callId : deriveSummary(variant, argsRaw) + const base = argsRaw === '' ? block.callId : relativizeToCwd(deriveSummary(variant, argsRaw), cwd) const toolTitle = TOOL_TITLES[toolName] // Others keeps the static "Tool call" title (figma literal); the real tool // name rides the mutable summary slot unless the tool owns a specific title. @@ -150,6 +194,7 @@ export function toolRowModel(toolName: string, block: ToolCallBlock): ToolRowMod variant, title: toolTitle ?? VARIANT_TITLES[variant], summary, + filePath: deriveFilePath(variant, argsRaw), body: deriveBody(variant, argsRaw), state, } 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 be68ea1394..de974da86c 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -54,8 +54,8 @@ border: none; border-radius: 12px; background: transparent; - font-size: 13px; - line-height: 16px; + font-size: 14px; + line-height: 20px; color: var(--dsw-alias-label-tertiary); text-overflow: ellipsis; white-space: nowrap; @@ -72,13 +72,6 @@ cursor: default; } -.meta { - margin-left: 4px; - font-size: 12px; - line-height: 18px; - color: var(--dsw-alias-label-tertiary); -} - /* figma Tab_Group 34:11441: 35px strip, gap 36, pad-left 8, tabs bottom-aligned. */ .tabs { display: flex; @@ -87,7 +80,7 @@ padding-left: 8px; } -/* figma .Tab 34:11442: 13/16 wt510 text, gap 8 to the 3px bar (no bottom rounding). */ +/* figma .Tab 34:11442: 13/16 text (figma wt510, rendered 500), gap 8 to the 3px bar (no bottom rounding). */ .tab { position: relative; padding: 0 0 11px; @@ -95,7 +88,7 @@ background: transparent; font-size: 13px; line-height: 16px; - font-weight: 510; + font-weight: 500; color: var(--dsw-alias-label-tertiary); cursor: pointer; } @@ -139,11 +132,45 @@ NOT absolute+transform: a transform would make this box the containing block for position:fixed descendants (pickers/modals), shrinking them. */ .composerHero { + position: relative; /* .heroGlow positioning context */ align-self: center; + /* figma 75:8208: 12 between hero chrome / workspace row / card. */ + gap: 12px; + /* Foot inside the centered box floats the stack a bit above true center. */ + padding-bottom: 32px; width: min(776px, calc(100% - 48px)); z-index: 1; } +/* Blue backdrop ellipse (figma 313:14109), centered on the input card: the + card's resting center sits ~92px above the stack bottom (32 foot pad + + half of the ~120px two-row card); width tracks the card (glow asset 1051 + vs design card 776) so blur scales in userSpace with it. z-index -1 keeps + it behind the in-flow hero content inside this stacking context. */ +.heroGlow { + position: absolute; + left: 50%; + bottom: 92px; + z-index: -1; + width: calc(100% * 1051 / 776); + aspect-ratio: 1051 / 468; + transform: translate(-50%, 50%); + pointer-events: none; +} + +.heroWorkspaceRow { + display: flex; + align-items: center; + min-width: 0; + padding-left: 8px; +} + .root[data-phase='hero'] { justify-content: center; } + +/* Settling (session replaying, hero/docked unknown): keep the composer + mounted but invisible so no wrong layout flashes before the phase lands. */ +.root[data-phase='settling'] .composerStack { + visibility: hidden; +} diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index d6e7492836..b110860685 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -6,7 +6,7 @@ import { useEffect, useRef, useState } from 'react' import clsx from 'clsx' import type { WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationSlotProps, InputZone } from '../contract/slots.ts' -import { HeroShell, WorkspaceChip, workspaceLabel } from './EmptyHero.tsx' +import { HeroGlow, HeroShell, WorkspaceChip, workspaceLabel } from './EmptyHero.tsx' import { DisabledInputBar } from './DisabledInputBar.tsx' import css from './ConversationRoot.module.css' @@ -36,33 +36,53 @@ export function ConversationRoot({ workspace => workspace.workspaceId === pendingWorkspaceId, ) + // Clear the pending pick once the session lands in it, or when the picked + // workspace disappears from a ready list (deleted from the sidebar). useEffect(() => { - if (pendingWorkspaceId !== undefined - && sessionWorkspace?.workspaceId === pendingWorkspaceId) { + if (pendingWorkspaceId === undefined) return + if (sessionWorkspace?.workspaceId === pendingWorkspaceId + || (workspaces.phase === 'ready' && pendingWorkspace === undefined)) { setPendingWorkspaceId(undefined) } - }, [pendingWorkspaceId, sessionWorkspace?.workspaceId]) + }, [pendingWorkspaceId, sessionWorkspace?.workspaceId, workspaces.phase, pendingWorkspace]) - const hero = sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || openState === 'loading')) + // While a session is still replaying (loading + blank) the hero/docked + // choice is unknowable — render the composer hidden instead of flashing + // the centered hero and snapping to the docked bar (or vice versa). + const settling = sessionId !== undefined && composerPhase === 'blank' && openState === 'loading' + const hero = sessionId === undefined || (composerPhase === 'blank' && openState === 'open') const zone: InputZone | undefined = session === undefined || inputState === undefined ? undefined : { session, input: inputState } + // Flow optimization — worth a close PR review for code/boundary issues. + // The chip is a selector; label resolution walks the flow top-down: + // 1. a just-picked workspace (pending) → its title; + // 2. cold start, no session yet → placeholder ("Choose workspace"); + // 3. the blank session's workspace is in the list → its title; + // 4. list still loading → cwd folder name bridges so the title does not + // flash on refresh (empty cwd → placeholder); + // 5. list ready but no owning workspace (deleted from the sidebar) → + // placeholder, never the deleted folder's name via cwd. + const chipTitle = pendingWorkspace?.title + ?? (sessionId === undefined + ? undefined + : sessionWorkspace?.title + ?? (workspaces.phase === 'ready' || cwd === undefined || cwd === '' + ? undefined + : workspaceLabel(cwd))) + const heroWorkspaceRow = ( - <> +
{ setPickerOpen(open => !open) }} /> {renderSlot('conversation.hero.workspace', { open: pickerOpen, anchorRef: pickerAnchor, + selectedId: pendingWorkspaceId ?? sessionWorkspace?.workspaceId, onPick: (workspaceId) => { setPickerOpen(false) setPendingWorkspaceId(workspaceId) @@ -72,10 +92,13 @@ export function ConversationRoot({ }, onClose: () => { setPickerOpen(false) }, })} - +
) - const inputBar = sessionId === undefined + // The placeholder chip ("Choose workspace") and the inert input travel + // together: a blank session whose workspace vanished (deleted from the + // sidebar) reverts to the same disabled bar as the initial no-session state. + const inputBar = sessionId === undefined || (hero && chipTitle === undefined) ? : renderSlot('conversation.composer.bar', { variant: hero ? 'hero' : 'composer', @@ -87,6 +110,7 @@ export function ConversationRoot({ const composerBar = (
+ {hero && } {hero && } {hero && heroWorkspaceRow} {!hero && zone !== undefined && renderSlot('conversation.input.dock', zone)} @@ -96,7 +120,7 @@ export function ConversationRoot({ ) return ( -
+
{/* Mounted for every real session, hero included: ConversationSession renders no chrome while blank but owns the draft-persistence mirror bind — unmounting it in the hero would lose pre-first-send text on diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx index 45d98c1693..0c1addcba7 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx @@ -31,7 +31,6 @@ export function ConversationSession({ const activeId = useStore(s => s.view) ?? 'chat' const active = tabs.find(view => view.id === activeId) ?? tabs[0] const ancestry = useSessions(s => deriveAncestry(s, sessionId), shallowEqual) - const turns = useSession(s => countTurns(s)) const composerPhase = useSession(s => s.composerPhase) const blank = useSession(s => s.blank) const inputState = useInput(s => s) @@ -69,7 +68,6 @@ export function ConversationSession({ ) })} {ancestry.length === 0 && {sessionId}} - · {turns} turns
{tabs.length > 1 && ( @@ -95,9 +93,3 @@ export function ConversationSession({ ) } - -function countTurns(snapshot: { nodes: readonly { kind: string }[] }): number { - let count = 0 - for (const node of snapshot.nodes) if (node.kind === 'user') count += 1 - return count -} diff --git a/packages/client/ui-conversation/src/client/skeleton/DisabledInputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/DisabledInputBar.tsx index 118baf5ac9..bfaa0b8d02 100644 --- a/packages/client/ui-conversation/src/client/skeleton/DisabledInputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/DisabledInputBar.tsx @@ -29,7 +29,7 @@ export function DisabledInputBar() {
diff --git a/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx b/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx index c6122ecbcc..7fbed0ee25 100644 --- a/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx @@ -7,20 +7,18 @@ import { useId } from 'react' import type { ReactNode, RefObject } from 'react' import { - FishLogo, IconChevronDownOutline14, IconFolderOpen16, + FishLogo, IconChevronDownOutline14, IconFolderClose16, IconFolderOpen16, } from '@deepseek-ai/dsh-client-ui-primitives' import { workspaceTitleOf } from '@deepseek-ai/dsh-client-runtime/client' import css from './HeroShell.module.css' /** - * Basename label for the workspace chip / menu rows (the shared derivation); - * empty → the design's "New Workspace" placeholder copy; separator-only - * paths echo the raw cwd. - * @param cwd - workspace directory path ('' for none). + * Basename label for the workspace chip (the shared derivation); + * separator-only paths echo the raw cwd. + * @param cwd - workspace directory path (non-empty). * @returns chip label. */ export function workspaceLabel(cwd: string): string { - if (cwd === '') return 'New Workspace' const base = workspaceTitleOf(cwd) return base !== '' ? base : cwd } @@ -28,15 +26,17 @@ export function workspaceLabel(cwd: string): string { /** * The workspace chip (folder + label + chevron), always interactive: before * the first message the workspace stays switchable — picking another one - * moves the New Session flow to that workspace's blank session. - * @param props.label - chip label (see {@link workspaceLabel}). + * moves the New Session flow to that workspace's blank session. Without a + * label the chip renders its placeholder state: closed folder + the + * "Choose workspace" call to action. + * @param props.label - chip label (see {@link workspaceLabel}); omitted → placeholder. * @param props.menuOpen - menu expansion echo. * @param props.onClick - menu toggle. * @returns the chip button element. */ export function WorkspaceChip({ buttonRef, label, menuOpen = false, onClick }: { buttonRef?: RefObject - label: string + label?: string | undefined menuOpen?: boolean onClick?: () => void }) { @@ -50,13 +50,49 @@ export function WorkspaceChip({ buttonRef, label, menuOpen = false, onClick }: { aria-expanded={menuOpen} onClick={onClick} > - - {label} + {label === undefined + ? + : } + {label ?? 'Choose workspace'} ) } +/** + * The soft blue backdrop ellipse (figma 313:14109). Rendered by the hero + * owner (ConversationRoot), not HeroShell, so it can center on the input + * card; the owner's className supplies all positioning. + * @param props.className - positioning class from the owner. + * @returns the blurred-ellipse svg element. + */ +export function HeroGlow({ className }: { className?: string | undefined }) { + // Stable filter id so multiple hero mounts do not collide in the DOM. + const glowFilterId = `empty-glow-${useId().replace(/:/g, '')}` + return ( + + ) +} + /** Hero chrome props. The workspace row rides the InputBar accessory hole, not here. */ export interface HeroShellProps { /** Overlay content after the stack (modals). */ @@ -64,13 +100,12 @@ export interface HeroShellProps { } /** - * Render the hero chrome (headline + glow; no composer, no workspace row). + * Render the hero chrome (headline only; no glow, no composer, no workspace + * row — the glow is the owner's {@link HeroGlow}). * @param props - see {@link HeroShellProps}. * @returns the centered hero element tree. */ export function HeroShell({ children }: HeroShellProps) { - // Stable filter id so multiple hero mounts do not collide in the DOM. - const glowFilterId = `empty-glow-${useId().replace(/:/g, '')}` return (
@@ -80,29 +115,6 @@ export function HeroShell({ children }: HeroShellProps) { Let's start building
- {/* figma 313:14109: soft ellipse behind workspace + composer; width - tracks the card (glow asset 1051 vs design card 776) so blur - scales in userSpace with it. */} - {/* The resident composer (rendered by ConversationRoot at its stable tree position; the workspace row rides its accessory hole) is CSS-positioned into this gap during the hero phase — see diff --git a/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css b/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css index 3bba50c67c..71b90bfdfa 100644 --- a/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css @@ -8,8 +8,7 @@ justify-content: center; height: 100%; min-width: 0; - padding: 24px; - margin-bottom: -70px; + padding: 0 24px; } /* Cap matches InputBar card width (800). Glow may paint past the sides. */ @@ -24,17 +23,15 @@ overflow: visible; } -/* figma 34:10411: fish + title, gap 10, centered; 26/32 wt600; title block - keeps 36px below the headline before the flex gap. */ +/* figma 34:10411: fish + title, gap 10, centered; 26/32 wt500. */ .headline { display: flex; align-items: center; justify-content: center; gap: 10px; - padding-bottom: 36px; font-size: 26px; line-height: 32px; - font-weight: 600; + font-weight: 500; color: var(--dsw-alias-label-primary); } @@ -44,8 +41,9 @@ color: var(--dsw-alias-state-business-primary); } -/* Workspace row sits 12px above the input card (figma y80 → y112). Glow is - centered on this block so it stays under the picker + InputBar together. */ +/* Workspace row sits 12px above the input card (figma y80 → y112). The blue + glow lives with the owner (ConversationRoot .heroGlow) so it can center on + the input card. */ .body { position: relative; display: flex; @@ -55,19 +53,7 @@ overflow: visible; } -/* Design input 776 → glow SVG 1051×468 (ellipse 851×268 + blur pad). */ -.glow { - position: absolute; - left: 50%; - top: 50%; - z-index: 0; - width: calc(100% * 1051 / 776); - aspect-ratio: 1051 / 468; - transform: translate(-50%, -50%); - pointer-events: none; -} - -.body > :not(.glow) { +.body > * { position: relative; z-index: 1; } @@ -88,7 +74,7 @@ display: inline-flex; align-items: center; gap: 4px; - max-width: fit-content; + max-width: min(100%, 360px); min-height: 28px; padding: 0 8px; border: none; 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 7b2711da39..0cb3920f6e 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -171,6 +171,10 @@ .input, .mirror, .backdrop { + /* Textareas default to content-box (unlike buttons/inputs): without this the + width:100% textarea gains its padding OUTSIDE the card and text runs past + the right padding — and wraps 28px later than the mirror/backdrop layers. */ + box-sizing: border-box; /* figma .InputText 34:10434: pl 16 / pr 12 / pt 4. Backdrop MUST share these metrics or the highlight ranges drift off the glyphs. */ padding: 4px 12px 0 16px; @@ -306,11 +310,14 @@ border: none; border-radius: 999px; background: var(--dsw-alias-button-info-fill); - color: var(--dsw-alias-label-primary-foreground); + /* Static white, not the foreground token: the arrow stays white on the blue + fill in both themes (design 34:10465). */ + color: #fff; cursor: pointer; + transition: background-color 100ms ease; } -.primary:hover { +.primary:hover:not(:disabled) { background: var(--dsw-alias-button-info-hover); } @@ -319,14 +326,6 @@ cursor: default; } -/* Stop state: same slot, dimmed brand fill — the running-state send-key - replacement is a design gap filled by us (figma gives no stop form). */ -.stopping, -.stopping:hover { - background: var(--dsw-alias-button-primary-dimmed); - color: var(--dsw-alias-label-primary); -} - .retry { margin-left: 8px; padding: 1px 8px; diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index a844c18a72..26a65def6a 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -372,7 +372,7 @@ export function InputBar({ {machineBusy && } diff --git a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css index 8086cbfea7..c6b50a2c77 100644 --- a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css @@ -92,15 +92,15 @@ color: var(--dsw-alias-state-success-primary); } +.glyphPending { + color: var(--dsw-alias-label-caption); +} + .glyphProgress { color: var(--dsw-alias-state-business-primary); animation: todo-progress-spin 1s linear infinite; } -.glyphPending { - color: var(--dsw-alias-label-caption); -} - @keyframes todo-progress-spin { to { transform: rotate(360deg); diff --git a/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css b/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css index 9c42e69b59..2e5d601e62 100644 --- a/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css +++ b/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css @@ -1,16 +1,35 @@ /* Bash toolview: same geometry/tokens as ToolRow (figma Bash · description). */ .root { + position: relative; /* sweep-glare overlay anchor */ + overflow: hidden; display: flex; align-items: center; height: 24px; min-width: 0; - cursor: pointer; - border-radius: 6px; } -.root:hover { - background: var(--dsw-alias-interactive-bg-hover); +/* Running sweep glare — same deepsuite ShimmerText pattern as ToolRow. */ +.root[data-state='running']::after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 300px; + background: linear-gradient( + 90deg, + transparent 0%, + color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%, + transparent 100% + ); + animation: dsh-bash-row-sweep 2.6s ease-out infinite; + pointer-events: none; +} + +@keyframes dsh-bash-row-sweep { + 0% { left: -300px; } + 90%, 100% { left: 100%; } } .leading { @@ -39,7 +58,7 @@ flex: none; font-size: 14px; line-height: 24px; - color: var(--dsw-alias-label-primary-dimmed); + color: var(--dsw-alias-label-secondary); } .sep { diff --git a/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx b/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx index 616eee5943..d972a35274 100644 --- a/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx +++ b/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx @@ -12,10 +12,10 @@ import css from './bash-sample.module.css' function leadingFor(state: ToolRowState) { switch (state) { - case 'running': return case 'error': return case 'stopped': return - default: return + // Running keeps the icon — the row sweep carries the in-flight signal. + default: return } } @@ -30,7 +30,7 @@ function stateStatus(state: ToolRowState): string | null { } /** Bash row: icon + Bash · {description}, matching the shared ToolRow chrome. */ -export function BashRow({ toolName, block, openDetails, sessionId, useSessions }: ToolRowProps) { +export function BashRow({ toolName, block, sessionId, useSessions }: ToolRowProps) { const model = toolRowModel(toolName, block) const isChild = useSessions(list => list.byId[sessionId]?.parentId !== undefined) const status = stateStatus(model.state) @@ -40,8 +40,6 @@ export function BashRow({ toolName, block, openDetails, sessionId, useSessions } data-sample={isChild ? 'bash-scoped' : 'bash-global'} data-variant="bash" data-state={model.state} - data-clickable - onClick={openDetails} > {leadingFor(model.state)} {status !== null && {status}} diff --git a/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css b/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css index 406a7901d3..d49f30485c 100644 --- a/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css +++ b/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css @@ -6,12 +6,6 @@ align-items: center; height: 24px; min-width: 0; - cursor: pointer; - border-radius: 6px; -} - -.row:hover { - background: var(--dsw-alias-interactive-bg-hover); } .leading { @@ -29,6 +23,7 @@ flex: none; font-size: 14px; line-height: 24px; + font-weight: 500; /* figma wt510, rendered 500 */ color: var(--dsw-alias-label-primary-dimmed); } diff --git a/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx b/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx index 5ba7d64e43..5797469536 100644 --- a/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx +++ b/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx @@ -6,7 +6,6 @@ // ellipsized text; the durable list itself renders in the TodoPanel above the // composer, so the row stays one line. Chrome matches ToolRow (figma 780:53675). -import type { KeyboardEvent } from 'react' import type { Context } from 'cordis' import { IconChecklistOutline16, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' import type { ToolRowProps } from '../contract/slots.ts' @@ -61,29 +60,18 @@ function leadingFor(state: ToolRowState) { } } -/** One-line plan update row (click opens the raw args in details). Non-ok - * execution states keep the generic row's dot semantics — a cancelled call - * wrote no todo/write, so it must not read as a completed update. */ -export function TodoRow({ toolName, block, openDetails }: ToolRowProps) { +/** One-line plan update row. Non-ok execution states keep the generic row's + * dot semantics — a cancelled call wrote no todo/write, so it must not read + * as a completed update. */ +export function TodoRow({ toolName, block }: ToolRowProps) { const model = toolRowModel(toolName, block) const argsRaw = ('kind' in block ? block.call?.argsRaw : block.argsRaw) ?? '' const summary = summarize(argsRaw) ?? { text: model.summary, extra: 0 } - // Button semantics, not a + {subOpen && entry.submenu !== undefined && ( +
+ {entry.submenu.map(sub => ( + + ))} +
+ )} +
+ ) + } + + // Portal lists render hidden until placed: the placement effect measures + // this pre-render in the same commit, so the first painted frame is + // already at the final position (with getAnchorRect returning null the + // list simply stays hidden). + const list = open && (
{ onClose() } : undefined} // React portals bubble synthetic events through the REACT tree: without @@ -158,63 +255,14 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align // (open/toggle) after onSelect. onClick={(e) => { e.stopPropagation() }} > - {items.map((entry) => { - if (isSeparator(entry)) { - return
- } - if (isLabel(entry)) { - return
{entry.text}
- } - const hasSub = entry.submenu !== undefined && entry.submenu.length > 0 - const subOpen = hasSub && openSubmenuId === entry.id - return ( -
{ setOpenSubmenuId(hasSub ? entry.id : null) }} - onMouseLeave={() => { setOpenSubmenuId(null) }} - > - - {subOpen && entry.submenu !== undefined && ( -
- {entry.submenu.map(sub => ( - - ))} -
- )} -
- ) - })} +
+ {items.map(renderEntry)} +
+ {footer !== undefined && footer.length > 0 && ( +
+ {footer.map(renderEntry)} +
+ )}
) diff --git a/packages/client/ui-primitives/src/Modal.module.css b/packages/client/ui-primitives/src/Modal.module.css index 02c075803e..0c6d68e5fa 100644 --- a/packages/client/ui-primitives/src/Modal.module.css +++ b/packages/client/ui-primitives/src/Modal.module.css @@ -54,7 +54,7 @@ margin: 0; font-size: 16px; line-height: 24px; - font-weight: 510; + font-weight: 500; /* figma wt510, rendered 500 */ color: var(--dsw-alias-label-primary); } diff --git a/packages/client/ui-primitives/src/StateDot.module.css b/packages/client/ui-primitives/src/StateDot.module.css index e5a8ebdb50..e9442f46bb 100644 --- a/packages/client/ui-primitives/src/StateDot.module.css +++ b/packages/client/ui-primitives/src/StateDot.module.css @@ -1,7 +1,7 @@ /* Ongoing blue has no alias token (state-business-primary is the 500 step, * not this 450) — component-level var pinned to the static scale instead. */ .dot, -.ring { +.matrix { --dsh-state-ongoing: var(--dsw-static-deepseek-450); } @@ -42,24 +42,24 @@ color: var(--dsw-alias-state-error-primary); } -.ring { +/* Pixel chase: each outer cell holds a discrete brightness step (flat keyframe + * holds, no tweening — the retro feel), peaking when the chase hits it and + * decaying over the next three cells. Phase offsets come from per-rect + * animation-delay (index * -125ms) set inline by the component. */ +.matrix { flex: none; color: var(--dsh-state-ongoing); - animation: dsh-state-dot-spin 1s linear infinite; } -.stopFrom { - stop-color: currentColor; - stop-opacity: 1; +.cell { + fill: currentColor; + opacity: 0.15; + animation: dsh-state-dot-chase 1s infinite; } -.stopTo { - stop-color: currentColor; - stop-opacity: 0; -} - -@keyframes dsh-state-dot-spin { - to { - transform: rotate(360deg); - } +@keyframes dsh-state-dot-chase { + 0%, 12.4% { opacity: 1; } + 12.5%, 24.9% { opacity: 0.6; } + 25%, 37.4% { opacity: 0.35; } + 37.5%, 100% { opacity: 0.15; } } diff --git a/packages/client/ui-primitives/src/StateDot.tsx b/packages/client/ui-primitives/src/StateDot.tsx index c4117673ba..643f9283f4 100644 --- a/packages/client/ui-primitives/src/StateDot.tsx +++ b/packages/client/ui-primitives/src/StateDot.tsx @@ -1,15 +1,19 @@ // StateDot: session state indicator (figma nodes 14:3303/3305/3312, 122:9182). // done/warning/error: 10x10 halo (same color, 10% opacity) around a 6x6 solid -// core. ongoing: 10x10 ring, 1px inside stroke, color fading out along a -// linear gradient, spinning. Colors resolve through --dsw-* tokens only. +// core. ongoing: a pixel-art chase — the 8 outer cells of a 3x3 matrix light +// up clockwise with a stepped trail. Colors resolve through --dsw-* tokens only. -import { useId } from 'react' import clsx from 'clsx' import css from './StateDot.module.css' /** Four-color session state semantic (green done / amber approval-waiting / blue running ring / red error). */ export type StateDotState = 'done' | 'warning' | 'ongoing' | 'error' +/** Outer 3x3 matrix cells (2px pixels on a 10px grid), clockwise from top-left. */ +const MATRIX_CELLS: readonly (readonly [number, number])[] = [ + [0, 0], [4, 0], [8, 0], [8, 4], [8, 8], [4, 8], [0, 8], [0, 4], +] + /** * Render a state dot. * @param props.state - which of the four states to show. @@ -22,25 +26,29 @@ export function StateDot({ state, size = 10, className }: { size?: number className?: string }) { - const gradientId = useId() if (state === 'ongoing') { return ( ) } diff --git a/packages/client/ui-primitives/src/Tooltip.tsx b/packages/client/ui-primitives/src/Tooltip.tsx index 2c48854055..30e0c5fe0e 100644 --- a/packages/client/ui-primitives/src/Tooltip.tsx +++ b/packages/client/ui-primitives/src/Tooltip.tsx @@ -72,7 +72,7 @@ export function Tooltip({ label, side = 'right', disabled = false, children }: { {cloneElement(children, { ref: mergedRef, onMouseEnter: (e) => { children.props.onMouseEnter?.(e); triggers.current.hover = true; show() }, - onMouseLeave: (e) => { children.props.onMouseLeave?.(e); triggers.current.hover = false; hide() }, + onMouseLeave: (e) => { children.props.onMouseLeave?.(e); triggers.current.hover = false; setPos(null) }, onFocus: (e) => { children.props.onFocus?.(e); triggers.current.focus = true; show() }, onBlur: (e) => { children.props.onBlur?.(e); triggers.current.focus = false; hide() }, })} diff --git a/packages/client/ui-primitives/tests/atoms.spec.tsx b/packages/client/ui-primitives/tests/atoms.spec.tsx index 9e298d057a..7724afa493 100644 --- a/packages/client/ui-primitives/tests/atoms.spec.tsx +++ b/packages/client/ui-primitives/tests/atoms.spec.tsx @@ -271,14 +271,47 @@ describe('Menu', () => { expect(onClose).toHaveBeenCalledTimes(1) }) - it('portal mode positions from the opposite edges for align=end / side=top', () => { + it('portal mode resolves align=end / side=top to clamped left/top coordinates', () => { render( trigger} items={items} onSelect={() => {}} onClose={() => {}} />) const menu = screen.getByRole('menu') - expect(menu.style.right).not.toBe('') - expect(menu.style.bottom).not.toBe('') - expect(menu.style.left).toBe('') - expect(menu.style.top).toBe('') + expect(menu.style.left).not.toBe('') + expect(menu.style.top).not.toBe('') + expect(menu.style.right).toBe('') + expect(menu.style.bottom).toBe('') + }) + + it('renders footer rows in a pinned section below the items; they still select', () => { + const onSelect = vi.fn() + render( + trigger} + items={items} + footer={[{ id: 'new', label: 'Create new' }]} + onSelect={onSelect} + onClose={() => {}} + />) + const footerItem = screen.getByRole('menuitem', { name: 'Create new' }) + expect((footerItem.closest('div[class*="footer"]'))).not.toBeNull() + expect(screen.getByRole('menuitem', { name: 'Alpha' }).closest('div[class*="footer"]')).toBeNull() + fireEvent.click(footerItem) + expect(onSelect).toHaveBeenCalledWith('new') + }) + + it('caps the list height for internal scrolling unless a submenu row is present', () => { + const { rerender } = render( + trigger} items={items} onSelect={() => {}} onClose={() => {}} />) + expect(screen.getByRole('menu').className).toMatch(/scrollable/) + rerender( + trigger} + items={[{ id: 'p', label: 'Parent', submenu: [{ id: 's', label: 'Sub' }] }]} + onSelect={() => {}} + onClose={() => {}} + />) + expect(screen.getByRole('menu').className).not.toMatch(/scrollable/) }) }) diff --git a/packages/client/ui-primitives/tests/state-dot.spec.tsx b/packages/client/ui-primitives/tests/state-dot.spec.tsx index a3759174ff..86e41c7787 100644 --- a/packages/client/ui-primitives/tests/state-dot.spec.tsx +++ b/packages/client/ui-primitives/tests/state-dot.spec.tsx @@ -14,16 +14,17 @@ describe('StateDot', () => { expect(dot.getAttribute('aria-hidden')).toBe('true') }) - it('solid states are spans; ongoing is an svg gradient ring', () => { + it('solid states are spans; ongoing is an svg pixel matrix', () => { const { container, rerender } = render() expect(container.firstElementChild?.tagName).toBe('SPAN') rerender() - const ring = container.firstElementChild as SVGSVGElement - expect(ring.tagName).toBe('svg') - const circle = ring.querySelector('circle') - expect(circle?.getAttribute('stroke-width')).toBe('1') - expect(circle?.getAttribute('stroke')).toMatch(/^url\(#/) - expect(ring.querySelector('linearGradient')).not.toBeNull() + const matrix = container.firstElementChild as SVGSVGElement + expect(matrix.tagName).toBe('svg') + const cells = matrix.querySelectorAll('rect') + expect(cells).toHaveLength(8) + // Chase phase: every cell carries its own negative animation delay. + const delays = [...cells].map(cell => (cell).style.animationDelay) + expect(new Set(delays).size).toBe(8) }) it('sizes via the size prop in both shapes', () => { diff --git a/packages/client/ui-primitives/tests/tooltip.spec.tsx b/packages/client/ui-primitives/tests/tooltip.spec.tsx index 6741921c66..591a5eb67a 100644 --- a/packages/client/ui-primitives/tests/tooltip.spec.tsx +++ b/packages/client/ui-primitives/tests/tooltip.spec.tsx @@ -81,23 +81,20 @@ describe('Tooltip', () => { expect(screen.getByRole('tooltip')).toBeTruthy() }) - it('keeps the bubble while either hover or focus is still active', () => { + it('mouse leave hides the bubble immediately, even while the anchor stays focused', () => { render( , ) const anchor = screen.getByText('anchor') - // Focused AND hovered: leaving with the mouse must not drop the bubble. + // Focused AND hovered: leaving with the mouse drops the bubble at once. fireEvent.focus(anchor) fireEvent.mouseEnter(anchor) fireEvent.mouseLeave(anchor) - expect(screen.getByRole('tooltip')).toBeTruthy() - fireEvent.blur(anchor) expect(screen.queryByRole('tooltip')).toBeNull() - // Symmetric: blurring while still hovered keeps it, mouseleave ends it. + // Re-entering shows it again; blurring while still hovered keeps it. fireEvent.mouseEnter(anchor) - fireEvent.focus(anchor) fireEvent.blur(anchor) expect(screen.getByRole('tooltip')).toBeTruthy() fireEvent.mouseLeave(anchor) diff --git a/packages/client/ui-settings-general/src/client/GeneralSection.module.css b/packages/client/ui-settings-general/src/client/GeneralSection.module.css index 5e053a49c5..aced3b2962 100644 --- a/packages/client/ui-settings-general/src/client/GeneralSection.module.css +++ b/packages/client/ui-settings-general/src/client/GeneralSection.module.css @@ -72,6 +72,10 @@ cursor: pointer; } +.selector:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover); +} + .selector:disabled { cursor: default; } @@ -80,18 +84,21 @@ flex: none; } -/* Tool Call mode cubes share an 8px gap. */ +/* Tool Call mode cubes share an 8px gap and wrap to one per row when the + panel is too narrow. */ .cubeRow { display: flex; align-items: stretch; gap: 8px; + flex-wrap: wrap; } -/* Tool Call mode cube (figma '.Selector Cube' 418w r16; horizontal inset = - * outer pad 4 + inner .Menu_cell pad 10, vertical = inner pad 8). */ +/* Tool Call mode cube (figma '.Selector Cube' 418w r16, flexed to fit the + * 800 panel; horizontal inset = outer pad 4 + inner .Menu_cell pad 10, + * vertical = inner pad 8). */ .modeCube { box-sizing: border-box; - width: 418px; + flex: 1 1 276px; display: flex; flex-direction: column; justify-content: center; @@ -101,6 +108,11 @@ border-radius: 16px; background: transparent; text-align: left; + cursor: pointer; +} + +.modeCube:hover:not(.selected) { + background: var(--dsw-alias-interactive-bg-hover); } /* Selected cube: #F5F6F7 fill + #ADB2B8 border (static token — the bluish-400 diff --git a/packages/client/ui-settings/src/client/SettingsRoot.module.css b/packages/client/ui-settings/src/client/SettingsRoot.module.css index e2b2c878df..5f7ca9ec23 100644 --- a/packages/client/ui-settings/src/client/SettingsRoot.module.css +++ b/packages/client/ui-settings/src/client/SettingsRoot.module.css @@ -44,7 +44,8 @@ white-space: nowrap; } -/* Full-viewport layer (figma Mask 501:29946 #000@24%, no blur). */ +/* Full-viewport layer (figma Mask 501:29946 #000@24%): mask tokens match the + Modal primitive (--dsw-alias-bg-mask-1 + --dsw-mask-blur). */ .overlay { position: fixed; inset: 0; @@ -58,21 +59,22 @@ position: absolute; inset: 0; background: var(--dsw-alias-bg-mask-1); + backdrop-filter: var(--dsw-mask-blur); } -/* Panel (figma Settings 501:29947): 1080x700, r24, white, lv3 shadow - (figma effects match --dsw-shadow-lv3 exactly). */ +/* Panel (figma Settings 501:29947): r24, white, lv3 shadow (figma effects + match --dsw-shadow-lv3 exactly); figma's 1080x700 is shrunk to 800x600. */ .panel { position: relative; z-index: 1; display: flex; - width: 1080px; - height: 700px; + width: 800px; + height: 600px; max-width: calc(100vw - 48px); max-height: calc(100vh - 48px); border-radius: 24px; overflow: hidden; - background: var(--dsw-alias-bg-layer-1); + background: var(--dsw-alias-bg-layer-2); box-shadow: var(--dsw-shadow-lv3); } diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css index fe73435df8..ebb47467af 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css @@ -79,13 +79,19 @@ /* Brand group (figma I133:7632): the full wordmark rides the text ink (figma-flows ruling: main-screen instance is black; blue is brand - emphasis only). */ + emphasis only). A button only in behavior (New Session shortcut): the + pointer cursor is the sole affordance — no hover chrome on the mark. */ .brand { flex: 1; min-width: 0; display: inline-flex; align-items: center; overflow: hidden; + padding: 0; + border: none; + background: transparent; + color: inherit; + cursor: pointer; } .iconButton { diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx index f5f810cbbe..30d705c780 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx @@ -61,10 +61,17 @@ export function SidebarRoot({ style={wide ? { width: collapsed ? lastWideWidth.current : width } : undefined} >
+ {/* Expanded, the wordmark doubles as a New Session shortcut; the + collapsed rail's logo is the expand toggle below instead. */} {wide && ( - + )} {/* Rail resting state is the whale mark; hovering swaps in the panel icon (the expand affordance, figma sidebar-hover flow). */} diff --git a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx index 458ed0bda4..3c8086e4ce 100644 --- a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx +++ b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx @@ -54,10 +54,13 @@ function mountShell({ collapsed = false, width = 300 }: { collapsed?: boolean; w } describe('SidebarRoot shell', () => { - it('routes New Session and the column toggle', () => { + it('routes New Session (capsule + wordmark) and the column toggle', () => { const b = mountShell() - fireEvent.click(screen.getByRole('button', { name: 'New session' })) - expect(b.startSession).toHaveBeenCalledWith() + // Expanded, both the wordmark and the capsule start a session. + const starters = screen.getAllByRole('button', { name: 'New session' }) + expect(starters).toHaveLength(2) + for (const button of starters) fireEvent.click(button) + expect(b.startSession).toHaveBeenCalledTimes(2) fireEvent.click(screen.getByRole('button', { name: 'Collapse sidebar' })) expect(b.toggleSidebar).toHaveBeenCalledOnce() }) diff --git a/packages/client/ui-theme/src/client/AppearanceRow.module.css b/packages/client/ui-theme/src/client/AppearanceRow.module.css index 9619caa823..6c5354b0ac 100644 --- a/packages/client/ui-theme/src/client/AppearanceRow.module.css +++ b/packages/client/ui-theme/src/client/AppearanceRow.module.css @@ -20,13 +20,15 @@ display: flex; align-items: stretch; gap: 8px; + flex-wrap: wrap; } /* Appearance cube (figma '.Selector Cube' 276x82 r16, pad 20/32, centered - * icon-over-label column, gap 4). */ + * icon-over-label column, gap 4); flexed down from the figma width so all + * three sit on one row in the 800 panel, wrapping when narrower. */ .themeCube { box-sizing: border-box; - width: 276px; + flex: 1 1 180px; display: flex; flex-direction: column; align-items: center; @@ -43,6 +45,10 @@ cursor: pointer; } +.themeCube:hover:not(.selected) { + background: var(--dsw-alias-interactive-bg-hover); +} + /* Selected cube: #F5F6F7 fill + #ADB2B8 border (static token — the bluish-400 * step has no alias-layer name). */ .selected { diff --git a/packages/client/ui-theme/src/styles/design-platform.css b/packages/client/ui-theme/src/styles/design-platform.css index e00ec415b7..3e8710822e 100644 --- a/packages/client/ui-theme/src/styles/design-platform.css +++ b/packages/client/ui-theme/src/styles/design-platform.css @@ -1,3 +1,6 @@ +/* Figma font-weight 510 (an SF Pro variable-font weight) always renders as + font-weight: 500 in this UI — non-variable webfonts snap intermediate + weights unpredictably across platforms. */ body { --dsw-static-amber-100: rgb(254, 245, 231); --dsw-static-amber-400: rgb(247, 173, 49); @@ -20,7 +23,7 @@ body { --dsw-static-deepseek-300: rgb(183, 200, 254); --dsw-static-deepseek-400: rgb(103, 158, 254); --dsw-static-deepseek-450: rgb(86, 134, 254); - --dsw-static-deepseek-500: rgb(57, 100, 254); + --dsw-static-deepseek-500: rgb(65, 118, 230); --dsw-static-deepseek-50: rgb(237, 243, 254); --dsw-static-deepseek-600: rgb(72, 104, 178); --dsw-static-deepseek-700-delete: rgb(47, 76, 143); @@ -95,7 +98,7 @@ body[data-ds-dark-theme] { --dsw-static-deepseek-300: rgb(183, 200, 254); --dsw-static-deepseek-400: rgb(103, 158, 254); --dsw-static-deepseek-450: rgb(86, 134, 254); - --dsw-static-deepseek-500: rgb(57, 100, 254); + --dsw-static-deepseek-500: rgb(65, 118, 230); --dsw-static-deepseek-50: rgb(237, 243, 254); --dsw-static-deepseek-600: rgb(72, 104, 178); --dsw-static-deepseek-700-delete: rgb(47, 76, 143); @@ -302,7 +305,7 @@ body[data-ds-dark-theme] { --dsw-alias-scrollbar-bg-l2: var(--dsw-static-neutral-600); --dsw-alias-scrollbar-hover-l1: var(--dsw-static-neutral-600); --dsw-alias-scrollbar-hover-l2: var(--dsw-static-neutral-550); - --dsw-alias-state-business-primary: var(--dsw-static-deepseek-500); + --dsw-alias-state-business-primary: var(--dsw-static-deepseek-400); --dsw-alias-state-business-tertiary: var(--dsw-static-deepseek-800); --dsw-alias-state-error-primary: var(--dsw-static-red-400); --dsw-alias-state-error-secondary: var(--dsw-static-red-400); diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css b/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css index c03d511c92..7dcb4f9b9d 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css @@ -129,6 +129,7 @@ reads the shell's class names): the two icon controls stack as 36x36 circles matching the shell's rail rhythm. */ .rail .sectionHeader { + gap: 0; padding-left: 0; margin-bottom: 12px; } diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index ea1753a63e..dfca686a6e 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -265,6 +265,7 @@ export function WorkspaceBrowser({ // states; the menu anchors on this button). const [wsPickerOpen, setWsPickerOpen] = useState(false) const wsPlusRef = useRef(null) + const composingRef = useRef(false) // Rail search = expand + land in the search box: the flag arms before the // expand request; once the shell flips wide the input mounts and takes focus. @@ -358,7 +359,6 @@ export function WorkspaceBrowser({ className={css.iconButton} aria-label="Create workspace" onClick={() => { - if (!wide) expandSidebar() setWsPickerOpen(v => !v) }} > @@ -372,6 +372,8 @@ export function WorkspaceBrowser({ useWorkspaces={useWorkspaces} createWorkspace={createWorkspace} pickDirectory={pickDirectory} + createOnly + side="right" onPick={(workspaceId) => { setWsPickerOpen(false) startSession(workspaceId) @@ -459,9 +461,12 @@ export function WorkspaceBrowser({ aria-label="Workspace name" autoFocus disabled={renaming} + onFocus={(e) => { e.target.select() }} onChange={(e) => { setRenameDraft(e.target.value); setRenameError(null) }} + onCompositionStart={() => { composingRef.current = true }} + onCompositionEnd={() => { composingRef.current = false }} onKeyDown={(e) => { - if (e.key === 'Enter') { + if (e.key === 'Enter' && !composingRef.current) { e.preventDefault() confirmRename() } diff --git a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx index 4ec84c7d3d..f33e7dc526 100644 --- a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx +++ b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx @@ -5,7 +5,7 @@ * slot registration. */ import type { RefObject } from 'react' -import { useCallback, useState } from 'react' +import { useCallback, useRef, useState } from 'react' import { Button, IconFolderClose16, IconPlusOutline16, Menu, Modal, type MenuEntry, } from '@deepseek-ai/dsh-client-ui-primitives' @@ -37,6 +37,12 @@ export interface WorkspaceCreateFlowProps { onPick: (workspaceId: WorkspaceId) => void /** Close the popover (outside click / Escape / post-pick). */ onClose: () => void + /** Only show create actions (open folder / create new), hide existing workspaces. */ + createOnly?: boolean + /** Menu opening direction relative to the anchor. */ + side?: 'bottom' | 'top' | 'right' + /** Currently active workspace (trailing check in the picker list). */ + selectedId?: WorkspaceId | undefined } /** @@ -52,6 +58,9 @@ export function WorkspaceCreateFlow({ pickDirectory, onPick, onClose, + createOnly = false, + side = 'bottom', + selectedId, }: WorkspaceCreateFlowProps) { const workspaceSnapshot = useWorkspaces(state => state) const workspaces = workspaceSnapshot.items @@ -65,21 +74,26 @@ export function WorkspaceCreateFlow({ const [modalError, setModalError] = useState(null) const [pickingFolder, setPickingFolder] = useState(false) const [folderConflict, setFolderConflict] = useState(false) + const composingRef = useRef(false) const normalizedWorkspaceName = workspaceName.trim() const duplicateWorkspaceName = !creating && normalizedWorkspaceName !== '' && workspaces.some(workspace => workspace.title === normalizedWorkspaceName) - const items: MenuEntry[] = [ - ...workspaces.map(workspace => ({ + const createEntries: MenuEntry[] = [ + { id: OPEN_LOCAL_FOLDER, label: 'Open local folder…', icon: , disabled: pickingFolder }, + { id: CREATE_NEW, label: 'Create a new workspace', icon: , disabled: pickingFolder }, + ] + // With workspaces listed, the create actions pin below the scroll region + // (divider + always visible); otherwise they ARE the menu. + const pinCreate = !createOnly && workspaces.length > 0 + const items: MenuEntry[] = pinCreate + ? workspaces.map(workspace => ({ id: workspace.workspaceId, label: workspace.title, icon: , disabled: pickingFolder, - })), - ...(workspaces.length > 0 ? [{ type: 'separator' as const, id: 'sep-create' }] : []), - { id: OPEN_LOCAL_FOLDER, label: 'Open local folder…', icon: , disabled: pickingFolder }, - { id: CREATE_NEW, label: 'Create a new workspace', icon: , disabled: pickingFolder }, - ] + })) + : createEntries const closeModal = (): void => { if (creating) return @@ -114,7 +128,7 @@ export function WorkspaceCreateFlow({ } if (id === CREATE_NEW) { onClose() - setWorkspaceName('workspace') + setWorkspaceName('') setModalError(null) setModalKind('create') return @@ -149,8 +163,11 @@ export function WorkspaceCreateFlow({ open={open} anchor={null} items={items} + {...pinCreate ? { footer: createEntries } : {}} + selectedId={selectedId} onSelect={handleSelect} onClose={onClose} + side={side} portal getAnchorRect={getAnchorRect} /> @@ -194,12 +211,15 @@ export function WorkspaceCreateFlow({ { setWorkspaceName(event.target.value); setModalError(null) }} + onCompositionStart={() => { composingRef.current = true }} + onCompositionEnd={() => { composingRef.current = false }} onKeyDown={(event) => { - if (event.key === 'Enter') { + if (event.key === 'Enter' && !composingRef.current) { event.preventDefault() confirmCreate() } @@ -225,6 +245,7 @@ export function WorkspacePicker({ open, anchorRef, useWorkspaces, + selectedId, onPick, onClose, createWorkspace, @@ -237,6 +258,7 @@ export function WorkspacePicker({ useWorkspaces={useWorkspaces} createWorkspace={createWorkspace} pickDirectory={pickDirectory} + selectedId={selectedId} onPick={onPick} onClose={onClose} /> diff --git a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx index 7b8462f6db..e8405c6bcd 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx @@ -263,25 +263,21 @@ describe('WorkspaceBrowser', () => { } }) - it('rail create-workspace expands the shell and opens the picker; wide toggles in place', () => { + it('rail create-workspace toggles the create-only picker in place, without expanding', () => { const expandSidebar = vi.fn() - const b = mount({ wide: false, expandSidebar, useWorkspaces: hook(workspaceState([workspace('alpha', [])])) }) + mount({ wide: false, expandSidebar, useWorkspaces: hook(workspaceState([workspace('alpha', [])])) }) fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) - expect(expandSidebar).toHaveBeenCalledTimes(1) - rerender(b, { wide: true }) - // The picker menu is open (anchored on the +); picking starts a session. - fireEvent.click(screen.getByRole('menuitem', { name: 'alpha' })) - expect(b.props.startSession).toHaveBeenCalledWith(wid('alpha')) - expect(screen.queryByRole('menu')).toBeNull() - // Wide toggle: open and close without expand requests. - fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) - expect(screen.getByRole('menu')).toBeTruthy() + expect(expandSidebar).not.toHaveBeenCalled() + // createOnly: existing workspaces are not listed, only the create actions. + expect(screen.queryByRole('menuitem', { name: 'alpha' })).toBeNull() + expect(screen.getByRole('menuitem', { name: 'Open local folder…' })).toBeTruthy() + // Toggle: open and close in place. fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) expect(screen.queryByRole('menu')).toBeNull() - expect(expandSidebar).toHaveBeenCalledTimes(1) // Escape closes the picker through its own onClose. fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) + expect(screen.getByRole('menu')).toBeTruthy() fireEvent.keyDown(document, { key: 'Escape' }) expect(screen.queryByRole('menu')).toBeNull() }) diff --git a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx index 6cad175ff3..081ff0c044 100644 --- a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx @@ -205,6 +205,8 @@ describe('WorkspacePicker', () => { it('reports non-Error creation failures', async () => { const b = mount([], vi.fn(async () => { throw 'permission denied' })) chooseItem('Create a new workspace') + // The name field starts empty (no prefill); a name is required to submit. + fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: 'broken' } }) fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('Workspace creation failed: permission denied') diff --git a/packages/client/web/src/base.css b/packages/client/web/src/base.css index b8449634eb..03593fa829 100644 --- a/packages/client/web/src/base.css +++ b/packages/client/web/src/base.css @@ -15,6 +15,10 @@ body, body { font-family: var(--dsw-font-family); + /* Grayscale antialiasing over subpixel rendering: WebKit/Blink and the + Firefox macOS equivalent; other engines ignore both lines. */ + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-base); } diff --git a/packages/compact/compact/src/invariant.ts b/packages/compact/compact/src/invariant.ts index da5d5eba5a..7dd06722e8 100644 --- a/packages/compact/compact/src/invariant.ts +++ b/packages/compact/compact/src/invariant.ts @@ -17,6 +17,11 @@ interface CompactionTrace { summarized: boolean } +interface SessionTrace { + openTurn: number | null + compaction: CompactionTrace | undefined +} + type CompactionTransition = | { kind: 'start'; turn: number } | { kind: 'summary'; turn: number } @@ -24,16 +29,27 @@ type CompactionTransition = /** Validate one compaction event without advancing committed trace state. */ function validateCompactionEvent( - open: CompactionTrace | undefined, + trace: SessionTrace, event: SessionEvent, fail: InvariantFailure, ): CompactionTransition | undefined { + if (event.type !== 'compact/start' && event.type !== 'compact/summary' && event.type !== 'compact/end') { + return undefined + } + if (trace.openTurn === null) fail(`${event.type} appended outside any open turn`) + const open = trace.compaction if (event.type === 'compact/start') { if (open !== undefined) fail(`compact/start for turn ${event.data.turn} while turn ${open.turn} is still compacting`) + if (event.data.turn !== trace.openTurn) { + fail(`compact/start names turn ${event.data.turn} but open turn is ${trace.openTurn}`) + } return { kind: 'start', turn: event.data.turn } } if (event.type === 'compact/summary') { if (open === undefined) fail('compact/summary has no matching compact/start') + if (open.turn !== trace.openTurn) { + fail(`compact/summary belongs to turn ${open.turn} but open turn is ${trace.openTurn}`) + } if (open.summarized) fail('compact/summary repeated within one compaction') const seqs = event.data.shadowedSeqs if (seqs.length === 0) fail('compact/summary shadowedSeqs must be non-empty') @@ -45,11 +61,13 @@ function validateCompactionEvent( } return { kind: 'summary', turn: open.turn } } - if (event.type !== 'compact/end') return undefined if (open === undefined) fail('compact/end has no matching compact/start') if (event.data.turn !== open.turn) { fail(`compact/end turn ${event.data.turn} does not match compact/start turn ${open.turn}`) } + if (event.data.turn !== trace.openTurn) { + fail(`compact/end names turn ${event.data.turn} but open turn is ${trace.openTurn}`) + } if (event.data.error === undefined && !open.summarized) { fail('successful compact/end requires one compact/summary') } @@ -69,29 +87,39 @@ function applyCompactionTransition( // Event owners keep precommit staging local so their vocabularies never move into a central helper. /* jscpd:ignore-start */ const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { - const traces = new WeakMap() + const traces = new WeakMap() const staged = new WeakMap() - const seed = (session: Session): void => { - let open: CompactionTrace | undefined + const seed = (session: Session): SessionTrace => { + const trace: SessionTrace = { openTurn: null, compaction: undefined } + traces.set(session, trace) for (const event of session.events) { - const transition = validateCompactionEvent(open, event, fail) - if (transition !== undefined) open = applyCompactionTransition(transition) + if (event.type === 'turn/start') trace.openTurn = event.data.turn + else if (event.type === 'turn/end') trace.openTurn = null + const transition = validateCompactionEvent(trace, event, fail) + if (transition !== undefined) trace.compaction = applyCompactionTransition(transition) } - if (open !== undefined) traces.set(session, open) + return trace } - const traceFor = (session: Session): CompactionTrace | undefined => traces.get(session) + const traceFor = (session: Session): SessionTrace => traces.get(session) ?? seed(session) for (const session of ctx.sessions.list()) seed(session) ctx.on('session/created', (session) => { seed(session) }, { global: true }) ctx.on('session/event', (session, event) => { + const trace = traceFor(session) + if (event.type === 'turn/start') { + trace.openTurn = event.data.turn + return + } + if (event.type === 'turn/end') { + trace.openTurn = null + return + } if (event.type !== 'compact/start' && event.type !== 'compact/summary' && event.type !== 'compact/end') return const candidate = staged.get(event) /* v8 ignore next -- internal/dispatch stages every compaction event */ if (candidate === undefined || candidate.session !== session) return fail('compaction event published without pre-commit validation') staged.delete(event) - const next = applyCompactionTransition(candidate.transition) - if (next === undefined) traces.delete(session) - else traces.set(session, next) + trace.compaction = applyCompactionTransition(candidate.transition) }, { global: true }) ctx.on('internal/dispatch', (_mode, eventName, args) => { if (eventName !== 'session/event') return diff --git a/packages/compact/compact/tests/invariant.spec.ts b/packages/compact/compact/tests/invariant.spec.ts index 2f4d10ff71..f5fc5c87c7 100644 --- a/packages/compact/compact/tests/invariant.spec.ts +++ b/packages/compact/compact/tests/invariant.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import SessionStore from '@deepseek-ai/dsh-session' +import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' import * as CompactInvariant from '@deepseek-ai/dsh-compact/invariant' import InvariantService from '@deepseek-ai/dsh-invariants' @@ -22,15 +22,21 @@ const summary = (overrides: Record = {}) => ({ ...overrides, }) +function startTurn(session: ReturnType, turn = 1): void { + session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } }) +} + describe('compaction invariants', () => { it('accepts successful and failed compaction lifecycles', async () => { const ctx = await setup() const success = ctx.sessions.create() + startTurn(success) success.append('compact/start', { turn: 1 }) success.append('compact/summary', summary()) success.append('compact/end', { turn: 1 }) const failed = ctx.sessions.create() + startTurn(failed, 2) failed.append('compact/start', { turn: 2 }) failed.append('compact/end', { turn: 2, error: 'provider failed' }) }) @@ -40,13 +46,68 @@ describe('compaction invariants', () => { await ctx.plugin(SessionStore) const session = ctx.sessions.create() session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('compact/start', { turn: 3 }) + session.append('compact/start', { turn: 1 }) await ctx.plugin(InvariantService) await ctx.plugin(CompactInvariant) - expect(() => session.append('compact/end', { turn: 3, error: 'resume failed' })).not.toThrow() + expect(() => session.append('compact/end', { turn: 1, error: 'resume failed' })).not.toThrow() session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) }) + it('adopts a bare session and ignores unrelated committed events', async () => { + const ctx = await setup() + const session = new Session(SessionId('bare-compaction-session')) + expect(() => { + ctx.emit('session/event', session, { + type: 'turn/start', seq: 0, time: 0, + data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + }) + ctx.emit('session/event', session, { + type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 }, + }) + ctx.emit('session/event', session, { + type: 'compact/start', seq: 2, time: 2, data: { turn: 1 }, + }) + }).not.toThrow() + }) + + it('rejects compaction outside or for a different open turn', async () => { + const ctx = await setup() + const session = ctx.sessions.create() + expect(() => session.append('compact/start', { turn: 1 })).toThrow(/outside any open turn/) + startTurn(session) + expect(() => session.append('compact/start', { turn: 2 })).toThrow(/but open turn is 1/) + }) + + it('rejects an unenclosed compaction event when replaying an existing session', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const session = ctx.sessions.create() + startTurn(session) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + session.append('compact/start', { turn: 1 }) + await ctx.plugin(InvariantService) + await expect(ctx.plugin(CompactInvariant).then(() => undefined)).rejects.toThrow(/outside any open turn/) + }) + + it('rejects an open compaction that crosses into another turn', async () => { + const ctx = await setup() + const summarySession = ctx.sessions.create() + startTurn(summarySession) + summarySession.append('compact/start', { turn: 1 }) + summarySession.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + startTurn(summarySession, 2) + expect(() => summarySession.append('compact/summary', summary())) + .toThrow(/belongs to turn 1 but open turn is 2/) + + const endSession = ctx.sessions.create() + startTurn(endSession) + endSession.append('compact/start', { turn: 1 }) + endSession.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + startTurn(endSession, 2) + expect(() => endSession.append('compact/end', { turn: 1, error: 'late' })) + .toThrow(/names turn 1 but open turn is 2/) + }) + it.each([ ['summary without start', (session: ReturnType) => { session.append('compact/summary', summary()) @@ -85,6 +146,8 @@ describe('compaction invariants', () => { }, /requires one compact\/summary/], ])('rejects %s', async (_name, action, message) => { const ctx = await setup() - expect(() => { action(ctx.sessions.create()) }).toThrow(message) + const session = ctx.sessions.create() + startTurn(session) + expect(() => { action(session) }).toThrow(message) }) }) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 084652ea26..47f8a1a54f 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -634,10 +634,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'async flush(session: Session): Promise', jsDoc: '/**\n * Dispatch the awaited `session/flush` durability checkpoint for `session`,\n * with the carrier captured at {@link enter}. THE flush entry point: the\n * store owns the carrier, so callers (the loop\'s turn-end checkpoint, idle\n * injection, teardown drains) must come through here rather than dispatch a\n * raw `ctx.parallel(\'session/flush\', …)` — one owner, one spelling, and the\n * scoped-dispatch invariant can pin it.\n * @param session - the session whose buffered events must reach durable storage.\n * @returns resolves when every flush listener has settled; after all settle,\n * rejects with the first registered listener failure if any listener failed.\n */', }, - { - signature: 'async appendOutOfBand( session: Session, type: T, data: SessionEventMap[T], trigger: TurnTrigger, ): Promise>', - jsDoc: '/**\n * Append one plugin-declared log-only event without borrowing the agent\n * loop\'s lifecycle. An open turn receives the event directly and remains\n * responsible for its ordinary checkpoint. A closed log receives one\n * zero-step turn around the event, followed by an awaited flush.\n *\n * Once the synthetic `turn/start` commits, this method always attempts its\n * matching `turn/end` and flush, including when the target append fails.\n * Detachment requested by an event or flush listener is deferred until that\n * sequence settles, so publication cannot switch from a live scoped session\n * to an unobserved bare `Session` halfway through the update.\n *\n * @param session - exact live session that owns the target log.\n * @param type - event type opted into {@link OutOfBandSessionEventMap} by its owner.\n * @param data - typed JSON payload for the target event.\n * @param trigger - plugin-owned turn trigger used only when the log is closed.\n * @returns the accepted target event with its assigned sequence and timestamp.\n * @throws when the session is detached, another out-of-band append is active,\n * event acceptance fails, the synthetic turn cannot close, or flushing fails.\n */', - }, { signature: 'get(id: SessionId): Session | undefined', jsDoc: '/**\n * Look up a live session.\n * @param id - the session id to look up.\n * @returns the session, or undefined when no live session has that id.\n */', @@ -648,7 +644,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session', - jsDoc: '/**\n * Create a live child session from a turn-enclosed prefix of a live source.\n * `boundary` is an inclusive source event seq; omitted means the source\'s\n * current last event. A non-empty selected slice must end at `turn/end`.\n *\n * @param source - Live source session object or id.\n * @param boundary - Inclusive source event seq to fork through; omitted means\n * the source\'s current last event, and omitted on an empty source forks an\n * empty child.\n * @param childSessionId - Optional child session id; omitted delegates to\n * `SessionStore`\'s id policy.\n * @returns The created live child session.\n */', + jsDoc: '/**\n * Create a live child session from a stable prefix of a live source.\n * `boundary` is an inclusive source event seq; omitted means the source\'s\n * current last event. The selected slice may end with a between-turn event\n * but must not end inside an open turn.\n *\n * @param source - Live source session object or id.\n * @param boundary - Inclusive source event seq to fork through; omitted means\n * the source\'s current last event, and omitted on an empty source forks an\n * empty child.\n * @param childSessionId - Optional child session id; omitted delegates to\n * `SessionStore`\'s id policy.\n * @returns The created live child session.\n */', }, ], }, @@ -662,7 +658,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'async refresh(session: Session, signal?: AbortSignal): Promise', - jsDoc: '/**\n * Explicitly retry the registered provider, or materialize the built-in\n * fallback when no provider is registered.\n * @param session - exact live session to refresh.\n * @param signal - optional caller cancellation; an in-progress fallback append may finish durably before rejection.\n * @returns latest accepted title, or `undefined` when no eligible text exists.\n */', + jsDoc: '/**\n * Explicitly retry the registered provider, or materialize the built-in\n * fallback when no provider is registered.\n * @param session - exact live session to refresh.\n * @param signal - optional caller cancellation.\n * @returns latest accepted title, or `undefined` when no eligible text exists.\n */', }, { signature: 'register(provider: SessionTitleProvider): () => Promise', @@ -1805,14 +1801,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ObjectJsonSchema', declaration: 'export type ObjectJsonSchema = JsonSchemaNode & {\n type: \'object\';\n};', }, - { - name: 'OutOfBandSessionEventMap', - declaration: 'export interface OutOfBandSessionEventMap {\n}', - }, - { - name: 'OutOfBandSessionEventType', - declaration: 'export type OutOfBandSessionEventType = Exclude, SurfaceEventType>;', - }, { name: 'PreparedLlmCall', declaration: 'export interface PreparedLlmCall {\n readonly config: LlmCallConfig;\n stream(options: GenerateOptions): AsyncIterable;\n}', diff --git a/packages/core/session/README.i18n.yaml b/packages/core/session/README.i18n.yaml index b52609f52f..cc97d6d28a 100644 --- a/packages/core/session/README.i18n.yaml +++ b/packages/core/session/README.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 -README.md: 95b67fc5977a73d0b7fbf8d37d27eccfcd981338 -README.zh.md: 47c97256fb14a21adef2a10589d0d7fa22ab2646 +# pnpm run verify-translation-pairing --write packages/core/session/README.md +README.md: f54dc3048fb9ee765a420f387dce9a729c8a85ef +README.zh.md: a3737a12ebe5c86d77ceb6776359a84fb7f0d84f diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 95b67fc597..f54dc3048f 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -14,9 +14,8 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall - `ctx.sessions.create(id?, { seed?, meta? }?)` validates and detaches durable seed/header data, fills the version and id, defaults `createdAt` to now, publishes the session, and binds it to the calling fiber. Persisted reconstruction supplies its original `createdAt`, `seedLength`, and `delegationDepth`. - `ctx.sessions.flush(session)` dispatches the awaited parallel durability checkpoint through the session's captured scope. Every listener starts and the call waits for all to settle before reporting failure; unpublished, detached, and stale objects reject. -- `ctx.sessions.appendOutOfBand(session, type, data, trigger)` accepts only plugin event types opted into `OutOfBandSessionEventMap`. It appends directly inside an open turn; otherwise it atomically opens a zero-step plugin turn, appends, closes, and flushes. A target failure still closes and flushes the synthetic turn, and detach is deferred until the sequence settles. -- `findLastMessageTurnEnd(events)` pairs message-triggered starts with their ends and returns the latest matched `turn/end`. Outcome consumers use this fold instead of the raw latest turn boundary because a later injection or plugin-owned zero-step turn has its own outcome. -- `ctx.sessions.fork(source, boundary?, childSessionId?): Session` — Resolve a live session object or id, select a seed through the inclusive `boundary` event seq (default: current last event), require that boundary to be `turn/end`, and create a live child session with lineage metadata. +- `findLastMessageTurnEnd(events)` pairs message-triggered starts with their ends and returns the latest matched `turn/end`. Outcome consumers use this fold instead of the raw latest log event because between-turn records and non-message turns have no prompt outcome. +- `ctx.sessions.fork(source, boundary?, childSessionId?): Session` — Resolve a live session object or id, select a seed through the inclusive `boundary` event seq (default: current last event), require that prefix to end outside an open turn, and create a live child session with lineage metadata. - `ctx.sessions.get(id: SessionId): Session | undefined` - `ctx.sessions.list(): Session[]` @@ -74,7 +73,7 @@ A `user/message` renders its `content` verbatim as a user-role message whether i The append-only log's event types, enumerated member by member — payloads, surface badges, provenance — in the generated [persistence log event catalog](../../../docs/persistence-catalog.md). Token accounting reads per-step `assistant/chunk { type: 'usage' }` records and treats `assistant/message.usage` as the committed-step fallback when no usage chunk exists; failed model-request attempts have no assistant message. Provider/model/replay provenance rides on `assistant/message`; an operational error's step is on `turn/end.reason` for `kind: 'error'`, with structured provider facts for a final model-request failure. -Merge-extensible via `SessionEventMap` — a plugin declaration-merges its own types (the compaction seam's `compact/*`, bounded recovery's non-surface `llm/retry`, the hook bridges' `hook/*`); merged members appear in the same catalog. `OutOfBandSessionEventMap` is a separate empty-by-default marker map: an event owner must merge the same key there before `appendOutOfBand()` accepts that log-only type, while surface and lifecycle types remain excluded. +Merge-extensible via `SessionEventMap` — a plugin declaration-merges its own types (the compaction seam's `compact/*`, bounded recovery's non-surface `llm/retry`, the hook bridges' `hook/*`); merged members appear in the same catalog. A plugin owns the relational invariant for its merged events, including whether a log-only event may appear between turns. A producer that requires durability appends through `Session` and then awaits `ctx.sessions.flush(session)` without fabricating an execution turn. Also defines `TurnTriggerMap` and `TurnEndReasonMap` (merge-extensible sum types for typed turn boundaries — `kind`-tagged instead of strings). A final model-request error retains one structured `LlmFailure`; other turn errors retain message/code, and both identify the failed step. @@ -142,6 +141,6 @@ Logging causes no invalidation, and exact reconstruction preserves request-prefi ## Known Limitations and Deferred Work - **Session branching/tree** (pi-style entry tree) — deferred unless needed beyond boundary-based `fork()`. -- **`fork()` cuts only at closed-turn boundaries of live sessions** — the boundary must be a `turn/end` event and the source must be in the store; forking a persisted-but-unloaded session is excluded from the [fork API](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md). +- **`fork()` cuts only at stable boundaries of live sessions** — the selected prefix must end outside an open turn and the source must be in the store; forking a persisted-but-unloaded session is excluded from the [fork API](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md). - **`SESSION_FORMAT_VERSION` stays pinned at `0`** — pre-release, no compatibility implied: a backend rejects any other version, and no migration path exists until the first release ([policy](../../../AGENTS.md)). - **`TurnEndReasonMap` omits the ACP-named `refusal` / `max_turn_requests` variants** — producer-gated: they land when an adapter or the loop first emits them. diff --git a/packages/core/session/README.zh.md b/packages/core/session/README.zh.md index 47c97256fb..a3737a12eb 100644 --- a/packages/core/session/README.zh.md +++ b/packages/core/session/README.zh.md @@ -14,9 +14,8 @@ - `ctx.sessions.create(id?, { seed?, meta? }?)` 校验持久种子/头部数据并生成脱离副本,补齐版本和 id,在未提供 `createdAt` 时使用当前时间,发布会话并将其绑定到调用方 fiber。持久化重建会提供原始的 `createdAt`、`seedLength` 和 `delegationDepth`。 - `ctx.sessions.flush(session)` 通过会话捕获的作用域分发受等待的并行持久性检查点。每个监听器都会启动;调用会等待全部结算后才报告失败。未发布、已脱离和陈旧的对象会被拒绝。 -- `ctx.sessions.appendOutOfBand(session, type, data, trigger)` 只接受已在 `OutOfBandSessionEventMap` 中显式准入的插件事件类型。若轮次已打开,它会直接追加;否则会原子地开启一个零步骤插件轮次,依次追加、关闭并刷新。即使目标事件追加失败,仍会关闭并刷新合成轮次,且在整个序列结算前延后脱离操作。 -- `findLastMessageTurnEnd(events)` 将由消息触发的开始与结束配对,并返回最近匹配的 `turn/end`。结果消费方使用该折叠逻辑,而不直接取最近的原始轮次边界,因为更晚的注入或插件所有的零步骤轮次具有自己的结果。 -- `ctx.sessions.fork(source, boundary?, childSessionId?): Session`:解析实时会话对象或 id,选取截至 `boundary` 事件序号(含该事件)的种子(默认为当前最后一个事件),要求边界为 `turn/end`,再创建带谱系元数据的实时子会话。 +- `findLastMessageTurnEnd(events)` 将由消息触发的开始与结束配对,并返回最近匹配的 `turn/end`。结果消费方使用该折叠逻辑,而不直接取日志中最近的事件,因为轮次间记录和非消息轮次没有提示词结果。 +- `ctx.sessions.fork(source, boundary?, childSessionId?): Session`:解析实时会话对象或 id,选取截至 `boundary` 事件序号(含该事件)的种子(默认为当前最后一个事件),要求所选前缀结束时没有开放轮次,再创建带谱系元数据的实时子会话。 - `ctx.sessions.get(id: SessionId): Session | undefined` - `ctx.sessions.list(): Session[]` @@ -74,7 +73,7 @@ 生成的[持久化日志事件目录](../../../docs/persistence-catalog.md)逐成员列举仅追加日志的事件类型、载荷、surface 标记和溯源信息。Token 记账读取每个步骤的 `assistant/chunk { type: 'usage' }` 记录;如果没有用量分片,则将 `assistant/message.usage` 作为已提交步骤的后备。失败的模型请求尝试没有 assistant 消息。提供方/模型/回放溯源信息随 `assistant/message` 一同保存;运行错误的步骤记录在 `turn/end.reason` 上(此时为 `kind: 'error'`),最终模型请求失败时还包含结构化的提供方事实。 -`SessionEventMap` 可通过合并扩展:插件使用声明合并添加自身类型(压缩 seam 的 `compact/*`、有界恢复的非 surface `llm/retry`、hook(钩子)桥接层的 `hook/*`);合并成员会出现在同一目录中。`OutOfBandSessionEventMap` 是独立、默认为空的标记映射:事件所有方必须在其中合并相同键,`appendOutOfBand()` 才接受该仅日志类型;surface 和生命周期类型仍被排除。 +`SessionEventMap` 可通过合并扩展:插件使用声明合并添加自身类型(压缩 seam 的 `compact/*`、有界恢复的非 surface `llm/retry`、hook(钩子)桥接层的 `hook/*`);合并成员会出现在同一目录中。插件拥有其合并事件的关系不变量,包括是否允许纯日志事件出现在轮次之间。需要持久性的生产方通过 `Session` 追加,再等待 `ctx.sessions.flush(session)`,无需虚构一个执行轮次。 此包还定义 `TurnTriggerMap` 和 `TurnEndReasonMap`(用于类型化轮次边界、可合并扩展的和类型;以 `kind` 为标签而不是字符串)。最终模型请求错误保留一个结构化 `LlmFailure`;其他轮次错误保留消息/代码,两者均标识失败步骤。 @@ -142,6 +141,6 @@ ## 已知限制与暂缓工作 - **会话分支/树**(pi 风格条目树):除非需要超越基于边界的 `fork()` 能力,否则暂缓。 -- **`fork()` 仅在实时会话已关闭轮次的边界处切分**:边界必须是 `turn/end` 事件,且源会话必须位于存储中;[fork API](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md) 不支持对已持久化但未加载的会话进行 fork。 +- **`fork()` 仅在实时会话的稳定边界处切分**:所选前缀结束时不得有开放轮次,且源会话必须位于存储中;[fork API](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md) 不支持对已持久化但未加载的会话进行 fork。 - **`SESSION_FORMAT_VERSION` 固定为 `0`**:预发布阶段不承诺兼容性;后端会拒绝其他任何版本,首次发布前不提供迁移路径([政策](../../../AGENTS.md))。 - **`TurnEndReasonMap` 不含 ACP(Agent Client Protocol)命名的 `refusal`/`max_turn_requests` 变体**:受生产方约束;只有当适配器或循环首次产生这些变体时才加入。 diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 0156eb9a13..5619724525 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -13,7 +13,7 @@ import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope' import type { Message } from '@deepseek-ai/dsh-llm' import { SESSION_FORMAT_VERSION, SessionId } from './types.ts' -import type { CreateSessionOptions, EpochHeader, OutOfBandSessionEventType, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType, TurnTrigger } from './types.ts' +import type { CreateSessionOptions, EpochHeader, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts' import { snapshotJsonValue } from './json.ts' import { SurfaceManager } from './surface.ts' import type { SessionSurface } from './surface.ts' @@ -30,8 +30,8 @@ export { foldSurface, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts export { canonicalHeader, foldRequestHeader, headerEquals } from './request-header.ts' /** - * Find the latest closed message-triggered turn, excluding injection and - * plugin-owned zero-step turns. + * Find the latest closed message-triggered turn, ignoring other triggers and + * between-turn events. * @param events - session events, or an owned suffix, to inspect. * @returns the latest matching turn end, or `undefined`. */ @@ -257,7 +257,6 @@ interface SessionEntry { announced: boolean announcing: boolean appending: boolean - outOfBand: boolean detachRequested: boolean detach(): void } @@ -446,7 +445,7 @@ export class Session { } finally { if (entry !== undefined) { entry.appending = false - if (entry.detachRequested && !entry.announcing && !entry.outOfBand) entry.detach() + if (entry.detachRequested && !entry.announcing) entry.detach() } } } @@ -587,8 +586,8 @@ export type SessionForkSource = Session | SessionId * live store (`SESSION_NOT_FOUND`) or names a session object that is not the * store's live instance (`SESSION_NOT_LIVE`); the requested child id is * already taken (`SESSION_ALREADY_EXISTS`); the boundary is not a contiguous - * existing seq (`INVALID_BOUNDARY`); or the boundary event is not a - * `turn/end` — a fork must cut on a closed turn (`OPEN_TURN`). + * existing seq (`INVALID_BOUNDARY`); or the selected prefix ends inside an + * open turn (`OPEN_TURN`). */ export type SessionForkErrorCode = | 'SESSION_NOT_FOUND' @@ -729,7 +728,6 @@ export class SessionStore extends Service { announced: false, announcing: false, appending: false, - outOfBand: false, detachRequested: false, detach: () => { this.detachEntered(entry) }, } @@ -742,7 +740,7 @@ export class SessionStore extends Service { // A lifecycle listener may own the advanced detach capability. Keep the // entry and its publication hooks live until synchronous creation or append // publication unwinds, then publish the paired disposal edge. - if (entry.announcing || entry.appending || entry.outOfBand) { + if (entry.announcing || entry.appending) { entry.detachRequested = true return } @@ -796,7 +794,7 @@ export class SessionStore extends Service { } } finally { entry.announcing = false - if (entry.detachRequested && !entry.appending && !entry.outOfBand) entry.detach() + if (entry.detachRequested && !entry.appending) entry.detach() } } @@ -840,87 +838,6 @@ export class SessionStore extends Service { if (failure !== undefined) throw failure.reason } - /** - * Append one plugin-declared log-only event without borrowing the agent - * loop's lifecycle. An open turn receives the event directly and remains - * responsible for its ordinary checkpoint. A closed log receives one - * zero-step turn around the event, followed by an awaited flush. - * - * Once the synthetic `turn/start` commits, this method always attempts its - * matching `turn/end` and flush, including when the target append fails. - * Detachment requested by an event or flush listener is deferred until that - * sequence settles, so publication cannot switch from a live scoped session - * to an unobserved bare `Session` halfway through the update. - * - * @param session - exact live session that owns the target log. - * @param type - event type opted into {@link OutOfBandSessionEventMap} by its owner. - * @param data - typed JSON payload for the target event. - * @param trigger - plugin-owned turn trigger used only when the log is closed. - * @returns the accepted target event with its assigned sequence and timestamp. - * @throws when the session is detached, another out-of-band append is active, - * event acceptance fails, the synthetic turn cannot close, or flushing fails. - */ - async appendOutOfBand( - session: Session, - type: T, - data: SessionEventMap[T], - trigger: TurnTrigger, - ): Promise> { - const entry = this.liveEntryFor(session) - if (entry.outOfBand) { - throw new Error(`session "${session.id}" already has an out-of-band append in progress`) - } - entry.outOfBand = true - // `T` is excluded from SurfaceEventType by OutOfBandSessionEventType, but - // TypeScript does not reduce Session.append's conditional rest parameter - // through a generic intersection. Preserve that proven two-argument call - // shape without widening the public Session.append overload. - const appendLogOnly = session.append.bind(session) as unknown as ( - eventType: K, - eventData: SessionEventMap[K], - ) => SessionEvent - try { - const lastBoundary = session.events.findLast(event => event.type === 'turn/start' || event.type === 'turn/end') - if (lastBoundary?.type === 'turn/start') { - return appendLogOnly(type, data) - } - - const lastStart = session.events.findLast(event => event.type === 'turn/start') - const turn = (lastStart?.data.turn ?? 0) + 1 - let accepted: SessionEvent | undefined - let failure: unknown - let opened = false - try { - session.append('turn/start', { turn, trigger }) - opened = true - accepted = appendLogOnly(type, data) - } catch (error: unknown) { - failure = error - } finally { - if (opened) { - // The only target types admitted by OutOfBandSessionEventMap are - // log-only plugin events, so the synthetic turn remains open here. - session.append('turn/end', { turn, reason: { kind: 'completed' } }) - try { - await this.flush(session) - } catch (error: unknown) { - if (failure === undefined) failure = error - } - } - } - if (failure !== undefined) { - // eslint-disable-next-line @typescript-eslint/only-throw-error -- preserve an arbitrary flush-listener rejection exactly - throw failure - } - /* v8 ignore next -- accepted is assigned unless an append failure was captured above. */ - if (accepted === undefined) throw new Error('out-of-band append completed without an accepted event') - return accepted - } finally { - entry.outOfBand = false - if (entry.detachRequested && !entry.announcing && !entry.appending) entry.detach() - } - } - /** Return the exact live entry; detached/prepared objects reject. */ private liveEntryFor(session: Session): SessionEntry { const entry = attachments.get(session) @@ -948,9 +865,10 @@ export class SessionStore extends Service { } /** - * Create a live child session from a turn-enclosed prefix of a live source. + * Create a live child session from a stable prefix of a live source. * `boundary` is an inclusive source event seq; omitted means the source's - * current last event. A non-empty selected slice must end at `turn/end`. + * current last event. The selected slice may end with a between-turn event + * but must not end inside an open turn. * * @param source - Live source session object or id. * @param boundary - Inclusive source event seq to fork through; omitted means @@ -1007,9 +925,11 @@ export class SessionStore extends Service { 'INVALID_BOUNDARY', ) } - if (boundaryEvent.type !== 'turn/end') { + const lastTurnBoundary = events.slice(0, boundary + 1) + .findLast(event => event.type === 'turn/start' || event.type === 'turn/end') + if (lastTurnBoundary?.type === 'turn/start') { throw new SessionForkError( - `fork boundary ${boundary} in session "${session.id}" must be turn/end, got ${boundaryEvent.type}`, + `fork boundary ${boundary} in session "${session.id}" ends inside open turn ${lastTurnBoundary.data.turn}`, 'OPEN_TURN', ) } diff --git a/packages/core/session/src/invariant.ts b/packages/core/session/src/invariant.ts index 0c08c1697f..84e6feb292 100644 --- a/packages/core/session/src/invariant.ts +++ b/packages/core/session/src/invariant.ts @@ -66,8 +66,8 @@ function validateEvent( let nextStep = trace.nextStep let pendingCalls: SessionTraceTransition['pendingCalls'] = { kind: 'none' } - // Model input may be appended between turns without running the model. - // Merge-extensible package events remain turn-enclosed by default. + // Context and plugin-owned log-only events may be appended between model + // executions. Core execution events retain their explicit turn relations. switch (event.type) { case 'turn/start': { if (trace.openTurn !== null) { @@ -143,12 +143,17 @@ function validateEvent( } case 'user/message': break - default: { + case 'steering/message': + case 'todo/write': + case 'request/header': { if (trace.openTurn === null) { - fail(`${event.type} appended outside any open turn (every event must be turn-enclosed)`) + fail(`${event.type} appended outside any open turn (core execution events must be turn-enclosed)`) } break } + default: + // Merge-extensible event relations belong to their owning plugin. + break } return { scalars: { lastSeq: event.seq, openTurn, openStep, nextTurn, nextStep }, diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 74f53b3c4d..ac91485ff9 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -257,23 +257,9 @@ export interface SessionEventMap { 'request/header': { header: EpochHeader; reason: RequestHeaderReason } } -/** - * Marker map for plugin-owned log-only events accepted by - * `SessionStore.appendOutOfBand()`. A plugin extends this map with the same key - * it adds to {@link SessionEventMap}; surface and lifecycle events stay - * ineligible unless their owner explicitly opts them into this narrow seam. - */ -export interface OutOfBandSessionEventMap {} - /** The appendable event-type keys of {@link SessionEventMap}, plugin-merged extensions included. */ export type SessionEventType = keyof SessionEventMap -/** Plugin-declared non-surface event types accepted by `SessionStore.appendOutOfBand()`. */ -export type OutOfBandSessionEventType = Exclude< - Extract, - SurfaceEventType -> - /** * The subset of {@link SessionEventType} values whose events produce LLM * messages and are eligible to appear on the ordered surface. Only these diff --git a/packages/core/session/tests/fork.spec.ts b/packages/core/session/tests/fork.spec.ts index 921232d724..f996faa313 100644 --- a/packages/core/session/tests/fork.spec.ts +++ b/packages/core/session/tests/fork.spec.ts @@ -4,6 +4,12 @@ import { CallId } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionForkError, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session' +declare module '@deepseek-ai/dsh-session' { + interface SessionEventMap { + 'test/log-only': { value: string } + } +} + async function setup(): Promise<{ ctx: Context; sessions: SessionStore }> { const ctx = new Context() await ctx.plugin(SessionStore) @@ -83,6 +89,21 @@ describe('SessionStore.fork', () => { }) }) + it('includes stable log-only events appended after a closed turn', async () => { + const { ctx, sessions } = await setup() + const source = ctx.sessions.create(SessionId('log-only-parent')) + appendClosedTurn(source, 1, 'hello') + source.append('test/log-only', { value: 'after execution' }) + + const child = sessions.fork(source, undefined, SessionId('log-only-child')) + + expect(child.events).toEqual(source.events) + expect(child.events.at(-1)).toMatchObject({ + type: 'test/log-only', + data: { value: 'after execution' }, + }) + }) + it('forks from an earlier turn boundary even when the source currently has an open tail', async () => { const { ctx, sessions } = await setup() const source = ctx.sessions.create(SessionId('parent'), { meta: { cwd: '/workspace' } }) @@ -217,7 +238,7 @@ describe('SessionStore.fork', () => { const boundary = build(source) expect(() => sessions.fork(source, boundary)) - .toThrow(new SessionForkError(`fork boundary ${boundary} in session "open-${lastType}" must be turn/end, got ${lastType}`, 'OPEN_TURN')) + .toThrow(new SessionForkError(`fork boundary ${boundary} in session "open-${lastType}" ends inside open turn 1`, 'OPEN_TURN')) } }) diff --git a/packages/core/session/tests/invariant.spec.ts b/packages/core/session/tests/invariant.spec.ts index d6fb2950e9..b421c8f141 100644 --- a/packages/core/session/tests/invariant.spec.ts +++ b/packages/core/session/tests/invariant.spec.ts @@ -102,7 +102,7 @@ describe('session-log invariants', () => { } as never) }).toThrow(/seq must strictly increase/) }) - it('enforces turn numbering and encloses events other than idle context', async () => { + it('enforces turn numbering and core execution enclosure', async () => { const first = await setup() const open = first.ctx.sessions.create() open.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) @@ -127,9 +127,13 @@ describe('session-log invariants', () => { content: [{ type: 'text', text: 'go' }], source: { kind: 'user' }, }, { surfaceOp: 'append' })).toThrow(/outside any open turn/) - // Merge-extensible session events use the same default enclosure branch. + // The owning plugin decides whether a merge-extensible event is log-only. const appendUnknown = outside.append.bind(outside) as (type: string, data: unknown) => unknown - expect(() => { appendUnknown('plugin/marker', {}) }).toThrow(/outside any open turn/) + expect(() => { appendUnknown('plugin/marker', {}) }).not.toThrow() + expect(() => outside.append('turn/start', { + turn: 1, + trigger: { kind: 'message', source: { kind: 'user' } }, + })).not.toThrow() }) it('enforces open-step identity and numbering', async () => { diff --git a/packages/core/session/tests/out-of-band.spec.ts b/packages/core/session/tests/out-of-band.spec.ts deleted file mode 100644 index 0265b1e8d6..0000000000 --- a/packages/core/session/tests/out-of-band.spec.ts +++ /dev/null @@ -1,226 +0,0 @@ -import { Context } from 'cordis' -import { describe, expect, it } from 'vitest' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' - -declare module '@deepseek-ai/dsh-session' { - interface SessionEventMap { - 'test/log-only': { value: string } - } - - interface OutOfBandSessionEventMap { - 'test/log-only': true - } - -} - -const updateTrigger = { kind: 'injection', source: { kind: 'plugin', plugin: 'test' } } as const - -describe('SessionStore.appendOutOfBand', () => { - it('joins an open turn without adding a boundary or flushing it', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const session = ctx.sessions.create(SessionId('open')) - let flushes = 0 - ctx.on('session/flush', () => { flushes += 1 }) - session.append('turn/start', { - turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, - }) - - const event = await ctx.sessions.appendOutOfBand( - session, - 'test/log-only', - { value: 'inside' }, - updateTrigger, - ) - - expect(event).toMatchObject({ type: 'test/log-only', seq: 1, data: { value: 'inside' } }) - expect(session.events.map(item => item.type)).toEqual(['turn/start', 'test/log-only']) - expect(flushes).toBe(0) - }) - - it('wraps a closed log in one zero-step turn and flushes the balanced update', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const session = ctx.sessions.create(SessionId('closed')) - const flushedTypes: string[][] = [] - ctx.on('session/flush', (flushed) => { - flushedTypes.push(flushed.events.map(event => event.type)) - }) - - const first = await ctx.sessions.appendOutOfBand( - session, - 'test/log-only', - { value: 'first' }, - updateTrigger, - ) - const second = await ctx.sessions.appendOutOfBand( - session, - 'test/log-only', - { value: 'second' }, - updateTrigger, - ) - - expect(first.seq).toBe(1) - expect(second.seq).toBe(4) - expect(session.events).toMatchObject([ - { type: 'turn/start', seq: 0, data: { turn: 1, trigger: updateTrigger } }, - { type: 'test/log-only', seq: 1, data: { value: 'first' } }, - { type: 'turn/end', seq: 2, data: { turn: 1, reason: { kind: 'completed' } } }, - { type: 'turn/start', seq: 3, data: { turn: 2, trigger: updateTrigger } }, - { type: 'test/log-only', seq: 4, data: { value: 'second' } }, - { type: 'turn/end', seq: 5, data: { turn: 2, reason: { kind: 'completed' } } }, - ]) - expect(flushedTypes).toEqual([ - ['turn/start', 'test/log-only', 'turn/end'], - ['turn/start', 'test/log-only', 'turn/end', 'turn/start', 'test/log-only', 'turn/end'], - ]) - }) - - it('closes and flushes a zero-step turn when the target event is rejected', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const session = ctx.sessions.create(SessionId('rejected')) - let flushes = 0 - ctx.on('session/flush', () => { flushes += 1 }) - - await expect(ctx.sessions.appendOutOfBand( - session, - 'test/log-only', - { value: 1n } as never, - updateTrigger, - )).rejects.toThrow(/non-JSON-serializable/) - - expect(session.events).toMatchObject([ - { type: 'turn/start', data: { turn: 1 } }, - { type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } }, - ]) - expect(flushes).toBe(1) - }) - - it('does not flush when the synthetic turn cannot open', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const session = ctx.sessions.create(SessionId('start-failure')) - let flushes = 0 - ctx.on('session/flush', () => { flushes += 1 }) - - await expect(ctx.sessions.appendOutOfBand( - session, - 'test/log-only', - { value: 'unreachable' }, - { ...updateTrigger, invalid: 1n } as never, - )).rejects.toThrow(/non-JSON-serializable/) - - expect(session.events).toEqual([]) - expect(flushes).toBe(0) - }) - - it('preserves a target rejection when the balancing flush also rejects', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const session = ctx.sessions.create(SessionId('target-and-flush-failure')) - ctx.on('session/flush', () => { throw new Error('disk failed') }) - - await expect(ctx.sessions.appendOutOfBand( - session, - 'test/log-only', - { value: 1n } as never, - updateTrigger, - )).rejects.toThrow(/non-JSON-serializable/) - - expect(session.events.map(event => event.type)).toEqual([ - 'turn/start', - 'turn/end', - ]) - }) - - it('keeps the session attached through publication and its flush', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const session = ctx.sessions.prepare(SessionId('dispose')) - const detach = ctx.sessions.enter(session) - ctx.sessions.announce(session) - let liveDuringFlush = false - ctx.on('session/event', (_observed, event) => { - if (event.type === 'turn/start') detach() - }) - ctx.on('session/flush', () => { - liveDuringFlush = ctx.sessions.get(session.id) === session - }) - - await ctx.sessions.appendOutOfBand( - session, - 'test/log-only', - { value: 'last' }, - updateTrigger, - ) - - expect(session.events.map(event => event.type)).toEqual([ - 'turn/start', - 'test/log-only', - 'turn/end', - ]) - expect(liveDuringFlush).toBe(true) - expect(ctx.sessions.get(session.id)).toBeUndefined() - }) - - it('rejects detached sessions before opening a turn', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const session = ctx.sessions.prepare(SessionId('detached')) - - await expect(ctx.sessions.appendOutOfBand( - session, - 'test/log-only', - { value: 'nope' }, - updateTrigger, - )).rejects.toThrow('session "detached" is not live in this store') - expect(session.events).toEqual([]) - }) - - it('leaves a balanced log when the durability checkpoint rejects', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const session = ctx.sessions.create(SessionId('flush-failure')) - ctx.on('session/flush', () => { throw new Error('disk failed') }) - - await expect(ctx.sessions.appendOutOfBand( - session, - 'test/log-only', - { value: 'accepted' }, - updateTrigger, - )).rejects.toThrow('disk failed') - expect(session.events.map(event => event.type)).toEqual([ - 'turn/start', - 'test/log-only', - 'turn/end', - ]) - }) - - it('rejects overlapping updates while the first append is still settling', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const session = ctx.sessions.create(SessionId('overlap')) - let release!: () => void - const checkpoint = new Promise((resolve) => { - release = resolve - }) - ctx.on('session/flush', () => checkpoint) - - const first = ctx.sessions.appendOutOfBand( - session, - 'test/log-only', - { value: 'first' }, - updateTrigger, - ) - await expect(ctx.sessions.appendOutOfBand( - session, - 'test/log-only', - { value: 'overlap' }, - updateTrigger, - )).rejects.toThrow(/out-of-band append in progress/) - release() - await expect(first).resolves.toMatchObject({ data: { value: 'first' } }) - }) -}) diff --git a/packages/core/tools/src/code-mode.ts b/packages/core/tools/src/code-mode.ts index 5f1ef46891..244934de3f 100644 --- a/packages/core/tools/src/code-mode.ts +++ b/packages/core/tools/src/code-mode.ts @@ -43,7 +43,7 @@ declare module '@deepseek-ai/dsh-session' { * Log-only: `deriveMessages()` ignores it, so sub-calls never re-enter * model context; persistence and UIs get every call. Appended inside the * parent `run_code`'s execution (the bridge drains in-flight dispatches - * before returning), so the turn-enclosure invariant holds by + * before returning), so its execution-enclosure relation holds by * construction. */ 'tool/code-dispatch': { parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown; isError: boolean; content: ContentBlock[] } diff --git a/packages/core/tools/src/invariant.ts b/packages/core/tools/src/invariant.ts index 2f5f27a281..78666f5890 100644 --- a/packages/core/tools/src/invariant.ts +++ b/packages/core/tools/src/invariant.ts @@ -1,6 +1,7 @@ /** Package-owned tool-pipeline invariants. @module @deepseek-ai/dsh-tools/invariant */ import type { Context } from 'cordis' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' import type { ToolExecution, ToolExecutionResult } from './index.ts' @@ -28,10 +29,40 @@ function validateResult( } } -/** Install monotonic pipeline and final-snapshot checks. */ -const install: InvariantInstaller = (ctx, fail) => { +/** Install monotonic pipeline, final-snapshot, and code-dispatch enclosure checks. */ +const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { const stages = new WeakMap() + const openTurns = new WeakMap() + const seed = (session: Session): number | null => { + let openTurn: number | null = null + for (const event of session.events) { + if (event.type === 'turn/start') openTurn = event.data.turn + else if (event.type === 'turn/end') openTurn = null + else if ((event.type === 'tool/code-dispatch-start' || event.type === 'tool/code-dispatch') + && openTurn === null) { + fail(`${event.type} appended outside any open turn`) + } + } + openTurns.set(session, openTurn) + return openTurn + } + const openTurnFor = (session: Session): number | null => openTurns.get(session) ?? seed(session) + + for (const session of ctx.sessions.list()) seed(session) + ctx.on('session/created', (session) => { seed(session) }, { global: true }) + ctx.on('session/event', (session, event) => { + if (event.type === 'turn/start') openTurns.set(session, event.data.turn) + else if (event.type === 'turn/end') openTurns.set(session, null) + }, { global: true }) ctx.on('internal/dispatch', (_mode, eventName, args) => { + if (eventName === 'session/event') { + const [session, event] = args as [Session, SessionEvent] + if ((event.type === 'tool/code-dispatch-start' || event.type === 'tool/code-dispatch') + && openTurnFor(session) === null) { + fail(`${event.type} appended outside any open turn`) + } + return + } if (eventName === 'tools/pre-execute') { const exec = args[0] as ToolExecution if (stages.has(exec)) fail('tools/pre-execute repeated for one execution') @@ -58,7 +89,7 @@ const install: InvariantInstaller = (ctx, fail) => { validateResult(exec, result, fail) stages.delete(exec) }, { global: true }) -} +}, { inject: ['sessions'] }) /** * Register the tools invariant companion. diff --git a/packages/core/tools/tests/invariant.spec.ts b/packages/core/tools/tests/invariant.spec.ts index 3752dc7321..f703934a5d 100644 --- a/packages/core/tools/tests/invariant.spec.ts +++ b/packages/core/tools/tests/invariant.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { scopeTarget } from '@deepseek-ai/dsh-scope' import { CallId } from '@deepseek-ai/dsh-llm' +import SessionStore from '@deepseek-ai/dsh-session' import type { ToolExecution, ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools' import * as ToolsInvariant from '@deepseek-ai/dsh-tools/invariant' import InvariantService from '@deepseek-ai/dsh-invariants' @@ -10,6 +11,7 @@ const testToolSignal = new AbortController().signal async function setup(): Promise { const ctx = new Context() + await ctx.plugin(SessionStore) await ctx.plugin(InvariantService) await ctx.plugin(ToolsInvariant) return ctx @@ -85,4 +87,50 @@ describe('tool-pipeline invariants', () => { const anonymous = Object.freeze(execution({ name: '' })) expect(() => { emitResult(ctx, anonymous, outcome()) }).toThrow(/non-empty name and callId/) }) + + it('requires code-dispatch records to be turn-enclosed', async () => { + const ctx = await setup() + const session = ctx.sessions.create() + const data = { + parentCallId: CallId('parent'), + subCallId: CallId('child'), + name: 'echo', + arguments: {}, + } + expect(() => session.append('tool/code-dispatch-start', data)).toThrow(/outside any open turn/) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + expect(() => session.append('tool/code-dispatch-start', data)).not.toThrow() + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + }) + + it('replays enclosed code-dispatch records on late registration', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const session = ctx.sessions.create() + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('tool/code-dispatch', { + parentCallId: CallId('parent'), + subCallId: CallId('child'), + name: 'echo', + arguments: {}, + isError: false, + content: [{ type: 'text', text: 'ok' }], + }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + await ctx.plugin(InvariantService) + await expect(ctx.plugin(ToolsInvariant).then(() => undefined)).resolves.toBeUndefined() + }) + + it('rejects an unenclosed code-dispatch record on late registration', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + ctx.sessions.create().append('tool/code-dispatch-start', { + parentCallId: CallId('parent'), + subCallId: CallId('child'), + name: 'echo', + arguments: {}, + }) + await ctx.plugin(InvariantService) + await expect(ctx.plugin(ToolsInvariant).then(() => undefined)).rejects.toThrow(/outside any open turn/) + }) }) diff --git a/packages/hooks/hook-protocol/README.i18n.yaml b/packages/hooks/hook-protocol/README.i18n.yaml index 3fa0f9eecd..f74f1bb3a8 100644 --- a/packages/hooks/hook-protocol/README.i18n.yaml +++ b/packages/hooks/hook-protocol/README.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 -README.md: 144616248d4e811b112f4c556502a960e681950b -README.zh.md: 036434ea6f10156a565734ac4da40f447e38ebb8 +# pnpm run verify-translation-pairing --write packages/hooks/hook-protocol/README.md +README.md: 10cfcdcbf819f318f2ccaf412ae04bba60812397 +README.zh.md: f6fd30c968f68faa46d7ea07188cb22ee5ef3afe diff --git a/packages/hooks/hook-protocol/README.md b/packages/hooks/hook-protocol/README.md index 144616248d..10cfcdcbf8 100644 --- a/packages/hooks/hook-protocol/README.md +++ b/packages/hooks/hook-protocol/README.md @@ -29,7 +29,7 @@ Why a shared lib at all: Codex deliberately reimplements a *subset* of the Claud Declaration-merged into `SessionEventMap` (log-only, like `compact/*` — NOT a `SurfaceEventType`, no `surfaceOp`): `hook/invoked` (a hook command ran) and `hook/result` (its outcome, paired by `handlerId`, with `appendHookResult` owning the decision rule). Payloads and per-event JSDoc are in the generated [persistence log event catalog](../../../docs/persistence-catalog.md); `stderrSummary` is truncated to the record's `stderrSummaryMaxChars` (the bridge's config, reference default `DEFAULT_STDERR_SUMMARY_MAX_CHARS` = 500; omitted when empty). -Like every event they must sit inside an open turn. The mid-turn points (`PreToolUse`/`PostToolUse`/`Stop`) fire inside the loop's open turn by construction. `SessionStart` and the pre-turn `UserPromptSubmit` admission seam get no `hook/*` record; allowed context is instead evidenced by its sourced `user/message` — see the hooks Agent Note. +Hook provenance records must sit inside an open turn. The mid-turn points (`PreToolUse`/`PostToolUse`/`Stop`) satisfy that owner-defined relation by construction. `SessionStart` and the pre-turn `UserPromptSubmit` admission seam get no `hook/*` record; allowed context is instead evidenced by its sourced `user/message` — see the hooks Agent Note. ## Model Experience diff --git a/packages/hooks/hook-protocol/README.zh.md b/packages/hooks/hook-protocol/README.zh.md index 036434ea6f..f6fd30c968 100644 --- a/packages/hooks/hook-protocol/README.zh.md +++ b/packages/hooks/hook-protocol/README.zh.md @@ -29,7 +29,7 @@ Claude Code/Codex hook 协议格式的**共享核心**。它不是 cordis 插 通过 declaration merging 合并到 `SessionEventMap`(仅日志,与 `compact/*` 相同;不是 `SurfaceEventType`,没有 `surfaceOp`):`hook/invoked`(hook 命令已运行)与 `hook/result`(其结果,按 `handlerId` 配对,由 `appendHookResult` 拥有决策规则)。Payload 与每事件 JSDoc 位于生成的 [持久化日志事件目录](../../../docs/persistence-catalog.md);`stderrSummary` 会截断到记录的 `stderrSummaryMaxChars`(桥接配置,参考默认值 `DEFAULT_STDERR_SUMMARY_MAX_CHARS` = 500;为空时省略)。 -与每个事件一样,它们必须位于开启轮次内。轮次中点(`PreToolUse`/`PostToolUse`/`Stop`)按构造位于 loop 的开启轮次中。`SessionStart` 与轮次前的 `UserPromptSubmit` 准入 seam 没有 `hook/*` 记录;获准的上下文改由其带来源的 `user/message` 作为证据,详见 hooks Agent Note。 +Hook 溯源记录必须位于开启轮次内。轮次中点(`PreToolUse`/`PostToolUse`/`Stop`)按构造满足这条由所有方定义的关系。`SessionStart` 与轮次前的 `UserPromptSubmit` 准入 seam 没有 `hook/*` 记录;获准的上下文改由其带来源的 `user/message` 作为证据,详见 hooks Agent Note。 ## 模型体验 diff --git a/packages/hooks/hook-protocol/src/invariant.ts b/packages/hooks/hook-protocol/src/invariant.ts index 6972d109da..9bc5e6a1e1 100644 --- a/packages/hooks/hook-protocol/src/invariant.ts +++ b/packages/hooks/hook-protocol/src/invariant.ts @@ -17,6 +17,11 @@ interface HookTransition { delta: 1 | -1 } +interface HookTrace { + openTurn: number | null + pending: Map +} + /** Correlation key shared by an invoked/result pair. */ function hookKey(data: { turn: number; point: string; handlerId: string }): string { return `${data.turn}\0${data.point}\0${data.handlerId}` @@ -24,10 +29,15 @@ function hookKey(data: { turn: number; point: string; handlerId: string }): stri /** Validate one hook event against committed pending invocations. */ function validateHookEvent( - pending: ReadonlyMap, + trace: HookTrace, event: SessionEvent, fail: InvariantFailure, ): HookTransition | undefined { + if (event.type !== 'hook/invoked' && event.type !== 'hook/result') return undefined + if (trace.openTurn === null) fail(`${event.type} appended outside any open turn`) + if (event.data.turn !== trace.openTurn) { + fail(`${event.type} names turn ${event.data.turn} but open turn is ${trace.openTurn}`) + } if (event.type === 'hook/invoked') { if (event.data.point.length === 0 || event.data.handlerId.length === 0) { fail('hook/invoked point and handlerId must be non-empty') @@ -38,9 +48,8 @@ function validateHookEvent( } return { key: hookKey(event.data), delta: 1 } } - if (event.type !== 'hook/result') return undefined const key = hookKey(event.data) - if ((pending.get(key) ?? 0) === 0) { + if ((trace.pending.get(key) ?? 0) === 0) { fail(`hook/result has no matching hook/invoked for ${JSON.stringify(event.data.handlerId)}`) } if (!Number.isFinite(event.data.durationMs) || event.data.durationMs < 0) { @@ -60,28 +69,39 @@ function applyHookTransition(pending: Map, transition: HookTrans // Event owners keep precommit staging local so their vocabularies never move into a central helper. /* jscpd:ignore-start */ const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { - const traces = new WeakMap>() + const traces = new WeakMap() const staged = new WeakMap() - const seed = (session: Session): Map => { - const pending = new Map() - traces.set(session, pending) + const seed = (session: Session): HookTrace => { + const trace: HookTrace = { openTurn: null, pending: new Map() } + traces.set(session, trace) for (const event of session.events) { - const transition = validateHookEvent(pending, event, fail) - if (transition !== undefined) applyHookTransition(pending, transition) + if (event.type === 'turn/start') trace.openTurn = event.data.turn + else if (event.type === 'turn/end') trace.openTurn = null + const transition = validateHookEvent(trace, event, fail) + if (transition !== undefined) applyHookTransition(trace.pending, transition) } - return pending + return trace } - const traceFor = (session: Session): Map => traces.get(session) ?? seed(session) + const traceFor = (session: Session): HookTrace => traces.get(session) ?? seed(session) for (const session of ctx.sessions.list()) seed(session) ctx.on('session/created', (session) => { seed(session) }, { global: true }) ctx.on('session/event', (session, event) => { + const trace = traceFor(session) + if (event.type === 'turn/start') { + trace.openTurn = event.data.turn + return + } + if (event.type === 'turn/end') { + trace.openTurn = null + return + } if (event.type !== 'hook/invoked' && event.type !== 'hook/result') return const candidate = staged.get(event) /* v8 ignore next -- internal/dispatch stages every hook provenance event */ if (candidate === undefined || candidate.session !== session) return fail('hook event published without pre-commit validation') staged.delete(event) - applyHookTransition(traceFor(session), candidate.transition) + applyHookTransition(trace.pending, candidate.transition) }, { global: true }) ctx.on('internal/dispatch', (_mode, eventName, args) => { if (eventName !== 'session/event') return diff --git a/packages/hooks/hook-protocol/tests/invariant.spec.ts b/packages/hooks/hook-protocol/tests/invariant.spec.ts index dc7b1d38bb..5092758e74 100644 --- a/packages/hooks/hook-protocol/tests/invariant.spec.ts +++ b/packages/hooks/hook-protocol/tests/invariant.spec.ts @@ -29,12 +29,18 @@ const result = (overrides: Record = {}) => ({ ...overrides, }) +function startTurn(session: Session, turn = 1): void { + session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } }) +} + describe('hook-protocol invariants', () => { it('pairs serial and repeated handler invocations', async () => { const ctx = await setup() const session = ctx.sessions.create() + startTurn(session) session.append('hook/invoked', invoked()) session.append('hook/invoked', invoked()) + session.append('step/start', { turn: 1, step: 1 }) session.append('hook/result', result()) session.append('hook/result', result()) }) @@ -56,26 +62,52 @@ describe('hook-protocol invariants', () => { const session = new Session(SessionId('bare-hook-session')) expect(() => { ctx.emit('session/event', session, { - type: 'hook/invoked', seq: 0, time: 0, data: invoked(), + type: 'turn/start', seq: 0, time: 0, + data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, }) ctx.emit('session/event', session, { - type: 'hook/result', seq: 1, time: 1, data: result(), + type: 'hook/invoked', seq: 1, time: 1, data: invoked(), + }) + ctx.emit('session/event', session, { + type: 'hook/result', seq: 2, time: 2, data: result(), }) }).not.toThrow() }) + it('rejects hook events outside or for a different open turn', async () => { + const ctx = await setup() + const session = ctx.sessions.create() + expect(() => session.append('hook/invoked', invoked())).toThrow(/outside any open turn/) + startTurn(session) + expect(() => session.append('hook/invoked', invoked({ turn: 2 }))).toThrow(/but open turn is 1/) + }) + + it('rejects an unenclosed hook event when replaying an existing session', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const session = ctx.sessions.create() + startTurn(session) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + session.append('hook/invoked', invoked()) + await ctx.plugin(InvariantService) + await expect(ctx.plugin(HookInvariant).then(() => undefined)).rejects.toThrow(/outside any open turn/) + }) + it.each([ [invoked({ point: '' }), /point and handlerId must be non-empty/], [invoked({ handlerId: '' }), /point and handlerId must be non-empty/], [invoked({ dialect: 'other' }), /unknown dialect/], ])('rejects malformed hook invocation %#', async (data, message) => { const ctx = await setup() - expect(() => ctx.sessions.create().append('hook/invoked', data as never)).toThrow(message) + const session = ctx.sessions.create() + startTurn(session) + expect(() => session.append('hook/invoked', data as never)).toThrow(message) }) it('rejects unmatched and malformed results', async () => { const ctx = await setup() const session = ctx.sessions.create() + startTurn(session) expect(() => session.append('hook/result', result())).toThrow(/no matching hook\/invoked/) session.append('hook/invoked', invoked()) expect(() => session.append('hook/result', result({ durationMs: -1 }))) diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 28ff470c0b..7805412e39 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: d6db9a9541b0727b61dbe501f7234564ffef139e -README.zh.md: 4175c8fdb98aad2882718a2c95cd9e45825d787d +README.md: 33a4752f97c17e7879dff11a33acc93e335496b7 +README.zh.md: 45aee0225717ad8a4d9b923067f196535c7ab3f9 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index d6db9a9541..33a4752f97 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -18,6 +18,8 @@ Workspace and Session lists are separate reconnect baselines. `workspace.create` `host.pickDirectory` opens one native directory picker and returns its selected path, or `null` when the user cancels. Its host implementation invokes platform tools without a shell: `osascript` on macOS, an STA PowerShell `FolderBrowserDialog` on Windows, and Zenity with a KDialog fallback on Linux. The picker function is injectable for tests. This user-paced method is the sole unary call exempt from the default 30-second timeout; caller and connection aborts still propagate to the native process. The browser carrier separately restricts this privileged method to loopback, same-origin requests. +`host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, `xdg-open` on Linux). The opener is injectable for tests. The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`. + `session.history` pages on message boundaries, and its tail page (no `beforeSeq`) carries two session-level extras the page window cannot supply: the in-flight partial's chunk events, and `todos` — the latest `todo/write` whole-list projection over the full log. Older pages omit `todos` because the projection is session-level, not per-page; a tail response that omits it means the whole log holds no `todo/write`, so clients read the absent field as the empty plan rather than as unchanged state. The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `command.execute` runs a slash-command line host-side and returns a detached result; the carrier's request signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 4175c8fdb9..45aee02257 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -18,6 +18,8 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr `host.pickDirectory` 会打开一个原生目录选择器并返回选中的路径;用户取消时返回 `null`。宿主实现不经 shell 调用平台工具:macOS 使用 `osascript`,Windows 使用以 STA 模式运行的 PowerShell `FolderBrowserDialog`,Linux 使用 Zenity,并以 KDialog 作为回退。选择器函数可在测试中注入。该方法需等待用户完成操作,是唯一不受默认 30 秒超时限制的一元调用;调用方发出的中止信号和连接中止仍会传播至原生进程。浏览器载体另行将这一特权方法限制为仅接受来自回环地址的同源请求。 +`host.openPath` 会用操作系统的默认应用打开一个文件系统路径(macOS 为 `open`,Windows 为 `Invoke-Item`,Linux 为 `xdg-open`)。打开器可在测试中注入。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制。 + `session.history` 按消息边界分页,其尾页(不带 `beforeSeq`)额外携带两项页窗口本身无法提供的会话级数据:进行中局部消息的 chunk 事件,以及 `todos`——整份日志上最后一次 `todo/write` 的整表投影。较早的页面不带 `todos`,因为该投影是会话级而非分页级的;尾页响应缺少该字段意味着整份日志中没有任何 `todo/write`,因此客户端要把缺失字段读作空计划,而不是读作「状态未变」。 `command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`command.execute` 在宿主侧运行一条斜杠命令行并返回脱耦结果;载体的请求信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 58922284be..43541469fe 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -40,6 +40,7 @@ import type { } from '@deepseek-ai/dsh-user-interaction' import { UserInteractionError } from '@deepseek-ai/dsh-user-interaction' import { pickNativeDirectory } from './native-directory-picker.ts' +import { openNativePath } from './native-path-opener.ts' /** Page size when history is called without maxMessages. */ const DEFAULT_MAX_MESSAGES = 50 @@ -203,6 +204,8 @@ export interface ApiProxyDefaults { workspaceRoot: string /** Native single-directory picker; injectable for carrier tests. */ pickDirectory?: (signal: AbortSignal) => Promise + /** Native open-with-default-application; injectable for carrier tests. */ + openPath?: (path: string, signal: AbortSignal) => Promise } /** The tool/call payload fields the presenter path reads. */ @@ -1012,6 +1015,28 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }) } }, + + async openPath(request, signal) { + try { + const open = defaults.openPath + ?? ((path: string, openSignal: AbortSignal) => openNativePath(path, openSignal)) + await open(request.payload.path, signal) + return ok(request, { opened: true as const }) + } catch (error: unknown) { + if (signal.aborted) { + return err(request, { + code: 'cancelled', + message: 'path open was aborted', + details: {}, + }) + } + return err(request, { + code: 'internal', + message: `path open failed: ${error instanceof Error ? error.message : String(error)}`, + details: {}, + }) + } + }, }, commands: { diff --git a/packages/host/apiproxy/src/api/host.schema.ts b/packages/host/apiproxy/src/api/host.schema.ts index c7353c4360..e451c62100 100644 --- a/packages/host/apiproxy/src/api/host.schema.ts +++ b/packages/host/apiproxy/src/api/host.schema.ts @@ -25,3 +25,13 @@ export const hostPickDirectoryRequestSchema = z.object({}) satisfies z.ZodType>> + +/** host.openPath request payload. */ +export const hostOpenPathRequestSchema = z.object({ + path: z.string().min(1), +}) satisfies z.ZodType>> + +/** host.openPath response value. */ +export const hostOpenPathValueSchema = z.object({ + opened: z.literal(true), +}) satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/host.ts b/packages/host/apiproxy/src/api/host.ts index e3573346f4..2e62c972ae 100644 --- a/packages/host/apiproxy/src/api/host.ts +++ b/packages/host/apiproxy/src/api/host.ts @@ -28,4 +28,14 @@ export interface HostApi { request: RpcRequest<{}>, signal: AbortSignal, ): Promise> + + /** + * Open a filesystem path with the operating system's default application + * (Finder / Explorer / xdg-open hand-off). The browser carrier restricts this + * privileged method to loopback, same-origin requests. + */ + openPath( + request: RpcRequest<{ path: string }>, + signal: AbortSignal, + ): Promise> } diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index d98a7c01b3..7beabd2696 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -26,6 +26,7 @@ export interface RpcMethodMap { 'session.cancel': SessionsApi['cancel'] 'host.describe': HostApi['describe'] 'host.pickDirectory': HostApi['pickDirectory'] + 'host.openPath': HostApi['openPath'] 'workspace.list': WorkspaceApi['list'] 'workspace.create': WorkspaceApi['create'] 'workspace.rename': WorkspaceApi['rename'] diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 62bef32b29..ceca9fee7e 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -13,7 +13,9 @@ import { RpcId } from '../api/rpc.ts' import type { Wire } from '../api/rpc.schema.ts' import { rpcReceiptSchema, serverRequestSchema, serverResponseSchema } from '../api/rpc.schema.ts' import { hostFrameSchema, muxFrameSchema } from '../api/events.schema.ts' -import { hostDescribeValueSchema, hostPickDirectoryValueSchema } from '../api/host.schema.ts' +import { + hostDescribeValueSchema, hostOpenPathValueSchema, hostPickDirectoryValueSchema, +} from '../api/host.schema.ts' import { sessionCancelValueSchema, sessionCreateValueSchema, @@ -61,6 +63,7 @@ export interface IApiClient { host: { describe(payload: RequestPayload<'host.describe'>, signal?: AbortSignal): Promise>> pickDirectory(payload: RequestPayload<'host.pickDirectory'>, signal?: AbortSignal): Promise>> + openPath(payload: RequestPayload<'host.openPath'>, signal?: AbortSignal): Promise>> } workspace: { list(payload: RequestPayload<'workspace.list'>, signal?: AbortSignal): Promise>> @@ -98,6 +101,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType this.callUnary('host.pickDirectory', payload, signal, false), + openPath: (payload, signal) => this.callUnary('host.openPath', payload, signal), } readonly workspace: IApiClient['workspace'] = { diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index f0535dc939..505239c084 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -23,7 +23,9 @@ import { sessionPromptRequestSchema, sessionSelectModelRequestSchema, } from '../api/sessions.schema.ts' -import { hostDescribeRequestSchema, hostPickDirectoryRequestSchema } from '../api/host.schema.ts' +import { + hostDescribeRequestSchema, hostOpenPathRequestSchema, hostPickDirectoryRequestSchema, +} from '../api/host.schema.ts' import { workspaceCreateRequestSchema, workspaceDeleteRequestSchema, @@ -60,6 +62,7 @@ const UNARY_ROUTES: UnaryRoutes = { 'session.cancel': { schema: sessionCancelRequestSchema, invoke: (api, r) => api.sessions.cancel(r) }, 'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) }, 'host.pickDirectory': { schema: hostPickDirectoryRequestSchema, invoke: (api, r, signal) => api.host.pickDirectory(r, signal) }, + 'host.openPath': { schema: hostOpenPathRequestSchema, invoke: (api, r, signal) => api.host.openPath(r, signal) }, 'workspace.list': { schema: workspaceListRequestSchema, invoke: (api, r) => api.workspace.list(r) }, 'workspace.create': { schema: workspaceCreateRequestSchema, invoke: (api, r) => api.workspace.create(r) }, 'workspace.rename': { schema: workspaceRenameRequestSchema, invoke: (api, r) => api.workspace.rename(r) }, diff --git a/packages/host/apiproxy/src/native-command.ts b/packages/host/apiproxy/src/native-command.ts new file mode 100644 index 0000000000..0efe9679d8 --- /dev/null +++ b/packages/host/apiproxy/src/native-command.ts @@ -0,0 +1,38 @@ +/** Shared no-shell `execFile` runner for native host dialogs and openers. */ + +import { execFile } from 'node:child_process' + +/** Testable command boundary; native implementations never invoke a shell. */ +export type NativeCommandRunner = ( + command: string, + args: readonly string[], + signal: AbortSignal, +) => Promise<{ stdout: string; stderr: string }> + +/** + * Run a host command with utf8 stdio, abort propagation, and Windows hide. + * @param command - executable path or PATH name. + * @param args - argv (never a shell string). + * @param signal - caller/connection lifetime; abort terminates the child. + * @returns captured stdout/stderr on exit 0. + */ +export const runNativeCommand: NativeCommandRunner = (command, args, signal) => + new Promise((resolve, reject) => { + execFile( + command, + [...args], + { encoding: 'utf8', signal, windowsHide: true }, + (error, stdout, stderr) => { + if (error !== null) { + const failure = Object.assign(new Error(error.message, { cause: error }), { + code: error.code, + stdout, + stderr, + }) + reject(failure) + return + } + resolve({ stdout, stderr }) + }, + ) + }) diff --git a/packages/host/apiproxy/src/native-directory-picker.ts b/packages/host/apiproxy/src/native-directory-picker.ts index 8bc0bc99ce..0ddba8e30d 100644 --- a/packages/host/apiproxy/src/native-directory-picker.ts +++ b/packages/host/apiproxy/src/native-directory-picker.ts @@ -1,13 +1,9 @@ /** Cross-platform native single-directory picker used by the local GUI carrier. */ -import { execFile } from 'node:child_process' +import { runNativeCommand, type NativeCommandRunner } from './native-command.ts' /** Testable command boundary; native implementations never invoke a shell. */ -export type DirectoryPickerRunner = ( - command: string, - args: readonly string[], - signal: AbortSignal, -) => Promise<{ stdout: string; stderr: string }> +export type DirectoryPickerRunner = NativeCommandRunner /** Injectable platform facts for deterministic adapter tests. */ export interface DirectoryPickerInternals { @@ -15,27 +11,6 @@ export interface DirectoryPickerInternals { run?: DirectoryPickerRunner } -const runCommand: DirectoryPickerRunner = (command, args, signal) => - new Promise((resolve, reject) => { - execFile( - command, - [...args], - { encoding: 'utf8', signal, windowsHide: true }, - (error, stdout, stderr) => { - if (error !== null) { - const failure = Object.assign(new Error(error.message, { cause: error }), { - code: error.code, - stdout, - stderr, - }) - reject(failure) - return - } - resolve({ stdout, stderr }) - }, - ) - }) - function outputPath(stdout: string): string | null { const path = stdout.replace(/[\r\n]+$/, '') return path === '' ? null : path @@ -72,7 +47,7 @@ export async function pickNativeDirectory( internals: DirectoryPickerInternals = {}, ): Promise { const platform = internals.platform ?? process.platform - const run = internals.run ?? runCommand + const run = internals.run ?? runNativeCommand if (platform === 'darwin') { try { diff --git a/packages/host/apiproxy/src/native-path-opener.ts b/packages/host/apiproxy/src/native-path-opener.ts new file mode 100644 index 0000000000..15a6d7a73b --- /dev/null +++ b/packages/host/apiproxy/src/native-path-opener.ts @@ -0,0 +1,53 @@ +/** Cross-platform open-with-default-application used by the local GUI carrier. */ + +import { runNativeCommand, type NativeCommandRunner } from './native-command.ts' + +/** Testable command boundary; native implementations never invoke a shell. */ +export type PathOpenerRunner = NativeCommandRunner + +/** Injectable platform facts for deterministic adapter tests. */ +export interface PathOpenerInternals { + platform?: NodeJS.Platform + run?: PathOpenerRunner +} + +/** PowerShell single-quoted literal (doubles embedded quotes). */ +function powershellLiteral(path: string): string { + return `'${path.replace(/'/g, "''")}'` +} + +/** + * Open a filesystem path with the operating system's default application. + * @param path - absolute or host-resolvable path (caller owns resolution). + * @param signal - caller/connection lifetime; abort terminates the native command. + * @param internals - platform and runner seam for deterministic tests. + */ +export async function openNativePath( + path: string, + signal: AbortSignal, + internals: PathOpenerInternals = {}, +): Promise { + const platform = internals.platform ?? process.platform + const run = internals.run ?? runNativeCommand + + if (platform === 'darwin') { + await run('open', [path], signal) + return + } + + if (platform === 'win32') { + await run('powershell.exe', [ + '-NoProfile', + '-Command', + `Invoke-Item -LiteralPath ${powershellLiteral(path)}`, + ], signal) + return + } + + if (platform === 'linux') { + await run('xdg-open', [path], signal) + return + } + + throw new Error(`native path opener is unsupported on ${platform}`) +} diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index c6354db928..bd0790b5c3 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -57,7 +57,10 @@ function stubAgent(session: Session): Agent { /** Compose the API over real Session, Agent, Storage, Domain, and Workspace services. */ async function harness( workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-workspace-'))), - pickDirectory?: (signal: AbortSignal) => Promise, + extras: { + pickDirectory?: (signal: AbortSignal) => Promise + openPath?: (path: string, signal: AbortSignal) => Promise + } = {}, ) { const ctx = new Context() await ctx.plugin(SessionStore) @@ -97,26 +100,29 @@ async function harness( model: 'test-model', cwd: workspaceRoot, workspaceRoot, - ...pickDirectory === undefined ? {} : { pickDirectory }, + ...extras.pickDirectory === undefined ? {} : { pickDirectory: extras.pickDirectory }, + ...extras.openPath === undefined ? {} : { openPath: extras.openPath }, }) return { api, ctx, storageDomain, workspaceRoot } } describe('host.pickDirectory', () => { it('returns a selected path or explicit cancellation from the injected native boundary', async () => { - const selected = await harness(undefined, async () => '/tmp/project') + const selected = await harness(undefined, { pickDirectory: async () => '/tmp/project' }) expect((await selected.api.host.pickDirectory(request({}), new AbortController().signal)).result) .toEqual({ ok: true, value: { path: '/tmp/project' } }) - const cancelled = await harness(undefined, async () => null) + const cancelled = await harness(undefined, { pickDirectory: async () => null }) expect((await cancelled.api.host.pickDirectory(request({}), new AbortController().signal)).result) .toEqual({ ok: true, value: { path: null } }) }) it('propagates abort into the native boundary as a cancelled RPC error', async () => { - const { api } = await harness(undefined, signal => new Promise((_resolve, reject) => { - signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true }) - })) + const { api } = await harness(undefined, { + pickDirectory: signal => new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true }) + }), + }) const abort = new AbortController() const pending = api.host.pickDirectory(request({}), abort.signal) abort.abort() @@ -124,6 +130,30 @@ describe('host.pickDirectory', () => { }) }) +describe('host.openPath', () => { + it('opens through the injected native boundary', async () => { + const opened: string[] = [] + const { api } = await harness(undefined, { + openPath: async (path) => { opened.push(path) }, + }) + expect((await api.host.openPath(request({ path: '/tmp/a.txt' }), new AbortController().signal)).result) + .toEqual({ ok: true, value: { opened: true } }) + expect(opened).toEqual(['/tmp/a.txt']) + }) + + it('propagates abort into the native boundary as a cancelled RPC error', async () => { + const { api } = await harness(undefined, { + openPath: (_path, signal) => new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true }) + }), + }) + const abort = new AbortController() + const pending = api.host.openPath(request({ path: '/tmp/a.txt' }), abort.signal) + abort.abort() + expect((await pending).result).toMatchObject({ ok: false, error: { code: 'cancelled' } }) + }) +}) + describe('workspace.create', () => { it('serializes concurrent names and rejects the duplicate', async () => { const { api, workspaceRoot } = await harness() diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 2dbc962a00..2794aa13d2 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -50,6 +50,7 @@ function scriptedApi(overrides: { host: { describe: r => ok(r, { version: '0-test', cwd: '/t', attachedSessions: 0 }), pickDirectory: r => ok(r, { path: null }), + openPath: r => ok(r, { opened: true as const }), ...overrides.host, }, workspace: { diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 0e6dc8dc4a..c67eeb9fc5 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -80,6 +80,9 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra async pickDirectory(request) { return { rpcId: request.rpcId, result: { ok: true, value: { path: null } } } }, + async openPath(request) { + return { rpcId: request.rpcId, result: { ok: true, value: { opened: true as const } } } + }, }, workspace: { async list(request) { @@ -205,6 +208,18 @@ describe('unary round trip (handler ⇄ client, no network)', () => { expect(response.result).toEqual({ ok: true, value: { path: '/tmp/project' } }) }) + it('round-trips host.openPath through the wire form', async () => { + const api = fakeApi() + let opened: string | undefined + api.host.openPath = async (request) => { + opened = request.payload.path + return { rpcId: request.rpcId, result: { ok: true, value: { opened: true as const } } } + } + const response = await client(api).host.openPath({ path: '/tmp/a.txt' }) + expect(opened).toBe('/tmp/a.txt') + expect(response.result).toEqual({ ok: true, value: { opened: true } }) + }) + it('round-trips command.list / command.execute / skill.list through the wire form', async () => { const c = client() const list = await c.commands.list({ sessionId: 's' as never }) diff --git a/packages/host/apiproxy/tests/native-path-opener.spec.ts b/packages/host/apiproxy/tests/native-path-opener.spec.ts new file mode 100644 index 0000000000..c8622002e2 --- /dev/null +++ b/packages/host/apiproxy/tests/native-path-opener.spec.ts @@ -0,0 +1,82 @@ +type ExecFileCallback = ( + error: (Error & { code?: string | number }) | null, + stdout: string, + stderr: string, +) => void +type ExecFileMock = ( + command: string, + args: readonly string[], + options: { encoding: string; signal: AbortSignal; windowsHide: boolean }, + callback: ExecFileCallback, +) => void + +const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn() })) + +vi.mock('node:child_process', () => ({ execFile: execFileMock })) + +import { describe, expect, it, vi } from 'vitest' +import { openNativePath, type PathOpenerRunner } from '../src/native-path-opener.ts' + +const signal = () => new AbortController().signal + +describe('native path opener', () => { + it('opens with macOS open(1)', async () => { + const run = vi.fn(async () => ({ stdout: '', stderr: '' })) + await openNativePath('/Users/test/file.txt', signal(), { platform: 'darwin', run }) + expect(run).toHaveBeenCalledWith('open', ['/Users/test/file.txt'], expect.any(AbortSignal)) + }) + + it('opens with Windows Invoke-Item and escapes single quotes', async () => { + const run = vi.fn(async () => ({ stdout: '', stderr: '' })) + await openNativePath("C:\\work\\o'reilly.txt", signal(), { platform: 'win32', run }) + expect(run).toHaveBeenCalledWith( + 'powershell.exe', + ['-NoProfile', '-Command', "Invoke-Item -LiteralPath 'C:\\work\\o''reilly.txt'"], + expect.any(AbortSignal), + ) + }) + + it('opens with Linux xdg-open', async () => { + const run = vi.fn(async () => ({ stdout: '', stderr: '' })) + await openNativePath('/tmp/a.txt', signal(), { platform: 'linux', run }) + expect(run).toHaveBeenCalledWith('xdg-open', ['/tmp/a.txt'], expect.any(AbortSignal)) + }) + + it('rejects unsupported platforms', async () => { + await expect(openNativePath('/x', signal(), { platform: 'freebsd' as NodeJS.Platform })) + .rejects.toThrow('unsupported on freebsd') + }) + + it('uses the current process platform when no platform override is supplied', async () => { + const run = vi.fn(async () => ({ stdout: '', stderr: '' })) + await openNativePath('/tmp/platform-default.txt', signal(), { run }) + const expected = process.platform === 'win32' + ? 'powershell.exe' + : process.platform === 'linux' + ? 'xdg-open' + : 'open' + expect(run.mock.calls[0]?.[0]).toBe(expected) + }) + + it('runs the default command adapter without a shell and preserves command failures', async () => { + execFileMock.mockImplementationOnce((_command, _args, _options, callback) => { + callback(null, '', '') + }) + await openNativePath('/tmp/default.txt', signal(), { platform: 'darwin' }) + const [command, args, options] = execFileMock.mock.calls[0]! + expect(command).toBe('open') + expect(args).toEqual(['/tmp/default.txt']) + expect(options.encoding).toBe('utf8') + expect(options.windowsHide).toBe(true) + expect(options.signal).toBeInstanceOf(AbortSignal) + + const commandError = Object.assign(new Error('open failed'), { code: 1 }) + execFileMock.mockImplementationOnce((_command, _args, _options, callback) => { + callback(commandError, 'partial output', 'failure details') + }) + await expect(openNativePath('/tmp/missing.txt', signal(), { platform: 'darwin' })).rejects.toMatchObject({ + message: 'open failed', cause: commandError, code: 1, + stdout: 'partial output', stderr: 'failure details', + }) + }) +}) diff --git a/packages/llm/llm-retry/tests/transport-recovery.spec.ts b/packages/llm/llm-retry/tests/transport-recovery.spec.ts index fcaa9c8d91..9c61f84848 100644 --- a/packages/llm/llm-retry/tests/transport-recovery.spec.ts +++ b/packages/llm/llm-retry/tests/transport-recovery.spec.ts @@ -204,7 +204,9 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => { apiKey: 'mock-key', successText: 'recovered after timeout', }) - context = await harness(server.baseURL, { streamIdleTimeoutMs: 30 }) + // This crosses the real HTTP idle timer, so leave scheduler slack between + // the stalled attempt and the mock server's immediate successful response. + context = await harness(server.baseURL, { streamIdleTimeoutMs: 1_000 }) const agent = context.agentLoop.create(SessionId('wire-stall'), { provider: 'deepseek', model: 'mock-model', @@ -216,7 +218,7 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => { expect(agent.session.events.filter(event => event.type === 'llm/retry').map(event => event.data.failure.code)) .toEqual(['TIMEOUT']) expect(finalAssistantText(agent)).toBe('recovered after timeout') - }) + }, 10_000) it('stops after the configured transport retry budget is exhausted', async () => { const server = await start(['connection_reset', 'connection_reset', 'connection_reset'], { diff --git a/packages/plan/plan-mode/src/invariant.ts b/packages/plan/plan-mode/src/invariant.ts index f501d2fe44..7c377daac6 100644 --- a/packages/plan/plan-mode/src/invariant.ts +++ b/packages/plan/plan-mode/src/invariant.ts @@ -11,9 +11,10 @@ export const name = 'plan-mode-invariant' /** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Validate one `plan/mode` payload before it reaches the durable log. */ -function validateEvent(event: SessionEvent, fail: InvariantFailure): void { +/** Validate one `plan/mode` event before it reaches the durable log. */ +function validateEvent(openTurn: number | null, event: SessionEvent, fail: InvariantFailure): void { if (event.type !== 'plan/mode') return + if (openTurn === null) fail('plan/mode appended outside any open turn') const active = (event.data as { active?: unknown }).active if (typeof active !== 'boolean') { fail(`plan/mode carries invalid active state ${JSON.stringify(active)}; expected a boolean`) @@ -23,13 +24,30 @@ function validateEvent(event: SessionEvent, fail: InvariantFailure): void { /* jscpd:ignore-start -- package companions share replay and dispatch plumbing */ /** Install validation for loaded and newly appended plan-mode state. */ const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { - for (const session of ctx.sessions.list()) { - for (const event of session.events) validateEvent(event, fail) + const traces = new WeakMap() + const seed = (session: Session): number | null => { + let openTurn: number | null = null + traces.set(session, openTurn) + for (const event of session.events) { + if (event.type === 'turn/start') openTurn = event.data.turn + else if (event.type === 'turn/end') openTurn = null + validateEvent(openTurn, event, fail) + traces.set(session, openTurn) + } + return openTurn } + const traceFor = (session: Session): number | null => traces.get(session) ?? seed(session) + + for (const session of ctx.sessions.list()) seed(session) + ctx.on('session/created', (session) => { seed(session) }, { global: true }) + ctx.on('session/event', (session, event) => { + if (event.type === 'turn/start') traces.set(session, event.data.turn) + else if (event.type === 'turn/end') traces.set(session, null) + }, { global: true }) ctx.on('internal/dispatch', (_mode, eventName, args) => { if (eventName !== 'session/event') return - const event = (args as [Session, SessionEvent])[1] - validateEvent(event, fail) + const [session, event] = args as [Session, SessionEvent] + validateEvent(traceFor(session), event, fail) }, { global: true }) }, { inject: ['sessions'] }) /* jscpd:ignore-end */ diff --git a/packages/plan/plan-mode/tests/invariant.spec.ts b/packages/plan/plan-mode/tests/invariant.spec.ts index e5bb13687e..569fbdd09e 100644 --- a/packages/plan/plan-mode/tests/invariant.spec.ts +++ b/packages/plan/plan-mode/tests/invariant.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import SessionStore, { type Session, type SessionEvent } from '@deepseek-ai/dsh-session' +import SessionStore, { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import * as PlanModeInvariant from '@deepseek-ai/dsh-plan-mode/invariant' import InvariantService from '@deepseek-ai/dsh-invariants' @@ -16,24 +16,46 @@ function event(active: unknown): SessionEvent { return { type: 'plan/mode', seq: 0, time: 0, data: { active } } as SessionEvent } +function emitTurnStart(ctx: Context, session: Session): void { + ctx.emit('session/event', session, { + type: 'turn/start', seq: 0, time: 0, + data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + }) +} + describe('plan-mode stream invariants', () => { it('accepts either boolean state', async () => { const ctx = await setup() - expect(() => { ctx.emit('session/event', {} as Session, event(true)) }).not.toThrow() - expect(() => { ctx.emit('session/event', {} as Session, event(false)) }).not.toThrow() + const session = new Session(SessionId('plan-state')) + emitTurnStart(ctx, session) + expect(() => { ctx.emit('session/event', session, event(true)) }).not.toThrow() + expect(() => { ctx.emit('session/event', session, event(false)) }).not.toThrow() + ctx.emit('session/event', session, { + type: 'turn/end', seq: 3, time: 3, + data: { turn: 1, reason: { kind: 'completed' } }, + }) }) it.each([42, 'plan', undefined])('rejects invalid durable plan state %j', async (active) => { const ctx = await setup() - expect(() => { ctx.emit('session/event', {} as Session, event(active)) }) + const session = new Session(SessionId(`invalid-${String(active)}`)) + emitTurnStart(ctx, session) + expect(() => { ctx.emit('session/event', session, event(active)) }) .toThrow(/expected a boolean/) }) + it('rejects plan state outside any open turn', async () => { + const ctx = await setup() + expect(() => ctx.sessions.create().append('plan/mode', { active: true })) + .toThrow(/outside any open turn/) + }) + it('ignores unrelated dispatches and session events', async () => { const ctx = await setup() + const session = new Session(SessionId('unrelated')) expect(() => { ctx.emit('tools/change') - ctx.emit('session/event', {} as Session, { + ctx.emit('session/event', session, { type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, }) }).not.toThrow() @@ -42,9 +64,33 @@ describe('plan-mode stream invariants', () => { it('rejects invalid existing state on late registration', async () => { const ctx = new Context() await ctx.plugin(SessionStore) - ctx.sessions.create().append('plan/mode', { active: 'plan' as unknown as boolean }) + const session = ctx.sessions.create() + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('plan/mode', { active: 'plan' as unknown as boolean }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) await ctx.plugin(InvariantService, { enabled: true }) await expect(ctx.plugin(PlanModeInvariant).then(() => undefined)).rejects.toThrow(/expected a boolean/) }) + + it('replays enclosed existing plan state through its closing boundary', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const session = ctx.sessions.create() + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('plan/mode', { active: true }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + await ctx.plugin(InvariantService, { enabled: true }) + + await expect(ctx.plugin(PlanModeInvariant).then(() => undefined)).resolves.toBeUndefined() + }) + + it('rejects unenclosed existing plan state on late registration', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + ctx.sessions.create().append('plan/mode', { active: true }) + await ctx.plugin(InvariantService, { enabled: true }) + + await expect(ctx.plugin(PlanModeInvariant).then(() => undefined)).rejects.toThrow(/outside any open turn/) + }) }) diff --git a/packages/pty/pty-local/README.i18n.yaml b/packages/pty/pty-local/README.i18n.yaml index 279b7ab950..04e34c3f3c 100644 --- a/packages/pty/pty-local/README.i18n.yaml +++ b/packages/pty/pty-local/README.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 -README.md: abc7d9bc81b6ec43bed6277de7f42ddf5728c7a8 -README.zh.md: 7a99a303ac3b6c83e2acd0f8154e9b313ef673d1 +# pnpm run verify-translation-pairing --write packages/pty/pty-local/README.md +README.md: 6f243a6edf3ab8bc228cfda3f6b3b774dabadcbb +README.zh.md: c417828a443d227b5bfcdc1c788cf4ab6751b2ee diff --git a/packages/pty/pty-local/README.md b/packages/pty/pty-local/README.md index abc7d9bc81..6f243a6edf 100644 --- a/packages/pty/pty-local/README.md +++ b/packages/pty/pty-local/README.md @@ -8,7 +8,7 @@ Local Linux/macOS `node-pty` backend for `ctx.pty`; loading it on another platfo The plugin injects `pty`, `sandbox`, and `sandboxPolicy`, then registers the configured backend type (`shell`). `danger-full-access` starts the shell directly; confined modes wrap the exact shell argv through `ctx.sandbox`. The effective session mode is resolved at spawn. A change to a different effective mode is rejected before its `sandbox/mode` event commits while that owner has an open PTY or a spawn in progress; the fence is attached to the exact owner and therefore outlives a local-provider reload that retains existing sessions. Wait for creation to settle and close the sessions before changing modes, so a terminal opened with wider access cannot survive a downgrade. -Linux readiness combines a foreground-verified private bash prompt marker, foreground-process-group syscall inspection, silence fallback, and absolute timeout. macOS uses the verified prompt marker plus silence/timeout because it has no `/proc` syscall surface. A marker is not ready until printable prompt text arrives, including when the OSC marker and `PS1` are split across data callbacks; when bash prints the marker before the kernel publishes its return to the foreground process group, polling retains the candidate for `handoffGraceMs` past the ordinary silence bound so a coincident handoff can win; that grace must cover at least one `pollIntervalMs` and is rejected at load otherwise. An interactive child that inherits `PROMPT_COMMAND` therefore cannot suppress inferred-idle readiness until the absolute timeout. Unrecognized or unreadable process state is never a positive exact-idle signal. During unpublished startup, a fallback requires observed output; zero-output silence cannot publish an empty session, and timeout rejects the spawn. Cancellation closes the unpublished shell and rejects with the caller's exact abort reason even when its foreground process group is not observable yet; if that close fails, `PtyBackendCleanupError` separately preserves the cleanup failure for registry disposal. Incomplete terminal-control sequences are bounded by `maxReadBytes` and discarded through their terminator after crossing that limit; a trailing carriage return is carried across callbacks so split CRLF becomes one newline. +Linux readiness combines a foreground-verified private bash prompt marker, foreground-process-group syscall inspection, silence fallback, and absolute timeout. macOS uses the verified prompt marker plus silence/timeout because it has no `/proc` syscall surface. A marker is not ready until printable prompt text arrives, including when the OSC marker and `PS1` are split across data callbacks; when bash prints the marker before the kernel publishes its return to the foreground process group, polling retains the candidate for `handoffGraceMs` past the ordinary silence bound so a coincident handoff can win; that grace must cover at least one `pollIntervalMs` and is rejected at load otherwise. An interactive child that inherits `PROMPT_COMMAND` therefore cannot suppress inferred-idle readiness until the absolute timeout. Unrecognized or unreadable process state is never a positive exact-idle signal. A foreground group's stdin wait that already existed before a send is likewise not post-write readiness: the same group must be observed outside that wait before a later wait can settle the send, while a changed foreground group is new evidence. During unpublished startup, a fallback requires observed output; zero-output silence cannot publish an empty session, and timeout rejects the spawn. Cancellation closes the unpublished shell and rejects with the caller's exact abort reason even when its foreground process group is not observable yet; if that close fails, `PtyBackendCleanupError` separately preserves the cleanup failure for registry disposal. Incomplete terminal-control sequences are bounded by `maxReadBytes` and discarded through their terminator after crossing that limit; a trailing carriage return is carried across callbacks so split CRLF becomes one newline. Send cancellation resolves the current foreground process group and delivers a real `SIGINT`; it never emulates interruption by writing `\x03`, so raw-mode programs remain cancellable. Close sends `SIGTERM` to descendants, waits, then sends `SIGKILL` to the union of captured survivors and newly scanned descendants so reparenting cannot hide a process from teardown. It verifies that every retained identity is gone or, on Linux, a non-executing zombie before stopping the shell; zombie entries are quiescent and are reaped as the shell exits. A survivor failure does not cache a permanently rejected close; a later close retries the teardown. diff --git a/packages/pty/pty-local/README.zh.md b/packages/pty/pty-local/README.zh.md index 7a99a303ac..c417828a44 100644 --- a/packages/pty/pty-local/README.zh.md +++ b/packages/pty/pty-local/README.zh.md @@ -8,7 +8,7 @@ 该插件注入 `pty`、`sandbox` 和 `sandboxPolicy`,然后注册所配置的后端类型(`shell`)。`danger-full-access` 会直接启动 shell;受限模式则通过 `ctx.sandbox` 包装确切的 shell argv。系统在 spawn 时解析会话的实际模式。当某个所有者存在开放的 PTY 或正在进行 spawn 时,如果配置变更会得到不同的实际模式,系统会在对应 `sandbox/mode` 事件提交前拒绝该变更。该限制绑定到确切所有者,因此即使本地提供方重新加载并保留现有会话,它仍然有效。更改模式前,请等待创建结算并关闭会话,避免以更宽权限打开的终端在权限降级后继续存在。 -Linux 的就绪检测结合以下机制:由前台状态验证的私有 bash 提示符标记、前台进程组 syscall 检查、静默回退和绝对超时。macOS 没有 `/proc` syscall 接口,因此使用经过验证的提示符标记以及静默/超时。当可打印的提示符文本尚未到达时,即使 OSC 标记和 `PS1` 被拆到多个数据回调中,系统也不会把标记视为就绪。如果 bash 在内核发布其重新取得前台进程组的状态前打印标记,轮询会在普通静默上限之后再保留该候选状态 `handoffGraceMs`,使恰好同时发生的前台交接有机会胜出;该宽限至少要覆盖一个 `pollIntervalMs`,否则加载时即被拒绝。因此,继承 `PROMPT_COMMAND` 的交互式子进程无法持续压制推断空闲就绪,最多只能延续到绝对超时。无法识别或读取的进程状态绝不会作为精确空闲的正向信号。尚未发布的启动过程中,回退路径要求已经观察到输出;零输出静默不能发布空会话,超时则拒绝 spawn。取消操作会关闭尚未发布的 shell,并以调用方提供的确切中止原因拒绝,即使当时还无法观察其前台进程组。如果关闭失败,`PtyBackendCleanupError` 会单独保留清理失败,供注册表释放资源时处理。未完成的终端控制序列受 `maxReadBytes` 限制;超过上限后,系统会丢弃内容直到其终止符。末尾的回车会跨回调保留,使拆分的 CRLF 合并为一个换行。 +Linux 的就绪检测结合以下机制:由前台状态验证的私有 bash 提示符标记、前台进程组 syscall 检查、静默回退和绝对超时。macOS 没有 `/proc` syscall 接口,因此使用经过验证的提示符标记以及静默/超时。当可打印的提示符文本尚未到达时,即使 OSC 标记和 `PS1` 被拆到多个数据回调中,系统也不会把标记视为就绪。如果 bash 在内核发布其重新取得前台进程组的状态前打印标记,轮询会在普通静默上限之后再保留该候选状态 `handoffGraceMs`,使恰好同时发生的前台交接有机会胜出;该宽限至少要覆盖一个 `pollIntervalMs`,否则加载时即被拒绝。因此,继承 `PROMPT_COMMAND` 的交互式子进程无法持续压制推断空闲就绪,最多只能延续到绝对超时。无法识别或读取的进程状态绝不会作为精确空闲的正向信号。同样,一次 send 之前就已存在的前台进程组 stdin 等待并不代表写入后就绪:必须先观察到同一进程组脱离该等待,之后再次进入等待才能使该次 send 完成;前台进程组发生变化则构成新的证据。尚未发布的启动过程中,回退路径要求已经观察到输出;零输出静默不能发布空会话,超时则拒绝 spawn。取消操作会关闭尚未发布的 shell,并以调用方提供的确切中止原因拒绝,即使当时还无法观察其前台进程组。如果关闭失败,`PtyBackendCleanupError` 会单独保留清理失败,供注册表释放资源时处理。未完成的终端控制序列受 `maxReadBytes` 限制;超过上限后,系统会丢弃内容直到其终止符。末尾的回车会跨回调保留,使拆分的 CRLF 合并为一个换行。 取消发送时,系统会解析当前前台进程组并发送真正的 `SIGINT`;它绝不会通过写入 `\x03` 模拟中断,因此原始模式程序仍可取消。关闭操作先向后代发送 `SIGTERM` 并等待,再向已捕获的存活进程与新扫描到的后代之并集发送 `SIGKILL`,防止进程通过重新设定父进程而逃避清理。系统确认每个保留的进程身份都已消失;在 Linux 上,非执行中的僵尸进程也视为完全停稳,并会随 shell 退出而回收。如果仍有进程存活,失败结果不会缓存成永久拒绝的关闭操作;后续关闭仍会重试清理。 diff --git a/packages/pty/pty-local/src/session.ts b/packages/pty/pty-local/src/session.ts index f6e6e7a20d..46f3be2ca8 100644 --- a/packages/pty/pty-local/src/session.ts +++ b/packages/pty/pty-local/src/session.ts @@ -79,14 +79,18 @@ class LocalSendOperation implements PtySendOperation { private readonly output: BoundedTextBuffer private readonly promise: PromiseWithResolvers private finished = false + private initialForegroundLeftWait: boolean constructor( maxBytes: number, readonly startedAt: number, + private readonly initialForegroundPgid: number | undefined, + initialForegroundWasWaiting: boolean, private readonly onCancel: () => void, ) { this.output = new BoundedTextBuffer(maxBytes) this.promise = Promise.withResolvers() + this.initialForegroundLeftWait = !initialForegroundWasWaiting } get done(): Promise { @@ -119,6 +123,15 @@ class LocalSendOperation implements PtySendOperation { return this.output.consume() } + acceptsStdinWait(pgid: number, waiting: boolean): boolean { + // The same group may still expose the wait that existed before terminal.write. + // Observe every poll so a departure before the exact-settlement threshold + // still makes a later return to that wait post-write evidence. + if (pgid !== this.initialForegroundPgid) return waiting + if (!waiting) this.initialForegroundLeftWait = true + return waiting && this.initialForegroundLeftWait + } + cancel(): boolean { if (this.finished) return false this.onCancel() @@ -200,9 +213,14 @@ export class LocalPtySession implements PtyBackendSession { if (this.active !== undefined) throw new Error('PTY session already has an active send') if (request.signal?.aborted === true) throw new Error('PTY send aborted before write') + const initialForegroundPgid = this.inspector.foregroundPgid(this.pid) + const initialForegroundWasWaiting = initialForegroundPgid !== undefined + && this.inspector.isStdinWaiting(initialForegroundPgid) const operation = new LocalSendOperation( this.config.maxReadBytes, Date.now(), + initialForegroundPgid, + initialForegroundWasWaiting, () => { this.interrupt(operation) }, ) this.active = operation @@ -321,12 +339,15 @@ export class LocalPtySession implements PtyBackendSession { } const elapsed = Date.now() - operation.startedAt const startupHasOutput = !this.initializing || this.scrollback.snapshot().text.length > 0 - if (startupHasOutput && elapsed >= this.config.exactProbeAfterMs) { + let acceptsStdinWait = false + if (startupHasOutput) { const pgid = this.inspector.foregroundPgid(this.pid) - if (pgid !== undefined && this.inspector.isStdinWaiting(pgid)) { - this.settleActive('stdin_read') - return - } + acceptsStdinWait = pgid !== undefined + && operation.acceptsStdinWait(pgid, this.inspector.isStdinWaiting(pgid)) + } + if (elapsed >= this.config.exactProbeAfterMs && acceptsStdinWait) { + this.settleActive('stdin_read') + return } // A prompt candidate can race bash's foreground handoff, but an interactive // child also inherits PROMPT_COMMAND. Silence therefore remains the bound diff --git a/packages/pty/pty-local/tests/local.spec.ts b/packages/pty/pty-local/tests/local.spec.ts index c57dc11c31..3e5a12173e 100644 --- a/packages/pty/pty-local/tests/local.spec.ts +++ b/packages/pty/pty-local/tests/local.spec.ts @@ -170,12 +170,15 @@ describe('pty-local real shell', () => { controller.abort() const result = await foreground.done expectReadyForNextSend(result.waitReason) - const after = await ctx.pty.startSend(agent, created.sessionId, { - text: 'echo AFTER_SIGINT', + const afterReady = 'AFTER_SIGINT' + const afterCommand = 'printf "AFTER_%s\\n" SIGINT' + expect(afterCommand).not.toContain(afterReady) + const after = ctx.pty.startSend(agent, created.sessionId, { + text: afterCommand, submit: true, - }).done - expect(after.viewport).toContain('AFTER_SIGINT') - expectReadyForNextSend(after.waitReason) + }) + await waitForOutput(after, afterReady, 15_000) + expectReadyForNextSend((await after.done).waitReason) await ctx.pty.kill(agent, created.sessionId) - }, 20_000) + }, 35_000) }) diff --git a/packages/pty/pty-local/tests/session.spec.ts b/packages/pty/pty-local/tests/session.spec.ts index e4dbacaa2a..53ce61eebc 100644 --- a/packages/pty/pty-local/tests/session.spec.ts +++ b/packages/pty/pty-local/tests/session.spec.ts @@ -115,12 +115,60 @@ describe('LocalPtySession readiness and output', () => { inspector.waiting = true const operation = session.startSend({ text: 'python3', submit: true }) expect(terminal.writes).toEqual(['python3', '\r']) + inspector.pgid = 789 terminal.emitData('Python\r\n>>> ') await vi.advanceTimersByTimeAsync(20) expect(await operation.done).toMatchObject({ waitReason: 'stdin_read', viewport: 'Python\n>>> ', sessionStatus: { kind: 'running' } }) expect(operation.cancel()).toBe(false) }) + it('does not reuse a pre-write stdin wait as post-write readiness', async () => { + vi.useFakeTimers() + const terminal = new FakeTerminal() + const inspector = new FakeInspector() + const session = new LocalPtySession(terminal.asPty(), inspector, config()) + await initialize(session, terminal) + + inspector.waiting = true + const operation = session.startSend({ text: 'echo ready', submit: true }) + let settled = false + void operation.done.then(() => { settled = true }) + await vi.advanceTimersByTimeAsync(20) + expect(settled).toBe(false) + + inspector.waiting = false + await vi.advanceTimersByTimeAsync(10) + expect(settled).toBe(false) + inspector.waiting = true + await vi.advanceTimersByTimeAsync(10) + expect((await operation.done).waitReason).toBe('stdin_read') + }) + + it('tracks a pre-write wait exit before exact probing begins', async () => { + vi.useFakeTimers() + const terminal = new FakeTerminal() + const inspector = new FakeInspector() + const session = new LocalPtySession(terminal.asPty(), inspector, config({ + exactProbeAfterMs: 50, + idleSilenceMs: 100, + timeoutMs: 200, + })) + await initialize(session, terminal) + + inspector.waiting = true + const operation = session.startSend({ text: 'fast command', submit: true }) + let settled = false + void operation.done.then(() => { settled = true }) + inspector.waiting = false + await vi.advanceTimersByTimeAsync(10) + inspector.waiting = true + await vi.advanceTimersByTimeAsync(30) + expect(settled).toBe(false) + await vi.advanceTimersByTimeAsync(10) + expect(settled).toBe(true) + expect((await operation.done).waitReason).toBe('stdin_read') + }) + it('distinguishes inferred idle, timeout, exit signal, and operation reads', async () => { vi.useFakeTimers() const terminal = new FakeTerminal() diff --git a/packages/sandbox/sandbox-policy/README.i18n.yaml b/packages/sandbox/sandbox-policy/README.i18n.yaml index a098b0ba0d..c324986a78 100644 --- a/packages/sandbox/sandbox-policy/README.i18n.yaml +++ b/packages/sandbox/sandbox-policy/README.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 -README.md: e01db9f618fcc8ad139c7b7aaa3b942150df3194 -README.zh.md: 2341850cefadff1bba8d0f738320028e00ea9ae5 +# pnpm run verify-translation-pairing --write packages/sandbox/sandbox-policy/README.md +README.md: dca54330bc888af9ecac21aa92019d8a2b0140bd +README.zh.md: abf2d9fb8830fdcaf7f1357b393b434de4a5ad8d diff --git a/packages/sandbox/sandbox-policy/README.md b/packages/sandbox/sandbox-policy/README.md index e01db9f618..dca54330bc 100644 --- a/packages/sandbox/sandbox-policy/README.md +++ b/packages/sandbox/sandbox-policy/README.md @@ -21,7 +21,7 @@ Two families enforce the same mode vocabulary: the sandboxed bash executor (`@de - `setSandboxMode(session, mode)` — THE write path for a per-session override: appends exactly one `sandbox/mode` event. The switch IS its event; nothing mutates the mode out of band. - `SANDBOX_MODES` — every mode, for option advertisement and runtime validation. -The optional `./invariant` companion rejects a forged durable `sandbox/mode` event whose value falls outside that closed vocabulary; Session and its companion own the surrounding storage and turn-enclosure rules. +The optional `./invariant` companion rejects a forged durable `sandbox/mode` event whose value falls outside that closed vocabulary; Session and its companion own the surrounding storage and core execution-enclosure rules. ## The per-session store diff --git a/packages/sandbox/sandbox-policy/README.zh.md b/packages/sandbox/sandbox-policy/README.zh.md index 2341850cef..abf2d9fb88 100644 --- a/packages/sandbox/sandbox-policy/README.zh.md +++ b/packages/sandbox/sandbox-policy/README.zh.md @@ -21,7 +21,7 @@ - `setSandboxMode(session, mode)`:逐会话覆盖的唯一写入路径:恰好追加一条 `sandbox/mode` 事件。切换本身就是事件;不会在带外修改模式。 - `SANDBOX_MODES`:所有模式,用于选项展示与运行时验证。 -可选的 `./invariant` 配套组件会拒绝伪造的持久 `sandbox/mode` 事件,只要其值不在该封闭词汇中;Session 与其配套组件拥有周围的存储与轮次封闭规则。 +可选的 `./invariant` 配套组件会拒绝伪造的持久 `sandbox/mode` 事件,只要其值不在该封闭词汇中;Session 与其配套组件拥有周围的存储与核心执行封闭规则。 ## 逐会话 store diff --git a/packages/session-persistence/session-persistence-jsonl/src/format.ts b/packages/session-persistence/session-persistence-jsonl/src/format.ts index 5018e19dbf..03e8b15da0 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/format.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/format.ts @@ -253,10 +253,10 @@ export function scanLog(buffer: Buffer): { meta: SessionHeader; events: SessionE const headerLine = parsedHeader // Parse and decode every complete line first so the last valid `turn/end` - // determines whether an earlier hole is committed corruption or an - // uncommitted tail. One line yields one event, or a whole run for a packed - // chunk row; a row-tagged line that fails row validation is a hole, exactly - // like unparsable JSON. + // determines whether an earlier hole interrupts an otherwise closed + // execution or belongs to a tolerable final suffix. One line yields one + // event, or a whole run for a packed chunk row; a row-tagged line that fails + // row validation is a hole, exactly like unparsable JSON. interface Parsed { ok: boolean; events?: SessionEvent[]; endByte: number } const parsed: Parsed[] = eventEntries.map((entry) => { try { @@ -266,9 +266,11 @@ export function scanLog(buffer: Buffer): { meta: SessionHeader; events: SessionE } }) - // The last index (into eventEntries) that ends in a valid `turn/end` — the - // last fully-committed boundary (the loop flushes only at turn/end). A packed - // row never stores a turn/end, so only single-event lines can match. + // The last index (into eventEntries) that ends in a valid `turn/end`. A hole + // before this boundary cannot be a torn final suffix because later execution + // already closed. Standalone events after it remain part of the preserved + // contiguous prefix. A packed row never stores a turn/end, so only + // single-event lines can match. let lastTurnEnd = -1 for (let i = parsed.length - 1; i >= 0; i--) { const p = parsed[i] diff --git a/packages/session-title/session-title-llm/README.i18n.yaml b/packages/session-title/session-title-llm/README.i18n.yaml index 5911c38ba7..2343a6cb3a 100644 --- a/packages/session-title/session-title-llm/README.i18n.yaml +++ b/packages/session-title/session-title-llm/README.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 -README.md: 5d004b634e6242a8a558182e6c953e4c43b25e1f -README.zh.md: 36567d950e612561d8af804eca64ac6f9f3cd788 +# pnpm run verify-translation-pairing --write packages/session-title/session-title-llm/README.md +README.md: 342687b5aa0cd35a70abf8cc66b3fe80342bce0b +README.zh.md: 2bb48b52ae6298c5c895ef99ff54cf5f1d7a6d8c diff --git a/packages/session-title/session-title-llm/README.md b/packages/session-title/session-title-llm/README.md index 5d004b634e..342687b5aa 100644 --- a/packages/session-title/session-title-llm/README.md +++ b/packages/session-title/session-title-llm/README.md @@ -10,7 +10,7 @@ This package is a library, not a Cordis plugin. The provider plugins call `regis `provider` and `model` overrides are optional but must be supplied together as non-empty strings. Without that pair, the helper uses the exact provider/model route captured from the current session's logged `request/header`; an explicit refresh before any route exists therefore needs overrides. The helper measures the final JSON-framed user prompt, including seq fields, wrappers, and JSON escaping, against `maxInputBytes` before logging or dispatch instead of truncating it. Timeout and caller cancellation are rechecked while consuming the stream and after it completes, so a late successful result cannot be accepted even if an interceptor or adapter ignores abort. Malformed or empty output, tool calls, and non-stop finish reasons also reject; the session-title service decides whether that rejection is an automatic warning or an explicit caller failure. -After route and input validation, the helper appends a log-only `session/title-llm-request` event before model dispatch. It contains the title-provider id, exact source seqs, route, system prompt, message list, and output-token cap used by the call. The append shares the title capability's per-session settlement queue, so a superseding request cannot collide with an earlier fallback, request record, or accepted-title flush. The dispatched envelope is deep-frozen, carries `purpose: 'session-title'`, and deliberately lacks dsh-agent-loop's process-local request identity. Interceptors stay aligned with the record while loop-only reconstruction observers do not compare it with the conversation header. The DeepSeek adapter maps that purpose to thinking-disabled so the small output budget is reserved for visible title text; other adapters own their purpose-specific behavior. A later model failure leaves the request record intact; validation failures that never become dispatchable requests do not create one. The event stays outside derived model history. +After route and input validation, the helper appends a log-only `session/title-llm-request` event directly through `Session` before model dispatch. It contains the title-provider id, exact source seqs, route, system prompt, message list, and output-token cap used by the call. Persistence observes the record eagerly; the append does not need a title-specific marker, cast, settlement queue, or flush. The dispatched envelope is deep-frozen, carries `purpose: 'session-title'`, and deliberately lacks dsh-agent-loop's process-local request identity. Interceptors stay aligned with the record while loop-only reconstruction observers do not compare it with the conversation header. The DeepSeek adapter maps that purpose to thinking-disabled so the small output budget is reserved for visible title text; other adapters own their purpose-specific behavior. A later model failure leaves the request record intact; validation failures that never become dispatchable requests do not create one. The event stays outside derived model history. ## Configuration diff --git a/packages/session-title/session-title-llm/README.zh.md b/packages/session-title/session-title-llm/README.zh.md index 36567d950e..2bb48b52ae 100644 --- a/packages/session-title/session-title-llm/README.zh.md +++ b/packages/session-title/session-title-llm/README.zh.md @@ -10,7 +10,7 @@ `provider` 和 `model` 覆盖项都是可选的,但必须同时作为非空字符串提供。如果没有这一对取值,辅助模块会使用当前会话已记录 `request/header` 中捕获的确切提供方/模型路由;因此,在任何路由出现前显式刷新时必须提供覆盖项。辅助模块在记录或分发前,以 `maxInputBytes` 测量最终 JSON 封装的用户提示词,包括 seq 字段、包装层与 JSON 转义,而不是将其截断。消费流期间和流完成后都会重新检查超时与调用方取消,因此即使 interceptor 或适配器忽略 abort,也不能接受迟到的成功结果。格式错误或空输出、工具调用和非 stop 结束原因同样会 reject;会话标题服务决定该 reject 属于自动警告还是显式调用方失败。 -路由与输入验证完成后,辅助模块会在模型分发前追加仅写入日志的 `session/title-llm-request` 事件。它包含标题提供方 id、确切来源 seq、路由、系统提示词、消息列表,以及该调用使用的输出 token 上限。追加操作共享标题能力的逐会话结算队列,因此取代当前请求的新请求不会与更早回退、请求记录或已接受标题的 flush 冲突。分发的 envelope 会深度冻结,携带 `purpose: 'session-title'`,且有意不包含 dsh-agent-loop 的进程本地请求身份。Interceptor 会与记录保持一致,而循环专用重建观察者不会把它与对话 header 比较。DeepSeek 适配器会将该 purpose 映射为关闭 thinking,使少量输出预算全部用于可见标题文本;其他适配器负责自身 purpose 专用行为。后续模型失败会保留请求记录;从未成为可分发请求的验证失败不会创建记录。该事件始终位于派生模型历史之外。 +路由与输入验证完成后,辅助模块会在模型分发前直接通过 `Session` 追加仅写入日志的 `session/title-llm-request` 事件。它包含标题提供方 id、确切来源 seq、路由、系统提示词、消息列表,以及该调用使用的输出 token 上限。持久化会尽快观察该记录;追加不需要标题专属标记、类型断言、结算队列或 flush。分发的 envelope 会深度冻结,携带 `purpose: 'session-title'`,且有意不包含 dsh-agent-loop 的进程本地请求身份。Interceptor 会与记录保持一致,而循环专用重建观察者不会把它与对话 header 比较。DeepSeek 适配器会将该 purpose 映射为关闭 thinking,使少量输出预算全部用于可见标题文本;其他适配器负责自身 purpose 专用行为。后续模型失败会保留请求记录;从未成为可分发请求的验证失败不会创建记录。该事件始终位于派生模型历史之外。 ## 配置 diff --git a/packages/session-title/session-title-llm/src/index.ts b/packages/session-title/session-title-llm/src/index.ts index 673b50311a..51ac30bdf1 100644 --- a/packages/session-title/session-title-llm/src/index.ts +++ b/packages/session-title/session-title-llm/src/index.ts @@ -10,7 +10,6 @@ import { BlockAssembler, deepFreeze } from '@deepseek-ai/dsh-llm' import type { FinishReason, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' import { deadline, MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { - appendSessionTitleOutOfBand, normalizeSessionTitle, SessionTitleProviderId, } from '@deepseek-ai/dsh-session-title' @@ -43,10 +42,6 @@ declare module '@deepseek-ai/dsh-session' { /** Log-only pre-dispatch record of one session-title model request. */ 'session/title-llm-request': SessionTitleLlmRequestEventData } - - interface OutOfBandSessionEventMap { - 'session/title-llm-request': true - } } /** Capability-owned timeout reason code for auxiliary title requests. */ @@ -264,14 +259,14 @@ export async function generateSessionTitleWithLlm( purpose: 'session-title', signal: callDeadline.signal, }) - await appendSessionTitleOutOfBand(ctx, request.session, 'session/title-llm-request', { + request.session.append('session/title-llm-request', { titleProvider, messageSeqs: selectedMessages.map(message => message.seq), route, system, messages, maxTokens: config.maxOutputTokens, - }, callDeadline.signal) + }) callDeadline.signal.throwIfAborted() const assembler = new BlockAssembler() for await (const chunk of ctx.llm.stream(options)) { diff --git a/packages/session-title/session-title/README.i18n.yaml b/packages/session-title/session-title/README.i18n.yaml index 29748c59a5..1e430b8974 100644 --- a/packages/session-title/session-title/README.i18n.yaml +++ b/packages/session-title/session-title/README.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 -README.md: 2be6f37ce37f525dd567772144e925886ad51b42 -README.zh.md: 7f55b7bba912bf4611f5a058a0390d67b8860b24 +# pnpm run verify-translation-pairing --write packages/session-title/session-title/README.md +README.md: 1939d00f7e78834ec19e2d6b4590cf12af297a30 +README.zh.md: d373c212a193a82567686a634bad79726185e832 diff --git a/packages/session-title/session-title/README.md b/packages/session-title/session-title/README.md index 2be6f37ce3..1939d00f7e 100644 --- a/packages/session-title/session-title/README.md +++ b/packages/session-title/session-title/README.md @@ -9,10 +9,10 @@ Only text blocks from human `user/message` events are eligible. The first eligib ## Service: `SessionTitleService` (ctx key: `sessionTitle`) - `get(session)` folds the latest accepted title from a live or replayed log. -- `refresh(session, signal?)` materializes the fallback when needed, then explicitly runs the registered provider over the current eligible messages. Provider errors and caller cancellation reject; cancellation does not roll back a fallback append already entering durability. +- `refresh(session, signal?)` materializes the fallback when needed, then explicitly runs the registered provider over the current eligible messages. Provider errors and caller cancellation reject; cancellation does not roll back an already accepted fallback event. - `register(provider)` installs the sole optional provider and returns its awaitable Cordis effect disposer. A second registration throws immediately; disposal aborts pending and active calls, waits for their settlement, and only then permits another provider to register. -Automatic work never delays the main agent response. A provider starts only after a marked loop-built request's exact route matches the current logged `request/header`, including when the unchanged header needs no new snapshot. Its late completion joins an open turn or uses a flushed zero-step `session-title` turn through `ctx.sessions.appendOutOfBand()`. Automatic failures warn and retain the latest title. New all-message revisions, provider disposal, session disposal, and explicit refresh abort older work, and a stale completion cannot append. Concurrent explicit refreshes reserve their order before fallback durability waits, while overlapping automatic and explicit fallback requests share one session-local in-flight append. Service and bundled model-provider records use `appendSessionTitleOutOfBand()` to share a per-session settlement queue, so a replacement request record waits for an earlier title write without serializing the superseded model call itself. Service teardown cancels queued work and drains calls that ignore cancellation before unloading completes. +Automatic work never delays the main agent response. A provider starts only after a marked loop-built request's exact route matches the current logged `request/header`, including when the unchanged header needs no new snapshot. Its late completion appends a standalone log-only event directly through `Session` without opening a turn. Persistence observes that event eagerly and drains on ordinary lifecycle checkpoints; title publication itself does not force a flush. Automatic failures warn and retain the latest title. New all-message revisions, provider disposal, session disposal, and explicit refresh abort older work, and a stale completion cannot append. Concurrent explicit refreshes reserve their revision before provider work, while overlapping automatic and explicit fallback requests share one session-local in-flight append. The service and bundled model provider each append their own literal event type, so no generic title-write marker, cast, or settlement queue is needed. Service teardown cancels queued work and drains calls that ignore cancellation before unloading completes. Forks inherit title events in their seed unchanged. The first-message cadence does not automatically retitle a child; the all-messages cadence may append a new revision after the child receives a later human prompt. diff --git a/packages/session-title/session-title/README.zh.md b/packages/session-title/session-title/README.zh.md index 7f55b7bba9..d373c212a1 100644 --- a/packages/session-title/session-title/README.zh.md +++ b/packages/session-title/session-title/README.zh.md @@ -9,10 +9,10 @@ ## 服务:`SessionTitleService`(ctx 键:`sessionTitle`) - `get(session)` 从活跃或回放日志折叠最新已接受标题。 -- `refresh(session, signal?)` 在需要时物化回退,然后显式运行已注册提供方,处理当前符合条件的消息。提供方错误与调用方取消会 reject;取消不会回滚已经进入持久化流程的回退追加。 +- `refresh(session, signal?)` 在需要时物化回退,然后显式运行已注册提供方,处理当前符合条件的消息。提供方错误与调用方取消会 reject;取消不会回滚已接受的回退事件。 - `register(provider)` 安装唯一可选提供方,并返回可等待的 Cordis effect disposer。第二次注册会立即抛出;资源释放会中止待处理和活跃调用,等待其结算,之后才允许注册另一个提供方。 -自动工作绝不会延迟主 agent 响应。只有当带标记、由循环构建的请求,其确切路由与当前已记录的 `request/header` 匹配时,提供方才会启动;即使 header 未变而无需新快照,也适用此规则。延迟完成会加入开放轮次,或使用已经 flush 的零步骤 `session-title` 轮次,并通过 `ctx.sessions.appendOutOfBand()` 追加。自动失败会发出警告并保留最新标题。新的全消息 revision、提供方资源释放、会话资源释放和显式刷新都会中止旧工作,陈旧完成值无法追加。并发显式刷新会在等待回退持久化前预留顺序;重叠的自动/显式回退请求共享一个会话本地进行中追加。服务与随附模型提供方记录使用 `appendSessionTitleOutOfBand()`,共享逐会话结算队列,因此替换请求记录会等待更早标题写入,但无需串行等待被取代的模型调用本身。服务 teardown 会取消排队工作,并在卸载完成前排空忽略取消的调用。 +自动工作绝不会延迟主 agent(智能体)响应。只有当带标记、由循环构建的请求,其确切路由与当前已记录的 `request/header` 匹配时,提供方才会启动;即使 header 未变而无需新快照,也适用此规则。延迟完成会直接通过 `Session` 追加一个独立的纯日志事件,而不打开轮次。持久化会尽快观察该事件,并在常规生命周期检查点排空;标题发布本身不会强制 flush。自动失败会发出警告并保留最新标题。新的全消息 revision、提供方资源释放、会话资源释放和显式刷新都会中止旧工作,陈旧完成值无法追加。并发显式刷新会在提供方工作之前预留修订号;重叠的自动/显式回退请求共享一个会话本地进行中追加。服务与随附模型提供方各自追加自己的字面量事件类型,因此不需要通用标题写入标记、类型断言或结算队列。服务 teardown 会取消排队工作,并在卸载完成前排空忽略取消的调用。 Fork 会原样继承 seed 中的标题事件。首消息节奏不会自动为子会话重新生成标题;全消息节奏可以在子会话收到后续用户提示词后追加新 revision。 diff --git a/packages/session-title/session-title/src/index.ts b/packages/session-title/session-title/src/index.ts index 628cc2b551..4708dcafe7 100644 --- a/packages/session-title/session-title/src/index.ts +++ b/packages/session-title/session-title/src/index.ts @@ -9,10 +9,8 @@ import type { Branded } from '@deepseek-ai/dsh-brand' import { deepFreeze, isAgentLoopRequest } from '@deepseek-ai/dsh-llm' import type { GenerateOptions } from '@deepseek-ai/dsh-llm' import type { - OutOfBandSessionEventType, Session, SessionEvent, - SessionEventMap, } from '@deepseek-ai/dsh-session' import { fallbackSessionTitle, normalizeSessionTitle } from './normalize.ts' @@ -82,11 +80,6 @@ declare module 'cordis' { } declare module '@deepseek-ai/dsh-session' { - interface TurnTriggerMap { - /** Zero-step turn opened only to durably append a late title update. */ - 'session-title': { kind: 'session-title' } - } - interface SessionEventMap { /** * Latest-wins session title snapshot. Log-only: it never enters the model @@ -94,50 +87,6 @@ declare module '@deepseek-ai/dsh-session' { */ 'session/title': SessionTitleEventData } - - interface OutOfBandSessionEventMap { - 'session/title': true - } -} - -/** Per-session settlement tails for title-capability out-of-band writes. */ -const SESSION_TITLE_WRITE_TAILS = new WeakMap>() - -/** Convert either write outcome into a fulfilled queue tail. */ -function settleSessionTitleWrite(): void {} - -/** - * Serialize one title-capability out-of-band event with its session peers. - * Cancellation is checked when the write reaches the head of the queue; once - * the core append starts, its durability contract runs to completion. - * @param ctx - context exposing the live session store. - * @param session - exact live session that owns the title-capability event. - * @param type - plugin-declared log-only title event type. - * @param data - typed JSON payload for the event. - * @param signal - service or provider lifetime checked before publication starts. - * @returns the durably accepted event. - */ -export async function appendSessionTitleOutOfBand( - ctx: Context, - session: Session, - type: T, - data: SessionEventMap[T], - signal: AbortSignal, -): Promise> { - const predecessor = SESSION_TITLE_WRITE_TAILS.get(session) - const run = Promise.resolve(predecessor).then(() => { - signal.throwIfAborted() - return ctx.sessions.appendOutOfBand(session, type, data, { kind: 'session-title' }) - }) - const tail = run.then(settleSessionTitleWrite, settleSessionTitleWrite) - SESSION_TITLE_WRITE_TAILS.set(session, tail) - try { - return await run - } finally { - if (SESSION_TITLE_WRITE_TAILS.get(session) === tail) { - SESSION_TITLE_WRITE_TAILS.delete(session) - } - } } /** One eligible human text message exposed to title providers. */ @@ -163,7 +112,7 @@ export interface SessionTitleProviderRequest { readonly signal: AbortSignal } -/** Provider output before service-owned normalization and durable acceptance. */ +/** Provider output before service-owned normalization and log acceptance. */ export interface SessionTitleProviderResult { /** Proposed title text. */ readonly title: string @@ -360,7 +309,7 @@ export class SessionTitleService extends Service { * Explicitly retry the registered provider, or materialize the built-in * fallback when no provider is registered. * @param session - exact live session to refresh. - * @param signal - optional caller cancellation; an in-progress fallback append may finish durably before rejection. + * @param signal - optional caller cancellation. * @returns latest accepted title, or `undefined` when no eligible text exists. */ async refresh(session: Session, signal?: AbortSignal): Promise { @@ -509,7 +458,7 @@ export class SessionTitleService extends Service { return this.track(run, work.registration) } - /** Execute and durably accept one current provider revision. */ + /** Execute and accept one current provider revision. */ private async runProvider( session: Session, work: ActiveProviderWork, @@ -528,7 +477,7 @@ export class SessionTitleService extends Service { }) this.assertCurrent(session, work) const accepted = this.validateResult(result, messages) - await appendSessionTitleOutOfBand(this.ctx, session, 'session/title', { + session.append('session/title', { title: accepted.title, messageSeqs: [...accepted.messageSeqs], source: { @@ -536,7 +485,7 @@ export class SessionTitleService extends Service { provider: work.registration.provider.id, ...accepted.model === undefined ? {} : { model: accepted.model }, }, - }, work.signal) + }) return this.get(session) } finally { const state = this.work.get(session) @@ -711,11 +660,20 @@ export class SessionTitleService extends Service { if (title.length === 0) return undefined const state = this.stateFor(session) if (state.fallback !== undefined) return state.fallback - const fallback = appendSessionTitleOutOfBand(this.ctx, session, 'session/title', { - title, - messageSeqs: [first.seq], - source: { kind: 'fallback' }, - }, this.lifetime.signal).then(() => this.get(session)) + const fallback = Promise.resolve().then(() => { + this.assertServiceActive() + if (this.ctx.sessions.get(session.id) !== session) { + throw new Error(`session "${session.id}" is not live in this store`) + } + const accepted = this.get(session) + if (accepted !== undefined) return accepted + session.append('session/title', { + title, + messageSeqs: [first.seq], + source: { kind: 'fallback' }, + }) + return this.get(session) + }) state.fallback = fallback try { return await fallback diff --git a/packages/session-title/session-title/src/invariant.ts b/packages/session-title/session-title/src/invariant.ts index ac6a513391..9bae01a72f 100644 --- a/packages/session-title/session-title/src/invariant.ts +++ b/packages/session-title/session-title/src/invariant.ts @@ -15,8 +15,9 @@ export const name = 'session-title-invariant' export const inject = ['invariants'] /** - * No runtime invariant: the service validates provider revisions before their single durable - * append, and its remaining provider lifecycle state is process-local and covered by package tests. + * No runtime invariant: the service validates provider revisions before their + * title append, and its remaining lifecycle state is process-local and covered + * by package tests. */ const install: InvariantInstaller = () => {} diff --git a/packages/session-title/session-title/tests/persistence.spec.ts b/packages/session-title/session-title/tests/persistence.spec.ts index 9981b0f87c..949b73273a 100644 --- a/packages/session-title/session-title/tests/persistence.spec.ts +++ b/packages/session-title/session-title/tests/persistence.spec.ts @@ -30,9 +30,8 @@ async function appendPersistedTitle(ctx: Context, id: ReturnType setTimeout(resolve, 0)) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - await ctx.parallel('session/flush', session) + await ctx.sessionTitle.refresh(session) } async function expectPersistedTitle(ctx: Context, id: ReturnType): Promise { @@ -41,13 +40,13 @@ async function expectPersistedTitle(ctx: Context, id: ReturnType event.type)).toEqual([ 'turn/start', 'user/message', - 'session/title', 'turn/end', + 'session/title', ]) } diff --git a/packages/session-title/session-title/tests/service-contracts.spec.ts b/packages/session-title/session-title/tests/service-contracts.spec.ts index 1e5f0c8b26..f3a4a2ab3c 100644 --- a/packages/session-title/session-title/tests/service-contracts.spec.ts +++ b/packages/session-title/session-title/tests/service-contracts.spec.ts @@ -2,7 +2,6 @@ import { Context, type Fiber } from 'cordis' import { describe, expect, it, vi } from 'vitest' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' import SessionTitleService, { - appendSessionTitleOutOfBand, SessionTitleProviderId, type Config, type SessionTitleProvider, @@ -10,16 +9,6 @@ import SessionTitleService, { type SessionTitleProviderResult, } from '@deepseek-ai/dsh-session-title' -declare module '@deepseek-ai/dsh-session' { - interface SessionEventMap { - 'test/title-provider-request': { revision: number } - } - - interface OutOfBandSessionEventMap { - 'test/title-provider-request': true - } -} - const CONFIG = { fallbackMaxWords: 5, fallbackMaxBytes: 40, @@ -173,38 +162,7 @@ describe('SessionTitleService configuration and refresh boundaries', () => { expect(disposeSignal?.aborted).toBe(true) }) - it('rejects fallback refresh cancellation that arrives during durability flush', async () => { - const ctx = await setup() - const seed = new Session(SessionId('fallback-cancel-seed')) - seed.append('turn/start', { - turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, - }) - const source = appendPrompt(seed, 'Persist this fallback despite caller cancellation') - seed.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - const session = ctx.sessions.create(SessionId('fallback-cancel'), { seed: seed.events }) - const flushStarted = deferred() - const releaseFlush = deferred() - ctx.on('session/flush', async (subject) => { - if (subject !== session) return - flushStarted.resolve(undefined) - await releaseFlush.promise - }) - const controller = new AbortController() - - const refresh = ctx.sessionTitle.refresh(session, controller.signal) - await flushStarted.promise - controller.abort(new Error('cancelled while fallback flushed')) - releaseFlush.resolve(undefined) - - await expect(refresh).rejects.toThrow('cancelled while fallback flushed') - expect(ctx.sessionTitle.get(session)).toMatchObject({ - messageSeqs: [source.seq], - source: { kind: 'fallback' }, - }) - }) - - it('shares one durable fallback across concurrent refreshes', async () => { + it('shares one fallback across concurrent refreshes', async () => { const ctx = await setup() const seed = new Session(SessionId('fallback-concurrency-seed')) seed.append('turn/start', { @@ -214,10 +172,6 @@ describe('SessionTitleService configuration and refresh boundaries', () => { const source = appendPrompt(seed, 'Create exactly one fallback title') seed.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) const session = ctx.sessions.create(SessionId('fallback-concurrency'), { seed: seed.events }) - let flushes = 0 - ctx.on('session/flush', (subject) => { - if (subject === session) flushes += 1 - }) const results = await Promise.all([ ctx.sessionTitle.refresh(session), @@ -226,136 +180,62 @@ describe('SessionTitleService configuration and refresh boundaries', () => { expect(results[0]).toEqual(results[1]) expect(session.events.filter(event => event.type === 'session/title')).toHaveLength(1) - expect(session.events.filter(event => event.type === 'turn/start' - && event.data.trigger.kind === 'session-title')).toHaveLength(1) + expect(session.events.map(event => event.type)).toEqual([ + 'turn/start', + 'user/message', + 'turn/end', + 'session/title', + ]) expect(ctx.sessionTitle.get(session)?.messageSeqs).toEqual([source.seq]) - expect(flushes).toBe(1) }) - it('reserves overlapping refresh order before fallback durability settles', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(SessionTitleService, CONFIG) - const seed = new Session(SessionId('refresh-order-seed')) - seed.append('turn/start', { - turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, + it('reuses a title accepted before the queued fallback commits', async () => { + const ctx = await setup() + const session = startSession(ctx, 'fallback-already-accepted') + const source = appendPrompt(session, 'Reuse the title that wins the fallback race') + + const refresh = ctx.sessionTitle.refresh(session) + session.append('session/title', { + title: 'Already accepted', + messageSeqs: [source.seq], + source: { kind: 'fallback' }, }) - const source = appendPrompt(seed, 'Keep the newest explicit refresh') - seed.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - const session = ctx.sessions.create(SessionId('refresh-order'), { seed: seed.events }) - const flushStarted = deferred() - const releaseFlush = deferred() - let flushCount = 0 - ctx.on('session/flush', async (subject) => { - if (subject !== session || ++flushCount !== 1) return - flushStarted.resolve(undefined) - await releaseFlush.promise - }) - const result = deferred() + + await expect(refresh).resolves.toMatchObject({ title: 'Already accepted' }) + expect(session.events.filter(event => event.type === 'session/title')).toHaveLength(1) + }) + + it('lets the newest overlapping explicit refresh win', async () => { + const ctx = await setup() + const session = startSession(ctx, 'refresh-order') + const source = appendPrompt(session, 'Keep the newest explicit refresh') + await settle() + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) const requests: SessionTitleProviderRequest[] = [] + const results: Array>> = [] ctx.sessionTitle.register({ id: SessionTitleProviderId('refresh-order'), automatic: 'first-message', generate(request) { requests.push(request) + const result = deferred() + results.push(result) return result.promise }, }) const older = ctx.sessionTitle.refresh(session) - const olderOutcome = older.then( - () => undefined, - (error: unknown) => error, - ) - await flushStarted.promise + await settle() const newer = ctx.sessionTitle.refresh(session) await settle() - expect(requests).toHaveLength(1) - expect(requests[0]?.signal.aborted).toBe(false) - releaseFlush.resolve(undefined) - await settle() - expect(requests).toHaveLength(1) - expect(requests[0]?.signal.aborted).toBe(false) - result.resolve({ title: 'Newest explicit title', messageSeqs: [source.seq] }) + expect(requests).toHaveLength(2) + expect(requests[0]?.signal.aborted).toBe(true) + expect(requests[1]?.signal.aborted).toBe(false) + results[0]?.resolve({ title: 'Obsolete title', messageSeqs: [source.seq] }) + await expect(older).rejects.toThrow(/superseded/) + results[1]?.resolve({ title: 'Newest explicit title', messageSeqs: [source.seq] }) await expect(newer).resolves.toMatchObject({ title: 'Newest explicit title' }) - const olderError = await olderOutcome - expect(olderError).toBeInstanceOf(Error) - if (!(olderError instanceof Error)) throw new Error('expected older refresh to reject') - expect(olderError.message).toMatch(/superseded/) - }) - - it('serializes a newer provider write after the superseded write', async () => { - const ctx = await setup() - const session = startSession(ctx, 'refresh-provider-write-order') - const source = appendPrompt(session, 'Serialize explicit provider writes') - await settle() - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - const flushStarted = deferred() - const releaseFlush = deferred() - let flushCount = 0 - ctx.on('session/flush', async (subject) => { - if (subject !== session || ++flushCount !== 1) return - flushStarted.resolve(undefined) - await releaseFlush.promise - }) - let generation = 0 - ctx.sessionTitle.register({ - id: SessionTitleProviderId('refresh-provider-write-order'), - automatic: 'first-message', - async generate(request) { - generation += 1 - const revision = generation - await appendSessionTitleOutOfBand(ctx, request.session, 'test/title-provider-request', { - revision, - }, request.signal) - return { - title: `Generated title ${revision}`, - messageSeqs: [source.seq], - } - }, - }) - - const older = ctx.sessionTitle.refresh(session) - const olderOutcome = older.then( - () => undefined, - (error: unknown) => error, - ) - await flushStarted.promise - const middle = ctx.sessionTitle.refresh(session) - const middleOutcome = middle.then( - value => value, - (error: unknown) => error, - ) - await settle() - - expect(generation).toBe(2) - expect(session.events.filter(event => event.type === 'test/title-provider-request')) - .toHaveLength(1) - const newer = ctx.sessionTitle.refresh(session) - const newerOutcome = newer.then( - value => value, - (error: unknown) => error, - ) - await settle() - expect(generation).toBe(3) - expect(session.events.filter(event => event.type === 'test/title-provider-request')) - .toHaveLength(1) - - releaseFlush.resolve(undefined) - const newerResult = await newerOutcome - expect(newerResult).toMatchObject({ title: 'Generated title 3' }) - const olderError = await olderOutcome - expect(olderError).toBeInstanceOf(Error) - if (!(olderError instanceof Error)) throw new Error('expected older refresh to reject') - expect(olderError.message).toMatch(/superseded/) - const middleError = await middleOutcome - expect(middleError).toBeInstanceOf(Error) - if (!(middleError instanceof Error)) throw new Error('expected middle refresh to reject') - expect(middleError.message).toMatch(/superseded/) - expect(session.events.filter(event => event.type === 'test/title-provider-request').map(event => event.data.revision)) - .toEqual([1, 3]) }) it('cancels a queued fallback when the session-title service unloads', async () => { @@ -390,6 +270,21 @@ describe('SessionTitleService configuration and refresh boundaries', () => { expect(inactiveError.message).toBe('session-title service disposed') }) + it('suppresses a queued fallback failure after service unload begins', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const fiber = await ctx.plugin(SessionTitleService, CONFIG) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) + const session = startSession(ctx, 'service-unload-started-fallback') + appendPrompt(session, 'Start fallback before unloading the service') + + await Promise.resolve() + await fiber.dispose() + + expect(session.events.some(event => event.type === 'session/title')).toBe(false) + expect(warn).not.toHaveBeenCalled() + }) + it('aborts pending and active provider work and drains ignored cancellation during service unload', async () => { const ctx = new Context() await ctx.plugin(SessionStore) @@ -430,31 +325,6 @@ describe('SessionTitleService configuration and refresh boundaries', () => { await expect(refreshOutcome).resolves.toEqual(expect.objectContaining({ message: 'session-title service disposed' })) }) - it('suppresses a queued fallback failure after service unload begins', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const fiber = await ctx.plugin(SessionTitleService, CONFIG) - const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) - const session = startSession(ctx, 'service-unload-flush') - appendPrompt(session, 'Fallback whose flush outlives the service') - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - const flushStarted = deferred() - const releaseFlush = deferred() - ctx.on('session/flush', async (subject) => { - if (subject !== session) return - flushStarted.resolve(undefined) - await releaseFlush.promise - throw new Error('flush failed during service unload') - }) - - await flushStarted.promise - const disposal = fiber.dispose() - releaseFlush.resolve(undefined) - await disposal - - expect(warn).not.toHaveBeenCalled() - }) - it('warns when a detached session prevents queued fallback publication', async () => { const ctx = await setup() const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) diff --git a/packages/subagent/subagent-inprocess/README.i18n.yaml b/packages/subagent/subagent-inprocess/README.i18n.yaml index 555a9956ac..82c3f1a57a 100644 --- a/packages/subagent/subagent-inprocess/README.i18n.yaml +++ b/packages/subagent/subagent-inprocess/README.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 -README.md: 74a1b504270ec18921156c490c9898a1b0ec1d0e -README.zh.md: 2c358f7ed48cfbbefacf00ce307253d1bdb0c3c8 +# pnpm run verify-translation-pairing --write packages/subagent/subagent-inprocess/README.md +README.md: 220a3f44d0eb14dfed3241c9561800f86a90aecf +README.zh.md: cd03f01cff19c42b8dc91a906ebb89779c494a24 diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index 74a1b50427..220a3f44d0 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -14,7 +14,7 @@ The driver follows this sequence: 2. Call `parent.ctx.agents.create` directly, passing the required request signal into the factory's creation transaction. 3. During that transaction's unpublished setup window, install the requested persona, tool restriction, and structured-output runtime. 4. Publish the child, retain the returned `AgentHandle`, and drive one task with `child.followup(prompt)` followed by `child.whenIdle()`. -5. Read the child's own last assistant message and latest message-triggered turn reason, excluding any fork seed and later plugin-owned zero-step turns. +5. Read the child's own last assistant message and latest message-triggered turn reason, excluding any fork seed and later between-turn records. The child gets the parent's working-directory/session lineage and inherits the parent model unless `request.agentOptions` overrides it. It gets a fresh flat registration scope: parent ownership does not import parent tool restrictions or establish an authority subset. diff --git a/packages/subagent/subagent-inprocess/README.zh.md b/packages/subagent/subagent-inprocess/README.zh.md index 2c358f7ed4..cd03f01cff 100644 --- a/packages/subagent/subagent-inprocess/README.zh.md +++ b/packages/subagent/subagent-inprocess/README.zh.md @@ -14,7 +14,7 @@ 2. 直接调用 `parent.ctx.agents.create`,把必需的请求信号传入工厂的创建事务。 3. 在该事务未发布的设置窗口中,安装请求的 persona、工具限制和结构化输出运行时。 4. 发布子 agent,保留返回的 `AgentHandle`,并通过先调用 `child.followup(prompt)`、再调用 `child.whenIdle()` 来驱动一项任务。 -5. 读取子 agent 自身最后一条 assistant 消息,以及由消息触发的最新轮次原因;排除任何 fork 初始内容和后续由插件拥有的零步骤轮次。 +5. 读取子 agent 自身最后一条 assistant 消息,以及由消息触发的最新轮次原因;排除任何 fork 初始内容和后续轮次间记录。 子 agent 会获得父 agent 的工作目录/会话谱系;除非 `request.agentOptions` 覆盖,否则还会继承父 agent 模型。它获得全新的扁平注册作用域:父级所有权不会导入父 agent 的工具限制,也不会建立权限子集。 diff --git a/packages/support/acp-snapshot/src/harness.ts b/packages/support/acp-snapshot/src/harness.ts index ff98e620d3..d11a4f1b9c 100644 --- a/packages/support/acp-snapshot/src/harness.ts +++ b/packages/support/acp-snapshot/src/harness.ts @@ -50,6 +50,7 @@ const WAIT_POLL_INTERVAL_MS = 10 * `waitForTurnStart` waits for an open durable turn, optionally at or beyond a * specified turn number. `waitForTurnEnd` holds the subprocess open until the * selected session's latest complete raw-JSONL turn boundary is `turn/end`. + * `waitForTitleAfterTurnEnd` additionally waits for a later durable title. * A standalone `cancel` may also wait for a cwd-relative readiness marker. * All wait timeouts default to 10s. */ @@ -67,6 +68,7 @@ export type InputStep = } | { op: 'waitForTurnStart'; minimumTurn?: number; timeoutMs?: number } | { op: 'waitForTurnEnd'; timeoutMs?: number } + | { op: 'waitForTitleAfterTurnEnd'; timeoutMs?: number } | { op: 'cancel'; waitForFile?: { path: string; timeoutMs?: number } } /** A scenario's `input.json`: an ordered list of input steps. */ @@ -280,6 +282,7 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise (id) => { sessionId = id }, (id, timeoutMs, minimumTurn) => waitForPersistedTurnStart(sessionsRoot, id, timeoutMs, minimumTurn), (id, timeoutMs) => waitForPersistedTurnEnd(sessionsRoot, id, timeoutMs), + (id, timeoutMs) => waitForPersistedTitleAfterTurnEnd(sessionsRoot, id, timeoutMs), ) // A permission exchange happens while a step's request is in flight, so // by the time the step settles any script bug it exposed is captured — @@ -353,6 +356,7 @@ async function runStep( setSessionId: (id: string) => void, waitForTurnStart: (sessionId: string, timeoutMs?: number, minimumTurn?: number) => Promise, waitForTurnEnd: (sessionId: string, timeoutMs?: number) => Promise, + waitForTitleAfterTurnEnd: (sessionId: string, timeoutMs?: number) => Promise, ): Promise { switch (step.op) { case 'initialize': @@ -430,6 +434,12 @@ async function runStep( await waitForTurnEnd(sessionId, step.timeoutMs) return } + case 'waitForTitleAfterTurnEnd': { + const sessionId = getSessionId() + if (sessionId === undefined) throw new Error('snapshot-harness: waitForTitleAfterTurnEnd before newSession') + await waitForTitleAfterTurnEnd(sessionId, step.timeoutMs) + return + } case 'waitForTurnStart': { const sessionId = getSessionId() if (sessionId === undefined) throw new Error('snapshot-harness: waitForTurnStart before newSession') @@ -497,6 +507,20 @@ async function waitForPersistedTurnEnd( }, { interval: WAIT_POLL_INTERVAL_MS, timeout: timeoutMs }) } +/** Wait until a complete provider or fallback title record follows the latest closed turn. */ +async function waitForPersistedTitleAfterTurnEnd( + root: string, + sessionId: string, + timeoutMs = DEFAULT_WAIT_TIMEOUT_MS, +): Promise { + await vi.waitFor(async () => { + const log = (await harvestSessionLogs(root)).find(candidate => candidate.id === sessionId) + if (log === undefined || !latestTitleFollowsTurnEnd(log.content)) { + throw new Error(`snapshot-harness: session "${sessionId}" did not persist session/title after turn/end within ${timeoutMs}ms`) + } + }, { interval: WAIT_POLL_INTERVAL_MS, timeout: timeoutMs }) +} + /** Wait for a cwd-relative marker proving an external action reached readiness. */ async function waitForWorkspaceFile( cwd: string, @@ -518,6 +542,13 @@ function latestTurnIsClosed(content: string): boolean { > complete.lastIndexOf('\n{"type":"turn/start",') } +/** Return whether the last complete title record occurs after the last complete turn end. */ +function latestTitleFollowsTurnEnd(content: string): boolean { + const complete = content.slice(0, content.lastIndexOf('\n') + 1) + const turnEnd = complete.lastIndexOf('\n{"type":"turn/end",') + return turnEnd >= 0 && complete.lastIndexOf('\n{"type":"session/title",') > turnEnd +} + /** Return the latest open turn number, validating the persisted boundary record. */ function latestOpenTurn(content: string): number | undefined { const complete = content.slice(0, content.lastIndexOf('\n') + 1) @@ -571,8 +602,8 @@ async function harvestSessionLogs(root: string): Promise { // so session..jsonl maps to the same child on record and replay — replay // re-sorts childFiles by the same key, so the two stay consistent. logs.sort((a, b) => { - const ap = a.parentSession === undefined ? 0 : 1 - const bp = b.parentSession === undefined ? 0 : 1 + const ap = Number(a.parentSession !== undefined) + const bp = Number(b.parentSession !== undefined) return ap - bp || a.createdAt - b.createdAt || a.id.localeCompare(b.id) }) return logs diff --git a/packages/support/acp-snapshot/tests/harness.spec.ts b/packages/support/acp-snapshot/tests/harness.spec.ts index 4d60afb148..b908330554 100644 --- a/packages/support/acp-snapshot/tests/harness.spec.ts +++ b/packages/support/acp-snapshot/tests/harness.spec.ts @@ -536,7 +536,7 @@ describe('runScenario', () => { waitForText: 'thinking about it', }], }, - { agent: AGENT, mode: 'replay', fixtureFile }, + { agent: AGENT, mode: 'replay', fixtureFile, configPath: AGENT.configPath }, ) expect(result.rawStdout).toContain('thinking about it') }) @@ -560,6 +560,32 @@ describe('runScenario', () => { expect(result.sessionLogs[0]?.content).toContain('"type":"turn/end"') }) + it('waitForTitleAfterTurnEnd holds the app through a standalone durable title', { timeout: 20_000 }, async () => { + const { fixtureFile } = await scenario({ + prompt: 'hang-until-cancel', + persistLogsOnCancel: true, + logs: [{ + file: 'project/main/session.jsonl', + lines: [ + { type: 'session', version: 0, id: '{{SID}}', createdAt: 1, delegationDepth: 0 }, + { type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'aborted' } } }, + { type: 'session/title', seq: 2, time: 3, data: { title: 'Late title' } }, + ], + }], + }) + const result = await runScenario( + { + steps: [ + ...boot, + { op: 'promptAndCancel', text: 'hang' }, + { op: 'waitForTitleAfterTurnEnd' }, + ], + }, + { agent: AGENT, mode: 'replay', fixtureFile }, + ) + expect(result.sessionLogs[0]?.content).toMatch(/"turn\/end"[\s\S]*"session\/title"/) + }) + it('waitForTurnStart can require a later durable turn before continuing', { timeout: 20_000 }, async () => { const { fixtureFile } = await scenario({ prompt: 'hang-until-cancel', @@ -692,6 +718,31 @@ describe('runScenario', () => { )).rejects.toThrow(/did not persist turn\/end within 20ms/) }) + it('waitForTitleAfterTurnEnd times out when the title precedes the boundary', { timeout: 20_000 }, async () => { + const { fixtureFile } = await scenario({ + prompt: 'hang-until-cancel', + persistLogsOnCancel: true, + logs: [{ + file: 'project/main/session.jsonl', + lines: [ + { type: 'session', version: 0, id: '{{SID}}', createdAt: 1, delegationDepth: 0 }, + { type: 'session/title', seq: 1, time: 1, data: { title: 'Early title' } }, + { type: 'turn/end', seq: 2, time: 2, data: { turn: 1, reason: { kind: 'aborted' } } }, + ], + }], + }) + await expect(runScenario( + { + steps: [ + ...boot, + { op: 'promptAndCancel', text: 'hang' }, + { op: 'waitForTitleAfterTurnEnd', timeoutMs: 20 }, + ], + }, + { agent: AGENT, mode: 'replay', fixtureFile }, + )).rejects.toThrow(/did not persist session\/title after turn\/end within 20ms/) + }) + it('promptExpectError swallows a model-error response as the expected outcome', { timeout: 20_000 }, async () => { const { fixtureFile } = await scenario({ prompt: 'error' }) const result = await runScenario( @@ -797,6 +848,7 @@ describe('runScenario', () => { [{ op: 'promptAndCancel', text: 'x' }, /promptAndCancel before newSession/], [{ op: 'waitForTurnStart' }, /waitForTurnStart before newSession/], [{ op: 'waitForTurnEnd' }, /waitForTurnEnd before newSession/], + [{ op: 'waitForTitleAfterTurnEnd' }, /waitForTitleAfterTurnEnd before newSession/], [{ op: 'cancel' }, /cancel before newSession/], ] as [InputStep, RegExp][])('rejects %j before newSession', { timeout: 20_000 }, async (step, message) => { const { fixtureFile } = await scenario({}) diff --git a/packages/telemetry/session-telemetry/README.i18n.yaml b/packages/telemetry/session-telemetry/README.i18n.yaml index 7699174575..ae4c852011 100644 --- a/packages/telemetry/session-telemetry/README.i18n.yaml +++ b/packages/telemetry/session-telemetry/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/telemetry/session-telemetry/README.md -README.md: 4905b4492b3c7266c2d18c32302782d1185d80b5 -README.zh.md: dd70f6274878baa95c0c224c7fd9aff861a8ca32 +README.md: 272c9abe78849be3d2bba2c54cd7e25bcbe2d4c2 +README.zh.md: fdf35d423b0a650f4e6280e500436fe8bcf5a48c diff --git a/packages/telemetry/session-telemetry/README.md b/packages/telemetry/session-telemetry/README.md index 4905b4492b..272c9abe78 100644 --- a/packages/telemetry/session-telemetry/README.md +++ b/packages/telemetry/session-telemetry/README.md @@ -10,7 +10,7 @@ The telemetry seam: the CAPTURE side of session-event reporting, behind a backen ## Capture points -The coordinator registers, all through the composing fiber's effects: `session/created` (adopt: record the header, read the log back through the projection from the construction boundary — constructor seeds from fork/resume never re-emit on the firehose and never re-export), `session/event` (project, deep-copy, redact, hand off; zero I/O), `session/flush` (forward the optional `flush()` hint and return void — the loop's awaited parallel must never wait on telemetry), `session/disposed` (emit the session's `shutdown` operational record at its own termination edge — where receivers key crash detection — then retire it, so a long-lived backend neither retains closed sessions nor re-marks them at unload), `agent/error` (the one live-bus relay; turn-enclosure structurally bars those errors from the log), a dispose effect (mark each session still alive at teardown, then await the backend's `shutdown()`; failures warn instead of throwing), and an adoption sweep of `ctx.sessions.list()` (a hot reload does not replay `session/created`). +The coordinator registers, all through the composing fiber's effects: `session/created` (adopt: record the header, read the log back through the projection from the construction boundary — constructor seeds from fork/resume never re-emit on the firehose and never re-export), `session/event` (project, deep-copy, redact, hand off; zero I/O), `session/flush` (forward the optional `flush()` hint and return void — the loop's awaited parallel must never wait on telemetry), `session/disposed` (emit the session's `shutdown` operational record at its own termination edge — where receivers key crash detection — then retire it, so a long-lived backend neither retains closed sessions nor re-marks them at unload), `agent/error` (the one live-bus relay; the session event vocabulary intentionally has no operational-error record), a dispose effect (mark each session still alive at teardown, then await the backend's `shutdown()`; failures warn instead of throwing), and an adoption sweep of `ctx.sessions.list()` (a hot reload does not replay `session/created`). ## The redact waterfall diff --git a/packages/telemetry/session-telemetry/README.zh.md b/packages/telemetry/session-telemetry/README.zh.md index dd70f62748..fdf35d423b 100644 --- a/packages/telemetry/session-telemetry/README.zh.md +++ b/packages/telemetry/session-telemetry/README.zh.md @@ -10,7 +10,7 @@ ## 捕获点 -协调器的全部注册都经由组合方 fiber 的 effect 完成:`session/created`(收养:记录 header,并经投影从构造边界起回读日志;来自 fork 或恢复的构造函数种子绝不会在 firehose 上再次发出,也绝不会再次导出)、`session/event`(投影、深拷贝、脱敏、交接;零 I/O)、`session/flush`(转发可选的 `flush()` 提示并返回 void;循环所等待的并行任务绝不能等待遥测)、`session/disposed`(在会话自身的终止边缘发出该会话的 `shutdown` 运维记录,接收端正是在这个边缘锚定崩溃检测;随后将该会话退役,因此长生命周期的后端既不会保留已关闭的会话,也不会在卸载时再次标记它们)、`agent/error`(唯一的实时总线转发;轮次封闭机制在结构上决定了这些错误进不了日志)、一个 dispose effect(拆卸时先标记每个仍存活的会话,再等待后端的 `shutdown()`;失败只发出警告而不抛出),以及对 `ctx.sessions.list()` 的收养扫描(热重载不会重放 `session/created`)。 +协调器的全部注册都经由组合方 fiber 的 effect 完成:`session/created`(收养:记录 header,并经投影从构造边界起回读日志;来自 fork 或恢复的构造函数种子绝不会在 firehose 上再次发出,也绝不会再次导出)、`session/event`(投影、深拷贝、脱敏、交接;零 I/O)、`session/flush`(转发可选的 `flush()` 提示并返回 void;循环所等待的并行任务绝不能等待遥测)、`session/disposed`(在会话自身的终止边缘发出该会话的 `shutdown` 运维记录,接收端正是在这个边缘锚定崩溃检测;随后将该会话退役,因此长生命周期的后端既不会保留已关闭的会话,也不会在卸载时再次标记它们)、`agent/error`(唯一的实时总线转发;会话事件词汇有意不包含运行错误记录)、一个 dispose effect(拆卸时先标记每个仍存活的会话,再等待后端的 `shutdown()`;失败只发出警告而不抛出),以及对 `ctx.sessions.list()` 的收养扫描(热重载不会重放 `session/created`)。 ## 脱敏 waterfall diff --git a/packages/ui/jsonrpc/README.i18n.yaml b/packages/ui/jsonrpc/README.i18n.yaml index b91eb86bde..28bb4357db 100644 --- a/packages/ui/jsonrpc/README.i18n.yaml +++ b/packages/ui/jsonrpc/README.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 -README.md: 96dae46c9c6ecce6643bb408a5e57c2db2275a83 -README.zh.md: 2c257b13a15ad3c6c1bf0c4dd44a04e308e8f0b0 +# pnpm run verify-translation-pairing --write packages/ui/jsonrpc/README.md +README.md: 48eb6106fe4b015f114b264a312249a92128266f +README.zh.md: 8ec2d57a206d770d0b77ee69036457e3b2864303 diff --git a/packages/ui/jsonrpc/README.md b/packages/ui/jsonrpc/README.md index 96dae46c9c..48eb6106fe 100644 --- a/packages/ui/jsonrpc/README.md +++ b/packages/ui/jsonrpc/README.md @@ -22,7 +22,7 @@ The plugin answers `shutdown`, disposes SDK-owned agents and subscriptions to qu ## Wire notes -`initialize.serverInfo.name` is the wire-stable `deepseek-harness-sdk-runtime`. A session accepts one in-flight prompt; overlap fails immediately, other sessions remain independent, and the session is reusable after settlement. `session.finished` reports that prompt's message-triggered turn outcome; later injection or plugin-owned zero-step turns still stream as `session.event` notifications but cannot replace the prompt status. Persistence roots and persona come from `cordis.yml`. +`initialize.serverInfo.name` is the wire-stable `deepseek-harness-sdk-runtime`. A session accepts one in-flight prompt; overlap fails immediately, other sessions remain independent, and the session is reusable after settlement. `session.finished` reports that prompt's message-triggered turn outcome; later between-turn records still stream as `session.event` notifications but cannot replace the prompt status. Persistence roots and persona come from `cordis.yml`. ## Model Experience diff --git a/packages/ui/jsonrpc/README.zh.md b/packages/ui/jsonrpc/README.zh.md index 2c257b13a1..8ec2d57a20 100644 --- a/packages/ui/jsonrpc/README.zh.md +++ b/packages/ui/jsonrpc/README.zh.md @@ -22,7 +22,7 @@ Stdout 只承载 JSON-RPC 帧。部署不得组合 stdout logger;诊断应写 ## 协议说明 -`initialize.serverInfo.name` 的协议稳定值为 `deepseek-harness-sdk-runtime`。一个会话只接受一个进行中的提示词;重叠请求会立即失败,其他会话保持独立,当前请求结算后该会话可再次使用。`session.finished` 报告由该提示词消息触发的轮次结果;后续注入或插件持有的零步骤轮次仍会作为 `session.event` 通知流式发出,但不能替换该提示词的状态。持久化根目录和 persona 由 `cordis.yml` 提供。 +`initialize.serverInfo.name` 的协议稳定值为 `deepseek-harness-sdk-runtime`。一个会话只接受一个进行中的提示词;重叠请求会立即失败,其他会话保持独立,当前请求结算后该会话可再次使用。`session.finished` 报告由该提示词消息触发的轮次结果;后续轮次间记录仍会作为 `session.event` 通知流式发出,但不能替换该提示词的状态。持久化根目录和 persona 由 `cordis.yml` 提供。 ## 模型体验 diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index f9d2bc0206..c226b7eab3 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -3040,12 +3040,14 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).toContain('advertised by multiple providers') expect(result.terminal.output).toContain('already alpha/a1') + const firstSelectorOutput = result.terminal.output.length result.terminal.send('/model') result.terminal.send('\r') result.terminal.send('/model') result.terminal.send('\r') - await tick() - expect(result.terminal.output).toContain('Select model') + await vi.waitFor(() => { + expect(result.terminal.output.slice(firstSelectorOutput)).toContain('Select model') + }) result.terminal.send('\x1b') await tick() diff --git a/packages/ui/user-approval/src/invariant.ts b/packages/ui/user-approval/src/invariant.ts index 5643e412c5..6eca2571ff 100644 --- a/packages/ui/user-approval/src/invariant.ts +++ b/packages/ui/user-approval/src/invariant.ts @@ -18,19 +18,26 @@ type ApprovalTransition = | { kind: 'asked'; id: ApprovalRequestId } | { kind: 'decided'; id: ApprovalRequestId } +interface ApprovalTrace { + openTurn: number | null + pending: Set +} + /** Validate one approval event against committed unmatched questions. */ function validateApprovalEvent( - pending: ReadonlySet, + trace: ApprovalTrace, event: SessionEvent, fail: InvariantFailure, ): ApprovalTransition | undefined { if (event.type === 'approval/asked') { + if (trace.openTurn === null) fail('approval/asked appended outside any open turn') if (event.data.toolName.length === 0) fail('approval/asked toolName must be non-empty') - if (pending.has(event.data.id)) fail(`approval/asked repeated open id ${JSON.stringify(event.data.id)}`) + if (trace.pending.has(event.data.id)) fail(`approval/asked repeated open id ${JSON.stringify(event.data.id)}`) return { kind: 'asked', id: event.data.id } } if (event.type === 'approval/decided') { - if (!pending.has(event.data.id)) fail(`approval/decided has no matching approval/asked for id ${JSON.stringify(event.data.id)}`) + if (trace.openTurn === null) fail('approval/decided appended outside any open turn') + if (!trace.pending.has(event.data.id)) fail(`approval/decided has no matching approval/asked for id ${JSON.stringify(event.data.id)}`) if (!APPROVAL_OUTCOMES.includes(event.data.outcome)) { fail(`approval/decided carries unknown outcome ${JSON.stringify(event.data.outcome)}`) } @@ -52,28 +59,39 @@ function applyApprovalTransition(pending: Set, transition: Ap // Event owners keep precommit staging local so their vocabularies never move into a central helper. /* jscpd:ignore-start */ const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { - const traces = new WeakMap>() + const traces = new WeakMap() const staged = new WeakMap() - const seed = (session: Session): Set => { - const pending = new Set() - traces.set(session, pending) + const seed = (session: Session): ApprovalTrace => { + const trace: ApprovalTrace = { openTurn: null, pending: new Set() } + traces.set(session, trace) for (const event of session.events) { - const transition = validateApprovalEvent(pending, event, fail) - if (transition !== undefined) applyApprovalTransition(pending, transition) + if (event.type === 'turn/start') trace.openTurn = event.data.turn + else if (event.type === 'turn/end') trace.openTurn = null + const transition = validateApprovalEvent(trace, event, fail) + if (transition !== undefined) applyApprovalTransition(trace.pending, transition) } - return pending + return trace } - const traceFor = (session: Session): Set => traces.get(session) ?? seed(session) + const traceFor = (session: Session): ApprovalTrace => traces.get(session) ?? seed(session) for (const session of ctx.sessions.list()) seed(session) ctx.on('session/created', (session) => { seed(session) }, { global: true }) ctx.on('session/event', (session, event) => { + const trace = traceFor(session) + if (event.type === 'turn/start') { + trace.openTurn = event.data.turn + return + } + if (event.type === 'turn/end') { + trace.openTurn = null + return + } if (event.type !== 'approval/asked' && event.type !== 'approval/decided') return const candidate = staged.get(event) /* v8 ignore next -- internal/dispatch stages every package-owned pair event */ if (candidate === undefined || candidate.session !== session) return fail('approval audit event published without pre-commit validation') staged.delete(event) - applyApprovalTransition(traceFor(session), candidate.transition) + applyApprovalTransition(trace.pending, candidate.transition) }, { global: true }) ctx.on('internal/dispatch', (_mode, eventName, args) => { if (eventName !== 'session/event') return diff --git a/packages/ui/user-approval/tests/invariant.spec.ts b/packages/ui/user-approval/tests/invariant.spec.ts index ea9d4472e5..924272a360 100644 --- a/packages/ui/user-approval/tests/invariant.spec.ts +++ b/packages/ui/user-approval/tests/invariant.spec.ts @@ -13,10 +13,15 @@ async function setup(): Promise { return ctx } +function startTurn(session: Session): void { + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) +} + describe('approval invariants', () => { it('accepts paired audit events and closed policy values', async () => { const ctx = await setup() const session = ctx.sessions.create() + startTurn(session) const id = ApprovalRequestId('ask-1') session.append('approval/asked', { id, toolName: 'bash' }) session.append('approval/decided', { id, outcome: 'allowed-once' }) @@ -47,14 +52,43 @@ describe('approval invariants', () => { type: 'approval/decided', seq: 1, time: 1, data: { id, outcome: 'rejected' as const }, } as const expect(() => { + ctx.emit('session/event', session, { + type: 'turn/start', seq: 0, time: 0, + data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + }) ctx.emit('session/event', session, asked) ctx.emit('session/event', session, decided) }).not.toThrow() }) + it('rejects audit events outside any open turn', async () => { + const ctx = await setup() + const session = ctx.sessions.create() + expect(() => session.append('approval/asked', { + id: ApprovalRequestId('ask-1'), toolName: 'bash', + })).toThrow(/outside any open turn/) + expect(() => session.append('approval/decided', { + id: ApprovalRequestId('ask-1'), outcome: 'rejected', + })).toThrow(/outside any open turn/) + }) + + it('rejects an unenclosed audit event when replaying an existing session', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const session = ctx.sessions.create() + startTurn(session) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + session.append('approval/asked', { + id: ApprovalRequestId('ask-replay'), toolName: 'bash', + }) + await ctx.plugin(InvariantService) + await expect(ctx.plugin(ApprovalInvariant).then(() => undefined)).rejects.toThrow(/outside any open turn/) + }) + it('rejects malformed and unpaired audit events', async () => { const ctx = await setup() const session = ctx.sessions.create() + startTurn(session) const id = ApprovalRequestId('ask-1') expect(() => session.append('approval/asked', { id, toolName: '' })) .toThrow(/toolName must be non-empty/) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 86ef6d8477..b10ad9dfac 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -128,7 +128,6 @@ export const LINK_MAP: Record = { ScopeKey: 'scope.md', Scoped: 'scope.md', EpochHeader: 'session.md', - OutOfBandSessionEventType: 'session.md', Session: 'session.md', SessionEventMap: 'session.md', TurnEndReason: 'session.md', diff --git a/scripts/publint-all.spec.ts b/scripts/publint-all.spec.ts index 22dd80d6b0..9e6d4ef26e 100644 --- a/scripts/publint-all.spec.ts +++ b/scripts/publint-all.spec.ts @@ -58,4 +58,10 @@ describe('publint package runner', () => { expect(result.status).toBe(1) expect(result.stdout).toContain('unpublished.js') }) + + it('rejects a public export whose built file is missing', () => { + const result = run(fixture('./lib/missing.js')) + expect(result.status).toBe(1) + expect(result.stdout).toContain('missing.js') + }) }) diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts new file mode 100644 index 0000000000..38b7d96a0c --- /dev/null +++ b/scripts/run-gates.spec.ts @@ -0,0 +1,137 @@ +import { describe, expect, it, vi } from 'vitest' +import { + defaultConcurrency, + formatGateResultReason, + gatesForMode, + runGate, + runGates, + type Gate, + type GateResult, +} from './run-gates.ts' + +function gate(id: string, options: Partial = {}): Gate { + return { + id, + label: id, + displayCommand: `run ${id}`, + command: process.execPath, + args: ['-e', ''], + ...options, + } +} + +function resultFor(subject: Gate, status: GateResult['status'] = 'passed'): GateResult { + return { + gate: subject, + status, + durationMs: 10, + output: [], + exitCode: status === 'passed' ? 0 : 1, + signalCode: null, + } +} + +function withPnpmEntrypoint(action: () => T): T { + const previous = process.env.npm_execpath + process.env.npm_execpath = '/private/pnpm.cjs' + try { + return action() + } finally { + if (previous === undefined) Reflect.deleteProperty(process.env, 'npm_execpath') + else process.env.npm_execpath = previous + } +} + +describe('gate graph validation', () => { + it.each([ + 'ci-primary', + 'ci-static', + 'ci-lint', + 'ci-coverage', + 'ci-snapshot', + 'ci-artifacts', + 'ci-consumers', + 'ci-windows-blocking', + 'ci-windows-complete', + 'ci-windows-observational', + 'node-compat', + 'check-all', + 'doc-sync', + ] as const)('constructs and executes preflight for a valid non-empty %s graph', async (mode) => { + const subject = withPnpmEntrypoint(() => gatesForMode(mode)) + const execute = vi.fn(async (item: Gate) => resultFor(item)) + + await expect(runGates(subject, subject.length, execute)).resolves.toHaveLength(subject.length) + }) + + it.each([ + ['empty', [], /gate graph has no gates/], + ['duplicate ids', [gate('same'), gate('same')], /duplicate gate id "same"/], + ['unknown dependencies', [gate('subject', { needs: ['missing'] })], /depends on unknown gate "missing"/], + ['cycles', [gate('first', { needs: ['second'] }), gate('second', { needs: ['first'] })], /dependency cycle: first -> second -> first/], + ] as const)('rejects %s before starting a child', async (_label, invalid, message) => { + const execute = vi.fn(async (subject: Gate) => resultFor(subject)) + + await expect(runGates([...invalid], 1, execute)).rejects.toThrow(message) + expect(execute).not.toHaveBeenCalled() + }) + + it('rejects an invalid worker count before starting a child', async () => { + const execute = vi.fn(async (subject: Gate) => resultFor(subject)) + + await expect(runGates([gate('subject')], 0, execute)).rejects.toThrow('max concurrency must be a positive integer') + expect(execute).not.toHaveBeenCalled() + }) + + it('skips dependents after their prerequisite fails', async () => { + const dependent = gate('dependent', { needs: ['root'] }) + const root = gate('root') + const execute = vi.fn(async (subject: Gate) => resultFor(subject, 'failed')) + + const results = await runGates([dependent, root], 1, execute) + + expect(execute).toHaveBeenCalledOnce() + expect(execute).toHaveBeenCalledWith(root) + expect(results[0]).toMatchObject({ gate: dependent, status: 'skipped', error: 'dependency failed or skipped: root' }) + }) +}) + +describe('Node 24 consumer graph', () => { + it('owns the seven-command pool and orders restored-artifact consumers', () => { + const subject = withPnpmEntrypoint(() => gatesForMode('ci-consumers')) + + expect(defaultConcurrency('ci-consumers', subject.length, 4)).toEqual({ + workers: 7, + source: 'ci-consumers gate count', + }) + expect(subject.map(item => item.id)).toEqual([ + 'lint-and-duplication', + 'node-compat', + 'snapshot', + 'publint', + 'node-next-types', + 'built-package-invariants', + 'built-bin-smoke', + ]) + expect(subject.find(item => item.id === 'publint')?.needs).toBeUndefined() + expect(subject.find(item => item.id === 'built-package-invariants')?.needs).toEqual(['publint']) + expect(subject.find(item => item.id === 'lint-and-duplication')?.needs).toEqual(['built-package-invariants']) + for (const id of ['snapshot', 'node-next-types', 'built-bin-smoke']) { + expect(subject.find(item => item.id === id)?.needs).toEqual(['built-package-invariants']) + } + expect(subject.find(item => item.id === 'snapshot')?.env).toEqual({ DSH_EXAMPLE_MODE: 'lib' }) + }) +}) + +describe('gate process outcomes', () => { + it.skipIf(process.platform === 'win32')('reports signal termination independently from exit status', async () => { + const result = await runGate(gate('terminated', { + args: ['-e', "process.kill(process.pid, 'SIGTERM')"], + })) + + expect(result.status).toBe('failed') + expect(result.exitCode).toBeNull() + expect(result.signalCode).toBe('SIGTERM') + expect(formatGateResultReason(result)).toBe('signal SIGTERM') + }) +}) diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index ae11479274..aa2378b24f 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -1,31 +1,36 @@ /** * Run local and CI quality gates with bounded in-process scheduling. * - * The gate vocabulary stays in package.json; this runner only decides which - * independent commands can overlap and which commands wait for built artifacts. + * Package scripts own public aggregate names; this runner owns their validated + * dependency graphs, scheduler environment, and process diagnostics. + * @see ../.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md */ import { spawn } from 'node:child_process' import { availableParallelism } from 'node:os' import { resolve } from 'node:path' import { performance } from 'node:perf_hooks' -type Mode = +/** A named aggregate exposed by the gate runner. */ +export type Mode = | 'ci-primary' | 'ci-static' | 'ci-lint' | 'ci-coverage' | 'ci-snapshot' | 'ci-artifacts' + | 'ci-consumers' | 'ci-windows-blocking' | 'ci-windows-complete' | 'ci-windows-observational' | 'node-compat' - | 'pre-push' | 'check-all' | 'doc-sync' -type GateStatus = 'pending' | 'running' | 'passed' | 'failed' | 'skipped' -interface Gate { +type GateResultStatus = 'passed' | 'failed' | 'skipped' +type GateState = 'pending' | 'running' | GateResultStatus + +/** A command and its dependency metadata inside one aggregate. */ +export interface Gate { id: string label: string displayCommand: string @@ -33,19 +38,17 @@ interface Gate { args: string[] needs?: string[] env?: Record - input?: string - verify?: (result: GateResult) => Promise allowFailure?: boolean } -interface GateResult { +/** The observed outcome of one gate process. */ +export interface GateResult { gate: Gate - status: GateStatus + status: GateResultStatus durationMs: number - stdout: string - stderr: string output: GateOutputChunk[] exitCode: number | null + signalCode: NodeJS.Signals | null error?: string } @@ -64,25 +67,31 @@ interface ConcurrencyDefault { source: string } +type GateExecutor = (gate: Gate) => Promise +type ResultObserver = (result: GateResult) => void + const root = resolve(import.meta.dirname, '..') -const mode = parseMode(process.argv[2]) -const gates = gatesForMode(mode) -const concurrencyDefault = defaultConcurrency(mode, gates.length) -const concurrencyOverride = process.env.DSH_GATE_CONCURRENCY -const maxConcurrency = concurrencyFromEnv('DSH_GATE_CONCURRENCY', concurrencyDefault.workers) -const verbose = process.env.DSH_GATE_VERBOSE === '1' -const startedAt = performance.now() +if (import.meta.main) { + process.exitCode = await main(process.argv.slice(2)) +} -const concurrencySource = concurrencyOverride === undefined || concurrencyOverride === '' - ? concurrencyDefault.source - : '$DSH_GATE_CONCURRENCY' -console.log(`run-gates: ${mode} running ${gates.length} gate(s) with ${maxConcurrency} worker(s) from ${concurrencySource}.`) +async function main(args: string[]): Promise { + const mode = parseMode(args[0]) + const gates = gatesForMode(mode) + const concurrencyDefault = defaultConcurrency(mode, gates.length) + const concurrencyOverride = process.env.DSH_GATE_CONCURRENCY + const maxConcurrency = concurrencyFromEnv('DSH_GATE_CONCURRENCY', concurrencyDefault.workers) + const concurrencySource = concurrencyOverride === undefined || concurrencyOverride === '' + ? concurrencyDefault.source + : '$DSH_GATE_CONCURRENCY' + const startedAt = performance.now() + console.log(`run-gates: ${mode} running ${gates.length} gate(s) with ${maxConcurrency} worker(s) from ${concurrencySource}.`) -const results = await runGates(gates, maxConcurrency) -printSummary(results, performance.now() - startedAt) - -if (results.some(result => result.gate.allowFailure !== true && (result.status === 'failed' || result.status === 'skipped'))) { - process.exit(1) + const results = await runGates(gates, maxConcurrency, runGate, printResult) + printSummary(results, performance.now() - startedAt) + return results.some(result => result.gate.allowFailure !== true && (result.status === 'failed' || result.status === 'skipped')) + ? 1 + : 0 } function parseMode(raw: string | undefined): Mode { @@ -93,26 +102,37 @@ function parseMode(raw: string | undefined): Mode { case 'ci-coverage': case 'ci-snapshot': case 'ci-artifacts': + case 'ci-consumers': case 'ci-windows-blocking': case 'ci-windows-complete': case 'ci-windows-observational': case 'node-compat': - case 'pre-push': case 'check-all': case 'doc-sync': return raw default: throw new Error( - `run-gates: expected mode ci-primary | ci-static | ci-lint | ci-coverage | ci-snapshot | ci-artifacts | ci-windows-blocking | ci-windows-complete | ci-windows-observational | node-compat | pre-push | check-all | doc-sync, got ${JSON.stringify(raw)}.`, + `run-gates: expected mode ci-primary | ci-static | ci-lint | ci-coverage | ci-snapshot | ci-artifacts | ci-consumers | ci-windows-blocking | ci-windows-complete | ci-windows-observational | node-compat | check-all | doc-sync, got ${JSON.stringify(raw)}.`, ) } } -function defaultConcurrency(selectedMode: Mode, total: number): ConcurrencyDefault { - const available = availableParallelism() +/** + * Resolve the default worker count for one aggregate. + * @param selectedMode - aggregate whose resource posture applies. + * @param total - number of gates in the aggregate. + * @param available - host CPU availability for ordinary modes. + * @returns the default worker count and its diagnostic source. + */ +export function defaultConcurrency( + selectedMode: Mode, + total: number, + available = availableParallelism(), +): ConcurrencyDefault { + if (selectedMode === 'ci-consumers') return { workers: total, source: 'ci-consumers gate count' } // Local modes cap workers: several doc gates each build a full ts.Program, // so an uncapped default on a large host trades wall clock for memory blowups. - const localCap = selectedMode === 'pre-push' || selectedMode === 'check-all' || selectedMode === 'doc-sync' + const localCap = selectedMode === 'check-all' || selectedMode === 'doc-sync' const modeLimit = localCap ? Math.min(4, available) : available return { workers: Math.min(total, modeLimit), @@ -165,7 +185,12 @@ function nodeOptions(...options: string[]): string { return [process.env.NODE_OPTIONS, ...options].filter(option => option !== undefined && option !== '').join(' ') } -function gatesForMode(selected: Mode): Gate[] { +/** + * Construct the complete gate list for a named aggregate. + * @param selected - aggregate mode to construct. + * @returns the aggregate's gate graph. + */ +export function gatesForMode(selected: Mode): Gate[] { switch (selected) { case 'ci-primary': return ciPrimaryGates() @@ -182,6 +207,8 @@ function gatesForMode(selected: Mode): Gate[] { return [pnpmScript('build', 'build'), snapshotGate()] case 'ci-artifacts': return ciArtifactGates() + case 'ci-consumers': + return ciConsumerGates() case 'ci-windows-blocking': return ciWindowsBlockingGates() case 'ci-windows-complete': @@ -190,7 +217,6 @@ function gatesForMode(selected: Mode): Gate[] { return ciWindowsObservationalGates() case 'node-compat': return nodeCompatGates() - case 'pre-push': return [] case 'check-all': return [ pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }), @@ -294,6 +320,26 @@ function ciArtifactGates(): Gate[] { ] } +function ciConsumerGates(): Gate[] { + const publicArtifacts = ['publint'] + const restoredBuild = ['built-package-invariants'] + return [ + pnpmScript('lint-and-duplication', 'check:ci:lint', { + label: 'lint and duplication', + needs: restoredBuild, + }), + pnpmScript('node-compat', 'check:node-compat', { label: 'Node compatibility' }), + snapshotGate(restoredBuild), + pnpmScript('publint', 'publint'), + pnpmScript('node-next-types', 'verify-node-next-types', { + label: 'node-next types', + needs: restoredBuild, + }), + builtPackageInvariantsGate(publicArtifacts), + builtBinSmokeGate(restoredBuild), + ] +} + function ciWindowsBlockingGates(): Gate[] { return [ pnpmScript('windows-build', 'build', { label: 'build' }), @@ -381,11 +427,11 @@ function coverageGate(): Gate { // Example and package snapshots boot their bins in `lib` mode (built artifacts under plain Node, // plugins via real exports); repository-script snapshots execute their real source entry path. -// CI and check-all already build before either class runs, so the suite waits on `build`. -function snapshotGate(): Gate { +// Build-owning modes wait on `build`; a restored-artifact mode passes its validation dependency. +function snapshotGate(needs: string[] = ['build']): Gate { return pnpmScript('snapshot', 'test:snapshot', { env: { DSH_EXAMPLE_MODE: 'lib' }, - needs: ['build'], + needs, }) } @@ -468,7 +514,7 @@ function docSyncLeafGates(options: { ] } -function builtBinSmokeGate(): Gate { +function builtBinSmokeGate(needs: string[] = ['build']): Gate { return pnpmExec('built-bin-smoke', [ 'vitest', 'run', @@ -486,44 +532,125 @@ function builtBinSmokeGate(): Gate { 'packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts', ], { label: 'built-bin smoke', - needs: ['build'], + needs, env: { DSH_EXAMPLE_MODE: 'lib' }, }) } -async function runGates(allGates: Gate[], maxActive: number): Promise { - const states = new Map(allGates.map(gate => [gate.id, 'pending'])) +/** + * Reject a gate list whose graph cannot be executed unambiguously. + * @param gates - complete aggregate to validate. + */ +function validateGateGraph(gates: readonly Gate[]): void { + if (gates.length === 0) throw new Error('run-gates: gate graph has no gates.') + + const ids = new Set() + for (const gate of gates) { + if (ids.has(gate.id)) throw new Error(`run-gates: duplicate gate id ${JSON.stringify(gate.id)}.`) + ids.add(gate.id) + } + for (const gate of gates) { + for (const dependency of gate.needs ?? []) { + if (!ids.has(dependency)) { + throw new Error(`run-gates: gate ${JSON.stringify(gate.id)} depends on unknown gate ${JSON.stringify(dependency)}.`) + } + } + } + + const cycle = findDependencyCycle(gates) + if (cycle !== undefined) throw new Error(`run-gates: dependency cycle: ${cycle.join(' -> ')}.`) +} + +function findDependencyCycle(gates: readonly Gate[]): string[] | undefined { + const byId = new Map(gates.map(gate => [gate.id, gate])) + const complete = new Set() + const active = new Map() + const path: string[] = [] + + const visit = (id: string): string[] | undefined => { + if (complete.has(id)) return undefined + const cycleStart = active.get(id) + if (cycleStart !== undefined) return [...path.slice(cycleStart), id] + const gate = byId.get(id) + if (gate === undefined) return undefined + + active.set(id, path.length) + path.push(id) + for (const dependency of gate.needs ?? []) { + const cycle = visit(dependency) + if (cycle !== undefined) return cycle + } + path.pop() + active.delete(id) + complete.add(id) + return undefined + } + + for (const gate of gates) { + const cycle = visit(gate.id) + if (cycle !== undefined) return cycle + } + return undefined +} + +/** + * Validate and run one aggregate before the injected executor can start a child. + * @param gates - complete aggregate to execute. + * @param maxActive - maximum concurrent child count. + * @param execute - child-process executor. + * @param observe - result observer invoked when each gate settles. + * @returns results in aggregate order. + */ +export async function runGates( + gates: Gate[], + maxActive: number, + execute: GateExecutor, + observe: ResultObserver = () => {}, +): Promise { + validateGateGraph(gates) + if (!Number.isSafeInteger(maxActive) || maxActive < 1) { + throw new Error(`run-gates: max concurrency must be a positive integer, got ${JSON.stringify(maxActive)}.`) + } + const states = new Map(gates.map(gate => [gate.id, 'pending'])) const results = new Map() const running: RunningGate[] = [] for (;;) { let madeProgress = false while (running.length < maxActive) { - const ready = allGates.find(gate => states.get(gate.id) === 'pending' && dependenciesPassed(gate, states)) + const ready = gates.find(gate => states.get(gate.id) === 'pending' && dependenciesPassed(gate, states)) if (ready === undefined) break states.set(ready.id, 'running') - running.push({ gate: ready, promise: runGate(ready) }) + running.push({ gate: ready, promise: execute(ready) }) console.log(`run-gates: start ${ready.label}`) madeProgress = true } if (running.length === 0) { - const pending = allGates.filter(gate => states.get(gate.id) === 'pending') - for (const gate of pending) { - const failedDeps = (gate.needs ?? []).filter(id => states.get(id) !== 'passed') + let pending = gates.filter(gate => states.get(gate.id) === 'pending') + while (pending.length > 0) { + const gate = pending.find(item => (item.needs ?? []).some((id) => { + const state = states.get(id) + return state === 'failed' || state === 'skipped' + })) + if (gate === undefined) throw new Error('run-gates: validated graph stalled without a failed dependency.') + const failedDeps = (gate.needs ?? []).filter((id) => { + const state = states.get(id) + return state === 'failed' || state === 'skipped' + }) const result: GateResult = { gate, status: 'skipped', durationMs: 0, - stdout: '', - stderr: '', output: [], exitCode: null, + signalCode: null, error: `dependency failed or skipped: ${failedDeps.join(', ')}`, } states.set(gate.id, 'skipped') results.set(gate.id, result) - printResult(result) + observe(result) + pending = pending.filter(item => item !== gate) } break } @@ -533,29 +660,35 @@ async function runGates(allGates: Gate[], maxActive: number): Promise { + return gates.map((gate) => { const result = results.get(gate.id) if (result === undefined) throw new Error(`run-gates: missing result for ${gate.id}.`) return result }) } -function dependenciesPassed(gate: Gate, states: Map): boolean { +function dependenciesPassed(gate: Gate, states: Map): boolean { return (gate.needs ?? []).every(id => states.get(id) === 'passed') } -async function runGate(gate: Gate): Promise { +/** + * Execute one gate through the real shell-free child-process boundary. + * @param gate - command and scheduler environment to execute. + * @returns the complete process outcome. + */ +export async function runGate(gate: Gate): Promise { const started = performance.now() - let stdout = '' - let stderr = '' const output: GateOutputChunk[] = [] let spawnError: string | undefined - const exitCode = await new Promise((resolveExit) => { + const outcome = await new Promise<{ + exitCode: number | null + signalCode: NodeJS.Signals | null + }>((resolveExit) => { const child = spawn(gate.command, gate.args, { cwd: root, env: { ...process.env, ...gate.env }, @@ -564,47 +697,50 @@ async function runGate(gate: Gate): Promise { child.stdout.setEncoding('utf8') child.stderr.setEncoding('utf8') child.stdout.on('data', (chunk: string) => { - stdout += chunk output.push({ stream: 'stdout', text: chunk }) }) child.stderr.on('data', (chunk: string) => { - stderr += chunk output.push({ stream: 'stderr', text: chunk }) }) child.on('error', (error) => { spawnError = `failed to start command: ${error.message}` - resolveExit(null) + resolveExit({ exitCode: null, signalCode: null }) }) - child.on('close', resolveExit) - if (gate.input !== undefined) child.stdin.end(gate.input) - else child.stdin.end() + child.on('close', (exitCode, signalCode) => { + resolveExit({ exitCode, signalCode }) + }) + child.stdin.end() }) + const { exitCode, signalCode } = outcome - let status: GateStatus = exitCode === 0 && spawnError === undefined ? 'passed' : 'failed' - let error = spawnError - if (status === 'passed' && gate.verify !== undefined) { - try { - await gate.verify({ gate, status, durationMs: performance.now() - started, stdout, stderr, output, exitCode }) - } catch (verifyError: unknown) { - status = 'failed' - error = verifyError instanceof Error ? verifyError.message : String(verifyError) - } - } - + const status: GateResultStatus = exitCode === 0 && signalCode === null && spawnError === undefined ? 'passed' : 'failed' const result: GateResult = { gate, status, durationMs: performance.now() - started, - stdout, - stderr, output, exitCode, + signalCode, } - if (error !== undefined) result.error = error + if (spawnError !== undefined) result.error = spawnError return result } +/** + * Format every independently observed failure fact for the aggregate summary. + * @param result - unsuccessful gate result. + * @returns error, exit, and signal facts without allowing one to hide another. + */ +export function formatGateResultReason(result: GateResult): string { + const facts: string[] = [] + if (result.error !== undefined) facts.push(result.error) + if (result.exitCode !== null) facts.push(`exit ${result.exitCode}`) + if (result.signalCode !== null) facts.push(`signal ${result.signalCode}`) + return facts.length === 0 ? 'no exit code or signal' : facts.join(', ') +} + function printResult(result: GateResult): void { + const verbose = process.env.DSH_GATE_VERBOSE === '1' const seconds = (result.durationMs / 1000).toFixed(2) if (result.status === 'passed' && !verbose) { console.log(`run-gates: PASS ${result.gate.label} (${seconds}s)`) @@ -614,9 +750,11 @@ function printResult(result: GateResult): void { const heading = `${result.status.toUpperCase()} ${result.gate.label} (${seconds}s)` const writeHeading = result.status === 'passed' ? console.log : console.error writeHeading(`\n== ${heading} ==`) - if (result.status !== 'passed') console.error(`command: ${result.gate.displayCommand}`) + if (result.status !== 'passed') { + console.error(`command: ${result.gate.displayCommand}`) + console.error(`outcome: ${formatGateResultReason(result)}`) + } printOutput(result.output) - if (result.error !== undefined) console.error(result.error) } function printSummary(results: GateResult[], durationMs: number): void { @@ -632,7 +770,7 @@ function printSummary(results: GateResult[], durationMs: number): void { console.error('run-gates: unsuccessful gates:') for (const result of unsuccessful) { const duration = (result.durationMs / 1000).toFixed(2) - const reason = result.error ?? (result.exitCode === null ? 'no exit code' : `exit ${result.exitCode}`) + const reason = formatGateResultReason(result) const disposition = result.gate.allowFailure === true ? 'NON-BLOCKING ' : '' console.error(` - ${disposition}${result.status.toUpperCase()} ${result.gate.label} (${duration}s, ${reason})`) console.error(` ${result.gate.displayCommand}`) diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index b6545cf07a..b607995003 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -328,11 +328,6 @@ "symbol": "SessionEventMap", "source": "packages/core/session/src/types.ts" }, - { - "doc": "docs/core-data-structures/session.md", - "symbol": "OutOfBandSessionEventMap", - "source": "packages/core/session/src/types.ts" - }, { "doc": "docs/core-data-structures/session.md", "symbol": "EpochHeader", diff --git a/scripts/wine-windows-gates.sh b/scripts/wine-windows-gates.sh index 8b6dcd8a0a..7706a88a4b 100755 --- a/scripts/wine-windows-gates.sh +++ b/scripts/wine-windows-gates.sh @@ -125,8 +125,26 @@ supportedArchitectures: os: [current, win32] cpu: [current, x64] EOF - (cd "$scratch/tree" && pnpm install --frozen-lockfile --ignore-scripts > "$scratch/logs/install.log" 2>&1) \ - || { tail -40 "$scratch/logs/install.log" >&2; return 1; } + # The hoisted linker — used only by this lane — has an upstream rename + # race (pnpm/pnpm#12880): parallel linkers staging a nested package copy + # (observed on the tree's nested esbuild versions) rename their _tmp_* + # directory onto a path another racer already claimed, and the loser + # exits ERR_PNPM_ENOENT although an identical re-install succeeds. + # Exactly that signature earns up to two retries on a clean tree — the + # snapshot contains no node_modules, so wiping them restores the + # pre-install state; any other failure, or the race still standing after + # the final attempt, fails loud with the log tail. + local attempt + for attempt in 1 2 3; do + (cd "$scratch/tree" && pnpm install --frozen-lockfile --ignore-scripts > "$scratch/logs/install.log" 2>&1) \ + && return 0 + grep -q 'ERR_PNPM_ENOENT.*rename.*_tmp_' "$scratch/logs/install.log" || break + (( attempt < 3 )) || break + echo "wine-windows-gates: pnpm hoisted-linker rename race (pnpm/pnpm#12880) on install attempt $attempt; retrying on a clean tree" >&2 + find "$scratch/tree" -name node_modules -type d -prune -exec rm -rf {} + + done + tail -40 "$scratch/logs/install.log" >&2 + return 1 } mkdir "$scratch/tree" diff --git a/vitest.config.ts b/vitest.config.ts index d19b1aa0f8..0141b11f96 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -103,6 +103,9 @@ export default defineConfig({ // yet. TODO(gui): cover and remove as the client test lane matures. 'packages/client/ui-trajectory/src/*', 'packages/client/ui-question/src/client/QuestionComposer.tsx', + 'packages/client/ui-primitives/src/Menu.tsx', + 'packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx', + 'packages/client/ui-workspace/src/client/WorkspacePicker.tsx', 'packages/client/web-react/src/*', 'packages/client/runtime/src/*', 'packages/client/ui-conversation/src/*',