Merge origin/master into feat/code-runtime-python-backend
This commit is contained in:
commit
d87b755e37
116 changed files with 2875 additions and 436 deletions
|
|
@ -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-25-web-input-machine-and-slash-pipeline.md
|
||||
2026-07-25-web-input-machine-and-slash-pipeline.md: 3508de5e8a3980a87c344c5b76c060f6119ee686
|
||||
2026-07-25-web-input-machine-and-slash-pipeline.zh.md: eebfdae780157dfd0dace1386169c5fee8c1d564
|
||||
2026-07-25-web-input-machine-and-slash-pipeline.md: 69899efcda42eb1087aaa68d1eba8c08dd14f361
|
||||
2026-07-25-web-input-machine-and-slash-pipeline.zh.md: 7e37dd67a2d5a7943a8c601a890d2de7489b227d
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ Calls that stay un-evented (registry registration → explicit call → await):
|
|||
A trigger/menu/pick pipeline with zero knowledge of "commands":
|
||||
|
||||
- The service holds only the source registry (`InputTriggerSource{trigger: '/'|'@', name, order?, candidates, onPick, matchSpace?, matchEnter?}`; (trigger,name) unique; the optional `order` sorts the roster — lower first, default 0, ties keep registration order — and that sorted roster is both group order and polling order) and `sessionOf(sctx)`. Implementing a match hook IS the declaration of participation in space/enter adjudication; the pipeline polls in roster order, the first non-undefined answer wins, and no claimant means the default sink. matchSpace is synchronous (space fires mid-keystroke; hot cache only); matchEnter is asynchronous (it may await the source's own warmup, and a warmup failure rejects).
|
||||
- The controller holds the single authoritative hit (span included; retained for Space after the menu closes), the per-session menu store, the candidate-fetch generation, keyboard arbitration (combobox mode: focus stays in the composer surface, ↑↓/Enter/Escape are intercepted and all pass the IME composition guard, with the single exception Shift+Enter unconditionally going first), and pick orchestration (outcome → self-dispatched bail events). `toggleSource(name, syntheticHit)` is the chrome-launch path: it seeds only that registered source over the caller's composer selection and publishes `launcher = name` until close; ordinary typed tracking clears the launcher and restores the full trigger roster. Both paths render the same MenuView and execute the same `onPick` chain. A `dismiss()` verb backs MenuView's injected `onDismiss` (a pointer down outside both the menu and the surrounding composer card closes the menu; MenuView also localizes group titles through the `slash.menu` locale namespace and clamps its height to the viewport space above the composer via ui-primitives' `useAnchoredMaxHeight`); at each session scope's birth it runs `warm(projection)` once over the source roster — within that scope the projection holds only the stable sessionId, with no published/capability transitions; the scope disposer tears down the controller.
|
||||
- The controller holds the single authoritative hit (span included; retained for Space after the menu closes), the per-session menu store, the candidate-fetch generation, keyboard arbitration (combobox mode: focus stays in the composer surface; ↑↓/Enter/Escape are intercepted; Tab settles a highlighted completion, using the candidate's drill action when available and its ordinary pick otherwise, while no highlight preserves native focus traversal; all arbitration passes the IME composition guard, with the single exception Shift+Enter unconditionally going first), and pick orchestration (outcome → self-dispatched bail events). `toggleSource(name, syntheticHit)` is the chrome-launch path: it seeds only that registered source over the caller's composer selection and publishes `launcher = name` until close; ordinary typed tracking clears the launcher and restores the full trigger roster. Both paths render the same MenuView and execute the same `onPick` chain. A `dismiss()` verb backs MenuView's injected `onDismiss` (a pointer down outside both the menu and the surrounding composer card closes the menu; MenuView also localizes group titles through the `slash.menu` locale namespace and clamps its height to the viewport space above the composer via ui-primitives' `useAnchoredMaxHeight`); at each session scope's birth it runs `warm(projection)` once over the source roster — within that scope the projection holds only the stable sessionId, with no published/capability transitions; the scope disposer tears down the controller.
|
||||
- Trigger-detection word boundaries (`user@host` and URL `/` never trigger) and the guard tiers (plain: `/` everywhere + `@` inline / claimed: `/` suppressed, `@` live / frozen: none) are the frozen pure core.
|
||||
|
||||
### hub / facade: the resident shell and the strict-session input body
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ Status: implemented
|
|||
对「命令」零知识的触发/菜单/pick 流水线:
|
||||
|
||||
- 服务只有 source 注册表(`InputTriggerSource{trigger: '/'|'@', name, order?, candidates, onPick, matchSpace?, matchEnter?}`;(trigger,name) 唯一;可选 `order` 对 roster 排序——越小越靠前、默认 0、同值保持注册序——排序后的 roster 同时是组序与轮询序)与 `sessionOf(sctx)`。实现 match 钩子即参与空格/回车裁决的声明;流水线按 roster 序轮询,首个非 undefined 应答胜出,无人认领落 default sink。matchSpace 同步(空格在击键中触发,只许热缓存);matchEnter 异步(可 await 源自身预热,预热失败即 reject)。
|
||||
- controller 持有唯一权威 hit(含 span;菜单关闭后为 Space 保留)、每会话 menu store、候选 fetch generation、键盘仲裁(combobox 模式:焦点始终在编辑器表面,↑↓/Enter/Escape 拦截且全程过 IME composition 守卫,唯一例外 Shift+Enter 无条件先行),以及 pick 编排(outcome → 自派 bail 事件)。`toggleSource(name, syntheticHit)` 是 chrome launcher 路径:它基于调用方的编辑器 selection,只 seed 对应的已注册 source,并发布 `launcher = name` 直至关闭;普通的键入式 tracking 会清除 launcher 并恢复完整的 trigger roster。两条路径渲染同一个 MenuView,并执行同一条 `onPick` 链。`dismiss()` 动词支撑 MenuView 注入的 `onDismiss`(指针落在菜单与所在 composer 卡片之外即关闭菜单;MenuView 还经 `slash.menu` locale 命名空间本地化组标题,并经 ui-primitives 的 `useAnchoredMaxHeight` 把高度收敛到 composer 上方的视口空间);每个会话作用域出生时对 source roster 做一次 `warm(projection)`,projection 在该 scope 内只有稳定的 sessionId,无 published/能力跃迁;scope disposer 拆除 controller。
|
||||
- controller 持有唯一权威 hit(含 span;菜单关闭后为 Space 保留)、每会话 menu store、候选 fetch generation、键盘仲裁(combobox 模式:焦点始终在编辑器表面;↑↓/Enter/Escape 会被拦截;Tab 会选定高亮补全项,候选项可下钻时走 drill 动作,否则走普通 pick,无高亮时保留原生焦点遍历;所有仲裁都经过 IME composition 守卫,唯一例外是 Shift+Enter 无条件先行),以及 pick 编排(outcome → 自派 bail 事件)。`toggleSource(name, syntheticHit)` 是 chrome launcher 路径:它基于调用方的编辑器 selection,只 seed 对应的已注册 source,并发布 `launcher = name` 直至关闭;普通的键入式 tracking 会清除 launcher 并恢复完整的 trigger roster。两条路径渲染同一个 MenuView,并执行同一条 `onPick` 链。`dismiss()` 动词支撑 MenuView 注入的 `onDismiss`(指针落在菜单与所在 composer 卡片之外即关闭菜单;MenuView 还经 `slash.menu` locale 命名空间本地化组标题,并经 ui-primitives 的 `useAnchoredMaxHeight` 把高度收敛到 composer 上方的视口空间);每个会话作用域出生时对 source roster 做一次 `warm(projection)`,projection 在该 scope 内只有稳定的 sessionId,无 published/能力跃迁;scope disposer 拆除 controller。
|
||||
- 触发检测词边界(`user@host`、URL `/` 永不触发)、守卫分档(plain:`/` 到处 + `@` 行内 / claimed:`/` 抑制、`@` 活 / frozen:全无)为冻结纯核。
|
||||
|
||||
### hub / facade:常驻外壳与严格会话输入体
|
||||
|
|
|
|||
|
|
@ -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-08-08-client-tool-presentation-ownership.md
|
||||
2026-08-08-client-tool-presentation-ownership.md: 1daad1559a6c8ef15fadb8e7c8dfeb2874ae3f9a
|
||||
2026-08-08-client-tool-presentation-ownership.zh.md: f980db28e1174aa95b29defb8b0a36fc0ba4cf2e
|
||||
2026-08-08-client-tool-presentation-ownership.md: bf8568150cc173f0dc46ba0a125ca9c784d18f2e
|
||||
2026-08-08-client-tool-presentation-ownership.zh.md: 015a48a0499b84bf034dc905780eca0dcef99c10
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ A business Tool plugin receives one standard `ToolCallBlock`, identity, workspac
|
|||
|
||||
The details panel is a second Tool presentation point, not the call-tree owner. `ui-conversation` locates the selected call and delegates its output body through `'conversation.details.tool'`; `ui-tool` reuses the card model, while the conversation fallback retains raw result text when the plugin is absent.
|
||||
|
||||
Generic row models retain the original argument string as `bodyRaw` and expose no preformatted body. `ToolRow` and the Bash fallback format it only while an expanded generic input section is visible; closing the row removes the formatted text, and rows rendering a structured card skip generic-body formatting.
|
||||
|
||||
## Runtime and render path
|
||||
|
||||
```text
|
||||
|
|
@ -33,6 +35,8 @@ Session Event window
|
|||
-> tool.call.toolview(entryKey = toolName)
|
||||
|- registered atomic view
|
||||
`- GenericToolCard fallback
|
||||
|- collapsed or structured card: retain argsRaw only
|
||||
`- expanded generic input: format argsRaw
|
||||
```
|
||||
|
||||
## Ownership boundary
|
||||
|
|
@ -42,12 +46,12 @@ Session Event window
|
|||
| Client Runtime Conversation engine | Context identity, Location, history replay, view Node publication | Tool event meaning, call tree, Tool renderer |
|
||||
| `ui-conversation` Tool Definition | call/result pairing, Code Dispatch topology, running/settled/interrupted `ToolCallBlock`, Chat ordering anchor | Tool-name dispatch, card models, recursive React structure |
|
||||
| `ui-conversation` Chat view | keyed Node order, scroll anchors, selection, and host actions | Tool lifecycle, subcall composition, atomic Tool renderers |
|
||||
| `ui-tool` | root/subcall recursive rendering, atomic keyed dispatch, fallback, card models, and details output | Session Event fold, Chat ordering |
|
||||
| `ui-tool` | root/subcall recursive rendering, atomic keyed dispatch, fallback, card models, expansion-time argument formatting, and details output | Session Event fold, Chat ordering |
|
||||
| Business Tool plugin | atomic renderers for one or more wire Tool names | root/subcall placement, lifecycle pairing, Session projectors |
|
||||
|
||||
## Verification
|
||||
|
||||
`ui-conversation` tests pin the Tool Definition's call/result pairing, Code Dispatch, interruption, and running-to-settled keyed identity without importing production `ui-tool` renderers. `ui-tool` tests mount the real conversation host and pin root/subcall recursion, keyed dispatch, Generic fallback, selection, details, and concrete Tool cards. Assembled Web tests cover the path with both plugins loaded.
|
||||
`ui-conversation` tests pin the Tool Definition's call/result pairing, Code Dispatch, interruption, and running-to-settled keyed identity without importing production `ui-tool` renderers. `ui-tool` tests mount the real conversation host and pin root/subcall recursion, keyed dispatch, Generic fallback, selection, details, concrete Tool cards, and expansion-only generic-body formatting. Assembled Web tests cover the path with both plugins loaded.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
|
@ -61,8 +65,12 @@ Session Event window
|
|||
|
||||
**Let `ui-conversation` import `ui-tool` components directly.** Rejected: this would reverse the feature dependency and make Tool presentation mandatory. Slots preserve independent loading, lifecycle, and fallback behavior.
|
||||
|
||||
**Keep a preformatted body on the row model for compatibility.** Rejected: every collapsed row would retain a second full argument string, and the compatibility field would let future consumers restore eager formatting. The model exposes only `bodyRaw`, making expansion-time formatting the only generic path.
|
||||
|
||||
## Consequences
|
||||
|
||||
`ui-conversation` no longer depends on presentation for concrete Tool names, and root and subcalls cannot drift onto different dispatch paths. Business packages can independently own atomic Tool renderers; if `ui-tool` is absent, Conversation data assembly remains valid, Chat Nodes use the generic fallback, and details retain raw results.
|
||||
|
||||
Collapsed Tool rows retain the existing `argsRaw` reference without a pretty-printed copy or its formatting call. Expanding a generic input performs that work for the visible row, and closing it permits the derived text to be collected; repeated expansion trades bounded recomputation for lower retained memory.
|
||||
|
||||
The cost is an explicit dependency from `ui-tool` on the business Node slot and locale namespace declared by conversation, plus one Tool-specific child slot. Tool Definition remains in `ui-conversation` because this change does not split packages; it can later move through the Conversation registry seam without changing the presentation ownership recorded here.
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ Conversation 数据组装遵循后续的 [Conversation 业务节点决策](2026-
|
|||
|
||||
details panel 是第二个工具展示点,但不是调用树所有者。`ui-conversation` 定位 selected call,并通过 `'conversation.details.tool'` 委托 output body;`ui-tool` 复用 card model,插件缺席时 conversation fallback 保留 raw result text。
|
||||
|
||||
Generic row model 保留原始参数字符串 `bodyRaw`,不暴露预格式化 body。`ToolRow` 与 Bash fallback 只在展开后的 generic input section 可见时格式化该字符串;收起行会移除格式化文本,渲染结构化卡片的行则跳过 generic body 格式化。
|
||||
|
||||
## 运行时与渲染路径
|
||||
|
||||
```text
|
||||
|
|
@ -33,6 +35,8 @@ Session Event window
|
|||
-> tool.call.toolview(entryKey = toolName)
|
||||
|- registered atomic view
|
||||
`- GenericToolCard fallback
|
||||
|- collapsed or structured card: retain argsRaw only
|
||||
`- expanded generic input: format argsRaw
|
||||
```
|
||||
|
||||
## 所有权边界
|
||||
|
|
@ -42,12 +46,12 @@ Session Event window
|
|||
| Client 运行时 Conversation engine | 上下文 identity、Location、历史回放、view Node 发布 | 工具事件含义、调用树、工具 renderer |
|
||||
| `ui-conversation` 工具 Definition | call/result 配对、Code Dispatch 拓扑、running/settled/interrupted `ToolCallBlock`、Chat 排序 anchor | 工具名称分发、card model、递归 React 结构 |
|
||||
| `ui-conversation` Chat view | keyed Node 顺序、scroll anchor、selection 与宿主动作 | 工具 lifecycle、subcall 组合、原子工具 renderer |
|
||||
| `ui-tool` | root/subcall 递归渲染、原子 keyed dispatch、fallback、card model 与 details output | 会话事件 fold、Chat 排序 |
|
||||
| `ui-tool` | root/subcall 递归渲染、原子 keyed dispatch、fallback、card model、展开时参数格式化与 details output | 会话事件 fold、Chat 排序 |
|
||||
| 业务工具插件 | 一个或多个 wire 工具名称的原子 renderer | root/subcall 位置、生命周期配对、会话 projector |
|
||||
|
||||
## 验证
|
||||
|
||||
`ui-conversation` 测试固定工具 Definition 的 call/result 配对、Code Dispatch、interruption 和 running-to-settled keyed identity,不导入 `ui-tool` 的生产 renderer。`ui-tool` 测试挂载真实 conversation 宿主,固定 root/subcall 递归、keyed dispatch、Generic fallback、selection、details 和具体工具 card。组装后的 Web 测试覆盖两个插件共同装载的路径。
|
||||
`ui-conversation` 测试固定工具 Definition 的 call/result 配对、Code Dispatch、interruption 和 running-to-settled keyed identity,不导入 `ui-tool` 的生产 renderer。`ui-tool` 测试挂载真实 conversation 宿主,固定 root/subcall 递归、keyed dispatch、Generic fallback、selection、details、具体工具 card 与只在展开时执行的 generic body 格式化。组装后的 Web 测试覆盖两个插件共同装载的路径。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
|
|
@ -61,8 +65,12 @@ Session Event window
|
|||
|
||||
**让 `ui-conversation` 直接导入 `ui-tool` 组件。** 拒绝:这会反转功能依赖并把工具展示变成必选能力。slot 保留独立装载、生命周期和 fallback。
|
||||
|
||||
**为兼容性在 row model 上保留预格式化 body。** 拒绝:每个折叠行都会保留第二份完整参数字符串,而且兼容字段会让后续消费方恢复 eager 格式化。model 只暴露 `bodyRaw`,使展开时格式化成为唯一 generic 路径。
|
||||
|
||||
## 后果
|
||||
|
||||
`ui-conversation` 不再依赖工具名称对应的业务展示,root 与 subcall 也不会漂移到不同分发路径。业务包可以独立拥有原子工具 renderer;`ui-tool` 缺席时,Conversation 数据组装仍然成立,Chat Node 使用通用 fallback,details 保留 raw result。
|
||||
|
||||
折叠的工具行只保留既有 `argsRaw` 引用,不创建 pretty-print 副本,也不执行对应的格式化调用。展开 generic input 时才为当前可见行完成这项工作,收起后派生文本可以被回收;重复展开以有界重算换取更低的常驻内存。
|
||||
|
||||
代价是 `ui-tool` 明确依赖 conversation 声明的业务 Node slot 和 locale namespace,并拥有一个工具专属子 slot。工具 Definition 暂时位于 `ui-conversation`,因为本次没有拆包;它以后可以沿 Conversation 注册表 seam 移动,而不会改变本记录规定的展示所有权。
|
||||
|
|
|
|||
|
|
@ -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-08-09-client-conversation-node-assembly.md
|
||||
2026-08-09-client-conversation-node-assembly.md: e37f2ab3bdf9f3943cb1bcd193d5ea578a0b2c19
|
||||
2026-08-09-client-conversation-node-assembly.zh.md: be23b321b710f2f56e3e7af870ba554adb355cef
|
||||
2026-08-09-client-conversation-node-assembly.md: 4831c2261791749804b6d0bd555423b7d4894520
|
||||
2026-08-09-client-conversation-node-assembly.zh.md: 37463d0543bbabc5d236f662827b932b55bbb11d
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ Client Runtime provides a target-neutral Conversation Node assembly engine. Busi
|
|||
|
||||
This Note retains the derivation, business-by-business validation, responsibilities, algorithms, and trade-offs that remain relevant after implementation.
|
||||
|
||||
Chat registers an Inbox Definition only for `next-step`, because message classification is its sole consumer; `next-turn` splices remain durable Session inputs but create no Chat Context. Chat and Trajectory each keep target-owned next-step state. Every insertion stores only message IDs in an immutable splice node. A successful claim materializes the pending chain once, replaces the previous claimed set with that batch, and lets later Contexts share the set until another claim. The AgentLoop appends every message admitted from that claim before it can claim another batch; a rejected claim appends no `user/message`, so later classification needs only the current batch. Historical Contexts therefore retain linear ID state instead of cumulative array and Set snapshots.
|
||||
|
||||
### Responsibility layers
|
||||
|
||||
| Layer | Durable responsibility | Explicitly does not own |
|
||||
|
|
@ -257,8 +259,7 @@ Page size, record packing, the number of history loads, and RAF coalescing affec
|
|||
|
||||
| Business / `kind` | Stable ID | Start Match | Update Matches | State and cross-Context reads |
|
||||
|---|---|---|---|---|
|
||||
| Next-turn Inbox / `inbox-next-turn` | Splice Event seq | Each `agent/inbox/spliced` targeting next-turn | None | Apply the current splice to the pending/claimed instantaneous state from `reader.previous(ownKind)` |
|
||||
| Next-step Inbox / `inbox-next-step` | Splice Event seq | Each `agent/inbox/spliced` targeting next-step | None | Build the same per-instruction instantaneous state; Message reads its claimed set |
|
||||
| Next-step Inbox / `inbox-next-step` | Splice Event seq | Each `agent/inbox/spliced` targeting next-step | None | Append message IDs to persistent splice state; materialize once per claim and expose the shared current claimed batch to Message |
|
||||
| Message / `input-message` | Message ID | Append-surface `user/message` | None | Use source for a context message, or read the nearest next-step Inbox to distinguish user from steering |
|
||||
| Request Prompt / `request-prompt` | Header Event seq | Each `request/header` | None | Read the preceding Request Prompt through Reader, retain the full prompt state, and classify system/tool changes |
|
||||
| Assistant / `assistant-step` | `turn:step` | `step/start` | Scalar or packed `assistant/chunk`, final `assistant/message`, and same-step Retry | Aggregate blocks, usage, first-token time, final evidence, and retry-hidden state, then publish same-key Step data |
|
||||
|
|
@ -275,7 +276,7 @@ Page size, record packing, the number of history loads, and RAF coalescing affec
|
|||
|
||||
| Business | `publication()` | Chat output | History and runtime behavior |
|
||||
|---|---|---|---|
|
||||
| Inbox | `none` | No Node | Recompute instantaneous states along the Reader chain when prepend supplies earlier splices |
|
||||
| Inbox | `none` | No Node | Recompute next-step ID state along the Reader chain when prepend supplies earlier splices; next-turn creates no Chat Context |
|
||||
| Message | Immediate by default | `user`, `steering`, or `context` | Window-gap repair can reclassify the same message key |
|
||||
| Request Prompt | Immediate by default | One `system-prompt` for every header carrying a non-empty system field | A step's first header anchors before its request messages; a later same-step series anchors after its surface rewrite; prepend of the preceding header can correct a partial-window anchor |
|
||||
| Assistant | RAF for scalar chunks and packed runs, immediate for final, none for pure usage/finish | Same-key `assistant-step` with running/settled/interrupted status | Scalar and packed reducers are equivalent; Matches support fallback without `step/start`; Location close produces interruption presentation |
|
||||
|
|
@ -288,7 +289,7 @@ Page size, record packing, the number of history loads, and RAF coalescing affec
|
|||
| Deliverables | Immediate by default | No Node | Tool settlement incrementally updates Turn data; the Turn Tail extension slot reads produced files |
|
||||
| Fallback | Immediate by default | `unknown` JSON row | Covers only append-surface Events; an ordinary business that claimed but has not rendered an Event does not duplicate it |
|
||||
|
||||
Inbox demonstrates that every Event can be a start-only instantaneous-state Context; not every business requires a start/update pair. Reader links each state to the prior same-kind Context instead of inventing a lifecycle ID for the entire Inbox.
|
||||
Inbox demonstrates that every Event can be a start-only instantaneous-state Context; not every business requires a start/update pair. Reader links each next-step state to the prior same-kind Context instead of inventing a lifecycle ID for the entire Inbox. The state itself shares immutable pending splice nodes and one current claimed-batch Set, while unconsumed next-turn input remains outside Conversation because no Chat or Trajectory classification reads it.
|
||||
|
||||
Request Prompt demonstrates shared pure interpretation without shared target State: Chat and Trajectory call `inspectRequestPrompt()` from their own Definitions. The function canonicalizes the full header and classifies model-visible system/tool differences; each target then chooses its own output. Chat materializes every header carrying a non-empty system field, including `series` snapshots that repeat an unchanged header for an explicitly declared series or a post-replacement request, while Trajectory retains the complete request fact and its change classification. Ordinary append-only later Turns do not write another unchanged header. The first header in a Step follows the provider envelope rather than the header Event position: step one uses the owning Turn start and later steps use their Step start, placing the system field before the request's user-role messages; a later header in the same Step stays at its own Event after the surface rewrite that began the new series. When the preceding header is outside a partial window, a non-`initial` header stays at its own Event until prepend supplies that predecessor. Every header is a full snapshot, so a first loaded `resume`, `change`, or `series` header can render its system field without fabricating a comparison to unloaded history.
|
||||
|
||||
|
|
@ -306,9 +307,13 @@ Unknown fallback demonstrates Registry ownership: it handles only append-surface
|
|||
|
||||
## View Builder and React identity
|
||||
|
||||
[`ConversationViewRegistry`](../../../../packages/client/ui-conversation/src/client/conversation/view-registry.ts) creates an independent per-Session builder for each target. The Registry stores factories and shares no Session's ordering or caches.
|
||||
[`ConversationViewRegistry`](../../../../packages/client/ui-conversation/src/client/conversation/view-registry.ts) stores an independent builder factory for each target and shares no Session's ordering or caches.
|
||||
|
||||
The Assembler calls `replace({ nodes, timeline })` on low-frequency complete replacements and `apply({ upserts, timeline })` for ordinary prepend/append flushes. Builders receive only final target Nodes already constructed by Definitions.
|
||||
A shell selection or a target source's first subscriber adds that target to the Session's monotonic active-target set. The Assembler indexes each Context under its sole target but creates no builder, Node, or snapshot for an inactive target. First activation flushes pending target-neutral work, creates the builder, and calls `replace({ nodes, timeline })` once from that target's current Contexts.
|
||||
|
||||
The shell synchronously resolves the persisted selection when a Session binding becomes available, when a cached binding becomes current, or when the View roster changes, then explicitly activates that registered View or the Chat fallback. Tab and focus actions activate their resolved target before updating selection state. A blank Session does not render the View slot, and `ConversationSnapshot.activeTargets` derives only from materialized active snapshots without querying inactive target Contexts for activity.
|
||||
|
||||
Ordinary prepend and append flushes call `apply({ upserts, timeline })` only for active targets. Complete window replacement and Registry rebuild call `replace()` only for active targets. Unsubscription does not remove a target, so returning to an opened View does not rebuild it.
|
||||
|
||||
[`ChatSnapshotBuilder`](../../../../packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts) maintains `order`, a keyed `nodes` store, the turn/step `locations` index, `timeline`, and the `legacy` slice used by StatsLine and mirrored into top-level public compatibility fields.
|
||||
|
||||
|
|
@ -345,15 +350,15 @@ SessionEventLike window
|
|||
-> Context matches + State + Location
|
||||
-> Definition.buildLocationData(step -> turn)
|
||||
-> StepLocation.data / TurnLocation.data
|
||||
-> Definition.buildViewNode() for its declared target
|
||||
-> target View Builder
|
||||
-> Definition.buildViewNode() for each active target
|
||||
-> active target View Builder
|
||||
-> chat: ChatSnapshotBuilder -> ChatView -> keyed ChatNodeSeat
|
||||
-> trajectory: TrajectorySnapshotBuilder -> stages/layout/table
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
Runtime tests pin Definition lifecycle registration, exact-ID append, update-before-start collection followed by forward replay after start, prepend identity, Reader window-gap repair, transitive dependencies, Location closure, Step→Turn data phase order, Location data replacement, publication cadence, illegal withdrawal, and per-target Builders.
|
||||
Runtime tests pin Definition lifecycle registration, exact-ID append, update-before-start collection followed by forward replay after start, prepend identity, Reader window-gap repair, transitive dependencies, Location closure, Step→Turn data phase order, Location data replacement, publication cadence, illegal withdrawal, first-subscription activation, monotonic active targets, and per-target Builders.
|
||||
|
||||
Conversation tests cover every built-in Chat Definition, Assistant Step data, Turn Tail and Deliverables Turn data, Chat ordering and structural sharing, selector isolation, Assistant and Tool running-to-settled identity, nested Code Dispatch, steering, Compaction, Retry, interruption, load-older anchoring, and slot dispatch. Trajectory tests cover its independently registered Message, Assistant, Tool, Compaction, Request-header, and boundary Definitions together with the preserved stage-oriented view model.
|
||||
|
||||
|
|
@ -389,6 +394,8 @@ History-path tests cover complete replace, non-overlapping prepend, complete-ran
|
|||
|
||||
**Reuse one Event Definition across Chat and Trajectory by branching in `buildViewNode(target)`.** Rejected: the views require different business State and intermediate records, so a shared Definition would make each package carry the other's conditions and payloads. Separate target-owned Definitions keep those choices local while sharing the Assembler's ingestion and lifecycle contracts.
|
||||
|
||||
**Deactivate a target when its last subscriber leaves.** Rejected: returning to the View would repeatedly rebuild its complete snapshot. Subscription establishes first use; the target then stays incremental for the remaining Session lifetime.
|
||||
|
||||
**Add a generic layout model above final business Nodes.** Rejected: activity, tail candidacy, and layout enums would centralize current Chat business semantics in the engine again. Final Nodes carry renderer-required data directly and share only identity, ordering, and Location facts.
|
||||
|
||||
**Register the Turn-data Hook only on the Assistant renderer.** Rejected: current-Node Location access is a common capability of the `conversation.chat.node` slot, not one business renderer. The parent Chat entry registers common inject once, and every keyed renderer shares the same strongly typed contract.
|
||||
|
|
@ -407,8 +414,12 @@ Append does not scan historical Contexts; prepend replays only Contexts whose Ma
|
|||
|
||||
Separating State updates from publication cadence folds every live Assistant delta and each historical packed run while materializing at most once per animation frame. Step or Turn close and final Events can immediately publish the latest State.
|
||||
|
||||
An inactive target retains Definition State and a target Context index but no builder, materialized Nodes, or snapshot. The mounted built-in or third-party View activates its own target through normal subscription; previously opened targets continue receiving incremental updates.
|
||||
|
||||
Steps and Turns are stable homes for cross-business aggregates. Turn Tail and Deliverables derive their values without renderer scans of global Nodes; slot-level `useTurnData()` narrows common reads to the current Node's Turn and uses selector equality to isolate unrelated updates.
|
||||
|
||||
Inbox Context retention grows with splice count and claimed message count rather than their cumulative prefixes. This removes duplicate state growth but does not deduplicate message content in durable Session events or bound the loaded event window.
|
||||
|
||||
The cost is new Runtime contracts for Registry, Assembler, Location data, dependency replay, and per-target Builders, plus parent-owned common inject and per-occurrence `hookContext` in UI Slots. Definitions that consume Assistant deltas also maintain equivalent scalar and packed update branches. Definition authors must understand stable IDs, unique scalar starts, forward replay, Step→Turn publication order, read-only Reader access, and the prohibition on Node withdrawal.
|
||||
|
||||
`useTurnData()` does not revoke the standard `useSession` capability from session-scoped renderers, so this boundary relies on API guidance and tests rather than capability isolation. Registry changes remain low-frequency full rebuilds; the Chat Builder still maintains a legacy slice for StatsLine and the top-level public fields, while Trajectory owns target-specific Definitions and a Builder over the shared Session window. Built-in Definitions remain in their respective UI packages, and these compatibility boundaries do not return business interpretation to Session.
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ Client Runtime 提供 target-neutral 的 Conversation Node 组装引擎,业务
|
|||
|
||||
本 Note 保留实现后仍有价值的方案推导、逐业务适配、职责、算法和取舍。
|
||||
|
||||
Chat 只注册 `next-step` Inbox Definition,因为消息分类是其唯一消费方;`next-turn` splice 仍是持久 Session input,但不会创建 Chat Context。Chat 与 Trajectory 各自维护 target 专属 next-step state。每次插入只把消息 ID 写入不可变 splice 节点。成功 claim 时只 materialize 一次 pending 链,以当前批次替换上一个 claimed Set,并让后续 Context 共享该 Set,直到下一次 claim。AgentLoop 会在领取下一批消息之前追加当前 claim 接纳的全部消息;被拒绝的 claim 不追加 `user/message`,因此后续分类只需当前批次。历史 Context 因而只保留线性 ID state,不再保留累计数组和 Set 快照。
|
||||
|
||||
### 责任分层
|
||||
|
||||
| 层 | 长期职责 | 明确不负责 |
|
||||
|
|
@ -257,8 +259,7 @@ Chat `order` 的结构性变化仍可能重排当前可见 key;纯 data 更新
|
|||
|
||||
| 业务 / `kind` | 稳定 ID | start Match | update Matches | State 与跨 Context 读取 |
|
||||
|---|---|---|---|---|
|
||||
| Next-turn Inbox / `inbox-next-turn` | splice Event seq | 每条目标为 next-turn 的 `agent/inbox/spliced` | 无 | 从 `reader.previous(ownKind)` 的 pending/claimed 瞬间态应用当前 splice |
|
||||
| Next-step Inbox / `inbox-next-step` | splice Event seq | 每条目标为 next-step 的 `agent/inbox/spliced` | 无 | 同样形成逐指令瞬间态,claimed 集合供 Message 读取 |
|
||||
| Next-step Inbox / `inbox-next-step` | splice Event seq | 每条目标为 next-step 的 `agent/inbox/spliced` | 无 | 把消息 ID 追加到持久 splice state;每次 claim 只 materialize 一次,并向 Message 暴露共享的当前 claimed batch |
|
||||
| Message / `input-message` | message ID | append-surface `user/message` | 无 | 根据 source 生成 context message,或读取最近 next-step Inbox 判断 user/steering |
|
||||
| Request Prompt / `request-prompt` | header Event seq | 每条 `request/header` | 无 | 通过 Reader 读取前一条 Request Prompt,保留完整 prompt 状态,并判定 system/tool 变化 |
|
||||
| Assistant / `assistant-step` | `turn:step` | `step/start` | scalar 或 packed `assistant/chunk`、final `assistant/message`、同 step Retry | 聚合 blocks、usage、首 token 时间、final 和 retry 隐藏状态,并发布同 key Step data |
|
||||
|
|
@ -275,7 +276,7 @@ Chat `order` 的结构性变化仍可能重排当前可见 key;纯 data 更新
|
|||
|
||||
| 业务 | `publication()` | Chat 产物 | 历史分页与运行时行为 |
|
||||
|---|---|---|---|
|
||||
| Inbox | `none` | 不生成 Node | prepend 补前序 splice 时沿 Reader 链重算瞬间态 |
|
||||
| Inbox | `none` | 不生成 Node | prepend 补前序 splice 时沿 Reader 链重算 next-step ID state;next-turn 不创建 Chat Context |
|
||||
| Message | 默认 immediate | `user`、`steering` 或 `context` | window gap 修复可让同一 message key 重新分类 |
|
||||
| Request Prompt | 默认 immediate | 每条带非空 system 字段的 header 都生成一个 `system-prompt` | Step 首条 header 锚定在请求消息之前;同 step 后续序列锚定在表层改写之后;prepend 补入前序 header 后可纠正部分窗口的锚点 |
|
||||
| Assistant | scalar chunk 与 packed run 为 RAF,final immediate,纯 usage/finish 为 none | 同 key `assistant-step`,状态为 running/settled/interrupted | scalar 与 packed reducer 等价;缺 `step/start` 可先用 Matches fallback;Location close 生成中断表现 |
|
||||
|
|
@ -288,7 +289,7 @@ Chat `order` 的结构性变化仍可能重排当前可见 key;纯 data 更新
|
|||
| Deliverables | 默认 immediate | 不生成 Node | Tool 结算增量更新所属 Turn data,Turn Tail 扩展槽读取 produced files |
|
||||
| Fallback | 默认 immediate | `unknown` JSON row | 只兜底 append surface,普通业务已认领但暂不可见时不会重复生成 |
|
||||
|
||||
Inbox 展示了“每条 Event 都是一个 start-only 瞬间态 Context”,不是所有业务都需要 start/update 配对。它通过 Reader 与前一个同 kind Context 形成连续 fold,而非给整个 Inbox 人工制造生命周期 ID。
|
||||
Inbox 展示了“每条 Event 都是一个 start-only 瞬间态 Context”,不是所有业务都需要 start/update 配对。每个 next-step state 通过 Reader 与前一个同 kind Context 形成连续 fold,而非给整个 Inbox 人工制造生命周期 ID。state 自身共享不可变 pending splice 节点和一个当前 claimed-batch Set;未消费的 next-turn input 不进入 Conversation,因为 Chat 与 Trajectory 都不读取它来分类。
|
||||
|
||||
Request Prompt 展示了如何在不共享 target State 的前提下共用纯解释逻辑:Chat 与 Trajectory 各自在自己的 Definition 中调用 `inspectRequestPrompt()`。该函数规范化完整 header,并判定面向模型的 system/tool 差异;随后每个 target 自行选择产物。Chat 会物化每条带非空 system 字段的 header,包括为显式声明的序列或表层替换后的请求重复未变 header 的 `series` 快照;Trajectory 则保留完整请求事实及其变化分类。普通的仅追加后续 Turn 不会再次写入未变 header。一个 Step 中的首条 header 遵循提供方信封,而不是 header Event 位置:step one 使用所属 Turn start,后续 step 使用各自的 Step start,把 system 字段放到该请求的 user-role 消息之前;同一 Step 的后续 header 保留在开启新序列的表层改写之后。部分窗口未包含前序 header 时,非 `initial` header 会保留在自身 Event,直到 prepend 补入该前序 header。每条 header 都是完整快照,因此已加载窗口中的首条 `resume`、`change` 或 `series` header 无需凭空构造与未加载历史的比较,也能渲染其 system 字段。
|
||||
|
||||
|
|
@ -306,9 +307,13 @@ Unknown fallback 展示了 Registry ownership:fallback 只处理没有任何
|
|||
|
||||
## View Builder 与 React identity
|
||||
|
||||
[`ConversationViewRegistry`](../../../../packages/client/ui-conversation/src/client/conversation/view-registry.ts) 为每个 target 创建独立的 per-Session builder。Registry 保存 factory,不共享某个 Session 的排序或缓存。
|
||||
[`ConversationViewRegistry`](../../../../packages/client/ui-conversation/src/client/conversation/view-registry.ts) 为每个 target 保存独立的 builder factory,不共享某个 Session 的排序或缓存。
|
||||
|
||||
Assembler 低频完整替换时调用 `replace({ nodes, timeline })`;普通 prepend/append flush 调用 `apply({ upserts, timeline })`。Builder 只接收 Definition 已构造完成的 target Nodes。
|
||||
shell 选择或 target source 的首个 subscriber 会把该 target 加入 Session 单调增长的 active-target set。Assembler 按唯一 target 索引每个 Context,但不会为 inactive target 创建 builder、Node 或 snapshot。首次激活会 flush 尚未发布的 target-neutral 工作、创建 builder,并从该 target 的当前 Context 调用一次 `replace({ nodes, timeline })`。
|
||||
|
||||
Session binding 可用、缓存的 binding 成为 current 或 View roster 变化时,shell 会同步解析持久化选择,再显式激活已注册的偏好 View 或 Chat fallback。Tab 与 focus action 在更新选择状态前先激活解析出的 target。blank Session 不渲染 View slot;`ConversationSnapshot.activeTargets` 只从已物化的 active snapshot 派生,不查询 inactive target Context 的 activity。
|
||||
|
||||
普通 prepend 与 append flush 只对 active target 调用 `apply({ upserts, timeline })`。完整 window replace 与 Registry rebuild 只对 active target 调用 `replace()`。取消订阅不会移除 target,因此返回已打开的 View 不会重建。
|
||||
|
||||
[`ChatSnapshotBuilder`](../../../../packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts) 维护 `order`、keyed `nodes` store、turn/step `locations` index、`timeline`,以及由 StatsLine 使用并镜像到顶层公共兼容字段的 `legacy` slice。
|
||||
|
||||
|
|
@ -345,15 +350,15 @@ SessionEventLike window
|
|||
-> Context matches + State + Location
|
||||
-> Definition.buildLocationData(step -> turn)
|
||||
-> StepLocation.data / TurnLocation.data
|
||||
-> Definition.buildViewNode() for its declared target
|
||||
-> target View Builder
|
||||
-> Definition.buildViewNode() for each active target
|
||||
-> active target View Builder
|
||||
-> chat: ChatSnapshotBuilder -> ChatView -> keyed ChatNodeSeat
|
||||
-> trajectory: TrajectorySnapshotBuilder -> stages/layout/table
|
||||
```
|
||||
|
||||
## 验证
|
||||
|
||||
Runtime tests 固定 Definition 生命周期注册、exact-ID append、update-before-start 收集与 start 后正序 replay、prepend identity、Reader window-gap 修复、传递依赖、Location closure、Step→Turn data phase order、Location data replacement、publication cadence、非法撤回和 per-target Builder。
|
||||
Runtime tests 固定 Definition 生命周期注册、exact-ID append、update-before-start 收集与 start 后正序 replay、prepend identity、Reader window-gap 修复、传递依赖、Location closure、Step→Turn data phase order、Location data replacement、publication cadence、非法撤回、首次订阅 activation、单调 active target 和 per-target Builder。
|
||||
|
||||
Conversation tests 覆盖全部内建 Chat Definition、Assistant Step data、Turn Tail 与 Deliverables Turn data、Chat 排序和结构共享、selector isolation、Assistant/Tool running-to-settled identity、nested Code Dispatch、steering、Compaction、Retry、interruption、load-older anchoring 和 slot dispatch。Trajectory tests 则覆盖它独立注册的 Message、Assistant、Tool、Compaction、Request-header 与 boundary Definition,以及继续保留的 stage-oriented view model。
|
||||
|
||||
|
|
@ -389,6 +394,8 @@ Assembled Web snapshot、GUI 和浏览器场景覆盖真实 plugin graph。浏
|
|||
|
||||
**在同一个 Event Definition 内通过 `buildViewNode(target)` 为 Chat 与 Trajectory 分支。** 拒绝:两种视图需要不同的业务 State 与中间记录,共用 Definition 会迫使每个 package 携带另一边的条件与 payload。target 自有的 Definition 把这些选择留在本地,同时复用 Assembler 的摄入与生命周期约定。
|
||||
|
||||
**最后一个 subscriber 离开时停用 target。** 拒绝:返回该 View 会反复重建完整 snapshot。订阅只确认首次使用;随后 target 在 Session 剩余生命周期中保持增量更新。
|
||||
|
||||
**在最终业务 Node 上再叠一层通用 layout model。** 拒绝:activity、tail candidacy 和 layout enum 会把当前 Chat 的业务语义重新集中到引擎。最终 Node 直接携带 renderer 所需 data,只共享 identity、排序和 Location 事实。
|
||||
|
||||
**只在 Assistant renderer 注册 Turn data Hook。** 拒绝:访问当前 Node Location 是 `conversation.chat.node` slot 的公共能力,不属于某个业务 renderer。父 Chat entry 注册一次 common inject,所有 keyed renderer 共享同一强类型约定。
|
||||
|
|
@ -407,8 +414,12 @@ Append 不扫描历史 Context;prepend 只 replay Match、Location 或 Reader
|
|||
|
||||
State 更新与发布频率分离后,Assistant 的每条 live delta 与每个历史 packed run 都会被 fold,同时每 animation frame 最多 materialize 一次。step/turn close 和 final 可立即发布最新 State。
|
||||
|
||||
inactive target 会保留 Definition State 和 target Context 索引,但不保留 builder、已物化 Node 或 snapshot。已挂载的内建或第三方 View 通过正常订阅激活自己的 target;已经打开的 target 则继续接收增量更新。
|
||||
|
||||
Step/Turn 是业务间共享聚合的稳定宿主。Turn Tail 和 Deliverables 无需由 renderer 扫描全局 Nodes 即可派生值;Slot-level `useTurnData()` 把常见读取限制到当前 Node 所属 Turn,并通过 selector equality 隔离无关更新。
|
||||
|
||||
Inbox Context 的保留量随 splice 数和已 claim 消息数增长,不再随其累计前缀增长。该结构消除了重复 state 增长,但不会对持久 Session event 中的消息正文去重,也不会限制已加载 event window。
|
||||
|
||||
代价是 Runtime 新增 Registry、Assembler、Location data、依赖重放和 per-target Builder 契约,UI Slots 也新增 parent-owned common inject 与 per-occurrence `hookContext`。消费 Assistant delta 的 Definition 还需要维护等价的 scalar 与 packed update 分支。Definition 作者必须理解稳定 ID、唯一 scalar start、正序 replay、Step→Turn 发布顺序、只读 Reader 和 Node 不撤回规则。
|
||||
|
||||
`useTurnData()` 不撤销 session-scoped renderer 的标准 `useSession`,因此该边界依靠 API 引导和测试,而不是能力隔离。Registry 变化仍是低频完整 rebuild;Chat Builder 继续为 StatsLine 和顶层公共字段维护 legacy slice,Trajectory 则在共享 Session 窗口上拥有 target 专属 Definition 与 Builder。内建 Definition 分别留在所属 UI package;这些兼容边界不把业务解释权交还给 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/architecture/2026-08-11-trajectory-conversation-context-assembly.md
|
||||
2026-08-11-trajectory-conversation-context-assembly.md: 7d0aea2fc09f0f04bd5de923bee15c42a773489a
|
||||
2026-08-11-trajectory-conversation-context-assembly.zh.md: 1909243da02093de1a5f1d0857a11d3742258492
|
||||
2026-08-11-trajectory-conversation-context-assembly.md: c041d7a8ba9f39f74cf55f8a35513724eaeb4f54
|
||||
2026-08-11-trajectory-conversation-context-assembly.zh.md: 06e7b801b67b789ddffe991916aceee86cc903f8
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ Trajectory registers target-owned Conversation Definitions and a `trajectory` Vi
|
|||
|
||||
Each Definition belongs to one target. Chat and Trajectory may recognize the same durable Event family, but they keep separate State and final Node payloads. They share only the Assembler's exact-ID matching, ordered Matches, Location facts, Reader dependencies, publication scheduling, and replace/prepend/append lifecycle.
|
||||
|
||||
Trajectory's target source activates its View Builder on first subscription. Before that subscription, its Definitions maintain current State while the Assembler skips `buildViewNode()` and snapshot assembly. The target remains active after unsubscription, so later visits reuse the incrementally maintained snapshot.
|
||||
|
||||
The existing [Trajectory inspection ledger](../feature/2026-07-27-trajectory-inspection-ledger.md) remains the view model. The Trajectory Builder converts materialized target Nodes into its established `eventNodes`, Requests, Tool schemas, running calls, and Location map; layout, table virtualization, selection, Overview, and inspector behavior do not become generic Conversation contracts.
|
||||
|
||||
### Business Definitions
|
||||
|
|
@ -40,7 +42,7 @@ Assistant chunks update only their `turn:step` Context. Content-bearing chunks r
|
|||
|
||||
Trajectory reconstructs steering from durable inbox history, using the same identity rule as the [Chat steering decision](../feature/2026-08-04-web-context-source-and-steer-marks.md) without sharing Chat's final Node.
|
||||
|
||||
Each `agent/inbox/spliced` Event targeting `next-step` starts an invisible Context identified by its Event seq. Its `start()` reads the nearest earlier inbox Context, applies the splice, and stores the pending identities plus the cumulative set of claimed message IDs. A later user-origin `user/message` reads the nearest earlier inbox Context: a claimed ID produces a Steering Node, while every other user-origin message produces an ordinary User Node.
|
||||
Each `agent/inbox/spliced` Event targeting `next-step` starts an invisible Context identified by its Event seq. Its `start()` reads the nearest earlier inbox Context, appends the splice to persistent pending-ID state, and materializes that state only when a claim replaces the current claimed batch. The AgentLoop appends every admitted message from one claim before it can claim another batch; a rejected claim appends no `user/message`. A later user-origin `user/message` reads the nearest earlier inbox Context: an ID in the current claim produces a Steering Node, while every other user-origin message produces an ordinary User Node.
|
||||
|
||||
A Reader miss while older history remains records a window-gap dependency. When prepend supplies the missing predecessor, the Assembler replays the affected inbox chain and message Contexts in forward Event order. Historical page direction therefore cannot permanently misclassify a message.
|
||||
|
||||
|
|
@ -52,11 +54,11 @@ Let `E` be the loaded raw Event count, `P` one newly prepended page, `D` the num
|
|||
|
||||
| Path | Context work | Target snapshot work | Result |
|
||||
|---|---|---|---|
|
||||
| Initial tail or reconnect replace | Match the loaded window in `O(E × D)` and build State in forward Event order | Build and order `C` contributions | A full replace remains proportional to the loaded window |
|
||||
| Older-page prepend | Match only fresh Events and replay only Contexts whose Match, Location, or Reader answer changed, in `O(P × D + Mᵣ)` | Rebuild the stage snapshot from `C` contributions | Business folding does not restart over all `E` Events |
|
||||
| Live append | Match in `O(D)`, locate the keyed Context in `O(1)`, and update only that State | Replace a same-anchor contribution in `O(1)` before snapshot assembly | Business correlation is independent of loaded Event history |
|
||||
| Initial tail or reconnect replace | Match the loaded window in `O(E × D)` and build State in forward Event order | Inactive: none; active: build and order `C` contributions | A full Context replace remains proportional to the loaded window |
|
||||
| Older-page prepend | Match only fresh Events and replay only Contexts whose Match, Location, or Reader answer changed, in `O(P × D + Mᵣ)` | Inactive: none; active: rebuild the stage snapshot from `C` contributions | Business folding does not restart over all `E` Events |
|
||||
| Live append | Match in `O(D)`, locate the keyed Context in `O(1)`, and update only that State | Inactive: none; active: replace a same-anchor contribution in `O(1)` before snapshot assembly | Business correlation is independent of loaded Event history |
|
||||
|
||||
The Builder stores contributions by Context key and keeps a key-to-position index. A content update with the same anchor replaces one contribution in place; a new contribution or anchor change rebuilds and sorts contribution order. Snapshot assembly then walks `C` contributions, indexes Request headers and Tool schemas with Maps, and handles Compaction boundaries and Turn errors with linear cursors or indexes.
|
||||
First activation builds the current `C` target Contexts without rematching Events and calls `replace()` once. Once active, the Builder stores contributions by Context key and keeps a key-to-position index. A content update with the same anchor replaces one contribution in place; a new contribution or anchor change rebuilds and sorts contribution order. Snapshot assembly then walks `C` contributions, indexes Request headers and Tool schemas with Maps, and handles Compaction boundaries and Turn errors with linear cursors or indexes.
|
||||
|
||||
Final Event and Request ordering keeps a publication's current upper bound at `O(C log C)`. The migration removes repeated reverse lookups and the old raw-history refold, but it does not claim end-to-end `O(1)` publication. Chat retains its existing keyed snapshot behavior and complexity; adding the Trajectory target does not make Chat scan Trajectory Contexts or Nodes.
|
||||
|
||||
|
|
@ -90,7 +92,7 @@ Display memoization and search indexing stay separate. Search must include off-s
|
|||
|
||||
## Verification
|
||||
|
||||
Runtime tests pin target registration, exact-ID append, update-before-start replay, prepend identity, Reader window-gap repair, Location replay, and isolation between Chat and Trajectory snapshots.
|
||||
Runtime tests pin target registration, first-subscription activation, exact-ID append, update-before-start replay, prepend identity, Reader window-gap repair, Location replay, and isolation between Chat and Trajectory snapshots.
|
||||
|
||||
Trajectory Definition and Builder tests pin Assistant streaming and interruption, nested Tool calls and parallel interruption, Compaction and prompt inheritance, Steering classification and Step placement, Request marker order, stable contribution replacement, and prepend expansion. Table, layout, Timeline, and search tests pin deferred Markdown work, throttled index updates, tooltip-time formatting, and stable search results across append and prepend.
|
||||
|
||||
|
|
@ -98,7 +100,7 @@ Trajectory Definition and Builder tests pin Assistant streaming and interruption
|
|||
|
||||
Trajectory business assembly now scales with the changed page or keyed Context instead of restarting from the complete raw Event window. Target-owned Definitions can evolve independently from Chat while retaining one Session window and one set of lifecycle rules. Steering becomes a first-class Trajectory record at its actual Step position without adding steering-specific state to Session.
|
||||
|
||||
The retained stage-oriented Builder still performs work proportional to materialized Trajectory contributions and may sort on publication. The search index still performs a light linear signature pass when its input layout changes. These costs are explicit target-view work, not hidden full Event refolding.
|
||||
After first activation, the retained stage-oriented Builder still performs work proportional to materialized Trajectory contributions and may sort on publication. Before activation, the target retains Context State and one target index but no Builder, materialized Node, or snapshot. The search index still performs a light linear signature pass when its input layout changes.
|
||||
|
||||
Definition authors must provide stable protocol identities. Old Events without a required ID can disappear from the affected Trajectory business view, which is preferable to joining unrelated records or failing history load; producers that require faithful display must log the identity.
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ Trajectory 针对共享的 [`ConversationNodeAssembler`](2026-08-09-client-conve
|
|||
|
||||
每个 Definition 只属于一个 target。Chat 与 Trajectory 可以识别同一持久 Event 族,但分别维护自己的 State 和最终 Node payload。它们只共享 Assembler 的精确 ID 匹配、有序 Match、Location 事实、Reader 依赖、发布调度,以及 replace/prepend/append 生命周期。
|
||||
|
||||
Trajectory 的 target source 在首次订阅时激活 View Builder。在此次订阅之前,其 Definition 会维护最新 State,而 Assembler 跳过 `buildViewNode()` 和 snapshot assembly。取消订阅后 target 仍保持 active,因此后续访问会复用持续增量维护的 snapshot。
|
||||
|
||||
既有的 [Trajectory 检查记录表](../feature/2026-07-27-trajectory-inspection-ledger.zh.md)继续作为视图模型。Trajectory Builder 把已物化的 target Node 转换为原有的 `eventNodes`、Requests、Tool schema、运行中调用和 Location map;layout、表格虚拟化、选择、Overview 与检查器行为不会成为通用 Conversation 约定。
|
||||
|
||||
### 业务 Definition
|
||||
|
|
@ -40,7 +42,7 @@ Assistant chunk 只更新对应的 `turn:step` Context。带内容的 chunk 请
|
|||
|
||||
Trajectory 从持久 inbox 历史恢复 steering,使用与 [Chat steering 决策](../feature/2026-08-04-web-context-source-and-steer-marks.zh.md)相同的标识规则,但不共享 Chat 的最终 Node。
|
||||
|
||||
每条目标为 `next-step` 的 `agent/inbox/spliced` Event 都会启动一个以 Event seq 标识的不可见 Context。它的 `start()` 读取最近的前序 inbox Context,应用 splice,并存储待处理标识以及累计的已领取 message ID 集合。后续用户来源的 `user/message` 读取最近的前序 inbox Context:已领取的 ID 生成 Steering Node,其余用户来源消息生成普通 User Node。
|
||||
每条目标为 `next-step` 的 `agent/inbox/spliced` Event 都会启动一个以 Event seq 标识的不可见 Context。它的 `start()` 读取最近的前序 inbox Context,把 splice 追加到持久的 pending ID state,并只在 claim 时 materialize 该 state、替换当前 claimed batch。AgentLoop 会在领取下一批消息之前追加当前 claim 接纳的全部消息;被拒绝的 claim 不追加 `user/message`。后续用户来源的 `user/message` 读取最近的前序 inbox Context:ID 属于当前 claim 时生成 Steering Node,其余用户来源消息生成普通 User Node。
|
||||
|
||||
仍有更早历史时,Reader miss 会记录 window-gap 依赖。prepend 补齐缺失的前驱后,Assembler 按 Event 正序重放受影响的 inbox chain 与 message Context。因此,历史分页方向不会永久错误分类消息。
|
||||
|
||||
|
|
@ -52,11 +54,11 @@ Trajectory 从持久 inbox 历史恢复 steering,使用与 [Chat steering 决
|
|||
|
||||
| 链路 | Context 工作量 | Target snapshot 工作量 | 结果 |
|
||||
|---|---|---|---|
|
||||
| 初始尾页或重连 replace | 以 `O(E × D)` 匹配已加载窗口,并按 Event 正序构造 State | 构造并排序 `C` 个 contribution | 完整 replace 仍与已加载窗口成正比 |
|
||||
| 更早页面 prepend | 只匹配新 Event,并只重放 Match、Location 或 Reader 答案发生变化的 Context,成本为 `O(P × D + Mᵣ)` | 从 `C` 个 contribution 重建 stage snapshot | 业务 fold 不会从头重跑全部 `E` 个 Event |
|
||||
| 实时 append | 以 `O(D)` 匹配,以 `O(1)` 找到 keyed Context,并只更新对应 State | snapshot 组装前,以 `O(1)` 替换 anchor 未变的 contribution | 业务关联成本与已加载 Event 历史无关 |
|
||||
| 初始尾页或重连 replace | 以 `O(E × D)` 匹配已加载窗口,并按 Event 正序构造 State | inactive:无;active:构造并排序 `C` 个 contribution | 完整 Context replace 仍与已加载窗口成正比 |
|
||||
| 更早页面 prepend | 只匹配新 Event,并只重放 Match、Location 或 Reader 答案发生变化的 Context,成本为 `O(P × D + Mᵣ)` | inactive:无;active:从 `C` 个 contribution 重建 stage snapshot | 业务 fold 不会从头重跑全部 `E` 个 Event |
|
||||
| 实时 append | 以 `O(D)` 匹配,以 `O(1)` 找到 keyed Context,并只更新对应 State | inactive:无;active:在 snapshot 组装前以 `O(1)` 替换 anchor 未变的 contribution | 业务关联成本与已加载 Event 历史无关 |
|
||||
|
||||
Builder 按 Context key 保存 contribution,并维护 key-to-position index。anchor 相同的内容更新会原位替换一个 contribution;新增 contribution 或 anchor 变化才会重建并排序 contribution 顺序。随后,snapshot assembly 遍历 `C` 个 contribution,用 Map 索引 Request header 与 Tool schema,并以线性游标或索引处理 Compaction boundary 与 Turn error。
|
||||
首次激活会从当前 `C` 个 target Context 构建 Node,而不会重新匹配 Event,并调用一次 `replace()`。激活后,Builder 按 Context key 保存 contribution,并维护 key-to-position index。anchor 相同的内容更新会原位替换一个 contribution;新增 contribution 或 anchor 变化才会重建并排序 contribution 顺序。随后,snapshot assembly 遍历 `C` 个 contribution,用 Map 索引 Request header 与 Tool schema,并以线性游标或索引处理 Compaction boundary 与 Turn error。
|
||||
|
||||
最终 Event 和 Request 排序使单次发布的当前上界保持为 `O(C log C)`。本次迁移移除了重复反向查找和旧的原始历史 refold,但不声称端到端发布达到 `O(1)`。Chat 保持既有 keyed snapshot 行为与复杂度;增加 Trajectory target 不会让 Chat 扫描 Trajectory Context 或 Node。
|
||||
|
||||
|
|
@ -90,7 +92,7 @@ Context 迁移与下列表现层优化解决的是不同成本。这些优化保
|
|||
|
||||
## 验证
|
||||
|
||||
Runtime 测试固定 target 注册、精确 ID append、先 update 后 start 的 replay、prepend identity、Reader window-gap 修复、Location replay,以及 Chat 与 Trajectory snapshot 隔离。
|
||||
Runtime 测试固定 target 注册、首次订阅 activation、精确 ID append、先 update 后 start 的 replay、prepend identity、Reader window-gap 修复、Location replay,以及 Chat 与 Trajectory snapshot 隔离。
|
||||
|
||||
Trajectory Definition 与 Builder 测试固定 Assistant streaming 与 interruption、嵌套 Tool call 和并行 interruption、Compaction 与 prompt 继承、Steering 分类和 Step 位置、Request 标记顺序、稳定 contribution 替换与 prepend 扩展。Table、layout、Timeline 与搜索测试固定延迟 Markdown 工作、节流索引更新、Tooltip 展示时格式化,以及 append/prepend 期间稳定的搜索结果。
|
||||
|
||||
|
|
@ -98,7 +100,7 @@ Trajectory Definition 与 Builder 测试固定 Assistant streaming 与 interrupt
|
|||
|
||||
Trajectory 业务组装的成本随变化页面或 keyed Context 增长,不再从完整原始 Event 窗口重新开始。target 自有 Definition 可以独立于 Chat 演进,同时继续共享一份 Session 窗口和一套生命周期规则。steering 会在实际所属 Step 位置成为一等 Trajectory record,不需要向 Session 增加 steering 专属状态。
|
||||
|
||||
保留的 stage-oriented Builder 仍会执行与已物化 Trajectory contribution 数量成正比的工作,并可能在发布时排序。输入 layout 变化时,搜索索引仍会执行一次轻量线性签名检查。这些成本是显式的 target view 工作,不是隐藏的完整 Event refold。
|
||||
首次激活后,保留的 stage-oriented Builder 仍会执行与已物化 Trajectory contribution 数量成正比的工作,并可能在发布时排序。激活前,target 保留 Context State 和一个 target 索引,但不保留 Builder、已物化 Node 或 snapshot。输入 layout 变化时,搜索索引仍会执行一次轻量线性签名检查。
|
||||
|
||||
Definition 作者必须提供稳定的协议标识。缺少必要 ID 的旧 Event 可能不会出现在受影响的 Trajectory 业务视图中;与合并无关记录或让历史加载失败相比,这是更安全的退化方式。要求完整展示的生产方必须记录该标识。
|
||||
|
||||
|
|
|
|||
|
|
@ -2,5 +2,5 @@
|
|||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md
|
||||
2026-07-06-parallel-pre-push-gates.md: 22d69478f0fe664b91c4ada2c5c97e7c61ee7deb
|
||||
2026-07-06-parallel-pre-push-gates.zh.md: 98de527688399b8f6c09e91f55916361bf79d12d
|
||||
2026-07-06-parallel-pre-push-gates.md: 54fb01f03de1d0d198e373d960e9bd68b8687d60
|
||||
2026-07-06-parallel-pre-push-gates.zh.md: d7a949af649d3cf83da91358015f9196f71bc459
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ Status: implemented
|
|||
|
||||
English | [中文](2026-07-06-parallel-pre-push-gates.zh.md)
|
||||
|
||||
The local-hook portion of this record is superseded by [Fast local Git hooks](2026-07-22-fast-local-git-hooks.md). The bounded gate scheduler and package-level `publint` parallelism remain in force for CI, `doc-sync`, and explicit local commands.
|
||||
The local-hook portion of this record is superseded by [Fast local Git hooks](2026-07-22-fast-local-git-hooks.md). The bounded gate scheduler and package-level `publint` parallelism remain in force for CI, `doc-sync`, and explicit local commands. The scheduler's fail-fast option is recorded in [Gate-runner fail-fast](2026-08-27-gate-runner-fail-fast.md).
|
||||
|
||||
## Problem
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ Status: implemented
|
|||
|
||||
[English](2026-07-06-parallel-pre-push-gates.md) | 中文
|
||||
|
||||
本记录中的本地钩子部分已由[快速本地 Git 钩子](2026-07-22-fast-local-git-hooks.zh.md) 取代。有界门禁调度器和包级 `publint` 并行机制仍用于 CI、`doc-sync` 和显式本地命令。
|
||||
本记录中的本地钩子部分已由[快速本地 Git 钩子](2026-07-22-fast-local-git-hooks.zh.md) 取代。有界门禁调度器和包级 `publint` 并行机制仍用于 CI、`doc-sync` 和显式本地命令。调度器的快速失败选项记录在[门禁运行器快速失败](2026-08-27-gate-runner-fail-fast.zh.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/process/2026-08-08-native-windows-pull-request-ci.md
|
||||
2026-08-08-native-windows-pull-request-ci.md: ff01add055b6daa3cd2ea1dd774c4c8973388113
|
||||
2026-08-08-native-windows-pull-request-ci.zh.md: 5378607687d03f1c98b4f6abc0b65b5d8e9ed2bb
|
||||
2026-08-08-native-windows-pull-request-ci.md: ade3b19bc1adbcd75ec7d3908670b9664186cba8
|
||||
2026-08-08-native-windows-pull-request-ci.zh.md: 10db2657a7f02813152d2905693627e44ce6caf7
|
||||
|
|
|
|||
|
|
@ -18,9 +18,9 @@ Every pull request also starts four independent native jobs on the organization-
|
|||
|
||||
`windows-build` and `windows-native-tests` are dependencies of `all checks passed`; their workspace-build and targeted native-process results are blocking. `windows-coverage` remains an ordinary job but is absent from aggregate `needs`, so its 100%-per-file result stays red and visible without delaying the required verdict. `windows-observational` is also absent from aggregate `needs` and uses `continue-on-error` because Linux owns the blocking static, documentation, package, and built-artifact verdicts.
|
||||
|
||||
`windows-coverage` completes a workspace build before [in-job partitioned coverage](2026-08-18-in-job-partitioned-coverage.md) starts four single-worker instrumented shards beside a two-worker exempt-heavy gate. Both coverage gates set Vitest's default per-test and polling budgets to 30 seconds. `windows-observational` owns its own workspace build and production-site validation, starts the independent static gates together, and caps `publint` at eight workers. Its built-bin smoke starts only after every other observational gate settles; the smoke's `needs` edge still requires a successful build, while its `after` edges preserve the diagnostic after another gate fails. This keeps bounded real-application startup measurements from competing with tool-catalog, NodeNext, package, and documentation processes. The script-only translation-pairing merge suite runs in the exempt-heavy gate because it imports only `scripts/` sources and child processes; V8 instrumentation contributes no threshold coverage there but magnifies Git-process latency. Lefthook concurrency fixtures retain their outcomes with 30-second case budgets and a 10-second process-ready probe, while the installer allows five seconds for a preempted lock owner to publish its record after exclusive creation. Directory-picker composition gives its debounced config write an explicit 15-second poll budget; workspace-context composition fixtures use a test-owned signal without an unrelated one-second deadline. The LSP sources and the ACL-sandbox sources remain in the Windows denominator: stub-based failure-path suites carry every in-process ACL-sandbox file to 100%, and only the runner entry stays excluded — it executes exclusively as a spawned child outside the instrumented run, its behavior pinned end-to-end by the runner suite. Narrow annotated V8 ignores cover only unreachable branches (peer-platform arms and lifecycle-unreachable guards), with their behavior tests retained on the owning platform.
|
||||
`windows-coverage` runs [in-job partitioned coverage](2026-08-18-in-job-partitioned-coverage.md) without a preceding build, matching the Linux coverage lane: four single-worker instrumented shards beside a two-worker exempt-heavy gate; workspace imports resolve to `src` through the tsconfig paths map, and the lib-consuming suites self-skip on unbuilt checkouts. Both coverage gates raise Vitest's per-test, expect.poll, and hook budgets through `DSH_COVERAGE_TEST_TIMEOUT_MS=90000`. `windows-observational` owns its own workspace build and production-site validation, starts the independent static gates together, and caps `publint` at eight workers. Its built-bin smoke starts only after every other observational gate settles; the smoke's `needs` edge still requires a successful build, while its `after` edges preserve the diagnostic after another gate fails. This keeps bounded real-application startup measurements from competing with tool-catalog, NodeNext, package, and documentation processes. The script-only translation-pairing merge suite runs in the exempt-heavy gate because it imports only `scripts/` sources and child processes; V8 instrumentation contributes no threshold coverage there but magnifies Git-process latency. Lefthook concurrency fixtures retain their outcomes with 30-second case budgets and a 10-second process-ready probe, while the installer allows five seconds for a preempted lock owner to publish its record after exclusive creation. Directory-picker composition gives its debounced config write an explicit 15-second poll budget; workspace-context composition fixtures use a test-owned signal without an unrelated one-second deadline. The LSP sources and the ACL-sandbox sources remain in the Windows denominator: stub-based failure-path suites carry every in-process ACL-sandbox file to 100%, and only the runner entry stays excluded — it executes exclusively as a spawned child outside the instrumented run, its behavior pinned end-to-end by the runner suite. Narrow annotated V8 ignores cover only unreachable branches (peer-platform arms and lifecycle-unreachable guards), with their behavior tests retained on the owning platform.
|
||||
|
||||
The 16-core allocation is the measured capacity point for this inventory. Six-worker coverage trials produced complete passes in 6 minutes 27 seconds and 7 minutes 50 seconds, while exact-head trials with four, three, and two concurrent workers inside one instrumented Vitest process exposed unreliable fixtures and worker exits. Separate single-worker child processes retain process isolation. Historical sixteen-shard samples reduced instrumented coverage to 112.66–122.01 seconds. The pull-request coverage job schedules four instrumented children plus two exempt workers after the build, while the self-hosted complete reference runs its unsharded coverage gates serially with one worker. A six-partition pull-request profile creates enough process and type-aware lint contention to violate bounded test deadlines. Sixteen instrumented shards plus two exempt workers would exceed a 16-core allocation before system overhead. A 32-core comparison reduced aggregate gate time by only 1.47 seconds and still triggered the CJS-lexer fatal inside a fork worker, so additional cores did not provide a reliable wall-clock improvement.
|
||||
The 16-core allocation is the measured capacity point for this inventory. Six-worker coverage trials produced complete passes in 6 minutes 27 seconds and 7 minutes 50 seconds, while exact-head trials with four, three, and two concurrent workers inside one instrumented Vitest process exposed unreliable fixtures and worker exits. Separate single-worker child processes retain process isolation. Historical sixteen-shard samples reduced instrumented coverage to 112.66–122.01 seconds. The pull-request coverage job schedules four instrumented children plus two exempt workers without a preceding build, while the self-hosted complete reference runs its unsharded coverage gates serially with one worker. A six-partition pull-request profile creates enough process and type-aware lint contention to violate bounded test deadlines. Sixteen instrumented shards plus two exempt workers would exceed a 16-core allocation before system overhead. A 32-core comparison reduced aggregate gate time by only 1.47 seconds and still triggered the CJS-lexer fatal inside a fork worker, so additional cores did not provide a reliable wall-clock improvement.
|
||||
|
||||
The first native run exposed two failures hidden by the compatibility lane. Documentation projection tests derived an image basename by splitting only on `/`; they now use Node's platform basename. Chokidar consumers received `%TEMP%` through the `C:\\Users\\RUNNER~1` 8.3 alias while libuv returned the long directory name, tripping its Windows event-path assertion. Shared settings and credentials watchers, plus Cordis module and exact-config HMR, now canonicalize the existing native watch base or deepest existing ancestor before opening the watcher and preserve a missing suffix, while file access and diagnostics retain the configured path. Module HMR attaches listeners and awaits the main watcher's ready event before plugin startup settles, so an immediate post-boot edit cannot race the initial scan. HMR acceptance derives expected identities through the same asynchronous native realpath operation, avoiding a synchronous Windows spelling that can retain the 8.3 alias.
|
||||
|
||||
|
|
@ -52,6 +52,6 @@ Shiki disables lazy TextMate-regex compilation and warms each boot grammar befor
|
|||
|
||||
Wine preserves the required aggregate's existing critical path and job identity. Native coverage and observational results can still be pending or red when `all checks passed` turns green, so branch protection consumes Wine plus the targeted native build and process checks while reviewers and follow-up automation consume the remaining native results.
|
||||
|
||||
Every pull request nevertheless receives a real NT kernel, NTFS, PowerShell, Windows process, native addon, and supported-source coverage signal. The native jobs duplicate setup and repeat builds across the build, coverage, and observational workspaces, but they lower each job's process count and expose path, watcher, lifecycle, and fixture defects hidden by the compatibility lane.
|
||||
Every pull request nevertheless receives a real NT kernel, NTFS, PowerShell, Windows process, native addon, and supported-source coverage signal. The native jobs duplicate setup across the build, coverage, and observational workspaces and repeat builds in the build and observational ones, but they lower each job's process count and expose path, watcher, lifecycle, and fixture defects hidden by the compatibility lane.
|
||||
|
||||
Maintainers must preserve two intentional execution topologies: the Wine snapshot uses Linux installation plus a hoisted layout to reach win32 binaries, while the native jobs use separate immutable workspaces on the organization-owned 16-core Windows runner. A failure unique to either topology must be classified against that boundary rather than weakened or silently skipped.
|
||||
|
|
|
|||
|
|
@ -18,9 +18,9 @@ Status: implemented
|
|||
|
||||
`windows-build` 与 `windows-native-tests` 是 `all checks passed` 的依赖项;其工作区构建和定向原生进程结果具有阻断性。`windows-coverage` 仍是常规作业,但不在聚合流程的 `needs` 中,因此逐文件 100% 覆盖率结果会保持红灯并可见,却不会延迟必需判定。`windows-observational` 同样不在聚合流程的 `needs` 中,并使用 `continue-on-error`,因为静态检查、文档、包与构建产物的阻断性判定由 Linux 负责。
|
||||
|
||||
`windows-coverage` 会先完成一次工作区构建,再由[job 内分区覆盖率](2026-08-18-in-job-partitioned-coverage.zh.md)启动 4 个单 worker 插桩分片,并与一个双 worker 的豁免重型门禁并行运行。两项覆盖率门禁都将 Vitest 默认的单测试和轮询时间预算设为 30 秒。`windows-observational` 拥有自己的工作区构建和生产网站验证,会一起启动相互独立的静态门禁,并将 `publint` 限制为最多 8 个 worker。其 built-bin 冒烟测试只在其他所有观测性门禁结算后启动;冒烟测试的 `needs` 边仍要求构建成功,而 `after` 边会在其他门禁失败后保留这项诊断。这可避免有界的真实应用启动测量与 tool-catalog、NodeNext、包及文档进程争抢资源。translation-pairing 合并套件只导入 `scripts/` 源码和子进程,因此放入豁免重型套件门禁;V8 插桩不会为它贡献任何阈值覆盖率,却会放大 Git 进程延迟。Lefthook 并发 fixture 保留原有结果,采用 30 秒单用例预算与 10 秒进程就绪探测;安装器则允许被抢占的 lock 持有者在独占创建后用 5 秒发布记录。directory-picker 组合为防抖配置写入提供显式的 15 秒轮询预算;workspace-context 组合 fixture 使用测试自有、没有无关 1 秒截止时间的信号。LSP 源码与 ACL 沙箱源码仍计入 Windows 分母:基于 stub 的失败路径套件把每个进程内 ACL 沙箱文件都带到 100%,只有 runner 入口保持排除——它只作为 spawn 出的子进程在插桩运行之外执行,其行为由 runner 套件端到端钉住。窄范围且带注释的 V8 ignore 只覆盖不可达分支(另一平台专属分支、生命周期内不可达的防御守卫),其行为测试仍保留在所属平台。
|
||||
`windows-coverage` 与 Linux 覆盖率通道一致,不先构建工作区就运行[job 内分区覆盖率](2026-08-18-in-job-partitioned-coverage.zh.md):4 个单 worker 插桩分片与一个双 worker 的豁免重型门禁并行运行;工作区导入通过 tsconfig paths 映射解析到 `src`,消费构建产物的套件在未构建的检出上会自跳。两项覆盖率门禁都通过 `DSH_COVERAGE_TEST_TIMEOUT_MS=90000` 提高 Vitest 的单测试、expect.poll 与 hook 预算。`windows-observational` 拥有自己的工作区构建和生产网站验证,会一起启动相互独立的静态门禁,并将 `publint` 限制为最多 8 个 worker。其 built-bin 冒烟测试只在其他所有观测性门禁结算后启动;冒烟测试的 `needs` 边仍要求构建成功,而 `after` 边会在其他门禁失败后保留这项诊断。这可避免有界的真实应用启动测量与 tool-catalog、NodeNext、包及文档进程争抢资源。translation-pairing 合并套件只导入 `scripts/` 源码和子进程,因此放入豁免重型套件门禁;V8 插桩不会为它贡献任何阈值覆盖率,却会放大 Git 进程延迟。Lefthook 并发 fixture 保留原有结果,采用 30 秒单用例预算与 10 秒进程就绪探测;安装器则允许被抢占的 lock 持有者在独占创建后用 5 秒发布记录。directory-picker 组合为防抖配置写入提供显式的 15 秒轮询预算;workspace-context 组合 fixture 使用测试自有、没有无关 1 秒截止时间的信号。LSP 源码与 ACL 沙箱源码仍计入 Windows 分母:基于 stub 的失败路径套件把每个进程内 ACL 沙箱文件都带到 100%,只有 runner 入口保持排除——它只作为 spawn 出的子进程在插桩运行之外执行,其行为由 runner 套件端到端钉住。窄范围且带注释的 V8 ignore 只覆盖不可达分支(另一平台专属分支、生命周期内不可达的防御守卫),其行为测试仍保留在所属平台。
|
||||
|
||||
16 核配置是这项清单经实测选定的容量规格。使用 6 个 coverage worker 的试验分别以 6 分 27 秒和 7 分 50 秒跑出完整通过结果,而在单个插桩 Vitest 进程内使用 4 个、3 个和 2 个并发 worker 的分支头精确试验暴露出不稳定的 fixture 与 worker 退出。相互独立的单 worker 子进程保留进程隔离。历史上的 16 分片样本把插桩覆盖率缩短到 112.66–122.01 秒。拉取请求覆盖率作业会在构建后调度 4 个插桩子进程和 2 个豁免 worker,而自托管完整参考流程会用 1 个 worker 串行运行未分片的覆盖率门禁。拉取请求若采用 6 分片配置,就会产生足以违反有界测试截止时间的进程与类型感知 lint 争用。16 个插桩分片加 2 个豁免 worker 会在计入系统开销前就超过 16 核分配。32 核对比仅将聚合门禁时间缩短 1.47 秒,且仍在 fork worker 内触发 CJS lexer 致命故障,因此增加核心数没有带来可靠的墙钟时间改善。
|
||||
16 核配置是这项清单经实测选定的容量规格。使用 6 个 coverage worker 的试验分别以 6 分 27 秒和 7 分 50 秒跑出完整通过结果,而在单个插桩 Vitest 进程内使用 4 个、3 个和 2 个并发 worker 的分支头精确试验暴露出不稳定的 fixture 与 worker 退出。相互独立的单 worker 子进程保留进程隔离。历史上的 16 分片样本把插桩覆盖率缩短到 112.66–122.01 秒。拉取请求覆盖率作业会在没有前置构建的情况下调度 4 个插桩子进程和 2 个豁免 worker,而自托管完整参考流程会用 1 个 worker 串行运行未分片的覆盖率门禁。拉取请求若采用 6 分片配置,就会产生足以违反有界测试截止时间的进程与类型感知 lint 争用。16 个插桩分片加 2 个豁免 worker 会在计入系统开销前就超过 16 核分配。32 核对比仅将聚合门禁时间缩短 1.47 秒,且仍在 fork worker 内触发 CJS lexer 致命故障,因此增加核心数没有带来可靠的墙钟时间改善。
|
||||
|
||||
首次原生运行暴露出两项被兼容性通道掩盖的故障。文档投影测试此前只按 `/` 拆分来派生图片 basename;现在改为使用 Node 根据平台计算的 basename。Chokidar 消费方收到的 `%TEMP%` 以 `C:\\Users\\RUNNER~1` 这个 8.3 别名表示,而 libuv 返回的是长目录名,导致其 Windows 事件路径断言失败。共享的设置 watcher 与凭据 watcher,以及 Cordis 的模块 HMR(热模块替换)与精确配置 HMR,现在都会在打开 watcher 前规范化现有的原生监听基准路径或层级最深的现有祖先路径,并保留尚不存在的后缀;文件访问和诊断仍使用配置路径。模块 HMR 会挂接监听器并等待主 watcher 的 ready 事件,之后插件启动才会完成,因此启动后立即发生的编辑无法与初始扫描形成竞态。HMR 验收通过相同的异步原生 realpath 操作派生预期身份,避免同步 Windows 路径写法仍保留 8.3 别名。
|
||||
|
||||
|
|
@ -52,6 +52,6 @@ Shiki 会禁用 TextMate 正则的延迟编译,并在用户内容进入保持
|
|||
|
||||
Wine 保留必需聚合流程现有的关键路径和作业身份。`all checks passed` 变绿时,原生覆盖率与观测性结果仍可能处于待处理或红灯状态,因此分支保护采用 Wine 加定向原生构建和进程检查,而评审者和后续自动化采用其余原生结果。
|
||||
|
||||
尽管如此,每个拉取请求都会获得真实 NT 内核、NTFS、PowerShell、Windows 进程、原生插件和受支持源码覆盖率信号。原生作业会重复设置流程,并在构建、覆盖率与观测性工作区中重复构建,但它们会降低每个作业的进程数,并暴露兼容性通道掩盖的路径、watcher、生命周期与 fixture 缺陷。
|
||||
尽管如此,每个拉取请求都会获得真实 NT 内核、NTFS、PowerShell、Windows 进程、原生插件和受支持源码覆盖率信号。原生作业会在构建、覆盖率与观测性工作区中重复设置流程,并在构建与观测性工作区中重复构建,但它们会降低每个作业的进程数,并暴露兼容性通道掩盖的路径、watcher、生命周期与 fixture 缺陷。
|
||||
|
||||
维护者必须保留两种有意设计的执行拓扑:Wine 快照使用 Linux 安装加 hoisted 布局来触达 win32 二进制文件,而原生作业在组织自有的 16 核 Windows 运行器上使用相互独立的不可变工作区。任一拓扑独有的失败都必须依据该边界分类,不得削弱或静默跳过。
|
||||
|
|
|
|||
|
|
@ -2,5 +2,5 @@
|
|||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.md
|
||||
2026-08-10-event-directed-pr-review-status.md: 3ed6038929d3c2c1e9cd82182978262ee363f5ab
|
||||
2026-08-10-event-directed-pr-review-status.zh.md: 1fa8650057e53ab894c597b712720a3ee7a5c46a
|
||||
2026-08-10-event-directed-pr-review-status.md: 47f6f1731b037ae55a994c3373c0f99917da98dd
|
||||
2026-08-10-event-directed-pr-review-status.zh.md: 8062ab5b1f2efdfcba92f0675af700e59a358d25
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ The Issue lifecycle workflow treats review webhooks as commands. `pull_request.r
|
|||
|
||||
Ordinary subscribed pull-request events remain forward-only implementation signals: they can move `Inbox`, `Backlog`, or `Ready` to `In progress`, but they cannot move `In review` backward. Review-request commands can move any earlier active status to `In review`. Changes-requested commands can move earlier active statuses forward to `In progress` and can move `In review` back only when the latest status event for the target Project was written by the configured lifecycle actor. A human or unknown latest actor preserves the current status.
|
||||
|
||||
The handler resolves only exact same-repository `Fixes`, `Closes`, or `Resolves` references. It does not alter terminal statuses, add an Issue with no Project status, depend on PR metadata validity, query `reviewDecision`, reconstruct review rounds, look up pull requests from Issues, or run a scheduled reconciler.
|
||||
The status projection resolves only exact same-repository `Fixes`, `Closes`, or `Resolves` references. It does not alter terminal statuses, add an Issue with no Project status, depend on PR metadata validity, query `reviewDecision`, reconstruct review rounds, look up pull requests from Issues, or run a scheduled reconciler. [PR-opened Issue start dates](2026-08-31-pr-opened-issue-start-dates.md) own the separate date initialization for every same-repository Issue reference.
|
||||
|
||||
[Issue lifecycle](../../../../.github/workflows/issue-lifecycle.yml) remains unsubscribed from `pull_request.ready_for_review`; neither event command depends on that action. [Issue policy](../../../../.github/workflows/issue-policy.yml) retains `ready_for_review` because it owns required-check enforcement when a human pull request enters review.
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ Issue 生命周期工作流把评审 webhook 视为命令。`pull_request.review
|
|||
|
||||
工作流订阅的普通 PR 事件仍是只向前推进的实现信号:它们可以将 `Inbox`、`Backlog` 或 `Ready` 推进至 `In progress`,但不能让 `In review` 倒退。请求评审命令可将任意较早的活跃状态推进至 `In review`。请求修改命令可将较早的活跃状态推进至 `In progress`;它也可以让 `In review` 状态回退,但仅在目标 Project 的最新状态事件由配置的生命周期执行主体写入时进行。若最新状态事件的执行主体是人工用户或未知主体,则保留当前状态。
|
||||
|
||||
处理器仅解析同一仓库内严格匹配的 `Fixes`、`Closes` 或 `Resolves` 引用。它不会更改终态、将没有 Project 状态的 Issue 添加到 Project、依赖 PR 元数据是否有效、查询 `reviewDecision`、重建评审轮次、从 Issue 反向查找 PR,或运行定时协调器。
|
||||
状态投影仅解析同一仓库内严格匹配的 `Fixes`、`Closes` 或 `Resolves` 引用。它不会更改终态、将没有 Project 状态的 Issue 添加到 Project、依赖 PR 元数据是否有效、查询 `reviewDecision`、重建评审轮次、从 Issue 反向查找 PR,或运行定时协调器。独立的日期初始化由[在 PR 创建时设置 Issue 开始日期](2026-08-31-pr-opened-issue-start-dates.zh.md)负责,并处理每个同仓库 Issue 引用。
|
||||
|
||||
[Issue 生命周期](../../../../.github/workflows/issue-lifecycle.yml)仍不订阅 `pull_request.ready_for_review`;两条事件命令均不依赖该动作。[Issue 策略](../../../../.github/workflows/issue-policy.yml)保留 `ready_for_review`,因为人工提交的 PR 进入评审时,该工作流负责执行必需检查门禁。
|
||||
|
||||
|
|
|
|||
|
|
@ -2,5 +2,5 @@
|
|||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.md
|
||||
2026-08-18-in-job-partitioned-coverage.md: 33824f0aa6f3541df8ca2e0cc8c417b40b5d7933
|
||||
2026-08-18-in-job-partitioned-coverage.zh.md: c32738462f7e33d9377d314a9ded5f82cebe9db3
|
||||
2026-08-18-in-job-partitioned-coverage.md: ded8d4a84d24fa42f6b48ac56472c9e54925772b
|
||||
2026-08-18-in-job-partitioned-coverage.zh.md: cf37d61744ab6482d67423275fa8ffb52cb851e0
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ When partitioning is enabled, `scripts/run-gates.ts` selects `pnpm run test:cove
|
|||
|
||||
The coordinator waits for every child, validates that the blob directory contains exactly the expected files, and then runs one `vitest --merge-reports ... --coverage` command. Only that merged command applies the repository's per-file statement, branch, function, and line thresholds, so a partition is never judged against an intentionally partial inventory.
|
||||
|
||||
`DSH_COVERAGE_MAX_WORKERS` continues to size the uninstrumented exempt gate and the ordinary non-partitioned path; it does not resize partition children. Native Windows gives the exempt gate two workers and admits four concurrent outer gates. The workspace build and production-site validation start immediately; both coverage gates wait for the complete build. The instrumented suite contains packer assertions over built `lib/` output, so this dependency prevents it from reading a partially emitted package closure, while also preventing the exempt gate's temporary Oxlint probes from racing source compilation. The observational inventory waits only for both coverage gates to settle, so it still runs after a coverage failure; each gate's `needs` dependencies remain pass-required. Linux overlaps four instrumented partition processes with two exempt workers, restoring the ordinary path's former four-way instrumented concurrency while keeping every instrumented process single-worker.
|
||||
`DSH_COVERAGE_MAX_WORKERS` continues to size the uninstrumented exempt gate and the ordinary non-partitioned path; it does not resize partition children. Native Windows gives the exempt gate two workers and admits four concurrent outer gates. In the complete reference, the workspace build and production-site validation start immediately and both coverage gates wait for the complete build; the wait also keeps the exempt gate's temporary Oxlint probes from racing source compilation. The pull-request coverage job runs the same zero-build coverage as Linux: workspace imports resolve to `src` through the tsconfig paths map, and the lib-consuming suites — the exempt gate's packer image assertions and full-corpus import sweep, and the instrumented corpus's client-bundle artifact check — self-skip on unbuilt checkouts. The observational inventory waits only for both coverage gates to settle, so it still runs after a coverage failure; each gate's `needs` dependencies remain pass-required. Linux overlaps four instrumented partition processes with two exempt workers, restoring the ordinary path's former four-way instrumented concurrency while keeping every instrumented process single-worker.
|
||||
|
||||
## Failure and output semantics
|
||||
|
||||
|
|
@ -30,7 +30,7 @@ A normal failed test still emits a blob through `--coverage.reportOnFailure`, al
|
|||
|
||||
`scripts/coverage-partitions.spec.ts` pins argument construction, package-script separator removal, one-worker partitions, weighted longest-processing-time assignment (including a case that fails when assignment ignores recorded weights), the single merged threshold command, failed-test merging, failure diagnostics before complete-blob validation, waiting for sibling partitions after a spawn failure, and link-safe cleanup. `scripts/run-gates.spec.ts` pins opt-in selection, invalid-count rejection, both native Windows coverage gates' complete-build dependency, the complete Windows inventory with its blocking split, and unbuffered streamed output. React fake-timer cases that can move between partitions advance timers inside `act()`; geometry-dependent portal tests stub their element rectangles so a different shard schedule cannot turn deferred updates or jsdom coordinates into coverage-only failures.
|
||||
|
||||
Completed native Windows comparisons measured two partitions near 405 seconds and sixteen partitions at 112.66–122.01 seconds under the earlier gate ordering; those values compare partition latency, not the current peak. The current post-build phase runs four instrumented partition processes beside two exempt workers, for six coverage execution units. Sixteen partitions would raise that phase to eighteen before any still-running production-site work or system overhead. Four partitions keep separate-process isolation and match Linux, at the cost of a longer single-job coverage wall time; the trade-off is accepted to reduce vitest worker startup failures under high self-hosted concurrency. Two Linux samples measured the conservative two-partition configuration at 276.68 and 282.27 seconds; that configuration was stable but halved the ordinary path's four instrumented workers. Four partitions restore that fan-out, for six total coverage execution units on the 16-core hosted runner and at most 36 across the failover VM's six runner instances. These values come from completed runs or fixed capacity bounds; an unfinished run crossing an arbitrary elapsed-time mark is not evidence for increasing concurrency.
|
||||
Completed native Windows comparisons measured two partitions near 405 seconds and sixteen partitions at 112.66–122.01 seconds under the earlier gate ordering; those values compare partition latency, not the current peak. The current coverage phase runs four instrumented partition processes beside two exempt workers, for six coverage execution units. Sixteen partitions would raise that phase to eighteen before any still-running production-site work or system overhead. Four partitions keep separate-process isolation and match Linux, at the cost of a longer single-job coverage wall time; the trade-off is accepted to reduce vitest worker startup failures under high self-hosted concurrency. Two Linux samples measured the conservative two-partition configuration at 276.68 and 282.27 seconds; that configuration was stable but halved the ordinary path's four instrumented workers. Four partitions restore that fan-out, for six total coverage execution units on the 16-core hosted runner and at most 36 across the failover VM's six runner instances. These values come from completed runs or fixed capacity bounds; an unfinished run crossing an arbitrary elapsed-time mark is not evidence for increasing concurrency.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
|
@ -46,6 +46,6 @@ Completed native Windows comparisons measured two partitions near 405 seconds an
|
|||
|
||||
Coverage pays one Vitest startup/configuration cost per partition and one report-merge cost, but it avoids another workflow topology and keeps one final threshold verdict. Partition output may interleave, while the partition start labels and Vitest file identities retain attribution.
|
||||
|
||||
Linux and Windows use the same coordinator with platform-specific partition counts and surrounding worker budgets. Native Windows starts both coverage gates after the complete build because its instrumented corpus can consume built artifacts; Linux's dedicated coverage job does not share a workspace with a concurrent build. Local coverage stays simple unless a caller explicitly chooses the partitioned package script and supplies a valid count greater than one.
|
||||
Linux and Windows use the same coordinator with platform-specific partition counts and surrounding worker budgets. Both pull-request coverage lanes run without a preceding build; the complete reference (serial-windows standby) still starts its coverage gates after the build gate so the lib-consuming suites execute against real artifacts. Local coverage stays simple unless a caller explicitly chooses the partitioned package script and supplies a valid count greater than one.
|
||||
|
||||
Future tuning starts from completed runs at one fixed configuration. Slow progress alone never raises partition count or outer concurrency, because repeated restarts would erase the only evidence needed to choose a stable setting.
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ Status: implemented
|
|||
|
||||
协调器等待全部子进程结束,验证 blob 目录只包含预期文件,然后执行一次 `vitest --merge-reports ... --coverage`。只有这条合并命令应用仓库的逐文件语句、分支、函数与行阈值,因此系统不会拿有意不完整的测试清单单独判定任一分区。
|
||||
|
||||
`DSH_COVERAGE_MAX_WORKERS` 继续控制无插桩豁免门禁和普通非分区路径的规模,不会调整分区子进程。原生 Windows 为豁免门禁分配 2 个 worker,并允许 4 道外层门禁并发。工作区构建与生产网站验证会立即启动;两道覆盖率门禁都等待完整构建。插桩套件包含针对已构建 `lib/` 输出的打包器断言,因此这项依赖可避免它读取只完成部分产出的包闭包,也可避免豁免门禁的临时 Oxlint 探针与源码编译竞态。观测性清单只等待两道覆盖率门禁结算,因此在覆盖率失败后仍会运行;各门禁自身的 `needs` 依赖仍要求前置门禁通过。Linux 让 4 个插桩分区进程与 2 个豁免 worker 重叠运行,在保持每个插桩进程只有 1 个 worker 的同时,恢复普通路径原有的 4 路插桩并发。
|
||||
`DSH_COVERAGE_MAX_WORKERS` 继续控制无插桩豁免门禁和普通非分区路径的规模,不会调整分区子进程。原生 Windows 为豁免门禁分配 2 个 worker,并允许 4 道外层门禁并发。在完整参考流程中,工作区构建与生产网站验证会立即启动,两道覆盖率门禁都等待完整构建;这次等待也能避免豁免门禁的临时 Oxlint 探针与源码编译竞态。拉取请求覆盖率 job 与 Linux 一样以零构建方式运行:工作区导入通过 tsconfig paths 映射解析到 `src`,而消费构建产物的套件——豁免门禁的打包器镜像断言与全语料导入 sweep,以及插桩语料的 client-bundle 产物校验——在未构建的检出上会自跳。观测性清单只等待两道覆盖率门禁结算,因此在覆盖率失败后仍会运行;各门禁自身的 `needs` 依赖仍要求前置门禁通过。Linux 让 4 个插桩分区进程与 2 个豁免 worker 重叠运行,在保持每个插桩进程只有 1 个 worker 的同时,恢复普通路径原有的 4 路插桩并发。
|
||||
|
||||
## 失败与输出语义
|
||||
|
||||
|
|
@ -30,7 +30,7 @@ Status: implemented
|
|||
|
||||
`scripts/coverage-partitions.spec.ts` 固定了参数构造、包脚本分隔符移除、单 worker 分区、加权最长处理时间分配(含一个在分配忽略记录权重时必然失败的用例)、唯一一次合并阈值命令、失败测试合并、完整 blob 校验前的失败诊断、spawn 失败后等待兄弟分区,以及链接安全清理。`scripts/run-gates.spec.ts` 固定了显式启用、非法数量拒绝、两道原生 Windows 覆盖率门禁对完整构建的依赖、完整 Windows 清单及其阻断性划分,以及不缓冲的流式输出。可能在分区间移动的 React fake-timer 用例会在 `act()` 内推进计时器;依赖几何位置的 portal 测试会固定元素矩形,使不同分片调度不会把延迟更新或 jsdom 坐标变成只在覆盖率运行中出现的失败。
|
||||
|
||||
已完成的原生 Windows 对比中,双分区耗时约 405 秒,16 分区耗时 112.66–122.01 秒;这些数据来自先前的门禁顺序,只用于比较分区延迟,不代表当前峰值。当前的构建后阶段会让 4 个插桩分区进程与 2 个豁免 worker 并行,共形成 6 个覆盖率执行单元。若改为 16 个分区,则在尚未结束的生产网站工作或系统开销计入之前,该阶段就会达到 18 个执行单元。4 个分区保留独立进程隔离并与 Linux 对齐,代价是单 job 覆盖率墙钟更长;这是为了降低自托管高并发下 vitest worker 启动失败而接受的取舍。两个 Linux 样本中,保守的双分区配置耗时 276.68 秒和 282.27 秒;该配置运行稳定,却把普通路径原有的 4 个插桩 worker 减半。4 个分区恢复这份并发,使 16 核托管 runner 上的覆盖率执行单元总数为 6,故障切换虚拟机的 6 个 runner 实例最多合计 36 个执行单元。这些数值来自完整运行或固定容量上限;运行尚未结束时跨过任意耗时刻度,不构成增加并发的证据。
|
||||
已完成的原生 Windows 对比中,双分区耗时约 405 秒,16 分区耗时 112.66–122.01 秒;这些数据来自先前的门禁顺序,只用于比较分区延迟,不代表当前峰值。当前的覆盖率阶段会让 4 个插桩分区进程与 2 个豁免 worker 并行,共形成 6 个覆盖率执行单元。若改为 16 个分区,则在尚未结束的生产网站工作或系统开销计入之前,该阶段就会达到 18 个执行单元。4 个分区保留独立进程隔离并与 Linux 对齐,代价是单 job 覆盖率墙钟更长;这是为了降低自托管高并发下 vitest worker 启动失败而接受的取舍。两个 Linux 样本中,保守的双分区配置耗时 276.68 秒和 282.27 秒;该配置运行稳定,却把普通路径原有的 4 个插桩 worker 减半。4 个分区恢复这份并发,使 16 核托管 runner 上的覆盖率执行单元总数为 6,故障切换虚拟机的 6 个 runner 实例最多合计 36 个执行单元。这些数值来自完整运行或固定容量上限;运行尚未结束时跨过任意耗时刻度,不构成增加并发的证据。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
|
|
@ -46,6 +46,6 @@ Status: implemented
|
|||
|
||||
每个分区都要支付 1 次 Vitest 启动与配置开销,最后还要执行 1 次报告合并,但它不引入另一套工作流拓扑,并保留唯一的最终阈值判定。分区输出可能交错,但分区启动标签和 Vitest 文件标识仍可用于归因。
|
||||
|
||||
Linux 与 Windows 使用相同的协调器,并各自设置分区数量与外围 worker 预算。原生 Windows 会在完整构建之后启动两道覆盖率门禁,因为其插桩语料可能消费构建产物;Linux 的专用覆盖率 job 不会与同一工作区中的并发构建共享目录。本地覆盖率默认保持简单;只有调用方显式选择分区包脚本并提供大于 1 的合法数量时,才启用分区。
|
||||
Linux 与 Windows 使用相同的协调器,并各自设置分区数量与外围 worker 预算。两条拉取请求覆盖率通道都以零构建方式运行;完整参考流程(serial-windows standby)仍在构建门禁之后启动其覆盖率门禁,使消费构建产物的套件对真实产物执行。本地覆盖率默认保持简单;只有调用方显式选择分区包脚本并提供大于 1 的合法数量时,才启用分区。
|
||||
|
||||
未来调优从一个固定配置的完整运行开始。进度缓慢本身绝不会提高分区数量或外层并发,因为反复重启会抹掉选择稳定设置所需的唯一证据。
|
||||
|
|
|
|||
|
|
@ -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/process/2026-08-27-gate-runner-fail-fast.md
|
||||
2026-08-27-gate-runner-fail-fast.md: b11e3336aad01d2d4bf57c132e1aeaddfac5c258
|
||||
2026-08-27-gate-runner-fail-fast.zh.md: 93e1b0314289afa17e89afea19c8b8c0563362f4
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
# Agent Note: Gate-runner fail-fast
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-27-gate-runner-fail-fast.zh.md)
|
||||
|
||||
[Parallel pre-push gates](2026-07-06-parallel-pre-push-gates.md) owns the bounded gate scheduler in `scripts/run-gates.ts`; this note adds one scheduling option to that scheduler.
|
||||
|
||||
## Problem
|
||||
|
||||
The gate scheduler in `scripts/run-gates.ts` runs every independent gate in an aggregate to completion and reports `run-gates: N passed, M failed`. A gate failure does not stop the remaining gates; only gates whose `needs` dependency failed are skipped. On an aggregate that is already red, the remaining gates keep consuming runner time and produce evidence that cannot change the verdict. The largest single cost is the instrumented coverage run in the `ci-coverage` aggregate, which has taken about 27 minutes; in `ci-consumers`, the Node compatibility smoke runs independently of the build, so it keeps running after a build failure that already settles the verdict.
|
||||
|
||||
GitHub Actions provides no native cross-job cancellation: `fail-fast` applies only inside a matrix, and the `all checks passed` aggregate settles only after every needed job finishes, so it cannot cancel siblings early. The only in-repository lever is the gate scheduler itself.
|
||||
|
||||
## Decision
|
||||
|
||||
`run-gates.ts` accepts a fail-fast scheduling option. When enabled, the first blocking gate failure (a gate whose `allowFailure` is not true) aborts the aggregate: the shared `AbortSignal` terminates every running gate's process tree, and every not-yet-run gate is recorded as `skipped` with the error `aborted by fail-fast: <label> failed` (or `aborted by fail-fast: host interruption` when the host signal aborted the run). The exit status remains 1, including when a killed child traps the signal and exits zero: such a result carries the abort mark and is recorded `skipped`, never passed. A gate that settled before the abort took effect keeps its real result, so the summary stays truthful about what produced evidence.
|
||||
|
||||
Termination covers the whole tree, not just the direct pnpm wrapper: POSIX signals the detached child's process group (`kill(-pid, SIGTERM)`, escalating unconditionally to `SIGKILL` after 5 seconds) and additionally signals every transitive descendant read from the process table, so the detached leaves of a nested run-gates (the `check:node-compat` and `check:ci:lint:contracts-ready` gates inside `ci-consumers`) are killed without relying on the inner scheduler's own escalation. The descendant list is primed at spawn and refreshed every 5 seconds while the child runs, each tick merging the fresh enumeration into the live-filtered cache (the enumeration is asynchronous — a slow WMI/CIM call is bounded by its own 10-second timeout and never blocks the gate's output draining or exit handling; an enumeration still in flight when the gate settles is cancelled rather than left holding stdio handles, and a snapshot that settles after the child exited is still merged, because the child may be gone while a grandchild keeps `close` pending — exactly when the abort needs the list) so a descendant reparented by an exited intermediate stays tracked across ticks; at abort the fresh enumeration is merged into the same list, then re-signalled on the escalation, because the group kill reaps the direct child and reparents its detached descendants, making them unreachable by parent id afterwards; settlement waits until the group and the captured descendants are gone. On the abort path only, a bounded pipe-drain timer force-closes the stdio streams 10 seconds after the abort, so a descendant holding the write ends (uninterruptible I/O included) cannot keep `close` pending to the job timeout; ordinary runs keep waiting rather than report passed over a live leak. Windows runs `taskkill /PID <pid> /T /F` immediately, because a taskkill without `/F` does not terminate console processes, which is what gate commands are; the same process-table enumeration as POSIX supplies a descendant list there, and each captured descendant is also taskkilled, because a `taskkill /T` rooted at a pid that already exited finds nothing. Windows never reparents, so an exited root's descendants keep it as their parent and remain reachable through the table; the same sampler cadence as POSIX keeps the cache crossing a vanished intermediate's table record (the enumeration is bounded by a 10-second PowerShell timeout so a hung WMI/CIM call cannot stall the abort path). Without this, Windows has no signal forwarding and a wrapper-only kill would orphan the script tree on the shared self-hosted pool. Children are detached into their own POSIX process group only when fail-fast is enabled; ordinary runs keep them in the host group so terminal Ctrl+C still reaches them. Host `SIGINT`/`SIGTERM` on a fail-fast run is forwarded to the abort path, so an interrupted or runner-cancelled run drains and kills its gate trees instead of orphaning them.
|
||||
|
||||
The option is enabled through `DSH_GATE_FAIL_FAST` (accepted values: `1` or unset; anything else fails loud through the existing `flagEnabled` contract) on every run-gates aggregate job in `ci.yml`: the three blocking Linux jobs (`node-24` static, `node-24-coverage`, `node-24-consumers`), the Node compatibility matrix (`node-compat`), and the two native Windows lanes that drive aggregates (`windows-build`, `windows-coverage`). `scripts/ci-workflow.spec.ts` pins the flag on those jobs and pins its absence on `windows-observational`, so removing it fails the CI gate.
|
||||
|
||||
The `windows-observational` lane stays complete: it is `continue-on-error` by design and exists to collect as much Windows-native evidence per run as possible, so the first failure must not truncate the rest. The `windows` Wine lane and `windows-native-tests` run a single script or Vitest command rather than a run-gates aggregate, so the scheduler option does not apply to them. The master serial standby lanes (`serial-linux-selfhosted`, `serial-windows`) and the manual runner benchmarks do not set the flag: they are completeness drills that must execute the full aggregate to prove pool readiness.
|
||||
|
||||
## Consequences
|
||||
|
||||
A red pull-request run ends sooner. The largest saving is in `ci-coverage`: a failing exempt-heavy gate aborts the multi-minute instrumented coverage gate instead of letting it run out.
|
||||
|
||||
The trade-off is diagnostic: one push returns only the first blocking failure instead of the full failure set, so resolving several independent failures may take more push-fix rounds. Killed gates are recorded as `skipped` with the fail-fast error, so the summary line `N passed, M failed, K skipped` remains truthful about what produced evidence and what did not. A gate that ignores `SIGTERM` is force-killed after the 5-second grace. A tree that survives both signals holds the aggregate only while its direct child's stdio stays open; once `close` fires, the group-liveness poll gives up after 8 seconds and the run settles with a loud `gate tree not quiescent` warning instead of reporting a clean tree.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Cross-job cancellation watchdog.** A job that polls sibling conclusions and calls the run-cancel API would stop all lanes on the first failure. It is not native, adds a polling dependency and token surface, and discards the parallel evidence other jobs have already produced. Rejected; fail-fast at the scheduler is orthogonal to the job topology and carries none of that.
|
||||
|
||||
**Consolidating the three Linux jobs into one check.** A single job could fail fast natively, but it would lose the independent runner allocation whose queue-delay overlap is documented in the [independent CI consumer build](2026-07-30-independent-ci-consumer-build.md) note, and it would make the coverage long tail the tail of the whole job. Rejected; fail-fast applies within the existing job split instead.
|
||||
|
||||
**Signaling only the direct child.** The first implementation sent `SIGTERM` to the pnpm wrapper and relied on pnpm forwarding it to the script child. A probe confirmed the forwarding on POSIX. Rejected: Windows has no signal forwarding, and a wrapper-only kill orphans the script tree on the shared self-hosted pool; the tree termination above covers both platforms.
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
# Agent Note: 门禁运行器快速失败
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-27-gate-runner-fail-fast.md) | 中文
|
||||
|
||||
[并行推送前门禁](2026-07-06-parallel-pre-push-gates.zh.md)拥有 `scripts/run-gates.ts` 中有界的门禁调度器;本笔记为该调度器新增一个调度选项。
|
||||
|
||||
## 问题
|
||||
|
||||
`scripts/run-gates.ts` 中的门禁调度器会把一个聚合流程里的每个独立门禁都跑完,然后报告 `run-gates: N passed, M failed`。某个门禁失败并不会停止其余门禁;只有 `needs` 依赖失败的门禁会被跳过。当聚合流程已经确定失败时,剩余门禁仍在消耗运行器时间,产出的证据也无法改变结论。最大的单项成本是 `ci-coverage` 聚合流程里的插桩覆盖率运行,实测约 27 分钟;在 `ci-consumers` 中,Node 兼容性冒烟检查与构建相互独立,因此它会在构建失败、结论已定的情况下继续运行。
|
||||
|
||||
GitHub Actions 不提供原生的跨作业取消:`fail-fast` 只作用于矩阵内部,而 `all checks passed` 聚合流程要等所有必需作业结束后才落定,因此它无法提前取消兄弟作业。仓库内唯一可用的杠杆就是门禁调度器本身。
|
||||
|
||||
## 决策
|
||||
|
||||
`run-gates.ts` 接受一个快速失败调度选项。启用后,首个阻塞性门禁失败(`allowFailure` 不为 true 的门禁)即中止整个聚合流程:共享的 `AbortSignal` 终止每个运行中门禁的整个进程树,所有尚未运行的门禁记录为 `skipped`,错误信息为 `aborted by fail-fast: <label> failed`(当宿主信号中止运行时为 `aborted by fail-fast: host interruption`)。退出状态保持为 1——即使被终止的子进程捕获信号并以 0 退出,这类结果也带有中止标记、记为 `skipped`,绝不记为通过。在中止生效前已结算的门禁保留其真实结果,因此汇总行仍如实反映哪些门禁产出了证据。
|
||||
|
||||
终止覆盖整棵进程树,而不只是直接的 pnpm 包装进程:POSIX 向分离子进程所在的进程组发信号(`kill(-pid, SIGTERM)`,5 秒后无条件升级为 `SIGKILL`),并额外按进程表逐个信号所有传递性后代——嵌套 run-gates(`ci-consumers` 里的 `check:node-compat` 与 `check:ci:lint:contracts-ready` 门禁)分离出去的叶子进程因此不依赖内层调度器自己的升级也能被杀掉。后代列表在 spawn 时预填、随后在子进程运行期间每 5 秒刷新(枚举是异步的——慢的 WMI/CIM 调用以自身 10 秒超时封顶,绝不会阻塞门禁的输出排空或退出处理;门禁结算时仍在途的枚举会被取消,而不是留着持有 stdio 句柄;子进程退出后才结算的快照仍会被合并,因为子进程可能已消失而孙进程仍持有 `close` 未触发——这正是中止需要该列表的时刻),每个采样 tick 把新枚举结果并入存活过滤后的缓存,被已退出中间进程 reparent 的后代因此跨 tick 仍被跟踪;中止时把新枚举结果并入同一列表,随后在升级时再次信号:组杀会重收直接子进程并 reparent 其分离后代,之后按父 id 不可达,因此结算要等进程组与已捕获后代都消失。仅在中止路径上,有界 pipe-drain 定时器会在中止后 10 秒强制关闭 stdio 流,因此持有写端的后代进程(包括不可中断 I/O)无法让 `close` 一直挂到作业超时;普通运行保持等待,而不是在有存活泄漏时报告通过。Windows 立即运行 `taskkill /PID <pid> /T /F`,因为不带 `/F` 的 taskkill 无法终止控制台进程,而门禁命令正是控制台进程;与 POSIX 相同的进程表枚举在 Windows 上也提供后代列表,每个捕获的后代同样被 taskkill——因为以已退出 pid 为根执行 `taskkill /T` 找不到任何东西,而 Windows 从不 reparent,已退出根的子孙仍以它为父、通过进程表可达;与 POSIX 相同的采样节奏让缓存跨过已消失中间进程的进程表记录(枚举以 10 秒 PowerShell 超时封顶,挂起的 WMI/CIM 调用不会拖住中止路径)。没有这一步,Windows 上不存在信号转发,只杀包装进程会在共享自托管池上留下孤儿脚本树。只有在启用快速失败时,子进程才会分离到自己的 POSIX 进程组;普通运行保持子进程在宿主进程组内,终端 Ctrl+C 仍能送达它们。快速失败运行上的宿主 `SIGINT`/`SIGTERM` 会转发到中止路径,因此被中断或被运行器取消的运行会排干并杀死自己的门禁进程树,而不是留下孤儿。
|
||||
|
||||
该选项通过 `DSH_GATE_FAIL_FAST` 开启(可接受值:`1` 或未设置;其它值通过既有的 `flagEnabled` 契约响亮失败),作用于 `ci.yml` 中所有由 run-gates 驱动的聚合作业:三个阻塞性 Linux 作业(`node-24` 静态、`node-24-coverage`、`node-24-consumers`)、Node 兼容性矩阵(`node-compat`)以及两个驱动聚合流程的原生 Windows 车道(`windows-build`、`windows-coverage`)。`scripts/ci-workflow.spec.ts` 固定了这些作业上的该标志,并固定 `windows-observational` 上不存在该标志,移除它会令 CI 门禁失败。
|
||||
|
||||
`windows-observational` 车道保持完整执行:它按设计是 `continue-on-error`,存在的意义是每次运行尽量收集 Windows 原生证据,因此首个失败不应截断其余部分。`windows` Wine 车道和 `windows-native-tests` 运行的是单个脚本或 Vitest 命令,不是 run-gates 聚合流程,因此调度选项不适用于它们。master 串行备用车道(`serial-linux-selfhosted`、`serial-windows`)和手动运行器基准测试不设置该标志:它们是完整性演练,必须执行完整聚合流程以证明池的可用性。
|
||||
|
||||
## 结果
|
||||
|
||||
红色拉取请求运行会更早结束。最大节省在 `ci-coverage`:失败的豁免重型门禁会中止多分钟的插桩覆盖率门禁,而不是让它跑完。
|
||||
|
||||
代价在诊断侧:一次推送只返回第一条阻塞性失败,而不是完整失败集,因此解决多个独立失败可能需要更多次推送-修复往返。被终止的门禁记录为带快速失败错误的 `skipped`,因此 `N passed, M failed, K skipped` 汇总行仍然如实反映哪些门禁产出了证据、哪些没有。忽略 `SIGTERM` 的门禁会在 5 秒宽限期后被强制终止。同时扛过两种信号仍存活的进程树,只有在直接子进程的 stdio 保持打开时才会拖住聚合流程;一旦 `close` 触发,进程组存活轮询在 8 秒后放弃,运行以响亮的 `gate tree not quiescent` 警告结算,而不是报告一棵干净的树。
|
||||
|
||||
## 备选方案
|
||||
|
||||
**跨作业取消看门狗。** 一个轮询兄弟作业结论并调用运行取消 API 的作业可以在首个失败时停止所有车道。它不是原生能力,会增加轮询依赖和令牌暴露面,并丢弃其它作业已并行产出的证据。已拒绝;调度器层面的快速失败与作业拓扑正交,不带来上述任何负担。
|
||||
|
||||
**把三个 Linux 作业合并成一个检查。** 单个作业可以原生快速失败,但会失去独立的运行器分配——其排队延迟重叠的收益记录在[独立 CI 消费方构建](2026-07-30-independent-ci-consumer-build.zh.md)笔记中——并且会让覆盖率长尾成为整个作业的尾部。已拒绝;快速失败在既有作业拆分内生效即可。
|
||||
|
||||
**只向直接子进程发信号。** 第一版实现向 pnpm 包装进程发送 `SIGTERM`,依赖 pnpm 把它转发给脚本子进程。探针确认了 POSIX 上的转发。已拒绝:Windows 没有信号转发,只杀包装进程会在共享自托管池上留下孤儿脚本树;上述整树终止覆盖两个平台。
|
||||
|
|
@ -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/process/2026-08-31-pr-opened-issue-start-dates.md
|
||||
2026-08-31-pr-opened-issue-start-dates.md: f8976b8b0499aa5c68c9637e8571805b78ce6d48
|
||||
2026-08-31-pr-opened-issue-start-dates.zh.md: 3142bad5007cbbdd27e1f564bc3ccec9101d3d79
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
# Agent Note: PR-opened Issue start dates
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-31-pr-opened-issue-start-dates.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The Issue Project records planned work in a `Start date` field, but adding or linking an Issue does not provide a date value. A pull request can identify both Issues it resolves and Issues that supply related implementation context, and either relationship marks the start of repository work.
|
||||
|
||||
Updating the field on every pull-request event would assign dates to existing work after edits, pushes, or reopenings. Replacing an existing date would also discard a manually planned date or a date recorded by an earlier pull request.
|
||||
|
||||
## Decision
|
||||
|
||||
The Issue lifecycle workflow initializes `Start date` only for `pull_request.opened`. It reads the pull request's live body, retains every same-repository reference that resolves to an Issue, converts `created_at` to a calendar date in the configured Project time zone, ensures the Issue is a Project item, and writes the configured Date field only when the current value is empty.
|
||||
|
||||
The configuration names the Project field and time zone. Missing configuration fails when the policy module loads; a missing field, a non-Date field, an invalid timestamp, or a failed API request fails the workflow at the first relevant pull request.
|
||||
|
||||
[Event-directed PR review status commands](2026-08-10-event-directed-pr-review-status.md) continue to own Status transitions. Date initialization includes resolving and informational Issue references, runs for Draft and automated pull requests, and does not depend on PR policy enforcement.
|
||||
|
||||
## Verification
|
||||
|
||||
[Issue-management tests](../../../../.github/issue-management/policy.test.mjs) cover the Shanghai date boundary, opened-only dispatch, all retained Issue references, empty-value writes, existing-value preservation, missing Project items, invalid field configuration, and the GraphQL mutation variables. [Workflow tests](../../../../scripts/ci-workflow.spec.ts) require the `pull_request.opened` subscription.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Use a built-in Project workflow.** The built-in workflows own fixed Project item and Status transitions; the repository workflow already owns authenticated GraphQL mutations and can supply the PR creation date.
|
||||
|
||||
**Process every subscribed PR event or run a reconciler.** Later events would fill dates for existing pull requests and references added after creation, but they would make the field a repair projection instead of a record created with the pull request and would add repeated Project reads.
|
||||
|
||||
**Update only resolving Issue references.** Informational references also identify Issues whose implementation work begins with the pull request, so the date initializer uses the existing all-reference set while Status transitions retain resolving-only semantics.
|
||||
|
||||
**Overwrite an existing date.** A later pull request must not replace a manual plan or the date written for earlier work, so the mutation follows an empty-value read.
|
||||
|
||||
## Consequences
|
||||
|
||||
Only pull requests opened after the workflow ships initialize dates. References added after creation and existing open pull requests remain unchanged, and the workflow does not scan existing Project items or pull requests.
|
||||
|
||||
The empty-value read makes retries idempotent in ordinary operation. ProjectV2 has no conditional field update, so simultaneous pull requests that reference the same empty Issue can both write; per-PR concurrency does not serialize that Issue, and the last mutation can win.
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
# Agent Note: 在 PR 创建时设置 Issue 开始日期
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-31-pr-opened-issue-start-dates.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
Issue Project 使用 `Start date` 字段记录已规划工作的开始日期,但加入或关联 Issue 不会提供日期值。PR 可以同时标识它所解决的 Issue 和提供相关实现上下文的 Issue;两种关系都表示仓库工作已经开始。
|
||||
|
||||
如果每个 PR 事件都更新该字段,编辑、推送或重新打开 PR 会为已有工作补上日期。覆盖已有日期还会丢弃人工规划的日期或较早 PR 记录的日期。
|
||||
|
||||
## 决策
|
||||
|
||||
Issue 生命周期工作流仅在 `pull_request.opened` 时初始化 `Start date`。工作流读取 PR 的实时正文,保留每个能解析为 Issue 的同仓库引用,把 `created_at` 按配置的 Project 时区转换为日历日期,确保 Issue 是 Project item,并仅在当前值为空时写入配置的 Date 字段。
|
||||
|
||||
配置指定 Project 字段和时区。配置缺失会在策略模块加载时失败;字段缺失、字段不是 Date 类型、时间戳无效或 API 请求失败会让首个相关 PR 的工作流失败。
|
||||
|
||||
[由事件直接指定的 PR 评审状态命令](2026-08-10-event-directed-pr-review-status.zh.md)继续负责 Status 转换。日期初始化同时包含解决型和信息型 Issue 引用,对 Draft PR 和自动化 PR 同样运行,也不依赖 PR 策略检查是否生效。
|
||||
|
||||
## 验证
|
||||
|
||||
[Issue 管理测试](../../../../.github/issue-management/policy.test.mjs)覆盖上海时区日期边界、仅 opened 分派、全部保留的 Issue 引用、空值写入、已有值保留、Project item 缺失、字段配置无效和 GraphQL mutation 变量。[工作流测试](../../../../scripts/ci-workflow.spec.ts)要求保留 `pull_request.opened` 订阅。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**使用 Project 内置工作流。** 内置工作流负责固定的 Project item 和 Status 转换;仓库工作流已经负责经过身份验证的 GraphQL mutation,并且能够提供 PR 创建日期。
|
||||
|
||||
**处理每个已订阅 PR 事件或运行协调器。** 后续事件可以为已有 PR 和创建后新增的引用补上日期,但这会让该字段成为修复型投影,而不是随 PR 创建的记录,并且会增加重复 Project 读取。
|
||||
|
||||
**仅更新解决型 Issue 引用。** 信息型引用同样标识随该 PR 开始实现工作的 Issue,因此日期初始化使用现有的全部引用集合,Status 转换仍只处理解决型引用。
|
||||
|
||||
**覆盖已有日期。** 后续 PR 不得替换人工计划或为较早工作写入的日期,因此 mutation 先读取并只处理空值。
|
||||
|
||||
## 后果
|
||||
|
||||
只有工作流发布后新建的 PR 会初始化日期。创建后新增的引用和现有开放 PR 保持不变,工作流不会扫描已有 Project item 或 PR。
|
||||
|
||||
空值读取使重试在通常情况下保持幂等。ProjectV2 没有条件字段更新,因此同时引用同一个空日期 Issue 的 PR 可能都会写入;按 PR 设置的并发控制不会串行化该 Issue,最后一次 mutation 可能胜出。
|
||||
|
|
@ -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/process/2026-08-31-serial-windows-notices-timeout-budget.md
|
||||
2026-08-31-serial-windows-notices-timeout-budget.md: 184004122bf8050fa00038b7c57ae680748463f8
|
||||
2026-08-31-serial-windows-notices-timeout-budget.zh.md: 4fc00ed25cc24ef76e43a0099a362b8be0e59e3e
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
# Agent Note: serial-windows notices timeout budget and generator store-scan cost
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-31-serial-windows-notices-timeout-budget.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The `serial / windows (self-hosted standby)` master lane failed its `test:coverage` gate four times in a week (runs 33333033178, 33311481884, 33352293522, 33353113100), always on the same case: `scripts/gen-third-party-notices.spec.ts > THIRD_PARTY_NOTICES.md > matches what the generator produces from the current manifests`, with `Error: Test timed out in 5000ms`. Measured test wall times on the shared Windows host were 4149–8853 ms against Vitest's default 5000 ms per-test budget. All other 26 cases in the file finished in 0–3 ms, and the passing run two hours later (33360033028) had the same code green.
|
||||
|
||||
The lane runs the complete unsharded Windows gate inventory serially with `DSH_COVERAGE_MAX_WORKERS=1`, so `render()` regenerates `THIRD_PARTY_NOTICES.md` from the workspace manifests and the installed pnpm store on a host shared by 32 runners. The cold path is dominated by `workspaceLinkedManifest`, which re-ran `loadWorkspaceManifests()` — a glob plus reads and JSON-parses of every workspace `package.json` — once per cache-missing external dependency name: 130 names × 270 manifests ≈ 35k file operations, on top of a `.pnpm` store scan per name. Under v8 coverage instrumentation and shared-host I/O contention that crossed the 5 s default.
|
||||
|
||||
The lane also had no `DSH_COVERAGE_TEST_TIMEOUT_MS`, unlike the pull-request `windows-coverage` lane ([ci.yml](../../../../.github/workflows/ci.yml)) which grants 90000 ms, so the serial reference ran the same coverage inventory at the strictest budget of any lane.
|
||||
|
||||
## Decision
|
||||
|
||||
Two changes:
|
||||
|
||||
1. [scripts/gen-third-party-notices.ts](../../../../scripts/gen-third-party-notices.ts) loads the workspace manifests once in `render()` and threads the map through `collectNpmDeps` → `installedMetadata` → `installedManifest` → `workspaceLinkedManifest` instead of reloading it per external dependency name. The cold `render()` wall time on the same checkout fell from ~893 ms to ~86 ms with byte-identical output (verified by diffing the rendered documents before and after the change).
|
||||
|
||||
2. [ci-master.yml](../../../../.github/workflows/ci-master.yml) `serial-windows` step "Run complete unsharded Windows gate inventory serially" gains `DSH_COVERAGE_TEST_TIMEOUT_MS: '90000'`, matching the pull-request `windows-coverage` lane budget. This extends the per-test, expect.poll, and hook budget mechanism defined by [the Windows lane hook and Lefthook budget note](../testing/2026-08-29-windows-lane-hook-and-lefthook-budget.md) to a second lane; that note records which lanes set the env. `scripts/ci-workflow.spec.ts` pins this env with a `toMatchObject` assertion; removing the env turns the spec red (negative control exercised).
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Raise the lane budget only** - rejected as the sole fix: it would mask the O(names × manifests) reload for every lane that runs the generator, including the pre-commit hook and the standalone `--check` path.
|
||||
- **Module-level cache for `loadWorkspaceManifests()`** - rejected in favor of explicit threading, which keeps the single-load contract visible at the call site and avoids a second hidden cache next to `workspaceLinkedManifestCache`.
|
||||
|
||||
## Consequences
|
||||
|
||||
The generator resolves installed metadata from one manifest load per `render()` call, and clears the name-keyed linked-manifest cache at the start of each call so the cache cannot outlive the map it was resolved from. The serial-windows lane runs the coverage inventory at the same 90000 ms per-test budget as the pull-request coverage lane. `THIRD_PARTY_NOTICES.md` bytes are unchanged; the freshness spec still compares `render()` against the committed document.
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
# Agent Note:serial-windows 的 notices 超时预算与 generator store 扫描成本
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-31-serial-windows-notices-timeout-budget.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
`serial / windows (self-hosted standby)` master lane 一周内四次失败在 `test:coverage` gate(run 33333033178、33311481884、33352293522、33353113100),失败用例每次都相同:`scripts/gen-third-party-notices.spec.ts > THIRD_PARTY_NOTICES.md > matches what the generator produces from the current manifests`,报 `Error: Test timed out in 5000ms`。共享 Windows 主机上该用例实测 4149–8853 ms,超出 Vitest 默认的 5000 ms 单测预算。文件其余 26 个用例全部 0–3 ms 通过,两小时后的 passing run(33360033028)用同一份代码全绿。
|
||||
|
||||
该 lane 以 `DSH_COVERAGE_MAX_WORKERS=1` 串行跑完整的无分片 Windows gate 清单,`render()` 要从 workspace manifest 和已安装的 pnpm store 全量重生成 `THIRD_PARTY_NOTICES.md`,而主机被 32 个 runner 共享。冷路径的代价集中在 `workspaceLinkedManifest`:它对每个未缓存的外部依赖名重跑一遍 `loadWorkspaceManifests()`——glob 并读取、解析全部 workspace `package.json`——即 130 名 × 270 manifest ≈ 3.5 万次文件操作,另加每个名字一次 `.pnpm` store 扫描。叠加 v8 覆盖率插桩与共享主机 I/O 争抢后越过 5 秒默认值。
|
||||
|
||||
该 lane 还没有 `DSH_COVERAGE_TEST_TIMEOUT_MS`,而 pull-request 的 `windows-coverage` lane([ci.yml](../../../../.github/workflows/ci.yml))给的是 90000 ms——于是这条 serial 参考 lane 用全仓库最紧的预算跑同一份 coverage 清单。
|
||||
|
||||
## Decision
|
||||
|
||||
两处改动:
|
||||
|
||||
1. [scripts/gen-third-party-notices.ts](../../../../scripts/gen-third-party-notices.ts) 在 `render()` 里只加载一次 workspace manifest,把 map 沿 `collectNpmDeps` → `installedMetadata` → `installedManifest` → `workspaceLinkedManifest` 显式传递,不再按外部依赖名逐个重载。同一 checkout 下冷 `render()` 墙钟从约 893 ms 降到约 86 ms,输出逐字节一致(改动前后渲染结果 diff 验证)。
|
||||
|
||||
2. [ci-master.yml](../../../../.github/workflows/ci-master.yml) `serial-windows` 的 "Run complete unsharded Windows gate inventory serially" 步骤增加 `DSH_COVERAGE_TEST_TIMEOUT_MS: '90000'`,与 pull-request `windows-coverage` lane 对齐。这是把 [Windows 覆盖率 lane 的 hook 预算与 Lefthook 套件预算 note](../testing/2026-08-29-windows-lane-hook-and-lefthook-budget.zh.md) 定义的 per-test、expect.poll 与 hook 预算机制扩展到第二个 lane;该 note 记录了哪些 lane 设置此 env。`scripts/ci-workflow.spec.ts` 用 `toMatchObject` 断言钉住该 env;删掉 env 会让 spec 变红(已做负例验证)。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **只放宽 lane 预算** - 否决作为唯一修复:会掩盖所有运行 generator 的 lane 上的 O(名×manifest) 重载成本,包括 pre-commit hook 与独立 `--check` 路径。
|
||||
- **给 `loadWorkspaceManifests()` 加模块级缓存** - 否决,改用显式传递:把「单次加载」契约留在调用点可见,避免在 `workspaceLinkedManifestCache` 之外再加一层隐藏缓存。
|
||||
|
||||
## Consequences
|
||||
|
||||
generator 每次 `render()` 调用只加载一次 manifest 来解析已安装元数据,并在调用开头清空按名字作键的 linked-manifest 缓存,使缓存不会活过它解析自的那份 map。serial-windows lane 与 pull-request coverage lane 一样按 90000 ms 单测预算跑 coverage 清单。`THIRD_PARTY_NOTICES.md` 字节不变;新鲜度 spec 仍把 `render()` 与已提交文档逐字节比较。
|
||||
|
|
@ -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/process/2026-08-31-windows-coverage-flaky-test-budgets.md
|
||||
2026-08-31-windows-coverage-flaky-test-budgets.md: 475bd79adf2721202d860d3c7bc86e1e4b68d6c2
|
||||
2026-08-31-windows-coverage-flaky-test-budgets.zh.md: 2a16ba8c6ea983d7ad457eb579c9e5e367cf4d68
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
# Agent Note: deterministic assertions and dispose budgets for the Windows coverage lane
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-31-windows-coverage-flaky-test-budgets.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The `windows node 24 / coverage` lane is excluded from `all-checks-passed.needs` because it is unstable, not because its verdict is unimportant. The instability is a set of timing-sensitive tests that pass on a quiet runner and fail on a contended one. Two failure shapes recur across many PRs (3184, 3185, 3179, 3181) and are unrelated to the PR diffs that trigger them:
|
||||
|
||||
1. `packages/session/session-projection-cache/tests/cache.spec.ts` — `SessionProjectionCache` writes are fail-soft and fire-and-forget (the event listener calls `void flushSoft(...)`, `coldSnapshot` calls `void this.put(...)`). Six tests asserted the durable outcome after a fixed `settle()` of 40 ms. On a contended runner the write does not drain within 40 ms, so the mock assertion fails with `AssertionError: expected "Mock" to be called with arguments: [ StringContaining{…} ]` at the `expect(warn).toHaveBeenCalledWith(...)` lines, or the stored-row assertion reads a stale cut. These are the two cache.spec cases that fail on every affected run.
|
||||
2. `packages/sdk/client/tests/sdk-client.spec.ts` and `packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts` — dispose ladder tests launch a real child process and pass tight confirmation budgets (`disposeGraceMs: 100`–`300`). On a contended runner the child's exit edge after SIGKILL can arrive after the budget, so `close()` rejects with `runtime process did not exit within 100ms after SIGKILL` even though the child was reaped correctly. The product defaults are `disposeEofGraceMs: 6000` / `disposeGraceMs: 3000`; the tight values were test-only speed choices that misreport slow reaps as dispose failures.
|
||||
|
||||
A separate, historical coverage gap in `packages/workflow/workflow-worker-thread` (host.ts/index.ts below the per-file 100% gate) was tracked as part of this lane's instability. Investigation in this change found that the local reproduction was a DSH-session environment artifact, not a code defect: the session exports `TSX_TSCONFIG_PATH` pointing at the DSH staging checkout's tsconfig, which redirects the tsx-in-worker resolution of workspace bare specifiers to the staging copy and drops their named exports. With `TSX_TSCONFIG_PATH` unset, `workflow-worker-thread.spec.ts` passes 54/54. The Windows-side reports of that gap predate the ReFS clone install (#3342) and have not recurred since; any recurrence needs Windows-side per-line uncovered lists before it can be attributed.
|
||||
|
||||
## Decision
|
||||
|
||||
Replace every fixed-wait assertion in cache.spec.ts with `vi.waitFor` polling of the observable outcome, with a 5 s timeout (the same pattern the file already used for the cold-read write-back cases since `7746ed64f0`). The two mock-assertion cases poll for the warning call itself:
|
||||
|
||||
```ts ignore-check
|
||||
await vi.waitFor(() => {
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('turn/end write for "fail-soft" failed'))
|
||||
}, { timeout: 5_000 })
|
||||
```
|
||||
|
||||
The polling assertion still fails when the condition never becomes true — the negative case (an impossible string) times out and fails the test — so the fail-soft contract stays enforced.
|
||||
|
||||
For the dispose-ladder tests, pass the product-default budgets instead of the tight test-only values:
|
||||
|
||||
- `sdk-client.spec.ts`: `disposeGraceMs` `100` → `3_000` (bounds-profile case), `1_000` → `3_000` (SIGTERM ladder), `300` → `3_000` (SIGKILL escalation).
|
||||
- `subagent-dsh-sdk.spec.ts`: the concurrent diagnostic-isolation case uses `DEFAULT_SHUTDOWN_TIMEOUT_MS` / `DEFAULT_DISPOSE_EOF_GRACE_MS` / `DEFAULT_DISPOSE_GRACE_MS` from `run.ts` instead of `100`/`200`/`200`.
|
||||
|
||||
The dispose.spec.ts negative cases (`disposeGraceMs: 10` with a fake child that never exits) still verify that a truly stuck child fails the ladder within its budget; only the real-child tests with overly tight budgets were widened.
|
||||
|
||||
## Verification
|
||||
|
||||
- cache.spec.ts: 17/17 pass locally; negative case (impossible warning string) fails via the `vi.waitFor` timeout.
|
||||
- sdk-client.spec.ts: 42/42 pass locally; dispose.spec.ts 16/16 pass (the 10 ms refused/accepted negative cases still fail correctly).
|
||||
- subagent-dsh-sdk.spec.ts: 55/55 pass locally.
|
||||
- workflow-worker-thread.spec.ts: 54/54 pass locally with `TSX_TSCONFIG_PATH` unset — no code change made for the historical coverage gap.
|
||||
- CI on this PR: the windows coverage lane should stop failing on these tests.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep the fixed settle windows and rerun flaky lanes.** Reruns eventually pass, but every affected PR pays a re-run cycle and the lane stays excluded from `all-checks-passed.needs`. The polled assertion costs nothing when the write is prompt and removes the timing dependency entirely, matching the file's existing `vi.waitFor` pattern from `7746ed64f0`.
|
||||
|
||||
**Keep the tight dispose budgets and treat SIGKILL timeouts as runner faults.** A truly stuck child must still fail the ladder, which the fake-child negative cases in dispose.spec.ts already cover at 10 ms. The real-child cases were widened to the product defaults because they measure the ladder's escalation, not a performance bound, and a contended runner's exit edge is not a code defect.
|
||||
|
||||
## Consequences
|
||||
|
||||
The windows coverage lane keeps its per-file 100% gate while its tests no longer depend on a 40 ms wall-clock window or a 100–300 ms SIGKILL confirmation. The two cache.spec mock-assertion cases and the concurrent subagent-dsh-sdk case stop failing under runner contention, so the lane's flake rate drops without weakening any assertion: every polled condition still fails on timeout, and every dispose negative case still bounds a stuck child.
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
# Agent Note:Windows coverage lane 的确定性断言与 dispose 预算
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-31-windows-coverage-flaky-test-budgets.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
`windows node 24 / coverage` lane 不在 `all-checks-passed.needs` 里,是因为它不稳定,而不是它的结论不重要。不稳定来自一组对时序敏感的测试:在空闲 runner 上通过,在争抢的 runner 上失败。两种失败形态在多个 PR(3184、3185、3179、3181)反复出现,与触发它们的 PR diff 无关:
|
||||
|
||||
1. `packages/session/session-projection-cache/tests/cache.spec.ts` —— `SessionProjectionCache` 的写入是 fail-soft 且 fire-and-forget(事件监听器调 `void flushSoft(...)`,`coldSnapshot` 调 `void this.put(...)`)。六个测试在固定 `settle()` 40 ms 后断言持久化结果。争抢的 runner 上写入 40 ms 内没有排空,mock 断言以 `AssertionError: expected "Mock" to be called with arguments: [ StringContaining{…} ]` 失败(在 `expect(warn).toHaveBeenCalledWith(...)` 行),或 stored-row 断言读到陈旧 cut。这正是每次受影响 run 都失败的 cache.spec 两个用例。
|
||||
2. `packages/sdk/client/tests/sdk-client.spec.ts` 与 `packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts` —— dispose 梯子测试启动真实子进程并传入紧的确认预算(`disposeGraceMs: 100`–`300`)。争抢的 runner 上 SIGKILL 后子进程的退出边缘可能晚于预算到达,于是 `close()` 以 `runtime process did not exit within 100ms after SIGKILL` reject,即使子进程已被正确回收。产品默认是 `disposeEofGraceMs: 6000` / `disposeGraceMs: 3000`;紧值是测试只为提速的选择,却把慢回收误报成 dispose 失败。
|
||||
|
||||
另有一个历史性的覆盖率缺口在 `packages/workflow/workflow-worker-thread`(host.ts/index.ts 低于 per-file 100% 门禁),曾被当作本 lane 不稳定的一部分跟踪。本次调查发现本机复现是 DSH 会话的环境假象而非代码缺陷:会话导出了指向 DSH staging checkout tsconfig 的 `TSX_TSCONFIG_PATH`,把 tsx-in-worker 对 workspace bare specifier 的解析重定向到 staging 副本并丢掉了 named exports。unset `TSX_TSCONFIG_PATH` 后 `workflow-worker-thread.spec.ts` 54/54 通过。Windows 侧对该缺口的报告早于 ReFS clone 安装(#3342),此后未再出现;任何复发都需要 Windows 侧逐行未覆盖清单才能归因。
|
||||
|
||||
## Decision
|
||||
|
||||
把 cache.spec.ts 里每个固定等待断言改成用 `vi.waitFor` 轮询可观察结果,超时 5 s(与该文件自 `7746ed64f0` 起在 cold-read write-back 用例中使用的模式一致)。两个 mock 断言用例轮询警告调用本身:
|
||||
|
||||
```ts ignore-check
|
||||
await vi.waitFor(() => {
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('turn/end write for "fail-soft" failed'))
|
||||
}, { timeout: 5_000 })
|
||||
```
|
||||
|
||||
轮询断言在条件永远不成立时仍会失败——负例(不可能的字符串)超时并使测试失败——所以 fail-soft 契约仍被强制。
|
||||
|
||||
对 dispose 梯子测试,改用产品默认预算,去掉只属于测试的紧值:
|
||||
|
||||
- `sdk-client.spec.ts`:`disposeGraceMs` `100` → `3_000`(bounds-profile 用例)、`1_000` → `3_000`(SIGTERM 梯子)、`300` → `3_000`(SIGKILL 升级)。
|
||||
- `subagent-dsh-sdk.spec.ts`:并发诊断隔离用例改用 `run.ts` 的 `DEFAULT_SHUTDOWN_TIMEOUT_MS` / `DEFAULT_DISPOSE_EOF_GRACE_MS` / `DEFAULT_DISPOSE_GRACE_MS`,而不是 `100`/`200`/`200`。
|
||||
|
||||
dispose.spec.ts 的负例(`disposeGraceMs: 10`,fake child 永不退出)仍验证真正卡住的子进程会在预算内使梯子失败;只有真实子进程用例的过紧预算被放宽。
|
||||
|
||||
## Verification
|
||||
|
||||
- cache.spec.ts:本地 17/17 通过;负例(不可能的警告字符串)经 `vi.waitFor` 超时失败。
|
||||
- sdk-client.spec.ts:本地 42/42 通过;dispose.spec.ts 16/16 通过(10 ms refused/accepted 负例仍正确失败)。
|
||||
- subagent-dsh-sdk.spec.ts:本地 55/55 通过。
|
||||
- workflow-worker-thread.spec.ts:unset `TSX_TSCONFIG_PATH` 后本地 54/54 通过——历史覆盖率缺口未改代码。
|
||||
- CI on this PR:windows coverage lane 应不再因这些测试失败。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**保留固定 settle 窗口并重跑 flaky lane。** 重跑最终会通过,但每个受影响的 PR 都要付出一次重跑周期,lane 仍被排除在 `all-checks-passed.needs` 之外。轮询断言在写入及时时零成本,并完全消除时序依赖,与该文件自 `7746ed64f0` 起已有的 `vi.waitFor` 模式一致。
|
||||
|
||||
**保留紧 dispose 预算并把 SIGKILL 超时当作 runner 故障。** 真正卡住的子进程仍必须让梯子失败——dispose.spec.ts 的 fake-child 负例已用 10 ms 覆盖。真实子进程用例放宽到产品默认,因为它们测的是梯子的升级路径而不是性能上限,争抢 runner 上的退出边缘不是代码缺陷。
|
||||
|
||||
## Consequences
|
||||
|
||||
windows coverage lane 保留 per-file 100% 门禁,而其测试不再依赖 40 ms 墙钟窗口或 100–300 ms 的 SIGKILL 确认。cache.spec 两个 mock 断言用例与 subagent-dsh-sdk 并发用例在 runner 争抢下不再失败,lane 的 flake 率下降而不削弱任何断言:每个轮询条件超时仍失败,每个 dispose 负例仍约束卡住的子进程。
|
||||
|
|
@ -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/testing/2026-08-29-windows-lane-hook-and-lefthook-budget.md
|
||||
2026-08-29-windows-lane-hook-and-lefthook-budget.md: 6886e3ad4958d20a88a66df6a9e02f5a60a36a6a
|
||||
2026-08-29-windows-lane-hook-and-lefthook-budget.zh.md: 56c1e625d92f01e24f2268deb5d03040278da6af
|
||||
2026-08-29-windows-lane-hook-and-lefthook-budget.md: 40ebba25e459abd6d9ae755f831e703aa826ae39
|
||||
2026-08-29-windows-lane-hook-and-lefthook-budget.zh.md: c14be5da098920dbb7360c01bbb94f0bc22f700b
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ A `git` or `node` spawn spike on the shared-volume runners no longer decides eit
|
|||
|
||||
Both budgets widen what counts as an acceptable duration, so a real slowdown into tens of seconds now passes where the previous ceilings would have caught it. That detection is traded away deliberately: those ceilings were firing on host contention rather than on regressions.
|
||||
|
||||
The hook change applies wherever `DSH_COVERAGE_TEST_TIMEOUT_MS` is set, which today is the Windows coverage lane alone. Lanes that leave it unset keep every Vitest default, including the 10 s hook budget.
|
||||
The hook change applies wherever `DSH_COVERAGE_TEST_TIMEOUT_MS` is set: the Windows coverage lane in [ci.yml](../../../../.github/workflows/ci.yml) and the `serial-windows` master standby in [ci-master.yml](../../../../.github/workflows/ci-master.yml) ([the serial-windows notices timeout note](../process/2026-08-31-serial-windows-notices-timeout-budget.md) records the second lane's adoption). Lanes that leave it unset keep every Vitest default, including the 10 s hook budget.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ Lefthook 套件取 `{ timeout: 90_000 }`,与 [`.github/workflows/ci.yml`](../.
|
|||
|
||||
两份预算都放宽了「多长算可接受」,因此一个退化到几十秒的真实变慢现在会通过,而此前的上限会拦住它。这项检测能力是有意换掉的:那些上限触发的是宿主机争抢,不是回归。
|
||||
|
||||
hook 的改动在所有设置了 `DSH_COVERAGE_TEST_TIMEOUT_MS` 的地方生效,目前仅 Windows 覆盖率 lane 一处。不设置它的 lane 保持全部 Vitest 默认值,包括 10 秒的 hook 预算。
|
||||
hook 的改动在所有设置了 `DSH_COVERAGE_TEST_TIMEOUT_MS` 的地方生效:[ci.yml](../../../../.github/workflows/ci.yml) 的 Windows 覆盖率 lane,以及 [ci-master.yml](../../../../.github/workflows/ci-master.yml) 的 `serial-windows` master standby([serial-windows notices 超时 note](../process/2026-08-31-serial-windows-notices-timeout-budget.zh.md) 记录了第二个 lane 的采用)。不设置它的 lane 保持全部 Vitest 默认值,包括 10 秒的 hook 预算。
|
||||
|
||||
## 备选方案
|
||||
|
||||
|
|
|
|||
2
.github/issue-management/config.json
vendored
2
.github/issue-management/config.json
vendored
|
|
@ -5,6 +5,8 @@
|
|||
"projectTitle": "DSH Issue Management",
|
||||
"lifecycleActor": "dsh-issue-management",
|
||||
"priorityField": "Priority",
|
||||
"startDateField": "Start date",
|
||||
"projectTimeZone": "Asia/Shanghai",
|
||||
"allowUnassignedOwner": true,
|
||||
"statuses": [
|
||||
"Inbox",
|
||||
|
|
|
|||
118
.github/issue-management/policy.mjs
vendored
118
.github/issue-management/policy.mjs
vendored
|
|
@ -52,6 +52,13 @@ for (const status of ['In progress', 'In review']) {
|
|||
if (typeof config.lifecycleActor !== 'string' || !config.lifecycleActor) {
|
||||
throw new Error('config.lifecycleActor 未设置')
|
||||
}
|
||||
if (typeof config.startDateField !== 'string' || !config.startDateField) {
|
||||
throw new Error('config.startDateField 未设置')
|
||||
}
|
||||
if (typeof config.projectTimeZone !== 'string' || !config.projectTimeZone) {
|
||||
throw new Error('config.projectTimeZone 未设置')
|
||||
}
|
||||
Intl.DateTimeFormat('en-US', { timeZone: config.projectTimeZone })
|
||||
|
||||
/**
|
||||
* Return Markdown outside balanced details elements.
|
||||
|
|
@ -215,6 +222,29 @@ export function nextResolvingIssueStatus(currentStatus, command, currentStatusAc
|
|||
return currentIndex >= 0 && currentIndex < targetIndex ? target : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a GitHub timestamp to a Project date in one configured time zone.
|
||||
* @param {string} timestamp ISO timestamp.
|
||||
* @param {string} timeZone IANA time-zone name.
|
||||
* @returns {string} Calendar date in YYYY-MM-DD form.
|
||||
*/
|
||||
export function projectDate(timestamp, timeZone = config.projectTimeZone) {
|
||||
const instant = new Date(timestamp)
|
||||
if (Number.isNaN(instant.getTime())) throw new Error(`无效的 PR 创建时间:${timestamp}`)
|
||||
const parts = Object.fromEntries(
|
||||
new Intl.DateTimeFormat('en-US', {
|
||||
timeZone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
})
|
||||
.formatToParts(instant)
|
||||
.filter((part) => part.type !== 'literal')
|
||||
.map((part) => [part.type, part.value]),
|
||||
)
|
||||
return `${parts.year}-${parts.month}-${parts.day}`
|
||||
}
|
||||
|
||||
function stripIgnoredMarkdown(body) {
|
||||
const lines = body.replace(/<!--[\s\S]*?-->/g, '').split(/\r?\n/)
|
||||
const kept = []
|
||||
|
|
@ -437,7 +467,7 @@ async function issueSnapshot(number, status = undefined) {
|
|||
}
|
||||
}
|
||||
|
||||
async function projectContext(number, includeStatusActor = false) {
|
||||
async function projectContext(number, includeStatusActor = false, includeStartDate = false) {
|
||||
const data = await graphql(
|
||||
`query(
|
||||
$organization: String!
|
||||
|
|
@ -445,6 +475,8 @@ async function projectContext(number, includeStatusActor = false) {
|
|||
$number: Int!
|
||||
$project: Int!
|
||||
$includeStatusActor: Boolean!
|
||||
$includeStartDate: Boolean!
|
||||
$startDateField: String!
|
||||
) {
|
||||
organization(login: $organization) {
|
||||
projectV2(number: $project) {
|
||||
|
|
@ -452,7 +484,8 @@ async function projectContext(number, includeStatusActor = false) {
|
|||
title
|
||||
fields(first: 50) {
|
||||
nodes {
|
||||
... on ProjectV2SingleSelectField { id name options { id name } }
|
||||
... on ProjectV2Field { id name dataType }
|
||||
... on ProjectV2SingleSelectField { id name dataType options { id name } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -477,6 +510,10 @@ async function projectContext(number, includeStatusActor = false) {
|
|||
fieldValueByName(name: "Status") {
|
||||
... on ProjectV2ItemFieldSingleSelectValue { name optionId }
|
||||
}
|
||||
startDateValue: fieldValueByName(name: $startDateField)
|
||||
@include(if: $includeStartDate) {
|
||||
... on ProjectV2ItemFieldDateValue { date }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -488,6 +525,8 @@ async function projectContext(number, includeStatusActor = false) {
|
|||
number,
|
||||
project: config.projectNumber,
|
||||
includeStatusActor,
|
||||
includeStartDate,
|
||||
startDateField: config.startDateField,
|
||||
},
|
||||
)
|
||||
const project = data.organization?.projectV2
|
||||
|
|
@ -496,15 +535,24 @@ async function projectContext(number, includeStatusActor = false) {
|
|||
if (!issue) throw new Error(`#${number} 不存在`)
|
||||
const statusField = project.fields.nodes.find((field) => field?.name === 'Status')
|
||||
if (!statusField) throw new Error('Project 缺少 Status 字段')
|
||||
const startDateField = includeStartDate
|
||||
? project.fields.nodes.find((field) => field?.name === config.startDateField)
|
||||
: null
|
||||
if (includeStartDate && !startDateField) {
|
||||
throw new Error(`Project 缺少 ${config.startDateField} 字段`)
|
||||
}
|
||||
if (startDateField && startDateField.dataType !== 'DATE') {
|
||||
throw new Error(`Project ${config.startDateField} 字段必须为 Date`)
|
||||
}
|
||||
const item = issue.projectItems.nodes.find((candidate) => candidate.project.id === project.id)
|
||||
const latestStatusEvent = issue.timelineItems?.nodes
|
||||
?.filter((event) => event?.project?.id === project.id)
|
||||
.at(-1)
|
||||
const statusActor =
|
||||
latestStatusEvent?.status === item?.fieldValueByName?.name
|
||||
latestStatusEvent && latestStatusEvent.status === item?.fieldValueByName?.name
|
||||
? (latestStatusEvent.actor?.login ?? null)
|
||||
: null
|
||||
return { project, issue, statusField, item, statusActor }
|
||||
return { project, issue, statusField, startDateField, item, statusActor }
|
||||
}
|
||||
|
||||
async function projectStatus(number) {
|
||||
|
|
@ -512,8 +560,8 @@ async function projectStatus(number) {
|
|||
return context.item?.fieldValueByName?.name ?? null
|
||||
}
|
||||
|
||||
async function ensureProjectItem(number) {
|
||||
const context = await projectContext(number)
|
||||
async function ensureProjectItem(number, includeStartDate = false) {
|
||||
const context = await projectContext(number, false, includeStartDate)
|
||||
if (context.item) return context
|
||||
const data = await graphql(
|
||||
`mutation($projectId: ID!, $contentId: ID!) {
|
||||
|
|
@ -525,10 +573,58 @@ async function ensureProjectItem(number) {
|
|||
)
|
||||
return {
|
||||
...context,
|
||||
item: { id: data.addProjectV2ItemById.item.id, fieldValueByName: null },
|
||||
item: {
|
||||
id: data.addProjectV2ItemById.item.id,
|
||||
fieldValueByName: null,
|
||||
startDateValue: null,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize one Issue's Project Start date when it is empty.
|
||||
* @param {number} number Same-repository Issue number.
|
||||
* @param {string} date Date in YYYY-MM-DD form.
|
||||
* @returns {Promise<void>} Resolves after the conditional Project update.
|
||||
*/
|
||||
export async function initializeIssueStartDate(number, date) {
|
||||
const context = await ensureProjectItem(number, true)
|
||||
if (context.item.startDateValue?.date) return
|
||||
await graphql(
|
||||
`mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $date: Date!) {
|
||||
updateProjectV2ItemFieldValue(input: {
|
||||
projectId: $projectId,
|
||||
itemId: $itemId,
|
||||
fieldId: $fieldId,
|
||||
value: {date: $date}
|
||||
}) { projectV2Item { id } }
|
||||
}`,
|
||||
{
|
||||
projectId: context.project.id,
|
||||
itemId: context.item.id,
|
||||
fieldId: context.startDateField.id,
|
||||
date,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize every referenced Issue from a newly opened PR.
|
||||
* @param {{createdAt: string, references: {all: number[]}}} pull Pull-request snapshot.
|
||||
* @param {string} action Pull-request event action.
|
||||
* @param {(number: number, date: string) => Promise<void>} initialize Date writer.
|
||||
* @returns {Promise<void>} Resolves after all eligible Issues are processed.
|
||||
*/
|
||||
export async function initializePullRequestStartDates(
|
||||
pull,
|
||||
action,
|
||||
initialize = initializeIssueStartDate,
|
||||
) {
|
||||
if (action !== 'opened') return
|
||||
const date = projectDate(pull.createdAt)
|
||||
for (const number of pull.references.all) await initialize(number, date)
|
||||
}
|
||||
|
||||
async function updateStatus(context, status) {
|
||||
const option = context.statusField.options.find((candidate) => candidate.name === status)
|
||||
if (!option) throw new Error(`Status 不存在:${status}`)
|
||||
|
|
@ -631,7 +727,10 @@ async function pullRequestSnapshot(number) {
|
|||
|
||||
async function lifecyclePullRequestSnapshot(number) {
|
||||
const pull = await api(`/repos/${config.organization}/${config.repository}/pulls/${number}`)
|
||||
return resolvingReferencesSnapshot(number, pull)
|
||||
return {
|
||||
...(await resolvingReferencesSnapshot(number, pull)),
|
||||
createdAt: pull.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
async function transitionResolvingIssues(pull, command) {
|
||||
|
|
@ -683,6 +782,9 @@ async function runLifecycle(eventName, event) {
|
|||
if (!command) return
|
||||
const pull = await lifecyclePullRequestSnapshot(event.pull_request.number)
|
||||
await transitionResolvingIssues(pull, command)
|
||||
if (eventName === 'pull_request') {
|
||||
await initializePullRequestStartDates(pull, event.action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
139
.github/issue-management/policy.test.mjs
vendored
139
.github/issue-management/policy.test.mjs
vendored
|
|
@ -3,8 +3,11 @@ import test from 'node:test'
|
|||
|
||||
import {
|
||||
countVisibleUnits,
|
||||
initializeIssueStartDate,
|
||||
initializePullRequestStartDates,
|
||||
nextResolvingIssueStatus,
|
||||
parseReferences,
|
||||
projectDate,
|
||||
retainIssueReferences,
|
||||
resolvingIssueStatusCommand,
|
||||
requiresPullRequestPolicy,
|
||||
|
|
@ -13,6 +16,63 @@ import {
|
|||
validatePullRequest,
|
||||
} from './policy.mjs'
|
||||
|
||||
const projectGraphqlData = ({
|
||||
projectItem = true,
|
||||
startDate = null,
|
||||
startDateField = true,
|
||||
startDateType = 'DATE',
|
||||
} = {}) => ({
|
||||
organization: {
|
||||
projectV2: {
|
||||
id: 'project-id',
|
||||
title: 'DSH Issue Management',
|
||||
fields: {
|
||||
nodes: [
|
||||
{ id: 'status-field-id', name: 'Status', dataType: 'SINGLE_SELECT', options: [] },
|
||||
...(startDateField
|
||||
? [{ id: 'start-date-field-id', name: 'Start date', dataType: startDateType }]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
repository: {
|
||||
issue: {
|
||||
id: 'issue-id',
|
||||
projectItems: {
|
||||
nodes: projectItem
|
||||
? [
|
||||
{
|
||||
id: 'item-id',
|
||||
project: { id: 'project-id' },
|
||||
fieldValueByName: { name: 'Inbox', optionId: 'inbox-option-id' },
|
||||
startDateValue: startDate === null ? null : { date: startDate },
|
||||
},
|
||||
]
|
||||
: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const mockGraphql = (t, resolve) => {
|
||||
const requests = []
|
||||
const previousToken = process.env.GH_TOKEN
|
||||
process.env.GH_TOKEN = 'test-token'
|
||||
t.after(() => {
|
||||
if (previousToken === undefined) delete process.env.GH_TOKEN
|
||||
else process.env.GH_TOKEN = previousToken
|
||||
})
|
||||
t.mock.method(globalThis, 'fetch', async (url, options) => {
|
||||
assert.equal(url, 'https://api.github.com/graphql')
|
||||
assert.equal(options.headers.Authorization, 'Bearer test-token')
|
||||
const request = JSON.parse(options.body)
|
||||
requests.push(request)
|
||||
return Response.json({ data: resolve(request, requests.length - 1) })
|
||||
})
|
||||
return requests
|
||||
}
|
||||
|
||||
const withDetails = (summary) =>
|
||||
`${summary}\n\n<details><summary>验收与细节</summary>待补充。</details>`
|
||||
|
||||
|
|
@ -171,6 +231,85 @@ test('separates resolving and informational references', () => {
|
|||
)
|
||||
})
|
||||
|
||||
test('converts PR creation timestamps to Shanghai Project dates', () => {
|
||||
assert.equal(projectDate('2026-08-27T15:59:59Z', 'Asia/Shanghai'), '2026-08-27')
|
||||
assert.equal(projectDate('2026-08-27T16:00:00Z', 'Asia/Shanghai'), '2026-08-28')
|
||||
assert.throws(() => projectDate('invalid', 'Asia/Shanghai'), /无效的 PR 创建时间/)
|
||||
})
|
||||
|
||||
test('initializes every referenced Issue only for a PR opened event', async () => {
|
||||
const writes = []
|
||||
const pull = {
|
||||
createdAt: '2026-08-27T16:00:00Z',
|
||||
references: { all: [4, 7, 12] },
|
||||
}
|
||||
const initialize = async (number, date) => writes.push({ number, date })
|
||||
|
||||
await initializePullRequestStartDates(pull, 'opened', initialize)
|
||||
assert.deepEqual(writes, [
|
||||
{ number: 4, date: '2026-08-28' },
|
||||
{ number: 7, date: '2026-08-28' },
|
||||
{ number: 12, date: '2026-08-28' },
|
||||
])
|
||||
|
||||
for (const action of ['edited', 'synchronize', 'reopened']) {
|
||||
await initializePullRequestStartDates(pull, action, initialize)
|
||||
}
|
||||
assert.equal(writes.length, 3)
|
||||
})
|
||||
|
||||
test('writes an empty Project Start date with the configured field', async (t) => {
|
||||
const requests = mockGraphql(t, (request) => {
|
||||
if (request.query.includes('query(')) return projectGraphqlData()
|
||||
return { updateProjectV2ItemFieldValue: { projectV2Item: { id: 'item-id' } } }
|
||||
})
|
||||
|
||||
await initializeIssueStartDate(42, '2026-08-28')
|
||||
|
||||
assert.equal(requests.length, 2)
|
||||
assert.match(requests[1].query, /value: \{date: \$date\}/)
|
||||
assert.deepEqual(requests[1].variables, {
|
||||
projectId: 'project-id',
|
||||
itemId: 'item-id',
|
||||
fieldId: 'start-date-field-id',
|
||||
date: '2026-08-28',
|
||||
})
|
||||
})
|
||||
|
||||
test('preserves an existing Project Start date', async (t) => {
|
||||
const requests = mockGraphql(t, () => projectGraphqlData({ startDate: '2026-08-01' }))
|
||||
|
||||
await initializeIssueStartDate(42, '2026-08-28')
|
||||
|
||||
assert.equal(requests.length, 1)
|
||||
})
|
||||
|
||||
test('adds a referenced Issue to the Project before setting Start date', async (t) => {
|
||||
const requests = mockGraphql(t, (request) => {
|
||||
if (request.query.includes('query(')) return projectGraphqlData({ projectItem: false })
|
||||
if (request.query.includes('addProjectV2ItemById')) {
|
||||
return { addProjectV2ItemById: { item: { id: 'new-item-id' } } }
|
||||
}
|
||||
return { updateProjectV2ItemFieldValue: { projectV2Item: { id: 'new-item-id' } } }
|
||||
})
|
||||
|
||||
await initializeIssueStartDate(42, '2026-08-28')
|
||||
|
||||
assert.equal(requests.length, 3)
|
||||
assert.deepEqual(requests[1].variables, { projectId: 'project-id', contentId: 'issue-id' })
|
||||
assert.equal(requests[2].variables.itemId, 'new-item-id')
|
||||
})
|
||||
|
||||
test('rejects a missing or non-Date Start date field', async (t) => {
|
||||
let response = projectGraphqlData({ startDateField: false })
|
||||
const requests = mockGraphql(t, () => response)
|
||||
|
||||
await assert.rejects(initializeIssueStartDate(42, '2026-08-28'), /Project 缺少 Start date 字段/)
|
||||
response = projectGraphqlData({ startDateType: 'TEXT' })
|
||||
await assert.rejects(initializeIssueStartDate(42, '2026-08-28'), /Start date 字段必须为 Date/)
|
||||
assert.equal(requests.length, 2)
|
||||
})
|
||||
|
||||
test('does not treat pull request references as Issue associations', () => {
|
||||
const references = {
|
||||
all: [123, 1180, 1181],
|
||||
|
|
|
|||
1
.github/workflows/ci-master.yml
vendored
1
.github/workflows/ci-master.yml
vendored
|
|
@ -208,6 +208,7 @@ jobs:
|
|||
shell: pwsh
|
||||
env:
|
||||
DSH_COVERAGE_MAX_WORKERS: '1'
|
||||
DSH_COVERAGE_TEST_TIMEOUT_MS: '90000'
|
||||
DSH_GATE_CONCURRENCY: '1'
|
||||
DSH_PUBLINT_CONCURRENCY: '1'
|
||||
run: pnpm run check:ci:windows-complete
|
||||
|
|
|
|||
27
.github/workflows/ci.yml
vendored
27
.github/workflows/ci.yml
vendored
|
|
@ -46,6 +46,9 @@ jobs:
|
|||
name: node 24 / static
|
||||
env:
|
||||
DSH_GATE_CONCURRENCY: '8'
|
||||
# Stop the aggregate at the first blocking gate failure so a red run
|
||||
# does not keep burning enterprise runner time on the remaining gates.
|
||||
DSH_GATE_FAIL_FAST: '1'
|
||||
steps:
|
||||
# Fetch complete history so the archive gate can read the trusted PR base from a reused shallow checkout.
|
||||
- uses: actions/checkout@v6
|
||||
|
|
@ -102,6 +105,9 @@ jobs:
|
|||
DSH_COVERAGE_MAX_WORKERS: '6'
|
||||
DSH_COVERAGE_PARTITIONS: '4'
|
||||
DSH_GATE_CONCURRENCY: '3'
|
||||
# A gate failure aborts the sibling gate instead of waiting out its
|
||||
# multi-minute instrumented run.
|
||||
DSH_GATE_FAIL_FAST: '1'
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
|
|
@ -164,6 +170,10 @@ jobs:
|
|||
DSH_OXLINT_THREADS: '8'
|
||||
DSH_PUBLINT_CONCURRENCY: '8'
|
||||
DSH_WEB_SNAPSHOT_WORKERS: '6'
|
||||
# A failing gate aborts its running siblings: a failing build stops the
|
||||
# independent Node compatibility smoke, and a failing reader (e.g.
|
||||
# publint) stops the remaining artifact consumers.
|
||||
DSH_GATE_FAIL_FAST: '1'
|
||||
# Failover halves snapshot concurrency for the shared 64-core VM.
|
||||
DSH_SNAPSHOT_MAX_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '12' || '32' }}
|
||||
steps:
|
||||
|
|
@ -244,6 +254,9 @@ jobs:
|
|||
env:
|
||||
DSH_GATE_CONCURRENCY: ${{ matrix.gate_concurrency }}
|
||||
DSH_NODE_COMPAT_SKIP_TYPECHECK: '1'
|
||||
# A failed smoke aborts the remaining compatibility gates instead of
|
||||
# letting the build-backed legs run against an already-red aggregate.
|
||||
DSH_GATE_FAIL_FAST: '1'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
|
|
@ -425,6 +438,9 @@ jobs:
|
|||
|| 'dsh-windows-2025-16core' }}
|
||||
name: windows node 24 / build
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
# A failing build or site aborts the sibling gate on the same runner.
|
||||
DSH_GATE_FAIL_FAST: '1'
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
|
|
@ -471,6 +487,9 @@ jobs:
|
|||
DSH_COVERAGE_PARTITIONS: '4'
|
||||
DSH_COVERAGE_TEST_TIMEOUT_MS: '90000'
|
||||
DSH_GATE_CONCURRENCY: '3'
|
||||
# A gate failure aborts the sibling gate instead of waiting out its
|
||||
# multi-minute instrumented run.
|
||||
DSH_GATE_FAIL_FAST: '1'
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
|
|
@ -512,9 +531,11 @@ jobs:
|
|||
} else {
|
||||
pnpm install --frozen-lockfile
|
||||
}
|
||||
- name: Build before coverage
|
||||
shell: pwsh
|
||||
run: pnpm run build
|
||||
# No build before coverage, matching the Linux lane: workspace imports
|
||||
# resolve to src through the tsconfig paths map, and the lib-consuming
|
||||
# suites (webworker-packer image-loadable, webworker-runtime
|
||||
# transform-corpus, client ui-trajectory client-bundle) self-skip on
|
||||
# unbuilt checkouts.
|
||||
- name: Run Windows coverage
|
||||
shell: pwsh
|
||||
run: pnpm run check:ci:coverage
|
||||
|
|
|
|||
|
|
@ -43,15 +43,19 @@ describe('web e2e: /goal human transcript presentation', () => {
|
|||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('shows the bare input and result from a fresh session without a model turn', async () => {
|
||||
it('completes with Tab and shows the bare input and result without a model turn', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-goal-command-presentation'))
|
||||
await expect.poll(() => page.getByText('Into the Unknown', { exact: false }).count(), {
|
||||
timeout: 15_000,
|
||||
}).toBe(1)
|
||||
const input = page.locator('[data-composer-input]').first()
|
||||
await input.fill('/goal')
|
||||
await input.press('Enter')
|
||||
await input.fill('/go')
|
||||
const menu = page.getByRole('listbox', { name: 'Trigger suggestions' })
|
||||
await menu.getByRole('option', { name: 'goal set or view the goal for a long-running task' })
|
||||
.waitFor({ timeout: 10_000 })
|
||||
await input.press('Tab')
|
||||
await expect.poll(() => input.textContent()).toBe('/goal ')
|
||||
await expect.poll(() => menu.count()).toBe(0)
|
||||
await input.press('Enter')
|
||||
|
||||
const commandInput = page.locator('[data-command-input]')
|
||||
|
|
|
|||
|
|
@ -2,5 +2,5 @@
|
|||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/subsystems/conversation.md
|
||||
conversation.md: 28a7b3d497182f2f560f2b54f89fcfd3091af673
|
||||
conversation.zh.md: 7cf98fe2b18d9fd53e5f49f48330a5585e04c54d
|
||||
conversation.md: df1476537b95690ae2055f367e8586653b99a9a9
|
||||
conversation.zh.md: 784f52975cbb829d8914ef630aa1041693e1de62
|
||||
|
|
|
|||
|
|
@ -20,6 +20,12 @@ The Session Controller owns the contiguous loaded logical-event window. Each `Se
|
|||
|
||||
Chat and Trajectory may recognize the same durable event family, but each keeps its own Definition State and final node payload. Shared target-neutral machinery is limited to identity routing, ordered replay, Location data, predecessor dependencies, and publication cadence.
|
||||
|
||||
## Target activation
|
||||
|
||||
Each Session keeps a monotonic set of active targets. Creating or reading a target source does not activate it. The shell explicitly activates its persisted or newly selected View, while another consumer activates a target through its first source subscription. First activation creates that target's builder and calls `replace()` once from the current target-indexed Contexts. Later flushes call `apply()` for every active target, and unsubscription does not remove one.
|
||||
|
||||
The shell owns View selection and resolves the registered preferred View or Chat fallback before rendering when a binding is created or selected as current, and after View-roster changes. The assembler receives only the resolved target id and does not select Chat or another default target. A third-party View participates through the same selection and activation operations.
|
||||
|
||||
## Replayable event families
|
||||
|
||||
Choose one stable business id before writing the Definition. Every event that contributes to the same Node must carry that id or derive it independently from its own payload; the client must never assign an update to “the latest unfinished” Context.
|
||||
|
|
@ -247,5 +253,6 @@ Add focused tests that establish these outcomes:
|
|||
5. Repeated visible deltas preserve `context.key` and publish at most once per animation frame when requested.
|
||||
6. The keyed renderer consumes `node.data` and constrained Location hooks only; it does not scan the Session event window, Contexts, or Chat Nodes.
|
||||
7. Scalar and packed Assistant history produce the same final State, timing boundaries, and target snapshot, while one packed run remains one Match through replace, prepend, Location replay, and registry rebuild.
|
||||
8. Creating a target source performs no builder work; explicit selection or the first subscription performs one complete replacement, later updates reach every active target, and repeated activation performs no replacement.
|
||||
|
||||
Use [`packages/client/ui-chat/src/client/conversation-nodes/assistant.ts`](../../packages/client/ui-chat/src/client/conversation-nodes/assistant.ts) for streaming and interruption, [`inbox.ts`](../../packages/client/ui-chat/src/client/conversation-nodes/inbox.ts) plus [`message.ts`](../../packages/client/ui-chat/src/client/conversation-nodes/message.ts) for predecessor queries, and [`packages/client/ui-deliverables`](../../packages/client/ui-deliverables) for a Definition that publishes Turn data without creating its own Node.
|
||||
|
|
|
|||
|
|
@ -20,6 +20,12 @@ Session Controller 拥有连续的已加载逻辑 event window。每个 `Session
|
|||
|
||||
Chat 与 Trajectory 可以识别同一个持久 event family,但各自保留自己的 Definition State 与最终 node payload。共享的 target-neutral 机制只包括 identity routing、有序 replay、Location data、predecessor dependency 与 publication cadence。
|
||||
|
||||
## Target 激活
|
||||
|
||||
每个 Session 都保留单调增长的 active target 集合。创建或读取 target source 不会激活它。shell 会显式激活持久化选择或新选择的 View,其他消费者则通过 target source 的首个订阅激活 target。首次激活会创建该 target 的 builder,并从当前按 target 索引的 Context 调用一次 `replace()`。后续 flush 对每个 active target 调用 `apply()`,取消订阅不会移除 target。
|
||||
|
||||
shell 拥有 View 选择,并在 binding 创建、被选为 current 或 View roster 变化时,于渲染前解析已注册的偏好 View 或 Chat fallback。assembler 只接收解析后的 target id,不自行选择 Chat 或其他默认 target。第三方 View 使用相同的选择与激活操作。
|
||||
|
||||
## 可回放 event family
|
||||
|
||||
编写 Definition 前先选定稳定的业务 id。构成同一个 Node 的每条事件都必须携带该 id,或只凭自身 payload 独立推导出该 id;Client 绝不能把 update 猜测为属于“最近一个未完成”的 Context。
|
||||
|
|
@ -247,5 +253,6 @@ Assembler 会记录这项依赖。如果后续 older prepend 带来了更近的
|
|||
5. 重复的可见 delta 保持 `context.key`,并在请求 `animation-frame` 时每帧最多发布一次。
|
||||
6. keyed renderer 只消费 `node.data` 与受限 Location hook,不扫描 Session 事件窗口、Context 或 Chat Node。
|
||||
7. scalar 与 packed Assistant 历史产生相同的最终 State、timing boundary 和 target snapshot;一个 packed run 在 replace、prepend、Location replay 与 registry rebuild 中始终只保留一个 Match。
|
||||
8. 创建 target source 不执行 builder 工作;显式选择或首次订阅执行一次完整 replace,后续更新送达所有 active target,重复激活不会再次 replace。
|
||||
|
||||
流式与中断处理可参考 [`packages/client/ui-chat/src/client/conversation-nodes/assistant.ts`](../../packages/client/ui-chat/src/client/conversation-nodes/assistant.ts),前序查询可参考 [`inbox.ts`](../../packages/client/ui-chat/src/client/conversation-nodes/inbox.ts) 与 [`message.ts`](../../packages/client/ui-chat/src/client/conversation-nodes/message.ts),只发布 Turn data 而不创建自有 Node 的例子见 [`packages/client/ui-deliverables`](../../packages/client/ui-deliverables)。
|
||||
|
|
|
|||
|
|
@ -2,5 +2,5 @@
|
|||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-chat/README.md
|
||||
README.md: 4f5e969197453ee030e2deb8a09f36d3327b5ed0
|
||||
README.zh.md: 47ed92366f6c9e0c5b54c8d5095d30c8561bad47
|
||||
README.md: 9fe462316e6ab6d664a43b9fc481e07b01eda093
|
||||
README.zh.md: bad8a9143cd0307cdc5edb3e66bb9984cd5ca2be
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ English | [中文](README.zh.md)
|
|||
|
||||
## Summary
|
||||
|
||||
The browser Chat target for Conversation assembly. It registers Chat event definitions and snapshot construction, supplies `useChat`, renders transcript nodes and details, and owns Chat-specific stores, actions, localization, and scroll restoration; historical image URLs resolve through the Conversation-owned per-session cache (`ctx.uiConversation.imageUrl`). Its Assistant and Turn Tail definitions fold packed historical Assistant runs without expanding their members. Local submission echoes (`SessionSnapshot.pendingSubmissions`) retain the surface selected when the submit begins: transcript echoes render at the flow tail, steering echoes render with the pending-steering marker, and queued echoes stay out of Chat. Each echo is hidden per render once a user/steering node or queue occurrence carries its prompt `rpcId`, so the handoff is atomic.
|
||||
The browser Chat target for Conversation assembly. It registers Chat event definitions and snapshot construction, supplies `useChat`, renders transcript nodes and details, and owns Chat-specific stores, actions, localization, and scroll restoration; historical image URLs resolve through the Conversation-owned per-session cache (`ctx.uiConversation.imageUrl`). Its Assistant and Turn Tail definitions fold packed historical Assistant runs without expanding their members. Steering classification retains only next-step Inbox IDs through persistent splice state; next-turn splices create no Chat Context. Local submission echoes (`SessionSnapshot.pendingSubmissions`) retain the surface selected when the submit begins: transcript echoes render at the flow tail, steering echoes render with the pending-steering marker, and queued echoes stay out of Chat. Each echo is hidden per render once a user/steering node or queue occurrence carries its prompt `rpcId`, so the handoff is atomic.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ kind: "package-reference"
|
|||
|
||||
## 概述
|
||||
|
||||
Conversation 组装的浏览器 Chat target。本包注册 Chat event definition 与 snapshot 构造、提供 `useChat`、渲染 transcript node 和详情,并拥有 Chat 专属 store、action、本地化与滚动位置恢复;历史图片 URL 通过 Conversation 持有的按会话缓存(`ctx.uiConversation.imageUrl`)解析。其中 Assistant 与 Turn Tail definition 会直接 fold packed Assistant 历史 run,不展开其成员。本地提交回显(`SessionSnapshot.pendingSubmissions`)保留提交开始时选定的区域:transcript 回显位于消息流末尾,steering 回显带 pending-steering 标记,queued 回显不进入 Chat。一旦 user/steering 节点或 queue occurrence 携带回显的 prompt `rpcId`,该回显即在同一渲染中隐藏,因此交接是原子的。
|
||||
Conversation 组装的浏览器 Chat target。本包注册 Chat event definition 与 snapshot 构造、提供 `useChat`、渲染 transcript node 和详情,并拥有 Chat 专属 store、action、本地化与滚动位置恢复;历史图片 URL 通过 Conversation 持有的按会话缓存(`ctx.uiConversation.imageUrl`)解析。其中 Assistant 与 Turn Tail definition 会直接 fold packed Assistant 历史 run,不展开其成员。steering 分类通过持久 splice state 只保留 next-step Inbox ID;next-turn splice 不创建 Chat Context。本地提交回显(`SessionSnapshot.pendingSubmissions`)保留提交开始时选定的区域:transcript 回显位于消息流末尾,steering 回显带 pending-steering 标记,queued 回显不进入 Chat。一旦 user/steering 节点或 queue occurrence 携带回显的 prompt `rpcId`,该回显即在同一渲染中隐藏,因此交接是原子的。
|
||||
|
||||
## 目录
|
||||
|
||||
|
|
|
|||
|
|
@ -2,68 +2,133 @@ import type { Context } from '@deepseek-ai/cordis'
|
|||
import type {
|
||||
ConversationNodeDefinition, ConversationPreviousContext,
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { InboxTarget } from '@deepseek-ai/dsh-agent/types'
|
||||
|
||||
interface InboxIdentity {
|
||||
readonly id: string
|
||||
}
|
||||
|
||||
interface InboxSplice {
|
||||
readonly target: InboxTarget
|
||||
readonly start: number
|
||||
readonly removedCount?: number
|
||||
readonly inserted: readonly InboxIdentity[]
|
||||
readonly outcome?: 'canceled'
|
||||
}
|
||||
|
||||
/** Cumulative state after one durable inbox splice. */
|
||||
export interface InboxState {
|
||||
readonly pending: readonly InboxIdentity[]
|
||||
readonly claimed: ReadonlySet<string>
|
||||
interface PendingSnapshot {
|
||||
readonly kind: 'snapshot'
|
||||
readonly ids: readonly string[]
|
||||
}
|
||||
|
||||
interface PendingSplice {
|
||||
readonly kind: 'splice'
|
||||
readonly previous: PendingState
|
||||
readonly start: number
|
||||
readonly removedCount: number
|
||||
readonly inserted: readonly string[]
|
||||
}
|
||||
|
||||
type PendingState = PendingSnapshot | PendingSplice
|
||||
|
||||
/** Persistent next-step state after one durable Inbox splice. */
|
||||
export interface InboxState {
|
||||
/** Persistent splice chain materialized only when a next-step batch is claimed. */
|
||||
readonly pending: PendingState
|
||||
/** Message ids in the current claim, shared until the next claim. */
|
||||
readonly currentClaimed: ReadonlySet<string>
|
||||
}
|
||||
|
||||
const EMPTY_PENDING: PendingState = { kind: 'snapshot', ids: [] }
|
||||
const EMPTY_CURRENT_CLAIMED: ReadonlySet<string> = new Set()
|
||||
|
||||
function materializePending(state: PendingState): string[] {
|
||||
const splices: PendingSplice[] = []
|
||||
let current = state
|
||||
while (current.kind === 'splice') {
|
||||
splices.push(current)
|
||||
current = current.previous
|
||||
}
|
||||
const pending = [...current.ids]
|
||||
for (const splice of splices.reverse()) {
|
||||
pending.splice(splice.start, splice.removedCount, ...splice.inserted)
|
||||
}
|
||||
return pending
|
||||
}
|
||||
|
||||
function withoutInserted(
|
||||
claimed: ReadonlySet<string>,
|
||||
inserted: readonly string[],
|
||||
): ReadonlySet<string> {
|
||||
let next: Set<string> | undefined
|
||||
for (const id of inserted) {
|
||||
if (!claimed.has(id)) continue
|
||||
next ??= new Set(claimed)
|
||||
next.delete(id)
|
||||
}
|
||||
return next ?? claimed
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one next-step splice under the AgentLoop's durable event ordering.
|
||||
* An entered claim logs its complete message batch before another claim; a
|
||||
* rejected claim logs no messages, so only the current claim can classify a
|
||||
* later `user/message`.
|
||||
*/
|
||||
function applySplice(
|
||||
previous: ConversationPreviousContext<InboxState> | undefined,
|
||||
splice: InboxSplice,
|
||||
): InboxState {
|
||||
const pending = [...(previous?.state.pending ?? [])]
|
||||
const claimed = new Set(previous?.state.claimed ?? [])
|
||||
const removed = pending.splice(splice.start, splice.removedCount ?? 0, ...splice.inserted)
|
||||
for (const identity of splice.inserted) claimed.delete(identity.id)
|
||||
if (splice.target === 'next-step' && splice.outcome !== 'canceled') {
|
||||
for (const identity of removed) claimed.add(identity.id)
|
||||
const priorPending = previous?.state.pending ?? EMPTY_PENDING
|
||||
const inserted = splice.inserted.map(identity => identity.id)
|
||||
const removedCount = splice.removedCount ?? 0
|
||||
if (removedCount > 0 && splice.outcome !== 'canceled') {
|
||||
const pending = materializePending(priorPending)
|
||||
const removed = pending.splice(splice.start, removedCount, ...inserted)
|
||||
return {
|
||||
pending: { kind: 'snapshot', ids: pending },
|
||||
currentClaimed: new Set(removed),
|
||||
}
|
||||
}
|
||||
return { pending, claimed }
|
||||
}
|
||||
|
||||
function inboxDefinition(target: InboxTarget): ConversationNodeDefinition<InboxState> {
|
||||
const kind = `inbox-${target}`
|
||||
const currentClaimed = withoutInserted(
|
||||
previous?.state.currentClaimed ?? EMPTY_CURRENT_CLAIMED,
|
||||
inserted,
|
||||
)
|
||||
return {
|
||||
kind,
|
||||
match: event => event.type === 'agent/inbox/spliced'
|
||||
&& event.data.target === target
|
||||
? { id: String(event.seq), role: 'start' }
|
||||
: null,
|
||||
start: (_context, match, reader) => {
|
||||
if (match.event.type !== 'agent/inbox/spliced') throw new Error(`${kind} start requires agent/inbox/spliced`)
|
||||
return applySplice(reader.previous<InboxState>(kind), match.event.data)
|
||||
pending: {
|
||||
kind: 'splice',
|
||||
previous: priorPending,
|
||||
start: splice.start,
|
||||
removedCount,
|
||||
inserted,
|
||||
},
|
||||
update: context => context.state,
|
||||
publication: () => 'none',
|
||||
currentClaimed,
|
||||
}
|
||||
}
|
||||
|
||||
/** Cumulative next-turn inbox splice Definition. */
|
||||
export const nextTurnInboxDefinition = inboxDefinition('next-turn')
|
||||
const NEXT_STEP_INBOX_KIND = 'inbox-next-step'
|
||||
|
||||
/** Cumulative next-step inbox splice Definition used to classify steering. */
|
||||
export const nextStepInboxDefinition = inboxDefinition('next-step')
|
||||
/** Persistent next-step Inbox state used to classify the current claimed batch as steering. */
|
||||
export const nextStepInboxDefinition: ConversationNodeDefinition<InboxState> = {
|
||||
kind: NEXT_STEP_INBOX_KIND,
|
||||
match: (event) => {
|
||||
if (event.type === 'agent/inbox/spliced' && event.data.target === 'next-step') {
|
||||
return { id: String(event.seq), role: 'start' }
|
||||
}
|
||||
return null
|
||||
},
|
||||
start: (_context, match, reader) => {
|
||||
if (match.event.type !== 'agent/inbox/spliced') {
|
||||
throw new Error('inbox-next-step start requires agent/inbox/spliced')
|
||||
}
|
||||
return applySplice(reader.previous<InboxState>(NEXT_STEP_INBOX_KIND), match.event.data)
|
||||
},
|
||||
update: context => context.state,
|
||||
publication: () => 'none',
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the two durable Inbox-state contributions.
|
||||
* Register the next-step Inbox state used by Chat message classification.
|
||||
* @param ctx - owning UI Conversation context.
|
||||
*/
|
||||
export function registerInboxConversationNodes(ctx: Context): void {
|
||||
ctx.uiConversation.events.register(nextTurnInboxDefinition)
|
||||
ctx.uiConversation.events.register(nextStepInboxDefinition)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,7 +58,8 @@ export const messageDefinition: ConversationNodeDefinition<MessageNode> = {
|
|||
form: contextForm(event.data.source),
|
||||
}
|
||||
}
|
||||
const claimed = reader.previous<InboxState>('inbox-next-step')?.state.claimed.has(String(event.data.id)) === true
|
||||
const claimed = reader.previous<InboxState>('inbox-next-step')
|
||||
?.state.currentClaimed.has(String(event.data.id)) === true
|
||||
return claimed
|
||||
? {
|
||||
kind: 'steering',
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import { chatViewDefinition } from '../src/client/conversation-nodes/chat-snapsh
|
|||
import { commandDefinition } from '../src/client/conversation-nodes/command.ts'
|
||||
import { compactionDefinition } from '../src/client/conversation-nodes/compaction.ts'
|
||||
import { unknownFallbackDefinition } from '../src/client/conversation-nodes/fallback.ts'
|
||||
import { nextStepInboxDefinition, nextTurnInboxDefinition } from '../src/client/conversation-nodes/inbox.ts'
|
||||
import { nextStepInboxDefinition } from '../src/client/conversation-nodes/inbox.ts'
|
||||
import { messageDefinition } from '../src/client/conversation-nodes/message.ts'
|
||||
import { inspectRequestPrompt } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { requestPromptDefinition } from '../src/client/conversation-nodes/request-prompt.ts'
|
||||
|
|
@ -37,7 +37,6 @@ import type {
|
|||
} from '../src/client/contract/chat-nodes.ts'
|
||||
|
||||
const DEFINITIONS: readonly ConversationNodeDefinition[] = [
|
||||
nextTurnInboxDefinition,
|
||||
nextStepInboxDefinition,
|
||||
messageDefinition,
|
||||
requestPromptDefinition(inspectRequestPrompt),
|
||||
|
|
@ -107,7 +106,7 @@ function packedInputs(entries: readonly SessionLiveEventEntry[]): SessionEventLi
|
|||
function assembler(entries: readonly SessionEventLikeEntry[] = [], hasMore = false): ConversationNodeAssembler {
|
||||
const value = new ConversationNodeAssembler(new TestEventDefinitions(), new TestViewDefinitions())
|
||||
value.replaceWindow(entries, hasMore)
|
||||
value.flush()
|
||||
value.activateTarget('chat')
|
||||
return value
|
||||
}
|
||||
|
||||
|
|
@ -419,6 +418,63 @@ describe('built-in conversation node Definitions', () => {
|
|||
])
|
||||
})
|
||||
|
||||
it('replays pending splice chains and scopes steering to the current claim', () => {
|
||||
const first = textMessage('claim-first', 'first')
|
||||
const second = textMessage('claim-second', 'second')
|
||||
const canceled = textMessage('claim-canceled', 'canceled')
|
||||
const requeued = textMessage('claim-requeued', 'requeued')
|
||||
const later = textMessage('claim-later', 'later')
|
||||
const current = snapshot(assembler([
|
||||
at(1, 'agent/inbox/spliced', {
|
||||
target: 'next-step', start: 0, inserted: [first],
|
||||
}),
|
||||
at(2, 'agent/inbox/spliced', {
|
||||
target: 'next-step', start: 1, inserted: [canceled],
|
||||
}),
|
||||
at(3, 'agent/inbox/spliced', {
|
||||
target: 'next-step', start: 1, inserted: [second],
|
||||
}),
|
||||
at(4, 'agent/inbox/spliced', {
|
||||
target: 'next-step', start: 2, removedCount: 1, inserted: [], outcome: 'canceled',
|
||||
}),
|
||||
at(5, 'agent/inbox/spliced', {
|
||||
target: 'next-step', start: 0, removedCount: 2, inserted: [],
|
||||
}),
|
||||
at(6, 'user/message', first, { surfaceOp: 'append' }),
|
||||
at(7, 'user/message', second, { surfaceOp: 'append' }),
|
||||
at(8, 'agent/inbox/spliced', {
|
||||
target: 'next-step', start: 0, inserted: [requeued],
|
||||
}),
|
||||
at(9, 'agent/inbox/spliced', {
|
||||
target: 'next-step', start: 0, removedCount: 1, inserted: [],
|
||||
}),
|
||||
at(10, 'agent/inbox/spliced', {
|
||||
target: 'next-step', start: 0, inserted: [requeued],
|
||||
}),
|
||||
at(11, 'user/message', requeued, { surfaceOp: 'append' }),
|
||||
at(12, 'user/message', canceled, { surfaceOp: 'append' }),
|
||||
at(13, 'agent/inbox/spliced', {
|
||||
target: 'next-step', start: 0, removedCount: 1, inserted: [], outcome: 'canceled',
|
||||
}),
|
||||
at(14, 'agent/inbox/spliced', {
|
||||
target: 'next-step', start: 0, inserted: [later],
|
||||
}),
|
||||
at(15, 'agent/inbox/spliced', {
|
||||
target: 'next-step', start: 0, removedCount: 1, inserted: [],
|
||||
}),
|
||||
at(16, 'user/message', later, { surfaceOp: 'append' }),
|
||||
]))
|
||||
|
||||
expect(current.order.map(key => current.nodes.get(key)).filter(node =>
|
||||
node?.kind === 'user' || node?.kind === 'steering')).toMatchObject([
|
||||
{ kind: 'steering', data: { seq: 6 } },
|
||||
{ kind: 'steering', data: { seq: 7 } },
|
||||
{ kind: 'user', data: { seq: 11 } },
|
||||
{ kind: 'user', data: { seq: 12 } },
|
||||
{ kind: 'steering', data: { seq: 16 } },
|
||||
])
|
||||
})
|
||||
|
||||
it('orders a command-started Turn first steering before its process control', () => {
|
||||
const steering = textMessage('command-task', 'plan this change')
|
||||
const value = assembler([
|
||||
|
|
|
|||
|
|
@ -2,5 +2,5 @@
|
|||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
|
||||
README.md: 629b8b4f7987fc072066e58692396354f7b6ad6a
|
||||
README.zh.md: cb622f0308ddb0a978cfb665105d79aadfb3135a
|
||||
README.md: ce99ede306e0b0aeac24d7b21beacd0a538380b6
|
||||
README.zh.md: 5a2e481071ed3303983d5b57c68690ea9f4c4ab3
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ English | [中文](README.zh.md)
|
|||
|
||||
The adapter passes each `SessionEventLikeEntry` directly to the assembler. Its outer `type` distinguishes scalar and packed records, while its inner `event` always exposes `type`, `seq`, `time`, and `data`; Definitions receive that inner `SessionEventLike`. Historical replace and prepend accept both entry variants, while live append accepts only `SessionLiveEventEntry`. Every Definition uses the same `match` and `update` methods for both event forms, while `start` receives only a standard event and the assembler rejects a packed start. Definitions that do not consume Assistant deltas return `null` for the packed tags. Replacement windows and revision gaps rebuild from the complete loaded window; contiguous append and prepend revisions use incremental assembly without expanding packed members. The assembler owns Context matching, Turn/Step locations, target node materialization, target activity, and stable target sources. `ConversationSnapshot` contains only target-neutral views and active-target facts; Session lifecycle state remains in `SessionSnapshot`.
|
||||
|
||||
A target becomes active when shell selection resolves it or when its source receives a first subscriber. The assembler replaces that target from current Contexts once and keeps it active for later incremental flushes; creating a source does not activate it and unsubscription does not deactivate it.
|
||||
|
||||
Target packages declaration-merge their snapshot and Location data maps, then register with `ctx.uiConversation.events.register(...)` and `ctx.uiConversation.views.register(...)`. A target reads its Session-owned source with `ctx.uiConversation.binding(binding).target(targetId)`. Registrations are Cordis effects and their returned disposers remove the contribution from the same registry.
|
||||
|
||||
<a id="shell-and-standard-props"></a>
|
||||
|
|
@ -38,6 +40,8 @@ The package registers the optional-Session `conversation` shell, strict Session
|
|||
|
||||
View selection is deterministic: a registered persisted selection wins, otherwise registered `chat` wins, otherwise no View renders. It never chooses the first registered View. Shell phase combines Session lifecycle with the active-target set; no target-specific snapshot is read by the shell.
|
||||
|
||||
The shell reads the persisted View preference before rendering when a Session first binds or a cached Session becomes current, activates the registered preferred View or Chat fallback, and activates later tab or focus selections before committing them to the store. A blank Session still omits the `conversation.view` slot; no unselected target is activated.
|
||||
|
||||
The resident composer survives no-Session and Session transitions. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local image previews or durable image parts as thumbnails, while an edit exposes the literal sent text. Durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace.
|
||||
|
||||
Default sends commit optimistically: Enter clears the draft, occurrence table, and undo history in the same transaction, keeps the composer in `plain`, and runs the send as a detached attempt, so typing and further sends continue during the flight. `sendSession` registers a Session submission echo (`session.beginSubmission`) with the delivery mode before serializing; Session derives the placement from that mode and its current running state, so idle sends use the transcript, busy Queue sends use QueueDock, and busy Steer sends use the pending-steering surface. It then yields one paint and encodes images through the browser's native `FileReader` data-URL path. Concurrent failures are restored together in submission order until the user edits the restored content; command submissions keep the frozen `submitting` phase. Detached attempts retain their image ids through admission and Session scope disposal. When an echo retires as observed, the durable image cache exposes its preview immediately, fetches the admitted attachment, replaces the preview with the canonical URL, and revokes each URL after its use ends. Direct subagent continuations skip local echoes because their transport does not preserve the browser request id.
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ kind: "package-reference"
|
|||
|
||||
adapter 把每个 `SessionEventLikeEntry` 直接交给 assembler。外层 `type` 区分 scalar 与 packed record,内部 `event` 则统一公开 `type`、`seq`、`time` 与 `data`;Definition 接收这个内部 `SessionEventLike`。历史 replace 与 prepend 接受两种 entry,实时 append 只接受 `SessionLiveEventEntry`。两种 event 都使用 Definition 的同一组 `match` 与 `update` 方法,`start` 则只接收标准 event,assembler 会拒绝 packed start。不消费 Assistant delta 的 Definition 对 packed tag 返回 `null`。replace window 或 revision 断档从完整已加载窗口重建;连续 revision 的 append 和 prepend 使用增量组装,并且不展开 packed member。assembler 拥有 Context 匹配、Turn/Step location、target node 物化、target activity 和稳定 target source。`ConversationSnapshot` 只包含与 target 无关的 View 与 active-target 事实;Session lifecycle 状态仍属于 `SessionSnapshot`。
|
||||
|
||||
shell 选择解析出 target 或 target source 收到首个 subscriber 时,该 target 进入 active 状态。assembler 从当前 Context 对它执行一次 replace,并使它参与后续增量 flush;创建 source 不会激活 target,取消订阅也不会停用 target。
|
||||
|
||||
target package 通过 declaration merge 扩展 snapshot 与 Location data map,再调用 `ctx.uiConversation.events.register(...)` 和 `ctx.uiConversation.views.register(...)`。target 通过 `ctx.uiConversation.binding(binding).target(targetId)` 读取其 Session-owned source。注册属于 Cordis effect,返回的 disposer 从同一个 registry 移除 contribution。
|
||||
|
||||
<a id="shell-and-standard-props"></a>
|
||||
|
|
@ -38,6 +40,8 @@ target package 通过 declaration merge 扩展 snapshot 与 Location data map,
|
|||
|
||||
View 选择规则固定:有效且已注册的持久化选择优先,其次是已注册的 `chat`,否则不渲染 View;绝不选择第一个已注册 View。Shell phase 只组合 Session lifecycle 与 active-target set,不读取任何 target-specific snapshot。
|
||||
|
||||
Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染前读取持久化 View 偏好,激活已注册的偏好 View 或 Chat fallback,并在后续 tab 或 focus 选择写入 store 前先激活对应 target。blank Session 仍不渲染 `conversation.view` slot;未选中的 target 不会激活。
|
||||
|
||||
常驻 composer 在无 Session 与有 Session 之间保持挂载。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并把本地图片预览或持久化图片部分显示为缩略图,编辑态则展示字面发送文本。持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。
|
||||
|
||||
默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,发送期间可以继续输入和提交。`sendSession` 在序列化之前用投递模式注册 Session 提交回显(`session.beginSubmission`);Session 根据该模式与当前运行状态推导位置,因此空闲发送进入 transcript,繁忙时 Queue 进入 QueueDock,繁忙时 Steer 进入 pending-steering 区域。随后让出一帧,图片经浏览器原生 `FileReader` data-URL 路径编码。多个并发发送失败时,在用户编辑还原内容之前按提交顺序合并还原;命令提交保持冻结的 `submitting` 阶段。Detached attempt 持有图片 id,直到 admission 完成或 Session scope 销毁。回显以 observed 退休时,durable 图片缓存立即公开预览 URL,同时读取 admitted 附件,随后用规范化 URL 替换预览,并在两个 URL 各自停止使用后撤销。直接 subagent continuation 不创建本地回显,因为其 transport 不保留浏览器 request id。
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import type {
|
|||
ConversationSessionInjected,
|
||||
} from './contract/slots.ts'
|
||||
import type { InputNotice } from './contract/input.ts'
|
||||
import { createConversationStore } from './stores.ts'
|
||||
import { createConversationStore, readConversationViewPreference } from './stores.ts'
|
||||
import { ConversationController, UnsupportedImageMediaTypeError } from './service.ts'
|
||||
import type { IConversation } from './service.ts'
|
||||
import { ComposerBlockRegistry } from './input/blocks.ts'
|
||||
|
|
@ -30,6 +30,7 @@ import { ConversationRoot } from './skeleton/ConversationRoot.tsx'
|
|||
import { ConversationSession, ConversationSessionHeader } from './skeleton/ConversationSession.tsx'
|
||||
import { InputBar } from './skeleton/InputBar.tsx'
|
||||
import { todoDockEntry } from './skeleton/TodoPanel.tsx'
|
||||
import { resolveActiveView } from './view-selection.ts'
|
||||
import { en, NS, zh, type ConversationKey } from './locales.ts'
|
||||
import { CONVERSATION_SETTINGS_NAMESPACE, type ConversationSettings } from '../submission-settings.ts'
|
||||
|
||||
|
|
@ -129,25 +130,47 @@ export function apply(ctx: Context): void {
|
|||
}
|
||||
return tabs
|
||||
}
|
||||
const activateView = (sessionId: SessionId, preferred: string | null): void => {
|
||||
const active = resolveActiveView(viewTabs(), preferred)
|
||||
if (active !== undefined) uiConversation.binding(sessionId).activate(active.id)
|
||||
}
|
||||
const restoreView = (sessionId: SessionId): void => {
|
||||
activateView(sessionId, readConversationViewPreference(sessionId))
|
||||
}
|
||||
const restoreCurrentView = (): void => {
|
||||
const sessionId = sessions.list.getSnapshot().current
|
||||
if (sessionId !== undefined && sessions.binding(sessionId) !== undefined) {
|
||||
restoreView(sessionId)
|
||||
}
|
||||
}
|
||||
const conversationViews = createSnapshotStore<readonly ViewTab[]>(viewTabs())
|
||||
const refreshViews = (): void => {
|
||||
const current = conversationViews.getSnapshot()
|
||||
const next = viewTabs()
|
||||
if (current.length === next.length
|
||||
const unchanged = current.length === next.length
|
||||
&& current.every((tab, index) => {
|
||||
const candidate = next.at(index)
|
||||
return candidate !== undefined && tab.id === candidate.id && tab.label === candidate.label
|
||||
})) return
|
||||
conversationViews.set(next)
|
||||
})
|
||||
if (!unchanged) conversationViews.set(next)
|
||||
restoreCurrentView()
|
||||
}
|
||||
ctx.effect(() => {
|
||||
let currentSessionId = sessions.list.getSnapshot().current
|
||||
const disposeViews = slots.subscribe('conversation.view', refreshViews)
|
||||
const disposeLocale = ctx.locale.subscribe(refreshViews)
|
||||
const disposeCurrent = sessions.list.subscribe(() => {
|
||||
const nextSessionId = sessions.list.getSnapshot().current
|
||||
if (nextSessionId === currentSessionId) return
|
||||
currentSessionId = nextSessionId
|
||||
restoreCurrentView()
|
||||
})
|
||||
return () => {
|
||||
disposeCurrent()
|
||||
disposeLocale()
|
||||
disposeViews()
|
||||
}
|
||||
}, 'ui-conversation: View roster')
|
||||
}, 'ui-conversation: View selection')
|
||||
|
||||
const inputHub = new InputHub(ctx, t)
|
||||
const composerBlocks = new ComposerBlockRegistry()
|
||||
|
|
@ -159,9 +182,11 @@ export function apply(ctx: Context): void {
|
|||
props: ['inputActions'],
|
||||
resolve: (binding) => {
|
||||
const shell = inputHub.shellFor(binding)
|
||||
const conversation = uiConversation.binding(binding)
|
||||
restoreView(binding.sessionId)
|
||||
return {
|
||||
hooks: {
|
||||
conversation: uiConversation.binding(binding).snapshot,
|
||||
conversation: conversation.snapshot,
|
||||
input: shell.state,
|
||||
},
|
||||
props: { inputActions: shell.actions },
|
||||
|
|
@ -218,9 +243,13 @@ export function apply(ctx: Context): void {
|
|||
'conversation.view': { kind: 'list', scope: 'session' },
|
||||
},
|
||||
store: conversationStore,
|
||||
inject: (sessionId: SessionId, _actions: BoundActions<typeof conversationStore>): ConversationSessionInjected => ({
|
||||
inject: (sessionId: SessionId, actions: BoundActions<typeof conversationStore>): ConversationSessionInjected => ({
|
||||
hooks: { conversationViews },
|
||||
bindDraftMirror: write => inputHub.shell(sessionId).bindMirror(write),
|
||||
openView: (view, focus) => {
|
||||
activateView(sessionId, view)
|
||||
actions.openView(view, focus)
|
||||
},
|
||||
}),
|
||||
}, ConversationSession)
|
||||
|
||||
|
|
@ -233,9 +262,13 @@ export function apply(ctx: Context): void {
|
|||
'conversation.session.header.utilities': { kind: 'list', scope: 'session' },
|
||||
},
|
||||
store: conversationStore,
|
||||
inject: (): ConversationSessionHeaderInjected => ({
|
||||
inject: (sessionId: SessionId, actions: BoundActions<typeof conversationStore>): ConversationSessionHeaderInjected => ({
|
||||
hooks: { conversationViews },
|
||||
open: (id) => { sessions.open(id) },
|
||||
selectView: (view) => {
|
||||
activateView(sessionId, view)
|
||||
actions.setView(view)
|
||||
},
|
||||
}),
|
||||
}, ConversationSessionHeader)
|
||||
|
||||
|
|
|
|||
|
|
@ -253,7 +253,7 @@ export interface ConversationViewBuilder<Node extends ConversationViewNode = Con
|
|||
}): Snapshot
|
||||
}
|
||||
|
||||
/** Registry contribution that creates one isolated view builder per Session. */
|
||||
/** Registry contribution that creates an isolated builder when a Session first uses this target. */
|
||||
export interface ConversationViewDefinition<Node extends ConversationViewNode = ConversationViewNode, Snapshot = unknown> {
|
||||
readonly target: string
|
||||
/** @returns a new Session-owned incremental builder. */
|
||||
|
|
|
|||
|
|
@ -226,6 +226,8 @@ export interface ConversationSessionInjected {
|
|||
readonly hooks: { readonly conversationViews: ObservableSnapshot<readonly ViewTab[]> }
|
||||
/** Bind input draft persistence to the Session-owned store instance. */
|
||||
bindDraftMirror: (write: (text: string) => void) => () => void
|
||||
/** Select and activate one View while addressing an opaque focus request to it. */
|
||||
openView: (view: string, focus: string) => void
|
||||
}
|
||||
|
||||
/** Business callbacks injected into the strict Session header. */
|
||||
|
|
@ -234,6 +236,8 @@ export interface ConversationSessionHeaderInjected {
|
|||
readonly hooks: { readonly conversationViews: ObservableSnapshot<readonly ViewTab[]> }
|
||||
/** Select a Session through the Session Controller. */
|
||||
open: (sessionId: SessionId) => void
|
||||
/** Select and activate one registered Conversation View. */
|
||||
selectView: (view: string) => void
|
||||
}
|
||||
|
||||
/** Owner share of the resident composer bar. */
|
||||
|
|
|
|||
|
|
@ -44,8 +44,9 @@ interface PendingMatch {
|
|||
|
||||
interface ViewState {
|
||||
readonly target: string
|
||||
readonly builder: ConversationViewBuilder
|
||||
readonly definition: ConversationViewDefinition
|
||||
readonly isActive: ((snapshot: unknown) => boolean) | undefined
|
||||
builder: ConversationViewBuilder | undefined
|
||||
snapshot: unknown
|
||||
}
|
||||
|
||||
|
|
@ -158,12 +159,15 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
|
|||
private readonly contexts = new Map<string, InternalContext>()
|
||||
private readonly contextsByKind = new Map<string, InternalContext[]>()
|
||||
private readonly contextsBySeq = new Map<number, Set<InternalContext>>()
|
||||
private readonly contextsByTarget = new Map<string, Set<InternalContext>>()
|
||||
private readonly inputs = new Map<number, SessionEventLikeEntry>()
|
||||
private readonly locationIndex = new ConversationLocationIndex()
|
||||
private readonly dirty = new Set<InternalContext>()
|
||||
private readonly dirtyByTarget = new Map<string, Set<InternalContext>>()
|
||||
private readonly revised = new Set<InternalContext>()
|
||||
private readonly dependents = new Map<string, Set<InternalContext>>()
|
||||
private readonly views = new Map<string, ViewState>()
|
||||
private readonly activeTargets = new Set<string>()
|
||||
private hasMore = false
|
||||
private replacePending = true
|
||||
private timelineDirty = true
|
||||
|
|
@ -189,8 +193,10 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
|
|||
this.contexts.clear()
|
||||
this.contextsByKind.clear()
|
||||
this.contextsBySeq.clear()
|
||||
this.contextsByTarget.clear()
|
||||
this.inputs.clear()
|
||||
this.dirty.clear()
|
||||
this.dirtyByTarget.clear()
|
||||
this.revised.clear()
|
||||
this.dependents.clear()
|
||||
this.hasMore = hasMore
|
||||
|
|
@ -201,7 +207,7 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
|
|||
for (const entry of sorted) this.matchInput(entry)
|
||||
this.replayDependencies()
|
||||
this.revised.clear()
|
||||
for (const context of this.contexts.values()) this.dirty.add(context)
|
||||
for (const context of this.contexts.values()) this.markDirty(context)
|
||||
this.replacePending = true
|
||||
return 'immediate'
|
||||
}
|
||||
|
|
@ -278,68 +284,74 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
|
|||
}
|
||||
|
||||
/**
|
||||
* Materialize dirty Contexts and advance every registered view builder.
|
||||
* Materialize dirty Contexts and advance every active view builder.
|
||||
* @returns whether any view snapshot was rebuilt or incrementally applied.
|
||||
*/
|
||||
flush(): boolean {
|
||||
if (!this.replacePending && this.dirty.size === 0 && !this.timelineDirty) return false
|
||||
if (this.replacePending) {
|
||||
this.replaceLocationData()
|
||||
const allByTarget = new Map<string, ConversationViewNode[]>()
|
||||
for (const target of this.views.keys()) allByTarget.set(target, [])
|
||||
for (const context of this.contexts.values()) {
|
||||
const target = context.definition.target
|
||||
if (target === undefined || !this.views.has(target)) continue
|
||||
const node = this.buildNode(context, target)
|
||||
context.current.set(target, node)
|
||||
if (node !== null) allByTarget.get(target)?.push(node)
|
||||
}
|
||||
for (const view of this.views.values()) {
|
||||
view.snapshot = view.builder.replace({
|
||||
nodes: allByTarget.get(view.target) ?? [],
|
||||
let published = false
|
||||
for (const target of this.activeTargets) {
|
||||
const view = this.views.get(target)
|
||||
if (view === undefined) continue
|
||||
const builder = view.builder ?? view.definition.create()
|
||||
view.builder = builder
|
||||
view.snapshot = builder.replace({
|
||||
nodes: this.buildTargetNodes(target, this.contextsByTarget.get(target)),
|
||||
timeline: this.locationIndex.snapshot(),
|
||||
})
|
||||
published = true
|
||||
}
|
||||
this.replacePending = false
|
||||
this.dirty.clear()
|
||||
this.dirtyByTarget.clear()
|
||||
this.timelineDirty = false
|
||||
return true
|
||||
return published
|
||||
}
|
||||
|
||||
const upsertsByTarget = new Map<string, ConversationViewNode[]>()
|
||||
for (const target of this.views.keys()) upsertsByTarget.set(target, [])
|
||||
let published = false
|
||||
if (this.applyDirtyLocationData()) this.timelineDirty = true
|
||||
for (const context of this.dirty) {
|
||||
const target = context.definition.target
|
||||
if (target === undefined || !this.views.has(target)) continue
|
||||
const previous = context.current.get(target) ?? null
|
||||
const node = this.buildNode(context, target)
|
||||
if (node === null && previous !== null) {
|
||||
throw new Error(
|
||||
`conversation Definition "${context.kind}" withdrew materialized target "${target}"; return the same key with hidden visibility instead`,
|
||||
)
|
||||
}
|
||||
context.current.set(target, node)
|
||||
if (node !== null) upsertsByTarget.get(target)?.push(node)
|
||||
}
|
||||
this.dirty.clear()
|
||||
const timelineDirty = this.timelineDirty
|
||||
this.timelineDirty = false
|
||||
for (const view of this.views.values()) {
|
||||
const upserts = upsertsByTarget.get(view.target) ?? []
|
||||
for (const target of this.activeTargets) {
|
||||
const view = this.views.get(target)
|
||||
if (view === undefined) continue
|
||||
const builder = view.builder
|
||||
if (builder === undefined) continue
|
||||
const upserts = this.buildTargetUpserts(target, this.dirtyByTarget.get(target))
|
||||
if (upserts.length === 0 && !timelineDirty) continue
|
||||
view.snapshot = view.builder.apply({
|
||||
view.snapshot = builder.apply({
|
||||
upserts,
|
||||
timeline: this.locationIndex.snapshot(),
|
||||
})
|
||||
published = true
|
||||
}
|
||||
this.dirty.clear()
|
||||
this.dirtyByTarget.clear()
|
||||
this.timelineDirty = false
|
||||
return published
|
||||
}
|
||||
|
||||
/**
|
||||
* Add one target to the monotonic active set and materialize its current snapshot.
|
||||
* Pending Context work is flushed before the first complete replacement.
|
||||
* @param target - registered or subsequently registered view target.
|
||||
* @returns whether any active target snapshot changed.
|
||||
*/
|
||||
activateTarget(target: string): boolean {
|
||||
const view = this.views.get(target)
|
||||
if (this.activeTargets.has(target)) return false
|
||||
const published = this.flush()
|
||||
this.activeTargets.add(target)
|
||||
if (view === undefined) return published
|
||||
this.replaceView(view)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the latest snapshot of a registered target.
|
||||
* @param target - registered view target.
|
||||
* @returns target snapshot, or undefined when no builder is registered.
|
||||
* @returns target snapshot, or undefined before registration or activation.
|
||||
*/
|
||||
snapshot(target: string): unknown {
|
||||
return this.views.get(target)?.snapshot
|
||||
|
|
@ -353,11 +365,13 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
|
|||
|
||||
/**
|
||||
* Read targets whose owners classify their latest snapshot as visible activity.
|
||||
* @returns active target ids.
|
||||
* @returns target ids contributing visible activity.
|
||||
*/
|
||||
activeTargets(): ReadonlySet<string> {
|
||||
activityTargets(): ReadonlySet<string> {
|
||||
const active = new Set<string>()
|
||||
for (const view of this.views.values()) {
|
||||
for (const target of this.activeTargets) {
|
||||
const view = this.views.get(target)
|
||||
if (view === undefined) continue
|
||||
if (view.isActive?.(view.snapshot) === true) active.add(view.target)
|
||||
}
|
||||
return active
|
||||
|
|
@ -419,6 +433,30 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
|
|||
return publication
|
||||
}
|
||||
|
||||
private createContext(
|
||||
definition: ConversationNodeDefinition,
|
||||
id: string,
|
||||
key: string,
|
||||
): InternalContext {
|
||||
const context: InternalContext = {
|
||||
key,
|
||||
kind: definition.kind,
|
||||
id,
|
||||
definition,
|
||||
startSeq: undefined,
|
||||
start: undefined,
|
||||
matches: [],
|
||||
state: undefined,
|
||||
revision: 0,
|
||||
current: new Map(),
|
||||
locationData: emptyLocationData(),
|
||||
dependencies: new Map(),
|
||||
}
|
||||
this.contexts.set(key, context)
|
||||
this.indexTargetContext(context)
|
||||
return context
|
||||
}
|
||||
|
||||
private acceptMatch(
|
||||
definition: ConversationNodeDefinition,
|
||||
id: string,
|
||||
|
|
@ -430,23 +468,7 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
|
|||
if (role === 'start' && context?.start !== undefined) {
|
||||
throw new Error(`conversation Context ${key} received more than one start Match`)
|
||||
}
|
||||
if (context === undefined) {
|
||||
context = {
|
||||
key,
|
||||
kind: definition.kind,
|
||||
id,
|
||||
definition,
|
||||
startSeq: undefined,
|
||||
start: undefined,
|
||||
matches: [],
|
||||
state: undefined,
|
||||
revision: 0,
|
||||
current: new Map(),
|
||||
locationData: emptyLocationData(),
|
||||
dependencies: new Map(),
|
||||
}
|
||||
this.contexts.set(key, context)
|
||||
}
|
||||
context ??= this.createContext(definition, id, key)
|
||||
const match = conversationMatch(
|
||||
key,
|
||||
input,
|
||||
|
|
@ -478,7 +500,7 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
|
|||
context.revision++
|
||||
this.revised.add(context)
|
||||
}
|
||||
this.dirty.add(context)
|
||||
this.markDirty(context)
|
||||
return definition.publication?.(match) ?? 'immediate'
|
||||
}
|
||||
|
||||
|
|
@ -491,23 +513,7 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
|
|||
const first = entries[0]
|
||||
if (first === undefined) continue
|
||||
let context = this.contexts.get(key)
|
||||
if (context === undefined) {
|
||||
context = {
|
||||
key,
|
||||
kind: first.definition.kind,
|
||||
id: first.id,
|
||||
definition: first.definition,
|
||||
startSeq: undefined,
|
||||
start: undefined,
|
||||
matches: [],
|
||||
state: undefined,
|
||||
revision: 0,
|
||||
current: new Map(),
|
||||
locationData: emptyLocationData(),
|
||||
dependencies: new Map(),
|
||||
}
|
||||
this.contexts.set(key, context)
|
||||
}
|
||||
context ??= this.createContext(first.definition, first.id, key)
|
||||
let discoveredStart: ConversationStartMatch | undefined
|
||||
const additions = entries
|
||||
.map((entry) => {
|
||||
|
|
@ -538,7 +544,7 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
|
|||
throw new Error(`conversation Context ${context.key} received an update before its start Match`)
|
||||
}
|
||||
affected.add(context)
|
||||
this.dirty.add(context)
|
||||
this.markDirty(context)
|
||||
}
|
||||
for (const [kind, contexts] of startsByKind) this.indexStartedContexts(kind, contexts)
|
||||
}
|
||||
|
|
@ -549,7 +555,7 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
|
|||
for (const context of ordered) {
|
||||
if (context.start === undefined) {
|
||||
context.state = undefined
|
||||
this.dirty.add(context)
|
||||
this.markDirty(context)
|
||||
continue
|
||||
}
|
||||
this.replayContext(context)
|
||||
|
|
@ -586,7 +592,24 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
|
|||
}
|
||||
context.revision++
|
||||
this.revised.add(context)
|
||||
this.markDirty(context)
|
||||
}
|
||||
|
||||
private indexTargetContext(context: InternalContext): void {
|
||||
const target = context.definition.target
|
||||
if (target === undefined) return
|
||||
const contexts = this.contextsByTarget.get(target) ?? new Set<InternalContext>()
|
||||
contexts.add(context)
|
||||
this.contextsByTarget.set(target, contexts)
|
||||
}
|
||||
|
||||
private markDirty(context: InternalContext): void {
|
||||
this.dirty.add(context)
|
||||
const target = context.definition.target
|
||||
if (target === undefined || !this.activeTargets.has(target)) return
|
||||
const contexts = this.dirtyByTarget.get(target) ?? new Set<InternalContext>()
|
||||
contexts.add(context)
|
||||
this.dirtyByTarget.set(target, contexts)
|
||||
}
|
||||
|
||||
private replaceDependencies(context: InternalContext, dependencies: Map<string, Dependency>): void {
|
||||
|
|
@ -759,6 +782,47 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
|
|||
return node
|
||||
}
|
||||
|
||||
private replaceView(view: ViewState): void {
|
||||
const builder = view.builder ?? view.definition.create()
|
||||
view.builder = builder
|
||||
view.snapshot = builder.replace({
|
||||
nodes: this.buildTargetNodes(view.target, this.contextsByTarget.get(view.target)),
|
||||
timeline: this.locationIndex.snapshot(),
|
||||
})
|
||||
}
|
||||
|
||||
private buildTargetNodes(
|
||||
target: string,
|
||||
contexts: Iterable<InternalContext> | undefined,
|
||||
): ConversationViewNode[] {
|
||||
const nodes: ConversationViewNode[] = []
|
||||
for (const context of contexts ?? []) {
|
||||
const node = this.buildNode(context, target)
|
||||
context.current.set(target, node)
|
||||
if (node !== null) nodes.push(node)
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
|
||||
private buildTargetUpserts(
|
||||
target: string,
|
||||
contexts: Iterable<InternalContext> | undefined,
|
||||
): ConversationViewNode[] {
|
||||
const upserts: ConversationViewNode[] = []
|
||||
for (const context of contexts ?? []) {
|
||||
const previous = context.current.get(target) ?? null
|
||||
const node = this.buildNode(context, target)
|
||||
if (node === null && previous !== null) {
|
||||
throw new Error(
|
||||
`conversation Definition "${context.kind}" withdrew materialized target "${target}"; return the same key with hidden visibility instead`,
|
||||
)
|
||||
}
|
||||
context.current.set(target, node)
|
||||
if (node !== null) upserts.push(node)
|
||||
}
|
||||
return upserts
|
||||
}
|
||||
|
||||
private buildLocationData(
|
||||
context: InternalContext,
|
||||
scope: ConversationLocationDataScope,
|
||||
|
|
@ -817,15 +881,16 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
|
|||
private resetViewBuilders(): void {
|
||||
this.views.clear()
|
||||
for (const definition of this.viewDefinitions.entries()) {
|
||||
const builder = definition.create()
|
||||
this.views.set(definition.target, {
|
||||
const view: ViewState = {
|
||||
target: definition.target,
|
||||
builder,
|
||||
definition,
|
||||
isActive: definition.isActive === undefined
|
||||
? undefined
|
||||
: snapshot => definition.isActive?.(snapshot) === true,
|
||||
snapshot: builder.empty,
|
||||
})
|
||||
builder: undefined,
|
||||
snapshot: undefined,
|
||||
}
|
||||
this.views.set(definition.target, view)
|
||||
}
|
||||
this.replacePending = true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,8 +23,15 @@ import { ConversationViewRegistry } from './view-registry.ts'
|
|||
/** Observable faces published for one Session's Conversation assembly. */
|
||||
export interface ConversationBinding {
|
||||
readonly snapshot: ObservableSnapshot<ConversationSnapshot>
|
||||
/**
|
||||
* Add one selected target to the Session's monotonic active set.
|
||||
* @param target - registered or subsequently registered Conversation target.
|
||||
*/
|
||||
activate(target: string): void
|
||||
/**
|
||||
* Resolve one target-owned snapshot source.
|
||||
* The first subscriber activates the target unless shell selection already
|
||||
* activated it; activation lasts for the remaining Session lifetime.
|
||||
* @param target - registered Conversation target.
|
||||
* @returns identity-stable source following the target.
|
||||
*/
|
||||
|
|
@ -61,13 +68,21 @@ class BoundConversation implements ConversationBinding {
|
|||
const views = this.viewStore as unknown as { get(key: string): unknown }
|
||||
source = {
|
||||
getSnapshot: () => views.get(target),
|
||||
subscribe: (listener) => { return this.snapshot.subscribe(listener) },
|
||||
subscribe: (listener) => {
|
||||
const unsubscribe = this.snapshot.subscribe(listener)
|
||||
this.activate(target)
|
||||
return unsubscribe
|
||||
},
|
||||
}
|
||||
this.targetSources.set(target, source)
|
||||
}
|
||||
return source as ObservableSnapshot<ConversationViewSnapshotMap[Target] | undefined>
|
||||
}
|
||||
|
||||
activate(target: string): void {
|
||||
if (this.assembler.activateTarget(target)) this.snapshot.set(this.currentSnapshot())
|
||||
}
|
||||
|
||||
rebuild(): void { this.publish(this.assembler.rebuildRegistry()) }
|
||||
|
||||
dispose(): void {
|
||||
|
|
@ -125,7 +140,7 @@ class BoundConversation implements ConversationBinding {
|
|||
private currentSnapshot(): ConversationSnapshot {
|
||||
return {
|
||||
views: this.viewStore,
|
||||
activeTargets: this.assembler.activeTargets(),
|
||||
activeTargets: this.assembler.activityTargets(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ export function registerComposerKeymap(editor: LexicalEditor, handlers: Composer
|
|||
|
||||
const arrow = (key: ArbitrateKey) => (event: KeyboardEvent | null): boolean => {
|
||||
const inComposition = event !== null && isComposingEvent(event, recentlyComposing)
|
||||
if (handlers.arbitrate(key, inComposition) === 'consumed') {
|
||||
if (handlers.arbitrate(key, inComposition) !== 'pass') {
|
||||
event?.preventDefault()
|
||||
return true
|
||||
}
|
||||
|
|
@ -84,8 +84,8 @@ export function registerComposerKeymap(editor: LexicalEditor, handlers: Composer
|
|||
}),
|
||||
editor.registerCommand(KEY_ARROW_UP_COMMAND, arrow('up'), COMMAND_PRIORITY_CRITICAL),
|
||||
editor.registerCommand(KEY_ARROW_DOWN_COMMAND, arrow('down'), COMMAND_PRIORITY_CRITICAL),
|
||||
// Tab drills into a drillable highlighted row; otherwise it passes so the
|
||||
// browser keeps its native focus traversal.
|
||||
// Tab acts only when the trigger menu has a highlighted completion;
|
||||
// otherwise it passes so the browser keeps its native focus traversal.
|
||||
editor.registerCommand(KEY_TAB_COMMAND, arrow('tab'), COMMAND_PRIORITY_CRITICAL),
|
||||
editor.registerCommand(KEY_ESCAPE_COMMAND, (event) => {
|
||||
// Escape layering: an open overlay closes; claimed without an overlay
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import type {
|
|||
ConversationSessionHeaderSlotProps, ConversationSessionSlotProps,
|
||||
} from '../contract/slots.ts'
|
||||
import { conversationPhase } from '../contract/snapshot.ts'
|
||||
import type { ViewTab } from '../contract/views.ts'
|
||||
import { resolveActiveView } from '../view-selection.ts'
|
||||
import css from './ConversationRoot.module.css'
|
||||
|
||||
/** Full props composed from the strict session body contract. */
|
||||
|
|
@ -23,14 +23,6 @@ interface Breadcrumb {
|
|||
readonly subagent: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_VIEW_ID = 'chat'
|
||||
|
||||
/** Resolve a persisted selection, then registered Chat, without choosing another View. */
|
||||
function resolveActiveView(tabs: readonly ViewTab[], selectedId: string | null): ViewTab | undefined {
|
||||
const selected = selectedId === null ? undefined : tabs.find(view => view.id === selectedId)
|
||||
return selected ?? tabs.find(view => view.id === DEFAULT_VIEW_ID)
|
||||
}
|
||||
|
||||
function deriveAncestry(list: SessionListState, id: SessionId): readonly Breadcrumb[] {
|
||||
const chain: Breadcrumb[] = []
|
||||
const seen = new Set<SessionId>()
|
||||
|
|
@ -65,8 +57,8 @@ function equalBreadcrumbs(left: readonly Breadcrumb[], right: readonly Breadcrum
|
|||
* @returns the hidden blank-session header or visible title and tabs.
|
||||
*/
|
||||
export function ConversationSessionHeader({
|
||||
sessionId, useSession, useSessions, useConversation, useConversationViews, useStore, actions,
|
||||
renderSlot, open, t,
|
||||
sessionId, useSession, useSessions, useConversation, useConversationViews, useStore,
|
||||
renderSlot, open, selectView, t,
|
||||
}: ConversationSessionHeaderProps) {
|
||||
const tabs = useConversationViews(value => value)
|
||||
const selectedId = useStore(s => s.view)
|
||||
|
|
@ -151,7 +143,7 @@ export function ConversationSessionHeader({
|
|||
role="tab"
|
||||
aria-selected={viewTab.id === active?.id}
|
||||
className={clsx(css.tab, viewTab.id === active?.id && css.tabActive)}
|
||||
onClick={() => { actions.setView(viewTab.id) }}
|
||||
onClick={() => { selectView(viewTab.id) }}
|
||||
>
|
||||
{viewTab.label}
|
||||
</button>
|
||||
|
|
@ -172,7 +164,7 @@ export function ConversationSessionHeader({
|
|||
*/
|
||||
export function ConversationSession({
|
||||
useSession, useConversation, useConversationViews, useInput, inputActions, useStore, actions,
|
||||
renderSlot, bindDraftMirror,
|
||||
renderSlot, bindDraftMirror, openView,
|
||||
}: ConversationSessionProps) {
|
||||
const tabs = useConversationViews(value => value)
|
||||
const selectedId = useStore(s => s.view)
|
||||
|
|
@ -196,7 +188,7 @@ export function ConversationSession({
|
|||
<div className={css.viewArea}>
|
||||
{active !== undefined && renderSlot('conversation.view', {
|
||||
viewRequest,
|
||||
openView: actions.openView,
|
||||
openView,
|
||||
completeViewRequest: actions.completeViewRequest,
|
||||
}, { only: active.id })}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
/** Per-session Conversation store shared by the shell body and header. */
|
||||
import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { ConversationStoreState } from './contract/views.ts'
|
||||
|
||||
const CONVERSATION_STORE_KEY = 'dsh.conversation'
|
||||
|
||||
/** Declared write set for the Conversation shell. */
|
||||
type ConversationActions = {
|
||||
setDraft: (draft: ConversationStoreState, text: string) => void
|
||||
|
|
@ -17,7 +20,7 @@ type ConversationActions = {
|
|||
export function createConversationStore(): EngineStoreHandle<ConversationStoreState, ConversationActions> {
|
||||
return defineStore({
|
||||
init: (): ConversationStoreState => ({ draft: '', view: null, viewRequest: null }),
|
||||
persist: 'dsh.conversation',
|
||||
persist: CONVERSATION_STORE_KEY,
|
||||
actions: {
|
||||
setDraft: (d, text: string) => { d.draft = text },
|
||||
setView: (d, view: string) => { d.view = view },
|
||||
|
|
@ -29,3 +32,21 @@ export function createConversationStore(): EngineStoreHandle<ConversationStoreSt
|
|||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the persisted View preference before the Slot store is materialized.
|
||||
* @param sessionId - Session-scoped persistence suffix.
|
||||
* @returns the preferred View id, or null when storage has no usable value.
|
||||
*/
|
||||
export function readConversationViewPreference(sessionId: SessionId): string | null {
|
||||
if (typeof localStorage === 'undefined') return null
|
||||
try {
|
||||
const raw = localStorage.getItem(`${CONVERSATION_STORE_KEY}.${sessionId}`)
|
||||
if (raw === null) return null
|
||||
const stored: unknown = JSON.parse(raw)
|
||||
if (typeof stored !== 'object' || stored === null || !('view' in stored)) return null
|
||||
return typeof stored.view === 'string' ? stored.view : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
|
|
|||
17
packages/client/ui-conversation/src/client/view-selection.ts
Normal file
17
packages/client/ui-conversation/src/client/view-selection.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import type { ViewTab } from './contract/views.ts'
|
||||
|
||||
const DEFAULT_VIEW_ID = 'chat'
|
||||
|
||||
/**
|
||||
* Resolve a preferred registered View, then Chat, without choosing another View.
|
||||
* @param tabs - currently registered Views.
|
||||
* @param selectedId - preferred View identity, when one is stored.
|
||||
* @returns the selected View, Chat fallback, or undefined when neither is registered.
|
||||
*/
|
||||
export function resolveActiveView(
|
||||
tabs: readonly ViewTab[],
|
||||
selectedId: string | null,
|
||||
): ViewTab | undefined {
|
||||
const selected = selectedId === null ? undefined : tabs.find(view => view.id === selectedId)
|
||||
return selected ?? tabs.find(view => view.id === DEFAULT_VIEW_ID)
|
||||
}
|
||||
|
|
@ -9,7 +9,7 @@ import {
|
|||
import type { SessionBehaviorOverrides } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import {
|
||||
apply, inject, type ComposerBarInjected, type ConversationInjected,
|
||||
type ConversationSessionInjected, type ViewTab,
|
||||
type ConversationSessionHeaderInjected, type ConversationSessionInjected, type ViewTab,
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
|
||||
|
|
@ -51,7 +51,7 @@ async function bench() {
|
|||
|
||||
const feature = await runtime.mount({ inject: [...inject], apply })
|
||||
runtime.renderRoot()
|
||||
const entryOf = (key: 'conversation' | 'conversation.session' | 'conversation.composer.bar') =>
|
||||
const entryOf = (key: 'conversation' | 'conversation.session' | 'conversation.session.header' | 'conversation.composer.bar') =>
|
||||
runtime.slots.entries(key)[0]!
|
||||
const conversationApi = (id: SessionId) => {
|
||||
const entry = entryOf('conversation.session')
|
||||
|
|
@ -66,6 +66,15 @@ async function bench() {
|
|||
const entry = entryOf('conversation')
|
||||
return (entry.inject as unknown as (sessionId: SessionId | undefined) => ConversationInjected)(id)
|
||||
}
|
||||
const headerApi = (id: SessionId) => {
|
||||
const entry = entryOf('conversation.session.header')
|
||||
const instance = runtime.storeOf('conversation.session.header', id) as ConversationInstance
|
||||
const injected = (entry.inject as unknown as (
|
||||
sessionId: SessionId,
|
||||
actions: ConversationActions,
|
||||
) => ConversationSessionHeaderInjected)(id, instance.actions)
|
||||
return { instance, injected }
|
||||
}
|
||||
const composerApi = (id: SessionId | undefined) => {
|
||||
const entry = entryOf('conversation.composer.bar')
|
||||
return (entry.inject as unknown as (sessionId: SessionId | undefined) => ComposerBarInjected)(id)
|
||||
|
|
@ -77,7 +86,7 @@ async function bench() {
|
|||
const viewSource = (id: SessionId): ObservableSnapshot<readonly ViewTab[]> =>
|
||||
conversationApi(id).injected.hooks.conversationViews
|
||||
return {
|
||||
runtime, feature, slots: runtime.slots, entryOf, conversationApi, residentApi, composerApi,
|
||||
runtime, feature, slots: runtime.slots, entryOf, conversationApi, headerApi, residentApi, composerApi,
|
||||
inputApi, viewSource, sessionFake, connectWorkspace,
|
||||
}
|
||||
}
|
||||
|
|
@ -87,11 +96,79 @@ describe('Conversation inject API', () => {
|
|||
const b = await bench()
|
||||
const { injected } = b.conversationApi(ROOT)
|
||||
expect(b.sessionFake.loadOlder).not.toHaveBeenCalled()
|
||||
expect(Object.keys(injected)).toEqual(['hooks', 'bindDraftMirror'])
|
||||
expect(Object.keys(injected)).toEqual(['hooks', 'bindDraftMirror', 'openView'])
|
||||
expect(b.viewSource(ROOT).getSnapshot()).toEqual([])
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('activates a target before committing an explicit View selection', async () => {
|
||||
const b = await bench()
|
||||
const binding = b.runtime.ctx.uiConversation.binding(ROOT)
|
||||
const activate = vi.spyOn(binding, 'activate')
|
||||
const removeChat = b.slots.register(
|
||||
{ name: 'conversation.view', id: 'chat', order: 0 },
|
||||
(() => null) as never,
|
||||
)
|
||||
const removeTrajectory = b.slots.register(
|
||||
{ name: 'conversation.view', id: 'trajectory', order: 10 },
|
||||
(() => null) as never,
|
||||
)
|
||||
await Promise.resolve()
|
||||
activate.mockClear()
|
||||
|
||||
const body = b.conversationApi(ROOT)
|
||||
body.injected.openView('trajectory', 'call-1')
|
||||
expect(activate).toHaveBeenLastCalledWith('trajectory')
|
||||
expect(body.instance.store.getSnapshot()).toMatchObject({
|
||||
view: 'trajectory',
|
||||
viewRequest: { view: 'trajectory', focus: 'call-1' },
|
||||
})
|
||||
|
||||
const header = b.headerApi(ROOT)
|
||||
header.injected.selectView('chat')
|
||||
expect(activate).toHaveBeenLastCalledWith('chat')
|
||||
expect(header.instance.store.getSnapshot().view).toBe('chat')
|
||||
|
||||
removeTrajectory()
|
||||
removeChat()
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('restores the selected View when a cached Session becomes current', async () => {
|
||||
const b = await bench()
|
||||
const binding = b.runtime.ctx.uiConversation.binding(ROOT)
|
||||
const activate = vi.spyOn(binding, 'activate')
|
||||
const removeChat = b.slots.register(
|
||||
{ name: 'conversation.view', id: 'chat', order: 0 },
|
||||
(() => null) as never,
|
||||
)
|
||||
let removeCustom: (() => void) | undefined
|
||||
try {
|
||||
await b.runtime.flush()
|
||||
localStorage.setItem(`dsh.conversation.${ROOT}`, JSON.stringify({
|
||||
draft: '', view: 'custom', viewRequest: null,
|
||||
}))
|
||||
|
||||
b.runtime.ctx.uiSession.adapter.resolve(ROOT)
|
||||
expect(activate).toHaveBeenLastCalledWith('chat')
|
||||
activate.mockClear()
|
||||
|
||||
removeCustom = b.slots.register(
|
||||
{ name: 'conversation.view', id: 'custom', order: 10 },
|
||||
(() => null) as never,
|
||||
)
|
||||
await b.runtime.flush()
|
||||
expect(activate).not.toHaveBeenCalled()
|
||||
|
||||
await b.runtime.sessions.setCurrent(ROOT)
|
||||
expect(activate).toHaveBeenLastCalledWith('custom')
|
||||
} finally {
|
||||
removeCustom?.()
|
||||
removeChat()
|
||||
await b.runtime.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('submits through the provided input machine and mirrors accepted draft edits', async () => {
|
||||
const b = await bench()
|
||||
const { injected } = b.conversationApi(ROOT)
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import type {
|
|||
import type { ChunkRowEvent } from '@deepseek-ai/dsh-api-session-controller/types'
|
||||
import type { ChunkRow } from '@deepseek-ai/dsh-session/chunk-rows'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import { ConversationNodeAssembler } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { ConversationNodeAssembler as RuntimeConversationNodeAssembler } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type {
|
||||
ConversationMatch, ConversationNodeContext,
|
||||
ConversationNodeDefinition, ConversationViewDefinition, ConversationViewNode,
|
||||
|
|
@ -63,6 +63,13 @@ class TestViewDefinitions {
|
|||
}
|
||||
}
|
||||
|
||||
class ConversationNodeAssembler extends RuntimeConversationNodeAssembler {
|
||||
constructor(events: TestEventDefinitions, views: TestViewDefinitions) {
|
||||
super(events, views)
|
||||
for (const view of views.entries()) this.activateTarget(view.target)
|
||||
}
|
||||
}
|
||||
|
||||
function testView(
|
||||
apply = vi.fn(),
|
||||
): ConversationViewDefinition<ConversationViewNode, TestSnapshot> {
|
||||
|
|
@ -92,6 +99,21 @@ function testView(
|
|||
}
|
||||
}
|
||||
|
||||
function trackedView(target: string) {
|
||||
const replace = vi.fn(({ nodes }: { readonly nodes: readonly ConversationViewNode[] }) => nodes)
|
||||
const apply = vi.fn(({ upserts }: { readonly upserts: readonly ConversationViewNode[] }) => upserts)
|
||||
const create = vi.fn(() => ({
|
||||
empty: [] as readonly ConversationViewNode[],
|
||||
replace,
|
||||
apply,
|
||||
}))
|
||||
const definition: ConversationViewDefinition<ConversationViewNode, readonly ConversationViewNode[]> = {
|
||||
target,
|
||||
create,
|
||||
}
|
||||
return { definition, create, replace, apply }
|
||||
}
|
||||
|
||||
function at(seq: number, type: string, data: unknown): SessionEvent {
|
||||
return { seq, time: 1_700_000_000_000 + seq, type, data } as SessionEvent
|
||||
}
|
||||
|
|
@ -139,6 +161,89 @@ function fallbackDefinition(start: () => string): ConversationNodeDefinition<str
|
|||
}
|
||||
|
||||
describe('ConversationNodeAssembler', () => {
|
||||
it('reports a replacement only when an active target has a registered builder', () => {
|
||||
const assembler = new RuntimeConversationNodeAssembler(
|
||||
new TestEventDefinitions([]),
|
||||
new TestViewDefinitions([]),
|
||||
)
|
||||
|
||||
expect(assembler.activateTarget('registered-later')).toBe(false)
|
||||
assembler.replaceWindow([], false)
|
||||
|
||||
expect(assembler.flush()).toBe(false)
|
||||
})
|
||||
|
||||
it('updates only active targets and never deactivates one after first use', () => {
|
||||
type State = { readonly updates: number }
|
||||
const definition = (
|
||||
target: string,
|
||||
buildViewNode: NonNullable<ConversationNodeDefinition<State>['buildViewNode']>,
|
||||
): ConversationNodeDefinition<State> => ({
|
||||
kind: `active-${target}`,
|
||||
target,
|
||||
match: (event) => {
|
||||
const type = event.type as string
|
||||
if (type === 'active/start') return { id: 'one', role: 'start' }
|
||||
if (type === 'active/update') return { id: 'one', role: 'update' }
|
||||
return null
|
||||
},
|
||||
start: () => ({ updates: 0 }),
|
||||
update: context => ({ updates: context.state.updates + 1 }),
|
||||
buildViewNode,
|
||||
})
|
||||
const chat = trackedView('chat')
|
||||
const trajectory = trackedView('trajectory')
|
||||
const build = (target: string) => vi.fn((context: ConversationNodeContext<State>): ConversationViewNode => ({
|
||||
key: context.key,
|
||||
kind: context.kind,
|
||||
id: context.id,
|
||||
target,
|
||||
data: context.state,
|
||||
}))
|
||||
const buildChat = build('chat')
|
||||
const buildTrajectory = build('trajectory')
|
||||
const assembler = new RuntimeConversationNodeAssembler(
|
||||
new TestEventDefinitions([
|
||||
definition('chat', buildChat),
|
||||
definition('trajectory', buildTrajectory),
|
||||
]),
|
||||
new TestViewDefinitions([chat.definition, trajectory.definition]),
|
||||
)
|
||||
|
||||
assembler.replaceWindow([input(at(1, 'active/start', {}))], false)
|
||||
expect(assembler.flush()).toBe(false)
|
||||
expect(chat.create).not.toHaveBeenCalled()
|
||||
expect(trajectory.create).not.toHaveBeenCalled()
|
||||
expect(buildChat).not.toHaveBeenCalled()
|
||||
expect(buildTrajectory).not.toHaveBeenCalled()
|
||||
|
||||
expect(assembler.activateTarget('chat')).toBe(true)
|
||||
expect(chat.replace).toHaveBeenCalledOnce()
|
||||
expect(trajectory.replace).not.toHaveBeenCalled()
|
||||
expect(buildChat).toHaveBeenCalledOnce()
|
||||
expect(buildTrajectory).not.toHaveBeenCalled()
|
||||
|
||||
assembler.append(input(at(2, 'active/update', {})))
|
||||
expect(assembler.flush()).toBe(true)
|
||||
expect(chat.apply).toHaveBeenCalledOnce()
|
||||
expect(trajectory.apply).not.toHaveBeenCalled()
|
||||
expect(buildTrajectory).not.toHaveBeenCalled()
|
||||
|
||||
expect(assembler.activateTarget('trajectory')).toBe(true)
|
||||
expect(trajectory.replace).toHaveBeenCalledOnce()
|
||||
expect((assembler.snapshot('trajectory') as readonly ConversationViewNode[])
|
||||
.map(node => node.data)).toEqual([{ updates: 1 }])
|
||||
|
||||
assembler.append(input(at(3, 'active/update', {})))
|
||||
expect(assembler.flush()).toBe(true)
|
||||
expect(chat.apply).toHaveBeenCalledTimes(2)
|
||||
expect(trajectory.apply).toHaveBeenCalledOnce()
|
||||
expect(assembler.activateTarget('chat')).toBe(false)
|
||||
expect(assembler.activateTarget('trajectory')).toBe(false)
|
||||
expect(chat.replace).toHaveBeenCalledOnce()
|
||||
expect(trajectory.replace).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('appends through an exact business-id Context without replaying unrelated Contexts', () => {
|
||||
const starts = vi.fn((
|
||||
_context: ConversationNodeContext<{ callSeq: number; results: number }>,
|
||||
|
|
|
|||
|
|
@ -245,4 +245,40 @@ describe('Conversation registries', () => {
|
|||
expect(rebuild).toHaveBeenCalledTimes(2)
|
||||
rebuild.mockRestore()
|
||||
})
|
||||
|
||||
it('activates each target on explicit selection or first use and never deactivates it', async () => {
|
||||
const { uiConversation, binding, views } = await bootRegistries()
|
||||
const chat = viewDefinition('chat')
|
||||
const trajectory = viewDefinition('trajectory')
|
||||
const createChat = vi.spyOn(chat, 'create')
|
||||
const createTrajectory = vi.spyOn(trajectory, 'create')
|
||||
views.register(chat)
|
||||
const disposeTrajectory = views.register(trajectory)
|
||||
await Promise.resolve()
|
||||
|
||||
const conversation = uiConversation.binding(binding)
|
||||
const chatSource = conversation.target('chat')
|
||||
const trajectorySource = conversation.target('trajectory')
|
||||
expect(createChat).not.toHaveBeenCalled()
|
||||
expect(createTrajectory).not.toHaveBeenCalled()
|
||||
|
||||
conversation.activate('chat')
|
||||
expect(createChat).toHaveBeenCalledOnce()
|
||||
|
||||
const unsubscribeChat = chatSource.subscribe(vi.fn())
|
||||
const trajectoryListener = vi.fn()
|
||||
const unsubscribeTrajectory = trajectorySource.subscribe(trajectoryListener)
|
||||
unsubscribeChat()
|
||||
const unsubscribeTrajectoryAgain = trajectorySource.subscribe(vi.fn())
|
||||
unsubscribeTrajectoryAgain()
|
||||
expect(createChat).toHaveBeenCalledOnce()
|
||||
expect(createTrajectory).toHaveBeenCalledOnce()
|
||||
expect(trajectoryListener).toHaveBeenCalledOnce()
|
||||
|
||||
disposeTrajectory()
|
||||
await Promise.resolve()
|
||||
expect(trajectorySource.getSnapshot()).toBeUndefined()
|
||||
expect(trajectoryListener).toHaveBeenCalledTimes(2)
|
||||
unsubscribeTrajectory()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createConversationStore } from '../src/client/stores.ts'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { createConversationStore, readConversationViewPreference } from '../src/client/stores.ts'
|
||||
|
||||
const KEY = 'dsh.conversation'
|
||||
|
||||
|
|
@ -54,4 +55,14 @@ describe('createConversationStore', () => {
|
|||
first.actions.setDraft('only first')
|
||||
expect(second.store.getSnapshot().draft).toBe('')
|
||||
})
|
||||
|
||||
it('reads only a usable persisted View preference', () => {
|
||||
const sessionId = 'sess-1' as SessionId
|
||||
const store = createConversationStore().create(sessionId)
|
||||
store.actions.setView('trajectory')
|
||||
expect(readConversationViewPreference(sessionId)).toBe('trajectory')
|
||||
|
||||
localStorage.setItem(`${KEY}.${sessionId}`, '{invalid')
|
||||
expect(readConversationViewPreference(sessionId)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -441,7 +441,8 @@ function assemble(entries: readonly SessionEventLikeEntry[]): FoldSnapshots {
|
|||
{ entries: () => [viewDefinition('chat'), viewDefinition('trajectory')] },
|
||||
)
|
||||
assembler.replaceWindow(entries, false)
|
||||
assembler.flush()
|
||||
assembler.activateTarget('chat')
|
||||
assembler.activateTarget('trajectory')
|
||||
return {
|
||||
chat: assembler.snapshot('chat'),
|
||||
trajectory: assembler.snapshot('trajectory'),
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ describe('keymap keydown routing', () => {
|
|||
registerPlainText(editor)
|
||||
const arbitrate = vi.fn<(key: string, composing: boolean) => 'consumed' | 'pick-highlighted' | 'pass'>()
|
||||
.mockReturnValueOnce('consumed')
|
||||
.mockReturnValueOnce('pick-highlighted')
|
||||
.mockReturnValue('pass')
|
||||
registerComposerKeymap(editor, {
|
||||
arbitrate,
|
||||
|
|
@ -56,8 +57,9 @@ describe('keymap keydown routing', () => {
|
|||
const consumed = fireEvent.keyDown(root, { key: 'Tab', keyCode: 9 })
|
||||
expect(arbitrate).toHaveBeenCalledWith('tab', false)
|
||||
expect(consumed).toBe(false) // consumed: preventDefault fired
|
||||
const picked = fireEvent.keyDown(root, { key: 'Tab', keyCode: 9 })
|
||||
expect(picked).toBe(false) // picked: the completion replaces native traversal
|
||||
const passed = fireEvent.keyDown(root, { key: 'Tab', keyCode: 9 })
|
||||
expect(passed).toBe(true) // pass: the browser keeps native focus traversal
|
||||
})
|
||||
|
||||
})
|
||||
|
|
|
|||
|
|
@ -203,6 +203,7 @@ function mount(
|
|||
actions={store.actions}
|
||||
renderSlot={renderSlot as never}
|
||||
open={open}
|
||||
selectView={(view) => { store.actions.setView(view) }}
|
||||
t={t}
|
||||
/>
|
||||
)
|
||||
|
|
@ -227,6 +228,7 @@ function mount(
|
|||
actions={store.actions}
|
||||
renderSlot={renderSlot as never}
|
||||
bindDraftMirror={write => wiring.bindMirror(write)}
|
||||
openView={(view, focus) => { store.actions.openView(view, focus) }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -433,8 +435,7 @@ describe('ConversationRoot resident composer', () => {
|
|||
{ ...workspace('second'), title: 'Selected Folder' },
|
||||
],
|
||||
)
|
||||
// Hero chrome present, view ring absent; scroll host already wraps the
|
||||
// resident composer so the blank → active flip does not remount it.
|
||||
// Hero chrome is present and the selected View slot remains absent.
|
||||
const host = b.view.container.querySelector('[data-conversation-scroll]')
|
||||
const header = b.view.container.querySelector('header')
|
||||
expect(host).not.toBeNull()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveActiveView } from '../src/client/view-selection.ts'
|
||||
import type { ViewTab } from '../src/client/contract/views.ts'
|
||||
|
||||
describe('resolveActiveView', () => {
|
||||
it('resolves the preferred registered View and Chat fallback', () => {
|
||||
const tabs: readonly ViewTab[] = [
|
||||
{ id: 'chat', label: 'Chat' },
|
||||
{ id: 'custom', label: 'Custom' },
|
||||
]
|
||||
|
||||
expect(resolveActiveView(tabs, 'custom')?.id).toBe('custom')
|
||||
expect(resolveActiveView(tabs, 'removed')?.id).toBe('chat')
|
||||
expect(resolveActiveView(tabs, null)?.id).toBe('chat')
|
||||
})
|
||||
|
||||
it('does not choose an arbitrary registered View', () => {
|
||||
expect(resolveActiveView([{ id: 'custom', label: 'Custom' }], null)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
|
@ -165,7 +165,7 @@ function result(seq: number, callId: string, isError = false, turn = 1): Session
|
|||
function assembler(entries: readonly SessionLiveEventEntry[], hasMore = false): ConversationNodeAssembler {
|
||||
const value = new ConversationNodeAssembler(new TestEventDefinitions(), new TestViewDefinitions())
|
||||
value.replaceWindow(entries, hasMore)
|
||||
value.flush()
|
||||
value.activateTarget('test')
|
||||
return value
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ function entry(seq: number, type: string, data: unknown): SessionLiveEventEntry
|
|||
function snapshot(entries: readonly SessionLiveEventEntry[], hasMore = false): ChatSnapshot {
|
||||
const assembler = new ConversationNodeAssembler(new TestEventDefinitions(), new TestViewDefinitions())
|
||||
assembler.replaceWindow(entries, hasMore)
|
||||
assembler.flush()
|
||||
assembler.activateTarget('chat')
|
||||
const value = assembler.snapshot('chat') as ChatSnapshot | undefined
|
||||
if (value === undefined) throw new Error('chat view was not registered')
|
||||
return value
|
||||
|
|
|
|||
|
|
@ -2,5 +2,5 @@
|
|||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-input-trigger/README.md
|
||||
README.md: 1f316229327d33f48e0950a2c27e143c6c991f99
|
||||
README.zh.md: 324541120406eac8677afcfda0967540dd74848e
|
||||
README.md: 83fcffbd1dbf20ad070ef9edccb74f2e5beed9e6
|
||||
README.zh.md: a7c2bbad1a1a982a99327d21d7afd6466cbd201e
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ Mount this plugin alongside `ui-conversation`; the menu then appears in the inpu
|
|||
|
||||
### Keyboard and mouse
|
||||
|
||||
The composer surface keeps focus while the menu is open: rows pick on mousedown, the highlight rides `aria-activedescendant`, and a pointer press outside both the menu and the composer card dismisses it. Space and Enter adjudication polls the optional `matchSpace`/`matchEnter` hooks in registration order; the first non-undefined answer wins, and a source can refuse a submission it cannot consume whole. A candidate declaring `drill: true` carries a second verb beside the settling pick: its trailing chevron and the Tab key route the same row through `onPick` with `action: 'drill'` (every other path reports `'pick'`), and Tab passes untouched on rows without the flag so native focus traversal survives. A source implementing the optional `header` hook additionally publishes crumbs above its group: the pipeline re-polls it on every hit with the live query and whether a drill, rather than typing, produced it, and a crumb pick routes back through `onPick` with `action: 'drill'`.
|
||||
The composer surface keeps focus while the menu is open: rows pick on mousedown, the highlight rides `aria-activedescendant`, and a pointer press outside both the menu and the composer card dismisses it. Space and Enter adjudication polls the optional `matchSpace`/`matchEnter` hooks in registration order; the first non-undefined answer wins, and a source can refuse a submission it cannot consume whole. Tab acts on the highlighted completion: a candidate declaring `drill: true` routes through `onPick` with `action: 'drill'`, while an ordinary candidate settles through `action: 'pick'`; without a highlight, Tab passes untouched so native focus traversal survives. A drillable row's trailing chevron exposes the same second verb to pointer users. A source implementing the optional `header` hook additionally publishes crumbs above its group: the pipeline re-polls it on every hit with the live query and whether a drill, rather than typing, produced it, and a crumb pick routes back through `onPick` with `action: 'drill'`.
|
||||
|
||||
-----
|
||||
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ kind: "package-reference"
|
|||
|
||||
### 键盘与鼠标
|
||||
|
||||
菜单打开期间 composer 表面保持焦点:行在 mousedown 时完成 pick,高亮由 `aria-activedescendant` 承载,指针落在菜单与所在 composer 卡片之外即关闭菜单。空格与回车裁决按注册序轮询可选的 `matchSpace`/`matchEnter` 钩子;第一个非 undefined 的应答胜出,source 也可以拒绝它无法整体消费的提交。声明 `drill: true` 的候选行在选定 pick 之外携带第二个动词:行尾的 chevron 与 Tab 键把同一行以 `action: 'drill'` 送入 `onPick`(其余路径一律报告 `'pick'`);未声明该标记的行上 Tab 原样放行,原生焦点遍历不受影响。实现可选 `header` 钩子的 source 还会在其分组上方发布面包屑:管线在每次命中时用实时查询、以及该查询由下钻还是由键入产生这一事实重新询问它,点击面包屑经 `onPick` 以 `action: 'drill'` 回到该 source。
|
||||
菜单打开期间 composer 表面保持焦点:行在 mousedown 时完成 pick,高亮由 `aria-activedescendant` 承载,指针落在菜单与所在 composer 卡片之外即关闭菜单。空格与回车裁决按注册序轮询可选的 `matchSpace`/`matchEnter` 钩子;第一个非 undefined 的应答胜出,source 也可以拒绝它无法整体消费的提交。Tab 会作用于高亮补全项:声明 `drill: true` 的候选项以 `action: 'drill'` 进入 `onPick`,普通候选项则以 `action: 'pick'` 完成选定;没有高亮项时 Tab 原样放行,原生焦点遍历不受影响。可下钻行尾的 chevron 向指针用户提供同一个动词。实现可选 `header` 钩子的 source 还会在其分组上方发布面包屑:管线在每次命中时用实时查询、以及该查询由下钻还是由键入产生这一事实重新询问它,点击面包屑经 `onPick` 以 `action: 'drill'` 回到该 source。
|
||||
|
||||
-----
|
||||
|
||||
|
|
|
|||
|
|
@ -214,7 +214,11 @@ export class InputTriggerController {
|
|||
* Keyboard arbitration while the menu is open.
|
||||
* @param key - intercepted key.
|
||||
* @param composing - inside IME composition: everything passes.
|
||||
* @returns consumed / pick-highlighted / pass.
|
||||
* @returns `pass` when the browser keeps the key (closed menu, no
|
||||
* highlight, or a vanished candidate), `consumed` when the menu handled
|
||||
* the key without a settling pick (move, close, drill descent, or a
|
||||
* pending-refinement no-op), or `pick-highlighted` when the highlighted
|
||||
* candidate settled and the menu closed.
|
||||
*/
|
||||
arbitrate(key: ArbitrateKey, composing: boolean): ArbitrateOutcome {
|
||||
if (composing || this.disposed) return 'pass'
|
||||
|
|
@ -245,16 +249,19 @@ export class InputTriggerController {
|
|||
return 'pick-highlighted'
|
||||
}
|
||||
case 'tab': {
|
||||
// Tab drills into the highlighted candidate when it offers descent;
|
||||
// otherwise the key passes so native focus behavior is untouched.
|
||||
if (state.highlight === null) return 'pass'
|
||||
const group = state.groups.find(g => g.source === state.highlight?.source)
|
||||
const item = group !== undefined && group.status === 'ready'
|
||||
? group.items[state.highlight.index]
|
||||
: undefined
|
||||
if (item?.drill !== true) return 'pass'
|
||||
this.pick(state.highlight.source, state.highlight.index, 'drill')
|
||||
return 'consumed'
|
||||
// Pending refinement keeps the stale highlight visible: consume the
|
||||
// gesture rather than pick a stale row or let Tab move focus away.
|
||||
if (group === undefined || group.status !== 'ready') return 'consumed'
|
||||
const item = group.items[state.highlight.index]
|
||||
if (item === undefined) return 'pass'
|
||||
if (item.drill === true) {
|
||||
this.pick(state.highlight.source, state.highlight.index, 'drill')
|
||||
return 'consumed'
|
||||
}
|
||||
this.pick(state.highlight.source, state.highlight.index)
|
||||
return 'pick-highlighted'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -889,7 +889,7 @@ describe('arbitrate', () => {
|
|||
expect(controller.menu.getSnapshot().open).toBe(false)
|
||||
})
|
||||
|
||||
it('tab drills into a drillable highlight and passes on plain rows', async () => {
|
||||
it('tab drills into a drillable highlight and picks a plain completion', async () => {
|
||||
const drillable = readySource('/', 'command', [{ name: 'src', drill: true }, { name: 'plan' }], () => undefined)
|
||||
const { controller } = controllerBench([drillable.source])
|
||||
controller.track('/s', 2, { tier: 'plain' }, 1)
|
||||
|
|
@ -897,12 +897,37 @@ describe('arbitrate', () => {
|
|||
expect(controller.arbitrate('tab', false)).toBe('consumed')
|
||||
expect(drillable.picks[0]!.action).toBe('drill')
|
||||
expect(drillable.picks[0]!.candidate.name).toBe('src')
|
||||
// Plain row (no drill flag): the key passes so native focus stays intact.
|
||||
// Plain row (no drill flag): Tab settles the highlighted completion.
|
||||
controller.track('/s', 2, { tier: 'plain' }, 2)
|
||||
await tick()
|
||||
controller.arbitrate('down', false)
|
||||
expect(controller.arbitrate('tab', false)).toBe('pass')
|
||||
expect(drillable.picks).toHaveLength(1)
|
||||
expect(controller.arbitrate('tab', false)).toBe('pick-highlighted')
|
||||
expect(drillable.picks[1]!.action).toBe('pick')
|
||||
expect(drillable.picks[1]!.candidate.name).toBe('plan')
|
||||
expect(controller.menu.getSnapshot().open).toBe(false)
|
||||
})
|
||||
|
||||
it('tab during a pending refinement is consumed: no pick, no focus traversal', async () => {
|
||||
const picks: string[] = []
|
||||
const cmd = deferredSource('/', 'command', {
|
||||
onPick: (pick) => { picks.push(pick.candidate.name); return undefined },
|
||||
})
|
||||
const { controller } = controllerBench([cmd.source])
|
||||
controller.track('/g', 2, { tier: 'plain' }, 1)
|
||||
cmd.pending[0]!.resolve([{ name: 'goal' }, { name: 'plan' }])
|
||||
await tick()
|
||||
expect(controller.menu.getSnapshot().highlight).toEqual({ source: 'command', index: 0 })
|
||||
// Refinement: previous rows and highlight stay visible while the fetch pends.
|
||||
controller.track('/go', 3, { tier: 'plain' }, 2)
|
||||
expect(controller.menu.getSnapshot().highlight).toEqual({ source: 'command', index: 0 })
|
||||
expect(controller.arbitrate('tab', false)).toBe('consumed')
|
||||
expect(picks).toHaveLength(0)
|
||||
expect(controller.menu.getSnapshot().open).toBe(true)
|
||||
// Settled: the same gesture settles the highlighted completion.
|
||||
cmd.pending[1]!.resolve([{ name: 'goal' }])
|
||||
await tick()
|
||||
expect(controller.arbitrate('tab', false)).toBe('pick-highlighted')
|
||||
expect(picks).toEqual(['goal'])
|
||||
})
|
||||
|
||||
it('a settling pick reports the pick action', async () => {
|
||||
|
|
@ -913,19 +938,21 @@ describe('arbitrate', () => {
|
|||
|
||||
it('IME composition passes every key untouched', async () => {
|
||||
const { controller } = await menuBench()
|
||||
for (const key of ['up', 'down', 'enter', 'escape'] as const) {
|
||||
for (const key of ['up', 'down', 'enter', 'escape', 'tab'] as const) {
|
||||
expect(controller.arbitrate(key, true)).toBe('pass')
|
||||
}
|
||||
expect(controller.menu.getSnapshot().open).toBe(true)
|
||||
})
|
||||
|
||||
it('closed menu passes; an open menu without a highlight passes enter', () => {
|
||||
it('closed menu passes; an open menu without a highlight passes picking keys', () => {
|
||||
const cmd = deferredSource('/', 'command')
|
||||
const { controller } = controllerBench([cmd.source])
|
||||
expect(controller.arbitrate('enter', false)).toBe('pass')
|
||||
expect(controller.arbitrate('tab', false)).toBe('pass')
|
||||
// Open with the only group still pending: nothing to pick yet.
|
||||
controller.track('/g', 2, { tier: 'plain' }, 1)
|
||||
expect(controller.arbitrate('enter', false)).toBe('pass')
|
||||
expect(controller.arbitrate('tab', false)).toBe('pass')
|
||||
})
|
||||
|
||||
it('enter during a pending refinement is consumed: no pick, no submit fallthrough', async () => {
|
||||
|
|
|
|||
|
|
@ -2,5 +2,5 @@
|
|||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-tool/README.md
|
||||
README.md: 773a93801ebc214e2d5c94d52864f5c5dd887100
|
||||
README.zh.md: 88df08d7b5b7d5d3978d90fd4df4cbbb2efeb1fa
|
||||
README.md: 895274748d8ee5b7bece16e5b76836966cc52663
|
||||
README.zh.md: f4cf11cabdb94f45c9b11e7f7538ad5042a791e6
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ The package realizes one dispatch rule: atomic Tool views are keyed by wire Tool
|
|||
|
||||
### Details and cards
|
||||
|
||||
The package fills `conversation.details.tool` with `ToolDetails`. Row and Details renderers share one pure card model for each terminal, read, diff, search, and web card. These models validate raw call arguments, result content, failure state, persisted metadata, Code Dispatch `parentCallId`, and Session path facts. Unsupported or malformed inputs use flattened Tool result text. Card-specific limits and fallback rules remain in the owning [terminal](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md), [diff](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md), [read](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md), [search](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md), [web](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md), and [question](../../../.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md) notes.
|
||||
The package fills `conversation.details.tool` with `ToolDetails`. Row and Details renderers share one pure card model for each terminal, read, diff, search, and web card. These models validate raw call arguments, result content, failure state, persisted metadata, Code Dispatch `parentCallId`, and Session path facts. Generic rows retain the original `argsRaw` reference and format their input body only while it is expanded; structured cards skip generic-body formatting. Unsupported or malformed inputs use flattened Tool result text. Card-specific limits and fallback rules remain in the owning [terminal](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md), [diff](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md), [read](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md), [search](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md), [web](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md), and [question](../../../.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md) notes.
|
||||
|
||||
</details>
|
||||
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block`
|
|||
|
||||
### 详情与卡片
|
||||
|
||||
本包通过 `ToolDetails` 填充 `conversation.details.tool`。行 renderer 与 Details renderer 分别为 terminal、read、diff、search 和 web 卡片复用同一个纯 card model。这些 model 校验原始调用参数、结果内容、失败状态、持久 metadata、Code Dispatch `parentCallId` 与 Session 路径事实。不受支持或格式错误的输入使用压平的工具结果文本。各类卡片的上限与 fallback 规则仍由对应的 [terminal](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md)、[diff](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md)、[read](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.zh.md)、[search](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md)、[web](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md) 与 [question](../../../.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md) 笔记负责。
|
||||
本包通过 `ToolDetails` 填充 `conversation.details.tool`。行 renderer 与 Details renderer 分别为 terminal、read、diff、search 和 web 卡片复用同一个纯 card model。这些 model 校验原始调用参数、结果内容、失败状态、持久 metadata、Code Dispatch `parentCallId` 与 Session 路径事实。Generic row 保留原始 `argsRaw` 引用,只在展开期间格式化 input body;结构化卡片跳过 generic body 格式化。不受支持或格式错误的输入使用压平的工具结果文本。各类卡片的上限与 fallback 规则仍由对应的 [terminal](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md)、[diff](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md)、[read](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.zh.md)、[search](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md)、[web](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md) 与 [question](../../../.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md) 笔记负责。
|
||||
|
||||
</details>
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,9 @@ import {
|
|||
diffBlockLabels, readBlockLabels, searchBlockLabels, webBlockLabels,
|
||||
} from '../models/primitive-labels.ts'
|
||||
import type { AskQuestionCardModel } from '../models/ask-question-card-model.ts'
|
||||
import type { ToolRowState, ToolRowVariant } from '../models/tool-call-model.ts'
|
||||
import {
|
||||
formatToolBody, type ToolRowState, type ToolRowVariant,
|
||||
} from '../models/tool-call-model.ts'
|
||||
import type { WebCardModelProps } from '../models/web-card-model.ts'
|
||||
import { AskQuestionCard } from './AskQuestionCard.tsx'
|
||||
import css from './ToolRow.module.css'
|
||||
|
|
@ -36,8 +38,8 @@ export interface ToolRowProps {
|
|||
* error row, whose collapsed summary is the failure line instead.
|
||||
*/
|
||||
summarySuffix?: string | null | undefined
|
||||
/** Expanded-body input text; null = no input section. */
|
||||
body: string | null
|
||||
/** Original argument JSON formatted only while the row is expanded. */
|
||||
bodyRaw?: string | null | undefined
|
||||
/** Flattened result text for the expanded Output section; null/absent = no output section. */
|
||||
output?: string | null | undefined
|
||||
/** Ask-user transcript card; card fields are mutually exclusive and replace text sections. */
|
||||
|
|
@ -94,7 +96,7 @@ export function ToolRow({
|
|||
title,
|
||||
summary,
|
||||
summarySuffix,
|
||||
body,
|
||||
bodyRaw,
|
||||
output,
|
||||
askQuestion,
|
||||
errorSummary,
|
||||
|
|
@ -124,8 +126,12 @@ export function ToolRow({
|
|||
const askQuestionBody = askQuestion ?? null
|
||||
const outputText = output ?? null
|
||||
const card = askQuestionBody ?? terminalBody ?? diffBody ?? readBody ?? searchBody ?? webBody
|
||||
const expandable = body !== null || outputText !== null || card !== null
|
||||
const expandable = bodyRaw != null || outputText !== null || card !== null
|
||||
const open = expanded && expandable
|
||||
const bodyText = useMemo(
|
||||
() => open && card === null && bodyRaw != null ? formatToolBody(variant, bodyRaw) : null,
|
||||
[bodyRaw, card, open, variant],
|
||||
)
|
||||
const status = stateStatus(state, t)
|
||||
// A failure must replace, not supplement, the normal summary.
|
||||
const failureLine = state === 'error' ? errorSummary ?? null : null
|
||||
|
|
@ -156,7 +162,7 @@ export function ToolRow({
|
|||
}
|
||||
// The code variant's program renders through CodeBlock (shiki), so only its
|
||||
// output joins the IN/OUT card; every other variant's input does too.
|
||||
const cardBody = variant === 'code' ? null : body
|
||||
const cardBody = variant === 'code' ? null : bodyText
|
||||
return (
|
||||
<div className={css.root} data-variant={variant} data-tool={toolName} data-state={state}>
|
||||
{status !== null && <span className={css.visuallyHidden}>{status}</span>}
|
||||
|
|
@ -235,9 +241,9 @@ export function ToolRow({
|
|||
? <WebBlock {...webBody} labels={webLabels} className={css.webBody} />
|
||||
: (
|
||||
<>
|
||||
{variant === 'code' && body !== null && (
|
||||
{variant === 'code' && bodyText !== null && (
|
||||
<div className={css.bodyScroll}>
|
||||
<CodeBlock code={body} lang="typescript" copyLabel={t('copy')} copiedLabel={t('copied')} className={css.codeBody} />
|
||||
<CodeBlock code={bodyText} lang="typescript" copyLabel={t('copy')} copiedLabel={t('copied')} className={css.codeBody} />
|
||||
</div>
|
||||
)}
|
||||
{(cardBody !== null || outputText !== null) && (
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
/**
|
||||
* Pure row-model derivation for tool summary rows: variant classification,
|
||||
* one-line summary, expanded-body text, and flattened result output from the
|
||||
* frozen call slice. Input material comes from the call ARGUMENTS; output and
|
||||
* error material from the settled result node. A supported terminal call gets
|
||||
* its expanded body from `terminalCardModel` instead.
|
||||
* one-line summary, expansion-time body input, and flattened result output
|
||||
* from the frozen call slice. Input material comes from the call ARGUMENTS;
|
||||
* output and error material from the settled result node. A supported terminal
|
||||
* call gets its expanded body from `terminalCardModel` instead.
|
||||
*/
|
||||
// The block union's defining home is runtime (fold-product types); this
|
||||
// contract only forwards it (type-definition authority stays with the layer
|
||||
|
|
@ -92,8 +92,8 @@ export interface ToolRowModel {
|
|||
* relative values against the session cwd before opening.
|
||||
*/
|
||||
filePath: string | undefined
|
||||
/** Expanded-body input text (pretty args); null = no input section. */
|
||||
body: string | null
|
||||
/** Original argument JSON retained for expansion-time body formatting. */
|
||||
bodyRaw: string | null
|
||||
/** Flattened result text ({@link resultText}); null while running or when the result carries no text. */
|
||||
output: string | null
|
||||
/** First line of the result text on an error row; null for every other state. */
|
||||
|
|
@ -196,7 +196,13 @@ function deriveFilePath(variant: ToolRowVariant, argsRaw: string): string | unde
|
|||
return picked === undefined ? undefined : firstLine(picked)
|
||||
}
|
||||
|
||||
function deriveBody(variant: ToolRowVariant, argsRaw: string): string | null {
|
||||
/**
|
||||
* Format one argument payload when its generic input body becomes visible.
|
||||
* @param variant - row presentation selected for the Tool name.
|
||||
* @param argsRaw - original argument JSON or incomplete raw text.
|
||||
* @returns display body, or null for empty input.
|
||||
*/
|
||||
export function formatToolBody(variant: ToolRowVariant, argsRaw: string): string | null {
|
||||
if (argsRaw === '') return null
|
||||
const parsed = parseArgs(argsRaw)
|
||||
if (parsed === undefined) return argsRaw
|
||||
|
|
@ -238,12 +244,13 @@ export function toolRowModel(toolName: string, block: ToolCallBlock, cwd?: strin
|
|||
// would erase the collapsed error row's summary slot.
|
||||
const output = done ? (resultText(block) || null) : null
|
||||
const errorSummary = state === 'error' && output !== null ? firstLine(output) : null
|
||||
const bodyRaw = argsRaw === '' ? null : argsRaw
|
||||
return {
|
||||
variant,
|
||||
titleKey: toolTitleKey ?? VARIANT_TITLE_KEYS[variant],
|
||||
summary,
|
||||
filePath: deriveFilePath(variant, argsRaw),
|
||||
body: deriveBody(variant, argsRaw),
|
||||
bodyRaw,
|
||||
output,
|
||||
errorSummary,
|
||||
state,
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ export function GenericToolCard({ toolName, block, cwd, home, openFile, inspect,
|
|||
// Single-file tools never expose an args body — the path link is the only
|
||||
// args interaction. A card is not an args body: a read/write/edit row is
|
||||
// single-file AND carries a card, so the card expands under the path link.
|
||||
body={singleFile ? null : model.body}
|
||||
bodyRaw={singleFile ? null : model.bodyRaw}
|
||||
output={model.output}
|
||||
errorSummary={model.errorSummary}
|
||||
terminal={terminal}
|
||||
|
|
|
|||
|
|
@ -186,7 +186,7 @@ export function AskQuestionRow({ toolName, block, inspect, t }: AskQuestionRowPr
|
|||
icon={<IconQuestionOutline14 />}
|
||||
title={t('ask.rowTitle')}
|
||||
summary={summary}
|
||||
body={transcript === null ? model.body : null}
|
||||
bodyRaw={transcript === null ? model.bodyRaw : null}
|
||||
output={transcript === null ? model.output : null}
|
||||
askQuestion={transcript}
|
||||
state={state}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, type KeyboardEvent } from 'react'
|
||||
import { useMemo, useState, type KeyboardEvent } from 'react'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
|
|
@ -13,7 +13,7 @@ import {
|
|||
terminalCardModel,
|
||||
terminalFailed,
|
||||
} from '../models/terminal-card-model.ts'
|
||||
import { toolRowModel, type ToolRowState } from '../models/tool-call-model.ts'
|
||||
import { formatToolBody, toolRowModel, type ToolRowState } from '../models/tool-call-model.ts'
|
||||
import { CONVERSATION_NS as NS } from '../../locale.ts'
|
||||
import css from './bash-sample.module.css'
|
||||
|
||||
|
|
@ -58,9 +58,15 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }:
|
|||
// body; background acknowledgements and malformed calls remain collapsed.
|
||||
const genericBody = terminal === null
|
||||
&& (model.state === 'error' || isSettledPersistentShellCall(block))
|
||||
&& (model.body !== null || model.output !== null)
|
||||
&& (model.bodyRaw !== null || model.output !== null)
|
||||
const expandable = terminal !== null || genericBody
|
||||
const open = expanded && expandable
|
||||
const body = useMemo(
|
||||
() => open && genericBody && model.bodyRaw !== null
|
||||
? formatToolBody(model.variant, model.bodyRaw)
|
||||
: null,
|
||||
[genericBody, model.bodyRaw, model.variant, open],
|
||||
)
|
||||
const failureLine = model.state === 'error' ? model.errorSummary : null
|
||||
const toggleExpand = () => {
|
||||
setExpanded(v => !v)
|
||||
|
|
@ -115,13 +121,13 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }:
|
|||
)
|
||||
: (
|
||||
<div className={css.ioCard}>
|
||||
{model.body !== null && (
|
||||
{body !== null && (
|
||||
<div className={css.ioSection}>
|
||||
<span className={css.ioLabel}>{t('row.input')}</span>
|
||||
<span className={css.ioText}>{model.body}</span>
|
||||
<span className={css.ioText}>{body}</span>
|
||||
</div>
|
||||
)}
|
||||
{model.body !== null && model.output !== null && (
|
||||
{body !== null && model.output !== null && (
|
||||
<span className={css.ioDivider} aria-hidden />
|
||||
)}
|
||||
{model.output !== null && (
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ export function FileMutationRow({ toolName, block, cwd, home, openFile, inspect,
|
|||
icon={<IconEditOutline16 size={14} />}
|
||||
title={t(model.titleKey)}
|
||||
summary={model.summary}
|
||||
body={null}
|
||||
output={model.output}
|
||||
errorSummary={model.errorSummary}
|
||||
diff={diff}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ export function ReadRow({ toolName, block, cwd, home, openFile, inspect, t }: Re
|
|||
icon={<IconBrowseOutline16 size={14} />}
|
||||
title={t(model.titleKey)}
|
||||
summary={model.summary}
|
||||
body={null}
|
||||
output={model.output}
|
||||
errorSummary={model.errorSummary}
|
||||
read={read}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ export function SearchRow({ toolName, block, inspect, t }: SearchRowProps) {
|
|||
? SEARCH_TITLE_KEYS.grep
|
||||
: toolName === 'glob' ? SEARCH_TITLE_KEYS.glob : model.titleKey)}
|
||||
summary={model.summary}
|
||||
body={null}
|
||||
// ToolRow ignores output when a structured card is present; otherwise it
|
||||
// preserves the generic fallback for errors and legacy results.
|
||||
output={model.output}
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ export function TodoRow({ toolName, block, inspect, t }: TodoRowProps) {
|
|||
title={t('todo.rowTitle')}
|
||||
summary={summary.text}
|
||||
summarySuffix={summary.extra > 0 ? `+${summary.extra}` : null}
|
||||
body={model.body}
|
||||
bodyRaw={model.bodyRaw}
|
||||
output={model.output}
|
||||
errorSummary={model.errorSummary}
|
||||
state={model.state}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ export function WebRow({ toolName, block, inspect, t }: WebRowProps) {
|
|||
? WEB_TITLE_KEYS.web_search
|
||||
: toolName === 'web_fetch' ? WEB_TITLE_KEYS.web_fetch : model.titleKey)}
|
||||
summary={model.summary}
|
||||
body={null}
|
||||
output={model.output}
|
||||
errorSummary={model.errorSummary}
|
||||
web={web}
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ function bashProps(block: RunningToolCall | ToolResultNode): BashRowProps {
|
|||
describe('Tool presentation tails', () => {
|
||||
it('ToolRow stopped state renders the warning dot in the leading slot', () => {
|
||||
const view = render(
|
||||
<ToolRow t={t} variant="bash" icon={<i data-testid="icon" />} title="Bash" summary="s" body={null} state="stopped" />,
|
||||
<ToolRow t={t} variant="bash" icon={<i data-testid="icon" />} title="Bash" summary="s" state="stopped" />,
|
||||
)
|
||||
expect(view.queryByTestId('icon')).toBeNull()
|
||||
expect(view.container.querySelector('[data-state="stopped"]')).not.toBeNull()
|
||||
|
|
|
|||
|
|
@ -5,13 +5,16 @@ import { cleanup, fireEvent, render } from '@testing-library/react'
|
|||
import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import { classifyTool, resultText, toolRowModel } from '../src/client/tool/models/tool-call-model.ts'
|
||||
import {
|
||||
classifyTool, formatToolBody, resultText, toolRowModel,
|
||||
} from '../src/client/tool/models/tool-call-model.ts'
|
||||
import { ToolRow } from '../src/client/tool/components/ToolRow.tsx'
|
||||
import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx'
|
||||
import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts'
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
const t: GenericToolCardProps['t'] = makeTranslate(zh, commonZh)
|
||||
|
|
@ -163,14 +166,17 @@ describe('tool-call-model', () => {
|
|||
})
|
||||
|
||||
it('body pretty-prints JSON args, keeps raw non-JSON, null when empty', () => {
|
||||
expect(toolRowModel('bash', running({ argsRaw: '{"a":1}' })).body).toBe('{\n "a": 1\n}')
|
||||
expect(toolRowModel('bash', running({ argsRaw: 'raw' })).body).toBe('raw')
|
||||
expect(toolRowModel('bash', running({ argsRaw: '' })).body).toBeNull()
|
||||
expect(toolRowModel('bash', result({ call: null })).body).toBeNull()
|
||||
expect(formatToolBody('bash', toolRowModel('bash', running({ argsRaw: '{"a":1}' })).bodyRaw ?? ''))
|
||||
.toBe('{\n "a": 1\n}')
|
||||
expect(formatToolBody('bash', toolRowModel('bash', running({ argsRaw: 'raw' })).bodyRaw ?? ''))
|
||||
.toBe('raw')
|
||||
expect(toolRowModel('bash', running({ argsRaw: '' })).bodyRaw).toBeNull()
|
||||
expect(toolRowModel('bash', result({ call: null })).bodyRaw).toBeNull()
|
||||
})
|
||||
|
||||
it('a code row with an empty program falls back to the args JSON envelope', () => {
|
||||
expect(toolRowModel('run_code', running({ name: 'run_code', argsRaw: '{"code":""}' })).body)
|
||||
const model = toolRowModel('run_code', running({ name: 'run_code', argsRaw: '{"code":""}' }))
|
||||
expect(formatToolBody(model.variant, model.bodyRaw ?? ''))
|
||||
.toBe('{\n "code": ""\n}')
|
||||
})
|
||||
|
||||
|
|
@ -228,7 +234,7 @@ describe('ToolRow', () => {
|
|||
const rowProps = {
|
||||
t,
|
||||
variant: 'bash' as const, icon: <i data-testid="tool-icon" />, title: 'Bash',
|
||||
summary: 'List files', body: '{\n "a": 1\n}', state: 'ok' as const,
|
||||
summary: 'List files', bodyRaw: '{"a":1}', state: 'ok' as const,
|
||||
}
|
||||
|
||||
it('renders leading icon, title and summary while collapsed', () => {
|
||||
|
|
@ -252,6 +258,28 @@ describe('ToolRow', () => {
|
|||
expect(view.getByText('List files')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('formats the argument body only while expanding it', () => {
|
||||
const stringify = vi.spyOn(JSON, 'stringify')
|
||||
const bodyFormatCalls = () => stringify.mock.calls.filter(
|
||||
([value, replacer, space]) => typeof value === 'object'
|
||||
&& value !== null
|
||||
&& 'a' in value
|
||||
&& (value as { a?: unknown }).a === 1
|
||||
&& replacer === null
|
||||
&& space === 2,
|
||||
).length
|
||||
const view = render(<ToolRow {...rowProps} />)
|
||||
expect(bodyFormatCalls()).toBe(0)
|
||||
|
||||
fireEvent.click(view.getByRole('button'))
|
||||
expect(bodyFormatCalls()).toBe(1)
|
||||
expect(view.getByText(/"a": 1/)).toBeTruthy()
|
||||
|
||||
fireEvent.click(view.getByRole('button'))
|
||||
expect(bodyFormatCalls()).toBe(1)
|
||||
expect(view.queryByText(/"a": 1/)).toBeNull()
|
||||
})
|
||||
|
||||
it('running keeps the icon (row sweep carries the signal); error swaps in a StateDot', () => {
|
||||
const runningView = render(<ToolRow {...rowProps} state="running" />)
|
||||
expect(runningView.queryByTestId('tool-icon')).not.toBeNull()
|
||||
|
|
@ -264,7 +292,7 @@ describe('ToolRow', () => {
|
|||
})
|
||||
|
||||
it('non-expandable rows render a passive leading slot and no row button', () => {
|
||||
const view = render(<ToolRow {...rowProps} body={null} />)
|
||||
const view = render(<ToolRow {...rowProps} bodyRaw={null} />)
|
||||
expect(view.queryByRole('button')).toBeNull()
|
||||
expect(view.container.querySelector('[aria-expanded]')).toBeNull()
|
||||
expect(view.queryByTestId('tool-icon')).not.toBeNull()
|
||||
|
|
@ -391,7 +419,7 @@ describe('ToolRow', () => {
|
|||
expect(inputOnly.getByText('输入')).toBeTruthy()
|
||||
expect(inputOnly.queryByText('输出')).toBeNull()
|
||||
cleanup()
|
||||
const outputOnly = render(<ToolRow {...rowProps} body={null} output="only out" />)
|
||||
const outputOnly = render(<ToolRow {...rowProps} bodyRaw={null} output="only out" />)
|
||||
fireEvent.click(outputOnly.getByRole('button'))
|
||||
expect(outputOnly.queryByText('输入')).toBeNull()
|
||||
expect(outputOnly.getByText('输出')).toBeTruthy()
|
||||
|
|
|
|||
|
|
@ -2,5 +2,5 @@
|
|||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-trajectory/README.md
|
||||
README.md: cb11a8186f32761b6af208ae79196feff8f098d5
|
||||
README.zh.md: 5b02e965b3d4e041b7a405d210bace366551cecb
|
||||
README.md: 73bacf0761e2427b0c2632ed274de68986058ebd
|
||||
README.zh.md: 8672ff0787472b284ccb4639876f7fc3097af9e1
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ A fixed Overview above the ledger projects real record start/duration timing fro
|
|||
<details>
|
||||
<summary>Implementation internals — click to expand</summary>
|
||||
|
||||
The view is a pure projection: Trajectory-owned Definitions assemble business records from the shared Session window — including durable cancellation-finalized prefixes, chunk-only interruption fallbacks, and interrupted Tool records — so Trajectory neither reads nor changes the Chat conversation snapshot.
|
||||
The view is a pure projection: Trajectory-owned Definitions assemble business records from the shared Session window — including durable cancellation-finalized prefixes, chunk-only interruption fallbacks, and interrupted Tool records — so Trajectory neither reads nor changes the Chat conversation snapshot. Its steering classifier retains only next-step Inbox IDs through persistent splice state and shares each current claimed batch across later Contexts.
|
||||
|
||||
### Virtual rows
|
||||
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ kind: "package-reference"
|
|||
<details>
|
||||
<summary>实现细节——点击展开</summary>
|
||||
|
||||
视图是纯投影:Trajectory 自有的 Definition 从共享 Session 窗口组装业务记录——包括持久化的取消定稿前缀、只能从分片恢复的打断前缀与被打断的工具记录——因此 Trajectory 既不读取也不改变 Chat 会话快照。
|
||||
视图是纯投影:Trajectory 自有的 Definition 从共享 Session 窗口组装业务记录——包括持久化的取消定稿前缀、只能从分片恢复的打断前缀与被打断的工具记录——因此 Trajectory 既不读取也不改变 Chat 会话快照。其 steering 分类器通过持久 splice state 只保留 next-step Inbox ID,并让后续 Context 共享当前 claimed batch。
|
||||
|
||||
### 虚拟行
|
||||
|
||||
|
|
|
|||
|
|
@ -21,33 +21,105 @@ interface InboxSplice {
|
|||
readonly outcome?: 'canceled'
|
||||
}
|
||||
|
||||
interface PendingSnapshot {
|
||||
readonly kind: 'snapshot'
|
||||
readonly ids: readonly string[]
|
||||
}
|
||||
|
||||
interface PendingSplice {
|
||||
readonly kind: 'splice'
|
||||
readonly previous: PendingState
|
||||
readonly start: number
|
||||
readonly removedCount: number
|
||||
readonly inserted: readonly string[]
|
||||
}
|
||||
|
||||
type PendingState = PendingSnapshot | PendingSplice
|
||||
|
||||
interface InboxState {
|
||||
readonly pending: readonly InboxIdentity[]
|
||||
readonly claimed: ReadonlySet<string>
|
||||
/** Persistent splice chain materialized only when a next-step batch is claimed. */
|
||||
readonly pending: PendingState
|
||||
/** Message ids in the current claim, shared until the next claim. */
|
||||
readonly currentClaimed: ReadonlySet<string>
|
||||
}
|
||||
|
||||
type MessageNode = UserMessageNode | SteeringMessageNode | ContextMessageNode
|
||||
|
||||
const EMPTY_PENDING: PendingState = { kind: 'snapshot', ids: [] }
|
||||
const EMPTY_CURRENT_CLAIMED: ReadonlySet<string> = new Set()
|
||||
|
||||
function materializePending(state: PendingState): string[] {
|
||||
const splices: PendingSplice[] = []
|
||||
let current = state
|
||||
while (current.kind === 'splice') {
|
||||
splices.push(current)
|
||||
current = current.previous
|
||||
}
|
||||
const pending = [...current.ids]
|
||||
for (const splice of splices.reverse()) {
|
||||
pending.splice(splice.start, splice.removedCount, ...splice.inserted)
|
||||
}
|
||||
return pending
|
||||
}
|
||||
|
||||
function withoutInserted(
|
||||
claimed: ReadonlySet<string>,
|
||||
inserted: readonly string[],
|
||||
): ReadonlySet<string> {
|
||||
let next: Set<string> | undefined
|
||||
for (const id of inserted) {
|
||||
if (!claimed.has(id)) continue
|
||||
next ??= new Set(claimed)
|
||||
next.delete(id)
|
||||
}
|
||||
return next ?? claimed
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one next-step splice under the AgentLoop's durable event ordering.
|
||||
* An entered claim logs its complete message batch before another claim; a
|
||||
* rejected claim logs no messages, so only the current claim can classify a
|
||||
* later `user/message`.
|
||||
*/
|
||||
function applySplice(
|
||||
previous: ConversationPreviousContext<InboxState> | undefined,
|
||||
splice: InboxSplice,
|
||||
): InboxState {
|
||||
const pending = [...(previous?.state.pending ?? [])]
|
||||
const claimed = new Set(previous?.state.claimed ?? [])
|
||||
const removed = pending.splice(splice.start, splice.removedCount ?? 0, ...splice.inserted)
|
||||
for (const identity of splice.inserted) claimed.delete(identity.id)
|
||||
if (splice.outcome !== 'canceled') {
|
||||
for (const identity of removed) claimed.add(identity.id)
|
||||
const priorPending = previous?.state.pending ?? EMPTY_PENDING
|
||||
const inserted = splice.inserted.map(identity => identity.id)
|
||||
const removedCount = splice.removedCount ?? 0
|
||||
if (removedCount > 0 && splice.outcome !== 'canceled') {
|
||||
const pending = materializePending(priorPending)
|
||||
const removed = pending.splice(splice.start, removedCount, ...inserted)
|
||||
return {
|
||||
pending: { kind: 'snapshot', ids: pending },
|
||||
currentClaimed: new Set(removed),
|
||||
}
|
||||
}
|
||||
const currentClaimed = withoutInserted(
|
||||
previous?.state.currentClaimed ?? EMPTY_CURRENT_CLAIMED,
|
||||
inserted,
|
||||
)
|
||||
return {
|
||||
pending: {
|
||||
kind: 'splice',
|
||||
previous: priorPending,
|
||||
start: splice.start,
|
||||
removedCount,
|
||||
inserted,
|
||||
},
|
||||
currentClaimed,
|
||||
}
|
||||
return { pending, claimed }
|
||||
}
|
||||
|
||||
const trajectoryInboxDefinition: ConversationNodeDefinition<InboxState> = {
|
||||
kind: 'trajectory-inbox-next-step',
|
||||
match: event => event.type === 'agent/inbox/spliced'
|
||||
&& event.data.target === 'next-step'
|
||||
? { id: String(event.seq), role: 'start' }
|
||||
: null,
|
||||
match: (event) => {
|
||||
if (event.type === 'agent/inbox/spliced' && event.data.target === 'next-step') {
|
||||
return { id: String(event.seq), role: 'start' }
|
||||
}
|
||||
return null
|
||||
},
|
||||
start: (_context, match, reader) => {
|
||||
if (match.event.type !== 'agent/inbox/spliced') {
|
||||
throw new Error('trajectory-inbox-next-step start requires agent/inbox/spliced')
|
||||
|
|
@ -84,7 +156,7 @@ const trajectoryMessageDefinition: ConversationNodeDefinition<MessageNode> = {
|
|||
}
|
||||
}
|
||||
const claimed = reader.previous<InboxState>('trajectory-inbox-next-step')
|
||||
?.state.claimed.has(String(event.data.id)) === true
|
||||
?.state.currentClaimed.has(String(event.data.id)) === true
|
||||
return claimed
|
||||
? {
|
||||
kind: 'steering',
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue