mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge remote-tracking branch 'origin/master' into feat/ripgrep-packaged-binary
This commit is contained in:
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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) | 中文
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 组合控制作为独立功能存在,而非隐藏的作用域语义。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -26,7 +26,7 @@ A tool card has one colored `Tool / <name>` status header over one dim body. Pre
|
||||
|
||||
Injected context renders as prose in `ContextCardComponent`, not through the XML tree renderer. Exact matched outer `<system-reminder>` 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
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ Status: implemented
|
||||
|
||||
注入上下文由 `ContextCardComponent` 按普通文本呈现,不经过 XML 树渲染器。仅移除精确配对的外层 `<system-reminder>` 行;不匹配、单边或正文内类似标签的文本都原样保留。面向模型的内容不变。折叠在正文组装完成后使用共享 `preview` 辅助函数,因此只取决于行数,不依赖解析是否成功或载荷包含哪些字符。
|
||||
|
||||
`Ctrl+O` 在折叠、展开和隐藏之间循环。隐藏状态会连同卡片自有的前导间距一起移除工具卡片。上下文卡片参与折叠和展开状态,但工具隐藏时回到折叠状态,因为注入指令不是可丢弃的工具流量。
|
||||
`Ctrl+O` 在折叠、展开和隐藏之间循环。隐藏状态会连同卡片自有的前导间距一起移除工具卡片。上下文卡片参与折叠和展开状态,但工具隐藏时回到折叠状态,因为注入指令不是可丢弃的工具流量。隐藏阶段还会把每个轮次的 assistant 步骤折叠为一条消息;该规则由[隐藏模式 assistant 折叠 Agent Note](../feature/2026-07-29-tui-hidden-mode-assistant-fold.md)负责。
|
||||
|
||||
### 跨工作区恢复
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,直到下次安排线上变更。
|
||||
|
||||
## 后果
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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 覆盖率固定终端中的组合回答流程,单选题的宿主覆盖率则固定其余的互斥规则。
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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` 路径保留其原有断言,确认正常退出时同样会出现该重置序列。
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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` 终端快照在卡片折叠和展开状态下固定了中性上下文样式、变更行的语义色彩、精确结果页脚和近似回退。
|
||||
@@ -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
|
||||
@@ -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 `<goal_complete>`/`<goal_blocked>` 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:<regex>}}` 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.
|
||||
@@ -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 消息,携带 `<goal_complete>`/`<goal_blocked>` 指令,要求模型向用户写出有依据的收尾消息且不再调用工具。之后轮次经由 agent loop 常规的无工具调用停止路径结束,因此不存在新的 loop 原语,steering 语义不受影响。人类直接变更保持原样、不注入指令。代价是每个 goal 生命周期一次额外模型请求,而非每轮一次。
|
||||
|
||||
指令措辞通过在 `deepseek-v4-pro` 上用重构的 Goal Round 转录做 A/B 采样选定:结构化指令(结果、验证、产物、后续)在完整度上稳定优于极简“总结一下”;补充“以会话内证据为准”的 grounding 条款让无依据细节从断言事实退为带保留的建议;而无指令对照组的收尾方差很大,包括言之凿凿的文件级细节编造。
|
||||
|
||||
为让 keyless 证明可脚本化,快照设施补了一项能力:`dsh-llm-replay` 会针对实时请求解析脚本条目中的 `{{fromRequest:<regex>}}` 占位符,因为静态伴随文件不可能预知模型必须回填进 `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 覆盖。
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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 尾部,以及后续工具行和被中断推理行导致的不可用控件。
|
||||
@@ -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
|
||||
@@ -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`.
|
||||
@@ -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` 覆盖。
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(中途引导)仍可参与普通的停止检查。
|
||||
|
||||
### 执行权限
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 契约。
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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 路径均保持不变。
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 浮层几何形状与检查器。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 行菜单分支。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 驻留时间。
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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` 固定折叠后的帧。
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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` 后仍打开的开关,包括其背后恢复显示的工具卡片。
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 组合,验证遮罩的精确几何尺寸和计算样式,在确认前后分别重新加载,继续进入凭据缺失设置流程,确认凭据已配置时确认版本不匹配仍会使通知重新出现,并检查浏览器控制台。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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,那是唯一一处一个实现对两者都正确的部分。
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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 `<img>` 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 `<img>`.
|
||||
|
||||
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.
|
||||
@@ -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) 图片目标地址渲染为延迟加载的响应式 `<img>` 元素,并使用异步解码与 `referrerPolicy="no-referrer"`。相对路径、绝对本地路径、`file:` URL 与不支持的协议继续沿用现有的替代文本回退。原始 HTML 保持禁用,因此 assistant 无法通过手写 `<img>` 绕过 Markdown 图片组件。
|
||||
|
||||
图片组件复用渲染器的绝对 URL 策略,不新增主机代理、本地文件路由、Session 依赖、净化器或图片抓取器。已完成的历史消息、流式输出、被中断的部分输出以及其他所有 `MarkdownText` 消费方均获得同一行为。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**将所有图片都保留为替代文本。** 这种方案维持了最小的网络边界,但无法满足在行内查看网络托管的视觉产物这一产品需求。
|
||||
|
||||
**通过主机代理远程图片。** 代理可以向图片源站隐藏浏览器的网络地址,但这会让主机执行任意出站请求,并且需要单独制定重定向、DNS、大小与内容策略。直接加载 HTTP(S) 图片可让浏览器控制机制继续观察该请求;不发送 referrer 可减少对话来源信息的暴露。
|
||||
|
||||
**在同一变更中支持本地路径。** Web 源无法直接加载主机文件。安全的实现需要单独评审的权限边界,因此相对路径、绝对本地路径与 `file:` URL 保持禁用。
|
||||
|
||||
**允许 `data:` 图片。** 大型 data URL 会将二进制内容以文本形式重复写入持久化的 transcript(文本记录)。仅允许 HTTP(S) 的策略足以满足当前需求,且不会扩大会话日志。
|
||||
|
||||
## 后果
|
||||
|
||||
assistant 回复会在流式输出与回放期间显示远程图片,且不改变会话事件或主机协议。远程源站仍可观察到图片请求、客户端网络地址,以及浏览器策略允许发送给该源站的任何凭据。本地及不支持的目标地址仍只显示不会发起请求的替代文本。
|
||||
@@ -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
|
||||
|
||||
@@ -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<boolean>`. 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<boolean>`. 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.
|
||||
|
||||
@@ -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<boolean>`。至少一个作用域内的持久性监听器成功参与后,它解析为 `true`;监听器快照为空时解析为 `false`;所有监听器结算后,如有失败,则以注册顺序最靠前的监听器错误拒绝。普通检查点可以忽略该布尔值。可继续提供方在最终结果边界要求该值为 `true`,并将 `false` 或拒绝映射为 `DURABILITY_FAILED`。
|
||||
`SessionStore.flush(session)` 是唯一的持久性屏障,并返回 `Promise<boolean>`。至少一个作用域内监听器成功参与后,它解析为 `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 的实现。
|
||||
|
||||
22
.github/ISSUE_TEMPLATE/bug.md
vendored
Normal file
22
.github/ISSUE_TEMPLATE/bug.md
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
---
|
||||
name: Bug
|
||||
about: 记录现有预期行为的失效
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
type: Bug
|
||||
---
|
||||
|
||||
<!-- 标题写中文行动或结果句;外露正文不超过 50 单位。 -->
|
||||
一句话说明错误结果。
|
||||
|
||||
<details>
|
||||
<summary>复现、预期与验收</summary>
|
||||
|
||||
- 复现步骤:
|
||||
- 实际结果:
|
||||
- 预期结果:
|
||||
- 环境:
|
||||
- 验收条件:
|
||||
|
||||
</details>
|
||||
2
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
2
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links: []
|
||||
20
.github/ISSUE_TEMPLATE/feature.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/feature.md
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: Feature
|
||||
about: 新增或有意改变可观察行为
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
type: Feature
|
||||
---
|
||||
|
||||
<!-- 标题写中文行动或结果句;外露正文不超过 50 单位。 -->
|
||||
一句话说明预期结果。
|
||||
|
||||
<details>
|
||||
<summary>验收与细节</summary>
|
||||
|
||||
- 验收条件:
|
||||
- 用户或模型可见变化:
|
||||
- 测试证据:
|
||||
|
||||
</details>
|
||||
20
.github/ISSUE_TEMPLATE/idea.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/idea.md
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: Idea
|
||||
about: 记录尚未承诺实施、但具有行动可能的想法
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
type: Idea
|
||||
---
|
||||
|
||||
<!-- 标题写中文行动或结果句;外露正文不超过 50 单位。 -->
|
||||
一句话说明价值假设。
|
||||
|
||||
<details>
|
||||
<summary>价值与细节</summary>
|
||||
|
||||
- 价值假设:
|
||||
- 需要验证:
|
||||
- 可能的后续工作:
|
||||
|
||||
</details>
|
||||
21
.github/ISSUE_TEMPLATE/research.md
vendored
Normal file
21
.github/ISSUE_TEMPLATE/research.md
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
name: Research
|
||||
about: 形成结论、证据或决策
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
type: Research
|
||||
---
|
||||
|
||||
<!-- 标题写中文行动或结果句;外露正文不超过 50 单位。 -->
|
||||
一句话说明待回答的问题。
|
||||
|
||||
<details>
|
||||
<summary>问题与证据标准</summary>
|
||||
|
||||
- 核心问题:
|
||||
- 证据标准:
|
||||
- 交付结论:
|
||||
- 可能的后续工作:
|
||||
|
||||
</details>
|
||||
20
.github/ISSUE_TEMPLATE/task.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/task.md
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: Task
|
||||
about: 明确的非 Feature、非 Bug 工作
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
type: Task
|
||||
---
|
||||
|
||||
<!-- 标题写中文行动或结果句;外露正文不超过 50 单位。 -->
|
||||
一句话说明要完成的工作。
|
||||
|
||||
<details>
|
||||
<summary>验收与细节</summary>
|
||||
|
||||
- 验收条件:
|
||||
- 交付物:
|
||||
- 测试证据:
|
||||
|
||||
</details>
|
||||
17
.github/issue-management/config.json
vendored
Normal file
17
.github/issue-management/config.json
vendored
Normal file
@@ -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"
|
||||
]
|
||||
}
|
||||
559
.github/issue-management/policy.mjs
vendored
Normal file
559
.github/issue-management/policy.mjs
vendored
Normal file
@@ -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 = '<!-- dsh-issue-policy -->'
|
||||
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(/<!--[\s\S]*?-->/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('正文必须包含默认收起的 <details> 区域')
|
||||
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(/<!--[\s\S]*?-->/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<number, unknown>} 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<typeof parseReferences>, issues: Map<number, {priority: string|null}>}} 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
|
||||
})
|
||||
}
|
||||
259
.github/issue-management/policy.test.mjs
vendored
Normal file
259
.github/issue-management/policy.test.mjs
vendored
Normal file
@@ -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<details><summary>验收与细节</summary>待补充。</details>`
|
||||
|
||||
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。<details>隐藏文字</details>'), {
|
||||
units: 4,
|
||||
balanced: true,
|
||||
detailsCount: 1,
|
||||
allCollapsed: true,
|
||||
})
|
||||
})
|
||||
|
||||
test('requires a balanced default-collapsed details region', () => {
|
||||
assert.deepEqual(validateBody({ body: '完成工作。', assignees: [] }), [
|
||||
'正文必须包含默认收起的 <details> 区域',
|
||||
])
|
||||
assert.deepEqual(
|
||||
validateBody({
|
||||
body: '完成工作。\n\n<details open><summary>细节</summary>待补充。</details>',
|
||||
assignees: [],
|
||||
}),
|
||||
['details 必须默认收起,不得设置 open'],
|
||||
)
|
||||
assert.deepEqual(
|
||||
validateBody({ body: '完成工作。\n\n<details><summary>细节</summary>', 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',
|
||||
),
|
||||
)
|
||||
})
|
||||
13
.github/pull_request_template.md
vendored
Normal file
13
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
<!-- 写 Fixes #NN 表示解决并自动关闭;写 Related to #NN 仅关联。 -->
|
||||
<!-- 进入评审的非 Draft 人类 PR 至少引用一个同仓库 Issue。 -->
|
||||
<!-- 解决型 PR 与 Issue 同步 Priority;解决多个 Issue 时取最高值。 -->
|
||||
|
||||
关联 Issue:
|
||||
|
||||
<details>
|
||||
<summary>变更与验证</summary>
|
||||
|
||||
- 变更:
|
||||
- 验证:
|
||||
|
||||
</details>
|
||||
58
.github/workflows/issue-lifecycle.yml
vendored
Normal file
58
.github/workflows/issue-lifecycle.yml
vendored
Normal file
@@ -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
|
||||
27
.github/workflows/issue-policy.yml
vendored
Normal file
27
.github/workflows/issue-policy.yml
vendored
Normal file
@@ -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
|
||||
@@ -28,6 +28,9 @@
|
||||
- id: tool-subagent-control
|
||||
disabled: true
|
||||
|
||||
- id: tool-subagent-list-agents
|
||||
disabled: true
|
||||
|
||||
- id: tool-subagent
|
||||
disabled: true
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
10
apps/cli/tests/fixtures/tui-invalid-provider.cordis.yml
vendored
Normal file
10
apps/cli/tests/fixtures/tui-invalid-provider.cordis.yml
vendored
Normal file
@@ -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
|
||||
16
apps/cli/tests/fixtures/tui-scripted-llm.ts
vendored
16
apps/cli/tests/fixtures/tui-scripted-llm.ts
vendored
@@ -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.' },
|
||||
|
||||
@@ -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:<name> [instructions] — load a skill into the conversation "
|
||||
48| "/skill:<name> [instructions] — load a skill into the conversation "
|
||||
style 0-64 dim
|
||||
47| <blank>
|
||||
48| "Context · snapshot-injector"
|
||||
49| <blank>
|
||||
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| <blank>
|
||||
51| "… earlier context was compacted … "
|
||||
style 0-32 dim
|
||||
52| <blank>
|
||||
53| "You "
|
||||
53| "… earlier context was compacted … "
|
||||
style 0-32 dim
|
||||
54| <blank>
|
||||
55| "You "
|
||||
style 0-2 fg=bright-magenta bold underline
|
||||
54| "Reply with exactly the word: TWO. No tools. "
|
||||
55| <blank>
|
||||
56| "Compacted 2 history items (~387 tokens). "
|
||||
style 0-39 dim
|
||||
56| "Reply with exactly the word: TWO. No tools. "
|
||||
57| <blank>
|
||||
58| "Assistant "
|
||||
58| "Compacted 2 history items (~387 tokens). "
|
||||
style 0-39 dim
|
||||
59| <blank>
|
||||
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| <blank>
|
||||
64| "/workspace/project deepseek-v4-flash ↑2.9k ↓41 cache 49% 3% cont"
|
||||
65| <blank>
|
||||
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
|
||||
|
||||
@@ -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) },
|
||||
|
||||
82
apps/web/tests/bash-abort-row.e2e.ts
Normal file
82
apps/web/tests/bash-abort-row.e2e.ts
Normal file
@@ -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<typeof watchConsole>
|
||||
|
||||
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'])
|
||||
})
|
||||
})
|
||||
@@ -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<HTMLElement>('[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 })
|
||||
|
||||
@@ -72,7 +72,6 @@ describe('core Web profile', () => {
|
||||
"tools": [
|
||||
"bash",
|
||||
"str_replace_editor",
|
||||
"list_agents",
|
||||
],
|
||||
}
|
||||
`)
|
||||
|
||||
205
apps/web/tests/markdown-images.e2e.ts
Normal file
205
apps/web/tests/markdown-images.e2e.ts
Normal file
@@ -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<ImageOrigin> {
|
||||
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<void>((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<void> {
|
||||
await new Promise<void>((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_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<typeof watchConsole>
|
||||
|
||||
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)
|
||||
})
|
||||
@@ -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)),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
53
apps/web/tests/remote-welcome.e2e.ts
Normal file
53
apps/web/tests/remote-welcome.e2e.ts
Normal file
@@ -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<typeof watchConsole>
|
||||
|
||||
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)
|
||||
})
|
||||
@@ -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:<bound port>). */
|
||||
/** 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<WebScaffold> {
|
||||
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<We
|
||||
// to the production OTLP endpoint (or whatever DSH_TELEMETRY_OTLP_URL
|
||||
// names in the ambient environment).
|
||||
{ id: 'telemetry-otel', disabled: true },
|
||||
{ id: 'webserver', config: { host: '127.0.0.1', port: 0, distIndex: DIST_INDEX } },
|
||||
{
|
||||
id: 'webserver',
|
||||
config: { host: '127.0.0.1', port: 0, distIndex: DIST_INDEX },
|
||||
},
|
||||
...options.remoteAuthority === undefined
|
||||
? []
|
||||
: [{ id: 'connection', config: { trustedHosts: [options.remoteAuthority] } }],
|
||||
{ id: 'settings', config: { dshHome: harnessHome } },
|
||||
{ id: 'credentials', config: { dshHome: harnessHome } },
|
||||
// The shipped directory-picker row is the -auto chooser, which resolves
|
||||
@@ -352,7 +365,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
return {
|
||||
harnessHome,
|
||||
mode,
|
||||
baseUrl: `http://127.0.0.1:${port}`,
|
||||
baseUrl: `http://${browserHost}:${port}`,
|
||||
ctx,
|
||||
workspaceCwd,
|
||||
persistenceRoot,
|
||||
@@ -511,11 +524,19 @@ function normalizeAria(snapshot: string, workspaceCwd: string): string {
|
||||
.split(workspaceCwd).join('{{cwd}}')
|
||||
.split(base).join('{{workspace}}')
|
||||
.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '{{uuid}}')
|
||||
.replace(/\b\d+(?:\.\d+)?(?:ms|s|秒)\b/g, '{{duration}}')
|
||||
.replace(
|
||||
/~\d+(?:y(?: \d+mo)?|mo(?: \d+d)?)|\b(?:\d+d(?: \d+h(?: \d+m \d+s)?)?|\d+h \d+m \d+s|\d+m \d+s|\d+(?:\.\d+)?s|\d+(?:\.\d+)?ms)\b/g,
|
||||
duration => 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(/(?<!\d)\d{1,2}:\d{2}:\d{2}(?:\.\d+)?(?:\s*[AP]M)?(?!\d)/gi, '{{clock}}')
|
||||
.replace(/(?<!\d)\d{2}:\d{2}(?!\d)/g, '{{clock}}')
|
||||
}
|
||||
|
||||
|
||||
33
apps/web/tests/snapshots/bash-abort-row/ui.expected.md
Normal file
33
apps/web/tests/snapshots/bash-abort-row/ui.expected.md
Normal file
@@ -0,0 +1,33 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- 'button "Run two shell commands: wait" [disabled]'
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- text: "Run two shell commands: wait for cancellation, then write skipped.txt. {{date}} {{clock}}"
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
- img
|
||||
- text: Available only on the last message of a completed turn
|
||||
- button "Context injection":
|
||||
- img
|
||||
- img
|
||||
- text: Context injection
|
||||
- 'button "Failed Bash Error: command aborted" [expanded]':
|
||||
- img
|
||||
- text: "Failed Bash Error: command aborted"
|
||||
- text: "IN { \"command\": \"node -e \\\"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user