diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.i18n.yaml b/.agents/notes/archived/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.i18n.yaml similarity index 66% rename from .agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.i18n.yaml rename to .agents/notes/archived/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.i18n.yaml index a8472075e4..635e3fca62 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.i18n.yaml +++ b/.agents/notes/archived/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.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/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.md -2026-07-27-tui-diff-card-redundant-path-header.md: 708e543ff079828b4929d2a50ac697a9c846608a -2026-07-27-tui-diff-card-redundant-path-header.zh.md: 863868ae707f37689bbc202267c5470d8c3163e9 +2026-07-27-tui-diff-card-redundant-path-header.md: 608a11892a20d020087180175eff847021dc0554 +2026-07-27-tui-diff-card-redundant-path-header.zh.md: bf7f1c1eeb994f9940b5f7dfb7db72d422293bd4 diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.md b/.agents/notes/archived/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.md similarity index 99% rename from .agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.md rename to .agents/notes/archived/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.md index 708e543ff0..608a11892a 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.md +++ b/.agents/notes/archived/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.md @@ -1,6 +1,7 @@ # Agent Note: TUI diff card dropped the duplicated file path Status: implemented +Archived: 2026-07-31 English | [中文](2026-07-27-tui-diff-card-redundant-path-header.zh.md) diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.zh.md b/.agents/notes/archived/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.zh.md similarity index 99% rename from .agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.zh.md rename to .agents/notes/archived/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.zh.md index 863868ae70..bf7f1c1eeb 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.zh.md +++ b/.agents/notes/archived/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.zh.md @@ -1,6 +1,7 @@ # Agent Note: TUI diff 卡片重复打印文件路径 Status: implemented +Archived: 2026-07-31 [English](2026-07-27-tui-diff-card-redundant-path-header.md) | 中文 diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index e46d7c34cd..1ed77225ae 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -46,6 +46,9 @@ "bug-fix/2026-07-26-intent-draft-same-tick-echo.i18n.yaml": "sha256:c623947c4fa00e6d4b51792c7972ba09582bbcb7605beb373725c0dd666f2c81", "bug-fix/2026-07-26-intent-draft-same-tick-echo.md": "sha256:fa8b1417b2cdd3deecbf8e55bdddd73dd3a8c6e3486fd399b0b8bdf317e56373", "bug-fix/2026-07-26-intent-draft-same-tick-echo.zh.md": "sha256:00ce72552dbaa11562fbc541343a5d33f9449edabbe6dd354eb879a7d4d530f8", + "bug-fix/2026-07-27-tui-diff-card-redundant-path-header.i18n.yaml": "sha256:8613a1cfcf4b9c7fafa78a8d8565e2a65ef0335b7b826af9b2bb32097836af55", + "bug-fix/2026-07-27-tui-diff-card-redundant-path-header.md": "sha256:1bd344aec5454d2a2d6e1e6a32eff035c4a99c3df409f2624b39fd32e23ee402", + "bug-fix/2026-07-27-tui-diff-card-redundant-path-header.zh.md": "sha256:0a1747006efb1a4b67feceb9b627a437a0f023158e90ae86e1fe8aef76485384", "feature/2026-06-14-acp-agent-client-protocol.i18n.yaml": "sha256:006795baa43ae962a8d125cc0f1e9f134bc2ee9fb758b6e7669e3fa0126e1918", "feature/2026-06-14-acp-agent-client-protocol.md": "sha256:6828c0af74bb3fb96206ca6b21c0e56a000b50e4744aad4bc2c05092f3a5a31b", "feature/2026-06-14-acp-agent-client-protocol.zh.md": "sha256:ba104e841a1fb84edbd3b6c8119d50445b7785255a7a8d13bb9ac8a2cb4d2e69", diff --git a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml index 67955d377d..cdee6ab259 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.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-08-agent-scope-contexts.md: e4c076189a8e8a438b561232d3779ad1f6ab0d08 -2026-07-08-agent-scope-contexts.zh.md: 35e725e43d402b048daf12c3b4be384b3fd2d2ce +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md +2026-07-08-agent-scope-contexts.md: 5e09bdbcae1e57e6b65eb7d1720a6e7a7f758a9f +2026-07-08-agent-scope-contexts.zh.md: 4714045f28e0386a3a53b53437d063462e75a9f1 diff --git a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md index e4c076189a..5e09bdbcae 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md +++ b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md @@ -108,11 +108,11 @@ A listener registered with `{ global: true }` deliberately bypasses contextual a ### Creation publishes last and disposal revokes last -`ctx.agents.create()` and `resume()` build an unpublished session, scope, agent, and driver. They await `setup`, admit the final session and agent entries, announce them in order, start the loop, and only then return a handle. +`ctx.agents.create()` and `resume()` build an unpublished session, scope, agent, and driver. They await `setup`, synchronously invoke its optional `AgentSetupCommit`, admit the final session and agent entries, announce them in order, start the loop, and only then return a handle. The commit lets mutable provisioning revalidate at the exact publication boundary after every setup await; a throw rolls the private transaction back before either identity is announced, while revocation after a successful commit is ordinary live teardown. An optional creation signal cancels work only while create or resume is pending. After the promise resolves, the returned `AgentHandle` owns explicit disposal. -If loading, setup, admission, or publication fails, the private transaction rolls back everything it prepared. Concurrent operations using the same caller-supplied live ID may both reach setup, but final registry entry admits only one; every loser rejects and cleans its private resources. Sequential reuse after awaited disposal remains valid. +If loading, setup, the optional setup commit, admission, or publication fails, the private transaction rolls back everything it prepared. Concurrent operations using the same caller-supplied live ID may both reach setup, but final registry entry admits only one; every loser rejects and cleans its private resources. Sequential reuse after awaited disposal remains valid. `AgentHandle.dispose()` reverses the boundary. It deactivates creation or driving, waits for synchronous publication to unwind, stops and drains the driver and final session flushes, detaches the agent and session, and finally disposes the scope. Repeated or racing disposal requests join one completion promise. @@ -122,12 +122,14 @@ The calling Cordis context and the concrete AgentLoop factory are structural co- flowchart TB request["Create or resume"] --> privateWorld["Build private session, scope, agent, and driver"] privateWorld --> setup["Await composition through agent.ctx"] - setup --> admission["Admit final session and agent entries"] + setup --> setupCommit["Commit optional mutable provisioning"] + setupCommit --> admission["Admit final session and agent entries"] admission --> publish["Announce lifecycle and start the driver"] publish --> live["Return AgentHandle"] privateWorld -->|"failure, cancellation, or owner loss"| rollback["Rollback private work"] setup -->|"failure, cancellation, or owner loss"| rollback + setupCommit -->|"revalidation failure or owner loss"| rollback admission -->|"duplicate or owner loss"| rollback publish -->|"listener failure or owner loss"| rollback live -->|"handle or owner disposal"| quiesce["Stop and drain work"] @@ -166,6 +168,6 @@ Parentage describes lifetime and conversation lineage, not a universal merge pol ## Consequences -Contributors use one familiar pattern: register shared behavior through a plugin context, register local behavior through `agent.ctx`, select the real agent on operations, and dispose the returned handle. Setup is atomic from an observer's perspective, and teardown preserves local behavior until work stops. +Contributors use one familiar pattern: register shared behavior through a plugin context, register local behavior through `agent.ctx`, select the real agent on operations, and dispose the returned handle. Setup and its optional publication commit are atomic from an observer's perspective, and teardown preserves local behavior until work stops. The cost is explicit subject selection, asynchronous programmatic creation, and service-specific scope adoption. Flat registration scope is intentionally not authority, and subagent composition controls remain a separate feature rather than hidden scope semantics. diff --git a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md index 35e725e43d..4714045f28 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md @@ -108,11 +108,11 @@ setup 接收一个完整的受信 Cordis 上下文,因此可以组合普通插 ### 创建最后发布,dispose 最后撤销 -`ctx.agents.create()` 和 `resume()` 构建未发布的会话、作用域、agent 和驱动器。它们等待 `setup`,准入最终的会话和 agent 条目,按序公告,启动循环,然后才返回 handle。 +`ctx.agents.create()` 和 `resume()` 构建未发布的会话、作用域、agent 和驱动器。它们等待 `setup`,同步调用其可选的 `AgentSetupCommit`,准入最终的会话和 agent 条目,按序公告,启动循环,然后才返回 handle。该提交操作让可变的配置状态在所有 setup 的 await 均结算后,于确切的发布边界重新校验;若其抛出异常,则会在公告任何一个身份前回滚私有事务,而成功提交后的撤销属于普通的实时拆卸。 可选的创建信号仅在创建或恢复挂起期间取消工作。promise resolve 后,返回的 `AgentHandle` 拥有显式 dispose 权。 -如果加载、setup、准入或发布失败,私有事务回滚其准备的一切。使用同一个调用方提供的存活 ID 的并发操作可能都到达 setup,但最终注册表条目只准入一个;每个失败者拒绝并清理其私有资源。在等待 dispose 完成后的顺序复用仍然有效。 +如果加载、setup、可选的 setup 提交、准入或发布失败,私有事务回滚其准备的一切。使用同一个调用方提供的存活 ID 的并发操作可能都到达 setup,但最终注册表条目只准入一个;每个失败者拒绝并清理其私有资源。在等待 dispose 完成后的顺序复用仍然有效。 `AgentHandle.dispose()` 反转边界。它停用创建或驱动,等待同步发布解除,停止并排空驱动器和最终会话刷写,分离 agent 和会话,最后 dispose 作用域。重复或竞争的 dispose 请求合并为一个完成 promise。 @@ -122,12 +122,14 @@ setup 接收一个完整的受信 Cordis 上下文,因此可以组合普通插 flowchart TB request["Create or resume"] --> privateWorld["Build private session, scope, agent, and driver"] privateWorld --> setup["Await composition through agent.ctx"] - setup --> admission["Admit final session and agent entries"] + setup --> setupCommit["Commit optional mutable provisioning"] + setupCommit --> admission["Admit final session and agent entries"] admission --> publish["Announce lifecycle and start the driver"] publish --> live["Return AgentHandle"] privateWorld -->|"failure, cancellation, or owner loss"| rollback["Rollback private work"] setup -->|"failure, cancellation, or owner loss"| rollback + setupCommit -->|"revalidation failure or owner loss"| rollback admission -->|"duplicate or owner loss"| rollback publish -->|"listener failure or owner loss"| rollback live -->|"handle or owner disposal"| quiesce["Stop and drain work"] @@ -166,6 +168,6 @@ agent 作用域组合的是受信的同进程注册。它不沙箱化插件、 ## 后果 -贡献者使用一种熟悉的模式:通过插件上下文注册共享行为,通过 `agent.ctx` 注册本地行为,在操作中选择真实 agent,dispose 返回的 handle。从观察者角度看 setup 是原子的,拆除则保留本地行为直到工作停止。 +贡献者使用一种熟悉的模式:通过插件上下文注册共享行为,通过 `agent.ctx` 注册本地行为,在操作中选择真实 agent,dispose 返回的 handle。从观察者角度看,setup 及其可选的发布提交是原子的,拆除则保留本地行为直到工作停止。 代价是显式的主体选择、异步的编程式创建,以及服务需要逐个采纳作用域。扁平注册作用域有意不等同于权限,subagent 组合控制作为独立功能存在,而非隐藏的作用域语义。 diff --git a/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.i18n.yaml index 629476cf8e..43675cbd56 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.md -2026-07-28-consolidated-tui-presentation.md: f87d543a698d6e77abf9120c6579100df4b60b64 -2026-07-28-consolidated-tui-presentation.zh.md: 005e408f0e75207027315546942f9eab57d595d1 +2026-07-28-consolidated-tui-presentation.md: 8200c8e96cdea7cf5ae54c1328623bb03f841d00 +2026-07-28-consolidated-tui-presentation.zh.md: 852bd413b25f93f3b9096b78d69b21ab420df37b diff --git a/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.md b/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.md index f87d543a69..8200c8e96c 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.md +++ b/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.md @@ -26,7 +26,7 @@ A tool card has one colored `Tool / ` status header over one dim body. Pre Injected context renders as prose in `ContextCardComponent`, not through the XML tree renderer. Exact matched outer `` lines are stripped, but mismatched, unpaired, or inline tag-like text remains verbatim. Model-facing content is unchanged. Folding uses the shared `preview` helper after body assembly, so it depends only on row count, never parser success or payload characters. -`Ctrl+O` cycles collapsed, expanded, and hidden. Tool cards disappear in the hidden state together with their card-owned leading gap. Context cards participate in collapsed and expanded states but fall back to collapsed while tools are hidden, because injected instructions are not disposable tool traffic. +`Ctrl+O` cycles collapsed, expanded, and hidden. Tool cards disappear in the hidden state together with their card-owned leading gap. Context cards participate in collapsed and expanded states but fall back to collapsed while tools are hidden, because injected instructions are not disposable tool traffic. The hidden phase additionally folds each turn's assistant steps into one message; the [hidden-mode assistant fold Agent Note](../feature/2026-07-29-tui-hidden-mode-assistant-fold.md) owns that rule. ### Cross-workspace resume diff --git a/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.zh.md b/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.zh.md index 005e408f0e..852bd413b2 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.zh.md @@ -26,7 +26,7 @@ Status: implemented 注入上下文由 `ContextCardComponent` 按普通文本呈现,不经过 XML 树渲染器。仅移除精确配对的外层 `` 行;不匹配、单边或正文内类似标签的文本都原样保留。面向模型的内容不变。折叠在正文组装完成后使用共享 `preview` 辅助函数,因此只取决于行数,不依赖解析是否成功或载荷包含哪些字符。 -`Ctrl+O` 在折叠、展开和隐藏之间循环。隐藏状态会连同卡片自有的前导间距一起移除工具卡片。上下文卡片参与折叠和展开状态,但工具隐藏时回到折叠状态,因为注入指令不是可丢弃的工具流量。 +`Ctrl+O` 在折叠、展开和隐藏之间循环。隐藏状态会连同卡片自有的前导间距一起移除工具卡片。上下文卡片参与折叠和展开状态,但工具隐藏时回到折叠状态,因为注入指令不是可丢弃的工具流量。隐藏阶段还会把每个轮次的 assistant 步骤折叠为一条消息;该规则由[隐藏模式 assistant 折叠 Agent Note](../feature/2026-07-29-tui-hidden-mode-assistant-fold.md)负责。 ### 跨工作区恢复 diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml index 6855a0af2b..048536527a 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.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-28-directory-picker-capability-seam.md -2026-07-28-directory-picker-capability-seam.md: 9884385cf9e0d51604bab9e4fd3c4bee77448331 -2026-07-28-directory-picker-capability-seam.zh.md: 8c229b9fb08d5052ba8a512f2153a89a9e5fd455 +2026-07-28-directory-picker-capability-seam.md: 01968990db81852dbf965a90fc151bab357ecb55 +2026-07-28-directory-picker-capability-seam.zh.md: ffbb939eabcca3e16711a4cadcadad50660a9e04 diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md index 9884385cf9..01968990db 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md @@ -20,7 +20,8 @@ Placement and policy rulings folded into this decision: - **Dependency survey (hand-roll vs adopt).** Node's stdlib *is* the maintained cross-platform OS layer (`readdir(withFileTypes)`, `homedir`, path semantics); surveyed alternatives fail the dependency bar — file-manager packages (`node-file-manager`, `files-and-folders`, Syncfusion's provider) are whole HTTP apps (fit), drive-letter helpers (`drivelist` native addon, `windows-drive-letters` ~7y stale) fail health/proportionality. The browse backend is a thin adapter over stdlib. - **Hidden entries: return-and-flag.** The host stamps `hidden` (POSIX dot convention) and returns everything; the client filters. Display policy stays client-side, and the show-hidden toggle shipped as exactly that client-only change: a fixed-label footer toggle whose state lives in the pressed presentation (`aria-pressed` + check glyph), a dot-led path-draft prefix reveals the hidden entries it names, and the current selection is exempt from both the hidden and the prefix filter (it anchors the two-pane view). Windows' `FILE_ATTRIBUTE_HIDDEN` is not exposed by dirents — documented limitation until a native probe pays for itself. - **Path-editor cancel scope: the dialog card.** The browse client's path editor cancels on Escape and on focus leaving the card, both observed at a card-scope wrapper rather than the input — after Tab parks focus on a filtered row the input is off the event path, yet Escape must collapse the editor (not the dialog) and a later focus departure must still cancel. Non-cancel exemptions: window/tab focus loss, in-card focus moves, and pointer paths (rows and the toggle suppress focus steal on mousedown while editing). Separators for seeding and draft-tail filtering are inferred from `listing.home`; the wire-field alternative below records the deferred authoritative form. Combobox semantics between the editor and the list it filters (`aria-expanded`/`aria-controls`/active-descendant, result announcements) are likewise deferred — today they read to assistive tech as separate widgets. -- **Navigation lands selection-anchored, quiet, and bounded.** Away from the display root (the same collapse the crumb header renders, so crumbs and pane shape never disagree), the landing is two-pane: the target's actual parent-level entry re-selected (platform case folding on Windows), its children on the right, so a crumb jump reads as stepping back one pane rather than collapsing to a single column. Target and parent legs land as **one frame** when the parent leg settles within the 200ms wait bound — the stale view keeps rendering until then, so navigation swaps the panes without an intermediate single-pane flash — and past the bound the target commits alone at once (an Enter-submitted navigation is never held hostage by a stalled parent) with the late parent leg upgrading the landing in place. The parent leg runs under the landing's supersession scope and is aborted on the wire by any newer intent (Escape inside the landing window therefore withdraws the whole navigation); a failed parent leg, or a truncated parent window lacking the target, leaves the single-pane landing — the upgrade must never orphan the selection it exists to anchor. The loading indicator follows the same quiet rule: it floats over the content's bottom-right corner (never a layout-shifting row; the truncated/error rows own the bottom left and keep rendering through a scan) and only once a scan outlives a 300ms silence window, so a local listing swaps with nothing shown at all. Row picks are deliberately exempt from the one-frame rule: a pick's immediate pane split is its selected-state feedback (aria-current, crumbs following), while a navigation has nothing to acknowledge the click but the swap itself. Both timing constants are calibrated for local enumeration; a remote deployment (one RPC per level, commonly 100–400ms) would sit inside the silence window with no pressed state on the crumbs — revisit the window or add pressed feedback when a remote consumer lands. +- **The path editor advertises itself, and the panes follow the draft.** The click-to-edit zone is not invisible: a pencil glyph sits at the bar's right edge and hover/focus lights the WHOLE bar in the editor's own box — the bar carries the outline and padding in both modes, so the hover previews exactly the field the click produces and nothing resizes when zone and input swap. While the editor is open the panes track the draft instead of whatever level happened to be listed when it opened — the final segment prefix-filters the level its directory part names, a tail nobody matches releases the filter (a name still being spelled must not empty the pane it is being spelled into), and any other directory part is scanned after a 250ms rest and lands through the same selection-anchored, two-pane landing every navigation uses, so typing a path moves the Miller view exactly as a crumb jump does — typing deeper descends, erasing segments walks back up — without leaving the editor. **The pane arity is the invariant**: the last pane always lists the level the path names, with its parent beside it and nothing but a display root listing alone. Skipping the scan whenever *any* pane happened to list the directory was the cheaper rule and the wrong one — erasing a segment then left the level being typed on the left with its own child pane still standing to its right, so the panes stopped reading as "where I am, and where I came from". Only the last pane's own tail costs no scan. One landing shape, two callers: a submitted path closes the editor and announces failures, the draft-following scan keeps both to itself. That scan is speculative — half-typed directories are unreadable most of the time — so a failure keeps the last readable panes and stays silent. Enter remains the authoritative commit: it owns the view from submission until landing (a debounce timer armed by the same keystrokes is held back rather than superseding the navigation, and a rejected submission stays held until the next edit) and it alone surfaces the failure. Two consequences are deliberate. The wait is keyed on the draft, not on the directory part it names, so a keystroke that superseded an in-flight scan re-arms one and an edit after a rejected submission releases the hold; the panes it reads are a ref rather than a dependency, or the landing would re-arm the wait and a host answering with a differently spelled path would scan forever. And a walk is not rewound: closing the editor — cancellation included — leaves the panes where the draft took them, named by the crumbs and followed by Open's fallback target, because the operator watched them move. A landing that unmounts the row a keyboard operator Tabbed onto re-parks focus on the editor, since the Modal has no focus trap. Two further rules keep one keystroke to one movement: the walk waits BOTH legs out rather than taking the submitted-navigation wait bound (nothing waits on a speculative scan, so landing single-pane and upgrading would be the very flash this exists to avoid, and it would strand the two-pane view whenever a tail keystroke aborted a slow parent leg), and the tail filters only the LAST pane — narrowing a pane the draft has walked away from would move the view once as it narrows and again as its landing replaces it. A level also keeps answering the directory text that produced it (`scanned`), because the Host resolves what it is given: `..` segments and, on Windows, forward slashes reach a level whose own path spells the request differently, and without the memo those drafts would rescan on every keystroke and never filter. +- **Navigation lands selection-anchored, quiet, and bounded.** Away from the display root (the same collapse the crumb header renders, so crumbs and pane shape never disagree), the landing is two-pane: the target's actual parent-level entry re-selected (platform case folding on Windows), its children on the right, so a crumb jump reads as stepping back one pane rather than collapsing to a single column. Target and parent legs land as **one frame** when the parent leg settles within the 200ms wait bound — the stale view keeps rendering until then, so navigation swaps the panes without an intermediate single-pane flash — and past the bound the target commits alone at once (an Enter-submitted navigation is never held hostage by a stalled parent) with the late parent leg upgrading the landing in place. The parent leg runs under the landing's supersession scope and is aborted on the wire by any newer intent (Escape inside the landing window therefore withdraws the whole navigation); a failed parent leg, or a truncated parent window lacking the target, leaves the single-pane landing — the upgrade must never orphan the selection it exists to anchor. The loading indicator follows the same quiet rule: it floats over the content's bottom-right corner (never a layout-shifting row; the truncated/error rows own the bottom left and keep rendering through a scan) and only once a scan outlives a 300ms silence window, so a local listing swaps with nothing shown at all. Row picks are deliberately exempt from the one-frame rule: a pick's immediate pane split is its selected-state feedback (aria-current, crumbs following), while a navigation has nothing to acknowledge the click but the swap itself. All three timing constants — the 200ms parent-leg bound, the 300ms silence window, and the editor's 250ms draft rest — are calibrated for local enumeration; a remote deployment (one RPC per level, commonly 100–400ms) would sit inside the silence window with no pressed state on the crumbs, and would pay rest plus RPC before the panes follow a typed path — revisit all three together when a remote consumer lands. - **Symlinks: follow for enterability.** `stat` probes symlinks (broken/cyclic → skipped); crumbs keep the logical path the operator navigated, and `workspace.create` already canonicalizes via realpath at adoption. - **Listing levels are bounded, and streamed.** One `list` call returns at most `maxEntries` rows (config, default 1000 — GitHub's web-UI directory-listing bound). The level streams via `opendir` into a name-sorted window of `maxEntries + 1` candidates, so memory stays O(maxEntries) and enterability probing touches only windowed candidates; the wire `DirectoryListing` carries a required `truncated` flag so the client states incompleteness instead of silently missing tail entries. A windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated. Window insertion is binary with an O(1) full-window tail rejection (an oversized level must not pay a window scan per dirent), and `list(path, signal)` threads the carrier's request signal so a scan of a stalled network directory cannot outlive a disconnected caller — every await in the scan (open, each read, each symlink probe) races the signal, an aborted exit abandons rather than awaits the close (Node queues close behind in-flight reads), and abandoned settlements are swallowed so cleanup can never surface as an unhandled rejection. An unbounded level is a memory/responsiveness hole for large or adversarial directories. - **Whole-filesystem scope, no roots config.** `workspace.create` accepts arbitrary paths and the API serves bash-driving methods, so a browse root would be UX scoping, not a boundary; configurability without a consumer fails the evidence bar. Deferred until a deployment needs it. @@ -34,6 +35,9 @@ Placement and policy rulings folded into this decision: - **Adopting a file-manager/drive-enumeration dependency.** Rejected per the survey above; recorded here as the dependency policy requires. - **A flip-label show-hidden toggle ("Hide hidden files").** Rejected: a flipping action label is ambiguous between state and action and doubles the negative; the fixed label with a pressed presentation states both at once. - **Pure relatedTarget blur cancellation (no mousedown suppression).** Rejected: Safari does not focus buttons on pointer down, so a click's focusout carries a null `relatedTarget` and would cancel the editor before the click lands; editing-scoped mousedown suppression plus the card-anchored relatedTarget guard covers pointer and keyboard paths together. +- **A permanently visible path input above the Miller view.** Rejected: the breadcrumb is already the "where am I" reading, and a second always-present field duplicates it while costing a row of a 500px card that the columns need. The glyph plus the hover-lit zone puts the affordance on the bar that already answers the question. +- **Scanning the draft on every keystroke, or only on Enter.** Per keystroke: walking one path segment issues a listing per character, most of them for directories the operator is typing through, not at. Only on Enter (what shipped first): the panes and the typed text disagreed for the whole edit — the complaint this bullet answers. The 250ms rest keeps one scan per directory the typing actually settles on. +- **Emptying a pane on a prefix miss (what shipped first).** Rejected: mid-name the miss is the normal state, so the pane blanked exactly while the operator needed it to confirm the name; releasing the filter keeps the level readable and costs only the transient wideness. - **A wire `separator` field on `DirectoryListing` (host stamps `path.sep`).** Deferred, not rejected: it is the authoritative form — a POSIX home directory containing a backslash defeats the `listing.home` heuristic — but it touches the seam type and every backend; the browse client's `separatorOf` carries a TODO pointing at this alternative until a wire change is next scheduled. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md index 8c229b9fb0..ffbb939eab 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md @@ -20,7 +20,8 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **依赖调研(手写 vs 引入)。** Node 标准库本身就是维护中的跨平台 OS 层(`readdir(withFileTypes)`、`homedir`、路径语义);调研过的替代品都过不了依赖门槛——文件管理器包(`node-file-manager`、`files-and-folders`、Syncfusion 的 provider)是整套 HTTP 应用(契合度不过),盘符工具(原生插件 `drivelist`、约七年未更的 `windows-drive-letters`)健康度/比例失当。browse 后端是标准库上的薄适配。 - **隐藏条目:返回并打标。** 宿主标注 `hidden`(POSIX 点前缀约定)并返回全部条目;客户端过滤。展示策略留在客户端,"显示隐藏"开关正是作为这一纯客户端改动落地:标签固定的 footer 开关,其状态由按下态呈现承载(`aria-pressed` + 勾选符号);以点开头的路径草稿前缀会显出它所指名的隐藏条目;当前选中项则不受隐藏与前缀两种过滤影响(它锚定着双栏视图)。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制,直到原生探测值回其成本。 - **路径编辑器的取消范围:对话框卡片。** browse 客户端的路径编辑器在按 Escape 与焦点离开卡片时取消,两者都在卡片范围的包装层而非输入框上监听——Tab 把焦点停到某个过滤命中的行之后,输入框已不在事件路径上,但 Escape 仍须收起编辑器(而非对话框),其后的焦点离开也仍须取消。不取消的豁免:窗口/标签页失焦、卡片内焦点移动,以及指针路径(编辑期间行与开关在 mousedown 时抑制焦点夺取)。预填与草稿末段过滤所用的分隔符从 `listing.home` 推断;下文的线上字段替代方案记录了被延期的权威形态。编辑器与其过滤的列表之间的 combobox 语义(`aria-expanded`/`aria-controls`/active-descendant、结果播报)同样被延期——目前二者在辅助技术看来是彼此独立的控件。 -- **导航以选中项为锚、安静且有界地落地。** 在展示根之外(与 crumb 头部渲染的是同一塌缩,因此 crumb 与分栏形态永不相左),落地即双栏:重新选中目标在父层级中的实际条目(Windows 上按平台惯例折叠大小写),右侧展示其子项,因此 crumb 跳转读作后退一栏,而不是塌缩成单列。父层级这一程在 200ms 等待上限内落定时,目标与父层级两程以**同一帧**落地——在此之前陈旧视图持续渲染,导航换栏时因此没有中间的单栏闪现——超出该上限则目标即刻单独提交(Enter 提交的导航绝不会被滞塞的父层级扣作人质),迟到的父层级这一程再就地升级这次落地。父层级这一程在落地的 supersession 范围下运行,任何较新的意图都会在线上将其中止(因此在落地窗口内按 Escape 即撤回整次导航);父层级这一程失败,或被截断的父窗口缺少目标时,都保留单栏落地——升级的存在正是为了锚定选中项,绝不能反而让它悬空。加载指示器遵循同一安静规则:它浮于内容右下角(绝不是会挪动布局的一行;截断/错误行占据左下角,并在扫描期间持续渲染),且仅在扫描超出 300ms 静默窗口后才出现,因此本地列举切换时什么也不显示。行选取被刻意豁免于同一帧规则:选取后立即分栏本身就是其选中态反馈(aria-current、crumb 跟随),而导航除了换栏本身没有任何东西可确认这次点击。两个时序常量都按本地列举校准;远程部署(每层级一次 RPC,通常 100–400ms)会落在静默窗口之内、crumb 上却没有按下态——待远程消费方落地时,重新审视该窗口或补上按下反馈。 +- **路径编辑器自我点明,各栏跟随草稿。** 点击即编辑的区域不再是隐形的:栏右端坐着一枚铅笔图标,悬停/聚焦时**整条栏**以编辑器自身的那只框亮起——轮廓与内边距在两种模式下都由栏承载,于是悬停预览的正是点击后出现的那只输入框,区域与输入框互换时也没有任何尺寸变化。编辑器打开期间,各栏跟随草稿,而不是停在它打开那一刻恰好列出的层级——末段对其目录部分所指的层级做前缀过滤,无一匹配的末段解除过滤(还在拼写中的名字不该把正在拼写它的那一栏清空),而其余任何目录部分都会在停顿 250ms 后被扫描,并经由每次导航共用的那套以选中项为锚的双栏落地落定,于是键入路径移动 Miller 视图的方式与 crumb 跳转完全一致——继续键入即下潜、删掉末段即上退——全程不必离开编辑器。**分栏个数才是不变量**:最后一栏永远是路径所指的那一层,其上一层在它旁边,只有展示根会独占一栏。"只要任意一栏碰巧列出了该目录就跳过扫描"是更省事、也是错的规则——删掉一段之后,正在键入的那一层会留在左栏,而它自己的子栏仍立在右边,于是两栏不再读作"我在哪儿、我从哪儿来"。只有最后一栏自己的末段不需要扫描。一种落地形态、两个调用方:提交的路径关闭编辑器并呈现失败,草稿跟随扫描则两者都不做。该扫描是推测性的——键入到一半的目录多数时候读不出来——因此失败时保留最后一次可读的分栏并保持沉默。Enter 仍是权威提交:自提交至落地由它独占视图(同一批按键武装的防抖计时器会被扣住,而不是顶掉这次导航;提交被拒后仍扣住,直到下一次编辑),也只有它把失败呈现出来。有两点是刻意为之。等待以草稿为键,而非以它指名的目录部分为键,于是顶掉在飞扫描的那次按键会重新武装等待,被拒提交之后的编辑也能释放那道扣留;而它读取的分栏是 ref 而非依赖,否则落地会重新武装等待,遇到以不同拼写作答的宿主便会永远扫描下去。以及,走过的路不回退:关闭编辑器——包括取消——都把分栏留在草稿带到的地方,由面包屑指明、Open 的兜底目标随之而动,因为操作者亲眼看着它们移动。若落地卸载了键盘操作者 Tab 停留的那一行,焦点会被重新停回编辑器——Modal 并没有焦点陷阱。另有两条规则保证一次按键只让视图移动一次:这段行走会**等齐两程**,而不套用提交导航的等待上限(推测性扫描没有任何东西在等它,先落单栏再升级恰恰就是它要避免的那次闪动,而且一旦末段按键中止了缓慢的父层级这一程,双栏视图就会永久丢失);末段也只过滤**最后一栏**——去收窄一个草稿已经走开的分栏,会让视图先因收窄动一次、再因它自己的落地动一次。此外,层级会持续应答产生它的那段目录文本(`scanned`),因为宿主会规范化它收到的东西:`..` 段与 Windows 的正斜杠都会抵达一个自身路径拼写不同的层级;没有这份记忆,这类草稿会每敲一键就重扫一次,而且永远过滤不了。 +- **导航以选中项为锚、安静且有界地落地。** 在展示根之外(与 crumb 头部渲染的是同一塌缩,因此 crumb 与分栏形态永不相左),落地即双栏:重新选中目标在父层级中的实际条目(Windows 上按平台惯例折叠大小写),右侧展示其子项,因此 crumb 跳转读作后退一栏,而不是塌缩成单列。父层级这一程在 200ms 等待上限内落定时,目标与父层级两程以**同一帧**落地——在此之前陈旧视图持续渲染,导航换栏时因此没有中间的单栏闪现——超出该上限则目标即刻单独提交(Enter 提交的导航绝不会被滞塞的父层级扣作人质),迟到的父层级这一程再就地升级这次落地。父层级这一程在落地的 supersession 范围下运行,任何较新的意图都会在线上将其中止(因此在落地窗口内按 Escape 即撤回整次导航);父层级这一程失败,或被截断的父窗口缺少目标时,都保留单栏落地——升级的存在正是为了锚定选中项,绝不能反而让它悬空。加载指示器遵循同一安静规则:它浮于内容右下角(绝不是会挪动布局的一行;截断/错误行占据左下角,并在扫描期间持续渲染),且仅在扫描超出 300ms 静默窗口后才出现,因此本地列举切换时什么也不显示。行选取被刻意豁免于同一帧规则:选取后立即分栏本身就是其选中态反馈(aria-current、crumb 跟随),而导航除了换栏本身没有任何东西可确认这次点击。三个时序常量——200ms 父层级上限、300ms 静默窗口,以及编辑器的 250ms 草稿停顿——都按本地列举校准;远程部署(每层级一次 RPC,通常 100–400ms)会落在静默窗口之内、crumb 上却没有按下态,而且要先付停顿再付 RPC 分栏才跟上——待远程消费方落地时,三者一并重新审视。 - **符号链接:为可进入性而跟随。** 用 `stat` 探测符号链接(断链/循环→跳过);面包屑保留操作者导航的逻辑路径,`workspace.create` 在接纳时本就做 realpath 规范化。 - **列举层级有上限,且流式处理。** 单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限)。层级经 `opendir` 流入一个按名排序、容量 `maxEntries + 1` 的候选窗口,内存保持 O(maxEntries),可进入性探测只触及窗口内候选;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。窗口内的断链符号链接不从窗口外回填——发生过驱逐本身已把层级标记为截断。窗口插入为二分查找、满窗尾部单次比较即拒绝(超大层级不能为每个 dirent 付出一次全窗扫描),且 `list(path, signal)` 透传载体的请求信号,滞塞网络目录的扫描不会在调用方断连后继续存活——扫描中的每个 await(打开、每次读取、每次符号链接探测)都与信号赛跑,中止路径放弃而非等待 close(Node 会把 close 排在在飞读取之后),被放弃的 settlement 全部吞掉,清理不会以未处理拒绝的形式冒出。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。 - **全盘可浏览,不做 roots 配置。** `workspace.create` 接受任意路径且 API 本就提供驱动 bash 的方法,浏览根只会是 UX 范围而非边界;没有消费方的可配置性过不了证据门槛。等到有部署需要再做。 @@ -34,6 +35,9 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **引入文件管理器/盘符枚举依赖。** 按上文调研否决;依赖政策要求记录于此。 - **动作标签随状态翻转的"显示隐藏"开关("隐藏隐藏文件")。** 否决:会翻转的动作标签在状态与动作之间有歧义,还把否定叠了两层;固定标签加按下态呈现一次说清两者。 - **纯 relatedTarget 失焦取消(不做 mousedown 抑制)。** 否决:Safari 在指针按下时不给按钮聚焦,点击触发的 focusout 因而携带空 `relatedTarget`,会在点击落地前就取消编辑器;编辑期作用的 mousedown 抑制加上锚定卡片的 relatedTarget 守卫才能同时覆盖指针与键盘路径。 +- **在 Miller 视图上方常驻一个路径输入框。** 否决:面包屑本就在回答"我在哪儿",再常驻一个字段是重复回答,还要从 500px 卡片里挪走一行——那是列需要的高度。图标加悬停亮起的区域,把这个入口放在了已经回答该问题的那一栏上。 +- **每敲一个键就扫描草稿,或只在 Enter 时扫描。** 每键扫描:走完一段路径就是每个字符一次列举,其中多数目录操作者只是路过而非停留。只在 Enter 时扫描(最初落地的行为):整个编辑过程中各栏与所键入文本各说各话——正是本条所回应的抱怨。250ms 的停顿把扫描收敛为"键入真正停下来的每个目录一次"。 +- **前缀无一匹配时清空该栏(最初落地的行为)。** 否决:名字敲到一半时"无匹配"才是常态,于是恰恰在操作者需要它确认名字时把栏清空了;解除过滤保住了层级的可读性,代价只是短暂的宽松。 - **在 `DirectoryListing` 上增设线上 `separator` 字段(宿主标注 `path.sep`)。** 延期而非否决:它才是权威形态——含反斜杠的 POSIX 家目录会击穿 `listing.home` 启发式——但它触及 seam 类型与每个后端;browse 客户端的 `separatorOf` 挂着指向本方案的 TODO,直到下次安排线上变更。 ## 后果 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.i18n.yaml new file mode 100644 index 0000000000..2f06390bdf --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.md +2026-07-30-multi-select-custom-answer-composition.md: 581beec89a0f0018ec2df687f5dfe1b1b5b86d22 +2026-07-30-multi-select-custom-answer-composition.zh.md: 5c9cb59822aca3fbf49fbbdf522c76f963df3480 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.md b/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.md new file mode 100644 index 0000000000..581beec89a --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.md @@ -0,0 +1,25 @@ +# Agent Note: Multi-select custom answer composition + +Status: implemented + +English | [中文](2026-07-30-multi-select-custom-answer-composition.zh.md) + +## Problem + +The user-interaction result vocabulary carries selected option labels and optional custom text in separate fields, but its original semantics made them mutually exclusive for every question. On a multi-select question, opening or typing the custom answer discarded labels the user had already selected. The TUI returned only the custom text, and the Web host rejected a client response that preserved both fields. + +## Decision + +For a question with `multiSelect: true`, one answer item may contain both a non-empty `selected` array and non-empty `custom` text. Web drafts preserve both values regardless of whether the user selects an option or types custom text first; the TUI retains pending custom text across option/custom mode switches and projects it with checked labels from either submit mode; and the Web host accepts the combined response after applying its existing id, label, uniqueness, batch, and non-empty-text validation. + +Single-select and optionless questions keep exclusive semantics: custom text overrides any selected option. The result shape remains `{ id, selected, custom? }`, so no wire or tool-output schema changes. + +## Alternatives considered + +**Encode custom text as another `selected` label.** Rejected because it would erase the distinction between caller-provided option labels and human-authored text, weakening validation and forcing consumers to infer which value was custom. + +**Allow `selected` and `custom` together for every question.** Rejected because a single-select question represents one answer; permitting a selected option plus custom text would make its cardinality ambiguous. The combined form is limited to questions that explicitly opt into multiple answers. + +## Consequences + +Multi-select UIs can represent the user's complete answer without discarding either source. Providers and consumers retain the existing DTO, while request-aware validators interpret the allowed combination from `multiSelect`. Web component and assembled-browser coverage, TUI coverage, host-response coverage, and tool-projection coverage pin the combined result. Web, TUI, and tool-projection coverage also retain labels-only answers; assembled keyless TUI coverage pins the combined terminal flow, and single-select host coverage pins the remaining exclusivity rule. diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.zh.md b/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.zh.md new file mode 100644 index 0000000000..5c9cb59822 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.zh.md @@ -0,0 +1,25 @@ +# Agent Note: 多选题自定义答案组合 + +Status: implemented + +[English](2026-07-30-multi-select-custom-answer-composition.md) | 中文 + +## 问题 + +用户交互结果的词汇分别通过不同字段携带选中的选项标签和可选的自定义文本,但最初的语义要求每个问题的这两个字段互斥。对于多选题,打开自定义答案或输入文本会丢弃用户已选中的标签。TUI 只返回自定义文本,而 Web 宿主会拒绝同时保留两个字段的客户端响应。 + +## 决策 + +对于 `multiSelect: true` 的问题,一个回答项可以同时包含非空 `selected` 数组与非空 `custom` 文本。无论用户先选择选项还是先输入自定义文本,Web 草稿都会保留两个值;TUI 在选项与自定义模式之间切换时会保留待提交的自定义文本,并在任一模式提交时将其与已勾选的标签一同投影;Web 宿主则在应用现有的 id、标签、唯一性、批次和非空文本校验后接受组合响应。 + +单选题和无选项问题仍保持互斥语义:自定义文本会覆盖任何已选中的选项。结果形状仍为 `{ id, selected, custom? }`,因此协议或工具输出 schema 均无需变更。 + +## 考虑过的替代方案 + +**把自定义文本编码为另一个 `selected` 标签。** 不予采纳,因为这样会抹去调用方提供的选项标签与用户填写文本之间的区别,削弱校验,并迫使消费方推断哪个值属于自定义内容。 + +**允许所有问题同时使用 `selected` 与 `custom`。** 不予采纳,因为单选题只表示一个回答;允许选中选项与自定义文本并存会使其基数含义模糊。组合形式仅适用于显式选择多项回答的问题。 + +## 后果 + +多选 UI 可以完整表达用户的回答,不会丢弃任一来源。提供方和消费方继续使用现有 DTO,而请求感知的校验器会根据 `multiSelect` 判断是否允许组合。Web 组件与组装浏览器的覆盖率、TUI 覆盖率、宿主响应覆盖率和工具投影覆盖率共同固定组合结果。Web、TUI 与工具投影覆盖率还固定了仅含标签的回答形态;组装后的无密钥 TUI 覆盖率固定终端中的组合回答流程,单选题的宿主覆盖率则固定其余的互斥规则。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.i18n.yaml new file mode 100644 index 0000000000..4931fa907b --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.md +2026-07-31-fail-loud-releases-the-terminal.md: 2a6e7fcbbdd5d35bcf70dee09fdb9e5592486b78 +2026-07-31-fail-loud-releases-the-terminal.zh.md: f75c21cf79b241e6714c10ec7df9ac25f3d978b4 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.md b/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.md new file mode 100644 index 0000000000..2a6e7fcbbd --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.md @@ -0,0 +1,59 @@ +# Agent Note: fail-loud releases the terminal before exiting + +Status: implemented + +English | [中文](2026-07-31-fail-loud-releases-the-terminal.zh.md) + +## Problem + +A `dsh` launch whose config failed validation printed its diagnostic and returned the user to a broken shell. Typing was invisible, and the next command was mangled by stray text: + +``` +dsh: fatal load failure: ValidationError: invalid config: + - $.providers expected object but got [object Object] (at providers) +$ 1;2;4cecho hello +zsh: command not found: 4cecho +``` + +The Loader mounts entries concurrently, so entry failure order is not startup order. `ui-tui` activates and calls pi-tui's `ProcessTerminal.start()`, which puts stdin in raw mode, enables bracketed paste, and writes the Kitty keyboard-protocol probe — a sequence ending in a Device Attributes query (`ESC [ c`). A sibling entry (here `llm-pi-ai`) then rejects on its own config. At the time, that rejection surfaced as an unhandled rejection, and `installFailLoud` wrote one stderr line and called `process.exit(1)` immediately. (The transactional Loader now settles config-tree failures through `boot()`, which disposes the partial context itself; the release hook remains the guard for rejections `boot()` cannot see — a plugin's detached async work rejecting during or after mounting.) + +Nothing disposed the tree, so `ProcessTerminal.stop()` never ran: raw mode, bracketed paste, and the keyboard protocol stayed set on the shell that outlived the process. The terminal's answer to the Device Attributes query (`1;2;4c`) arrived after exit and was read by the shell as typed input — the literal text above. + +The `/exit` path was never affected, because it disposes the tree and reaches the TUI's own `shutdown()`, which calls `drainInput()` (absorbing the pending reply) and then `ui.stop()`. The defect was that a *failed boot* had no path to that same teardown. + +## Decision + +`installFailLoud` takes an optional `release` teardown, awaited between the diagnostic and the exit: + +- The diagnostic is written **before** the release, so a hanging or failing disposer cannot swallow the reason. +- A latch, not an uninstall, keeps the first rejection the reported one. Removing the listener during teardown would let a second concurrent rejection become uncaught, and Node would kill the process mid-teardown — stranding exactly the terminal state this restores. Later rejections, including the release's own, fall through to the pending exit. +- The release is bounded by `FAIL_LOUD_RELEASE_TIMEOUT_MS` (2s) and its rejection is swallowed. A wedged or failing disposer delays the fatal exit; it never cancels it. That timer stays **referenced**: an `unref()`ed one lets Node reach an empty event loop and exit 0 on the very failure being reported, because an `unhandledRejection` listener suppresses the default fatal exit. +- Omitting `release` keeps the previous behavior exactly, so the ACP, JSON-RPC, and demo bins are unchanged. + +`dsh`'s TUI launcher passes a release that disposes the root context, which runs the TUI's existing `shutdown()` and hands the terminal back. + +The launcher captures the root context in `boot()`'s `prepare` hook rather than from its return value. The rejection arrives while `boot()` is still in flight, so `app.current` assigned after the `await` would still be `undefined` at exactly the moment the hook needs it. `prepare` runs after the Loader installs and before any config-tree entry mounts, which covers the whole window in which an entry can reject. + +## Alternatives considered + +**Reset the terminal from the fail-loud handler** (write `ESC [ ? 2004 l`, pop the keyboard protocol, clear raw mode). This duplicates pi-tui's teardown in a package that owns no terminal, and would drift as pi-tui's startup sequence changes. It also cannot absorb the in-flight Device Attributes reply, which is what corrupts the next prompt — only draining stdin while it is still raw does that. + +**Register a `process.on('exit')` terminal reset in the TUI.** Exit handlers are synchronous, so they cannot await `drainInput()`; the stray reply would still land. It also puts teardown on a global hook rather than the disposal path that already exists. + +**Have the TUI refuse to start until the tree settles.** This serializes a deliberately concurrent Loader and delays first paint for every healthy launch to fix a failure path. + +**Reorder config entries so `llm-pi-ai` mounts before `ui-tui`.** Ordering is not a guarantee the Loader makes, and any future entry could fail after the TUI mounts. + +## Consequences + +A failed boot now costs one tree disposal (bounded at 2s) before exit, and the exit code stays 1. In exchange, a misconfigured `dsh` returns a usable shell instead of one needing `stty sane` or `reset`. + +The guarantee belongs to whichever bin owns the terminal: a surface that grabs terminal state and does not pass `release` reintroduces this defect. `installFailLoud` cannot detect that on its own, since it has no view of what a mounted plugin did to the process. + +## Testing + +`packages/ui/app-boot/tests/app-boot.spec.ts` covers the release contract: the hook is awaited before the exit commits, a rejecting hook still exits 1, a never-settling hook exits after `FAIL_LOUD_RELEASE_TIMEOUT_MS`, and a burst of rejections reports only the first while the release still completes. + +Those fake-process tests cannot observe the two failure modes that matter most — process exit code with a real event loop, and terminal state after exit — so the regression lives in `apps/cli/tests/tui-keyless-smoke.e2e.ts`. It boots the shipped tree in a real PTY over `fixtures/tui-invalid-provider.cordis.yml` (a list-shaped `providers`, the mistake users actually make), expects exit 1, and asserts the captured bytes contain both the labelled boot rejection (`dsh: plugin tree failed to load:`) and `ESC[?2004l`. The same case pins the boot path end to end: it caught the [HMR initial-scan boot deadlock](2026-08-03-hmr-initial-scan-boot-deadlock.md) that silently exited 13 with the terminal stranded. + +Testing policy requires a PTY case whenever terminal teardown changes, and this is it. The `/exit` path keeps its existing assertion that the same reset appears on a clean exit. diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.zh.md b/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.zh.md new file mode 100644 index 0000000000..f75c21cf79 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.zh.md @@ -0,0 +1,59 @@ +# Agent Note:fail-loud 在退出前释放终端 + +Status: implemented + +[English](2026-07-31-fail-loud-releases-the-terminal.md) | 中文 + +## Problem + +配置校验失败的 `dsh` 启动会打印诊断信息,然后把用户丢回一个损坏的 shell:输入不可见,下一条命令还会被残留文本弄乱: + +``` +dsh: fatal load failure: ValidationError: invalid config: + - $.providers expected object but got [object Object] (at providers) +$ 1;2;4cecho hello +zsh: command not found: 4cecho +``` + +Loader 并发挂载各个条目,因此条目失败的顺序并不等于启动顺序。`ui-tui` 会先激活并调用 pi-tui 的 `ProcessTerminal.start()`,它把 stdin 置为 raw 模式、启用 bracketed paste,并写出 Kitty 键盘协议探测序列——该序列以一个 Device Attributes 查询(`ESC [ c`)结尾。随后某个同级条目(这里是 `llm-pi-ai`)因自身配置而 rejection。 + +在当时,该 rejection 以未处理 rejection 的形式浮现,而 `installFailLoud` 只写一行 stderr 就立即调用 `process.exit(1)`。(事务化 Loader 现在让配置树失败经 `boot()` 结算,由它自行释放部分构建的上下文;release 回调仍然守护 `boot()` 看不到的 rejection——插件游离的异步工作在挂载期间或挂载之后失败。)没有任何环节释放这棵树,因此 `ProcessTerminal.stop()` 从未执行:raw 模式、bracketed paste 和键盘协议都残留在比进程活得更久的 shell 上。终端对 Device Attributes 查询的回应(`1;2;4c`)在进程退出之后才到达,被 shell 当作用户输入读入——也就是上面那段字面文本。 + +`/exit` 路径从不受影响,因为它会释放整棵树,从而进入 TUI 自身的 `shutdown()`:先 `drainInput()`(吸收尚未返回的响应),再 `ui.stop()`。缺陷在于**启动失败**没有通往这同一套拆卸流程的路径。 + +## Decision + +`installFailLoud` 新增可选的 `release` 拆卸回调,在诊断信息与退出之间被等待: + +- 诊断信息在 release **之前**写出,因此卡住或失败的 disposer 无法吞掉失败原因。 +- 使用闩锁(latch)而非卸载监听器,来保证被报告的始终是第一个 rejection。若在拆卸期间移除监听器,第二个并发 rejection 就会变成未捕获错误,Node 会在拆卸中途杀死进程——恰好残留下本次要恢复的终端状态。后续 rejection(包括 release 自身的)都会落入已挂起的退出流程。 +- release 以 `FAIL_LOUD_RELEASE_TIMEOUT_MS`(2 秒)为上限,且其 rejection 被吞掉。卡住或失败的 disposer 只会延迟致命退出,绝不会取消它。该定时器保持 **referenced**:一旦 `unref()`,Node 就会在事件循环清空后、恰恰在报告这次失败时以 0 退出,因为 `unhandledRejection` 监听器抑制了默认的致命退出。 +- 不传 `release` 时行为与此前完全一致,因此 ACP、JSON-RPC 和各 demo bin 均无变化。 + +`dsh` 的 TUI 启动器传入的 release 会释放根上下文,从而执行 TUI 已有的 `shutdown()` 并把终端交还。 + +启动器在 `boot()` 的 `prepare` 回调中捕获根上下文,而不是取其返回值。rejection 到达时 `boot()` 尚未结算,因此在 `await` 之后赋值的 `app.current` 恰好在回调需要它的那一刻仍是 `undefined`。`prepare` 在 Loader 安装之后、任何配置树条目挂载之前运行,覆盖了条目可能 rejection 的整个窗口。 + +## Alternatives considered + +**在 fail-loud 处理函数里直接重置终端**(写 `ESC [ ? 2004 l`、弹出键盘协议、清除 raw 模式)。这会在一个并不拥有终端的包里重复 pi-tui 的拆卸逻辑,并随 pi-tui 启动序列的变化而漂移。它同样无法吸收尚未返回的 Device Attributes 响应——而这正是弄乱下一个提示符的原因,只有在 stdin 仍处于 raw 模式时排空它才能解决。 + +**在 TUI 中注册 `process.on('exit')` 终端重置。** exit 处理函数是同步的,无法等待 `drainInput()`,残留响应依旧会落到 shell;而且这把拆卸挂到全局钩子上,而非已经存在的释放路径。 + +**让 TUI 等整棵树结算后再启动。** 这会把刻意并发的 Loader 串行化,并为修复一条失败路径而拖慢每一次正常启动的首次绘制。 + +**调整配置顺序,让 `llm-pi-ai` 先于 `ui-tui` 挂载。** 顺序并不是 Loader 提供的保证,而且未来任何条目都可能在 TUI 挂载之后失败。 + +## Consequences + +启动失败现在会在退出前多付出一次树释放的代价(上限 2 秒),退出码仍为 1。作为交换,配置错误的 `dsh` 会交还一个可用的 shell,而不是需要 `stty sane` 或 `reset` 才能恢复的终端。 + +这项保证属于**拥有终端的那个 bin**:任何抢占终端状态却不传 `release` 的界面都会重新引入该缺陷。`installFailLoud` 自身无法察觉这一点,因为它看不到已挂载的插件对进程做了什么。 + +## Testing + +`packages/ui/app-boot/tests/app-boot.spec.ts` 覆盖 release 契约:退出提交前会等待该回调;回调 rejection 时仍退出 1;永不结算的回调会在 `FAIL_LOUD_RELEASE_TIMEOUT_MS` 后退出;以及一连串 rejection 只报告第一个,同时 release 仍能跑完。 + +这些基于假进程的测试无法观测到最关键的两种失败形态——真实事件循环下的进程退出码,以及退出之后的终端状态——因此回归用例放在 `apps/cli/tests/tui-keyless-smoke.e2e.ts`。它在真实 PTY 中以 `fixtures/tui-invalid-provider.cordis.yml`(`providers` 为列表形状,正是用户真实会犯的错误)启动出厂配置树,期望退出码为 1,并断言捕获到的字节流同时包含带标签的启动 rejection(`dsh: plugin tree failed to load:`)与 `ESC[?2004l`。同一用例端到端钉住了启动路径:正是它发现了以 13 静默退出、终端状态被残留的 [HMR 初始扫描启动死锁](2026-08-03-hmr-initial-scan-boot-deadlock.md)。 + +测试规范要求:只要改动终端拆卸,就必须有 PTY 用例——这就是它。`/exit` 路径保留其原有断言,确认正常退出时同样会出现该重置序列。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.i18n.yaml new file mode 100644 index 0000000000..b2bfc991c9 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.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-31-tui-diff-context-line-accounting.md +2026-07-31-tui-diff-context-line-accounting.md: d465568d5f6cad15ef4647be7ef936c2f5824bba +2026-07-31-tui-diff-context-line-accounting.zh.md: dd1a3eb1acf03477059d48461219700b224b2f96 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.md b/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.md new file mode 100644 index 0000000000..d465568d5f --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.md @@ -0,0 +1,33 @@ +# Agent Note: TUI diff context lines stay neutral + +Status: implemented + +English | [中文](2026-07-31-tui-diff-context-line-accounting.zh.md) + +## Problem + +Result-time filesystem diffs carry the applied change with three surrounding context lines in each `FileDiff.oldText` and `FileDiff.newText`. The TUI rendered every old-side row as removed and every new-side row as added, including the identical context present on both sides. A one-line edit therefore appeared as seven removals plus seven additions, and the footer repeated those inflated totals. + +## Decision + +The TUI compares each `FileDiff` whose old and new text are both available. Added and removed rows retain their green `+` and red `-` markers; equal context rows use the recessed body tone with a neutral two-space prefix. The footer sums only the rows classified as added or removed. `maxDiffEditLength` bounds the exact comparison by its combined added and removed line count; the default is 1000. Exceeding the bound renders the complete old side as removed and the complete new side as added, marks the footer approximate, and caches that result so redraws do not repeat the comparison. A tool result clears the pending-view cache before deriving the settled view, including when a presenter mutates and reuses the same view object. + +When `oldText` is `null`, the renderer cannot distinguish a create from a pending overwrite or an argument fallback whose prior text is unavailable. It therefore shows every non-empty new-side row as added, without claiming those rows were absent from an existing file. Empty new content renders no synthetic added row. + +This remains a consumer-side interpretation of the existing `FileDiff` contract. Filesystem tools continue to persist contextual before/after snippets, so other consumers keep their placement context and existing session logs replay with corrected TUI presentation. The TUI uses the same maintained `diff` package as `dsh-tool-fs` instead of introducing a second line-diff implementation. + +## Alternatives considered + +**Remove context from filesystem result metadata.** Rejected: contextual applied hunks are intentional producer output used by capable editors, and changing them would weaken every consumer while leaving old session logs misleading in the TUI. + +**Extend `FileDiff` with persisted per-line tags.** Rejected: the tags can be derived deterministically from the existing before/after pair; persisting them would widen the cross-package and session-log contract solely for one renderer. + +**Match equal lines by position without a diff algorithm.** Rejected: insertions and deletions shift subsequent context, so positional pairing would misclassify valid hunks. + +**Run every comparison to completion.** Rejected: pending tool views can contain unrestricted model-authored old and new strings, and an unbounded Myers comparison can block the synchronous terminal renderer. + +## Consequences + +TUI diff cards distinguish evidence-bearing context from the mutation itself, and an exact `+A -R` footer reports the actual line delta. Replaying an existing contextual diff gains the corrected rendering without a migration. Result-time filesystem hunks are context-bounded; unrestricted pending views either complete within the configured edit-length budget or degrade to an explicitly approximate linear rendering. + +The focused TUI tests cover neutral context, exact totals, an empty create, bounded fallback, result-time cache invalidation, and redraw cache reuse. The assembled `advanced-cards` terminal snapshots pin the neutral context style, semantic change colors, exact footer, and approximate fallback through collapsed and expanded card states. diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.zh.md b/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.zh.md new file mode 100644 index 0000000000..dd1a3eb1ac --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.zh.md @@ -0,0 +1,33 @@ +# Agent Note: TUI diff 上下文行保持中性 + +Status: implemented + +[English](2026-07-31-tui-diff-context-line-accounting.md) | 中文 + +## 问题 + +文件系统 diff 返回结果时,每个 `FileDiff.oldText` 和 `FileDiff.newText` 都会包含已应用的变更及其前后各 3 行上下文。TUI 将旧侧的每一行都渲染为删除行,将新侧的每一行都渲染为新增行,其中包括两侧相同的上下文。因此,一行编辑会显示为删除 7 行并新增 7 行,页脚还会重复这些虚高的合计值。 + +## 决策 + +TUI 会比较每个变更前后文本均可用的 `FileDiff`。新增行和删除行仍分别使用绿色 `+` 和红色 `-` 标记;相同的上下文行则使用弱化的正文色调,并带有由两个空格构成的中性前缀。页脚只汇总归类为新增或删除的行。`maxDiffEditLength` 以新增行与删除行的合计数为精确比较设置上限,默认值为 1000。超过上限时,TUI 会把完整旧侧渲染为删除内容、把完整新侧渲染为新增内容,将页脚标记为近似结果,并缓存该结果,避免后续重绘重复比较。工具结果会在派生已结算视图前清除待处理视图缓存,即使 presenter 修改并复用同一个视图对象也一样。 + +当 `oldText` 为 `null` 时,渲染器无法区分文件创建、待处理覆写,以及旧文本不可用的参数回退。因此,它会把新侧的每个非空行显示并计作新增行,但不会声称这些行原先不存在于已有文件中。新内容为空时,不会渲染虚构的新增行。 + +该行为仍然只是消费方对现有 `FileDiff` 契约的解释。文件系统工具仍会持久化带上下文的变更前后片段,因此其他消费方仍能获得定位上下文,已有会话日志在回放时也会采用修正后的 TUI 呈现。TUI 与 `dsh-tool-fs` 共用同一个受维护的 `diff` 包(package),无需引入第二套逐行 diff 实现。 + +## 考虑过的替代方案 + +**从文件系统结果元数据中移除上下文。** 不予采纳:带上下文的已应用 hunk 是有意保留的生产方输出,供具备相应能力的编辑器使用;更改这些内容会让所有消费方丢失信息,同时旧会话日志在 TUI 中仍会产生误导。 + +**为 `FileDiff` 扩展持久化的逐行标签。** 不予采纳:这些标签可以根据现有的变更前后文本对确定性派生;仅为一个渲染器持久化标签,会扩大跨包契约和会话日志契约。 + +**不使用 diff 算法,按位置匹配相同行。** 不予采纳:插入和删除会使后续上下文发生位移,因此按位置配对会把有效 hunk 错误分类。 + +**让所有比较都运行至完成。** 不予采纳:待处理工具视图可能包含由模型生成且长度不受限制的新旧字符串,无界的 Myers 比较可能阻塞同步终端渲染器。 + +## 后果 + +TUI diff 卡片会区分用于佐证的上下文与变更本身,精确的 `+A -R` 页脚会报告实际的行变更量。回放已有的上下文 diff 无需迁移即可获得修正后的渲染。结果时刻的文件系统 hunk 受上下文范围限制;不受限制的待处理视图要么在配置的编辑长度预算内完成比较,要么降级为明确标注为近似结果的线性渲染。 + +聚焦的 TUI 测试覆盖中性上下文、精确合计值、空文件创建、有界回退、结果到达时的缓存失效和重绘缓存复用。组装后的 `advanced-cards` 终端快照在卡片折叠和展开状态下固定了中性上下文样式、变更行的语义色彩、精确结果页脚和近似回退。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-02-goal-round-wrapup-message.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-02-goal-round-wrapup-message.i18n.yaml new file mode 100644 index 0000000000..c8784f5991 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-02-goal-round-wrapup-message.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-08-02-goal-round-wrapup-message.md +2026-08-02-goal-round-wrapup-message.md: c6bc3d5912b0789efde55880c2be892e98e34a5b +2026-08-02-goal-round-wrapup-message.zh.md: 0a504b4dcc61feb932775b3d9ffd8424f3b0597d diff --git a/.agents/notes/implemented/bug-fix/2026-08-02-goal-round-wrapup-message.md b/.agents/notes/implemented/bug-fix/2026-08-02-goal-round-wrapup-message.md new file mode 100644 index 0000000000..c6bc3d5912 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-02-goal-round-wrapup-message.md @@ -0,0 +1,31 @@ +# Agent Note: Goal-round wrap-up message + +Status: implemented + +English | [中文](2026-08-02-goal-round-wrapup-message.zh.md) + +## Problem + +An autonomous goal round that reported `update_goal` `complete` or `blocked` concluded the physical turn at the tool result, so the model never spoke after the call. Sessions ended on a bare `update_goal` card, and internal testers read that as the agent stopping mid-sentence: the model's pre-call text routinely announces a report ("goal achieved, marking complete:") that never arrives, because the standard tool-use expectation is one more assistant message after a tool result and neither the goal-round prompt nor the tool description said the call was terminal. The hard stop came from the [goal-tool decision](../feature/2026-07-19-model-facing-goal-tools.md), whose turn-stop clause this note supersedes. + +## Decision + +A goal-round `complete` or `blocked` success no longer calls `concludeTurn()`. Instead the tool defers one wrap-up context onto its own result: a `{ kind: 'plugin', plugin: 'tool-goal' }`-sourced user message carrying a ``/`` instruction to write a grounded closing message to the user and call no more tools. The turn then ends through the agent loop's ordinary no-tool-calls stop, so no new loop primitive exists and steering semantics are untouched. Direct-human mutations remain uninstructed exactly as before. The cost is one additional model request per goal lifecycle, not per round. + +The instruction wording was selected by A/B sampling on `deepseek-v4-pro` with a reconstructed goal-round transcript: a structured instruction (outcome, verification, artifacts, next steps) consistently beat a minimal "summarize" one on completeness; adding a session-grounding clause shifted unsupported detail from asserted fact to hedged suggestion; and the no-instruction control produced high-variance closings, including confidently fabricated file-level detail. + +Scripting the keyless proof required one snapshot-harness addition: `dsh-llm-replay` resolves `{{fromRequest:}}` placeholders in scripted entries against the live request, because a static sidecar cannot know the randomly minted goal id the model must echo into `update_goal`. + +## Verification + +`tool-goal` package tests pin the injected context (source, tag, objective, no-more-tools clause) and the absent `concludesTurn` for both terminal actions, plus the uninstructed direct-human pause and complete paths, at 100% file coverage. `llm-replay` unit tests pin the placeholder contract: last-match-wins capture, whole-match fallback, and loud failures for unmatched, invalid, and unterminated patterns. The new keyless ACP snapshot `goal-wrapup` drives the shipped application through create → round one → autonomous complete and asserts the plugin-sourced wrap-up injection, the same-turn closing assistant message, and the `completed` turn end in both the durable session log and the ACP stdout stream. + +## Alternatives considered + +- **Surface the completion text on the `update_goal` UI card** — rejected: `complete` carries no free text today, and adding a `summary` argument would route a user-facing report through tool arguments while still cutting off the model's natural post-result message. +- **Keep `concludeTurn()` and add a "one more text-only step" loop primitive** — rejected: new `agent-loop` machinery for behavior the ordinary stop already provides once nothing concludes the turn. +- **Instruct inside the tool result content** — rejected: the goal tools' canonical output is compact JSON consumed programmatically; a prose instruction block inside it would mix the model-facing contract with the tool's replayable value. + +## Consequences + +Every autonomous goal ends with a user-facing closing message instead of a bare tool card, at the cost of one model request per goal lifecycle. `concludeTurn()` keeps its loop semantics but loses its only first-party caller outside subagent structured output. Snapshot scenarios can now script values that only exist at run time via `{{fromRequest:...}}`, which unblocks keyless coverage of any echo-an-id tool flow, goal or otherwise. diff --git a/.agents/notes/implemented/bug-fix/2026-08-02-goal-round-wrapup-message.zh.md b/.agents/notes/implemented/bug-fix/2026-08-02-goal-round-wrapup-message.zh.md new file mode 100644 index 0000000000..0a504b4dcc --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-02-goal-round-wrapup-message.zh.md @@ -0,0 +1,31 @@ +# Agent Note:Goal Round 收尾消息 + +Status: implemented + +[English](2026-08-02-goal-round-wrapup-message.md) | 中文 + +## 问题 + +自主 Goal Round 报告 `update_goal` `complete` 或 `blocked` 时,物理轮次在工具结果处直接终结,模型在调用之后再无发言机会。会话终止在一张裸的 `update_goal` 卡片上,内测同学的观感是 agent 话说到一半戛然而止:模型调用前的文本通常预告了一份汇报(“目标达成,标记完成:”)却永远没有下文,因为标准 tool-use 预期是工具结果之后还有一条 assistant 消息,而 Goal Round 提示词与工具描述都没有说明这次调用是终点。硬停止来自 [goal 工具决策](../feature/2026-07-19-model-facing-goal-tools.md),本 note 取代其中的轮次停止条款。 + +## 决策 + +Goal Round 的 `complete` 或 `blocked` 成功不再调用 `concludeTurn()`。工具改为在自己的结果上附带一条收尾上下文:以 `{ kind: 'plugin', plugin: 'tool-goal' }` 为 source 的 user 消息,携带 ``/`` 指令,要求模型向用户写出有依据的收尾消息且不再调用工具。之后轮次经由 agent loop 常规的无工具调用停止路径结束,因此不存在新的 loop 原语,steering 语义不受影响。人类直接变更保持原样、不注入指令。代价是每个 goal 生命周期一次额外模型请求,而非每轮一次。 + +指令措辞通过在 `deepseek-v4-pro` 上用重构的 Goal Round 转录做 A/B 采样选定:结构化指令(结果、验证、产物、后续)在完整度上稳定优于极简“总结一下”;补充“以会话内证据为准”的 grounding 条款让无依据细节从断言事实退为带保留的建议;而无指令对照组的收尾方差很大,包括言之凿凿的文件级细节编造。 + +为让 keyless 证明可脚本化,快照设施补了一项能力:`dsh-llm-replay` 会针对实时请求解析脚本条目中的 `{{fromRequest:}}` 占位符,因为静态伴随文件不可能预知模型必须回填进 `update_goal` 的随机生成 goal id。 + +## 验证 + +`tool-goal` 包测试钉住两个终态 action 注入的上下文(source、标签、objective、禁止再调工具条款)与不存在的 `concludesTurn`,以及人类直接 pause 与 complete 的不注入路径,文件覆盖率 100%。`llm-replay` 单元测试钉住占位符契约:最后一次匹配取胜的捕获、无捕获组时整体匹配回退,以及未匹配、非法、未闭合模式的明确报错。新增 keyless ACP 快照 `goal-wrapup` 驱动成品应用走完 create → 第一轮 → 自主 complete,并在持久会话日志与 ACP stdout 流中同时断言 plugin 来源的收尾注入、同轮内的收尾 assistant 消息与 `completed` 轮次结束。 + +## 曾考虑的替代方案 + +- **在 `update_goal` 的 UI 卡片上展示完成文本** — 拒绝:`complete` 如今不携带任何自由文本;新增 `summary` 参数会让面向用户的汇报走工具参数通道,而且依然砍掉了模型在结果之后的自然发言。 +- **保留 `concludeTurn()` 并新增“再多一步纯文本”的 loop 原语** — 拒绝:为常规停止路径已经能提供的行为(只要没有结果终结轮次)增加新的 `agent-loop` 机制。 +- **把指令写进工具结果内容** — 拒绝:goal 工具的规范输出是被程序化消费的紧凑 JSON;在其中混入散文指令会把模型侧契约和工具的可回放值搅在一起。 + +## Consequences + +每个自主 goal 都以一条面向用户的收尾消息结束,而非一张裸工具卡片,代价是每个 goal 生命周期一次模型请求。`concludeTurn()` 保留其 loop 语义,但在 subagent 结构化输出之外失去了唯一的一方调用者。快照场景现在可以通过 `{{fromRequest:...}}` 脚本化只在运行时才存在的值,为任何“回显 id”类工具流程(不限于 goal)解锁 keyless 覆盖。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.i18n.yaml new file mode 100644 index 0000000000..9ac8e49fe6 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.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-08-02-message-fork-actions-require-completed-turn-tail.md +2026-08-02-message-fork-actions-require-completed-turn-tail.md: f2e7fd67b65a6ce4a86ba3f4405f78842be8f234 +2026-08-02-message-fork-actions-require-completed-turn-tail.zh.md: 2c3feeaa3ef01dbde67faa73257520918996f9c8 diff --git a/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md b/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md new file mode 100644 index 0000000000..f2e7fd67b6 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md @@ -0,0 +1,29 @@ +# Agent Note: Message fork actions require a completed turn tail + +Status: implemented + +English | [中文](2026-08-02-message-fork-actions-require-completed-turn-tail.zh.md) + +## Problem + +The Web conversation attached branch to the last assistant node with nonempty text in each turn. A later tool result, interrupted reasoning node, or terminal error did not take ownership because those rows have no content-text IconActions. The branch icon could therefore appear beneath an assistant response while more rows from the same turn remained below it. The Host correctly expanded that message anchor through the containing `turn/end`, but the placement made the action look like a message-level cut and the child visibly inherited the same-turn suffix. + +## Decision + +`ConversationSnapshot.turnEnds` retains the completed turn boundaries present in the raw event window. The conversation view walks transcript nodes through each boundary and enables branch only when the boundary's last node is a user message, a durable steering message, or a content-bearing assistant message. Open turns have no eligible message, and a later tool result, reasoning-only interruption, turn error, or other transcript node leaves branch unavailable on earlier messages. The unavailable control stays visible, focusable, and hoverable; `aria-disabled`, a tooltip, and `aria-describedby` explain the completed-tail requirement without sending a Host request. Copy and clock remain available under their existing message chrome, and the Host's completed-turn fork semantics remain unchanged. + +This narrows the message eligibility established by the earlier [Web session fork action decision](../feature/2026-07-27-web-session-fork-actions.md). Session-row forking still selects the latest completed turn, and eligible message actions still pass their event seq through the shared client runtime operation. + +## Alternatives considered + +**Cut the event log at the clicked assistant message.** Rejected because an assistant message can sit inside an open step and can contain tool calls whose results occur later. A raw prefix at that seq is not a balanced turn and may not be a valid provider transcript. + +**Infer completion from `running` or the next user message.** Rejected because retry and steering turns need not align with the next visible user bubble, and a paged window may omit that later bubble. The durable `turn/end` event is the authoritative completion fact. + +**Hide branch from every interrupted turn.** Rejected because an aborted turn is durably closed and its final interrupted text can be the true transcript tail. Eligibility depends on the completed boundary and node order, not the outcome kind. + +**Hide ineligible message controls.** Rejected because a disappearing control does not explain the boundary requirement and shifts otherwise stable message chrome. A focusable unavailable control preserves the affordance while preventing the request. + +## Consequences + +An enabled branch icon denotes the same completed-turn boundary that the Host will copy. In the reported response → tool → interrupted Think shape, the response keeps copy, clock, and a disabled branch control that explains why it cannot act. This change deliberately does not provide same-turn transcript editing or a retry-before-turn operation; the Session-row action remains available when a reader wants to copy the latest completed turn in full. Runtime tests pin boundary projection and reference stability, while conversation tests cover assistant, user-only, and durable-steering tails plus unavailable controls caused by later tool and interrupted reasoning rows. diff --git a/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.zh.md b/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.zh.md new file mode 100644 index 0000000000..2c3feeaa3e --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 消息 fork 操作要求消息位于已完成轮次尾部 + +Status: implemented + +[English](2026-08-02-message-fork-actions-require-completed-turn-tail.md) | 中文 + +## 问题 + +Web 会话把分支操作挂到每个轮次中最后一个文本非空的 assistant 节点上。如果后面还有工具结果、被中断的推理(reasoning)节点或终态错误,这些行也不会接管操作,因为它们没有内容文本 IconActions。因此,分支图标可能出现在 assistant 响应下方,而同一轮次的更多行仍位于其后。Host 会正确地把该消息锚点扩展到其所在的 `turn/end`,但图标位置使操作看起来像在消息级截断,子会话又会明显继承同轮次的后缀。 + +## 决策 + +`ConversationSnapshot.turnEnds` 保留原始事件窗口中的已完成轮次边界。会话视图按各边界遍历 transcript(文本记录)节点,仅当边界的最后一个节点是用户消息、持久 steering(中途引导)消息或含内容的 assistant 消息时才启用分支操作。开放轮次没有符合条件的消息;如果后面还有工具结果、只有推理内容的中断、轮次错误或其他 transcript 节点,较早消息上的分支操作会保持不可用。不可用的控件仍然可见、可聚焦、可悬停;`aria-disabled`、tooltip 与 `aria-describedby` 会说明已完成尾部这一要求,且不会发送 Host 请求。复制和时钟仍可在既有消息 chrome 下使用,Host 按已完成轮次 fork 的语义保持不变。 + +本决策收紧了较早的 [Web 会话 fork 操作决策](../feature/2026-07-27-web-session-fork-actions.md)所定义的消息资格。Session 行 fork 仍选择最新的已完成轮次;符合条件的消息操作仍通过共享 client 运行时操作传递其事件 seq。 + +## 考虑过的替代方案 + +**在点击的 assistant 消息处截断事件日志。** 不予采纳:assistant 消息可能位于尚未结束的步骤内,也可能包含结果随后才出现的工具调用。以该 seq 截取的原始前缀并不是结构完整的轮次,也可能不是有效的提供方 transcript。 + +**从 `running` 或下一条用户消息推断完成状态。** 不予采纳:重试轮次与 steering 轮次不一定和下一个可见用户气泡对齐,分页窗口也可能省略该气泡。持久 `turn/end` 事件才是权威的完成事实。 + +**对每个被中断轮次隐藏分支。** 不予采纳:已中止的轮次会持久关闭,其最终的中断文本可能正是真正的 transcript 尾部。资格取决于已完成边界与节点顺序,而非结果类别。 + +**隐藏不符合条件的消息控件。** 不予采纳:消失的控件无法说明边界要求,还会让本应稳定的消息 chrome 发生位移。保留可聚焦但不可用的控件,既能维持操作提示,也能阻止请求。 + +## 后果 + +启用的分支图标现在表示的已完成轮次边界与 Host 实际复制的边界一致。在所报告的「响应 → 工具 → 被中断的 Think」形态中,响应仍保留复制、时钟,以及一个说明无法操作原因的禁用分支控件。本变更刻意不提供同轮次 transcript 编辑,也不提供轮次前重试操作;当读者希望完整复制最新的已完成轮次时,仍可使用 Session 行操作。运行时测试固定边界投影和引用稳定性,会话测试则覆盖 assistant 尾部、纯用户消息尾部、持久 steering 尾部,以及后续工具行和被中断推理行导致的不可用控件。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-03-hmr-initial-scan-boot-deadlock.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-03-hmr-initial-scan-boot-deadlock.i18n.yaml new file mode 100644 index 0000000000..170627ea76 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-03-hmr-initial-scan-boot-deadlock.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-08-03-hmr-initial-scan-boot-deadlock.md +2026-08-03-hmr-initial-scan-boot-deadlock.md: 4b3e259c216d258c321ab06c41225b33ed240d19 +2026-08-03-hmr-initial-scan-boot-deadlock.zh.md: ce1bc8396ac6e7fb6ecb1647fe2b29cdc788c7e1 diff --git a/.agents/notes/implemented/bug-fix/2026-08-03-hmr-initial-scan-boot-deadlock.md b/.agents/notes/implemented/bug-fix/2026-08-03-hmr-initial-scan-boot-deadlock.md new file mode 100644 index 0000000000..4b3e259c21 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-03-hmr-initial-scan-boot-deadlock.md @@ -0,0 +1,41 @@ +# Agent Note: HMR's initial scan deadlocked a failing boot into a silent exit 13 + +Status: implemented + +English | [中文](2026-08-03-hmr-initial-scan-boot-deadlock.zh.md) + +## Problem + +A `dsh` launch whose config-tree failed validation exited 13 (unsettled top-level await) with no diagnostic at all, and left the TUI's terminal state stranded on the shell — the exact symptom the [fail-loud release](2026-07-31-fail-loud-releases-the-terminal.md) fixed, reintroduced through a different mechanism after the [transactional config reload](2026-07-20-config-hot-reload-resilience.md). + +Two defects compounded: + +1. **Concurrent Include applies corrupt the transactional group update.** The HMR main watcher's chokidar initial scan re-announces every existing file as `add`. Its `add` for the config file triggered `Include.refresh()` while the Include's initial apply was still in flight (`this.content`, the changed-content dedup key, commits only after apply). Two concurrent `EntryGroup.update` calls on one group interleave create and rollback on the same entries, and the Include fiber never settles — `loader.create` hangs, `boot()` neither resolves nor rejects, and Node exits 13 once the loop drains. +2. **Serialized applies alone deadlock the failure rollback.** With Include mutations queued, a failing initial apply rolls back by disposing every mounted entry — including `hmr`, whose teardown drains its refresh tasks. The scan-triggered refresh task sits in the Include queue behind the very apply whose rollback is disposing HMR: rollback waits on HMR, HMR waits on the refresh, the refresh waits on the apply. + +## Decision + +Both halves are fixed in the vendored packages (logged in `vendor/README.md`): + +- `include/src/index.ts` funnels every child-tree mutation — initial apply, refresh, and `internal/update` patch re-application — through one per-Include promise queue. The group's transactional `update` is not reentrant, so serialization is a correctness requirement, not a throughput choice. `refresh()` also reads inside the queue so its changed-content check compares against the predecessor's committed state. +- `hmr/src/index.ts` passes `ignoreInitial: true` to the main watcher. The initial scan only re-announces files boot has just consumed; suppressing it removes both the boot-time refresh and the spurious `add` events for already-loaded modules. `registerConfig()` keeps its own `ignoreInitial: false` watcher because a personal config present at registration must apply exactly once. + +With both in place a failing boot follows the intended path: the single apply fails, the rollback disposes the tree (running the TUI's own shutdown, restoring the terminal), `loader.create` rejects, and `boot()` rethrows the labelled diagnostic with exit 1. + +## Alternatives considered + +**Only `ignoreInitial: true`.** Removes the trigger but leaves the corruption: any genuinely concurrent refresh (a config edit racing a slow apply) still interleaves two group updates and strands the fiber. + +**Only serialization.** Converts the corruption into the rollback deadlock described above; the process still exits 13 silently. + +**Cancel queued refreshes on HMR teardown.** Requires cancellation plumbing through `refreshConfig`'s task loop and the Include queue for a case `ignoreInitial` already removes from every boot; not worth the machinery until a real trigger remains. + +## Consequences + +A config file edit landing inside the watcher's startup scan window is now picked up by the next `change` event rather than the scan itself; steady-state reload behavior is unchanged. + +One latent gap remains: a config edit made during a *failing* initial apply can still queue a refresh that the rollback's HMR teardown waits on — the same deadlock shape with a human-scale trigger window of one failing boot. If that ever bites, the fix is refresh-task cancellation at HMR teardown. + +## Testing + +The `dsh` invalid-provider PTY case in `apps/cli/tests/tui-keyless-smoke.e2e.ts` pins the end-to-end contract: exit 1, the labelled `dsh: plugin tree failed to load:` diagnostic naming `$.providers`, and the bracketed-paste reset proving the tree was disposed. Before this fix the same case observed exit 13 with no diagnostic. Reload behavior stays covered by `packages/ui/app-boot/tests/config-reload.spec.ts` and `packages/ui/app-boot/tests/hmr-config.spec.ts`. diff --git a/.agents/notes/implemented/bug-fix/2026-08-03-hmr-initial-scan-boot-deadlock.zh.md b/.agents/notes/implemented/bug-fix/2026-08-03-hmr-initial-scan-boot-deadlock.zh.md new file mode 100644 index 0000000000..ce1bc8396a --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-03-hmr-initial-scan-boot-deadlock.zh.md @@ -0,0 +1,41 @@ +# Agent Note:HMR 初始扫描使失败的启动死锁为静默的 exit 13 + +状态:已实现 + +[English](2026-08-03-hmr-initial-scan-boot-deadlock.md) | 中文 + +## 问题 + +当 `dsh` 启动时配置树校验失败,进程以 13 退出(未结算的顶层 await),不输出任何诊断,并把 TUI 的终端状态残留在 shell 上——这正是 [fail-loud release](2026-07-31-fail-loud-releases-the-terminal.md) 修复过的症状,在[事务化配置重载](2026-07-20-config-hot-reload-resilience.md)之后经由另一条机制重新出现。 + +两个缺陷叠加: + +1. **并发的 Include apply 破坏事务化的 group update。** HMR 主 watcher 的 chokidar 初始扫描会把每个已存在的文件重新宣告为 `add`。其中配置文件的 `add` 在 Include 的首次 apply 尚未结束时触发了 `Include.refresh()`(内容去重键 `this.content` 只在 apply 完成后才提交)。同一 group 上两个并发的 `EntryGroup.update` 会在相同条目上交错执行 create 与回滚,导致 Include fiber 永远无法结算:`loader.create` 挂起,`boot()` 既不 resolve 也不 reject,事件循环排空后 Node 以 13 退出。 +2. **仅序列化 apply 会让失败回滚死锁。** 将 Include 的变更排入队列后,首次 apply 失败时的回滚会释放每个已挂载条目——包括 `hmr`,而它的拆卸会等待自身的 refresh 任务排空。扫描触发的 refresh 任务正排在 Include 队列中、位于正在回滚的那次 apply 之后:回滚等 HMR,HMR 等 refresh,refresh 等 apply。 + +## 决定 + +两处修复都落在 vendored 包中(记录于 `vendor/README.md`): + +- `include/src/index.ts` 将每次子树变更——首次 apply、refresh、`internal/update` 补丁重应用——汇入每个 Include 一条的 promise 队列。group 的事务化 `update` 不可重入,因此序列化是正确性要求,而不是吞吐取舍。`refresh()` 也在队列内读取文件,使其内容变更判断与前一任务提交后的状态比较。 +- `hmr/src/index.ts` 给主 watcher 传入 `ignoreInitial: true`。初始扫描只会重新宣告启动刚刚消费过的文件;抑制它同时消除了启动期 refresh 和对已加载模块的多余 `add` 事件。`registerConfig()` 保留自己 `ignoreInitial: false` 的 watcher,因为注册时已存在的个人配置必须恰好应用一次。 + +两者齐备后,失败的启动走上预期路径:唯一一次 apply 失败,回滚释放整棵树(执行 TUI 自身的 shutdown、恢复终端),`loader.create` reject,`boot()` 重新抛出带标签的诊断并以 1 退出。 + +## 曾考虑的替代方案 + +**只加 `ignoreInitial: true`。** 消除了触发条件,但保留了破坏本身:任何真正并发的 refresh(配置编辑与缓慢的 apply 竞争)仍会交错两次 group update 并使 fiber 悬置。 + +**只做序列化。** 把破坏转化为上述回滚死锁;进程仍然静默地以 13 退出。 + +**在 HMR 拆卸时取消排队中的 refresh。** 需要在 `refreshConfig` 的任务循环和 Include 队列中铺设取消机制,而 `ignoreInitial` 已把该场景从每次启动中移除;在真实触发条件出现之前不值得引入这套机构。 + +## 后果 + +落在 watcher 启动扫描窗口内的配置文件编辑,现在由下一个 `change` 事件而非扫描本身拾取;稳态的重载行为不变。 + +仍留有一个潜在缺口:在一次*失败的*首次 apply 期间进行的配置编辑,仍可能排入一个被回滚的 HMR 拆卸所等待的 refresh——同样的死锁形态,但触发窗口缩小到一次失败启动的人力尺度。若它真的发生,修复方向是在 HMR 拆卸时取消 refresh 任务。 + +## 测试 + +`apps/cli/tests/tui-keyless-smoke.e2e.ts` 中 `dsh` 无效 provider 的 PTY 用例钉住了端到端契约:以 1 退出、带标签的 `dsh: plugin tree failed to load:` 诊断指明 `$.providers`、以及证明整棵树已被释放的 bracketed-paste 复位序列。此修复之前,同一用例观察到的是无诊断的 exit 13。重载行为仍由 `packages/ui/app-boot/tests/config-reload.spec.ts` 与 `packages/ui/app-boot/tests/hmr-config.spec.ts` 覆盖。 diff --git a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml index 6cf16c04ad..1f787065ab 100644 --- a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.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-19-model-facing-goal-tools.md: bc4305af80bb13ceeff1888d489dcd8a00132f94 -2026-07-19-model-facing-goal-tools.zh.md: b07f62aa526902c4b2e9c081777a76ca53783d31 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md +2026-07-19-model-facing-goal-tools.md: 18235c484194f5daf10556ebfc13bdc2d672be2e +2026-07-19-model-facing-goal-tools.zh.md: cc23a76e5faac2c203052d834ca0a87ca5dbed2a diff --git a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md index bc4305af80..18235c4841 100644 --- a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md +++ b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md @@ -22,7 +22,7 @@ The prompt tells the model that it may infer goal intent from a direct human req All three tools use exclusive execution so a model-ordered batch observes prior mutations and their new revisions. Results are compact JSON. UI presentation is a pure function of arguments and uses generic read or mutation cards; mutation cards select meaningful action values before the goal id, so accepted fillers cannot blank their input. Activation is reported only as live observation and is never written into replay state. -An autonomous goal round that successfully reports completion or blocking marks its tool result as concluding the physical turn, preventing an unnecessary follow-up request. Direct-human mutations do not conclude the turn: the assistant can acknowledge the change, and concurrent human steering remains available to ordinary stopping checks. +An autonomous goal round that successfully reports completion or blocking defers one wrap-up instruction onto its tool result so the model still addresses the user before the turn ends through the ordinary no-tool-calls stop; the original conclude-at-result stop is superseded by the [goal-round wrap-up decision](../bug-fix/2026-08-02-goal-round-wrapup-message.md). Direct-human mutations receive no instruction: the assistant can acknowledge the change, and concurrent human steering remains available to ordinary stopping checks. ### Execution authority diff --git a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md index b07f62aa52..cc23a76e5f 100644 --- a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md +++ b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md @@ -22,7 +22,7 @@ Status: implemented 三个工具都采用独占执行,使模型排序的批次可以观察此前变更及其新修订号。结果为紧凑 JSON。UI 展示是参数的纯函数,使用通用读取或变更卡片;变更卡片选择输入时,先取有实际意义的操作值,再取目标 id,因此允许的占位值不会使卡片输入留空。激活态仅作为实时观察返回,绝不会写入回放状态。 -自主目标回合成功报告完成或阻塞后,其工具结果会被标记为结束该物理轮次,避免再发起一次不必要的模型请求。直接人类发起的变更不会结束轮次:agent 可以确认该变更,并且并发的人类 steering(中途引导)仍可参与普通的停止检查。 +自主目标回合成功报告完成或阻塞后,其工具结果会附带一条收尾指令,模型仍会在轮次经由常规无工具调用停止路径结束前向用户发言;原先在结果处终结轮次的做法已被[Goal Round 收尾决策](../bug-fix/2026-08-02-goal-round-wrapup-message.md)取代。直接人类发起的变更不会收到指令:agent 可以确认该变更,并且并发的人类 steering(中途引导)仍可参与普通的停止检查。 ### 执行权限 diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml index 1f52492649..656a52d300 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.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-23-web-assistant-markdown.md: 38d193271d88b3a8f32ba1b191e8a6d432176281 -2026-07-23-web-assistant-markdown.zh.md: be3cd041c6012af142fc27934fda125dfc4cf6de +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md +2026-07-23-web-assistant-markdown.md: d5074e6090699229f5c43dd93eef0fdfbfedab76 +2026-07-23-web-assistant-markdown.zh.md: 31f0fd6835c9921f544f4b6217a0c834dff79859 diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md index 38d193271d..d5074e6090 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md @@ -20,7 +20,7 @@ The dependency is explicit in `ui-primitives`; because that pure library is seed ## Untrusted output policy -Assistant-authored destinations are restricted to absolute HTTP, HTTPS, and mailto URLs. HTTP(S) links open in a new tab with `rel="noopener noreferrer"`; relative destinations and other protocols render as non-navigable text. Markdown images render only their alt text, so model output cannot initiate a remote image request. Raw HTML remains inert source text because no HTML parser enters the pipeline. Shiki output is a static span tree generated from the fence text (no scripts or user HTML). +Assistant-authored link destinations are restricted to absolute HTTP, HTTPS, and mailto URLs. HTTP(S) links open in a new tab with `rel="noopener noreferrer"`; relative destinations and other protocols render as non-navigable text. Markdown images follow the separate [remote-image policy](2026-07-30-web-remote-markdown-images.md). Raw HTML remains inert source text because no HTML parser enters the pipeline. Shiki output is a static span tree generated from the fence text (no scripts or user HTML). Fenced code and GFM tables own horizontal overflow so long content cannot widen the conversation column. @@ -32,7 +32,7 @@ Fenced code and GFM tables own horizontal overflow so long content cannot widen **Parse Markdown into session snapshots.** This would make React nodes or presentation ASTs durable runtime state and reintroduce a final-versus-streaming mode boundary. Parsing stays at the presentation leaf instead. -**Enable raw HTML or remote images with sanitization.** Neither capability has a current product need, while both enlarge the executable or network privacy boundary. They remain disabled rather than adding sanitizer and image-policy dependencies. +**Enable raw HTML with sanitization.** Raw HTML has no current product need and would enlarge the executable-content boundary, so it remains disabled rather than adding a sanitizer dependency. Remote images are governed by the later [image policy](2026-07-30-web-remote-markdown-images.md). **Port deepsuite Prism `highlight.css` and the mdast pipeline.** Appearance parity is owned by CSS Modules and shared `--dsw-*` tokens; highlighting stays on the existing shiki allowlist so the client does not take a second highlighter or Prism class contract. diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md index be3cd041c6..31f0fd6835 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md @@ -20,7 +20,7 @@ Web 对话通过会话事件、历史回放与流式累积保留 assistant Markd ## 不受信任输出策略 -assistant 生成的目标地址仅限绝对 HTTP、HTTPS 与 mailto URL。HTTP(S) 链接会在新标签页中打开,并带有 `rel="noopener noreferrer"`;相对目标地址与其他协议会渲染为不可导航的文本。Markdown 图片仅渲染替代文本,因此模型输出无法发起远程图片请求。由于管线中未引入 HTML 解析器,原始 HTML 仍是不会生效的源文本。Shiki 输出是由围栏文本生成的静态 span 树(不含脚本或用户 HTML)。 +assistant 生成的链接目标地址仅限绝对 HTTP、HTTPS 与 mailto URL。HTTP(S) 链接会在新标签页中打开,并带有 `rel="noopener noreferrer"`;相对目标地址与其他协议会渲染为不可导航的文本。Markdown 图片遵循独立的[远程图片策略](2026-07-30-web-remote-markdown-images.md)。由于流水线中未引入 HTML 解析器,原始 HTML 仍是不会生效的源文本。Shiki 输出是由围栏文本生成的静态 span 树(不含脚本或用户 HTML)。 围栏代码与 GFM 表格各自处理横向溢出,因此较长内容无法撑宽对话栏。 @@ -32,7 +32,7 @@ assistant 生成的目标地址仅限绝对 HTTP、HTTPS 与 mailto URL。HTTP(S **将 Markdown 解析为会话快照。**这会让 React 节点或呈现层 AST 成为持久的运行时状态,并重新引入最终输出与流式输出之间的模式边界。解析仍留在呈现层的叶节点中。 -**通过净化启用原始 HTML 或远程图片。**当前产品并不需要这两项功能,但二者都会扩大可执行行为或网络隐私边界。因此它们保持禁用,无需增加净化器与图片策略依赖。 +**通过净化启用原始 HTML。** 原始 HTML 当前没有产品需求,并且会扩大可执行内容边界,因此保持禁用,无需增加净化器依赖。远程图片由后续的[图片策略](2026-07-30-web-remote-markdown-images.md)约束。 **移植 deepsuite 的 Prism `highlight.css` 与 mdast 管线。**外观一致性由 CSS Modules 与共享的 `--dsw-*` token 负责;高亮仍走现有的 shiki 允许列表,使客户端不必引入第二套高亮器或 Prism class 契约。 diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.i18n.yaml new file mode 100644 index 0000000000..8cc4d64238 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.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-24-tui-question-dialog-multiline.md +2026-07-24-tui-question-dialog-multiline.md: fc6e9bceeee4abc46a69a23124d09fcd4f3c7224 +2026-07-24-tui-question-dialog-multiline.zh.md: a56821921bad1016009687bde63eae5f4d893cdf diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.md b/.agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.md new file mode 100644 index 0000000000..fc6e9bceee --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.md @@ -0,0 +1,41 @@ +# Agent Note: TUI QuestionDialog renders options across multiple lines + +Status: implemented + +English | [中文](2026-07-24-tui-question-dialog-multiline.zh.md) + +## Problem + +`ctx.userInteraction.ask()` must keep question text, supporting `detail`, option labels, descriptions, validation, and controls readable inside configured width and height bounds. The question panel also belongs directly above the editor: placing it at the terminal edge separates the pending decision from both the transcript that prompted it and the input that follows it. + +## Decision + +The TUI renders a pending question as an inline modal between the transcript/status area and the editor while retaining the shared FIFO with model and plugin overlays: + +- `InlineModalComponent` applies `questionDialogWidth` and `questionDialogMaxHeight` inside the normal component flow. The effective question height is additionally clamped to the current viewport after reserving the editor, so the editor remains below the question during resize. +- `renderOptionBlock` wraps each label beneath its cursor/number prefix and renders the muted description on separately wrapped, equally indented lines. The progress header, question, custom-answer hint, validation text, and final rows are width-bounded as well; the final ellipsis clamp is only a safety boundary for prefixes or other indivisible content. The explicit `↑ N lines hidden` fallback is reserved for a viewport below the configured minimum, where the whole semantic layout cannot fit. +- When question text or `detail` exceeds the header allocation, the header becomes a paged line viewport with its own `… lines A-B/N • PgUp/PgDn` status row. Page Up and Page Down traverse both line viewports: forward navigation exhausts the header/detail pages before entering oversized selected-option pages, and backward navigation reverses that order. This keeps plan-review detail reachable rather than leaving it behind the height clamp. +- The option-line budget subtracts padding, header, position, and footer rows before `windowBlocks` runs. The window obeys both `maxQuestionOptions` and the remaining row budget, keeps the selected option visible, and renders omitted options as `↑ N more` / `↓ N more` markers. If fixed chrome would leave fewer than four option rows, the compact header becomes the line pager so selected content, paging status, and both option markers still fit. +- When one selected block exceeds its allocation, it becomes a line viewport with a `lines A-B/N • PgUp/PgDn` status row. Page Up and Page Down expose every wrapped line without allowing the block to hide the option markers, validation, or controls. + +Package tests pin count and height bounds, header and selected-block paging order, narrow-width wrapping, selection behavior, and placement relative to retained editor input. Semantic TUI snapshots pin the assembled terminal layout, header/detail and selected-option page transitions, and validation state. + +## Alternatives considered + +**Ellipsis-only horizontal truncation.** Keeping one option per row would signal lost text without making the description readable and would not address vertical bounds. The implementation wraps readable content and retains an ellipsis only as a final safety boundary. + +**Wrap the combined label and description.** A composite row couples their widths, so either side can starve the other. Separate lines keep both widths predictable. + +**Keep the question as a bottom-edge overlay.** A terminal-edge anchor can place the panel after the editor or cover lower chrome, depending on transcript and viewport height. The inline modal preserves ordering while the modal manager retains focus and FIFO ownership. + +**Push the bounds into pi-tui.** Generic overlay slicing cannot identify option boundaries, selected content, controls, or the inline editor relationship. The owning dialog therefore applies semantic count, row, and paging rules. + +**Use only the option-count cap.** `maxQuestionOptions` remains a public count bound, but it cannot contain wrapped blocks by itself. The dialog enforces the count and row bounds together. + +## Consequences + +- Descriptions consume additional rows, so fewer options can be visible than `maxQuestionOptions`; markers state the omitted option counts. +- Long question text and plan-review detail remain reachable inside a height-bounded panel, at the cost of sharing Page Up and Page Down with selected-option paging. +- An oversized selected block reserves one status row and requires Page Up or Page Down to read beyond the current line page. +- The inline question can displace older transcript rows from a short viewport. Below the configured minimum height, the final fallback can collapse upper rows behind an explicit hidden-line marker so the input controls and editor remain available. +- The model-facing schema, selected labels, abort/cancel behavior, and ACP elicitation path are unchanged. diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.zh.md b/.agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.zh.md new file mode 100644 index 0000000000..a56821921b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.zh.md @@ -0,0 +1,41 @@ +# Agent Note: TUI QuestionDialog 以多行方式渲染选项 + +Status: implemented + +[English](2026-07-24-tui-question-dialog-multiline.md) | 中文 + +## 问题 + +`ctx.userInteraction.ask()` 必须确保问题正文、`detail` 补充内容、选项标签、描述、校验信息和控件在已配置的宽度与高度边界内均可读。问题面板也直接位于编辑器上方:若将其置于终端边缘,待处理决策就会同时脱离触发该决策的 transcript(文本记录)和后续输入。 + +## 决策 + +TUI 将待处理问题渲染为位于 transcript/状态区域与编辑器之间的内联模态框,同时仍与模型浮层和插件浮层共享 FIFO: + +- `InlineModalComponent` 在正常组件流内应用 `questionDialogWidth` 和 `questionDialogMaxHeight`。系统在为编辑器预留空间后,还会根据当前视口限制问题的实际高度,因此调整窗口大小时,编辑器仍位于问题下方。 +- `renderOptionBlock` 将每个标签换行到光标/编号前缀下方,并在另行换行且缩进相同的行上渲染弱化的描述。进度标题、问题、自定义答案提示、校验文本和末尾行也受宽度边界约束;最终的省略号截断仅作为前缀或其他不可拆分内容的安全边界。明确的 `↑ N lines hidden` 回退仅用于低于已配置最小值、无法容纳完整语义布局的视口。 +- 当问题正文或 `detail` 超出头部分配的空间时,头部会成为带有独立 `… lines A-B/N • PgUp/PgDn` 状态行的分页行视口。Page Up 和 Page Down 会遍历这两个行视口:向前导航先翻完问题正文/`detail` 页面,再进入超大选中选项页面;向后导航则采用相反顺序。这样可确保计划评审的 `detail` 内容始终可达,而不会被高度边界挡住。 +- 在 `windowBlocks` 运行前,选项行预算会扣除内边距、标题行、位置行和页脚行。窗口同时遵守 `maxQuestionOptions` 和剩余行预算,保持选中项可见,并将省略的选项渲染为 `↑ N more`/`↓ N more` 标记。若固定界面元素会使选项行少于四行,紧凑头部会转为行分页器,从而容纳选中内容、分页状态和上下两个选项标记。 +- 当一个选中块超出分配空间时,它会成为带有 `lines A-B/N • PgUp/PgDn` 状态行的行视口。Page Up 和 Page Down 可展示每一行已换行内容,同时防止该块遮住选项标记、校验信息或控件。 + +包(package)测试固定数量和高度边界、头部与选中块的分页顺序、窄宽度换行、选择行为,以及问题相对于保留的编辑器输入的位置。语义 TUI 快照固定组装后的终端布局、头部/详情与选中选项的分页转换,以及校验状态。 + +## 备选方案 + +**仅用省略号进行横向截断。** 保持每个选项占一行,只能提示文本有所丢失,无法使描述变得可读,也无法处理纵向边界。该实现会对可读内容换行,仅将省略号保留为最终安全边界。 + +**将标签与描述合并后换行。** 组合行会将两者的宽度耦合在一起,任一方都可能挤占另一方的空间。分行渲染可使二者的宽度保持可预测。 + +**将问题保留为终端底边浮层。** 根据 transcript 和视口高度,锚定在终端边缘的面板可能出现在编辑器之后,也可能遮盖下方界面元素。内联模态框可保留顺序,同时由模态管理器继续负责焦点和 FIFO 所有权。 + +**将边界处理下推至 pi-tui。** 通用浮层切片无法识别选项边界、选中内容、控件或内联编辑器关系。因此,负责该语义的对话框会应用数量、行数和分页规则。 + +**仅使用选项数量上限。** `maxQuestionOptions` 仍是公开的数量边界,但仅靠它无法容纳已换行的块。对话框会同时执行数量边界和行数边界。 + +## 后果 + +- 描述会占用额外行,因此可见选项数可能少于 `maxQuestionOptions`;标记会说明省略的选项数量。 +- 较长的问题正文和计划评审 `detail` 在受高度约束的面板内仍然可达,代价是 Page Up 和 Page Down 需要与选中选项分页共用。 +- 超出空间的选中块会预留一行状态信息;若要阅读当前页面之外的行,必须使用 Page Up 或 Page Down。 +- 在较矮的视口内,内联问题可能将较早的 transcript 行挤出可见区域。低于已配置最小高度时,最终回退可能将上部行折叠到明确的隐藏行标记之后,从而让输入控件和编辑器仍然可用。 +- 面向模型的 schema、选中的标签、中止/取消行为,以及 ACP(Agent Client Protocol)的 elicitation 路径均保持不变。 diff --git a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml index 6e9974fdce..d7e0f75f2d 100644 --- a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.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-27-trajectory-inspection-ledger.md -2026-07-27-trajectory-inspection-ledger.md: 8c2a7c42b7898776de5d42459b09c0fb1737ec0b -2026-07-27-trajectory-inspection-ledger.zh.md: 6c5733046dc2cfd3fd2bd005f4cf5c2d2bd110af +2026-07-27-trajectory-inspection-ledger.md: cdeaa30ea64f47b0e0110baf566f747a4591a384 +2026-07-27-trajectory-inspection-ledger.zh.md: df2a3d266161a7c1c4444863971f3d177533af8c diff --git a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md index 8c2a7c42b7..cdeaa30ea6 100644 --- a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md +++ b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md @@ -21,7 +21,9 @@ Trajectory has to make prose, machine payloads, token usage, timing, and nested - Call schemas come from the active recorded Request header. Keyless snapshot fixtures deliberately replace that catalog with the non-array `{{tools}}` token, which the durable inspection boundary treats as unavailable instead of attempting to project or fabricate schemas. - Selecting a record or Request opens an inspector inside Trajectory. Tabs and Summary sections follow the selected entity: Markdown messages expose rendered, source, provenance, and hierarchy views; tools add JSON payload/result and schema views; Requests add options, usage, timing, and result navigation. Images render as media rather than serialized data. - Turn folding removes all rows after its first record and replaces them with a compact step/tool-call count; Assistant folding applies the same interaction to its tool-call descendants. Global controls fold or expand both levels. -- The separate Waterfall tab is removed. A fixed Overview above the ledger projects every record with known `startedAt` onto three semantic timing lanes using its own duration. Dragging left or right commits an inclusive interval filter: any record whose active interval overlaps either boundary remains visible, records without known timing leave the focused ledger, and clearing the selection restores the full branch. The Overview keeps the full time domain while focused so the selection can be resized or cleared without losing orientation. +- The separate Waterfall tab is removed. A fixed Overview above the ledger projects every record with known `startedAt` onto three semantic timing lanes using its own duration. Finalized Assistant spans divide the recorded interval at the first non-empty token delta, so distinct TTFT and decoding colors retain their actual ratio; incomplete timing falls back to one Assistant color. Hovering for 500 ms exposes exact start/end, total duration, TTFT, and decoding time without relying on the browser's native tooltip delay. Dragging left or right commits an inclusive interval filter: any record whose active interval overlaps either boundary remains visible, records without known timing leave the focused ledger, and clearing the selection restores the full branch. Wheel gestures zoom the time domain. A right-button click clears the interval selection; dragging instead pans an already zoomed viewport without mutating it. The Overview keeps the full time domain while focused so the selection can be resized or cleared without losing orientation. +- Live history updates retain the ledger's bottom position only while the user is already following its tail. Scrolling upward clears that follow state, so streamed chunks and newly appended records do not interrupt inspection of earlier rows. +- Trajectory opts into a conversation-owned composer overlay through `data-conversation-composer-overlay`. `ConversationRoot` positions the composer seat and publishes its live height; Trajectory keeps the ledger at full height and reserves that height plus 16 px inside its vertical table and inspector scrollers. Those panes adapt to the available width instead of exposing horizontal scrollbars beneath the overlay. - This local inspector remains independent from the conversation-wide Chat details column. At narrow widths it overlays the ledger and remains dismissible by keyboard or pointer. ## Alternatives considered @@ -34,10 +36,12 @@ Trajectory has to make prose, machine payloads, token usage, timing, and nested **Reuse the global Chat details column.** Rejected: it would couple local inspection to conversation navigation and make a row click unexpectedly change another view's state. +**Override the composer seat from Trajectory CSS.** Rejected: a cross-package selector would depend on generated class specificity and stylesheet order. An explicit view marker keeps seat geometry and active-phase precedence in `ConversationRoot`, while Trajectory owns only its internal clearance. + **Keep timing in a separate Waterfall tab.** Rejected: the placeholder summarized node counts rather than record timing and forced users to switch away from the rows they wanted to focus. A full-domain Overview keeps timing and filtered records in one visual context. **Change global theme tokens to match the reference.** Rejected: the existing theme already provides paired light and dark semantic layers, and a local redesign does not justify changing unrelated surfaces. ## Consequences -Trajectory shows more useful records per viewport while retaining Turn and Request orientation. Context rewrites and compactions remain inline with their surrounding history, while a rewind begins a successor branch that inherits only the retained prefix. The main ledger omits token usage and duration so content receives the available width; the local inspector exposes those facts together with full payloads, provenance, schemas, and request timing. The Overview uses recorded start/duration facts without fabricating live elapsed time, and its inclusive focus behavior matches the interaction users already know from Chrome DevTools Network. Focused component tests pin projection, folding, record and interval selection, entity-specific tabs, and running/error semantics; the assembled Web snapshot pins the ledger, Overview, and inspector through the real client composition. +Trajectory shows more useful records per viewport while retaining Turn and Request orientation. Context rewrites and compactions remain inline with their surrounding history, while a rewind begins a successor branch that inherits only the retained prefix. The floating composer leaves the ledger visible to the viewport edge without covering its final rows or hiding horizontal controls. The main ledger omits token usage and duration so content receives the available width; the local inspector exposes those facts together with full payloads, provenance, schemas, and request timing. The Overview uses recorded start/duration and token-boundary facts without fabricating live elapsed time, and its inclusive focus behavior matches the interaction users already know from Chrome DevTools Network. Focused component tests pin tail following, timing projection, delayed detail disclosure, folding, record and interval selection, entity-specific tabs, and running/error semantics; the assembled Web snapshot pins the ledger, Overview timing details, composer overlay geometry, and inspector through the real client composition. diff --git a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md index 6c5733046d..df2a3d2661 100644 --- a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md @@ -21,7 +21,9 @@ Status: implemented - 调用 schema 来自当前生效且已记录的请求头。无密钥快照 fixture(测试前置数据)有意将该目录替换为非数组 token `{{tools}}`,持久化检查边界会将其视为不可用,而不是尝试投影或虚构 schema。 - 选择记录或请求后,轨迹视图内部会打开检查器,其标签页和概览区域随实体类型变化:Markdown 消息提供渲染、源码、来源和层级视图;工具提供 JSON 载荷/结果和 schema 视图;请求提供选项、用量、计时和结果跳转。图片以媒体形式渲染,而不是显示为序列化数据。 - 折叠轮次时保留其第一条记录,将后续行替换为紧凑的步骤和工具调用数量;折叠助手时对其工具调用后代应用相同交互。全局控件可以分别折叠或展开这两个层级。 -- 移除独立的 waterfall(瀑布式事件)标签页。固定在记录表上方的 Overview 区域将所有 `startedAt` 已知的记录按各自耗时投影到三条语义计时轨道。向左或向右拖动会提交包含边界的区间筛选:任何活动区间与所选区间相交的记录都会保留,计时未知的记录会从聚焦后的记录表中移除,清除选择则恢复完整分支。聚焦后,Overview 区域仍保留完整时间范围,以便在不失去方位的情况下调整或清除选择。 +- 移除独立的 waterfall(瀑布式事件)标签页。固定在记录表上方的 Overview 区域将所有 `startedAt` 已知的记录按各自耗时投影到三条语义计时轨道。已完成的助手时间条以首个非空 token 增量为分界,用不同颜色按真实比例表示 TTFT 与解码时间;计时不完整时退化为单一助手色。悬停 500 ms 后会显示精确起止时刻、总耗时、TTFT 和解码时间,而不依赖浏览器原生 tooltip 的延迟。向左或向右拖动会提交包含边界的区间筛选:任何活动区间与所选区间相交的记录都会保留,计时未知的记录会从聚焦后的记录表中移除,清除选择则恢复完整分支。滚轮手势用于缩放时间域。右键单击会清除区间选择;右键拖动则只会平移已放大的 viewport,不会改变该选区。聚焦后,Overview 区域仍保留完整时间范围,以便在不失去方位的情况下调整或清除选择。 +- 实时历史更新仅在用户已经跟随记录表末尾时保留底部位置。向上滚动会清除跟随状态,因此流式分块和新追加的记录不会打断对旧记录的检查。 +- Trajectory 通过 `data-conversation-composer-overlay` 启用由会话持有的 composer 浮层模式。`ConversationRoot` 负责定位 composer seat 并发布其实时高度;Trajectory 让记录表保持全高,并在记录表与检查器的纵向滚动容器内预留该高度加 16 px。这两个窗格会根据可用宽度自适应,而不会在浮层下方暴露横向滚动条。 - 此局部检查器与会话级 Chat 详情栏相互独立。在窄屏下,检查器会覆盖记录表,并且仍可通过键盘或指针关闭。 ## 曾考虑的替代方案 @@ -34,10 +36,12 @@ Status: implemented **复用全局 Chat 详情栏。** 不予采纳:这会让局部检查与会话导航耦合,还会使行点击意外改变另一个视图的状态。 +**由 Trajectory CSS 覆盖 composer seat。** 不予采纳:跨包(package)选择器会依赖生成类选择器的优先级和样式表顺序。显式视图标记让 seat 几何形状和活跃阶段优先级留在 `ConversationRoot` 中,而 Trajectory 只负责自身内部的避让空间。 + **将计时保留在独立的 waterfall 标签页中。** 不予采纳:占位实现汇总的是节点数而非记录计时,并迫使用户离开想要聚焦的记录。保留完整时间范围的 Overview 区域让计时和筛选后的记录处于同一视觉上下文中。 **修改全局主题 token 以匹配参考设计。** 不予采纳:现有主题已经提供配对的亮色与暗色语义层,局部重新设计不足以成为修改无关表面的理由。 ## 后果 -轨迹视图在保留轮次与请求定位的同时,每个视口可以显示更多有效记录。上下文 `rewrite` 与压缩保持在周边历史中的原始位置,`rewind` 则建立仅继承保留前缀的后继分支。主记录表省略 token 用量和耗时,让内容获得可用宽度;局部检查器展示这些数据以及完整载荷、来源、schema 和请求计时。Overview 区域使用记录的开始时间与耗时数据,而不虚构实时流逝时间,其包含边界的聚焦行为与用户熟悉的 Chrome DevTools Network 交互一致。针对性组件测试锁定投影、折叠、记录与区间选择、实体特定标签页和运行/错误语义;组装后的 Web 快照则通过真实客户端组合锁定记录表、Overview 区域与检查器。 +轨迹视图在保留轮次与请求定位的同时,每个视口可以显示更多有效记录。上下文 `rewrite` 与压缩保持在周边历史中的原始位置,`rewind` 则建立仅继承保留前缀的后继分支。浮动 composer 让记录表一直显示到视口边缘,同时不会遮住最后几行,也不会隐藏横向控件。主记录表省略 token 用量和耗时,让内容获得可用宽度;局部检查器展示这些数据以及完整载荷、来源、schema 和请求计时。Overview 区域使用记录的开始时间、耗时与 token 边界数据,而不虚构实时流逝时间,其包含边界的聚焦行为与用户熟悉的 Chrome DevTools Network 交互一致。针对性组件测试锁定末尾跟随、计时投影、延迟展示详情、折叠、记录与区间选择、实体特定标签页和运行/错误语义;组装后的 Web 快照则通过真实客户端组合锁定记录表、Overview 计时详情、composer 浮层几何形状与检查器。 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.i18n.yaml index 21eea20254..5268ea62f9 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.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-27-web-session-fork-actions.md -2026-07-27-web-session-fork-actions.md: 58960169a2e499d953840e5769e7689b5cd48047 -2026-07-27-web-session-fork-actions.zh.md: ea2f9030f672f00fb91bce3546836689a7d41004 +2026-07-27-web-session-fork-actions.md: 578e59ec92e003fe5c8cdfe951a595f3a7371ecf +2026-07-27-web-session-fork-actions.zh.md: d90124f6e6e164b0a1e0fce734f52976630cd848 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.md b/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.md index 58960169a2..578e59ec92 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.md @@ -10,7 +10,9 @@ The Session store already provides a fork primitive that creates a child session ## Decision -The Web Session-row menu and message IconActions share the client runtime's `sessions.fork` action. A Session row passes `{ sessionId, increaseTitle: true }`, so it forks at the source session's last completed turn; a user message or settled assistant content message passes `{ sessionId, atSeq: node.seq, increaseTitle: true }`, so it forks at the turn containing that event. Only the client consumes `increaseTitle`: after adding the child session to its local list, the client increments a trailing `(N)` or `(N)` in the source session's persisted title without changing bracket style, appends ` (1)` to an unnumbered title, and skips the rename when no persisted title exists; the Host fork request still contains only `sessionId` and the optional `atSeq`. The caller opens the child only after the rename succeeds; a fork or rename failure leaves the source session and current selection unchanged, while a child created before a rename failure remains in the list. +The message-eligibility portion of this decision is narrowed by the [completed-turn-tail decision](../bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md); the shared runtime action, injection ownership, title handling, and peer-list decisions remain current. + +The Web Session-row menu and message IconActions share the client runtime's `sessions.fork` action. A Session row passes `{ sessionId, increaseTitle: true }`, so it forks at the source session's last completed turn; an eligible completed-turn-tail message passes `{ sessionId, atSeq: node.seq, increaseTitle: true }`, so it forks at the turn ending at that message. Only the client consumes `increaseTitle`: after adding the child session to its local list, the client increments a trailing `(N)` or `(N)` in the source session's persisted title without changing bracket style, appends ` (1)` to an unnumbered title, and skips the rename when no persisted title exists; the Host fork request still contains only `sessionId` and the optional `atSeq`. The caller opens the child only after the rename succeeds; a fork or rename failure leaves the source session and current selection unchanged, while a child created before a rename failure remains in the list. `forkAt(seq)` touches the session service only in ui-conversation's apply injection layer; message components report only the event `seq`. Session rows likewise initiate the operation only through ui-workspace's injected callback. Neither presentation package owns session mutation state or duplicates the host's boundary evaluation. @@ -28,6 +30,6 @@ Session lineage is not projected into a list hierarchy. WorkSpace mode displays ## Consequences -Users can create forks from Session rows, user messages, or settled assistant content messages; all three entry points ultimately use the same runtime/host operation. Message entry points preserve the exact event boundary, while the list entry point preserves the “latest completed turn” shortcut. Successive fork titles increment through `(1)`, `(2)`, and so on instead of repeatedly appending `(1)`; titles with fullwidth parentheses retain that style. Every fork child immediately appears as an ordinary peer row, so the list no longer needs session expansion state, recursive nodes, or twist controls. +Users can create forks from Session rows or eligible completed-turn-tail messages; both entry points ultimately use the same runtime/host operation. Message entry points preserve the exact event boundary, while the list entry point preserves the “latest completed turn” shortcut. Successive fork titles increment through `(1)`, `(2)`, and so on instead of repeatedly appending `(1)`; titles with fullwidth parentheses retain that style. Every fork child immediately appears as an ordinary peer row, so the list no longer needs session expansion state, recursive nodes, or twist controls. -Fork and child-rename failures stay silent and preserve the source selection, preventing a derivation action from disrupting the current reading position; this tradeoff also means the UI does not yet expose a failure reason or retry entry point. Package tests separately pin the two message `seq` paths, title increments, and the peer-list derivation; `apps/web/tests/message-actions.e2e.ts` exercises assistant-message branching and Session-row menu branching through the assembled application. +Fork and child-rename failures stay silent and preserve the source selection, preventing a derivation action from disrupting the current reading position; this tradeoff also means the UI does not yet expose a failure reason or retry entry point. Package tests pin eligible message `seq` forwarding, title increments, and the peer-list derivation; `apps/web/tests/message-actions.e2e.ts` exercises assistant-message branching and Session-row menu branching through the assembled application. diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.zh.md index ea2f9030f6..d90124f6e6 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.zh.md @@ -10,7 +10,9 @@ Session store 已提供按完成轮前缀创建子会话的 fork 原语,但 We ## Decision -Web 的 session 行菜单与消息 IconActions 共用 client runtime 的 `sessions.fork` 操作。Session 行传 `{ sessionId, increaseTitle: true }`,因此在源会话最后一个已完成轮次处分支;用户消息与已定稿 assistant 内容消息传 `{ sessionId, atSeq: node.seq, increaseTitle: true }`,因此在包含该事件的轮次处分支。`increaseTitle` 只由 client 消费:子会话进入本地列表后,client 把源会话持久化标题尾部的 `(N)` 或 `(N)` 递增并保留括号样式,无编号时追加 ` (1)`,没有持久化标题时不改名;Host fork 请求仍只有 `sessionId` 与可选的 `atSeq`。改名成功后调用方才打开子会话;fork 或改名失败时保持源会话与当前选择不变,改名失败时已创建的子会话仍留在列表中。 +本决策中的消息资格部分由[已完成轮次尾部决策](../bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md)收紧;共享运行时操作、注入归属、标题处理和同级列表决策仍然有效。 + +Web 的 session 行菜单与消息 IconActions 共用 client runtime 的 `sessions.fork` 操作。Session 行传 `{ sessionId, increaseTitle: true }`,因此在源会话最后一个已完成轮次处分支;符合条件且位于已完成轮次尾部的消息传 `{ sessionId, atSeq: node.seq, increaseTitle: true }`,因此在以该消息结束的轮次处分支。`increaseTitle` 只由 client 消费:子会话进入本地列表后,client 把源会话持久化标题尾部的 `(N)` 或 `(N)` 递增并保留括号样式,无编号时追加 ` (1)`,没有持久化标题时不改名;Host fork 请求仍只有 `sessionId` 与可选的 `atSeq`。改名成功后调用方才打开子会话;fork 或改名失败时保持源会话与当前选择不变,改名失败时已创建的子会话仍留在列表中。 `forkAt(seq)` 只在 ui-conversation 的 apply 注入层接触 session 服务,消息组件只回传事件 `seq`。Session 行同理只通过 ui-workspace 的注入回调发起操作;两个呈现包都不持有 session mutation 状态,也不复制 host 的边界求值。 @@ -28,6 +30,6 @@ Session lineage 不投影成列表层级。WorkSpace 模式按 `WorkspaceView.se ## Consequences -用户可从 session 行、用户消息或已定稿 assistant 内容消息创建分支,三处最终走同一个 runtime/host 操作;消息点位保留精确事件边界,列表点位保留「最新完成轮」快捷语义。连续 fork 的标题按 `(1)`、`(2)` 递增,而不是重复追加 `(1)`;全角括号标题保持全角样式。所有 fork 子会话立即作为普通同级行出现,列表不再需要 session 展开状态、递归节点或 twist 控件。 +用户可从 session 行或符合条件的已完成轮次尾部消息创建分支,两处最终走同一个 runtime/host 操作;消息点位保留精确事件边界,列表点位保留「最新完成轮」快捷语义。连续 fork 的标题按 `(1)`、`(2)` 递增,而不是重复追加 `(1)`;全角括号标题保持全角样式。所有 fork 子会话立即作为普通同级行出现,列表不再需要 session 展开状态、递归节点或 twist 控件。 -Fork 与子会话改名失败都保持静默并保留源选择,避免一个派生操作破坏当前阅读位置;该取舍也意味着 UI 暂不提供失败原因或重试入口。Package tests 分别钉住两种消息 `seq`、标题递增与同级列表派生,`apps/web/tests/message-actions.e2e.ts` 通过装配后的应用执行 assistant 消息分支与 session 行菜单分支。 +Fork 与子会话改名失败都保持静默并保留源选择,避免一个派生操作破坏当前阅读位置;该取舍也意味着 UI 暂不提供失败原因或重试入口。包级测试固定符合条件的消息 `seq` 转发、标题递增与同级列表派生,`apps/web/tests/message-actions.e2e.ts` 通过装配后的应用执行 assistant 消息分支与 session 行菜单分支。 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml index bd780ca60d..abb388a410 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.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-27-web-subagent-conversations.md -2026-07-27-web-subagent-conversations.md: 34acb1410cf6316bca2980ed012046ffab9623f6 -2026-07-27-web-subagent-conversations.zh.md: 5dcd7025c5cd03fed34266834795de1f2b630648 +2026-07-27-web-subagent-conversations.md: 03c805d30dbdbbdb33d0b06ba8036ec181035ac6 +2026-07-27-web-subagent-conversations.zh.md: e83f07fb21f58ad175ab3e5638981648fa4cef05 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md index 34acb1410c..03c805d30d 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md @@ -33,14 +33,16 @@ The Figma [subagent list](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Ha | The session header opens a compact child list. | The trigger aggregates the complete subagent-only descendant lineage; the tree shows every direct catalog entry in service order, including disabled diagnostics. | | Selecting a row reuses the conversation UI. | Addressed history never activates the child; only a continuable row with a live parent retains the ordinary composer. | | Nested agents expand progressively. | Each row carries a one-level `hasChildren` snapshot; disclosure reserves known direct-descendant rows immediately, then loads only that row's direct catalog and retains its own parent address. | -| Rows show labels, state, and relative time without duplicating sidebar rows. | Mode and `running`/`inactive` activity are textual as well as visual; optional title and time come from summaries. `SessionHeader.origin` removes duplicate navigation rows but grants no capability. | +| Rows show labels, state, usage, and active duration without duplicating sidebar rows. | Mode and `running`/`inactive` activity are textual as well as visual; optional title, durable token usage, and active-turn duration come from the list's retained projection values. Compact duration loses smaller units above one day, while hover and accessible naming retain exact whole seconds. `SessionHeader.origin` removes duplicate navigation rows but grants no capability. | ## Product contract -The header action is absent only after a complete empty direct-catalog response. Its trigger counts every known session-summary descendant reached through an uninterrupted `origin: 'subagent'` lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. Every healthy direct-catalog row carries a read-time `hasChildren` hint derived only from direct lineage headers with durable `origin: 'subagent'`; normal healthy and diagnostic subagent candidates carry that marker, while ordinary forks do not. This lookahead reads no descendant event log, and the descriptor-backed catalog loaded after disclosure remains authoritative. The UI omits disclosure for a known leaf before interaction; the hint does not promise that the child will remain a leaf. While an expanded direct catalog is loading, known lineage reserves one disabled loading row per direct descendant without recursively fetching descendant catalogs. The tree then presents continuable and one-shot rows, falling back to the session id when an optional one-shot label is absent. Corrupt, unsupported, and unavailable candidates remain visible as disabled diagnostic rows. +The header action is absent only when a complete empty direct-catalog response agrees with the session-summary projection that no subagent descendants are known. Its trigger counts every known session-summary descendant reached through an uninterrupted `origin: 'subagent'` lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. Every healthy direct-catalog row carries a read-time `hasChildren` hint derived only from direct lineage headers with durable `origin: 'subagent'`; normal healthy and diagnostic subagent candidates carry that marker, while ordinary forks do not. This lookahead reads no descendant event log, and the descriptor-backed catalog loaded after disclosure remains authoritative. When summaries establish descendants before that catalog exists or after a stale empty response, the action stays visible and exposes only disabled loading rows until opening it refreshes the catalog; summary-only rows never grant navigation. The UI omits disclosure for a known leaf before interaction; the hint does not promise that the child will remain a leaf. While an expanded direct catalog is loading, known lineage reserves one disabled loading row per direct descendant without recursively fetching descendant catalogs. The tree then presents continuable and one-shot rows, falling back to the session id when an optional one-shot label is absent. Corrupt, unsupported, and unavailable candidates remain visible as disabled diagnostic rows. `running` means the exact child Agent driver is draining work at the Host sampling boundary; `inactive` means that driver is idle or absent. The UI does not translate either value into success, failure, cancellation, completeness, or resumability. `subagent.list` supplies the current driver-status baseline, `host/session-status` updates known activity in place, request-local replay prevents an older in-flight list response from overwriting a newer transition, and `host/session-removed` returns a known row to `inactive`; reconnect reads a fresh baseline. A `host/session-added` frame for a direct subagent immediately flips any loaded parent row to `hasChildren: true`, and that positive hint survives an older in-flight catalog response; membership, labels, mode, diagnostics, and the authoritative snapshot still require a debounced `subagent.list` refresh while the affected branch is open. A prompt response remains delivery-time authority. +Healthy rows reuse the standard session projections retained in the list mirror. The token figure sums the four disjoint `tokenUsage` buckets across the durable log. `subagentTiming` resets at every descriptor so an inherited fork seed cannot enter the child's total, accumulates completed `turn/start` → `turn/end` spans, and carries same-cut `active.since` and `active.through` bounds for an open turn. Existing session events advance `active.through` while that turn remains open; the menu adds no separate timer or log read and advances its local clock only while a known descendant is running. Below one day it formats whole seconds; longer visual values retain at most two adjacent units, using approximate 30-day months and 365-day years, while hover and accessible naming preserve the exact day/hour/minute/second duration. An inactive row bounds an interrupted open turn with `active.through`, so a stale projection never borrows newer session metadata and reopening the menu never restarts completed work. Neither metric implies a durable outcome. + Selecting a row records its exact address before opening the resident client `Session`. History pagination, event folding, tool render intents, titles, and live mux reconciliation reuse the ordinary conversation machinery. Breadcrumbs use catalog labels, follow parent links only through `origin: 'subagent'` rows, include the first ordinary owner, and keep ordinary forks single-level. Forking an addressed subagent creates an ordinary fork with direct source lineage and attaches it to the nearest workspace-owning ancestor. The catalog is an ARIA tree with lazy ArrowRight/ArrowLeft disclosure, linear ArrowUp/ArrowDown navigation, Home/End, Escape, and focus restoration. A one-shot row always replaces the composer with copy explaining that the execution record is read-only. A continuable row does so only while `parentAvailable` is false. When enabled, its Send action admits another FIFO turn even if the child is currently running; it never becomes Stop. Prompt failures retain the draft through the ordinary error behavior. @@ -65,7 +67,7 @@ The adapter stays in `dsh-host-apiproxy`; `dsh-host-webserver` remains a carrier ## Client object layer and presentation -The React-free runtime owns catalogs, single-flight refreshes, retained addresses, availability hints, and transport selection. Re-selecting a known child retains its address so navigation cannot silently switch to ordinary session APIs. A missing intermediate breadcrumb address can be recovered from an already-loaded ancestor catalog, but it is not retained for transport and creates no scope until the user selects that breadcrumb. Restored navigation persists the full mode-bearing address. +The React-free runtime owns catalogs, single-flight refreshes, retained addresses, availability hints, transport selection, and a reference-stable map of each list row's current projection values. Re-selecting a known child retains its address so navigation cannot silently switch to ordinary session APIs. A missing intermediate breadcrumb address can be recovered from an already-loaded ancestor catalog, but it is not retained for transport and creates no scope until the user selects that breadcrumb. Restored navigation persists the full mode-bearing address. Catalogs ride the standard `useSessions` snapshot. Component-local state owns menu visibility, expanded branches, and focus. `ui-conversation` declares the generic header-action list slot and dispatches the current conversation snapshot through its composer chain; it contains no subagent-specific takeover flag. `@deepseek-ai/dsh-client-ui-subagent` registers the catalog action and elects a reason-specific read-only composer from ordinary owner props. Components receive derived props and callbacks, never `ctx`. @@ -102,14 +104,14 @@ The shipped Web composition mounts SQLite session query beside JSONL persistence - Host protocol tests pin schemas including required boolean expandability, id echoing, mode verification, non-activating history, exact-parent enforcement, FIFO admission receipts, cancellation, and sanitized failure mapping. - Generic Host tests pin attached and cold history and forks without Agent publication, cold projection folding, descriptor/origin/runtime-owner denial, explicit-id adoption denial, and the direct queue-control fence. - Client object tests pin retained and restored addresses, one-shot read-only rejection, history routing, continuable prompt routing, no addressed cancellation, suppression of Agent-bound model controls, live activity flips including in-flight response replay and detach fallback, subagent-parent expandability flips, and membership refresh. -- jsdom tests pin the aggregate descendant count and activity, known loading-row shape, mixed-mode rows, pre-click leaf disclosure, diagnostics, lazy descendant disclosure, direct-parent addresses, keyboard behavior, and both read-only reasons. -- The keyless assembled Web snapshot contains an inactive continuable child, an inactive one-shot sibling, and a persisted grandchild; it pins the three-descendant trigger and aggregate running transition, expands without activation, opens persisted history, admits a human FIFO follow-up, reconciles child mux events, and proves one-shot history remains read-only. +- jsdom tests pin the aggregate descendant count and activity, token totals, second-precision running and frozen inactive durations, adaptive long-duration units with exact accessible text, the summary-backed root action across absent and stale-empty catalogs, known loading-row shape, mixed-mode rows, pre-click leaf disclosure, diagnostics, lazy descendant disclosure, direct-parent addresses, keyboard behavior, and both read-only reasons. +- The keyless assembled Web snapshot contains an inactive continuable child with durable usage, an inactive one-shot sibling with a deterministic long duration, and a persisted grandchild; it pins the three-descendant trigger across a stale empty catalog response, usage and timing rows, adaptive long-duration presentation, and aggregate running transition, expands without activation, opens persisted history, admits a human FIFO follow-up, reconciles child mux events, and proves one-shot history remains read-only. - Navigation tests pin subagent-only breadcrumbs, workspace placement for forks created from subagents, and `origin: 'subagent'` sidebar filtering without hiding ordinary forks. ## Consequences -- Catalog reads may rescan persisted lineage and each direct candidate's descriptor log, but expandability reuses only descendant headers already present in that trace; the Web activity baseline adds one Agent-registry lookup per healthy row and then uses existing live frames, while membership refresh stays debounced and single-flight. +- Catalog reads may rescan persisted lineage and each direct candidate's descriptor log, but expandability reuses only descendant headers already present in that trace; the Web activity baseline adds one Agent-registry lookup per healthy row and then uses existing live frames, while usage and duration reuse projection baselines and pushes with no per-row log read, and membership refresh stays debounced and single-flight. - Parent availability, child activity, and `hasChildren` are snapshots. Publication, disposal, another sender, or another process may win after listing; typed prompt failure remains expected. - A child may publish between history fetch and mux subscription, so the existing sequence reconciliation also covers the cold-to-live addressed path. - Persisted origin adds one deliberately weak product-classification field to child headers and list projections; it cannot become an authorization shortcut. -- The UI has no child cancellation, durable outcome, activation duration, deletion, or independently interactive offline mode, and its text must not imply those capabilities. +- The UI has no child cancellation, durable outcome, Activation identity, deletion, or independently interactive offline mode, and its text must not imply those capabilities. Active-turn duration measures logged work rather than Activation residency. diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md index 5dcd7025c5..e83f07fb21 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md @@ -33,14 +33,16 @@ Figma 中的 [subagent 列表](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5 | 会话页头可打开紧凑的 child 列表。 | 触发器会汇总仅含 subagent 的完整后代谱系;树按服务顺序显示每个直接目录条目,包括已禁用的 diagnostic。 | | 选择一行会复用对话 UI。 | 已寻址历史绝不激活 child;只有 parent 存活的可继续行才保留普通输入框。 | | 嵌套 agent 会逐层展开。 | 每行携带一层 `hasChildren` 快照;展开时会立即预留已知直接后代行,随后仍只加载该行的直接目录,并保留其自身的 parent 地址。 | -| 条目显示 label、状态与相对时间,同时避免侧边栏条目重复。 | mode 与 `running`/`inactive` 活动状态会同时以文字和视觉呈现;可选 title 与时间来自摘要。`SessionHeader.origin` 会移除重复的导航条目,但不授予任何功能权限。 | +| 条目显示 label、状态、token 用量与活跃耗时,同时避免侧边栏条目重复。 | mode 与 `running`/`inactive` 活动状态会同时以文字和视觉呈现;可选 title、持久化 token 用量与活跃轮次耗时来自列表保留的投影值。紧凑耗时从一天起省略更小的单位,而悬停和无障碍名称仍保留精确的整秒数。`SessionHeader.origin` 会移除重复的导航条目,但不授予任何功能权限。 | ## 产品契约 -只有在完整的直接目录响应为空后,才不显示页头操作。其触发器会统计经不间断的 `origin: 'subagent'` 谱系可达的每个已知会话摘要后代,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。每个健康的直接目录行都携带读取时的 `hasChildren` 提示,该值只根据持久化 `origin: 'subagent'` 的直接谱系 header 派生;正常的健康与 diagnostic subagent 候选都会携带该标记,而普通 fork 不会。该预查不读取任何后代事件日志,展开后仍以描述符支撑的目录为权威依据。UI 会在交互前就省略已知叶子节点的展开控件;该提示不承诺 child 会一直是叶子。已展开的直接目录加载期间,已知谱系会为每个直接后代预留一行禁用的加载行,而不会递归获取后代目录。随后树会呈现可继续与 one-shot 行;one-shot 的可选 label 缺失时,回退到其会话 id。损坏、不受支持或不可用的候选仍以禁用的 diagnostic 行显示。 +只有当完整的直接目录空响应与会话摘要投影相符,二者均表明没有已知的 subagent 后代时,才不显示页头操作。其触发器会统计经不间断的 `origin: 'subagent'` 谱系可达的每个已知会话摘要后代,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。每个健康的直接目录行都携带读取时的 `hasChildren` 提示,该值只根据持久化 `origin: 'subagent'` 的直接谱系 header 派生;正常的健康与 diagnostic subagent 候选都会携带该标记,而普通 fork 不会。该预查不读取任何后代事件日志,展开后仍以描述符支撑的目录为权威依据。当摘要在该目录尚不存在时或在一次陈旧的空响应后确认已有后代时,该操作会保持可见,并且在打开它以刷新目录之前仅显示禁用的加载行;仅由摘要支撑的行绝不会提供导航能力。UI 会在交互前就省略已知叶子节点的展开控件;该提示不承诺 child 会一直是叶子。已展开的直接目录加载期间,已知谱系会为每个直接后代预留一行禁用的加载行,而不会递归获取后代目录。随后树会呈现可继续与 one-shot 行;one-shot 的可选 label 缺失时,回退到其会话 id。损坏、不受支持或不可用的候选仍以禁用的 diagnostic 行显示。 `running` 表示在 Host 采样边界,确切 child Agent driver 正在处理工作;`inactive` 表示该 driver 空闲或不存在。UI 不会把任一值解释为成功、失败、取消、完成状态或可恢复性。`subagent.list` 提供当前 driver 状态基线,`host/session-status` 会就地更新已知活动状态,请求内回放会阻止更早发起但尚未完成的列表响应覆盖较新的状态转换,`host/session-removed` 则会使已知行恢复为 `inactive`;重连时会读取新的基线。直接 subagent 的 `host/session-added` 帧会立即把任何已加载的 parent 行翻转为 `hasChildren: true`,并使这项正向提示不被更早发起但尚未完成的目录响应覆盖;受影响分支打开期间,成员、label、mode、diagnostic 与权威快照仍需要通过去抖动的 `subagent.list` 刷新来更新。消息投递时仍以提示词响应为权威依据。 +健康行会复用列表镜像中保留的标准会话投影。token 用量数值会汇总持久化日志中四个互不重叠的 `tokenUsage` 桶。`subagentTiming` 会在每个描述符处重置,使继承的 fork 种子不会计入 child 总量;它会累加已完成的 `turn/start` → `turn/end` 时段,并携带未结束轮次同一切面的 `active.since` 和 `active.through` 边界。该轮次保持未结束期间,现有会话事件会推进 `active.through`;菜单不会增加单独的计时器或日志读取,且仅在有已知后代处于运行状态时才推进其本地时钟。不足一天时,菜单会以整秒格式化时间;达到一天后的视觉值最多保留两个相邻单位,其中月份按近似 30 天计算,年份按近似 365 天计算,而悬停信息与无障碍名称会保留精确的天/小时/分钟/秒耗时。对 inactive 行,菜单以 `active.through` 为被中断未结束轮次的上界,因此陈旧投影绝不会借用更新的会话元数据,且重新打开菜单绝不会让已完成工作重新计时。这两项指标都不蕴含持久化结果语义。 + 选择一行后,系统会先记录其确切地址,再打开常驻客户端 `Session`。历史分页、事件 fold、工具渲染意图、title 与实时 mux 归并都会复用普通对话机制。面包屑导航使用目录 label,只会沿 `origin: 'subagent'` 行的父链接逐级回溯,包含第一个普通 owner,并让普通 fork 保持单层。从已寻址 subagent 创建 fork 时,会生成具有直接源谱系的普通 fork,并将其附加到最近拥有 Workspace 的祖先。目录是一棵 ARIA 树,支持懒加载式 ArrowRight/ArrowLeft 展开与折叠、线性 ArrowUp/ArrowDown 导航、Home/End、Escape 以及焦点恢复。 one-shot 行始终会用文案替代输入框,说明执行记录为只读。可继续行仅在 `parentAvailable` 为 false 时如此。启用后,即使 child 正在运行,其 Send 操作也会准入另一个 FIFO 轮次,绝不会变成 Stop。提示词失败会通过普通错误行为保留草稿。 @@ -65,7 +67,7 @@ one-shot 行始终会用文案替代输入框,说明执行记录为只读。 ## 客户端对象层与呈现 -不依赖 React 的运行时负责目录、单次并发刷新、保留的地址、可用性提示与传输选择。再次选择已知 child 时会保留其地址,避免导航静默切换到普通会话 API。缺失的中间面包屑地址可以从已加载的祖先目录恢复,但在用户选择该面包屑之前不会保留为传输地址,也不会创建 scope。恢复的导航会持久化包含 mode 的完整地址。 +不依赖 React 的运行时负责目录、单次并发刷新、保留的地址、可用性提示、传输选择,以及每个列表行当前投影值的引用稳定映射。再次选择已知 child 时会保留其地址,避免导航静默切换到普通会话 API。缺失的中间面包屑地址可以从已加载的祖先目录恢复,但在用户选择该面包屑之前不会保留为传输地址,也不会创建 scope。恢复的导航会持久化包含 mode 的完整地址。 目录通过标准 `useSessions` 快照传递。组件局部状态负责菜单可见性、已展开分支与焦点。`ui-conversation` 声明通用页头操作列表 slot,并通过其编辑器链分发当前对话快照;其中没有 subagent 专用的接管标记。`@deepseek-ai/dsh-client-ui-subagent` 注册目录操作,并根据普通 owner props 选择按原因区分的只读编辑器。组件只接收派生 props 与回调,绝不接收 `ctx`。 @@ -102,14 +104,14 @@ one-shot 行始终会用文案替代输入框,说明执行记录为只读。 - 宿主协议测试固定 schema(包括必需的布尔可展开性)、id 回显、mode 校验、非激活式历史、确切 parent 强制要求、FIFO 准入回执、取消与脱敏后的失败映射。 - 通用 Host 测试固定在不发布 Agent 的情况下读取已附加与冷态历史及执行 fork、冷态投影归并、按描述符/origin/运行时 owner 拒绝、拒绝显式 id 接纳,以及直接队列控制栅栏。 - 客户端对象测试固定已保留与已恢复的地址、one-shot 只读拒绝、历史路由、可继续提示词路由、已寻址对话不提供取消、屏蔽绑定到 agent 的模型控件、实时活动状态翻转(包括在途响应回放与 detach 回退)、subagent parent 可展开性翻转与成员刷新。 -- jsdom 测试固定后代聚合计数与活动状态、已知加载行的形态、混合 mode 行、点击前的叶子展开控件、diagnostic、后代懒加载展开、直接 parent 地址、键盘行为与两种只读原因。 -- 无密钥的组装 Web 快照包含一个 inactive 的可继续 child、一个 inactive 的 one-shot sibling 和一个持久化 grandchild;它会固定触发器显示三个后代及聚合 `running` 状态转换,在不激活的情况下展开、打开持久化历史、准入一条用户 FIFO 后续消息、归并 child mux 事件,并证明 one-shot 历史仍然只读。 +- jsdom 测试固定后代聚合计数与活动状态、token 用量总计、精确到秒的运行中耗时与冻结后 inactive 耗时、采用自适应单位的长耗时及其精确无障碍文本、目录缺失或为陈旧空目录时由摘要支撑的根操作、已知加载行的形态、混合 mode 行、点击前的叶子展开控件、diagnostic、后代懒加载展开、直接 parent 地址、键盘行为与两种只读原因。 +- 无密钥的组装 Web 快照包含一个具有持久化 token 用量的 inactive 可继续 child、一个具有确定性长耗时的 inactive one-shot sibling 和一个持久化 grandchild;它会固定触发器在一次陈旧的空目录响应后仍显示三个后代,并固定 token 用量与计时行、自适应长耗时呈现以及聚合 `running` 状态转换,在不激活的情况下展开、打开持久化历史、准入一条用户 FIFO 后续消息、归并 child mux 事件,并证明 one-shot 历史仍然只读。 - 导航测试固定仅含 subagent 的面包屑导航、从 subagent 创建 fork 时的 Workspace 归属,以及 `origin: 'subagent'` 侧边栏过滤,同时不隐藏普通 fork。 ## 后果 -- 目录读取可能重新扫描持久化谱系与每个直接候选的描述符日志,但可展开性只复用该追踪中已有的后代 header;Web 活动基线会为每个健康行增加一次 Agent 注册表查找,随后使用现有实时帧,而成员刷新保持去抖动和单次并发。 +- 目录读取可能重新扫描持久化谱系与每个直接候选的描述符日志,但可展开性只复用该追踪中已有的后代 header;Web 活动基线会为每个健康行增加一次 Agent 注册表查找,随后使用现有实时帧,而 token 用量与耗时会复用投影基线和推送,无需按行读取日志,成员刷新则保持去抖动和单次并发。 - parent 可用性、child 活动状态与 `hasChildren` 都是快照。列出之后,发布、dispose、其他发送方或其他进程都可能抢先改变状态;类型化提示词失败仍属预期行为。 - child 可能在历史获取与 mux 订阅之间发布,因此现有序号归并也涵盖从冷态转为存活的已寻址路径。 - 持久化 origin 会为 child header 与列表投影添加一个有意保持弱约束的产品分类字段;它不能变成授权捷径。 -- UI 不提供 child 取消、持久化结果、激活耗时、删除或可独立交互的离线 mode,其文案不得暗示这些功能已经存在。 +- UI 不提供 child 取消、持久化结果、Activation 身份、删除或可独立交互的离线 mode,其文案不得暗示这些功能已经存在。活跃轮次耗时度量的是已记录工作,而非 Activation 驻留时间。 diff --git a/.agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.i18n.yaml new file mode 100644 index 0000000000..338f90664d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.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-29-tui-hidden-mode-assistant-fold.md +2026-07-29-tui-hidden-mode-assistant-fold.md: e2bae1d4669fd0a4c8f9278c58f641704b425109 +2026-07-29-tui-hidden-mode-assistant-fold.zh.md: 583d6099f9cd5edfc592a9d510d260d7a4172013 diff --git a/.agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.md b/.agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.md new file mode 100644 index 0000000000..e2bae1d466 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.md @@ -0,0 +1,25 @@ +# Agent Note: TUI hidden mode folds a turn's assistant steps into one message + +Status: implemented + +English | [中文](2026-07-29-tui-hidden-mode-assistant-fold.zh.md) + +## Problem + +Ctrl+O's hidden phase ([consolidated TUI presentation](../architecture/2026-07-28-consolidated-tui-presentation.md)) drops tool cards so the transcript reads as a conversation, but each model step still rendered its own `Assistant` header. A multi-step turn (text → tools → text) therefore showed several consecutive `Assistant` blocks with nothing between them — the removed tool cards were the only thing that had justified the repeated headers. Codex-style conversation-only reading wants one assistant message per turn. + +## Decision + +Hidden mode is also a fold rule, applied purely as TUI presentation: per turn, the first step whose rendered content is visible (text, or reasoning while reasoning display is on) owns the turn's single `Assistant` header; every other step renders as a headerless continuation, and a step with no visible body renders nothing at all — a tool-only step neither consumes the header nor leaves a blank segment. Collapsed and expanded phases keep per-step headers; leaving hidden restores them. + +Mechanics: `StreamingAssistantComponent` carries its `StepPosition` and a `setFoldedContinuation` presentation flag; `createTuiChat` keeps a per-turn list of step components and re-derives the fold on Ctrl+O, on each streamed text/reasoning chunk, on message settle, and on retraction of a failed stream (which may hand the header to the next step). Transcript rebuild clears the map and replays the log, so resume, compaction replacement, resize, and theme swaps converge on the same fold. Step timing footers keep their per-step ownership and are unaffected. + +## Alternatives considered + +- **Merge steps into one component** — collides with per-step streaming lifecycle, retry retraction, and timing footers; the flag on existing components changes only the header/spacer. +- **Fold in the session log or `deriveMessages`** — mutates durable/model-visible history for a UI reading mode; the log stays step-shaped. +- **Always fold (all visibility phases)** — collapsed/expanded interleave tool cards between steps, where per-step headers delimit which output belongs to which step. + +## Consequences + +Hidden mode now reads as one assistant message per turn; turns stay separated by their headers. The fold is recomputed state, never stored, so no session or persistence format changes. Coverage: TUI unit specs for the Ctrl+O cycle header counts, tool-only first step header handoff, per-turn separation, and live streaming + rebuild convergence; keyless snapshot `tool-cards-hidden-folded` pins the folded frame. diff --git a/.agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.zh.md b/.agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.zh.md new file mode 100644 index 0000000000..583d6099f9 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.zh.md @@ -0,0 +1,25 @@ +# Agent Note: TUI 隐藏模式把一个轮次的 assistant 步骤折叠为一条消息 + +Status: implemented + +[English](2026-07-29-tui-hidden-mode-assistant-fold.md) | 中文 + +## 问题 + +Ctrl+O 的隐藏阶段([整合的 TUI 展示](../architecture/2026-07-28-consolidated-tui-presentation.md))去掉工具卡片,让 transcript(文本记录)读作一段对话,但每个模型步骤仍渲染自己的 `Assistant` 标题。因此一个多步骤轮次(文本 → 工具 → 文本)会显示多个连续、之间空无一物的 `Assistant` 区块——被移除的工具卡片正是重复标题曾经的唯一理由。Codex 风格的纯对话阅读需要每轮次一条 assistant 消息。 + +## 决定 + +隐藏模式同时也是一条折叠规则,且纯粹作为 TUI 展示实现:在每个轮次内,第一个渲染内容可见(有文本,或在 reasoning 显示开启时有 reasoning)的步骤拥有该轮次唯一的 `Assistant` 标题;其余步骤渲染为无标题的续段,没有可见正文的步骤则完全不渲染——仅有工具调用的步骤既不占用标题,也不留下空白段。折叠与展开阶段保留每步各自的标题;离开隐藏阶段会恢复它们。 + +机制:`StreamingAssistantComponent` 携带自己的 `StepPosition` 和一个 `setFoldedContinuation` 展示标志;`createTuiChat` 维护每轮次的步骤组件列表,并在 Ctrl+O、每个流式 text/reasoning chunk、消息结算,以及失败流被撤回(可能把标题移交给下一个步骤)时重新推导折叠。transcript 重建会清空该映射并重放日志,因此恢复、压缩替换、调整尺寸和主题切换收敛到同一折叠结果。步骤计时页脚保持按步骤归属,不受影响。 + +## 考虑过的替代方案 + +- **把多个步骤合并为一个组件**——与按步骤的流式生命周期、重试撤回和计时页脚冲突;在现有组件上加标志只改变标题与前导间距。 +- **在会话日志或 `deriveMessages` 中折叠**——为一种 UI 阅读模式改变持久 / 模型可见的历史;日志保持按步骤的形状。 +- **所有可见性阶段都折叠**——折叠 / 展开阶段在步骤之间穿插工具卡片,此时每步的标题用来划分哪段输出属于哪个步骤。 + +## 后果 + +隐藏模式现在每轮次读作一条 assistant 消息;轮次之间仍由各自的标题分隔。折叠是重新计算的状态,从不存储,因此会话与持久化格式没有变化。覆盖:TUI 单元测试覆盖 Ctrl+O 循环的标题计数、仅工具的首步骤标题移交、按轮次分隔,以及实时流式 + 重建收敛;无密钥快照 `tool-cards-hidden-folded` 固定折叠后的帧。 diff --git a/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.i18n.yaml index 53d40cf299..54966549ba 100644 --- a/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md -2026-07-30-continuable-subagent-report-tool.md: 24922cfe88084bb0f9fea8c9363980a224b875da -2026-07-30-continuable-subagent-report-tool.zh.md: bb0b1847f157dba6116526851194cf1228e52e49 +2026-07-30-continuable-subagent-report-tool.md: 8324f1fa08f7dace6153712575e7e70a07ee9344 +2026-07-30-continuable-subagent-report-tool.zh.md: e7599c1d85328e83c7773718101b59e763a1e37f diff --git a/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md b/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md index 24922cfe88..8324f1fa08 100644 --- a/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md +++ b/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md @@ -54,7 +54,7 @@ The first version provides no durable mailbox, idempotency key, delivery receipt The subagent seam adds `registerContinuableSetup(contribution): () => void`, backed by `SubagentActivationSetupRegistry`. Each synchronous contribution receives the unpublished child context and returns the disposer for its installation. The continuation manager first applies base child composition, then current contributions in registration order through the same setup closure used for fresh creation and cold resume. -The registry owns registration, per-child installation records, setup rollback, child-scope cleanup, and immediate revocation. A throwing or concurrently revoked contribution rejects before Activation publication and rolls back the batch. New registrations affect a resident child only on its next Activation; removing a registration first closes it to new setup and then revokes every provisioning or resident installation immediately. Registration disposal and child-context disposal are idempotent and attempt every release before aggregating failures. +The registry owns registration, per-child installation records, setup rollback, child-scope cleanup, and immediate revocation. Applying a batch returns the Agent setup commit that revalidates provisioning after every setup await and immediately before Agent publication. A throwing or concurrently revoked contribution therefore rejects before either Agent or Session publication and rolls back the batch. New registrations affect a resident child only on its next Activation; removing a registration first closes it to new setup and then revokes every provisioning or resident installation immediately. Registration disposal and child-context disposal are idempotent and attempt every release before aggregating failures. This seam keeps the continuation manager unaware of tool names. The report package installs only `report`; `@deepseek-ai/dsh-tool-subagent-control` independently installs parent-side `send_message` and `list_agents`. A deployment can install either direction, both, or neither. Providers remain data-only, durable descriptors do not snapshot report availability or delivery mode, and cold resume uses the deployment's current contributions and policy. @@ -94,6 +94,10 @@ Mutating or cold-resuming an absent parent requires a new durable addressing, au A result-bearing wrapper makes one report or one turn appear terminal and recreates the lifetime mismatch that continuable Activations removed. Explicit repeatable sends need no intermediate execution object. +### Validate setup after Agent creation + +A post-creation revocation check can reject the Activation only after the Agent and Session have been published. Disposing the returned handle removes the live objects but cannot delete persistence through the current seam, leaving a resumable child that the continuation manager said was never established. Returning an `AgentSetupCommit` instead lets the Agent factory perform the same mutable-state check synchronously at its publication boundary. + ## Consequences - A continuable in-process child exposes exactly one scope-local `report` schema only while the report package's contribution is installed; unrelated Agents never expose it. @@ -112,5 +116,3 @@ The acceptance boundary is weaker than durable end-to-end delivery. A crash can Wakeup mode can amplify model work when nested children report frequently. Deployment ownership and a quiet default limit but do not remove that risk. Registry presence is the parent liveness signal. A host-owned parent whose `AgentHandle.dispose()` has started but has not yet unwound its scope can still accept and append a report that it will not act on in this process. Closing that gap requires an Agent-level disposal-start signal rather than subagent-layer inference. - -The final setup-revocation check runs after `ctx.agents.create()` or `ctx.agents.resume()` returns, after lower-level Agent and Session publication. Revocation in this window rolls back the handle and prevents the subagent Activation start edge but may leave a persisted Session. Moving the cutoff before lower-level publication requires a future Agent-creation setup transaction seam. diff --git a/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.zh.md b/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.zh.md index bb0b1847f1..e7599c1d85 100644 --- a/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.zh.md @@ -54,7 +54,7 @@ root、one-shot child、伪造对象、陈旧 Agent 和同 id 替换对象都以 subagent seam 新增 `registerContinuableSetup(contribution): () => void`,由 `SubagentActivationSetupRegistry` 支撑。每个同步贡献都会接收尚未发布的 child 上下文,并返回其安装的 disposer。继续执行管理器首先应用基础 child 组合,然后通过同一个用于首次创建与冷恢复的设置闭包,按注册顺序应用当前贡献。 -注册表负责注册、每个 child 的安装记录、设置回滚、child 作用域清理和立即撤销。某项贡献抛出异常或被并发撤销时,会在 Activation 发布前拒绝操作并回滚该批次。新注册项只会在驻留 child 的下一个 Activation 生效;移除注册项时,会先将它对新设置关闭,再立即撤销为正在配置或驻留的每个 child 安装的实例。注册 dispose 与 child 上下文 dispose 都是幂等的,两者都会先尝试每项释放,再聚合失败。 +注册表负责注册、每个 child 的安装记录、设置回滚、child 作用域清理和立即撤销。应用一个批次会返回 Agent setup 提交对象,用于在所有 setup 的 await 均结算后、紧邻 Agent 发布前重新校验配置状态。因此,某项贡献抛出异常或被并发撤销时,会在 Agent 与 Session 发布前拒绝操作并回滚该批次。新注册项只会在驻留 child 的下一个 Activation 生效;移除注册项时,会先将它对新设置关闭,再立即撤销为正在配置或驻留的每个 child 安装的实例。注册 dispose 与 child 上下文 dispose 都是幂等的,两者都会先尝试每项释放,再聚合失败。 该 seam 使继续执行管理器无需知道工具名。report 包只安装 `report`;`@deepseek-ai/dsh-tool-subagent-control` 则独立安装 parent 侧的 `send_message` 和 `list_agents`。部署时可安装任一方向、同时安装两者或两者均不安装。提供方仍只负责数据,持久化描述符不会对 report 可用性或投递模式建立快照,冷恢复则使用部署当前的贡献与策略。 @@ -94,6 +94,10 @@ ACP(Agent Client Protocol)快照 harness 新增 `waitForSubagentTurnEnd`, 承载结果的包装层会让一次报告或一个轮次看似具有终止性,并重新引入可继续 Activation 已经移除的生命周期不匹配。显式、可重复的发送无需中间执行对象。 +### 在 Agent 创建后校验 setup + +创建完成后的撤销检查只能在 Agent 与 Session 均已发布后拒绝 Activation。对返回的 handle 执行 dispose 会移除实时对象,但当前 seam 无法删除持久化内容,因此会留下一个仍可恢复的 child,而继续执行管理器却判定它从未建立。改为返回 `AgentSetupCommit`,Agent 工厂便可在自身的发布边界同步执行同一项可变状态检查。 + ## 影响 - 只有安装 report 包贡献时,可继续进程内 child 才会恰好暴露一个作用域局部 `report` schema;无关 Agent 永远不会暴露该 schema。 @@ -112,5 +116,3 @@ ACP(Agent Client Protocol)快照 harness 新增 `waitForSubagentTurnEnd`, wakeup 模式可能在嵌套 child 频繁报告时放大模型工作量。由部署所有者控制并默认静默,可以限制该风险,但无法完全消除。 注册表中的存在性就是 parent 在线信号。宿主拥有的 parent 如果已开始 `AgentHandle.dispose()` 但尚未展开其作用域,仍可能接受并追加一条本进程不会再处理的报告。要弥合这个缺口,需要 Agent 层面的 dispose 开始信号,不能由 subagent 层推断。 - -最终 setup 撤销检查发生在 `ctx.agents.create()` 或 `ctx.agents.resume()` 返回之后,此时底层 Agent 和 Session 已经发布。在该窗口内撤销会回滚 handle,并阻止 subagent Activation 的 start 边,但可能留下持久化 Session。若要把截止点移到底层发布之前,需要未来提供 Agent 创建 setup 事务 seam。 diff --git a/.agents/notes/implemented/feature/2026-07-30-tui-details-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.i18n.yaml new file mode 100644 index 0000000000..5a1b64f6b3 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-tui-details-command.md +2026-07-30-tui-details-command.md: fb7c4dfaedeff27c9cafd0ba82daf4739665f19c +2026-07-30-tui-details-command.zh.md: 5f9e033311d1999998ef08b8f340d71f751b11f6 diff --git a/.agents/notes/implemented/feature/2026-07-30-tui-details-command.md b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.md new file mode 100644 index 0000000000..fb7c4dfaed --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.md @@ -0,0 +1,34 @@ +# Agent Note: /details command for transcript detail state + +Status: implemented + +English | [中文](2026-07-30-tui-details-command.zh.md) + +## Problem + +The TUI's transcript detail state — tool-card visibility (`collapsed`/`expanded`/`hidden`, per the [consolidated TUI presentation](../architecture/2026-07-28-consolidated-tui-presentation.md)) and reasoning-block display — was reachable only through the Ctrl+O cycle and the Ctrl+R toggle. A user who wants a specific mode must cycle through the others, cannot set both dimensions in one action, and has no way to query the current state; a terminal that swallows those control keys has no fallback at all. + +## Decision + +`dsh-tui` registers `/details` beside its other agent-scoped commands. Bare `/details` opens `DetailsDialog`, a centered keyboard toggle with one entry per dimension — `Tool cards` and `Reasoning` — showing the live values: Tab cycles the highlighted entry and applies the change immediately, so the transcript behind the dialog is the preview, and Enter, Esc, or Ctrl+C closes; its width is the `detailsDialogWidth` config key and a second `/details` replaces an open selector, mirroring the `/model` overlay. Arguments name target states directly: `collapsed|expanded|hidden` jumps tool cards to that phase, `reasoning on|off` sets reasoning display, bare `reasoning` toggles it, and directives combine in one invocation. An unknown token returns a command error carrying the usage line. Every entry mutates the same closure state as the shortcuts, refactored so the cycle and toggle are thin wrappers over `setToolsVisibility`/`setReasoning`; the shortcuts and their notices are unchanged. + +A combined invocation applies reasoning before visibility because `setReasoning` rebuilds the transcript from session events, which drops non-durable notice components; applying it last would erase the just-appended visibility notice. + +The reasoning rebuild exposed a replay defect that this change fixes in `renderEvent`: the live path cleared a settled `StreamingAssistantComponent` before a later `assistant/message` of the same step (so the second message got a fresh component), but `rebuildTranscript` replay reused the settled component and `settle()` overwrote its content, silently dropping the earlier message's text. The settled check now lives in `renderEvent`'s `assistant/message` case — one home for both paths — and the previously wrong `untrusted-controls` snapshot (an empty `Assistant` header where reasoning and text had been dropped) was re-recorded with the content present. + +## Alternatives considered + +**Cycle on bare `/details`, mirroring Ctrl+O.** Rejected: the command's value over the shortcut is naming an absolute state; a cycling command is the shortcut with more keystrokes, and bare invocation is more useful as the selector, which shows the current state while offering every target. + +**Bare `/details` as a text-only state report.** Shipped first, replaced by the selector: the report answered "where am I" but still required a second, argument-spelling invocation to change anything, while the selector shows the same state and applies a change in one interaction. The textual grammar remains for scripts, muscle memory, and combined two-dimension changes. + +**Separate `/tools` and `/reasoning` commands.** Rejected: both dimensions are one presentation concern ("how much detail does the transcript show"), and a single command keeps the registry and `/help` list small while allowing one combined invocation. + +**Config-key defaults per mode.** Out of scope: `showReasoning` already exists as config; the command is runtime state on top of it, matching the shortcuts. + +## Consequences + +- A user can jump to any detail mode, set both dimensions at once, and see the current state in the selector — including on terminals that intercept Ctrl+O/Ctrl+R. +- The parser accepts order-free tokens, so `/details reasoning expanded` toggles reasoning and expands cards; last directive wins per dimension. This leniency is deliberate and documented in the README. +- The selector has no pending state or cancel: every Tab is a real, already-notified change, and closing never reverts. A user who over-cycles simply Tabs on to the wanted value. +- Transcript rebuilds no longer lose assistant messages when a step carries more than one `assistant/message` event; the `details-command` snapshot pins the argument surface and the fixed replay, and `details-selector` pins the open toggle right after a Tab applied `hidden` -> `collapsed`, including the restored tool card behind it. diff --git a/.agents/notes/implemented/feature/2026-07-30-tui-details-command.zh.md b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.zh.md new file mode 100644 index 0000000000..5f9e033311 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.zh.md @@ -0,0 +1,34 @@ +# Agent Note: 用于 transcript 细节状态的 /details 命令 + +Status: implemented + +[English](2026-07-30-tui-details-command.md) | 中文 + +## Problem + +TUI 的 transcript(文本记录)细节状态——工具卡片可见性(`collapsed`/`expanded`/`hidden`,见[整合的 TUI 展示](../architecture/2026-07-28-consolidated-tui-presentation.md))与 reasoning 块显示——过去只能通过 Ctrl+O 循环和 Ctrl+R 切换来触达。想要某个特定模式的用户必须循环经过其他模式,无法一次操作同时设置两个维度,也无法查询当前状态;吞掉这些控制键的终端更是完全没有替代途径。 + +## Decision + +`dsh-tui` 在其他 agent 作用域命令旁注册 `/details`。裸 `/details` 打开 `DetailsDialog`:一个居中的键盘开关,每个维度一个条目——`Tool cards` 与 `Reasoning`——显示实时值:Tab 循环高亮条目并立即应用变更,对话框背后的 transcript 即是预览,Enter、Esc 或 Ctrl+C 关闭;其宽度由配置键 `detailsDialogWidth` 决定,选择器打开时再次执行 `/details` 会替换它,与 `/model` 浮层一致。参数直接命名目标状态:`collapsed|expanded|hidden` 让工具卡片跳到该阶段,`reasoning on|off` 设置 reasoning 显示,裸 `reasoning` 切换它,且指令可在一次调用中组合。未知 token 返回携带用法行的命令错误。每个入口改动的都是与快捷键相同的闭包状态,重构后循环与切换成为 `setToolsVisibility`/`setReasoning` 之上的薄封装;快捷键及其通知保持不变。 + +组合调用先应用 reasoning 再应用可见性,因为 `setReasoning` 会从会话事件重建 transcript,而重建会丢弃非持久的通知组件;若最后才应用它,会抹掉刚追加的可见性通知。 + +reasoning 重建暴露了一个重放缺陷,本变更在 `renderEvent` 中修复:实时路径会在同一步骤的后续 `assistant/message` 之前清除已结算的 `StreamingAssistantComponent`(因此第二条消息获得新组件),但 `rebuildTranscript` 重放复用了已结算组件,`settle()` 覆盖其内容,静默丢掉了前一条消息的文本。已结算检查现在位于 `renderEvent` 的 `assistant/message` 分支——两条路径共用一个归属地——此前错误的 `untrusted-controls` 快照(reasoning 与文本被丢弃后只剩空 `Assistant` 标题)已重录为包含内容的版本。 + +## Alternatives considered + +**裸 `/details` 像 Ctrl+O 一样循环。** 否决:命令相对快捷键的价值在于命名绝对状态;循环命令只是按键更多的快捷键,裸调用作为选择器更有用——它在展示当前状态的同时提供所有目标。 + +**裸 `/details` 仅输出文本状态报告。** 首版如此实现,后被选择器取代:报告回答了“我在哪”,但改变任何东西仍需第二次、拼写参数的调用;选择器展示同样的状态并在一次交互中应用变更。文本语法保留给脚本、肌肉记忆和两维组合变更。 + +**拆分 `/tools` 与 `/reasoning` 两个命令。** 否决:两个维度同属一个展示关注点(“transcript 显示多少细节”),单一命令让注册表与 `/help` 列表更小,同时允许一次组合调用。 + +**按模式提供配置键默认值。** 超出范围:`showReasoning` 已作为配置存在;命令是其上的运行时状态,与快捷键一致。 + +## Consequences + +- 用户可以跳到任意细节模式、一次设置两个维度,并在选择器中看到当前状态——包括在拦截 Ctrl+O/Ctrl+R 的终端上。 +- 解析器接受无序 token,因此 `/details reasoning expanded` 会切换 reasoning 并展开卡片;每个维度以最后一个指令为准。这一宽松是刻意的,并记录在 README 中。 +- 选择器没有待定状态与取消:每次 Tab 都是已生效、已通知的真实变更,关闭从不回退。循环过头的用户继续 Tab 到想要的值即可。 +- 当一个步骤携带多条 `assistant/message` 事件时,transcript 重建不再丢失 assistant 消息;`details-command` 快照固定参数表面与修复后的重放,`details-selector` 固定 Tab 将 `hidden` 应用为 `collapsed` 后仍打开的开关,包括其背后恢复显示的工具卡片。 diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.i18n.yaml index 579586f30a..10de05ea42 100644 --- a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md -2026-07-30-versioned-gui-welcome-onboarding.md: 8155838f3b6b50f3474ef6c30065ad0d79e6f8af -2026-07-30-versioned-gui-welcome-onboarding.zh.md: c221a6d663af60b03757f135045961bcbcdd0da7 +2026-07-30-versioned-gui-welcome-onboarding.md: 4707769d4fa9fbf184e09a2e73087dfd326070be +2026-07-30-versioned-gui-welcome-onboarding.zh.md: c9d2e6274c476c59abc077aa3255ffe57a8a48bc diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md index 8155838f3b..4707769d4f 100644 --- a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md +++ b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md @@ -14,15 +14,15 @@ The GUI's credential onboarding begins with a DeepSeek-specific readiness check, **Ownerless product onboarding belongs to `ui-settings-general`.** `src/onboarding-copy.ts` is the single editable source for the complete notice, the Continue label, and `WELCOME_NOTICE_VERSION`; both supported GUI locales intentionally render the same Chinese owner copy. Runtime locale dictionaries derive their welcome values from that file, and tests import the same owner instead of repeating paragraph text. The notice is browser UI only: it creates no Session event and contributes no model-visible content. The notice identifies `DSH_TELEMETRY_DISABLED=1` as the telemetry opt-out. -**Acknowledgement is durable per Harness profile.** The Host half registers a `ui-onboarding` section in the user-settings seam, stored under the active `$DSH_HOME/settings.yaml`. The browser shows the notice unless `welcomeNoticeVersion` equals the owner constant exactly. Continue applies one path mutation with the current version and calls `complete()` only after the Host commits it; a failed write leaves the notice open, and closing the page or process writes nothing. Bumping the constant intentionally makes every profile acknowledge the revised copy once. +**Loopback acknowledgement is durable per Harness profile.** The Host half registers a `ui-onboarding` section in the user-settings seam, stored under the active `$DSH_HOME/settings.yaml`. The connection plugin publishes whether the current page uses a loopback authority as `ctx.connection.isLoopback`; hostname classification remains internal to the connection package, and other client plugins consume the service state instead of importing its implementation. A loopback browser shows the notice unless `welcomeNoticeVersion` equals the owner constant exactly. Continue applies one path mutation with the current version and calls `complete()` only after the Host commits it; a failed write leaves the notice open, and closing the page or process writes nothing. Bumping the constant intentionally makes every profile acknowledge the revised copy once. A non-loopback browser must not call the loopback-only settings API. It presents the same notice, but explicit Continue completes the step only in the current browser process; reload or a new process presents it again. -**Concurrent views converge without stale replacement.** The acknowledgement write omits `expectedRevision` deliberately: every tab writes the same version to one path, so the operation is idempotent and preserves sibling fields instead of rebuilding the section. `settings/document-updated` becomes `host/settings-changed`; an already mounted tab refetches and advances when another tab or an external editor commits the current version. The API proxy exposes this one product namespace through a closed allowlist beside configurable-provider namespaces, without treating its changes as model-catalog invalidations. +**Concurrent loopback views converge without stale replacement.** The acknowledgement write omits `expectedRevision` deliberately: every loopback tab writes the same version to one path, so the operation is idempotent and preserves sibling fields instead of rebuilding the section. `settings/document-updated` becomes `host/settings-changed`; an already mounted loopback tab refetches and advances when another tab or an external editor commits the current version. The API proxy exposes this one product namespace through a closed allowlist beside configurable-provider namespaces, without treating its changes as model-catalog invalidations. **Onboarding temporarily owns the viewport as one continuous stage.** A solid product surface replaces the complete application view through a body-level portal and marks the underlying app root inert; the exact required mask remains mounted behind that surface with `position:absolute`, zero left/right/bottom offsets, `top:80px`, `rgba(0, 0, 0, 0.24)`, and `backdrop-filter: blur(2px)`. Welcome and conditional credential setup render as successive pages in this stage instead of independent modals. Both pages reuse the Web UI's black `BrandWordmark`. The welcome page preserves the four authored paragraphs verbatim under the `内测声明` title; every paragraph uses one 16/28 body scale, and only the requested action clause inside the final paragraph receives a subtle 500 weight. A short staggered opacity/vertical entrance supplies pacing without blocking interaction and disappears under reduced motion. The title receives initial focus, Continue is the sole button, and no close, Escape, or mask-click path exists. ## Alternatives considered -**Browser local storage** — rejected because acknowledgement would follow one browser profile rather than `$DSH_HOME`; a fresh Harness profile could incorrectly inherit a prior acknowledgement, and external profile edits would have no authoritative update stream. +**Browser local storage** — rejected because acknowledgement would follow one browser profile rather than `$DSH_HOME`; a fresh Harness profile could incorrectly inherit a prior acknowledgement, and external profile edits would have no authoritative update stream. Non-loopback fallback therefore remains process-local rather than browser-profile-local. **A second independent modal in `ui-settings-general`** — rejected because list registrants would still stack whenever welcome and credential readiness were both true. Ordered ownership belongs to the shell that declares and renders the list. @@ -32,4 +32,4 @@ The GUI's credential onboarding begins with a DeepSeek-specific readiness check, ## Consequences -A fresh profile always sees the welcome notice before provider-specific onboarding; an already configured credential skips only the later DeepSeek step. Reloading after Continue stays past the acknowledged version, changing the owner version presents it again, and closing before Continue leaves the next launch unchanged. Focused store and React tests pin exact-version comparison, write failure, sole-action behavior, no-dismiss paths, coordinator ordering, conditional DeepSeek transfer, and HMR cleanup. The real Chromium scenario boots the shipped Web composition with an isolated harness home, verifies the exact mask geometry and computed styles, reloads before and after acknowledgement, continues into missing-credential setup, confirms an acknowledged-version mismatch returns while the credential is configured, and checks the browser console. +A fresh profile always sees the welcome notice before provider-specific onboarding; an already configured credential skips only the later DeepSeek step. On loopback, reloading after Continue stays past the acknowledged version, changing the owner version presents it again, and closing before Continue leaves the next launch unchanged. On non-loopback, Continue advances the live process without a privileged settings request and reload presents the notice again. Focused store and React tests pin both persistence modes, exact-version comparison, write failure, sole-action behavior, no-dismiss paths, coordinator ordering, conditional DeepSeek transfer, and HMR cleanup. The real Chromium scenario boots the shipped Web composition with an isolated harness home, verifies the exact mask geometry and computed styles, reloads before and after acknowledgement, continues into missing-credential setup, confirms an acknowledged-version mismatch returns while the credential is configured, and checks the browser console. diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.zh.md b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.zh.md index c221a6d663..c9d2e6274c 100644 --- a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.zh.md @@ -14,15 +14,15 @@ GUI 的凭据引导从 DeepSeek 专用的就绪状态检查开始,但内部测 **不属于单一功能的产品引导由 `ui-settings-general` 持有。** `src/onboarding-copy.ts` 是完整通知、「继续」按钮文案和 `WELCOME_NOTICE_VERSION` 的唯一可编辑来源;GUI 支持的两种 locale 都有意渲染同一份中文所有者文案。运行时 locale 字典从该文件派生欢迎文案,测试也导入同一个所有者,而不重复段落文本。该通知只存在于浏览器 UI:它不会创建会话事件,也不会贡献任何模型可见内容。通知明确以 `DSH_TELEMETRY_DISABLED=1` 作为遥测关闭方式。 -**确认状态按 Harness profile 持久化。** 宿主端在 user-settings seam 中注册 `ui-onboarding` 分节,并存入当前 `$DSH_HOME/settings.yaml`。除非 `welcomeNoticeVersion` 与文案所有者文件中的常量精确相等,否则浏览器会显示通知。「继续」会以当前版本执行一次路径变更,并且仅在宿主端提交成功后调用 `complete()`;写入失败时通知保持打开,关闭页面或进程则不会写入任何内容。提升该常量会有意要求每个 profile 对修订后的文案重新确认一次。 +**loopback 确认状态按 Harness profile 持久化。** 宿主端在 user-settings seam 中注册 `ui-onboarding` 分节,并存入当前 `$DSH_HOME/settings.yaml`。connection 插件通过 `ctx.connection.isLoopback` 统一发布当前页面是否使用 loopback authority;hostname 判定函数留在 connection 包内,其他客户端插件只消费服务状态,不跨插件导入实现函数。除非 `welcomeNoticeVersion` 与文案所有者文件中的常量精确相等,否则 loopback 浏览器会显示通知。「继续」会以当前版本执行一次路径变更,并且仅在宿主端提交成功后调用 `complete()`;写入失败时通知保持打开,关闭页面或进程则不会写入任何内容。提升该常量会有意要求每个 profile 对修订后的文案重新确认一次。非 loopback 浏览器不能调用仅限 loopback 的 settings API;它仍显示同一通知,但显式点击「继续」只会在当前浏览器进程中完成该步骤,重新加载或新进程会再次显示通知。 -**并发视图无需陈旧的整体替换即可收敛。** 确认写入有意省略 `expectedRevision`:每个标签页都向同一路径写入相同版本,因此该操作是幂等的,并会保留同级字段,而不是重建整个分节。`settings/document-updated` 会转为 `host/settings-changed`;另一个标签页或外部编辑器提交当前版本后,已挂载的标签页会重新拉取状态并推进。API 网关在可配置提供方 namespace 之外,通过封闭的允许列表暴露这一个产品 namespace,同时不会把它的变更视为模型目录失效事件。 +**并发 loopback 视图无需陈旧的整体替换即可收敛。** 确认写入有意省略 `expectedRevision`:每个 loopback 标签页都向同一路径写入相同版本,因此该操作是幂等的,并会保留同级字段,而不是重建整个分节。`settings/document-updated` 会转为 `host/settings-changed`;另一个标签页或外部编辑器提交当前版本后,已挂载的 loopback 标签页会重新拉取状态并推进。API 网关在可配置提供方 namespace 之外,通过封闭的允许列表暴露这一个产品 namespace,同时不会把它的变更视为模型目录失效事件。 **引导流程会暂时接管视口,形成一个连续阶段。** 纯色产品界面通过挂载到 `body` 的 portal 取代完整的应用视图,并将底层应用根节点标记为 inert;严格符合要求的遮罩仍挂载在该界面后方,并保留 `position:absolute`、left/right/bottom 偏移量为零、`top:80px`、`rgba(0, 0, 0, 0.24)` 和 `backdrop-filter: blur(2px)`。欢迎页和按条件显示的凭据设置页在这一阶段中依次呈现,而不是各自作为独立的模态窗口。两个页面都复用 Web UI 的黑色 `BrandWordmark`。欢迎页在 `内测声明` 标题下逐字保留既定的四段文案;所有段落统一采用 16/28 的正文字号与行高,只有最后一段中指定的行动语句使用较为克制的 500 字重。短暂的错落式透明度与纵向位移动画营造出舒缓节奏,但不会阻碍交互,并会在用户启用减少动态效果时禁用。初始焦点落在标题上,「继续」是唯一按钮,且不存在关闭、Escape 或点击遮罩的退出路径。 ## 曾考虑的替代方案 -**浏览器本地存储**:不予采用,因为确认状态会跟随某个浏览器 profile,而不是 `$DSH_HOME`;全新的 Harness profile 可能错误继承此前的确认状态,外部 profile 编辑也没有权威更新流。 +**浏览器本地存储**:不予采用,因为确认状态会跟随某个浏览器 profile,而不是 `$DSH_HOME`;全新的 Harness profile 可能错误继承此前的确认状态,外部 profile 编辑也没有权威更新流。因此,非 loopback 的回退保持为进程内状态,而不是浏览器 profile 状态。 **在 `ui-settings-general` 中再增加一个独立模态窗口**:不予采用,因为欢迎通知和凭据就绪状态同时为真时,list 注册方仍会堆叠。声明并渲染该 list 的外壳应当持有有序所有权。 @@ -32,4 +32,4 @@ GUI 的凭据引导从 DeepSeek 专用的就绪状态检查开始,但内部测 ## 后果 -全新 profile 始终会在提供方专用引导之前看到欢迎通知;凭据已经配置时,只会跳过后续 DeepSeek 步骤。点击「继续」后重新加载不会再次显示已确认版本,更改文案所有者文件中的版本值会让通知重新出现,而确认前关闭窗口不会改变下次启动。针对性的 store 与 React 测试固化了精确版本比较、写入失败、单一操作、不可关闭路径、协调器顺序、按条件移交 DeepSeek 步骤和 HMR(热模块替换)清理行为。真实 Chromium 场景会使用隔离的 harness 家目录启动随产品提供的 Web 组合,验证遮罩的精确几何尺寸和计算样式,在确认前后分别重新加载,继续进入凭据缺失设置流程,确认凭据已配置时确认版本不匹配仍会使通知重新出现,并检查浏览器控制台。 +全新 profile 始终会在提供方专用引导之前看到欢迎通知;凭据已经配置时,只会跳过后续 DeepSeek 步骤。在 loopback 上,点击「继续」后重新加载不会再次显示已确认版本,更改文案所有者文件中的版本值会让通知重新出现,而确认前关闭窗口不会改变下次启动。在非 loopback 上,「继续」会在不发起受保护 settings 请求的情况下推进当前进程,重新加载则再次显示通知。针对性的 store 与 React 测试固化了两种持久化模式、精确版本比较、写入失败、单一操作、不可关闭路径、协调器顺序、按条件移交 DeepSeek 步骤和 HMR(热模块替换)清理行为。真实 Chromium 场景会使用隔离的 harness 家目录启动随产品提供的 Web 组合,验证遮罩的精确几何尺寸和计算样式,在确认前后分别重新加载,继续进入凭据缺失设置流程,确认凭据已配置时确认版本不匹配仍会使通知重新出现,并检查浏览器控制台。 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.i18n.yaml index f9b2cfa908..388a8de9cd 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-diff-card.md -2026-07-30-web-diff-card.md: 396bdbc2843c1bbed5c6a913be436d8b9e96a81c -2026-07-30-web-diff-card.zh.md: afdeafa6e94b46b4f0fbd4a065afdac8a93ac57d +2026-07-30-web-diff-card.md: eb43e09d6173ca2270df97cecaab6da36c70a679 +2026-07-30-web-diff-card.zh.md: 669cd49abc8eba8637705cd7c9f331cc51607755 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.md b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.md index 396bdbc284..eb43e09d61 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.md @@ -16,13 +16,13 @@ This is the [terminal card](2026-07-28-web-terminal-card.md) done for the `diff` `DiffBlock` is a `ui-primitives` component that renders a file mutation as an inline diff surface, and both Web render sites for a write/edit call consume the diff render intent through it: the chat tool row's body and the details panel's Output section. `ui-conversation/src/client/contract/diff-card-model.ts` is the single place that turns the snapshot's `callView`/`resultView` pair into the component's props, so the two sites cannot disagree about a change. It returns null — the generic path — whenever neither side declares `card: 'diff'`, including a `card` value this client version does not know, and whenever a settled call's result view is generic, which is how write/edit keep their execution errors on the generic path. The result side is authoritative once the call settles: the applied hunks replace the call-time diff derived from the arguments alone. A paging window that drops the call head still renders, because the result view carries the whole change. -The component's contract follows the TUI's `diffLines` (`packages/ui/tui/src/components/transcript.ts`) so a diff reads the same shape across front ends: +The component shares the TUI's single-column framing, line-terminator rule, and distinct-path file count. Line classification differs: Web renders the complete old and new sides, while the TUI derives neutral context and exact changed rows when its bounded comparison completes and labels its whole-side fallback approximate. -- **One path header per file.** A new file opens a bold path header; a same-file second hunk (a scattered edit, or a `replace_all`) opens with a `⋯` gap instead of repeating the path. The `N file(s)` footer counts DISTINCT paths on both front ends — this PR moved the TUI footer off `diffs.length` onto the distinct-path count, so two hunks in one file read as `1 file` in both. -- **The change in the diff's own colors.** A removed line is `- ` on the error token, an added line is `+ ` on the success token, drawn verbatim with `white-space: pre` inside a horizontally scrolling box — a source line is read by its indentation, so it scrolls rather than folds. A create (`oldText: null`) has no removed side. +- **Path grouping.** A new file opens a bold path header; a same-file second hunk (a scattered edit, or a `replace_all`) opens with a `⋯` gap instead of repeating the path. The TUI keeps a path header on every hunk, but both front ends count distinct paths in the `N file(s)` footer, so two hunks in one file read as `1 file`. +- **Whole-side change colors.** Every old-side line is `- ` on the error token and every new-side line is `+ ` on the success token, drawn verbatim with `white-space: pre` inside a horizontally scrolling box — a source line is read by its indentation, so it scrolls rather than folds. A create (`oldText: null`) has no removed side. - **Height cap with an expand control.** A diff longer than `DEFAULT_DIFF_MAX_LINES` (16) shows `ceil(max/2)` head rows plus the remaining tail rows, with a button between reporting the hidden count. The split arithmetic matches `TerminalBlock` and the TUI's collapsed card, so a long diff's head and tail slices agree across front ends. -- **Line terminator.** A side's content splits on `\n` under the terminator rule `TerminalBlock` uses: empty text is zero lines (a full deletion's `newText`, a create's absent `oldText` side), a single trailing newline terminates its last line rather than adding a phantom empty one, and an interior blank line survives. This PR applied the same rule to the TUI diff branch, so the `+A -R` footer counts agree on both front ends for the newline-terminated content real write/edit calls carry. -- **Footer and copy.** A dim `└ +A -R · N file(s)` footer summarizes the change; `+A -R` are the added/removed line counts, the same per-side counts the TUI footer draws. The copy control copies the prefixed diff text (path headers, `- `/`+ ` lines, the `⋯` gap), so a multi-file copy stays attributable. +- **Line terminator.** A side's content splits on `\n` under the terminator rule `TerminalBlock` and the TUI use: empty text is zero lines (a full deletion's `newText`, a create's absent `oldText` side), a single trailing newline terminates its last line rather than adding a phantom empty one, and an interior blank line survives. +- **Footer and copy.** A dim `└ +A -R · N file(s)` footer reports the Web card's complete new- and old-side line counts. The TUI footer instead reports exact changed rows when available and marks a bounded whole-side fallback approximate; both use the same distinct-path file count. The copy control copies the prefixed Web diff text (path headers, `- `/`+ ` lines, the `⋯` gap), so a multi-file copy stays attributable. Geometry, radius, and fonts mirror `CodeBlock`/`TerminalBlock` so a diff card, a terminal card, and a fenced block read as one family; `white-space: pre` plus horizontal scroll is the deliberate divergence. The copy control floats in the card's top-right corner rather than on a banner row of its own, because a banner carrying only a copy button drew an empty band above the first diff line — the TUI diff card has no banner either, only the footer. diff --git a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md index afdeafa6e9..669cd49abc 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md @@ -16,13 +16,13 @@ Web 客户端忽略了它。write/edit 调用落到 `GenericToolCard`,其行 `DiffBlock` 是一个 `ui-primitives` 组件,把文件改动渲染为内联 diff 表面,write/edit 调用的两个 Web 渲染点都通过它消费 diff 渲染意图:chat 工具行的行体和详情面板的 Output 区。`ui-conversation/src/client/contract/diff-card-model.ts` 是唯一把快照的 `callView`/`resultView` 对转成组件 props 的地方,因此两个渲染点不会对一次改动产生分歧。当两侧都未声明 `card: 'diff'` 时它返回 null —— 走通用路径 —— 包括本客户端版本不认识的 `card` 值,以及已结算调用的 result view 是 generic 的情况(write/edit 的执行错误正是这样留在通用路径上的)。调用结算后 result 侧是权威:已应用的 hunk 替换仅从参数推导的 call 时 diff。分页窗口丢弃了 call 头也仍能渲染,因为 result view 携带完整改动。 -组件的契约遵循 TUI 的 `diffLines`(`packages/ui/tui/src/components/transcript.ts`),使 diff 在两个前端读起来是同一形态: +该组件与 TUI 共用单栏框架、行终止符规则和去重路径计数。两者的行分类不同:Web 渲染完整的变更前后两侧,而 TUI 会在有界比较完成时派生中性上下文和精确变更行,并把整侧回退标记为近似结果。 -- **每个文件一个路径头。** 新文件开启一个粗体路径头;同文件的第二个 hunk(分散编辑,或 `replace_all`)以一个 `⋯` gap 开启,而非重复路径。`N file(s)` 页脚在两个前端都统计**去重后的路径数** —— 本 PR 把 TUI 页脚从 `diffs.length` 改为去重路径计数,因此同文件两个 hunk 在两端都读作 `1 file`。 -- **改动用 diff 自身的颜色。** 删除行是 error token 上的 `- `,新增行是 success token 上的 `+ `,在横向滚动的盒子里以 `white-space: pre` 逐字绘制 —— 源码行靠缩进阅读,所以滚动而不折行。新建(`oldText: null`)没有删除侧。 +- **路径分组。** 新文件开启一个粗体路径头;同文件的第二个 hunk(分散编辑,或 `replace_all`)以一个 `⋯` gap 开启,而非重复路径。TUI 在每个 hunk 上都保留路径头,但两个前端的 `N file(s)` 页脚都按去重路径计数,因此同文件两个 hunk 在两端都读作 `1 file`。 +- **整侧改动配色。** 旧侧每一行都以 error token 上的 `- ` 显示,新侧每一行都以 success token 上的 `+ ` 显示,并在横向滚动的盒子里以 `white-space: pre` 逐字绘制:源码行靠缩进阅读,因此滚动而不折行。新建(`oldText: null`)没有删除侧。 - **高度上限带展开控件。** 长于 `DEFAULT_DIFF_MAX_LINES`(16)的 diff 显示 `ceil(max/2)` 个头部行加剩余尾部行,中间一个按钮报告隐藏行数。分割算术与 `TerminalBlock` 和 TUI 的折叠卡片一致,因此长 diff 的头尾切片在两个前端一致。 -- **行终止符。** 每一侧的内容按 `TerminalBlock` 的终止符规则在 `\n` 上切分:空文本是零行(整文件删除的 `newText`、新建缺失的 `oldText` 侧),单个结尾换行终止其最后一行而非新增一条幻影空行,内部空行保留。本 PR 把同一规则应用到了 TUI diff 分支,因此对于真实 write/edit 调用携带的以换行结尾的内容,两个前端的 `+A -R` 页脚计数一致。 -- **页脚与复制。** 暗色 `└ +A -R · N file(s)` 页脚概括改动;`+A -R` 是新增/删除行数,与 TUI 页脚绘制的每侧计数相同。复制控件复制带前缀的 diff 文本(路径头、`- `/`+ ` 行、`⋯` gap),使多文件复制保持可归属。 +- **行终止符。** 每一侧的内容按 `TerminalBlock` 与 TUI 共用的终止符规则在 `\n` 上切分:空文本是零行(整文件删除的 `newText`、新建缺失的 `oldText` 侧),单个结尾换行终止其最后一行而非新增一条幻影空行,内部空行保留。 +- **页脚与复制。** 暗色 `└ +A -R · N file(s)` 页脚报告 Web 卡片完整新侧与旧侧的行数。TUI 页脚则在可用时报告精确变更行数,并把有界整侧回退标记为近似结果;两者使用相同的去重路径计数。复制控件复制带前缀的 Web diff 文本(路径头、`- `/`+ ` 行、`⋯` gap),使多文件复制保持可辨别归属。 几何、圆角、字体镜像 `CodeBlock`/`TerminalBlock`,使 diff 卡片、terminal 卡片、代码块读起来是一家;`white-space: pre` 加横向滚动是刻意的分歧。复制控件浮在卡片右上角,而非占据自己的 banner 行,因为只放一个复制按钮的 banner 会在第一行 diff 上方画出一条空带 —— TUI 的 diff 卡片也没有 banner,只有页脚。 @@ -32,7 +32,7 @@ chat 行把 diff 常驻渲染在路径链接摘要之下,上限 `CHAT_DIFF_MAX **并排(双栏)diff。** owner 目前拒绝:它更密但不适合狭窄的 chat 行,目标是与 TUI 单栏统一形式对齐。详情面板里的双栏模式是后续的 props 改动,不是重设计。 -**git 式行号槽。** `FileDiff` 契约只携带 `{ path, oldText, newText }` —— `structuredPatch` 的 hunk 起始行在 `diff.ts` 里被丢弃,所以没有行号抵达客户端。渲染行号槽需要后端契约改动(携带 `oldStart`/`newStart`)并同步升级 TUI 以保持一致;推迟,使本 PR 保持为对既有契约的纯 Web 消费。 +**git 式行号槽。** `FileDiff` 契约只携带 `{ path, oldText, newText }` —— `structuredPatch` 的 hunk 起始行在 `diff.ts` 里被丢弃,所以没有行号抵达客户端。渲染行号槽需要后端契约改动(携带 `oldStart`/`newStart`)并同步升级 TUI 以保持一致;推迟,使本 PR(Pull Request)保持为对既有契约的纯 Web 消费。 **复用 `CodeBlock`。** 因与 terminal 卡片相同的理由拒绝:`CodeBlock` 会折行,且没有每行 `+`/`-` 角色、没有路径头、没有页脚。两者共享几何与字体 token,那是唯一一处一个实现对两者都正确的部分。 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-remote-markdown-images.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-remote-markdown-images.i18n.yaml new file mode 100644 index 0000000000..afe776d402 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-web-remote-markdown-images.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-remote-markdown-images.md +2026-07-30-web-remote-markdown-images.md: 23dc699aab87597b7b5bfee83d0d745d4798303e +2026-07-30-web-remote-markdown-images.zh.md: 54db9b25e9f558d77c84bb67c05fadea2c9086ac diff --git a/.agents/notes/implemented/feature/2026-07-30-web-remote-markdown-images.md b/.agents/notes/implemented/feature/2026-07-30-web-remote-markdown-images.md new file mode 100644 index 0000000000..23dc699aab --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-web-remote-markdown-images.md @@ -0,0 +1,29 @@ +# Agent Note: Remote Web Markdown images + +Status: implemented + +English | [中文](2026-07-30-web-remote-markdown-images.zh.md) + +## Problem + +Assistant Markdown can name diagrams and screenshots with standard image syntax, but the Web renderer replaces every image with italic alt text. Even absolute HTTP(S) destinations therefore lose ordinary Markdown behavior. + +## Decision + +`MarkdownText` renders absolute HTTP(S) image destinations as lazy, responsive `` elements with asynchronous decoding and `referrerPolicy="no-referrer"`. Relative paths, absolute local paths, `file:` URLs, and unsupported schemes retain the existing alt-text fallback. Raw HTML stays disabled, so an assistant cannot bypass the Markdown image component with a hand-authored ``. + +The image component reuses the renderer's absolute-URL policy without adding a host proxy, local-file route, Session dependency, sanitizer, or image fetcher. Finalized history, streaming output, interrupted partials, and every other `MarkdownText` consumer receive the same behavior. + +## Alternatives considered + +**Keep all images as alt text.** This preserves the smallest network boundary but defeats the product need to inspect network-hosted visual artifacts inline. + +**Proxy remote images through the host.** A proxy could hide the browser's network address from the image origin, but it would make the host perform arbitrary outbound fetches and require a separate redirect, DNS, size, and content policy. Direct HTTP(S) loading keeps that request visible to browser controls; omitting the referrer limits conversation-origin disclosure. + +**Support local paths in the same change.** Web origins cannot directly load host files. A safe implementation needs a separately reviewed authority boundary, so relative paths, absolute local paths, and `file:` URLs remain disabled. + +**Allow `data:` images.** Large data URLs duplicate binary content into durable transcript text. The HTTP(S)-only policy covers the current need without expanding session logs. + +## Consequences + +Assistant replies display remote images during streaming and replay without changing session events or host protocols. Remote origins still observe the image request, client network address, and any credentials that browser policy permits for that origin. Local and unsupported destinations remain inert alt text. diff --git a/.agents/notes/implemented/feature/2026-07-30-web-remote-markdown-images.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-remote-markdown-images.zh.md new file mode 100644 index 0000000000..54db9b25e9 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-web-remote-markdown-images.zh.md @@ -0,0 +1,29 @@ +# Agent Note: Web 中的远程 Markdown 图片 + +Status: implemented + +[English](2026-07-30-web-remote-markdown-images.md) | 中文 + +## 问题 + +assistant Markdown 可以使用标准图片语法引用图表和截图,但 Web 渲染器会把每张图片替换为斜体替代文本。因此,即使目标地址是绝对 HTTP(S) URL,也无法获得普通的 Markdown 图片行为。 + +## 决策 + +`MarkdownText` 将绝对 HTTP(S) 图片目标地址渲染为延迟加载的响应式 `` 元素,并使用异步解码与 `referrerPolicy="no-referrer"`。相对路径、绝对本地路径、`file:` URL 与不支持的协议继续沿用现有的替代文本回退。原始 HTML 保持禁用,因此 assistant 无法通过手写 `` 绕过 Markdown 图片组件。 + +图片组件复用渲染器的绝对 URL 策略,不新增主机代理、本地文件路由、Session 依赖、净化器或图片抓取器。已完成的历史消息、流式输出、被中断的部分输出以及其他所有 `MarkdownText` 消费方均获得同一行为。 + +## 考虑过的替代方案 + +**将所有图片都保留为替代文本。** 这种方案维持了最小的网络边界,但无法满足在行内查看网络托管的视觉产物这一产品需求。 + +**通过主机代理远程图片。** 代理可以向图片源站隐藏浏览器的网络地址,但这会让主机执行任意出站请求,并且需要单独制定重定向、DNS、大小与内容策略。直接加载 HTTP(S) 图片可让浏览器控制机制继续观察该请求;不发送 referrer 可减少对话来源信息的暴露。 + +**在同一变更中支持本地路径。** Web 源无法直接加载主机文件。安全的实现需要单独评审的权限边界,因此相对路径、绝对本地路径与 `file:` URL 保持禁用。 + +**允许 `data:` 图片。** 大型 data URL 会将二进制内容以文本形式重复写入持久化的 transcript(文本记录)。仅允许 HTTP(S) 的策略足以满足当前需求,且不会扩大会话日志。 + +## 后果 + +assistant 回复会在流式输出与回放期间显示远程图片,且不改变会话事件或主机协议。远程源站仍可观察到图片请求、客户端网络地址,以及浏览器策略允许发送给该源站的任何凭据。本地及不支持的目标地址仍只显示不会发起请求的替代文本。 diff --git a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.i18n.yaml index ad2e2c40bd..f0587a9b39 100644 --- a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.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/simplification/2026-07-27-intent-named-subagent-continuation-operations.md -2026-07-27-intent-named-subagent-continuation-operations.md: 5029d8335f699e99e67c6027b7d1666880db4724 -2026-07-27-intent-named-subagent-continuation-operations.zh.md: 0785730c1934a192380af41f3ad88f95a2747cf7 +2026-07-27-intent-named-subagent-continuation-operations.md: e74d62b7582e92f8e5ce68327a677259c8453d24 +2026-07-27-intent-named-subagent-continuation-operations.zh.md: dae4dd37fa9950f0b8d1ba6ec5c46b99977a7201 diff --git a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md index 5029d8335f..e74d62b758 100644 --- a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md +++ b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md @@ -4,7 +4,7 @@ Status: implemented English | [中文](2026-07-27-intent-named-subagent-continuation-operations.zh.md) -The `followup` operation this record names is retained by [Continuable subagents](../feature/2026-07-28-continuable-subagent-conversations.md), which replaces its Task-backed return value with the accepted `MessageId`, retains its bare `Agent` parameter as exact live-direct-parent authority, and replaces provider `resume` dispatch with `prepareContinuable`. +The current activation-based realization is owned by [Continuable subagents](../feature/2026-07-28-continuable-subagent-conversations.md). It retains the `followup` operation this record names, returns the accepted `MessageId`, uses the bare `Agent` parameter as exact live-direct-parent authority, and limits provider participation in continuable children to `prepareContinuable`. ## Problem @@ -14,25 +14,25 @@ The durability boundary also exposed both `SessionStore.flush()` and `flushRequi ## Decision -`SubagentService` exposes three execution intents: `start(name, request)` for an ordinary holder-owned run, `startContinuable(spec)` for a durable Task-backed child, and `followup(parent, childId, content, { source, signal })` for later content. The last verb matches `Agent.followup()`, while `SubagentRun.steer()` remains the narrower confirmed live-activation capability. The model-facing tool keeps its stable `send_message` name and delegates routing to `followup()`. +`SubagentService` separates four execution intents: `start(name, request)` returns an ordinary holder-owned one-shot run; `startContinuable(spec)` establishes a durable child and returns its id plus the accepted initial `MessageId`; `followup(parent, childId, content, { source, signal })` sends later parent content; and `reportFrom(child, content, { delivery, signal })` sends selected child content to its direct parent. `followup` matches `Agent.followup()`, while `SubagentRun.steer()` remains the narrower confirmed live-run capability. The model-facing tools keep their stable `send_message` and `report` names and delegate routing to the corresponding intent methods. -Caller and provider requests are distinct. `SubagentStartRequest` contains only caller-supplied start data; `SubagentProviderStartRequest` adds service-resolved continuation state. Ordinary `start()` clears that state before provider dispatch. `SubagentProviderResumeRequest` remains part of the provider seam, but `SubagentService.resume()` is absent: the continuation manager loads the descriptor, authorizes the parent, and invokes private provider start/resume closures owned by the service. Provider dispatch still receives the same capability checks and run lifecycle observation without becoming a caller operation. +Caller and provider requests are distinct. `SubagentStartRequest` contains caller-supplied one-shot data; `ResolvedSubagentStartRequest` adds the service-resolved descriptor before `SubagentProvider.start()`. For continuable creation, the manager passes a `ContinuableCreateRequest` to optional `SubagentProvider.prepareContinuable()` and receives detached creation data only. `SubagentService.resume()` and provider resume dispatch are absent: the continuation manager loads the descriptor, authorizes the parent, and owns Agent materialization, prompt delivery, cold resume, and teardown. -`SessionStore.flush(session)` returns `Promise`. It resolves `true` after at least one scoped durability listener participates successfully, resolves `false` for an empty listener snapshot, and rejects with the first registered listener failure after all listeners settle. Ordinary checkpoints may ignore the boolean. A continuable provider requires `true` at its final result boundary and maps `false` or rejection to `DURABILITY_FAILED`. +`SessionStore.flush(session)` is the single durability barrier and returns `Promise`. It resolves `true` after at least one scoped listener participates successfully, resolves `false` for an empty listener snapshot, and rejects with the first registered listener failure after all listeners settle. Participation cannot identify whether a selected persistence backend stored the state. Ordinary checkpoints may ignore the boolean; the continuation manager also treats its final flush as a best-effort barrier, deliberately ignores participation, logs rejection, and still disposes the child and releases ownership. ## Alternatives considered -**Keep public provider resume dispatch.** No production caller outside the continuation manager owns the descriptor lookup, direct-parent authorization, Task cancellation, and activation association needed to call it safely. A public method would expose resolved implementation data without a valid independent intent. +**Keep public provider resume dispatch.** No production caller outside the continuation manager owns descriptor lookup, direct-parent authorization, Agent materialization, Activation ownership, and child-first teardown. A public method would expose resolved implementation data without a valid independent intent; providers instead contribute detached first-creation data through `prepareContinuable` and never participate in cold resume. **Keep `sendMessage` on the service.** The model tool sends a message, but the service operation represents a follow-up that may steer or cold-resume. `followup` aligns with the structural `Agent` interface and does not promise a particular route. **Keep `flushRequired()`.** A second method hides only an empty-listener check. Returning participation from the existing barrier keeps dispatch in one implementation and lets each caller state whether absence is acceptable. -**Fold ordinary and continuable starts together.** A flag would make one method return either an awaited holder-owned run or immediate child/Task identities. Separate intent methods preserve the ownership and timing distinction without a return union. +**Fold ordinary and continuable starts together.** A flag would make one method return either an awaited holder-owned one-shot run or immediate durable child and message identities. Separate intent methods preserve the ownership and timing distinction without a return union. ## Consequences -- The Cordis service catalog contains only caller operations; provider reconstruction remains extensible through `SubagentProvider.resume?()` without exposing its resolved request as a service method. +- The Cordis service catalog contains only caller operations; a provider can opt into continuable first creation through `SubagentProvider.prepareContinuable?()` without receiving Agent lifecycle authority or a public resume operation. - Follow-up source and cancellation travel in one options object, matching the intent-helper shape on `Agent` while retaining the existing live-delivery and cold-resume semantics. -- Session durability has one barrier operation. Callers that require a backend must inspect its participation result rather than selecting a second dispatch method. -- The `send_message` schema, route results, Task ownership, durable event vocabulary, and model-visible transcript remain unchanged. +- Session durability has one barrier operation. Its participation result remains observable, but no continuable-child path treats arbitrary listener participation as proof that a persistence backend stored the state. +- The `send_message` and `report` schemas, accepted message identities, `AgentHandle` ownership, durable event vocabulary, and model-visible transcript follow the activation-based realization linked above. diff --git a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.zh.md b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.zh.md index 0785730c19..dae4dd37fa 100644 --- a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-07-27-intent-named-subagent-continuation-operations.md) | 中文 -本记录命名的 `followup` 操作由[可继续的 subagent](../feature/2026-07-28-continuable-subagent-conversations.md)保留,但后者以已接受的 `MessageId` 替换其基于 Task 的返回值,保留裸 `Agent` 参数作为准确的实时直属父级权限,并以 `prepareContinuable` 替换提供方 `resume` 派发。 +当前基于 Activation 的实现由[可继续的 subagent](../feature/2026-07-28-continuable-subagent-conversations.md)负责。它保留本记录命名的 `followup` 操作,返回已接受的 `MessageId`,使用裸 `Agent` 参数作为确切的在线直属父级权限,并将提供方对可继续 child 的参与限制为 `prepareContinuable`。 ## 问题 @@ -14,25 +14,25 @@ Status: implemented ## 决策 -`SubagentService` 公开三种执行意图:`start(name, request)` 用于普通的、由持有方负责的 run;`startContinuable(spec)` 用于具备持久性且由 Task 支撑的 child;`followup(parent, childId, content, { source, signal })` 用于投递后续内容。最后一个动词与 `Agent.followup()` 一致,而 `SubagentRun.steer()` 仍是范围更窄的能力,仅向已确认仍在运行的激活提供 steering(中途引导)。面向模型的工具保留稳定的 `send_message` 名称,并将路由委托给 `followup()`。 +`SubagentService` 分离四种执行意图:`start(name, request)` 返回普通的、由持有方负责的 one-shot run;`startContinuable(spec)` 建立持久化 child,并返回其 id 与已接受的初始 `MessageId`;`followup(parent, childId, content, { source, signal })` 发送后续 parent 内容;`reportFrom(child, content, { delivery, signal })` 将选定的 child 内容发送给其直接 parent。`followup` 与 `Agent.followup()` 一致,而 `SubagentRun.steer()` 仍是范围更窄的能力,仅向已确认仍在运行的 run 提供 steering。面向模型的工具保留稳定的 `send_message` 与 `report` 名称,并将路由委托给对应的意图方法。 -调用方请求与提供方请求相互分离。`SubagentStartRequest` 只包含调用方提供的启动数据;`SubagentProviderStartRequest` 则加入由服务解析的继续执行状态。普通 `start()` 在分发给提供方之前会清除该状态。`SubagentProviderResumeRequest` 仍属于提供方 seam,但 `SubagentService.resume()` 不对外公开:继续执行管理器加载描述符、对 parent 进行鉴权,并调用由服务持有的私有提供方启动与恢复闭包。提供方分发仍会经过相同的功能检查和 run 生命周期观测,而无需将其变成调用方操作。 +调用方请求与提供方请求相互分离。`SubagentStartRequest` 包含调用方提供的 one-shot 数据;`ResolvedSubagentStartRequest` 会在调用 `SubagentProvider.start()` 前加入由服务解析的描述符。创建可继续 child 时,管理器将 `ContinuableCreateRequest` 传给可选的 `SubagentProvider.prepareContinuable()`,且只接收分离的创建数据。`SubagentService.resume()` 与提供方恢复分发均不存在:继续执行管理器加载描述符、对 parent 进行鉴权,并负责 Agent 实体化、提示词投递、冷恢复与 teardown。 -`SessionStore.flush(session)` 返回 `Promise`。至少一个作用域内的持久性监听器成功参与后,它解析为 `true`;监听器快照为空时解析为 `false`;所有监听器结算后,如有失败,则以注册顺序最靠前的监听器错误拒绝。普通检查点可以忽略该布尔值。可继续提供方在最终结果边界要求该值为 `true`,并将 `false` 或拒绝映射为 `DURABILITY_FAILED`。 +`SessionStore.flush(session)` 是唯一的持久性屏障,并返回 `Promise`。至少一个作用域内监听器成功参与后,它解析为 `true`;监听器快照为空时解析为 `false`;所有监听器结算后,如有失败,则以注册顺序最靠前的监听器错误拒绝。参与结果无法表明所选的持久化后端是否已经存储状态。普通检查点可以忽略该布尔值;继续执行管理器同样将最终 flush 视为 best-effort 屏障,有意忽略参与结果,记录拒绝日志,并仍会对 child 执行 dispose(资源释放)并释放所有权。 ## 已考虑的替代方案 -**保留公开的提供方恢复分发。** 继续执行管理器之外没有任何生产调用方负责安全调用所需的描述符查找、直接 parent 鉴权、Task 取消与激活关联。公开方法会暴露已解析的实现数据,但并不存在与之对应的合理独立调用意图。 +**保留公开的提供方恢复分发。** 继续执行管理器之外,没有任何生产调用方同时负责安全调用所需的描述符查找、直接 parent 鉴权、Agent 实体化、Activation 所有权与 child-first teardown。公开方法会暴露已解析的实现数据,却没有合理的独立调用意图;提供方改为通过 `prepareContinuable` 贡献分离的首次创建数据,且从不参与冷恢复。 **在服务上保留 `sendMessage`。** 面向模型的工具发送消息,但服务操作表达的是后续操作,既可能对运行中的激活执行 steering,也可能从持久化存储恢复。`followup` 与结构化 `Agent` 接口保持一致,也不承诺特定路由。 **保留 `flushRequired()`。** 第二个方法只封装了空监听器检查。由现有屏障返回是否有监听器参与,可以让分发只保留一套实现,并让每个调用方自行判定缺少监听器是否可接受。 -**合并普通启动与可继续启动。** 一个标志会让同一方法要么等待由持有方负责的 run 就绪后返回,要么立即返回 child 和 Task 标识。按意图拆分的方法无需返回值联合类型即可保留所有权与时序差异。 +**合并普通启动与可继续启动。** 一个标志会让同一方法要么等待由持有方负责的 one-shot run 就绪后返回,要么立即返回持久化 child 与消息标识。按意图拆分的方法无需返回值联合类型即可保留所有权与时序差异。 ## 影响 -- Cordis 服务目录只包含调用方操作;提供方的重建能力仍可通过 `SubagentProvider.resume?()` 扩展,同时不会将已解析的请求暴露为服务方法。 +- Cordis 服务目录只包含调用方操作;提供方可以通过 `SubagentProvider.prepareContinuable?()` 选择参与可继续 child 的首次创建,但不会获得 Agent 生命周期权限或公开恢复操作。 - 后续操作的来源与取消信号通过同一个选项对象传递,与 `Agent` 上按意图命名的辅助方法形态一致,同时保留在线投递与从持久化存储恢复的语义。 -- 会话持久性只保留一个屏障操作。需要后端参与的调用方必须检查参与结果,而不是选择第二种分发方法。 -- `send_message` schema、路由结果、Task 所有权、持久化事件词汇与模型可见的 transcript(文本记录)保持不变。 +- 会话持久性只有一个屏障操作。参与结果仍可观测,但任何可继续 child 路径都不会将任意监听器参与视为持久化后端已存储状态的证明。 +- `send_message` 与 `report` schema、已接受的消息标识、`AgentHandle` 所有权、持久化事件词汇与模型可见的 transcript(文本记录)遵循上文链接的基于 Activation 的实现。 diff --git a/.github/ISSUE_TEMPLATE/bug.md b/.github/ISSUE_TEMPLATE/bug.md new file mode 100644 index 0000000000..9427dc1d52 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.md @@ -0,0 +1,22 @@ +--- +name: Bug +about: 记录现有预期行为的失效 +title: '' +labels: '' +assignees: '' +type: Bug +--- + + +一句话说明错误结果。 + +
+复现、预期与验收 + +- 复现步骤: +- 实际结果: +- 预期结果: +- 环境: +- 验收条件: + +
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000..8005e32267 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,2 @@ +blank_issues_enabled: false +contact_links: [] diff --git a/.github/ISSUE_TEMPLATE/feature.md b/.github/ISSUE_TEMPLATE/feature.md new file mode 100644 index 0000000000..2c65f5544d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.md @@ -0,0 +1,20 @@ +--- +name: Feature +about: 新增或有意改变可观察行为 +title: '' +labels: '' +assignees: '' +type: Feature +--- + + +一句话说明预期结果。 + +
+验收与细节 + +- 验收条件: +- 用户或模型可见变化: +- 测试证据: + +
diff --git a/.github/ISSUE_TEMPLATE/idea.md b/.github/ISSUE_TEMPLATE/idea.md new file mode 100644 index 0000000000..c8bf80402d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/idea.md @@ -0,0 +1,20 @@ +--- +name: Idea +about: 记录尚未承诺实施、但具有行动可能的想法 +title: '' +labels: '' +assignees: '' +type: Idea +--- + + +一句话说明价值假设。 + +
+价值与细节 + +- 价值假设: +- 需要验证: +- 可能的后续工作: + +
diff --git a/.github/ISSUE_TEMPLATE/research.md b/.github/ISSUE_TEMPLATE/research.md new file mode 100644 index 0000000000..8acfe2cc6e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/research.md @@ -0,0 +1,21 @@ +--- +name: Research +about: 形成结论、证据或决策 +title: '' +labels: '' +assignees: '' +type: Research +--- + + +一句话说明待回答的问题。 + +
+问题与证据标准 + +- 核心问题: +- 证据标准: +- 交付结论: +- 可能的后续工作: + +
diff --git a/.github/ISSUE_TEMPLATE/task.md b/.github/ISSUE_TEMPLATE/task.md new file mode 100644 index 0000000000..376855c27c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/task.md @@ -0,0 +1,20 @@ +--- +name: Task +about: 明确的非 Feature、非 Bug 工作 +title: '' +labels: '' +assignees: '' +type: Task +--- + + +一句话说明要完成的工作。 + +
+验收与细节 + +- 验收条件: +- 交付物: +- 测试证据: + +
diff --git a/.github/issue-management/config.json b/.github/issue-management/config.json new file mode 100644 index 0000000000..41019f0aa2 --- /dev/null +++ b/.github/issue-management/config.json @@ -0,0 +1,17 @@ +{ + "organization": "deepseek-harness", + "repository": "deepseek-harness", + "projectNumber": 1, + "projectTitle": "DSH Issue Management", + "priorityField": "Priority", + "allowUnassignedOwner": true, + "statuses": [ + "Inbox", + "Backlog", + "Ready", + "In progress", + "In review", + "Done", + "No action" + ] +} diff --git a/.github/issue-management/policy.mjs b/.github/issue-management/policy.mjs new file mode 100644 index 0000000000..4c9242bab5 --- /dev/null +++ b/.github/issue-management/policy.mjs @@ -0,0 +1,559 @@ +#!/usr/bin/env node + +import fs from 'node:fs' +import process from 'node:process' +import { pathToFileURL } from 'node:url' + +import config from './config.json' with { type: 'json' } + +const API_VERSION = '2026-03-10' +const BODY_LIMIT = 50 +const AUDIT_MARKER = '' +const OWNER_LINE = /^Owner: @([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?)$/ +const TYPES = new Set(['Idea', 'Feature', 'Bug', 'Research', 'Task']) +const PRIORITIES = ['p0', 'p1', 'p2', 'p3'] + +/** + * Return Markdown outside balanced details elements. + * @param {string} body Markdown body. + * @returns {{text: string, balanced: boolean, detailsCount: number, allCollapsed: boolean}} Visible source and details shape. + */ +export function extractOutsideDetails(body) { + const source = body.replace(//g, '') + const tag = /<\/?details\b[^>]*>/gi + let depth = 0 + let cursor = 0 + let balanced = true + let text = '' + let detailsCount = 0 + let allCollapsed = true + + for (const match of source.matchAll(tag)) { + const index = match.index ?? 0 + if (depth === 0) text += source.slice(cursor, index) + if (/^<\//.test(match[0])) { + if (depth === 0) balanced = false + else depth -= 1 + } else { + depth += 1 + detailsCount += 1 + if (/\sopen(?:\s|=|>)/i.test(match[0])) allCollapsed = false + } + cursor = index + match[0].length + } + + if (depth === 0) text += source.slice(cursor) + if (depth !== 0) balanced = false + return { text, balanced, detailsCount, allCollapsed } +} + +/** + * Count Chinese characters and contiguous Latin, numeric, or code tokens. + * @param {string} body Markdown body. + * @returns {{units: number, balanced: boolean, detailsCount: number, allCollapsed: boolean}} Visible unit count and details shape. + */ +export function countVisibleUnits(body) { + const outside = extractOutsideDetails(body) + const visible = outside.text + .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1') + .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') + .replace(/\[([^\]]+)\]\[[^\]]*\]/g, '$1') + .replace(/<((?:https?:\/\/|mailto:)[^>]+)>/gi, '$1') + .replace(/<[^>]+>/g, ' ') + .replace(/&(?:[A-Za-z]+|#\d+|#x[0-9A-Fa-f]+);/g, ' ') + .replace(/[\u0060*~\[\]{}()<>#!|]/g, ' ') + const han = visible.match(/\p{Script=Han}/gu)?.length ?? 0 + const tokens = visible.match(/[\p{Script=Latin}\p{Number}_./:@+-]+/gu)?.length ?? 0 + return { + units: han + tokens, + balanced: outside.balanced, + detailsCount: outside.detailsCount, + allCollapsed: outside.allCollapsed, + } +} + +function firstNonblankLine(body) { + return body + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean) +} + +/** + * Validate body shape and Owner against assignees. + * @param {{body: string, assignees: string[], allowUnassignedOwner?: boolean}} input Body input. + * @returns {string[]} Validation errors. + */ +export function validateBody({ + body, + assignees, + allowUnassignedOwner = config.allowUnassignedOwner ?? false, +}) { + const errors = [] + const count = countVisibleUnits(body) + const owner = firstNonblankLine(body)?.match(OWNER_LINE)?.[1] ?? null + const normalized = [...new Set(assignees.map((login) => login.toLowerCase()))] + + if (!count.balanced) errors.push('details 标签必须成对闭合') + if (count.detailsCount === 0) errors.push('正文必须包含默认收起的
区域') + if (!count.allCollapsed) errors.push('details 必须默认收起,不得设置 open') + if (count.units > BODY_LIMIT) { + errors.push(`正文外露部分为 ${count.units} 单位,超过 50 单位`) + } + if (normalized.length >= 2 && !owner) { + errors.push('多个 Assignees 时首个非空行必须是 Owner: @login') + } else if (normalized.length >= 2 && !normalized.includes(owner.toLowerCase())) { + errors.push('Owner 必须属于 Assignees') + } else if ( + normalized.length < 2 && + owner && + !(normalized.length === 0 && allowUnassignedOwner) + ) { + errors.push('零或一个 Assignee 时不得写 Owner 行') + } + return errors +} + +/** + * Decide whether a PR has entered the human-review enforcement boundary. + * @param {{isDraft: boolean, authorType: string, reviewRequestCount: number, reviewCount: number}} input PR state. + * @returns {boolean} Whether the PR policy is mandatory. + */ +export function requiresPullRequestPolicy({ + isDraft, + authorType, + reviewRequestCount, + reviewCount, +}) { + const automated = authorType === 'Bot' || authorType === 'App' + return !isDraft && !automated && (reviewRequestCount > 0 || reviewCount > 0) +} + +function stripIgnoredMarkdown(body) { + const lines = body.replace(//g, '').split(/\r?\n/) + const kept = [] + let fence = null + for (const line of lines) { + const marker = line.match(/^\s*([\u0060~]{3,})/) + if (marker) { + if (fence === null) fence = marker[1][0] + else if (marker[1][0] === fence) fence = null + continue + } + if (fence === null) kept.push(line) + } + return kept.join('\n').replace(/\u0060[^\u0060]*\u0060/g, ' ') +} + +/** + * Parse same-repository resolving and informational references. + * @param {{body: string, repository: string}} input PR body and repository. + * @returns {{all: number[], resolving: number[], related: number[]}} References. + */ +export function parseReferences({ body, repository }) { + const source = stripIgnoredMarkdown(body) + const expected = repository.toLowerCase() + const all = new Set() + const resolving = new Set() + const reference = + /(?:([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)#|#)(\d+)|https:\/\/github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)\/issues\/(\d+)/gi + const closing = + /\b(?:close(?:s|d)?|fix(?:es|ed)?|resolve(?:s|d)?)\s*:?\s+(?:(?:([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)#|#)(\d+)|https:\/\/github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)\/issues\/(\d+))/gi + + for (const match of source.matchAll(reference)) { + const explicit = (match[1] ?? match[3] ?? '').toLowerCase() + const number = Number(match[2] ?? match[4]) + if (!explicit || explicit === expected) all.add(number) + } + for (const match of source.matchAll(closing)) { + const explicit = (match[1] ?? match[3] ?? '').toLowerCase() + const number = Number(match[2] ?? match[4]) + if (!explicit || explicit === expected) { + all.add(number) + resolving.add(number) + } + } + return { + all: [...all].sort((left, right) => left - right), + resolving: [...resolving].sort((left, right) => left - right), + related: [...all].filter((number) => !resolving.has(number)).sort((a, b) => a - b), + } +} + +/** + * Retain only references that resolve to Issues rather than pull requests. + * @param {{all: number[], resolving: number[], related: number[]}} references Parsed references. + * @param {Map} issues Resolved same-repository Issues. + * @returns {{all: number[], resolving: number[], related: number[]}} Issue-only references. + */ +export function retainIssueReferences(references, issues) { + return { + all: references.all.filter((number) => issues.has(number)), + resolving: references.resolving.filter((number) => issues.has(number)), + related: references.related.filter((number) => issues.has(number)), + } +} + +/** + * Validate one Issue with its Project status. + * @param {{title: string, body: string, assignees: string[], labels: string[], type: string|null, priority: string|null, status: string|null, state: string, stateReason: string|null}} issue Issue snapshot. + * @returns {string[]} Validation errors. + */ +export function validateIssue(issue) { + const errors = validateBody(issue) + const status = issue.status + + if (!/\p{Script=Han}/u.test(issue.title)) errors.push('Issue 标题必须包含中文') + if ( + /^\s*(?:\[(?:Idea|Feature|Bug|Research|Task|P[0-3]|Inbox|Backlog|Ready|In progress|In review|Done|No action|Owner|area\/[^\]]+)[^\]]*\]|(?:Idea|Feature|Bug|Research|Task|P[0-3]|Inbox|Backlog|Ready|In progress|In review|Done|No action|Owner|area\/[^:: ]+)\s*[::-])/iu.test( + issue.title, + ) + ) { + errors.push('Issue 标题不得带 Type、Priority、Status、area 或 Owner 前缀') + } + if (!TYPES.has(issue.type ?? '')) errors.push('Type 必须是五种原生英文 Type 之一') + if (!status || !config.statuses.includes(status)) errors.push('Issue 必须在 Project 中且具有合法 Status') + if (issue.priority !== null && !PRIORITIES.includes(issue.priority.toLowerCase())) { + errors.push('Priority 必须为空或为 P0–P3') + } + if (status === 'Done' && (issue.state !== 'closed' || issue.stateReason !== 'completed')) { + errors.push('Done 必须对应 Completed 关闭原因') + } + if ( + status === 'No action' && + (issue.state !== 'closed' || issue.stateReason !== 'not_planned') + ) { + errors.push('No action 必须对应 Not planned 关闭原因') + } + if (!['Done', 'No action'].includes(status ?? '') && issue.state !== 'open') { + errors.push(`${status} 必须对应开放 Issue`) + } + return errors +} + +/** + * Validate PR metadata and its referenced Issues. + * @param {{authorType: string, labels: string[], references: ReturnType, issues: Map}} input PR snapshot. + * @returns {string[]} Validation errors. + */ +export function validatePullRequest(input) { + if (!requiresPullRequestPolicy(input)) return [] + const errors = [] + const kinds = input.labels.filter((label) => label.startsWith('kind/')) + const priorities = input.labels.filter((label) => PRIORITIES.includes(label)) + const areas = input.labels.filter((label) => label.startsWith('area/')) + + if (input.references.all.length === 0) errors.push('PR 正文必须引用至少一个同仓库 Issue') + if (kinds.length !== 1) errors.push(`PR 必须恰好有一个 kind/*,当前为 ${kinds.length}`) + if (priorities.length > 1) errors.push(`PR 最多有一个 p0–p3,当前为 ${priorities.length}`) + if (areas.length === 0) errors.push('PR 必须至少有一个 area/*') + for (const number of input.references.all) { + if (!input.issues.has(number)) errors.push(`#${number} 不是同仓库 Issue`) + } + + const resolving = input.references.resolving + .map((number) => [number, input.issues.get(number)]) + .filter((entry) => entry[1]) + if (resolving.length === 0) return errors + + const issuePriorities = resolving + .map(([, issue]) => issue.priority?.toLowerCase()) + .filter((priority) => PRIORITIES.includes(priority)) + if (priorities.length === 0 && issuePriorities.length > 0) { + const highest = issuePriorities.sort( + (left, right) => PRIORITIES.indexOf(left) - PRIORITIES.indexOf(right), + )[0] + errors.push(`PR Priority 应为 ${highest}`) + } else if (priorities.length === 1 && issuePriorities.length !== resolving.length) { + errors.push('有 Priority 的解决型 PR 要求每个被解决 Issue 都设置 Priority') + } else if (priorities.length === 1) { + const highest = issuePriorities.sort( + (left, right) => PRIORITIES.indexOf(left) - PRIORITIES.indexOf(right), + )[0] + if (priorities[0] !== highest) errors.push(`PR Priority 应为 ${highest}`) + } + return errors +} + +function token() { + const value = process.env.GH_TOKEN || process.env.GITHUB_TOKEN + if (!value) throw new Error('GH_TOKEN 或 GITHUB_TOKEN 未设置') + return value +} + +async function api(path, options = {}) { + const response = await fetch(`${process.env.GITHUB_API_URL ?? 'https://api.github.com'}${path}`, { + ...options, + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${token()}`, + 'X-GitHub-Api-Version': API_VERSION, + 'User-Agent': 'dsh-issue-policy', + ...options.headers, + }, + }) + if (options.allow404 && response.status === 404) return null + if (!response.ok) { + const body = await response.text() + throw new Error(`${options.method ?? 'GET'} ${path}: ${response.status} ${body}`) + } + if (response.status === 204) return null + return response.json() +} + +async function graphql(query, variables) { + const result = await api('/graphql', { + method: 'POST', + body: JSON.stringify({ query, variables }), + headers: { 'Content-Type': 'application/json' }, + }) + if (result.errors?.length) throw new Error(result.errors.map((error) => error.message).join('; ')) + return result.data +} + +async function issueSnapshot(number, status = undefined) { + const issue = await api(`/repos/${config.organization}/${config.repository}/issues/${number}`) + if (issue.pull_request) return null + const values = await api( + `/repos/${config.organization}/${config.repository}/issues/${number}/issue-field-values?per_page=100`, + ) + const field = (name) => values.find((value) => value.issue_field_name === name) + return { + number, + nodeId: issue.node_id, + title: issue.title, + body: issue.body ?? '', + assignees: issue.assignees.map((assignee) => assignee.login), + labels: issue.labels.map((label) => label.name), + type: issue.type?.name ?? null, + priority: field(config.priorityField)?.single_select_option?.name ?? null, + status: status === undefined ? await projectStatus(number) : status, + state: issue.state, + stateReason: issue.state_reason ?? null, + } +} + +async function projectContext(number) { + const data = await graphql( + `query($organization: String!, $repository: String!, $number: Int!, $project: Int!) { + organization(login: $organization) { + projectV2(number: $project) { + id + title + fields(first: 50) { + nodes { + ... on ProjectV2SingleSelectField { id name options { id name } } + } + } + } + } + repository(owner: $organization, name: $repository) { + issue(number: $number) { + id + projectItems(first: 20, includeArchived: true) { + nodes { + id + project { id } + fieldValueByName(name: "Status") { + ... on ProjectV2ItemFieldSingleSelectValue { name optionId } + } + } + } + } + } + }`, + { + organization: config.organization, + repository: config.repository, + number, + project: config.projectNumber, + }, + ) + const project = data.organization?.projectV2 + const issue = data.repository?.issue + if (!project || project.title !== config.projectTitle) throw new Error('目标 Project 不存在或标题不匹配') + if (!issue) throw new Error(`#${number} 不存在`) + const statusField = project.fields.nodes.find((field) => field?.name === 'Status') + if (!statusField) throw new Error('Project 缺少 Status 字段') + const item = issue.projectItems.nodes.find((candidate) => candidate.project.id === project.id) + return { project, issue, statusField, item } +} + +async function projectStatus(number) { + const context = await projectContext(number) + return context.item?.fieldValueByName?.name ?? null +} + +async function ensureProjectItem(number) { + const context = await projectContext(number) + if (context.item) return context + const data = await graphql( + `mutation($projectId: ID!, $contentId: ID!) { + addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) { + item { id } + } + }`, + { projectId: context.project.id, contentId: context.issue.id }, + ) + return { + ...context, + item: { id: data.addProjectV2ItemById.item.id, fieldValueByName: null }, + } +} + +async function setStatus(number, status) { + const context = await ensureProjectItem(number) + const option = context.statusField.options.find((candidate) => candidate.name === status) + if (!option) throw new Error(`Status 不存在:${status}`) + if (context.item.fieldValueByName?.name === status) return + await graphql( + `mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId, + itemId: $itemId, + fieldId: $fieldId, + value: {singleSelectOptionId: $optionId} + }) { projectV2Item { id } } + }`, + { + projectId: context.project.id, + itemId: context.item.id, + fieldId: context.statusField.id, + optionId: option.id, + }, + ) +} + +async function upsertAudit(number, errors) { + const comments = await api( + `/repos/${config.organization}/${config.repository}/issues/${number}/comments?per_page=100`, + ) + const existing = comments.find( + (comment) => comment.user?.type === 'Bot' && comment.body?.includes(AUDIT_MARKER), + ) + if (errors.length === 0) { + if (existing) { + await api(`/repos/${config.organization}/${config.repository}/issues/comments/${existing.id}`, { + method: 'DELETE', + }) + } + return + } + const body = `${AUDIT_MARKER}\n⚠️ Issue policy 未通过:\n\n${errors.map((error) => `- ${error}`).join('\n')}` + if (existing) { + if (existing.body === body) return + await api(`/repos/${config.organization}/${config.repository}/issues/comments/${existing.id}`, { + method: 'PATCH', + body: JSON.stringify({ body }), + headers: { 'Content-Type': 'application/json' }, + }) + } else { + await api(`/repos/${config.organization}/${config.repository}/issues/${number}/comments`, { + method: 'POST', + body: JSON.stringify({ body }), + headers: { 'Content-Type': 'application/json' }, + }) + } +} + +async function auditIssue(number, extraErrors = [], status = undefined) { + const issue = await issueSnapshot(number, status) + if (!issue) return [] + const errors = [...extraErrors, ...validateIssue(issue)] + await upsertAudit(number, errors) + return errors +} + +async function pullRequestSnapshot(number) { + const pull = await api(`/repos/${config.organization}/${config.repository}/pulls/${number}`) + const [reviewRequests, reviews] = await Promise.all([ + api(`/repos/${config.organization}/${config.repository}/pulls/${number}/requested_reviewers`), + api(`/repos/${config.organization}/${config.repository}/pulls/${number}/reviews?per_page=100`), + ]) + const references = parseReferences({ + body: pull.body ?? '', + repository: `${config.organization}/${config.repository}`, + }) + const issues = new Map() + for (const issueNumber of references.all) { + const issue = await issueSnapshot(issueNumber, null) + if (issue) issues.set(issueNumber, issue) + } + return { + number, + isDraft: pull.draft, + authorType: pull.user?.type ?? 'User', + reviewRequestCount: reviewRequests.users.length + reviewRequests.teams.length, + reviewCount: reviews.length, + labels: pull.labels.map((label) => label.name), + references: retainIssueReferences(references, issues), + issues, + } +} + +async function moveResolvingIssues(pull, from, to) { + for (const number of pull.references.resolving) { + const current = await issueSnapshot(number) + if (!current || current.status !== from) continue + await setStatus(number, to) + await auditIssue(number) + } +} + +async function runPullRequestCheck(event) { + const pull = await pullRequestSnapshot(event.pull_request.number) + const errors = validatePullRequest(pull) + if (errors.length > 0) { + for (const error of errors) process.stdout.write(`::error::${error}\n`) + throw new Error(`Issue policy 未通过,共 ${errors.length} 项`) + } + process.stdout.write( + requiresPullRequestPolicy(pull) ? 'Issue policy 通过。\n' : 'PR 尚未进入 Issue policy 强制范围。\n', + ) +} + +async function runLifecycle(eventName, event) { + if (eventName === 'issues') { + const number = event.issue.number + if (event.action === 'opened') await setStatus(number, 'Inbox') + if (event.action === 'closed') { + const target = event.issue.state_reason === 'not_planned' ? 'No action' : 'Done' + await setStatus(number, target) + } + if (event.action === 'reopened') { + await setStatus(number, 'Inbox') + } + await ensureProjectItem(number) + await auditIssue(number) + return + } + + if (eventName === 'pull_request' || eventName === 'pull_request_review') { + const pull = await pullRequestSnapshot(event.pull_request.number) + const errors = validatePullRequest(pull) + if (errors.length > 0) return + await moveResolvingIssues(pull, 'Ready', 'In progress') + if (pull.reviewRequestCount > 0 || pull.reviewCount > 0) { + await moveResolvingIssues(pull, 'In progress', 'In review') + } + } +} + +function readEvent() { + if (!process.env.GITHUB_EVENT_PATH) throw new Error('GITHUB_EVENT_PATH 未设置') + return JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, 'utf8')) +} + +async function main(argv) { + const [command] = argv + if (command === 'pr') await runPullRequestCheck(readEvent()) + else if (command === 'lifecycle') await runLifecycle(process.env.GITHUB_EVENT_NAME, readEvent()) + else throw new Error('用法:policy.mjs pr|lifecycle') +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main(process.argv.slice(2)).catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`) + process.exitCode = 1 + }) +} diff --git a/.github/issue-management/policy.test.mjs b/.github/issue-management/policy.test.mjs new file mode 100644 index 0000000000..8e0c253796 --- /dev/null +++ b/.github/issue-management/policy.test.mjs @@ -0,0 +1,259 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + countVisibleUnits, + parseReferences, + retainIssueReferences, + requiresPullRequestPolicy, + validateBody, + validateIssue, + validatePullRequest, +} from './policy.mjs' + +const withDetails = (summary) => + `${summary}\n\n
验收与细节待补充。
` + +const legalIssue = { + title: '完成议题管理校验', + body: withDetails('完成议题管理校验。'), + assignees: [], + labels: [], + type: 'Idea', + priority: null, + status: 'In review', + state: 'open', + stateReason: null, +} + +test('counts only text outside details', () => { + assert.deepEqual(countVisibleUnits('支持 GitHub Project。
隐藏文字
'), { + units: 4, + balanced: true, + detailsCount: 1, + allCollapsed: true, + }) +}) + +test('requires a balanced default-collapsed details region', () => { + assert.deepEqual(validateBody({ body: '完成工作。', assignees: [] }), [ + '正文必须包含默认收起的
区域', + ]) + assert.deepEqual( + validateBody({ + body: '完成工作。\n\n
细节待补充。
', + assignees: [], + }), + ['details 必须默认收起,不得设置 open'], + ) + assert.deepEqual( + validateBody({ body: '完成工作。\n\n
细节', assignees: [] }), + ['details 标签必须成对闭合'], + ) +}) + +test('requires Owner for multiple assignees', () => { + assert.deepEqual( + validateBody({ + body: withDetails('完成工作。'), + assignees: ['tianyicui', 'tianyicui-bot'], + }), + ['多个 Assignees 时首个非空行必须是 Owner: @login'], + ) +}) + +test('accepts an intended Owner while assignment permission is pending', () => { + assert.deepEqual( + validateBody({ + body: withDetails('Owner: @octocat\n\n完成工作。'), + assignees: [], + }), + [], + ) + assert.deepEqual( + validateBody({ + body: withDetails('Owner: @octocat\n\n完成工作。'), + assignees: ['hubot'], + }), + ['零或一个 Assignee 时不得写 Owner 行'], + ) +}) + +test('allows optional metadata in every open Status', () => { + assert.deepEqual(validateIssue(legalIssue), []) + for (const status of ['Inbox', 'Backlog', 'Ready', 'In progress', 'In review']) { + assert.deepEqual(validateIssue({ ...legalIssue, status }), []) + } +}) + +test('rejects metadata prefixes in an Issue title', () => { + const errors = validateIssue({ ...legalIssue, title: '[Bug] 修复恢复错误' }) + assert.ok(errors.includes('Issue 标题不得带 Type、Priority、Status、area 或 Owner 前缀')) +}) + +test('keeps terminal Status aligned with the native close reason', () => { + assert.deepEqual( + validateIssue({ ...legalIssue, status: 'Done', state: 'closed', stateReason: 'completed' }), + [], + ) + assert.deepEqual( + validateIssue({ + ...legalIssue, + status: 'No action', + state: 'closed', + stateReason: 'not_planned', + }), + [], + ) + assert.ok(validateIssue({ ...legalIssue, status: 'Done' }).includes('Done 必须对应 Completed 关闭原因')) +}) + +test('separates resolving and informational references', () => { + assert.deepEqual( + parseReferences({ + body: 'Fixes #12\nRelated to #4\nRefs deepseekharness/dsh-test#7', + repository: 'deepseekharness/dsh-test', + }), + { all: [4, 7, 12], resolving: [12], related: [4, 7] }, + ) +}) + +test('does not treat pull request references as Issue associations', () => { + const references = { + all: [123, 1180, 1181], + resolving: [123, 1180], + related: [1181], + } + const issues = new Map([ + [1180, {}], + [1181, {}], + ]) + + assert.deepEqual(retainIssueReferences(references, issues), { + all: [1180, 1181], + resolving: [1180], + related: [1181], + }) +}) + +test('allows informational references without cross-object constraints', () => { + const errors = validatePullRequest({ + isDraft: false, + authorType: 'User', + reviewRequestCount: 1, + reviewCount: 0, + labels: ['kind/cleanup', 'area/infra'], + references: { all: [4], resolving: [], related: [4] }, + issues: new Map([[4, { type: 'Bug', priority: 'P0', labels: ['area/web'] }]]), + }) + assert.deepEqual(errors, []) +}) + +test('enforces highest resolving Priority without Type or area synchronization', () => { + const pull = { + isDraft: false, + authorType: 'User', + reviewRequestCount: 0, + reviewCount: 1, + labels: ['kind/cleanup', 'p0', 'area/web'], + references: { all: [2, 3], resolving: [2, 3], related: [] }, + issues: new Map([ + [2, { type: 'Feature', priority: 'P2', labels: ['area/web'] }], + [3, { type: 'Bug', priority: 'P0', labels: ['area/session'] }], + ]), + } + assert.deepEqual(validatePullRequest(pull), []) + assert.ok( + validatePullRequest({ ...pull, labels: ['kind/cleanup', 'p2', 'area/web'] }).includes( + 'PR Priority 应为 p0', + ), + ) +}) + +test('requires policy only after a human PR enters review', () => { + assert.equal( + requiresPullRequestPolicy({ + isDraft: false, + authorType: 'User', + reviewRequestCount: 1, + reviewCount: 0, + }), + true, + ) + assert.equal( + requiresPullRequestPolicy({ + isDraft: false, + authorType: 'User', + reviewRequestCount: 0, + reviewCount: 0, + }), + false, + ) +}) + +test('exempts Draft, Bot, and App PRs', () => { + const invalid = { + isDraft: false, + labels: [], + references: { all: [], resolving: [], related: [] }, + issues: new Map(), + reviewRequestCount: 1, + reviewCount: 0, + } + assert.deepEqual(validatePullRequest({ ...invalid, authorType: 'Bot' }), []) + assert.deepEqual(validatePullRequest({ ...invalid, authorType: 'App' }), []) + assert.deepEqual(validatePullRequest({ ...invalid, authorType: 'User', isDraft: true }), []) + assert.ok(validatePullRequest({ ...invalid, authorType: 'User' }).length > 0) +}) + +test('requires repository PR labels in the enforcement scope', () => { + const errors = validatePullRequest({ + isDraft: false, + authorType: 'User', + reviewRequestCount: 1, + reviewCount: 0, + labels: [], + references: { all: [2], resolving: [], related: [2] }, + issues: new Map([[2, { priority: null }]]), + }) + assert.ok(errors.includes('PR 必须恰好有一个 kind/*,当前为 0')) + assert.ok(errors.includes('PR 必须至少有一个 area/*')) +}) + +test('accepts repository-extensible kind labels', () => { + assert.deepEqual( + validatePullRequest({ + isDraft: false, + authorType: 'User', + reviewRequestCount: 1, + reviewCount: 0, + labels: ['kind/dependency', 'area/infra'], + references: { all: [2], resolving: [], related: [2] }, + issues: new Map([[2, { priority: null }]]), + }), + [], + ) +}) + +test('allows missing Priority only when resolving Issues are also unprioritized', () => { + const pull = { + isDraft: false, + authorType: 'User', + reviewRequestCount: 1, + reviewCount: 0, + labels: ['kind/feature', 'area/web'], + references: { all: [2], resolving: [2], related: [] }, + issues: new Map([[2, { priority: null }]]), + } + assert.deepEqual(validatePullRequest(pull), []) + assert.ok( + validatePullRequest({ ...pull, issues: new Map([[2, { priority: 'P2' }]]) }).includes( + 'PR Priority 应为 p2', + ), + ) + assert.ok( + validatePullRequest({ ...pull, labels: [...pull.labels, 'p2'] }).includes( + '有 Priority 的解决型 PR 要求每个被解决 Issue 都设置 Priority', + ), + ) +}) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000000..e960016ae8 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,13 @@ + + + + +关联 Issue: + +
+变更与验证 + +- 变更: +- 验证: + +
diff --git a/.github/workflows/issue-lifecycle.yml b/.github/workflows/issue-lifecycle.yml new file mode 100644 index 0000000000..4dc6869e27 --- /dev/null +++ b/.github/workflows/issue-lifecycle.yml @@ -0,0 +1,58 @@ +name: Issue lifecycle + +on: + issues: + types: + - opened + - edited + - assigned + - unassigned + - labeled + - unlabeled + - closed + - reopened + - field_added + - field_removed + pull_request: + types: + - opened + - edited + - synchronize + - reopened + - labeled + - unlabeled + - ready_for_review + - review_requested + pull_request_review: + types: + - submitted + +permissions: + contents: read + +concurrency: + group: issue-lifecycle-${{ github.event.issue.number || github.event.pull_request.number || github.run_id }} + cancel-in-progress: false + +jobs: + lifecycle: + name: Issue lifecycle + runs-on: ubuntu-latest + steps: + - name: Check out trusted policy + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + - name: Create project token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 + with: + client-id: ${{ vars.DSH_ISSUE_APP_CLIENT_ID }} + private-key: ${{ secrets.DSH_ISSUE_APP_PRIVATE_KEY }} + owner: deepseek-harness + repositories: deepseek-harness + - name: Handle repository event + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: node .github/issue-management/policy.mjs lifecycle diff --git a/.github/workflows/issue-policy.yml b/.github/workflows/issue-policy.yml new file mode 100644 index 0000000000..dde9462c33 --- /dev/null +++ b/.github/workflows/issue-policy.yml @@ -0,0 +1,27 @@ +name: Issue policy + +on: + pull_request: + types: [opened, edited, synchronize, reopened, labeled, unlabeled, ready_for_review, review_requested] + pull_request_review: + types: [submitted] + +permissions: + contents: read + issues: read + pull-requests: read + +jobs: + policy: + name: Issue policy + runs-on: ubuntu-latest + steps: + - name: Check out trusted policy + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + - name: Validate pull request + env: + GITHUB_TOKEN: ${{ github.token }} + run: node .github/issue-management/policy.mjs pr diff --git a/apps/cli/config/core-web.cordis.yml b/apps/cli/config/core-web.cordis.yml index 2a5205cd0d..6b31c8f424 100644 --- a/apps/cli/config/core-web.cordis.yml +++ b/apps/cli/config/core-web.cordis.yml @@ -28,6 +28,9 @@ - id: tool-subagent-control disabled: true +- id: tool-subagent-list-agents + disabled: true + - id: tool-subagent disabled: true diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index 32b767cb2c..5af1a32cbb 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -115,7 +115,6 @@ export async function runTui( ) process.exit(1) } - installFailLoud(NAME) // The bin already loaded the invoking directory's .env, and that is the // whole environment: $DSH_HOME/.env is credentials-local's writable store, // and hoisting it would make every stored key read as a read-only ambient @@ -142,6 +141,19 @@ export async function runTui( const entry = process.argv[1] const execve = process.execve?.bind(process) const app: { current?: Context } = {} + // The Loader mounts entries concurrently, so `ui-tui` can already hold the + // terminal (raw mode, bracketed paste, keyboard protocol) when something + // else fails. A config-tree failure settles through `boot`, which disposes + // the tree itself; this release covers the rejections `boot` cannot see — a + // plugin's detached async work rejecting while mounting is still in flight + // or after the tree settled. Disposing the tree runs the TUI's own shutdown, + // which stops the terminal and hands the shell back; without it such a + // failure returns to a corrupted prompt. `app.current` is captured from + // boot's `prepare` hook, so it holds the root context for the whole mounting + // window rather than only after boot resolves. + installFailLoud(NAME, process, async () => { + await app.current?.fiber.dispose() + }) // Resume always enters the default surface because meta rejects // parent options, including `--resume`. The resumed session already persists // its cwd. @@ -219,6 +231,10 @@ export async function runTui( bootConfig, patches, (hostCtx) => { + // Runs after the Loader installs and before any config-tree entry mounts, + // so the fail-loud release hook can reach the tree for the whole window in + // which an entry may reject. + app.current = hostCtx // The launcher owns session identity and the exit line: a config-mounted // app bundle reads both from these slots, so no cordis.yml key can drop // resume. diff --git a/apps/cli/tests/fixtures/tui-invalid-provider.cordis.yml b/apps/cli/tests/fixtures/tui-invalid-provider.cordis.yml new file mode 100644 index 0000000000..f03a58d5d7 --- /dev/null +++ b/apps/cli/tests/fixtures/tui-invalid-provider.cordis.yml @@ -0,0 +1,10 @@ +# An overlay whose `llm-pi-ai` config fails schema validation: `providers` is a +# dict keyed by provider name, and a list is the shape users reach for. The +# entry rejects while `ui-tui` — mounted concurrently by the Loader — already +# holds the terminal, which is the boot failure the fail-loud release hook +# exists for. +- id: llm-pi-ai + config: + providers: + - provider: openai + apiKey: keyless-invalid-shape diff --git a/apps/cli/tests/fixtures/tui-scripted-llm.ts b/apps/cli/tests/fixtures/tui-scripted-llm.ts index 79c4f0504a..c9bd2e1fe6 100644 --- a/apps/cli/tests/fixtures/tui-scripted-llm.ts +++ b/apps/cli/tests/fixtures/tui-scripted-llm.ts @@ -117,8 +117,19 @@ class ScriptedTuiAdapter extends LlmAdapter { const blocks = lastMessage?.content ?? [] if (blocks.some(block => block.type === 'tool-result')) { - const answered = blocks.some(block => block.type === 'tool-result' && block.toolCallId === BASH_FAILURE_CALL_ID) - for (const chunk of textChunks(answered ? BASH_FAILURE_TEXT : FINAL_TEXT)) yield chunk + const answeredBash = blocks.some(block => + block.type === 'tool-result' && block.toolCallId === BASH_FAILURE_CALL_ID) + if (answeredBash) { + for (const chunk of textChunks(BASH_FAILURE_TEXT)) yield chunk + return + } + const toolResultText = blocks.flatMap(block => block.type === 'tool-result' + ? block.content.flatMap(content => content.type === 'text' ? [content.text] : []) + : []).join('\n') + if (toolResultText !== '{"answers":[{"id":"mode","selected":["Safe"],"custom":"Release notes"}]}') { + throw new Error(`the scripted TUI request received an unexpected question answer: ${toolResultText}`) + } + for (const chunk of textChunks(FINAL_TEXT)) yield chunk return } if (lastText.includes(BASH_FAILURE_PROBE)) { @@ -140,6 +151,7 @@ class ScriptedTuiAdapter extends LlmAdapter { id: 'mode', header: 'Execution mode', question: 'How should the scripted run proceed?', + multi_select: true, options: [ { label: 'Safe', description: 'Use the guarded path.' }, { label: 'Fast', description: 'Use the shorter path.' }, diff --git a/apps/cli/tests/snapshots/queued-manual-compact/terminal.expected.txt b/apps/cli/tests/snapshots/queued-manual-compact/terminal.expected.txt index 0df46a7018..1d9c7048c6 100644 --- a/apps/cli/tests/snapshots/queued-manual-compact/terminal.expected.txt +++ b/apps/cli/tests/snapshots/queued-manual-compact/terminal.expected.txt @@ -1,7 +1,7 @@ -terminal 100x36 buffer=normal length=66 base=30 viewport=30 +terminal 100x36 buffer=normal length=68 base=32 viewport=32 lifecycle started=1 stopped=0 progress=inactive title "Reply with exactly the word: — DSH TUI snapshot" -cursor hidden column=7 viewportRow=35 bufferRow=65 +cursor hidden column=7 viewportRow=35 bufferRow=67 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-magenta bold @@ -73,56 +73,60 @@ buffer style 0-64 dim 37| "/compact — Compact older conversation history " style 0-44 dim -38| "/exit — Exit after the active turn reaches idle " +38| "/details [collapsed|expanded|hidden] [reasoning [on|off]] — Select tool-card visibility and " + style 0-99 dim +39| "reasoning display " + style 0-16 dim +40| "/exit — Exit after the active turn reaches idle " style 0-46 dim -39| "/help — Show keyboard shortcuts and commands " +41| "/help — Show keyboard shortcuts and commands " style 0-43 dim -40| "/model [[provider/]model] — Show or switch this session's model " +42| "/model [[provider/]model] — Show or switch this session's model " style 0-62 dim -41| "/palette — Show every color and attribute role this terminal renders " +43| "/palette — Show every color and attribute role this terminal renders " style 0-67 dim -42| "/quit — Exit after the active turn reaches idle " +44| "/quit — Exit after the active turn reaches idle " style 0-46 dim -43| "/reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) " +45| "/reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) " style 0-87 dim -44| "/resume — List this workspace's resumable sessions " +46| "/resume — List this workspace's resumable sessions " style 0-49 dim -45| "/status — Show session diagnostics, system prompt, and registered tools " +47| "/status — Show session diagnostics, system prompt, and registered tools " style 0-70 dim -46| "/skill: [instructions] — load a skill into the conversation " +48| "/skill: [instructions] — load a skill into the conversation " style 0-64 dim -47| -48| "Context · snapshot-injector" +49| +50| "Context · snapshot-injector" style 0-26 dim -49| "Injected while compaction was running. " +51| "Injected while compaction was running. " style 0-37 dim -50| -51| "… earlier context was compacted … " - style 0-32 dim 52| -53| "You " +53| "… earlier context was compacted … " + style 0-32 dim +54| +55| "You " style 0-2 fg=bright-magenta bold underline -54| "Reply with exactly the word: TWO. No tools. " -55| -56| "Compacted 2 history items (~387 tokens). " - style 0-39 dim +56| "Reply with exactly the word: TWO. No tools. " 57| -58| "Assistant " +58| "Compacted 2 history items (~387 tokens). " + style 0-39 dim +59| +60| "Assistant " style 0-8 fg=bright-magenta bold underline -59| "Reasoning " +61| "Reasoning " style 0-8 dim italic -60| "The user wants me to reply with exactly the word \"TWO\" and no tools. " +62| "The user wants me to reply with exactly the word \"TWO\" and no tools. " style 0-67 dim italic -61| "TWO " -62| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " +63| "TWO " +64| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " style 0-46 dim -63| -64| "/workspace/project deepseek-v4-flash ↑2.9k ↓41 cache 49% 3% cont" +65| +66| "/workspace/project deepseek-v4-flash ↑2.9k ↓41 cache 49% 3% cont" style 0-49 fg=bright-magenta bold style 52-68 dim style 71-90 dim style 93-99 dim -65| " dsh ◍ " +67| " dsh ◍ " style 1-3 fg=bright-magenta bold style 5-6 dim style 7-7 inverse diff --git a/apps/cli/tests/tui-keyless-smoke.e2e.ts b/apps/cli/tests/tui-keyless-smoke.e2e.ts index f61d37360b..17b33f37ce 100644 --- a/apps/cli/tests/tui-keyless-smoke.e2e.ts +++ b/apps/cli/tests/tui-keyless-smoke.e2e.ts @@ -26,6 +26,9 @@ const dshBinScript = fileURLToPath(new URL('../src/bin.ts', import.meta.url)) // `--config` layers an overlay over the shared base, so the default surface // needs no config argument at all; these are the overlays under test. const scriptedConfigPath = fileURLToPath(new URL('./fixtures/tui-scripted.cordis.yml', import.meta.url)) +// An overlay whose `llm-pi-ai` config fails validation, so an entry rejects +// while the TUI already holds the terminal. +const invalidProviderConfigPath = fileURLToPath(new URL('./fixtures/tui-invalid-provider.cordis.yml', import.meta.url)) const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) const firstRunSnapshots = fileURLToPath(new URL('./tui-first-run-snapshots/', import.meta.url)) const synchronizedFrameEnd = '\x1b[?2026l' @@ -256,6 +259,7 @@ const SELECT_PRO_MODEL = [ { waitFor: 'scripted TUI ready.', send: '/model\r' }, { waitFor: 'Select model', send: '\x1b[B\x1b[Z\r' }, ] as const +const ANSWER_MULTI_WITH_CUSTOM = ' \tRelease notes\r' describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { it.each([ @@ -414,6 +418,28 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { expect(output).toContain('\u001B[?2004l') }, PTY_SMOKE_TEST_TIMEOUT_MS) + // The Loader mounts entries concurrently, so `ui-tui` can already own the + // terminal when a sibling entry rejects on its config. Exiting without the + // tree's own teardown left raw mode and bracketed paste set on the user's + // shell, and the pending Device Attributes reply landed there as literal + // text. The transactional mount must settle (an HMR initial-scan refresh + // once deadlocked its rollback into a silent exit 13) so `boot` disposes + // the tree — reaching the TUI's own shutdown — and rejects with the + // labelled diagnostic. + it('restores the terminal when a sibling entry fails to validate during boot', async () => { + const output = await smoke({ + label: 'dsh invalid provider config', + tempDirPrefix: 'dsh-tui-invalid-config-', + configPath: invalidProviderConfigPath, + expectedExitCode: 1, + }) + expect(output).toContain('dsh: plugin tree failed to load:') + expect(output).toContain('$.providers') + // Bracketed paste is disabled again, which only `ProcessTerminal.stop()` + // writes — proof the tree was disposed rather than exited out from under. + expect(output).toContain('\u001B[?2004l') + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('switches models, streams a response, answers a user-question dialog, and exits cleanly', async () => { const output = await smoke({ label: 'dsh conversation', @@ -425,7 +451,10 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { // The question text first appears in the streamed tool-call card. Wait // for the dialog's input legend so Enter cannot arrive before it owns // terminal input when pre-dispatch policy yields. - { waitFor: 'Tab custom answer • ↑/↓ navigate • Enter submit • Esc interrupt', send: '\r' }, + { + waitFor: 'Tab custom answer • ↑/↓ navigate • Space toggle • Enter submit • Esc interrupt', + send: ANSWER_MULTI_WITH_CUSTOM, + }, { waitFor: 'Decision received. Scripted TUI run complete.', send: '' }, // Session title: the first user message drives the first-message-llm // provider's tool-less title call; the scripted adapter answers it, the @@ -451,6 +480,7 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { expect(output).not.toContain('\u001B[999CMODEL_CURSOR') expect(output).not.toContain('\u009B31mMODEL_C1') expect(output).toContain('Safe') + expect(output).toContain('Release notes') expect(output).toContain('\u001B]0;scripted session title — DeepSeek Harness\u0007') expect(output).toContain('Session status') expect(output).toContain('Title') @@ -836,7 +866,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { actions: [ ...SELECT_PRO_MODEL, { waitFor: 'Model selected: tui-scripted/tui-scripted-model-pro.', send: 'exercise the TUI\r' }, - { waitFor: 'How should the scripted run proceed?', send: '\r' }, + { waitFor: 'How should the scripted run proceed?', send: ANSWER_MULTI_WITH_CUSTOM }, { waitFor: 'Decision received. Scripted TUI run complete.', send: '/exit\r' }, ], inspect: async (cwd) => { context = await readLoggedRequestContext(cwd) }, diff --git a/apps/web/tests/bash-abort-row.e2e.ts b/apps/web/tests/bash-abort-row.e2e.ts new file mode 100644 index 0000000000..717cebdd36 --- /dev/null +++ b/apps/web/tests/bash-abort-row.e2e.ts @@ -0,0 +1,82 @@ +// Web e2e scenario: a cancelled Bash call can settle without terminal-card +// material. Borrow the real cancellation fixture and prove the keyed Bash row +// still exposes the recorded command and full error without any model call. +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, + launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +const FIXTURE = fileURLToPath(new URL('../../../examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl', import.meta.url)) +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/bash-abort-row', import.meta.url)) +const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md') +const MODE = webSnapshotMode() +const SEED_ID = 'bash-abort-row-web-e2e' +const PROMPT = 'Run two shell commands: wait for cancellation, then write skipped.txt.' + +describe.skipIf(MODE === 'record')('web e2e: cancelled Bash row disclosure', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + const fixture = await readFile(FIXTURE, 'utf8') + expect(fixtureUserPrompts(fixture)).toEqual([PROMPT]) + scaffold = await launchWebScaffold({}) + await seedSession(scaffold, fixture, SEED_ID) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + + const groupRow = page.locator('[role="treeitem"]').first() + await groupRow.waitFor({ timeout: 15_000 }) + await groupRow.click() + const sessionRow = page.locator('[role="treeitem"]').nth(1) + await sessionRow.waitFor({ timeout: 10_000 }) + await sessionRow.click() + await page.locator('[data-sample="bash"]').nth(1).waitFor({ timeout: 15_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('expands the aborted row to its command and full error', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-bash-abort-row')) + const row = page.locator('[data-sample="bash"]').first() + const call = row.locator('xpath=..') + await expect.poll(() => row.getAttribute('aria-expanded')).toBe('false') + await expect.poll(() => call.getByText('Error: command aborted', { exact: true }).count()).toBe(1) + await row.click() + + await expect.poll(() => row.getAttribute('aria-expanded')).toBe('true') + await call.getByText('IN', { exact: true }).waitFor() + await call.getByText('OUT', { exact: true }).waitFor() + await call.getByText('Wait until cancellation', { exact: false }).waitFor() + await call.getByText('setInterval(() => {}, 1000)', { exact: false }).waitFor() + await expect.poll(() => call.getByText('Error: command aborted', { exact: true }).count()).toBe(2) + + const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) + // The borrowed fixture's UTC date is still the previous day in PDT; + // the disclosure golden must not depend on the runner timezone. + .replace(/\b\d{1,2}\/\d{1,2}(?= \{\{clock\}\})/g, '{{date}}') + .split(SEED_ID).join('{{seededId}}') + await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 60_000) + + it('keeps its snapshot inventory closed', async () => { + await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md']) + }) +}) diff --git a/apps/web/tests/built-boot.snapshot.ts b/apps/web/tests/built-boot.snapshot.ts index 707d7b72a0..bdf0d4f7d1 100644 --- a/apps/web/tests/built-boot.snapshot.ts +++ b/apps/web/tests/built-boot.snapshot.ts @@ -6,10 +6,10 @@ // layers, per-plugin CSS injection, and a rendered journey reaching chat // content from the keyless FixtureApiClient transport. // -// Behavior assertions do NOT belong here: component and wiring behavior is -// pinned by the per-package suites (SlotTestRuntime benches over src), which -// this smoke's plugin set cannot influence — bundling, module-table -// resolution, and boot layering are the only failure modes left to it. +// Component behavior remains owned by per-package suites (SlotTestRuntime +// benches over src). This smoke additionally pins the resident approval +// fixture's cross-plugin projection because only the built connection/runtime/ +// workspace graph can prove that transport-to-row path end to end. import { readFileSync } from 'node:fs' import { join } from 'node:path' import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react' @@ -105,8 +105,17 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) await within(tree).findByText('4 sessions') + // The resident approval fixture proves the assembled workspace plugin + // distinguishes a blocked running session from an ordinarily busy one. + const waitingTitle = await within(tree).findByText('Fixture 历史会话') + const waitingRow = waitingTitle.closest('[role="treeitem"]') + if (waitingRow === null) throw new Error('fixture Session title must belong to a tree row') + expect(waitingRow.querySelector('[data-state="warning"]')).not.toBeNull() + expect(waitingRow.querySelector('[data-state="ongoing"]')).toBeNull() + within(waitingRow).getByText('Waiting for approval') + // Opening a session reaches chat content through the fixture transport. - fireEvent.click(await within(tree).findByText('Fixture 历史会话')) + fireEvent.click(waitingTitle) await waitFor(() => { expect(document.querySelector('[data-sample="bash"]')).not.toBeNull() }, { timeout: 10_000 }) diff --git a/apps/web/tests/core-web-profile.snapshot.ts b/apps/web/tests/core-web-profile.snapshot.ts index 5f3334c0e7..58f2a34858 100644 --- a/apps/web/tests/core-web-profile.snapshot.ts +++ b/apps/web/tests/core-web-profile.snapshot.ts @@ -72,7 +72,6 @@ describe('core Web profile', () => { "tools": [ "bash", "str_replace_editor", - "list_agents", ], } `) diff --git a/apps/web/tests/markdown-images.e2e.ts b/apps/web/tests/markdown-images.e2e.ts new file mode 100644 index 0000000000..8dce7f405b --- /dev/null +++ b/apps/web/tests/markdown-images.e2e.ts @@ -0,0 +1,205 @@ +// Web e2e scenario: absolute HTTP(S) Markdown images. A validated session +// assembled through the Session API is seeded cold into the real web +// composition, then a separate image origin proves that the browser receives +// a real network image while local-path Markdown remains inert alt text. +import { createServer, type Server } from 'node:http' +import { fileURLToPath } from 'node:url' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import { + SESSION_FORMAT_VERSION, + Session, + SessionId, +} from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-session-title' +import { + assertFixtureInventory, + captureStableAria, + compareOrRefreshGolden, + launchWebScaffold, + seedSession, + watchConsole, + webSnapshotMode, + type WebScaffold, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/markdown-images', import.meta.url)) +const UI_EXPECTED = fileURLToPath(new URL('./snapshots/markdown-images/ui.expected.md', import.meta.url)) +const MODE = webSnapshotMode() +const SEED_ID = 'markdown-images-web-e2e' +const REMOTE_ALT = 'Remote test image' +const LOCAL_ALT = 'Local test image' +const PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', + 'base64', +) + +interface ImageOrigin { + server: Server + url: string + requests: Array<{ path: string | undefined; referer: string | undefined }> +} + +/** Start the deterministic remote image origin used by this browser scenario. */ +async function startImageOrigin(): Promise { + const requests: ImageOrigin['requests'] = [] + const server = createServer((request, response) => { + requests.push({ path: request.url, referer: request.headers.referer }) + response.writeHead(200, { + 'cache-control': 'no-store', + 'content-length': PNG.length, + 'content-type': 'image/png', + }) + response.end(PNG) + }) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolve) + }) + const address = server.address() + if (address === null || typeof address === 'string') { + throw new Error('image origin did not expose an IP socket') + } + return { + server, + url: `http://127.0.0.1:${String(address.port)}/image.png`, + requests, + } +} + +/** Stop one image origin after the browser and host release their requests. */ +async function stopServer(server: Server): Promise { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error === undefined) resolve() + else reject(error) + }) + }) +} + +/** Build one closed, invariant-checked session fixture with remote and local image Markdown. */ +function markdownImageFixture(remoteUrl: string): string { + const session = new Session(SessionId('markdown-image-source')) + session.append('turn/start', { + turn: 1, + trigger: { kind: 'message', source: { kind: 'user' } }, + }) + const user = session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'Show the Markdown image policy.' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + session.append('session/title', { + title: 'Markdown image policy', + messageSeqs: [user.seq], + source: { kind: 'fallback' }, + }) + session.append('step/start', { turn: 1, step: 1 }) + session.append('assistant/message', { + turn: 1, + step: 1, + message: createMessage({ + role: 'assistant', + content: [{ + type: 'text', + text: [ + '## Markdown images', + '', + `![${REMOTE_ALT}](${remoteUrl})`, + '', + `![${LOCAL_ALT}](./local-image.png)`, + '', + 'REMOTE_IMAGE_DONE', + ].join('\n'), + }], + source: { kind: 'model', provider: 'fixture', model: 'fixture' }, + }), + }, { surfaceOp: 'append' }) + session.append('step/end', { turn: 1, step: 1 }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + + const header = { + type: 'session', + version: SESSION_FORMAT_VERSION, + id: '{{sessionId}}', + createdAt: 0, + cwd: '{{cwd}}', + } + return [ + JSON.stringify(header), + ...session.events.map(event => JSON.stringify(event)), + '', + ].join('\n') +} + +describe('web e2e: remote Markdown image rendering', () => { + let scaffold: WebScaffold + let imageOrigin: ImageOrigin + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + imageOrigin = await startImageOrigin() + scaffold = await launchWebScaffold({}) + await seedSession(scaffold, markdownImageFixture(imageOrigin.url), SEED_ID) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + await stopServer(imageOrigin.server) + }) + + it.skipIf(MODE === 'record')('loads only the remote image and matches the conversation golden', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-images')) + const groupRow = page.locator('[role="treeitem"]').first() + await groupRow.waitFor({ timeout: 15_000 }) + await groupRow.click() + const sessionRow = page.locator('[role="treeitem"]').nth(1) + await sessionRow.waitFor({ timeout: 10_000 }) + await sessionRow.click() + await expect.poll(() => page.getByText('REMOTE_IMAGE_DONE', { exact: true }).count(), { + timeout: 15_000, + }).toBe(1) + + const image = page.getByRole('img', { name: REMOTE_ALT }) + await image.waitFor({ timeout: 10_000 }) + await expect.poll(() => image.evaluate(element => (element as HTMLImageElement).naturalWidth), { + timeout: 10_000, + }).toBeGreaterThan(0) + expect(await image.evaluate((element) => { + const computed = getComputedStyle(element) + return { + borderRadius: computed.borderRadius, + decoding: element.getAttribute('decoding'), + loading: element.getAttribute('loading'), + maxWidth: computed.maxWidth, + referrerPolicy: element.getAttribute('referrerpolicy'), + } + })).toEqual({ + borderRadius: '8px', + decoding: 'async', + loading: 'lazy', + maxWidth: '100%', + referrerPolicy: 'no-referrer', + }) + expect(await page.getByRole('img', { name: LOCAL_ALT }).count()).toBe(0) + expect(await page.getByText(LOCAL_ALT, { exact: true }).count()).toBe(1) + expect(imageOrigin.requests).toEqual([{ path: '/image.png', referer: undefined }]) + + const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) + .split(SEED_ID).join('{{seededId}}') + await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md']) + }, 60_000) +}) diff --git a/apps/web/tests/message-actions.e2e.ts b/apps/web/tests/message-actions.e2e.ts index bb58ccefc4..aa64816420 100644 --- a/apps/web/tests/message-actions.e2e.ts +++ b/apps/web/tests/message-actions.e2e.ts @@ -1,7 +1,7 @@ -// Web e2e scenario: message IconActions + clocks. Cold-seeds the seeded-history -// fixture (zero model calls) and pins the settled conversation aria after the -// user/assistant footers are focus-revealed — the surface package jsdom tests -// cannot substitute for (docs/testing.md snapshot rule). +// Web e2e scenario: message IconActions + clocks. Cold-seeds a deterministic +// completed-turn-tail fork case (zero model calls) and pins the settled +// conversation aria after the footers are focus-revealed — the surface package +// jsdom tests cannot substitute for (docs/testing.md snapshot rule). import { mkdir, readFile, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -25,6 +25,48 @@ const MODE = webSnapshotMode() const SEED_ID = 'message-actions-web-e2e' const PROMPT = 'Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop.' +const MID_TURN_TEXT = 'I will read both files before answering.' +const SECOND_PROMPT = 'Now give the final answer.' + +/** + * Adapt the borrowed recording into response -> tools -> interrupted Think, + * followed by one ordinary completed response. The first response keeps + * copy/clock but is not a legal branch point; the second is the real turn tail. + * @param raw - Recorded seeded-history JSONL. + * @returns A contiguous, closed two-turn fixture. + */ +function completedTailFixture(raw: string): string { + const kept: string[] = [] + for (const line of raw.trimEnd().split('\n')) { + const row = JSON.parse(line) as { + type: string + seq?: number + seq0?: number + data?: { content?: unknown[] } + } + const firstSeq = row.seq ?? row.seq0 + if (firstSeq !== undefined && firstSeq >= 101) break + if (row.type === 'assistant/message' && row.seq === 64) { + const content = row.data?.content + if (!Array.isArray(content)) throw new Error('borrowed step-one assistant message has no content') + content.splice(1, 0, { type: 'text', text: MID_TURN_TEXT }) + kept.push(JSON.stringify(row)) + } else { + kept.push(line) + } + } + const tail = [ + { type: 'step/end', seq: 101, time: 1784974102749, data: { turn: 1, step: 2 } }, + { type: 'turn/end', seq: 102, time: 1784974102750, data: { turn: 1, reason: { kind: 'aborted' } } }, + { type: 'turn/start', seq: 103, time: 1784974103000, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user', rpcId: '{{rpcId}}' } } } }, + { type: 'user/message', seq: 104, time: 1784974103001, data: { content: [{ type: 'text', text: SECOND_PROMPT }], source: { kind: 'user', rpcId: '{{rpcId}}' } }, surfaceOp: 'append' }, + { type: 'step/start', seq: 105, time: 1784974103002, data: { turn: 2, step: 1 } }, + { type: 'assistant/message', seq: 106, time: 1784974103003, data: { turn: 2, step: 1, content: [{ type: 'text', text: 'DONE' }], provenance: { provider: 'deepseek-official', model: 'deepseek-v4-flash' } }, sourceEventSeqs: [], surfaceOp: 'append' }, + { type: 'step/end', seq: 107, time: 1784974103004, data: { turn: 2, step: 1 } }, + { type: 'turn/end', seq: 108, time: 1784974103005, data: { turn: 2, reason: { kind: 'completed' } } }, + ] + return `${[...kept, ...tail.map(row => JSON.stringify(row))].join('\n')}\n` +} describe('web e2e: message IconActions and clocks on settled history', () => { let scaffold: WebScaffold @@ -38,8 +80,8 @@ describe('web e2e: message IconActions and clocks on settled history', () => { await mkdir(sessionCwd, { recursive: true }) await writeFile(join(sessionCwd, 'a.txt'), 'alpha\n') await writeFile(join(sessionCwd, 'b.txt'), 'beta\n') - const raw = await readFile(SEED, 'utf8') - expect(fixtureUserPrompts(raw), 'borrowed seed must carry the drive prompt').toEqual([PROMPT]) + const raw = completedTailFixture(await readFile(SEED, 'utf8')) + expect(fixtureUserPrompts(raw), 'adapted seed must carry both prompts').toEqual([PROMPT, SECOND_PROMPT]) await seedSession(scaffold, raw, SEED_ID) browser = await chromium.launch() page = await newEnglishPage(browser) @@ -53,7 +95,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => { await scaffold?.close() }) - it.skipIf(MODE === 'record')('lists the seeded session and reveals user/assistant IconActions', async () => { + it.skipIf(MODE === 'record')('enables branch only on the completed transcript tail', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-message-actions')) const groupRow = page.locator('[role="treeitem"]').first() await groupRow.waitFor({ timeout: 15_000 }) @@ -61,16 +103,24 @@ describe('web e2e: message IconActions and clocks on settled history', () => { const sessionRow = page.locator('[role="treeitem"]').nth(1) await sessionRow.waitFor({ timeout: 10_000 }) await sessionRow.click() + await expect.poll(() => page.getByText(MID_TURN_TEXT, { exact: true }).count(), { timeout: 15_000 }).toBe(1) await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBe(1) // Focus-reveal the footers (hover:hover keeps them opacity-hidden until - // hover/focus-within). User and each turn's last content assistant both - // have copy + branch. + // hover/focus-within). Every durable message footer keeps branch visible, + // but only the final assistant at a completed transcript tail enables it. const copyButtons = page.getByRole('button', { name: 'Copy' }) - await expect.poll(() => copyButtons.count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2) + await expect.poll(() => copyButtons.count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(4) await copyButtons.first().focus() - await expect.poll(() => page.getByRole('button', { name: 'Branch into a new conversation' }).count(), { timeout: 5_000 }) - .toBeGreaterThanOrEqual(2) + const branchButtons = page.getByRole('button', { name: 'Branch into a new conversation' }) + await expect.poll(() => branchButtons.count(), { timeout: 5_000 }).toBe(4) + await expect.poll( + () => branchButtons.evaluateAll(buttons => buttons.map(button => button.getAttribute('aria-disabled'))), + { timeout: 5_000 }, + ).toEqual(['true', 'true', 'true', null]) + await branchButtons.first().focus() + await expect.poll(() => page.getByRole('tooltip').textContent(), { timeout: 5_000 }) + .toBe('Available only on the last message of a completed turn') await expect.poll(() => page.getByRole('button', { name: 'Edit' }).count(), { timeout: 5_000 }).toBe(0) }, 60_000) @@ -89,8 +139,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => { it.skipIf(MODE === 'record')('forks through the settled-message and session-row actions', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-message-fork')) - // Exercise the assistant action specifically; package coverage pins the - // user action separately at its own event seq. + // The last message action belongs to the completed second-turn assistant. await page.getByRole('button', { name: 'Branch into a new conversation' }).last().click() await expect.poll( () => scaffold.ctx.agents.list().find(agent => agent.session.header.parentSession === SessionId(SEED_ID)), diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index 35f77b96ec..4f13ea21cf 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -138,6 +138,23 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-trajectory')) await page.getByRole('tab', { name: 'Trajectory' }).click() await page.waitForTimeout(100) + const overlayLayout = await page.getByRole('table').evaluate((table) => { + const host = table.closest('[data-conversation-scroll]') + const seat = host?.querySelector('[data-composer-seat]') ?? null + const pane = table.parentElement + return { + hostPosition: host === null ? null : getComputedStyle(host).position, + paneOverflowX: pane === null ? null : getComputedStyle(pane).overflowX, + paneScrollableWidth: pane === null ? null : pane.scrollWidth - pane.clientWidth, + seatPosition: seat === null ? null : getComputedStyle(seat).position, + } + }) + expect(overlayLayout).toEqual({ + hostPosition: 'relative', + paneOverflowX: 'hidden', + paneScrollableWidth: 0, + seatPosition: 'absolute', + }) expect({ pageErrors: tripwire.pageErrors, slotErrors, @@ -153,6 +170,8 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { await page.locator('tr[data-kind="tool"]').first().click() const details = page.getByRole('complementary', { name: 'Event details' }) await expect.poll(() => details.count(), { timeout: 10_000 }).toBe(1) + expect(await details.getByRole('tabpanel').evaluate(panel => getComputedStyle(panel).overflowX)) + .toBe('hidden') await page.evaluate(() => { document.body.setAttribute('data-ds-dark-theme', '') }) const darkSummarySurfaces = await details.getByRole('heading', { name: 'Payload' }).evaluate(heading => ({ heading: getComputedStyle(heading).backgroundColor, @@ -162,6 +181,17 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { await page.evaluate(() => { document.body.removeAttribute('data-ds-dark-theme') }) await page.getByRole('tab', { name: 'Result' }).click() await expect.poll(() => page.getByText('NAVIGATION_OK', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1) + const assistantSpan = page.locator('[data-timeline-span="message"][data-assistant-timing="true"]').first() + await assistantSpan.hover() + const timingTooltip = page.getByRole('tooltip') + await timingTooltip.waitFor({ timeout: 5_000 }) + await expect.poll(() => timingTooltip.textContent(), { timeout: 5_000 }).toMatch(/TTFT .* Decoding/) + const assistantTimingStyle = await assistantSpan.evaluate(node => ({ + background: getComputedStyle(node).backgroundImage, + ttft: getComputedStyle(node).getPropertyValue('--trajectory-assistant-ttft'), + })) + expect(assistantTimingStyle.background).toContain('linear-gradient') + expect(assistantTimingStyle.ttft).toMatch(/%$/) const snapshot = (await captureStableAria(page, '[class*="viewArea"]', scaffold.workspaceCwd)) .split(SEED_ID).join('{{seededId}}') await compareOrRefreshGolden(TRAJECTORY_EXPECTED, snapshot, MODE) diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index 37f6964c73..ebbaf759c4 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -23,15 +23,16 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/question-composer', import.meta.url)) const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md') -// Second golden: the answered transcript — the question resolved into its -// tool round trip and the final reply, the state the waiting golden cannot see. +const COMPOSED_EXPECTED = join(SNAPSHOT_DIR, 'composed.expected.md') +// Final golden: the answered transcript — the question resolved into its tool +// round trip and the final reply, the state the composer goldens cannot see. const ANSWERED_EXPECTED = join(SNAPSHOT_DIR, 'answered.expected.md') const MODE = webSnapshotMode() // The options carry long descriptions on purpose: the squeeze assertion below // needs option copy that WRAPS, which is the only shape that reproduces a // collapsed row painting its copy outside its own box. -const PROMPT = 'Use the ask_user_question tool to ask me exactly one question with id "color", question "Which color do you prefer?", header "Pick one", and two options: label "Blue" with description "A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.", and label "Green" with description "A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions." After I answer, reply with the single word DONE and stop.' +const PROMPT = 'Use the ask_user_question tool to ask me exactly one multi-select question with id "color", question "Which color do you prefer?", header "Pick one", and two options: label "Blue" with description "A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.", and label "Green" with description "A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions." Set multi_select to true. After I answer, reply with the single word DONE and stop.' describe('web e2e: resident question composer round trip', () => { let scaffold: WebScaffold @@ -124,9 +125,17 @@ describe('web e2e: resident question composer round trip', () => { await page.setViewportSize(original) } - await composer.getByRole('radio', { name: 'Blue' }).click() - // Submit: Enter on the focused option (the composer's documented submit). - await composer.getByRole('radio', { name: 'Blue' }).press('Enter') + const blue = composer.getByRole('checkbox', { name: 'Blue' }) + await blue.click() + const custom = composer.getByRole('textbox') + await custom.fill('Include accessibility notes') + expect(await blue.getAttribute('aria-checked')).toBe('true') + expect(await custom.inputValue()).toBe('Include accessibility notes') + if (MODE !== 'record') { + const snapshot = await captureStableAria(page, '[data-question-key]', scaffold.workspaceCwd) + await compareOrRefreshGolden(COMPOSED_EXPECTED, snapshot, MODE) + } + await custom.press('Enter') const sessionId = await settled if (MODE === 'record') { @@ -135,7 +144,14 @@ describe('web e2e: resident question composer round trip', () => { } // World state: the tool result carries the chosen answer, and DONE lands. const results = sessionEvents.filter(e => e.type === 'tool/result') - expect(JSON.stringify(results.at(-1))).toContain('Blue') + const answerText = results.flatMap(event => event.data.message.content.flatMap(block => + block.type === 'tool-result' + ? block.content.filter(item => item.type === 'text').map(item => item.text) + : [], + )).at(-1) + expect(JSON.parse(answerText ?? '')).toEqual({ + answers: [{ id: 'color', selected: ['Blue'], custom: 'Include accessibility notes' }], + }) await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1) // Composer gone; regular input restored. expect(await page.locator('[data-question-key]').count()).toBe(0) @@ -149,6 +165,11 @@ describe('web e2e: resident question composer round trip', () => { }, 200_000) it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { - await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'ui.expected.md', 'answered.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, [ + 'session.jsonl', + 'ui.expected.md', + 'composed.expected.md', + 'answered.expected.md', + ]) }) }) diff --git a/apps/web/tests/remote-welcome.e2e.ts b/apps/web/tests/remote-welcome.e2e.ts new file mode 100644 index 0000000000..483d6c369c --- /dev/null +++ b/apps/web/tests/remote-welcome.e2e.ts @@ -0,0 +1,53 @@ +// Trusted non-loopback Web access must not wedge on the loopback-only +// settings API while the mandatory product notice owns the viewport. +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { + acknowledgeReloadConnectionLoss, launchWebScaffold, watchConsole, webSnapshotMode, + type WebScaffold, +} from './scaffold.ts' +import { ZH_BROWSER_LOCALE } from './support.ts' +import { WELCOME_NOTICE_COPY } from '@deepseek-ai/dsh-client-ui-settings-general' + +const MODE = webSnapshotMode() + +describe.skipIf(MODE === 'record')('web e2e: remote welcome notice', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({ remoteAuthority: 'remote.localhost', welcomeNoticePending: true }) + browser = await chromium.launch() + page = await browser.newPage({ viewport: { width: 1440, height: 960 }, locale: ZH_BROWSER_LOCALE }) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('#root', { timeout: 30_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('advances process-locally and presents the notice again after reload', async () => { + const welcome = page.getByRole('region', { name: WELCOME_NOTICE_COPY.zh.title }) + await welcome.waitFor({ timeout: 15_000 }) + expect(await page.locator('#root').evaluate(root => (root as HTMLElement).inert)).toBe(true) + + await welcome.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel }).click() + await welcome.waitFor({ state: 'detached', timeout: 15_000 }) + await expect.poll( + () => page.locator('#root').evaluate(root => (root as HTMLElement).inert), + { timeout: 15_000 }, + ).toBe(false) + const reloadWarnings = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + acknowledgeReloadConnectionLoss(tripwire, reloadWarnings) + await welcome.waitFor({ timeout: 15_000 }) + expect(tripwire.warnings).toEqual([]) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) +}) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 1b8afbb247..8316e75e3d 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -89,7 +89,7 @@ const REPLAY_PROVIDERS = [{ export interface WebScaffold { /** The active snapshot mode this scaffold booted under. */ mode: WebSnapshotMode - /** Browser-facing origin (http://127.0.0.1:). */ + /** Browser-facing origin for the bound test server. */ baseUrl: string /** Settled root context (the in-process barrier seam; headless event subscription is its sanctioned use). */ ctx: Context @@ -166,6 +166,12 @@ export interface LaunchOptions { } /** Leave the current welcome notice unacknowledged; ordinary scenarios publish it as complete before browser boot. */ welcomeNoticePending?: boolean + /** + * Browse through a trusted non-loopback hostname that the browser resolves + * to loopback (for example `*.localhost`). The test server stays bound to + * 127.0.0.1; a non-resolving authority fails before Host trust is exercised. + */ + remoteAuthority?: string } /** Dispose the booted tree and remove both owned temp roots, reporting every independent cleanup failure. */ @@ -185,6 +191,7 @@ async function cleanupScaffoldWorld(ctx: Context, workspaceCwd: string, persiste export async function launchWebScaffold(options: LaunchOptions = {}): Promise { requireDist() const mode = webSnapshotMode() + const browserHost = options.remoteAuthority ?? '127.0.0.1' if (mode === 'record') { // Both owning vitest configs (web unconditionally, snapshot in record // mode) load the repo-root .env before this file runs. @@ -261,7 +268,13 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise duration.startsWith('~') ? duration : '{{duration}}', + ) + .replace( + /约\d+(?:年(?:\d+个月)?|个月(?:\d+天)?)|\d+(?:天(?:\d+小时(?:\d+分\d+秒)?)?|小时\d+分\d+秒|分\d+秒|(?:\.\d+)?秒)/g, + duration => duration.startsWith('约') ? duration : '{{duration}}', + ) // Message IconActions clocks widen by calendar day/year; collapse every // shape so goldens stay stable across midnight and year boundaries. .replace(/\d{4}年\d{1,2}月\d{1,2}日 \d{2}:\d{2}/g, '{{clock}}') .replace(/\d{1,2}月\d{1,2}日 \d{2}:\d{2}/g, '{{clock}}') + .replace(/(? {}, 1000)\\\"\", \"description\": \"Wait until cancellation\" } OUT Error: command aborted" +- button "Inspect" +- 'button "Failed Bash Error: tool call aborted before dispatch"': + - img + - text: "Failed Bash Error: tool call aborted before dispatch" +- textbox "Message the agent" +- button "Commands": + - img +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model, current deepseek-v4-flash": + - text: deepseek-v4-flash + - img +- button "Send message" [disabled] +- text: 1 turns · 1 steps Tool call {{duration}} Cache hit 0% Input 10 tok · Output 10 tok diff --git a/apps/web/tests/snapshots/code-mode-round/ui.expected.md b/apps/web/tests/snapshots/code-mode-round/ui.expected.md index 8f4c7e5bf2..c6799a2247 100644 --- a/apps/web/tests/snapshots/code-mode-round/ui.expected.md +++ b/apps/web/tests/snapshots/code-mode-round/ui.expected.md @@ -7,8 +7,9 @@ - text: "Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt catching its error in the program. Return an object with both outcomes. Then reply DONE and stop. {{clock}}" - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md index e4d5ac8426..fc6d312821 100644 --- a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md +++ b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md @@ -7,8 +7,9 @@ - text: "Use only Cordis tools. First call cordis_inspect with what \"temporary\". Then call cordis_mount with this exact code: \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\". Read its returned id and call cordis_unmount with that exact id. After all three calls succeed, reply exactly CORDIS_UI_DONE and stop. {{clock}}" - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md index facb7b58cc..17df480cd9 100644 --- a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md +++ b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md @@ -7,8 +7,9 @@ - text: "Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop. {{clock}}" - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md index 5965797c69..5e0b4f73bb 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md @@ -7,8 +7,9 @@ - text: Reply with the single word LIGHTHOUSE and stop. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/live-interactions/cancel.expected.md b/apps/web/tests/snapshots/live-interactions/cancel.expected.md index d1e4d2bbef..d18e061a1a 100644 --- a/apps/web/tests/snapshots/live-interactions/cancel.expected.md +++ b/apps/web/tests/snapshots/live-interactions/cancel.expected.md @@ -7,8 +7,9 @@ - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md index fb9337e978..1a4aec678c 100644 --- a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md +++ b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md @@ -7,8 +7,9 @@ - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/live-interactions/loading.expected.md b/apps/web/tests/snapshots/live-interactions/loading.expected.md index a5dfd08fb5..c50b440f86 100644 --- a/apps/web/tests/snapshots/live-interactions/loading.expected.md +++ b/apps/web/tests/snapshots/live-interactions/loading.expected.md @@ -7,8 +7,9 @@ - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/live-interactions/retry.expected.md b/apps/web/tests/snapshots/live-interactions/retry.expected.md index 6380eaf5c6..d5df08c05e 100644 --- a/apps/web/tests/snapshots/live-interactions/retry.expected.md +++ b/apps/web/tests/snapshots/live-interactions/retry.expected.md @@ -7,8 +7,9 @@ - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/markdown-images/ui.expected.md b/apps/web/tests/snapshots/markdown-images/ui.expected.md new file mode 100644 index 0000000000..58b72e0d65 --- /dev/null +++ b/apps/web/tests/snapshots/markdown-images/ui.expected.md @@ -0,0 +1,31 @@ +- banner: + - navigation "Session hierarchy": + - button "Markdown image policy" [disabled] + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- text: Show the Markdown image policy. {{clock}} +- button "Copy": + - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn +- heading "Markdown images" [level=2] +- paragraph: + - img "Remote test image" +- paragraph: Local test image +- paragraph: REMOTE_IMAGE_DONE +- button "Copy": + - img +- button "Branch into a new conversation": + - img +- text: {{clock}} +- textbox "Message the agent" +- button "Commands": + - img +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model, current deepseek-v4-flash": + - text: deepseek-v4-flash + - img +- button "Send message" [disabled] +- text: 1 turns · 1 steps Input 0 tok · Output 0 tok diff --git a/apps/web/tests/snapshots/message-actions/ui.expected.md b/apps/web/tests/snapshots/message-actions/ui.expected.md index bf67498178..3d085b0931 100644 --- a/apps/web/tests/snapshots/message-actions/ui.expected.md +++ b/apps/web/tests/snapshots/message-actions/ui.expected.md @@ -8,12 +8,19 @@ - button "Copy": - img - tooltip "Copy" -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel.": - img - img - text: Think The user wants me to read a.txt and b.txt, then reply with "DONE". Let me do both reads in parallel. +- paragraph: I will read both files before answering. +- button "Copy": + - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn 7/25 {{clock}} - button "Read a.txt": - img - img @@ -28,6 +35,12 @@ - img - img - text: Think Both files have been read. a.txt contains "alpha" and b.txt contains "beta". I'll now reply with DONE as instructed. +- text: Stopped Now give the final answer. 7/25 {{clock}} +- button "Copy": + - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn - paragraph: DONE - button "Copy": - img @@ -42,4 +55,4 @@ - text: deepseek-v4-flash - img - button "Send message" [disabled] -- text: 1 turns · 2 steps Tool call {{duration}} Cache hit 98% Input 15.8K tok · Output 135 tok +- text: 2 turns · 3 steps Tool call {{duration}} Cache hit 98% Input 7.8K tok · Output 103 tok diff --git a/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md b/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md index 788b8a3233..3572409fc0 100644 --- a/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md +++ b/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md @@ -4,7 +4,8 @@ - button "Collapse calls": Calls - img - searchbox "Search trajectory" -- region "Trajectory timeline" +- region "Trajectory timeline": + - tooltip "ASSISTANT {{clock}} → {{clock}} Total 1.5 s · TTFT 368 ms · Decoding 1.2 s" - table: - rowgroup: - row "SYSTEM, Initial System Prompt": diff --git a/apps/web/tests/snapshots/plan-review/approved.expected.md b/apps/web/tests/snapshots/plan-review/approved.expected.md index 5be3f83247..108ca47986 100644 --- a/apps/web/tests/snapshots/plan-review/approved.expected.md +++ b/apps/web/tests/snapshots/plan-review/approved.expected.md @@ -8,8 +8,9 @@ - text: "plan Plan mode on. Use /plan off to leave. Plan a small change: add a --greeting flag to a CLI. Do not read or write any files. Call exit_plan_mode with a short plan of at most five bullet points. Once the plan is approved, reply with the single word DONE and stop. {{clock}}" - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/question-composer/answered.expected.md b/apps/web/tests/snapshots/question-composer/answered.expected.md index 28297569ab..7fdddfa8a8 100644 --- a/apps/web/tests/snapshots/question-composer/answered.expected.md +++ b/apps/web/tests/snapshots/question-composer/answered.expected.md @@ -4,11 +4,12 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- text: "Use the ask_user_question tool to ask me exactly one question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" After I answer, reply with the single word DONE and stop. {{clock}}" +- text: "Use the ask_user_question tool to ask me exactly one multi-select question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" Set multi_select to true. After I answer, reply with the single word DONE and stop. {{clock}}" - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/question-composer/composed.expected.md b/apps/web/tests/snapshots/question-composer/composed.expected.md new file mode 100644 index 0000000000..c18e6225c6 --- /dev/null +++ b/apps/web/tests/snapshots/question-composer/composed.expected.md @@ -0,0 +1,17 @@ +- region "Which color do you prefer?": + - text: Pick one + - heading "Which color do you prefer?" [level=2] + - button "Dismiss all questions": + - img + - group: + - checkbox "Blue" [checked]: Blue A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards. + - checkbox "Green": Green A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions. + - textbox "Type your answer": Include accessibility notes + - button "Previous question" [disabled]: + - img + - text: 1 / 1 + - button "Next question" [disabled]: + - img + - status + - button "Skip this question" + - button "Submit" diff --git a/apps/web/tests/snapshots/question-composer/session.jsonl b/apps/web/tests/snapshots/question-composer/session.jsonl index 2ac86783c2..7dac5f236d 100644 --- a/apps/web/tests/snapshots/question-composer/session.jsonl +++ b/apps/web/tests/snapshots/question-composer/session.jsonl @@ -1,20 +1,20 @@ {"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785150167878,"cwd":"{{cwd}}/workspace"} {"type":"turn/start","seq":0,"time":1785150167924,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}} -{"type":"user/message","seq":1,"time":1785150167925,"data":{"content":[{"type":"text","text":"Use the ask_user_question tool to ask me exactly one question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" After I answer, reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"} +{"type":"user/message","seq":1,"time":1785150167925,"data":{"content":[{"type":"text","text":"Use the ask_user_question tool to ask me exactly one multi-select question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" Set multi_select to true. After I answer, reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1785150167927,"data":{"title":"Use the ask_user_question tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1785150167928,"data":{"turn":1,"step":1}} {"type":"request/header","seq":4,"time":1785150167929,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":1785150168452,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} {"type":"reasoning-chunks","seq0":6,"time0":1785150168452,"data":{"turn":1,"step":1,"index":0,"dt":[87,26,1,0,0,0,38,0,0,0,0,1,12,27,0,27,0,0,1,25,0],"texts":["The"," user"," wants"," me"," to"," use"," the"," ask","_user","_","question"," tool"," with"," specific"," parameters","."," Let"," me"," do"," exactly"," that","."]}} {"type":"assistant/chunk","seq":28,"time":1785150168775,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":29,"time0":1785150168776,"data":{"turn":1,"step":1,"index":1,"dt":[25,1,0,0,0,25,0,0,0,26,1,0,0,0,0,25,1,0,0,0,0,25,1,0,0,0,0,25,1,0,0,0,1,25,0,0,0,0,1,25,1,0,0,0,0,25,1,0,0,26,0,0,1,0,24,1,0,0,0,1,26,1,0,0,0,0,25,0,1,0,0,0,25,1,0,0,25,0,0,0,0,1,25,0,0,1,0,0,25,0,0,0,1,0,26,1,24],"id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","args":["","{","\"","questions","\"",": ","[","{\"","id","\":"," \"","color","\","," \"","question","\":"," \"","Which"," color"," do"," you"," prefer","?\","," \"","header","\":"," \"","Pick"," one","\","," \"","options","\":"," [","{\"","label","\":"," \"","Blue","\","," \"","description","\":"," \"","A"," cool"," recessive"," hue"," that"," reads"," as"," calm"," and"," trustworthy"," in"," long"," reading"," sessions"," and"," dense"," dash","boards",".\"","},"," {\"","label","\":"," \"","Green","\","," \"","description","\":"," \"","A"," rest","ful"," mid","-spect","rum"," hue"," with"," the"," highest"," perceived"," brightness",","," easiest"," on"," the"," eye"," over"," long"," sessions",".\"","}]","}]","}"]}} +{"type":"tool-call-chunks","seq0":29,"time0":1785150168776,"data":{"turn":1,"step":1,"index":1,"dt":[25,1,0,0,0,25,0,0,0,26,1,0,0,0,0,25,1,0,0,0,0,25,1,0,0,0,0,25,1,0,0,0,1,25,0,0,0,0,1,25,1,0,0,0,0,25,1,0,0,26,0,0,1,0,24,1,0,0,0,1,26,1,0,0,0,0,25,0,1,0,0,0,25,1,0,0,25,0,0,0,0,1,25,0,0,1,0,0,25,0,0,0,1,0,26,1,24],"id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","args":["","{","\"","questions","\"",": ","[","{\"","id","\":"," \"","color","\","," \"","question","\":"," \"","Which"," color"," do"," you"," prefer","?\","," \"","header","\":"," \"","Pick"," one","\", \"multi_select\": true,"," \"","options","\":"," [","{\"","label","\":"," \"","Blue","\","," \"","description","\":"," \"","A"," cool"," recessive"," hue"," that"," reads"," as"," calm"," and"," trustworthy"," in"," long"," reading"," sessions"," and"," dense"," dash","boards",".\"","},"," {\"","label","\":"," \"","Green","\","," \"","description","\":"," \"","A"," rest","ful"," mid","-spect","rum"," hue"," with"," the"," highest"," perceived"," brightness",","," easiest"," on"," the"," eye"," over"," long"," sessions",".\"","}]","}]","}"]}} {"type":"assistant/chunk","seq":127,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that."}}}} -{"type":"assistant/chunk","seq":128,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}}}} +{"type":"assistant/chunk","seq":128,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"multi_select\": true, \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}}}} {"type":"assistant/chunk","seq":129,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":113,"outputTokens":158,"cacheReadTokens":4096,"reasoningTokens":22}}}} {"type":"assistant/chunk","seq":130,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":131,"time":1785150169311,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that."},{"type":"tool-call","id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":113,"outputTokens":158,"cacheReadTokens":4096,"reasoningTokens":22}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130],"surfaceOp":"append"} -{"type":"tool/call","seq":132,"time":1785150169312,"data":{"turn":1,"step":1,"callId":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}} -{"type":"tool/result","seq":133,"time":1785150169787,"data":{"turn":1,"step":1,"callId":"call_00_Cijldc88LYmVPCXYUsRq1617","content":[{"type":"text","text":"{\"answers\":[{\"id\":\"color\",\"selected\":[\"Blue\"]}]}"}],"isError":false},"sourceEventSeqs":[132],"surfaceOp":"append"} +{"type":"assistant/message","seq":131,"time":1785150169311,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that."},{"type":"tool-call","id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"multi_select\": true, \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":113,"outputTokens":158,"cacheReadTokens":4096,"reasoningTokens":22}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130],"surfaceOp":"append"} +{"type":"tool/call","seq":132,"time":1785150169312,"data":{"turn":1,"step":1,"callId":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"multi_select\": true, \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}} +{"type":"tool/result","seq":133,"time":1785150169787,"data":{"turn":1,"step":1,"callId":"call_00_Cijldc88LYmVPCXYUsRq1617","content":[{"type":"text","text":"{\"answers\":[{\"id\":\"color\",\"selected\":[\"Blue\"],\"custom\":\"Include accessibility notes\"}]}"}],"isError":false},"sourceEventSeqs":[132],"surfaceOp":"append"} {"type":"step/end","seq":134,"time":1785150169790,"data":{"turn":1,"step":1}} {"type":"step/start","seq":135,"time":1785150169790,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":136,"time":1785150170605,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/apps/web/tests/snapshots/question-composer/ui.expected.md b/apps/web/tests/snapshots/question-composer/ui.expected.md index 894f84d9ba..c2ee767319 100644 --- a/apps/web/tests/snapshots/question-composer/ui.expected.md +++ b/apps/web/tests/snapshots/question-composer/ui.expected.md @@ -3,9 +3,9 @@ - heading "Which color do you prefer?" [level=2] - button "Dismiss all questions": - img - - radiogroup: - - radio "Blue": 1 Blue A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards. - - radio "Green": 2 Green A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions. + - group: + - checkbox "Blue": Blue A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards. + - checkbox "Green": Green A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions. - textbox "Type your answer" - button "Previous question" [disabled]: - img diff --git a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md index b9dee060ac..bd44e33ad0 100644 --- a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md +++ b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md @@ -7,8 +7,9 @@ - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/queue-actions/editing.expected.md b/apps/web/tests/snapshots/queue-actions/editing.expected.md index bfff0cd9f0..8df2ea2940 100644 --- a/apps/web/tests/snapshots/queue-actions/editing.expected.md +++ b/apps/web/tests/snapshots/queue-actions/editing.expected.md @@ -7,8 +7,9 @@ - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/queue-actions/preserved.expected.md b/apps/web/tests/snapshots/queue-actions/preserved.expected.md index fe6f5b6087..74de289ee9 100644 --- a/apps/web/tests/snapshots/queue-actions/preserved.expected.md +++ b/apps/web/tests/snapshots/queue-actions/preserved.expected.md @@ -7,8 +7,9 @@ - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img @@ -22,8 +23,9 @@ - text: {{clock}} Edited queue item {{clock}} - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - paragraph: partial - status: Deep diving... - list: diff --git a/apps/web/tests/snapshots/queue-actions/ui.expected.md b/apps/web/tests/snapshots/queue-actions/ui.expected.md index db860c30d1..7afdf3c8b4 100644 --- a/apps/web/tests/snapshots/queue-actions/ui.expected.md +++ b/apps/web/tests/snapshots/queue-actions/ui.expected.md @@ -7,8 +7,9 @@ - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/seeded-history/command-row.expected.md b/apps/web/tests/snapshots/seeded-history/command-row.expected.md index b916a3add2..b4eddfc24e 100644 --- a/apps/web/tests/snapshots/seeded-history/command-row.expected.md +++ b/apps/web/tests/snapshots/seeded-history/command-row.expected.md @@ -7,8 +7,9 @@ - text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. 7/25 {{clock}}" - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel.": - img - img diff --git a/apps/web/tests/snapshots/seeded-history/ui.expected.md b/apps/web/tests/snapshots/seeded-history/ui.expected.md index a168d5e2a3..4643c60f3b 100644 --- a/apps/web/tests/snapshots/seeded-history/ui.expected.md +++ b/apps/web/tests/snapshots/seeded-history/ui.expected.md @@ -7,8 +7,9 @@ - text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. 7/25 {{clock}}" - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel.": - img - img diff --git a/apps/web/tests/snapshots/steering/mid-steer.expected.md b/apps/web/tests/snapshots/steering/mid-steer.expected.md index 61f48ffbab..5b316f4efb 100644 --- a/apps/web/tests/snapshots/steering/mid-steer.expected.md +++ b/apps/web/tests/snapshots/steering/mid-steer.expected.md @@ -7,8 +7,9 @@ - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md index be28e218d3..4d8d35a085 100644 --- a/apps/web/tests/snapshots/steering/settled.expected.md +++ b/apps/web/tests/snapshots/steering/settled.expected.md @@ -7,8 +7,9 @@ - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img @@ -24,8 +25,9 @@ - text: "Interjection: include the word BANANA in your final reply. {{clock}}" - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Think The user selected \"Yes\" and wants me to include the word \"BANANA\" in my final reply. Let me acknowledge their answer.": - img - img diff --git a/apps/web/tests/snapshots/subagent-conversation/branchless.expected.md b/apps/web/tests/snapshots/subagent-conversation/branchless.expected.md new file mode 100644 index 0000000000..1f51342ed6 --- /dev/null +++ b/apps/web/tests/snapshots/subagent-conversation/branchless.expected.md @@ -0,0 +1,2 @@ +- tree "Subagent sessions": + - treeitem "example editor continuable · not running 0 tok · {{duration}}" [level=1]: example editor continuable · not running 0 tok {{duration}} diff --git a/apps/web/tests/snapshots/subagent-conversation/nested.expected.md b/apps/web/tests/snapshots/subagent-conversation/nested.expected.md index 7d7595c7a4..9f7c0f23f7 100644 --- a/apps/web/tests/snapshots/subagent-conversation/nested.expected.md +++ b/apps/web/tests/snapshots/subagent-conversation/nested.expected.md @@ -14,5 +14,5 @@ - button "Branch into a new conversation": - img - status: - - strong: 此子代理暂时只读 - - text: 父会话当前不在线,重新打开父会话后即可继续发送消息。 + - strong: This subagent is read-only for now + - text: The parent session is offline; reopen it to continue sending messages. diff --git a/apps/web/tests/snapshots/subagent-conversation/stale-catalog.expected.md b/apps/web/tests/snapshots/subagent-conversation/stale-catalog.expected.md new file mode 100644 index 0000000000..bd2a1b7918 --- /dev/null +++ b/apps/web/tests/snapshots/subagent-conversation/stale-catalog.expected.md @@ -0,0 +1,3 @@ +- tree "Subagent sessions": + - treeitem "Loading subagents" [disabled] [level=1]: Loading subagents… + - treeitem "Loading subagents" [disabled] [level=1]: Loading subagents… diff --git a/apps/web/tests/snapshots/subagent-conversation/tree.expected.md b/apps/web/tests/snapshots/subagent-conversation/tree.expected.md index 12520a65c4..e71d768153 100644 --- a/apps/web/tests/snapshots/subagent-conversation/tree.expected.md +++ b/apps/web/tests/snapshots/subagent-conversation/tree.expected.md @@ -1,8 +1,8 @@ -- tree "子代理会话": - - treeitem "event-sourcing researcher Explain event sourcing in one · 可继续 · 当前未运行 刚刚" [expanded] [level=1]: - - button "收起 event-sourcing researcher 的下级子代理": +- tree "Subagent sessions": + - treeitem "event-sourcing reviewer one-shot · not running 0 tok · {{duration}}" [level=1]: event-sourcing reviewer one-shot · not running 0 tok ~6mo 12d + - treeitem "event-sourcing researcher Explain event sourcing in one · continuable · not running 7.9K tok · {{duration}}" [expanded] [level=1]: + - button "Collapse event-sourcing researcher descendants": - img - - text: event-sourcing researcher Explain event sourcing in one · 可继续 · 当前未运行 刚刚 + - text: event-sourcing researcher Explain event sourcing in one · continuable · not running 7.9K tok {{duration}} - group: - - treeitem "example editor 可继续 · 当前未运行 刚刚" [level=2] - - treeitem "event-sourcing reviewer 一次性 · 当前未运行 刚刚" [level=1] + - treeitem "example editor continuable · not running 0 tok · {{duration}}" [level=2]: example editor continuable · not running 0 tok {{duration}} diff --git a/apps/web/tests/snapshots/subagent-conversation/ui.expected.md b/apps/web/tests/snapshots/subagent-conversation/ui.expected.md index 6513bb60e3..9497cc36d3 100644 --- a/apps/web/tests/snapshots/subagent-conversation/ui.expected.md +++ b/apps/web/tests/snapshots/subagent-conversation/ui.expected.md @@ -3,8 +3,8 @@ - button "Ask a research subagent to" - text: / - button "event-sourcing researcher" [disabled] - - button "1 个子代理": - - text: 1 个子代理 + - button "1 subagent": + - text: 1 subagent - img - tablist: - tab "Chat" [selected] @@ -12,8 +12,9 @@ - text: Explain event sourcing in one sentence. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img @@ -30,8 +31,9 @@ - text: {{clock}} Now give the same explanation to a human reader. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.": - img - img diff --git a/apps/web/tests/snapshots/web-search-round/ui.expected.md b/apps/web/tests/snapshots/web-search-round/ui.expected.md index 4ff674462e..97ca405141 100644 --- a/apps/web/tests/snapshots/web-search-round/ui.expected.md +++ b/apps/web/tests/snapshots/web-search-round/ui.expected.md @@ -7,8 +7,9 @@ - text: Use web_search to search exactly "DeepSeek Harness snapshot search". Then reply exactly SEARCH_DONE and stop. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/workspace-management/directory-browser.expected.md b/apps/web/tests/snapshots/workspace-management/directory-browser.expected.md index baaaa6f3dc..47957dab82 100644 --- a/apps/web/tests/snapshots/workspace-management/directory-browser.expected.md +++ b/apps/web/tests/snapshots/workspace-management/directory-browser.expected.md @@ -4,7 +4,8 @@ - button "Home" - img - button "browse-golden" - - button "Edit path" + - button "Edit path": + - img - list: - listitem: - button "adopted": diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index e71b54e0d9..0049cb2791 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -15,11 +15,13 @@ import { launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspace, saveFailureShot } from './support.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' const BASE_FIXTURE = fileURLToPath(new URL('./snapshots/live-interactions/session.jsonl', import.meta.url)) const AVAILABLE_CHILD_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/ui.expected.md', import.meta.url)) const TREE_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/tree.expected.md', import.meta.url)) +const BRANCHLESS_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/branchless.expected.md', import.meta.url)) +const STALE_CATALOG_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/stale-catalog.expected.md', import.meta.url)) const SIDEBAR_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/sidebar.expected.md', import.meta.url)) const UNAVAILABLE_GRANDCHILD_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/nested.expected.md', import.meta.url)) const FORK_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/fork.expected.md', import.meta.url)) @@ -77,7 +79,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = paceMs: 25, }) browser = await chromium.launch() - page = await browser.newPage({ viewport: { width: 1680, height: 1000 } }) + page = await newEnglishPage(browser) page.on('request', (request) => { const path = new URL(request.url()).pathname if (path.startsWith('/api/')) apiCalls.push(path) @@ -107,7 +109,8 @@ describe('web e2e: persisted subagent conversation and human continuation', () = childId = started.childId await waitForAgentToSettle(scaffold, childId) oneShotId = sessionId('recorded-one-shot') - const oneShotAt = Date.now() + const oneShotDurationMs = 192 * 24 * 60 * 60 * 1_000 + const oneShotAt = Date.now() - oneShotDurationMs await scaffold.ctx.sessionPersistence.create({ version: SESSION_FORMAT_VERSION, id: oneShotId, @@ -145,10 +148,11 @@ describe('web e2e: persisted subagent conversation and human continuation', () = { type: 'turn/end', seq: 3, - time: oneShotAt + 3, + time: oneShotAt + oneShotDurationMs, data: { turn: 1, reason: { kind: 'completed' } }, }, ] as SessionEvent[]) + await scaffold.ctx.sessionProjectionCache.coldSnapshot(oneShotId) grandchildId = sessionId('recorded-grandchild') const authoredAt = Date.now() await scaffold.ctx.sessionPersistence.create({ @@ -192,18 +196,19 @@ describe('web e2e: persisted subagent conversation and human continuation', () = data: { turn: 1, reason: { kind: 'completed' } }, }, ] as SessionEvent[]) + await scaffold.ctx.sessionProjectionCache.coldSnapshot(grandchildId) expect(scaffold.ctx.agents.get(childId)).toBeUndefined() expect(scaffold.ctx.agents.get(oneShotId)).toBeUndefined() expect(scaffold.ctx.agents.get(grandchildId)).toBeUndefined() await expect(scaffold.ctx.subagents.listChildren(parent.id)).resolves.toMatchObject([ - { - kind: 'child', id: childId, mode: 'continuable', label: LABEL, - activity: 'inactive', hasChildren: true, - }, { kind: 'child', id: oneShotId, mode: 'one-shot', label: ONE_SHOT_LABEL, activity: 'inactive', hasChildren: false, }, + { + kind: 'child', id: childId, mode: 'continuable', label: LABEL, + activity: 'inactive', hasChildren: true, + }, ]) await expect(scaffold.ctx.subagents.listChildren(childId)).resolves.toMatchObject([ { @@ -217,13 +222,13 @@ describe('web e2e: persisted subagent conversation and human continuation', () = const warningStart = tripwire.warnings.length await page.reload({ waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) - const catalogButton = page.getByRole('button', { name: /个子代理/ }) + const catalogButton = page.getByRole('button', { name: /subagents/ }) await catalogButton.waitFor({ timeout: 15_000 }) await catalogButton.click() - const catalogTree = page.getByRole('tree', { name: '子代理会话' }) + const catalogTree = page.getByRole('tree', { name: 'Subagent sessions' }) await catalogTree.getByRole('treeitem').nth(1).waitFor({ timeout: 15_000 }) await catalogTree.press('Escape') - await page.getByRole('button', { name: '3 个子代理' }).waitFor({ timeout: 15_000 }) + await page.getByRole('button', { name: '3 subagents' }).waitFor({ timeout: 15_000 }) acknowledgeReloadConnectionLoss(tripwire, warningStart) }, 120_000) @@ -239,28 +244,88 @@ describe('web e2e: persisted subagent conversation and human continuation', () = if (failures.length > 1) throw new AggregateError(failures, 'subagent Web teardown failed') }) + it('keeps known descendants reachable across a stale empty catalog response', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-stale-catalog')) + const pattern = '**/api/subagent.list' + let firstClaimed = false + let emptyDelivered = false + let trailingRequested = false + let releaseCatalog = (): void => {} + const catalogHeld = new Promise((resolve) => { releaseCatalog = resolve }) + await page.route(pattern, async (route) => { + if (firstClaimed) { + const response = await route.fetch() + trailingRequested = true + await catalogHeld + await route.fulfill({ response }) + return + } + firstClaimed = true + const response = await route.fetch() + const body = await response.json() as { + result: { ok: true; value: { entries: unknown[] } } | { ok: false } + } + if (body.result.ok) body.result.value.entries = [] + await route.fulfill({ response, json: body }) + emptyDelivered = true + }) + + const warningStart = tripwire.warnings.length + try { + await page.reload({ waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await expect.poll(() => emptyDelivered, { timeout: 15_000 }).toBe(true) + await page.getByRole('button', { name: '3 subagents' }).waitFor({ timeout: 15_000 }) + acknowledgeReloadConnectionLoss(tripwire, warningStart) + + await page.getByRole('button', { name: '3 subagents' }).click() + await expect.poll(() => trailingRequested, { timeout: 15_000 }).toBe(true) + const tree = page.getByRole('tree', { name: 'Subagent sessions' }) + await tree.getByRole('treeitem', { name: 'Loading subagents' }).first().waitFor() + expect(await tree.getByRole('treeitem', { name: 'Loading subagents' }).count()).toBe(2) + await compareOrRefreshGolden( + STALE_CATALOG_EXPECTED, + await captureStableAria(page, '[role="tree"][aria-label="Subagent sessions"]', scaffold.workspaceCwd), + MODE, + ) + releaseCatalog() + await tree.getByRole('treeitem', { name: new RegExp(LABEL) }).waitFor({ timeout: 15_000 }) + await tree.press('Escape') + } finally { + releaseCatalog() + await page.unroute(pattern) + } + }) + it('expands a persisted grandchild progressively without activating either level', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-tree')) - await page.getByRole('button', { name: '3 个子代理' }).click() + await page.getByRole('button', { name: '3 subagents' }).click() expect(await page.getByRole('button', { - name: `展开 ${ONE_SHOT_LABEL} 的下级子代理`, + name: `Expand ${ONE_SHOT_LABEL} descendants`, }).count()).toBe(0) - await page.getByRole('button', { name: `展开 ${LABEL} 的下级子代理` }).click() + const oneShotRow = page.getByRole('treeitem', { name: new RegExp(ONE_SHOT_LABEL) }) + expect(await oneShotRow.getByText('~6mo 12d', { exact: true }).count()).toBe(1) + expect(await oneShotRow.getAttribute('aria-label')).toContain('192d 00h 00m 00s') + await page.getByRole('button', { name: `Expand ${LABEL} descendants` }).click() + const childRow = page.getByRole('treeitem', { name: new RegExp(LABEL) }) + const childLabel = await childRow.getAttribute('aria-label') + await page.waitForTimeout(1_100) + expect(await childRow.getAttribute('aria-label')).toBe(childLabel) await page.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }).waitFor({ timeout: 15_000 }) expect(scaffold.ctx.agents.get(childId)).toBeUndefined() expect(scaffold.ctx.agents.get(grandchildId)).toBeUndefined() const snapshot = await captureStableAria( page, - '[role="tree"][aria-label="子代理会话"]', + '[role="tree"][aria-label="Subagent sessions"]', scaffold.workspaceCwd, ) await compareOrRefreshGolden(TREE_EXPECTED, snapshot, MODE) - await page.getByRole('tree', { name: '子代理会话' }).press('Escape') + await page.getByRole('tree', { name: 'Subagent sessions' }).press('Escape') }) it('opens the completed child from persistence without activating it', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-open')) - await page.getByRole('button', { name: '3 个子代理' }).click() + await page.getByRole('button', { name: '3 subagents' }).click() await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click() await expect.poll( () => page.getByText(INITIAL_PROMPT, { exact: true }).count(), @@ -302,18 +367,18 @@ describe('web e2e: persisted subagent conversation and human continuation', () = ).toBe('running') const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' }) await hierarchy.getByRole('button').first().click() - const runningTrigger = page.getByRole('button', { name: '3 个子代理,正在运行' }) + const runningTrigger = page.getByRole('button', { name: '3 subagents running' }) await runningTrigger.waitFor({ timeout: 10_000 }) expect(await runningTrigger.locator('[data-state="ongoing"]').count()).toBe(1) await runningTrigger.click() await page.getByRole('treeitem', { - name: new RegExp(`${LABEL}.*正在运行`), + name: new RegExp(`${LABEL}.*running`), }).waitFor({ timeout: 10_000 }) await ended await page.getByRole('treeitem', { - name: new RegExp(`${LABEL}.*当前未运行`), + name: new RegExp(`${LABEL}.*not running`), }).waitFor({ timeout: 10_000 }) - expect(await page.getByRole('button', { name: '3 个子代理' }) + expect(await page.getByRole('button', { name: '3 subagents' }) .locator('[data-state="ongoing"]').count()).toBe(0) await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click() await expect.poll(() => page.getByText(FOLLOWUP, { exact: true }).count(), { timeout: 10_000 }).toBe(1) @@ -331,9 +396,28 @@ describe('web e2e: persisted subagent conversation and human continuation', () = it('opens an unavailable persisted grandchild after recording the available child', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-grandchild')) - await page.getByRole('button', { name: '1 个子代理' }).click() - await page.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }).click() - await page.getByText('父会话当前不在线,重新打开父会话后即可继续发送消息。').waitFor() + await page.getByRole('button', { name: '1 subagent' }).click() + const tree = page.getByRole('tree', { name: 'Subagent sessions' }) + const nestedRow = tree.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }) + expect(await nestedRow.locator(':scope > *').count()).toBe(1) + const clickArea = nestedRow.locator(':scope > *') + const [treeBox, clickAreaBox] = await Promise.all([ + tree.boundingBox(), + clickArea.boundingBox(), + ]) + expect(treeBox).not.toBeNull() + expect(clickAreaBox).not.toBeNull() + expect([ + Math.round(clickAreaBox!.x - treeBox!.x), + Math.round(treeBox!.x + treeBox!.width - clickAreaBox!.x - clickAreaBox!.width), + ]).toEqual([5, 5]) + await compareOrRefreshGolden( + BRANCHLESS_EXPECTED, + await captureStableAria(page, '[role="tree"][aria-label="Subagent sessions"]', scaffold.workspaceCwd), + MODE, + ) + await nestedRow.click() + await page.getByText('The parent session is offline; reopen it to continue sending messages.').waitFor() const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' }) const crumbs = await hierarchy.getByRole('button').allTextContents() expect(crumbs.slice(-2)).toEqual([LABEL, NESTED_LABEL]) @@ -352,9 +436,9 @@ describe('web e2e: persisted subagent conversation and human continuation', () = .getByRole('treeitem') .last() await parentSession.click() - await page.getByRole('button', { name: '3 个子代理' }).click() + await page.getByRole('button', { name: '3 subagents' }).click() await page.getByRole('treeitem', { name: new RegExp(ONE_SHOT_LABEL) }).click() - await page.getByText('一次性任务不支持后续消息,可在这里查看完整执行记录。').waitFor() + await page.getByText('One-shot tasks do not accept follow-ups; review the full execution record here.').waitFor() expect(scaffold.ctx.agents.get(oneShotId)).toBeUndefined() }) @@ -363,7 +447,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = await page.getByRole('tree', { name: 'Sessions' }) .getByRole('treeitem', { name: /Ask a research subagent to/ }) .click() - await page.getByRole('button', { name: '3 个子代理' }).click() + await page.getByRole('button', { name: '3 subagents' }).click() await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click() await page.getByRole('textbox', { name: 'Message the agent' }).waitFor() const forkResponse = page.waitForResponse(response => @@ -389,7 +473,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-post-fork-followup')) const sessions = page.getByRole('tree', { name: 'Sessions' }) await sessions.getByRole('treeitem', { name: /Ask a research subagent to/ }).click() - await page.getByRole('button', { name: '3 个子代理' }).click() + await page.getByRole('button', { name: '3 subagents' }).click() await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click() await page.locator('textarea:enabled').first().waitFor() expect(scaffold.ctx.agents.get(childId)).toBeUndefined() @@ -406,7 +490,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = await expect.poll(() => scaffold.ctx.agents.get(forkId)).not.toBeUndefined() await sessions.getByRole('treeitem', { name: /Ask a research subagent to/ }).click() - await page.getByRole('button', { name: '3 个子代理' }).click() + await page.getByRole('button', { name: '3 subagents' }).click() await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click() const input = page.locator('textarea:enabled').first() await input.waitFor() diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index 1ffcf6f490..b25320f488 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/apps/web/tests/workspace-management.e2e.ts @@ -1,6 +1,7 @@ // Web e2e scenarios: workspace management — adding a workspace through the // composed directory dialog (its own New folder affordance is the product's -// one creation route), same-basename directory adoption, the rename round +// one creation route), the dialog's path editor walking the panes with the +// typed draft, same-basename directory adoption, the rename round // trip over the real wire (workspace.rename RPC + durable registry), the // duplicate-name pre-check, the // flat "In one list" view with its persisted group-by preference, the session @@ -12,7 +13,7 @@ // seeded-history seed reused verbatim — no new recording). import { mkdir, readFile, stat, writeFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' -import { join } from 'node:path' +import { join, sep } from 'node:path' import type { Browser, Locator, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' @@ -403,6 +404,43 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff expect(tripwire.pageErrors).toEqual([]) }, 60_000) + it('walks the panes with the typed path: deeper past a separator, back up on erase, whole on a miss', async () => { + // The panes must track the draft without leaving the editor, so the + // typed text and what is listed under it never disagree. + // Staged by this scenario itself (mkdir is recursive and idempotent), so + // running it alone through -t sees the same tree the assertions describe. + const staged = join(scaffold.workspaceCwd, 'browse-golden') + await mkdir(join(staged, 'alpha', 'only-under-alpha'), { recursive: true }) + await mkdir(join(staged, 'beta'), { recursive: true }) + const dialog = await browseTo(staged) + await expect.poll(() => dialog.getByText('alpha', { exact: true }).count(), { timeout: 10_000 }).toBe(1) + await dialog.getByRole('button', { name: 'Edit path' }).click() + const path = dialog.getByLabel('Edit path') + // A directory part no pane lists: the panes walk to it, landing the + // ordinary two-pane Miller view (level | its children) with the editor + // still up and the draft intact. + await path.fill(`${join(staged, 'alpha')}${sep}`) + await expect.poll(() => dialog.getByText('only-under-alpha', { exact: true }).count(), { timeout: 10_000 }).toBe(1) + await expect.poll(() => dialog.getByRole('list').count(), { timeout: 10_000 }).toBe(2) + expect(await path.inputValue()).toBe(`${join(staged, 'alpha')}${sep}`) + // Erasing back past the separator walks the panes up, so the level being + // typed is the last pane again (its children no longer stand to its + // right) and the tail filters it. + await path.fill(`${staged}${sep}al`) + await expect.poll(() => dialog.getByText('only-under-alpha', { exact: true }).count(), { timeout: 10_000 }).toBe(0) + expect(await dialog.getByText('alpha', { exact: true }).count()).toBe(1) + expect(await dialog.getByText('beta', { exact: true }).count()).toBe(0) + await expect.poll(() => dialog.getByRole('list').count(), { timeout: 10_000 }).toBe(2) + // A tail nobody matches is a name still being spelled: the level shows + // whole instead of emptying under it. + await path.fill(`${staged}${sep}zzz`) + await expect.poll(() => dialog.getByText('beta', { exact: true }).count(), { timeout: 10_000 }).toBe(1) + expect(await dialog.getByText('alpha', { exact: true }).count()).toBe(1) + await dialog.getByRole('button', { name: 'Cancel' }).click() + await dialog.waitFor({ state: 'hidden', timeout: 10_000 }) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + /** * Expand Ungrouped and return its seeded session row. The only visible child * is the non-blank persisted Session; the blank Session created while diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 9693ed1885..7a244ff454 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -36,6 +36,7 @@ "tests/settings-chrome.e2e.ts", "tests/models-settings.e2e.ts", "tests/onboarding-deepseek-config.e2e.ts", + "tests/remote-welcome.e2e.ts", "tests/workspace-management.e2e.ts", "tests/replay-round-trip.e2e.ts", "tests/hmr-live.e2e.ts", @@ -46,6 +47,7 @@ "tests/cordis-tool-round.e2e.ts", "tests/web-search-round.e2e.ts", "tests/message-actions.e2e.ts", + "tests/markdown-images.e2e.ts", "tests/queue-actions.e2e.ts", "tests/skill-invocation-policy.e2e.ts", "tests/permission-policy-context.e2e.ts", @@ -53,7 +55,8 @@ "tests/shipped-composition.e2e.ts", "tests/goal-bar.e2e.ts", "tests/startup-auto-selection.e2e.ts", - "tests/subagent-conversation.e2e.ts" + "tests/subagent-conversation.e2e.ts", + "tests/bash-abort-row.e2e.ts" ], "references": [ { diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index f0e2a9892c..bded8b85c8 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: 4bb0cb1f29bb48adf89c97af7c85c90219d0558a -architecture.zh.md: 4c6ff721d894aaee4cc14ff321967580997aba56 +architecture.md: b11ab9bc3060aea668d142139e0a25f491a777d1 +architecture.zh.md: 5eb1cab7e453dc0423cbb42348e918de798f1f9b diff --git a/docs/architecture.md b/docs/architecture.md index 4bb0cb1f29..b11ab9bc30 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -76,7 +76,7 @@ Creation without an id mints `-session-`; `sessionId` resumes o ```text choose declarative identity and fresh/resume path - -> prepare private session + agent.ctx -> await unpublished setup + -> prepare private session + agent.ctx -> await unpublished setup -> invoke optional synchronous setup commit -> enter session + agent -> session/created -> agent/created -> enable driving -> agent/session-start(source) -> start driver forever: @@ -141,7 +141,7 @@ Turn and step events are turn-enclosed. Idle `user/message` and standalone `comp ### Agent Scope -Each agent owns scoped `agent.ctx`; shared storage overlays its tool, prompt, and command entries on globals while preserving domain views ([decision](../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md)). Scoped listeners filter dispatch; contributions unwind with awaited cleanup. `CreateAgentOptions.setup(agentCtx)` composes before publication. Typed resolvers derive carrier checks from merged `Events` and `scopeTarget` ([semantic gates](../.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md)). Details: [agent scope](../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md), [subagent composition](../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md). `AgentLoop` runs under `ctx.agents.withInitiator()`; private orchestration derives `agent.session`, but turn, step, signal, cwd, and authority stay explicit ([decision](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md)). +Each agent owns scoped `agent.ctx`; shared storage overlays its tool, prompt, and command entries on globals while preserving domain views ([decision](../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md)). Scoped listeners filter dispatch; contributions unwind with awaited cleanup. `CreateAgentOptions.setup(agentCtx)` composes before publication and may return a synchronous commit that the factory invokes immediately before registry entry, after every setup await. Typed resolvers derive carrier checks from merged `Events` and `scopeTarget` ([semantic gates](../.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md)). Details: [agent scope](../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md), [subagent composition](../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md). `AgentLoop` runs under `ctx.agents.withInitiator()`; private orchestration derives `agent.session`, but turn, step, signal, cwd, and authority stay explicit ([decision](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md)). ## State diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 4c6ff721d8..5eb1cab7e4 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -76,7 +76,7 @@ waterfall(瀑布式事件)是环绕中间件:监听器通过 `next()` 委 ```text choose declarative identity and fresh/resume path - -> prepare private session + agent.ctx -> await unpublished setup + -> prepare private session + agent.ctx -> await unpublished setup -> invoke optional synchronous setup commit -> enter session + agent -> session/created -> agent/created -> enable driving -> agent/session-start(source) -> start driver forever: @@ -141,7 +141,7 @@ idle inject: ### Agent 作用域 -每个 agent 都拥有作用域化的 `agent.ctx`;共享存储会将其工具、提示词和命令条目叠加到全局条目之上,同时保留各领域视图([决策](../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md))。作用域监听器会过滤分派;贡献都会在撤销时等待清理完成。`CreateAgentOptions.setup(agentCtx)` 在发布前完成组合。类型化解析器从合并后的 `Events` 和 `scopeTarget` 推导载体检查([语义门禁](../.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md))。详情见 [agent 作用域](../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md)和 [subagent 组合](../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md)。`AgentLoop` 在 `ctx.agents.withInitiator()` 内运行;私有编排会派生 `agent.session`,但轮次、步骤、信号、cwd 和权限仍保持显式([决策](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md))。 +每个 agent 都拥有作用域化的 `agent.ctx`;共享存储会将其工具、提示词和命令条目叠加到全局条目之上,同时保留各领域视图([决策](../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md))。作用域监听器会过滤分派;贡献都会在撤销时等待清理完成。`CreateAgentOptions.setup(agentCtx)` 在发布前完成组合,并可返回一个同步提交操作;所有 setup 的 await 均完成后,工厂会在进入注册表前立即调用该操作。类型化解析器从合并后的 `Events` 和 `scopeTarget` 推导载体检查([语义门禁](../.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md))。详情见 [agent 作用域](../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md)和 [subagent 组合](../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md)。`AgentLoop` 在 `ctx.agents.withInitiator()` 内运行;私有编排会派生 `agent.session`,但轮次、步骤、信号、cwd 和权限仍保持显式([决策](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md))。 ## 状态 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 9a2eb8f0be..07aab60fed 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -777,7 +777,7 @@ export interface ReplayModelConfig { Depends on: [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) -Source: [`packages/support/llm-replay/src/index.ts:617`](../packages/support/llm-replay/src/index.ts) +Source: [`packages/support/llm-replay/src/index.ts:707`](../packages/support/llm-replay/src/index.ts) ## `@deepseek-ai/dsh-llm-retry` @@ -1817,7 +1817,7 @@ export interface Config { } ``` -Source: [`packages/goal/tool-goal/src/index.ts:25`](../packages/goal/tool-goal/src/index.ts) +Source: [`packages/goal/tool-goal/src/index.ts:26`](../packages/goal/tool-goal/src/index.ts) ## `@deepseek-ai/dsh-tool-lsp` @@ -2098,7 +2098,7 @@ export interface Config { export type ToolPresentationMode = 'native' | 'code' | 'both' ``` -Source: [`packages/core/tools/src/index.ts:589`](../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:592`](../packages/core/tools/src/index.ts) ## `@deepseek-ai/dsh-tui` @@ -2126,6 +2126,8 @@ export interface TuiConfig { showReasoning?: boolean /** Maximum tool-card body lines retained in its collapsed head/tail preview. */ maxToolOutputLines?: number + /** Maximum added and removed lines explored while deriving an exact line diff. */ + maxDiffEditLength?: number /** Maximum options visible at once in a user-question panel. */ maxQuestionOptions?: number /** Maximum models visible at once in the model selector. */ @@ -2140,6 +2142,8 @@ export interface TuiConfig { modelDialogWidth?: number /** Model-selector maximum height in terminal rows. */ modelDialogMaxHeight?: number + /** Transcript-details selector width in terminal columns. */ + detailsDialogWidth?: number /** Maximum fuzzy file candidates displayed for one `@` query. */ fileSearchMaxResults?: number /** Maximum paths retained in one `@` workspace index. */ @@ -2171,7 +2175,7 @@ export interface TuiThemeConfig { } ``` -Source: [`packages/ui/tui/src/config.ts:117`](../packages/ui/tui/src/config.ts) +Source: [`packages/ui/tui/src/config.ts:125`](../packages/ui/tui/src/config.ts) ## `@deepseek-ai/dsh-typert-loader` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 521a2fa735..8f4385938c 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -794,7 +794,7 @@ A published child settled. Scope-filtered dispatch uses the same delegating pare Types: [Scoped](../core-data-structures/scope.md) · [SubagentService](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:158`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:160`](../../packages/subagent/subagent/src/index.ts) ### `subagent/provider-added` — emit @@ -811,7 +811,7 @@ A provider became resolvable in the registry. Types: [SubagentProvider](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:132`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:134`](../../packages/subagent/subagent/src/index.ts) ### `subagent/provider-removed` — emit @@ -826,7 +826,7 @@ A provider left the registry. Accepted runs remain holder-owned. 'subagent/provider-removed'(name: string): void ``` -Source: [`packages/subagent/subagent/src/index.ts:138`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:140`](../../packages/subagent/subagent/src/index.ts) ### `subagent/start` — emit @@ -848,7 +848,7 @@ A provider established a published child. For in-process providers, `ctx.agents. Types: [Scoped](../core-data-structures/scope.md) · [SubagentService](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:149`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:151`](../../packages/subagent/subagent/src/index.ts) ## `system-prompt/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 790e18ce1e..eb6993962c 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -216,7 +216,7 @@ roots(): Agent[] Types: [Agent](../core-data-structures/core.md) · [SessionId](../core-data-structures/core.md) -Source: [`packages/core/agent/src/index.ts:217`](../../packages/core/agent/src/index.ts) +Source: [`packages/core/agent/src/index.ts:242`](../../packages/core/agent/src/index.ts) ## `ctx.approval` — `ApprovalService` @@ -2072,7 +2072,7 @@ async start(name: string, request: SubagentStartRequest): Promise Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [ContinuableSetupContribution](../core-data-structures/subagent.md) · [ContinuableStart](../core-data-structures/subagent.md) · [ContinuableStartSpec](../core-data-structures/subagent.md) · [MessageId](../core-data-structures/core.md) · [SessionId](../core-data-structures/core.md) · [SubagentFollowupOptions](../core-data-structures/subagent.md) · [SubagentListEntry](../core-data-structures/subagent.md) · [SubagentProvider](../core-data-structures/subagent.md) · [SubagentReportOptions](../core-data-structures/subagent.md) · [SubagentRun](../core-data-structures/subagent.md) · [SubagentStartRequest](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:163`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:165`](../../packages/subagent/subagent/src/index.ts) ## `ctx.subprocess` — `SubprocessService` (abstract seam) @@ -2428,7 +2428,7 @@ async execute(exec: ToolExecutionInput): Promise Types: [ScopeKey](../core-data-structures/scope.md) · [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionMode](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolGuard](../core-data-structures/tools.md) · [ToolRestriction](../core-data-structures/tools.md) · [ToolSchema](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:711`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:714`](../../packages/core/tools/src/index.ts) ## `ctx.tui` — `TuiExtensionService` (abstract seam) @@ -2451,7 +2451,7 @@ The concrete provider retains pi-tui, focus, and terminal lifecycle state. Plugi abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession ``` -Source: [`packages/ui/tui/src/index.ts:242`](../../packages/ui/tui/src/index.ts) +Source: [`packages/ui/tui/src/index.ts:245`](../../packages/ui/tui/src/index.ts) ## `ctx.typert` — `TypertRegistry` diff --git a/docs/core-data-structures/tools.i18n.yaml b/docs/core-data-structures/tools.i18n.yaml index 156324472b..a955adc963 100644 --- a/docs/core-data-structures/tools.i18n.yaml +++ b/docs/core-data-structures/tools.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/tools.md -tools.md: 98b642b846b23e2b29e6c6d800fe4106235eda85 -tools.zh.md: 1ef90c1e76ace7485ed6267de5ee82cbb4de6aa6 +tools.md: acaf5d32dd5481aec495ac49f727c9b64f25211e +tools.zh.md: 5c39226fdf5d1406dab7383d40227c26ff1e447c diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md index 98b642b846..acaf5d32dd 100644 --- a/docs/core-data-structures/tools.md +++ b/docs/core-data-structures/tools.md @@ -200,20 +200,23 @@ interface ToolExecutionInput { } ``` -A tool body receives the runtime extension. `deferContext()` is the composite-tool channel: it records nested-dispatch context without injecting inside the still-open outer call. +A tool body receives the runtime extension. `deferContext()` attaches context to the execution's own result — the composite-tool nested-dispatch channel, also usable by a leaf tool minting a plugin-sourced instruction — without injecting inside the still-open outer call. ```ts type-equiv /** * Runtime context handed to a tool implementation after the registry has - * accepted a {@link ToolExecution}. A composite tool uses - * {@link deferContext} to ferry context produced by nested dispatches back to - * the outer result; the loop appends it only after the outer `tool/result`. + * accepted a {@link ToolExecution}. {@link deferContext} attaches context to + * this execution's own result — a composite tool ferries nested-dispatch + * context back to the outer result, and a leaf tool may mint a fresh + * plugin-sourced instruction; the loop appends it only after the + * `tool/result`. */ interface ToolRunContext extends ToolExecution { /** - * Defer one nested-dispatch context until this tool's final result reaches - * the agent loop. Contexts retain their individual source and metadata and - * are emitted in call order. + * Defer one context — typically a nested-dispatch context ferried by a + * composite tool, or a fresh plugin-sourced instruction — until this tool's + * final result reaches the agent loop. Contexts retain their individual + * source and metadata and are emitted in call order. */ deferContext(context: UserMessage): void /** diff --git a/docs/core-data-structures/tools.zh.md b/docs/core-data-structures/tools.zh.md index 1ef90c1e76..5c39226fdf 100644 --- a/docs/core-data-structures/tools.zh.md +++ b/docs/core-data-structures/tools.zh.md @@ -200,20 +200,23 @@ interface ToolExecutionInput { } ``` -工具函数体接收运行时扩展。`deferContext()` 是组合工具的通道:它记录嵌套分派产生的上下文,而不会在外层调用尚未结束时注入这些上下文。 +工具函数体接收运行时扩展。`deferContext()` 把上下文附着到本次执行自己的结果上——既是组合工具转运嵌套分派上下文的通道,也可供叶子工具铸造插件来源指令——而不会在外层调用尚未结束时注入这些上下文。 ```ts type-equiv /** * Runtime context handed to a tool implementation after the registry has - * accepted a {@link ToolExecution}. A composite tool uses - * {@link deferContext} to ferry context produced by nested dispatches back to - * the outer result; the loop appends it only after the outer `tool/result`. + * accepted a {@link ToolExecution}. {@link deferContext} attaches context to + * this execution's own result — a composite tool ferries nested-dispatch + * context back to the outer result, and a leaf tool may mint a fresh + * plugin-sourced instruction; the loop appends it only after the + * `tool/result`. */ interface ToolRunContext extends ToolExecution { /** - * Defer one nested-dispatch context until this tool's final result reaches - * the agent loop. Contexts retain their individual source and metadata and - * are emitted in call order. + * Defer one context — typically a nested-dispatch context ferried by a + * composite tool, or a fresh plugin-sourced instruction — until this tool's + * final result reaches the agent loop. Contexts retain their individual + * source and metadata and are emitted in call order. */ deferContext(context: UserMessage): void /** diff --git a/docs/core-data-structures/user-interaction.i18n.yaml b/docs/core-data-structures/user-interaction.i18n.yaml index bc281d9bb7..36352bb747 100644 --- a/docs/core-data-structures/user-interaction.i18n.yaml +++ b/docs/core-data-structures/user-interaction.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/user-interaction.md -user-interaction.md: 2483382eee07d379b13d456149096c8afab70e4b -user-interaction.zh.md: 57f57277975597fff712705cc741ae8e78643642 +user-interaction.md: 16478168c8fcfeafbd3fdbbf30e732de81a3a8dd +user-interaction.zh.md: 7c8baaf189d759ca609c1d51652120f973ae48b6 diff --git a/docs/core-data-structures/user-interaction.md b/docs/core-data-structures/user-interaction.md index 2483382eee..16478168c8 100644 --- a/docs/core-data-structures/user-interaction.md +++ b/docs/core-data-structures/user-interaction.md @@ -86,14 +86,14 @@ interface AskUserQuestionRequest { ## Answer -Providers return one answer item per question id. `selected` contains selected option labels, and `custom` carries a free-form "Other" answer when the user typed one. When `custom` is present, `selected` is empty; custom text is an answer override, not a supplement to selected choices. A UI may also use an item with empty `selected` and no `custom` to preserve a skipped question in an otherwise completed batch. +Providers return one answer item per question id. `selected` contains selected option labels, and `custom` carries a free-form "Other" answer when the user typed one. For a single-select question, `custom` overrides the selected choice and `selected` is empty. For a multi-select question, `custom` may supplement the labels in `selected`. A UI may also use an item with empty `selected` and no `custom` to preserve a skipped question in an otherwise completed batch. ```ts type-equiv /** Answer to one question. */ interface AskUserQuestionAnswerItem { /** The answered question id. */ id: string - /** Selected option labels. Empty for custom or unanswered choices. */ + /** Selected option labels. May accompany custom text for a multi-select question. */ selected: string[] /** Optional free-text "Other" answer. */ custom?: string diff --git a/docs/core-data-structures/user-interaction.zh.md b/docs/core-data-structures/user-interaction.zh.md index 57f5727797..7c8baaf189 100644 --- a/docs/core-data-structures/user-interaction.zh.md +++ b/docs/core-data-structures/user-interaction.zh.md @@ -86,14 +86,14 @@ interface AskUserQuestionRequest { ## 回答 -提供方为每个问题 id 返回一个回答项。`selected` 包含选中的选项标签,`custom` 在用户输入自由文本时携带「其他」回答。当 `custom` 存在时,`selected` 为空;自定义文本是对选中项的覆盖,而非补充。UI 也可以使用 `selected` 为空且不含 `custom` 的回答项,在其余问题均已完成的批次中保留被跳过的问题。 +提供方为每个问题 id 返回一个回答项。`selected` 包含选中的选项标签,`custom` 在用户输入自由文本时携带「其他」回答。对于单选题,`custom` 会覆盖选中的选项,且 `selected` 为空。对于多选题,`custom` 可以补充 `selected` 中的标签。UI 也可以使用 `selected` 为空且不含 `custom` 的回答项,在其余问题均已完成的批次中保留被跳过的问题。 ```ts type-equiv /** Answer to one question. */ interface AskUserQuestionAnswerItem { /** The answered question id. */ id: string - /** Selected option labels. Empty for custom or unanswered choices. */ + /** Selected option labels. May accompany custom text for a multi-select question. */ selected: string[] /** Optional free-text "Other" answer. */ custom?: string diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index d1457a75af..b59b8733d5 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -41,10 +41,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `settings/document-updated` | `emit` | [`packages/settings/settings/src/index.ts:150`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` | | `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:137`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:188`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | [`tui`](../packages/ui/tui) | -| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:158`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) | -| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:132`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:138`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:149`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:160`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) | +| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:134`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:140`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:151`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `telemetry/record` | `waterfall` | [`packages/telemetry/session-telemetry/src/index.ts:41`](../packages/telemetry/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/telemetry/session-telemetry) (`waterfall`) | - | diff --git a/docs/module-graph.md b/docs/module-graph.md index 5c709fc06f..edb925960b 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -38,12 +38,9 @@ flowchart TD end subgraph group_bash["packages/bash"] pkg_bash["bash"] - pkg_bash_env["bash-env"] pkg_bash_local["bash-local"] pkg_bash_sandbox["bash-sandbox"] - pkg_pwsh_local["pwsh-local"] pkg_tool_bash["tool-bash"] - pkg_tool_pwsh["tool-pwsh"] end subgraph group_fs["packages/fs"] pkg_fs["fs"] @@ -507,10 +504,6 @@ flowchart TD pkg_bash_local --> pkg_invariants pkg_bash_local --> pkg_subprocess pkg_bash_local --> pkg_timeout - pkg_pwsh_local --> pkg_bash - pkg_pwsh_local --> pkg_invariants - pkg_pwsh_local --> pkg_subprocess - pkg_pwsh_local --> pkg_timeout pkg_fs_local --> pkg_fs pkg_fs_local --> pkg_invariants pkg_fs_policy --> pkg_fs @@ -698,11 +691,18 @@ flowchart TD pkg_tool_goal --> pkg_session pkg_tool_goal --> pkg_system_prompt pkg_tool_goal --> pkg_tools - pkg_bash_env --> pkg_bash - pkg_bash_env --> pkg_invariants - pkg_bash_env --> pkg_paths - pkg_bash_env --> pkg_session_persistence - pkg_bash_env --> pkg_tools + pkg_tool_bash --> pkg_agent + pkg_tool_bash --> pkg_bash + pkg_tool_bash --> pkg_invariants + pkg_tool_bash --> pkg_llm + pkg_tool_bash --> pkg_paths + pkg_tool_bash --> pkg_sandbox + pkg_tool_bash --> pkg_sandbox_policy + pkg_tool_bash --> pkg_session_persistence + pkg_tool_bash --> pkg_system_prompt + pkg_tool_bash --> pkg_tasks + pkg_tool_bash --> pkg_tools + pkg_tool_bash --> pkg_user_approval pkg_tool_fs --> pkg_fs pkg_tool_fs --> pkg_invariants pkg_tool_fs --> pkg_llm @@ -737,6 +737,7 @@ flowchart TD pkg_subagent --> pkg_scope pkg_subagent --> pkg_session pkg_subagent --> pkg_session_persistence + pkg_subagent --> pkg_session_projection pkg_subagent --> pkg_session_query pkg_subagent --> pkg_tasks pkg_subagent --> pkg_tools @@ -832,12 +833,6 @@ flowchart TD pkg_client_ui_goal --> pkg_client_ui_slots pkg_client_ui_goal --> pkg_goal pkg_client_ui_goal --> pkg_invariants - pkg_client_ui_subagent --> pkg_client_runtime - pkg_client_ui_subagent --> pkg_client_ui_conversation - pkg_client_ui_subagent --> pkg_client_ui_primitives - pkg_client_ui_subagent --> pkg_client_ui_slash - pkg_client_ui_subagent --> pkg_client_ui_slots - pkg_client_ui_subagent --> pkg_invariants pkg_session_reference --> pkg_agent pkg_session_reference --> pkg_compact pkg_session_reference --> pkg_invariants @@ -891,25 +886,6 @@ flowchart TD pkg_tool_workflow --> pkg_system_prompt pkg_tool_workflow --> pkg_tools pkg_tool_workflow --> pkg_workflow - pkg_tool_bash --> pkg_agent - pkg_tool_bash --> pkg_bash - pkg_tool_bash --> pkg_bash_env - pkg_tool_bash --> pkg_invariants - pkg_tool_bash --> pkg_llm - pkg_tool_bash --> pkg_sandbox - pkg_tool_bash --> pkg_sandbox_policy - pkg_tool_bash --> pkg_system_prompt - pkg_tool_bash --> pkg_tasks - pkg_tool_bash --> pkg_tools - pkg_tool_bash --> pkg_user_approval - pkg_tool_pwsh --> pkg_agent - pkg_tool_pwsh --> pkg_bash - pkg_tool_pwsh --> pkg_bash_env - pkg_tool_pwsh --> pkg_invariants - pkg_tool_pwsh --> pkg_llm - pkg_tool_pwsh --> pkg_system_prompt - pkg_tool_pwsh --> pkg_tasks - pkg_tool_pwsh --> pkg_tools pkg_subagent_acp --> pkg_agent pkg_subagent_acp --> pkg_invariants pkg_subagent_acp --> pkg_llm @@ -999,6 +975,36 @@ flowchart TD pkg_client_ui_plan --> pkg_client_ui_slots pkg_client_ui_plan --> pkg_invariants pkg_client_ui_plan --> pkg_plan_mode + pkg_client_ui_subagent --> pkg_client_locale + pkg_client_ui_subagent --> pkg_client_runtime + pkg_client_ui_subagent --> pkg_client_ui_conversation + pkg_client_ui_subagent --> pkg_client_ui_primitives + pkg_client_ui_subagent --> pkg_client_ui_slash + pkg_client_ui_subagent --> pkg_client_ui_slots + pkg_client_ui_subagent --> pkg_invariants + pkg_client_ui_subagent --> pkg_subagent + pkg_client_ui_subagent --> pkg_token_meter + pkg_agent_spine_demo --> pkg_agent + pkg_agent_spine_demo --> pkg_agent_loop + pkg_agent_spine_demo --> pkg_goal + pkg_agent_spine_demo --> pkg_goal_session + pkg_agent_spine_demo --> pkg_invariants + pkg_agent_spine_demo --> pkg_llm + pkg_agent_spine_demo --> pkg_llm_retry + pkg_agent_spine_demo --> pkg_paths + pkg_agent_spine_demo --> pkg_scope + pkg_agent_spine_demo --> pkg_session + pkg_agent_spine_demo --> pkg_session_title + pkg_agent_spine_demo --> pkg_skill + pkg_agent_spine_demo --> pkg_skill_local + pkg_agent_spine_demo --> pkg_system_prompt + pkg_agent_spine_demo --> pkg_tasks_local + pkg_agent_spine_demo --> pkg_tool_bash + pkg_agent_spine_demo --> pkg_tool_goal + pkg_agent_spine_demo --> pkg_tool_skill + pkg_agent_spine_demo --> pkg_tool_tasks + pkg_agent_spine_demo --> pkg_tools + pkg_agent_spine_demo --> pkg_workspace_context pkg_sdk_protocol --> pkg_invariants pkg_sdk_protocol --> pkg_llm pkg_sdk_protocol --> pkg_session @@ -1034,39 +1040,6 @@ flowchart TD pkg_jsonrpc --> pkg_sdk_protocol pkg_jsonrpc --> pkg_session pkg_jsonrpc --> pkg_subagent - pkg_agent_spine_demo --> pkg_agent - pkg_agent_spine_demo --> pkg_agent_loop - pkg_agent_spine_demo --> pkg_bash_env - pkg_agent_spine_demo --> pkg_goal - pkg_agent_spine_demo --> pkg_goal_session - pkg_agent_spine_demo --> pkg_invariants - pkg_agent_spine_demo --> pkg_llm - pkg_agent_spine_demo --> pkg_llm_retry - pkg_agent_spine_demo --> pkg_paths - pkg_agent_spine_demo --> pkg_scope - pkg_agent_spine_demo --> pkg_session - pkg_agent_spine_demo --> pkg_session_title - pkg_agent_spine_demo --> pkg_skill - pkg_agent_spine_demo --> pkg_skill_local - pkg_agent_spine_demo --> pkg_system_prompt - pkg_agent_spine_demo --> pkg_tasks_local - pkg_agent_spine_demo --> pkg_tool_bash - pkg_agent_spine_demo --> pkg_tool_goal - pkg_agent_spine_demo --> pkg_tool_skill - pkg_agent_spine_demo --> pkg_tool_tasks - pkg_agent_spine_demo --> pkg_tools - pkg_agent_spine_demo --> pkg_workspace_context - pkg_sdk_client --> pkg_invariants - pkg_sdk_client --> pkg_llm - pkg_sdk_client --> pkg_sdk_protocol - pkg_sdk_client --> pkg_session - pkg_subagent_dsh_sdk --> pkg_agent - pkg_subagent_dsh_sdk --> pkg_invariants - pkg_subagent_dsh_sdk --> pkg_llm - pkg_subagent_dsh_sdk --> pkg_sdk_client - pkg_subagent_dsh_sdk --> pkg_session - pkg_subagent_dsh_sdk --> pkg_subagent - pkg_subagent_dsh_sdk --> pkg_subprocess pkg_acp_demo --> pkg_acp pkg_acp_demo --> pkg_agent_spine_demo pkg_acp_demo --> pkg_app_boot @@ -1087,6 +1060,17 @@ flowchart TD pkg_cli_demo --> pkg_session_persistence_jsonl pkg_cli_demo --> pkg_tools pkg_cli_demo --> pkg_workspace_context + pkg_sdk_client --> pkg_invariants + pkg_sdk_client --> pkg_llm + pkg_sdk_client --> pkg_sdk_protocol + pkg_sdk_client --> pkg_session + pkg_subagent_dsh_sdk --> pkg_agent + pkg_subagent_dsh_sdk --> pkg_invariants + pkg_subagent_dsh_sdk --> pkg_llm + pkg_subagent_dsh_sdk --> pkg_sdk_client + pkg_subagent_dsh_sdk --> pkg_session + pkg_subagent_dsh_sdk --> pkg_subagent + pkg_subagent_dsh_sdk --> pkg_subprocess ``` | Package | Group | Depends on | @@ -1175,7 +1159,6 @@ flowchart TD | [`token-meter`](../packages/llm/token-meter) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection) | | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection) | | [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | -| [`pwsh-local`](../packages/bash/pwsh-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | | [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | | [`skill-local`](../packages/skill/skill-local) | `skill` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`skill`](../packages/skill/skill) | @@ -1216,12 +1199,12 @@ flowchart TD | [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | `telemetry` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`session-telemetry`](../packages/telemetry/session-telemetry) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`bash-env`](../packages/bash/bash-env) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools) | +| [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | -| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | +| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-query`](../packages/session-query/session-query), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-web`](../packages/web/tool-web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | | [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) | | [`timeout-policy`](../packages/timeout/timeout-policy) | `timeout` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | @@ -1238,7 +1221,6 @@ flowchart TD | [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | -| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | | [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | @@ -1248,8 +1230,6 @@ flowchart TD | [`tool-pty`](../packages/pty/tool-pty) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`pty`](../packages/pty/pty), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | -| [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | -| [`tool-pwsh`](../packages/bash/tool-pwsh) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | @@ -1261,14 +1241,15 @@ flowchart TD | [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-permission`](../packages/client/ui-permission) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-command`](../packages/client/ui-command), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`permission`](../packages/ui/permission) | | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) | +| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | +| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks-local`](../packages/tasks/tasks-local), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`sdk-protocol`](../packages/sdk/sdk-protocol) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | -| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`bash-env`](../packages/bash/bash-env), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks-local`](../packages/tasks/tasks-local), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | -| [`sdk-client`](../packages/sdk/sdk-client) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session) | -| [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/sdk-client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | | [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/acp/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`cli-demo`](../packages/examples/cli-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | +| [`sdk-client`](../packages/sdk/sdk-client) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session) | +| [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/sdk-client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 0aa81a7939..b3ac5aae70 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -531,7 +531,7 @@ Source: [`packages/core/session/src/types.ts:222`](../packages/core/session/src/ 'subagent/descriptor': SubagentDescriptorData ``` -Source: [`packages/subagent/subagent/src/descriptor.ts:32`](../packages/subagent/subagent/src/descriptor.ts) +Source: [`packages/subagent/subagent/src/descriptor.ts:37`](../packages/subagent/subagent/src/descriptor.ts) ### `todo/*` diff --git a/examples/acp-agent/tests/goal-snapshots/goal-wrapup/input.json b/examples/acp-agent/tests/goal-snapshots/goal-wrapup/input.json new file mode 100644 index 0000000000..8b6865a6ed --- /dev/null +++ b/examples/acp-agent/tests/goal-snapshots/goal-wrapup/input.json @@ -0,0 +1,13 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { + "op": "promptAndWaitForAgentMessage", + "text": "Create a durable goal for the wrap-up snapshot, then report readiness.", + "waitForText": "GOAL READY" + }, + { "op": "waitForTurnStart", "minimumTurn": 2 }, + { "op": "waitForTurnEnd" } + ] +} diff --git a/examples/acp-agent/tests/goal-snapshots/goal-wrapup/replay.override.json b/examples/acp-agent/tests/goal-snapshots/goal-wrapup/replay.override.json new file mode 100644 index 0000000000..d36b73f313 --- /dev/null +++ b/examples/acp-agent/tests/goal-snapshots/goal-wrapup/replay.override.json @@ -0,0 +1,42 @@ +[ + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "tool-call" }, + { "type": "tool-call-delta", "index": 0, "id": "call_goal_create", "name": "create_goal", "argumentsDelta": "{\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"max_goal_rounds\":2}" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_goal_create", "name": "create_goal", "arguments": "{\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"max_goal_rounds\":2}" } }, + { "type": "usage", "usage": { "inputTokens": 20, "outputTokens": 8 } }, + { "type": "finish", "reason": { "kind": "tool-calls" } } + ] + }, + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "text" }, + { "type": "text-delta", "index": 0, "text": "GOAL READY" }, + { "type": "block-end", "index": 0, "block": { "type": "text", "text": "GOAL READY" } }, + { "type": "usage", "usage": { "inputTokens": 28, "outputTokens": 2 } }, + { "type": "finish", "reason": { "kind": "stop" } } + ] + }, + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "tool-call" }, + { "type": "tool-call-delta", "index": 0, "id": "call_goal_complete", "name": "update_goal", "argumentsDelta": "{\"goal_id\":\"{{fromRequest:goal-[0-9a-f-]+}}\",\"revision\":1,\"action\":\"complete\"}" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_goal_complete", "name": "update_goal", "arguments": "{\"goal_id\":\"{{fromRequest:goal-[0-9a-f-]+}}\",\"revision\":1,\"action\":\"complete\"}" } }, + { "type": "usage", "usage": { "inputTokens": 40, "outputTokens": 9 } }, + { "type": "finish", "reason": { "kind": "tool-calls" } } + ] + }, + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "text" }, + { "type": "text-delta", "index": 0, "text": "GOAL WRAP-UP: the snapshot objective is achieved and this closing message reaches the user." }, + { "type": "block-end", "index": 0, "block": { "type": "text", "text": "GOAL WRAP-UP: the snapshot objective is achieved and this closing message reaches the user." } }, + { "type": "usage", "usage": { "inputTokens": 52, "outputTokens": 14 } }, + { "type": "finish", "reason": { "kind": "stop" } } + ] + } +] diff --git a/examples/acp-agent/tests/goal-snapshots/goal-wrapup/session.expected.jsonl b/examples/acp-agent/tests/goal-snapshots/goal-wrapup/session.expected.jsonl new file mode 100644 index 0000000000..4355ccb070 --- /dev/null +++ b/examples/acp-agent/tests/goal-snapshots/goal-wrapup/session.expected.jsonl @@ -0,0 +1,50 @@ +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Create a durable goal for the wrap-up snapshot, then report readiness."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":0,"data":{"title":"Create a durable goal for","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"user/message","seq":3,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"step/start","seq":4,"time":0,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":5,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":6,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_goal_create","name":"create_goal","argumentsDelta":"{\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"max_goal_rounds\":2}"}}} +{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"max_goal_rounds\":2}"}}}} +{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":20,"outputTokens":8}}}} +{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":12,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"max_goal_rounds\":2}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":20,"outputTokens":8}},"sourceEventSeqs":[7,8,9,10,11],"surfaceOp":"append"} +{"type":"tool/call","seq":13,"time":0,"data":{"turn":1,"step":1,"callId":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"max_goal_rounds\":2}"}} +{"type":"tool/result","seq":14,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_goal_create"},"content":[{"type":"tool-result","toolCallId":"call_goal_create","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":2},\"activation\":\"armed\"}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[13],"surfaceOp":"append"} +{"type":"user/message","seq":15,"time":0,"data":{"content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"phase\":\"active\",\"maxGoalRounds\":2},\"roundsStarted\":0,\"createdAt\":0,\"updatedAt\":0}"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":0,"change":{"kind":"goal/change","version":1,"operation":"create","goal":{"id":"goal-{{sessionId}}","revision":1,"objective":"Finish the ACP goal wrap-up snapshot proof","phase":"active","maxGoalRounds":2},"roundsStarted":0,"createdAt":0,"updatedAt":0}},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"step/end","seq":16,"time":0,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":17,"time":0,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"GOAL READY"}}} +{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"GOAL READY"}}}} +{"type":"assistant/chunk","seq":21,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":28,"outputTokens":2}}}} +{"type":"assistant/chunk","seq":22,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":23,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"GOAL READY"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":28,"outputTokens":2}},"sourceEventSeqs":[18,19,20,21,22],"surfaceOp":"append"} +{"type":"step/end","seq":24,"time":0,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":25,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"turn/start","seq":26,"time":0,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":1}}}} +{"type":"user/message","seq":27,"time":0,"data":{"content":[{"type":"text","text":"\nObjective: \"Finish the ACP goal wrap-up snapshot proof\"\nRound: 1/2\n\nContinue working toward the objective in this same session. Treat the current workspace, tool results, and durable session state as authoritative; inspect them instead of assuming earlier narration is still current. Make concrete progress and verify the result. Before claiming completion, gather evidence that the whole objective is achieved, read the current goal, and mark it complete. If work remains, leave the goal active for the next round. Follow the configured goal-tool policy before reporting a blocker.\n"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":1},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"step/start","seq":28,"time":0,"data":{"turn":2,"step":1}} +{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":30,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_goal_complete","name":"update_goal","argumentsDelta":"{\"goal_id\":\"goal-{{sessionId}}\",\"revision\":1,\"action\":\"complete\"}"}}} +{"type":"assistant/chunk","seq":31,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_complete","name":"update_goal","arguments":"{\"goal_id\":\"goal-{{sessionId}}\",\"revision\":1,\"action\":\"complete\"}"}}}} +{"type":"assistant/chunk","seq":32,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":40,"outputTokens":9}}}} +{"type":"assistant/chunk","seq":33,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":34,"time":0,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_goal_complete","name":"update_goal","arguments":"{\"goal_id\":\"goal-{{sessionId}}\",\"revision\":1,\"action\":\"complete\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":40,"outputTokens":9}},"sourceEventSeqs":[29,30,31,32,33],"surfaceOp":"append"} +{"type":"tool/call","seq":35,"time":0,"data":{"turn":2,"step":1,"callId":"call_goal_complete","name":"update_goal","arguments":"{\"goal_id\":\"goal-{{sessionId}}\",\"revision\":1,\"action\":\"complete\"}"}} +{"type":"tool/result","seq":36,"time":0,"data":{"turn":2,"step":1,"message":{"source":{"kind":"tool","callId":"call_goal_complete"},"content":[{"type":"tool-result","toolCallId":"call_goal_complete","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":2,\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"phase\":\"complete\",\"roundsStarted\":1,\"maxGoalRounds\":2},\"activation\":\"disarmed\"}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[35],"surfaceOp":"append"} +{"type":"user/message","seq":37,"time":0,"data":{"content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":2,\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"phase\":\"complete\",\"maxGoalRounds\":2},\"roundsStarted\":1,\"createdAt\":0,\"updatedAt\":0}"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":2,"round":0,"change":{"kind":"goal/change","version":1,"operation":"complete","goal":{"id":"goal-{{sessionId}}","revision":2,"objective":"Finish the ACP goal wrap-up snapshot proof","phase":"complete","maxGoalRounds":2},"roundsStarted":1,"createdAt":0,"updatedAt":0}},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","seq":38,"time":0,"data":{"content":[{"type":"text","text":"\nObjective: \"Finish the ACP goal wrap-up snapshot proof\"\nThe goal is marked complete and this autonomous run is ending. Write the closing message to the user now: state the outcome, summarize what was done and how it was verified, and point to the concrete results (files, commits, or other artifacts). Report only what earlier rounds and tool results in this session actually establish; when a detail is not in the session, say so instead of inventing it. Note anything the user should review or do next. Address the user directly. Do not call any more tools in this run; further work waits for the user's next instruction.\n"}],"source":{"kind":"plugin","plugin":"tool-goal"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"step/end","seq":39,"time":0,"data":{"turn":2,"step":1}} +{"type":"step/start","seq":40,"time":0,"data":{"turn":2,"step":2}} +{"type":"assistant/chunk","seq":41,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":42,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"text-delta","index":0,"text":"GOAL WRAP-UP: the snapshot objective is achieved and this closing message reaches the user."}}} +{"type":"assistant/chunk","seq":43,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"GOAL WRAP-UP: the snapshot objective is achieved and this closing message reaches the user."}}}} +{"type":"assistant/chunk","seq":44,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":52,"outputTokens":14}}}} +{"type":"assistant/chunk","seq":45,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":46,"time":0,"data":{"turn":2,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"GOAL WRAP-UP: the snapshot objective is achieved and this closing message reaches the user."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":52,"outputTokens":14}},"sourceEventSeqs":[41,42,43,44,45],"surfaceOp":"append"} +{"type":"step/end","seq":47,"time":0,"data":{"turn":2,"step":2}} +{"type":"turn/end","seq":48,"time":0,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/goal-snapshots/goal-wrapup/session.jsonl b/examples/acp-agent/tests/goal-snapshots/goal-wrapup/session.jsonl new file mode 100644 index 0000000000..94002ff85d --- /dev/null +++ b/examples/acp-agent/tests/goal-snapshots/goal-wrapup/session.jsonl @@ -0,0 +1 @@ +{"type":"session","version":0,"id":"goal-wrapup-placeholder","createdAt":0,"cwd":"{{cwd}}"} diff --git a/examples/acp-agent/tests/goal-snapshots/goal-wrapup/stdout.expected.jsonl b/examples/acp-agent/tests/goal-snapshots/goal-wrapup/stdout.expected.jsonl new file mode 100644 index 0000000000..e5c0dbb921 --- /dev/null +++ b/examples/acp-agent/tests/goal-snapshots/goal-wrapup/stdout.expected.jsonl @@ -0,0 +1,5 @@ +{"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":"GOAL READY"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"GOAL WRAP-UP: the snapshot objective is achieved and this closing message reaches the user."}}}} diff --git a/examples/acp-agent/tests/goal.snapshot.ts b/examples/acp-agent/tests/goal.snapshot.ts index b6a44cb66c..a7757eacf7 100644 --- a/examples/acp-agent/tests/goal.snapshot.ts +++ b/examples/acp-agent/tests/goal.snapshot.ts @@ -21,6 +21,7 @@ const fixtureFile = join(scenarioDir, 'session.jsonl') const overrideFile = join(scenarioDir, 'replay.override.json') const stdoutExpected = join(scenarioDir, 'stdout.expected.jsonl') const sessionExpected = join(scenarioDir, 'session.expected.jsonl') +const wrapupDir = join(dirname(fileURLToPath(import.meta.url)), 'goal-snapshots/goal-wrapup') const refreshing = process.env.DSH_SNAPSHOT === 'refresh' const agent: AgentUnderTest = { @@ -112,4 +113,62 @@ describe('same-session goal snapshot through the ACP automation driver', () => { expect(stdout).toBe(await readFile(stdoutExpected, 'utf8')) expect(session).toBe(await readFile(sessionExpected, 'utf8')) }) + + it('injects the wrap-up instruction after an autonomous completion and delivers a closing message', async () => { + const input = JSON.parse(await readFile(join(wrapupDir, 'input.json'), 'utf8')) as InputScript + const result = await runScenario(input, { + agent, + mode: 'replay', + fixtureFile: join(wrapupDir, 'session.jsonl'), + overrideFile: join(wrapupDir, 'replay.override.json'), + configPath: agent.configPath, + }) + + expect(result.stderr).toBe('') + expect(result.sessionLogs).toHaveLength(1) + const log = result.sessionLogs[0] + if (log === undefined) throw new Error('goal wrap-up snapshot did not persist its session') + const records = parseJsonl(log.content) + const events = records.slice(1) as unknown as SessionEvent[] + const calls = events.filter(event => event.type === 'tool/call').map(event => event.data.name) + expect(calls).toEqual(['create_goal', 'update_goal']) + expect(foldGoal(events)).toMatchObject({ + goal: { + objective: 'Finish the ACP goal wrap-up snapshot proof', + phase: 'complete', + revision: 2, + }, + roundsStarted: 1, + }) + // The wrap-up instruction is one plugin-sourced context injected after the + // terminal tool result, and the model still answers inside the same turn. + const wrapups = events.filter(event => event.type === 'user/message' + && event.data.source.kind === 'plugin' && event.data.source.plugin === 'tool-goal') + expect(wrapups).toHaveLength(1) + const wrapupText = wrapups.map(event => event.type === 'user/message' ? event.data.content : [])[0] + expect(JSON.stringify(wrapupText)).toContain('') + const closing = events.filter(event => event.type === 'assistant/message') + .flatMap(event => event.data.message.content) + .filter(block => block.type === 'text' && block.text.startsWith('GOAL WRAP-UP')) + expect(closing).toHaveLength(1) + const roundTurnEnds = events.filter(event => event.type === 'turn/end' && event.data.turn === 2) + expect(roundTurnEnds).toEqual([expect.objectContaining({ data: { turn: 2, reason: { kind: 'completed' } } })]) + + const context: NormalizeContext = { + sessionIds: [result.sessionId, log.id].filter((id): id is string => id !== undefined), + cwd: result.cwd, + } + const stdout = normalizeStdout(result.rawStdout, context) + const session = normalizeGoalLog(log.content, context) + const wrapupStdoutExpected = join(wrapupDir, 'stdout.expected.jsonl') + const wrapupSessionExpected = join(wrapupDir, 'session.expected.jsonl') + if (refreshing) { + await Promise.all([ + writeFile(wrapupStdoutExpected, stdout), + writeFile(wrapupSessionExpected, session), + ]) + } + expect(stdout).toBe(await readFile(wrapupStdoutExpected, 'utf8')) + expect(session).toBe(await readFile(wrapupSessionExpected, 'utf8')) + }) }) diff --git a/packages/acp/acp/src/index.ts b/packages/acp/acp/src/index.ts index 0a2f7f7f68..b88322012c 100644 --- a/packages/acp/acp/src/index.ts +++ b/packages/acp/acp/src/index.ts @@ -14,7 +14,7 @@ import { randomUUID } from 'node:crypto' import { isAbsolute } from 'node:path' import { Readable, Writable } from 'node:stream' import Schema from 'schemastery' -import { createUserMessage } from '@deepseek-ai/dsh-llm' +import { createUserMessage, errorChain } from '@deepseek-ai/dsh-llm' import { AgentSideConnection, ndJsonStream, @@ -368,7 +368,14 @@ export function apply(ctx: Context, config: AcpConfig): void { if (result.status === 'rejected') failures.push(result.reason as unknown) } if (failures.length > 0) { - throw new AggregateError(failures, `ACP agent teardown failed for ${failures.length} session(s)`) + // The production consumer logs this AggregateError through `String`, + // which renders only its message. Embed every per-session diagnostic, + // including nested causes and aggregate members, in that message. + const detail = failures.map(failure => errorChain(failure)).join('; ') + throw new AggregateError( + failures, + `ACP agent teardown failed for ${failures.length} session(s): ${detail}`, + ) } })() return quiescing diff --git a/packages/acp/acp/tests/dispose.spec.ts b/packages/acp/acp/tests/dispose.spec.ts index 5f6b5babd5..b4b1eaeef2 100644 --- a/packages/acp/acp/tests/dispose.spec.ts +++ b/packages/acp/acp/tests/dispose.spec.ts @@ -96,7 +96,7 @@ describe('ACP connection ownership', () => { expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined() }) - it('awaits every owned session disposal before reporting one failure', async () => { + it('awaits every owned session disposal and reports nested failure reasons', async () => { harness = await makeBridgeHarness() const create = harness.ctx.agents.create.bind(harness.ctx.agents) const releaseSecond = Promise.withResolvers() @@ -110,7 +110,10 @@ describe('ACP connection ownership', () => { if (created++ === 0) { handle.dispose = async () => { await originalDispose() - throw new Error('first session cleanup failed') + throw new AggregateError([ + new Error('scope cleanup failed', { cause: new Error('sqlite busy') }), + new Error('hook cleanup failed'), + ], 'first session cleanup failed') } } else { handle.dispose = async () => { @@ -131,7 +134,11 @@ describe('ACP connection ownership', () => { releaseSecond.resolve(undefined) await vi.waitFor(() => { - expect(warnings.some(warning => warning.includes('ACP agent teardown failed for 1 session(s)'))).toBe(true) + expect(warnings.some(warning => + warning.includes( + 'ACP agent teardown failed for 1 session(s): ' + + 'first session cleanup failed [scope cleanup failed: sqlite busy; hook cleanup failed]', + ))).toBe(true) expect(harness!.ctx.agents.get(SessionId(first.sessionId))).toBeUndefined() expect(harness!.ctx.agents.get(SessionId(second.sessionId))).toBeUndefined() }) diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index 974e3014d6..a636d8bc49 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/connection/README.md -README.md: c8b7c4787cbcbf6a202fb944459a589fcadd7c8d -README.zh.md: 693420183ffa4fb20e1fecbff523a12261a45d45 +README.md: f537fee3273e3b5d2411197cf1a1a6e0d34af5f9 +README.zh.md: a29d2c00e7df3f6290a03ffdad59b70b43702aca diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index c8b7c4787c..f537fee327 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`, reads included, since describing returns the exposed configuration and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3. +Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. Loopback hostname classification stays package-internal: the `/api` Host fence uses it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`, reads included, since describing returns the exposed configuration and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3. ## /api browser-trust fence diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index 693420183f..a29d2c00e7 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`,读取也在内,因为 describe 会返回已暴露的配置,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。 +协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`,读取也在内,因为 describe 会返回已暴露的配置,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。 ## /api 浏览器信任栅栏 diff --git a/packages/client/connection/src/api-request-trust.ts b/packages/client/connection/src/api-request-trust.ts index 8c1bddd631..ecb180dca7 100644 --- a/packages/client/connection/src/api-request-trust.ts +++ b/packages/client/connection/src/api-request-trust.ts @@ -14,6 +14,7 @@ */ import type { IncomingHttpHeaders } from 'node:http' +import { isLoopbackHostname } from './loopback-hostname.ts' /** The request facts the fence reads (structural subset of IncomingMessage). */ interface ApiTrustRequest { @@ -25,14 +26,6 @@ function header(headers: IncomingHttpHeaders, name: string): string | undefined return typeof value === 'string' ? value : undefined } -function isLoopbackHostname(hostname: string): boolean { - if (hostname === 'localhost' || hostname === '[::1]') return true - const parts = hostname.split('.') - return parts.length === 4 - && parts[0] === '127' - && parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255) -} - /** Normalized URL of a Host-header authority (hostname lowercased, default port stripped, IPv6 bracketed), or undefined when unparsable. */ function parseAuthority(authority: string): URL | undefined { try { diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index a7ebfbbd86..9e33e63ce2 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -8,6 +8,7 @@ import type { IApiClient } from './api.ts' import { ConnectionController, type ConnectionConfig, type ConnectionSinks, type ConnectionState } from './connection.ts' import { FixtureApiClient } from './fixture.ts' import { WebApiClient } from './web-api-client.ts' +import { isLoopbackHostname } from '../loopback-hostname.ts' // ---- Contract re-exports (browser-safe apiproxy channels + core types) ---- export type { @@ -48,6 +49,8 @@ export const inject: string[] = [] export interface ConnectionHandle { /** Shared api client (fixture or real, decided at boot from the page URL). */ readonly api: IApiClient + /** Whether the current page authority is loopback; non-browser contexts default to true. */ + readonly isLoopback: boolean /** * Start the connect/pump/reconnect loop with the consumer's frame sinks. * One consumer owns the streams (the runtime object layer); a second call @@ -64,11 +67,13 @@ export interface ConnectionHandle { * @param ctx - client cordis context. */ export function apply(ctx: Context): void { - const fixture = typeof location !== 'undefined' && new URLSearchParams(location.search).has('fixture') + const pageLocation = typeof location === 'undefined' ? undefined : location + const fixture = pageLocation !== undefined && new URLSearchParams(pageLocation.search).has('fixture') const api: IApiClient = fixture ? new FixtureApiClient() : new WebApiClient() let started = false const handle: ConnectionHandle = { api, + isLoopback: pageLocation === undefined || isLoopbackHostname(pageLocation.hostname), start(sinks, config) { if (started) throw new Error('connection: the stream loop is already owned by another consumer') started = true diff --git a/packages/client/connection/src/loopback-hostname.ts b/packages/client/connection/src/loopback-hostname.ts new file mode 100644 index 0000000000..fe2fe93fc9 --- /dev/null +++ b/packages/client/connection/src/loopback-hostname.ts @@ -0,0 +1,18 @@ +/** + * Browser-safe, zero-dependency loopback classification shared by the `/api` + * Host fence and the package's `ctx.connection` state. The predicate stays + * package-internal; client plugins consume the derived state through Cordis. + */ + +/** + * Whether a normalized URL hostname names the local loopback authority. + * @param hostname - WHATWG URL hostname (IPv6 literals retain brackets). + * @returns true for localhost, IPv6 loopback, or any IPv4 address in 127/8. + */ +export function isLoopbackHostname(hostname: string): boolean { + if (hostname === 'localhost' || hostname === '[::1]') return true + const parts = hostname.split('.') + return parts.length === 4 + && parts[0] === '127' + && parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255) +} diff --git a/packages/client/connection/tests/client-apply.spec.ts b/packages/client/connection/tests/client-apply.spec.ts index 6892dc7721..43c71dffb7 100644 --- a/packages/client/connection/tests/client-apply.spec.ts +++ b/packages/client/connection/tests/client-apply.spec.ts @@ -8,7 +8,7 @@ import { apply, type ConnectionHandle } from '../src/client/index.ts' import { FixtureApiClient } from '../src/client/fixture.ts' import { WebApiClient } from '../src/client/web-api-client.ts' -type Win = { location?: { search: string } } +type Win = { location?: { hostname: string; search: string } } afterEach(() => { delete (globalThis as Win).location @@ -24,20 +24,28 @@ async function mount(): Promise { describe('connection client apply', () => { it('mounts ctx.connection with the real client when no ?fixture switch is present', async () => { - ;(globalThis as Win).location = { search: '' } + ;(globalThis as Win).location = { hostname: 'localhost', search: '' } const handle = await mount() expect(handle.api).toBeInstanceOf(WebApiClient) + expect(handle.isLoopback).toBe(true) }) it('selects the fixture client under ?fixture (and with no location at all stays real)', async () => { - ;(globalThis as Win).location = { search: '?fixture' } + ;(globalThis as Win).location = { hostname: '127.0.0.1', search: '?fixture' } expect((await mount()).api).toBeInstanceOf(FixtureApiClient) delete (globalThis as Win).location - expect((await mount()).api).toBeInstanceOf(WebApiClient) + const handle = await mount() + expect(handle.api).toBeInstanceOf(WebApiClient) + expect(handle.isLoopback).toBe(true) + }) + + it('reports non-loopback page authority through the connection handle', async () => { + ;(globalThis as Win).location = { hostname: '192.0.2.20', search: '' } + expect((await mount()).isLoopback).toBe(false) }) it('start() hands out one loop, rejects a second consumer, and stop() aborts the streams', async () => { - ;(globalThis as Win).location = { search: '?fixture' } + ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } const handle = await mount() // config omitted: the `config ?? {}` default arm is part of the surface. const loop = handle.start({}) @@ -46,7 +54,7 @@ describe('connection client apply', () => { }) it('WebApiClient carries requests over globalThis.fetch', async () => { - ;(globalThis as Win).location = { search: '' } + ;(globalThis as Win).location = { hostname: 'localhost', search: '' } const handle = await mount() const original = globalThis.fetch const seen: string[] = [] diff --git a/packages/client/connection/tests/loopback-hostname.spec.ts b/packages/client/connection/tests/loopback-hostname.spec.ts new file mode 100644 index 0000000000..d0eaf3e0c4 --- /dev/null +++ b/packages/client/connection/tests/loopback-hostname.spec.ts @@ -0,0 +1,18 @@ +/** Shared loopback-hostname semantics for the Host fence and browser UI. */ + +import { describe, expect, it } from 'vitest' +import { isLoopbackHostname } from '../src/loopback-hostname.ts' + +describe('isLoopbackHostname', () => { + it('accepts localhost, IPv6 loopback, and the whole IPv4 127/8 block', () => { + for (const hostname of ['localhost', '[::1]', '127.0.0.1', '127.8.9.10', '127.255.255.255']) { + expect(isLoopbackHostname(hostname)).toBe(true) + } + }) + + it('refuses malformed and non-loopback hostnames', () => { + for (const hostname of ['remote.localhost', '::1', '128.0.0.1', '127.0.0', '127.0.0.256', '127.0.0.-1']) { + expect(isLoopbackHostname(hostname)).toBe(false) + } + }) +}) diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 6f31ca3beb..c3850804ff 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/runtime/README.md -README.md: eca7db1f9b2d5c7e28fa86a363ca4408703b99df -README.zh.md: 6a2e8c6085d06a9f04c1270e5976452b995a7e77 +README.md: 89e58f967f852bb0786a5b7d73fa8e924fa282e0 +README.zh.md: 960e2fceede1b500af9ee2063ec9283e2b7b271a diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index eca7db1f9b..89e58f967f 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager, and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. +Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager, and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions. ## Workspace and Session lists @@ -26,7 +26,7 @@ SlotsService gives the renderer separate bare observables for `useSessions` and ## The human transcript -`ConversationSnapshot.nodes` is the human transcript, not the model surface. `TranscriptAdapter` projects the raw window in log order — every append-origin surface event (`isAppendSurfaceEvent`) at its own log position, plus one `CompactionSummaryNode` marker per landed compaction checkpoint — and never consults surface order. A landed compaction therefore keeps the conversation it shadowed on the model side: the marker reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies stay out: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary. A checkpoint is a `user/message` carrying the compaction seam's plugin source that **replaced** a surface range; an appending plugin-sourced `user/message` is injected context, not a compaction. The adapter's plugin literal is pinned to the seam's own declaration by a type-only import of the cordis-free [`dsh-compact/checkpoint`](../../compact/compact/README.md) leaf, so renaming it there fails `tsc` here; a **value** import of the package would fail the client purity gate, and the package **root** is unreachable even as a type (it reaches `dsh-session`'s root, whose `Context` merge collides the host `sessions` with this program's). `tests/compact-checkpoint-pin.spec.ts` covers the same drift behaviorally. +`ConversationSnapshot.nodes` is the human transcript, not the model surface. `TranscriptAdapter` projects the raw window in log order — every append-origin surface event (`isAppendSurfaceEvent`) at its own log position, plus one `CompactionSummaryNode` marker per landed compaction checkpoint — and never consults surface order. `ConversationSnapshot.turnEnds` maps each completed turn in that window to its `turn/end` seq, retaining turn completion independently from the transcript so presentation can require a real boundary before enabling an action. A landed compaction therefore keeps the conversation it shadowed on the model side: the marker reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies stay out: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary. A checkpoint is a `user/message` carrying the compaction seam's plugin source that **replaced** a surface range; an appending plugin-sourced `user/message` is injected context, not a compaction. The adapter's plugin literal is pinned to the seam's own declaration by a type-only import of the cordis-free [`dsh-compact/checkpoint`](../../compact/compact/README.md) leaf, so renaming it there fails `tsc` here; a **value** import of the package would fail the client purity gate, and the package **root** is unreachable even as a type (it reaches `dsh-session`'s root, whose `Context` merge collides the host `sessions` with this program's). `tests/compact-checkpoint-pin.spec.ts` covers the same drift behaviorally. Because the projection is log-ordered, the node array is seq-monotonic by construction: log-only `command/run` / `command/done` nodes splice in by seq, `Session` merges interrupted frozen nodes by their fractional seqs, and a window whose checkpoint cites a shadowed range outside it renders the marker with nothing logged. The marker's summary text comes from the checkpoint's `compact/summary` provenance; a window cut that left the provenance outside makes the row non-expandable rather than empty, and a later page that supplies it resolves the text. Performance contract: one append materializes at most one node and copies the projection only when it adds that node; an event that changes no node keeps the previous array reference (a chunk storm costs nothing), and unchanged nodes keep their object identity. diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 6a2e8c6085..960e2fceed 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态;SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本;WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager,并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed`、`settings/changed`、`credentials/changed`、`models/changed`),使各表面缓存无需触碰流即可重拉。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。契约:api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。 +客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态;SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本;WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager,并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed`、`settings/changed`、`credentials/changed`、`models/changed`),使各表面缓存无需触碰流即可重拉。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。契约:api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。 ## Workspace 与 Session 列表 @@ -26,7 +26,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 ## 面向人的 transcript(文本记录) -`ConversationSnapshot.nodes` 是面向人的 transcript,不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口——每个 append 来源的 surface 事件(`isAppendSurfaceEvent`)落在它自己的日志位置上,外加每次落地的压缩(compaction)检查点贡献一个 `CompactionSummaryNode` 标记——且从不查询 surface 顺序。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话:标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不标记任何边界。检查点是携带压缩 seam 插件来源、且**替换**了一段 surface 范围的 `user/message`;一条 append 的插件来源 `user/message` 是注入上下文,不是压缩。适配器的插件字面量通过对无 cordis 的 [`dsh-compact/checkpoint`](../../compact/compact/README.md) 叶子做仅类型导入,钉在压缩 seam 自己的声明上:在那里改名会让此处 `tsc` 失败;而对该包(package)做**值**导入会被客户端纯度门禁拒绝,包的**根**即便作为类型也无法到达(它会到达 `dsh-session` 的根,其 `Context` 合并会让 host 的 `sessions` 与本程序的冲突)。`tests/compact-checkpoint-pin.spec.ts` 从行为侧覆盖同一漂移。 +`ConversationSnapshot.nodes` 是面向人的 transcript,不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口——每个 append 来源的 surface 事件(`isAppendSurfaceEvent`)落在它自己的日志位置上,外加每次落地的压缩(compaction)检查点贡献一个 `CompactionSummaryNode` 标记——且从不查询 surface 顺序。`ConversationSnapshot.turnEnds` 把该窗口中的每个已完成轮次映射到其 `turn/end` seq;它独立于 transcript 保留轮次完成状态,使呈现层能够在启用操作前要求存在真实边界。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话:标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不标记任何边界。检查点是携带压缩 seam 插件来源、且**替换**了一段 surface 范围的 `user/message`;一条 append 的插件来源 `user/message` 是注入上下文,不是压缩。适配器的插件字面量通过对无 cordis 的 [`dsh-compact/checkpoint`](../../compact/compact/README.md) 叶子做仅类型导入,钉在压缩 seam 自己的声明上:在那里改名会让此处 `tsc` 失败;而对该包(package)做**值**导入会被客户端纯度门禁拒绝,包的**根**即便作为类型也无法到达(它会到达 `dsh-session` 的根,其 `Context` 合并会让 host 的 `sessions` 与本程序的冲突)。`tests/compact-checkpoint-pin.spec.ts` 从行为侧覆盖同一漂移。 由于投影按日志顺序,节点数组天然按 seq 单调:仅日志的 `command/run` / `command/done` 节点按 seq 插入,`Session` 按分数 seq 归并被打断的冻结节点,而检查点所引范围落在窗口之外的窗口会渲染出标记且不打印任何日志。标记的摘要文本来自检查点的 `compact/summary` 溯源;窗口切分把溯源留在窗口外时该行不可展开而非空白,后续补上溯源的分页会解析出文本。性能契约:一次追加最多物化一个节点,并且仅在加入该节点时复制投影;不改变任何节点的事件保持上一次的数组引用(分片风暴零成本),未变化的节点保持其对象标识。 diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 31872356c8..46cc5b0391 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -331,6 +331,8 @@ export interface ConversationSnapshot { sessionId: SessionId /** Human transcript plus retry notices and interrupted-turn terminal nodes in event order. */ nodes: readonly ConversationNode[] + /** In-window completed turn number -> its `turn/end` event seq. */ + turnEnds: ReadonlyMap partial: PartialAssistant | null runningCalls: readonly RunningToolCall[] /** diff --git a/packages/client/runtime/src/client/sessions/lineage.ts b/packages/client/runtime/src/client/sessions/lineage.ts index 08f8361a92..4f26674420 100644 --- a/packages/client/runtime/src/client/sessions/lineage.ts +++ b/packages/client/runtime/src/client/sessions/lineage.ts @@ -3,10 +3,13 @@ // Orphaned lineage degrades to root level; cycles fail soft and emit as roots. import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types' /** Host list summary enriched with the latest mux-projected durable title. */ export interface TitledSessionSummary extends SessionSummary { title?: string + /** Current host-computed projection values for list consumers. */ + projectionValues?: Readonly> } /** One flattened session-list row (summary + lineage indent depth + live pending-approval bit). */ @@ -21,6 +24,8 @@ export interface SessionListEntry { /** Coarse durable origin for navigation filtering; not a continuation capability. */ origin?: 'subagent' cwd?: string + /** Current host-computed projection values for list consumers. */ + projectionValues?: Readonly> /** An approval question is pending on this session (mux-frame derived; the sidebar's amber dot). */ waitingApproval: boolean /** Lineage indent depth: root = 0; the UI just multiplies by the indent width. */ diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index 1019327df5..b4bf12a0e3 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -59,6 +59,8 @@ interface CatalogInflight { readonly promise: Promise readonly expandableRows: Set readonly activityRows: Map + /** Removal-time invalidation replayed over the response this request predates. */ + parentAvailableOverride: false | undefined } type SessionListMutation = @@ -101,6 +103,8 @@ export class SessionManager { private readonly addresses = new Map() private readonly catalogs = new Map() private readonly catalogInflight = new Map() + /** Catalog owners whose membership changed while a pull was in flight: one trailing refresh after it settles. */ + private readonly catalogStale = new Set() private readonly openCatalogs = new Set() private readonly catalogDebounce = new Map>() @@ -301,22 +305,26 @@ export class SessionManager { try { const { result } = await this.api.subagents.list({ parentSessionId }) if (result.ok) { + const parentAvailable = this.catalogInflight.get(parentSessionId)?.parentAvailableOverride + ?? result.value.parentAvailable this.catalogs.set(parentSessionId, { ...result.value, entries: this.withCatalogMutations(result.value.entries, expandableRows, activityRows), + parentAvailable, state: 'ready', error: null, }) for (const [childId, address] of this.addresses) { if (address.parentSessionId !== parentSessionId) continue - this.sessions.get(childId)?.handleSubagentParentAvailable(result.value.parentAvailable) + this.sessions.get(childId)?.handleSubagentParentAvailable(parentAvailable) } } else { this.catalogs.set(parentSessionId, { entries: this.withCatalogMutations( previous?.entries ?? [], expandableRows, activityRows, ), - parentAvailable: previous?.parentAvailable ?? false, + parentAvailable: this.catalogInflight.get(parentSessionId)?.parentAvailableOverride + ?? previous?.parentAvailable ?? false, state: 'error', error: result.error, }) @@ -327,16 +335,26 @@ export class SessionManager { entries: this.withCatalogMutations( previous?.entries ?? [], expandableRows, activityRows, ), - parentAvailable: previous?.parentAvailable ?? false, + parentAvailable: this.catalogInflight.get(parentSessionId)?.parentAvailableOverride + ?? previous?.parentAvailable ?? false, state: 'error', error: folded.ok ? null : folded.error, }) } finally { this.catalogInflight.delete(parentSessionId) + // Re-arm the trailing pull before the dirty notify: the response the + // caller observed predates the stale-marking change, so the follow-up + // refresh is the only carrier of that change. + if (this.catalogStale.delete(parentSessionId)) void this.refreshSubagents(parentSessionId) this.notifier.markDirty() } })() - this.catalogInflight.set(parentSessionId, { promise: operation, expandableRows, activityRows }) + this.catalogInflight.set(parentSessionId, { + promise: operation, + expandableRows, + activityRows, + parentAvailableOverride: undefined, + }) return operation } @@ -673,6 +691,29 @@ export class SessionManager { this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation this.waitingApprovals.delete(frame.sessionId) // a removed session cannot wait on anyone if (!durableSubagent) this.projectionStores.delete(frame.sessionId) + // A pull already in flight was requested before this removal and can + // carry the pre-removal parentAvailable:true, which would resurrect + // the writable editor this invalidation just closed. Replay false over + // that response and queue one trailing refresh so the post-removal + // host truth converges. + const inflightCatalog = this.catalogInflight.get(frame.sessionId) + if (inflightCatalog !== undefined) { + inflightCatalog.parentAvailableOverride = false + this.catalogStale.add(frame.sessionId) + } + // The removed session can no longer be the delivery owner of its + // catalog: invalidate availability immediately. Removal schedules no + // catalog refresh, and without this an addressed child keeps a + // writable editor against a dead continuation owner until an + // unrelated refresh (or forever, for a closed menu). + const ownedCatalog = this.catalogs.get(frame.sessionId) + if (ownedCatalog !== undefined && ownedCatalog.parentAvailable) { + this.catalogs.set(frame.sessionId, { ...ownedCatalog, parentAvailable: false }) + } + for (const [childId, address] of this.addresses) { + if (address.parentSessionId !== frame.sessionId) continue + this.sessions.get(childId)?.handleSubagentParentAvailable(false) + } return } case 'host/session-status': { @@ -724,11 +765,18 @@ export class SessionManager { for (const session of this.sessions.values()) void session.resync() } - /** Debounce membership refetches while one parent catalog is open. */ + /** Debounce membership refetches while one parent catalog is selected or open. */ private scheduleCatalogRefresh(parentSessionId: SessionId): void { if (this.catalogDebounce.has(parentSessionId)) return const timer = setTimeout(() => { this.catalogDebounce.delete(parentSessionId) + // The in-flight response predates the membership frame that scheduled + // this callback. Queue one post-settlement pull instead of treating an + // ordinary overlapping read as evidence that catalog membership changed. + if (this.catalogInflight.has(parentSessionId)) { + this.catalogStale.add(parentSessionId) + return + } void this.refreshSubagents(parentSessionId) }, 50) this.catalogDebounce.set(parentSessionId, timer) @@ -798,10 +846,14 @@ export class SessionManager { const merged: TitledSessionSummary[] = this.summaries.map((summary) => { // List rows read the generic 'title' projection key (host-computed unit // value; the bespoke session/title frame is retired). - const title = this.projectionStores.get(summary.sessionId)?.get('title') - return typeof title === 'string' && title !== '' - ? { ...summary, title } - : summary + const projectionStore = this.projectionStores.get(summary.sessionId) + const title = projectionStore?.get('title') + const projectionValues = projectionStore?.values() + return { + ...summary, + ...(typeof title === 'string' && title !== '' ? { title } : {}), + ...(projectionValues === undefined ? {} : { projectionValues }), + } }) const fresh = flattenLineage(merged, new Set(this.waitingApprovals.keys())) const items = fresh.map((entry) => { @@ -812,6 +864,7 @@ export class SessionManager { && prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd && prev.origin === entry.origin && prev.title === entry.title && prev.depth === entry.depth && prev.waitingApproval === entry.waitingApproval + && prev.projectionValues === entry.projectionValues ) return prev this.entryCache.set(entry.sessionId, entry) return entry diff --git a/packages/client/runtime/src/client/sessions/projection-store.ts b/packages/client/runtime/src/client/sessions/projection-store.ts index 4e6e7dd626..32401146af 100644 --- a/packages/client/runtime/src/client/sessions/projection-store.ts +++ b/packages/client/runtime/src/client/sessions/projection-store.ts @@ -74,6 +74,7 @@ interface Channel { export class ProjectionValueStore { private readonly rows = new Map() private readonly channels = new Map() + private valuesCache: Readonly> | undefined /** Coarse any-key channel (no snapshot cache to rebuild: reads hit rows directly). */ private readonly anyNotifier = new Notifier(() => {}) @@ -98,6 +99,19 @@ export class ProjectionValueStore { return this.rows.get(key)?.value } + /** + * Read every current projection value as one reference-stable snapshot. + * @returns The same frozen value map until a row changes. + */ + values(): Readonly> { + if (this.valuesCache === undefined) { + this.valuesCache = Object.freeze(Object.fromEntries( + [...this.rows].map(([key, row]) => [key, row.value]), + )) + } + return this.valuesCache + } + /** * Subscribe to any-key changes (microtask-batched) — the manager's list * rebuild channel. @@ -160,6 +174,7 @@ export class ProjectionValueStore { } private changed(key: string): void { + this.valuesCache = undefined this.channels.get(key)?.notifier.markDirty() this.anyNotifier.markDirty() } diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index 087a07b02d..47ee641053 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -25,6 +25,7 @@ import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-host-apiproxy/api' import type { HostObservable, SessionMaybeProvideInfo, SessionProvideInfo, } from '@deepseek-ai/dsh-client-ui-slots' +import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types' import type { SnapshotStore } from '../contract/store.ts' import { createSnapshotStore } from '../contract/store.ts' import type { SessionFace } from '../contract/session.ts' @@ -57,6 +58,8 @@ export interface SessionSummary { */ blank: boolean updatedAt: number + /** Current host-computed projection values retained by the object layer. */ + projectionValues?: Readonly> } /** @@ -613,6 +616,9 @@ export class SessionsService implements ISessions { waitingApproval: entry.waitingApproval, blank: entry.blank, updatedAt: entry.updatedAt, + ...(entry.projectionValues === undefined + ? {} + : { projectionValues: entry.projectionValues }), ...(entry.title !== undefined ? { title: entry.title } : {}), ...(entry.cwd !== undefined ? { cwd: entry.cwd } : {}), ...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}), diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 58d3028fba..cce554f3ad 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -113,6 +113,11 @@ export class Session implements SessionFace { private pendingCache: { rev: number; value: PendingInteraction[] } | null = null private derivedRev = 0 private nodesCache: { projected: readonly ConversationNode[]; derivedRev: number; value: readonly ConversationNode[] } | null = null + /** Completed turn boundaries retained from the raw window so presentation + * actions never infer a safe fork point from transcript content alone. */ + private turnEnds = new Map() + private turnEndsRev = 0 + private turnEndsCache: { rev: number; value: ReadonlyMap } | null = null /** Authoritative stream-only inbox snapshot; pending work never hits history. */ private queued: QueuedMessage[] = [] private queueRev = 0 @@ -821,6 +826,8 @@ export class Session implements SessionFace { return } case 'turn/end': { + this.turnEnds.set(event.data.turn, event.seq) + this.turnEndsRev++ if (event.data.reason.kind === 'aborted' || event.data.reason.kind === 'disposed') { this.settleScheduledRetry('cancelled', event.data.turn) } @@ -911,6 +918,8 @@ export class Session implements SessionFace { this.callsRev++ this.derivedNodes = [] this.derivedRev++ + this.turnEnds = new Map() + this.turnEndsRev++ this.codeDispatches = new Map() this.dispatchesRev++ for (let i = 0; i < this.events.length; i++) { @@ -942,6 +951,9 @@ export class Session implements SessionFace { if (this.callsCache === null || this.callsCache.rev !== this.callsRev) { this.callsCache = { rev: this.callsRev, value: [...this.openCalls.values()] } } + if (this.turnEndsCache === null || this.turnEndsCache.rev !== this.turnEndsRev) { + this.turnEndsCache = { rev: this.turnEndsRev, value: new Map(this.turnEnds) } + } if (this.pendingCache === null || this.pendingCache.rev !== this.pendingRev) { this.pendingCache = { rev: this.pendingRev, value: [...this.pending.values()] } } @@ -955,6 +967,7 @@ export class Session implements SessionFace { return { sessionId: this.sessionId, nodes, + turnEnds: this.turnEndsCache.value, partial, runningCalls: this.callsCache.value, pending: this.pendingCache.value, diff --git a/packages/client/runtime/tests/client-apply.spec.ts b/packages/client/runtime/tests/client-apply.spec.ts index d389efe319..e5691a0619 100644 --- a/packages/client/runtime/tests/client-apply.spec.ts +++ b/packages/client/runtime/tests/client-apply.spec.ts @@ -26,6 +26,7 @@ async function mount(): Promise { const bench: Bench = { ctx, api, sinks: undefined, stopped: 0 } const handle: ConnectionHandle = { api, + isLoopback: true, start: (sinks) => { bench.sinks = sinks return { stop: () => { bench.stopped += 1 } } diff --git a/packages/client/runtime/tests/manager.spec.ts b/packages/client/runtime/tests/manager.spec.ts index 6ada34de7f..f6d7baf318 100644 --- a/packages/client/runtime/tests/manager.spec.ts +++ b/packages/client/runtime/tests/manager.spec.ts @@ -529,6 +529,149 @@ describe('subagent catalogs', () => { { kind: 'child', id: S2, activity: 'inactive' }, ]) }) + + it('coalesces overlapping catalog reads without scheduling a trailing pull', async () => { + const api = new FakeApiClient() + const root = 'fk-root' as SessionId + const first = deferred>>() + api.onSubagentList = () => first.promise + const manager = new SessionManager(api) + + const refresh = manager.refreshSubagents(root) + expect(manager.refreshSubagents(root)).toBe(refresh) + api.onSubagentList = () => Promise.resolve(ok({ entries: [], parentAvailable: true })) + first.resolve(ok({ entries: [], parentAvailable: true })) + await refresh + + expect(api.callsOf('subagent.list')).toHaveLength(1) + }) + + it('runs one trailing catalog refresh for a membership change coalesced into an in-flight pull', async () => { + vi.useFakeTimers() + try { + const api = new FakeApiClient() + const root = 'fk-root' as SessionId + const first = deferred>>() + const second = deferred>>() + api.onSubagentList = () => first.promise + const manager = new SessionManager(api, root) + const refresh = manager.refreshSubagents(root) + + // A membership frame arrives while the pull is in flight; the debounced + // refresh it schedules fires 50ms later and is coalesced into the pull — + // which was requested before the new child existed. The stale mark must + // queue one trailing pull carrying the change. + manager.handleHostEnvelope({ + rpcId: 'child-added' as never, + payload: { + type: 'host/session-added', sessionId: S2, parentSessionId: root, blank: false, + }, + }) + await vi.advanceTimersByTimeAsync(50) + api.onSubagentList = () => second.promise + first.resolve(ok({ + entries: [{ + kind: 'child', id: S1, mode: 'continuable', label: 'older', + activity: 'inactive', hasChildren: false, + }] as never[], + parentAvailable: true, + })) + await refresh + // The trailing pull is already in flight (kicked synchronously in finally). + second.resolve(ok({ + entries: [ + { + kind: 'child', id: S1, mode: 'continuable', label: 'older', + activity: 'inactive', hasChildren: false, + }, + { + kind: 'child', id: S2, mode: 'continuable', label: 'new child', + activity: 'inactive', hasChildren: false, + }, + ] as never[], + parentAvailable: true, + })) + await second.promise + + expect(api.callsOf('subagent.list')).toHaveLength(2) + expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([ + { kind: 'child', id: S1, label: 'older' }, + { kind: 'child', id: S2, label: 'new child' }, + ]) + } finally { + vi.useRealTimers() + } + }) + + it('keeps removal invalidation across a stale success and failed trailing pull', async () => { + const api = new FakeApiClient() + const root = 'fk-root' as SessionId + const child = () => ({ + kind: 'child' as const, id: S2, mode: 'continuable' as const, label: 'worker', + activity: 'inactive' as const, hasChildren: false, + }) + const first = deferred>>() + api.onSubagentList = () => first.promise + const manager = new SessionManager(api) + const refresh = manager.refreshSubagents(root) + first.resolve(ok({ entries: [child()] as never[], parentAvailable: true })) + await refresh + manager.selectSubagent({ parentSessionId: root, childSessionId: S2, mode: 'continuable' }) + + // The removal lands while a second pull is in flight: the invalidation + // must survive the pre-removal ok response, so one trailing pull runs. + const mid = deferred>>() + api.onSubagentList = () => mid.promise + const midRefresh = manager.refreshSubagents(root) + manager.handleHostEnvelope({ + rpcId: 'parent-removed-mid-pull' as never, + payload: { type: 'host/session-removed', sessionId: root }, + }) + const trailing = deferred>>() + api.onSubagentList = () => trailing.promise + mid.resolve(ok({ entries: [child()] as never[], parentAvailable: true })) + await midRefresh + expect(manager.getListSnapshot().subagentsByParent[root]?.parentAvailable).toBe(false) + expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: false }) + + trailing.resolve(err({ code: 'internal', message: 'trailing pull failed', details: {} })) + await vi.waitFor(() => { + expect(manager.getListSnapshot().subagentsByParent[root]).toMatchObject({ + state: 'error', + parentAvailable: false, + }) + }) + + const rootCalls = api.callsOf('subagent.list') + .filter(call => (call as { parentSessionId: SessionId }).parentSessionId === root) + expect(rootCalls).toHaveLength(3) + expect(manager.getListSnapshot().subagentsByParent[root]?.parentAvailable).toBe(false) + expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: false }) + }) + + it('invalidates catalog availability when the owning parent is removed', async () => { + const api = new FakeApiClient() + const root = 'fk-root' as SessionId + api.onSubagentList = () => Promise.resolve(ok({ + entries: [{ + kind: 'child', id: S2, mode: 'continuable', label: 'worker', + activity: 'inactive', hasChildren: false, + }] as never[], + parentAvailable: true, + })) + const manager = new SessionManager(api) + await manager.refreshSubagents(root) + manager.selectSubagent({ parentSessionId: root, childSessionId: S2, mode: 'continuable' }) + expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: true }) + + manager.handleHostEnvelope({ + rpcId: 'parent-removed' as never, + payload: { type: 'host/session-removed', sessionId: root }, + }) + + expect(manager.getListSnapshot().subagentsByParent[root]?.parentAvailable).toBe(false) + expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: false }) + }) }) describe('remaining branches', () => { diff --git a/packages/client/runtime/tests/projection-store.spec.ts b/packages/client/runtime/tests/projection-store.spec.ts index eea43b67f3..143da92348 100644 --- a/packages/client/runtime/tests/projection-store.spec.ts +++ b/packages/client/runtime/tests/projection-store.spec.ts @@ -86,6 +86,17 @@ describe('ProjectionValueStore semantics', () => { const store = new ProjectionValueStore() expect(store.faceOf('test/marks')).toBe(store.faceOf('test/marks')) }) + + it('publishes one reference-stable whole-value snapshot until a row changes', () => { + const store = new ProjectionValueStore() + const empty = store.values() + expect(store.values()).toBe(empty) + store.apply('test/marks', { marks: ['a'] }, 1) + const populated = store.values() + expect(populated).toEqual({ 'test/marks': { marks: ['a'] } }) + expect(populated).not.toBe(empty) + expect(store.values()).toBe(populated) + }) }) describe('Session tail-page seeding', () => { @@ -167,6 +178,36 @@ describe('manager frame routing', () => { expect(manager.getListSnapshot().items[0]?.title).toBeUndefined() }) + it('projects every retained value into list rows with stable snapshot identity', async () => { + const api = new FakeApiClient() + const manager = new SessionManager(api) + api.onList = () => Promise.resolve(ok({ + items: [{ + sessionId: sid('s1'), updatedAt: 1, running: false, blank: false, + projections: { + asOfSeq: 2, + values: { 'test/marks': { marks: ['baseline'] } }, + }, + }], + }) as never) + await manager.refreshList() + const baseline = manager.getListSnapshot().items[0]?.projectionValues + expect(baseline).toEqual({ 'test/marks': { marks: ['baseline'] } }) + expect(manager.getListSnapshot().items[0]?.projectionValues).toBe(baseline) + + manager.handleMuxEnvelope({ + rpcId: 'p2' as never, + payload: { + type: 'session/projection', sessionId: sid('s1'), key: 'test/marks', + value: { marks: ['live'] }, seq: 3, + } as never, + }) + await Promise.resolve() + expect(manager.getListSnapshot().items[0]?.projectionValues) + .toEqual({ 'test/marks': { marks: ['live'] } }) + expect(manager.getListSnapshot().items[0]?.projectionValues).not.toBe(baseline) + }) + it('drops the projection store with the removed session', async () => { const api = new FakeApiClient() const manager = new SessionManager(api) diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index 7f5f793948..6e75593e3a 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -426,6 +426,7 @@ describe('live event path', () => { feed(ev.turnEnd(10, 1, 'aborted')) // no assistant/message ever arrives const snapshot = session.getSnapshot() expect(snapshot.partial).toBeNull() + expect(snapshot.turnEnds.get(1)).toBe(10) const frozen = snapshot.nodes.at(-1) expect(frozen).toMatchObject({ kind: 'assistant', interrupted: true, blocks: [{ kind: 'text', text: '说到一半' }] }) // Ordered inside the flow: after the user message (seq 7), before any later turn. @@ -1215,6 +1216,7 @@ describe('reference stability (the memo contract)', () => { expect(after).not.toBe(before) expect(after.runningCalls).toBe(before.runningCalls) expect(after.pending).toBe(before.pending) + expect(after.turnEnds).toBe(before.turnEnds) // And a mutation on the tracked domain swaps that array. feed(ev.toolResult(11, 1, 'c1', 'ECHO')) const resolved = session.getSnapshot() diff --git a/packages/client/runtime/tests/wire-events.spec.ts b/packages/client/runtime/tests/wire-events.spec.ts index fd7858d60c..21e7f1fc06 100644 --- a/packages/client/runtime/tests/wire-events.spec.ts +++ b/packages/client/runtime/tests/wire-events.spec.ts @@ -20,6 +20,7 @@ async function mount(): Promise { const bench: Bench = { ctx, sinks: undefined } const handle: ConnectionHandle = { api, + isLoopback: true, start: (sinks) => { bench.sinks = sinks return { stop: () => {} } diff --git a/packages/client/test-runtime/src/fixtures.ts b/packages/client/test-runtime/src/fixtures.ts index 2544870ace..d033669215 100644 --- a/packages/client/test-runtime/src/fixtures.ts +++ b/packages/client/test-runtime/src/fixtures.ts @@ -46,6 +46,7 @@ export function conversationSnapshot(sessionId: SessionId): ConversationSnapshot return { sessionId, nodes: [], + turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index d131e31202..da6ea1a597 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: e610b990dd89204fd7e22e8b86f807d10b8ba439 -README.zh.md: 268e05a806db1468ba689608c178646af132fe25 +README.md: 78572ba0ab3ce9475dba31dee8844017564e2a18 +README.zh.md: 7708e980e24f4ea4365fbbacd641a5be6c61b138 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index e610b990dd..78572ba0ab 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -10,7 +10,7 @@ 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. -Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The sidebar mirrors the blocked state through the manager-tracked `waitingApproval` list bit (lit for uninstantiated sessions too), which outranks the running ring until the question resolves. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels. Safe preset picks submit `/permission ` immediately through the bar's injected `command` callback, while `danger-full-access` is presented as `Full access` and first opens an in-page Modal risk confirmation. The enabling action stays disabled until the user checks the acknowledgement; cancel, Escape, close, and mask click submit nothing. +Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The runtime manager tracks this approval wait through the `waitingApproval` list bit even for uninstantiated sessions; `ui-workspace` owns its sidebar presentation. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels. Safe preset picks submit `/permission ` immediately through the bar's injected `command` callback, while `danger-full-access` is presented as `Full access` and first opens an in-page Modal risk confirmation. The enabling action stays disabled until the user checks the acknowledgement; cancel, Escape, close, and mask click submit nothing. The session header declares and renders the session-scoped `'conversation.session.header.actions'` list beside the title, allowing feature plugins to contribute controls without entering the skeleton. The composer chain currency includes the current conversation `session`; ui-subagent selects one-shot or parent-unavailable addressed sessions for reason-specific read-only copy, while the ordinary InputBar keeps every addressed child Send-only because the continuation service exposes no public per-Activation cancellation operation and `session.cancel` would bypass its ownership. @@ -20,7 +20,7 @@ A Think row stays collapsed by default and exposes live reasoning throughput wit 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. -A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed per render intent — the terminal and web cards, each with its own bound; a generic tool's content remains panel-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)). +A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed per render intent — the terminal and web cards, each with its own bound. A Bash execution failure that settles on the generic path instead exposes its original arguments and full error through the same bounded IN/OUT disclosure, while successful generic results such as a background-start acknowledgement remain summary-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)). A tool call declaring the `web` render intent renders its web retrieval inline, at both conversation render sites, through ui-primitives' `WebBlock`. `contract/web-card-model.ts` is the single derivation from the snapshot's `resultView`, mirroring the terminal card, so the sites cannot disagree about what a web call shows; it yields null — the generic path — for a running call, a non-web result view, a generic result view, a `card` tag this client version does not know, or a web card whose `kind` this client version does not know (a newer host's value, which the wire cannot be trusted to be `search` or `fetch`). The keyed `WebRow` registers one component under both `web_search` and `web_fetch`, discriminating on the tool name only for its icon and title; it composes the shared `ToolRow`, feeding the card as ToolRow's `web` body, so the retrieval is the row's collapsed-by-default expanded card (the same unified expand every card row has). A web-declaring tool without a keyed row lands on the `GenericToolCard` fallback, which routes the card through ToolRow the same way, and the details panel renders it at the primitive's full source allowance and, below the card, the flattened model-visible result content — a fetch body is readable only there, since its card carries only the URL and status. Rows cap at `CHAT_WEB_MAX_SOURCES` (8) against the panel's 16, the same summary-versus-reading split the terminal card draws ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md)). @@ -38,7 +38,7 @@ The todo surfaces are two registrations over that shape, both plain registrant p `QueueDock` is the terminal input-dock entry at `order: 20`. It hides while empty, renders one pending row directly, and defaults two or more rows to a collapsed `" 条排队消息"` header whose button expands or collapses the complete list. The header exposes `aria-expanded` and `aria-controls`; the expanded list scrolls within a 180px height bound. An active edit or mutation keeps its rows visible, and emptying the queue restores the collapsed default for the next queue. Each visible ordinary-session row remains a single-line preview with its exact-occurrence edit, delete, and strict-steer actions; addressed subagents retain the rows as a read-only projection because their continuation transport does not expose queue mutation. If strict steer loses to a closed window, the original occurrence remains queued for normal delivery; if the driver already claimed it, normal delivery is already underway. Neither converged race displays a failure, while transport and unknown failures do. -The Host's placement-aware `session/queue` snapshot also carries pending steering. QueueDock filters it out, while ChatView projects it as a user-style bubble with Copy at the conversation tail; Fork stays absent because the message has not entered a durable turn. The Host delays steering retirement until the durable `steering/message` has entered the mux stream. On that accepted live event, the client runtime retires the first matching current steering occurrence before publishing the snapshot; historical events cannot hide later occurrences that reuse the same `MessageId`. The bubble therefore hands off without a gap or duplicate, restores Copy and Fork from the durable node, and survives reconnect from the same authority. +The Host's placement-aware `session/queue` snapshot also carries pending steering. QueueDock filters it out, while ChatView projects it as a user-style bubble with Copy at the conversation tail; Fork stays absent because the message has not entered a durable turn. The Host delays steering retirement until the durable `steering/message` has entered the mux stream. On that accepted live event, the client runtime retires the first matching current steering occurrence before publishing the snapshot; historical events cannot hide later occurrences that reuse the same `MessageId`. The bubble therefore hands off without a gap or duplicate, immediately restores Copy and the branch control from the durable node, enables branch only when that node is the completed turn's transcript tail, and survives reconnect from the same authority. Keyboard message submission resolves delivery from the addressed session's running state and steering capability. While idle, Enter and Cmd/Ctrl+Enter both perform an ordinary Queue send. While a primary session is running, the browser-persisted General Settings preference assigns plain Enter to `Queue` (the default) or `Steer`, and Cmd/Ctrl+Enter performs the other behavior; Shift+Enter remains a newline. Addressed subagents keep both gestures on their Queue-only continuation transport even while running. The preference affects only the steer-capable busy-state gesture pair, and the send button and non-keyboard submit actions remain Queue. Composer Steer uses the existing best-effort `session.prompt(mode: 'steer')` contract: if the current next-step window closes before acceptance, AgentLoop admits the message as the next waking Queue turn without surfacing a failure or losing the draft transaction. @@ -63,8 +63,8 @@ None; this package neither assembles nor sends a provider request. - **Compaction markers show no scale** — the row does not yet report how many messages or which range the checkpoint replaced. - **Stats-line durations cover the in-window flow only** — LLM and tool wall times fold the snapshot's assistant `timing` and tool call/result pairs, so nodes outside the loaded event window (older history) are not counted. - **Details panel is the minimal form and currently has no entry point** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred. Tool rows stopped being details-panel click targets and nothing replaced that gesture, so `ChatViewInjected.openDetails` is implemented but uncalled and the panel (including its terminal card) is unreachable in the assembled application; its rendering stays covered by mounting it with a selection directly. -- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / branch / clock) ships under the last content-text assistant of each turn only; mid-turn narration and Think-only nodes stay chrome-free. Branch forks through the turn containing that message, increments the inherited title on the client, and then opens the child, while a fork or rename failure leaves the source selected. -- **Sent user messages cannot be edited** — the user bubble's IconActions row carries clock / copy / branch only, and branching from the message is the nearest gesture. The control returns with the capability behind it: a client mutation over a settled user message, plus the host behavior for the turn that already consumed it ([decision](../../../.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md)). +- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / clock / branch) ships under the last content-text assistant of each turn only; mid-turn narration and Think-only nodes stay chrome-free. Branch stays disabled unless that message is also the last transcript node of a completed turn; when enabled, it forks through that turn, increments the inherited title on the client, and opens the child. A fork or rename failure leaves the source selected ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md)). +- **Sent user messages cannot be edited** — user bubbles retain clock, copy, and branch; branch stays disabled unless a completed turn's transcript ends at that user message. Editing returns with the capability behind it: a client mutation over a settled user message, plus the host behavior for the turn that already consumed it ([decision](../../../.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md)). - **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export. - **The approval panel's "Always allow this type" is deferred** — durable grants need a grant-storage design; only allow-once/reject answer today. - **TodoPanel truncates long item text to one ellipsized line** — the figma strip has no wrap or expand affordance; full text is not readable inline. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 268e05a806..7708e980e2 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -14,11 +14,11 @@ 已记录的非用户消息渲染为默认折叠的 `上下文注入` 展开项。它通过包内部的 `DisclosureRow` 与 `ToolRow` 共享 Tool calls 标题栏的几何与交互,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,并以内联 JSON 展示 `content` 和 `source`,且不会合成工具状态、摘要或键控 toolview 分发([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md))。 -Think 行默认保持折叠,并在不展开思维链的情况下暴露实时推理吞吐:当 reasoning block 是流式尾部时,摘要从结算后的首行切换到最新的非空行,其单行滚动区会随每个 delta 追到行内末端。展开该行会移除移动摘要,让完整 reasoning 进入普通页面流,因此页面阅读不会与内部跟随器争夺滚动;结算后恢复左对齐的稳定首行摘要([决策](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md))。 +Think 行默认保持折叠,并在不展开思维链的情况下暴露实时推理(reasoning)吞吐:当推理块是流式输出尾部时,摘要从结算后的首行切换到最新的非空行,其单行滚动区会随每个 delta 追到行内末端。展开该行会移除移动摘要,让完整推理进入普通页面流,因此页面阅读不会与内部跟随器争夺滚动;结算后恢复左对齐的稳定首行摘要([决策](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md))。 通用工具行把内置的 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 变体的可展开源码渲染。 -声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView`/`resultView` 对推导的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null,落回通用路径。因此两个渲染点也都显示卡片的运行状态点,它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片常驻在摘要行下方;由于工具行已不再是详情面板的点击目标,卡片的复制与展开控件就是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出按渲染意图开放——终端卡片与 web 卡片,各有自己的上限;通用工具的内容仍然只在面板中呈现([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。 +声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView`/`resultView` 对推导的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null,落回通用路径。因此两个渲染点也都显示卡片的运行状态点,它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片常驻在摘要行下方;由于工具行已不再是详情面板的点击目标,卡片的复制与展开控件就是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出按渲染意图开放——终端卡片与 web 卡片,各有自己的上限。若 Bash 执行失败时落在通用路径,则改用同样有界的 IN/OUT 展开区暴露原始参数和完整错误;后台启动确认等成功的通用结果仍只显示摘要([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。 声明 `web` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `WebBlock` 内联渲染其 web 检索。`contract/web-card-model.ts` 是从快照的 `resultView` 推导的唯一位置,镜像终端卡片,因此两个渲染点不可能对一次 web 调用的显示产生分歧;对运行中的调用、非 web 的 result view、generic result view、本客户端版本不认识的 `card` 标签,或本客户端版本不认识 `kind` 的 web 卡片(更新的 host 发来的值,wire 上不可信其为 `search` 或 `fetch`),它返回 null,落回通用路径。键控的 `WebRow` 把一个组件注册在 `web_search` 与 `web_fetch` 两个键下,仅根据工具名判别以选取图标与标题;它组合共享的 `ToolRow`,把卡片作为 ToolRow 的 `web` body 传入,因此检索成为该行默认折叠的展开卡片(与每个卡片行相同的统一展开交互)。没有自己键控行的 web 声明工具落到 `GenericToolCard` 兜底,它以同样方式经 ToolRow 渲染卡片,详情面板则以原语的完整 source 额度渲染它,并在卡片下方渲染摊平的模型可见结果内容——fetch 正文只在此处可读,因为其卡片只携带 URL 和状态。行的上限是 `CHAT_WEB_MAX_SOURCES`(8),面板为 16,与终端卡片所画的摘要面对阅读面的同一划分([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md))。 @@ -32,13 +32,13 @@ Think 行默认保持折叠,并在不展开思维链的情况下暴露实时 工具行同样是 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 已声明);bash 示例是第三方姿态的范例。Trajectory/waterfall(瀑布式事件)工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。 -审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位(未实例化会话同样点亮)镜像该阻塞状态,其优先级高于运行中圆环,直至问题解决。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,其中 kebab-case 预设名渲染为 Title Case 标签;普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission `,而 `danger-full-access` 在界面中显示为 `Full access`,选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用;取消、Escape、关闭按钮与点击遮罩都不会提交命令。 +审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。运行时 manager 通过 `waitingApproval` 列表位跟踪这种审批等待,未实例化的 Session 也不例外;`ui-workspace` 负责其侧边栏呈现。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,其中 kebab-case 预设名渲染为 Title Case 标签;普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission `,而 `danger-full-access` 在界面中显示为 `Full access`,选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用;取消、Escape、关闭按钮与点击遮罩都不会提交命令。 todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: 0` 占用 `'conversation.input.dock'` 列表 slot(位于 Goal 与 Queue 之前),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。 `QueueDock` 是 `order: 20` 的末端 input-dock 条目。队列为空时隐藏;只有一个待处理项时直接渲染该行;存在两个或更多待处理项时,默认收起为 `" 条排队消息"` 表头,其按钮可展开或收起完整列表。表头暴露 `aria-expanded` 和 `aria-controls`;展开后的列表以 180px 为高度上限,并可滚动。存在进行中的编辑或变更时,列表行会保持可见;队列清空后,下一次出现队列时会恢复默认收起状态。普通会话中的每条可见行仍是单行预览,并提供针对精确单次入队项的编辑、删除和严格 steering(中途引导)操作;已寻址 subagent 则保留只读行,因为其继续执行传输不提供 Queue 变更。如果严格 steering 输给已关闭的窗口,原单次入队项会留在 Queue 中正常投递;如果驱动器已经认领该项,正常投递就已开始。这两种已收敛的竞态都不显示失败,传输和未知错误仍会显示。 -Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。QueueDock 会将其过滤掉,ChatView 则把它投影为会话流末尾带复制操作的用户样式气泡;消息尚未进入持久轮次,因此不显示 fork。Host 会等持久 `steering/message` 进入 mux 流之后再退役 steering。客户端运行时接纳该实时事件时,会在发布快照前退役第一个匹配的当前 steering 单次入队项;历史事件无法隐藏后来复用同一 `MessageId` 的单次入队项。气泡交接时因而不会产生空档或重复,会从持久节点恢复复制与 fork 操作,并能在重连后从同一权威恢复。 +Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。QueueDock 会将其过滤掉,ChatView 则把它投影为会话流末尾带复制操作的用户样式气泡;消息尚未进入持久轮次,因此不显示 fork。Host 会等持久 `steering/message` 进入 mux 流之后再退役 steering。客户端运行时接纳该实时事件时,会在发布快照前退役第一个匹配的当前 steering 单次入队项;历史事件无法隐藏后来复用同一 `MessageId` 的单次入队项。气泡交接时因而不会产生空档或重复,会立即从持久节点恢复复制操作与分支控件,仅当该节点是已完成轮次的 transcript 尾部时才启用分支,并能在重连后从同一权威恢复。 键盘消息提交会根据所寻址会话的运行状态和 steering 能力解析投递方式。空闲时,Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。主会话运行期间,浏览器持久化的 General Settings 偏好会把普通 Enter 分配为 `Queue`(默认值)或 `Steer`,Cmd/Ctrl+Enter 则执行另一种行为;Shift+Enter 仍然换行。已寻址 subagent 即使正在运行,也会让这两个手势都使用其仅支持 Queue 的继续执行传输。该偏好只影响支持 steering 的繁忙态手势对,发送按钮与非键盘提交操作仍使用 Queue。Composer Steer 复用现有尽力而为的 `session.prompt(mode: 'steer')` 契约:如果当前 next-step 窗口在接纳前关闭,AgentLoop 会把消息接纳为下一条唤醒 Queue 轮次,不显示失败,也不会丢失草稿事务。 @@ -63,8 +63,8 @@ Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。Qu - **压缩标记不显示规模**:该行尚不报告检查点替换了多少条消息或哪段范围。 - **统计行的耗时只覆盖窗口内消息流**:LLM 与工具墙钟时间由快照的 assistant `timing` 与工具 call/result 配对折算,落在已加载事件窗口之外的节点(更早的历史)不计入。 - **详情面板是最小形态,且当前没有入口**:以原始形式显示已选择调用的参数/结果;Input/Output/Metadata 切换、Prev/Next 步进与 See-in-trajectory 深链接暂缓实现。工具行已不再是详情面板的点击目标,且没有任何手势接替它,因此 `ChatViewInjected.openDetails` 虽已实现却无人调用,该面板(含其终端卡片)在组装后的应用中不可达;其渲染仍由直接以选中态挂载它来覆盖。 -- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/分支/时钟)只挂在每个轮次中最后一条带 text 内容的 assistant 下;轮次中间的叙述与纯 Think 节点不带 chrome。分支会 fork 到包含该消息的轮次末尾,在 client 端递增继承标题后打开子会话,而 fork 或改名失败时源会话保持选中。 -- **已发送的 user 消息无法编辑**:user 气泡的 IconActions 行只有时钟/复制/分支,从该消息分支是最接近的手势。该控件要与其背后的能力一起回归:既需要针对已定稿 user 消息的 client 变更,也需要 host 侧对已经消费过它的轮次给出行为([决策](../../../.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md))。 +- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/时钟/分支)只挂在每个轮次中最后一条带 text 内容的 assistant 下;轮次中间的叙述与纯 Think 节点不带 chrome。除非该消息同时也是已完成轮次的最后一个 transcript 节点,否则分支保持禁用;启用后,它会 fork 到该轮次末尾,在 client 端递增继承标题并打开子会话。fork 或改名失败时源会话保持选中([决策](../../../.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md))。 +- **已发送的 user 消息无法编辑**:user 气泡保留时钟、复制和分支;除非已完成轮次的 transcript 结束于该 user 消息,否则分支保持禁用。编辑功能要与其背后的能力一起回归:既需要针对已定稿 user 消息的 client 变更,也需要 host 侧对已经消费过它的轮次给出行为([决策](../../../.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md))。 - **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。 - **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。 - **TodoPanel 将过长条目截成单行省略号**:figma 条没有换行或展开入口,完整文本无法在行内读完。 diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index f5cbadef8a..3f87e40e8b 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -4,9 +4,10 @@ // view groups them into tool rows through its keyed toolview slot (figma // 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. -// Finalized turn-tail content (text) nodes append IconActions once streaming -// ends (`time` is omitted for mid-turn narration); Think / tool-head-only -// nodes stay chrome-free. +// Finalized content (text) nodes append IconActions once streaming ends +// (`time` is omitted for mid-turn narration); their branch action is enabled +// only when the node is also the completed turn's transcript tail. Think / +// tool-head-only nodes stay chrome-free. import { memo, useMemo } from 'react' import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client' @@ -28,8 +29,10 @@ export interface AssistantMarkdownProps { time?: number | undefined /** Event sequence used as the fork boundary; omitted while streaming. */ seq?: number | undefined - /** Fork the session through the turn containing this finalized message. */ + /** Fork the session through this finalized message's completed turn when eligible. */ onFork?: ((seq: number) => void) | undefined + /** The message is not the transcript tail of a completed turn. */ + forkUnavailable?: boolean | undefined /** The owning view's locale seat, passed down as a plain prop. */ t: ChatViewSlotProps['t'] } @@ -76,7 +79,7 @@ function ThinkRow({ text, running, t }: { text: string; running: boolean; t: Ass } export const AssistantMarkdown = memo(function AssistantMarkdown({ - blocks, streaming, interrupted, time, seq, onFork, t, + blocks, streaming, interrupted, time, seq, onFork, forkUnavailable, t, }: AssistantMarkdownProps) { // Stable per locale revision (t identity changes on switch): a fresh object // per render would rebuild MarkdownText's component table every chunk. @@ -120,6 +123,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ time={time} clock="end" onBranch={onFork === undefined || seq === undefined ? undefined : () => { onFork(seq) }} + branchUnavailable={forkUnavailable} className={css.actions} t={t} /> diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index 989f56bba2..076da6c6ab 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -30,7 +30,7 @@ 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 { assistantActionsSeqs, deriveChatFlow, type ChatFlowItem } from './chat-flow.ts' +import { assistantActionsSeqs, deriveChatFlow, messageBranchSeqs, type ChatFlowItem } from './chat-flow.ts' import { AssistantMarkdown } from './AssistantMarkdown.tsx' import { GenericCommandCard } from './GenericCommandCard.tsx' import { GenericToolCard } from './GenericToolCard.tsx' @@ -236,6 +236,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt, t, }: ChatViewSlotProps) { const nodes = useSession(s => s.nodes) + const turnEnds = useSession(s => s.turnEnds) const inbox = useSession(s => s.queue) // Workspace root off the session list row: path summaries display relative to it. const cwd = useSessions(s => s.byId[sessionId]?.cwd) @@ -257,6 +258,7 @@ export function ChatView({ // Only the last content assistant of each turn owns IconActions; mid-turn // text (before tools) omits `time` so AssistantMarkdown stays chrome-free. const actionSeqs = useMemo(() => assistantActionsSeqs(nodes), [nodes]) + const branchSeqs = useMemo(() => messageBranchSeqs(nodes, turnEnds), [nodes, turnEnds]) const listRef = useRef(null) const atBottomRef = useRef(true) @@ -413,6 +415,7 @@ export function ChatView({ time={actionSeqs.has(node.seq) ? node.time : undefined} seq={node.seq} onFork={forkAt} + forkUnavailable={!branchSeqs.has(node.seq)} t={t} /> ) @@ -428,6 +431,7 @@ export function ChatView({ node={node} retryActive={node.kind === 'model-retry' && node.seq === activeRetry} onFork={forkAt} + forkUnavailable={!branchSeqs.has(node.seq)} t={t} /> ) diff --git a/packages/client/ui-conversation/src/client/chat/MessageIconActions.module.css b/packages/client/ui-conversation/src/client/chat/MessageIconActions.module.css index b247b7e2bf..8a43b42f83 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageIconActions.module.css +++ b/packages/client/ui-conversation/src/client/chat/MessageIconActions.module.css @@ -43,3 +43,23 @@ background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-secondary); } + +/* Unavailable stays focusable and hoverable so Tooltip can explain why. */ +.action[data-unavailable] { + cursor: default; + opacity: 0.4; +} + +.action[data-unavailable]:hover { + background: transparent; + color: var(--dsw-alias-label-tertiary); +} + +.visuallyHidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} diff --git a/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx b/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx index 91ba066544..6d77d4db68 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx @@ -1,7 +1,7 @@ // Shared IconActions chrome for user, steering, and assistant messages: copy // live, optional branch wiring, and an optional date-aware clock. -import { useCallback } from 'react' +import { useCallback, useId } from 'react' import { IconBranchOutline16, IconCopyOutline16, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' @@ -17,9 +17,11 @@ export interface MessageIconActionsProps { time?: number | undefined /** Clock before icons (user) or after (assistant). */ clock: 'start' | 'end' - /** Fork the session at this message. */ + /** Fork the session at this message; omission hides the branch action. */ onBranch?: (() => void) | undefined - /** Whether to render the branch action; defaults to true. */ + /** The message is not a completed transcript tail, so branch stays visible but unavailable. */ + branchUnavailable?: boolean | undefined + /** Additional branch visibility gate for transient message chrome; defaults to true. */ showBranch?: boolean | undefined /** Parent layout class composed onto the actions row. */ className?: string | undefined @@ -33,9 +35,10 @@ export interface MessageIconActionsProps { * @returns The actions row element. */ export function MessageIconActions({ - text, time, clock, onBranch, showBranch = true, className, t, + text, time, clock, onBranch, branchUnavailable = false, showBranch = true, className, t, }: MessageIconActionsProps) { const day = useCalendarDay() + const reasonId = useId() const onCopy = useCallback(() => { void writeClipboard(text) }, [text]) @@ -52,13 +55,25 @@ export function MessageIconActions({ - {showBranch && ( - - )} + {showBranch && onBranch !== undefined && branchUnavailable && ( + {t('message.branchUnavailable')} + )} {clock === 'end' ? clockEl : null} ) diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index 6e542bf468..df04ac19c2 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -26,8 +26,10 @@ export interface MessageItemProps { | TurnErrorNode | UnknownSurfaceNode retryActive?: boolean - /** Fork the session through the turn containing this message (user-bubble branch action). */ + /** Fork through this message's completed turn when eligible. */ onFork?: (seq: number) => void + /** The message is not the transcript tail of a completed turn. */ + forkUnavailable?: boolean /** The owning view's locale seat, passed down as a plain prop. */ t: ChatViewSlotProps['t'] } @@ -220,7 +222,7 @@ export function PendingSteeringBubble({ content, t }: { } export const MessageItem = memo(function MessageItem({ - node, retryActive = false, onFork, t, + node, retryActive = false, onFork, forkUnavailable = false, t, }: MessageItemProps) { const truncated = (total: number): string => t('json.truncated', { total }) switch (node.kind) { @@ -236,6 +238,7 @@ export const MessageItem = memo(function MessageItem({ time={node.time} clock="start" onBranch={onFork === undefined ? undefined : () => { onFork(node.seq) }} + branchUnavailable={forkUnavailable} className={css.actions} t={t} /> 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 4958894154..9de9876d89 100644 --- a/packages/client/ui-conversation/src/client/chat/chat-flow.ts +++ b/packages/client/ui-conversation/src/client/chat/chat-flow.ts @@ -5,8 +5,8 @@ * reuse the first notice's row while projecting the latest retry turn. * Item identity keys are stable across snapshots so the list parent can * subscribe to keys only while rows subscribe to content. IconActions ownership - * (last content assistant per turn) is derived here too so ChatView and the - * flow share one gate. + * and completed-turn branch points are derived here too so ChatView and the + * flow share their gates. */ import type { AssistantBlock, ConversationNode, ToolResultNode, @@ -47,6 +47,39 @@ export function assistantActionsSeqs(nodes: readonly ConversationNode[]): Readon return new Set(lastByTurn.values()) } +/** + * Seq set of message rows that may fork: the last transcript node of a + * completed turn, when that node owns message chrome. A later tool, reasoning, + * error, or other transcript node leaves the earlier message's branch action + * unavailable because the Host would include the whole turn. + * @param nodes - snapshot nodes in event order. + * @param turnEnds - completed turn boundaries retained from the event window. + * @returns Message seq values whose visible position matches the fork boundary. + */ +export function messageBranchSeqs( + nodes: readonly ConversationNode[], + turnEnds: ReadonlyMap, +): ReadonlySet { + const result = new Set() + const boundaries = [...turnEnds].sort((a, b) => a[1] - b[1]) + let nodeIndex = 0 + for (const [turn, endSeq] of boundaries) { + let tail: ConversationNode | undefined + while (nodeIndex < nodes.length) { + const candidate = nodes[nodeIndex] + if (candidate === undefined || candidate.seq > endSeq) break + tail = candidate + nodeIndex++ + } + if (tail?.kind === 'user' + || (tail?.kind === 'steering' && tail.turn === turn) + || (tail?.kind === 'assistant' && tail.turn === turn && hasContentText(tail.blocks))) { + result.add(tail.seq) + } + } + return result +} + /** * Group finalized nodes into the step-summary flow. * @param nodes - snapshot nodes in human-transcript and durable-notice order. diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 52dc8dd2d9..20a791c556 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -461,7 +461,7 @@ export interface ChatViewInjected { /** Last recorded offset, or null when pinned or never recorded. */ read: () => number | null } - /** Fork the session through the turn containing the message at `seq`, then open the child. */ + /** Fork through the completed turn ending at the eligible message `seq`, then open the child. */ forkAt: (seq: number) => void } diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 87252fbc77..7bc01b4d95 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -59,6 +59,7 @@ export const zh = { 'message.unknownBlock': '未知内容块', 'message.stopped': '已停止', 'message.branch': '在新对话中分支', + 'message.branchUnavailable': '仅可从已完成轮次的最后一条消息分支', 'message.retry.active': '正在重试模型请求', 'message.retry.cancelled': '模型请求重试已取消', 'message.retry.started': '已重试模型请求', @@ -166,6 +167,7 @@ export const en = { 'message.unknownBlock': 'Unknown content block', 'message.stopped': 'Stopped', 'message.branch': 'Branch into a new conversation', + 'message.branchUnavailable': 'Available only on the last message of a completed turn', 'message.retry.active': 'Retrying model request', 'message.retry.cancelled': 'Model request retry cancelled', 'message.retry.started': 'Retried model request', 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 be480eaea2..1a83efb551 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -208,6 +208,26 @@ ); } +/* Views may opt into a composer overlay while ConversationRoot retains + ownership of the seat geometry and its active-phase precedence. */ +.scrollBody:has([data-conversation-composer-overlay]) { + position: relative; + overflow: hidden; +} + +.scrollBody:has([data-conversation-composer-overlay]) > .viewArea { + flex: 1 1 0; + min-height: 0; + overflow: hidden; +} + +.scrollBody:has([data-conversation-composer-overlay]) > .composerSeat { + position: absolute; + right: 0; + bottom: 0; + left: 0; +} + /* Hero phase: the composer stack (hero chrome + workspace row + card) is flex-centered in the column; composer phase docks it at the bottom. Flex, NOT absolute+transform: a transform would make this box the containing 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 ef9f246dd7..d4323309d4 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 @@ -20,6 +20,63 @@ border: 1px solid var(--dsw-alias-border-l1); } +/* A bash execution error can settle without terminal-card material (for + example, command cancellation). Preserve ToolRow's bounded IN/OUT fallback + so the original command and full error remain available from this keyed row. */ +.ioCard { + display: flex; + flex-direction: column; + margin: 4px 0 4px 4px; + border: 1px solid var(--dsw-alias-border-l1); + border-radius: 12px; + background: var(--dsw-alias-markdown-code-block); + font: var(--dsw-font-markdown-code-block-small); +} + +.ioSection { + display: grid; + grid-template-columns: max-content 1fr; + column-gap: 14px; + align-items: baseline; + padding: 12px 16px; + max-height: 150px; + overflow-y: auto; +} + +.ioSection::-webkit-scrollbar-thumb { + border: 2px solid transparent; + background-clip: padding-box; + border-radius: 6px; +} + +.ioSection::-webkit-scrollbar-track { + margin: 6px 0; +} + +.ioLabel { + position: sticky; + top: 0; + align-self: start; + color: var(--dsw-alias-label-caption); +} + +.ioDivider { + flex: none; + height: 1px; + background: var(--dsw-alias-border-l2); +} + +.ioText { + min-width: 0; + white-space: pre-wrap; + word-break: break-word; + color: var(--dsw-alias-label-secondary); +} + +.ioText[data-error] { + color: var(--dsw-alias-state-error-primary); +} + /* ToolRow's unified expand interaction, replicated per the registrant posture: pointer on the expandable row (the icon→chevron hover preview is the affordance, no row fill). */ 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 5a3e3f40fe..fbe05e0a1b 100644 --- a/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx +++ b/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx @@ -2,8 +2,10 @@ // (ctx.slots.register + ToolRowProps only — never imports the chat domain). // Product chrome matches ToolRow / Think (figma: Bash · {description}). // -// A bash call declares the terminal render intent, so this row renders the -// command's own output through TerminalBlock — expand-gated exactly like +// A bash call normally declares the terminal render intent, so this row renders +// the command's own output through TerminalBlock. Execution failures that +// settle without terminal material use the bounded generic IN/OUT fallback — +// both are expand-gated exactly like // ToolRow's unified interaction: collapsed by default, the whole summary row // is the toggle (click / Enter / Space, icon→chevron hover preview; the // summary stays inline while open), @@ -48,7 +50,7 @@ function stateStatus(state: ToolRowState, t: BashRowProps['t']): string | null { /** * Bash row: icon + Bash · {description} in the shared ToolRow chrome, the - * whole row toggling the command's terminal card (ToolRow's unified + * whole row toggling the command's terminal or generic error card (ToolRow's unified * expand interaction, replicated locally per the registrant posture). */ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }: BashRowProps) { @@ -64,7 +66,13 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }: : model.state const status = stateStatus(state, t) const [expanded, setExpanded] = useState(false) - const expandable = terminal !== null + // Execution failures (for example cancellation before the process reports a + // terminal result) use the generic presenter. Keep their recorded args and + // full error reachable instead of collapsing the row to the first line. + const genericError = terminal === null + && model.state === 'error' + && (model.body !== null || model.output !== null) + const expandable = terminal !== null || genericError const open = expanded && expandable const failureLine = model.state === 'error' ? model.errorSummary : null const toggleExpand = () => { @@ -109,16 +117,40 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }: {failureLine ?? terminal?.description ?? model.summary} - {terminal !== null && open && ( + {open && ( /* Same hover-Inspect posture as ToolRow's expanded body, replicated locally per the registrant posture. */
- + {terminal !== null + ? ( + + ) + : ( +
+ {model.body !== null && ( +
+ IN + {model.body} +
+ )} + {model.body !== null && model.output !== null && ( + + )} + {model.output !== null && ( +
+ OUT + + {model.output} + +
+ )} +
+ )} {inspect !== undefined && ( + , + ) + const anchor = screen.getByText('anchor') + fireEvent.mouseEnter(anchor) + act(() => { vi.advanceTimersByTime(499) }) + expect(screen.queryByRole('tooltip')).toBeNull() + fireEvent.mouseLeave(anchor) + act(() => { vi.advanceTimersByTime(1) }) + expect(screen.queryByRole('tooltip')).toBeNull() + fireEvent.mouseEnter(anchor) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.getByRole('tooltip').textContent).toBe('Timing details') + fireEvent.mouseLeave(anchor) + fireEvent.focus(anchor) + expect(screen.getByRole('tooltip').textContent).toBe('Timing details') + } finally { + vi.useRealTimers() + } + }) + it('shows the bubble to the right on hover and hides it on leave', () => { render( diff --git a/packages/client/ui-question/README.i18n.yaml b/packages/client/ui-question/README.i18n.yaml index 914485fbbb..f1ef187514 100644 --- a/packages/client/ui-question/README.i18n.yaml +++ b/packages/client/ui-question/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-question/README.md -README.md: 5ebba2a1da6e6108b82e9deb235b84f987600345 -README.zh.md: 0aa6428a9b6472fc5b525c11b4716ebc50c378c3 +README.md: 72d94396771eec0a90b96008b1fd5e4a736a398c +README.zh.md: 3c2b12b30dd2858c7b8f99193829c3274f3f8228 diff --git a/packages/client/ui-question/README.md b/packages/client/ui-question/README.md index 5ebba2a1da..72d9439677 100644 --- a/packages/client/ui-question/README.md +++ b/packages/client/ui-question/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Web `ask_user_question` feature plugin. Its host half mounts `dsh-tool-ask-user` only when the Web feature is selected; its browser half registers the `question` entry in the conversation-owned `conversation.composer` keyed slot. -The component renders one question at a time with progress navigation, single- and multi-select choices, recommendation badges derived from label suffixes, and custom answers. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps its title, navigation, and submission actions fixed while long detail and choices share an internal scroll region. Single-select choices advance immediately, and Enter submits once every question is answered or skipped; Enter during IME composition confirms the input candidate without advancing. It submits one structured answer batch for the whole request: “Skip this question” retains other drafts and emits the existing blank `{ selected: [] }` shape for that item, while close rejects the whole wait as `ASK_CANCELLED`. +The component renders one question at a time with progress navigation, single- and multi-select choices, recommendation badges derived from label suffixes, and custom answers. A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps its title, navigation, and submission actions fixed while long detail and choices share an internal scroll region. Single-select choices advance immediately, and Enter submits once every question is answered or skipped; Enter during IME composition confirms the input candidate without advancing. It submits one structured answer batch for the whole request: “Skip this question” retains other drafts and emits the existing blank `{ selected: [] }` shape for that item, while close rejects the whole wait as `ASK_CANCELLED`. A request whose single question declares a presentation intent renders as that intent's own surface instead. `plan-review` — set by `dsh-plan-mode` on the `exit_plan_mode` review — takes the waiting-approval card shape: a `Plan review` strip, the plan as the scrolling markdown body, the question text as the card's accessible name, and one decision row of `Chat about it` / `Refuse` / `Approve`. Approve and Refuse answer with the asker's own option labels (the intent names which label approves, so the verdict never rides option order) and keep the asker's descriptions as tooltips; `Chat about it` rejects the wait as `ASK_CANCELLED`, returning the composer so the user can say what they want instead. The card claims a request only when it can send every answer that request allows: one question, the intent declared, the plan present as `detail`, the named approve label offered, and a binary single choice (at most one option besides approve, not multi-select). Anything else — no intent, a batch of several questions, a missing plan, an approve label naming no option, a third option, a multi-select decision — stays on the generic flow, which can express it. An intent changes the layout, never which answers are reachable. diff --git a/packages/client/ui-question/README.zh.md b/packages/client/ui-question/README.zh.md index 0aa6428a9b..3c2b12b30d 100644 --- a/packages/client/ui-question/README.zh.md +++ b/packages/client/ui-question/README.zh.md @@ -4,7 +4,7 @@ Web `ask_user_question` 功能插件。只有选择 Web 功能时,其主机侧才会挂载 `dsh-tool-ask-user`;浏览器侧会把 `question` 配置项注册到会话拥有的 `conversation.composer` 键控 slot 中。 -组件每次渲染一个问题,提供进度导航、单选和多选选项、由标签后缀派生的推荐徽标,以及自定义答案。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信内容策略。封顶卡片保持标题、导航与提交动作固定,超长的详情与选项共享内部滚动区。单选选项会立即前进;所有问题均已回答或跳过后,Enter 会提交;IME 输入法组合期间按 Enter 只会确认输入候选,不会前进。组件为整个请求提交一批结构化答案:「跳过此问题」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 形状;关闭则以 `ASK_CANCELLED` 拒绝整个等待。 +组件每次渲染一个问题,提供进度导航、单选和多选选项、由标签后缀派生的推荐徽标,以及自定义答案。用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信内容策略。封顶卡片保持标题、导航与提交动作固定,超长的详情与选项共享内部滚动区。单选选项会立即前进;所有问题均已回答或跳过后,Enter 会提交;IME 输入法组合期间按 Enter 只会确认输入候选,不会前进。组件为整个请求提交一批结构化答案:「跳过此问题」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 形状;关闭则以 `ASK_CANCELLED` 拒绝整个等待。 若某个请求的唯一问题声明了呈现意图,则改为渲染该意图自己的界面。`plan-review` —— 由 `dsh-plan-mode` 在 `exit_plan_mode` 审阅上设置 —— 采用等待审批卡片的形状:一条 `Plan review` 条带、计划作为可滚动的 markdown 主体、问题文本作为卡片的无障碍名称,以及一行 `Chat about it` / `Refuse` / `Approve` 的决定操作。Approve 与 Refuse 用提问方自己的选项标签回答(意图指名哪个标签表示批准,因此裁决绝不依赖选项顺序),并把提问方的描述保留为 tooltip;`Chat about it` 以 `ASK_CANCELLED` 拒绝该等待,让编辑器归位,用户可以直接说出他想说的话。卡片只在能够发出该请求允许的每一个答案时才接管:只有一个问题、声明了意图、计划以 `detail` 存在、提供了被指名的批准标签,且是二元单选(除批准外最多一个选项,且非多选)。其他任何情形 —— 没有意图、一批含多个问题、缺少计划、批准标签未命中任何选项、出现第三个选项、多选决定 —— 都留在能够表达它的通用流程上。意图改变的只是布局,从不改变可达的答案。 diff --git a/packages/client/ui-question/src/client/QuestionComposer.tsx b/packages/client/ui-question/src/client/QuestionComposer.tsx index abe87f567e..596e1ec727 100644 --- a/packages/client/ui-question/src/client/QuestionComposer.tsx +++ b/packages/client/ui-question/src/client/QuestionComposer.tsx @@ -98,12 +98,13 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick { updateDraft((current) => { - const selected = question.multiSelect === true - ? current.selected.includes(label) + if (question.multiSelect === true) { + const selected = current.selected.includes(label) ? current.selected.filter(item => item !== label) : [...current.selected, label] - : [label] - return { selected, custom: '', skipped: false } + return { ...current, selected, skipped: false } + } + return { selected: [label], custom: '', skipped: false } }) if (question.multiSelect !== true && index < questions.length - 1) { setIndex(current => current + 1) @@ -129,7 +130,7 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick): void => { const value = event.target.value updateDraft(current => ({ - ...current, selected: [], custom: value, skipped: false, + ...current, + selected: question.multiSelect === true ? current.selected : [], + custom: value, + skipped: false, })) } diff --git a/packages/client/ui-question/tests/question-composer.spec.tsx b/packages/client/ui-question/tests/question-composer.spec.tsx index e31d756a9f..91475f43e4 100644 --- a/packages/client/ui-question/tests/question-composer.spec.tsx +++ b/packages/client/ui-question/tests/question-composer.spec.tsx @@ -104,13 +104,19 @@ describe('QuestionComposer', () => { fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' })) fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' })) fireEvent.click(screen.getByRole('checkbox', { name: '代码质量' })) - fireEvent.keyDown(screen.getByRole('checkbox', { name: '代码质量' }), { key: 'Enter' }) + const multiCustom = screen.getByPlaceholderText('输入你的答案') + fireEvent.change(multiCustom, { target: { value: '沟通能力' } }) + fireEvent.click(screen.getByRole('checkbox', { name: '产品判断' })) + expect(screen.getByRole('checkbox', { name: '系统设计' }).getAttribute('aria-checked')).toBe('true') + expect(screen.getByRole('checkbox', { name: '代码质量' }).getAttribute('aria-checked')).toBe('true') + expect((multiCustom as HTMLInputElement).value).toBe('沟通能力') + fireEvent.keyDown(multiCustom, { key: 'Enter' }) // The domain face encoded the whole batch into one carrier envelope. expect(respond).toHaveBeenCalledWith(answeredEnvelope('question-1', [ { id: 'profile', selected: ['工程落地型 (Recommended)'] }, { id: 'detail', selected: [], custom: '要能独立排查线上问题' }, - { id: 'signals', selected: ['系统设计', '代码质量'] }, + { id: 'signals', selected: ['系统设计', '代码质量', '产品判断'], custom: '沟通能力' }, ])) expect(screen.getByRole('button', { name: '正在提交…' }).disabled).toBe(true) }) @@ -233,6 +239,11 @@ describe('QuestionComposer', () => { fireEvent.keyDown(custom, { key: 'Enter' }) fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' })) fireEvent.click(screen.getByRole('button', { name: '提交' })) + expect(respond).toHaveBeenNthCalledWith(1, answeredEnvelope('second', [ + { id: 'profile', selected: ['工程落地型 (Recommended)'] }, + { id: 'detail', selected: [], custom: 'x' }, + { id: 'signals', selected: ['系统设计'] }, + ])) expect(await screen.findByText('网络中断')).toBeTruthy() expect(screen.getByRole('button', { name: '提交' }).disabled).toBe(false) diff --git a/packages/client/ui-settings-general/README.i18n.yaml b/packages/client/ui-settings-general/README.i18n.yaml index e96dc8c9d2..b14bd3ded3 100644 --- a/packages/client/ui-settings-general/README.i18n.yaml +++ b/packages/client/ui-settings-general/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-settings-general/README.md -README.md: 4dbd339c93171b330895ab66366e76fd06013704 -README.zh.md: 8ad6de99ce78d3bdb1e7b35e872e5bfe6790e758 +README.md: 0202d596f509feeba39a38254e8bab2fae27b649 +README.zh.md: adec73edda00d34e209772f0bcc54a994f593997 diff --git a/packages/client/ui-settings-general/README.md b/packages/client/ui-settings-general/README.md index 4dbd339c93..0202d596f5 100644 --- a/packages/client/ui-settings-general/README.md +++ b/packages/client/ui-settings-general/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Settings ownerless-copy and product-onboarding plugin: registers everything on the Settings surface that belongs to no single feature — the shell's trigger/header/close chrome content, the General section and its `settings.general.item` slot, the `settings` dictionaries, and the first ordered welcome step. Feature-owned rows (Permission, Language, Appearance), sections (Models), and conditional onboarding steps stay with their feature packages. -`src/onboarding-copy.ts` is the single editable owner of the complete notice plus `WELCOME_NOTICE_VERSION`; both supported GUI locales intentionally render the same Chinese copy. The Host half registers `ui-onboarding` in the user-settings seam; the browser compares `welcomeNoticeVersion` for exact equality and writes the current value only after Continue succeeds. The path mutation is idempotent across tabs and preserves sibling settings, while `host/settings-changed` makes an externally acknowledged notice advance without a reload. A different version deliberately presents the notice again. The welcome page preserves every authored paragraph, gives the requested clause in the final paragraph the sole emphasis, initially focuses the title, and has no close, Escape, mask-click, or secondary path. None of its copy or acknowledgement enters a Session log or model request. The notice identifies `DSH_TELEMETRY_DISABLED=1` as the telemetry opt-out. +`src/onboarding-copy.ts` is the single editable owner of the complete notice plus `WELCOME_NOTICE_VERSION`; both supported GUI locales intentionally render the same Chinese copy. The Host half registers `ui-onboarding` in the user-settings seam. A loopback browser compares `welcomeNoticeVersion` for exact equality and writes the current value only after Continue succeeds. The path mutation is idempotent across tabs and preserves sibling settings, while `host/settings-changed` makes an externally acknowledged notice advance without a reload. A non-loopback browser cannot access the privileged settings API: it still presents the notice, but Continue advances only the current browser process and a reload presents the notice again. A different version deliberately presents the notice again. The welcome page preserves every authored paragraph, gives the requested clause in the final paragraph the sole emphasis, initially focuses the title, and has no close, Escape, mask-click, or secondary path. None of its copy or acknowledgement enters a Session log or model request. The notice identifies `DSH_TELEMETRY_DISABLED=1` as the telemetry opt-out. ## Model Experience diff --git a/packages/client/ui-settings-general/README.zh.md b/packages/client/ui-settings-general/README.zh.md index 8ad6de99ce..adec73edda 100644 --- a/packages/client/ui-settings-general/README.zh.md +++ b/packages/client/ui-settings-general/README.zh.md @@ -4,7 +4,7 @@ 设置界面无特定功能归属的文案与产品引导插件:在设置界面注册所有不属于单一功能的内容,包括外壳的触发器、标题栏与关闭控件内容,「通用」分区及其 `settings.general.item` slot、`settings` 字典,以及第一个有序欢迎步骤。归具体功能所有的行(「权限」、「语言」、「外观」)、分区(「模型」)和条件式首次使用引导步骤仍由各自的功能包提供。 -`src/onboarding-copy.ts` 是完整通知文案和 `WELCOME_NOTICE_VERSION` 的唯一可编辑来源;GUI 支持的两种 locale 都有意渲染同一份中文文案。宿主端在 user-settings seam 中注册 `ui-onboarding`;浏览器比较 `welcomeNoticeVersion` 是否精确相等,仅在「继续」操作成功后写入当前值。该路径变更在不同标签页间幂等,并会保留同级设置;`host/settings-changed` 则让页面在通知被外部确认后,无需重新加载即可推进。版本不同时,系统会有意重新显示通知。欢迎页保留原文的每个段落,仅强调最后一段中指定的句段,初始焦点落在标题上,并且没有关闭操作、Escape、点击遮罩或次要操作路径。其文案和确认状态均不会进入会话日志或模型请求。通知明确以 `DSH_TELEMETRY_DISABLED=1` 作为遥测关闭方式。 +`src/onboarding-copy.ts` 是完整通知文案和 `WELCOME_NOTICE_VERSION` 的唯一可编辑来源;GUI 支持的两种 locale 都有意渲染同一份中文文案。宿主端在 user-settings seam 中注册 `ui-onboarding`。loopback 浏览器会比较 `welcomeNoticeVersion` 是否精确相等,仅在「继续」操作成功后写入当前值。该路径变更在不同标签页间幂等,并会保留同级设置;`host/settings-changed` 则让页面在通知被外部确认后,无需重新加载即可推进。非 loopback 浏览器不能访问受保护的 settings API:它仍会显示通知,但「继续」只推进当前浏览器进程,重新加载后会再次显示通知。版本不同时,系统也会有意重新显示通知。欢迎页保留原文的每个段落,仅强调最后一段中指定的句段,初始焦点落在标题上,并且没有关闭操作、Escape、点击遮罩或次要操作路径。其文案和确认状态均不会进入会话日志或模型请求。通知明确以 `DSH_TELEMETRY_DISABLED=1` 作为遥测关闭方式。 ## 模型体验 diff --git a/packages/client/ui-settings-general/src/client/WelcomeNotice.tsx b/packages/client/ui-settings-general/src/client/WelcomeNotice.tsx index 0c381e02c9..c25b1bf3aa 100644 --- a/packages/client/ui-settings-general/src/client/WelcomeNotice.tsx +++ b/packages/client/ui-settings-general/src/client/WelcomeNotice.tsx @@ -31,7 +31,7 @@ export interface WelcomeNoticeInjected { export type WelcomeNoticeProps = PropsRuntime<'settings.onboarding'> & PropsLocale<'settings'> & WelcomeNoticeInjected -/** Render the mandatory notice until its current version commits durably. */ +/** Render the mandatory notice until its current version is acknowledged. */ export function WelcomeNotice(props: WelcomeNoticeProps): ReactNode { const { complete, controller, useSnapshot, t } = props const state = useSnapshot(snapshot => snapshot) diff --git a/packages/client/ui-settings-general/src/client/index.ts b/packages/client/ui-settings-general/src/client/index.ts index 8b3e969860..87b05e86cb 100644 --- a/packages/client/ui-settings-general/src/client/index.ts +++ b/packages/client/ui-settings-general/src/client/index.ts @@ -61,7 +61,7 @@ export function apply(ctx: ClientContext): void { // locale/change re-registration wiring. const t = ctx.locale.bind(NS) const connection = ctx.get('connection') as ConnectionHandle - const welcomeController = new WelcomeNoticeStore(connection.api) + const welcomeController = new WelcomeNoticeStore(connection.api, connection.isLoopback ? 'host' : 'memory') const useWelcomeSnapshot = bindSnapshotSelector(welcomeController.store) const welcomeInjected = (): WelcomeNoticeInjected => ({ controller: welcomeController, diff --git a/packages/client/ui-settings-general/src/client/welcome-store.ts b/packages/client/ui-settings-general/src/client/welcome-store.ts index ad0e18305c..c95c9e46d8 100644 --- a/packages/client/ui-settings-general/src/client/welcome-store.ts +++ b/packages/client/ui-settings-general/src/client/welcome-store.ts @@ -1,4 +1,4 @@ -/** Durable welcome-notice state over the Host settings document. */ +/** Welcome-notice state, durable when the browser may use Host settings. */ import type { IApiClient, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client' import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' @@ -24,7 +24,7 @@ function acknowledgementOf(view: SettingsNamespaceView): string | undefined { return typeof value === 'string' ? value : undefined } -/** Coordinates welcome acknowledgement reads and the sole durable write. */ +/** Coordinates durable Host acknowledgement or a process-local remote fallback. */ export class WelcomeNoticeStore { /** uSES-safe state source shared by the registered welcome step. */ readonly store: SnapshotStore = createSnapshotStore({ @@ -33,12 +33,22 @@ export class WelcomeNoticeStore { private generation = 0 - /** @param api - settings wire face used for durable reads and writes. */ - constructor(private readonly api: Pick) {} + /** + * @param api - settings wire face used for durable reads and writes. + * @param persistence - remote browsers use memory because settings is loopback-only. + */ + constructor( + private readonly api: Pick, + private readonly persistence: 'host' | 'memory' = 'host', + ) {} - /** Load the current acknowledgement from the Host settings document. */ + /** Load the acknowledgement from Host settings or initialize process-local state. */ async load(): Promise { const generation = ++this.generation + if (this.persistence === 'memory') { + this.store.update((state) => { state.status = 'ready'; state.error = null }) + return + } this.store.update((state) => { state.status = 'loading'; state.error = null }) try { const response = await this.api.settings.describe({}) @@ -64,12 +74,20 @@ export class WelcomeNoticeStore { } /** - * Persist this copy version. The path mutation is idempotent across tabs and - * preserves every sibling setting; failure leaves the step unacknowledged. - * @returns true only when the Host committed the acknowledgement. + * Acknowledge this copy version. The Host path mutation is idempotent across + * tabs and preserves sibling settings; remote fallback changes only this store. + * @returns true when the selected persistence mode accepted the acknowledgement. */ async acknowledge(): Promise { const generation = ++this.generation + if (this.persistence === 'memory') { + this.store.update((state) => { + state.status = 'ready' + state.acknowledged = true + state.error = null + }) + return true + } this.store.update((state) => { state.status = 'saving'; state.error = null }) try { const response = await this.api.settings.mutate({ @@ -99,7 +117,9 @@ export class WelcomeNoticeStore { } /** - * Refresh only after the welcome step has begun reading durable state. + * Refresh only after welcome state has left idle. A memory-mode load retains + * acknowledgement so reconnect and settings-change refreshes do not reopen a + * process-local notice. * @param controller - welcome state owner whose current status decides whether to load. */ export function refreshWelcomeIfLoaded(controller: WelcomeNoticeStore): void { diff --git a/packages/client/ui-settings-general/tests/apply.spec.ts b/packages/client/ui-settings-general/tests/apply.spec.ts index 81a56a9a4a..73f6d8207e 100644 --- a/packages/client/ui-settings-general/tests/apply.spec.ts +++ b/packages/client/ui-settings-general/tests/apply.spec.ts @@ -25,7 +25,7 @@ const SEATS = [ ['settings.onboarding', WelcomeNotice], ] as const -async function bench() { +async function bench(isLoopback = true) { const ctx = new Context() await ctx.plugin(SlotsService).await() const locale = new LocaleService(ctx) @@ -47,7 +47,7 @@ async function bench() { }, }, })) - ctx.provide('connection', { api: { settings: { describe: settingsDescribe } } } as never) + ctx.provide('connection', { api: { settings: { describe: settingsDescribe } }, isLoopback } as never) return { ctx, slots: ctx.get('slots') as SlotsService, locale, settingsDescribe } } @@ -159,6 +159,19 @@ describe('ui-settings-general apply', () => { await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledTimes(3) }) }) + it('keeps remote welcome acknowledgement process-local', async () => { + const b = await bench(false) + declare(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + const entry = b.slots.entries('settings.onboarding')[0]! + const { controller } = (entry.inject as unknown as () => WelcomeNoticeInjected)() + + await controller.load() + await expect(controller.acknowledge()).resolves.toBe(true) + expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true }) + expect(b.settingsDescribe).not.toHaveBeenCalled() + }) + it('re-registers after an HMR collapse of the declaring chain (stale disposers must not block)', async () => { const b = await bench() const redeclare = declare(b.slots) diff --git a/packages/client/ui-settings-general/tests/welcome-store.spec.ts b/packages/client/ui-settings-general/tests/welcome-store.spec.ts index 28c7b0509c..45e4ca5590 100644 --- a/packages/client/ui-settings-general/tests/welcome-store.spec.ts +++ b/packages/client/ui-settings-general/tests/welcome-store.spec.ts @@ -30,6 +30,21 @@ function deferred() { } describe('WelcomeNoticeStore', () => { + it('acknowledges in memory without calling loopback-only settings APIs', async () => { + const describe = vi.fn() + const mutate = vi.fn() + const controller = new WelcomeNoticeStore({ settings: { describe, mutate } } as never, 'memory') + + await controller.load() + expect(controller.store.getSnapshot()).toEqual({ status: 'ready', acknowledged: false, error: null }) + await expect(controller.acknowledge()).resolves.toBe(true) + expect(controller.store.getSnapshot()).toEqual({ status: 'ready', acknowledged: true, error: null }) + await controller.load() + expect(controller.store.getSnapshot()).toEqual({ status: 'ready', acknowledged: true, error: null }) + expect(describe).not.toHaveBeenCalled() + expect(mutate).not.toHaveBeenCalled() + }) + it('acknowledges only the exact current copy version', async () => { for (const [version, acknowledged] of [ [undefined, false], diff --git a/packages/client/ui-sidebar/README.i18n.yaml b/packages/client/ui-sidebar/README.i18n.yaml index 6c5f1735e3..c1f5d5df03 100644 --- a/packages/client/ui-sidebar/README.i18n.yaml +++ b/packages/client/ui-sidebar/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-sidebar/README.md -README.md: 93a1f15a5802f94a0ebe930dda1dbd4fbc7343c9 -README.zh.md: 8c8545a5d7d8cb4d58772abf867d7ee82c31bf1d +README.md: 19c2d1033de4475816249aa8429f4a589eeb6481 +README.zh.md: b8c154586570cf1b9fd4bf776bc09b36ab5ee7d2 diff --git a/packages/client/ui-sidebar/README.md b/packages/client/ui-sidebar/README.md index 93a1f15a58..19c2d1033d 100644 --- a/packages/client/ui-sidebar/README.md +++ b/packages/client/ui-sidebar/README.md @@ -22,6 +22,6 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **State dots have two live data states (running/none)** — the done/error/amber sources arrive with P-II approvals and notifications; the four-color primitive is already wired. +- **Session state-dot rendering is owned by [ui-workspace](../ui-workspace/README.md)** — done/error notification sources remain deferred. - **Group-by menu ships by-workspace only** — Update/Status grouping strategies are drawn without specs and deferred. - **"New task completed" unread marking is local viewing state** — completion-time > last-seen never reaches the host. diff --git a/packages/client/ui-sidebar/README.zh.md b/packages/client/ui-sidebar/README.zh.md index 8c8545a5d7..b8c1545865 100644 --- a/packages/client/ui-sidebar/README.zh.md +++ b/packages/client/ui-sidebar/README.zh.md @@ -22,6 +22,6 @@ New Session 会启动运行时的页面局部前端 Session Intent;真实 Work ## 已知限制与暂缓事项 -- **状态点只有两种实时数据状态(running/none)**:done/error/amber 的数据源将随 P-II 审批与通知功能一并提供;四色原语已接入。 +- **Session 状态点渲染由 [ui-workspace](../ui-workspace/README.md) 持有**:done/error 的通知数据源仍暂缓实现。 - **分组选单只提供按 Workspace 分组**:Update/Status 分组策略只有图稿而没有规范,暂缓实现。 - **「New task completed」未读标记是本地查看状态**:完成时间 > 上次查看时间这一事实永远不会到达宿主。 diff --git a/packages/client/ui-subagent/README.i18n.yaml b/packages/client/ui-subagent/README.i18n.yaml index 65f99af124..a519393a5a 100644 --- a/packages/client/ui-subagent/README.i18n.yaml +++ b/packages/client/ui-subagent/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-subagent/README.md -README.md: f6b3fa2e9cdf1479a739e0b4eab15a5423e878e4 -README.zh.md: fdfba385e9188cd42bd973b6f32bc01fe8d004f2 +README.md: cb210b219a8c66985eb4e1370468372eed9614b4 +README.zh.md: 7b87fa1095c404eda96066189b1e4480cd6d4c3c diff --git a/packages/client/ui-subagent/README.md b/packages/client/ui-subagent/README.md index f6b3fa2e9c..cb210b219a 100644 --- a/packages/client/ui-subagent/README.md +++ b/packages/client/ui-subagent/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Web subagent feature owner: contributes the lazily expandable catalog tree to `conversation.session.header.actions`, reason-specific read-only replacements to the conversation composer chain, and the existing `@` reference source to `ctx.slash`. -The header action reads `subagentsByParent` and session summaries through the standard `useSessions` hook. After a non-empty direct catalog arrives, its trigger counts the complete subagent-only descendant lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. The compact tree remains direct-catalog authoritative: continuable and one-shot rows display mode plus `running`/`inactive` activity, an optional log-backed title, and session-summary activity time; an unlabeled one-shot row falls back to its session id, while corrupt, unsupported, or unavailable rows remain readable but disabled. Each healthy row's `hasChildren` hint determines disclosure before interaction, so known leaves never show an arrow; expanding a branch immediately reserves one disabled loading row per known direct descendant, then lazily replaces them with that child's authoritative catalog. Every visible branch is reported to the runtime so membership frames cause a debounced refresh only where the tree is being consumed. Selecting any depth calls `SessionsService.openSubagent()` with the row's exact `{parentSessionId, childSessionId, mode}` address. Component-local state owns tree visibility, expanded branches, and keyboard focus. ArrowRight/ArrowLeft expand and collapse branches; ArrowUp/ArrowDown, Home, End, and Escape navigate or close the tree; closing returns focus to the trigger. Styling uses tokens only. +The header action reads `subagentsByParent` and session summaries through the standard `useSessions` hook. After a non-empty direct catalog arrives, its trigger counts the complete subagent-only descendant lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. The compact tree remains direct-catalog authoritative: continuable and one-shot rows display mode plus `running`/`inactive` activity and an optional log-backed title, while the trailing column stacks total durable provider usage above active-turn duration. Token totals sum the four disjoint `tokenUsage` buckets. Visual duration stays exact to the second below one day, then uses at most two adjacent units—days/hours, approximate months/days, or approximate years/months—while hover and the accessible name retain the exact day/hour/minute/second value. Duration sums completed `subagentTiming` turns, advances once per second only for an open turn on a running child, and freezes after the child becomes inactive; an interrupted open turn is bounded by its same-cut `active.through`, never by newer session metadata. An unlabeled one-shot row falls back to its session id, while corrupt, unsupported, or unavailable rows remain readable but disabled. Each healthy row's `hasChildren` hint determines disclosure before interaction, so known leaves never show an arrow; a catalog level reserves the disclosure column only when at least one healthy row is a branch, allowing branchless levels to start at the leading status marker. Expanding a branch immediately reserves one disabled loading row per known direct descendant, then lazily replaces them with that child's authoritative catalog. Every visible branch is reported to the runtime so membership frames cause a debounced refresh only where the tree is being consumed. Selecting any depth calls `SessionsService.openSubagent()` with the row's exact `{parentSessionId, childSessionId, mode}` address. Component-local state owns tree visibility, expanded branches, keyboard focus, and the running-duration clock. ArrowRight/ArrowLeft expand and collapse branches; ArrowUp/ArrowDown, Home, End, and Escape navigate or close the tree; closing returns focus to the trigger. Styling uses tokens only. A one-shot child always elects a read-only composer that identifies the transcript as a completed execution record. A continuable child does so only when its exact parent is unavailable, with copy explaining the recovery path. A continuable child with a live parent keeps the ordinary input chrome, whose Session routes through `subagent.prompt`; running input remains Send because every follow-up joins the child's FIFO inbox, and addressed sessions never expose Stop. This package never receives host context or calls a model-facing tool. The catalog and composer behavior are specified by the [Web subagent conversations Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md). @@ -30,5 +30,5 @@ Append-only. This package never edits earlier request tokens. ## Known Limitations and Deferred Work -- **The catalog has coarse activity only** — it cannot show durable outcome, elapsed time, Activation identity, or an authority-safe cancel button. +- **The catalog has no durable outcome** — activity and timing do not distinguish completion, failure, or cancellation, and the UI exposes neither Activation identity nor an authority-safe cancel button. - **`@` references remain display-title text** — duplicate or renamed labels are ambiguous, so they intentionally do not acquire continuation semantics. diff --git a/packages/client/ui-subagent/README.zh.md b/packages/client/ui-subagent/README.zh.md index fdfba385e9..7b87fa1095 100644 --- a/packages/client/ui-subagent/README.zh.md +++ b/packages/client/ui-subagent/README.zh.md @@ -4,7 +4,7 @@ Web subagent 功能 owner:向 `conversation.session.header.actions` 贡献可懒加载展开的目录树,向会话编辑器链贡献按原因区分的只读替代呈现,并保留注册到 `ctx.slash` 的既有 `@` 引用 source。 -页头操作通过标准 `useSessions` 钩子读取 `subagentsByParent` 与会话摘要。非空直接目录到达后,其触发器会统计仅含 subagent 的完整后代谱系,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。紧凑树仍以直接目录为权威依据:可继续和 one-shot 行会显示 mode、`running`/`inactive` 活动状态、由日志支撑的可选 title 与会话摘要中的活动时间;没有 label 的 one-shot 行会回退到其会话 id,而损坏、不受支持或不可用的行仍保持可读但禁用。每个健康行的 `hasChildren` 提示会在交互前决定是否显示展开控件,因此已知叶子节点从不显示箭头;展开分支时,会立即为每个已知直接后代预留一行禁用的加载行,随后再用该 child 的权威目录懒加载结果替换这些占位行。每个可见分支都会上报给运行时,使成员帧只在树正被消费的位置触发去抖动刷新。选择任意深度的条目都会使用该行的确切地址 `{parentSessionId, childSessionId, mode}` 调用 `SessionsService.openSubagent()`。组件局部状态负责树的可见性、已展开分支与键盘焦点。ArrowRight/ArrowLeft 展开和折叠分支;ArrowUp/ArrowDown、Home、End 与 Escape 用于导航或关闭树;关闭后焦点返回触发器。样式只使用 token。 +页头操作通过标准 `useSessions` 钩子读取 `subagentsByParent` 与会话摘要。非空直接目录到达后,其触发器会统计仅含 subagent 的完整后代谱系,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。紧凑树仍以直接目录为权威依据:可继续和 one-shot 行会显示 mode、`running`/`inactive` 活动状态和由日志支撑的可选 title,尾随列则在上行显示提供方的持久化 token 用量总计,在下行显示活跃轮次耗时。token 用量总计为四个互不重叠的 `tokenUsage` 桶之和。视觉耗时在不足一天时精确到秒,达到一天后则最多使用两个相邻单位——天/小时、近似月份/天或近似年份/月份——而悬停信息与无障碍名称会保留精确的天/小时/分钟/秒数值。耗时会累加已完成的 `subagentTiming` 轮次,仅在运行中 child 存在未结束轮次时每秒递增一次,并在 child 变为 inactive 后冻结;被中断的未结束轮次以其同一切面的 `active.through` 为上界,绝不使用更新的会话元数据。没有 label 的 one-shot 行会回退到其会话 id,而损坏、不受支持或不可用的行仍保持可读但禁用。每个健康行的 `hasChildren` 提示会在交互前决定是否显示展开控件,因此已知叶子节点从不显示箭头;每层目录仅在其中至少一个健康行是分支时才预留展开列,使完全不含分支的层级能从最前面的状态标记开始。展开分支时,会立即为每个已知直接后代预留一行禁用的加载行,随后再用该 child 的权威目录懒加载结果替换这些占位行。每个可见分支都会上报给运行时,使成员帧只在树正被消费的位置触发去抖动刷新。选择任意深度的条目都会使用该行的确切地址 `{parentSessionId, childSessionId, mode}` 调用 `SessionsService.openSubagent()`。组件局部状态负责树的可见性、已展开分支、键盘焦点与运行中耗时时钟。ArrowRight/ArrowLeft 展开和折叠分支;ArrowUp/ArrowDown、Home、End 与 Escape 用于导航或关闭树;关闭后焦点返回触发器。样式只使用 token。 one-shot child 始终选用只读编辑器,并将 transcript(文本记录)说明为已完成的执行记录。可继续 child 仅在其确切 parent 不可用时选用只读编辑器,并以文案说明恢复路径。确切 parent 存活时,可继续 child 保留普通输入 chrome,其 Session 会通过 `subagent.prompt` 路由;child 运行期间,输入操作仍为 Send,因为每条后续消息都会进入 child 的 FIFO inbox,且已寻址会话绝不公开 Stop。本包绝不接收宿主 context,也不调用面向模型的工具。目录与编辑器行为由 [Web subagent 对话 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md)规定。 @@ -30,5 +30,5 @@ one-shot child 始终选用只读编辑器,并将 transcript(文本记录) ## 已知限制与暂缓事项 -- **目录只有粗粒度活动状态**:它不能显示持久化结果、耗时、Activation 身份或具备安全授权的取消按钮。 +- **目录没有持久化结果**:活动状态与计时无法区分完成、失败或取消,且 UI 既不公开 Activation 身份,也不公开具备安全授权的取消按钮。 - **`@` 引用仍是显示标题文本**:重复或改名后的 label 会有歧义,因此它们刻意不获得继续执行语义。 diff --git a/packages/client/ui-subagent/package.json b/packages/client/ui-subagent/package.json index efec3bc047..9e6b120c1e 100644 --- a/packages/client/ui-subagent/package.json +++ b/packages/client/ui-subagent/package.json @@ -24,6 +24,7 @@ }, "dshClient": { "inject": [ + "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-conversation", "@deepseek-ai/dsh-client-ui-primitives", @@ -40,21 +41,28 @@ "react": "^18.2.0" }, "peerDependencies": { + "@deepseek-ai/dsh-client-locale": "^0.0.1", "@deepseek-ai/dsh-client-runtime": "^0.0.1", "@deepseek-ai/dsh-client-ui-conversation": "^0.0.1", "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", "@deepseek-ai/dsh-client-ui-slash": "^0.0.1", "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-subagent": "^0.0.1", + "@deepseek-ai/dsh-token-meter": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { + "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slash": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-token-meter": "workspace:^", "@types/react": "~18.3.1", "cordis": "^4.0.0-rc.7" }, diff --git a/packages/client/ui-subagent/src/client/SubagentCatalogAction.module.css b/packages/client/ui-subagent/src/client/SubagentCatalogAction.module.css index e2133e6ce9..fc3ddfea46 100644 --- a/packages/client/ui-subagent/src/client/SubagentCatalogAction.module.css +++ b/packages/client/ui-subagent/src/client/SubagentCatalogAction.module.css @@ -67,7 +67,7 @@ min-width: 0; } .menu > .node { - margin-left: -8px; + margin-left: -3px; } .row { @@ -173,15 +173,28 @@ } .summary, -.time { +.metrics { color: var(--dsw-alias-label-tertiary); font-size: 11px; line-height: 16px; } -.time { +.metrics { + display: grid; + grid-template-rows: 18px 16px; flex: none; - margin-top: 16px; + font-variant-numeric: tabular-nums; + text-align: right; + white-space: nowrap; +} + +.metricToken { + grid-row: 1; + line-height: 18px; +} + +.metricDuration { + grid-row: 2; } .children { diff --git a/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx b/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx index c23c82d77b..76a216ebd8 100644 --- a/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx +++ b/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx @@ -2,13 +2,17 @@ import { useEffect, useRef, useState, type KeyboardEvent, type MouseEvent, } from 'react' import type { - SessionId, SessionListState, SessionSummary, SubagentAddress, SubagentCatalogSnapshot, + SessionId, SessionListState, SessionProjectionMap, SessionSummary, SubagentAddress, + SubagentCatalogSnapshot, } from '@deepseek-ai/dsh-client-runtime/client' import { IconChevronDownOutline14, IconChevronRightOutline14, IconRefreshOutline14, StateDot, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { PropsLocale, PropsRuntime, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' +import { NS } from './locales.ts' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type {} from '@deepseek-ai/dsh-subagent/client' +import type {} from '@deepseek-ai/dsh-token-meter/client' import css from './SubagentCatalogAction.module.css' type CatalogEntry = SubagentCatalogSnapshot['entries'][number] @@ -23,7 +27,7 @@ export interface SubagentCatalogInjected { /** Full props for the session-header catalog action. */ export type SubagentCatalogActionProps = - PropsRuntime<'conversation.session.header.actions'> & SubagentCatalogInjected + PropsRuntime<'conversation.session.header.actions'> & SubagentCatalogInjected & PropsLocale interface CatalogRowsProps { parentSessionId: SessionId @@ -39,11 +43,14 @@ interface CatalogRowsProps { closeCatalog: () => void } -function diagnosticReason(entry: Extract): string { +function diagnosticReason( + entry: Extract, + t: TranslateNS, +): string { switch (entry.reason) { - case 'corrupt': return '会话记录损坏' - case 'unsupported': return '子代理记录版本不受支持' - case 'unavailable': return '会话记录暂不可用' + case 'corrupt': return t('diagnostic.corrupt') + case 'unsupported': return t('diagnostic.unsupported') + case 'unavailable': return t('diagnostic.unavailable') } } @@ -53,19 +60,115 @@ function treeItems(root: HTMLDivElement | null): HTMLElement[] { : Array.from(root.querySelectorAll('[role="treeitem"]:not([aria-disabled="true"])')) } -/** Compact trailing activity time for a catalog row. */ -function relativeTime(updatedAt: number | undefined, now: number): string | undefined { - if (updatedAt === undefined) return undefined - const minute = 60_000 - const hour = 60 * minute - const day = 24 * hour - const diff = Math.max(0, now - updatedAt) - if (diff < minute) return '刚刚' - if (diff < hour) return `${Math.floor(diff / minute)}分钟` - if (diff < day) return `${Math.floor(diff / hour)}小时` - if (diff < 30 * day) return `${Math.floor(diff / day)}天` - if (diff < 365 * day) return `${Math.floor(diff / (30 * day))}个月` - return `${Math.floor(diff / (365 * day))}年` +/** Compact token count shared in shape with the conversation stats strip. */ +function formatTokens(value: number): string { + const scaled = (next: number): string => next >= 100 + ? String(Math.round(next)) + : String(Math.round(next * 10) / 10) + if (value < 1_000) return String(value) + if (value < 1_000_000) return `${scaled(value / 1_000)}K` + return `${scaled(value / 1_000_000)}M` +} + +/** Sum the four disjoint durable provider-usage buckets. */ +function tokenTotal( + usage: SessionProjectionMap['tokenUsage'] | undefined, +): number | undefined { + return usage === undefined + ? undefined + : usage.uncachedInputTokens + usage.outputTokens + + usage.cacheReadTokens + usage.cacheWriteTokens +} + +/** Exact whole-second active-turn duration for one catalog row. */ +function activityDuration( + summary: SessionSummary | undefined, + activity: 'running' | 'inactive', + now: number, +): number | undefined { + if (summary === undefined) return undefined + const timing: SessionProjectionMap['subagentTiming'] | undefined + = summary.projectionValues?.subagentTiming + if (timing === undefined) return undefined + if (timing.active === undefined) return timing.settledMs + const end = activity === 'running' + ? now + : timing.active.through + return timing.settledMs + Math.max(0, end - timing.active.since) +} + +interface DurationParts { + seconds: number + minutes: number + hours: number + days: number + totalMinutes: number + totalHours: number +} + +function splitDuration(ms: number): DurationParts { + const totalSeconds = Math.floor(Math.max(0, ms) / 1_000) + const totalMinutes = Math.floor(totalSeconds / 60) + const totalHours = Math.floor(totalMinutes / 60) + return { + seconds: totalSeconds % 60, + minutes: totalMinutes % 60, + hours: totalHours % 24, + days: Math.floor(totalHours / 24), + totalMinutes, + totalHours, + } +} + +/** Format a duration with decreasing visual precision at larger scales. */ +function formatDuration(ms: number, t: TranslateNS): string { + const { seconds, minutes, hours, days, totalMinutes, totalHours } = splitDuration(ms) + if (days >= 365) { + const years = Math.floor(days / 365) + const months = Math.floor((days % 365) / 30) + return months === 0 + ? t('duration.years', { years }) + : t('duration.yearsMonths', { years, months }) + } + if (days >= 30) { + const months = Math.floor(days / 30) + const remainingDays = days % 30 + return remainingDays === 0 + ? t('duration.months', { months }) + : t('duration.monthsDays', { months, days: remainingDays }) + } + if (days > 0) { + return hours === 0 + ? t('duration.days', { days }) + : t('duration.daysHours', { days, hours }) + } + if (totalHours > 0) { + return t('duration.hours', { + hours: totalHours, + minutes: String(minutes).padStart(2, '0'), + seconds: String(seconds).padStart(2, '0'), + }) + } + if (totalMinutes > 0) { + return t('duration.minutes', { + minutes: totalMinutes, + seconds: String(seconds).padStart(2, '0'), + }) + } + return t('duration.seconds', { seconds }) +} + +/** Preserve exact whole seconds for hover and accessible naming. */ +function formatExactDuration(ms: number, t: TranslateNS): string { + const { seconds, minutes, hours, days } = splitDuration(ms) + return days === 0 + ? formatDuration(ms, t) + : t('duration.exactDays', { + days, + hours: String(hours).padStart(2, '0'), + minutes: String(minutes).padStart(2, '0'), + seconds: String(seconds).padStart(2, '0'), + }) } /** Aggregate the complete subagent-only descendant subtree from flat summaries. */ @@ -98,28 +201,30 @@ function CatalogLoadingRows({ parentSessionId, summaries, level, + t, }: { parentSessionId: SessionId summaries: Readonly> level: number + t: TranslateNS }) { const children = Object.values(summaries).filter(summary => ( summary.origin === 'subagent' && summary.parentId === parentSessionId )) - if (children.length === 0) return
正在加载子代理…
+ if (children.length === 0) return
{t('loading.label')}
return children.map(summary => (
- 正在加载子代理… + {t('loading.label')}
@@ -129,9 +234,12 @@ function CatalogLoadingRows({ /** Render one catalog level and recurse only through explicitly expanded rows. */ function CatalogRows({ parentSessionId, catalog, catalogs, summaries, expanded, level, now, - openChild, refresh, toggleBranch, closeCatalog, -}: CatalogRowsProps) { + openChild, refresh, toggleBranch, closeCatalog, t, +}: CatalogRowsProps & { t: TranslateNS }) { const emptyLoading = catalog.state === 'loading' && catalog.entries.length === 0 + const reserveDisclosure = catalog.entries.some( + entry => entry.kind === 'child' && entry.hasChildren, + ) return ( <> {emptyLoading && ( @@ -139,24 +247,25 @@ function CatalogRows({ parentSessionId={parentSessionId} summaries={summaries} level={level} + t={t} /> )} {catalog.state === 'error' && (
- {catalog.error?.message ?? '无法加载子代理'} + {catalog.error?.message ?? t('load.error')}
)} {catalog.entries.map((entry) => { if (entry.kind === 'diagnostic') { - const reason = diagnosticReason(entry) + const reason = diagnosticReason(entry, t) return (
- + {reserveDisclosure && } {entry.id} @@ -185,12 +294,29 @@ function CatalogRows({ || (childCatalog.state === 'loading' && childCatalog.entries.length === 0) const summary = summaries[entry.id] const label = entry.label ?? entry.id - const mode = entry.mode === 'one-shot' ? '一次性' : '可继续' - const activity = entry.activity === 'running' ? '正在运行' : '当前未运行' + const mode = entry.mode === 'one-shot' ? t('mode.oneShot') : t('mode.continuable') + const activity = entry.activity === 'running' ? t('activity.running') : t('activity.inactive') const secondary = [summary?.title, mode, activity] .filter(value => value !== undefined) .join(' · ') - const time = relativeTime(summary?.updatedAt, now) + const totalTokens = tokenTotal(summary?.projectionValues?.tokenUsage) + const durationMs = activityDuration( + summary, + entry.activity, + now, + ) + const tokenMetric = totalTokens === undefined + ? undefined + : `${formatTokens(totalTokens)} tok` + const durationMetric = durationMs === undefined + ? undefined + : { + compact: formatDuration(durationMs, t), + exact: formatExactDuration(durationMs, t), + } + const metrics = [tokenMetric, durationMetric?.exact] + .filter(value => value !== undefined) + .join(' · ') const open = (): void => { openChild({ parentSessionId, childSessionId: entry.id, mode: entry.mode }) @@ -222,20 +348,20 @@ function CatalogRows({ role="treeitem" tabIndex={0} aria-level={level} - aria-label={[label, secondary, time].filter(value => value !== undefined).join(' ')} + aria-label={[label, secondary, metrics].filter(value => value !== '').join(' ')} {...knownLeaf ? {} : { 'aria-expanded': isExpanded }} className={css.row} onClick={open} onKeyDown={handleKey} > {knownLeaf - ? + ? reserveDisclosure && : (
{isExpanded && !knownLeaf && ( @@ -262,6 +400,7 @@ function CatalogRows({ parentSessionId={entry.id} summaries={summaries} level={level + 1} + t={t} /> ) : ( @@ -277,6 +416,7 @@ function CatalogRows({ refresh={refresh} toggleBranch={toggleBranch} closeCatalog={closeCatalog} + t={t} /> )}
@@ -291,15 +431,16 @@ function CatalogRows({ /** * Render the current session's direct catalog and lazily expanded descendants. * @param props - session standard props plus catalog navigation actions. - * @returns The action only after a non-empty catalog arrives. + * @returns The action while the catalog is pending or summaries establish descendants. */ export function SubagentCatalogAction({ - sessionId, useSessions, openChild, refresh, setCatalogOpen, + sessionId, useSessions, openChild, refresh, setCatalogOpen, t, }: SubagentCatalogActionProps) { const catalogs = useSessions(state => state.subagentsByParent) const summaries = useSessions(state => state.byId) const catalog = catalogs[sessionId] const [open, setOpen] = useState(false) + const [now, setNow] = useState(() => Date.now()) const [expanded, setExpanded] = useState>(() => new Set()) const rootRef = useRef(null) const triggerRef = useRef(null) @@ -311,6 +452,20 @@ export function SubagentCatalogAction({ // The catalog can arrive before the session-list baseline; never undercount // the already-visible direct rows during that short bootstrap window. const descendantCount = Math.max(healthy.length, descendants.count) + const totalCountKey = descendantCount === 1 ? 'count.total.one' : 'count.total.other' + const runningCountKey = descendantCount === 1 ? 'count.running.one' : 'count.running.other' + // Session summaries can announce membership before the descriptor-backed catalog catches up. + // Keep that entry point visible through disabled loading rows; only catalog rows are navigable. + const summaryBackedLoading = descendants.count > 0 + && (catalog === undefined || (catalog.state === 'ready' && catalog.entries.length === 0)) + const presentedCatalog: SubagentCatalogSnapshot | undefined = summaryBackedLoading + ? { + entries: [], + parentAvailable: catalog?.parentAvailable ?? false, + state: 'loading', + error: null, + } + : catalog const observeCatalog = (parentSessionId: SessionId, next: boolean): void => { if (next) observedCatalogs.current.add(parentSessionId) @@ -328,7 +483,10 @@ export function SubagentCatalogAction({ const changeOpen = (next: boolean, restoreFocus = false): void => { setOpen(next) - if (next) observeCatalog(sessionId, true) + if (next) { + setNow(Date.now()) + observeCatalog(sessionId, true) + } else closeAllCatalogs() if (restoreFocus) queueMicrotask(() => { triggerRef.current?.focus() }) } @@ -368,6 +526,12 @@ export function SubagentCatalogAction({ return () => { document.removeEventListener('pointerdown', closeOutside) } }, [open]) + useEffect(() => { + if (!open || !descendants.running) return + const timer = setInterval(() => { setNow(Date.now()) }, 1_000) + return () => { clearInterval(timer) } + }, [open, descendants.running]) + useEffect(() => () => { for (const parentSessionId of observedCatalogs.current) { setCatalogOpenRef.current(parentSessionId, false) @@ -375,7 +539,8 @@ export function SubagentCatalogAction({ observedCatalogs.current.clear() }, []) - const visible = catalog !== undefined && (catalog.state !== 'ready' || catalog.entries.length > 0) + const visible = presentedCatalog !== undefined + && (presentedCatalog.state !== 'ready' || presentedCatalog.entries.length > 0) useEffect(() => { if (visible || !open) return setOpen(false) @@ -419,7 +584,7 @@ export function SubagentCatalogAction({ className={css.trigger} aria-haspopup="tree" aria-expanded={open} - aria-label={`${descendantCount} 个子代理${descendants.running ? ',正在运行' : ''}`} + aria-label={t(descendants.running ? runningCountKey : totalCountKey, { count: descendantCount })} onClick={() => { changeOpen(!open) }} onKeyDown={(event) => { if (event.key !== 'ArrowDown') return @@ -431,23 +596,24 @@ export function SubagentCatalogAction({ {descendants.running && } - {descendantCount} 个子代理 + {t(totalCountKey, { count: descendantCount })} {open && ( -
+
{ changeOpen(false) }} + t={t} />
)} diff --git a/packages/client/ui-subagent/src/client/SubagentReadOnlyComposer.tsx b/packages/client/ui-subagent/src/client/SubagentReadOnlyComposer.tsx index 0cc2699bd8..158e8cb77b 100644 --- a/packages/client/ui-subagent/src/client/SubagentReadOnlyComposer.tsx +++ b/packages/client/ui-subagent/src/client/SubagentReadOnlyComposer.tsx @@ -1,4 +1,5 @@ -import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import { NS } from './locales.ts' import css from './SubagentReadOnlyComposer.module.css' /** Why a catalog-addressed conversation cannot accept human input. */ @@ -8,7 +9,7 @@ export interface SubagentReadOnlyMatch { /** Full chain props after the read-only subagent selector accepts the owner currency. */ export type SubagentReadOnlyComposerProps = - PropsRuntime<'conversation.composer'> & { matched: SubagentReadOnlyMatch } + PropsRuntime<'conversation.composer'> & { matched: SubagentReadOnlyMatch } & PropsLocale /** * Explain why the normal composer is unavailable for an addressed child. @@ -16,16 +17,14 @@ export type SubagentReadOnlyComposerProps = * @returns A read-only composer replacement. */ export function SubagentReadOnlyComposer({ - matched, -}: Pick) { + matched, t, +}: Pick) { const oneShot = matched.reason === 'one-shot' return (
- {oneShot ? '一次性子代理记录' : '此子代理暂时只读'} + {t(oneShot ? 'readonly.oneShot.title' : 'readonly.title')} - {oneShot - ? '一次性任务不支持后续消息,可在这里查看完整执行记录。' - : '父会话当前不在线,重新打开父会话后即可继续发送消息。'} + {t(oneShot ? 'readonly.oneShot.body' : 'readonly.body')}
) diff --git a/packages/client/ui-subagent/src/client/index.ts b/packages/client/ui-subagent/src/client/index.ts index 239ffd4335..31579dc258 100644 --- a/packages/client/ui-subagent/src/client/index.ts +++ b/packages/client/ui-subagent/src/client/index.ts @@ -18,6 +18,15 @@ import { SubagentCatalogAction, type SubagentCatalogInjected } from './SubagentC import { SubagentReadOnlyComposer, type SubagentReadOnlyMatch, } from './SubagentReadOnlyComposer.tsx' +import type {} from '@deepseek-ai/dsh-client-locale/client' +import { en, NS, zh, type SubagentKey } from './locales.ts' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Subagent catalog and read-only composer copy. */ + 'subagent': SubagentKey + } +} export type { SubagentCatalogActionProps, SubagentCatalogInjected, @@ -27,7 +36,7 @@ export type { } from './SubagentReadOnlyComposer.tsx' /** Required services for references, conversation slots, and session navigation. */ -export const inject = ['slash', 'sessions', 'conversation', 'slots'] +export const inject = ['slash', 'sessions', 'conversation', 'slots', 'locale'] /** Claim the composer for one-shot history or an unavailable continuation owner. */ function selectReadOnlySubagent(owner: ComposerChainProps): SubagentReadOnlyMatch | null { @@ -42,6 +51,7 @@ function selectReadOnlySubagent(owner: ComposerChainProps): SubagentReadOnlyMatc * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-subagent: dictionaries') const sessions = ctx.sessions // Child labels live on the session list (parentId lineage + displayTitle), // not the conversation snapshot — the list store is the zero-RPC candidate feed. @@ -98,6 +108,7 @@ export function apply(ctx: ClientContext): void { name: 'conversation.session.header.actions', id: 'subagent-catalog', order: 10, + locale: NS, inject: catalogActions, }, SubagentCatalogAction), 'ui-subagent: lazy descendant catalog action', @@ -106,6 +117,7 @@ export function apply(ctx: ClientContext): void { () => ctx.slots.register({ name: 'conversation.composer', priority: -10, + locale: NS, select: selectReadOnlySubagent, }, SubagentReadOnlyComposer), 'ui-subagent: read-only addressed composer', diff --git a/packages/client/ui-subagent/src/client/locales.ts b/packages/client/ui-subagent/src/client/locales.ts new file mode 100644 index 0000000000..b3897d7216 --- /dev/null +++ b/packages/client/ui-subagent/src/client/locales.ts @@ -0,0 +1,81 @@ +/** `subagent` namespace dictionaries. */ + +/** Dictionary namespace owned by this plugin. */ +export const NS = 'subagent' + +/** Simplified Chinese dictionary (the key-set source of truth). */ +export const zh = { + 'diagnostic.corrupt': '会话记录损坏', + 'diagnostic.unsupported': '子代理记录版本不受支持', + 'diagnostic.unavailable': '会话记录暂不可用', + 'duration.seconds': '{seconds}秒', + 'duration.minutes': '{minutes}分{seconds}秒', + 'duration.hours': '{hours}小时{minutes}分{seconds}秒', + 'duration.days': '{days}天', + 'duration.daysHours': '{days}天{hours}小时', + 'duration.months': '约{months}个月', + 'duration.monthsDays': '约{months}个月{days}天', + 'duration.years': '约{years}年', + 'duration.yearsMonths': '约{years}年{months}个月', + 'duration.exactDays': '{days}天{hours}小时{minutes}分{seconds}秒', + 'duration.exactTitle': '总活跃耗时:{duration}', + 'loading.label': '正在加载子代理…', + 'loading.aria': '正在加载子代理', + 'load.error': '无法加载子代理', + 'retry': '重试', + 'mode.oneShot': '一次性', + 'mode.continuable': '可继续', + 'activity.running': '正在运行', + 'activity.inactive': '当前未运行', + 'branch.collapse': '收起 {label} 的下级子代理', + 'branch.expand': '展开 {label} 的下级子代理', + 'count.total.one': '{count} 个子代理', + 'count.total.other': '{count} 个子代理', + 'count.running.one': '{count} 个子代理,正在运行', + 'count.running.other': '{count} 个子代理,正在运行', + 'tree.aria': '子代理会话', + 'readonly.oneShot.title': '一次性子代理记录', + 'readonly.title': '此子代理暂时只读', + 'readonly.oneShot.body': '一次性任务不支持后续消息,可在这里查看完整执行记录。', + 'readonly.body': '父会话当前不在线,重新打开父会话后即可继续发送消息。', +} as const + +/** English dictionary, key-identical to the Chinese source of truth. */ +export const en: Record = { + 'diagnostic.corrupt': 'corrupted session record', + 'diagnostic.unsupported': 'unsupported subagent record version', + 'diagnostic.unavailable': 'session record temporarily unavailable', + 'duration.seconds': '{seconds}s', + 'duration.minutes': '{minutes}m {seconds}s', + 'duration.hours': '{hours}h {minutes}m {seconds}s', + 'duration.days': '{days}d', + 'duration.daysHours': '{days}d {hours}h', + 'duration.months': '~{months}mo', + 'duration.monthsDays': '~{months}mo {days}d', + 'duration.years': '~{years}y', + 'duration.yearsMonths': '~{years}y {months}mo', + 'duration.exactDays': '{days}d {hours}h {minutes}m {seconds}s', + 'duration.exactTitle': 'Total active duration: {duration}', + 'loading.label': 'Loading subagents…', + 'loading.aria': 'Loading subagents', + 'load.error': 'Unable to load subagents', + 'retry': 'Retry', + 'mode.oneShot': 'one-shot', + 'mode.continuable': 'continuable', + 'activity.running': 'running', + 'activity.inactive': 'not running', + 'branch.collapse': 'Collapse {label} descendants', + 'branch.expand': 'Expand {label} descendants', + 'count.total.one': '{count} subagent', + 'count.total.other': '{count} subagents', + 'count.running.one': '{count} subagent running', + 'count.running.other': '{count} subagents running', + 'tree.aria': 'Subagent sessions', + 'readonly.oneShot.title': 'One-shot subagent record', + 'readonly.title': 'This subagent is read-only for now', + 'readonly.oneShot.body': 'One-shot tasks do not accept follow-ups; review the full execution record here.', + 'readonly.body': 'The parent session is offline; reopen it to continue sending messages.', +} + +/** Key domain of the `subagent` namespace (zh is the source of truth). */ +export type SubagentKey = keyof typeof zh diff --git a/packages/client/ui-subagent/tests/browser-plugin.spec.ts b/packages/client/ui-subagent/tests/browser-plugin.spec.ts index 0db157a5a7..d2332cb3af 100644 --- a/packages/client/ui-subagent/tests/browser-plugin.spec.ts +++ b/packages/client/ui-subagent/tests/browser-plugin.spec.ts @@ -18,6 +18,7 @@ import { import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client' import { SlashService } from '@deepseek-ai/dsh-client-ui-slash/client' import type { ClientSessionContext, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client' +import { apply as applyLocale } from '@deepseek-ai/dsh-client-locale/client' import { SubagentCatalogAction, type SubagentCatalogInjected, } from '../src/client/SubagentCatalogAction.tsx' @@ -85,6 +86,7 @@ async function fullBench(sessions: SessionSummary[]) { ctx.provide('slash', { registerSource: (src: SlashSource) => { captured = src; return () => {} } }) ctx.provide('sessions', face) await provideSlotFaces(ctx) + await ctx.plugin({ inject: ['slots'], apply: applyLocale }).await() await ctx.plugin({ inject: [...inject], apply }).await() return { source: captured!, face, ctx } } @@ -111,7 +113,7 @@ const req = (query: string) => describe('apply', () => { it('declares the services it binds', () => { - expect(inject).toEqual(['slash', 'sessions', 'conversation', 'slots']) + expect(inject).toEqual(['slash', 'sessions', 'conversation', 'slots', 'locale']) }) it('registers the "@" subagent source; disposal frees the name (HMR safety)', async () => { @@ -119,6 +121,7 @@ describe('apply', () => { await ctx.plugin(SlashService).await() ctx.provide('sessions', sessionsWith(FAMILY)) await provideSlotFaces(ctx) + await ctx.plugin({ inject: ['slots'], apply: applyLocale }).await() const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() const slash = ctx.get('slash') as SlashService diff --git a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx index 6eb9f881c0..b6c02fcfb7 100644 --- a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx +++ b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx @@ -1,6 +1,7 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { cleanup, fireEvent, render, screen, within } from '@testing-library/react' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionId, SessionListState, SessionSummary, SubagentCatalogSnapshot, } from '@deepseek-ai/dsh-client-runtime/client' @@ -8,15 +9,18 @@ import { SubagentCatalogAction, type SubagentCatalogActionProps, } from '../src/client/SubagentCatalogAction.tsx' import { SubagentReadOnlyComposer } from '../src/client/SubagentReadOnlyComposer.tsx' +import { zh } from '../src/client/locales.ts' afterEach(() => { cleanup() + vi.useRealTimers() vi.restoreAllMocks() }) const PARENT = 'parent' as SessionId const CHILD = 'child' as SessionId const GRANDCHILD = 'grandchild' as SessionId +const t: SubagentCatalogActionProps['t'] = makeTranslate(zh) function catalog(over: Partial = {}): SubagentCatalogSnapshot { return { @@ -69,6 +73,7 @@ function props( openChild: vi.fn(), refresh: vi.fn(), setCatalogOpen: vi.fn(), + t, } as unknown as SubagentCatalogActionProps } @@ -146,6 +151,7 @@ describe('SubagentCatalogAction', () => { expect(diagnostic.getAttribute('aria-disabled')).toBe('true') expect(screen.getByRole('button', { name: '展开 worker 的下级子代理' })).toBeTruthy() expect(screen.queryByRole('button', { name: '展开 reviewer 的下级子代理' })).toBeNull() + expect(screen.getByRole('treeitem', { name: /reviewer/ }).children).toHaveLength(2) fireEvent.click(screen.getByRole('treeitem', { name: /worker/ })) expect(input.openChild).toHaveBeenCalledWith({ @@ -154,6 +160,37 @@ describe('SubagentCatalogAction', () => { expect(input.setCatalogOpen).toHaveBeenLastCalledWith(PARENT, false) }) + it('selects singular count keys for one descendant', () => { + const base = props(catalog({ + entries: [{ + kind: 'child', id: CHILD, mode: 'continuable', label: 'worker', + activity: 'running', hasChildren: false, + }], + }), {}, { + [CHILD]: { + ...summary(CHILD, Date.now()), parentId: PARENT, origin: 'subagent', running: true, + }, + }) + const translate = vi.fn(base.t) + render() + + expect(translate).toHaveBeenCalledWith('count.running.one', { count: 1 }) + expect(translate).toHaveBeenCalledWith('count.total.one', { count: 1 }) + }) + + it('removes the disclosure column from branchless catalog levels', () => { + const input = props(catalog({ + entries: [{ + kind: 'child', id: CHILD, mode: 'continuable', label: 'worker', + activity: 'running', hasChildren: false, + }], + })) + render() + fireEvent.click(screen.getByRole('button', { name: /1 个子代理/ })) + + expect(screen.getByRole('treeitem', { name: /worker/ }).children).toHaveLength(1) + }) + it('supports trigger/menu keyboard traversal, Escape focus restore, and outside close', async () => { const input = props(catalog()) render() @@ -217,42 +254,99 @@ describe('SubagentCatalogAction', () => { }) }) - it('renders compact activity times across every unit and clamps future timestamps', () => { + it('shows durable token totals, ticks active duration by seconds, and freezes inactive rows', async () => { const now = 2_000_000_000_000 - vi.spyOn(Date, 'now').mockReturnValue(now) const minute = 60_000 const hour = 60 * minute const day = 24 * hour + vi.useFakeTimers() + vi.setSystemTime(now) const rows = [ - ['future', now + minute], - ['minutes', now - 2 * minute], - ['hours', now - 2 * hour], - ['days', now - 2 * day], - ['months', now - 60 * day], - ['years', now - 2 * 365 * day], + ['running', 'running', 65_000, now - 5_000, now - 1_000, now], + ['finished', 'inactive', 3_723_000, undefined, undefined, now - 60_000], + ['interrupted', 'inactive', 2_000, now - 7_000, now - 3_000, now + 60_000], + ['days', 'inactive', 12 * day + 5 * hour + 6 * minute + 7_000, undefined, undefined, now], + ['whole-day', 'inactive', day, undefined, undefined, now], + ['months', 'inactive', 192 * day, undefined, undefined, now], + ['whole-month', 'inactive', 30 * day, undefined, undefined, now], + ['years', 'inactive', 832 * day, undefined, undefined, now], + ['whole-year', 'inactive', 365 * day, undefined, undefined, now], ] as const - const entries = rows.map(([id]) => ({ + const usageById = { + running: { + uncachedInputTokens: 1_000, + outputTokens: 200, + cacheReadTokens: 3_000, + cacheWriteTokens: 400, + }, + finished: { + uncachedInputTokens: 123, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + }, + interrupted: { + uncachedInputTokens: 123_000_000, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + }, + } as const + const entries = rows.map(([id, activity]) => ({ kind: 'child' as const, id: id as SessionId, mode: 'continuable' as const, label: id, - activity: 'inactive' as const, + activity, hasChildren: false, })) - const summaries = Object.fromEntries(rows.map(([id, updatedAt]) => [ - id, - summary(id as SessionId, updatedAt), - ])) as Record + const summaries = Object.fromEntries(rows.map(([ + id, activity, settledMs, activeSince, activeThrough, updatedAt, + ]) => { + const childId = id as SessionId + return [id, { + ...summary(childId, updatedAt), + parentId: PARENT, + origin: 'subagent' as const, + running: activity === 'running', + projectionValues: { + subagentTiming: { + settledMs, + ...(activeSince === undefined || activeThrough === undefined + ? {} + : { active: { since: activeSince, through: activeThrough } }), + }, + tokenUsage: id in usageById + ? usageById[id as keyof typeof usageById] + : undefined, + }, + }] + })) as Record const input = props(catalog({ entries }), {}, summaries) render() - fireEvent.click(screen.getByRole('button', { name: /6 个子代理/ })) + fireEvent.click(screen.getByRole('button', { name: /9 个子代理/ })) - expect(screen.getByRole('treeitem', { name: /future.*刚刚/ })).toBeTruthy() - expect(screen.getByRole('treeitem', { name: /minutes.*2分钟/ })).toBeTruthy() - expect(screen.getByRole('treeitem', { name: /hours.*2小时/ })).toBeTruthy() - expect(screen.getByRole('treeitem', { name: /days.*2天/ })).toBeTruthy() - expect(screen.getByRole('treeitem', { name: /months.*2个月/ })).toBeTruthy() - expect(screen.getByRole('treeitem', { name: /years.*2年/ })).toBeTruthy() + const runningRow = screen.getByRole('treeitem', { name: /running.*4\.6K tok · 1分10秒/ }) + const runningMetrics = within(runningRow) + const tokenMetric = runningMetrics.getByText('4.6K tok') + const durationMetric = runningMetrics.getByText('1分10秒') + expect(tokenMetric.parentElement).toBe(durationMetric.parentElement) + expect(tokenMetric.nextElementSibling).toBe(durationMetric) + expect(screen.getByRole('treeitem', { name: /finished.*123 tok · 1小时02分03秒/ })).toBeTruthy() + expect(screen.getByRole('treeitem', { name: /interrupted.*123M tok · 6秒/ })).toBeTruthy() + expect(screen.getByRole('treeitem', { name: /days.*12天05小时06分07秒/ })).toBeTruthy() + expect(screen.getByText('12天5小时').getAttribute('title')) + .toBe('总活跃耗时:12天05小时06分07秒') + expect(screen.getByText('1天')).toBeTruthy() + expect(screen.getByText('约6个月12天')).toBeTruthy() + expect(screen.getByText('约1个月')).toBeTruthy() + expect(screen.getByText('约2年3个月')).toBeTruthy() + expect(screen.getByText('约1年')).toBeTruthy() + + await vi.advanceTimersByTimeAsync(1_000) + expect(screen.getByRole('treeitem', { name: /running.*4\.6K tok · 1分11秒/ })).toBeTruthy() + expect(screen.getByRole('treeitem', { name: /finished.*123 tok · 1小时02分03秒/ })).toBeTruthy() + expect(screen.getByRole('treeitem', { name: /interrupted.*123M tok · 6秒/ })).toBeTruthy() }) it('lazily expands and collapses descendant catalogs with direct-parent navigation', () => { @@ -401,6 +495,32 @@ describe('SubagentCatalogAction', () => { expect(failed.refresh).toHaveBeenCalledWith(PARENT) }) + it('keeps known descendants reachable while their catalog is absent or stale-empty', () => { + const second = 'child-2' as SessionId + const summaries = { + [CHILD]: { + ...summary(CHILD, 1), parentId: PARENT, origin: 'subagent' as const, + }, + [second]: { + ...summary(second, 1), parentId: PARENT, origin: 'subagent' as const, running: true, + }, + } + const absent = props(undefined, {}, summaries) + const view = render() + + const trigger = screen.getByRole('button', { name: '2 个子代理,正在运行' }) + fireEvent.click(trigger) + expect(absent.setCatalogOpen).toHaveBeenCalledWith(PARENT, true) + expect(screen.getAllByRole('treeitem', { name: '正在加载子代理' })).toHaveLength(2) + expect(absent.openChild).not.toHaveBeenCalled() + + const staleEmpty = props(catalog({ entries: [] }), {}, summaries) + view.rerender() + expect(screen.getByRole('button', { name: '2 个子代理,正在运行' })).toBeTruthy() + expect(screen.getAllByRole('treeitem', { name: '正在加载子代理' })).toHaveLength(2) + expect(staleEmpty.openChild).not.toHaveBeenCalled() + }) + it('renders empty loading and fallback error states without focusable rows', async () => { const loading = props(catalog({ entries: [], state: 'loading' })) const view = render() @@ -454,12 +574,12 @@ describe('SubagentCatalogAction', () => { describe('SubagentReadOnlyComposer', () => { it('explains the exact missing-parent recovery path', () => { - render() + render() expect(screen.getByRole('status').textContent).toContain('父会话当前不在线') }) it('explains that one-shot histories never accept follow-ups', () => { - render() + render() expect(screen.getByRole('status').textContent).toContain('一次性任务不支持后续消息') }) }) diff --git a/packages/client/ui-subagent/tsconfig.json b/packages/client/ui-subagent/tsconfig.json index 395281ce6d..27d06d16c0 100644 --- a/packages/client/ui-subagent/tsconfig.json +++ b/packages/client/ui-subagent/tsconfig.json @@ -11,6 +11,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../locale" + }, { "path": "../runtime" }, @@ -26,6 +29,12 @@ { "path": "../ui-slots" }, + { + "path": "../../llm/token-meter" + }, + { + "path": "../../subagent/subagent" + }, { "path": "../../support/invariants" } diff --git a/packages/client/ui-trajectory/README.i18n.yaml b/packages/client/ui-trajectory/README.i18n.yaml index 41c954adc0..36e56c4569 100644 --- a/packages/client/ui-trajectory/README.i18n.yaml +++ b/packages/client/ui-trajectory/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-trajectory/README.md -README.md: a65c11aed9dd74f9b0b60795441f876c1d64b3ad -README.zh.md: 6e25d24c6b65673b3d003e624b6e0727be60c0e1 +README.md: 5d0ea3bbbbfca2b8c0ee02ed07ca956fbd377e11 +README.zh.md: 1bfff4c18ea2e834781e2c6cb76773595eeed5ad diff --git a/packages/client/ui-trajectory/README.md b/packages/client/ui-trajectory/README.md index a65c11aed9..5d0ea3bbbb 100644 --- a/packages/client/ui-trajectory/README.md +++ b/packages/client/ui-trajectory/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. A fixed Overview above the ledger projects real record start/duration timing from left to right; dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full branch. The runtime's independent history source supplies raw context lineage and projects cancellation-frozen Assistant and Tool records, so Trajectory neither reads nor changes the Chat conversation snapshot. The package remains a pure-consumer plugin (registers one view tab into the conversation's `'conversation.view'` slot ring, provides no service, declares no Context merge). Contract: api-contracts v3 §8. +Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. A fixed Overview above the ledger projects real record start/duration timing from left to right; Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full branch. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. Streaming updates keep the ledger pinned only when it was already at the bottom, so reading earlier records suspends tail following. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. The runtime's independent history source supplies raw context lineage and projects cancellation-frozen Assistant and Tool records, so Trajectory neither reads nor changes the Chat conversation snapshot. The package remains a pure-consumer plugin (registers one view tab into the conversation's `'conversation.view'` slot ring, provides no service, declares no Context merge). Contract: api-contracts v3 §8. ## Model Experience diff --git a/packages/client/ui-trajectory/README.zh.md b/packages/client/ui-trajectory/README.zh.md index 6e25d24c6b..1bfff4c18e 100644 --- a/packages/client/ui-trajectory/README.zh.md +++ b/packages/client/ui-trajectory/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带数值所有者的压缩仍位于其所属轮次内。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整分支。运行时的独立历史数据源提供原始上下文谱系,并投影因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包(package)保持为纯消费方插件(向会话的 `'conversation.view'` slot 环注册一个视图标签页,不提供服务,也不声明 Context 合并)。契约:api-contracts v3 §8。 +Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带数值所有者的压缩仍位于其所属轮次内。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整分支。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。仅当记录表在流式更新前已经位于底部时,更新才会保持贴底;向上阅读旧记录会暂停跟随。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。运行时的独立历史数据源提供原始上下文谱系,并投影因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包(package)保持为纯消费方插件(向会话的 `'conversation.view'` slot 环注册一个视图标签页,不提供服务,也不声明 Context 合并)。契约:api-contracts v3 §8。 ## 模型体验 diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css index 86cc8dfd8b..69d9d620e3 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css @@ -15,7 +15,9 @@ .tablePane { flex: 1; min-width: 0; - overflow: auto; + overflow-x: hidden; + overflow-y: auto; + padding-bottom: var(--dsh-trajectory-bottom-clearance, 0px); container: trajectory-table / inline-size; } @@ -27,7 +29,7 @@ ); width: 100%; - min-width: 480px; + min-width: 0; border-spacing: 0; table-layout: fixed; color: var(--dsw-alias-label-primary); @@ -98,7 +100,7 @@ } .table tbody tr[data-request-only='true'] td { - height: 1px; + height: 0; padding-top: 0; padding-bottom: 0; border-bottom: 0; @@ -123,10 +125,12 @@ } .requestBoundaryControl { + --request-boundary-base-left: 12px; + position: absolute; z-index: 6; top: -8px; - left: 12px; + left: calc(var(--request-boundary-base-left) + var(--request-boundary-offset, 0px)); width: 16px; height: 16px; padding: 0; @@ -191,6 +195,12 @@ box-shadow: 0 0 0 1.5px var(--dsw-alias-brand-primary-new-colorprimary-new-color); } +.requestBoundaryControl[data-request-status='error']::before, +.requestBoundaryControl[data-request-status='error']:hover::before, +.requestBoundaryControl[data-request-status='error']:focus-visible::before { + background: var(--dsw-alias-state-error-primary); +} + .requestBoundaryControl:hover::after, .requestBoundaryControl:focus-visible::after { opacity: 1; @@ -277,7 +287,7 @@ z-index: 3; top: 0; left: 0; - display: inline-flex; + display: inline-grid; flex: none; align-items: center; box-sizing: border-box; @@ -292,8 +302,18 @@ white-space: nowrap; } +.turnLabelFull, .turnLabelCompact { - display: none; + grid-area: 1 / 1; + max-width: 64px; + overflow: hidden; + opacity: 1; + white-space: nowrap; +} + +.turnLabelCompact { + max-width: 0; + opacity: 0; } .turnLabelActive { @@ -350,15 +370,23 @@ } .kindTagIcon { - display: none; + display: inline-flex; + flex: none; align-items: center; justify-content: center; - width: 13px; + width: 0; height: 13px; + overflow: hidden; + opacity: 0; + transform: scale(0.8); } .kindTagLabel { - display: inline; + display: inline-block; + max-width: 72px; + overflow: hidden; + opacity: 1; + white-space: nowrap; } .table .kindSlot .message { @@ -377,7 +405,7 @@ } .requestBoundaryControl { - left: 6px; + --request-boundary-base-left: 6px; } .kindSlot { @@ -393,19 +421,66 @@ } .kindTagIcon { - display: inline-flex; + width: 13px; + opacity: 1; + transform: scale(1); } .kindTagLabel { - display: none; + max-width: 0; + opacity: 0; } .turnLabelFull { - display: none; + max-width: 0; + opacity: 0; } .turnLabelCompact { - display: inline; + max-width: 64px; + opacity: 1; + } +} + +@media (prefers-reduced-motion: no-preference) { + .eventColumn, + .event, + .requestBoundaryControl, + .kindSlot, + .kindTag, + .kindTagIcon, + .kindTagLabel, + .turnLabelFull, + .turnLabelCompact { + transition-duration: 180ms; + transition-timing-function: var(--ds-ease-in-out); + } + + .eventColumn, + .kindSlot { + transition-property: width; + } + + .event { + transition-property: padding-right, padding-left; + } + + .requestBoundaryControl { + transition-property: left; + } + + .kindTag { + transition-property: padding-right, padding-left; + } + + .kindTagIcon { + transition-property: width, opacity, transform; + } + + .kindTagLabel, + .turnLabelFull, + .turnLabelCompact { + transition-property: max-width, opacity; } } @@ -834,7 +909,9 @@ .detailBody { flex: 1; min-height: 0; - overflow: auto; + overflow-x: hidden; + overflow-y: auto; + padding-bottom: var(--dsh-trajectory-bottom-clearance, 0px); scrollbar-gutter: stable; } @@ -842,7 +919,7 @@ display: flex; box-sizing: border-box; flex-direction: column; - padding-bottom: 12px; + padding-bottom: calc(12px + var(--dsh-trajectory-bottom-clearance, 0px)); overflow: hidden; } @@ -1171,9 +1248,19 @@ background: transparent; cursor: pointer; font: 600 12px/18px var(--dsw-font-family); + gap: 2px; user-select: none; } +.thinkingChevron { + flex: none; + transition: transform 120ms var(--ds-ease-in-out); +} + +.thinkingToggle[aria-expanded='true'] .thinkingChevron { + transform: rotate(90deg); +} + .thinkingToggle:hover { color: var(--dsw-alias-label-secondary); } diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index 4f88bf0bca..6f37c852c5 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -1,6 +1,6 @@ /** Turn-aware trajectory event ledger with a local record inspector. */ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import type { CSSProperties, ReactNode } from 'react' import { IconChevronRightOutline14, @@ -22,6 +22,8 @@ import { formatElapsedSeconds } from './trajectory-record.ts' import { trajectoryPreviewText, type TrajectoryTurnModel } from './layout.ts' import css from './TrajectoryTable.module.css' +const BOTTOM_FOLLOW_THRESHOLD_PX = 2 + const KIND_LABEL: Record = { system: 'SYSTEM', user: 'USER', @@ -189,6 +191,10 @@ type TrajectorySplitStyle = CSSProperties & { '--trajectory-tool-request-width': string } +type RequestBoundaryStyle = CSSProperties & { + '--request-boundary-offset': string +} + function clampDetailsWidth(width: number, splitWidth: number): number { const maxWidth = Math.max( DETAILS_MIN_WIDTH, @@ -451,6 +457,22 @@ function indexRequestNumbers( return numbers } +function indexRequestBoundaryRuns(records: readonly TableRecord[]): ReadonlyMap { + const indexes = new Map() + let runLength = 0 + for (const record of records) { + if (record.cell.requestOnly === true) { + indexes.set(record.cell.index, runLength++) + continue + } + if (runLength > 0 && record.groupStart && requestStep(record.group) !== undefined) { + indexes.set(record.cell.index, runLength) + } + runLength = 0 + } + return indexes +} + function summarizeTurn(records: readonly TableRecord[]): string { const steps = new Set( records @@ -1210,7 +1232,8 @@ function MarkdownRecordContent({ aria-expanded={thinkingExpanded} onClick={() => { onThinkingExpandedChange(!thinkingExpanded) }} > - {thinkingExpanded ? 'Thinking' : 'Thinking ...'} + Thinking + {thinkingExpanded && ( record.cell.index === selectedIndex) const selectedPrompt = selected?.cell.kind === 'system' ? selected.cell.promptDetail @@ -1711,6 +1735,9 @@ export function TrajectoryTable({ // ledger has rendered. Not-found leaves the request pending (`turns` in the // deps retries as history pages in); the ack clears the store field. const rootRef = useRef(null) + const tablePaneRef = useRef(null) + const followsTableTail = useRef(false) + const tableScrollInitialized = useRef(false) const pendingScrollIndex = useRef(null) const openRecordSummaryRef = useRef(openRecordSummary) openRecordSummaryRef.current = openRecordSummary @@ -1734,11 +1761,30 @@ export function TrajectoryTable({ row.scrollIntoView({ behavior: 'smooth', block: 'center' }) } }) + useLayoutEffect(() => { + const pane = tablePaneRef.current + if (pane === null) return + if (!tableScrollInitialized.current) { + tableScrollInitialized.current = true + followsTableTail.current = + pane.scrollHeight - pane.clientHeight - pane.scrollTop + <= BOTTOM_FOLLOW_THRESHOLD_PX + return + } + if (followsTableTail.current) pane.scrollTop = pane.scrollHeight + }, [turns]) return (
{ + const pane = event.currentTarget + followsTableTail.current = + pane.scrollHeight - pane.clientHeight - pane.scrollTop + <= BOTTOM_FOLLOW_THRESHOLD_PX + }} onClick={(event) => { if (event.target === event.currentTarget) clearAllSelections() }} @@ -1767,6 +1813,12 @@ export function TrajectoryTable({ const requestInfo = request === undefined ? undefined : sessionRequestNumbers?.find(candidate => candidate.number === request) + const requestStatus = requestInfo?.status + ?? (record.cell.isError === true ? 'error' : undefined) + const requestRunIndex = requestBoundaryRuns.get(record.cell.index) ?? 0 + const requestBoundaryStyle: RequestBoundaryStyle = { + '--request-boundary-offset': `${requestRunIndex * 8}px`, + } const requestLabel = request === undefined ? undefined : `Request #${request}${requestInfo?.purpose === 'compaction' ? ' · Compaction' : ''}` @@ -1858,6 +1910,9 @@ export function TrajectoryTable({ aria-label={requestLabel} aria-pressed={requestSelected} data-label={requestLabel} + data-request-run-index={requestRunIndex} + data-request-status={requestStatus} + style={requestBoundaryStyle} onClick={(event) => { event.stopPropagation() selectRequest({ @@ -1906,36 +1961,36 @@ export function TrajectoryTable({ - - - - {KIND_LABEL[record.cell.kind]} - + + + {KIND_LABEL[record.cell.kind]} - + )}
@@ -2473,7 +2528,7 @@ export function TrajectoryTable({ )} {selectedAssistantRequestTarget !== undefined && ( { selectRequest(selectedAssistantRequestTarget, 'timing') }} diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css index 1ca5ab2627..4d548e64d8 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css @@ -7,6 +7,10 @@ user-select: none; } +.root :global([role='tooltip']) { + font: var(--dsw-font-xxxs-11); +} + .plot { display: grid; grid-template-columns: 44px minmax(0, 1fr); @@ -53,6 +57,10 @@ touch-action: none; } +.track[data-panning='true'] { + cursor: grabbing; +} + .empty { position: absolute; top: 50%; @@ -105,8 +113,15 @@ .span { position: absolute; top: calc(var(--trajectory-span-lane) * 14px); - left: calc(var(--trajectory-span-left) + 1px); - width: max(2px, calc(var(--trajectory-span-width) - 2px)); + left: calc(var(--trajectory-span-left) + var(--trajectory-span-gap)); + width: max( + 2px, + calc( + var(--trajectory-span-width) + - var(--trajectory-span-gap) + - var(--trajectory-span-gap) + ) + ); height: 8px; min-width: 2px; border-radius: 1px; @@ -127,23 +142,35 @@ } .span[data-timeline-span='message'] { - background: color-mix( + --trajectory-assistant-decoding-color: color-mix( in srgb, var(--dsw-alias-brand-primary-new-colorprimary-new-color) 60%, var(--dsw-alias-state-error-secondary) ); -} - -.span[data-timeline-span='tool'] { - background: var(--dsw-alias-state-warn-label); -} - -.span[data-timeline-span='subtool'] { - background: color-mix( + --trajectory-assistant-ttft-color: color-mix( in srgb, - var(--dsw-alias-state-warn-label) 62%, - var(--dsw-alias-label-tertiary) + var(--trajectory-assistant-decoding-color) 54%, + var(--dsw-alias-bg-layer-2) ); + + background: var(--trajectory-assistant-decoding-color); + opacity: 1; +} + +.span[data-timeline-span='message'][data-assistant-timing='true'] { + background: linear-gradient( + to right, + var(--trajectory-assistant-ttft-color) 0, + var(--trajectory-assistant-ttft-color) var(--trajectory-assistant-ttft), + var(--trajectory-assistant-decoding-color) var(--trajectory-assistant-ttft), + var(--trajectory-assistant-decoding-color) 100% + ); +} + +.span[data-timeline-span='tool'], +.span[data-timeline-span='subtool'] { + background: var(--dsw-alias-state-warn-label); + opacity: 1; } .span[data-error='true'] { @@ -161,7 +188,7 @@ .span[data-hovered='true']:not([data-current='true']) { z-index: 1; - opacity: 0.78; + opacity: 1; box-shadow: 0 0 0 1px var(--dsw-alias-bg-layer-2), 0 0 0 2px color-mix( diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx index 0fd0825f95..87d7cdcd34 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx @@ -4,7 +4,9 @@ import { memo, useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent, } from 'react' +import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' import type { TrajectoryTurnModel } from './layout.ts' +import type { AssistantMetricDetail, TrajectoryCellKind, TrajectoryCellProps } from './trajectory-record.ts' import { deriveTrajectoryTimeline, formatTimelineOffset, @@ -18,6 +20,14 @@ const MINIMUM_ZOOM_OPERATIONS = 4 const EDGE_PAN_ZONE_FRACTION = 0.08 const EDGE_PAN_STEP_FRACTION = 0.025 const MAXIMUM_EDGE_PAN_PX = 32 +const TIMELINE_TOOLTIP_DELAY_MS = 500 + +interface TimelineRecordDetail { + decodingMs?: number + durationMs?: number + startedAt?: number + ttftMs?: number +} interface FractionRange { start: number @@ -29,6 +39,94 @@ interface HoverPoint { recordIndex: number | null } +interface PanGesture { + anchorClientX: number + anchorStart: number + moved: boolean + pannable: boolean + pointerId: number +} + +function assistantTimingDetail( + metrics: AssistantMetricDetail | undefined, +): Pick { + const start = metrics?.stepStartTime + const first = metrics?.firstTokenTime + const completed = metrics?.completedTime + if ( + metrics?.timingRecorded !== true + || typeof start !== 'number' + || typeof first !== 'number' + || typeof completed !== 'number' + || !Number.isFinite(start) + || !Number.isFinite(first) + || !Number.isFinite(completed) + || first < start + || completed < first + ) return {} + return { ttftMs: first - start, decodingMs: completed - first } +} + +function timelineRecordDetail(cell: TrajectoryCellProps): TimelineRecordDetail { + const durationMs = cell.timeSeconds === null || !Number.isFinite(cell.timeSeconds) + ? undefined + : Math.max(0, cell.timeSeconds * 1_000) + const startedAt = cell.startedAt === null || !Number.isFinite(cell.startedAt) + ? undefined + : cell.startedAt + return { + ...(durationMs === undefined ? {} : { durationMs }), + ...(startedAt === undefined ? {} : { startedAt }), + ...assistantTimingDetail(cell.assistantMetrics), + } +} + +function timelineKindLabel(kind: TrajectoryCellKind): string { + switch (kind) { + case 'system': return 'SYSTEM' + case 'user': return 'USER' + case 'context': return 'CONTEXT' + case 'compacted': return 'COMPACTED' + case 'message': return 'ASSISTANT' + case 'tool': return 'TOOL' + case 'subtool': return 'SUBTOOL' + } +} + +function formatRecordedTime(timestamp: number): string { + return new Date(timestamp).toLocaleTimeString(undefined, { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + fractionalSecondDigits: 3, + }) +} + +function timelineTooltipLabel( + kind: TrajectoryCellKind, + detail: TimelineRecordDetail | undefined, +): string { + const heading = timelineKindLabel(kind) + if (detail === undefined) return heading + const duration = detail.durationMs === undefined + ? null + : `Total ${formatTimelineOffset(detail.durationMs)}` + const range = detail.startedAt === undefined + ? null + : detail.durationMs === undefined + ? `Started ${formatRecordedTime(detail.startedAt)}` + : `${formatRecordedTime(detail.startedAt)} → ${formatRecordedTime( + detail.startedAt + detail.durationMs, + )}` + const segments = detail.ttftMs === undefined || detail.decodingMs === undefined + ? null + : `TTFT ${formatTimelineOffset(detail.ttftMs)} · Decoding ${formatTimelineOffset( + detail.decodingMs, + )}` + const timing = [duration, segments].filter(value => value !== null).join(' · ') + return [heading, range, timing].filter(value => value !== null && value !== '').join('\n') +} + /** Props for the fixed full-domain overview above the trajectory ledger. */ export interface TrajectoryTimelineProps { turns: readonly TrajectoryTurnModel[] @@ -105,14 +203,10 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ onRecordFocus, }: TrajectoryTimelineProps) { const model = useMemo(() => deriveTrajectoryTimeline(turns, mode), [mode, turns]) - const durationByIndex = useMemo( + const detailByIndex = useMemo( () => new Map(turns.flatMap(turn => turn.groups.flatMap(group => - group.cells.flatMap(cell => - cell.timeSeconds === null || !Number.isFinite(cell.timeSeconds) - ? [] - : [[cell.index, Math.max(0, cell.timeSeconds * 1_000)] as const], - ), + group.cells.map(cell => [cell.index, timelineRecordDetail(cell)] as const), ), )), [turns], @@ -123,10 +217,12 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ anchorClientX: number recordIndex: number | null } | null>(null) + const panRef = useRef(null) const rootRef = useRef(null) const trackRef = useRef(null) const [draft, setDraft] = useState(null) const [hover, setHover] = useState(null) + const [panning, setPanning] = useState(false) const [viewport, setViewport] = useState(null) const [animateViewport, setAnimateViewport] = useState(false) useEffect(() => { @@ -267,6 +363,21 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ } const onPointerDown = (event: PointerEvent) => { + if (event.button === 2) { + panRef.current = { + anchorClientX: event.clientX, + anchorStart: domainStart, + moved: false, + pannable: viewport !== null, + pointerId: event.pointerId, + } + if (viewport !== null) setAnimateViewport(false) + setPanning(true) + if (typeof event.currentTarget.setPointerCapture === 'function') { + event.currentTarget.setPointerCapture(event.pointerId) + } + return + } if (event.button !== 0) return const anchor = fractionAt(event) const anchorTime = domainStart + anchor * domainDuration @@ -285,10 +396,24 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ } const onPointerMove = (event: PointerEvent) => { - const drag = dragRef.current const rect = event.currentTarget.getBoundingClientRect() const fraction = fractionAt(event) setHover({ fraction, recordIndex: recordIndexAt(event) }) + const pan = panRef.current + if (pan !== null && pan.pointerId === event.pointerId) { + if (Math.abs(event.clientX - pan.anchorClientX) >= MINIMUM_DRAG_PX) { + pan.moved = true + } + if (!pan.pannable) return + const delta = (event.clientX - pan.anchorClientX) / Math.max(1, rect.width) + const nextStart = Math.min( + Math.max(pan.anchorStart - delta * domainDuration, model.start), + model.end - domainDuration, + ) + setViewport({ start: nextStart, end: nextStart + domainDuration }) + return + } + const drag = dragRef.current if (drag === null || drag.pointerId !== event.pointerId) return let nextDomainStart = domainStart if (viewport !== null) { @@ -326,6 +451,15 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ } const onPointerEnd = (event: PointerEvent) => { + const pan = panRef.current + if (pan !== null && pan.pointerId === event.pointerId) { + const moved = pan.moved + || Math.abs(event.clientX - pan.anchorClientX) >= MINIMUM_DRAG_PX + panRef.current = null + setPanning(false) + if (!moved) onRangeChange(null) + return + } const drag = dragRef.current if (drag === null || drag.pointerId !== event.pointerId) return const pointFraction = fractionAt(event) @@ -375,8 +509,10 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ const onPointerCancel = () => { dragRef.current = null + panRef.current = null setDraft(null) setHover(null) + setPanning(false) } return ( @@ -386,6 +522,7 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
{ - if (dragRef.current === null) setHover(null) + if (dragRef.current === null && panRef.current === null) setHover(null) }} onDoubleClick={(event) => { event.preventDefault() @@ -402,9 +539,6 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ }} onContextMenu={(event) => { event.preventDefault() - setAnimateViewport(false) - onRangeChange(null) - setViewport(null) }} > {hover !== null && hover.recordIndex === null && draft === null && ( @@ -466,7 +600,6 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ className={css.lanes} data-animate-viewport={animateViewport || undefined} data-timeline-domain - aria-hidden="true" style={projectedDomainStyle} > {model.spans @@ -476,34 +609,51 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ .map((span) => { const left = (span.start - model.start) / fullDuration const width = (span.end - span.start) / fullDuration - const durationMs = durationByIndex.get(span.index) + const widthPercent = Math.max(width * 100, 0.35) + const detail = detailByIndex.get(span.index) + const ttftMs = detail?.ttftMs + const decodingMs = detail?.decodingMs + const ttftFraction = ttftMs === undefined + || decodingMs === undefined + || ttftMs + decodingMs <= 0 + ? null + : ttftMs / (ttftMs + decodingMs) return ( - = activeRange.start - ? 'true' - : 'false'} + + label={timelineTooltipLabel(span.kind, detail)} + side="bottom" + delayMs={TIMELINE_TOOLTIP_DELAY_MS} + > + ) })}
diff --git a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx index d62074a26c..9ba75abaac 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx @@ -459,7 +459,7 @@ export function TrajectoryView({ } return ( -
+
{ diff --git a/packages/client/ui-trajectory/src/client/views.module.css b/packages/client/ui-trajectory/src/client/views.module.css index 326ac41a99..687f4a4657 100644 --- a/packages/client/ui-trajectory/src/client/views.module.css +++ b/packages/client/ui-trajectory/src/client/views.module.css @@ -13,19 +13,6 @@ background: var(--dsw-alias-bg-layer-1); } -/* Trajectory keeps the ledger and details panel inside the remaining - * conversation height. Only the ledger pane scrolls; the composer remains - * the fixed flex sibling below this view. */ -:global([data-conversation-scroll]):has(.root) { - overflow: hidden; -} - -:global([data-conversation-scroll]):has(.root) > :first-child { - flex: 1 1 0; - min-height: 0; - overflow: hidden; -} - .ledger { position: relative; z-index: 0; @@ -35,4 +22,7 @@ min-height: 0; min-width: 0; overflow: hidden; + + /* ConversationRoot publishes the floating composer's live height. */ + --dsh-trajectory-bottom-clearance: calc(var(--dsh-composer-height, 152px) + 16px); } diff --git a/packages/client/ui-trajectory/tests/table.spec.tsx b/packages/client/ui-trajectory/tests/table.spec.tsx index f02eb6e0cc..b9f5e5d7b7 100644 --- a/packages/client/ui-trajectory/tests/table.spec.tsx +++ b/packages/client/ui-trajectory/tests/table.spec.tsx @@ -64,7 +64,7 @@ describe('TrajectoryTable', () => { it('shows assistant timing facts after keyboard selection', () => { render() fireEvent.keyDown(screen.getByRole('row', { name: /ASSISTANT/ }), { key: 'Enter' }) - fireEvent.click(screen.getByRole('button', { name: 'Timing' })) + fireEvent.click(screen.getByRole('button', { name: 'Request Timing' })) expect(screen.getByText('500 ms')).toBeTruthy() expect(screen.getByText('1.00 s')).toBeTruthy() @@ -101,10 +101,13 @@ describe('TrajectoryTable', () => { render() fireEvent.click(screen.getByRole('row', { name: /ASSISTANT/ })) - const toggle = screen.getByRole('button', { name: 'Thinking ...' }) + const toggle = screen.getByRole('button', { name: 'Thinking' }) + expect(toggle.getAttribute('aria-expanded')).toBe('false') expect(screen.queryByText(thinking)).toBeNull() fireEvent.click(toggle) + expect(screen.getByRole('button', { name: 'Thinking' })).toBe(toggle) + expect(toggle.getAttribute('aria-expanded')).toBe('true') expect(toggle.parentElement?.textContent?.length).toBeGreaterThan(thinking.length) }) @@ -160,6 +163,53 @@ describe('TrajectoryTable', () => { expect(onClearSelection).toHaveBeenCalledOnce() }) + it('follows appended records only while the ledger is already at the bottom', () => { + const view = render() + const tablePane = screen.getByRole('table').parentElement as HTMLElement + let scrollHeight = 200 + Object.defineProperties(tablePane, { + clientHeight: { configurable: true, get: () => 100 }, + scrollHeight: { configurable: true, get: () => scrollHeight }, + }) + tablePane.scrollTop = 100 + fireEvent.scroll(tablePane) + + scrollHeight = 260 + view.rerender( + , + ) + expect(tablePane.scrollTop).toBe(260) + + tablePane.scrollTop = 20 + fireEvent.scroll(tablePane) + scrollHeight = 320 + view.rerender( + , + ) + expect(tablePane.scrollTop).toBe(20) + }) + it('keeps running and failure semantics distinct from record roles', () => { const view = render() expect(view.container.querySelector('tr[data-kind="tool"][data-running="true"]')).toBeTruthy() @@ -175,19 +225,79 @@ describe('TrajectoryTable', () => { expect(errorResult.closest('[class*="errorPayload"]')).toBeTruthy() }) - it('renders responsive role icons with a custom tooltip', () => { + it('marks failed requests and lays coincident request markers left to right', () => { + const turns: readonly TrajectoryTurnModel[] = [ + { + turn: 1, + groups: [{ + title: 'Step 1', + cells: [{ + index: 1, + kind: 'message', + text: '', + requestOnly: true, + isError: true, + timeSeconds: 0.1, + }], + }], + }, + { + turn: 2, + groups: [{ + title: 'Step 1', + cells: [{ + index: 2, + kind: 'message', + text: '', + requestOnly: true, + isError: true, + timeSeconds: 0.1, + }], + }], + }, + { + turn: 3, + groups: [{ + title: 'Step 1', + cells: [{ + index: 3, + kind: 'message', + text: 'Recovered response', + timeSeconds: 0.1, + }], + }], + }, + ] + render() + + const failed = screen.getByRole('button', { name: 'Request #1' }) + const retry = screen.getByRole('button', { name: 'Request #2' }) + const recovered = screen.getByRole('button', { name: 'Request #3' }) + expect(failed.getAttribute('data-request-status')).toBe('error') + expect(failed.getAttribute('data-request-run-index')).toBe('0') + expect(failed.style.getPropertyValue('--request-boundary-offset')).toBe('0px') + expect(retry.getAttribute('data-request-run-index')).toBe('1') + expect(retry.style.getPropertyValue('--request-boundary-offset')).toBe('8px') + expect(recovered.getAttribute('data-request-run-index')).toBe('2') + expect(recovered.style.getPropertyValue('--request-boundary-offset')).toBe('16px') + }) + + it('shows the custom role tooltip only from the responsive icon', () => { const view = render() const toolTag = view.container.querySelector('[data-role-kind="tool"]') + const toolIcon = toolTag?.querySelector('[data-role-icon="wrench"]') expect(toolTag).not.toBeNull() expect(toolTag?.getAttribute('title')).toBeNull() - expect(toolTag?.querySelector('[data-role-icon="wrench"]')).toBeTruthy() + expect(toolIcon).toBeTruthy() fireEvent.mouseEnter(toolTag as HTMLElement) + expect(screen.queryByRole('tooltip')).toBeNull() + fireEvent.mouseEnter(toolIcon as HTMLElement) const tooltip = screen.getByRole('tooltip') expect(tooltip.textContent).toBe('TOOL') expect(tooltip.getAttribute('data-side')).toBe('right') - fireEvent.mouseLeave(toolTag as HTMLElement) + fireEvent.mouseLeave(toolIcon as HTMLElement) expect(screen.queryByRole('tooltip')).toBeNull() }) diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index fca6608950..0650e28746 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -9,7 +9,7 @@ */ import { Context } from 'cordis' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { createElement, type ComponentProps, type FC, type ReactNode } from 'react' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' @@ -288,6 +288,7 @@ describe('tab switching in ConversationRoot', () => { expect(screen.queryByRole('columnheader')).toBeNull() expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy() expect(screen.getByRole('region', { name: 'Trajectory timeline' })).toBeTruthy() + expect(view.container.querySelector('[data-conversation-composer-overlay]')).toBeTruthy() fireEvent.click(screen.getByRole('button', { name: 'Collapse turns' })) expect(view.container.querySelector('[data-collapsed-summary="turn"]')).toBeTruthy() fireEvent.click(screen.getByRole('button', { name: 'Expand turns' })) @@ -445,7 +446,7 @@ describe('tab switching in ConversationRoot', () => { .toBe('outside') fireEvent.contextMenu(plot) expect(screen.getByRole('row', { name: /USER/ }).getAttribute('data-timeline-focus')) - .toBeNull() + .toBe('outside') }) it('clicking a timeline block clears the range, selects the record, and opens its inspector', async () => { @@ -528,6 +529,57 @@ describe('timeline projection', () => { }], }] satisfies readonly TrajectoryTurnModel[] + it('splits assistant time into recorded TTFT and decoding proportions with a delayed tooltip', () => { + vi.useFakeTimers() + try { + const view = render( + , + ) + const span = view.container.querySelector( + '[data-timeline-span="message"]', + ) + expect(span?.getAttribute('title')).toBeNull() + expect(span?.getAttribute('data-assistant-timing')).toBe('true') + expect(span?.style.getPropertyValue('--trajectory-assistant-ttft')).toBe('25%') + + fireEvent.mouseEnter(span as HTMLElement) + act(() => { vi.advanceTimersByTime(499) }) + expect(view.container.querySelector('[role="tooltip"]')).toBeNull() + act(() => { vi.advanceTimersByTime(1) }) + const tooltip = view.container.querySelector('[role="tooltip"]') + expect(tooltip?.textContent).toContain('Total 2.0 s') + expect(tooltip?.textContent).toContain('TTFT 500 ms') + expect(tooltip?.textContent).toContain('Decoding 1.5 s') + } finally { + vi.useRealTimers() + } + }) + it('cancels native scrolling across the timeline while zooming', () => { render( { })).toBe(false) }) + it('scales sequence gutters with narrow operation spans', () => { + const view = render( + , + ) + const span = view.container.querySelector('[data-timeline-span]') + expect(span?.style.getPropertyValue('--trajectory-span-width')).toBe('10%') + expect(span?.style.getPropertyValue('--trajectory-span-gap')) + .toBe('clamp(0.25px, 0.8%, 1px)') + }) + + it('clears the selection without changing zoom on a zoomed right click', () => { + const onRangeChange = vi.fn() + const view = render( + , + ) + const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events') + vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({ + x: 0, y: 0, left: 0, top: 0, right: 100, bottom: 72, width: 100, height: 72, + toJSON: () => ({}), + }) + fireEvent.wheel(plot, { clientX: 50, deltaY: -1_000 }) + const domain = view.container.querySelector('[data-timeline-domain]') + const domainWidth = domain?.style.getPropertyValue('--trajectory-domain-width') + expect(domainWidth).not.toBe('100%') + + fireEvent.pointerDown(plot, { button: 2, clientX: 50, pointerId: 1 }) + expect(fireEvent.contextMenu(plot)).toBe(false) + fireEvent.pointerUp(plot, { button: 2, clientX: 50, pointerId: 1 }) + + expect(onRangeChange).toHaveBeenCalledOnce() + expect(onRangeChange).toHaveBeenCalledWith(null) + expect(domain?.style.getPropertyValue('--trajectory-domain-width')).toBe(domainWidth) + }) + + it('clears the selection and suppresses the context menu at full zoom', () => { + const onRangeChange = vi.fn() + render( + , + ) + const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events') + + fireEvent.pointerDown(plot, { button: 2, clientX: 50, pointerId: 1 }) + expect(fireEvent.contextMenu(plot)).toBe(false) + fireEvent.pointerUp(plot, { button: 2, clientX: 50, pointerId: 1 }) + expect(onRangeChange).toHaveBeenCalledOnce() + expect(onRangeChange).toHaveBeenCalledWith(null) + }) + + it('pans the zoomed viewport with a right-button drag without changing the selection', () => { + const onRangeChange = vi.fn() + const view = render( + , + ) + const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events') + vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({ + x: 0, y: 0, left: 0, top: 0, right: 100, bottom: 72, width: 100, height: 72, + toJSON: () => ({}), + }) + fireEvent.wheel(plot, { clientX: 50, deltaY: -1_000 }) + const domain = view.container.querySelector('[data-timeline-domain]') + const before = domain?.style.getPropertyValue('--trajectory-domain-left') + + fireEvent.pointerDown(plot, { button: 2, clientX: 50, pointerId: 1 }) + expect(plot.getAttribute('data-panning')).toBe('true') + expect(fireEvent.contextMenu(plot)).toBe(false) + fireEvent.pointerMove(plot, { buttons: 2, clientX: 75, pointerId: 1 }) + fireEvent.pointerUp(plot, { button: 2, clientX: 75, pointerId: 1 }) + + expect(domain?.style.getPropertyValue('--trajectory-domain-left')).not.toBe(before) + expect(onRangeChange).not.toHaveBeenCalled() + expect(plot.getAttribute('data-panning')).toBeNull() + }) + it('pans the zoomed viewport only far enough to reveal a newly selected record', async () => { const onRangeChange = vi.fn() const view = render( diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 4023983d6d..374efd0f58 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md -README.md: 2670bdfa2fb1a223bf0c0ea65fbacc1cfb30c607 -README.zh.md: 1e68cfb1a94c240c32949059ca6a3c6adc25208b +README.md: 17105f9d70ab5fa0c0472c4b3fb39b759107f469 +README.zh.md: b40b9469271e539501a8f6fc0b70a84f8961f7ab diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index 2670bdfa2f..17105f9d70 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -12,6 +12,8 @@ Workspace and Session hover cards copy the value their row clips: activating a W The Session row's Fork action forks at the source's last completed turn, increments the inherited persisted title on the client, and then opens the child; a trailing ASCII or fullwidth parenthesized number is incremented in the same style, while an unnumbered title gets ` (1)` appended. The source and child always appear as peer rows within a workspace group, with lineage retained only as session data. A fork or rename failure leaves the current selection unchanged; after a rename failure, the created child remains in the list. +Session rows distinguish the runtime's live `waitingApproval` approval-request fact from an otherwise blue in-flight Session: an amber warning dot takes precedence over the running indicator, and the hover card reports **Waiting for approval** until the request is resolved. Every lit state carries a visually hidden label (`Waiting for approval` or `Running`) for assistive technology; an idle row leaves the reserved status slot empty. Question waits do not set a list-level status bit such as `waitingApproval`. + Both target slots are declared by other plugins, so `apply` registers through declaration-aware deferral and re-registers after a declaring slot is restored. The shared sidebar projection hides rows whose durable Session summary has `origin: 'subagent'`; users enter those conversations through the selected parent's subagent header catalog. Ordinary forks remain visible because lineage alone does not set that origin. The runtime keeps hidden rows available for conversation, title, and addressed transport state. @@ -28,4 +30,5 @@ None; this package neither assembles nor sends a provider request. - **No fuzzy content search or event deep links** — the content backend uses literal token/phrase matching, and selecting a result opens the Session rather than the matching event. - **No Session deletion or unarchive control** — archiving replaces the former Delete placeholder; archived sessions have no viewing or unarchive surface yet, and Workspace registration deletion does not delete Sessions. +- **Approval waiting is not aggregated into collapsed groups** — a waiting row inside a collapsed group lights no group-header indicator and becomes visible only after that group is expanded. - **Native folder selection depends on the local Host carrier** — under the `-native` composition, fixture-only or remote browser deployments cannot open a local operating-system dialog; platform failures are shown in a retryable modal. Remote-capable picking is the `-browse` composition's in-app flow. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index 1e68cfb1a9..b40b946927 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -12,6 +12,8 @@ Workspace 和 Session 悬浮卡片会复制对应行被截断的值:激活 Wor Session 行内的 Fork 操作在源会话最后一个已完成轮次处 fork,在 client 端递增继承的持久化标题后再打开子会话;尾部半角或全角括号编号会原样式递增,无编号标题追加 ` (1)`。源会话与子会话在 workspace 组内始终作为同级行展示,谱系只保留为 session 数据。Fork 或改名失败都不会改变当前选中项,改名失败时已创建的子会话仍会留在列表中。 +Session 行会把 runtime 的实时 `waitingApproval` 审批请求状态与原本显示为蓝色的进行中 Session 区分开:琥珀色警告点优先于运行指示器,hover 卡片则在请求解决前显示**等待审批**。每种点亮状态都带有面向辅助技术的视觉隐藏标签(等待审批或进行中,随词典本地化);空闲行会保留空的状态槽位。问题等待不会设置如 `waitingApproval` 这样的列表级状态位。 + 两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。 共享侧边栏投影会隐藏持久化 Session 摘要中带有 `origin: 'subagent'` 的行;用户从所选 parent 的 subagent 页头目录进入这些对话。普通 fork 仍然可见,因为仅有谱系不会设置该 origin。运行时仍保留隐藏行,供对话、标题与已寻址传输状态使用。 @@ -28,4 +30,5 @@ Session 行内的 Fork 操作在源会话最后一个已完成轮次处 fork, - **没有模糊内容搜索或事件深链接**:内容后端采用字面 token/短语匹配,选择结果会打开 Session,而不是匹配的事件。 - **没有 Session 删除与取消归档控件**:归档取代了原先的 Delete 占位;已归档会话尚无查看或取消归档入口;删除 Workspace 注册记录不会删除 Session。 +- **待审批状态不会聚合到折叠的分组上**:折叠分组内正在等待的行不会点亮分组头指示,只有展开该分组后才可见。 - **原生文件夹选择依赖本地 Host 载体**:在 `-native` 组合下,仅使用 fixture(测试前置数据)的部署或远程浏览器部署无法打开本地操作系统对话框;模态框会显示平台故障,并允许重试。可远程的选取是 `-browse` 组合的应用内流程。 diff --git a/packages/client/ui-workspace/src/client/locales.ts b/packages/client/ui-workspace/src/client/locales.ts index 3368f7b3e7..e64127a70d 100644 --- a/packages/client/ui-workspace/src/client/locales.ts +++ b/packages/client/ui-workspace/src/client/locales.ts @@ -46,6 +46,7 @@ export const zh = { 'actions.newSession.aria': '在“{name}”中新建会话', 'status.running': '进行中', 'status.idle': '空闲', + 'status.waitingApproval': '等待审批', 'hover.created': '创建于 {time}', 'hover.copied': '已复制', 'date.ymd': '{y}年{m}月{d}日', @@ -103,6 +104,7 @@ export const en = { 'actions.newSession.aria': 'New session in {name}', 'status.running': 'Running', 'status.idle': 'Idle', + 'status.waitingApproval': 'Waiting for approval', 'hover.created': 'Created {time}', 'hover.copied': 'Copied', 'date.ymd': '{y}-{m}-{d}', diff --git a/packages/client/ui-workspace/src/client/rows/Rows.module.css b/packages/client/ui-workspace/src/client/rows/Rows.module.css index ccbbabb9ce..612eb3e306 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.module.css +++ b/packages/client/ui-workspace/src/client/rows/Rows.module.css @@ -124,6 +124,15 @@ color: var(--dsw-alias-label-tertiary); } +.visuallyHidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} + .folderActive { color: var(--dsw-alias-state-business-primary); diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index 27a105df30..e961919498 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -8,10 +8,11 @@ import { useState } from 'react' import clsx from 'clsx' import { - HoverCard, IconBranchOutline16, IconDownloadOutline16, IconEditOutline16, + HoverCard, IconArchiveOutline20, IconBranchOutline16, IconEditOutline16, IconEllipsisOutline16, IconFolderClose16, IconFolderOpen16, IconPlusOutline16, IconTrashOutline16, IconTriangleRightFill14, Menu, StateDot, } from '@deepseek-ai/dsh-client-ui-primitives' +import type { StateDotState } from '@deepseek-ai/dsh-client-ui-primitives' import type { WorkspaceBrowserProps } from '../contract/slots.ts' import type { GroupNode, SearchResultNode, SessionNode } from '../tree.ts' import { relativeTime } from '../tree.ts' @@ -165,16 +166,16 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions, t }: { ) } -/** - * One top-level 34px session row with running dot and relative time. - * @param props.node - derived session node. - * @param props.currentId - selected session id (row highlight). - * @param props.now - epoch ms for relative-time formatting. - * @param props.onOpen - open a session by id. - * @returns the session row. - */ -/** Hover-card body: full title, relative time, and the status line (running/idle until wire status lands). */ +/** Session status presentation; approval waiting outranks the underlying running state. */ +function sessionStatus(node: SessionNode, t: RowTranslate): { state: StateDotState; label: string } { + if (node.waitingApproval) return { state: 'warning', label: t('status.waitingApproval') } + if (node.running) return { state: 'ongoing', label: t('status.running') } + return { state: 'done', label: t('status.idle') } +} + +/** Hover-card body: full title, relative time, and approval/running/idle status. */ function SessionHoverContent({ node, now, t }: { node: SessionNode; now: number; t: RowTranslate }) { + const status = sessionStatus(node, t) return (
{displayTitle(node, t)}
@@ -182,8 +183,8 @@ function SessionHoverContent({ node, now, t }: { node: SessionNode; now: number; before the first prompt. */} {!node.blank &&
{hoverTimeLabel(node.updatedAt, now, t)}
}
- - {node.running ? t('status.running') : t('status.idle')} + + {status.label}
) @@ -248,6 +249,20 @@ function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' | return e.clientY < rect.top + rect.height / 2 ? 'before' : 'after' } +/** + * One top-level 34px session row: status dot (approval waiting outranks + * running), title, relative time, and the row actions menu. + * @param props.node - derived session node. + * @param props.currentId - selected session id (row highlight). + * @param props.now - epoch ms for relative-time formatting. + * @param props.onOpen - open a session by id. + * @param props.onRename - open the session rename dialog (id + current title). + * @param props.onFork - fork a session at its last completed turn. + * @param props.onArchive - archive a session by id. + * @param props.drag - optional draggable-row wiring. + * @param props.t - the browser root's locale seat. + * @returns the session row. + */ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork, onArchive, drag, t }: { node: SessionNode currentId: string | undefined @@ -266,6 +281,7 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork const row = node const title = displayTitle(node, t) const selected = node.id === currentId + const status = sessionStatus(node, t) const [menuOpen, setMenuOpen] = useState(false) // Archive replaces the former Delete placeholder: it hides the row through // the registry-global archive set and never touches the session log, so it @@ -273,7 +289,8 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork const sessionMenuItems = [ { id: 'rename', label: t('rename'), icon: }, { id: 'fork', label: t('menu.fork'), icon: }, - { id: 'archive', label: t('menu.archiveSession'), icon: }, + // 20-native glyph in the menu's 16px icon slot (Menu.module.css .itemIcon). + { id: 'archive', label: t('menu.archiveSession'), icon: }, ] // Figma session cell: pad 8, status slot 16, then a 4px title gap. const ownRow = ( @@ -309,7 +326,14 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork drag.drop(rowHalf(e)) }} > - {row.running && } + + {status.state !== 'done' && ( + <> + + {status.label} + + )} + {title} {/* A blank New Session row is a provisional placeholder: nothing has happened in it yet, so a "now" timestamp and the row verbs diff --git a/packages/client/ui-workspace/src/client/tree.ts b/packages/client/ui-workspace/src/client/tree.ts index 6010a1030e..8c72608eaf 100644 --- a/packages/client/ui-workspace/src/client/tree.ts +++ b/packages/client/ui-workspace/src/client/tree.ts @@ -20,6 +20,8 @@ export interface SessionNode { title: string /** The provisional blank session (renderer shows the localized New Session title). */ blank: boolean + /** The runtime Session list reports a pending approval request for this Session. */ + waitingApproval: boolean running: boolean updatedAt: number } @@ -169,6 +171,7 @@ function sessionNode(s: SessionSummary): SessionNode { id: s.id, title: sessionTitle(s), blank: s.blank, + waitingApproval: s.waitingApproval, running: s.running, updatedAt: s.updatedAt, } diff --git a/packages/client/ui-workspace/tests/rows.spec.tsx b/packages/client/ui-workspace/tests/rows.spec.tsx index 7bef820d4a..1fdfbed0b9 100644 --- a/packages/client/ui-workspace/tests/rows.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.spec.tsx @@ -96,7 +96,7 @@ describe('workspace browser rows', () => { it('renders and opens a selected running Session row', () => { const node: SessionNode = { - id: sid('session'), title: 'Session', blank: false, running: true, updatedAt: 0, + id: sid('session'), title: 'Session', blank: false, waitingApproval: false, running: true, updatedAt: 0, } const onOpen = vi.fn() render( @@ -180,7 +180,7 @@ describe('workspace browser rows', () => { vi.useFakeTimers() try { const node: SessionNode = { - id: sid('s-blank'), title: 'ignored', blank: true, running: false, updatedAt: 0, + id: sid('s-blank'), title: 'ignored', blank: true, waitingApproval: false, running: false, updatedAt: 0, } render() @@ -206,7 +206,7 @@ describe('workspace browser rows', () => { const onFork = vi.fn() const onArchive = vi.fn() const node: SessionNode = { - id: sid('s1'), title: 'One', blank: false, running: false, updatedAt: 0, + id: sid('s1'), title: 'One', blank: false, waitingApproval: false, running: false, updatedAt: 0, } render() @@ -234,11 +234,12 @@ describe('workspace browser rows', () => { expect(screen.queryByRole('menu')).toBeNull() }) + it('shows the hover card after the dwell and suppresses it while the row menu is open', () => { vi.useFakeTimers() try { const node: SessionNode = { - id: sid('s1'), title: 'Hovered', blank: false, running: true, updatedAt: 0, + id: sid('s1'), title: 'Hovered', blank: false, waitingApproval: false, running: true, updatedAt: 0, } render() @@ -248,7 +249,7 @@ describe('workspace browser rows', () => { // Card body: full title + relative time + running status. expect(screen.getAllByText('Hovered')).toHaveLength(2) expect(screen.getByText('1分钟前')).toBeTruthy() - expect(screen.getByText('进行中')).toBeTruthy() + expect(screen.getAllByText('进行中')).toHaveLength(2) fireEvent.pointerLeave(wrapper) // Menu open (disabled=true) suppresses the card for the same hover. fireEvent.click(screen.getByRole('button', { name: '会话“Hovered”的操作' })) @@ -260,11 +261,38 @@ describe('workspace browser rows', () => { } }) + it('shows approval waiting as warning ahead of the running state', () => { + vi.useFakeTimers() + try { + const node: SessionNode = { + id: sid('approval'), title: 'Needs approval', blank: false, + waitingApproval: true, running: true, updatedAt: 0, + } + const view = render() + const row = screen.getByRole('treeitem') + expect(row.querySelector('[data-state="warning"]')).toBeTruthy() + expect(row.querySelector('[data-state="ongoing"]')).toBeNull() + expect(screen.getByText('等待审批')).toBeTruthy() + + view.rerender() + expect(screen.getByRole('treeitem').querySelector('[data-state="warning"]')).toBeTruthy() + + fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.getAllByText('等待审批')).toHaveLength(2) + expect(document.querySelectorAll('[data-state="warning"]')).toHaveLength(2) + } finally { + vi.useRealTimers() + } + }) + it('idle hover card shows the Idle status line', () => { vi.useFakeTimers() try { const node: SessionNode = { - id: sid('s1'), title: 'Quiet', blank: false, running: false, updatedAt: 0, + id: sid('s1'), title: 'Quiet', blank: false, waitingApproval: false, running: false, updatedAt: 0, } render() @@ -279,7 +307,7 @@ describe('workspace browser rows', () => { it('draggable row wires start/end and gates hover/drop on an active same-group drag', () => { const node: SessionNode = { - id: sid('s1'), title: 'Drag me', blank: false, running: false, updatedAt: 0, + id: sid('s1'), title: 'Drag me', blank: false, waitingApproval: false, running: false, updatedAt: 0, } const inactive = dragProps() const { rerender } = render( diff --git a/packages/client/ui-workspace/tests/tree.spec.ts b/packages/client/ui-workspace/tests/tree.spec.ts index 2d0055ce7f..249c3629a2 100644 --- a/packages/client/ui-workspace/tests/tree.spec.ts +++ b/packages/client/ui-workspace/tests/tree.spec.ts @@ -38,6 +38,14 @@ describe('deriveGroups', () => { expect(groups[0]!.sessions.map(session => session.id)).toEqual([sid('older'), sid('newer')]) }) + it('projects approval-waiting state into grouped and flat rows', () => { + const awaiting = { ...summary('awaiting', 10), waitingApproval: true, running: true } + const sessions = list(awaiting) + const grouped = deriveGroups(sessions, [workspace('project', ['awaiting'])], noArchive, view(['project'])) + expect(grouped[0]!.sessions[0]).toMatchObject({ waitingApproval: true, running: true }) + expect(deriveFlat(sessions, noArchive)[0]).toMatchObject({ waitingApproval: true, running: true }) + }) + it('puts only real unaccounted Sessions in the trailing Ungrouped group', () => { const sessions = list(summary('owned', 1, '/projects/first'), summary('loose', 9, '/other')) const groups = deriveGroups(sessions, [workspace('first', ['owned'])], noArchive, view([UNGROUPED_KEY])) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index dffe8a006b..c0c2f56cca 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1607,6 +1607,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'AgentOptions', declaration: 'export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n}', }, + { + name: 'AgentSetup', + declaration: 'export type AgentSetup = (agentCtx: Context) => AgentSetupCommit | Promise | void;', + }, + { + name: 'AgentSetupCommit', + declaration: 'export interface AgentSetupCommit {\n commit(): void;\n}', + }, { name: 'AgentStatus', declaration: 'export type AgentStatus = \'idle\' | \'running\';', @@ -1833,7 +1841,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'CreateAgentOptions', - declaration: 'export interface CreateAgentOptions {\n readonly sessionId: SessionId;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise | void;\n}', + declaration: 'export interface CreateAgentOptions {\n readonly sessionId: SessionId;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: AgentSetup;\n}', }, { name: 'CreateGoalRequest', @@ -2337,7 +2345,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ResumeAgentOptions', - declaration: 'export interface ResumeAgentOptions {\n readonly resumeSessionId: SessionId;\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise | void;\n}', + declaration: 'export interface ResumeAgentOptions {\n readonly resumeSessionId: SessionId;\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: AgentSetup;\n}', }, { name: 'SandboxEnforcement', diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml index 691115619b..81f5271097 100644 --- a/packages/core/agent-loop/README.i18n.yaml +++ b/packages/core/agent-loop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/agent-loop/README.md -README.md: c71b350adfe06a19d4c24cb7e67de895a662bd87 -README.zh.md: d30dfc85e1597a8e193019cc23f4c7c39c991776 +README.md: 2ce85071c4b7408adb4ee05291c499ec642be114 +README.zh.md: bc78c02fc046f3bb5820f89bae5a90b26b5a8ced diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index c71b350adf..2ce85071c4 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -10,7 +10,7 @@ This is the only package in the harness that contains concrete loop logic. Every ### Public API -Creation and resume are one rollback-covered transaction: construct a private session, concrete agent, and scoped context; await optional setup; enter both registries; announce `session/created` then `agent/created`; emit `agent/session-start`; and only then start the driver. Setup receives the full scoped `Context` as trusted same-process composition code and must not drive the unpublished agent. Ordinary typed identity and option inputs are borrowed under their readonly contract, while seed events and session metadata are validated and snapshotted because they cross the durable session boundary. An optional `AbortSignal` cancels only load/setup/publication and is detached before the returned handle becomes visible. +Creation and resume are one rollback-covered transaction: construct a private session, concrete agent, and scoped context; await optional setup; synchronously invoke its optional publication commit; enter both registries; announce `session/created` then `agent/created`; emit `agent/session-start`; and only then start the driver. Setup receives the full scoped `Context` as trusted same-process composition code and must not drive the unpublished agent. Its optional commit revalidates mutable provisioning after every setup await and immediately before registry entry; a throw rolls the private transaction back without publishing either id. Ordinary typed identity and option inputs are borrowed under their readonly contract, while seed events and session metadata are validated and snapshotted because they cross the durable session boundary. An optional `AbortSignal` cancels only load/setup/publication and is detached before the returned handle becomes visible. The caller fiber and the AgentLoop provider are co-owners. `AgentFactory.createAgent(ownerCtx, options)` and `resume(ownerCtx, options)` receive caller ownership explicitly, while the factory keeps its own dependency context for `sessions`/`llm`/`tools`/`systemPrompt`; this lets a caller inject only `agents` without shrinking the new agent's service surface. Caller unload, handle disposal, or provider unload converge on one memoized quiescence boundary. Provider shutdown waits both resource teardown and the public create/resume wrapper that observed deactivation, so no continuation can publish after dependencies disappear. @@ -20,8 +20,8 @@ Each agent and its session share one caller-chosen `SessionId`, assumed globally `AgentLoop` also implements the `AgentFactory` seam and registers itself via `ctx.agents.setFactory(this)`, so plugins create/resume agents through `ctx.agents` (the interface): -- `ctx.agents.create({ sessionId, meta?, seed?, agentOptions?, setup?, signal? }): Promise` — programmatic create under the caller-supplied shared id. It awaits the unpublished setup transaction before returning; `meta` carries cwd/lineage/seed-boundary metadata and `seed` reconstructs a forked child prefix after the session boundary validates and snapshots the durable values. `signal` applies only until this promise settles. The resolved [`AgentHandle`](../agent/README.md) owns exact teardown. -- `ctx.agents.resume({ resumeSessionId, agentOptions?, setup?, signal? }): Promise` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)), register the agent under that same id, reconstruct its history, then await setup against a fresh unpublished agent scope before rollback-covered publication. Turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work; `resume` rejects with a clear error when persistence is absent). `signal` is creation-only. Returns an `AgentHandle`. +- `ctx.agents.create({ sessionId, meta?, seed?, agentOptions?, setup?, signal? }): Promise` — programmatic create under the caller-supplied shared id. It awaits unpublished setup, invokes its optional synchronous commit at the publication boundary, and then enters both registries; `meta` carries cwd/lineage/seed-boundary metadata and `seed` reconstructs a forked child prefix after the session boundary validates and snapshots the durable values. `signal` applies only until this promise settles. The resolved [`AgentHandle`](../agent/README.md) owns exact teardown. +- `ctx.agents.resume({ resumeSessionId, agentOptions?, setup?, signal? }): Promise` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)), reconstruct its history under the same id, await setup against a fresh unpublished agent scope, invoke its optional synchronous commit, then use the same rollback-covered publication sequence. Turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work; `resume` rejects with a clear error when persistence is absent). `signal` is creation-only. Returns an `AgentHandle`. The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loop fiber (it discards the handle). For a programmatic agent, the handle holder is the only consumer-facing teardown capability; AgentLoop provider unload is the independent structural teardown edge, not another handle exposed to application code. diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md index d30dfc85e1..bc78c02fc0 100644 --- a/packages/core/agent-loop/README.zh.md +++ b/packages/core/agent-loop/README.zh.md @@ -10,7 +10,7 @@ ### 公开 API -创建与恢复属于同一个受回滚保护的事务:构造私有会话、实体 agent 和带作用域的上下文;等待可选 setup;进入两个注册表;依次宣告 `session/created` 和 `agent/created`;发出 `agent/session-start`;此后才启动驱动器。Setup 接收完整的带作用域 `Context`,作为受信任的同进程组合代码,并且不得驱动尚未发布的 agent。普通的类型化身份与选项输入遵循只读契约以借用方式传入;seed 事件与会话元数据会跨越持久会话边界,因此系统会验证并快照它们。可选的 `AbortSignal` 只取消加载/setup/发布,并在返回的 handle 可见前分离。 +创建与恢复属于同一个受回滚保护的事务:构造私有会话、实体 agent 和带作用域的上下文;等待可选 setup;同步调用其可选的发布提交;进入两个注册表;依次宣告 `session/created` 和 `agent/created`;发出 `agent/session-start`;此后才启动驱动器。Setup 接收完整的带作用域 `Context`,作为受信任的同进程组合代码,并且不得驱动尚未发布的 agent。其可选提交会在所有 setup 的 await 均结算后、进入注册表之前立即重新校验可变的配置状态;若其抛出异常,则回滚私有事务且不发布任何一个 id。普通的类型化身份与选项输入遵循只读契约以借用方式传入;seed 事件与会话元数据会跨越持久会话边界,因此系统会验证并快照它们。可选的 `AbortSignal` 只取消加载/setup/发布,并在返回的 handle 可见前分离。 调用方 fiber 与 AgentLoop 提供方共同拥有 agent。`AgentFactory.createAgent(ownerCtx, options)` 与 `resume(ownerCtx, options)` 显式接收调用方所有权,而工厂为 `sessions`/`llm`/`tools`/`systemPrompt` 保留自身的依赖上下文;这样,调用方可以只注入 `agents`,而不会缩减新 agent 的服务接口。调用方卸载、handle dispose(资源释放)或提供方卸载都会汇合到同一个记忆化的完全停稳边界。提供方关闭会同时等待资源 teardown,以及已经观测到停用的公开 create/resume 包装层,因此依赖消失后,任何 continuation 都无法继续发布。 @@ -20,8 +20,8 @@ `AgentLoop` 还实现 `AgentFactory` seam,并通过 `ctx.agents.setFactory(this)` 注册自身,因此插件会通过接口 `ctx.agents` 创建/恢复 agent: -- `ctx.agents.create({ sessionId, meta?, seed?, agentOptions?, setup?, signal? }): Promise`:使用调用方提供的共享 id 以编程方式创建。它会等待尚未发布的 setup 事务,然后才返回;`meta` 携带 cwd/谱系/seed 边界元数据,`seed` 则在会话边界验证并快照持久值后,重建 fork 子级的前缀。`signal` 只在此 Promise 结算前生效。解析得到的 [`AgentHandle`](../agent/README.md) 拥有确切的 teardown。 -- `ctx.agents.resume({ resumeSessionId, agentOptions?, setup?, signal? }): Promise`:通过 `ctx.sessionPersistence` 加载持久化会话(参见[会话持久化](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)),使用同一 id 注册 agent,重建历史,然后针对全新且尚未发布的 agent 作用域等待 setup,再执行受回滚保护的发布。轮次编号和派生历史从已加载日志继续。此操作要求存在会话持久化后端(不会硬注入,因此非持久化 demo 仍能工作;缺少持久化时,`resume` 会以明确错误拒绝)。`signal` 仅用于创建。返回 `AgentHandle`。 +- `ctx.agents.create({ sessionId, meta?, seed?, agentOptions?, setup?, signal? }): Promise`:使用调用方提供的共享 id 以编程方式创建。它会等待尚未发布的 setup,在发布边界调用其可选的同步提交,然后进入两个注册表;`meta` 携带 cwd/谱系/seed 边界元数据,`seed` 则在会话边界验证并快照持久值后,重建 fork 子级的前缀。`signal` 只在此 Promise 结算前生效。解析得到的 [`AgentHandle`](../agent/README.md) 拥有确切的 teardown。 +- `ctx.agents.resume({ resumeSessionId, agentOptions?, setup?, signal? }): Promise`:通过 `ctx.sessionPersistence` 加载持久化会话(参见[会话持久化](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)),在同一 id 下重建历史,针对全新且尚未发布的 agent 作用域等待 setup,调用其可选的同步提交,然后使用相同的受回滚保护发布序列。轮次编号和派生历史从已加载日志继续。此操作要求存在会话持久化后端(不会硬注入,因此非持久化 demo 仍能工作;缺少持久化时,`resume` 会以明确错误拒绝)。`signal` 仅用于创建。返回 `AgentHandle`。 配置驱动的 `ctx.agentLoop.create()` 路径让循环 fiber 拥有其 agent(该路径会丢弃 handle)。对于以编程方式创建的 agent,handle 持有者是唯一面向消费方的 teardown 能力;AgentLoop 提供方卸载是一条独立的结构化 teardown 边,而不是向应用代码公开的另一个 handle。 diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 1342fbd885..54b8f5c957 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -558,7 +558,10 @@ export class AgentLoop extends Service implements AgentFactory { const prepared = this.prepare(ownerCtx, options.sessionId, options.agentOptions ?? {}, session, options.signal) const published = (async () => { try { - await raceAbort(options.setup?.(prepared.agent.ctx), prepared.signal, options.sessionId) + const setupCommit = await raceAbort( + options.setup?.(prepared.agent.ctx), prepared.signal, options.sessionId, + ) + setupCommit?.commit() return prepared.publish('startup') } catch (error: unknown) { await prepared.dispose() @@ -617,7 +620,8 @@ export class AgentLoop extends Service implements AgentFactory { }) const prepared = this.prepare(ownerCtx, id, options.agentOptions ?? {}, session, options.signal) try { - await raceAbort(options.setup?.(prepared.agent.ctx), prepared.signal, id) + const setupCommit = await raceAbort(options.setup?.(prepared.agent.ctx), prepared.signal, id) + setupCommit?.commit() return prepared.publish('resume') } catch (error: unknown) { await prepared.dispose() diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index 7844aebd73..0bb7ce7d2e 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -291,6 +291,13 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', setupStarted.resolve(undefined) await gate.promise order.push('setup:end') + return { + commit: () => { + expect(ctx.agents.get(sessionId)).toBeUndefined() + expect(ctx.sessions.get(sessionId)).toBeUndefined() + order.push('setup:commit') + }, + } }, }) @@ -304,6 +311,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', expect(order).toEqual([ 'setup:start', 'setup:end', + 'setup:commit', 'session/created', 'setup-listener:session/created', 'agent/created', @@ -359,6 +367,33 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', await ctx.fiber.dispose() }) + it('resume setup commit rejection publishes nothing and releases the identity', async () => { + const sessionId = SessionId('resume-setup-commit-reject') + const root = await persistSession(sessionId) + const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')])) + const published: string[] = [] + ctx.on('session/created', () => void published.push('session/created')) + ctx.on('agent/created', () => void published.push('agent/created')) + + await expect(ctx.agents.resume({ + resumeSessionId: sessionId, + agentOptions: { provider: 'mock', model: 'mock' }, + setup: () => ({ + commit: () => { throw new Error('resume setup commit failed') }, + }), + })).rejects.toThrow('resume setup commit failed') + + expect(published).toEqual([]) + expect(ctx.agents.get(sessionId)).toBeUndefined() + expect(ctx.sessions.get(sessionId)).toBeUndefined() + const retry = await ctx.agents.resume({ + resumeSessionId: sessionId, + agentOptions: { provider: 'mock', model: 'mock' }, + }) + await retry.dispose() + await ctx.fiber.dispose() + }) + it('owner unload aborts resume setup and cannot publish after the callback settles', async () => { const sessionId = SessionId('resume-setup-owner-unload') const root = await persistSession(sessionId) diff --git a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts index 5f4e51784a..2420d20e8f 100644 --- a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts @@ -264,6 +264,13 @@ describe('agent scope lifecycle', () => { setupStarted.resolve(undefined) await gate.promise order.push('setup:end') + return { + commit: () => { + expect(ctx.agents.get(SessionId('atomic'))).toBeUndefined() + expect(ctx.sessions.get(SessionId('atomic'))).toBeUndefined() + order.push('setup:commit') + }, + } }, }) await setupStarted.promise @@ -276,6 +283,7 @@ describe('agent scope lifecycle', () => { expect(order).toEqual([ 'setup:start', 'setup:end', + 'setup:commit', 'session/created', 'setup-listener:session/created', 'agent/created', diff --git a/packages/core/agent/README.i18n.yaml b/packages/core/agent/README.i18n.yaml index f8431cf022..8673595322 100644 --- a/packages/core/agent/README.i18n.yaml +++ b/packages/core/agent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/agent/README.md -README.md: 4c6a6dd95541cfa559e95858fede01d7cd76637f -README.zh.md: 07bf887c557b410005bbe0fa1a988e63a765ad99 +README.md: 98421aa6de3d6778702665854ed723507e933028 +README.zh.md: bfc8d68a9656a29a809de0848986e4ee9eb3fe7c diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 4c6a6dd955..98421aa6de 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -12,7 +12,7 @@ Tracks live agents and carries the initiating Agent through asynchronous driver ### Public API -The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher for ordinary agent-subject operations (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. The registry lifecycle pair reuses one stable routing carrier. `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `installAgentLlmTarget(agentCtx, target)` snapshots a mutable provider/model/reasoning-effort selection during prompt assembly, applies the route to prompt variables, and applies the complete target to request routing for one step; an absent selected effort clears an inherited effort so the target uses adapter/provider defaults. `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while both objects remain unpublished. Setup is trusted, composition-only same-process code: drive the agent only after creation resolves. +The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher for ordinary agent-subject operations (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. The registry lifecycle pair reuses one stable routing carrier. `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `installAgentLlmTarget(agentCtx, target)` snapshots a mutable provider/model/reasoning-effort selection during prompt assembly, applies the route to prompt variables, and applies the complete target to request routing for one step; an absent selected effort clears an inherited effort so the target uses adapter/provider defaults. `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while both objects remain unpublished. Setup may return an `AgentSetupCommit`; after every setup await settles, the factory invokes its synchronous `commit()` immediately before registry entry, and a throw rolls the private transaction back without publishing either id. Setup remains trusted, composition-only same-process code: drive the agent only after creation resolves. `AgentOptions` supplies the initial provider/model route and an optional positive `maxTokens` output cap. The concrete loop resolves any exact-model adapter default, records the effective cap in the request header, and applies it to each conversation-model request; an explicit Agent option wins, while omission leaves the adapter or provider route default in control. @@ -39,8 +39,8 @@ The scope carries the `Agent` itself and is process-local. Ambient presence is n Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-agent-loop`), registered via `setFactory`. This keeps creation on the `dsh-agent` interface so consumers (UI, the ACP bridge) program against `ctx.agents` without depending on the concrete loop package. The registry canonicalizes an already traced Service to its concrete target and re-traces each call through the caller's context; this avoids nested Cordis shadows while passing an explicit caller-bound `ownerCtx` to plain factories. - `ctx.agents.setFactory(factory: AgentFactory): () => void` — register the creation factory (the loop calls this on construction). Throws on a second factory; the slot clears on dispose. -- `ctx.agents.create(options: CreateAgentOptions): Promise` — create a session and agent, await optional setup while unpublished, then publish through final `SessionStore.enter()` and `AgentRegistry.enter()` checks. Concurrent same-ID creation is unsupported: more than one operation may prepare, but only one can enter; every loser rolls its private scope/session/driver back. An optional creation-only `signal` cancels unpublished setup and is detached before the handle is returned; later cancellation uses `handle.dispose()` or `agent.cancel()`. Publication is rollback-covered and every delivered creation edge is paired during rollback. Rejects if no factory is registered. -- `ctx.agents.resume(options: ResumeAgentOptions): Promise` — load a persisted session ([session persistence](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)), mint a fresh unpublished agent scope, await optional setup, and use the same final-entry publication sequence. Its optional `signal` is likewise creation-only. Rejects if no factory is registered or session persistence is unconfigured. +- `ctx.agents.create(options: CreateAgentOptions): Promise` — create a session and agent, await optional setup while unpublished, invoke its optional synchronous commit, then publish through final `SessionStore.enter()` and `AgentRegistry.enter()` checks. Concurrent same-ID creation is unsupported: more than one operation may prepare, but only one can enter; every loser rolls its private scope/session/driver back. An optional creation-only `signal` cancels unpublished setup and is detached before the handle is returned; later cancellation uses `handle.dispose()` or `agent.cancel()`. Publication is rollback-covered and every delivered creation edge is paired during rollback. Rejects if no factory is registered. +- `ctx.agents.resume(options: ResumeAgentOptions): Promise` — load a persisted session ([session persistence](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)), mint a fresh unpublished agent scope, await optional setup, invoke its optional synchronous commit, and use the same final-entry publication sequence. Its optional `signal` is likewise creation-only. Rejects if no factory is registered or session persistence is unconfigured. `AgentHandle = { agent: Agent; dispose(): Promise }`. The disposer is a **consumer capability** — no observer holding the bare registry entry can tear the agent down. The caller fiber and the registered factory provider are structural co-owners: caller unload enforces structured ownership, while factory unload must stop old instances because their scoped dependency surface belongs to that provider. `dispose()` from any owner reaches one memoized quiescence boundary: it stops the loop, awaits its exit, unregisters the agent, removes its session from the store, and finally unwinds its scoped world. `ctx.agents.get(id)` still returns a bare `Agent`; the ACP bridge and in-process subagent backends hold consumer handles, while config-created agents are already owned by the loop fiber. diff --git a/packages/core/agent/README.zh.md b/packages/core/agent/README.zh.md index 07bf887c55..bfc8d68a96 100644 --- a/packages/core/agent/README.zh.md +++ b/packages/core/agent/README.zh.md @@ -12,7 +12,7 @@ Agent 接口、注册表、进程本地发起方作用域,以及 `agent/*` 事 ### 公开 API -带作用域的注册接口:`Agent.ctx` 是 agent 的作用域上下文(`dsh-scope`,键 = 该 agent)。通过它注册工具/段/变量/监听器,只对该 agent 生效,并在 dispose(资源释放)时全部撤销。`agentEvents(ctx, agent)` 是普通 agent 主体操作的融合分发器(一次完成载体 + 注入主体);其通知 mode 会调用每个监听器,并同时收容同步抛出和返回 Promise 的拒绝。注册表生命周期对复用一个稳定路由载体。`assembleContextFor(agent)` 构建按 agent 的组装上下文(同时包含 `agent` + `scope`)。`installAgentLlmTarget(agentCtx, target)` 在提示词组装期间快照可变的提供方/模型/推理(reasoning)强度选择,将路由应用到提示词变量,并将完整目标应用到一个步骤的请求路由;如果没有选定推理强度,则会清除继承的推理强度,使该目标使用适配器/提供方默认值。`CreateAgentOptions.setup(agentCtx)` 和 `ResumeAgentOptions.setup(agentCtx)` 在新建或恢复的 agent 尚未发布时,组合其带作用域的世界。Setup 是受信任、仅用于组合的同进程代码:只有创建完成后才能驱动 agent。 +带作用域的注册接口:`Agent.ctx` 是 agent 的作用域上下文(`dsh-scope`,键 = 该 agent)。通过它注册工具/段/变量/监听器,只对该 agent 生效,并在 dispose(资源释放)时全部撤销。`agentEvents(ctx, agent)` 是普通 agent 主体操作的融合分发器(一次完成载体 + 注入主体);其通知 mode 会调用每个监听器,并同时收容同步抛出和返回 Promise 的拒绝。注册表生命周期对复用一个稳定路由载体。`assembleContextFor(agent)` 构建按 agent 的组装上下文(同时包含 `agent` + `scope`)。`installAgentLlmTarget(agentCtx, target)` 在提示词组装期间快照可变的提供方/模型/推理(reasoning)强度选择,将路由应用到提示词变量,并将完整目标应用到一个步骤的请求路由;如果没有选定推理强度,则会清除继承的推理强度,使该目标使用适配器/提供方默认值。`CreateAgentOptions.setup(agentCtx)` 和 `ResumeAgentOptions.setup(agentCtx)` 在新建或恢复的 agent 尚未发布时,组合其带作用域的世界。Setup 可以返回一个 `AgentSetupCommit`;所有 setup 的 await 均结算后,工厂会在进入注册表前立即调用其同步 `commit()`,若其抛出异常,则回滚私有事务且不发布任何一个 id。Setup 仍是受信任、仅用于组合的同进程代码:只有创建完成后才能驱动 agent。 `AgentOptions` 提供初始的提供方/模型路由,以及可选的正数 `maxTokens` 输出上限。实体循环会解析确切模型的适配器默认值,把生效上限记录到请求 header,并应用到每次对话模型请求;显式 Agent 选项优先,省略时由适配器或提供方路由默认值控制。 @@ -39,8 +39,8 @@ Agent 接口、注册表、进程本地发起方作用域,以及 `agent/*` 事 Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供,并通过 `setFactory` 注册。这样,创建功能留在 `dsh-agent` 接口上,消费方(UI、ACP 桥接层)可以面向 `ctx.agents` 编程,而不依赖具体循环包。注册表会把已经 traced 的 Service 规范化为具体目标,并通过调用方上下文重新 trace 每次调用;这既避免嵌套 Cordis shadow,也会把显式、绑定调用方的 `ownerCtx` 传给普通工厂。 - `ctx.agents.setFactory(factory: AgentFactory): () => void`:注册创建工厂(循环在构造时调用)。第二个工厂会导致抛出;dispose 时清空槽位。 -- `ctx.agents.create(options: CreateAgentOptions): Promise`:创建会话和 agent,在不发布的情况下等待可选 setup,然后通过最终的 `SessionStore.enter()` 与 `AgentRegistry.enter()` 检查发布。不支持并发创建同一 ID:多个操作可以进行准备,但只有一个能进入;每个失败方都会回滚其私有作用域/会话/驱动器。可选且只用于创建的 `signal` 会取消未发布的 setup,并在返回 handle 前分离;之后的取消使用 `handle.dispose()` 或 `agent.cancel()`。发布包含在回滚范围内,回滚期间每条已交付创建边都会成对处理。未注册工厂时拒绝。 -- `ctx.agents.resume(options: ResumeAgentOptions): Promise`:加载持久化会话([会话持久化](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)),创建新的未发布 agent 作用域,等待可选 setup,并使用相同的最终进入发布序列。其可选 `signal` 同样只用于创建。未注册工厂或未配置会话持久化时拒绝。 +- `ctx.agents.create(options: CreateAgentOptions): Promise`:创建会话和 agent,在不发布的情况下等待可选 setup,调用其可选的同步提交,然后通过最终的 `SessionStore.enter()` 与 `AgentRegistry.enter()` 检查发布。不支持并发创建同一 ID:多个操作可以进行准备,但只有一个能进入;每个失败方都会回滚其私有作用域/会话/驱动器。可选且只用于创建的 `signal` 会取消未发布的 setup,并在返回 handle 前分离;之后的取消使用 `handle.dispose()` 或 `agent.cancel()`。发布包含在回滚范围内,回滚期间每条已交付创建边都会成对处理。未注册工厂时拒绝。 +- `ctx.agents.resume(options: ResumeAgentOptions): Promise`:加载持久化会话([会话持久化](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)),创建新的未发布 agent 作用域,等待可选 setup,调用其可选的同步提交,并使用相同的最终进入发布序列。其可选 `signal` 同样只用于创建。未注册工厂或未配置会话持久化时拒绝。 `AgentHandle = { agent: Agent; dispose(): Promise }`。Disposer 是一项 **消费方能力**;仅持有裸注册表条目的观察方不能 teardown agent。调用方 fiber 和已注册工厂提供方是结构化共同拥有者:调用方卸载会强制结构化所有权,而工厂卸载必须停止旧实例,因为它们的作用域依赖范围属于该提供方。任意拥有者调用 `dispose()` 都会到达同一个记忆化完全停稳边界:它停止循环,等待循环退出,注销 agent,从存储中移除其会话,最后撤销其作用域世界。`ctx.agents.get(id)` 仍返回裸 `Agent`;ACP 桥接层与进程内 subagent 后端持有消费方 handle,而配置创建的 agent 已由循环 fiber 拥有。 diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index d5c19aae3e..66dee4efb7 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -36,6 +36,27 @@ declare module 'cordis' { } } +/** + * Synchronous finalizer returned by unpublished Agent setup when its + * contributions need validation at the exact publication commit point. + */ +export interface AgentSetupCommit { + /** + * Validate and commit the prepared setup immediately before publication. + * @throws when publication must roll the unpublished Agent back. + */ + commit(): void +} + +/** + * Compose an unpublished Agent scope and optionally return its publication commit. + * @param agentCtx - unpublished Agent scope. + * @returns an optional synchronous commit invoked after setup awaits settle and immediately before publication. + */ +export type AgentSetup = ( + agentCtx: Context, +) => AgentSetupCommit | Promise | void + /** * Options for programmatically creating an agent through the registry factory * ({@link AgentRegistry.create}). The caller supplies the single live @@ -80,17 +101,21 @@ export interface CreateAgentOptions { * Creation-time composition of the agent's scoped world. The factory awaits * setup after minting `agentCtx` but BEFORE inserting or announcing either * the session or agent, so observers can never see a partially configured - * world. Everything registered through `agentCtx` (scoped tools, prompt - * sections/variables, `restrict()`, listeners, awaited child plugins) exists - * before `session/created`, `agent/created`, `agent/session-start`, and the - * first prompt assembly. A throw/rejection or owner disposal rolls the scope - * back without publishing either id. + * world. Setup may return an {@link AgentSetupCommit}; the factory invokes its + * synchronous `commit()` after every setup await settles and immediately + * before registry publication. This lets mutable provisioning revalidate at + * the exact publication boundary. Everything registered through `agentCtx` + * (scoped tools, prompt sections/variables, `restrict()`, listeners, awaited + * child plugins) exists before `session/created`, `agent/created`, + * `agent/session-start`, and the first prompt assembly. A setup + * throw/rejection, commit throw, or owner disposal rolls the scope back + * without publishing either id. * * **Setup composes, it never drives**: the callback is trusted same-process * code and receives the full scoped context, so this is a contract rather * than a runtime restriction. Drive the agent only after creation resolves. */ - readonly setup?: (agentCtx: Context) => Promise | void + readonly setup?: AgentSetup } /** @@ -108,12 +133,12 @@ export interface ResumeAgentOptions { * Resume-time composition of the agent's fresh scoped world. Persistence is * loaded first; the factory then mints `agentCtx` and awaits setup while the * reconstructed session and agent remain unpublished. The callback has the - * same trusted composition-only contract as - * {@link CreateAgentOptions.setup}: all registrations exist before either - * creation announcement, and rejection or owner disposal rolls the - * transaction back without publishing either id. + * same trusted composition-only contract and optional synchronous + * publication commit as {@link CreateAgentOptions.setup}: all registrations + * exist before either creation announcement, and rejection, commit failure, + * or owner disposal rolls the transaction back without publishing either id. */ - readonly setup?: (agentCtx: Context) => Promise | void + readonly setup?: AgentSetup } /** @@ -144,9 +169,9 @@ export interface AgentHandle { export interface AgentFactory { /** * Create a new agent on a caller-supplied session id. Async because creation - * awaits unpublished setup, inserts both session and agent, emits their - * creation notifications in order, emits `agent/session-start`, and only - * then starts the loop. The sequence is + * awaits unpublished setup, invokes its optional synchronous commit, inserts + * both session and agent, emits their creation notifications in order, emits + * `agent/session-start`, and only then starts the loop. The sequence is * rollback-covered, but notifications delivered before a later listener * failure remain observable; every agent or session creation announcement * that began is paired by `agent/disposed` or `session/disposed` during @@ -165,8 +190,8 @@ export interface AgentFactory { * Load a persisted session and resume an agent on it. Async because it awaits * both `ctx.sessionPersistence.load` and the optional unpublished setup * transaction; must be called after that service exists (consumers inject - * `sessionPersistence`). Publication follows the same ordered boundary as - * {@link createAgent}. + * `sessionPersistence`). Publication follows the same setup-commit and + * ordered boundary as {@link createAgent}. * @param ownerCtx - caller-bound context that owns load, setup, and the live handle. * @param options - persisted identity, configuration, and optional setup. * @returns the owned handle after setup, both announcements, and loop start complete. diff --git a/packages/core/tools/README.i18n.yaml b/packages/core/tools/README.i18n.yaml index d26f891f91..8fa82352b8 100644 --- a/packages/core/tools/README.i18n.yaml +++ b/packages/core/tools/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/tools/README.md -README.md: 15fc5839a3b0e3fa2d20c5a9cc50577e9807ffda -README.zh.md: 8547ee4a796dcd93945dfa40373c14c10d7d0c8a +README.md: 80ea3cc93437d48a7ea0ffba0ff4d2ef2407755f +README.zh.md: 1f0791c5df7afd4a3479afdd827c4fc148cf8883 diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index 15fc5839a3..80ea3cc934 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -43,7 +43,7 @@ The live registry pipeline has three transformable waterfalls, then the definiti - `ToolExecutionInput` — the caller-supplied call description: `{ callId, name, arguments, signal, agent?, parent? }`; `signal` is required and readonly, callers may pass an enclosing execution's opaque token as `parent`, and callers never choose the new execution's own token. - `ToolExecutionToken` — a fresh branded `Symbol` assigned by the registry. It supports equality correlation only and never crosses a model, log, or worker boundary. - `ToolExecution` — the readonly pipeline view: immutable `{ token, callId, name, arguments, signal, agent?, parent? }`; the registry separately retains and re-fuses the original caller signal. `ToolDispatchExecution` is the `tools/execute`-only view whose required signal is mutable, so a wrapper may replace and restore it but cannot delete it. A nested call's `parent` is a `ToolExecutionToken`, not an execution object. -- `ToolRunContext` — the execution passed to a tool body, extending `ToolExecution` with `deferContext(context)`. Composite tools use it to ferry context produced by nested dispatches to the outer result even when the tool later throws or cancellation wins; it never injects immediately. +- `ToolRunContext` — the execution passed to a tool body, extending `ToolExecution` with `deferContext(context)`. It defers one context until the tool's final result reaches the loop — typically a nested-dispatch context ferried by a composite tool, or a fresh plugin-sourced instruction minted by a leaf tool (`tool-goal`'s wrap-up) — even when the tool later throws or cancellation wins; it never injects immediately. - `ToolExecutionResult` — discriminated execution-local outcome. Success is `{ isError:false, value:JsonValue, content, meta?, additionalContexts? }`; failure is `{ isError:true, error:{ message, info? }, content, meta?, additionalContexts? }` and has no value. Call identity stays on the immutable `ToolExecution`. The registry snapshots, validates, and freezes the canonical value before rendering, then materializes the durable presentation fields before final observation. `ToolFailure.info` carries an internal `{ name, code }` for a `HarnessError`; `additionalContexts` preserves every deferred or post-execute identified `UserMessage` for the loop's post-result FIFO. - `PreToolDecision` — `{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`. Input rewrite is deliberately not offered; `ask` is serviced by [`ctx.approval`](../../ui/user-approval/README.md) when mounted and otherwise degrades to deny. - `PostToolDecision` — accept may replace `content` or `value`, never both, and may attach `additionalContexts`; block turns feedback into a valueless failure. Content replacement preserves the canonical value and metadata. Value replacement is revalidated and rerenders content/metadata. Accept preserves tool-deferred contexts before decision contexts; block discards tool-deferred contexts and exposes only contexts explicitly supplied by the blocking decision. diff --git a/packages/core/tools/README.zh.md b/packages/core/tools/README.zh.md index 8547ee4a79..1f0791c5df 100644 --- a/packages/core/tools/README.zh.md +++ b/packages/core/tools/README.zh.md @@ -43,7 +43,7 @@ tools: - `ToolExecutionInput`:调用方提供的调用描述:`{ callId, name, arguments, signal, agent?, parent? }`;`signal` 必填且只读,调用方可以将外层执行的不透明 token 作为 `parent` 传入,但绝不能选择新执行自身的 token。 - `ToolExecutionToken`:注册表分配的全新带品牌 `Symbol`。它只支持通过相等性进行关联,绝不会跨越模型、日志或 worker 边界。 - `ToolExecution`:只读流水线视图:不可变的 `{ token, callId, name, arguments, signal, agent?, parent? }`;注册表会另行保留并重新融合调用方的原始信号。`ToolDispatchExecution` 是仅供 `tools/execute` 使用的视图,其必填信号可变,因此包装层可以替换并还原它,但不能删除它。嵌套调用的 `parent` 是 `ToolExecutionToken`,而不是执行对象。 -- `ToolRunContext`:传给工具主体的执行上下文,在 `ToolExecution` 基础上增加 `deferContext(context)`。组合工具借此把嵌套分发产生的上下文传递到外层结果,即使工具后来抛出或取消胜出也不例外;该方法绝不会立即注入上下文。 +- `ToolRunContext`:传给工具主体的执行上下文,在 `ToolExecution` 基础上增加 `deferContext(context)`。它把一条上下文推迟到该工具的最终结果抵达循环时——通常是组合工具转运的嵌套分发上下文,也可以是叶子工具铸造的全新插件来源指令(如 `tool-goal` 的收尾注入)——即使工具后来抛出或取消胜出也不例外;该方法绝不会立即注入上下文。 - `ToolExecutionResult`:可辨识的执行局部结果。成功形态为 `{ isError:false, value:JsonValue, content, meta?, additionalContexts? }`;失败形态为 `{ isError:true, error:{ message, info? }, content, meta?, additionalContexts? }`,且不含值。调用身份保留在不可变的 `ToolExecution` 上。注册表会在呈现前快照、验证并冻结规范值,随后在最终观测前实体化持久呈现字段。`ToolFailure.info` 携带内部的 `{ name, code }`,用于表示 `HarnessError`;`additionalContexts` 会保留每个通过延迟或 post-execute 加入且带标识的 `UserMessage`,供循环在结果后按 FIFO 顺序处理。 - `PreToolDecision`:`{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`。该类型有意不提供输入改写;`ask` 在挂载 [`ctx.approval`](../../ui/user-approval/README.md) 时由它处理,否则退化为拒绝。 - `PostToolDecision`:接受决定可以替换 `content` 或 `value`(不能同时替换),并可附加 `additionalContexts`;阻止决定会把反馈变成无值失败。替换内容会保留规范值和元数据。替换值会重新验证,并重新呈现内容/元数据。接受决定会先保留工具延迟的上下文,再附加决定上下文;阻止决定会丢弃工具延迟的上下文,只公开阻止决定显式提供的上下文。 diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index f30dce6cd0..72254e2abd 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -344,15 +344,18 @@ export interface ToolDispatchExecution extends Omit { /** * Runtime context handed to a tool implementation after the registry has - * accepted a {@link ToolExecution}. A composite tool uses - * {@link deferContext} to ferry context produced by nested dispatches back to - * the outer result; the loop appends it only after the outer `tool/result`. + * accepted a {@link ToolExecution}. {@link deferContext} attaches context to + * this execution's own result — a composite tool ferries nested-dispatch + * context back to the outer result, and a leaf tool may mint a fresh + * plugin-sourced instruction; the loop appends it only after the + * `tool/result`. */ export interface ToolRunContext extends ToolExecution { /** - * Defer one nested-dispatch context until this tool's final result reaches - * the agent loop. Contexts retain their individual source and metadata and - * are emitted in call order. + * Defer one context — typically a nested-dispatch context ferried by a + * composite tool, or a fresh plugin-sourced instruction — until this tool's + * final result reaches the agent loop. Contexts retain their individual + * source and metadata and are emitted in call order. */ deferContext(context: UserMessage): void /** diff --git a/packages/goal/tool-goal/README.i18n.yaml b/packages/goal/tool-goal/README.i18n.yaml index 3b9dd9f964..354456002b 100644 --- a/packages/goal/tool-goal/README.i18n.yaml +++ b/packages/goal/tool-goal/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/goal/tool-goal/README.md -README.md: aaed61dd517aeb2f94efa22c34c64d1068155d46 -README.zh.md: 5365b64ef65fb3d3f00e19357327479ebd8285a8 +README.md: 2fa80c2e5fa3d675a48fc18506635fd811ac8f80 +README.zh.md: c6c39e3cc739fb39a4a36080db5246e7c7349147 diff --git a/packages/goal/tool-goal/README.md b/packages/goal/tool-goal/README.md index aaed61dd51..2fa80c2e5f 100644 --- a/packages/goal/tool-goal/README.md +++ b/packages/goal/tool-goal/README.md @@ -14,7 +14,7 @@ All calls are exclusive, so a model-ordered batch observes earlier mutations and All three canonical values match the compact JSON already rendered to Native callers: `{ goal: null }` or `{ goal: { id, revision, objective, phase, roundsStarted, maxGoalRounds, blockedReason? }, activation }`. Programmatic consumers therefore receive the same domain structure without parsing the rendered JSON. -An autonomous goal round that successfully reports `complete` or `blocked` marks that tool execution with `concludeTurn()` so the physical turn stops after the step. Direct-human mutations never contribute this stop: the assistant may acknowledge the change and concurrent human steering remains available to the loop. +An autonomous goal round that successfully reports `complete` or `blocked` defers one wrap-up context onto that tool result: an injected instruction telling the model to write a final closing message to the user and call no more tools, after which the turn ends through the ordinary no-tool-calls stop. Direct-human mutations receive no instruction: the assistant may acknowledge the change and concurrent human steering remains available to the loop. ## Authority @@ -61,11 +61,11 @@ Prefix-stable while the plugin scope, configured threshold, and guidance text ar #### What the model sees -The generated [`get_goal`, `create_goal`, and `update_goal` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-goal). Successful results are compact JSON. Mutation results are followed by the goal domain's raw `` snapshot after the tool batch. `activation` in a result is a live observation and never becomes replay authority. +The generated [`get_goal`, `create_goal`, and `update_goal` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-goal). Successful results are compact JSON. Mutation results are followed by the goal domain's raw `` snapshot after the tool batch. `activation` in a result is a live observation and never becomes replay authority. A goal-round `complete` or `blocked` result additionally injects one ``/`` wrap-up instruction that asks for a grounded closing message to the user without further tool calls. #### Token effect -Fixed schema cost plus one compact result per call. Mutations also retain the domain snapshot until compaction. +Fixed schema cost plus one compact result per call. Mutations also retain the domain snapshot until compaction. A goal-round terminal update adds the injected wrap-up instruction and one further model request for the closing message — once per goal lifecycle, not per round. #### KV Cache effect diff --git a/packages/goal/tool-goal/README.zh.md b/packages/goal/tool-goal/README.zh.md index 5365b64ef6..c6c39e3cc7 100644 --- a/packages/goal/tool-goal/README.zh.md +++ b/packages/goal/tool-goal/README.zh.md @@ -14,7 +14,7 @@ 3 个规范值都与已经渲染给 Native 调用方的紧凑 JSON 一致:`{ goal: null }` 或 `{ goal: { id, revision, objective, phase, roundsStarted, maxGoalRounds, blockedReason? }, activation }`。因此,编程消费方无需解析渲染后的 JSON,即可收到相同领域结构。 -自主 Goal Round 成功报告 `complete` 或 `blocked` 时,会用 `concludeTurn()` 标记该次工具执行,使物理轮次在该步骤后停止。人类直接变更绝不会导致这种停止:assistant 可以确认变更,循环仍可接收并发的人类 steering(中途引导)。 +自主 Goal Round 成功报告 `complete` 或 `blocked` 时,会在该次工具结果上附带一条收尾注入指令,要求模型面向用户写出最终收尾消息、不再调用工具,之后轮次经由常规的无工具调用停止路径结束。人类直接变更不会收到这条指令:assistant 可以确认变更,循环仍可接收并发的人类 steering(中途引导)。 ## 权限 @@ -61,11 +61,11 @@ Use goal tools for one long-running completion objective in the current session. #### 模型看到的内容 -生成的 [`get_goal`、`create_goal` 和 `update_goal` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-goal)。成功结果是紧凑 JSON。变更结果之后是工具批次结束后由 goal 领域产生的原始 `` 快照。结果中的 `activation` 是实时观察值,绝不会成为回放权限依据。 +生成的 [`get_goal`、`create_goal` 和 `update_goal` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-goal)。成功结果是紧凑 JSON。变更结果之后是工具批次结束后由 goal 领域产生的原始 `` 快照。结果中的 `activation` 是实时观察值,绝不会成为回放权限依据。Goal Round 的 `complete`/`blocked` 结果还会额外注入一条 ``/`` 收尾指令,要求模型向用户写出有依据的收尾消息且不再调用工具。 #### Token 影响 -固定 schema 成本,加上每次调用的一条紧凑结果。变更还会保留领域快照,直到压缩(compaction)。 +固定 schema 成本,加上每次调用的一条紧凑结果。变更还会保留领域快照,直到压缩(compaction)。Goal Round 的终态更新会增加注入的收尾指令和一次额外的模型请求用于收尾消息——每个 goal 生命周期一次,而非每轮一次。 #### KV Cache 影响 diff --git a/packages/goal/tool-goal/src/index.ts b/packages/goal/tool-goal/src/index.ts index e62510b06f..9ceed21bc9 100644 --- a/packages/goal/tool-goal/src/index.ts +++ b/packages/goal/tool-goal/src/index.ts @@ -8,7 +8,7 @@ import type { Context } from 'cordis' import z from 'schemastery' import { GoalId } from '@deepseek-ai/dsh-goal' import type { GoalRef, GoalView } from '@deepseek-ai/dsh-goal' -import { HarnessError } from '@deepseek-ai/dsh-llm' +import { createUserMessage, HarnessError } from '@deepseek-ai/dsh-llm' import { defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView } from '@deepseek-ai/dsh-tools' import type {} from '@deepseek-ai/dsh-system-prompt' @@ -17,6 +17,7 @@ import { goalToolExecution, requireDirectHuman, } from './authority.ts' +import { renderWrapupContext } from './wrapup.ts' export const name = 'tool-goal' export const inject = ['agents', 'goals', 'tools', 'systemPrompt'] @@ -309,7 +310,14 @@ export function apply(ctx: Context, config: Config): void { code: 'model-reported', message: args.blocked_reason as string, }) - if (authority.kind === 'goal-round') exec.concludeTurn() + if (authority.kind === 'goal-round') { + exec.deferContext(createUserMessage({ + content: args.action === 'complete' + ? renderWrapupContext(goal.objective) + : renderWrapupContext(goal.objective, args.blocked_reason as string), + source: { kind: 'plugin', plugin: 'tool-goal' }, + })) + } return Promise.resolve(goalValue(goal)) }, presentCall: args => present( diff --git a/packages/goal/tool-goal/src/wrapup.ts b/packages/goal/tool-goal/src/wrapup.ts new file mode 100644 index 0000000000..158f17e886 --- /dev/null +++ b/packages/goal/tool-goal/src/wrapup.ts @@ -0,0 +1,41 @@ +/** Model-visible wrap-up instruction for a terminal autonomous goal update. */ + +import type { ContentBlock } from '@deepseek-ai/dsh-llm' + +const GROUNDING = + 'Report only what earlier rounds and tool results in this session actually establish; ' + + 'when a detail is not in the session, say so instead of inventing it. ' + +/** + * Render the closing-message instruction injected after an autonomous goal + * round reports `complete` or `blocked`, replacing the former hard turn stop + * so the model still addresses the user once before the turn ends. + * @param objective - the terminal goal's objective, echoed for grounding. + * @param blockedReason - the validated report for `blocked`; omitted for `complete`. + * @returns a fresh one-block context for `ToolRunContext.deferContext()`. + */ +export function renderWrapupContext(objective: string, blockedReason?: string): ContentBlock[] { + const heading = `Objective: ${JSON.stringify(objective)}\n` + const text = blockedReason === undefined + ? '\n' + + heading + + 'The goal is marked complete and this autonomous run is ending. Write the closing ' + + 'message to the user now: state the outcome, summarize what was done and how it was ' + + 'verified, and point to the concrete results (files, commits, or other artifacts). ' + + GROUNDING + + 'Note anything the user should review or do next. Address the user directly. Do not ' + + "call any more tools in this run; further work waits for the user's next instruction.\n" + + '' + : '\n' + + heading + + `Blocked: ${JSON.stringify(blockedReason)}\n` + + 'The goal is marked blocked and this autonomous run is ending. Write the closing ' + + 'message to the user now: state what has been completed so far, describe the concrete ' + + 'blocking condition and what you tried, and say exactly what you need from the user to ' + + 'continue. ' + + GROUNDING + + 'Address the user directly. Do not call any more tools in this run; further work ' + + "waits for the user's next instruction.\n" + + '' + return [{ type: 'text', text }] +} diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index 4278fc5a50..9363081f85 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -347,7 +347,7 @@ describe('goal tool state transitions', () => { expect(goal).toMatchObject({ phase: 'active', revision: 4 }) }) - it('terminal-stops an autonomous completion but leaves a human pause interactive', async () => { + it('injects one wrap-up instruction for an autonomous completion but leaves a human pause interactive', async () => { const { ctx, root } = await harness() const humanTurn = openTurn(root, { kind: 'user' }) const created = ctx.goals.create(root.agent, { objective: 'pause cleanly' }) @@ -356,6 +356,7 @@ describe('goal tool state transitions', () => { }, root.agent) expect(resultGoal(paused)).toMatchObject({ phase: 'paused' }) expect(paused.concludesTurn).toBeUndefined() + expect(paused.additionalContexts).toBeUndefined() const resumed = resultGoal(await execute(ctx, 'update_goal', { goal_id: created.id, revision: 2, action: 'resume', }, root.agent)) @@ -368,7 +369,27 @@ describe('goal tool state transitions', () => { goal_id: created.id, revision: resumed['revision'], action: 'complete', }, root.agent) expect(resultGoal(complete)).toMatchObject({ phase: 'complete' }) - expect(complete.concludesTurn).toBe(true) + expect(complete.concludesTurn).toBeUndefined() + const contexts = complete.additionalContexts ?? [] + expect(contexts).toHaveLength(1) + expect(contexts[0]?.source).toEqual({ kind: 'plugin', plugin: 'tool-goal' }) + const block = contexts[0]?.content[0] + if (block?.type !== 'text') throw new Error('expected one text wrap-up block') + expect(block.text).toContain('') + expect(block.text).toContain('"pause cleanly"') + expect(block.text).toContain("Do not call any more tools in this run; further work waits for the user's next instruction.") + }) + + it('completes without a wrap-up instruction under direct human authority', async () => { + const { ctx, root } = await harness() + openTurn(root, { kind: 'user' }) + const created = ctx.goals.create(root.agent, { objective: 'finish now' }) + const complete = await execute(ctx, 'update_goal', { + goal_id: created.id, revision: created.revision, action: 'complete', + }, root.agent) + expect(resultGoal(complete)).toMatchObject({ phase: 'complete' }) + expect(complete.concludesTurn).toBeUndefined() + expect(complete.additionalContexts).toBeUndefined() }) it('rearms a restored active goal only after a new direct human prompt', async () => { @@ -550,6 +571,14 @@ describe('goal tool state transitions', () => { blockedReason: { code: 'model-reported', message: 'The required credential is still unavailable.' }, roundsStarted: 3, }) + expect(blocked.concludesTurn).toBeUndefined() + const contexts = blocked.additionalContexts ?? [] + expect(contexts).toHaveLength(1) + const block = contexts[0]?.content[0] + if (block?.type !== 'text') throw new Error('expected one text wrap-up block') + expect(block.text).toContain('') + expect(block.text).toContain('The required credential is still unavailable.') + expect(block.text).toContain("Do not call any more tools in this run; further work waits for the user's next instruction.") }) it('lets direct human authority block before the model threshold', async () => { @@ -570,5 +599,7 @@ describe('goal tool state transitions', () => { }, roundsStarted: 0, }) + expect(blocked.concludesTurn).toBeUndefined() + expect(blocked.additionalContexts).toBeUndefined() }) }) diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 4f0e10cb87..e7b813d391 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: d4af267ef46f327bdabf31429b291ffd04308203 -README.zh.md: f148cfa0c023b54d62016c5211205d391b634544 +README.md: ed04a3431e4c9114b3115119c1e69d378cec77ae +README.zh.md: e50a7f49cb8753f5b26dd27cb1d48a9cc18e9fd7 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index d4af267ef4..ed04a3431e 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -10,6 +10,8 @@ Wire messages form a four-quadrant discriminated union — who initiates × requ The layering/protocol decisions are recorded in the [GUI layering and RPC protocol RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md); the browser-side consumption architecture in the [web client architecture RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md). +Question responses are validated against their pending request before the first answer claims it. A multi-select item may carry both requested option labels in `selected` and non-empty `custom` text; a single-select item must use one or the other. Duplicate labels, unknown labels, mismatched ids, incomplete batches, and empty custom text are rejected as `bad-response`. + `session.history` reads an attached Session in memory or inspects a cold log through persistence without resuming or publishing an Agent, then pages on append-origin message boundaries. `maxMessages` counts `user/message`, `assistant/message`, and `steering/message` events that entered the surface by appending, so a model-only replacement copy consumes no quota. Each page stays one contiguous raw event range, which keeps a compaction's log-only provenance on the same page as the replacement that cites it. `session.history`'s tail page (`beforeSeq` absent) additionally carries an optional `projections` block — the watermark snapshot of every unit registered on `ctx.sessionProjections` (`@deepseek-ai/dsh-session-projection`), with `asOfSeq` = the last event seq the values reflect (`-1` on an empty log). The gateway also subscribes to the registry's change feed and mints a `session/projection` mux frame per changed unit (`{sessionId, key, value, seq}` — live push state, never logged; clients hold one generic per-session value store under higher-seq-wins). The carrier holds zero domain knowledge (each value passed its unit's own schema inside the registry; the wire schemas keep `values`/`value` wide); loadOlder pages never carry the block, and a composition without the registry serves histories without either surface. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index f148cfa0c0..e50a7f49cb 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -10,6 +10,8 @@ 分层与协议决策记录在 [GUI 分层与 RPC 协议 RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)中;浏览器侧消费架构记录在 [Web 客户端架构 RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md)中。 +首个回答认领待处理请求之前,系统会对照该请求校验问题响应。多选题的回答项可以同时携带 `selected` 中的请求选项标签与非空 `custom` 文本;单选题的回答项必须二选一。标签重复、标签未知、id 不匹配、批次不完整以及自定义文本为空都会以 `bad-response` 拒绝。 + `session.history` 会读取已附加 Session 的内存状态,或通过持久化检查冷日志,而不会恢复或发布 agent(智能体),然后按追加来源的消息边界分页。`maxMessages` 统计以追加方式进入 surface 的 `user/message`、`assistant/message` 和 `steering/message` 事件,因此仅供模型使用的替换副本不占用配额。每一页仍是一段连续的原始事件区间,从而让压缩(compaction)的仅日志溯源信息与引用它的替换留在同一页。 `session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections`(`@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq(空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元铸造一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有任何领域知识(每个值在注册表内部已过其单元自己的 schema;协议 schema 对 `values`/`value` 保持宽松);loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 124f3a5fd0..fd19541590 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -412,8 +412,10 @@ function matchesQuestions(payload: QuestionResponsePayload, pending: PendingQues if (new Set(answer.selected).size !== answer.selected.length) return false const custom = answer.custom?.trim() if (custom !== undefined && custom === '') return false - if (custom !== undefined && answer.selected.length > 0) return false - if (question.multiSelect !== true && answer.selected.length > 1) return false + if (question.multiSelect !== true) { + if (custom !== undefined && answer.selected.length > 0) return false + if (answer.selected.length > 1) return false + } const labels = new Set(question.options?.map(option => option.label) ?? []) return answer.selected.every(label => labels.has(label)) }) @@ -1034,8 +1036,14 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro /** Whether the session's own suffix carries the durable subagent discriminator. */ function hasSubagentDescriptor(session: Pick): boolean { - const ownStart = session.header.seedLength ?? 0 - return session.events.slice(ownStart).some(event => event.type === 'subagent/descriptor') + const events = session.events + // Indexed scan from the own-suffix start: slicing copies the whole suffix + // on every Agent-bound RPC, including each `session.prompt` on long + // transcripts. + for (let index = session.header.seedLength ?? 0; index < events.length; index += 1) { + if (events[index]?.type === 'subagent/descriptor') return true + } + return false } /** @@ -1076,13 +1084,28 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro return inspected } - async function agentFor(sessionId: SessionId): Promise<{ agent: Agent } | { error: RpcError }> { - const attached = ctx.sessions.get(sessionId) + /** + * Resolve one live registered identity through the subagent-ownership + * fence: subagent-owned agents answer `agent-busy`, plain agents pass. + * Fences the live agent's own session rather than trusting a + * "registered ⇒ attached-store" invariant — a registered subagent whose + * session is ever absent from the attached store must still not be handed + * out through generic Host routing. `undefined` means no live agent. + */ + function fencedLiveAgent(sessionId: SessionId): { agent: Agent } | { error: RpcError } | undefined { const live = ctx.agents.get(sessionId) - if (attached !== undefined && hasSubagentOwner(attached, live)) { + if (live === undefined) return undefined + if (hasSubagentOwner(live.session, live)) return { error: subagentOwnershipError(sessionId) } + return { agent: live } + } + + async function agentFor(sessionId: SessionId): Promise<{ agent: Agent } | { error: RpcError }> { + const fenced = fencedLiveAgent(sessionId) + if (fenced !== undefined) return fenced + const attached = ctx.sessions.get(sessionId) + if (attached !== undefined && hasSubagentOwner(attached, undefined)) { return { error: subagentOwnershipError(sessionId) } } - if (live !== undefined) return { agent: live } let resume = resumes.get(sessionId) if (resume === undefined) { resume = (async () => { @@ -1117,6 +1140,17 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro if (error instanceof SubagentSessionOwnership) { return { error: subagentOwnershipError(error.sessionId) } } + // A concurrent publish can win the identity between the pre-resume + // re-check and `ctx.agents.resume` publication; the ID-collision + // rejection falls through here. Mirror ensureSession's `.catch` in + // full: classify a subagent-owned winner into the stable ownership + // error, and hand a clean plain-agent winner straight back. + const fenced = fencedLiveAgent(sessionId) + if (fenced !== undefined) return fenced + const attached = ctx.sessions.get(sessionId) + if (attached !== undefined && hasSubagentOwner(attached, undefined)) { + return { error: subagentOwnershipError(sessionId) } + } // The internal details slot is contractually {}; the reason rides the message. return { error: { code: 'internal', message: `resume failed for session "${sessionId}": ${String(error)}`, details: {} } } } @@ -1192,13 +1226,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro ? undefined : (await persistence.list()).find(header => header.id === sessionId) if (persistence !== undefined && stored !== undefined) { - if (stored.cwd !== cwd) { - throw new SessionCwdConflict(sessionId, cwd, stored.cwd) - } const inspected = await persistence.inspect(sessionId) + // Ownership first: explicit-id adoption of a session-backed + // subagent must answer `agent-busy` regardless of the requested + // cwd (the api/commands.ts contract), not a cwd conflict. if (hasSubagentOwner({ header: inspected.meta, events: inspected.events }, undefined)) { throw new SubagentSessionOwnership(sessionId) } + if (inspected.meta.cwd !== cwd) { + throw new SessionCwdConflict(sessionId, cwd, inspected.meta.cwd) + } return (await ctx.agents.resume({ resumeSessionId: sessionId, agentOptions, @@ -2266,9 +2303,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }, commands: { - // Both methods address one session's agent (agentFor keeps its - // resume-on-miss: clients only send a sessionId for a published - // session, and resume restores an existing entity). + // Both methods address one session's agent. agentFor resumes on miss + // and fences every subagent-owned identity with `agent-busy`; the + // api/commands.ts module contract owns that fence's wording, so this + // comment only notes the routing shape: clients send a sessionId for a + // published session, and resume restores an existing entity. async list(request) { // Missing service = the deployment omitted dsh-commands from its // composition, not an empty catalog: fail loud instead of serving []. diff --git a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts index 1e5b095778..7eeeba3af6 100644 --- a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts @@ -338,4 +338,43 @@ describe('sessions.prompt synchronous rejection', () => { } } }) + + it('classifies a raced cold-resume ID collision as agent-busy', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(UserInteractionService) + const sessionId = sid('race-resume') + const meta: SessionHeader = header('race-resume', 1000) + ctx.provide('sessionPersistence', { + list: () => Promise.resolve([meta]), + inspect: () => Promise.resolve({ meta, events: [] as SessionEvent[] }), + locate: () => undefined, + } as never) + // The raced winner: a live parent-owned subagent publishes the identity + // while the generic cold resume is in flight, so the resume collides. + const parentSession = ctx.sessions.create(sid('race-parent'), { meta: { cwd: '/proj' } }) + const parent = { id: parentSession.id, session: parentSession, status: 'idle', ctx } as Agent + ctx.agents.register(parent) + const childSession = ctx.sessions.create(sessionId, { + meta: { cwd: '/proj', parentSession: parent.id, origin: 'subagent' }, + }) + const child = { id: sessionId, session: childSession, status: 'idle', ctx } as unknown as Agent + vi.spyOn(ctx.agents, 'resume').mockImplementationOnce(async () => { + // The parent's `enter()` wins the identity between the pre-resume + // re-check and publication; the generic resume then collides. + ctx.agents.register(child) + throw new Error('session id already published') + }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + + const models = await api.sessions.models(request({ sessionId })) + expect(models.result.ok).toBe(false) + if (!models.result.ok) { + expect(models.result.error).toMatchObject({ + code: 'agent-busy', + details: { reason: 'use subagent delivery for this child session' }, + }) + } + }) }) diff --git a/packages/host/apiproxy/tests/api-proxy-question.spec.ts b/packages/host/apiproxy/tests/api-proxy-question.spec.ts new file mode 100644 index 0000000000..e8eaae813f --- /dev/null +++ b/packages/host/apiproxy/tests/api-proxy-question.spec.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import SessionStore from '@deepseek-ai/dsh-session' +import UserInteractionService from '@deepseek-ai/dsh-user-interaction' +import type { ApiProxy, MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api' +import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' +import { createApiProxy } from '../src/api-proxy.ts' + +async function harness(): Promise<{ ctx: Context; api: ApiProxy }> { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(UserInteractionService) + return { + ctx, + api: createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }), + } +} + +function agent(id: string): Agent { + return { id } as unknown as Agent +} + +function openMux(api: ApiProxy, abort: AbortController): { + envelopes: RpcRequest[] + waitForQuestion(): Promise>> +} { + const envelopes: RpcRequest[] = [] + let resolveQuestion!: (value: RpcRequest>) => void + const question = new Promise>>((resolve) => { + resolveQuestion = resolve + }) + void (async () => { + for await (const envelope of api.events.mux({ rpcId: RpcId('question-mux'), payload: {} }, abort.signal)) { + envelopes.push(envelope) + if (envelope.payload.type === 'question/requested') { + resolveQuestion(envelope as RpcRequest>) + } + } + })() + return { envelopes, waitForQuestion: () => question } +} + +function answer( + envelope: RpcRequest>, + selected: string[], + custom?: string, +): Parameters[0] { + return { + type: 'client-response', + rpcId: envelope.rpcId, + result: { + ok: true, + value: { + sessionId: envelope.payload.sessionId, + answer: { + answers: [{ + id: envelope.payload.questions[0]?.id, + selected, + ...custom === undefined ? {} : { custom }, + }], + }, + }, + }, + } +} + +describe('question response validation', () => { + it('accepts selected options with custom text for multi-select questions', async () => { + const { ctx, api } = await harness() + const abort = new AbortController() + const mux = openMux(api, abort) + const asked = ctx.userInteraction.ask({ + agent: agent('session-multi'), + questions: [{ + id: 'targets', + question: 'Choose targets and add another', + multiSelect: true, + options: [{ label: 'Code' }, { label: 'Docs' }], + }], + }) + const envelope = await mux.waitForQuestion() + + expect(await api.respond(answer(envelope, ['Code', 'Docs'], 'Release notes'))) + .toEqual({ accepted: true }) + await expect(asked).resolves.toEqual({ + answers: [{ id: 'targets', selected: ['Code', 'Docs'], custom: 'Release notes' }], + }) + expect(mux.envelopes.some(item => item.payload.type === 'question/resolved')).toBe(true) + abort.abort() + }) + + it('keeps selected options and custom text mutually exclusive for single-select questions', async () => { + const { ctx, api } = await harness() + const abort = new AbortController() + const mux = openMux(api, abort) + const asked = ctx.userInteraction.ask({ + agent: agent('session-single'), + questions: [{ + id: 'target', + question: 'Choose one target', + options: [{ label: 'Code' }, { label: 'Docs' }], + }], + }) + const envelope = await mux.waitForQuestion() + + expect(await api.respond(answer(envelope, ['Code'], 'Release notes'))) + .toEqual({ accepted: false, reason: 'bad-response' }) + expect(await api.respond(answer(envelope, [], 'Release notes'))) + .toEqual({ accepted: true }) + await expect(asked).resolves.toEqual({ + answers: [{ id: 'target', selected: [], custom: 'Release notes' }], + }) + abort.abort() + }) +}) diff --git a/packages/host/directory-picker-browse/README.i18n.yaml b/packages/host/directory-picker-browse/README.i18n.yaml index 673d053e3a..7c2df43ab2 100644 --- a/packages/host/directory-picker-browse/README.i18n.yaml +++ b/packages/host/directory-picker-browse/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/directory-picker-browse/README.md -README.md: 52b5fe7e89f915be3b50324628e9d5c48f1ef94c -README.zh.md: 742da39470083887a71ddba4a7c8012f0ce0ea1f +README.md: 62384cc0b0e5756e56c1d608252721c506a0915f +README.zh.md: 8f495e1e4d87486d0565eadcbf7df694494c7096 diff --git a/packages/host/directory-picker-browse/README.md b/packages/host/directory-picker-browse/README.md index 52b5fe7e89..62384cc0b0 100644 --- a/packages/host/directory-picker-browse/README.md +++ b/packages/host/directory-picker-browse/README.md @@ -6,7 +6,7 @@ The **in-app browsing backend** of the [directory-picker seam](../directory-pick Behavior facts: listings return **directories only**, name-sorted, with symlinks-to-directories followed (broken/cyclic links skipped — the probe `stat` failing means "not enterable") and a host-owned `hidden` flag (POSIX dot convention) left for the client to act on; `crumbs` is the root-to-target ancestor chain, the root crumb labeled by its full path (`/`, `C:\`); an absent `list` path means the host account's home directory. `createDirectory` is non-recursive (a missing parent is a real failure, not a level to invent) and validates the name as a single non-blank segment even when called directly, mirroring the wire schema's fence. Both primitives reject an explicit path that is not fully qualified — relative forms, and on Windows the rooted drive-less forms (`\foo`, `/foo`) and incomplete UNC prefixes (`\\`, `\\server`) that `isAbsolute` accepts — with `directory-unreadable`/`directory-create-failed`, instead of letting `resolve` rebase it under the host process cwd or current drive. One `list` call returns at most `maxEntries` rows (config, default 1000 — the bound GitHub's web UI applies to directory listings), and the level streams through a bounded window so memory stays O(maxEntries) no matter how many children the directory holds: a cut level keeps the name-sorted head, counts hidden rows against the bound, probes only windowed candidates, and reports `truncated: true` so the client can say the level is incomplete (a windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated); window insertion is binary with an O(1) full-window tail rejection, and `list` threads the caller's `AbortSignal` so a disconnect or timeout stops the scan instead of letting it outlive the caller. Failures throw the seam's typed `DirectoryPickerError`. Policy rationale: [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md). -**Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view whose navigations land selection-anchored and quiet: the previous view keeps rendering while a crumb jump or a submitted path is scanned (a "Loading…" pill floats over it only once the scan outlives a 300ms silence window, never shifting the columns), then target and parent legs land as one two-pane frame with the target re-selected as its actual parent-level entry — so stepping back never collapses and no intermediate frame flashes (a parent leg outliving its 200ms wait bound lands the target alone and upgrades in place; a failed or truncated parent leg keeps the single-pane landing; the display root keeps the single wide level); breadcrumb with a click-to-edit path zone whose editor seeds a trailing separator, prefix-filters the listed level from the draft's final segment while typing (case-insensitively, over the listed — possibly truncated — rows only; Enter still navigates by the exact text), and cancels on Escape or when focus leaves the dialog card (window/tab switches and in-card focus moves keep the draft); a fixed-label show-hidden footer toggle over the host's `hidden` flags, with a dot-led typed prefix revealing its matches and the current selection exempt from both filters; nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind). +**Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view whose navigations land selection-anchored and quiet: the previous view keeps rendering while a crumb jump or a submitted path is scanned (a "Loading…" pill floats over it only once the scan outlives a 300ms silence window, never shifting the columns), then target and parent legs land as one two-pane frame with the target re-selected as its actual parent-level entry — so stepping back never collapses and no intermediate frame flashes (a parent leg outliving its 200ms wait bound lands the target alone and upgrades in place; a failed or truncated parent leg keeps the single-pane landing; the display root keeps the single wide level); breadcrumb with a click-to-edit path zone, advertised by the pencil glyph at the bar's right edge and lighting the whole bar — the editor's own box — on hover, whose editor seeds a trailing separator and then keeps the panes under the draft: the final segment prefix-filters the LAST pane while that pane lists the level the directory part names (case-insensitively, over the listed — possibly truncated — rows only; a tail nobody matches releases the filter instead of emptying the pane), while any other directory part is scanned after a 250ms rest and lands like any other navigation — selection-anchored, two-pane away from the display root, both legs waited out so one keystroke moves the view once — so typing deeper descends and erasing segments walks back up without leaving the editor; the pane arity is the invariant, the last pane always listing the level the path names with its parent beside it (only that level's own tail costs no scan, and only a display root lists alone), and a level still answers the text that produced it after the Host resolved it (`..` segments, Windows forward slashes) — a speculative scan is silent when it fails, and Enter still navigates by the exact text, owning the view until it lands; the editor cancels on Escape or when focus leaves the dialog card (window/tab switches and in-card focus moves keep the draft), and panes the draft walked to stay where the walk ended — the crumbs name that level and Open's fallback target follows them, so cancelling closes the editor rather than rewinding the walk; a fixed-label show-hidden footer toggle over the host's `hidden` flags, with a dot-led typed prefix revealing its matches and the current selection exempt from every filter; nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind). ## Model Experience diff --git a/packages/host/directory-picker-browse/README.zh.md b/packages/host/directory-picker-browse/README.zh.md index 742da39470..8f495e1e4d 100644 --- a/packages/host/directory-picker-browse/README.zh.md +++ b/packages/host/directory-picker-browse/README.zh.md @@ -6,7 +6,7 @@ 行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即"不可进入"),并携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/`、`C:\`);`list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空段,与协议 schema 的栅栏一致。两个原语都拒绝非完全限定的显式路径——相对形态,以及 Windows 上 `isAbsolute` 会放行的无盘符有根形态(`\foo`、`/foo`)与不完整的 UNC 前缀(`\\`、`\\server`)——报 `directory-unreadable`/`directory-create-failed`,而不是任由 `resolve` 把它重定位到宿主进程 cwd 或当前盘符之下。单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端对目录列举采用的同一上限),且层级以流式方式经过一个有界窗口,无论目录有多少子项内存都保持 O(maxEntries):被截断的层级保留按名排序的头部、隐藏行计入上限、只探测窗口内候选,并报告 `truncated: true`,供客户端提示层级不完整(窗口内的断链符号链接不会从窗口外回填——发生过驱逐本身已把层级标记为截断);窗口插入为二分查找、满窗尾部单次比较即拒绝,且 `list` 透传调用方的 `AbortSignal`,断连或超时会停止扫描而不是让它在调用方离开后继续。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 -**双面包**:browser half(`./client`)以应用内 **选择工作区目录** 对话框(figma `Harness` 813-23126 家族——Miller 双列视图,其导航以选中项为锚、安静落地:扫描 crumb 跳转或提交的路径期间,先前视图持续渲染("Loading…" 胶囊仅在扫描超出 300ms 静默窗口后才浮于其上,绝不挪动各列),随后目标与父层级两程以单个双栏帧落地,目标被重新选中为其在父层级中的实际条目——因此后退绝不塌缩,也没有中间帧闪现(父层级这一程超出其 200ms 等待上限时,目标单独落地,随后就地升级;父层级这一程失败或被截断时保持单栏落地;展示根保持单个宽层级);带点击即编辑路径区的面包屑,其编辑器预填尾随分隔符、输入时以草稿末段对所列层级做前缀过滤(不区分大小写,且仅作用于已列出、可能被截断的行;Enter 仍按确切文本导航)、按 Escape 或焦点离开对话框卡片即取消(窗口/标签页切换与卡片内焦点移动保留草稿);基于宿主 `hidden` 标志、标签固定的"显示隐藏"footer 开关,键入以点开头的前缀会显出其匹配项,且当前选中项不受这两种过滤影响;嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞,驱动 `host.listDirectory`/`host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`,zh 默认/en)。因此一行 cordis.yml 同时组合浏览交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。 +**双面包**:browser half(`./client`)以应用内 **选择工作区目录** 对话框(figma `Harness` 813-23126 家族——Miller 双列视图,其导航以选中项为锚、安静落地:扫描 crumb 跳转或提交的路径期间,先前视图持续渲染("Loading…" 胶囊仅在扫描超出 300ms 静默窗口后才浮于其上,绝不挪动各列),随后目标与父层级两程以单个双栏帧落地,目标被重新选中为其在父层级中的实际条目——因此后退绝不塌缩,也没有中间帧闪现(父层级这一程超出其 200ms 等待上限时,目标单独落地,随后就地升级;父层级这一程失败或被截断时保持单栏落地;展示根保持单个宽层级);带点击即编辑路径区的面包屑,该区由栏右端的铅笔图标点明,悬停时整条栏——也就是编辑器自身的那只框——亮起,其编辑器预填尾随分隔符,随后让下方各栏跟随草稿:当最后一栏正是目录部分所指的层级时,末段对这一栏做前缀过滤(不区分大小写,且仅作用于已列出、可能被截断的行;无一匹配的末段会解除过滤,而不是把该栏清空),而其余任何目录部分都会在停顿 250ms 后被扫描,并像其他任何一次导航那样落地——以选中项为锚,在展示根之外即双栏,且两程都等齐,于是一次按键只让视图移动一次——继续键入即下潜、删掉末段即上退,全程不必离开编辑器;分栏个数是这里的不变量:最后一栏永远是路径所指的那一层,其上一层在它旁边(只有这一层自己的末段不触发扫描,也只有展示根会独占一栏),而宿主规范化过路径之后(`..` 段、Windows 的正斜杠),该层级仍然应答产生它的那段文本——推测性扫描失败时保持沉默,而 Enter 仍按确切文本导航,并在落地前独占视图;编辑器按 Escape 或焦点离开对话框卡片即取消(窗口/标签页切换与卡片内焦点移动保留草稿),而草稿走到的层级会留在原地——面包屑指明该层级、Open 的兜底目标随之而动,因此取消只是关闭编辑器,并不回退这段行走;基于宿主 `hidden` 标志、标签固定的"显示隐藏"footer 开关,键入以点开头的前缀会显出其匹配项,且当前选中项不受任何过滤影响;嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞,驱动 `host.listDirectory`/`host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`,zh 默认/en)。因此一行 cordis.yml 同时组合浏览交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。 ## 模型体验 diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css index 2f207e4195..b5fbb611a4 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css @@ -49,11 +49,29 @@ color: var(--dsw-alias-label-primary); } +/* The bar IS the editor's box in both modes: it carries the rounded outline + * and the inner padding, the crumbs and the input sit inside it, and hovering + * the edit zone lights the whole row rather than the remainder right of the + * crumbs. The negative left margin pays back the border and padding, so the + * crumb (and input) text keeps the column the title sits in. */ .crumbBar { display: flex; align-items: center; gap: 4px; - min-height: 20px; + box-sizing: border-box; + min-height: 24px; + margin-left: -9px; + padding: 0 8px; + border: 1px solid transparent; + border-radius: 8px; +} + +/* Lit by the affordance the row belongs to, never by a crumb: a crumb's hover + * offers navigation, not path entry. Editing keeps the outline standing. */ +.crumbBar:has(.crumbEditZone:enabled:hover), +.crumbBar:has(.crumbEditZone:focus-visible), +.crumbBar:has(.pathInput) { + border-color: var(--dsw-alias-border-l2); } /* Deep chains scroll inside the trail (the effect pins the tail into view) @@ -118,25 +136,50 @@ color: var(--dsw-alias-label-tertiary); } -/* The empty remainder of the bar: invisible, but a real click target that - * flips the bar into path-edit mode. */ +/* The empty remainder of the bar: a real click target that flips the bar into + * path-edit mode. The pencil glyph seated at its right edge is the standing + * affordance; the outline the gesture lights belongs to the bar, so the whole + * row reads as the box the input will occupy. */ .crumbEditZone { + display: flex; + align-items: center; + justify-content: flex-end; flex: 1 0 34px; min-width: 34px; - align-self: stretch; + height: 22px; + padding: 0; border: none; background: transparent; cursor: text; + outline: none; } +.crumbEditGlyph { + flex: none; + color: var(--dsw-alias-label-tertiary); +} + +.crumbEditZone:enabled:hover .crumbEditGlyph, +.crumbEditZone:focus-visible .crumbEditGlyph { + color: var(--dsw-alias-label-primary); +} + +.crumbEditZone:disabled { + cursor: default; +} + +.crumbEditZone:disabled .crumbEditGlyph { + color: var(--dsw-alias-label-caption); +} + +/* Chrome-free: the bar around it draws the box (border, radius, padding). */ .pathInput { box-sizing: border-box; flex: 1 1 0; min-width: 0; - height: 24px; - padding: 0 8px; - border: 1px solid var(--dsw-alias-border-l2); - border-radius: 8px; + height: 22px; + padding: 0; + border: none; outline: none; background: transparent; font-size: 13px; diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index f5510fda7c..fc6dde82f1 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -18,15 +18,27 @@ * owning flow decides what "Open" means and owns the workspace-creation * error surface. Hidden entries are host-flagged and hidden by default; the * footer's fixed-label "Show hidden files" toggle (aria-pressed, check when - * on) reveals them (client-side only). The path editor opens seeded with a - * trailing separator, and while the draft's directory part names a listed - * level, its final segment prefix-filters that level's rows (a dot-led - * prefix also reveals the hidden entries it names). + * on) reveals them (client-side only). The path editor announces itself with + * a pencil glyph and a bar-wide hover-lit outline, opens seeded with a + * trailing separator, and keeps the panes under the draft: the final segment + * prefix-filters the LAST pane while that pane's level is the one the draft's + * directory part names (a dot-led prefix also reveals the hidden entries it + * names, and a prefix nobody matches releases the filter), while any other + * directory part is scanned after a short debounce and lands like any other + * navigation — selection-anchored and two-pane away from the display root, + * both legs waited out so one keystroke moves the view once. The pane arity + * holds throughout: the last pane is the level the path names and the one + * beside it is its parent, so typing deeper descends and erasing segments + * walks back up, moving the Miller view without leaving the editor. Panes the + * draft walked to stay put when the editor closes (cancellation included): + * the crumbs name where the walk ended, and Open's fallback target follows + * them. */ import { useCallback, useEffect, useRef, useState } from 'react' import clsx from 'clsx' import { - Button, IconCheckOutline16, IconChevronRightOutline14, IconFolderClose16, IconFolderOpen16, IconPlusOutline16, Modal, + Button, IconCheckOutline16, IconChevronRightOutline14, IconEditOutline16, IconFolderClose16, IconFolderOpen16, + IconPlusOutline16, Modal, } from '@deepseek-ai/dsh-client-ui-primitives' import type { DirectoryEntry, DirectoryListing } from '@deepseek-ai/dsh-client-runtime/client' import { DirectoryBrowseError } from '@deepseek-ai/dsh-client-runtime/client' @@ -75,6 +87,15 @@ const SLOW_SCAN_DELAY_MS = 300 */ const PARENT_LEG_WAIT_MS = 200 +/** + * How long a typed draft rests before the panes follow it to a directory no + * pane lists. The window absorbs the keystrokes that walk through + * intermediate directory parts (every character of `/usr/lo` past the + * separator would otherwise be its own scan) while staying short enough that + * a pause reads as "the list moved with me". + */ +const DRAFT_PREVIEW_DEBOUNCE_MS = 250 + /** * Breadcrumb rows for display: inside the home subtree the chain starts at a * localized Home crumb; outside it the full ancestry shows, the root labeled @@ -100,21 +121,89 @@ function separatorOf(listing: DirectoryListing): '\\' | '/' { return listing.home.includes('\\') ? '\\' : '/' } -/** - * The path draft's final segment, when its directory part is exactly the - * level `listing` lists — the segment the level prefix-filters on while the - * user types. Any other draft (no separator yet, or naming some other - * directory) leaves the level unfiltered. The directory part compares - * exactly (it is the host's own path text, reached by seeding or erasing); - * only the name filter downstream is case-insensitive. - */ -function draftPrefixFor(listing: DirectoryListing, draft: string | null): string | null { - if (draft === null) return null +/** The listed level as a directory part: its own path, separator-terminated (the root already is). */ +function levelDirectory(listing: DirectoryListing): string { const sep = separatorOf(listing) - const cut = draft.lastIndexOf(sep) - if (cut === -1) return null - const level = listing.path.endsWith(sep) ? listing.path : `${listing.path}${sep}` - return draft.slice(0, cut + 1) === level ? draft.slice(cut + 1) : null + return listing.path.endsWith(sep) ? listing.path : `${listing.path}${sep}` +} + +/** The directory text a draft-following scan last sent, with the level path the host answered it with. */ +interface ScannedDirectory { + /** The draft's directory part, verbatim as it went to the host. */ + readonly directory: string + /** `path` of the listing that came back. */ + readonly landed: string +} + +/** + * The draft's directory part — everything through its last separator — or + * null while no separator has been typed at all (nothing addresses a + * directory yet). The platform comes from `listing`: on Windows a forward + * slash separates too (the host's `resolve` accepts either), while on POSIX a + * backslash is a legal name character and never separates. + */ +function draftDirectory(listing: DirectoryListing, draft: string): string | null { + const cut = separatorOf(listing) === '\\' + ? Math.max(draft.lastIndexOf('\\'), draft.lastIndexOf('/')) + : draft.lastIndexOf('/') + return cut === -1 ? null : draft.slice(0, cut + 1) +} + +/** + * How the draft reads against one level: the directory part it names, and — + * when `listing` is the level that directory part addresses — the final + * segment that prefix-filters it while the user types (case-insensitively, + * downstream). A level answers a directory part when its own path is that + * part, or when it is the level that very text just produced (`scanned`): the + * host resolves what it is given, so `..` segments and Windows forward + * slashes reach a level whose path spells the request differently. + * @param listing - the level to read the draft against. + * @param draft - the current path draft. + * @param scanned - the last draft-following scan's directory and landing. + * @returns the draft's directory part (null with no separator typed) and its + * filtering tail (null when this level does not answer that directory). + */ +function readDraft( + listing: DirectoryListing, + draft: string, + scanned: ScannedDirectory | null, +): { directory: string | null; tail: string | null } { + const directory = draftDirectory(listing, draft) + if (directory === null) return { directory: null, tail: null } + const answers = directory === levelDirectory(listing) + || (scanned !== null && scanned.directory === directory && scanned.landed === listing.path) + return { directory, tail: answers ? draft.slice(directory.length) : null } +} + +/** + * The rows one column renders. The selection is exempt from every filter: it + * anchors the two-pane view (crumbs and the child pane point at it), so + * neither the hidden filter after a dot-reveal pick nor a prefix miss may + * orphan it. A prefix narrows the level only while some row it would actually + * show matches — a tail nobody matches is a name being spelled, not a demand + * for an empty pane, so the level shows whole and its hidden rows return to + * obeying the toggle. Counting only displayable rows is what keeps that true: + * were a hidden row ever to match a prefix that does not reveal it (today + * `hidden` means dot-prefixed, so it cannot), the level would narrow to + * nothing. + */ +function visibleEntries( + entries: readonly DirectoryEntry[], + selectedPath: string | null, + showHidden: boolean, + filterPrefix: string | null, +): readonly DirectoryEntry[] { + const needle = filterPrefix === null ? '' : filterPrefix.toLowerCase() + // A dot-led prefix names hidden entries explicitly, so matching ones + // surface even while the toggle keeps the rest hidden. + const displayable = (entry: DirectoryEntry): boolean => showHidden || !entry.hidden || needle.startsWith('.') + const matches = (entry: DirectoryEntry): boolean => displayable(entry) && entry.name.toLowerCase().startsWith(needle) + const narrowing = needle !== '' && entries.some(matches) + return entries.filter((entry) => { + if (entry.path === selectedPath) return true + if (narrowing) return matches(entry) + return showHidden || !entry.hidden + }) } /** One column of folder rows (the Miller view renders one or two of these). */ @@ -127,16 +216,7 @@ function LevelColumn({ entries, selectedPath, busy, onPick, showHidden, filterPr filterPrefix: string | null pathEditing: boolean }) { - const visible = entries.filter((entry) => { - // The selection is exempt from both filters: it anchors the two-pane - // view (crumbs and the child pane point at it), so neither the hidden - // filter after a dot-reveal pick nor a prefix miss may orphan it. - if (entry.path === selectedPath) return true - if (filterPrefix !== null && !entry.name.toLowerCase().startsWith(filterPrefix.toLowerCase())) return false - // A dot-led prefix names hidden entries explicitly, so matching ones - // surface even while the toggle keeps the rest hidden. - return showHidden || !entry.hidden || filterPrefix?.startsWith('.') === true - }) + const visible = visibleEntries(entries, selectedPath, showHidden, filterPrefix) return (
{visible.map((entry) => { @@ -264,27 +344,83 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, }, [restartSlowScanWindow, listDirectory]) /** - * Replace the whole view with a freshly navigated level. Away from the + * Enter owns the view from submission until its navigation lands, so the + * debounce timer the same keystrokes armed must not supersede it. Cleared + * by the next edit (and by opening the editor); a failed submission leaves + * it set until the operator edits again, so the rejected path is not + * immediately re-scanned as a preview. + */ + const previewSuspended = useRef(false) + + // The panes as the draft-following scan must read them when its wait + // fires: current, but NOT a dependency of the wait (see the effect below). + const viewRef = useRef<{ parent: DirectoryListing | null; child: DirectoryListing | null }>({ parent: null, child: null }) + useEffect(() => { viewRef.current = { parent, child } }, [parent, child]) + + // What the last draft-following scan asked for and what came back, so a + // level still answers the text that produced it after the host respelled + // it. Stale entries are harmless: a match needs both the directory text and + // that level's own path, which together already mean the same directory. + const scanned = useRef(null) + + /** + * A landed preview replaced the pane a keyboard operator may have Tabbed + * onto, so the focus it drops is re-parked on the still-open editor (the + * Modal has no focus trap). Consumed by the refocus effect below. + */ + const refocusPathInput = useRef(false) + + /** + * Replace the whole view with a freshly scanned level. Away from the * display root — the same collapse the crumb header renders, so crumbs and * pane shape never disagree — the landing is two-pane: the target's ACTUAL * parent-level entry re-selected (left pane = parent, right pane = the * target), so a crumb jump reads as stepping back one pane. Both legs land * as one frame when the parent leg settles within * {@link PARENT_LEG_WAIT_MS}; past that bound (or at the display root) the - * target commits alone — single wide level, the editor closes, loading - * ends — and a late parent leg still upgrades the landing in place. A - * failed parent leg, or a truncated parent window that lacks the target, - * leaves the single-pane landing — the upgrade must never orphan the - * selection it exists to anchor. Until whichever commit comes first, the - * previous view keeps rendering: navigation swaps the panes, it never - * blanks them. + * target commits alone — single wide level, loading ends — and a late + * parent leg still upgrades the landing in place. A failed parent leg, or a + * truncated parent window that lacks the target, leaves the single-pane + * landing — the upgrade must never orphan the selection it exists to + * anchor. Until whichever commit comes first, the previous view keeps + * rendering: a landing swaps the panes, it never blanks them. + * + * Two callers, one landing shape. A submitted path (Enter, a crumb) closes + * the editor on arrival, announces its failure, and takes the wait bound — + * it is answering a gesture, so it may not hang on a stalled parent. The + * editor's own draft-following scan keeps all three to itself: it is + * speculative, nothing waits on it, and the stale view keeps rendering, so + * it waits for BOTH legs rather than flashing a single pane it would then + * upgrade — one keystroke must move the view once. A failure leaves the + * last readable panes standing and says nothing, while an arrival clears + * the stale message and re-parks focus the swap dropped. + * @param path - the level to list; absent lists the Host home directory. + * @param options - `closeEditor` retires the path draft on arrival and + * bounds the wait for the parent leg; `announce` surfaces a failure as the + * dialog's alert. */ - const navigate = useCallback((path?: string) => { + const land = useCallback((path: string | undefined, options: { closeEditor: boolean; announce: boolean }) => { const { seq, scan } = launchListing(path) setLoading(true) - setError(null) + if (options.announce) setError(null) + // What every landing does once its panes are committed, whichever shape + // committed them. + const settle = (): void => { + setLoading(false) + if (options.closeEditor) { + setPathDraft(null) + return + } + setError(null) + refocusPathInput.current = true + } scan.then((target) => { if (seq !== requestSeq.current) return + // The level the panes will present as current answers this exact + // directory text, however the host respelled it (`..`, a Windows + // forward slash): the tail filters, and the same text asks for no + // second scan. + if (!options.closeEditor && path !== undefined) scanned.current = { directory: path, landed: target.path } // The single-pane landing; `landed` makes it first-commit-only, while // the two-pane commit below may still upgrade an already-landed view. let landed = false @@ -294,8 +430,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, setParent(target) setSelected(null) setChild(null) - setLoading(false) - setPathDraft(null) + settle() } // Arity is label-independent: only the collapsed chain's depth decides. if (displayCrumbs(target, '').length < 2) { landSingle(); return } @@ -316,23 +451,29 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, setChild(target) // Idempotent on a late upgrade of a timed-out landing: reopening the // editor or starting a newer scan supersedes this seq, so reaching - // here means the draft is closed and the loading flag is this - // navigation's own. - setLoading(false) - setPathDraft(null) + // here means the settlement is still this landing's own. + settle() }, () => { // The parent-leg failure (its abort included) never surfaces: the // target listed fine, and nobody asked to see the parent level. landSingle() }) - window.setTimeout(landSingle, PARENT_LEG_WAIT_MS) + // Only a submitted navigation is bounded: the walk waits both legs out + // (see the contract above), and a keystroke aborts it if the operator + // moves on first. + if (options.closeEditor) window.setTimeout(landSingle, PARENT_LEG_WAIT_MS) }, (reason: unknown) => { if (seq !== requestSeq.current) return setLoading(false) - setError(failureText(reason)) + if (options.announce) setError(failureText(reason)) }) }, [launchListing, continueScan]) + /** Commit a submitted path (Enter, a crumb, the initial home listing): the editor closes, failures surface. */ + const navigate = useCallback((path?: string) => { + land(path, { closeEditor: true, announce: true }) + }, [land]) + // Editor-close focus parking (consumed by the refocus effect below the // miller-row ref): a pick parks on the selection's row, Enter and an // input-focused Escape park on the crumb edit zone that replaces the @@ -381,6 +522,17 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, }) }, [launchListing, pathDraft]) + /** + * Walk the panes to the directory the draft addresses, WITHOUT closing the + * editor. The landing is an ordinary one — selection-anchored and two-pane + * away from the display root — so typing a path moves the Miller view + * exactly as a crumb jump does, and the draft's final segment + * prefix-filters the arrival from the next render on. + */ + const previewDraftLevel = useCallback((directory: string) => { + land(directory, { closeEditor: false, announce: false }) + }, [land]) + /** Abandon path editing (Escape or clicking away) and restore the crumb view. */ const cancelPathEdit = useCallback(() => { // Cancel also withdraws a navigation the editor already launched: its @@ -499,8 +651,40 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, return () => { window.clearTimeout(timer) } }, [loading, scanWindow]) + // The panes follow the draft: EVERY keystroke replaces the pending timer, + // and the target is decided when it fires, off the panes as they stand + // then. Keying the wait on the draft (not on the directory part it names) + // is what makes a keystroke that superseded an in-flight scan re-arm one, + // and what lets an edit after a rejected submission release the hold the + // submission took. The panes are read through a ref for the converse + // reason: were they dependencies, the landing this commits would re-arm the + // wait, and a host answering with a differently spelled path would scan + // forever. + useEffect(() => { + if (pathDraft === null) return + const timer = window.setTimeout(() => { + if (previewSuspended.current) return + // The level the panes present as current: it alone may answer the + // draft, so anything else it names is a level to walk to. + const current = viewRef.current.child ?? viewRef.current.parent + if (current === null) return + const { directory, tail } = readDraft(current, pathDraft, scanned.current) + if (directory === null || tail !== null) return + previewDraftLevel(directory) + }, DRAFT_PREVIEW_DEBOUNCE_MS) + return () => { window.clearTimeout(timer) } + }, [pathDraft, previewDraftLevel]) + // After the hooks: a closed dialog renders nothing and evaluates no copy. const crumbSource = child ?? parent + // The draft's tail filters the level it names, which by the pane invariant + // is the LAST pane — never a pane the draft has already walked away from. + // Narrowing that stale pane would move the view twice for one keystroke: + // once as it narrows, again as its landing replaces it. It holds still + // instead, and the filter arrives with the level it belongs to. + const typedPrefix = crumbSource === null || pathDraft === null + ? null + : readDraft(crumbSource, pathDraft, scanned.current).tail const crumbs = crumbSource === null ? [] : displayCrumbs(crumbSource, t('browser.home')) const crumbTail = crumbs.at(-1)?.path useEffect(() => { @@ -523,6 +707,12 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // replacing the picked button's column — while Enter and an input-focused // Escape land on the crumb edit zone that replaces the input. useEffect(() => { + if (refocusPathInput.current) { + refocusPathInput.current = false + // Only when the swap actually dropped focus to body: focus the operator + // still holds (the input itself, a surviving row) stays theirs. + if (document.activeElement === document.body) pathInputRef.current?.focus() + } if (pathDraft !== null) return if (refocusPick.current) { refocusPick.current = false @@ -637,11 +827,17 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, ))} - {/* The empty zone right of the crumbs is the path-edit affordance. */} + {/* The empty zone right of the crumbs is the path-edit + * affordance: the whole remainder of the bar clicks into + * the editor, and the pencil glyph parked at its right + * edge (with the same tooltip) is what says so — an + * invisible target the operator must guess at is the one + * way into typing a path. */} ) : ( @@ -682,6 +881,9 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // repopulate the view with the older path. supersede() setLoading(false) + // A fresh edit releases the submission hold: the panes + // may follow the new text wherever it points. + previewSuspended.current = false setPathDraft(event.target.value) }} {...compositionGuard} @@ -699,6 +901,11 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // focus on the returning crumb edit zone (a failure // keeps the editor, so the flag waits until close). refocusEditZone.current = true + // The submitted path owns the view now: a debounce + // timer still pending from these keystrokes would + // otherwise supersede this navigation and land the + // draft's parent directory instead. + previewSuspended.current = true navigate(pathDraft) } } @@ -716,7 +923,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, busy={parentInert} onPick={select} showHidden={showHidden} - filterPrefix={draftPrefixFor(parent, pathDraft)} + filterPrefix={child === null ? typedPrefix : null} pathEditing={draftPending} /> )} @@ -728,7 +935,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, busy={parentInert} onPick={advance} showHidden={showHidden} - filterPrefix={draftPrefixFor(child, pathDraft)} + filterPrefix={typedPrefix} pathEditing={draftPending} /> )} diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx index ce9f03fb0b..6329887add 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -11,9 +11,14 @@ const HOME = '/home/u' const DOCS = `${HOME}/Documents` const HARNESS = `${DOCS}/harness` -/** Listing fake over a tiny fixed tree; unknown paths reject like the Host. */ +/** + * Listing fake over a tiny fixed tree; unknown paths reject like the Host. + * A trailing separator is dropped the way the Host's own `resolve` drops it, + * so a directory part typed into the path editor addresses its level. + */ function listingFor(path?: string): DirectoryListing { - const target = path ?? HOME + const asked = path ?? HOME + const target = asked.length > 1 && asked.endsWith('/') ? asked.slice(0, -1) : asked const tree: Record = { [HOME]: { path: HOME, @@ -647,7 +652,7 @@ describe('DirectoryBrowser', () => { }) it('prefix-filters the listed level from the draft tail, dot revealing hidden matches', async () => { - mount() + const b = mount() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) const input = screen.getByLabelText('browser.editPath') @@ -659,16 +664,29 @@ describe('DirectoryBrowser', () => { // A dot-led prefix names hidden entries, so it reveals the match. fireEvent.change(input, { target: { value: `${HOME}/.co` } }) expect(screen.getByRole('listitem').textContent).toBe('.config') - // A prefix matching nothing empties the level (no stale rows linger). + // A prefix nobody matches releases the filter: the level shows whole + // (hidden rows back under the toggle) instead of emptying under a name + // the operator is still spelling. fireEvent.change(input, { target: { value: `${HOME}/zzz` } }) - expect(screen.queryByRole('listitem')).toBeNull() - // A draft naming some other directory (or none) leaves the level whole. + expect(screen.getAllByRole('listitem').map(item => item.textContent)).toEqual(['Documents']) + // Its dot-led reveal lapses with it. + fireEvent.change(input, { target: { value: `${HOME}/.zzz` } }) + expect(screen.getAllByRole('listitem').map(item => item.textContent)).toEqual(['Documents']) + // A tail inside the listed level names no level to walk to: the wait + // fires and finds nothing to scan. + const settled = b.listDirectory.mock.calls.length + await act(async () => { await new Promise((resolve) => { setTimeout(resolve, 400) }) }) + expect(b.listDirectory.mock.calls).toHaveLength(settled) + // A draft naming some other directory (or none) leaves the level whole — + // and a draft with no separator at all addresses no directory either. fireEvent.change(input, { target: { value: 'no-separator' } }) expect(screen.getByRole('listitem').textContent).toBe('Documents') + await act(async () => { await new Promise((resolve) => { setTimeout(resolve, 400) }) }) + expect(b.listDirectory.mock.calls).toHaveLength(settled) }) it('filters the child pane in two-pane mode and follows the draft back up a level', async () => { - mount() + const b = mount() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) fireEvent.click(rowButton(screen.getByRole('listitem'))) await waitFor(() => { expect(columns()).toHaveLength(2) }) @@ -679,15 +697,301 @@ describe('DirectoryBrowser', () => { expect(input.value).toBe(`${DOCS}/`) fireEvent.change(input, { target: { value: `${DOCS}/h` } }) expect(within(columns()[1]!).getByText('harness')).toBeTruthy() + // The child pane already lists that directory: no scan follows, and both + // panes stay. + const settled = b.listDirectory.mock.calls.length + await act(async () => { await new Promise((resolve) => { setTimeout(resolve, 400) }) }) + expect(b.listDirectory.mock.calls).toHaveLength(settled) + expect(columns()).toHaveLength(2) + // A miss releases the right pane's filter rather than emptying it. fireEvent.change(input, { target: { value: `${DOCS}/zzz` } }) - expect(within(columns()[1]!).queryAllByRole('listitem')).toHaveLength(0) - expect(within(columns()[0]!).getByText('Documents')).toBeTruthy() - // Erasing back into the parent's own path moves the filter to the LEFT - // pane and releases the right one. The selected row is exempt (it - // anchors the two-pane view), so it alone survives the miss. - fireEvent.change(input, { target: { value: `${HOME}/zz` } }) - expect(within(columns()[0]!).getAllByRole('listitem').map(item => item.textContent)).toEqual(['Documents']) expect(within(columns()[1]!).getByText('harness')).toBeTruthy() + expect(within(columns()[0]!).getByText('Documents')).toBeTruthy() + // Erasing back into the parent's own path re-lands on it rather than + // filtering the LEFT pane: the level being typed is always the last pane, + // never a pane with a deeper level standing to its right. Home is the + // display root, so it lands alone. + fireEvent.change(input, { target: { value: `${HOME}/zz` } }) + await waitFor(() => { expect(columns()).toHaveLength(1) }) + expect(screen.getAllByRole('listitem').map(item => item.textContent)).toEqual(['Documents']) + }) + + it('follows the draft into a directory no pane lists, landing the two-pane Miller view', async () => { + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + expect(columns()).toHaveLength(1) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + // Typing past a separator addresses a level nobody shows: the panes walk + // to it once the typing rests, landing the ordinary selection-anchored + // two-pane view (level | its children) with the tail filtering the right + // pane — a typed path moves the Miller view exactly as a crumb jump does. + fireEvent.change(input, { target: { value: `${DOCS}/h` } }) + await waitFor(() => { expect(columns()).toHaveLength(2) }) + expect(b.listDirectory).toHaveBeenCalledWith(`${DOCS}/`, expect.anything()) + expect(within(columns()[0]!).getByText('Documents')).toBeTruthy() + expect(within(columns()[1]!).getByText('harness')).toBeTruthy() + // Still editing: the panes moved under the draft, the editor stayed. + expect(screen.getByLabelText('browser.editPath').value).toBe(`${DOCS}/h`) + // Typing on inside a level the panes already list costs no scan at all: + // the prefix filter alone answers the draft, both panes stay. + const settled = b.listDirectory.mock.calls.length + fireEvent.change(input, { target: { value: `${DOCS}/ha` } }) + await act(async () => { await new Promise((resolve) => { setTimeout(resolve, 400) }) }) + expect(b.listDirectory.mock.calls).toHaveLength(settled) + expect(columns()).toHaveLength(2) + }) + + it('keeps the typed level in the last pane, its parent beside it, as the draft walks', async () => { + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + // Two levels down: the typed level on the right, its parent on the left. + fireEvent.change(input, { target: { value: `${HARNESS}/` } }) + await waitFor(() => { expect(within(columns()[0]!).getByText('harness')).toBeTruthy() }) + expect(columns()).toHaveLength(2) + expect(within(columns()[1]!).queryAllByRole('listitem')).toHaveLength(0) + // Erasing back to the parent's own path re-lands on it: the level being + // typed moves BACK into the last pane instead of staying on the left with + // its own child pane still to the right. + fireEvent.change(input, { target: { value: `${DOCS}/ha` } }) + await waitFor(() => { expect(within(columns()[0]!).getByText('Documents')).toBeTruthy() }) + expect(columns()).toHaveLength(2) + expect(within(columns()[1]!).getAllByRole('listitem').map(item => item.textContent)).toEqual(['harness']) + expect(b.listDirectory).toHaveBeenCalledWith(`${DOCS}/`, expect.anything()) + }) + + it('holds a stale pane still until its landing, instead of narrowing it first', async () => { + // Own three-level tree: the level that goes stale needs two rows for the + // narrowing this pins against to be visible at all. + const ROOT = '/u' + const MID = `${ROOT}/mid` + const LEAF = `${MID}/leaf` + const chain = [{ name: '/', path: '/', hidden: false }, { name: 'u', path: ROOT, hidden: false }] + const tree: Record = { + [ROOT]: { + path: ROOT, + home: ROOT, + crumbs: chain, + entries: [{ name: 'mid', path: MID, hidden: false }, { name: 'other', path: `${ROOT}/other`, hidden: false }], + truncated: false, + }, + [MID]: { + path: MID, + home: ROOT, + crumbs: [...chain, { name: 'mid', path: MID, hidden: false }], + entries: [{ name: 'leaf', path: LEAF, hidden: false }, { name: 'sibling', path: `${MID}/sibling`, hidden: false }], + truncated: false, + }, + [LEAF]: { + path: LEAF, + home: ROOT, + crumbs: [...chain, { name: 'mid', path: MID, hidden: false }, { name: 'leaf', path: LEAF, hidden: false }], + entries: [], + truncated: false, + }, + } + mount({ + listDirectory: vi.fn(async (path?: string) => { + const asked = path ?? ROOT + const found = tree[asked.length > 1 && asked.endsWith('/') ? asked.slice(0, -1) : asked] + if (found === undefined) throw new Error(`cannot list ${asked}`) + return found + }), + }) + await waitFor(() => { expect(screen.getByText('mid')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + fireEvent.change(input, { target: { value: `${LEAF}/` } }) + await waitFor(() => { expect(columns()).toHaveLength(2) }) + expect(within(columns()[0]!).getAllByRole('listitem').map(item => item.textContent)).toEqual(['leaf', 'sibling']) + // Deleting the separator names the level the LEFT pane lists. That pane + // is stale — its landing will move it right — so it must not narrow to + // the tail first: one deletion, one movement. + fireEvent.change(input, { target: { value: LEAF } }) + expect(within(columns()[0]!).getAllByRole('listitem').map(item => item.textContent)).toEqual(['leaf', 'sibling']) + await waitFor(() => { expect(within(columns()[0]!).getByText('other')).toBeTruthy() }) + expect(within(columns()[1]!).getAllByRole('listitem').map(item => item.textContent)).toEqual(['leaf']) + }) + + it('keeps the walked-to panes when the editor is cancelled, Open adopting where the walk ended', async () => { + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + fireEvent.change(input, { target: { value: `${DOCS}/h` } }) + await waitFor(() => { expect(columns()).toHaveLength(2) }) + fireEvent.keyDown(input, { key: 'Escape' }) + // Cancel closes the editor; it does not rewind the walk. The operator + // watched the panes move, so the crumbs, the panes, and Open's target all + // stay where the walk ended. + expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull() + expect(columns()).toHaveLength(2) + expect(within(columns()[0]!).getByText('Documents')).toBeTruthy() + expect(within(columns()[1]!).getByText('harness')).toBeTruthy() + expect(screen.getByRole('navigation').textContent).toContain('Documents') + const open = screen.getByRole('button', { name: 'browser.open' }) + expect(open.disabled).toBe(false) + fireEvent.click(open) + expect(b.onOpen).toHaveBeenCalledWith(DOCS) + }) + + it('waits both legs out for a walk: one keystroke never flashes a single pane', async () => { + let landParent = (): void => {} + const listDirectory = vi.fn(async (path?: string) => { + // The parent leg outlives the submitted-navigation wait bound; a walk + // has nothing waiting on it, so it holds the stale view instead of + // landing single-pane and upgrading. + if (path === HOME) return await new Promise((resolve) => { landParent = () => { resolve(listingFor(HOME)) } }) + return listingFor(path) + }) + mount({ listDirectory }) + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + fireEvent.change(input, { target: { value: `${DOCS}/h` } }) + await waitFor(() => { expect(listDirectory).toHaveBeenCalledWith(HOME, expect.anything()) }) + await act(async () => { await new Promise((resolve) => { setTimeout(resolve, 400) }) }) + // Well past the submitted-navigation bound: still the pre-walk view. + expect(columns()).toHaveLength(1) + expect(screen.getByText('Documents')).toBeTruthy() + await act(async () => { landParent() }) + await waitFor(() => { expect(columns()).toHaveLength(2) }) + expect(within(columns()[1]!).getByText('harness')).toBeTruthy() + }) + + it('walks the panes back up when erased segments leave the listed levels', async () => { + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + fireEvent.change(input, { target: { value: `${DOCS}/h` } }) + await waitFor(() => { expect(columns()).toHaveLength(2) }) + // Erasing back to a directory neither pane lists walks up to it; the + // filesystem root is the display root, so it lands the single wide level + // with the tail filtering it. + fireEvent.change(input, { target: { value: '/ho' } }) + await waitFor(() => { expect(columns()).toHaveLength(1) }) + expect(b.listDirectory).toHaveBeenCalledWith('/', expect.anything()) + expect(screen.getAllByRole('listitem').map(item => item.textContent)).toEqual(['home']) + }) + + it('re-arms the draft-following scan after a keystroke superseded one in flight', async () => { + let started = 0 + const listDirectory = vi.fn(async (path?: string) => { + if (path !== `${DOCS}/`) return listingFor(path) + started += 1 + // The first scan never settles: the next keystroke aborts it, and only + // a re-armed wait can still land the level the draft names. + if (started === 1) return await new Promise(() => {}) + return listingFor(path) + }) + mount({ listDirectory }) + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + fireEvent.change(input, { target: { value: `${DOCS}/h` } }) + await waitFor(() => { expect(started).toBe(1) }) + // A further tail keystroke supersedes the in-flight scan; the panes must + // still follow, not sit on the stale level until a separator is typed. + fireEvent.change(input, { target: { value: `${DOCS}/ha` } }) + await waitFor(() => { expect(screen.getByText('harness')).toBeTruthy() }) + }) + + it('follows the draft again after an edit releases a failed submission hold', async () => { + const listDirectory = vi.fn(async (path?: string) => { + if (path === HARNESS) throw new Error('target unreadable') + return listingFor(path) + }) + mount({ listDirectory }) + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + // Submitting inside the debounce window holds the pending scan back. + fireEvent.change(input, { target: { value: HARNESS } }) + fireEvent.keyDown(input, { key: 'Enter' }) + await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('target unreadable') }) + // Correcting only the final segment leaves the directory part unchanged; + // the edit must still release the hold and re-arm the wait. + fireEvent.change(input, { target: { value: `${HARNESS}x` } }) + await waitFor(() => { expect(listDirectory).toHaveBeenCalledWith(`${DOCS}/`, expect.anything()) }) + await waitFor(() => { expect(screen.getByText('harness')).toBeTruthy() }) + }) + + it('re-parks focus on the editor when a landed scan unmounts the focused row', async () => { + mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + // Two levels down, so the walk replaces the LEFT pane the focused row + // lives in (a landing that re-lists the same level reuses its rows). + fireEvent.change(input, { target: { value: `${HARNESS}/` } }) + // The keyboard path: focus Tabbed onto a row of the level about to be + // replaced. Without a re-park it would fall to body, outside a Modal that + // has no focus trap. + rowButton(screen.getByRole('listitem')).focus() + await waitFor(() => { expect(within(columns()[0]!).getByText('harness')).toBeTruthy() }) + expect(document.activeElement).toBe(screen.getByLabelText('browser.editPath')) + }) + + it('keeps the panes and stays silent when a draft-following scan fails', async () => { + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + fireEvent.change(input, { target: { value: `${HOME}/nope/x` } }) + await waitFor(() => { expect(b.listDirectory).toHaveBeenCalledWith(`${HOME}/nope/`, expect.anything()) }) + // A half-typed directory is unreadable most of the time: the last + // readable level keeps rendering and no error interrupts the typing. + expect(screen.getByText('Documents')).toBeTruthy() + expect(screen.queryByRole('alert')).toBeNull() + }) + + it('holds the draft-following scan while a submitted path is in flight', async () => { + const listDirectory = vi.fn(async (path?: string) => { + // The submitted leg never settles, so the debounce window elapses with + // the navigation still owning the view. + if (path === HARNESS) return await new Promise(() => {}) + return listingFor(path) + }) + mount({ listDirectory }) + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + fireEvent.change(input, { target: { value: HARNESS } }) + fireEvent.keyDown(input, { key: 'Enter' }) + await act(async () => { await new Promise((resolve) => { setTimeout(resolve, 400) }) }) + // Only the initial home listing and the submitted path — the draft's + // directory part was never scanned behind the navigation's back. + expect(listDirectory.mock.calls.map(call => call[0])).toEqual([undefined, HARNESS]) + }) + + it('discards draft-following scans that a newer edit superseded', async () => { + let landDocs = (): void => {} + let failRoot = (): void => {} + const listDirectory = vi.fn(async (path?: string) => { + if (path === `${DOCS}/`) return await new Promise((resolve) => { landDocs = () => { resolve(listingFor(DOCS)) } }) + if (path === '/') { + return await new Promise((_, reject) => { + failRoot = () => { reject(new Error('root unreadable')) } + }) + } + return listingFor(path) + }) + mount({ listDirectory }) + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + fireEvent.change(input, { target: { value: `${DOCS}/h` } }) + await waitFor(() => { expect(listDirectory).toHaveBeenCalledWith(`${DOCS}/`, expect.anything()) }) + fireEvent.change(input, { target: { value: '/x' } }) + await waitFor(() => { expect(listDirectory).toHaveBeenCalledWith('/', expect.anything()) }) + // Back onto the listed level: neither pending scan may still land. + fireEvent.change(input, { target: { value: `${HOME}/D` } }) + await act(async () => { landDocs(); failRoot() }) + expect(screen.getAllByRole('listitem').map(item => item.textContent)).toEqual(['Documents']) + expect(screen.queryByRole('alert')).toBeNull() }) it('keeps the draft and filter through window focus loss and in-dialog focus moves', async () => { @@ -813,7 +1117,8 @@ describe('DirectoryBrowser', () => { ], truncated: false, } - mount({ listDirectory: vi.fn(async () => windowsListing) }) + const listDirectory = vi.fn(async () => windowsListing) + mount({ listDirectory }) await waitFor(() => { expect(screen.getAllByRole('listitem')).toHaveLength(2) }) fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) const input = screen.getByLabelText('browser.editPath') @@ -821,6 +1126,18 @@ describe('DirectoryBrowser', () => { expect(input.value).toBe(ROOT) fireEvent.change(input, { target: { value: `${ROOT}u` } }) expect(screen.getByRole('listitem').textContent).toBe('Users') + // Windows separates on a forward slash too (so does the Host's resolve), + // so a path typed that way names its directory; the level the Host + // answers with spells it back with a backslash, and once that scan lands + // the level answers the typed spelling — the tail filters it. + fireEvent.change(input, { target: { value: 'C:/p' } }) + await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('Program Files') }) + // And the same spelling asks for no second scan. + const settled = listDirectory.mock.calls.length + fireEvent.change(input, { target: { value: 'C:/pr' } }) + await act(async () => { await new Promise((resolve) => { setTimeout(resolve, 400) }) }) + expect(listDirectory.mock.calls).toHaveLength(settled) + expect(screen.getByRole('listitem').textContent).toBe('Program Files') }) it('clicking away from the path editor cancels it back to the crumb view', async () => { @@ -977,6 +1294,10 @@ describe('DirectoryBrowser', () => { fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) const input = screen.getByLabelText('browser.editPath') fireEvent.change(input, { target: { value: DOCS } }) + // With no level listed there is no platform separator to read, so the + // draft-following wait resolves to nothing and the editor types blind. + await act(async () => { await new Promise((resolve) => { setTimeout(resolve, 400) }) }) + expect(listDirectory).toHaveBeenCalledTimes(1) listDirectory.mockImplementation(async (path?: string) => listingFor(path)) fireEvent.keyDown(input, { key: 'Enter' }) await waitFor(() => { expect(screen.getByText('harness')).toBeTruthy() }) diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index f7043e2403..ceac4245a5 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/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/subagent/subagent/README.md -README.md: 9aea27a0f150d90a41d9a7cb4cd422a75e6107fe -README.zh.md: 3f0b534deae53b8d5aff2765974050f26b931953 +README.md: e54f0b98ec3649cec428a47026e6657a9749608b +README.zh.md: 1624fa59854d9b61770c5ef0f9d89f7882198da4 diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 9aea27a0f1..e54f0b98ec 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -92,6 +92,8 @@ Provider additions and removals also emit `subagent/provider-added` and `subagen Continuable children do not create `SubagentRun` or Tasks. The continuation manager directly owns one process-local Activation and retained `AgentHandle` per resident child Session, uses the Agent inbox as the only FIFO, and cold-resumes from the durable descriptor. Exact live direct-parent identity authorizes parent-to-child delivery. Exact live child identity authorizes reports; the manager derives the recipient from durable `parentSession`, and `MessageSource` remains provenance rather than authority. +When `ctx.sessionProjections` is available, the service registers `subagentTiming`. The projection resets at each descriptor so a fork seed's ancestor work cannot enter the child's total, then accumulates `turn/start` → `turn/end` active time and retains same-cut `active.since` and `active.through` bounds for an open turn. While that turn remains open, `active.through` follows the latest folded event, giving an inactive consumer a conservative crash bound without mixing in newer session metadata. + `registerContinuableSetup()` lets optional packages add child-scoped capabilities without teaching the continuation manager their names. Contributions install synchronously before Activation publication, roll back with failed setup, and are released with the child scope. New grants wait for the next Activation, while contribution removal revokes every resident installation immediately. ## Collection model diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 3f0b534dea..1624fa5985 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -92,6 +92,8 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 可继续子级不会创建 `SubagentRun` 或 Task。延续管理器为每个驻留子 Session 直接拥有一个仅存在于当前进程的 Activation 和一个留存的 `AgentHandle`,使用 Agent inbox 作为唯一 FIFO,并从持久化描述符冷恢复。父到子投递由准确的实时直接父级身份授权。上报则由准确的实时子级身份授权;管理器根据持久化的 `parentSession` 推导接收方,`MessageSource` 仍只表示来源,不表示权限。 +当 `ctx.sessionProjections` 可用时,服务会注册 `subagentTiming`。该投影会在每个描述符处重置,使 fork 种子中的祖先工作不会计入 child 总量,随后累加 `turn/start` → `turn/end` 活跃时间,并为未结束的轮次保留同一切面的 `active.since` 和 `active.through` 边界。在该轮次保持未结束期间,`active.through` 会跟随最近折叠的事件,从而为 inactive 消费方提供保守的崩溃上界,又不会混入更新的会话元数据。 + `registerContinuableSetup()` 允许可选包添加子级作用域功能,而无需让延续管理器知道这些功能的名称。贡献会在 Activation 发布前同步安装,在设置失败时一并回滚,并随子级作用域释放。新授权须等到下一个 Activation,移除贡献则会立即撤销每个驻留安装项。 ## 收集模型 diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json index 863f04c117..8cd78c5526 100644 --- a/packages/subagent/subagent/package.json +++ b/packages/subagent/subagent/package.json @@ -15,17 +15,25 @@ "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, + "./client": { + "types": "./lib/types/client.d.ts", + "default": "./lib/types/client.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", "lib/invariant.js", + "lib/types/**/*.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", + "dependencies": { + "zod": "^4.4.3" + }, "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-brand": "^0.0.1", @@ -35,6 +43,7 @@ "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-persistence": "^0.0.1", "@deepseek-ai/dsh-session-query": "^0.0.1", + "@deepseek-ai/dsh-session-projection": "^0.0.1", "@deepseek-ai/dsh-tasks": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "cordis": "^4.0.0-rc.7" @@ -46,6 +55,9 @@ "@deepseek-ai/dsh-session-query": { "optional": true }, + "@deepseek-ai/dsh-session-projection": { + "optional": true + }, "@deepseek-ai/dsh-tasks": { "optional": true } @@ -59,6 +71,7 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-query": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-tasks": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "cordis": "^4.0.0-rc.7" diff --git a/packages/subagent/subagent/src/activation-setup-registry.ts b/packages/subagent/subagent/src/activation-setup-registry.ts index c0fc84552c..5681e89863 100644 --- a/packages/subagent/subagent/src/activation-setup-registry.ts +++ b/packages/subagent/subagent/src/activation-setup-registry.ts @@ -12,6 +12,7 @@ */ import type { Context } from 'cordis' +import type { AgentSetupCommit } from '@deepseek-ai/dsh-agent' import { errorChain } from '@deepseek-ai/dsh-llm' import { SubagentError } from './error.ts' @@ -47,17 +48,6 @@ interface TransactionState { invalidated: boolean } -/** Package-private setup transaction consumed by the continuation manager. */ -export interface ActivationSetupTransaction { - /** - * Reject a batch invalidated by revocation before publication. - * @throws {SubagentError} code `ACTIVATION_SETUP_REVOKED` after revocation. - */ - assertIntact(): void - /** Promote this batch to resident installations. */ - commit(): void -} - /** Re-read mutable removal state after a contribution may have revoked itself. */ function isRemoved(registration: Registration): boolean { return registration.removed @@ -95,9 +85,9 @@ export class SubagentActivationSetupRegistry { /** * Install every live contribution into one unpublished child context. * @param childCtx - the child's unpublished scoped context. - * @returns the provisioning transaction. + * @returns the provisioning commit consumed at Agent publication. */ - apply(childCtx: Context): ActivationSetupTransaction { + apply(childCtx: Context): AgentSetupCommit { const state: TransactionState = { installations: [], invalidated: false } try { for (const registration of [...this.registrations]) { @@ -135,15 +125,14 @@ export class SubagentActivationSetupRegistry { } childCtx.effect(() => () => { this.releaseChild(childCtx) }, 'subagents.activationSetup()') return { - assertIntact: () => { - if (!state.invalidated) return - throw new SubagentError( - 'a continuable-subagent setup contribution was revoked while this child was being built; ' - + 'the child was not established', - 'ACTIVATION_SETUP_REVOKED', - ) - }, commit: () => { + if (state.invalidated) { + throw new SubagentError( + 'a continuable-subagent setup contribution was revoked while this child was being built; ' + + 'the child was not established', + 'ACTIVATION_SETUP_REVOKED', + ) + } for (const installation of state.installations) installation.transaction = undefined }, } diff --git a/packages/subagent/subagent/src/client.ts b/packages/subagent/subagent/src/client.ts new file mode 100644 index 0000000000..928637dc7a --- /dev/null +++ b/packages/subagent/subagent/src/client.ts @@ -0,0 +1,7 @@ +/** + * Browser-safe subagent projection vocabulary. + * + * @module @deepseek-ai/dsh-subagent/client + */ + +export type { SubagentTimingProjection } from './projection-types.ts' diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index d21f235589..f690cab6cf 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -20,6 +20,7 @@ import type { Agent, AgentHandle, AgentOptions, + AgentSetupCommit, CreateAgentOptions, } from '@deepseek-ai/dsh-agent' import { createUserMessage, errorChain } from '@deepseek-ai/dsh-llm' @@ -42,7 +43,6 @@ import type { ContinuableCreateRequest, ContinuableCreateSpec, SubagentStartRequ import type { ActivationObserver } from './lifecycle.ts' import { SubagentError } from './error.ts' import type SubagentActivationSetupRegistry from './activation-setup-registry.ts' -import type { ActivationSetupTransaction } from './activation-setup-registry.ts' /** Attribution for a model coordinator's follow-up to one of its children. */ export interface CoordinatorMessageSource { @@ -800,10 +800,9 @@ export class SubagentContinuationManager { // `AgentRegistry.enter()` is the authoritative collision boundary for an id // some other owner holds — a duplicate would reject there with rollback. inputs.signal.throwIfAborted() - let setupTransaction!: ActivationSetupTransaction - const setup = (childCtx: Context): void => { + const setup = (childCtx: Context): AgentSetupCommit => { applyChildComposition(childCtx, inputs.composition) - setupTransaction = this.setupRegistry.apply(childCtx) + return this.setupRegistry.apply(childCtx) } const observer = this.host.observeActivation(provider, childId, parent) const { create } = inputs @@ -842,7 +841,6 @@ export class SubagentContinuationManager { try { inputs.signal.throwIfAborted() this.assertAdmitting(parent) - setupTransaction.assertIntact() this.acquireOwnership(parent, childId) // Every accepted id leaves the inbox exactly once, through dequeue or // discard. Clearing it there is what lets `stateOf()` distinguish a truly @@ -860,8 +858,8 @@ export class SubagentContinuationManager { for (const item of items) activation.accepted.delete(item.message.id) this.wake(activation) }) - // Resident setup revokes live from here instead of invalidating creation. - setupTransaction.commit() + // Agent creation committed setup at its publication boundary; + // revocations from here on are immediate live revocation. // Publish the start edge before any turn can run, so observers see this // epoch before its first request. observer.start(handle.agent) diff --git a/packages/subagent/subagent/src/descriptor.ts b/packages/subagent/subagent/src/descriptor.ts index 22acbecbdd..55ba73dcc4 100644 --- a/packages/subagent/subagent/src/descriptor.ts +++ b/packages/subagent/subagent/src/descriptor.ts @@ -12,6 +12,11 @@ * omits `subagentDepth` — cold resume trusts the persisted header's * `delegationDepth` as the monotone floor — and `outputSchema`, which belongs * to one activation's result contract rather than durable child composition. + * Per-activation knobs such as `maxTokens` are omitted for the same reason as + * `outputSchema`: they budget one activation. Cold resume requires the exact + * live parent for authorization but reconstructs child options only from the + * durable descriptor, so it neither restores the prior budget nor inherits + * the parent's current one; the resumed route's defaults apply instead. * * @module @deepseek-ai/dsh-subagent/descriptor */ diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 19ad74ff3a..975d07ce4c 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -65,6 +65,7 @@ import type { ContinuableSetupContribution } from './activation-setup-registry.t import { listChildren as listSubagentChildren } from './list-children.ts' import type { SubagentListEntry } from './list-children.ts' import { snapshotSubagentDescriptor } from './descriptor.ts' +import { subagentTimingProjectionDefinition } from './projection.ts' export * from './out-of-process.ts' export { SubagentRunId } from './types.ts' @@ -117,6 +118,7 @@ export type { export type { ContinuableSetupContribution } from './activation-setup-registry.ts' export type { SubagentListEntry } from './list-children.ts' export type { SubagentRunEndInfo, SubagentRunInfo } from './types.ts' +export type { SubagentTimingProjection } from './projection-types.ts' declare module 'cordis' { interface Context { @@ -186,6 +188,9 @@ export class SubagentService extends Service { if (this.continuations === manager) this.continuations = undefined }, 'subagents.continuationBinding()') }) + ctx.inject(['sessionProjections'], (projectionCtx) => { + projectionCtx.sessionProjections.register(subagentTimingProjectionDefinition) + }) } /** diff --git a/packages/subagent/subagent/src/projection-types.ts b/packages/subagent/subagent/src/projection-types.ts new file mode 100644 index 0000000000..c5a23b03b8 --- /dev/null +++ b/packages/subagent/subagent/src/projection-types.ts @@ -0,0 +1,25 @@ +/** + * Pure client-safe subagent projection vocabulary. + * + * @module @deepseek-ai/dsh-subagent/projection-types + */ + +/** Durable active-turn timing for one descriptor-backed child session. */ +export interface SubagentTimingProjection { + /** Milliseconds accumulated across completed turns after the child's own descriptor. */ + settledMs: number + /** Same-cut bounds of the currently open turn, when one has not reached `turn/end`. */ + active?: { + /** Start of the open turn. */ + since: number + /** Latest event time folded into this projection cut. */ + through: number + } +} + +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionMap { + /** Active-turn duration for a descriptor-backed subagent session. */ + subagentTiming: SubagentTimingProjection + } +} diff --git a/packages/subagent/subagent/src/projection.ts b/packages/subagent/subagent/src/projection.ts new file mode 100644 index 0000000000..ffdcb4fd09 --- /dev/null +++ b/packages/subagent/subagent/src/projection.ts @@ -0,0 +1,82 @@ +/** + * Pure session projection for subagent active-turn duration. + * + * @module @deepseek-ai/dsh-subagent/projection + */ + +import { z } from 'zod' +import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' +import type { SubagentTimingProjection } from './projection-types.ts' + +interface TimingState { + /** Milliseconds accumulated across completed post-descriptor turns. */ + settledMs: number + /** Current open interval kept paired inside the fold. */ + active?: { since: number; through: number } + /** Latest pre-descriptor turn start, promoted when the child's own descriptor arrives. */ + pendingTurnStart?: number + /** Whether the fold has crossed a descriptor in this logical log. */ + descriptorSeen: boolean +} + +// Zod's optional output includes explicit `undefined`; with +// exactOptionalPropertyTypes the public interface permits omission only. +const projectionSchema = z.object({ + settledMs: z.number().int().nonnegative(), + active: z.object({ + since: z.number().int().nonnegative(), + through: z.number().int().nonnegative(), + }).strict().optional(), +}).strict() as unknown as z.ZodType + +/** + * Fold turn boundaries around the child's own durable descriptor. + * + * A fork seed may contain an ancestor descriptor and completed turns. Every + * descriptor therefore resets the accumulated state; the healthy catalog + * admits only a child with exactly one descriptor in its own suffix, making + * the final reset the child's authoritative timing origin. + */ +export const subagentTimingProjectionDefinition: +ProjectionDefinition<'subagentTiming', TimingState> = { + key: 'subagentTiming', + schema: projectionSchema, + init: () => ({ descriptorSeen: false, settledMs: 0 }), + apply: (state, event) => { + if (event.type === 'turn/start') { + return state.descriptorSeen + ? { ...state, active: { since: event.time, through: event.time } } + : { ...state, pendingTurnStart: event.time } + } + if (event.type === 'subagent/descriptor') { + const activeSince = state.active?.since ?? state.pendingTurnStart + return { + descriptorSeen: true, + settledMs: 0, + ...(activeSince === undefined + ? {} + : { active: { since: activeSince, through: event.time } }), + } + } + if (event.type === 'turn/end') { + if (!state.descriptorSeen) { + if (state.pendingTurnStart === undefined) return state + const { pendingTurnStart: _closed, ...next } = state + return next + } + if (state.active === undefined) return state + const { active, ...rest } = state + return { + ...rest, + settledMs: state.settledMs + Math.max(0, event.time - active.since), + } + } + if (state.active === undefined) return state + return { ...state, active: { ...state.active, through: event.time } } + }, + view: state => ({ + settledMs: state.settledMs, + ...(state.active === undefined ? {} : { active: state.active }), + }), + stateVersion: 2, +} diff --git a/packages/subagent/subagent/tests/activation-setup-registry.spec.ts b/packages/subagent/subagent/tests/activation-setup-registry.spec.ts index 059befe236..6353f486c5 100644 --- a/packages/subagent/subagent/tests/activation-setup-registry.spec.ts +++ b/packages/subagent/subagent/tests/activation-setup-registry.spec.ts @@ -19,8 +19,7 @@ describe('SubagentActivationSetupRegistry', () => { const transaction = registry.apply(child.ctx) expect(order).toEqual(['first', 'second']) - expect(() => { transaction.assertIntact() }).not.toThrow() - transaction.commit() + expect(() => { transaction.commit() }).not.toThrow() expect(order).toEqual(['first', 'second']) }) @@ -68,7 +67,7 @@ describe('SubagentActivationSetupRegistry', () => { remove() expect(disposals).toBe(1) - expect(() => { transaction.assertIntact() }).toThrow(/revoked while this child was being built/) + expect(() => { transaction.commit() }).toThrow(/revoked while this child was being built/) }) it('catches a contribution revoked inside its own installer', () => { @@ -82,7 +81,7 @@ describe('SubagentActivationSetupRegistry', () => { const transaction = registry.apply(childContext().ctx) expect(disposals).toBe(1) - expect(() => { transaction.assertIntact() }).toThrow(/revoked/) + expect(() => { transaction.commit() }).toThrow(/revoked/) }) it('attempts every contribution-removal disposer before reporting failures', () => { diff --git a/packages/subagent/subagent/tests/timing-projection.spec.ts b/packages/subagent/subagent/tests/timing-projection.spec.ts new file mode 100644 index 0000000000..e9a3be43ea --- /dev/null +++ b/packages/subagent/subagent/tests/timing-projection.spec.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import SessionStore from '@deepseek-ai/dsh-session' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import SubagentService from '../src/index.ts' +import { subagentTimingProjectionDefinition } from '../src/projection.ts' + +function event(type: SessionEvent['type'], seq: number, time: number): SessionEvent { + return { type, seq, time, data: {} } as SessionEvent +} + +function fold(events: SessionEvent[]) { + let state = subagentTimingProjectionDefinition.init() + for (const item of events) state = subagentTimingProjectionDefinition.apply(state, item) + return subagentTimingProjectionDefinition.view(state) +} + +describe('subagent timing projection', () => { + it('registers with the optional session projection registry', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + const serviceFiber = await ctx.plugin(SubagentService) + + expect(ctx.sessionProjections.snapshot(ctx.sessions.create()).values.subagentTiming) + .toEqual({ settledMs: 0 }) + await serviceFiber.dispose() + expect(ctx.sessionProjections.snapshot(ctx.sessions.create()).values.subagentTiming) + .toBeUndefined() + }) + + it('resets inherited seed timing at the child descriptor and sums later completed turns', () => { + expect(fold([ + event('turn/start', 0, 100), + event('subagent/descriptor', 1, 110), + event('turn/end', 2, 300), + event('turn/start', 3, 1_000), + event('subagent/descriptor', 4, 1_100), + event('turn/end', 5, 4_100), + event('turn/start', 6, 10_000), + event('turn/end', 7, 12_000), + ])).toEqual({ settledMs: 5_100 }) + }) + + it('exposes an open turn start and never subtracts time for reversed boundaries', () => { + expect(fold([ + event('turn/start', 0, 1_000), + event('subagent/descriptor', 1, 1_100), + event('turn/end', 2, 900), + event('turn/start', 3, 2_000), + event('assistant/chunk', 4, 2_500), + ])).toEqual({ settledMs: 0, active: { since: 2_000, through: 2_500 } }) + }) + + it('ignores completed pre-descriptor turns and unrelated events', () => { + const initial = subagentTimingProjectionDefinition.init() + expect(subagentTimingProjectionDefinition.apply( + initial, + event('assistant/chunk', 0, 1), + )).toBe(initial) + expect(subagentTimingProjectionDefinition.apply( + initial, + event('turn/end', 1, 2), + )).toBe(initial) + const descriptor = subagentTimingProjectionDefinition.apply( + initial, + event('subagent/descriptor', 2, 3), + ) + expect(subagentTimingProjectionDefinition.apply( + descriptor, + event('turn/end', 3, 4), + )).toBe(descriptor) + expect(fold([ + event('turn/start', 0, 100), + event('turn/end', 1, 200), + event('subagent/descriptor', 2, 300), + ])).toEqual({ settledMs: 0 }) + }) +}) diff --git a/packages/subagent/subagent/tsconfig.json b/packages/subagent/subagent/tsconfig.json index 1c3a5fb6de..612330c646 100644 --- a/packages/subagent/subagent/tsconfig.json +++ b/packages/subagent/subagent/tsconfig.json @@ -32,6 +32,9 @@ { "path": "../../session-query/session-query" }, + { + "path": "../../session-projection/session-projection" + }, { "path": "../../tasks/tasks" }, diff --git a/packages/subagent/tool-subagent-report/README.i18n.yaml b/packages/subagent/tool-subagent-report/README.i18n.yaml index 849f6de67c..869dee55bf 100644 --- a/packages/subagent/tool-subagent-report/README.i18n.yaml +++ b/packages/subagent/tool-subagent-report/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/subagent/tool-subagent-report/README.md -README.md: e15b8b5d5881fd7b6868995fec22048a605f4c7e -README.zh.md: 0c41bc9c1e5aa4d728789b064f2d00c8da8ca6c8 +README.md: cd73154dfb9c8b37f4a811c3beedbe6a63207f58 +README.zh.md: 4b31bed48ea0e50ec3a9d507548658defb94b8b8 diff --git a/packages/subagent/tool-subagent-report/README.md b/packages/subagent/tool-subagent-report/README.md index e15b8b5d58..cd73154dfb 100644 --- a/packages/subagent/tool-subagent-report/README.md +++ b/packages/subagent/tool-subagent-report/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) The optional child-scoped `report` tool is a thin adapter over `ctx.subagents.reportFrom()`. It gives every continuable in-process child a return channel to the Agent that started it. The package registers a continuable-child setup contribution instead of a global tool, so `report` exists only inside those children. Roots, one-shot subagents, remote subagent providers, sibling scopes, and agentless tool execution never present or execute it. Installing this package grants only that child-scoped capability; the parent-to-child direction remains the independent [`@deepseek-ai/dsh-tool-subagent-control`](../tool-subagent-control/README.md), and continuable mode depends on neither package. -A child may call `report` zero or many times in one turn. A successful call neither concludes the turn, settles the Activation, nor prevents later parent follow-ups, and finishing a turn never reports automatically. The tool accepts no recipient: `exec.agent` is the sender's exact live Agent and the authority credential, and the service derives the sole recipient from that child's durable `parentSession`. Success returns the stable `MessageId` of the parent-accepted message, not a read receipt, an inbox-occurrence id, a parent-log acknowledgement, a turn-completion receipt, or a persistence flush. A missing, disposed, or closing parent fails the call with `direct parent is not live; report was not delivered`; the service performs no injection, parent cold resume, or offline mailbox write, so the durable child transcript remains the recovery source. +A child may call `report` zero or many times in one turn. A successful call neither concludes the turn, settles the Activation, nor prevents later parent follow-ups, and finishing a turn never reports automatically. The tool accepts no recipient: `exec.agent` is the sender's exact live Agent and the authority credential, and the service derives the sole recipient from that child's durable `parentSession`. Success returns the stable `MessageId` of the parent-accepted message, not a read receipt, an inbox-occurrence id, a parent-log acknowledgement, a turn-completion receipt, or a persistence flush. A parent absent from the registry fails the call with `direct parent is not live; report was not delivered` — registry presence governs parent resolution, and a registered parent already in host-owned disposal still accepts while its log admits appends. The service performs no injection, parent cold resume, or offline mailbox write; the durable child transcript remains the recovery source, and a failed tool call does not prove non-delivery (a later `tools/post-execute` veto can fail a call whose report was already accepted). `reportDelivery` selects parent scheduling for every accepted report. `quiet` (the default) uses `parent.inject()`, adding model-facing context without starting a parent model request: an idle parent's append completes before the call returns, while a report reaching an admitting or running parent stages for the next safe log position. `wakeup` uses `parent.followup()`, creating exactly one ordinary later parent turn and waking a parked parent driver; it never steers an open turn. This is deployment scheduling policy, so the model-facing schema cannot select or override it per call. @@ -58,7 +58,6 @@ Append-only; the report follows the parent's reusable request prefix. Waking del ## Known Limitations and Deferred Work -- **Setup revocation can follow lower-level Session publication** — the final revocation check runs after `ctx.agents.create()` or `ctx.agents.resume()` returns, by which point that call has already published its Agent and Session. Revocation in this window rolls back the handle and prevents the subagent Activation start edge, but may leave a persisted Session. Closing this gap requires a future Agent-creation setup transaction seam before lower-level publication. - **A parent whose host-owned disposal already started can still accept** — `AgentHandle.dispose()` cancels, awaits quiescence, and only then unwinds the scope and leaves the registry; it exposes no signal for "disposal started." A report accepted in that window is appended to the parent's transcript, but that parent will not act on it in this process. A continuation-manager-owned parent rejects forest teardown through the manager's admission boundary. - **Acceptance is weaker than durable delivery** — there is no durable mailbox, idempotency key, delivery receipt, retry protocol, or exactly-once claim. A process failure after one side recorded acceptance leaves the outcome ambiguous, and an external retry may duplicate the report. - **A staged quiet report is not immediately reconstructable** — acceptance returns its stable `MessageId`, but the parent Session reconstructs the framed content only after pending context reaches its ordinary log boundary. diff --git a/packages/subagent/tool-subagent-report/README.zh.md b/packages/subagent/tool-subagent-report/README.zh.md index 0c41bc9c1e..4b31bed48e 100644 --- a/packages/subagent/tool-subagent-report/README.zh.md +++ b/packages/subagent/tool-subagent-report/README.zh.md @@ -4,7 +4,7 @@ 可选的子级作用域 `report` 工具是 `ctx.subagents.reportFrom()` 之上的轻量适配器。它为每个可继续的进程内子级提供一条返回通道,指向启动该子级的 Agent(智能体)。本包(package)注册的是可继续子级设置贡献,而不是全局工具,因此 `report` 只存在于这些子级内部。根 Agent、一次性 subagent、远程 subagent 提供方、同级作用域以及不关联 Agent 的工具执行都不会提供或执行它。安装本包只授予这项子级作用域功能;父到子方向仍由独立的 [`@deepseek-ai/dsh-tool-subagent-control`](../tool-subagent-control/README.md) 负责,可继续模式不依赖这两个包中的任一个。 -子级可以在一个轮次中调用 `report` 零次或多次。调用成功既不会结束轮次或结算 Activation,也不会阻止父级后续消息;轮次结束也绝不会自动上报。该工具不接受接收方参数:`exec.agent` 是发送方准确的实时 Agent,也是权限凭据;服务根据该子级持久化的 `parentSession` 推导唯一接收方。成功时返回父级已接受消息的稳定 `MessageId`,不表示已读回执、inbox 中该次出现的 id、父级日志确认、轮次完成回执或持久化刷盘。父级不存在、已 dispose(资源释放)或正在关闭时,本次调用会失败并返回 `direct parent is not live; report was not delivered`;服务不会执行注入、父级冷恢复或离线 mailbox 写入,因此持久化子级 transcript(文本记录)仍是恢复真源。 +子级可以在一个轮次中调用 `report` 零次或多次。调用成功既不会结束轮次或结算 Activation,也不会阻止父级后续消息;轮次结束也绝不会自动上报。该工具不接受接收方参数:`exec.agent` 是发送方准确的实时 Agent,也是权限凭据;服务根据该子级持久化的 `parentSession` 推导唯一接收方。成功时返回父级已接受消息的稳定 `MessageId`,不表示已读回执、inbox 中该次出现的 id、父级日志确认、轮次完成回执或持久化刷盘。父级解析由注册表中的存在性决定:父级不在注册表时,调用失败并返回 `direct parent is not live; report was not delivered`;已开始宿主 dispose 但仍在注册表中的父级在其日志仍接受追加时仍会接受。服务不会执行注入、父级冷恢复或离线 mailbox 写入;持久化子级 transcript(文本记录)仍是恢复真源,且工具调用失败不能证明未送达(后续 `tools/post-execute` 否决可能让报告已被接受的调用以失败结束)。 `reportDelivery` 为每条已接受的报告选择父级调度方式。`quiet`(默认值)使用 `parent.inject()`,在不启动父级模型请求的情况下添加面向模型的上下文:父级空闲时,追加操作会在调用返回前完成;报告到达正在准入或运行的父级时,则会暂存到下一个安全日志位置。`wakeup` 使用 `parent.followup()`,准确创建一个普通的后续父级轮次,并唤醒停驻的父级驱动;它绝不会对正在运行的轮次进行 steering(中途引导)。这是部署调度策略,因此面向模型的 schema 不能在单次调用中选择或覆盖该策略。 @@ -58,7 +58,6 @@ ## 已知限制与暂缓事项 -- **setup 撤销可能发生在底层 Session 发布之后**:最终撤销检查发生在 `ctx.agents.create()` 或 `ctx.agents.resume()` 返回之后,此时该调用已发布其 Agent 和 Session。在这个窗口内撤销会回滚 handle,并阻止 subagent Activation 的 start 边,但可能留下持久化 Session。要弥合这个缺口,需要未来在底层发布之前提供 Agent 创建 setup 事务 seam。 - **父级可能在宿主启动 dispose 后继续接受报告**:`AgentHandle.dispose()` 会先取消并等待完全停稳,然后才撤销作用域并离开注册表;它不公开「dispose 已开始」信号。在该窗口内接受的报告会追加到父级 transcript,但该父级不会在本进程中处理它。对于由延续管理器拥有的父级,管理器的准入边界会在整棵子树拆卸期间拒绝该上报。 - **接受弱于持久投递**:没有持久化 mailbox、幂等键、投递回执、重试协议,也不保证恰好一次。任一侧记录接受后若进程失败,结果都不明确;外部重试可能产生重复上报。 - **暂存的静默报告无法立即重建**:接受时会返回其稳定 `MessageId`,但只有当待处理上下文到达普通日志边界后,父级 Session 才能重建带前缀的内容。 diff --git a/packages/subagent/tool-subagent-report/src/index.ts b/packages/subagent/tool-subagent-report/src/index.ts index e83d65f830..962d8cf382 100644 --- a/packages/subagent/tool-subagent-report/src/index.ts +++ b/packages/subagent/tool-subagent-report/src/index.ts @@ -88,7 +88,10 @@ export function installReportTool( * @param config - deployment scheduling policy. */ export function apply(ctx: Context, config: Config = {}): void { - const { reportDelivery = 'quiet' } = Config(config) + // Config() applies the schema default ('quiet') at runtime; the schemastery + // return type keeps the input's optional shape, so assert the resolved + // shape here — no runtime fallback exists or is wanted. + const { reportDelivery } = Config(config) as { reportDelivery: SubagentReportDelivery } ctx.subagents.registerContinuableSetup(childCtx => installReportTool(childCtx, ctx, reportDelivery)) } diff --git a/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts b/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts index 31837afa9d..84f33646bb 100644 --- a/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts +++ b/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts @@ -327,6 +327,15 @@ describe('dsh-tool-subagent-report', () => { return dispose }) + // No session may be announced for the rejected child: the setup + // validation must reject inside the creation callback, before the factory + // publishes — a post-publication rejection would persist a resumable + // ghost that `list_agents` surfaces and `send_message` can resurrect. + // The parent was created inside setup(), so any later announcement is the + // rejected child's. + const announced: SessionId[] = [] + const listener = (session: { id: SessionId }): void => { announced.push(session.id) } + const removeListener = ctx.on('session/created', listener) await expect(ctx.subagents.startContinuable({ provider: 'spawn', label: 'racing child', @@ -336,9 +345,56 @@ describe('dsh-tool-subagent-report', () => { }, signal: testSignal, })).rejects.toMatchObject({ code: 'ACTIVATION_SETUP_REVOKED' }) + removeListener() + expect(announced).toEqual([]) expect(ctx.agents.list().map(agent => agent.id)).toEqual([parent.id]) }) + it('rolls back materialization when setup revocation lands before publication', async () => { + const { ctx, parent } = await setup({ load: false }) + const self: { revoke?: () => void } = {} + let installed = false + self.revoke = ctx.subagents.registerContinuableSetup(() => { + installed = true + queueMicrotask(() => { self.revoke?.() }) + return () => { installed = false } + }) + const announced: SessionId[] = [] + const removeListener = ctx.on('session/created', (session) => { announced.push(session.id) }) + + await expect(ctx.subagents.startContinuable({ + provider: 'spawn', + label: 'revoked child', + request: { + prompt: [{ type: 'text', text: 'revoked child' }], + parent, + }, + signal: testSignal, + })).rejects.toMatchObject({ code: 'ACTIVATION_SETUP_REVOKED' }) + removeListener() + expect(installed).toBe(false) + expect(announced).toEqual([]) + expect(ctx.agents.list().map(agent => agent.id)).toEqual([parent.id]) + expect(ctx.sessions.list()).toEqual([parent.session]) + }) + + it('accepts a report into a host-disposing but still-registered parent', async () => { + const { ctx } = await setup() + const parentHandle = await ctx.agents.create({ + sessionId: SessionId('disposing-parent'), + agentOptions: { provider: 'mock', model: 'mock' }, + }) + const { child } = await startChild(ctx, parentHandle.agent) + // Host-owned disposal starts asynchronously; the parent stays registered + // until quiescence, and registry presence — not disposal state — is the + // acceptance gate (pins the README contract). + const disposing = parentHandle.dispose() + const accepted = await callReport(ctx, child, 'during-close') + expect(accepted.isError).toBe(false) + await disposing + expect((await callReport(ctx, child, 'after-close')).isError).toBe(true) + }) + it('keeps the namespace plugin shape and validates its default', () => { expect('default' in tool).toBe(false) expect(tool.name).toBe('tool-subagent-report') diff --git a/packages/support/llm-replay/README.i18n.yaml b/packages/support/llm-replay/README.i18n.yaml index e4f6711ea7..507c3da1a5 100644 --- a/packages/support/llm-replay/README.i18n.yaml +++ b/packages/support/llm-replay/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/support/llm-replay/README.md -README.md: 0deb6e76b29d40483b754ac01c98ee0e01bfcbe8 -README.zh.md: 7720e2d1bc6eb7bc5c89d5c1708767a54a7b0080 +README.md: 85aa56705929e7630e4cfb6c2a3c9cbbd0d843a6 +README.zh.md: 751f75dea197ffb112cfa703e3a5dbfaffb8c0b2 diff --git a/packages/support/llm-replay/README.md b/packages/support/llm-replay/README.md index 0deb6e76b2..85aa567059 100644 --- a/packages/support/llm-replay/README.md +++ b/packages/support/llm-replay/README.md @@ -12,6 +12,8 @@ The fixture IS the persisted session log (`/session.jsonl`). Its `assi Two failure modes are not reconstructable from `assistant/chunk` alone — a pure throw before any chunk (e.g. an HTTP 401, where the log holds only a `turn/end {error}` and no chunks) and a cancel/hang (timing, not chunk content). A scenario that needs those supplies an optional sidecar (`/replay.override.json`) that either replaces the derived script (a bare `ReplayEntry[]`) or augments it (`{ patches: [{ at, entry }] }`: keep every JSONL-derived call and swap the named 0-based call indexes; `at` equal to the derived length appends the retry attempt after an injected transient throw). Patch indexes must be unique. The override document, each patch and entry, and every chunk discriminant are validated when the file loads. A `hang` entry may name `readyFile`; replay writes that empty marker after its prefix chunks reach the loop and before it waits for cancellation, so an external driver can cancel deterministically without observing a presentation update. +A scripted string may embed `{{fromRequest:}}` to fill a value no static sidecar can know — for example a randomly minted goal id the model must echo back into `update_goal`. At stream time every placeholder resolves against the live request: the corpus is every string leaf of the request messages joined by newlines, the pattern's LAST corpus match wins, and its first capture group (or the whole match without one) substitutes in place. A pattern that matches nothing, an invalid pattern, and an unterminated placeholder each fail loud. The last two braces of a consecutive `}` run terminate the placeholder, so a pattern may end with a brace quantifier (`[0-9a-f]{4}`) but cannot contain `}}` followed by further pattern content. Resolution applies to every scripted entry, including ones derived from the recorded JSONL — a recorded fixture whose text legitimately contains the literal marker must be expressed through a sidecar without it. + ## Nested agents: per-session keying A scenario where a parent agent delegates to in-process subagents records more than one log: the parent (`session.jsonl`) plus one per child (`session.1.jsonl`, …). Each agent runs as its own `Session` on the same context, so replay must serve each one its own script. @@ -55,7 +57,7 @@ Replay keys every call by its calling session id (`GenerateOptions.sessionId`, s - `installLlmReplay(ctx, config)` — install the configured replay adapter or catch-all `llm/stream` listener; returns a `ReplayHandle` (`dispose()` for HMR safety plus `assertConsumed()`, the teardown check that every recorded script bound to a live session and every bound cursor drained — turning a scenario that silently drove fewer model calls than recorded into a crisp diagnostic). Use this in tests to drive replay without the Loader or env vars. - `loadSessionScripts(config)` — resolve the ordered `SessionScript[]` (primary + children) for a scenario, ready to bind to live sessions in first-call order. - `loadReplayScript(config)` — resolve the `ReplayEntry[]` for the primary session only (validated sidecar replacement/patches if present, else derived from the JSONL; fail-loud if the fixture is missing). -- `deriveReplayScript(events)` / `parseSessionLog(text)` / `parseSessionHeader(text)` — the pure helpers that turn a recorded session log into a script and read its header `id`/`createdAt`. A derived group must end in a `finish` chunk; a group without one is the fingerprint of a thrown `stream()` and must instead be expressed via an override sidecar. +- `deriveReplayScript(events)` / `parseSessionLog(text)` / `parseSessionHeader(text)` / `resolveScriptedEntry(entry, messages)` — the pure helpers that turn a recorded session log into a script, read its header `id`/`createdAt`, and resolve `{{fromRequest:...}}` placeholders against one live request. A derived group must end in a `finish` chunk; a group without one is the fingerprint of a thrown `stream()` and must instead be expressed via an override sidecar. - Types `ReplayEntry` / `ReplayOverrideDoc` / `ReplayOverridePatch` / `SessionScript` / `ReplayConfig` / `ReplayProviderConfig` / `ReplayModelConfig` / `ReplayHandle` / `Config`. ## Plugin export shape diff --git a/packages/support/llm-replay/README.zh.md b/packages/support/llm-replay/README.zh.md index 7720e2d1bc..751f75dea1 100644 --- a/packages/support/llm-replay/README.zh.md +++ b/packages/support/llm-replay/README.zh.md @@ -12,6 +12,8 @@ fixture 就是持久化的会话日志(`/session.jsonl`)。其 `as 有两种失败模式无法仅根据 `assistant/chunk` 重建:在产生任何分片前直接抛出异常(例如 HTTP 401,此时日志只有 `turn/end {error}` 而没有分片),以及取消或挂起(差异在时序,而非分片内容)。需要这些行为的场景可提供伴随文件(`/replay.override.json`):它可以替换派生脚本(裸 `ReplayEntry[]`),也可以增补派生脚本(`{ patches: [{ at, entry }] }`:保留所有从 JSONL 派生的调用,只替换指定的从 0 开始计数的调用索引;当 `at` 等于派生长度时,则在注入瞬态异常后的重试位置追加一次调用)。补丁索引不得重复。文件加载时会校验覆写文档、每个补丁和条目,以及每个分片的判别标签。`hang` 条目可以指定 `readyFile`;当前缀分片到达循环后、开始等待取消前,回放会写入这个空标记,使外部驱动程序无需观察展示层更新即可确定性地取消。 +脚本字符串可以内嵌 `{{fromRequest:}}`,用来填入静态伴随文件不可能预知的值——例如模型必须原样回填到 `update_goal` 的随机生成 goal id。回放时每个占位符针对实时请求解析:语料是请求消息的所有字符串叶子按换行拼接的结果,取该模式在语料中的最后一次匹配,用其第一个捕获组(无捕获组时用整个匹配)原位替换。模式匹配不到内容、模式非法、占位符未闭合都会明确报错。连续右花括号串的最后两个花括号才是占位符结束符,因此模式可以以花括号量词收尾(如 `[0-9a-f]{4}`),但不能在 `}}` 之后还有后续模式内容。解析作用于所有脚本条目,包括从已记录 JSONL 派生的条目——若录制文本本身合法地含有该字面量标记,需改用不含标记的伴随文件表达。 + ## 嵌套 agent:每会话键控 父 agent 委托给进程内 subagent(子 agent)的场景会记录多个日志:父会话使用 `session.jsonl`,每个子会话各使用一个日志(`session.1.jsonl` 等)。每个 agent 都在同一上下文中作为独立的 `Session` 运行,因此回放必须为每个 agent 提供各自的脚本。 @@ -55,7 +57,7 @@ fixture 就是持久化的会话日志(`/session.jsonl`)。其 `as - `installLlmReplay(ctx, config)`:安装已配置回放适配器或 catch-all `llm/stream` 监听器;返回 `ReplayHandle`(包含用于保证 HMR(热模块替换)安全的 `dispose()`,以及清理阶段执行的 `assertConsumed()` 检查;后者确保每个已记录脚本都绑定到实时会话,且每个已绑定游标都已耗尽,从而将场景静默驱动的模型调用少于记录数转换为明确诊断)。在测试中使用它,可以不通过 Loader 或 env var 驱动回放。 - `loadSessionScripts(config)`:解析场景的有序的 `SessionScript[]`(主会话 + 子会话),准备按首次调用顺序绑定到实时会话。 - `loadReplayScript(config)`:只解析主会话的 `ReplayEntry[]`(如果伴随文件存在,则使用经校验的替换或补丁;否则从 JSONL 派生;fixture 缺失时明确报错)。 -- `deriveReplayScript(events)` / `parseSessionLog(text)` / `parseSessionHeader(text)`:将已记录会话日志转换为脚本并读取其 header `id`/`createdAt` 的纯辅助工具。派生分组必须以 `finish` 分片结束;没有该分片的分组是 `stream()` 抛出异常的指纹,必须改用 override sidecar 表达。 +- `deriveReplayScript(events)` / `parseSessionLog(text)` / `parseSessionHeader(text)` / `resolveScriptedEntry(entry, messages)`:将已记录会话日志转换为脚本、读取其 header `id`/`createdAt`、并针对单次实时请求解析 `{{fromRequest:...}}` 占位符的纯辅助工具。派生分组必须以 `finish` 分片结束;没有该分片的分组是 `stream()` 抛出异常的指纹,必须改用 override sidecar 表达。 - 类型 `ReplayEntry` / `ReplayOverrideDoc` / `ReplayOverridePatch` / `SessionScript` / `ReplayConfig` / `ReplayProviderConfig` / `ReplayModelConfig` / `ReplayHandle` / `Config`。 ## 插件导出形态 diff --git a/packages/support/llm-replay/src/index.ts b/packages/support/llm-replay/src/index.ts index bfb9859415..4b89a844bf 100644 --- a/packages/support/llm-replay/src/index.ts +++ b/packages/support/llm-replay/src/index.ts @@ -241,6 +241,96 @@ const REPLAY_CHUNK_TYPES = new Set([ 'finish', ]) +const FROM_REQUEST_OPEN = '{{fromRequest:' +const FROM_REQUEST_CLOSE = '}}' + +/** Collect every string leaf of one JSON-shaped value, in traversal order. */ +function collectStrings(value: unknown, out: string[]): void { + if (typeof value === 'string') { + out.push(value) + return + } + if (Array.isArray(value)) { + for (const item of value) collectStrings(item, out) + return + } + if (value !== null && typeof value === 'object') { + for (const item of Object.values(value)) collectStrings(item, out) + } +} + +/** Resolve one placeholder pattern against the request corpus; the LAST match wins. */ +function resolveFromRequest(pattern: string, corpus: string): string { + let regex: RegExp + try { + regex = new RegExp(pattern, 'g') + } catch (error) { + // RegExp construction only throws SyntaxError; String() carries its message. + throw new Error(`llm-replay: fromRequest has an invalid pattern ${JSON.stringify(pattern)}: ${String(error)}`) + } + let last: RegExpExecArray | undefined + for (const match of corpus.matchAll(regex)) last = match + if (last === undefined) { + throw new Error(`llm-replay: fromRequest pattern ${JSON.stringify(pattern)} matched nothing in the request`) + } + return last[1] ?? last[0] +} + +/** Replace every `{{fromRequest:}}` occurrence in one scripted string. */ +function substituteString(text: string, corpus: string): string { + let result = '' + let cursor = 0 + while (true) { + const open = text.indexOf(FROM_REQUEST_OPEN, cursor) + if (open === -1) return result + text.slice(cursor) + let close = text.indexOf(FROM_REQUEST_CLOSE, open + FROM_REQUEST_OPEN.length) + if (close === -1) { + throw new Error(`llm-replay: fromRequest placeholder is unterminated in ${JSON.stringify(text)}`) + } + // The last two braces of a consecutive `}` run terminate the placeholder, + // so a pattern may end with a brace quantifier like `[0-9a-f]{4}`. + while (text[close + FROM_REQUEST_CLOSE.length] === '}') close += 1 + const pattern = text.slice(open + FROM_REQUEST_OPEN.length, close) + result += text.slice(cursor, open) + resolveFromRequest(pattern, corpus) + cursor = close + FROM_REQUEST_CLOSE.length + } +} + +/** Deep-copy one JSON-shaped value with scripted placeholders resolved. */ +function substituteValue(value: unknown, corpus: string): unknown { + if (typeof value === 'string') { + return value.includes(FROM_REQUEST_OPEN) ? substituteString(value, corpus) : value + } + if (Array.isArray(value)) return value.map(item => substituteValue(item, corpus)) + if (value !== null && typeof value === 'object') { + return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, substituteValue(item, corpus)])) + } + return value +} + +/** + * Resolve every `{{fromRequest:}}` placeholder in one scripted entry + * against the live request. The corpus is every string leaf of the request + * messages joined by newlines; the pattern's LAST corpus match wins and its + * first capture group (or, without one, the whole match) substitutes in place. + * Scenario sidecars use this to script arguments no static file can know, + * such as a randomly minted goal id the model must echo back. A pattern that + * matches nothing, an invalid pattern, and an unterminated placeholder each + * fail loud. The last two braces of a consecutive `}` run terminate the + * placeholder, so a pattern may end with a brace quantifier but cannot + * contain `}}` followed by further pattern content. Derived entries pass + * through the same resolution as sidecar entries. + * @param entry - the scripted entry about to replay. + * @param messages - the live request messages searched by the placeholders. + * @returns the entry itself when no placeholder appears, else a resolved deep copy. + */ +export function resolveScriptedEntry(entry: ReplayEntry, messages: GenerateOptions['messages']): ReplayEntry { + if (!JSON.stringify(entry).includes(FROM_REQUEST_OPEN)) return entry + const leaves: string[] = [] + collectStrings(messages, leaves) + return substituteValue(entry, leaves.join('\n')) as ReplayEntry +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } @@ -583,7 +673,7 @@ export function installLlmReplay(ctx: Context, config: ReplayConfig): ReplayHand + `but its script has only ${boundState.entries.length}; re-record the scenario`, ) } - yield* replayEntry(entry, options.signal, paceMs) + yield* replayEntry(resolveScriptedEntry(entry, options.messages), options.signal, paceMs) })() } const providers = config.providers ?? [] diff --git a/packages/support/llm-replay/tests/llm-replay.spec.ts b/packages/support/llm-replay/tests/llm-replay.spec.ts index d693c0571b..8113dbc922 100644 --- a/packages/support/llm-replay/tests/llm-replay.spec.ts +++ b/packages/support/llm-replay/tests/llm-replay.spec.ts @@ -4,7 +4,7 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' import type { SessionEvent } from '@deepseek-ai/dsh-session' -import LlmService, { GenerateOptions, LlmAdapter, StreamChunk } from '@deepseek-ai/dsh-llm' +import LlmService, { CallId, createUserMessage, GenerateOptions, LlmAdapter, StreamChunk } from '@deepseek-ai/dsh-llm' import { type ReplayEntry, type SessionScript, @@ -17,6 +17,7 @@ import { name, parseSessionHeader, parseSessionLog, + resolveScriptedEntry, } from '../src/index.ts' /** @@ -310,6 +311,80 @@ describe('installLlmReplay (through the real LlmService)', () => { expect(await drain(ctx.llm.stream({ provider: 'm', model: 'm', messages: [] }))).toEqual(TEXT_CHUNKS) }) + describe('{{fromRequest:...}} substitution', () => { + const requestMessages = [createUserMessage({ + content: [{ type: 'text' as const, text: 'stale {"goal":{"id":"goal-old"}} then {"goal":{"id":"goal-42ab"}}' }], + source: { kind: 'user' as const }, + })] + + function scriptedCall(argumentsDelta: string): StreamChunk[] { + return [ + { type: 'block-start', index: 0, blockType: 'tool-call' }, + { type: 'tool-call-delta', index: 0, id: CallId('c1'), name: 'update_goal', argumentsDelta }, + { type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('c1'), name: 'update_goal', arguments: argumentsDelta } }, + { type: 'finish', reason: { kind: 'tool-calls' } }, + ] + } + + async function streamScripted(argumentsDelta: string): Promise { + writeLog(TEXT_CHUNKS) + const overrideFile = join(dir, 'replay.override.json') + writeFileSync(overrideFile, JSON.stringify([{ kind: 'chunks', chunks: scriptedCall(argumentsDelta) }]), 'utf8') + const ctx = new Context() + await ctx.plugin(LlmService) + installLlmReplay(ctx, { file, overrideFile }) + return drain(ctx.llm.stream({ provider: 'm', model: 'm', messages: requestMessages })) + } + + it('resolves the capture group from the LAST request match in every scripted string field', async () => { + const streamed = await streamScripted('{"goal_id":"{{fromRequest:"id":"(goal-[^"]+)"}}","revision":1}') + const delta = streamed.find(chunk => chunk.type === 'tool-call-delta') + expect(delta).toMatchObject({ argumentsDelta: '{"goal_id":"goal-42ab","revision":1}' }) + const end = streamed.find(chunk => chunk.type === 'block-end') + expect(end).toMatchObject({ block: { arguments: '{"goal_id":"goal-42ab","revision":1}' } }) + }) + + it('substitutes the whole match when the pattern has no capture group', async () => { + const streamed = await streamScripted('{"goal_id":"{{fromRequest:goal-[0-9a-z]+}}"}') + const delta = streamed.find(chunk => chunk.type === 'tool-call-delta') + expect(delta).toMatchObject({ argumentsDelta: '{"goal_id":"goal-42ab"}' }) + }) + + it('keeps a trailing brace quantifier inside the pattern (terminator is the run tail)', async () => { + const streamed = await streamScripted('{"goal_id":"{{fromRequest:goal-[0-9a-z]{4}}}"}') + const delta = streamed.find(chunk => chunk.type === 'tool-call-delta') + expect(delta).toMatchObject({ argumentsDelta: '{"goal_id":"goal-42ab"}' }) + }) + + it('fails loud when a placeholder matches nothing in the request', async () => { + await expect(streamScripted('{"goal_id":"{{fromRequest:task-[0-9]+}}"}')) + .rejects.toThrow(/fromRequest.*matched nothing/) + }) + + it('fails loud on an invalid placeholder pattern', async () => { + await expect(streamScripted('{"goal_id":"{{fromRequest:(goal-}}"}')) + .rejects.toThrow(/fromRequest.*invalid pattern/) + }) + + it('fails loud on an unterminated placeholder', () => { + const entry: ReplayEntry = { kind: 'chunks', chunks: scriptedCall('{"goal_id":"{{fromRequest:goal-1"}') } + expect(() => resolveScriptedEntry(entry, requestMessages)).toThrow(/fromRequest placeholder is unterminated/) + }) + + it('returns the exact same entry when no placeholder appears', () => { + const entry: ReplayEntry = { kind: 'chunks', chunks: TEXT_CHUNKS } + expect(resolveScriptedEntry(entry, requestMessages)).toBe(entry) + }) + + it('skips non-string request leaves when building the corpus', () => { + const messages = requestMessages.map(message => ({ ...message, seq: 7 })) as unknown as GenerateOptions['messages'] + const entry: ReplayEntry = { kind: 'chunks', chunks: scriptedCall('{"goal_id":"{{fromRequest:goal-42[a-z]+}}"}') } + const resolved = resolveScriptedEntry(entry, messages) + if (resolved.kind !== 'chunks') throw new Error('expected chunks entry') + expect(resolved.chunks[1]).toMatchObject({ argumentsDelta: '{"goal_id":"goal-42ab"}' }) + }) + }) + it('registers a replay-only provider catalog when configured', async () => { writeLog(TEXT_CHUNKS) const ctx = new Context() diff --git a/packages/ui/app-boot/README.i18n.yaml b/packages/ui/app-boot/README.i18n.yaml index 65a471f69a..d565f6f11c 100644 --- a/packages/ui/app-boot/README.i18n.yaml +++ b/packages/ui/app-boot/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/ui/app-boot/README.md -README.md: 2eb9e904d574df39b0884558fc0a53f9dc04cdc1 -README.zh.md: 78bd99943fcadebf42a5d772d49f3bfcf6a8790a +README.md: 7e0466c40583e6f5b22e0d5ef25d211d595c3216 +README.zh.md: abb796aaa9fd6f8e6ee0578423382ed7f23909ab diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index 2eb9e904d5..7e0466c405 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -8,7 +8,8 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c |---|---| | `resolveConfigPath(path, snapshotMode, cwd?)` | Absolute config path; `snapshotMode === 'replay'` swaps a `cordis.yml`/`.yaml` basename for its sibling `cordis.snapshot.yml` | | `loadEnv(binName, dir?, warn?)` | Load the gitignored `.env` (Node `process.loadEnvFile`); absent file is fine, an unloadable one warns a single labelled line (default: stderr) | -| `installFailLoud(binName, proc?)` | Turn an unhandled boot or later Loader rejection into one labelled stderr line + `exit(1)`; returns the uninstaller (for tests) | +| `installFailLoud(binName, proc?, release?)` | Turn an unhandled boot or later Loader rejection into one labelled stderr line + `exit(1)`; the optional `release` teardown is awaited between the two (bounded by `FAIL_LOUD_RELEASE_TIMEOUT_MS`) so a terminal-owning surface restores the terminal before exit; returns the uninstaller (for tests) | +| `FAIL_LOUD_RELEASE_TIMEOUT_MS` | How long `installFailLoud` waits for its `release` hook; a wedged disposer delays the fatal exit, never cancels it | | `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber, reporting every unresolved plugin name as a Cordis startup failure | | `assertEntriesActivated(ctx, binName)` | Include the `assertEntriesLoaded` check, then await every enabled entry after the Loader settles; throw with each failed plugin's original stack or each pending plugin's unresolved services | | `loadPersonalPatches(binName, dir?)` | Parse the optional `config.yaml` in the Harness home (default [`resolveDshHome()`](../../util/paths/README.md): `$DSH_HOME`, else `~/.dsh`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws | @@ -22,6 +23,8 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c Loader settlement rejects import and lifecycle failures with the failing entry and stage; `boot()` disposes the partial context and wraps that failure with the bin name. Entries settlement leaves behind are audited separately: `assertEntriesLoaded` turns an enabled fiber-less entry into a rejection naming every unresolved plugin, and `assertEntriesActivated` awaits each failed fiber to include its original stack in the startup rejection and names each pending entry's unresolved services. Before throwing, the audit marks those exact rejection reasons through one process checkpoint so `installFailLoud` coalesces Loader's duplicate notification while every unrelated unhandled rejection remains fatal. +The Loader mounts entries concurrently, so a surface can already own the terminal when something else fails: exiting without the tree's own teardown would leave raw mode, bracketed paste, and the keyboard protocol set on the user's shell, and an in-flight terminal query's reply would land as literal text at the next prompt. A config-tree failure settles through `boot()`, whose disposal of the partial context runs the surface's own shutdown before the labelled rejection. For the rejections `boot()` cannot see — a plugin's detached async work rejecting during or after mounting — a terminal-owning bin passes `release` to dispose the tree before the exit commits; `dsh` captures the root context in `boot()`'s `prepare` hook rather than from its return value so the hook covers the whole mounting window. While a release is in flight the handler stays installed and latched: the first rejection is the reported one, and later rejections (teardown's own included) are swallowed rather than becoming uncaught and killing the process mid-teardown. + Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve through the Cordis Loader's internal module loader. Repository bins install Loader's optional `node-addon-require-builtin` peer; external callers must supply it or install plugins where plain Node import resolution can find them. Relative specifiers resolve against the config directory without the native helper. The built `dsh-app-boot` artifact embeds the statically mounted Include implementation while leaving Loader external, so the include tree and host bind to one Loader peer. The `dsh` source launcher additionally maps manifest-declared workspace packages to their TypeScript source; its configuration gate requires every TUI/Web bare plugin to appear in the resolver manifest's `dependencies`. The bins' subprocess smokes exercise the internal-loader path, while this package's unit suite drives `boot()` in-process against configs with relative specifiers. This package carries no loader hooks and no dev-mode surface. The [`dsh` app](../../../apps/cli/README.md) owns its Node source-launch hook and consumes these helpers for the boot sequence; built consumers continue to use plain Node package resolution. diff --git a/packages/ui/app-boot/README.zh.md b/packages/ui/app-boot/README.zh.md index 78bd99943f..abb796aaa9 100644 --- a/packages/ui/app-boot/README.zh.md +++ b/packages/ui/app-boot/README.zh.md @@ -8,7 +8,8 @@ |---|---| | `resolveConfigPath(path, snapshotMode, cwd?)` | 生成绝对配置路径;当 `snapshotMode === 'replay'` 时,把 basename 为 `cordis.yml`/`.yaml` 的文件替换为同级 `cordis.snapshot.yml` | | `loadEnv(binName, dir?, warn?)` | 加载已被 git 忽略的 `.env`(Node `process.loadEnvFile`);文件不存在不影响启动,文件无法加载时输出一行带标签的警告(默认写入 stderr) | -| `installFailLoud(binName, proc?)` | 将启动期或后续未处理的 Loader rejection 转换为一行带标签的 stderr 消息并执行 `exit(1)`;返回卸载函数(供测试使用) | +| `installFailLoud(binName, proc?, release?)` | 将启动期或后续未处理的 Loader rejection 转换为一行带标签的 stderr 消息并执行 `exit(1)`;两者之间会等待可选的 `release` 拆卸回调(以 `FAIL_LOUD_RELEASE_TIMEOUT_MS` 为上限),使持有终端的界面能在退出前恢复终端;返回卸载函数(供测试使用) | +| `FAIL_LOUD_RELEASE_TIMEOUT_MS` | `installFailLoud` 等待其 `release` 回调的时长;卡住的 disposer 只会延迟致命退出,而不会取消它 | | `assertEntriesLoaded(ctx, binName)` | 树结算后,如果其中存在已启用但没有 fiber 的条目,则抛出异常,并以 Cordis 启动故障的形式报告每个未解析插件的名称 | | `assertEntriesActivated(ctx, binName)` | 先执行 `assertEntriesLoaded` 检查,再在 Loader 结算后等待每个已启用配置项;抛出的错误包含每个失败插件的原始错误堆栈,或每个等待中插件尚未解析的服务 | | `loadPersonalPatches(binName, dir?)` | 解析 Harness home 中可选的 `config.yaml`(默认使用 [`resolveDshHome()`](../../util/paths/README.md):先取 `$DSH_HOME`,否则取 `~/.dsh`):其顶层是一个 YAML 数组,内容为 include 的 `PatchOptions`(按 id 定位的配置覆盖、`insert` 列表,允许 `!!js`);文件不存在时返回 `undefined`,文件不可读、不可解析或内容不是数组时抛出异常 | @@ -22,6 +23,8 @@ Loader 结算会在导入或生命周期失败时 reject,并携带失败的配置项与阶段;`boot()` 会 dispose 部分构造的上下文,并用 bin 名称包装该失败。结算后遗留的配置项由独立审计处理:`assertEntriesLoaded` 将已启用却没有 fiber 的配置项转换为 rejection 并列出每个未解析插件;`assertEntriesActivated` 会显式等待每个失败的 fiber,把原始错误堆栈写入启动 rejection,并列出每个等待中配置项尚未解析的服务。抛出错误前,审计会通过一个进程级检查点标记这些 rejection 的确切原因,从而让 `installFailLoud` 将 Loader 的重复通知合并为一次,而所有无关的未处理 rejection 仍然致命。 +Loader 并发挂载各个条目,因此当其他环节失败时,某个界面可能已经持有终端:此时不经过整棵树自身的拆卸就退出,会把 raw 模式、bracketed paste 和键盘协议残留在用户的 shell 上,而尚未返回的终端查询响应会在下一个提示符处显示为字面文本。配置树失败会经 `boot()` 结算:它先释放部分构建的上下文(从而执行该界面自身的 shutdown),再抛出带标签的 rejection。对于 `boot()` 看不到的 rejection(插件游离的异步工作在挂载期间或挂载完成后失败),持有终端的 bin 会传入 `release`,在提交退出前释放整棵树;`dsh` 在 `boot()` 的 `prepare` 回调中捕获根上下文,而不是取其返回值,使该回调覆盖整个挂载窗口。release 执行期间处理函数保持注册并加闩:被报告的始终是第一个 rejection,后续 rejection(包括拆卸自身的)会被吞掉,而不会变成未捕获错误、在拆卸中途杀死进程。 + 配置中的裸插件 specifier(`@deepseek-ai/dsh-*`、npm 包(package))通过 Cordis Loader 的内部模块 loader 解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。相对 specifier 无需原生 helper,并以配置目录为基准解析。构建后的 `dsh-app-boot` 产物内嵌静态挂载的 Include 实现,但仍将 Loader 保持为外部依赖,因此 include 树与 host 会绑定到同一个 Loader peer。`dsh` 源码启动器还会将 manifest(元数据清单)声明的 workspace 包映射到其 TypeScript 源码;其配置门禁要求每个 TUI/Web 裸插件都出现在解析所用 manifest 的 `dependencies` 中。bin 的子进程冒烟测试覆盖内部 loader 路径,而本包的单元测试套件会在进程内使用相对 specifier 配置驱动 `boot()`。 此包不包含 loader 钩子,也不提供开发模式接口。[`dsh` 应用](../../../apps/cli/README.md)持有自己的 Node 源码启动钩子,并在启动序列中使用这些 helper;构建后的消费方仍使用普通 Node 包解析。 diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index eb5003f72b..7f3579cda1 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -391,6 +391,11 @@ export interface FailLoudProcess { on(event: 'unhandledRejection', handler: (err: unknown) => void): unknown off(event: 'unhandledRejection', handler: (err: unknown) => void): unknown stderr: { write(chunk: string): unknown } + /** + * Terminate the process. Callers treat this as the end of the run, as + * `process.exit` is; a fake that returns lets the caller continue, which only + * a test observes. + */ exit(code: number): void } @@ -421,24 +426,81 @@ async function observeLoaderRejectionCheckpoint(reasons: readonly unknown[]): Pr } } +/** + * How long {@link installFailLoud} waits for its `release` hook before exiting + * anyway. A wedged disposer must delay the fatal exit, never cancel it. + */ +export const FAIL_LOUD_RELEASE_TIMEOUT_MS = 2_000 + /** * Install before boot to turn a late unhandled plugin-init rejection into one * labelled stderr diagnostic and `exit(1)`. A rejection already included by * {@link assertEntriesActivated} is ignored during its process checkpoint; * every other rejection remains fatal. Stdout remains untouched for ACP; the * returned function removes the handler. + * + * The Loader mounts entries concurrently, so a surface that owns the terminal + * can already hold it when a sibling entry rejects. Exiting straight from the + * handler would strand raw mode, bracketed paste, and the keyboard protocol on + * the user's shell, and leave an in-flight terminal query's reply to land as + * literal text at the next prompt. `release` is the terminal owner's chance to + * hand it back; it is awaited under {@link FAIL_LOUD_RELEASE_TIMEOUT_MS}, whose + * timer stays referenced so a never-settling disposer cannot let Node reach an + * empty event loop and exit 0 instead of failing. + * + * The diagnostic is written before the release so a hanging or failing disposer + * cannot swallow the reason. The handler stays installed while the release runs + * — removing it would let a second concurrent rejection become uncaught and kill + * the process mid-teardown, stranding exactly the terminal state this restores — + * so a latch keeps the first rejection the reported one and lets later + * rejections (including the release's own) fall through to the pending exit. * @param binName - the diagnostic prefix on the fatal-failure line. * @param proc - the process slice to register on; tests inject a fake. + * @param release - optional teardown awaited before exit, used by a + * terminal-owning surface to restore the terminal. Its own failure is + * swallowed because the pending fatal exit already owns the outcome. * @returns the uninstaller that removes the rejection handler. */ -export function installFailLoud(binName: string, proc: FailLoudProcess = process): () => void { +export function installFailLoud( + binName: string, + proc: FailLoudProcess = process, + release?: () => Promise | void, +): () => void { + let exiting = false const handler = (err: unknown): void => { if (assembledActivationRejections.has(err)) return + // A release in flight already owns the exit. Swallow later rejections + // (teardown's own included) rather than reporting a second failure over the + // real one or letting Node kill the process before the terminal is back. + if (exiting) return + exiting = true proc.stderr.write(`${binName}: fatal load failure: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`) - proc.exit(1) + if (release === undefined) { + proc.exit(1) + return + } + void (async () => { + // Definitely assigned: the timeout promise's executor runs synchronously + // while the race is being constructed, before the first await. + let timer!: ReturnType + try { + await Promise.race([ + (async () => release())(), + new Promise((resolve) => { + timer = setTimeout(resolve, FAIL_LOUD_RELEASE_TIMEOUT_MS) + }), + ]) + } catch { + // The terminal release failed; the fatal exit below is the outcome that + // matters, and no reporter runs after it. + } + clearTimeout(timer) + proc.exit(1) + })() } + const uninstall = (): void => void proc.off('unhandledRejection', handler) proc.on('unhandledRejection', handler) - return () => void proc.off('unhandledRejection', handler) + return uninstall } /** diff --git a/packages/ui/app-boot/tests/app-boot.spec.ts b/packages/ui/app-boot/tests/app-boot.spec.ts index 610f93b3d5..96cad31ea3 100644 --- a/packages/ui/app-boot/tests/app-boot.spec.ts +++ b/packages/ui/app-boot/tests/app-boot.spec.ts @@ -5,7 +5,8 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import { - addHarnessSourceSection, assertEntriesActivated, assertEntriesLoaded, boot, HARNESS_SOURCE_SECTION, + addHarnessSourceSection, assertEntriesActivated, assertEntriesLoaded, boot, + FAIL_LOUD_RELEASE_TIMEOUT_MS, HARNESS_SOURCE_SECTION, installFailLoud, loadEnv, loadOverlayPatches, resolveConfigPath, type FailLoudProcess, } from '../src/index.ts' @@ -109,16 +110,22 @@ describe('installFailLoud', () => { expect(proc.exits).toEqual([1]) }) + // One rejection is reported per install: the first is the diagnosis, so each + // formatting case needs its own handler rather than reusing a latched one. it('stringifies a non-Error rejection and an Error without a stack falls back to its message', () => { - const proc = fakeProc() - installFailLoud(NAME, proc) - proc.handlers[0]!('plain failure') - expect(proc.written[0]).toContain('plain failure') + const plain = fakeProc() + installFailLoud(NAME, plain) + plain.handlers[0]!('plain failure') + expect(plain.written[0]).toContain('plain failure') + expect(plain.exits).toEqual([1]) + const stackless = new Error('no stack') delete (stackless as { stack?: string }).stack - proc.handlers[0]!(stackless) - expect(proc.written[1]).toContain('no stack') - expect(proc.exits).toEqual([1, 1]) + const bare = fakeProc() + installFailLoud(NAME, bare) + bare.handlers[0]!(stackless) + expect(bare.written[0]).toContain('no stack') + expect(bare.exits).toEqual([1]) }) it('returns an uninstaller that removes the handler (and defaults to the real process)', () => { @@ -162,6 +169,64 @@ describe('installFailLoud', () => { proc.handlers[0]!(error) expect(proc.exits).toEqual([1]) }) + + // The Loader mounts entries concurrently, so a terminal-owning surface can + // already hold raw mode when a sibling entry rejects. Exiting without running + // its teardown strands the terminal on the user's shell. + it('awaits the release hook before exiting so the terminal owner can restore it', async () => { + const proc = fakeProc() + const order: string[] = [] + installFailLoud(NAME, proc, async () => { + await Promise.resolve() + order.push('released') + }) + proc.handlers[0]!(new Error('sibling entry rejected')) + expect(proc.written[0]).toContain(`${NAME}: fatal load failure: `) + // The release is in flight, so the exit has not committed yet. + expect(proc.exits).toEqual([]) + await vi.waitFor(() => { expect(proc.exits).toEqual([1]) }) + expect(order).toEqual(['released']) + }) + + it('still exits when the release hook rejects', async () => { + const proc = fakeProc() + installFailLoud(NAME, proc, () => Promise.reject(new Error('terminal stop failed'))) + proc.handlers[0]!(new Error('boom')) + await vi.waitFor(() => { expect(proc.exits).toEqual([1]) }) + }) + + it('exits without waiting when a release hook never settles', async () => { + vi.useFakeTimers() + try { + const proc = fakeProc() + installFailLoud(NAME, proc, () => new Promise(() => {})) + proc.handlers[0]!(new Error('boom')) + expect(proc.exits).toEqual([]) + await vi.advanceTimersByTimeAsync(FAIL_LOUD_RELEASE_TIMEOUT_MS) + expect(proc.exits).toEqual([1]) + } finally { + vi.useRealTimers() + } + }) + + // Loader failures arrive in bursts, and teardown's own disposers may reject. + // Only the first rejection is the diagnosis; the handler must stay installed + // so a later one cannot become uncaught and kill the process mid-teardown. + it('reports only the first rejection and keeps handling later ones during the release', async () => { + const proc = fakeProc() + let released = false + installFailLoud(NAME, proc, async () => { + await Promise.resolve() + released = true + }) + proc.handlers[0]!(new Error('first rejection')) + proc.handlers[0]!(new Error('second rejection')) + expect(proc.handlers).toHaveLength(1) + expect(proc.written).toHaveLength(1) + expect(proc.written[0]).toContain('first rejection') + await vi.waitFor(() => { expect(proc.exits).toEqual([1]) }) + expect(released).toBe(true) + }) }) describe('assertEntriesLoaded', () => { diff --git a/packages/ui/tool-ask-user/README.i18n.yaml b/packages/ui/tool-ask-user/README.i18n.yaml index 7b8e667b58..85beed8474 100644 --- a/packages/ui/tool-ask-user/README.i18n.yaml +++ b/packages/ui/tool-ask-user/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/ui/tool-ask-user/README.md -README.md: 8e779f4025c20cd200344efb7cb8cd6bc09ba64d -README.zh.md: acaffec0764404a0e0e842ffc2b4efdee8869c4f +README.md: 64da4d75d01a0df0ae51b1557ed1c796317b906f +README.zh.md: 48a5b5d0d1aadff8a522d0b6100c6d0479b948a5 diff --git a/packages/ui/tool-ask-user/README.md b/packages/ui/tool-ask-user/README.md index 8e779f4025..64da4d75d0 100644 --- a/packages/ui/tool-ask-user/README.md +++ b/packages/ui/tool-ask-user/README.md @@ -15,7 +15,7 @@ Model-facing `ask_user_question` tool over `ctx.userInteraction`. It lets the mo - `options` — optional choices with `label` and `description`. If recommending a choice, put it first and append `(Recommended)` to that label. - `multi_select` — whether that question may return more than one selected option. -The tool calls `ctx.userInteraction.ask()` and returns canonical `{ answers: [{ id, selected, custom? }] }`. `selected` contains option labels; `custom` is present only for a free-form answer and overrides selected choices. The Native renderer preserves the compact JSON text shape `{ "answers": [{ "id": "...", "selected": ["..."], "custom": "..." }] }`. +The tool calls `ctx.userInteraction.ask()` and returns canonical `{ answers: [{ id, selected, custom? }] }`. `selected` contains option labels; `custom` carries a free-form answer, supplementing `selected` for a multi-select question and overriding it for a single-select question. The Native renderer preserves the compact JSON text shape `{ "answers": [{ "id": "...", "selected": ["..."], "custom": "..." }] }`. ## Role diff --git a/packages/ui/tool-ask-user/README.zh.md b/packages/ui/tool-ask-user/README.zh.md index acaffec076..48a5b5d0d1 100644 --- a/packages/ui/tool-ask-user/README.zh.md +++ b/packages/ui/tool-ask-user/README.zh.md @@ -15,7 +15,7 @@ - `options`:可选选项,包含 `label` 和 `description`。如需推荐某个选项,请将其置于首位,并在该标签末尾追加 `(Recommended)`。 - `multi_select`:该问题是否可以返回多个选中的选项。 -工具调用 `ctx.userInteraction.ask()`,并返回规范的 `{ answers: [{ id, selected, custom? }] }`。`selected` 包含选项标签;仅当用户自由填写回答时才会出现 `custom`,并覆盖选中的选项。Native 渲染器会保留紧凑的 JSON 文本形式 `{ "answers": [{ "id": "...", "selected": ["..."], "custom": "..." }] }`。 +工具调用 `ctx.userInteraction.ask()`,并返回规范的 `{ answers: [{ id, selected, custom? }] }`。`selected` 包含选项标签;`custom` 携带自由填写的回答,对于多选题会补充 `selected`,对于单选题则会覆盖它。Native renderer 会保留紧凑的 JSON 文本形式 `{ "answers": [{ "id": "...", "selected": ["..."], "custom": "..." }] }`。 ## 职责 diff --git a/packages/ui/tool-ask-user/tests/tool-ask-user.spec.ts b/packages/ui/tool-ask-user/tests/tool-ask-user.spec.ts index 395986aed1..0c55e33ed7 100644 --- a/packages/ui/tool-ask-user/tests/tool-ask-user.spec.ts +++ b/packages/ui/tool-ask-user/tests/tool-ask-user.spec.ts @@ -140,7 +140,8 @@ describe('ask_user_question tool', () => { async ask() { return { answers: [ - { id: 'targets', selected: ['tests', 'docs'] }, + { id: 'targets', selected: ['tests', 'docs'], custom: 'release notes' }, + { id: 'labels-only', selected: ['tests'] }, { id: 'notes', selected: [], custom: 'ship today' }, ], } @@ -159,6 +160,12 @@ describe('ask_user_question tool', () => { options: [{ label: 'tests' }, { label: 'docs' }], multi_select: true, }, + { + id: 'labels-only', + question: 'Which labels should I keep?', + options: [{ label: 'tests' }, { label: 'docs' }], + multi_select: true, + }, { id: 'notes', question: 'Any note?' }, ], }, @@ -168,13 +175,14 @@ describe('ask_user_question tool', () => { if (result.isError) throw new Error('expected ask_user_question success') expect(result.value).toEqual({ answers: [ - { id: 'targets', selected: ['tests', 'docs'] }, + { id: 'targets', selected: ['tests', 'docs'], custom: 'release notes' }, + { id: 'labels-only', selected: ['tests'] }, { id: 'notes', selected: [], custom: 'ship today' }, ], }) expect(result.content).toEqual([{ type: 'text', - text: '{"answers":[{"id":"targets","selected":["tests","docs"]},{"id":"notes","selected":[],"custom":"ship today"}]}', + text: '{"answers":[{"id":"targets","selected":["tests","docs"],"custom":"release notes"},{"id":"labels-only","selected":["tests"]},{"id":"notes","selected":[],"custom":"ship today"}]}', }]) }) diff --git a/packages/ui/tui/README.i18n.yaml b/packages/ui/tui/README.i18n.yaml index 930bbf2c27..de0cf52845 100644 --- a/packages/ui/tui/README.i18n.yaml +++ b/packages/ui/tui/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/ui/tui/README.md -README.md: c81cac891403e5294c4456ce4d4048ecd74666ce -README.zh.md: 01055619f4df460284564f0a1816de366d809e01 +README.md: 5199e75d732f384497b28518920cc2c775c600c2 +README.zh.md: 02d4431fdab575b5596152c985b72594a948b081 diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index c81cac8914..5199e75d73 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -12,7 +12,7 @@ This package owns interactive terminal presentation and input only. It injects ` After terminal startup succeeds, the package provides the terminal-local `ctx.tui` extension service. A plugin that injects it can call `openOverlay()` with a component factory and constrained layout options; the host exposes the viewport, semantic theme (including terminal-safe DeepSeek `brand` treatment), display-text escaping, redraw, close, and a lifetime signal, but not the pi-tui tree, terminal, focus controller, or overlay handle. Plugin overlays, the model selector, and user questions share one FIFO modal queue. Each request is an effect of the calling plugin fiber, so unload removes queued work or closes visible work before cleanup settles; terminal shutdown unloads dependents before stopping pi-tui. Overlay state is not logged or replayed. Component code is trusted and may render ANSI styling, but must pass untrusted text through `host.display()`. The [interactive-extension Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md) owns the boundary and rejected alternatives. -The TUI rebuilds resumed history from the append-origin session events, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the standing `todo/write` plan above the editor (cleared on the next `turn/start`), and presents `ctx.userInteraction` questions in a wide bottom-left keyboard panel with progress, numbered options, and aligned descriptions. The latest logged session title becomes the header subtitle, with `welcome` before a title exists, and the terminal window title becomes ``. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view compares token-meter pressure with `ctx.llm.resolveModelInfo()` context for the current route, displays `context unknown` when the adapter has no capacity metadata, and also shows tool-card mode plus the current model and any explicitly selected reasoning effort; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. A surface replacement never rewrites the rendered transcript: the conversation it shadows stays readable, and a landed compaction checkpoint adds one dim `… earlier context was compacted …` marker at its log position, so the terminal reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies — a pruned tool result, a regenerated assistant message — render nothing. +The TUI rebuilds resumed history from the append-origin session events, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the standing `todo/write` plan above the editor (cleared on the next `turn/start`), and presents `ctx.userInteraction` questions inline between the transcript/status area and the editor. The question panel shows progress, numbered options, wrapped labels, and separately indented descriptions; it obeys both `maxQuestionOptions` and `questionDialogMaxHeight`, marks hidden options with `↑ N more` / `↓ N more`, and uses Page Up / Page Down to page long question/detail content before an individually oversized selected block while keeping the editor visible. The latest logged session title becomes the header subtitle, with `welcome` before a title exists, and the terminal window title becomes ``. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view compares token-meter pressure with `ctx.llm.resolveModelInfo()` context for the current route, displays `context unknown` when the adapter has no capacity metadata, and also shows tool-card mode plus the current model and any explicitly selected reasoning effort; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. A surface replacement never rewrites the rendered transcript: the conversation it shadows stays readable, and a landed compaction checkpoint adds one dim `… earlier context was compacted …` marker at its log position, so the terminal reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies — a pruned tool result, a regenerated assistant message — render nothing. An embedding may provide `TuiRuntime.formatCwd` when its logical workspace label differs from the session's host directory. The override changes only the footer label; tools continue to use the session `cwd`. @@ -22,7 +22,7 @@ Typing `@` at a token boundary searches files and directories under the session When optional `ctx.sessionReferences` is mounted, the same `@` menu also offers metadata-only session candidates, inserts `@[label](dsh-session:)`, and prepares the selected snapshots before dispatch. Session references remain structured because the model has no filesystem-like tool for retrieving session snapshots later. Preparation disables duplicate submission and restores the editor input on failure. The TUI chooses `agent.steer()` or `agent.followup()` from the status after that asynchronous preparation, so idle follow-ups still dispatch `agent/prompt-submit` while in-turn steering joins at a checkpoint without that hook. -While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.followup()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path automatically reaches the model. A command producer may explicitly schedule agent work; [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/palette`, `/reload`, `/resume`, `/status`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically, as do `/skill:` completions. A status line above the editor reports the turn phase the TUI derives from session events — waiting for the first token, thinking, responding, or executing tools — with the elapsed time in that phase and the running step total, refreshed each second, and ends with the `Enter sends steering, Esc cancels` hint; while steering messages wait to reach the model it inserts a `N queued ·` badge before the hint that clears as each drains. During a live standalone compaction bracket, a fixed `Context being compacted ` row appears above the prompt, the idle prompt caret becomes a one-cell throbbing `⊙`, and terminal progress stays active until close; the row and glyph share the bracket's one refresh timer. This live state is never reconstructed from the log; a failed close adds `Compaction failed: ` to the transcript, while a resumed orphaned start never activates the indicator ([decision](../../../.agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.md)). Ctrl+C or Escape cancels a running turn. Tool and injected-context cards collapse long bodies into a configurable head/tail preview; Ctrl+O cycles tool cards through collapsed preview, full output, and hidden — the hidden phase drops tool cards from the transcript entirely while context cards stay at their preview, since injected instructions are not tool traffic. An injected-context card renders its message as prose with the producer's outer reminder frame stripped, so neither the fold nor the frame stripping depends on the payload's syntax. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle. +While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.followup()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path automatically reaches the model. A command producer may explicitly schedule agent work; [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/details`, `/palette`, `/reload`, `/resume`, `/status`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically, as do `/skill:` completions. A status line above the editor reports the turn phase the TUI derives from session events — waiting for the first token, thinking, responding, or executing tools — with the elapsed time in that phase and the running step total, refreshed each second, and ends with the `Enter sends steering, Esc cancels` hint; while steering messages wait to reach the model it inserts a `N queued ·` badge before the hint that clears as each drains. During a live standalone compaction bracket, a fixed `Context being compacted ` row appears above the prompt, the idle prompt caret becomes a one-cell throbbing `⊙`, and terminal progress stays active until close; the row and glyph share the bracket's one refresh timer. This live state is never reconstructed from the log; a failed close adds `Compaction failed: ` to the transcript, while a resumed orphaned start never activates the indicator ([decision](../../../.agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.md)). Ctrl+C or Escape cancels a running turn. Tool and injected-context cards collapse long bodies into a configurable head/tail preview; Ctrl+O cycles tool cards through collapsed preview, full output, and hidden — the hidden phase drops tool cards from the transcript entirely while context cards stay at their preview, since injected instructions are not tool traffic. The hidden phase also folds each turn's assistant steps into one message: the first step with visible text or reasoning keeps the turn's single `Assistant` header, later steps render as headerless continuations, and a step without a visible body renders nothing; leaving the hidden phase restores the per-step headers. An injected-context card renders its message as prose with the producer's outer reminder frame stripped, so neither the fold nor the frame stripping depends on the payload's syntax. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle. `/details` names the same state those two shortcuts cycle: bare it opens a centered keyboard toggle with one entry per dimension — `Tool cards` and `Reasoning` — showing the live values, where Tab cycles the highlighted entry and applies the change immediately (the transcript behind the dialog is the preview), and Enter, Esc, or Ctrl+C closes; `/details collapsed|expanded|hidden` jumps tool cards to that phase directly, and `/details reasoning [on|off]` sets — or bare `reasoning` toggles — reasoning-block display; arguments combine in one invocation, an unknown argument fails with the usage line, and a combined invocation applies reasoning first so its transcript rebuild never drops the card notice. `/model` opens the advisory `ctx.llm` catalog as a keyboard selector: a filter box above the list narrows rows by a case-insensitive substring over each row's `provider/model` label, model name, and description, keeping the highlighted row selected when it survives the filter; Up/Down moves, Shift+Tab cycles the focused model's adapter-advertised reasoning efforts in display order, Enter selects the model and effort, and Escape clears a non-empty filter before a second Escape closes it. When an adapter does not advertise a default effort, the cycle also includes `Default`, which clears an explicit selection and preserves the provider default; models without selectable effort metadata ignore Shift+Tab. The selector renders the exact advertised effort list—including `off` when present—and does not synthesize, clamp, or transfer an effort between models. `/model ` still selects an unambiguous model id directly, while `/model /` selects an exact target and uses its adapter default when one exists. The configured target or latest logged request header initializes the selector, and an unlisted current model remains visible because catalogs are advisory. Selection is local to this TUI session. Prompt assembly snapshots the target for one step, replaces `{{provider}}` and `{{model}}`, and applies the same provider/model/reasoning-effort target through `agent/request`; a switch during assembly therefore starts with a later step. The request header durably records targets that reach the model, while an unused selection remains process-local. @@ -50,13 +50,15 @@ A launcher can seed a fresh session's first turn by providing `INITIAL_SKILL_KEY | `sessionId` | `main` | Exact shared agent/session identity driven by the terminal | | `showReasoning` | `true` | Render reasoning blocks | | `maxToolOutputLines` | `6` | Output lines retained across a collapsed tool card's head/tail preview | -| `maxQuestionOptions` | `8` | Visible options in a question panel | +| `maxDiffEditLength` | `1000` | Maximum added and removed lines explored for an exact diff before whole-side fallback | +| `maxQuestionOptions` | `8` | Maximum option blocks visible at once; the row bound may reduce this further | | `maxModelOptions` | `8` | Visible models in the model selector | | `maxResumeOptions` | `8` | Visible sessions in the resume selector | | `questionDialogWidth` | `200` | Question-panel width in columns, clamped to the terminal | -| `questionDialogMaxHeight` | `20` | Question-panel maximum rows | +| `questionDialogMaxHeight` | `20` | Maximum question-panel rows, further bounded to retain the editor | | `modelDialogWidth` | `76` | Model-selector width in columns | | `modelDialogMaxHeight` | `20` | Model-selector maximum rows | +| `detailsDialogWidth` | `72` | Transcript-details selector width in columns | | `fileSearchMaxResults` | `20` | Maximum file and directory candidates shown for one `@` query | | `fileSearchMaxEntries` | `10000` | Maximum paths retained in the bounded workspace index used by bare fuzzy queries | | `fileSearchExcludedDirectories` | `['.git', 'node_modules']` | Directory basenames omitted from traversal and direct completion | @@ -72,6 +74,7 @@ A launcher can seed a fresh session's first turn by providing `INITIAL_SKILL_KEY sessionId: main-session-123 showReasoning: true maxToolOutputLines: 6 + maxDiffEditLength: 1000 fileSearchExcludedDirectories: ['.git', 'node_modules', 'dist'] ``` @@ -83,7 +86,7 @@ Every general-purpose SGR code the TUI emits lives in one table, `paletteSpec` i There is one role per visual meaning: `dim` is the single recessed tone, `accent` the single interaction emphasis, and `brand` the DeepSeek mark's standard-ANSI fallback, while `success` and `error` double as a diff's added and removed lines. Colors and attributes are separately typed, so `bold(accent(x))` compiles and `accent(error(x))` does not — SGR has no color stack, so nesting one color inside another silently drops the outer color at the inner one's close. Attributes occupy independent SGR groups and compose with any color in either order. Run `/palette` to see every role as your terminal renders it, with its SGR pair. -Grouped regions (user prompts, assistant replies, tool cards) are separated by a bold, underlined role header in the role color and blank-line spacing rather than a filled block or a per-line prefix, so a mouse drag-select copies the message text without any leading bar or indent; a tool card's status (pending, error, success) shows in its colored, underlined title glyph and title. Inside a tool card, the whole body — presenter title, a terminal `$` command and cwd, and the tool's own output — renders in one dim tone, so only the status-colored header carries color and the body reads as one recessed block instead of a run of competing shades; an injected-context card's prose is the same tone as its header. A diff card's `+`/`-` lines and a `[signal …]` marker stay colored, because there the color is the meaning rather than emphasis. The question panel emphasizes its active row with bold accent text, while selectors use reverse video. These treatments are foreground-only, so they never collide with the terminal background. Set `color: false` to strip all styling. +Grouped regions (user prompts, assistant replies, tool cards) are separated by a bold, underlined role header in the role color and blank-line spacing rather than a filled block or a per-line prefix, so a mouse drag-select copies the message text without any leading bar or indent; a tool card's status (pending, error, success) shows in its colored, underlined title glyph and title. Inside a tool card, the whole body — presenter title, a terminal `$` command and cwd, and the tool's own output — renders in one dim tone, so only the status-colored header carries color and the body reads as one recessed block instead of a run of competing shades; an injected-context card's prose is the same tone as its header. A diff card with both sides available colors and counts exact added `+` and removed `-` lines, while unchanged context stays dim and uncounted. If exact comparison exceeds `maxDiffEditLength`, the card renders each old-side row as removed and each new-side row as added, marks the footer approximate, and caches that fallback for later redraws. When `oldText` is unavailable, including pending writes and replay fallbacks as well as creates, every non-empty new-side row is shown and counted as added; that count does not prove the rows were absent from an existing file. Empty new content produces no synthetic `+ ` row. A `[signal …]` marker remains colored because there the color is the meaning rather than emphasis. The question panel emphasizes its active row with bold accent text, while selectors use reverse video. These treatments are foreground-only, so they never collide with the terminal background. Set `color: false` to strip all styling. ## Model Experience @@ -153,7 +156,7 @@ Append-only; newly visible content follows the reusable request prefix and does #### What the model sees -When a consumer calls `ctx.userInteraction.ask()`, this provider presents each question in order and returns selected option labels or `custom` text. Abort, cancellation, or UI disposal becomes `Error: ask_user_question was interrupted before the user answered` through `dsh-tool-ask-user`. +When a consumer calls `ctx.userInteraction.ask()`, this provider presents each question in order and returns selected option labels, `custom` text, or both for a multi-select question. Pending custom text survives switching back to options and joins checked labels on a later options-mode submit. Abort, cancellation, or UI disposal becomes `Error: ask_user_question was interrupted before the user answered` through `dsh-tool-ask-user`. #### Token effect diff --git a/packages/ui/tui/README.zh.md b/packages/ui/tui/README.zh.md index 01055619f4..02d4431fda 100644 --- a/packages/ui/tui/README.zh.md +++ b/packages/ui/tui/README.zh.md @@ -12,7 +12,7 @@ DeepSeek Harness agent(智能体)的交互式终端入口,基于 [`@earend 终端成功启动后,本包会提供终端本地的 `ctx.tui` 扩展服务。注入该服务的插件可以使用组件工厂和受限布局选项调用 `openOverlay()`;宿主会公开 viewport、语义化主题(包括终端安全的 DeepSeek `brand` 样式)、显示文本转义、重绘、关闭和生命周期信号,但不公开 pi-tui 树、终端、焦点控制器或 overlay 句柄。插件 overlay、模型选择器和用户问题共用一个 FIFO 模态队列。每个请求都是调用方插件 fiber 的 effect,因此卸载会移除排队工作,或在清理结算前关闭可见工作;终端关闭会先卸载依赖项,再停止 pi-tui。Overlay 状态不会记录或回放。组件代码受信任,可以渲染 ANSI 样式,但必须通过 `host.display()` 处理不受信任文本。[交互式扩展 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md)持有该边界和未采用的替代方案。 -TUI 从追加来源的会话事件重建已恢复历史,渲染 Markdown 响应与 reasoning,将每个工具的 `presentCall` / `presentResult` 意图应用到终端、diff 或通用卡片,把站立的 `todo/write` 计划保留在编辑器上方(下一个 `turn/start` 时清空),并在左下方宽键盘面板中展示 `ctx.userInteraction` 问题,包含进度、编号选项和对齐说明。最新记录的会话标题成为 header 副标题;标题不存在时使用 `welcome`,终端窗口标题则变为 ``。持久 `llm/retry` 事件会撤回失败步骤的实时 chunk,并在 transcript(文本记录)中渲染计划重试次数、延迟和失败;成功、耗尽与取消随后通过普通会话事件结算。Footer 会对每个已记录模型步骤的用量只计一次,包括失败尝试;对于没有用量 chunk 的日志,以已提交消息的用量回退。其空闲视图会将 token-meter 压力与 `ctx.llm.resolveModelInfo()` 为当前路由返回的上下文容量进行比较;适配器没有容量元数据时显示 `context unknown`,并显示工具卡片模式、当前模型,以及任何显式选择的推理强度。Agent 运行时,这些摘要会替换为已经过工作时间指示器和 `esc interrupt`。表层替换从不重写已渲染的 transcript:被它遮蔽的对话仍可阅读,而已落地的压缩(compaction)检查点会在其日志位置添加一行暗色 `… earlier context was compacted …` 标记,因此终端报告的是模型从何处起不再看到那段历史,而不是把它抹掉。仅供模型使用的替换副本——被裁剪的工具结果、重新生成的 assistant 消息——不渲染任何内容。 +TUI 从追加来源的会话事件重建已恢复历史,渲染 Markdown 响应与 reasoning,将每个工具的 `presentCall` / `presentResult` 意图应用到终端、diff 或通用卡片,把站立的 `todo/write` 计划保留在编辑器上方(下一个 `turn/start` 时清空),并在 transcript/状态区域与编辑器之间内联展示 `ctx.userInteraction` 问题。问题面板会显示进度、编号选项、换行标签和另行缩进的描述;它同时遵守 `maxQuestionOptions` 和 `questionDialogMaxHeight`,用 `↑ N more`/`↓ N more` 标记隐藏选项,并在保持编辑器可见的同时,通过 Page Up 和 Page Down 先分页浏览过长的问题/详情内容,再分页浏览单个超大的选中块。最新记录的会话标题成为 header 副标题;标题不存在时使用 `welcome`,终端窗口标题则变为 ``。持久 `llm/retry` 事件会撤回失败步骤的实时 chunk,并在 transcript(文本记录)中渲染计划重试次数、延迟和失败;成功、耗尽与取消随后通过普通会话事件结算。Footer 会对每个已记录模型步骤的用量只计一次,包括失败尝试;对于没有用量 chunk 的日志,以已提交消息的用量回退。其空闲视图会将 token-meter 压力与 `ctx.llm.resolveModelInfo()` 为当前路由返回的上下文容量进行比较;适配器没有容量元数据时显示 `context unknown`,并显示工具卡片模式、当前模型,以及任何显式选择的推理强度。Agent 运行时,这些摘要会替换为已经过工作时间指示器和 `esc interrupt`。表层替换从不重写已渲染的 transcript:被它遮蔽的对话仍可阅读,而已落地的压缩(compaction)检查点会在其日志位置添加一行暗色 `… earlier context was compacted …` 标记,因此终端报告的是模型从何处起不再看到那段历史,而不是把它抹掉。仅供模型使用的替换副本——被裁剪的工具结果、重新生成的 assistant 消息——不渲染任何内容。 如果逻辑工作区标签与会话宿主目录不同,嵌入方可以提供 `TuiRuntime.formatCwd`。该覆盖只改变 footer 标签;工具仍使用会话 `cwd`。 @@ -22,7 +22,7 @@ TUI 从追加来源的会话事件重建已恢复历史,渲染 Markdown 响应 挂载可选的 `ctx.sessionReferences` 后,同一个 `@` 菜单还会提供仅含元数据的会话候选项,插入 `@[label](dsh-session:)`,并在分派前准备所选快照。会话引用保持结构化,因为模型没有类似文件系统的工具可在稍后检索会话快照。准备期间会禁止重复提交,并在失败时恢复编辑器输入。TUI 会在异步准备后根据状态选择 `agent.steer()` 或 `agent.followup()`,因此空闲 followup 仍会分派 `agent/prompt-submit`,而轮次中的 steering 会在检查点加入且不触发该 hook。 -Agent 运行时,普通编辑器提交会调用 `agent.steer()`;其他时候调用 `agent.followup()`。提交行以斜杠开头时会改为进入 `ctx.commands`:已知命令直接执行,未知命令产生警告,两条路径都不会自动到达模型。命令生产方可以显式调度 agent 工作;[`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) 使用该契约实现 `/plan [message]`。TUI 将 `/help`、`/model`、`/clear`、`/palette`、`/reload`、`/resume`、`/status` 和 `/exit` 注册为 agent 作用域定义;其他所有有效命令都会动态加入自动补全与 `/help`,`/skill:` 补全也相同。编辑器上方的状态行会报告 TUI 从会话事件派生的轮次阶段,包括等待首个 token、思考、响应或执行工具;它显示该阶段已经过时间和运行中的步骤总数,每秒刷新,并以 `Enter sends steering, Esc cancels` 提示结尾。Steering 消息等待到达模型期间,会在提示前插入 `N queued ·` 徽标,每条消息排空后随即清除。在实时独立压缩(compaction)标记对处于开启状态期间,提示词上方会显示固定的 `Context being compacted ` 状态行,空闲提示符光标会变成占一个终端字符单元并呈呼吸律动的 `⊙`,终端进度状态则会保持活跃,直至标记对闭合;该状态行和字形共用标记对的同一个刷新定时器。该实时状态绝不会从日志中重建;闭合失败时会向 transcript 添加 `Compaction failed: `,而恢复会话时遇到的陈旧未匹配 start 绝不会激活该指示器([决策](../../../.agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.md))。Ctrl+C 或 Escape 会取消运行中的轮次。工具卡片与注入上下文卡片都把长主体折叠为可配置的头尾预览;Ctrl+O 让工具卡片在折叠预览、完整输出、隐藏三种状态间循环——隐藏阶段把工具卡片从 transcript 中完全去掉,而上下文卡片保持预览,因为注入的指令不属于工具流量。注入上下文卡片把消息渲染为文本,并去掉生产方的外层提醒外框,因此折叠与去外框都不依赖载荷的语法。Ctrl+R 切换 reasoning,Ctrl+L 重绘,Ctrl+D 在空闲时退出。 +Agent 运行时,普通编辑器提交会调用 `agent.steer()`;其他时候调用 `agent.followup()`。提交行以斜杠开头时会改为进入 `ctx.commands`:已知命令直接执行,未知命令产生警告,两条路径都不会自动到达模型。命令生产方可以显式调度 agent 工作;[`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) 使用该契约实现 `/plan [message]`。TUI 将 `/help`、`/model`、`/clear`、`/details`、`/palette`、`/reload`、`/resume`、`/status` 和 `/exit` 注册为 agent 作用域定义;其他所有有效命令都会动态加入自动补全与 `/help`,`/skill:` 补全也相同。编辑器上方的状态行会报告 TUI 从会话事件派生的轮次阶段,包括等待首个 token、思考、响应或执行工具;它显示该阶段已经过时间和运行中的步骤总数,每秒刷新,并以 `Enter sends steering, Esc cancels` 提示结尾。Steering 消息等待到达模型期间,会在提示前插入 `N queued ·` 徽标,每条消息排空后随即清除。在实时独立压缩(compaction)标记对处于开启状态期间,提示词上方会显示固定的 `Context being compacted ` 状态行,空闲提示符光标会变成占一个终端字符单元并呈呼吸律动的 `⊙`,终端进度状态则会保持活跃,直至标记对闭合;该状态行和字形共用标记对的同一个刷新定时器。该实时状态绝不会从日志中重建;闭合失败时会向 transcript 添加 `Compaction failed: `,而恢复会话时遇到的陈旧未匹配 start 绝不会激活该指示器([决策](../../../.agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.md))。Ctrl+C 或 Escape 会取消运行中的轮次。工具卡片与注入上下文卡片都把长主体折叠为可配置的头尾预览;Ctrl+O 让工具卡片在折叠预览、完整输出、隐藏三种状态间循环——隐藏阶段把工具卡片从 transcript 中完全去掉,而上下文卡片保持预览,因为注入的指令不属于工具流量。隐藏阶段还会把每个轮次的 assistant 步骤折叠为一条消息:第一个有可见文本或 reasoning 的步骤保留该轮次唯一的 `Assistant` 标题,之后的步骤渲染为无标题的续段,没有可见正文的步骤则不渲染任何内容;离开隐藏阶段会恢复每步各自的标题。注入上下文卡片把消息渲染为文本,并去掉生产方的外层提醒外框,因此折叠与去外框都不依赖载荷的语法。Ctrl+R 切换 reasoning,Ctrl+L 重绘,Ctrl+D 在空闲时退出。`/details` 命名的正是这两个快捷键循环的同一份状态:不带参数时打开一个居中的键盘开关,每个维度一个条目——`Tool cards` 与 `Reasoning`——显示实时值,Tab 循环高亮条目并立即应用变更(对话框背后的 transcript 即是预览),Enter、Esc 或 Ctrl+C 关闭;`/details collapsed|expanded|hidden` 让工具卡片直接跳到该阶段,`/details reasoning [on|off]` 设置——或裸 `reasoning` 切换——reasoning 块显示;参数可在一次调用中组合,未知参数会以用法行报错,组合调用先应用 reasoning,使其 transcript 重建不会丢掉卡片通知。 `/model` 将建议性的 `ctx.llm` catalog 打开为键盘选择器:列表上方设有一个过滤框,按对每行 `provider/model` 标签、模型名称和描述的大小写不敏感子串匹配来缩小行集,并在高亮行仍通过过滤时保持其选中状态;Up/Down 移动,Shift+Tab 按显示顺序循环切换适配器为焦点模型公布的推理强度,Enter 选择模型和推理强度,Escape 会先清除非空过滤内容,再次按下才关闭选择器。适配器未公布默认推理强度时,循环还会包含 `Default`,该项会清除显式选择并保留提供方默认行为;没有可选推理强度元数据的模型会忽略 Shift+Tab。选择器会原样呈现公布的推理强度列表(包括存在时的 `off`),不会合成、自动调整或在模型之间转移推理强度。`/model ` 仍可直接选择无歧义的模型 id,`/model /` 则选择精确目标,并在存在时使用其适配器默认值。已配置目标或最新记录的请求 header 会初始化选择器;由于 catalog 仅提供建议,未列出的当前模型仍会显示。选择仅对本 TUI 会话有效。提示词组装会为一个步骤建立目标快照,替换 `{{provider}}` 和 `{{model}}`,并通过 `agent/request` 应用同一个提供方/模型/推理强度目标;因此组装期间的切换会从后续步骤开始生效。请求 header 会持久记录真正到达模型的目标,未使用的选择则只存在于进程本地。 @@ -50,13 +50,15 @@ Footer 将会话报告的用量汇总为 `↑`;任 | `sessionId` | `main` | 由终端驱动的精确共享 agent/会话身份 | | `showReasoning` | `true` | 渲染 reasoning 块 | | `maxToolOutputLines` | `6` | 折叠工具卡片的头尾预览所保留的输出行数 | -| `maxQuestionOptions` | `8` | 问题面板中可见的选项数 | +| `maxDiffEditLength` | `1000` | 回退到整侧展示前,精确 diff 最多探索的新增与删除行总数 | +| `maxQuestionOptions` | `8` | 一次最多可见的选项块数;行数边界可能进一步减少可见数量 | | `maxModelOptions` | `8` | 模型选择器中可见的模型数 | | `maxResumeOptions` | `8` | 恢复选择器中可见的会话数 | | `questionDialogWidth` | `200` | 问题面板宽度(列数),以终端宽度为上限 | -| `questionDialogMaxHeight` | `20` | 问题面板最大行数 | +| `questionDialogMaxHeight` | `20` | 问题面板最大行数,会进一步受限以保留编辑器 | | `modelDialogWidth` | `76` | 模型选择器宽度(列数) | | `modelDialogMaxHeight` | `20` | 模型选择器最大行数 | +| `detailsDialogWidth` | `72` | transcript 细节选择器宽度(列数) | | `fileSearchMaxResults` | `20` | 一次 `@` 查询显示的最大文件和目录候选数 | | `fileSearchMaxEntries` | `10000` | 无路径模糊查询使用的有界工作区索引最多保留的路径数 | | `fileSearchExcludedDirectories` | `['.git', 'node_modules']` | 遍历和直接补全时忽略的目录 basename | @@ -72,6 +74,7 @@ Footer 将会话报告的用量汇总为 `↑`;任 sessionId: main-session-123 showReasoning: true maxToolOutputLines: 6 + maxDiffEditLength: 1000 fileSearchExcludedDirectories: ['.git', 'node_modules', 'dist'] ``` @@ -83,7 +86,7 @@ TUI 发出的所有通用 SGR 代码都集中在一个表中,即 `components/t 每种视觉语义只对应一个角色:`dim` 是唯一的弱化色调,`accent` 是唯一的交互强调色,`brand` 是 DeepSeek 标志的标准 ANSI 回退色,`success` 和 `error` 还分别充当 diff 的新增行与删除行。颜色和属性分属不同类型,因此 `bold(accent(x))` 可以通过编译,`accent(error(x))` 则不行——SGR 没有颜色栈;在一种颜色内嵌套另一种颜色时,内层颜色闭合时会静默丢弃外层颜色。各属性占用彼此独立的 SGR 组,可以按任一顺序与任何颜色组合。运行 `/palette` 可查看每个角色在你的终端上的实际渲染效果及其 SGR 码对。 -成组区域(用户提示词、assistant 回复、工具卡片)通过以角色色渲染的粗体带下划线角色标题和空行分隔,而非填充背景块或逐行前缀,因此用鼠标框选复制时不会带上任何左侧竖条或缩进;工具卡片的状态(进行中、错误、成功)由其彩色带下划线的标题字形与标题体现。在工具卡片内部,整个正文——presenter 标题、终端 `$` 命令与 cwd,以及工具自身的输出——统一以同一种暗色渲染,因此只有带状态色的表头携带颜色,正文读作一个整体弱化的区块,而不是一串互相竞争的色调;注入上下文卡片的正文与其表头也是同一种色调。diff 卡片的 `+`/`-` 行与 `[signal …]` 标记保留颜色,因为那里的颜色本身就是语义,而非强调。问题面板使用粗体强调色文本突出活跃行,选择器则使用反色。所有效果都只作用于前景色,因此不会与终端背景冲突。设置 `color: false` 可移除所有样式。 +成组区域(用户提示词、assistant 回复、工具卡片)通过以角色色渲染的粗体带下划线角色标题和空行分隔,而非填充背景块或逐行前缀,因此用鼠标框选复制时不会带上任何左侧竖条或缩进;工具卡片的状态(进行中、错误、成功)由其彩色带下划线的标题字形与标题体现。在工具卡片内部,整个正文——presenter 标题、终端 `$` 命令与 cwd,以及工具自身的输出——统一以同一种暗色渲染,因此只有带状态色的表头携带颜色,正文读作一个整体弱化的区块,而不是一串互相竞争的色调;注入上下文卡片的正文与其表头也是同一种色调。当前后两侧文本均可用时,diff 卡片会为精确识别出的新增 `+` 行和删除 `-` 行着色并计数;未变更的上下文保持暗色且不纳入计数。如果精确比较超出 `maxDiffEditLength`,卡片会把旧侧每一行渲染为删除行、把新侧每一行渲染为新增行,将页脚标记为近似结果,并缓存该回退结果供后续重绘使用。当 `oldText` 不可用时(包括待处理写入、回放回退以及文件创建),新侧的每个非空行都会显示并计作新增行;该计数不能证明这些行原先不存在于已有文件中。新内容为空时,不会补出虚构的 `+ ` 行。`[signal …]` 标记仍保留颜色,因为那里的颜色本身就是语义,而非强调。问题面板使用粗体强调色文本突出活跃行,选择器则使用反色。所有效果都只作用于前景色,因此不会与终端背景冲突。设置 `color: false` 可移除所有样式。 ## 模型体验 @@ -153,7 +156,7 @@ Paths prefixed with @ are files explicitly referenced by the user. Use the read #### 模型看到的内容 -消费方调用 `ctx.userInteraction.ask()` 时,此提供方会按顺序显示各个问题,并返回选中选项标签或 `custom` 文本。中止、取消或 UI dispose 会变为 `Error: ask_user_question was interrupted before the user answered`;该转换由 `dsh-tool-ask-user` 完成。 +消费方调用 `ctx.userInteraction.ask()` 时,此提供方会按顺序显示各个问题,并返回选中选项标签、`custom` 文本,或为多选题同时返回两者。切回选项后,待提交的自定义文本仍会保留,并在之后从选项模式提交时与已勾选的标签一同返回。中止、取消或 UI dispose 会变为 `Error: ask_user_question was interrupted before the user answered`;该转换由 `dsh-tool-ask-user` 完成。 #### Token 影响 diff --git a/packages/ui/tui/package.json b/packages/ui/tui/package.json index c3506ea338..a68fe7968a 100644 --- a/packages/ui/tui/package.json +++ b/packages/ui/tui/package.json @@ -66,6 +66,7 @@ }, "dependencies": { "@earendil-works/pi-tui": "0.80.7", + "diff": "^9.0.0", "saxes": "6.0.0", "schemastery": "^3.18.0" }, diff --git a/packages/ui/tui/src/chat/questions.ts b/packages/ui/tui/src/chat/questions.ts index e5f2c8b806..5d96282860 100644 --- a/packages/ui/tui/src/chat/questions.ts +++ b/packages/ui/tui/src/chat/questions.ts @@ -29,7 +29,10 @@ interface PendingQuestion { } /** Collaborators the question queue needs from the chat channel. */ -export type QuestionQueueDeps = ChatChannelDeps +export interface QuestionQueueDeps extends ChatChannelDeps { + /** Current row budget after reserving the editor. */ + questionMaxHeight(): number +} /** Ask-user-question controller for one chat channel. */ export interface QuestionQueue { @@ -85,6 +88,7 @@ export function createQuestionQueue(deps: QuestionQueueDeps): QuestionQueue { pending.request.questions.length, pending.request.questions.length - pending.answers.length, resolved.maxQuestionOptions, + () => deps.questionMaxHeight(), palette, (selection) => { pending.overlay = undefined @@ -102,10 +106,8 @@ export function createQuestionQueue(deps: QuestionQueueDeps): QuestionQueue { options: { width: resolved.questionDialogWidth, maxHeight: resolved.questionDialogMaxHeight, - anchor: 'bottom-left', - margin: { bottom: 1 }, }, - }) + }, 'inline') pending.overlay = session void session.closed.then((result) => { if (pending.overlay !== session) return diff --git a/packages/ui/tui/src/components/dialogs.ts b/packages/ui/tui/src/components/dialogs.ts index bad6a5458b..6867a30240 100644 --- a/packages/ui/tui/src/components/dialogs.ts +++ b/packages/ui/tui/src/components/dialogs.ts @@ -34,6 +34,7 @@ import type { import type { AskUserQuestionItem } from '@deepseek-ai/dsh-user-interaction' import { BRACKETED_PASTE_END, BRACKETED_PASTE_START, displayText, sanitizePastedText } from './text.ts' import { dialogSelectTheme, type Palette } from './theme.ts' +import type { ToolCardVisibility } from './transcript.ts' import { renderTuiPromptTemplate, type TuiPromptTemplateToken, @@ -432,6 +433,79 @@ export class ModelDialog implements Component { } } +/** Both transcript-detail dimensions, applied immediately on each Tab. */ +export interface DetailsSelection { + readonly visibility: ToolCardVisibility + readonly showReasoning: boolean +} + +const TOOL_CARD_PHASES: readonly ToolCardVisibility[] = ['collapsed', 'expanded', 'hidden'] + +/** + * Keyboard toggle over the two transcript-detail entries — tool-card + * visibility and reasoning display. Tab cycles the highlighted entry's value + * and applies it immediately, so the transcript behind the dialog is the live + * preview; Enter, Esc, or Ctrl+C closes. + */ +export class DetailsDialog implements Component { + private readonly list: SelectList + private readonly toolsItem: SelectItem + private readonly reasoningItem: SelectItem + + constructor( + private visibility: ToolCardVisibility, + private showReasoning: boolean, + private readonly palette: Palette, + private readonly apply: (selection: DetailsSelection) => void, + private readonly close: () => void, + ) { + this.toolsItem = { value: 'tools', label: 'Tool cards', description: visibility } + this.reasoningItem = { value: 'reasoning', label: 'Reasoning', description: this.reasoningLabel() } + this.list = new SelectList([this.toolsItem, this.reasoningItem], 2, dialogSelectTheme(palette)) + this.list.onSelect = close + } + + private reasoningLabel(): string { + return this.showReasoning ? 'shown' : 'hidden' + } + + /** Cycle the highlighted entry one step and apply the new state. */ + private cycle(): void { + const selected = this.list.getSelectedItem() + /* v8 ignore next -- the two-entry list always has a selection. */ + if (selected === null) return + if (selected.value === 'tools') { + const index = TOOL_CARD_PHASES.indexOf(this.visibility) + this.visibility = TOOL_CARD_PHASES[(index + 1) % TOOL_CARD_PHASES.length] as ToolCardVisibility + this.toolsItem.description = this.visibility + } else { + this.showReasoning = !this.showReasoning + this.reasoningItem.description = this.reasoningLabel() + } + this.apply({ visibility: this.visibility, showReasoning: this.showReasoning }) + } + + invalidate(): void { + this.list.invalidate() + } + + handleInput(data: string): void { + if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) this.close() + else if (matchesKey(data, Key.tab)) this.cycle() + else this.list.handleInput(data) + this.invalidate() + } + + render(width: number): string[] { + const innerWidth = Math.max(1, width - 4) + return renderDialog('Transcript details', [ + ...this.list.render(innerWidth), + '', + this.palette.dim('↑/↓ move • Tab toggle • Enter/Esc close'), + ], width, this.palette) + } +} + /** The provider/model route recovered from a resume candidate's log. */ export interface ResumeRoute { provider: string @@ -748,10 +822,18 @@ export class ResumePicker implements Component, Focusable { } } -/** Bottom-anchored dialog for one user question with option or custom-answer modes. */ +interface SelectedBlockPage { + offset: number + size: number + maxOffset: number +} + +/** Inline dialog for one user question with option or custom-answer modes. */ export class QuestionDialog implements Component, Focusable { private selectedIndex = 0 private selected = new Set() + private headerPage: SelectedBlockPage = { offset: 0, size: 1, maxOffset: 0 } + private selectedBlockPage: SelectedBlockPage = { offset: 0, size: 1, maxOffset: 0 } private mode: 'options' | 'custom' private error = '' private readonly input = new Input() @@ -764,6 +846,7 @@ export class QuestionDialog implements Component, Focusable { private readonly total: number, private readonly unanswered: number, private readonly maxVisible: number, + private readonly maxHeight: () => number, private readonly palette: Palette, private readonly done: (selection: QuestionSelection) => void, private readonly cancel: () => void, @@ -787,6 +870,14 @@ export class QuestionDialog implements Component, Focusable { handleInput(data: string): void { this.invalidate() + if (matchesKey(data, Key.pageUp)) { + this.pageBackward() + return + } + if (matchesKey(data, Key.pageDown)) { + this.pageForward() + return + } if (this.mode === 'custom') { this.input.focused = this.focused this.input.handleInput(data) @@ -794,21 +885,27 @@ export class QuestionDialog implements Component, Focusable { } const options = this.options if (matchesKey(data, Key.up)) { + this.selectedBlockPage = { offset: 0, size: 1, maxOffset: 0 } this.selectedIndex = this.selectedIndex === 0 ? options.length - 1 : this.selectedIndex - 1 } else if (matchesKey(data, Key.down)) { + this.selectedBlockPage = { offset: 0, size: 1, maxOffset: 0 } this.selectedIndex = this.selectedIndex === options.length - 1 ? 0 : this.selectedIndex + 1 } else if (matchesKey(data, Key.space) && this.question.multiSelect) { if (this.selected.has(this.selectedIndex)) this.selected.delete(this.selectedIndex) else this.selected.add(this.selectedIndex) } else if (matchesKey(data, Key.enter)) { - const indices = this.question.multiSelect ? [...this.selected].sort((a, b) => a - b) : [this.selectedIndex] - if (indices.length === 0) { + const selected = this.question.multiSelect + ? this.selectedOptionLabels() + : [options[this.selectedIndex]?.label].filter((label): label is string => label !== undefined) + const custom = this.question.multiSelect ? this.input.getValue().trim() : '' + if (selected.length === 0 && custom === '') { this.error = 'Select at least one option, or press Tab for a custom answer.' return } - this.done({ selected: indices.map(index => options[index]?.label).filter((label): label is string => label !== undefined) }) + this.done({ selected, ...(custom === '' ? {} : { custom }) }) } else if (matchesKey(data, Key.tab) || data.toLowerCase() === 'c') { this.mode = 'custom' + this.selectedBlockPage = { offset: 0, size: 1, maxOffset: 0 } this.error = '' } else if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) { this.cancel() @@ -821,78 +918,362 @@ export class QuestionDialog implements Component, Focusable { this.error = 'Enter an answer before submitting.' return } - this.done({ selected: [], custom }) + this.done({ + selected: this.question.multiSelect ? this.selectedOptionLabels() : [], + custom, + }) + } + + private selectedOptionLabels(): string[] { + return [...this.selected] + .sort((a, b) => a - b) + .map(index => this.options[index]?.label) + .filter((label): label is string => label !== undefined) + } + + /** Page backward through an oversized option, then through question detail. */ + private pageBackward(): void { + if (this.mode === 'options' && this.selectedBlockPage.offset > 0) { + this.selectedBlockPage = { + ...this.selectedBlockPage, + offset: Math.max(0, this.selectedBlockPage.offset - this.selectedBlockPage.size), + } + return + } + this.headerPage = { + ...this.headerPage, + offset: Math.max(0, this.headerPage.offset - this.headerPage.size), + } + } + + /** Page forward through question detail, then through an oversized option. */ + private pageForward(): void { + if (this.headerPage.offset < this.headerPage.maxOffset) { + this.headerPage = { + ...this.headerPage, + offset: Math.min( + this.headerPage.maxOffset, + this.headerPage.offset + this.headerPage.size, + ), + } + return + } + if (this.mode === 'custom') return + this.selectedBlockPage = { + ...this.selectedBlockPage, + offset: Math.min( + this.selectedBlockPage.maxOffset, + this.selectedBlockPage.offset + this.selectedBlockPage.size, + ), + } } render(width: number): string[] { this.input.focused = this.focused - const innerWidth = Math.max(1, width - 4) + const horizontalPadding = Math.min(2, Math.max(0, Math.floor((width - 1) / 2))) + const innerWidth = Math.max(1, width - horizontalPadding * 2) const header = `Question ${this.position}/${this.total} (${this.unanswered} unanswered)${this.question.header === undefined ? '' : ` · ${displayText(this.question.header)}`}` - const lines = [ - this.palette.dim(header), - ...wrapTextWithAnsi(this.palette.text(displayText(this.question.question)), innerWidth), + const questionLines = wrapTextWithAnsi( + this.palette.text(displayText(this.question.question)), + innerWidth, + ) + const contentLines = [...questionLines] + const headerLines: string[] = [ + ...wrapTextWithAnsi(this.palette.dim(header), innerWidth), + ...questionLines, ] - const push = (line: string): void => { lines.push(line) } // Supporting detail (e.g. the full plan under review) renders between the // question and the answer surface, kept out of option labels. if (this.question.detail !== undefined) { - push('') - for (const line of wrapTextWithAnsi(displayText(this.question.detail), innerWidth)) push(line) - } - push('') - if (this.mode === 'custom') { - for (const line of this.input.render(innerWidth)) push(line) - push(this.palette.dim(this.options.length > 0 ? 'Enter submit • Esc options' : 'Enter submit • Esc cancel')) - } else { - const options = this.options - const start = Math.max(0, Math.min( - this.selectedIndex - Math.floor(this.maxVisible / 2), - options.length - this.maxVisible, - )) - const end = Math.min(options.length, start + this.maxVisible) - const optionRows = options.slice(start, end).map((option, offset) => { - const index = start + offset - const mark = this.question.multiSelect - ? this.selected.has(index) ? '[x] ' : '[ ] ' - : '' - return `${index === this.selectedIndex ? '›' : ' '} ${index + 1}. ${mark}${displayText(option.label)}` - }) - const descriptionColumn = Math.min( - Math.max(...optionRows.map(row => visibleWidth(row))) + 2, - Math.max(1, Math.floor(innerWidth * 0.55)), - ) - for (let index = start; index < end; index += 1) { - // `index < end <= options.length`; the options array is borrowed immutably for this dialog. - const option = options[index] as NonNullable[number] - const mark = this.question.multiSelect - ? this.selected.has(index) ? '[x] ' : '[ ] ' - : '' - const left = `${index === this.selectedIndex ? '›' : ' '} ${index + 1}. ${mark}${displayText(option.label)}` - const leftStyled = index === this.selectedIndex - ? this.palette.bold(this.palette.accent(left)) - : left - const description = option.description === undefined - ? '' - : `${' '.repeat(Math.max(1, descriptionColumn - visibleWidth(left)))}${this.palette.dim(displayText(option.description))}` - push(`${leftStyled}${description}`) + headerLines.push('') + contentLines.push('') + for (const line of wrapTextWithAnsi(displayText(this.question.detail), innerWidth)) { + headerLines.push(line) + contentLines.push(line) } - if (options.length > this.maxVisible) push(this.palette.dim(`${this.selectedIndex + 1}/${options.length}`)) + } + headerLines.push('') + + const customControls = [ + ...(this.options.length > 0 && this.question.multiSelect ? [`${this.selected.size} selected`] : []), + 'Enter submit', + this.options.length > 0 ? 'Esc options' : 'Esc cancel', + ] + const customHint = this.palette.dim(customControls.join(' • ')) + const footerLines: string[] = [] + if (this.mode === 'custom') { + for (const line of this.input.render(innerWidth)) footerLines.push(line) + for (const line of wrapTextWithAnsi(customHint, innerWidth)) footerLines.push(line) + } else { const controls = [ 'Tab custom answer', - ...(options.length > 1 ? ['↑/↓ navigate'] : []), + ...(this.options.length > 1 ? ['↑/↓ navigate'] : []), ...(this.question.multiSelect ? ['Space toggle'] : []), 'Enter submit', 'Esc interrupt', ] const hint = this.palette.dim(controls.join(' • ')) - for (const line of wrapTextWithAnsi(hint, innerWidth)) push(line) + for (const line of wrapTextWithAnsi(hint, innerWidth)) footerLines.push(line) } if (this.error) { - for (const line of wrapTextWithAnsi(this.palette.error(this.error), innerWidth)) push(line) + for (const line of wrapTextWithAnsi(this.palette.error(this.error), innerWidth)) footerLines.push(line) } - return ['', ...lines, ''].map((line) => { - const clipped = truncateToWidth(line, innerWidth, '') - return ` ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} ` + const positionLines = this.mode === 'options' && this.options.length > this.maxVisible + ? [this.palette.dim(`${this.selectedIndex + 1}/${this.options.length}`)] + : [] + + // Options receive only the rows left after fixed chrome and outer padding. + // The final height window handles fixed chrome that cannot fit even alone. + const paddingRows = 2 + const maxHeight = this.maxHeight() + const availableForOptions = Math.max( + this.mode === 'options' ? 4 : 1, + maxHeight - paddingRows - headerLines.length - positionLines.length - footerLines.length, + ) + + const body: string[] = [...headerLines] + const optionLines: string[] = [] + if (this.mode === 'custom') { + for (const line of footerLines) body.push(line) + } else { + const optionBlocks = this.options.map((option, index) => this.renderOptionBlock(option, index, innerWidth)) + const { visibleBlocks, hiddenBefore, hiddenAfter } = this.windowBlocks(optionBlocks, availableForOptions, innerWidth) + if (hiddenBefore > 0) optionLines.push(this.palette.dim(`↑ ${hiddenBefore} more`)) + for (const block of visibleBlocks) { + for (const line of block) optionLines.push(line) + } + if (hiddenAfter > 0) optionLines.push(this.palette.dim(`↓ ${hiddenAfter} more`)) + for (const line of optionLines) body.push(line) + for (const line of positionLines) body.push(line) + for (const line of footerLines) body.push(line) + } + + const rows = ['', ...body, ''] + let visibleRows = rows + if (rows.length <= maxHeight) this.headerPage = { offset: 0, size: 1, maxOffset: 0 } + if (rows.length > maxHeight && this.mode === 'options' && maxHeight >= 6) { + const headerBudget = Math.max( + 0, + maxHeight - optionLines.length - (this.error === '' ? 1 : 2), + ) + const compactFooter = [ + ...this.error === '' + ? [] + : [truncateToWidth(this.palette.error(`Error: ${this.error}`), innerWidth, '…')], + this.compactOptionControls( + innerWidth, + headerBudget === 1 && contentLines.length > headerBudget, + ), + ] + const compactHeader = this.compactQuestionHeader(contentLines, headerBudget, innerWidth) + visibleRows = [...compactHeader, ...optionLines, ...compactFooter] + } else if (rows.length > maxHeight && this.mode === 'custom' && maxHeight >= 2) { + const compactFooterSource = [ + ...this.input.render(innerWidth), + this.compactCustomControls(innerWidth), + ...this.error === '' + ? [] + : [truncateToWidth(this.palette.error(this.error), innerWidth, '…')], + ] + const footerBudget = Math.max(1, maxHeight - 1) + const compactFooter = compactFooterSource.length <= footerBudget + ? compactFooterSource + : footerBudget === 1 + ? compactFooterSource.slice(0, 1) + : [ + ...compactFooterSource.slice(0, 1), + ...compactFooterSource.slice(-(footerBudget - 1)), + ] + const compactHeader = this.compactQuestionHeader( + contentLines, + Math.max(0, maxHeight - compactFooter.length), + innerWidth, + ) + visibleRows = [...compactHeader, ...compactFooter] + } + if (visibleRows.length > maxHeight) { + visibleRows = maxHeight === 1 + ? [this.palette.dim(`↑ ${visibleRows.length} lines hidden`)] + : [ + this.palette.dim(`↑ ${visibleRows.length - maxHeight + 1} lines hidden`), + ...visibleRows.slice(-(maxHeight - 1)), + ] + } + return visibleRows.map((line) => { + const bounded = truncateToWidth(line, innerWidth, '…') + const pad = ' '.repeat(Math.max(0, innerWidth - visibleWidth(bounded))) + const outerPad = ' '.repeat(horizontalPadding) + return `${outerPad}${bounded}${pad}${outerPad}` }) } + + /** Render one option as wrapped label and indented description lines. */ + private renderOptionBlock( + option: NonNullable[number], + index: number, + innerWidth: number, + ): string[] { + const cursor = index === this.selectedIndex ? '›' : ' ' + const number = `${index + 1}. ` + const mark = this.question.multiSelect + ? this.selected.has(index) ? '[x] ' : '[ ] ' + : '' + const labelPrefixPlain = ` ${cursor} ${number}${mark}` + const labelPrefixWidth = visibleWidth(labelPrefixPlain) + const labelBodyWidth = Math.max(1, innerWidth - labelPrefixWidth) + const labelLines = wrapTextWithAnsi(displayText(option.label), labelBodyWidth) + const continuation = ' '.repeat(labelPrefixWidth) + const lines: string[] = [] + for (const [lineIndex, labelLine] of labelLines.entries()) { + const prefix = lineIndex === 0 ? labelPrefixPlain : continuation + const composed = `${prefix}${labelLine}` + lines.push(index === this.selectedIndex ? this.palette.bold(this.palette.accent(composed)) : composed) + } + if (option.description !== undefined) { + const descIndent = ' '.repeat(labelPrefixWidth) + const descBodyWidth = Math.max(1, innerWidth - labelPrefixWidth) + const descLines = wrapTextWithAnsi(displayText(option.description), descBodyWidth) + for (const descLine of descLines) lines.push(`${descIndent}${this.palette.dim(descLine)}`) + } + return lines + } + + /** Keep the question visible when fixed chrome must be compacted. */ + private compactQuestionHeader( + contentLines: readonly string[], + budget: number, + innerWidth: number, + ): string[] { + if (budget <= 0) return [] + if (contentLines.length <= budget) { + this.headerPage = { offset: 0, size: 1, maxOffset: 0 } + return [...contentLines] + } + const pageSize = Math.max(1, budget - 1) + const maxOffset = Math.max(0, contentLines.length - pageSize) + const offset = Math.min(this.headerPage.offset, maxOffset) + this.headerPage = { offset, size: pageSize, maxOffset } + const keptLines = contentLines.slice(offset, offset + pageSize) + if (budget === 1) { + // A page is non-empty because pageSize is one and offset is clamped inside contentLines. + return [keptLines[0] as string] + } + return [ + ...keptLines, + this.pagerStatus(offset + 1, offset + keptLines.length, contentLines.length, innerWidth), + ] + } + + /** Keep Page Up / Page Down discoverable when a full pager status cannot fit. */ + private pagerStatus(first: number, last: number, total: number, innerWidth: number): string { + const full = `… lines ${first}-${last}/${total} • PgUp/PgDn` + const compact = `PgUp/PgDn ${first}/${total}` + return this.palette.dim(truncateToWidth( + visibleWidth(full) <= innerWidth ? full : compact, + innerWidth, + '…', + )) + } + + /** Render custom-mode controls on one row when the header must compact. */ + private compactCustomControls(innerWidth: number): string { + const controls = this.options.length > 0 + ? 'Enter submit • Esc options' + : 'Enter submit • Esc cancel' + const fallback = this.options.length > 0 ? '↵ Esc options' : 'Enter Esc cancel' + const line = visibleWidth(controls) <= innerWidth ? controls : fallback + return this.palette.dim(truncateToWidth(line, innerWidth, '…')) + } + + /** Render a one-row option footer that retains every mode-specific control. */ + private compactOptionControls(innerWidth: number, showPager = false): string { + const controls = [ + ...(this.options.length > 1 ? ['↑/↓'] : []), + 'Tab custom', + ...(this.question.multiSelect ? ['Space toggle'] : []), + 'Enter', + 'Esc interrupt', + ...(showPager ? ['PgUp/PgDn'] : []), + ].join(' • ') + const optionNavigation = this.options.length > 1 ? '↑↓ ' : '' + const fallback = showPager + ? `P↑↓ ${optionNavigation}Tab${this.question.multiSelect ? ' S' : ''}↵Esc` + : this.question.multiSelect ? `${optionNavigation}Tab Sp ↵Esc` : `${optionNavigation}Tab ↵ Esc` + const line = visibleWidth(controls) <= innerWidth ? controls : fallback + return this.palette.dim(truncateToWidth(line, innerWidth, '…')) + } + + /** + * Choose option blocks that fit while keeping the selected option visible. + * Omitted blocks are counted at each end for explicit overflow markers. + */ + private windowBlocks( + blocks: readonly string[][], + budget: number, + innerWidth: number, + ): { visibleBlocks: string[][]; hiddenBefore: number; hiddenAfter: number } { + const totalLines = blocks.reduce((sum, block) => sum + block.length, 0) + if (totalLines <= budget && blocks.length <= this.maxVisible) { + return { visibleBlocks: [...blocks], hiddenBefore: 0, hiddenAfter: 0 } + } + // `blocks` is dense and selectedIndex is derived from the same options. + let start = this.selectedIndex + let end = this.selectedIndex + 1 + /* v8 ignore next -- selectedIndex stays inside [0, options.length). */ + let used = blocks[this.selectedIndex]?.length ?? 0 + const markerLines = (before: number, after: number): number => + (before > 0 ? 1 : 0) + (after > 0 ? 1 : 0) + const fits = (nextStart: number, nextEnd: number, nextUsed: number): boolean => + nextEnd - nextStart <= this.maxVisible + && nextUsed + markerLines(nextStart, blocks.length - nextEnd) <= budget + const selectedMarkers = markerLines(start, blocks.length - end) + if (used + selectedMarkers > budget) { + /* v8 ignore next -- selectedIndex stays inside [0, options.length). */ + const selectedBlock = blocks[this.selectedIndex] ?? [] + const hiddenBefore = start + const hiddenAfter = blocks.length - end + const pageSize = budget - selectedMarkers - 1 + const maxOffset = Math.max(0, selectedBlock.length - pageSize) + const offset = Math.min(this.selectedBlockPage.offset, maxOffset) + this.selectedBlockPage = { offset, size: pageSize, maxOffset } + const keptLines = selectedBlock.slice(offset, offset + pageSize) + const first = offset + 1 + const last = offset + keptLines.length + const overflow = this.pagerStatus(first, last, selectedBlock.length, innerWidth) + return { + visibleBlocks: [[...keptLines, overflow]], + hiddenBefore, + hiddenAfter, + } + } + this.selectedBlockPage = { offset: 0, size: 1, maxOffset: 0 } + let expanded = true + while (expanded && (start > 0 || end < blocks.length)) { + expanded = false + if (end < blocks.length) { + /* v8 ignore next -- guarded by `end < blocks.length` above. */ + const next = blocks[end]?.length ?? 0 + if (fits(start, end + 1, used + next)) { + used += next + end += 1 + expanded = true + continue + } + } + if (start > 0) { + /* v8 ignore next -- guarded by `start > 0` above. */ + const previous = blocks[start - 1]?.length ?? 0 + if (fits(start - 1, end, used + previous)) { + used += previous + start -= 1 + expanded = true + } + } + } + return { + visibleBlocks: blocks.slice(start, end), + hiddenBefore: start, + hiddenAfter: blocks.length - end, + } + } } diff --git a/packages/ui/tui/src/components/transcript.ts b/packages/ui/tui/src/components/transcript.ts index c8ba061166..f0422e8417 100644 --- a/packages/ui/tui/src/components/transcript.ts +++ b/packages/ui/tui/src/components/transcript.ts @@ -15,6 +15,7 @@ import { type Component, type MarkdownTheme, } from '@earendil-works/pi-tui' +import { diffLines as compareLines } from 'diff' import type { Agent } from '@deepseek-ai/dsh-agent' import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm' import type { JsonValue, SessionEvent, TodoItem } from '@deepseek-ai/dsh-session' @@ -52,12 +53,17 @@ function pretty(value: unknown): string { return displayText(serialized ?? String(value)) } +interface RenderedDiff { + lines: string[] + added: number + removed: number + approximate: boolean +} + /** * A side's content lines under the terminator rule the Web DiffBlock also - * applies: empty text is zero lines (a full deletion's `newText`, a create's - * absent `oldText`), and a single trailing newline terminates the last line - * rather than adding an empty one. An interior blank line survives. Keeping the - * two front ends on the same rule holds their `+A -R` footers in step. + * applies: empty text is zero lines, a trailing newline terminates the last + * line, and an interior blank line survives. */ function diffContentLines(text: string): string[] { if (text === '') return [] @@ -65,16 +71,47 @@ function diffContentLines(text: string): string[] { return body.split('\n') } -/** A file diff as colored `+`/`-` lines, optionally prefixed with its path. */ -function diffLines(diff: FileDiff, palette: Palette): string[] { +/** + * A file diff whose unchanged context stays neutral and does not affect exact + * change totals. Comparisons beyond the edit-distance budget fall back to + * whole-side rendering so a model-authored pending edit cannot stall the TUI. + */ +function renderDiff(diff: FileDiff, maxDiffEditLength: number, palette: Palette): RenderedDiff { // The card header is a fixed `Tool / ` frame that never names a file, so // each hunk always carries its own path header (no redundancy to suppress). const lines = [palette.bold(displayText(diff.path))] - if (diff.oldText !== null) { - for (const line of diffContentLines(displayText(diff.oldText))) lines.push(palette.error(`- ${line}`)) + let added = 0 + let removed = 0 + if (diff.oldText === null) { + const newLines = diffContentLines(displayText(diff.newText)) + added = newLines.length + for (const line of newLines) lines.push(palette.success(`+ ${line}`)) + return { lines, added, removed, approximate: false } } - for (const line of diffContentLines(displayText(diff.newText))) lines.push(palette.success(`+ ${line}`)) - return lines + const changes = compareLines(diff.oldText, diff.newText, { maxEditLength: maxDiffEditLength }) + if (changes === undefined) { + const oldLines = diffContentLines(displayText(diff.oldText)) + const newLines = diffContentLines(displayText(diff.newText)) + lines.push(palette.dim(`[exact line diff omitted: >${maxDiffEditLength} changed lines]`)) + removed = oldLines.length + added = newLines.length + for (const line of oldLines) lines.push(palette.error(`- ${line}`)) + for (const line of newLines) lines.push(palette.success(`+ ${line}`)) + return { lines, added, removed, approximate: true } + } + for (const change of changes) { + const changedLines = diffContentLines(displayText(change.value)) + if (change.added) { + added += changedLines.length + for (const line of changedLines) lines.push(palette.success(`+ ${line}`)) + } else if (change.removed) { + removed += changedLines.length + for (const line of changedLines) lines.push(palette.error(`- ${line}`)) + } else { + for (const line of changedLines) lines.push(palette.dim(` ${line}`)) + } + } + return { lines, added, removed, approximate: false } } /** @@ -149,20 +186,28 @@ export class UserMessageComponent extends Container { } } -/** Children of a settled assistant message: optional reasoning block then the response text. */ +/** + * Children of a settled assistant message: optional reasoning block then the + * response text. A folded continuation (a later step of a turn while tool cards + * are hidden) drops the `Assistant` header and renders nothing when it has no + * visible body, so tool-only steps leave no blank segment behind. + */ function assistantMessageChildren( content: readonly ContentBlock[], showReasoning: boolean, + foldedContinuation: boolean, palette: Palette, mdTheme: MarkdownTheme, ): Component[] { const reasoning = displayText(textBlocks(content, 'reasoning').trim()) const text = displayText(textBlocks(content, 'text').trim()) - const children: Component[] = [ - new Spacer(1), - new Text(messageHeader('Assistant', palette.accent, palette), 0, 0), - ] - if (reasoning && showReasoning) { + const showsReasoning = reasoning !== '' && showReasoning + if (foldedContinuation && !showsReasoning && text === '') return [] + const children: Component[] = [new Spacer(1)] + if (!foldedContinuation) { + children.push(new Text(messageHeader('Assistant', palette.accent, palette), 0, 0)) + } + if (showsReasoning) { children.push( new Text(palette.italic(palette.dim('Reasoning')), 0, 0), new Markdown(reasoning, 0, 0, mdTheme, { color: value => palette.dim(value), italic: true }), @@ -220,6 +265,7 @@ interface StreamingBlock { export class StreamingAssistantComponent extends Container { private readonly blocks = new Map() private settledContent: readonly ContentBlock[] | undefined + private foldedContinuation = false /** * The step's timing footer. The renderer keeps it at the tail of the chat so * it trails any tool cards the step appends after this assistant message; it @@ -228,7 +274,8 @@ export class StreamingAssistantComponent extends Container { readonly timing: StepTimingComponent constructor( - position: StepPosition, + /** The step's turn/step coordinates, used to group steps into their turn. */ + readonly position: StepPosition, events: () => readonly SessionEvent[], now: () => number, private showReasoning: boolean, @@ -299,18 +346,49 @@ export class StreamingAssistantComponent extends Container { this.rebuild() } - private rebuild(): void { - this.clear() - const content: readonly ContentBlock[] = this.settledContent ?? [...this.blocks.entries()] + /** + * Mark this step as a folded continuation of its turn: no `Assistant` header, + * and no output at all while the step has no visible body. Used while tool + * cards are hidden so a turn reads as one assistant message. + * @param folded - Whether to render as a headerless continuation. + */ + setFoldedContinuation(folded: boolean): void { + if (this.foldedContinuation === folded) return + this.foldedContinuation = folded + this.rebuild() + } + + /** + * Whether the step currently renders visible reasoning or text. + * @returns `true` when a header-owning render would show a body. + */ + hasVisibleBody(): boolean { + const content = this.presentedContent() + return textBlocks(content, 'text').trim() !== '' + || (this.showReasoning && textBlocks(content, 'reasoning').trim() !== '') + } + + /** The settled content when available, otherwise the streamed blocks in model order. */ + private presentedContent(): readonly ContentBlock[] { + return this.settledContent ?? [...this.blocks.entries()] .sort(([left], [right]) => left - right) .flatMap(([, block]) => { if (block.type === 'text') return [{ type: 'text', text: block.text }] if (block.type === 'reasoning') return [{ type: 'reasoning', text: block.text }] return [] }) - for (const child of assistantMessageChildren(content, this.showReasoning, this.palette, this.mdTheme)) { - this.addChild(child) - } + } + + private rebuild(): void { + this.clear() + const children = assistantMessageChildren( + this.presentedContent(), + this.showReasoning, + this.foldedContinuation, + this.palette, + this.mdTheme, + ) + for (const child of children) this.addChild(child) } } @@ -337,12 +415,14 @@ export class ToolCardComponent implements Component { private visibility: ToolCardVisibility = 'collapsed' private callView: ToolCallView private resultView: ToolResultView | undefined + private diffBodyCache: { view: ToolCallView | ToolResultView; body: CardBody } | undefined constructor( private readonly name: string, private readonly parsed: ParsedArguments, private readonly definition: ToolDefinition | undefined, private readonly maxOutputLines: number, + private readonly maxDiffEditLength: number, private readonly palette: Palette, private readonly mdTheme: MarkdownTheme, ) { @@ -366,6 +446,7 @@ export class ToolCardComponent implements Component { * @param event - The `tool/result` event payload. */ updateResult(event: Extract['data']): void { + this.diffBodyCache = undefined const result = event.message.content[0] this.result = { content: [...result.content], @@ -519,24 +600,28 @@ export class ToolCardComponent implements Component { return { prelude: prelude.filter(Boolean), lines: lines.filter(Boolean) } } if (view.card === 'diff') { + if (this.diffBodyCache?.view === view) return this.diffBodyCache.body // The header no longer names the file, so each diff keeps its own path - // header. A trailing footer summarizes the change (`+A -R · N file(s)`), - // on the same terminator rule and distinct-path count the Web DiffBlock - // uses, so the two front ends' footers agree. - let added = 0 - let removed = 0 - const paths = new Set() - const hunks = view.diffs.flatMap((diff, index) => { - paths.add(diff.path) - if (diff.oldText !== null) removed += diffContentLines(displayText(diff.oldText)).length - added += diffContentLines(displayText(diff.newText)).length - return [...index > 0 ? [''] : [], ...diffLines(diff, this.palette)] + // header. A trailing footer summarizes the exact changed rows when the + // bounded comparison succeeds (`+A -R · N file(s)`). + const renderedDiffs = view.diffs.map(diff => + renderDiff(diff, this.maxDiffEditLength, this.palette), + ) + const added = renderedDiffs.reduce((total, rendered) => total + rendered.added, 0) + const removed = renderedDiffs.reduce((total, rendered) => total + rendered.removed, 0) + const approximate = renderedDiffs.some(rendered => rendered.approximate) + const hunks = renderedDiffs.flatMap((rendered, index) => { + return [...index > 0 ? [''] : [], ...rendered.lines] }) - const files = paths.size - const footer = this.palette.dim(`└ +${added} -${removed} · ${files} file${files === 1 ? '' : 's'}`) + const files = new Set(view.diffs.map(diff => diff.path)).size + const footer = this.palette.dim( + `└ +${added} -${removed} · ${files} file${files === 1 ? '' : 's'}${approximate ? ' · approximate' : ''}`, + ) // A diff's own `+`/`-` colors carry its meaning, so it renders verbatim // rather than under the dim result-output color. - return { prelude: [...hunks, footer], lines: [] } + const body = { prelude: [...hunks, footer], lines: [] } + this.diffBodyCache = { view, body } + return body } // A generic or read card carries its own envelope-stripped `content`; a // search or web card carries no `content` copy and falls back to the raw diff --git a/packages/ui/tui/src/config.ts b/packages/ui/tui/src/config.ts index 010fe3285c..8b07629af8 100644 --- a/packages/ui/tui/src/config.ts +++ b/packages/ui/tui/src/config.ts @@ -34,6 +34,8 @@ export interface TuiConfig { showReasoning?: boolean /** Maximum tool-card body lines retained in its collapsed head/tail preview. */ maxToolOutputLines?: number + /** Maximum added and removed lines explored while deriving an exact line diff. */ + maxDiffEditLength?: number /** Maximum options visible at once in a user-question panel. */ maxQuestionOptions?: number /** Maximum models visible at once in the model selector. */ @@ -48,6 +50,8 @@ export interface TuiConfig { modelDialogWidth?: number /** Model-selector maximum height in terminal rows. */ modelDialogMaxHeight?: number + /** Transcript-details selector width in terminal columns. */ + detailsDialogWidth?: number /** Maximum fuzzy file candidates displayed for one `@` query. */ fileSearchMaxResults?: number /** Maximum paths retained in one `@` workspace index. */ @@ -64,6 +68,7 @@ export interface TuiConfig { const showReasoningSchema = z.boolean().default(true) const maxToolOutputLinesSchema = z.number().step(1).min(1).default(6) +const maxDiffEditLengthSchema = z.number().step(1).min(1).default(1000) const maxQuestionOptionsSchema = z.number().step(1).min(1).default(8) const maxModelOptionsSchema = z.number().step(1).min(1).default(8) const maxResumeOptionsSchema = z.number().step(1).min(1).default(8) @@ -71,6 +76,7 @@ const questionDialogWidthSchema = z.number().step(1).min(20).default(200) const questionDialogMaxHeightSchema = z.number().step(1).min(6).default(20) const modelDialogWidthSchema = z.number().step(1).min(20).default(76) const modelDialogMaxHeightSchema = z.number().step(1).min(6).default(20) +const detailsDialogWidthSchema = z.number().step(1).min(20).default(72) const fileSearchMaxResultsSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_RESULTS) const fileSearchMaxEntriesSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_ENTRIES) const fileSearchExcludedDirectoriesSchema = z.array(z.string()).default([...DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES]) @@ -95,6 +101,7 @@ const titleSchema = z.string().default('DeepSeek Harness') const tuiConfigSchemaFields = { showReasoning: showReasoningSchema, maxToolOutputLines: maxToolOutputLinesSchema, + maxDiffEditLength: maxDiffEditLengthSchema, maxQuestionOptions: maxQuestionOptionsSchema, maxModelOptions: maxModelOptionsSchema, maxResumeOptions: maxResumeOptionsSchema, @@ -102,6 +109,7 @@ const tuiConfigSchemaFields = { questionDialogMaxHeight: questionDialogMaxHeightSchema, modelDialogWidth: modelDialogWidthSchema, modelDialogMaxHeight: modelDialogMaxHeightSchema, + detailsDialogWidth: detailsDialogWidthSchema, fileSearchMaxResults: fileSearchMaxResultsSchema, fileSearchMaxEntries: fileSearchMaxEntriesSchema, fileSearchExcludedDirectories: fileSearchExcludedDirectoriesSchema, @@ -135,6 +143,7 @@ export const Config: z = z.object({ initialSkill: z.string(), showReasoning: tuiConfigSchemaFields.showReasoning, maxToolOutputLines: tuiConfigSchemaFields.maxToolOutputLines, + maxDiffEditLength: tuiConfigSchemaFields.maxDiffEditLength, maxQuestionOptions: tuiConfigSchemaFields.maxQuestionOptions, maxModelOptions: tuiConfigSchemaFields.maxModelOptions, maxResumeOptions: tuiConfigSchemaFields.maxResumeOptions, @@ -142,6 +151,7 @@ export const Config: z = z.object({ questionDialogMaxHeight: tuiConfigSchemaFields.questionDialogMaxHeight, modelDialogWidth: tuiConfigSchemaFields.modelDialogWidth, modelDialogMaxHeight: tuiConfigSchemaFields.modelDialogMaxHeight, + detailsDialogWidth: tuiConfigSchemaFields.detailsDialogWidth, fileSearchMaxResults: tuiConfigSchemaFields.fileSearchMaxResults, fileSearchMaxEntries: tuiConfigSchemaFields.fileSearchMaxEntries, fileSearchExcludedDirectories: tuiConfigSchemaFields.fileSearchExcludedDirectories, @@ -164,6 +174,7 @@ export interface ResolvedTuiThemeConfig { export interface ResolvedTuiConfig { showReasoning: boolean maxToolOutputLines: number + maxDiffEditLength: number maxQuestionOptions: number maxModelOptions: number maxResumeOptions: number @@ -171,6 +182,7 @@ export interface ResolvedTuiConfig { questionDialogMaxHeight: number modelDialogWidth: number modelDialogMaxHeight: number + detailsDialogWidth: number fileSearchMaxResults: number fileSearchMaxEntries: number fileSearchExcludedDirectories: string[] @@ -189,6 +201,7 @@ export function resolveTuiConfig(config: TuiConfig | undefined): ResolvedTuiConf return { showReasoning: config?.showReasoning ?? true, maxToolOutputLines: config?.maxToolOutputLines ?? 6, + maxDiffEditLength: config?.maxDiffEditLength ?? 1000, maxQuestionOptions: config?.maxQuestionOptions ?? 8, maxModelOptions: config?.maxModelOptions ?? 8, maxResumeOptions: config?.maxResumeOptions ?? 8, @@ -196,6 +209,7 @@ export function resolveTuiConfig(config: TuiConfig | undefined): ResolvedTuiConf questionDialogMaxHeight: config?.questionDialogMaxHeight ?? 20, modelDialogWidth: config?.modelDialogWidth ?? 76, modelDialogMaxHeight: config?.modelDialogMaxHeight ?? 20, + detailsDialogWidth: config?.detailsDialogWidth ?? 72, fileSearchMaxResults: config?.fileSearchMaxResults ?? DEFAULT_FILE_SEARCH_MAX_RESULTS, fileSearchMaxEntries: config?.fileSearchMaxEntries ?? DEFAULT_FILE_SEARCH_MAX_ENTRIES, fileSearchExcludedDirectories: [...(config?.fileSearchExcludedDirectories ?? DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES)], diff --git a/packages/ui/tui/src/extension/overlay-manager.ts b/packages/ui/tui/src/extension/overlay-manager.ts index 59d84ff3d5..ab80f49cab 100644 --- a/packages/ui/tui/src/extension/overlay-manager.ts +++ b/packages/ui/tui/src/extension/overlay-manager.ts @@ -12,7 +12,6 @@ import type { TuiExtensionService } from '../index.ts' import type { Component, Focusable, - OverlayHandle, } from '@earendil-works/pi-tui' import type { TuiComponent, @@ -36,14 +35,20 @@ export interface TuiOverlayDriver { theme(): TuiTheme /** Escape text at the terminal display boundary. */ display(value: string): string - /** Mount one guarded component and return its private pi-tui handle. */ - show(component: Component, options: TuiOverlayOptions | undefined): OverlayHandle + /** Mount one guarded modal and return its private focus/lifecycle handle. */ + show(component: Component, options: TuiOverlayOptions | undefined, placement: TuiOverlayPlacement): TuiModalHandle /** Invalidate the mounted UI and request a render. */ invalidate(): void /** Report a contained extension failure. */ reportError(error: unknown): void } +type TuiOverlayPlacement = 'overlay' | 'inline' + +interface TuiModalHandle { + hide(): void +} + interface OverlayEntry { readonly request: TuiOverlayRequest readonly controller: AbortController @@ -51,9 +56,10 @@ interface OverlayEntry { readonly closed: Promise readonly resolveClosed: (outcome: TuiOverlayOutcome) => void readonly session: TuiOverlaySession + readonly placement: TuiOverlayPlacement state: TuiOverlayState component?: GuardedOverlayComponent - handle?: OverlayHandle + handle?: TuiModalHandle removeRequestAbort?: () => void outcome?: TuiOverlayOutcome failing?: boolean @@ -165,11 +171,12 @@ export class TuiOverlayManager { } /** - * Queue one overlay without assigning Cordis ownership. + * Queue one modal without assigning Cordis ownership. * @param request - component factory, constraints, and request signal. + * @param placement - terminal overlay for extensions, or inline for the built-in question panel. * @returns an internal session that can close with an ownership reason. */ - open(request: TuiOverlayRequest): TuiOverlaySession & { + open(request: TuiOverlayRequest, placement: TuiOverlayPlacement = 'overlay'): TuiOverlaySession & { closeWith(reason: Exclude): Promise } { if (!this.accepting) throw new Error('TUI is shutting down') @@ -202,6 +209,7 @@ export class TuiOverlayManager { closed: deferred.promise, resolveClosed: deferred.resolve, session, + placement, state: 'queued', } if (requestSignal?.aborted === true) { @@ -251,7 +259,7 @@ export class TuiOverlayManager { }) entry.component = guarded try { - const handle = this.driver.show(guarded, entry.request.options) + const handle = this.driver.show(guarded, entry.request.options, entry.placement) if (this.active !== entry) { this.hide(handle) return @@ -306,7 +314,7 @@ export class TuiOverlayManager { } } - private hide(handle: OverlayHandle): void { + private hide(handle: TuiModalHandle): void { try { handle.hide() } catch (error) { diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index a2cf047e0a..0ddb73ed9c 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -15,6 +15,7 @@ import { ProcessTerminal, matchesKey, visibleWidth, + type Component, type EditorTheme, type SlashCommand, type TerminalColorScheme, @@ -105,6 +106,7 @@ import { } from './components/transcript.ts' import { compactTargetLabel, + DetailsDialog, diagnosticMeter, formatDiagnosticCount, formatDiagnosticNumber, @@ -113,6 +115,7 @@ import { StatusCardComponent, PromptContextComponent, targetLabel, + type DetailsSelection, type StatusCardRow, } from './components/dialogs.ts' import { @@ -287,6 +290,23 @@ interface FadingStatus { timer: ReturnType } +/** Width/height adapter for a modal component rendered inside the base TUI flow. */ +class InlineModalComponent extends Container { + constructor( + component: Component, + private readonly width: number, + private readonly maxHeight: number, + ) { + super() + this.addChild(component) + } + + override render(width: number): string[] { + const lines = super.render(Math.max(1, Math.min(width, this.width))) + return lines.slice(0, Math.max(1, this.maxHeight)) + } +} + /** Lifecycle handle for a mounted interactive terminal channel. */ export interface TuiController { /** Stop rendering, restore the terminal, and reject pending questions. */ @@ -314,6 +334,7 @@ export function createTuiChat( const ui = new TUI(runtime.terminal, resolved.showHardwareCursor) const chat = new Container() const todoContainer = new Container() + const questionContainer = new Container() const inputTemplate = parseTuiPromptTemplate(displayInlineText(resolved.theme.inputPrompt)) const renderInputPrompt = (): string => renderTuiPromptTemplate(inputTemplate, valueName => ctx.tuiPrompt.get(valueName)) const initialInputPrompt = renderInputPrompt() @@ -337,6 +358,10 @@ export function createTuiChat( let toolsVisibility: ToolCardVisibility = 'collapsed' let streaming: StreamingAssistantComponent | undefined let completedStreaming: StreamingAssistantComponent | undefined + // Assistant step components in model order per turn, for hidden-mode folding: + // with tool cards hidden, a turn keeps one Assistant header and later steps + // render as headerless continuations (see applyTurnFolding). + const assistantSteps = new Map() let runningStatus: RunningStatus | undefined let fadingStatus: FadingStatus | undefined /** @@ -474,6 +499,7 @@ export function createTuiChat( ui.addChild(todoContainer) ui.addChild(compactionStatusLine) ui.addChild(promptContext) + ui.addChild(questionContainer) ui.addChild(editor) ui.setFocus(editor) const updateTerminalTitle = (): void => { @@ -523,14 +549,32 @@ export function createTuiChat( }), theme: () => extensionTheme, display: displayText, - show: (component, options) => ui.showOverlay(component, options === undefined - ? undefined - : { - ...options, - ...typeof options.margin === 'object' - ? { margin: { ...options.margin } } - : {}, - }), + show: (component, options, placement) => { + if (placement === 'overlay') { + return ui.showOverlay(component, options === undefined + ? undefined + : { + ...options, + ...typeof options.margin === 'object' + ? { margin: { ...options.margin } } + : {}, + }) + } + const modal = new InlineModalComponent( + component, + resolved.questionDialogWidth, + resolved.questionDialogMaxHeight, + ) + questionContainer.clear() + questionContainer.addChild(modal) + ui.setFocus(component) + return { + hide(): void { + questionContainer.clear() + ui.setFocus(editor) + }, + } + }, invalidate: requestRender, reportError: (error) => { const message = errorChain(error) @@ -637,6 +681,7 @@ export function createTuiChat( parsed, ctx.tools.get(event.data.name, agent), resolved.maxToolOutputLines, + resolved.maxDiffEditLength, palette, mdTheme, ) @@ -646,6 +691,35 @@ export function createTuiChat( return card } + /** + * Re-derive hidden-mode folding for one turn: the first step with a visible + * body owns the turn's single Assistant header, every other step renders as a + * headerless continuation (empty ones render nothing). Any other visibility + * restores the per-step headers. + */ + const applyTurnFolding = (turn: number): void => { + const steps = assistantSteps.get(turn) + if (steps === undefined) return + let headerSeen = false + for (const step of steps) { + if (toolsVisibility !== 'hidden') { + step.setFoldedContinuation(false) + } else if (!headerSeen && step.hasVisibleBody()) { + headerSeen = true + step.setFoldedContinuation(false) + } else { + step.setFoldedContinuation(true) + } + } + } + + const registerAssistantStep = (component: StreamingAssistantComponent): void => { + const steps = assistantSteps.get(component.position.turn) ?? [] + steps.push(component) + assistantSteps.set(component.position.turn, steps) + applyTurnFolding(component.position.turn) + } + const removeStreaming = (current: StreamingAssistantComponent | undefined): void => { if (current === undefined) return for (const child of [current, current.timing]) { @@ -653,6 +727,15 @@ export function createTuiChat( /* v8 ignore next -- streaming components and their timing footers are retained only while attached to the chat. */ if (index >= 0) chat.children.splice(index, 1) } + const steps = assistantSteps.get(current.position.turn) + /* v8 ignore next -- every attached streaming component is registered in the fold map. */ + if (steps === undefined) return + const index = steps.indexOf(current) + /* v8 ignore next -- registration precedes attachment, so the component is present until this removal. */ + if (index < 0) return + steps.splice(index, 1) + // A retracted step may have owned the turn's hidden-mode header. + applyTurnFolding(current.position.turn) } /** @@ -691,6 +774,7 @@ export function createTuiChat( palette, mdTheme, ) + registerAssistantStep(streaming) chat.addChild(streaming) chat.addChild(streaming.timing) } @@ -754,12 +838,22 @@ export function createTuiChat( startAssistantStep(event.data) break case 'assistant/chunk': - if (options.renderChunks) streaming?.update(event.data.chunk) + if (options.renderChunks && streaming !== undefined) { + streaming.update(event.data.chunk) + // The first streamed text/reasoning may make this step the turn's + // hidden-mode header owner (or a continuation with a visible body). + applyTurnFolding(streaming.position.turn) + } break case 'assistant/message': completedStreaming = undefined - if (streaming === undefined || !chat.children.includes(streaming)) startAssistantStep(event.data) - streaming?.settle(event.data.message.content) + // A settled component stays attached but never absorbs a later message + // of the same step; both the live and replay paths start a new one. + if (streaming === undefined || streaming.isSettled() || !chat.children.includes(streaming)) startAssistantStep(event.data) + if (streaming !== undefined) { + streaming.settle(event.data.message.content) + applyTurnFolding(streaming.position.turn) + } break case 'llm/retry': { retractFailedStreaming() @@ -780,7 +874,15 @@ export function createTuiChat( const callId = event.data.message.source.callId let card = toolCards.get(callId) if (card === undefined) { - card = new ToolCardComponent('tool', { value: {}, valid: true }, undefined, resolved.maxToolOutputLines, palette, mdTheme) + card = new ToolCardComponent( + 'tool', + { value: {}, valid: true }, + undefined, + resolved.maxToolOutputLines, + resolved.maxDiffEditLength, + palette, + mdTheme, + ) card.setVisibility(toolsVisibility) chat.addChild(card) allToolCards.add(card) @@ -870,6 +972,7 @@ export function createTuiChat( toolCards.clear() allToolCards.clear() contextCards.clear() + assistantSteps.clear() streaming = undefined todo.update([]) const transcriptCalls = transcriptToolCallIds(agent.session) @@ -891,6 +994,14 @@ export function createTuiChat( overlayManager, requestRender, isDisposed, + questionMaxHeight: () => { + const width = runtime.terminal.columns + const editorRows = editor.render(width).length + return Math.max(1, Math.min( + resolved.questionDialogMaxHeight, + runtime.terminal.rows - editorRows, + )) + }, }) const resume = createResumeController({ @@ -979,32 +1090,99 @@ export function createTuiChat( // same reason. ui.queryTerminalColorScheme({ timeoutMs: 2000 }).catch(() => {}) - const toggleTools = (): void => { - // The cycle order puts the two common reading modes adjacent: preview -> - // full detail -> conversation-only, then back to the preview default. - toolsVisibility = toolsVisibility === 'collapsed' ? 'expanded' - : toolsVisibility === 'expanded' ? 'hidden' : 'collapsed' + const setToolsVisibility = (next: ToolCardVisibility): void => { + toolsVisibility = next for (const card of allToolCards) card.setVisibility(toolsVisibility) // Context cards carry injected instructions rather than tool traffic, so // they never hide: the hidden phase reads as their collapsed preview. for (const card of contextCards) card.setExpanded(toolsVisibility === 'expanded') + // Hidden mode folds each turn's steps into one assistant message; other + // modes restore the per-step Assistant headers. + for (const turn of assistantSteps.keys()) applyTurnFolding(turn) appendNotice(toolsVisibility === 'hidden' ? 'Tool cards hidden.' : `Tool and context cards ${toolsVisibility}.`) } - const toggleReasoning = (): void => { - showReasoning = !showReasoning + const toggleTools = (): void => { + // The cycle order puts the two common reading modes adjacent: preview -> + // full detail -> conversation-only, then back to the preview default. + setToolsVisibility(toolsVisibility === 'collapsed' ? 'expanded' + : toolsVisibility === 'expanded' ? 'hidden' : 'collapsed') + } + + const setReasoning = (show: boolean): void => { + showReasoning = show const activeStreaming = streaming rebuildTranscript(false) /* v8 ignore next -- the non-streaming command path is covered; this branch preserves an active stream across rebuild. */ if (activeStreaming !== undefined) { streaming = activeStreaming streaming.setShowReasoning(showReasoning) + registerAssistantStep(activeStreaming) chat.addChild(activeStreaming) chat.addChild(activeStreaming.timing) } appendNotice(`Reasoning blocks ${showReasoning ? 'shown' : 'hidden'}.`) } + const toggleReasoning = (): void => { setReasoning(!showReasoning) } + + // The selector and the argument grammar mutate the same closure state the + // Ctrl+O cycle and Ctrl+R toggle drive, so every entry converges. + let detailsOverlay: TuiOverlaySession | undefined + const showDetailsSelector = (): void => { + void detailsOverlay?.close() + const session = overlayManager.open({ + create: () => new DetailsDialog( + toolsVisibility, + showReasoning, + palette, + // Each Tab applies immediately; one dimension changes per call. + (selection: DetailsSelection) => { + if (selection.showReasoning !== showReasoning) setReasoning(selection.showReasoning) + if (selection.visibility !== toolsVisibility) setToolsVisibility(selection.visibility) + }, + () => { void session.close() }, + ), + options: { width: resolved.detailsDialogWidth, anchor: 'center', margin: 1 }, + }) + detailsOverlay = session + void session.closed.then(() => { + if (detailsOverlay === session) detailsOverlay = undefined + }) + requestRender() + } + + // `/details` names the same transcript-detail state the Ctrl+O cycle and + // Ctrl+R toggle mutate, so a user can jump to a mode without cycling. + const runDetails = (rawInput: string): CommandResult => { + const tokens = rawInput.split(/\s+/u).filter(token => token !== '') + if (tokens.length === 0) { + showDetailsSelector() + return { kind: 'success' } + } + let visibility: ToolCardVisibility | undefined + let reasoning: boolean | undefined + for (let token = tokens.shift(); token !== undefined; token = tokens.shift()) { + if (token === 'collapsed' || token === 'expanded' || token === 'hidden') { + visibility = token + } else if (token === 'reasoning') { + const value = tokens[0] + if (value === 'on' || value === 'off') { + tokens.shift() + reasoning = value === 'on' + } else { + reasoning = !showReasoning + } + } else { + return { kind: 'error', text: `Unknown /details argument "${token}". Usage: /details [collapsed|expanded|hidden] [reasoning [on|off]]` } + } + } + // Reasoning first: its transcript rebuild would drop the visibility notice. + if (reasoning !== undefined) setReasoning(reasoning) + if (visibility !== undefined) setToolsVisibility(visibility) + return { kind: 'success' } + } + const showHelp = (): void => { const commandLines = ctx.commands.list(agent).map((command) => { const input = command.input === undefined ? '' : ` ${command.input.hint}` @@ -1190,6 +1368,12 @@ export function createTuiChat( description: 'Clear the transcript view (session history is unchanged)', handler: () => { chat.clear(); requestRender(); return { kind: 'success' } }, }) + commandCtx.commands.register({ + name: 'details', + description: 'Select tool-card visibility and reasoning display', + input: { hint: '[collapsed|expanded|hidden] [reasoning [on|off]]' }, + handler: ({ rawInput }) => runDetails(rawInput), + }) commandCtx.commands.register({ name: 'palette', description: 'Show every color and attribute role this terminal renders', @@ -1529,7 +1713,6 @@ export function createTuiChat( if (event.type === 'tool/result') fileSearch.invalidate() recordEventUsage(tokens, event) if (event.type === 'turn/start' && runningStatus !== undefined) runningStatus.turn = event.data.turn - if (event.type === 'assistant/message' && streaming?.isSettled()) streaming = undefined // Track live standalone compaction state. if (event.type === 'compact/start' && event.data.turn === null) { if (compacting === undefined) { diff --git a/packages/ui/tui/tests/snapshots/advanced-cards-collapsed.expected.txt b/packages/ui/tui/tests/snapshots/advanced-cards-collapsed.expected.txt index 2a005383e1..20ec3ab324 100644 --- a/packages/ui/tui/tests/snapshots/advanced-cards-collapsed.expected.txt +++ b/packages/ui/tui/tests/snapshots/advanced-cards-collapsed.expected.txt @@ -1,7 +1,7 @@ -terminal 100x40 buffer=normal length=40 base=0 viewport=0 +terminal 100x40 buffer=normal length=41 base=1 viewport=1 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=7 viewportRow=34 bufferRow=34 +cursor hidden column=7 viewportRow=39 bufferRow=40 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-magenta bold @@ -31,9 +31,9 @@ buffer style 0-10 bold 14| "- old line " style 0-9 fg=red -15| "… +3 lines (Ctrl+O to expand) " +15| "… +2 lines (Ctrl+O to expand) " style 0-28 dim -16| "└ +2 -2 · 1 file " +16| "└ +1 -1 · 1 file " style 0-15 dim 17| 18| "● Tool / subagent" @@ -58,17 +58,27 @@ buffer style 0-99 dim 30| "Loaded review instructions. " style 0-99 dim -31| "Model wait 0.0s " +31| +32| "● Tool / large_edit" + style 0-18 fg=green +33| "src/large.ts " + style 0-11 bold +34| "[exact line diff omitted: >2 changed lines] " + style 0-42 dim +35| "… +6 lines (Ctrl+O to expand) " + style 0-28 dim +36| "└ +3 -3 · 1 file · approximate " + style 0-29 dim +37| "Model wait 0.0s " style 0-14 dim -32| -33| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" +38| +39| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" style 0-17 fg=bright-magenta bold style 18-31 dim style 34-50 dim style 53-57 dim style 60-69 dim -34| " dsh > " +40| " dsh > " style 1-3 fg=bright-magenta bold style 5-6 dim style 7-7 inverse -35-39| diff --git a/packages/ui/tui/tests/snapshots/advanced-cards-expanded.expected.txt b/packages/ui/tui/tests/snapshots/advanced-cards-expanded.expected.txt index 6f9aa094f3..7752484db7 100644 --- a/packages/ui/tui/tests/snapshots/advanced-cards-expanded.expected.txt +++ b/packages/ui/tui/tests/snapshots/advanced-cards-expanded.expected.txt @@ -1,7 +1,7 @@ -terminal 100x40 buffer=normal length=43 base=3 viewport=3 +terminal 100x40 buffer=normal length=53 base=13 viewport=13 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=7 viewportRow=39 bufferRow=42 +cursor hidden column=7 viewportRow=39 bufferRow=52 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-magenta bold @@ -37,54 +37,73 @@ buffer style 0-10 bold 17| "- old line " style 0-9 fg=red -18| "- keep " - style 0-5 fg=red -19| "+ new line " +18| "+ new line " style 0-9 fg=green -20| "+ keep " - style 0-5 fg=green -21| "└ +2 -2 · 1 file " +19| " keep " + style 0-5 dim +20| "└ +1 -1 · 1 file " style 0-15 dim -22| -23| "● Tool / subagent" +21| +22| "● Tool / subagent" style 0-16 fg=green -24| "Delegate renderer audit " +23| "Delegate renderer audit " style 0-99 dim -25| "The renderer has explicit lifecycle ownership. " +24| "The renderer has explicit lifecycle ownership. " style 0-99 dim -26| -27| "● Tool / task_output" +25| +26| "● Tool / task_output" style 0-19 fg=green -28| "Read output from background task subagent-7 " +27| "Read output from background task subagent-7 " style 0-99 dim -29| " " -30| "console " +28| " " +29| "console " style 0-6 dim -31| " started background task bash-5 " +30| " started background task bash-5 " style 0-1 dim style 2-31 fg=cyan dim style 32-99 dim -32| " " -33| -34| "● Tool / skill" +31| " " +32| +33| "● Tool / skill" style 0-13 fg=green -35| "Load skill dsh-code-review " +34| "Load skill dsh-code-review " style 0-99 dim -36| "Loaded review instructions. " +35| "Loaded review instructions. " style 0-99 dim -37| "Model wait 0.0s " +36| +37| "● Tool / large_edit" + style 0-18 fg=green +38| "src/large.ts " + style 0-11 bold +39| "[exact line diff omitted: >2 changed lines] " + style 0-42 dim +40| "- old one " + style 0-8 fg=red +41| "- old two " + style 0-8 fg=red +42| "- old three " + style 0-10 fg=red +43| "+ new one " + style 0-8 fg=green +44| "+ new two " + style 0-8 fg=green +45| "+ new three " + style 0-10 fg=green +46| "└ +3 -3 · 1 file · approximate " + style 0-29 dim +47| "Model wait 0.0s " style 0-14 dim -38| -39| "Tool and context cards expanded. " +48| +49| "Tool and context cards expanded. " style 0-31 dim -40| -41| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" +50| +51| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" style 0-17 fg=bright-magenta bold style 18-31 dim style 34-50 dim style 53-57 dim style 60-69 dim -42| " dsh > " +52| " dsh > " style 1-3 fg=bright-magenta bold style 5-6 dim style 7-7 inverse diff --git a/packages/ui/tui/tests/snapshots/details-command.expected.txt b/packages/ui/tui/tests/snapshots/details-command.expected.txt new file mode 100644 index 0000000000..4d971c88df --- /dev/null +++ b/packages/ui/tui/tests/snapshots/details-command.expected.txt @@ -0,0 +1,42 @@ +terminal 100x40 buffer=normal length=40 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=7 viewportRow=17 bufferRow=17 +buffer +0| " DEEPSEEK HARNESS" + style 1-8 fg=bright-magenta bold + style 10-16 bold +1| " Snapshot agent ready." + style 1-21 dim +2| " main-session" + style 1-12 dim +3| +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| "Running the check now. " +6| "Model wait 0.0s " + style 0-14 dim +7| +8| "You " + style 0-2 fg=bright-magenta bold underline +9| "Inspect the renderer. " +10| "Model wait 0.0s · Completed 2026-07-30 18:00:00 " + style 0-46 dim +11| +12| "Reasoning blocks hidden. " + style 0-23 dim +13| +14| "Tool cards hidden. " + style 0-17 dim +15| +16| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-magenta bold + style 18-31 dim + style 34-50 dim + style 53-57 dim + style 60-69 dim +17| " dsh > " + style 1-3 fg=bright-magenta bold + style 5-6 dim + style 7-7 inverse +18-39| diff --git a/packages/ui/tui/tests/snapshots/details-selector.expected.txt b/packages/ui/tui/tests/snapshots/details-selector.expected.txt new file mode 100644 index 0000000000..69ab18bddd --- /dev/null +++ b/packages/ui/tui/tests/snapshots/details-selector.expected.txt @@ -0,0 +1,72 @@ +terminal 100x40 buffer=normal length=40 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=0 viewportRow=39 bufferRow=39 +buffer +0| " DEEPSEEK HARNESS" + style 1-8 fg=bright-magenta bold + style 10-16 bold +1| " Snapshot agent ready." + style 1-21 dim +2| " main-session" + style 1-12 dim +3| +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| "Running the check now. " +6| "Model wait 0.0s " + style 0-14 dim +7| +8| "You " + style 0-2 fg=bright-magenta bold underline +9| "Inspect the renderer. " +10| +11| "Assistant " + style 0-8 fg=bright-magenta bold underline +12| +13| "● Tool / bash / Run the coverage gate" + style 0-36 fg=green +14| "$ pnpm run test:coverage " + style 0-23 dim +15| "/workspace/project " + style 0-17 dim +16| "… +4 lines (Ctrl+O to expand) " + style 0-28 dim +17| "[exit 0] ╭ Transcript details ──────────────────────────────────────────────────╮ " + style 0-7 dim + style 14-85 fg=bright-magenta +18| "Model wait 0.0│ → Tool cards collapsed │ " + style 0-13 dim + style 14-14 fg=bright-magenta + style 16-58 fg=bright-magenta inverse + style 85-85 fg=bright-magenta +19| " │ Reasoning hidden │ " + style 14-14 fg=bright-magenta + style 27-55 dim + style 85-85 fg=bright-magenta +20| "Reasoning bloc│ │ " + style 0-13 dim + style 14-14 fg=bright-magenta + style 85-85 fg=bright-magenta +21| " │ ↑/↓ move • Tab toggle • Enter/Esc close │ " + style 14-14 fg=bright-magenta + style 16-54 dim + style 85-85 fg=bright-magenta +22| "Tool cards hid╰──────────────────────────────────────────────────────────────────────╯ " + style 0-13 dim + style 14-85 fg=bright-magenta +23| +24| "Tool and context cards collapsed. " + style 0-32 dim +25| +26| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-magenta bold + style 18-31 dim + style 34-50 dim + style 53-57 dim + style 60-69 dim +27| " dsh > " + style 1-3 fg=bright-magenta bold + style 5-6 dim + style 7-7 inverse +28-39| diff --git a/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt b/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt index a6a73566d6..1ade76a5aa 100644 --- a/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt +++ b/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt @@ -1,7 +1,7 @@ -terminal 92x32 buffer=normal length=37 base=5 viewport=5 +terminal 92x32 buffer=normal length=39 base=7 viewport=7 lifecycle started=1 stopped=1 progress=inactive title "DSH snapshot" -cursor visible column=0 viewportRow=31 bufferRow=36 +cursor visible column=0 viewportRow=31 bufferRow=38 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-magenta bold @@ -29,48 +29,52 @@ buffer 12| " " 13| "/clear — Clear the transcript view (session history is unchanged) " style 0-64 dim -14| "/exit — Exit after the active turn reaches idle " +14| "/details [collapsed|expanded|hidden] [reasoning [on|off]] — Select tool-card visibility and " + style 0-91 dim +15| "reasoning display " + style 0-16 dim +16| "/exit — Exit after the active turn reaches idle " style 0-46 dim -15| "/help — Show keyboard shortcuts and commands " +17| "/help — Show keyboard shortcuts and commands " style 0-43 dim -16| "/model [[provider/]model] — Show or switch this session's model " +18| "/model [[provider/]model] — Show or switch this session's model " style 0-62 dim -17| "/palette — Show every color and attribute role this terminal renders " +19| "/palette — Show every color and attribute role this terminal renders " style 0-67 dim -18| "/quit — Exit after the active turn reaches idle " +20| "/quit — Exit after the active turn reaches idle " style 0-46 dim -19| "/reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) " +21| "/reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) " style 0-87 dim -20| "/resume — List this workspace's resumable sessions " +22| "/resume — List this workspace's resumable sessions " style 0-49 dim -21| "/status — Show session diagnostics, system prompt, and registered tools " +23| "/status — Show session diagnostics, system prompt, and registered tools " style 0-70 dim -22| "/skill: [instructions] — load a skill into the conversation " +24| "/skill: [instructions] — load a skill into the conversation " style 0-64 dim -23| -24| "provider stream failed after partial output " - style 0-42 fg=red 25| -26| "The previous process ended during this turn. " - style 0-43 fg=yellow +26| "provider stream failed after partial output " + style 0-42 fg=red 27| -28| "Turn stopped: the agent was disposed. " - style 0-36 fg=yellow +28| "The previous process ended during this turn. " + style 0-43 fg=yellow 29| -30| "Turn ended: plugin-policy. " - style 0-25 fg=yellow +30| "Turn stopped: the agent was disposed. " + style 0-36 fg=yellow 31| -32| "Unknown command: /unknown-advanced-command " - style 0-41 fg=yellow +32| "Turn ended: plugin-policy. " + style 0-25 fg=yellow 33| -34| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" +34| "Unknown command: /unknown-advanced-command " + style 0-41 fg=yellow +35| +36| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" style 0-17 fg=bright-magenta bold style 18-31 dim style 34-50 dim style 53-57 dim style 60-69 dim -35| " dsh > " +37| " dsh > " style 1-3 fg=bright-magenta bold style 5-6 dim style 7-7 inverse -36| +38| diff --git a/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt b/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt index b8b62bca11..7da75336e3 100644 --- a/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt +++ b/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt @@ -1,7 +1,7 @@ -terminal 92x32 buffer=normal length=36 base=4 viewport=4 +terminal 92x32 buffer=normal length=38 base=6 viewport=6 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=7 viewportRow=31 bufferRow=35 +cursor hidden column=7 viewportRow=31 bufferRow=37 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-magenta bold @@ -29,47 +29,51 @@ buffer 12| " " 13| "/clear — Clear the transcript view (session history is unchanged) " style 0-64 dim -14| "/exit — Exit after the active turn reaches idle " +14| "/details [collapsed|expanded|hidden] [reasoning [on|off]] — Select tool-card visibility and " + style 0-91 dim +15| "reasoning display " + style 0-16 dim +16| "/exit — Exit after the active turn reaches idle " style 0-46 dim -15| "/help — Show keyboard shortcuts and commands " +17| "/help — Show keyboard shortcuts and commands " style 0-43 dim -16| "/model [[provider/]model] — Show or switch this session's model " +18| "/model [[provider/]model] — Show or switch this session's model " style 0-62 dim -17| "/palette — Show every color and attribute role this terminal renders " +19| "/palette — Show every color and attribute role this terminal renders " style 0-67 dim -18| "/quit — Exit after the active turn reaches idle " +20| "/quit — Exit after the active turn reaches idle " style 0-46 dim -19| "/reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) " +21| "/reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) " style 0-87 dim -20| "/resume — List this workspace's resumable sessions " +22| "/resume — List this workspace's resumable sessions " style 0-49 dim -21| "/status — Show session diagnostics, system prompt, and registered tools " +23| "/status — Show session diagnostics, system prompt, and registered tools " style 0-70 dim -22| "/skill: [instructions] — load a skill into the conversation " +24| "/skill: [instructions] — load a skill into the conversation " style 0-64 dim -23| -24| "provider stream failed after partial output " - style 0-42 fg=red 25| -26| "The previous process ended during this turn. " - style 0-43 fg=yellow +26| "provider stream failed after partial output " + style 0-42 fg=red 27| -28| "Turn stopped: the agent was disposed. " - style 0-36 fg=yellow +28| "The previous process ended during this turn. " + style 0-43 fg=yellow 29| -30| "Turn ended: plugin-policy. " - style 0-25 fg=yellow +30| "Turn stopped: the agent was disposed. " + style 0-36 fg=yellow 31| -32| "Unknown command: /unknown-advanced-command " - style 0-41 fg=yellow +32| "Turn ended: plugin-policy. " + style 0-25 fg=yellow 33| -34| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" +34| "Unknown command: /unknown-advanced-command " + style 0-41 fg=yellow +35| +36| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" style 0-17 fg=bright-magenta bold style 18-31 dim style 34-50 dim style 53-57 dim style 60-69 dim -35| " dsh > " +37| " dsh > " style 1-3 fg=bright-magenta bold style 5-6 dim style 7-7 inverse diff --git a/packages/ui/tui/tests/snapshots/question-dialog-detail-paged.expected.txt b/packages/ui/tui/tests/snapshots/question-dialog-detail-paged.expected.txt new file mode 100644 index 0000000000..415bedd976 --- /dev/null +++ b/packages/ui/tui/tests/snapshots/question-dialog-detail-paged.expected.txt @@ -0,0 +1,39 @@ +terminal 56x20 buffer=normal length=25 base=5 viewport=5 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=56 viewportRow=13 bufferRow=18 +viewport +5| "Model wait 0.0s " + style 0-14 dim +6| +7| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 " + style 0-17 fg=bright-magenta bold + style 18-31 dim + style 34-50 dim + style 53-55 dim +8| " Review the complete plan including every required " +9| " checkpoint including every required checkpoint " +10| " including every required checkpoint including every " +11| " required checkpoint including every required " +12| " checkpoint including every required checkpoint " +13| " including every required checkpoint including every " +14| " required checkpoint including every required " +15| " checkpoint including every required checkpoint " +16| " including every required checkpoint including every " +17| " required checkpoint visible plan tail " +18| " … lines 4-13/13 • PgUp/PgDn " + style 2-28 dim +19| " › 1. [ ] Code Mode " + style 2-20 fg=bright-magenta bold +20| " run_code programs and captured output with " + style 12-53 dim +21| " … lines 1-2/12 • PgUp/PgDn " + style 2-27 dim +22| " ↓ 3 more " + style 2-9 dim +23| " ↑↓ Tab Sp ↵Esc " + style 2-15 dim +24| " dsh > " + style 1-3 fg=bright-magenta bold + style 5-6 dim + style 7-7 inverse diff --git a/packages/ui/tui/tests/snapshots/question-dialog-paged.expected.txt b/packages/ui/tui/tests/snapshots/question-dialog-paged.expected.txt new file mode 100644 index 0000000000..2969918f17 --- /dev/null +++ b/packages/ui/tui/tests/snapshots/question-dialog-paged.expected.txt @@ -0,0 +1,39 @@ +terminal 56x20 buffer=normal length=25 base=5 viewport=5 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=56 viewportRow=16 bufferRow=21 +viewport +5| "Model wait 0.0s " + style 0-14 dim +6| +7| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 " + style 0-17 fg=bright-magenta bold + style 18-31 dim + style 34-50 dim + style 53-55 dim +8| " Review the complete plan including every required " +9| " checkpoint including every required checkpoint " +10| " including every required checkpoint including every " +11| " required checkpoint including every required " +12| " checkpoint including every required checkpoint " +13| " including every required checkpoint including every " +14| " required checkpoint including every required " +15| " checkpoint including every required checkpoint " +16| " including every required checkpoint including every " +17| " required checkpoint visible plan tail " +18| " … lines 4-13/13 • PgUp/PgDn " + style 2-28 dim +19| " detail with complete wrapped detail " + style 12-46 dim +20| " visible tail " + style 12-23 dim +21| " … lines 11-12/12 • PgUp/PgDn " + style 2-29 dim +22| " ↓ 3 more " + style 2-9 dim +23| " ↑↓ Tab Sp ↵Esc " + style 2-15 dim +24| " dsh > " + style 1-3 fg=bright-magenta bold + style 5-6 dim + style 7-7 inverse diff --git a/packages/ui/tui/tests/snapshots/question-dialog-single-option.expected.txt b/packages/ui/tui/tests/snapshots/question-dialog-single-option.expected.txt index a81556d42a..6cfef15ead 100644 --- a/packages/ui/tui/tests/snapshots/question-dialog-single-option.expected.txt +++ b/packages/ui/tui/tests/snapshots/question-dialog-single-option.expected.txt @@ -1,7 +1,7 @@ terminal 56x20 buffer=normal length=20 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=0 viewportRow=19 bufferRow=19 +cursor hidden column=56 viewportRow=16 bufferRow=16 viewport 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-magenta bold @@ -21,20 +21,20 @@ viewport style 18-31 dim style 34-50 dim style 53-55 dim -8| " dsh > " +8| " " +9| " Question 1/1 (1 unanswered) · Confirm " + style 2-38 dim +10| " Continue with this change? " +11| " " +12| " › 1. Proceed " + style 2-14 fg=bright-magenta bold +13| " Apply the proposed change " + style 8-32 dim +14| " Tab custom answer • Enter submit • Esc interrupt " + style 2-49 dim +15| " " +16| " dsh > " style 1-3 fg=bright-magenta bold style 5-6 dim style 7-7 inverse -9-11| -12| " " -13| " Question 1/1 (1 unanswered) · Confirm " - style 2-38 dim -14| " Continue with this change? " -15| " " -16| " › 1. Proceed Apply the proposed change " - style 2-13 fg=bright-magenta bold - style 16-40 dim -17| " Tab custom answer • Enter submit • Esc interrupt " - style 2-49 dim -18| " " -19| +17-19| diff --git a/packages/ui/tui/tests/snapshots/question-dialog-validation.expected.txt b/packages/ui/tui/tests/snapshots/question-dialog-validation.expected.txt index 14193efdda..ecd949cb8d 100644 --- a/packages/ui/tui/tests/snapshots/question-dialog-validation.expected.txt +++ b/packages/ui/tui/tests/snapshots/question-dialog-validation.expected.txt @@ -1,40 +1,40 @@ -terminal 56x20 buffer=normal length=20 base=0 viewport=0 +terminal 56x20 buffer=normal length=25 base=5 viewport=5 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=56 viewportRow=17 bufferRow=17 +cursor hidden column=56 viewportRow=17 bufferRow=22 viewport -0| " DEEPSEEK HARNESS" - style 1-8 fg=bright-magenta bold - style 10-16 bold -1| " Snapshot agent ready." - style 1-21 dim -2| " main-session" - style 1-12 dim -3| -4| "Assistant " - style 0-8 fg=bright-magenta bold underline -5| " " -6| " Question 1/3 (3 unanswered) · Coverage " - style 2-39 dim -7| " Which advanced TUI states belong in the required " -8| " matrix? " -9| " " -10| " › 1. [ ] Code Mode run_code programs and capture " - style 2-19 fg=bright-magenta bold - style 25-53 dim -11| " 2. [ ] Workflows phases and parallel agents " - style 25-50 dim -12| " 3. [ ] Cordis tools inspect, mount, and unmount " - style 25-51 dim -13| " 1/4 " - style 2-4 dim -14| " Tab custom answer • ↑/↓ navigate • Space toggle • " - style 2-55 dim -15| " Enter submit • Esc interrupt " +5| "Model wait 0.0s " + style 0-14 dim +6| +7| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 " + style 0-17 fg=bright-magenta bold + style 18-31 dim + style 34-50 dim + style 53-55 dim +8| " Review the complete plan including every required " +9| " checkpoint including every required checkpoint " +10| " including every required checkpoint including every " +11| " required checkpoint including every required " +12| " checkpoint including every required checkpoint " +13| " including every required checkpoint including every " +14| " required checkpoint including every required " +15| " checkpoint including every required checkpoint " +16| " including every required checkpoint including every " +17| " … lines 4-12/13 • PgUp/PgDn " + style 2-28 dim +18| " detail with complete wrapped detail " + style 12-46 dim +19| " visible tail " + style 12-23 dim +20| " … lines 11-12/12 • PgUp/PgDn " style 2-29 dim -16| " Select at least one option, or press Tab for a " - style 2-55 fg=red -17| " custom answer. " - style 2-15 fg=red -18| " " -19| +21| " ↓ 3 more " + style 2-9 dim +22| " Error: Select at least one option, or press Tab for… " + style 2-52 fg=red +23| " ↑↓ Tab Sp ↵Esc " + style 2-15 dim +24| " dsh > " + style 1-3 fg=bright-magenta bold + style 5-6 dim + style 7-7 inverse diff --git a/packages/ui/tui/tests/snapshots/question-dialog.expected.txt b/packages/ui/tui/tests/snapshots/question-dialog.expected.txt index 640bd6fed2..4b576099ae 100644 --- a/packages/ui/tui/tests/snapshots/question-dialog.expected.txt +++ b/packages/ui/tui/tests/snapshots/question-dialog.expected.txt @@ -1,39 +1,39 @@ -terminal 56x20 buffer=normal length=20 base=0 viewport=0 +terminal 56x20 buffer=normal length=25 base=5 viewport=5 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=0 viewportRow=19 bufferRow=19 +cursor hidden column=56 viewportRow=19 bufferRow=24 viewport -0| " DEEPSEEK HARNESS" - style 1-8 fg=bright-magenta bold - style 10-16 bold -1| " Snapshot agent ready." - style 1-21 dim -2| " main-session" - style 1-12 dim -3| -4| "Assistant " - style 0-8 fg=bright-magenta bold underline 5| "Model wait 0.0s " style 0-14 dim 6| -7| " " -8| " Question 1/3 (3 unanswered) · Coverage " - style 2-39 dim -9| " Which advanced TUI states belong in the required " -10| " matrix? " -11| " " -12| " › 1. [ ] Code Mode run_code programs and capture " - style 2-19 fg=bright-magenta bold - style 25-53 dim -13| " 2. [ ] Workflows phases and parallel agents " - style 25-50 dim -14| " 3. [ ] Cordis tools inspect, mount, and unmount " - style 25-51 dim -15| " 1/4 " - style 2-4 dim -16| " Tab custom answer • ↑/↓ navigate • Space toggle • " - style 2-55 dim -17| " Enter submit • Esc interrupt " - style 2-29 dim -18| " " -19| +7| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 " + style 0-17 fg=bright-magenta bold + style 18-31 dim + style 34-50 dim + style 53-55 dim +8| " Which advanced TUI states belong in the required " +9| " matrix? " +10| " " +11| " Review the complete plan including every required " +12| " checkpoint including every required checkpoint " +13| " including every required checkpoint including every " +14| " required checkpoint including every required " +15| " checkpoint including every required checkpoint " +16| " including every required checkpoint including every " +17| " required checkpoint including every required " +18| " … lines 1-10/13 • PgUp/PgDn " + style 2-28 dim +19| " › 1. [ ] Code Mode " + style 2-20 fg=bright-magenta bold +20| " run_code programs and captured output with " + style 12-53 dim +21| " … lines 1-2/12 • PgUp/PgDn " + style 2-27 dim +22| " ↓ 3 more " + style 2-9 dim +23| " ↑↓ Tab Sp ↵Esc " + style 2-15 dim +24| " dsh > " + style 1-3 fg=bright-magenta bold + style 5-6 dim + style 7-7 inverse diff --git a/packages/ui/tui/tests/snapshots/tool-cards-hidden-folded.expected.txt b/packages/ui/tui/tests/snapshots/tool-cards-hidden-folded.expected.txt new file mode 100644 index 0000000000..d4da5ab39c --- /dev/null +++ b/packages/ui/tui/tests/snapshots/tool-cards-hidden-folded.expected.txt @@ -0,0 +1,46 @@ +terminal 100x40 buffer=normal length=40 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=7 viewportRow=20 bufferRow=20 +buffer +0| " DEEPSEEK HARNESS" + style 1-8 fg=bright-magenta bold + style 10-16 bold +1| " Snapshot agent ready." + style 1-21 dim +2| " main-session" + style 1-12 dim +3| +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| "Inspecting the renderer first. " +6| "Model wait 0.0s " + style 0-14 dim +7| +8| "You " + style 0-2 fg=bright-magenta bold underline +9| "Refactor the renderer. " +10| "Model wait 0.0s · Completed 2026-07-29 22:30:00 " + style 0-46 dim +11| +12| "The renderer is sound; no refactor needed. " +13| "Model wait 0.0s · Completed 2026-07-29 22:30:00 " + style 0-46 dim +14| +15| "Tool and context cards expanded. " + style 0-31 dim +16| +17| "Tool cards hidden. " + style 0-17 dim +18| +19| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-magenta bold + style 18-31 dim + style 34-50 dim + style 53-57 dim + style 60-69 dim +20| " dsh > " + style 1-3 fg=bright-magenta bold + style 5-6 dim + style 7-7 inverse +21-39| diff --git a/packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt b/packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt index 74cdd416d9..5cb2529355 100644 --- a/packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt +++ b/packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt @@ -1,7 +1,7 @@ -terminal 100x34 buffer=normal length=34 base=0 viewport=0 +terminal 100x34 buffer=normal length=40 base=6 viewport=6 lifecycle started=1 stopped=0 progress=inactive title "Unsafe terminal title \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m" -cursor hidden column=0 viewportRow=33 bufferRow=33 +cursor hidden column=100 viewportRow=33 bufferRow=39 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-magenta bold @@ -13,55 +13,65 @@ buffer 3| 4| "Assistant " style 0-8 fg=bright-magenta bold underline -5| -6| "You " +5| "Reasoning " + style 0-8 dim italic +6| "Unsafe reasoning \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " + style 0-61 dim italic +7| "Unsafe assistant \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " +8| "Model wait 0.0s " + style 0-14 dim +9| +10| "You " style 0-2 fg=bright-magenta bold underline -7| "Unsafe user \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " -8| -9| "● Tool / unsafe / Unsafe description \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m" +11| "Unsafe user \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " +12| +13| "Assistant " + style 0-8 fg=bright-magenta bold underline +14| +15| "● Tool / unsafe / Unsafe description \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m" style 0-81 fg=green -10| "$ Unsafe title \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " +16| "$ Unsafe title \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " style 0-59 dim -11| "/unsafe/\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " +17| "/unsafe/\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " style 0-52 dim -12| "Unsafe output \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " +18| "Unsafe output \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " style 0-58 dim -13| "[signal SIG\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m] " +19| "[signal SIG\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m] " style 0-56 fg=red -14| "Model wait 0.0s · Completed 2026-07-21 15:00:00 " +20| "Model wait 0.0s · Completed 2026-07-21 15:00:00 " style 0-46 dim -15| -16| "Context · unsafe-\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m" +21| +22| "Context · unsafe-\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m" style 0-61 dim -17| "Unsafe context \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " +23| "Unsafe context \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " style 0-59 dim -18| -19| "Unsafe turn error \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " +24| +25| "Unsafe turn error \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " style 0-62 fg=red -20-21| -22| "Plan" +26-27| +28| "Plan" style 0-3 fg=bright-magenta bold -23| " ● Unsafe todo \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m" +29| " ● Unsafe todo \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m" style 2-2 fg=yellow -24| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" +30| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" style 0-17 fg=bright-magenta bold style 18-31 dim style 34-50 dim style 53-57 dim style 60-69 dim -25| " dsh > " +31| " " +32| " Question 1/1 (1 unanswered) · Unsafe header \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " + style 2-90 dim +33| " Unsafe question \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " +34| " " +35| " › 1. Unsafe option \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " + style 2-66 fg=bright-magenta bold +36| " Unsafe detail \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " + style 8-66 dim +37| " Tab custom answer • Enter submit • Esc interrupt " + style 2-49 dim +38| " " +39| " dsh > " style 1-3 fg=bright-magenta bold style 5-6 dim style 7-7 inverse -26| " " -27| " Question 1/1 (1 unanswered) · Unsafe header \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " - style 2-90 dim -28| " Unsafe question \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " -29| " " -30| " › 1. Unsafe option \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m Unsafe detail \\x1b]2;snapshot-c " - style 2-65 fg=bright-magenta bold - style 67-97 dim -31| " Tab custom answer • Enter submit • Esc interrupt " - style 2-49 dim -32| " " -33| diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index 18f0a9a793..21696153a2 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/packages/ui/tui/tests/tui.snapshot.ts @@ -44,8 +44,13 @@ const CHECKPOINTS = [ 'cordis-tools-pending', 'advanced-cards-collapsed', 'advanced-cards-expanded', + 'tool-cards-hidden-folded', + 'details-command', + 'details-selector', 'untrusted-controls', 'question-dialog', + 'question-dialog-detail-paged', + 'question-dialog-paged', 'question-dialog-single-option', 'question-dialog-validation', 'surface-before-compaction', @@ -269,13 +274,32 @@ const ADVANCED_CARD_TOOLS: Record = { edit: visualTool( 'edit', () => ({ card: 'diff', title: 'Edit src/view.ts', diffs: [{ path: 'src/view.ts', oldText: 'old line', newText: 'new line' }] }), - // The real edit/write tools produce exactly one diff whose path the title - // already names, so the card omits the redundant per-file header. + // The fixed tool header never names a path, so the hunk retains its path. (): ToolResultView => ({ card: 'diff', diffs: [{ path: 'src/view.ts', oldText: 'old line\nkeep', newText: 'new line\nkeep' }], }), ), + large_edit: visualTool( + 'large_edit', + () => ({ + card: 'diff', + title: 'Edit src/large.ts', + diffs: [{ + path: 'src/large.ts', + oldText: 'old one\nold two\nold three', + newText: 'new one\nnew two\nnew three', + }], + }), + (): ToolResultView => ({ + card: 'diff', + diffs: [{ + path: 'src/large.ts', + oldText: 'old one\nold two\nold three', + newText: 'new one\nnew two\nnew three', + }], + }), + ), subagent: visualTool('subagent', args => ({ card: 'generic', title: 'Delegate renderer audit', @@ -585,7 +609,7 @@ describe('TUI terminal-state snapshots', () => { it('pins terminal, diff, subagent, task, skill, collapsed, and expanded cards', async () => { const harness = await setupSnapshot({ tools: ADVANCED_CARD_TOOLS, - config: { maxToolOutputLines: 3 }, + config: { maxToolOutputLines: 3, maxDiffEditLength: 2 }, }, { columns: 100, rows: 40 }) const calls = [ { id: 'advanced-1', name: 'bash', arguments: { command: 'pnpm run test:coverage' } }, @@ -593,6 +617,7 @@ describe('TUI terminal-state snapshots', () => { { id: 'advanced-3', name: 'subagent', arguments: { prompt: 'Review renderer ownership and report only gaps.' } }, { id: 'advanced-4', name: 'task_output', arguments: { task_id: 'subagent-7', wait: true } }, { id: 'advanced-5', name: 'skill', arguments: { name: 'dsh-code-review' } }, + { id: 'advanced-6', name: 'large_edit', arguments: { file_path: 'src/large.ts' } }, ] await renderAfter(harness, () => { appendToolCalls(harness.session, calls) @@ -601,6 +626,7 @@ describe('TUI terminal-state snapshots', () => { appendToolResult(harness.session, 'advanced-3', [{ type: 'text', text: 'The renderer has explicit lifecycle ownership.' }]) appendToolResult(harness.session, 'advanced-4', [{ type: 'text', text: 'audit complete\n[status: completed]' }]) appendToolResult(harness.session, 'advanced-5', [{ type: 'text', text: 'Loaded review instructions.' }]) + appendToolResult(harness.session, 'advanced-6', [{ type: 'text', text: 'large edit complete' }]) }) await checkpoint('advanced-cards-collapsed', harness.terminal, { includeScrollback: true }) @@ -609,6 +635,71 @@ describe('TUI terminal-state snapshots', () => { await disposeSnapshot(harness) }) + it('pins the hidden phase folding a multi-step turn into one assistant message', async () => { + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(new Date(2026, 6, 29, 22, 30, 0).getTime()) + const harness = await setupSnapshot({ + tools: ADVANCED_CARD_TOOLS, + config: { maxToolOutputLines: 3 }, + }, { columns: 100, rows: 40 }) + await renderAfter(harness, () => { + appendUser(harness.session, 'Refactor the renderer.') + appendAssistant(harness.session, [{ type: 'text', text: 'Inspecting the renderer first.' }]) + appendToolCalls(harness.session, [ + { id: 'fold-1', name: 'bash', arguments: { command: 'pnpm run test' } }, + ]) + appendToolResult(harness.session, 'fold-1', [{ type: 'text', text: 'all tests pass' }]) + harness.session.append('step/end', { turn: 1, step: 1 }) + harness.session.append('step/start', { turn: 1, step: 2 }) + appendAssistant(harness.session, [{ type: 'text', text: 'The renderer is sound; no refactor needed.' }], undefined, { turn: 1, step: 2 }) + harness.session.append('step/end', { turn: 1, step: 2 }) + harness.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + }) + // collapsed -> expanded -> hidden: one Assistant header, no tool card. + await renderAfter(harness, () => { harness.terminal.send('\x0f') }) + await renderAfter(harness, () => { harness.terminal.send('\x0f') }) + await checkpoint('tool-cards-hidden-folded', harness.terminal, { includeScrollback: true }) + nowSpy.mockRestore() + await disposeSnapshot(harness) + }) + + it('pins /details jumping card visibility and reasoning display to named states', async () => { + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(new Date(2026, 6, 30, 18, 0, 0).getTime()) + const harness = await setupSnapshot({ + tools: ADVANCED_CARD_TOOLS, + config: { maxToolOutputLines: 3 }, + }, { columns: 100, rows: 40 }) + await renderAfter(harness, () => { + appendUser(harness.session, 'Inspect the renderer.') + appendAssistant(harness.session, [ + { type: 'reasoning', text: 'The tool card and this block vanish under /details hidden reasoning off.' }, + { type: 'text', text: 'Running the check now.' }, + ]) + appendToolCalls(harness.session, [ + { id: 'details-1', name: 'bash', arguments: { command: 'pnpm run test' } }, + ]) + appendToolResult(harness.session, 'details-1', [{ type: 'text', text: 'all tests pass' }]) + harness.session.append('step/end', { turn: 1, step: 1 }) + harness.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + }) + await renderAfter(harness, () => { + harness.terminal.send('/details hidden reasoning off') + harness.terminal.send('\r') + }) + await checkpoint('details-command', harness.terminal, { includeScrollback: true }) + // Bare /details opens the two-entry toggle seeded with the current + // hidden/reasoning-off state; one Tab immediately cycles tool cards + // hidden -> collapsed, so the frame pins the applied notice, the restored + // tool card behind the dialog, and the updated entry value together. + await renderAfter(harness, () => { + harness.terminal.send('/details') + harness.terminal.send('\r') + harness.terminal.send('\t') + }) + await checkpoint('details-selector', harness.terminal, { includeScrollback: true }) + nowSpy.mockRestore() + await disposeSnapshot(harness) + }) + it('renders terminal controls as inert text across transcripts, tools, dialogs, diagnostics, and title', async () => { const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(new Date(2026, 6, 21, 15, 0, 0).getTime()) const tools = { @@ -697,9 +788,13 @@ describe('TUI terminal-state snapshots', () => { id: 'coverage', header: 'Coverage', question: 'Which advanced TUI states belong in the required matrix?', + detail: `Review the complete plan ${'including every required checkpoint '.repeat(12)}visible plan tail`, multiSelect: true, options: [ - { label: 'Code Mode', description: 'run_code programs and captured output' }, + { + label: 'Code Mode', + description: `run_code programs and captured output ${'with complete wrapped detail '.repeat(12)}visible tail`, + }, { label: 'Workflows', description: 'phases and parallel agents' }, { label: 'Cordis tools', description: 'inspect, mount, and unmount' }, { label: 'Compaction', description: 'surface replacement and reflow' }, @@ -714,6 +809,14 @@ describe('TUI terminal-state snapshots', () => { await harness.terminal.waitForFrame(beforeQuestion) await checkpoint('question-dialog', harness.terminal) + await renderAfter(harness, () => { harness.terminal.send('\x1b[6~') }) + await checkpoint('question-dialog-detail-paged', harness.terminal) + + await renderAfter(harness, () => { + for (let page = 0; page < 30; page += 1) harness.terminal.send('\x1b[6~') + }) + await checkpoint('question-dialog-paged', harness.terminal) + await renderAfter(harness, () => { harness.terminal.send('\r') }) await checkpoint('question-dialog-validation', harness.terminal) controller.abort() diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index fffcec4674..4ecf78d54a 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -50,6 +50,7 @@ import { disposeTuiTestHarness, type TuiHarnessOptions, } from './harness.ts' +import { HeadlessTerminal } from './headless-terminal.ts' import { TestSessionQueryService } from './session-query.ts' const UNUSED_TOOL_OUTPUT: ToolDefinition['output'] = { @@ -185,6 +186,7 @@ describe('TUI config', () => { expect(resolveTuiConfig(undefined)).toEqual({ showReasoning: true, maxToolOutputLines: 6, + maxDiffEditLength: 1000, maxQuestionOptions: 8, maxModelOptions: 8, maxResumeOptions: 8, @@ -192,6 +194,7 @@ describe('TUI config', () => { questionDialogMaxHeight: 20, modelDialogWidth: 76, modelDialogMaxHeight: 20, + detailsDialogWidth: 72, fileSearchMaxResults: 20, fileSearchMaxEntries: 10_000, fileSearchExcludedDirectories: ['.git', 'node_modules'], @@ -209,6 +212,7 @@ describe('TUI config', () => { expect(resolveTuiConfig({ showReasoning: false, maxToolOutputLines: 2, + maxDiffEditLength: 12, maxQuestionOptions: 3, maxModelOptions: 4, maxResumeOptions: 5, @@ -216,6 +220,7 @@ describe('TUI config', () => { questionDialogMaxHeight: 14, modelDialogWidth: 64, modelDialogMaxHeight: 16, + detailsDialogWidth: 44, fileSearchMaxResults: 7, fileSearchMaxEntries: 123, fileSearchExcludedDirectories: ['.git', 'generated'], @@ -225,6 +230,7 @@ describe('TUI config', () => { })).toEqual({ showReasoning: false, maxToolOutputLines: 2, + maxDiffEditLength: 12, maxQuestionOptions: 3, maxModelOptions: 4, maxResumeOptions: 5, @@ -232,6 +238,7 @@ describe('TUI config', () => { questionDialogMaxHeight: 14, modelDialogWidth: 64, modelDialogMaxHeight: 16, + detailsDialogWidth: 44, fileSearchMaxResults: 7, fileSearchMaxEntries: 123, fileSearchExcludedDirectories: ['.git', 'generated'], @@ -2687,6 +2694,94 @@ describe('pi-tui chat lifecycle and transcript', () => { await dispose(result) }) + it('/details sets card visibility and reasoning display from arguments', async () => { + const result = await setup() + const run = async (line: string): Promise => { + result.terminal.send(line) + result.terminal.send('\r') + await tick() + } + + await run('/details hidden') + expect(result.terminal.output).toContain('Tool cards hidden.') + + await run('/details expanded reasoning off') + expect(result.terminal.output).toContain('Tool and context cards expanded.') + expect(result.terminal.output).toContain('Reasoning blocks hidden.') + + await run('/details reasoning on') + expect(result.terminal.output).toContain('Reasoning blocks shown.') + + // Bare `reasoning` toggles: shown -> hidden. + const toggleOutput = result.terminal.output.length + await run('/details reasoning') + expect(result.terminal.output.slice(toggleOutput)).toContain('Reasoning blocks hidden.') + await run('/details collapsed') + expect(result.terminal.output.slice(toggleOutput)).toContain('Tool and context cards collapsed.') + + await run('/details bogus') + expect(result.terminal.output).toContain('Unknown /details argument "bogus"') + + await dispose(result) + }) + + it('bare /details opens the transcript-details toggle and Tab applies immediately', async () => { + const result = await setup() + const open = async (): Promise => { + const from = result.terminal.output.length + result.terminal.send('/details') + result.terminal.send('\r') + await vi.waitFor(() => { expect(result.terminal.output.slice(from)).toContain('Transcript details') }) + return from + } + + const opened = await open() + expect(result.terminal.output.slice(opened)).toContain('Tool cards') + expect(result.terminal.output.slice(opened)).toContain('Reasoning') + + // A second /details while the selector is open replaces the overlay + // instead of stacking a second one behind it. + await result.ctx.commands.execute(result.agent, '/details', new AbortController().signal) + await tick() + + // Each Tab applies one step immediately while the dialog stays open: + // collapsed -> expanded -> hidden -> collapsed (wraparound). + result.terminal.send('\t') + await tick() + expect(result.terminal.output).toContain('Tool and context cards expanded.') + result.terminal.send('\t') + await tick() + expect(result.terminal.output).toContain('Tool cards hidden.') + result.terminal.send('\t') + await tick() + expect(result.terminal.output).toContain('Tool and context cards collapsed.') + + // The reasoning entry toggles the same way. + result.terminal.send('\x1b[B') + result.terminal.send('\t') + await tick() + expect(result.terminal.output).toContain('Reasoning blocks hidden.') + + // Enter closes without further changes. + const entered = result.terminal.output.length + result.terminal.send('\r') + await tick() + expect(result.terminal.output.slice(entered)).not.toContain('Reasoning blocks') + + // Esc and Ctrl+C also close; the reopened dialog shows the live values. + const reopened = await open() + expect(result.terminal.output.slice(reopened)).toContain('collapsed') + expect(result.terminal.output.slice(reopened)).toContain('hidden') + result.terminal.send('\x1b') + await tick() + const ctrlCOutput = await open() + result.terminal.send('\x03') + await tick() + expect(result.terminal.output.slice(ctrlCOutput)).not.toContain('Reasoning blocks') + + await dispose(result) + }) + it('sends, steers, handles commands, global keys, and disposed-agent input', async () => { const result = await setup() @@ -4584,7 +4679,11 @@ describe('tool cards and surface replay', () => { presentCall: () => ({ card: 'diff', title: 'Edit src/only.ts', - diffs: [{ path: 'src/only.ts', oldText: 'old', newText: 'new' }], + diffs: [{ + path: 'src/only.ts', + oldText: 'my: my-MM\nne: ne-NP\nnl: nl-NL\nnb: no-NO\npa: pa-Guru-IN\npl: pl-PL\npt_pt: pt-PT', + newText: 'my: my-MM\nne: ne-NP\nnl: nl-NL\nnb: nb-NO\npa: pa-Guru-IN\npl: pl-PL\npt_pt: pt-PT', + }], }), }, scatteredDiff: { @@ -4938,7 +5037,7 @@ describe('tool cards and surface replay', () => { }) it('names a single-file diff in the body once, under a fixed Tool header', async () => { - const result = await setup({ tools }) + const result = await setup({ tools, config: { maxToolOutputLines: 20 } }) appendUser(result.session, 'edit one file') appendAssistant(result.session, [ { type: 'text', text: 'Editing' }, @@ -4954,9 +5053,123 @@ describe('tool cards and surface replay', () => { expect(output).toContain('Tool / singleDiff') expect(output).not.toContain('Edit src/only.ts') expect(output.split('src/only.ts').length - 1).toBe(1) - expect(output).toContain('- old') - expect(output).toContain('+ new') - expect(output).toContain('· 1 file') + expect(output).toContain(' my: my-MM') + expect(output).not.toContain('- my: my-MM') + expect(output).not.toContain('+ my: my-MM') + expect(output).toContain('- nb: no-NO') + expect(output).toContain('+ nb: nb-NO') + expect(output).toContain('└ +1 -1 · 1 file') + await dispose(result) + }) + + it('renders an empty create without a synthetic added row', async () => { + const emptyCreate: Record = { + emptyCreate: { + name: 'emptyCreate', + description: '', + parameters: {}, + output: UNUSED_TOOL_OUTPUT, + execute: async () => [], + presentCall: () => ({ + card: 'diff', + title: 'Write empty.txt', + diffs: [{ path: 'empty.txt', oldText: null, newText: '' }], + }), + }, + } + const result = await setup({ + tools: emptyCreate, + config: { maxToolOutputLines: 20, theme: { color: false } }, + }) + appendAssistant(result.session, [ + { type: 'tool-call', id: 'empty-create' as never, name: 'emptyCreate', arguments: '{}' }, + ]) + result.session.append('tool/call', { + turn: 1, + step: 1, + callId: 'empty-create' as never, + name: 'emptyCreate', + arguments: '{}', + }) + await tick() + const rows = result.terminal.output.split('\n').map(row => row.trim()) + expect(result.terminal.output).toContain('empty.txt') + expect(result.terminal.output).toContain('└ +0 -0 · 1 file') + expect(rows).not.toContain('+') + await dispose(result) + }) + + it('bounds and caches exact diff comparison before whole-side fallback', async () => { + let oldTextReads = 0 + let newText = 'new one\nnew two' + const boundedDiff = { + path: 'bounded.txt', + get oldText() { + oldTextReads += 1 + return 'old one\nold two' + }, + get newText() { return newText }, + } + const boundedView = { + card: 'diff' as const, + title: 'Edit bounded.txt', + diffs: [boundedDiff], + } + const bounded: Record = { + bounded: { + name: 'bounded', + description: '', + parameters: {}, + output: UNUSED_TOOL_OUTPUT, + execute: async () => [], + presentCall: () => boundedView, + presentResult: () => { + newText = 'settled one\nsettled two' + return boundedView + }, + }, + } + const result = await setup({ + tools: bounded, + config: { + maxToolOutputLines: 20, + maxDiffEditLength: 1, + theme: { color: false }, + }, + }) + appendAssistant(result.session, [ + { type: 'tool-call', id: 'bounded-diff' as never, name: 'bounded', arguments: '{}' }, + ]) + result.session.append('tool/call', { + turn: 1, + step: 1, + callId: 'bounded-diff' as never, + name: 'bounded', + arguments: '{}', + }) + await tick() + expect(result.terminal.output).toContain('[exact line diff omitted: >1 changed lines]') + expect(result.terminal.output).toContain('- old one') + expect(result.terminal.output).toContain('+ new one') + expect(result.terminal.output).toContain('└ +2 -2 · 1 file · approximate') + const readsAfterFirstRender = oldTextReads + expect(readsAfterFirstRender).toBeGreaterThan(0) + result.session.append('tool/result', { + turn: 1, + step: 1, + message: createToolResultMessage({ + callId: 'bounded-diff' as never, + content: [{ type: 'text', text: 'done' }], + isError: false, + }), + }, { surfaceOp: 'append' }) + await tick() + expect(result.terminal.output).toContain('+ settled one') + expect(oldTextReads).toBeGreaterThan(readsAfterFirstRender) + const readsAfterResult = oldTextReads + result.terminal.resize(87) + await tick() + expect(oldTextReads).toBe(readsAfterResult) await dispose(result) }) @@ -5167,9 +5380,185 @@ describe('tool cards and surface replay', () => { expect(mounted).not.toContain('stored model-only payload') await dispose(result) }) + + /** The last repainted frame, with CSI/OSC escapes and carriage returns stripped. */ + const lastFrame = (terminal: FakeTerminal): string => terminal.output + .slice(terminal.output.lastIndexOf('\x1b[2J')) + .replaceAll(/\x1b\[[0-9;]*[A-Za-z]|\x1b\][^\x07]*\x07|\r/g, '') + + const countAssistantHeaders = (frame: string): number => frame.split('\n') + .filter(row => row.trim() === 'Assistant').length + + /** One turn with text -> tool call/result -> text across two steps. */ + const appendTwoStepTurn = (session: Awaited>['session']): void => { + appendUser(session, 'fold me') + appendAssistant(session, [{ type: 'text', text: 'first step text' }]) + session.append('tool/call', { turn: 1, step: 1, callId: 'fold-1' as never, name: 'bash', arguments: '{}' }) + session.append('tool/result', { + turn: 1, step: 1, + message: createToolResultMessage({ + callId: 'fold-1' as never, content: [{ type: 'text', text: 'tool body' }], isError: false, + }), + }, { surfaceOp: 'append' }) + session.append('step/end', { turn: 1, step: 1 }) + session.append('step/start', { turn: 1, step: 2 }) + appendAssistant(session, [{ type: 'text', text: 'second step text' }], undefined, { turn: 1, step: 2 }) + session.append('step/end', { turn: 1, step: 2 }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + } + + it('folds a turn to one Assistant header in hidden mode and restores headers on cycle', async () => { + const result = await setup({ tools }) + appendTwoStepTurn(result.session) + await tick() + + // Collapsed (default): each step keeps its own header. + result.terminal.send('\x0c') + await tick() + expect(countAssistantHeaders(lastFrame(result.terminal))).toBe(2) + + // collapsed -> expanded -> hidden. + result.terminal.send('\x0f') + result.terminal.send('\x0f') + await tick() + result.terminal.send('\x0c') + await tick() + const hidden = lastFrame(result.terminal) + expect(countAssistantHeaders(hidden)).toBe(1) + expect(hidden).toContain('first step text') + expect(hidden).toContain('second step text') + expect(hidden).not.toContain('Tool / bash') + // The fold keeps model order: header text precedes the continuation. + expect(hidden.indexOf('first step text')).toBeLessThan(hidden.indexOf('second step text')) + + // hidden -> collapsed restores per-step headers. + result.terminal.send('\x0f') + await tick() + result.terminal.send('\x0c') + await tick() + expect(countAssistantHeaders(lastFrame(result.terminal))).toBe(2) + await dispose(result) + }) + + it('gives the hidden-mode header to the first step with a visible body and keeps turns separate', async () => { + const result = await setup({ tools }) + // Turn 1, step 1 is tool-only; step 2 carries the turn's text. + appendUser(result.session, 'tool-only first step') + appendAssistant(result.session, [{ type: 'tool-call', id: 'only-1' as never, name: 'bash', arguments: '{}' }]) + result.session.append('tool/call', { turn: 1, step: 1, callId: 'only-1' as never, name: 'bash', arguments: '{}' }) + result.session.append('tool/result', { + turn: 1, step: 1, + message: createToolResultMessage({ + callId: 'only-1' as never, content: [{ type: 'text', text: 'tool body' }], isError: false, + }), + }, { surfaceOp: 'append' }) + result.session.append('step/end', { turn: 1, step: 1 }) + result.session.append('step/start', { turn: 1, step: 2 }) + appendAssistant(result.session, [{ type: 'text', text: 'late turn-one text' }], undefined, { turn: 1, step: 2 }) + result.session.append('step/end', { turn: 1, step: 2 }) + result.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + // Turn 2 keeps its own header. + result.session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) + appendUser(result.session, 'next turn') + result.session.append('step/start', { turn: 2, step: 1 }) + appendAssistant(result.session, [{ type: 'text', text: 'turn-two text' }], undefined, { turn: 2, step: 1 }) + result.session.append('step/end', { turn: 2, step: 1 }) + result.session.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) + await tick() + + result.terminal.send('\x0f') + result.terminal.send('\x0f') + await tick() + result.terminal.send('\x0c') + await tick() + const hidden = lastFrame(result.terminal) + // One header per turn: the tool-only step neither renders a blank segment + // nor consumes turn one's header, which the late text step owns. + expect(countAssistantHeaders(hidden)).toBe(2) + expect(hidden).toContain('late turn-one text') + expect(hidden).toContain('turn-two text') + const rows = hidden.split('\n').map(row => row.trim()) + const turnOneHeader = rows.indexOf('Assistant') + expect(rows[turnOneHeader + 1]).toBe('late turn-one text') + await dispose(result) + }) + + it('folds live hidden-mode streaming once a later step shows text', async () => { + const result = await setup({ tools, status: 'running' }) + result.terminal.send('\x0f') + result.terminal.send('\x0f') + await tick() + result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'live first' } }) + result.session.append('step/end', { turn: 1, step: 1 }) + result.session.append('step/start', { turn: 1, step: 2 }) + result.session.append('assistant/chunk', { turn: 1, step: 2, chunk: { type: 'text-delta', index: 0, text: 'live second' } }) + await tick() + result.terminal.send('\x0c') + await tick() + const hidden = lastFrame(result.terminal) + expect(countAssistantHeaders(hidden)).toBe(1) + expect(hidden).toContain('live first') + expect(hidden).toContain('live second') + + // A transcript rebuild (resize) recomputes the same fold from the log. + result.terminal.resize(89) + await tick() + const rebuilt = lastFrame(result.terminal) + expect(countAssistantHeaders(rebuilt)).toBe(1) + expect(rebuilt).toContain('live second') + await dispose(result) + }) }) describe('TUI user-interaction dialogs', () => { + it('limits the visible option window to maxQuestionOptions', async () => { + const result = await setup({ + config: { maxQuestionOptions: 1, questionDialogWidth: 60, questionDialogMaxHeight: 20 }, + }) + const answer = result.ctx.userInteraction.ask({ + questions: [{ + id: 'cap', + question: 'Pick one', + options: [{ label: 'Visible first' }, { label: 'Hidden second' }], + }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + expect(result.terminal.output).toContain('Visible first') + expect(result.terminal.output).not.toContain('Hidden second') + expect(result.terminal.output).toContain('↓ 1 more') + result.terminal.send('\x03') + await rejected + + await dispose(result) + }) + + it('renders a pending question between the transcript and editor', async () => { + const result = await setup({ + config: { questionDialogWidth: 40, questionDialogMaxHeight: 10 }, + }) + result.terminal.send('draft input') + const answer = result.ctx.userInteraction.ask({ + questions: [{ + id: 'placement', + question: 'Pick one', + options: [{ label: 'First' }, { label: 'Second' }], + }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + result.terminal.resize(60, 20) + await tick() + const render = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J')) + const questionIndex = render.indexOf('Pick one') + const editorIndex = render.indexOf('draft input') + expect(questionIndex).toBeGreaterThanOrEqual(0) + expect(editorIndex).toBeGreaterThan(questionIndex) + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + it('answers single-select, multi-select, custom, and optionless questions', async () => { const result = await setup({ config: { maxQuestionOptions: 1 } }) @@ -5195,8 +5584,29 @@ describe('TUI user-interaction dialogs', () => { result.terminal.send(' ') result.terminal.send('\x1b[B') result.terminal.send(' ') + result.terminal.send('\t') + await tick() + expect(result.terminal.output).toContain('2 selected • Enter submit • Esc options') + result.terminal.send('Tests') result.terminal.send('\r') - await expect(multi).resolves.toEqual({ answers: [{ id: 'targets', selected: ['Code', 'Docs'] }] }) + await expect(multi).resolves.toEqual({ + answers: [{ id: 'targets', selected: ['Code', 'Docs'], custom: 'Tests' }], + }) + + const labelsOnly = result.ctx.userInteraction.ask({ + questions: [{ + id: 'labels-only', + question: 'Pick one target', + multiSelect: true, + options: [{ label: 'Code' }, { label: 'Docs' }], + }], + }) + await tick() + result.terminal.send(' ') + result.terminal.send('\r') + await expect(labelsOnly).resolves.toEqual({ + answers: [{ id: 'labels-only', selected: ['Code'] }], + }) const custom = result.ctx.userInteraction.ask({ questions: [{ id: 'other', question: 'Choose or type', options: [{ label: 'Default' }] }], @@ -5239,7 +5649,6 @@ describe('TUI user-interaction dialogs', () => { options: [{ label: 'One', description: 'first' }, { label: 'Two' }], }], }) - const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) await tick() result.terminal.send('\x1b[A') result.terminal.send('\x1b[B') @@ -5255,14 +5664,482 @@ describe('TUI user-interaction dialogs', () => { }) result.terminal.send('c') await tick() + result.terminal.send('keep this') + await tick() + expect(result.terminal.output).toContain('0 selected • Enter submit • Esc options') result.terminal.send('\x1b') await tick() expect(result.terminal.output).toContain('Space toggle') + result.terminal.send(' ') + result.terminal.send('\r') + await expect(answer).resolves.toEqual({ + answers: [{ id: 'options', selected: ['One'], custom: 'keep this' }], + }) + await dispose(result) + }) + + it('scrolls tall option lists with ↑/↓ overflow markers when the dialog height is capped', async () => { + const result = await setup({ + config: { + questionDialogWidth: 60, + questionDialogMaxHeight: 12, + maxQuestionOptions: 8, + }, + }) + const answer = result.ctx.userInteraction.ask({ + questions: [{ + id: 'scroll', + question: 'Pick one', + options: [ + { label: 'Alpha', description: 'first choice with a description that will wrap to multiple lines when the dialog is narrow' }, + { label: 'Bravo', description: 'second choice' }, + { label: 'Charlie', description: 'third choice' }, + { label: 'Delta', description: 'fourth choice' }, + { label: 'Echo', description: 'fifth choice' }, + { label: 'Foxtrot', description: 'sixth choice' }, + ], + }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + expect(result.terminal.output).toContain('↓') + expect(result.terminal.output).toContain('more') + for (let step = 0; step < 5; step += 1) result.terminal.send('\x1b[B') + await tick() + expect(result.terminal.output).toContain('↑') result.terminal.send('\x03') await rejected await dispose(result) }) + it('keeps controls visible when the selected option block exceeds the row budget', async () => { + const result = await setup({ + config: { questionDialogWidth: 40, questionDialogMaxHeight: 10 }, + }) + const answer = result.ctx.userInteraction.ask({ + questions: [{ + id: 'oversize', + question: 'Pick one', + options: [ + { label: 'Huge', description: `start ${'middle '.repeat(40)}visible tail` }, + { label: 'Other' }, + ], + }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + expect(result.terminal.output).toContain('Huge') + expect(result.terminal.output).toContain('PgUp/PgDn') + expect(result.terminal.output).toContain('↑↓ Tab ↵ Esc') + expect(result.terminal.output).not.toContain('visible tail') + for (let page = 0; page < 30; page += 1) result.terminal.send('\x1b[6~') + await tick() + expect(result.terminal.output).toContain('visible tail') + for (let page = 0; page < 30; page += 1) result.terminal.send('\x1b[5~') + result.terminal.send('\x1b[6~') + await tick() + expect(result.terminal.output).toContain('start middle') + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + + it('pages long question detail so every plan-review line remains reachable', async () => { + const result = await setup({ + config: { questionDialogWidth: 20, questionDialogMaxHeight: 10 }, + }) + const answer = result.ctx.userInteraction.ask({ + questions: [{ + id: 'long-detail', + question: 'Approve this plan?', + detail: `visible start ${'review step '.repeat(60)}visible tail`, + options: [{ label: 'Approve' }, { label: 'Reject' }], + }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + result.terminal.resize(60, 20) + await tick() + const initialRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J')) + expect(initialRender).toContain('plan?') + expect(initialRender).toContain('visible start') + expect(initialRender).not.toContain('visible tail') + expect(initialRender).toMatch(/PgUp\/PgDn \d+\/\d+/u) + for (let page = 0; page < 30; page += 1) result.terminal.send('\x1b[6~') + await tick() + const finalRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J')) + expect(finalRender).toContain('visible tail') + expect(finalRender).toContain('Approve') + result.terminal.send('\x1b[B') + await tick() + const movedRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J')) + expect(movedRender).toContain('visible tail') + expect(movedRender).toContain('Reject') + result.terminal.send('\x1b[A') + result.terminal.send('\t') + await tick() + result.terminal.send('\x1b[6~') + await tick() + result.terminal.send('\x1b[5~') + result.terminal.resize(61, 20) + await tick() + const customPagedRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J')) + expect(customPagedRender).not.toContain('visible tail') + expect(customPagedRender).toContain('Esc options') + result.terminal.send('\x1b') + for (let page = 0; page < 30; page += 1) result.terminal.send('\x1b[5~') + await tick() + const restoredRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J')) + expect(restoredRender).toContain('visible start') + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + + it('reclaims enough rows to keep selected content, paging, and option markers visible', async () => { + const result = await setup({ + config: { questionDialogWidth: 60, questionDialogMaxHeight: 8 }, + }) + const answer = result.ctx.userInteraction.ask({ + questions: [{ + id: 'one-row', + question: 'Pick one', + options: [ + { label: 'Selected first', description: `start ${'middle '.repeat(30)}visible tail` }, + { label: 'Hidden second' }, + ], + }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + expect(result.terminal.output).toContain('Selected first') + expect(result.terminal.output).not.toContain('Hidden second') + expect(result.terminal.output).toContain('↓ 1 more') + expect(result.terminal.output).toContain('PgUp/PgDn') + expect(result.terminal.output).toContain('Esc interrupt') + for (let page = 0; page < 30; page += 1) result.terminal.send('\x1b[6~') + await tick() + expect(result.terminal.output).toContain('visible tail') + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + + it('preserves both option markers and controls at the minimum configured height', async () => { + const result = await setup({ + config: { questionDialogWidth: 60, questionDialogMaxHeight: 6 }, + }) + const answer = result.ctx.userInteraction.ask({ + questions: [{ + id: 'minimum-options', + question: 'Pick one', + multiSelect: true, + options: ['One', 'Two', 'Three', 'Four', 'Five'].map(label => ({ + label, + description: `${label} ${'wrapped detail '.repeat(20)}`, + })), + }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + result.terminal.send('\x1b[B') + result.terminal.send('\x1b[B') + await tick() + expect(result.terminal.output).toContain('↑ 2 more') + expect(result.terminal.output).toContain('Three') + expect(result.terminal.output).toContain('PgUp/PgDn') + expect(result.terminal.output).toContain('↓ 2 more') + expect(result.terminal.output).toContain('Tab custom') + expect(result.terminal.output).toContain('Space toggle') + expect(result.terminal.output).toContain('Esc interrupt') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('Error: Select at least one') + result.terminal.resize(61) + await tick() + const validationRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J')) + expect(validationRender).toContain('Tab custom') + expect(validationRender).toContain('Space toggle') + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + + it('preserves detail text and every action when one compact header row remains', async () => { + const result = await setup({ + config: { questionDialogWidth: 20, questionDialogMaxHeight: 6 }, + }) + const answer = result.ctx.userInteraction.ask({ + questions: [{ + id: 'one-header-row', + question: 'Plan?', + detail: 'abcdvisible tail', + multiSelect: true, + options: [ + { label: 'Yes', description: 'accept' }, + { label: 'No', description: 'reject' }, + ], + }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + result.terminal.resize(60, 20) + await tick() + const initialRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J')) + expect(initialRender).toContain('P↑↓ ↑↓ Tab S↵Esc') + result.terminal.send('\x1b[6~') + result.terminal.send('\x1b[6~') + await tick() + const detailRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J')) + expect(detailRender).toContain('visible tail') + result.terminal.send('\x03') + await rejected + + const single = result.ctx.userInteraction.ask({ + questions: [{ + id: 'one-header-row-single', + question: 'Plan?', + detail: 'abcdvisible tail', + options: [ + { label: 'Yes', description: 'accept' }, + { label: 'No', description: 'reject' }, + ], + }], + }) + const singleRejected = expect(single).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + result.terminal.resize(61, 20) + await tick() + const singleRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J')) + expect(singleRender).toContain('P↑↓ ↑↓ Tab↵Esc') + result.terminal.send('\x03') + await singleRejected + + const compact = result.ctx.userInteraction.ask({ + questions: [{ + id: 'one-header-row-compact', + question: 'Pick?', + multiSelect: true, + options: [ + { label: 'Yes', description: 'accept' }, + { label: 'No', description: 'reject' }, + ], + }], + }) + const compactRejected = expect(compact).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + result.terminal.resize(60, 20) + await tick() + const compactRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J')) + expect(compactRender).toContain('↑↓ Tab Sp ↵Esc') + result.terminal.send('\x03') + await compactRejected + + const oneOption = result.ctx.userInteraction.ask({ + questions: [{ + id: 'one-header-row-one-option', + question: 'Pick?', + detail: 'Review every line.', + options: [{ label: 'Yes', description: 'wrapped detail '.repeat(8) }], + }], + }) + const oneOptionRejected = expect(oneOption).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + result.terminal.resize(61, 20) + await tick() + const oneOptionRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J')) + expect(oneOptionRender).toContain('P↑↓ Tab↵Esc') + expect(oneOptionRender).not.toContain('P↑↓ ↑↓') + result.terminal.send('\x03') + await oneOptionRejected + await dispose(result) + }) + + it('expands the visible option window forward and backward around the selection', async () => { + const result = await setup({ + config: { + questionDialogWidth: 60, + questionDialogMaxHeight: 14, + maxQuestionOptions: 8, + }, + }) + const answer = result.ctx.userInteraction.ask({ + questions: [{ + id: 'middle-scroll', + question: 'Pick one', + options: [ + { label: 'One', description: 'a' }, + { label: 'Two', description: 'b' }, + { label: 'Three', description: 'c' }, + { label: 'Four', description: 'd' }, + { label: 'Five', description: 'e' }, + { label: 'Six', description: 'f' }, + { label: 'Seven', description: 'g' }, + { label: 'Eight', description: 'h' }, + { label: 'Nine', description: 'i' }, + { label: 'Ten', description: 'j' }, + ], + }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + for (let step = 0; step < 4; step += 1) result.terminal.send('\x1b[B') + await tick() + expect(result.terminal.output).toContain('↑') + expect(result.terminal.output).toContain('↓') + expect(result.terminal.output).toContain('Five') + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + + it('wraps a long option label across multiple lines instead of truncating it', async () => { + const result = await setup({ config: { questionDialogWidth: 40 } }) + const longLabel = 'this is a very long option label that will not fit on one line in a narrow dialog' + const answer = result.ctx.userInteraction.ask({ + questions: [{ + id: 'long-label', + question: 'Pick one', + options: [{ label: longLabel }, { label: 'Short' }], + }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + expect(result.terminal.output).toContain('narrow dialog') + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + + it('wraps fixed question chrome within the minimum dialog width', async () => { + const result = await setup({ config: { questionDialogWidth: 20 } }) + const answer = result.ctx.userInteraction.ask({ + questions: [{ id: 'narrow', question: 'Answer?' }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + expect(result.terminal.output).not.toContain('Question 1/1 (1 unanswered)') + expect(result.terminal.output).toContain('unanswered)') + expect(result.terminal.output).not.toContain('Enter submit • Esc cancel') + expect(result.terminal.output).toContain('Esc cancel') + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + + it('keeps custom controls visible at the minimum dialog height', async () => { + const result = await setup({ + config: { questionDialogWidth: 20, questionDialogMaxHeight: 6 }, + }) + const answer = result.ctx.userInteraction.ask({ + questions: [{ id: 'short-viewport', question: 'Answer this deliberately long question?' }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + expect(result.terminal.output).toContain('long question?') + expect(result.terminal.output).toContain('Esc cancel') + result.terminal.resize(60, 4) + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('Enter an answer') + expect(result.terminal.output).toContain('long question?') + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + + it('compacts custom controls for a question that also has options', async () => { + const result = await setup({ + config: { questionDialogWidth: 20, questionDialogMaxHeight: 6 }, + }) + const answer = result.ctx.userInteraction.ask({ + questions: [{ + id: 'compact-custom-options', + question: 'Choose or type a deliberately long answer', + options: [{ label: 'Default' }], + }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + result.terminal.send('\t') + await tick() + expect(result.terminal.output).toContain('Esc options') + result.terminal.send('\x1b') + await tick() + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + + it('reports hidden question rows when the viewport leaves one row', async () => { + const result = await setup({ + config: { questionDialogWidth: 60, questionDialogMaxHeight: 6 }, + }) + result.terminal.resize(60, 2) + const answer = result.ctx.userInteraction.ask({ + questions: [{ id: 'one-row-dialog', question: 'Answer this deliberately long question?' }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + expect(result.terminal.output).toContain('lines hidden') + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + + it('keeps question text when the viewport leaves two question rows', async () => { + const result = await setup({ + config: { questionDialogWidth: 60, questionDialogMaxHeight: 6 }, + }) + result.terminal.resize(60, 3) + const answer = result.ctx.userInteraction.ask({ + questions: [{ id: 'two-row-dialog', question: 'Answer this deliberately long question?' }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + expect(result.terminal.output).toContain('long question?') + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + + it('bounds option mode when the viewport leaves three question rows', async () => { + const result = await setup({ + config: { questionDialogWidth: 60, questionDialogMaxHeight: 6 }, + }) + result.terminal.resize(60, 4) + const answer = result.ctx.userInteraction.ask({ + questions: [{ + id: 'three-row-options', + question: 'Pick one', + options: [{ label: 'First' }, { label: 'Second' }], + }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + expect(result.terminal.output).toContain('lines hidden') + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + + it('keeps question rows within a sub-five-column viewport', async () => { + const terminal = new HeadlessTerminal(4, 12) + const result = await createTuiTestHarness(terminal, vi.fn(), { + config: { questionDialogWidth: 20 }, + }) + const beforeQuestion = terminal.frames + const answer = result.ctx.userInteraction.ask({ + questions: [{ id: 'narrow-viewport', question: 'Pick?', options: [{ label: 'Yes' }] }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await terminal.waitForFrame(beforeQuestion) + await expect(terminal.snapshot()).resolves.toContain('terminal 4x12') + terminal.send('\x03') + await rejected + await disposeTuiTestHarness(result) + }) + it('asks batches in order and rejects cancelled or aborted work', async () => { const result = await setup() const preAborted = new AbortController() diff --git a/packages/ui/user-interaction/README.i18n.yaml b/packages/ui/user-interaction/README.i18n.yaml index a74e6ec71b..1909864c26 100644 --- a/packages/ui/user-interaction/README.i18n.yaml +++ b/packages/ui/user-interaction/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/ui/user-interaction/README.md -README.md: d62e75d110b8be339c5f9449b0834320f695ac99 -README.zh.md: 55258e85e56df2375ed8f195fa0b3b731a9cb816 +README.md: c7fec590d6e44a13b94cc682f5e069b2d3c5e416 +README.zh.md: 340af3541a09a528aa0fcc580bda070ea703f39e diff --git a/packages/ui/user-interaction/README.md b/packages/ui/user-interaction/README.md index d62e75d110..c7fec590d6 100644 --- a/packages/ui/user-interaction/README.md +++ b/packages/ui/user-interaction/README.md @@ -20,7 +20,7 @@ Abstract user-interaction seam. It owns `ctx.userInteraction`, the service a mod - `UserInteractionProvider` — UI implementation with `ask(request)`. - `UserInteractionError` — `HarnessError` subclass with codes such as `EMPTY_QUESTIONS`, `BAD_INTENT`, `NO_PROVIDER`, `DUPLICATE_PROVIDER`, and `ASK_ABORTED`. -When an answer includes `custom`, `selected` is empty; custom text is an override rather than a supplement to selected choices. A UI may preserve a skipped item as `{ id, selected: [] }`, keeping the existing answer shape while retaining other answers in the batch. +For a single-select question, `custom` overrides the selected choice and `selected` is empty. For a multi-select question, `custom` may supplement the labels in `selected`. A UI may preserve a skipped item as `{ id, selected: [] }`, keeping the existing answer shape while retaining other answers in the batch. ### Presentation intent diff --git a/packages/ui/user-interaction/README.zh.md b/packages/ui/user-interaction/README.zh.md index 55258e85e5..340af3541a 100644 --- a/packages/ui/user-interaction/README.zh.md +++ b/packages/ui/user-interaction/README.zh.md @@ -20,7 +20,7 @@ - `UserInteractionProvider`:包含 `ask(request)` 的 UI 实现。 - `UserInteractionError`:`HarnessError` 的子类,包含 `EMPTY_QUESTIONS`、`BAD_INTENT`、`NO_PROVIDER`、`DUPLICATE_PROVIDER` 和 `ASK_ABORTED` 等代码。 -当回答包含 `custom` 时,`selected` 为空;自定义文本是所选选项的替代,而不是补充。UI 可以把跳过的条目保留为 `{ id, selected: [] }`,既维持现有回答形态,也保留该批次中的其他回答。 +对于单选题,`custom` 会覆盖选中的选项,且 `selected` 为空。对于多选题,`custom` 可以补充 `selected` 中的标签。UI 可以把跳过的条目保留为 `{ id, selected: [] }`,既维持现有回答形态,也保留该批次中的其他回答。 ### 呈现意图 diff --git a/packages/ui/user-interaction/src/types.ts b/packages/ui/user-interaction/src/types.ts index 63949e81a1..51edfc6bf7 100644 --- a/packages/ui/user-interaction/src/types.ts +++ b/packages/ui/user-interaction/src/types.ts @@ -53,7 +53,7 @@ export interface AskUserQuestionItem { export interface AskUserQuestionAnswerItem { /** The answered question id. */ id: string - /** Selected option labels. Empty for custom or unanswered choices. */ + /** Selected option labels. May accompany custom text for a multi-select question. */ selected: string[] /** Optional free-text "Other" answer. */ custom?: string diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f2d9e34a75..c2494a99a5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1999,9 +1999,15 @@ importers: specifier: ^18.2.0 version: 18.3.1 devDependencies: + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../test-runtime '@deepseek-ai/dsh-client-ui-conversation': specifier: workspace:^ version: link:../ui-conversation @@ -2017,6 +2023,12 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants + '@deepseek-ai/dsh-subagent': + specifier: workspace:^ + version: link:../../subagent/subagent + '@deepseek-ai/dsh-token-meter': + specifier: workspace:^ + version: link:../../llm/token-meter '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -5005,6 +5017,10 @@ importers: version: link:../../../vendor/cordis packages/subagent/subagent: + dependencies: + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -5027,6 +5043,9 @@ importers: '@deepseek-ai/dsh-session-persistence': specifier: workspace:^ version: link:../../session-persistence/session-persistence + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session-projection/session-projection '@deepseek-ai/dsh-session-query': specifier: workspace:^ version: link:../../session-query/session-query @@ -5942,6 +5961,9 @@ importers: '@earendil-works/pi-tui': specifier: 0.80.7 version: 0.80.7(patch_hash=6c30c5386c0159131e1361023cddf31377f5728962524841964373312c1ed946) + diff: + specifier: ^9.0.0 + version: 9.0.0 saxes: specifier: 6.0.0 version: 6.0.0 diff --git a/tsconfig.host.json b/tsconfig.host.json index 165941d3fd..aace57d2c6 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -23,6 +23,7 @@ "apps/web/tests/settings-chrome.e2e.ts", "apps/web/tests/models-settings.e2e.ts", "apps/web/tests/onboarding-deepseek-config.e2e.ts", + "apps/web/tests/remote-welcome.e2e.ts", "apps/web/tests/workspace-management.e2e.ts", "apps/web/tests/replay-round-trip.e2e.ts", "apps/web/tests/hmr-live.e2e.ts", @@ -33,6 +34,7 @@ "apps/web/tests/cordis-tool-round.e2e.ts", "apps/web/tests/web-search-round.e2e.ts", "apps/web/tests/message-actions.e2e.ts", + "apps/web/tests/markdown-images.e2e.ts", "apps/web/tests/queue-actions.e2e.ts", "apps/web/tests/skill-invocation-policy.e2e.ts", "apps/web/tests/permission-policy-context.e2e.ts", @@ -41,6 +43,7 @@ "apps/web/tests/goal-bar.e2e.ts", "apps/web/tests/startup-auto-selection.e2e.ts", "apps/web/tests/subagent-conversation.e2e.ts", + "apps/web/tests/bash-abort-row.e2e.ts", "apps/cli/tests/**/*.ts", "examples/*/src/**/*.ts", "examples/*/start.ts", diff --git a/vendor/README.md b/vendor/README.md index 3872faa753..c05a65e28f 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -42,6 +42,7 @@ Keep this log exhaustive — every divergence from upstream must be listed. 10. **`loader/src/repository.ts`, `loader/tsdown.config.ts`, and the `@cordisjs/plugin-loader/repository` export**: the Node-only `RepositoryCache` installs one exact dependency specifier through the bundled `pnpm@11.7.0`, single-flights callers, and atomically publishes only a prepared package plus marker under the specifier hash. The subpath stays out of the browser-reachable Loader entry. Identical specifiers permanently reuse that entry; callers change the ref/specifier for another generation. The isolated workspace permits dependency build scripts because a configured repository is executable code, while the child drops ambient credential-shaped variables. Covered by `packages/ui/app-boot/tests/repository-cache.spec.ts`, including a keyless local-Git prepare run through the bundled pnpm. 11. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`; its built ESM/CJS entries retain explicit `.mjs`/`.cjs` extensions. 12. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. `applyEntryPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes one shared base (`apps/cli/config/base.cordis.yml`) with a surface overlay, an optional `--config` overlay, and the personal `~/.dsh/config.yaml` as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/ui/app-boot/tests/config-reload.spec.ts`. +13. **`include/src/index.ts` serialized child-tree mutation and `hmr/src/index.ts` main-watcher initial-scan suppression**: every Include child-tree mutation (initial apply, refresh, `internal/update` patch re-application) runs through one per-Include queue, because the group's transactional `update` is not reentrant — two concurrent applies interleave create and rollback on the same entries and strand the Include fiber without ever settling. The HMR main watcher passes `ignoreInitial: true`: the initial scan re-announced files boot had just consumed, and its `add` for a config file refreshed an Include mid-initial-apply; once serialized, a failing initial apply's rollback disposed HMR, whose teardown drain waited on the queued refresh sitting behind that same apply — a deadlock that exited 13 with no diagnostic and the TUI's terminal state stranded. `registerConfig()` keeps its own `ignoreInitial: false` watcher because a personal config present at registration must apply once. Covered by the `dsh` invalid-provider PTY case in `apps/cli/tests/tui-keyless-smoke.e2e.ts`. ## Sync procedure diff --git a/vendor/hmr/src/index.ts b/vendor/hmr/src/index.ts index 65ce923dc3..2484d0152a 100644 --- a/vendor/hmr/src/index.ts +++ b/vendor/hmr/src/index.ts @@ -209,6 +209,14 @@ class Hmr extends Service { ...this.config, cwd: this.baseDir, ignored: path => match(relative(this.baseDir, path)), + // The initial scan re-announces files the boot just consumed: an `add` + // for a config file refreshes an include whose initial apply may still + // be in flight, and a failing apply then rolls this plugin back while + // the scan-triggered refresh waits on that apply — a teardown deadlock + // that strands boot without a diagnostic. Only events after the scan + // matter here; `registerConfig` keeps its own initial scan because a + // personal config present at registration must apply once. + ignoreInitial: true, }) // Collect externals: framework modules reachable from the main entry. diff --git a/vendor/include/src/index.ts b/vendor/include/src/index.ts index a13d273bc2..4a9fd6be86 100644 --- a/vendor/include/src/index.ts +++ b/vendor/include/src/index.ts @@ -171,6 +171,7 @@ export class Include extends EntryTree { private content?: string private data?: EntryOptions[] private writeTask?: NodeJS.Timeout + private applyQueue: Promise = Promise.resolve() constructor(ctx: Context, public config: Include.Config) { super(ctx) @@ -186,12 +187,29 @@ export class Include extends EntryTree { ctx.on('internal/update', async (config, _, next) => { if (config.path !== this.config.path) return next() - const data = this.applyPatches(this.data!, config.patches) - await this.root.update(data) - this.config = config + await this.enqueue(async () => { + const data = this.applyPatches(this.data!, config.patches) + await this.root.update(data) + this.config = config + }) }) } + /** + * Serialize one child-tree mutation behind every earlier one. The group's + * transactional `update` is not reentrant: two concurrent applies (the init + * apply racing an HMR-triggered refresh from the watcher's initial scan) + * interleave create and rollback on the same entries and strand the include + * fiber without settling, so every apply path funnels through this queue. + * A predecessor's failure is its own caller's outcome and never gates the + * next task. + */ + private enqueue(task: () => Promise): Promise { + const run = this.applyQueue.then(task, task) + this.applyQueue = run.then(() => {}, () => {}) + return run + } + private async checkAccess() { if (!this.type) return try { @@ -262,12 +280,20 @@ export class Include extends EntryTree { * @throws when reading, parsing, validation, application, or rollback fails; the last good tree remains active when rollback succeeds. */ async refresh() { - const candidate = await this.read() - if (!candidate) return - await this.apply(candidate) + // Read inside the queue so the changed-content check compares against the + // predecessor's committed state, not a mid-apply snapshot. + await this.enqueue(async () => { + const candidate = await this.read() + if (!candidate) return + await this._apply(candidate) + }) } - private async apply(candidate: ReadCandidate) { + private apply(candidate: ReadCandidate) { + return this.enqueue(() => this._apply(candidate)) + } + + private async _apply(candidate: ReadCandidate) { const data = this.applyPatches(candidate.data, this.config.patches) await this.root.update(data) this.content = candidate.content