Merge latest master into perf/macos-packaging
This commit is contained in:
commit
a051fed540
191 files changed
+5157
-681
No files matched your search
+2
-2
@@ -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-23-client-derived-tool-presentation.md
|
||||
2026-08-23-client-derived-tool-presentation.md: 8befe840b08006a7d2ee0fa1b7875c2c989d4779
|
||||
2026-08-23-client-derived-tool-presentation.zh.md: 249366f10e6eb06941b344977169609358786413
|
||||
2026-08-23-client-derived-tool-presentation.md: 931276fa6d4c0590746b6c59eeae9f998f05629c
|
||||
2026-08-23-client-derived-tool-presentation.zh.md: b930f98da17a1cb6ecbf605e3a9f37255ba75fc9
|
||||
+2
-2
@@ -22,7 +22,7 @@ The required result is one raw Session journal and one Client presentation owner
|
||||
|
||||
## Decision
|
||||
|
||||
The visual-equivalence requirements below exclude the separately approved [nested terminal-card fix](../bug-fix/2026-09-05-nested-terminal-cards.md); all other presentation and ownership constraints remain.
|
||||
The visual-equivalence requirements below exclude the separately approved [nested terminal-card fix](../bug-fix/2026-09-05-nested-terminal-cards.md) and [compact tool details](2026-09-10-compact-tool-details.md); all other presentation and ownership constraints remain.
|
||||
|
||||
The Session Remote journal sends only raw, validated, persistable Session events. `session.page` and `session.follow` do not parse tool arguments, query the Tools registry, restore a presenter scope, execute `presentCall` or `presentResult`, or construct or clone any tool view.
|
||||
|
||||
@@ -51,7 +51,7 @@ The Host `ToolDefinition.presentCall`, `ToolDefinition.presentResult`, `ToolCall
|
||||
| Retained | the Session log format, Remote journal lifecycle, and Conversation identity/topology |
|
||||
| Retained | the existing keyed slot, Generic fallback, and Chat, Details, and Trajectory structure |
|
||||
| Forbidden | a new Client presenter service, parallel registry, or wire renderer id |
|
||||
| Forbidden | new cards, visual redesign, interaction redesign, or PTC dispatch rich-card enhancements except the [nested terminal-card exception](../bug-fix/2026-09-05-nested-terminal-cards.md) |
|
||||
| Forbidden | new cards, visual redesign, interaction redesign, or PTC dispatch rich-card enhancements except the [nested terminal-card exception](../bug-fix/2026-09-05-nested-terminal-cards.md) and [compact tool details](2026-09-10-compact-tool-details.md) |
|
||||
| Forbidden | compatibility dual-writing, version negotiation, or retention of the old `view` field |
|
||||
|
||||
## Terminology
|
||||
|
||||
+2
-2
@@ -22,7 +22,7 @@ Host presenter 与 Client keyed renderer 分担展示会形成对同一事件的
|
||||
|
||||
## Decision
|
||||
|
||||
下述展示对等要求不包含已独立批准的[嵌套 terminal 卡片修复](../bug-fix/2026-09-05-nested-terminal-cards.zh.md);其他展示与所有权约束全部保留。
|
||||
下述展示对等要求不包含已独立批准的[嵌套 terminal 卡片修复](../bug-fix/2026-09-05-nested-terminal-cards.zh.md)和[紧凑工具详情](2026-09-10-compact-tool-details.zh.md);其他展示与所有权约束全部保留。
|
||||
|
||||
Session Remote journal 只下发原始、已验证、可持久化的 Session event。`session.page` 和 `session.follow` 不解析工具参数,不查询 Tools registry,不恢复 presenter scope,不执行 `presentCall`/`presentResult`,也不构造或克隆任何 tool view。
|
||||
|
||||
@@ -51,7 +51,7 @@ Host 的 `ToolDefinition.presentCall`、`ToolDefinition.presentResult`、`ToolCa
|
||||
| 保留 | Session 日志格式、Remote journal 生命周期与 Conversation identity/topology |
|
||||
| 保留 | 现有 keyed slot、Generic fallback、Chat、Details 与 Trajectory 结构 |
|
||||
| 禁止 | 新 Client presenter service、平行 registry 或 wire renderer id |
|
||||
| 禁止 | 新卡片、视觉改版、交互改版或 PTC dispatch rich-card 增强,[嵌套 terminal 卡片例外](../bug-fix/2026-09-05-nested-terminal-cards.zh.md)除外 |
|
||||
| 禁止 | 新卡片、视觉改版、交互改版或 PTC dispatch rich-card 增强,[嵌套 terminal 卡片例外](../bug-fix/2026-09-05-nested-terminal-cards.zh.md)和[紧凑工具详情](2026-09-10-compact-tool-details.zh.md)除外 |
|
||||
| 禁止 | 为兼容保留双写、版本协商或旧 `view` 字段 |
|
||||
|
||||
## 术语
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-10-compact-tool-details.md
|
||||
2026-09-10-compact-tool-details.md: 6c1f29c5554c74b01fc2a86c2dee3750a529a6b5
|
||||
2026-09-10-compact-tool-details.zh.md: d0822a48f100f9f21ef4706204087fcd1e627da4
|
||||
@@ -0,0 +1,29 @@
|
||||
# Agent Note: Compact Tool Details from Recorded Results
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-10-compact-tool-details.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Goal, todo, and schedule results contain a small set of user-facing facts that take more effort to read as JSON. Dedicated expanded bodies need to fit the existing conversation rows without adding a second presentation registry or live state to historical calls.
|
||||
|
||||
## Decision
|
||||
|
||||
The `ui-tool` package registers goal and schedule toolviews through the existing keyed slot and shares one package-internal fields/list component with the todo view. Successful, recognized results display recorded values; incomplete calls, errors, malformed values, and unsupported result formats retain generic input/output. Schedule domain errors, including uncertain persistence and an unknown deletion target, retain their original result.
|
||||
|
||||
Tool rows keep their disclosure and Inspect interaction. Checklist marks are static and have accessible status labels. Schedule times use absolute dates with the viewer's time zone; states come from the recorded result. Goal details distinguish an active goal awaiting continuation from an armed goal. These display values do not subscribe to the current goal, todo, or schedule projection.
|
||||
|
||||
Todo changes compare each root or nested call with its preceding durable `todo/write`, so later writes cannot alter an earlier card. The conversation assembler repairs predecessors when older history loads. Until the predecessor is known, the card shows the submitted list without inferred additions or removals.
|
||||
|
||||
This scoped visual change is an exception to the visual-equivalence requirement in [Client-derived tool presentation](2026-08-23-client-derived-tool-presentation.md). Its raw-event ownership, generic fallback, and single keyed registry remain authoritative.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**A shared presenter framework:** the existing keyed slot already dispatches tools. Reusing only the detail body keeps the abstraction local until another package needs it.
|
||||
|
||||
**A dashboard inside each card:** interactive controls, progress graphics, and live states add visual weight and can misrepresent what a historical call returned. Compact read-only fields and lists preserve the conversation's hierarchy.
|
||||
|
||||
## Consequences
|
||||
|
||||
Registered tools gain readable details without changing Host tools, Session events, or public Client exports. Tool-specific adapters interpret recorded JSON and text; unrecognized formats retain generic output. The authored tool-details Session replay covers their assembled rendering.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Agent Note: Compact Tool Details from Recorded Results
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-10-compact-tool-details.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
目标、任务清单和定时任务的结果包含少量面向用户的事实,以 JSON 阅读时较费力。专用展开内容需要融入现有对话行,不能为历史调用新增一套呈现注册表或实时状态。
|
||||
|
||||
## 决策
|
||||
|
||||
`ui-tool` 包通过现有按键分发的插槽注册目标和定时任务视图,并与任务清单视图共用一个包内字段/列表组件。成功且可识别的结果显示记录值;未完成调用、错误、格式异常和不支持的结果格式保留通用输入/输出。定时任务领域错误(包括持久化不确定和删除目标不存在)保留原始结果。
|
||||
|
||||
工具行保留展开和 Inspect 交互。任务清单标记是静态的,并带有可访问的状态标签。定时任务使用查看者时区的绝对日期,状态取自记录的结果。目标详情区分等待继续的活动目标和已启动的目标。这些显示值不订阅当前目标、任务清单或定时任务投影。
|
||||
|
||||
任务清单的变化将每个根调用或嵌套调用与之前的持久化 `todo/write` 比较,因此后续写入不会改变较早的卡片。对话组装器在加载更早的历史时修复前序记录。前序记录尚不明确时,卡片展示提交的列表,不推断新增或删除。
|
||||
|
||||
这项有限的视觉调整是[客户端派生工具呈现](2026-08-23-client-derived-tool-presentation.zh.md)中视觉等价要求的例外。该决策中的原始事件所有权、通用回退和唯一按键注册表仍然有效。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**共享呈现框架:**现有按键插槽已负责工具分发。仅复用详情内容,可以在其他包出现需求前将抽象限制在包内。
|
||||
|
||||
**在每张卡片中嵌入仪表盘:**交互控件、进度图形和实时状态会增加视觉负担,也可能歪曲历史调用返回的事实。紧凑的只读字段和列表保留了对话的层级。
|
||||
|
||||
## 影响
|
||||
|
||||
已注册的工具获得可读详情,无需改变 Host 工具、Session 事件或公开的 Client 导出。工具专用适配器解读记录中的 JSON 和文本;未识别的格式保留通用输出。手写的 tool-details Session 回放覆盖这些工具组装后的渲染。
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-05-agent-teams.md
|
||||
2026-08-05-agent-teams.md: bdf646db9b1617ded7f50f4145887f34a52ae83c
|
||||
2026-08-05-agent-teams.zh.md: 5386044edb57dad9fabf114d565808c735846464
|
||||
2026-08-05-agent-teams.md: d108d4f9833aca2a24c84d18d895e7ec63c0bfc2
|
||||
2026-08-05-agent-teams.zh.md: abf2ce3716e44effc8e34c0911cd9561d815aa08
|
||||
@@ -40,7 +40,7 @@ Fresh children have no inherited conversation. Fork children capture the Lead's
|
||||
|
||||
Peer communication is a Lead-log mailbox. `team/message/queued` is appended and flushed before delivery. The target message carries the stable message id and sender identity in both durable source metadata and a short model-visible prefix. A target receipt is acknowledged with `team/message/delivered` only after its pending inbox item or recorded user message is flushed. Immediate admission is serialized per target in queued-log order, recovery retries queued-minus-delivered in the same order, and delivery folds live or persisted target inbox/history state before cold resume. Every current-version Team payload is runtime-validated before entering replay state. The Team runtime tracks dispatch and asynchronous acknowledgement work from synchronous admission until settlement; disposal closes admission and awaits both before removing the service. Current waiters wake only after the owning Team event flush succeeds.
|
||||
|
||||
`send_message` always attempts Steer delivery. A running target receives the message at the nearest step boundary, an idle target starts a turn, and an inactive teammate cold-resumes. Success means the message is already durable even when temporary delivery failure leaves it queued. The mechanism provides process-local retry and target-Session de-duplication, not a cross-process exactly-once claim. The [Team Steer messaging decision](../../archived/simplification/2026-08-30-team-send-message-steer.md) owns the single-tool scheduling rationale.
|
||||
`send_message` always attempts Steer delivery. A running target receives the message at the nearest step boundary; an inactive target starts a turn if loaded or cold-resumes otherwise. Success means the message is already durable even when temporary delivery failure leaves it queued. The mechanism provides process-local retry and target-Session de-duplication, not a cross-process exactly-once claim. The [Team Steer messaging decision](../../archived/simplification/2026-08-30-team-send-message-steer.md) owns the single-tool scheduling rationale.
|
||||
|
||||
Shared tasks are complete snapshots with Team-local ids and monotonic revisions. Every mutation carries `expectedRevision`. Any member creates, reads, or claims a ready unowned task; the owner or Lead edits and transitions it, while only the Lead assigns another member. Numeric task ids remain within the safe-integer allocation range, and exhaustion fails without reusing an id. Dependencies must name non-deleted tasks and form a complete DAG. Deleted tasks are retained tombstones. `writeScopes` are normalized path prefixes that produce overlap diagnostics but never block claim or authorize a write.
|
||||
|
||||
@@ -72,6 +72,8 @@ Worktree isolation is not a harness runtime behavior. A deployment or prompt may
|
||||
|
||||
Package tests cover identity, name and authority checks, provider selection, reserved-id persistence collisions, child-before-Lead flush ordering, durable provisioning failure and pending-inbox JSONL reconciliation, concurrent target-local ordering, pending/history de-duplication, mailbox limits, post-flush notification, bounded disposal with in-flight creation and dispatch cancellation, failed-member cleanup, task CAS and DAG validation, write-scope warnings, wait cancellation/timeout, inbox-preserving interruption, ordinary-fork isolation, legacy-control shadowing, compact declared-schema result rendering, and scoped registration HMR at per-file 100% coverage. A keyless product snapshot loads the Agent Teams profile bundle through `dsh --profile headless` and pins its complete model-visible tool list, Team policy, and durable workflow projection for two teammates, dependent tasks, peer delivery, waiting, completion, and aggregation. A CLI e2e reuses the same deterministic adapter and verifies normal process exit with persisted Team and child logs.
|
||||
|
||||
Model-facing member identities and availability follow the [tool projection decision](../simplification/2026-09-15-model-agent-availability-and-team-targets.md); service residency and durable identity remain distinct.
|
||||
|
||||
## Consequences
|
||||
|
||||
The Lead Session grows with whole task/member snapshots and mailbox acknowledgements. This favors independently inspectable recovery over compact deltas; configured task and pending-mail bounds cap active state, while deleted and delivered history remains append-only until broader Session retention applies.
|
||||
|
||||
@@ -40,7 +40,7 @@ fresh child 不继承对话。fork child 只捕获一次 Lead 已完成 turn 前
|
||||
|
||||
Peer 通讯使用 Lead 日志 mailbox。投递前先追加并 flush `team/message/queued`。target message 会在持久 source metadata 与短模型可见前缀中同时携带稳定 message id 和 sender identity。只有 pending inbox 条目或已记录用户消息完成 flush,Lead 日志才写入 `team/message/delivered` acknowledgement。即时准入按 target 和 queued 日志顺序串行化,恢复按同一顺序重试 queued-minus-delivered,并在冷恢复前折叠 live 或 persisted target 的 inbox/历史状态。每个当前版本 Team payload 都会经过运行时验证后才进入 replay state。Team runtime 从同步准入到 settlement 全程跟踪 dispatch 与异步 acknowledgement 工作;dispose 会关闭准入,并在移除服务前等待两者。当前 waiter 只在所属 Team event flush 成功后被唤醒。
|
||||
|
||||
`send_message` 始终尝试 Steer 投递。running target 在最近的步骤边界收到消息,idle target 启动一个轮次,inactive teammate 则冷恢复。即使临时投递失败让消息保持 queued,成功也表示消息已经持久化。该机制提供进程内重试与 target Session 去重,不宣称跨进程 exactly-once。[Team Steer 消息决策](../../archived/simplification/2026-08-30-team-send-message-steer.md)负责单工具调度的理由。
|
||||
`send_message` 始终尝试 Steer 投递。running target 在最近的步骤边界收到消息,inactive target 在已加载时启动一个轮次,否则冷恢复。即使临时投递失败让消息保持 queued,成功也表示消息已经持久化。该机制提供进程内重试与 target Session 去重,不宣称跨进程 exactly-once。[Team Steer 消息决策](../../archived/simplification/2026-08-30-team-send-message-steer.md)负责单工具调度的理由。
|
||||
|
||||
共享 task 是带 Team-local id 与单调 revision 的完整快照。每次变更都携带 `expectedRevision`。任意 member 可以创建、读取或 claim ready 且无 owner 的任务;Owner 或 Lead 可以编辑和转换;只有 Lead 可以分配给另一个 member。数字 task id 保持在安全整数分配范围内;该范围耗尽时会失败,不会复用 id。依赖必须指向未删除任务,并形成完整 DAG。删除任务保留为 tombstone。`writeScopes` 是规范化路径前缀,只产生重叠诊断,绝不会阻止 claim 或授予写权限。
|
||||
|
||||
@@ -72,6 +72,8 @@ Worktree isolation 不是 harness runtime 行为。deployment 或 prompt 可以
|
||||
|
||||
Package test 以逐文件 100% coverage 覆盖身份、名字与权限检查、provider 选择、预留 id 持久化冲突、child-before-Lead flush 顺序、持久 provisioning 失败与 pending-inbox JSONL 对账、target-local 并发顺序、pending/history 去重、mailbox 限额、flush 后 notification、取消在途创建与 dispatch 的有界 dispose、failed member cleanup、task CAS 与 DAG 校验、write-scope warning、wait cancel/timeout、保留 inbox 的 interrupt、普通 fork 隔离、旧 control shadowing、声明 schema 的紧凑结果渲染与 scoped registration HMR。一条 keyless 产品快照会通过 `dsh --profile headless` 加载 Agent Teams profile bundle,并为两个 teammate、依赖任务、peer 投递、等待、完成和汇总固定完整的面向模型工具列表、Team policy 与持久 workflow 投影。CLI e2e 会复用同一个确定性 adapter,并验证带持久 Team 与 child 日志的正常退出。
|
||||
|
||||
模型可见的成员身份与可用状态遵循[工具投影决策](../simplification/2026-09-15-model-agent-availability-and-team-targets.zh.md);服务驻留状态和持久身份仍保持区分。
|
||||
|
||||
## Consequences
|
||||
|
||||
Lead Session 会随着完整 task/member 快照与 mailbox acknowledgement 增长。该设计用可独立检查的恢复能力换取更紧凑的 delta;配置的 task 与 pending-mail 限额限制 active state,而 deleted 与 delivered 历史会保持 append-only,直到更广泛的 Session retention 生效。
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.md
|
||||
2026-09-13-macos-hidden-titlebar-vibrancy.md: 1bc531953cedac8633c4baa6da45aa9bcd77a728
|
||||
2026-09-13-macos-hidden-titlebar-vibrancy.zh.md: bbc593badbabdccaa037cca836a9554929d614bc
|
||||
2026-09-13-macos-hidden-titlebar-vibrancy.md: 99ac240698fb229f9a0d66a8000251058b52491e
|
||||
2026-09-13-macos-hidden-titlebar-vibrancy.zh.md: 26bbb616485e49de2f70a3d142ea84dfd5bd7cb9
|
||||
@@ -18,7 +18,7 @@ Every macOS web-side adjustment keys off `html[data-platform='darwin']`, which o
|
||||
|
||||
**Native theme sync.** The vibrancy material follows `nativeTheme.themeSource`, which otherwise tracks the OS appearance and diverges from the app's own theme preference. The ui-theme boot script and ui-layout `ThemePresenter` publish `html[data-ds-theme-source]` (`light`, `dark`, or `system`; a fixed preference, including registered theme ids, publishes its resolved scheme). The app preload observes the attribute and forwards it over `dsh-desktop:native-theme-set`; main validates the value and the sender (the main window's WebContents, including its local static Web document) and assigns `nativeTheme.themeSource`. Publishing the preference rather than the resolved scheme preserves OS-follow while the preference is `system`.
|
||||
|
||||
**Sidebar top strip and full hide.** On darwin the sidebar opens with a 52px top strip that clears the traffic lights, carries the collapse toggle, and is a window drag region (`-webkit-app-region: drag`; the toggle opts out). Collapsing the sidebar hides the column entirely — `computeColumns` takes an explicit `collapsedWidth` and the AppFrame passes 0 on darwin desktop — instead of the 56px rail the other platforms keep. The reopen affordances move into the conversation header: a new single, session-scoped slot `conversation.session.header.leading` sits before the breadcrumbs, and ui-sidebar registers `HeaderLeadingControls` (open sidebar + New Session, reusing the shell's inject face and locale) into it. Visibility is pure CSS against the AppFrame-published `data-sidebar-collapsed` attribute; no collapse-state pipe is added. The blank-session header keeps the leading seat mounted on darwin so a hidden sidebar always has a reopen control on screen.
|
||||
**Sidebar top strip and full hide.** On darwin the sidebar opens with a 52px top strip that clears the traffic lights, carries the collapse toggle, and is a window drag region (`-webkit-app-region: drag`; the toggle opts out). Collapsing the sidebar hides the column entirely — `computeColumns` takes an explicit `collapsedWidth` and the AppFrame passes 0 on darwin desktop — instead of the 56px rail the other platforms keep. The reopen affordances move into the conversation header: a single, root-scoped slot `conversation.header.leading` sits before the breadcrumbs, and ui-sidebar registers `HeaderLeadingControls` (open sidebar + New Session, reusing the shell's inject face and locale) into it. Visibility is pure CSS against the AppFrame-published `data-sidebar-collapsed` attribute; no collapse-state pipe is added. The resident header keeps the leading seat mounted on darwin with or without a selected Session so a hidden sidebar always has a reopen control on screen.
|
||||
|
||||
**Drag regions.** The conversation title row is a drag region on darwin with every interactive descendant opted out. Electron computes drag regions from window geometry in DOM order, not stacking: an overlay covering the title band must subtract itself or the band underneath keeps taking the pointer. The right sidebar's fullscreen panel therefore sets `-webkit-app-region: no-drag` on its whole box and restores drag on its tab strips' blank runs.
|
||||
|
||||
@@ -40,10 +40,10 @@ Every macOS web-side adjustment keys off `html[data-platform='darwin']`, which o
|
||||
|
||||
- The macOS window gains a translucent sidebar and hidden titlebar at zero cost to other platforms: every rule is scoped to `[data-platform='darwin']`, which only the Electron preload sets.
|
||||
- The vibrancy material follows the app theme, including third-party registered themes (their resolved scheme). Screenshots and screen recordings differ from the flat web rendering.
|
||||
- `conversation.session.header.leading` is a public slot in the client catalog; any package can occupy the seat, and ui-sidebar's occupant assumes it may render into it whenever the platform matches.
|
||||
- `conversation.header.leading` is a public slot in the client catalog; any package can occupy the seat, and ui-sidebar's occupant assumes it may render into it whenever the platform matches.
|
||||
- Drag-region geometry is a window-global invariant: any future overlay that covers the title band on darwin must subtract itself with `-webkit-app-region: no-drag` or its controls become unclickable.
|
||||
- The transparent window plus vibrancy is accepted to look different in screen sharing and may flash on startup; the transparent `backgroundColor` mitigates the flash.
|
||||
|
||||
## Testing
|
||||
|
||||
ui-theme boot and ui-layout presenter specs pin the `data-ds-theme-source` publication and disposal. The ui-sidebar apply spec pins the leading-seat registration (component, locale, shared inject face) and its removal on teardown. The ui-conversation skeleton spec pins the leading slot's render call in the active-phase header. The ui-theme corner-shape and full-round style gates cover the new stylesheet.
|
||||
ui-theme boot and ui-layout presenter specs pin the `data-ds-theme-source` publication and disposal. The ui-sidebar apply spec pins the leading-seat registration (component, locale, shared inject face) and its removal on teardown. The ui-conversation skeleton spec pins the leading slot's render call with and without a selected Session. The ui-theme corner-shape and full-round style gates cover the new stylesheet.
|
||||
@@ -18,7 +18,7 @@ Electron 主进程在 darwin 上以 `titleBarStyle: 'hiddenInset'`、`trafficLig
|
||||
|
||||
**原生主题同步。** 毛玻璃材质跟随 `nativeTheme.themeSource`,后者默认跟踪系统外观,会与应用自身的主题偏好背离。ui-theme 引导脚本与 ui-layout 的 `ThemePresenter` 发布 `html[data-ds-theme-source]`(`light`、`dark` 或 `system`;固定偏好——包括注册主题 id——发布其解析后的配色)。应用 preload 观察该属性并经 `dsh-desktop:native-theme-set` 转发;主进程校验取值与发送者(主窗口的 WebContents,包括其本地静态 Web 文档)后赋给 `nativeTheme.themeSource`。发布偏好而非解析值,可在偏好为 `system` 时保留跟随系统。
|
||||
|
||||
**侧边栏顶部条与完全隐藏。** darwin 上侧边栏展开时有一条 52px 的顶部条,避开红绿灯、承载收起按钮,并作为窗口拖拽区(`-webkit-app-region: drag`;按钮退出拖拽)。收起侧边栏时整列隐藏——`computeColumns` 接受显式 `collapsedWidth`,AppFrame 在 darwin 桌面传 0——而非其他平台保留的 56px rail。重新打开的入口移入会话头部:新增 single、session 作用域的 slot `conversation.session.header.leading` 位于面包屑之前,ui-sidebar 向其注册 `HeaderLeadingControls`(打开侧边栏 + 新会话,复用 shell 的 inject face 与 locale)。显隐纯由 CSS 依据 AppFrame 发布的 `data-sidebar-collapsed` 属性控制;不新增收起状态管道。darwin 上空白会话的头部保持 leading 座挂载,确保侧边栏隐藏时屏幕上始终有重新打开的控件。
|
||||
**侧边栏顶部条与完全隐藏。** darwin 上侧边栏展开时有一条 52px 的顶部条,避开红绿灯、承载收起按钮,并作为窗口拖拽区(`-webkit-app-region: drag`;按钮退出拖拽)。收起侧边栏时整列隐藏——`computeColumns` 接受显式 `collapsedWidth`,AppFrame 在 darwin 桌面传 0——而非其他平台保留的 56px rail。重新打开的入口移入会话头部:single、root 作用域的 slot `conversation.header.leading` 位于面包屑之前,ui-sidebar 向其注册 `HeaderLeadingControls`(打开侧边栏 + 新会话,复用 shell 的 inject face 与 locale)。显隐纯由 CSS 依据 AppFrame 发布的 `data-sidebar-collapsed` 属性控制;不新增收起状态管道。darwin 上常驻头部在选中或未选中 Session 时均保持 leading 座挂载,确保侧边栏隐藏时屏幕上始终有重新打开的控件。
|
||||
|
||||
**拖拽区。** darwin 上会话标题行是拖拽区,所有可交互后代退出。Electron 按 DOM 顺序以窗口几何计算拖拽区,不看层叠:覆盖标题带的浮层必须自行减除,否则下层区域仍会截获指针。因此右侧边栏的全屏面板对整个盒子设 `-webkit-app-region: no-drag`,再在其 tab 条空白处恢复拖拽。
|
||||
|
||||
@@ -40,10 +40,10 @@ Electron 主进程在 darwin 上以 `titleBarStyle: 'hiddenInset'`、`trafficLig
|
||||
|
||||
- macOS 窗口获得半透明侧边栏与隐藏标题栏,对其他平台零成本:所有规则限定在 `[data-platform='darwin']` 下,该属性仅由 Electron preload 设置。
|
||||
- 毛玻璃材质跟随应用主题,含第三方注册主题(取其解析配色)。截图与录屏与纯 Web 的平面渲染不同。
|
||||
- `conversation.session.header.leading` 是客户端 catalog 中的公开 slot;任何包都可占用该座位,ui-sidebar 的占用者假定平台匹配时随时可能渲染。
|
||||
- `conversation.header.leading` 是客户端 catalog 中的公开 slot;任何包都可占用该座位,ui-sidebar 的占用者假定平台匹配时随时可能渲染。
|
||||
- 拖拽区几何是窗口级全局不变量:今后任何在 darwin 上覆盖标题带的浮层必须用 `-webkit-app-region: no-drag` 自行减除,否则其控件不可点击。
|
||||
- 接受透明窗口 + 毛玻璃在屏幕共享中呈现不同、启动可能闪烁;透明 `backgroundColor` 缓解闪烁。
|
||||
|
||||
## Testing
|
||||
|
||||
ui-theme 引导与 ui-layout presenter 测试钉住 `data-ds-theme-source` 的发布与清除。ui-sidebar apply 测试钉住 leading 座注册(组件、locale、共享 inject face)及 teardown 移除。ui-conversation skeleton 测试钉住 active 阶段头部对 leading slot 的渲染调用。ui-theme 的 corner-shape 与 full-round 样式门覆盖新样式表。
|
||||
ui-theme 引导与 ui-layout presenter 测试钉住 `data-ds-theme-source` 的发布与清除。ui-sidebar apply 测试钉住 leading 座注册(组件、locale、共享 inject face)及 teardown 移除。ui-conversation skeleton 测试钉住 选中或未选中 Session 时对 leading slot 的渲染调用。ui-theme 的 corner-shape 与 full-round 样式门覆盖新样式表。
|
||||
+2
-2
@@ -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: e4fc7cab8c274148191632e8cc125ac75f2ec1d5
|
||||
2026-08-08-native-windows-pull-request-ci.zh.md: 27ad602c3748f2920e6b63f271a6a38b11c0ab77
|
||||
2026-08-08-native-windows-pull-request-ci.md: 64c4366ee8d60dd0137e6e4eb5473c0450204efe
|
||||
2026-08-08-native-windows-pull-request-ci.zh.md: 48725cdc58ab7b8db22461da64236c6de3bb3027
|
||||
@@ -14,11 +14,11 @@ A coverage audit found that stale branch state had restored temporary exclusions
|
||||
|
||||
The master-only `windows` job in [ci-master.yml](../../../../.github/workflows/ci-master.yml) runs `windows node 24 / wine` on `ubuntu-latest`. It retains the checksum-verified Windows Node, Wine apt and pnpm caches, a hoisted install confined to a workspace snapshot, and the [shared Wine gate script](../../../../scripts/wine-windows-gates.sh) that runs the workspace build and production site. Node distribution transfers use bounded retries; when nodejs.org stalls on the large archive, a range-capable transport mirror resumes the same bytes, but nodejs.org remains the version and SHA-256 authority and the archive is never promoted before that checksum passes. Wine is outside the PR aggregate under the [master-only platform policy](2026-09-06-master-only-platform-ci.md). The [archived Wine experiment](../../archived/process/2026-07-27-wine-windows-gates-experiment.md) preserves its measured trade-offs, while this note owns the current dual topology.
|
||||
|
||||
Every pull request also starts four independent native jobs on the organization-owned `dsh-windows-2025-16core` runner: `windows-build`, `windows-coverage`, `windows-native-tests`, and `windows-observational`. Each job enables Developer Mode for workspace symlinks, provisions the repository-pinned pnpm through `pnpm/action-setup`, performs an immutable install without a transferred store archive, and runs its inventory under native PowerShell. The Windows failover variable (`DSH_CI_FAILOVER_WINDOWS`) retargets all four jobs to the in-house pool under `selfhosted` and to Blacksmith's Windows runners under `blacksmith` (see the [blacksmith failover leg note](2026-09-09-blacksmith-failover-leg.md)). Per-job deadlines range from 60 to 120 minutes and bound stuck work without treating a performance target as a correctness deadline.
|
||||
Every pull request starts three native jobs: `windows-build`, `windows-coverage`, and `windows-native-tests`. Each job enables Developer Mode for workspace symlinks, provisions the repository-pinned pnpm through `pnpm/action-setup`, performs an immutable install without a transferred store archive, and runs its inventory under native PowerShell. The Windows failover variable (`DSH_CI_FAILOVER_WINDOWS`) selects the organization-owned `dsh-windows-2025-16core` runner by default, the in-house pool under `selfhosted`, or Blacksmith runners under `blacksmith` (see the [blacksmith failover leg note](2026-09-09-blacksmith-failover-leg.md)). Blacksmith build and coverage jobs request 16 vCPU; file-serial native tests request 2 vCPU. Per-job deadlines range from 60 to 120 minutes.
|
||||
|
||||
`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-build` and `windows-native-tests` are dependencies of `all checks passed`; their workspace-build and targeted native-process results are blocking. After its blocking build and production-site checks succeed, `windows-build` runs `check:ci:windows-observational-ready` in the same workspace. This step uses `continue-on-error`, disables fail-fast, and has a 15-minute timeout; failures produce a workflow warning because Linux owns the blocking static, documentation, package, and built-artifact verdicts. The required verdict waits for the step to finish but does not require its diagnostics to pass. `windows-coverage` remains outside aggregate `needs`, so its 100%-per-file result stays red and visible without delaying the verdict.
|
||||
|
||||
`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.
|
||||
`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`. The observational step reuses the successful build, retains the distinct MPA documentation build, starts independent static gates together, and uses CI worker defaults for `publint`, with an eight-worker override on shared self-hosted runners. Its built-bin smoke starts only after every other observational gate settles, including failed gates. The ready aggregate requires a successful prior workspace build; it removes only that build and its internal dependency edges from the shared internal inventory. 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 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.
|
||||
|
||||
@@ -40,7 +40,7 @@ Shiki disables lazy TextMate-regex compilation and warms each boot grammar befor
|
||||
|
||||
**Run only Wine on pull requests.** Wine reaches blocking win32 toolchain branches quickly, but can report green while a real NT, NTFS, PowerShell, process, or addon contract is broken.
|
||||
|
||||
**Mark every non-blocking native job `continue-on-error`.** The observational job uses this setting because Linux owns its blocking verdict. Coverage remains an ordinary job outside aggregate `needs`, so a threshold failure stays visibly red without blocking the aggregate.
|
||||
**Mark every non-blocking native job `continue-on-error`.** Only the observational step uses this setting because Linux owns its blocking verdict. Its containing build job must still fail when the blocking build or production site fails. Coverage remains an ordinary job outside aggregate `needs`, so a threshold failure stays visibly red without blocking the aggregate.
|
||||
|
||||
**Exclude unsupported-looking files or weaken Windows fixtures.** Rejected because the affected LSP, watcher, persistence, client, and process behavior is supported. Peer-platform branches are marked narrowly; portable outcomes stay in the denominator and are exercised through host-realistic fixtures.
|
||||
|
||||
@@ -50,8 +50,8 @@ Shiki disables lazy TextMate-regex compilation and warms each boot grammar befor
|
||||
|
||||
## Consequences
|
||||
|
||||
Wine provides post-merge toolchain evidence. Native coverage and observational results can still be pending or red when `all checks passed` turns green, so branch protection consumes the targeted native build and process checks while reviewers and follow-up automation consume the remaining native results.
|
||||
Wine provides post-merge toolchain evidence. Native coverage may still be pending or red when `all checks passed` turns green. Observational diagnostics finish inside the required build job. A diagnostic failure leaves the PR check successful; the failed step, workflow warning, and run summary expose the failure.
|
||||
|
||||
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.
|
||||
Every pull request receives a real NT kernel, NTFS, PowerShell, Windows process, native addon, and supported-source coverage signal. Observational checks reuse the successful build workspace, avoiding a second checkout, dependency installation, and workspace build. Their sequential position extends the build job and can delay the required verdict; it also separates diagnostic processes from compilation. The ready and master aggregates share one internal diagnostic inventory; master retains its own workspace build and full checks.
|
||||
|
||||
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.
|
||||
@@ -14,11 +14,11 @@ Wine 在 Linux 内核与区分大小写的 ext4 之上采用 hoisted 依赖布
|
||||
|
||||
[ci-master.yml](../../../../.github/workflows/ci-master.yml) 中仅 master 触发的 `windows` 作业在 `ubuntu-latest` 上运行 `windows node 24 / wine`。它保留经过校验和验证的 Windows Node、Wine apt 与 pnpm 缓存、仅限工作区快照的 hoisted 安装,以及运行工作区构建与生产网站的[共享 Wine 门禁脚本](../../../../scripts/wine-windows-gates.sh)。Node 分发文件传输采用有界重试;nodejs.org 的大文件传输停滞时,由支持范围请求的传输镜像续传相同字节,但版本和 SHA-256 权威仍属于 nodejs.org,归档通过该校验前绝不会投入使用。根据[仅 master 平台策略](2026-09-06-master-only-platform-ci.zh.md),Wine 不参与 PR 聚合。[已归档的 Wine 实验](../../archived/process/2026-07-27-wine-windows-gates-experiment.md)保留其实测取舍,而本文负责当前双通道拓扑。
|
||||
|
||||
每个拉取请求还会在组织自有的 `dsh-windows-2025-16core` 运行器上启动 4 个相互独立的原生作业:`windows-build`、`windows-coverage`、`windows-native-tests` 与 `windows-observational`。每个作业都会为工作区符号链接启用开发人员模式,通过 `pnpm/action-setup` 提供仓库固定版本的 pnpm,在不传输 store 归档的情况下执行不可变安装,并在原生 PowerShell 下运行自己的清单。Windows 故障切换变量(`DSH_CI_FAILOVER_WINDOWS`)在 `selfhosted` 下把这 4 个作业全部重定向到公司内部运行器池,在 `blacksmith` 下重定向到 Blacksmith 的 Windows 运行器(见 [blacksmith 故障切换支路笔记](2026-09-09-blacksmith-failover-leg.zh.md))。各作业采用 60 至 120 分钟的截止时间,以约束卡住的工作,同时不把性能目标当作正确性截止时间。
|
||||
每个拉取请求都会启动三个原生作业:`windows-build`、`windows-coverage` 和 `windows-native-tests`。每个作业都会为 workspace 符号链接启用开发者模式,通过 `pnpm/action-setup` 提供仓库固定版本的 pnpm,在不传输 store 归档的情况下执行不可变安装,并在原生 PowerShell 下运行自己的清单。Windows 故障切换变量(`DSH_CI_FAILOVER_WINDOWS`)默认选择组织自有的 `dsh-windows-2025-16core` 运行器,在 `selfhosted` 下选择公司内部运行器池,在 `blacksmith` 下选择 Blacksmith 运行器(见 [blacksmith 故障切换支路笔记](2026-09-09-blacksmith-failover-leg.zh.md))。Blacksmith 构建和覆盖率作业请求 16 vCPU;逐文件串行的原生测试请求 2 vCPU。各作业的截止时间为 60 至 120 分钟。
|
||||
|
||||
`windows-build` 与 `windows-native-tests` 是 `all checks passed` 的依赖项;其工作区构建和定向原生进程结果具有阻断性。`windows-coverage` 仍是常规作业,但不在聚合流程的 `needs` 中,因此逐文件 100% 覆盖率结果会保持红灯并可见,却不会延迟必需判定。`windows-observational` 同样不在聚合流程的 `needs` 中,并使用 `continue-on-error`,因为静态检查、文档、包与构建产物的阻断性判定由 Linux 负责。
|
||||
`windows-build` 与 `windows-native-tests` 是 `all checks passed` 的依赖项;其工作区构建和定向原生进程结果具有阻断性。阻断性构建和生产网站检查成功后,`windows-build` 在同一工作区运行 `check:ci:windows-observational-ready`。该步骤使用 `continue-on-error`、关闭 fail-fast,并设置 15 分钟超时;失败时会产生工作流警告,因为静态检查、文档、包与构建产物的阻断性判定由 Linux 负责。必需判定会等待该步骤结束,但不要求其诊断通过。`windows-coverage` 仍在聚合流程的 `needs` 之外,因此逐文件 100% 覆盖率结果会保持红灯并可见,却不会延迟判定。
|
||||
|
||||
`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 只覆盖不可达分支(另一平台专属分支、生命周期内不可达的防御守卫),其行为测试仍保留在所属平台。
|
||||
`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 预算。观测步骤复用成功的构建,保留独立的 MPA 文档构建,一起启动相互独立的静态门禁,并让 `publint` 使用 CI worker 默认值,在共享自托管运行器上覆盖为 8 个 worker。其 built-bin 冒烟测试只在其他所有观测性门禁结算后启动,包括失败的门禁。ready 聚合要求此前已成功构建工作区;它仅从内部共用清单中移除该构建及其内部依赖边。这可避免有界的真实应用启动测量与 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 致命故障,因此增加核心数没有带来可靠的墙钟时间改善。
|
||||
|
||||
@@ -40,7 +40,7 @@ Shiki 会禁用 TextMate 正则的延迟编译,并在用户内容进入保持
|
||||
|
||||
**只在拉取请求上运行 Wine。** Wine 能快速触达阻断性 win32 工具链分支,但即使真实 NT、NTFS、PowerShell、进程或原生插件约定已经损坏,也可能报告绿灯。
|
||||
|
||||
**将每个非阻断原生作业都标记为 `continue-on-error`。** 观测性作业采用该设置,因为它的阻断性判定由 Linux 负责。覆盖率仍是聚合流程 `needs` 之外的常规作业,因此阈值失败会保持明显红灯,却不会阻断聚合流程。
|
||||
**将每个非阻断原生作业都标记为 `continue-on-error`。** 只有观测步骤采用该设置,因为它的阻断性判定由 Linux 负责。包含该步骤的构建作业仍必须在阻断性构建或生产网站失败时失败。覆盖率仍是聚合流程 `needs` 之外的常规作业,因此阈值失败会保持明显红灯,却不会阻断聚合流程。
|
||||
|
||||
**排除看似不受支持的文件或削弱 Windows fixture。** 不予采纳,因为受影响的 LSP、watcher、持久化、客户端与进程行为均受支持。仅适用于另一平台的分支采用窄范围标注;可移植结果继续计入分母,并通过符合真实宿主行为的 fixture 验证。
|
||||
|
||||
@@ -50,8 +50,8 @@ Shiki 会禁用 TextMate 正则的延迟编译,并在用户内容进入保持
|
||||
|
||||
## 后果
|
||||
|
||||
Wine 提供合并后的工具链证据。`all checks passed` 变绿时,原生覆盖率与观测性结果仍可能处于待处理或红灯状态,因此分支保护采用定向原生构建和进程检查,而评审者和后续自动化采用其余原生结果。
|
||||
Wine 提供合并后的工具链证据。`all checks passed` 变绿时,原生覆盖率仍可能处于待处理或红灯状态。观测诊断在必需的构建作业内完成。诊断失败时,PR 检查仍显示成功;失败步骤、工作流警告和运行摘要会展示该失败。
|
||||
|
||||
尽管如此,每个拉取请求都会获得真实 NT 内核、NTFS、PowerShell、Windows 进程、原生插件和受支持源码覆盖率信号。原生作业会在构建、覆盖率与观测性工作区中重复设置流程,并在构建与观测性工作区中重复构建,但它们会降低每个作业的进程数,并暴露兼容性通道掩盖的路径、watcher、生命周期与 fixture 缺陷。
|
||||
每个拉取请求都会获得真实 NT 内核、NTFS、PowerShell、Windows 进程、原生插件和受支持源码覆盖率信号。观测检查复用成功构建的工作区,省去第二次检出、依赖安装和工作区构建。顺序执行会延长构建作业,并可能延迟必需判定;它也让诊断进程与编译分开运行。ready 与 master 聚合共用一份内部诊断清单;master 保留自己的工作区构建和完整检查。
|
||||
|
||||
维护者必须保留两种有意设计的执行拓扑: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-09-09-blacksmith-failover-leg.md
|
||||
2026-09-09-blacksmith-failover-leg.md: d4be1ad25c6c5b66e295439d4609726c8918e137
|
||||
2026-09-09-blacksmith-failover-leg.zh.md: cd6e74aefba2cba9481fa1ae9510bb1ce020325d
|
||||
2026-09-09-blacksmith-failover-leg.md: 4fb4ed4ccb500887622200e01310230393b3de12
|
||||
2026-09-09-blacksmith-failover-leg.zh.md: c2c4ee76a3fcd8c9f16c635ac81bc987d40a04e2
|
||||
@@ -10,9 +10,9 @@ The [failover runbook](2026-07-26-ci-failover-runbook.md) routes degraded enterp
|
||||
|
||||
## Decision
|
||||
|
||||
`DSH_CI_FAILOVER_LINUX` and `DSH_CI_FAILOVER_WINDOWS` additionally accept the value `blacksmith`. When any repository writer sets it, the platform's participating jobs retarget onto Blacksmith's hosted runners at the vCPU size matching the pool each job replaces; any other value (or unset) keeps today's runner, and `selfhosted` keeps routing to the in-house pools. Like the existing values this is writer-manageable repository state — no merge, re-run the affected jobs — and it costs money only while set, which is why it is an explicit outage or experiment choice, never the default.
|
||||
`DSH_CI_FAILOVER_LINUX` and `DSH_CI_FAILOVER_WINDOWS` additionally accept the value `blacksmith`. When any repository writer sets it, the platform's participating jobs retarget onto Blacksmith's hosted runners at each job's configured vCPU size; any other value (or unset) keeps today's runner, and `selfhosted` keeps routing to the in-house pools. Like the existing values this is writer-manageable repository state — no merge, re-run the affected jobs — and it costs money only while set, which is why it is an explicit outage or experiment choice, never the default.
|
||||
|
||||
Participating jobs under `blacksmith`: the three enterprise Linux workers, the `node-compat` legs, the `all-checks-passed` verdict, and the four Windows lanes in [ci.yml](../../../../.github/workflows/ci.yml) (16-core pools map to `blacksmith-16vcpu-ubuntu-2404` and `blacksmith-16vcpu-windows-2025`, standard-hosted legs to `blacksmith-4vcpu-ubuntu-2404`); `expected-filenames`; the sandbox bwrap leg; and the two manual benchmark matrices, whose 4-32 vCPU tiers map to Blacksmith equivalents while the 64/96-core rows keep the fleet labels because Blacksmith offers no such tier. The blacksmith branches carry no Dependabot exclusion: Blacksmith runners are ephemeral, so the persistent-VM rationale that keeps Dependabot on the hosted pool does not apply. The python-runtime builder stays hosted in full: its PR and master callers (`ci.yml`'s and `ci-master.yml`'s `python-runtime` jobs) pass real-API credentials (`DEEPSEEK_API_KEY_EXTERNAL`) into the reusable workflow, its macOS and arm64 single-exe legs need GitHub's images, and its wheel output feeds the PyPI publication chain. The docs-pages workflow is a tag-dispatched release publication, so it stays hosted too.
|
||||
Participating jobs under `blacksmith`: the three enterprise Linux workers, the `node-compat` legs, the `all-checks-passed` verdict, and the three Windows jobs in [ci.yml](../../../../.github/workflows/ci.yml), using the per-job runner sizes declared there; `expected-filenames`; the sandbox bwrap leg; and the two manual benchmark matrices, whose 4-32 vCPU tiers map to Blacksmith equivalents while the 64/96-core rows keep the fleet labels because Blacksmith offers no such tier. The blacksmith branches carry no Dependabot exclusion: Blacksmith runners are ephemeral, so the persistent-VM rationale that keeps Dependabot on the hosted pool does not apply. The python-runtime builder stays hosted in full: its PR and master callers (`ci.yml`'s and `ci-master.yml`'s `python-runtime` jobs) pass real-API credentials (`DEEPSEEK_API_KEY_EXTERNAL`) into the reusable workflow, its macOS and arm64 single-exe legs need GitHub's images, and its wheel output feeds the PyPI publication chain. The docs-pages workflow is a tag-dispatched release publication, so it stays hosted too.
|
||||
|
||||
Everything else keeps its own runner under every value: jobs pinned to standard hosted runners by workflow spec (`node-24-bench`, `python-sdk`, the master Wine gate, `release-publish` and `release-vendor-publish`, the Cloudflare preview, the weighted-approval pair); jobs that hold credentials (the real-API e2e lanes, the issue App-token jobs, the npm and PyPI publishers); jobs that need a host capability Blacksmith lacks (the Landlock legs and their pack verification, the Seatbelt leg, the linux-arm64 single-exe leg); and the release-shaped chains — the npm publishers, the PyPI publishers, the node-addon-system release chain, and the Pages deployment workflow — which stay entirely hosted because their artifacts feed published registry output. The release rehearsal jobs in `release.yml` and `release-vendor.yml` (`dependencies`, `pack`) keep their existing pre-change routing unchanged: they carry only the pre-existing `selfhosted` branch, so under `blacksmith` they fall back to their hosted `ubuntu-24.04` default exactly as under an unset variable, and they gain no `blacksmith` branch.
|
||||
|
||||
|
||||
@@ -10,9 +10,9 @@ Status: implemented
|
||||
|
||||
## 决策
|
||||
|
||||
`DSH_CI_FAILOVER_LINUX` 与 `DSH_CI_FAILOVER_WINDOWS` 额外接受值 `blacksmith`。任何仓库写者设置它时,该平台参与切换的作业按与被替换池匹配的 vCPU 档位重定向到 Blacksmith 托管运行器;任何其它值(或未设置)保持今天的运行器,`selfhosted` 继续路由到自有池。与既有取值一样,这是写者可管理的仓库状态——无需合并,重跑受影响的作业即可——并且只在设置期间产生费用,这正是它作为明确的故障或实验选择、而非默认的原因。
|
||||
`DSH_CI_FAILOVER_LINUX` 与 `DSH_CI_FAILOVER_WINDOWS` 额外接受值 `blacksmith`。任何仓库写者设置它时,该平台参与切换的作业按各作业配置的 vCPU 档位重定向到 Blacksmith 托管运行器;任何其它值(或未设置)保持今天的运行器,`selfhosted` 继续路由到自有池。与既有取值一样,这是写者可管理的仓库状态——无需合并,重跑受影响的作业即可——并且只在设置期间产生费用,这正是它作为明确的故障或实验选择、而非默认的原因。
|
||||
|
||||
`blacksmith` 下参与切换的作业:[ci.yml](../../../../.github/workflows/ci.yml) 中三个企业级 Linux 工作作业、`node-compat` 各腿、`all-checks-passed` 判定作业与四条 Windows 通道(16 核池映射到 `blacksmith-16vcpu-ubuntu-2404` 与 `blacksmith-16vcpu-windows-2025`,标准托管腿映射到 `blacksmith-4vcpu-ubuntu-2404`);`expected-filenames`;sandbox 的 bwrap 腿;以及两个手动基准矩阵——其 4-32 vCPU 档位映射到 Blacksmith 等价档,64/96 核行保留自有池标签,因为 Blacksmith 没有对应档位。blacksmith 分支不带 Dependabot 排除:Blacksmith 运行器是临时的,因此把 Dependabot 留在托管池的持久虚拟机理由不适用。python-runtime 构建器整体保持托管:其 PR 与 master 调用方(`ci.yml` 与 `ci-master.yml` 的 `python-runtime` 作业)会把真实 API 凭据(`DEEPSEEK_API_KEY_EXTERNAL`)传入该可复用工作流,其 macOS 与 arm64 单文件分支需要 GitHub 镜像,且其 wheel 产物供给 PyPI 发布链。docs-pages 工作流是 tag 派发的发布,因此同样保持托管。
|
||||
`blacksmith` 下参与切换的作业:[ci.yml](../../../../.github/workflows/ci.yml) 中三个企业级 Linux 工作作业、`node-compat` 各腿、`all-checks-passed` 判定作业与三个 Windows 作业,使用该工作流为各作业声明的运行器规格;`expected-filenames`;sandbox 的 bwrap 腿;以及两个手动基准矩阵——其 4-32 vCPU 档位映射到 Blacksmith 等价档,64/96 核行保留自有池标签,因为 Blacksmith 没有对应档位。blacksmith 分支不带 Dependabot 排除:Blacksmith 运行器是临时的,因此把 Dependabot 留在托管池的持久虚拟机理由不适用。python-runtime 构建器整体保持托管:其 PR 与 master 调用方(`ci.yml` 与 `ci-master.yml` 的 `python-runtime` 作业)会把真实 API 凭据(`DEEPSEEK_API_KEY_EXTERNAL`)传入该可复用工作流,其 macOS 与 arm64 单文件分支需要 GitHub 镜像,且其 wheel 产物供给 PyPI 发布链。docs-pages 工作流是 tag 派发的发布,因此同样保持托管。
|
||||
|
||||
其余作业在任何取值下都保留自己的运行器:被 workflow spec 钉死在标准托管运行器上的作业(`node-24-bench`、`python-sdk`、master 的 Wine 门禁、`release-publish` 与 `release-vendor-publish`、Cloudflare preview、weighted-approval 一对);持有凭据的作业(真实 API 的 e2e 通道、issue App token 作业、npm 与 PyPI 发布作业);需要 Blacksmith 缺失的宿主能力的作业(Landlock 各腿及其打包校验、Seatbelt 腿、linux-arm64 单文件分支);以及发布形态的链——npm 发布、PyPI 发布、node-addon-system 发布链与 Pages 部署工作流——它们整体保持托管,因为其产物供给已发布的 registry 输出。`release.yml` 与 `release-vendor.yml` 中的发布演练作业(`dependencies`、`pack`)保持改动前既有的路由不变:它们只带既有的 `selfhosted` 分支,因此在 `blacksmith` 取值下与变量未设置时一样回落到托管的 `ubuntu-24.04` 默认,并不新增 `blacksmith` 分支。
|
||||
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-09-15-model-agent-availability-and-team-targets.md
|
||||
2026-09-15-model-agent-availability-and-team-targets.md: e1f7f95cfa333be6d05677b4b19775c744f2817c
|
||||
2026-09-15-model-agent-availability-and-team-targets.zh.md: 0447d7d4059eb75d1fb9169389cd83a54292aadf
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
# Agent Note: Model agent availability and Team targets
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-15-model-agent-availability-and-team-targets.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Team operations accept member names, but returning both names and Session UUIDs invites the model to choose an unusable address. Loaded and stored agents also have distinct internal statuses despite requiring the same model action to continue work.
|
||||
|
||||
## Decision
|
||||
|
||||
The Team tool adapter returns `target` from the existing member name and omits member Session IDs from creation and listing results. Messaging, interruption, and task assignment consume that same name. Task `ownerName`, task IDs, and message IDs retain their existing meanings. Internal services, Web views, and persisted Team records retain Session identity.
|
||||
|
||||
Normal subagent tool results and Team service views project loaded-between-turns and stored agents as `inactive`. Only an executing turn is `running`; Team retains `provisioning` and `failed` for member creation, and ordinary subagent diagnostic rows retain their read-failure reasons. Interruption projects its previous status by the same rule. Availability does not report task outcomes or waiting dependencies.
|
||||
|
||||
The Team service owns roster and interruption availability for both tools and Web. The ordinary subagent projection belongs to its list tool. Runtime loading, cold resume, interruption authority, and mailbox delivery retain their existing behavior. Ordinary subagents continue to use `agent_id`; stable subagent names and historical-data handling are deferred. No persistence type or Session format changes.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Add stable names to ordinary subagents now.** This requires a durable naming policy and explicit handling of historical children without names. It is independent of removing misleading Team identities and model status distinctions.
|
||||
|
||||
**Merge internal runtime states.** Residency determines whether delivery starts a loaded agent or restores one from storage. Underlying Agent status and registry presence retain that distinction; Team views expose only turn availability.
|
||||
|
||||
## Consequences
|
||||
|
||||
Models can copy a Team result's target directly into applicable operations. They lose member UUIDs and residency details from these tool results; neither is needed for Team addressing. Ordinary subagent and Team addressing remain different until a separate naming decision.
|
||||
|
||||
Focused service and tool tests cover every status projection, returned-target operations, diagnostic rows, and live versus stored children. Recorded headless Team and SDK subagent scenarios pin the assembled model output and schemas. The [Agent Teams decision](../feature/2026-08-05-agent-teams.md) continues to own roster durability, scoping, and authority.
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
# Agent Note: 模型可见的 Agent 可用状态与 Team 目标
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-15-model-agent-availability-and-team-targets.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
Team 操作接受成员名称,但同时返回名称和 Session UUID 容易使模型选择不可用的地址。已加载与仅存储的 agent 也有不同的内部状态,尽管模型继续其工作时采取相同操作。
|
||||
|
||||
## 决策
|
||||
|
||||
Team 工具适配器从现有成员名称生成 `target`,并从创建和列表结果中省略成员 Session ID。消息、中断和任务分配使用同一名称。任务的 `ownerName`、任务 ID 和消息 ID 保持现有含义。内部服务、Web 视图和持久化 Team 记录保留 Session 身份。
|
||||
|
||||
普通 subagent 工具结果与 Team 服务视图把已加载但处于轮次之间的 agent 和仅存储的 agent 都投影为 `inactive`。只有正在执行轮次时才是 `running`;Team 保留表示成员创建状态的 `provisioning` 与 `failed`,普通 subagent 的诊断行保留读取失败原因。中断结果按同一规则投影之前的状态。可用状态不报告任务结果或等待依赖。
|
||||
|
||||
Team 服务为工具和 Web 统一提供 roster 与中断的可用状态。普通 subagent 的投影归其列表工具所有。运行时加载、冷恢复、中断权限和邮箱投递保持现有行为。普通 subagent 继续使用 `agent_id`;稳定的 subagent 名称和历史数据处理暂缓。不修改持久化类型或 Session 格式。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**立即为普通 subagent 增加稳定名称。** 这需要持久化命名策略,以及对历史无名称子级的明确处理。它与移除误导性的 Team 身份和模型状态区别相互独立。
|
||||
|
||||
**合并内部运行状态。** 驻留状态决定投递是启动已加载的 agent,还是从存储恢复。底层 Agent 状态与注册表中的存在性保留该区别;Team 视图仅暴露轮次可用状态。
|
||||
|
||||
## 影响
|
||||
|
||||
模型可以把 Team 结果的 target 直接复制到适用操作。这些工具结果不再提供成员 UUID 和驻留细节;Team 寻址不需要它们。在另行决定命名方案之前,普通 subagent 与 Team 的寻址方式仍不同。
|
||||
|
||||
定向服务与工具测试覆盖所有状态投影、返回目标的操作、诊断行和在线与仅存储子级。录制的 headless Team 与 SDK subagent 场景固定组装后的模型输出和 schema。[Agent Teams 决策](../feature/2026-08-05-agent-teams.zh.md)继续拥有 roster 持久化、作用域和权限规则。
|
||||
+22
-53
@@ -491,14 +491,12 @@ jobs:
|
||||
secrets:
|
||||
DEEPSEEK_API_KEY_EXTERNAL: ${{ secrets.DEEPSEEK_API_KEY_EXTERNAL }}
|
||||
|
||||
# Every pull request also gets real Windows-kernel signals. The former
|
||||
# monolithic windows-native job is split into smaller jobs so one slow
|
||||
# coverage gate does not hold up build/static results, while the total
|
||||
# per-job process count stays lower. Observational checks are non-blocking.
|
||||
# Native builds and process tests block pull requests independently of coverage.
|
||||
# Observational checks reuse the successful build and remain non-blocking.
|
||||
# Dependabot PRs are excluded from the self-hosted pool and stay queued for
|
||||
# the hosted runner.
|
||||
# Blacksmith uses 2 vCPU for file-serial native tests, 8 for observational
|
||||
# gates, and 16 for blocking builds and coverage.
|
||||
# Blacksmith uses 2 vCPU for file-serial native tests and 16 for coverage
|
||||
# and the shared build/observational workspace.
|
||||
windows-build:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: >-
|
||||
@@ -507,7 +505,7 @@ jobs:
|
||||
&& github.event.pull_request.user.login != 'dependabot[bot]'
|
||||
&& fromJSON('["self-hosted", "dsh-win-ci", "windows"]')
|
||||
|| 'dsh-windows-2025-16core' }}
|
||||
name: windows node 24 / build
|
||||
name: windows node 24 / build & observational
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
# A failing build or site aborts the sibling gate on the same runner.
|
||||
@@ -543,6 +541,23 @@ jobs:
|
||||
- name: Run blocking Windows builds
|
||||
shell: pwsh
|
||||
run: pnpm run check:ci:windows-blocking
|
||||
- name: Run Windows observational gates on built artifacts
|
||||
id: observational
|
||||
continue-on-error: true
|
||||
timeout-minutes: 15
|
||||
shell: pwsh
|
||||
env:
|
||||
DSH_GATE_FAIL_FAST: ''
|
||||
DSH_PUBLINT_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '8' || '' }}
|
||||
run: pnpm run check:ci:windows-observational-ready
|
||||
- name: Report Windows observational failures
|
||||
continue-on-error: true
|
||||
if: steps.observational.outcome == 'failure'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$message = 'Windows observational checks failed; see the observational step for diagnostics.'
|
||||
Write-Output "::warning::$message"
|
||||
$message | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
||||
|
||||
windows-coverage:
|
||||
if: github.event_name == 'pull_request'
|
||||
@@ -671,52 +686,6 @@ jobs:
|
||||
packages/workflow/tool-ralph/tests/integration.spec.ts
|
||||
packages/subprocess/subprocess-local/tests/process-exit.spec.ts
|
||||
|
||||
windows-observational:
|
||||
if: github.event_name == 'pull_request'
|
||||
continue-on-error: true
|
||||
runs-on: >-
|
||||
${{ vars.DSH_CI_FAILOVER_WINDOWS == 'blacksmith' && 'blacksmith-8vcpu-windows-2025'
|
||||
|| vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted'
|
||||
&& github.event.pull_request.user.login != 'dependabot[bot]'
|
||||
&& fromJSON('["self-hosted", "dsh-win-ci", "windows"]')
|
||||
|| 'dsh-windows-2025-16core' }}
|
||||
name: windows node 24 / observational
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
# Shared hosts expose host CPUs, not an isolated allocation.
|
||||
DSH_PUBLINT_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '8' || '' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Enable Developer Mode (symlink support)
|
||||
shell: pwsh
|
||||
run: >-
|
||||
reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock"
|
||||
/t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1"
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
dest: ${{ runner.temp }}/setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.PRIMARY_NODE_VERSION }}
|
||||
- name: Install (immutable)
|
||||
shell: pwsh
|
||||
# See 2026-08-30-windows-refs-store-block-clone-install for the
|
||||
# ReFS block-clone rationale; detect the workspace filesystem and
|
||||
# pass --package-import-method=clone only on ReFS.
|
||||
run: >-
|
||||
$drive = (Split-Path -Qualifier $env:GITHUB_WORKSPACE).TrimEnd(':');
|
||||
$fs = (Get-Volume -DriveLetter $drive).FileSystem;
|
||||
if ($fs -eq 'ReFS') {
|
||||
corepack pnpm install --frozen-lockfile --package-import-method=clone
|
||||
} else {
|
||||
pnpm install --frozen-lockfile
|
||||
}
|
||||
- name: Run Windows observational gates
|
||||
shell: pwsh
|
||||
run: pnpm run check:ci:windows-observational
|
||||
|
||||
# Single stable required check for branch protection: require "all checks
|
||||
# passed" instead of enumerating matrix legs whose names change as lanes and
|
||||
# node versions evolve. Every blocking job in THIS workflow must be listed in
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
- button "3 tool calls" [expanded]
|
||||
- button "Context injection @deepseek-ai/dsh-system-prompt"
|
||||
- button "Think I will inspect the current Session's dynamic Cordis Plugins before defining the snapshot Package."
|
||||
- 'button "Tool call cordis_inspect_self · {}"'
|
||||
- button "Inspect plugins 0 dynamic plugins"
|
||||
- button "Think No dynamic Plugins are present, so I will define the requested Host and Client Package."
|
||||
- button "Register Cordis Plugin snapshot noop does nothing, for the snapshot Ready" [expanded]
|
||||
- tablist "Plugin source":
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/** Global navigation before the first Session, through the shipped Web composition. */
|
||||
import { chromium, type Browser } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts'
|
||||
import { newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold()
|
||||
browser = await chromium.launch()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
try { await browser?.close() }
|
||||
finally { await scaffold?.close() }
|
||||
})
|
||||
|
||||
describe('navigation without a selected Session', () => {
|
||||
it.each(['web', 'win32', 'linux', 'darwin'] as const)('%s preserves its empty-header geometry and sidebar access', async (platform) => {
|
||||
const page = await newEnglishPage(browser)
|
||||
try {
|
||||
if (platform !== 'web') {
|
||||
await page.addInitScript((value) => {
|
||||
const mark = () => { document.documentElement.setAttribute('data-platform', value) }
|
||||
if (document.documentElement === null) document.addEventListener('DOMContentLoaded', mark, { once: true })
|
||||
else mark()
|
||||
}, platform)
|
||||
}
|
||||
const tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await page.getByRole('textbox', { name: 'Choose workspace', exact: true }).waitFor()
|
||||
const sessionHeader = page.locator('[data-slot="conversation.session.header"]')
|
||||
expect(await sessionHeader.count()).toBe(0)
|
||||
const header = page.locator('[data-slot="conversation.header"] > header')
|
||||
expect(await header.evaluate(element => element.getBoundingClientRect().height)).toBe(platform === 'darwin' ? 40 : 0)
|
||||
await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).click()
|
||||
const reopen = platform === 'darwin'
|
||||
? page.locator('[data-conversation-header-leading]').getByRole('button', { name: 'Open sidebar', exact: true })
|
||||
: page.getByRole('button', { name: 'Open sidebar', exact: true })
|
||||
await reopen.waitFor({ state: 'visible' })
|
||||
expect(await sessionHeader.count()).toBe(0)
|
||||
expect(await header.evaluate(element => element.getBoundingClientRect().height)).toBe(platform === 'darwin' ? 40 : 0)
|
||||
if (platform === 'darwin') {
|
||||
expect(await page.locator('[data-conversation-header-leading]').getByRole('button', { name: 'New session', exact: true }).isVisible()).toBe(true)
|
||||
}
|
||||
await reopen.click()
|
||||
await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).waitFor({ state: 'visible' })
|
||||
expect(await sessionHeader.count()).toBe(0)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
} catch (error) {
|
||||
await saveFailureShot(page, `sessionless-header-${platform}`)
|
||||
throw error
|
||||
} finally {
|
||||
await page.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -4,7 +4,7 @@
|
||||
- button "Refresh Team"
|
||||
- button "Close"
|
||||
- heading "Members" [level=3]
|
||||
- 'button "lead Idle · Model: deepseek-v4-flash" [disabled]'
|
||||
- 'button "lead Inactive · Model: deepseek-v4-flash" [disabled]'
|
||||
- heading "Shared tasks" [level=3]
|
||||
- button "New task"
|
||||
- article:
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/** Authored Session rendering of compact recorded Tool results. */
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
|
||||
launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/tool-details', import.meta.url))
|
||||
const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/tool-details/session.v3.jsonl', import.meta.url))
|
||||
const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/tool-details/ui.expected.md', import.meta.url))
|
||||
const MODE = webSnapshotMode()
|
||||
const SEED_ID = 'tool-details-web-e2e'
|
||||
const PROMPT = 'Show a goal, a to-do list, and reminders.'
|
||||
|
||||
describe.skipIf(MODE === 'record')('web e2e: compact Tool details', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
const fixture = await readFile(FIXTURE, 'utf8')
|
||||
expect(fixtureUserPrompts(fixture)).toEqual([PROMPT])
|
||||
scaffold = await launchWebScaffold({})
|
||||
await seedSession(scaffold, fixture, SEED_ID)
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
|
||||
const groupRow = page.locator('[role="treeitem"]').first()
|
||||
await groupRow.waitFor({ timeout: 15_000 })
|
||||
await groupRow.click()
|
||||
const sessionRow = page.locator('[role="treeitem"]').nth(1)
|
||||
await sessionRow.waitFor({ timeout: 10_000 })
|
||||
await sessionRow.click()
|
||||
const skillRow = page.locator('[data-tool="create_goal"]')
|
||||
await expandOwningTurnProcess(page, skillRow)
|
||||
await skillRow.waitFor({ timeout: 15_000 })
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
try { await browser?.close() } finally { await scaffold?.close() }
|
||||
})
|
||||
|
||||
it('expands every recorded tool through its keyed renderer', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-tool-details'))
|
||||
const rows = page.locator('[data-tool]')
|
||||
expect(await rows.count()).toBe(44)
|
||||
for (const row of await rows.all()) {
|
||||
await row.getByRole('button', { expanded: false }).first().click()
|
||||
expect(await row.getByRole('listitem').count()).toBeGreaterThan(0)
|
||||
expect(await row.locator('[class*="ioCard"]').count()).toBe(0)
|
||||
expect(await row.getByRole('button', { name: 'Inspect', exact: true }).count()).toBe(1)
|
||||
}
|
||||
const changedTodos = page.locator('[data-tool="todo_write"]').nth(1)
|
||||
await changedTodos.getByText('1 added · 1 updated · 1 removed', { exact: true }).waitFor()
|
||||
for (const summary of await page.locator('[data-tool] details > summary').all()) await summary.click()
|
||||
await page.locator('[data-tool="create_goal"]').getByText('Awaiting continuation', { exact: true }).waitFor()
|
||||
const schema = page.locator('[data-tool="cordis_inspect_list"] pre').first()
|
||||
await expect.poll(() => schema.textContent()).toContain('"properties"')
|
||||
const snapshots = await Promise.all(Array.from({ length: 44 }, (_, index) =>
|
||||
captureStableAria(page, `[data-chat-call-id="details-call-${index + 1}"] [data-tool]`, scaffold.workspaceCwd)))
|
||||
const snapshot = snapshots.join('\n')
|
||||
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
|
||||
await page.setViewportSize({ width: 360, height: 800 })
|
||||
const card = page.locator('[data-tool="schedule_create"]')
|
||||
expect(await card.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true)
|
||||
const reminders = page.locator('[data-tool="schedule_list"]')
|
||||
await reminders.scrollIntoViewIfNeeded()
|
||||
const list = reminders.getByRole('list').first()
|
||||
await list.evaluate((element) => {
|
||||
const second = element.children[1]!
|
||||
element.scrollTop += second.getBoundingClientRect().top - element.getBoundingClientRect().top
|
||||
})
|
||||
expect(await list.evaluate(element => element.scrollTop)).toBeGreaterThan(0)
|
||||
const geometry = await reminders.evaluate((element) => {
|
||||
const title = element.querySelector('ul')!.children[1]!.querySelector('[class*="heading"] > span')!.getBoundingClientRect()
|
||||
const inspect = element.querySelector('[class*="inspectButton"]')!.getBoundingClientRect()
|
||||
return {
|
||||
verticalOverlap: Math.min(title.bottom, inspect.bottom) - Math.max(title.top, inspect.top),
|
||||
horizontalGap: inspect.left - title.right,
|
||||
}
|
||||
})
|
||||
expect(geometry.verticalOverlap).toBeGreaterThan(0)
|
||||
expect(geometry.horizontalGap).toBeGreaterThanOrEqual(0)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('keeps the snapshot inventory closed', async () => {
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['session.v3.jsonl', 'ui.expected.md'])
|
||||
})
|
||||
|
||||
})
|
||||
@@ -187,7 +187,7 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () =
|
||||
expect(await terminalWorkflow.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(await terminalWorkflow.evaluate(element => getComputedStyle(element).cursor)).toBe('pointer')
|
||||
await terminalWorkflow.click()
|
||||
const terminalPhase = page.getByRole('button', { name: /^Run/ })
|
||||
const terminalPhase = page.locator('[data-workflow-run][data-run-status="completed"] [data-disclosure-row]').nth(1)
|
||||
await terminalPhase.waitFor()
|
||||
expect(await terminalPhase.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(await terminalPhase.evaluate(element => getComputedStyle(element).cursor)).toBe('pointer')
|
||||
@@ -210,7 +210,7 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () =
|
||||
const snapshot = await captureStableAria(page, '[data-chat-flow]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
|
||||
await workflow.click()
|
||||
const phase = page.getByRole('button', { name: /^Run/ })
|
||||
const phase = page.locator('[data-workflow-run][data-run-status="completed"] [data-disclosure-row]').nth(1)
|
||||
await phase.waitFor()
|
||||
expect(await phase.getAttribute('aria-expanded')).toBe('false')
|
||||
await phase.click()
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
"tests/trajectory-virtualization.e2e.ts",
|
||||
"tests/lifecycle-chrome.e2e.ts",
|
||||
"tests/details-session-lifecycle.e2e.ts",
|
||||
"tests/sessionless-header.e2e.ts",
|
||||
"tests/document-preview.e2e.ts",
|
||||
"tests/office-fixture.ts",
|
||||
"tests/sidebar-browser.e2e.ts",
|
||||
@@ -129,6 +130,7 @@
|
||||
"tests/auto-review-denial.e2e.ts",
|
||||
"tests/bash-abort-row.e2e.ts",
|
||||
"tests/skill-tool-row.e2e.ts",
|
||||
"tests/tool-details.e2e.ts",
|
||||
"tests/turn-tail-actions.e2e.ts",
|
||||
"tests/goal-multi-turn-actions.e2e.ts",
|
||||
"tests/chat-scroll-fixture.ts",
|
||||
|
||||
@@ -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/agent-team.md
|
||||
agent-team.md: 6f20ee11cbaf66028a5a3123d1682a0e9a87b014
|
||||
agent-team.zh.md: 5f2cfae582f275f25380cbe996f5bad711c0f917
|
||||
agent-team.md: 653bbbcad50d723690c013161a0c25eea301ff0b
|
||||
agent-team.zh.md: e9ef2d5dc824a81d1c666d0ea7a270c0cfe2c92e
|
||||
@@ -21,7 +21,7 @@ interface TeamMemberSnapshot {
|
||||
}
|
||||
```
|
||||
|
||||
Every member starts in `provisioning` and reaches exactly one terminal roster phase, `active` or `failed`. Runtime `running`/`idle`/`inactive` status is derived separately and never rewrites this record.
|
||||
Every member starts in `provisioning` and reaches exactly one terminal roster phase, `active` or `failed`. Roster `running`/`inactive` status is derived separately and never rewrites this record.
|
||||
|
||||
## Durable mailbox
|
||||
|
||||
@@ -38,7 +38,7 @@ interface TeamMessageSnapshot {
|
||||
}
|
||||
```
|
||||
|
||||
Every message attempts Steer delivery. A running target receives it at the nearest step boundary, an idle target starts a turn, and an inactive teammate cold-resumes. Scheduling is not stored in the durable record because callers cannot select another mode.
|
||||
Every message attempts Steer delivery. A running target receives it at the nearest step boundary; an inactive target starts a turn if loaded or cold-resumes otherwise. Scheduling is not stored in the durable record because callers cannot select another mode.
|
||||
|
||||
The target Session keeps message identity and sender attribution on both the pending inbox item and the eventual user message. Folding that source across inbox and history is the target-side de-duplication key; the model-visible framing repeats the id and sender.
|
||||
|
||||
@@ -168,7 +168,7 @@ async waitForChange(caller: Agent, timeoutMs: number, signal: AbortSignal): Prom
|
||||
* @param targetName - durable teammate name.
|
||||
* @returns the target status sampled before cancellation.
|
||||
*/
|
||||
interrupt(caller: Agent, targetName: string): { previousStatus: 'running' | 'idle' | 'inactive' }
|
||||
interrupt(caller: Agent, targetName: string): { previousStatus: 'running' | 'inactive' }
|
||||
|
||||
/**
|
||||
* Resolve a caller without throwing, used by scoped-tool installation and observers.
|
||||
|
||||
@@ -21,7 +21,7 @@ interface TeamMemberSnapshot {
|
||||
}
|
||||
```
|
||||
|
||||
每个 member 都从 `provisioning` 开始,并且只到达一个终态 roster phase:`active` 或 `failed`。运行时 `running`/`idle`/`inactive` 状态单独派生,绝不会重写该记录。
|
||||
每个 member 都从 `provisioning` 开始,并且只到达一个终态 roster phase:`active` 或 `failed`。roster 的 `running`/`inactive` 状态单独派生,绝不会重写该记录。
|
||||
|
||||
## 持久 mailbox
|
||||
|
||||
@@ -38,7 +38,7 @@ interface TeamMessageSnapshot {
|
||||
}
|
||||
```
|
||||
|
||||
每条消息都会尝试 Steer 投递。running target 在最近的步骤边界收到消息,idle target 启动一个轮次,inactive teammate 则冷恢复。调用方不能选择其他模式,因此持久记录不存储调度方式。
|
||||
每条消息都会尝试 Steer 投递。running target 在最近的步骤边界收到消息,inactive target 在已加载时启动一个轮次,否则冷恢复。调用方不能选择其他模式,因此持久记录不存储调度方式。
|
||||
|
||||
target Session 会在 pending inbox 条目和最终用户消息上保留消息身份与发送者归因。跨 inbox 与历史折叠该 source 构成 target 侧去重键;模型可见的 framing 会重复 id 和发送者。
|
||||
|
||||
@@ -168,7 +168,7 @@ async waitForChange(caller: Agent, timeoutMs: number, signal: AbortSignal): Prom
|
||||
* @param targetName - durable teammate name.
|
||||
* @returns the target status sampled before cancellation.
|
||||
*/
|
||||
interrupt(caller: Agent, targetName: string): { previousStatus: 'running' | 'idle' | 'inactive' }
|
||||
interrupt(caller: Agent, targetName: string): { previousStatus: 'running' | 'inactive' }
|
||||
|
||||
/**
|
||||
* Resolve a caller without throwing, used by scoped-tool installation and observers.
|
||||
|
||||
@@ -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/slots.md
|
||||
slots.md: a9f0ee6ab2ef37065ab965a7989f5989350bca8b
|
||||
slots.zh.md: da6a38eab6aac1c2670b131fa49a832ce41f711e
|
||||
slots.md: 1b7dbd9e8ce81d298094df8a2adf7da39178541d
|
||||
slots.zh.md: b852e3f204c0acea49f94cffcedf2eebad0e432e
|
||||
@@ -144,12 +144,13 @@ root
|
||||
│ │ │ └─ tool.view.cordis
|
||||
│ │ ├─ conversation.message.images
|
||||
│ │ └─ conversation.trajectory.images
|
||||
│ ├─ conversation.session.header
|
||||
│ │ ├─ conversation.session.header.lineage
|
||||
│ │ ├─ conversation.session.header.leading
|
||||
│ │ ├─ conversation.session.header.actions
|
||||
│ │ ├─ conversation.session.header.utilities
|
||||
│ │ └─ conversation.session.header.corner
|
||||
│ ├─ conversation.header
|
||||
│ │ ├─ conversation.header.leading
|
||||
│ │ └─ conversation.session.header
|
||||
│ │ ├─ conversation.session.header.lineage
|
||||
│ │ ├─ conversation.session.header.actions
|
||||
│ │ ├─ conversation.session.header.utilities
|
||||
│ │ └─ conversation.session.header.corner
|
||||
│ ├─ conversation.composer
|
||||
│ │ ├─ conversation.approval.detail
|
||||
│ │ └─ conversation.plan-review.actions
|
||||
|
||||
@@ -144,12 +144,13 @@ root
|
||||
│ │ │ └─ tool.view.cordis
|
||||
│ │ ├─ conversation.message.images
|
||||
│ │ └─ conversation.trajectory.images
|
||||
│ ├─ conversation.session.header
|
||||
│ │ ├─ conversation.session.header.lineage
|
||||
│ │ ├─ conversation.session.header.leading
|
||||
│ │ ├─ conversation.session.header.actions
|
||||
│ │ ├─ conversation.session.header.utilities
|
||||
│ │ └─ conversation.session.header.corner
|
||||
│ ├─ conversation.header
|
||||
│ │ ├─ conversation.header.leading
|
||||
│ │ └─ conversation.session.header
|
||||
│ │ ├─ conversation.session.header.lineage
|
||||
│ │ ├─ conversation.session.header.actions
|
||||
│ │ ├─ conversation.session.header.utilities
|
||||
│ │ └─ conversation.session.header.corner
|
||||
│ ├─ conversation.composer
|
||||
│ │ ├─ conversation.approval.detail
|
||||
│ │ └─ conversation.plan-review.actions
|
||||
|
||||
@@ -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/subagent.md
|
||||
subagent.md: 2bdb618f2196e5593fcdd4448504165a641fd23e
|
||||
subagent.zh.md: 0fbde9283617cf04907f830cc8486aef284eab68
|
||||
subagent.md: 32c9d47c288a352ef234a0e555484d3826f6adf5
|
||||
subagent.zh.md: 2e39072f820e3e4c5afc2884babbde63e23af7df
|
||||
@@ -266,7 +266,7 @@ A local one-shot provider appends the descriptor inside the child's initial turn
|
||||
|
||||
## Durable enumeration: `listChildren()`, `listDescendants()`, and their entries
|
||||
|
||||
`SubagentRuntime.listChildren(parentSessionId)` enumerates the parent's direct session-backed subagents from the live-preferred merge of `ctx.sessions` and the session-query engine's `listSessions()` — no Agent is loaded or resumed. Candidates are the direct children whose durable header carries `origin: 'subagent'`; the marker classifies enumeration and coarse generic-route denial but cannot establish a valid descriptor, resumability, or authorization — the projection fold owns identity, and the Activation contract owns resume. Each row's `mode`/`label` is the registered `subagent` projection unit's value, served through a three-rung ladder: the registry's watermark cache for a live child (zero log reads); the optional projection checkpoint cache for a cold one (`cachedSnapshot` — an identity passing the own-suffix seq gate is final, because an own descriptor is immutable once appended); otherwise one `query.observeSession()` cold observation folded through the registry (bounded concurrency, recomputed per listing). The cache is a pure optional accelerator: absent, serving the `null` sentinel or missing the key, failing the seq gate, or faulting, it falls silently through to the authoritative refold. The fold is `subagent/descriptor` last-wins with no failure channel: the child's own descriptor overrides a fork-seeded ancestor's, and a malformed or unknown-version payload folds to a serializable `null` sentinel, treated as no value. The result is one `SubagentListEntry[]` in `createdAt`-then-id order: a served identity yields a `child` entry with `mode: 'one-shot' | 'continuable'` and `activity: 'running' | 'inactive'`; continuable entries always carry `label`, while one-shot entries carry it only when the start caller supplied presentation metadata. A settled candidate whose fold served no identity yields a `corrupt` diagnostic — missing, malformed, and unknown-version descriptors deliberately undistinguished (`unsupported` remains in the type but is never produced); a running candidate without an identity is omitted (the creation window before its descriptor lands); a failed cold inspection yields one `unavailable` diagnostic retried on the next listing, so one damaged sibling cannot hide healthy children. `hasChildren` marks a direct descendant with durable subagent origin, read from the same merged material. Activity snapshots only whether the logical record is live in `ctx.sessions`, not outcome or resumability. Absent persistence, enumeration is live-only rather than an error — a cold child cannot be resumed then either. `listChildren()` throws `SubagentError` with code `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` when the `ctx.sessionProjections` registry is absent and `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` when the session store is, both checked before any read so a deployment with zero children still fails deterministically; the list tool requires `ctx.subagents` and `ctx.agents` at plugin load. A service consumer such as a UI can display both modes and choose an unlabeled one-shot fallback, while the model-facing `list_agents` adapter (the separately loadable `/list-agents` plugin of [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control)) keeps only continuable entries and refines status through the live Agent registry into its own `running`/`idle`/`ready` vocabulary, whose `ready` names a storage-only child as resumable rather than terminal. Listing does not consult the continuation manager's Activation map, Agent registry, or provider availability; `send_message` remains the authoritative delivery-time operation, and a listed running continuable child may still reject delivery as an ownership conflict. [The archived list-identity-projection record](../../.agents/notes/archived/architecture/2026-08-06-subagent-list-identity-projection.md) documents the original read-path decision.
|
||||
`SubagentRuntime.listChildren(parentSessionId)` enumerates the parent's direct session-backed subagents from the live-preferred merge of `ctx.sessions` and the session-query engine's `listSessions()` — no Agent is loaded or resumed. Candidates are the direct children whose durable header carries `origin: 'subagent'`; the marker classifies enumeration and coarse generic-route denial but cannot establish a valid descriptor, resumability, or authorization — the projection fold owns identity, and the Activation contract owns resume. Each row's `mode`/`label` is the registered `subagent` projection unit's value, served through a three-rung ladder: the registry's watermark cache for a live child (zero log reads); the optional projection checkpoint cache for a cold one (`cachedSnapshot` — an identity passing the own-suffix seq gate is final, because an own descriptor is immutable once appended); otherwise one `query.observeSession()` cold observation folded through the registry (bounded concurrency, recomputed per listing). The cache is a pure optional accelerator: absent, serving the `null` sentinel or missing the key, failing the seq gate, or faulting, it falls silently through to the authoritative refold. The fold is `subagent/descriptor` last-wins with no failure channel: the child's own descriptor overrides a fork-seeded ancestor's, and a malformed or unknown-version payload folds to a serializable `null` sentinel, treated as no value. The result is one `SubagentListEntry[]` in `createdAt`-then-id order: a served identity yields a `child` entry with `mode: 'one-shot' | 'continuable'` and `activity: 'running' | 'inactive'`; continuable entries always carry `label`, while one-shot entries carry it only when the start caller supplied presentation metadata. A settled candidate whose fold served no identity yields a `corrupt` diagnostic — missing, malformed, and unknown-version descriptors deliberately undistinguished (`unsupported` remains in the type but is never produced); a running candidate without an identity is omitted (the creation window before its descriptor lands); a failed cold inspection yields one `unavailable` diagnostic retried on the next listing, so one damaged sibling cannot hide healthy children. `hasChildren` marks a direct descendant with durable subagent origin, read from the same merged material. Activity snapshots only whether the logical record is live in `ctx.sessions`, not outcome or resumability. Absent persistence, enumeration is live-only rather than an error — a cold child cannot be resumed then either. `listChildren()` throws `SubagentError` with code `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` when the `ctx.sessionProjections` registry is absent and `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` when the session store is, both checked before any read so a deployment with zero children still fails deterministically; the list tool requires `ctx.subagents` and `ctx.agents` at plugin load. A service consumer such as a UI can display both modes and choose an unlabeled one-shot fallback, while the model-facing `list_agents` adapter (the separately loadable `/list-agents` plugin of [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control)) keeps only continuable entries and refines status through the live Agent registry into `running`/`inactive`; `inactive` includes loaded and storage-only children without describing task completion or outcome. Listing does not consult the continuation manager's Activation map, Agent registry, or provider availability; `send_message` remains the authoritative delivery-time operation, and a listed running continuable child may still reject delivery as an ownership conflict. [The archived list-identity-projection record](../../.agents/notes/archived/architecture/2026-08-06-subagent-list-identity-projection.md) documents the original read-path decision.
|
||||
|
||||
`SubagentRuntime.listDescendants(rootSessionId)` applies the same live-preferred corpus and projection-backed interpretation to the root's complete descendant tree in stable pre-order. Ordinary sessions and one-shot children remain traversal nodes, so continuable descendants below them are discovered; only `origin: 'subagent'` candidates produce rows. Each returned child or diagnostic adds its position from the enumerated durable header, while a cold inspection revalidates that complete lifecycle before serving identity:
|
||||
|
||||
|
||||
@@ -266,7 +266,7 @@ interface ContinuableCreateSpec {
|
||||
|
||||
## 持久化枚举:`listChildren()`、`listDescendants()` 与其条目
|
||||
|
||||
`SubagentRuntime.listChildren(parentSessionId)` 从 `ctx.sessions` 与会话查询引擎 `listSessions()` 的实时优先合并中枚举 parent 直接且由会话支撑的 subagent——不会加载或恢复任何 Agent。候选是持久 header 携带 `origin: 'subagent'` 的直接 child;该标记只负责枚举分类与粗粒度的通用路由拒绝,不能证明描述符有效、child 可恢复或操作已获授权——身份由投影折叠负责,恢复由 Activation 约定负责。每行的 `mode`/`label` 是已注册 `subagent` projection unit 的值,经三级阶梯供值:存活 child 由注册表水位缓存供值(零日志读取);冷 child 先读可选的投影 checkpoint 缓存(`cachedSnapshot`——过 own-suffix seq 门的身份即定值,own descriptor 一经追加不可变);否则在一次 `query.observeSession()` 冷观察上经注册表折叠(有界并发,每次列表重新计算)。该缓存是纯可选加速层:服务缺席、行里是 `null` 哨兵或 key 缺席、seq 门不过、读取出错,都静默落到权威重折。折叠规则是 `subagent/descriptor` last-wins 且没有失败通道:子 agent 自己的描述符覆盖 fork seed 中祖先的描述符,格式错误或版本不认识的载荷折叠为可序列化的 `null` 哨兵,视同无值。结果是按 `createdAt`、再按 id 排序的 `SubagentListEntry[]`:取到身份即生成带有 `mode: 'one-shot' | 'continuable'` 和 `activity: 'running' | 'inactive'` 的 `child` 条目;可继续条目始终携带 `label`,一次性条目则只在启动调用方提供展示元数据时携带该字段。已定局而折叠无身份的候选生成 `corrupt` diagnostic——缺失、格式错误与版本不认识的描述符有意不再细分(`unsupported` 仍保留在类型中但从不产出);运行中而无身份的候选被省略(描述符落盘前的创建窗口);冷检查失败生成一条 `unavailable` diagnostic 并在下次列表自然重试,因此一个损坏的 sibling 不会隐藏健康 child。`hasChildren` 标记存在持久 subagent origin 的直接后代,读取自同一份合并材料。活动状态只表示逻辑记录是否在 `ctx.sessions` 中存活,而不表示结果或可恢复性。缺少持久化时,枚举退化为仅存活枚举而不是报错——此时冷 child 本就无法恢复。缺少 `ctx.sessionProjections` 注册表时,`listChildren()` 抛出携带错误码 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 的 `SubagentError`,缺少会话存储时则抛出 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`,两者都在任何读取之前检查,因此零 child 的部署同样确定失败;列表工具在插件加载时要求 `ctx.subagents` 与 `ctx.agents`。UI 等服务消费方可以展示两种模式,并为无标签的一次性 child 选择回退展示;面向模型的 `list_agents` 适配器([dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control) 中可单独加载的 `/list-agents` 插件)则只保留可继续条目,并通过在线 Agent 注册表将状态细化为自己的 `running`/`idle`/`ready` 词汇,其中 `ready` 把仅存于存储的 child 命名为可恢复而非终态。枚举不会查询继续执行管理器的 Activation map、Agent 注册表或提供方可用性;`send_message` 仍是消息送达时的权威操作,列表中的运行中可继续 child 仍可能因所有权冲突而拒绝投递。[已归档的列表身份投影记录](../../.agents/notes/archived/architecture/2026-08-06-subagent-list-identity-projection.md)记录了最初的读路径决策。
|
||||
`SubagentRuntime.listChildren(parentSessionId)` 从 `ctx.sessions` 与会话查询引擎 `listSessions()` 的实时优先合并中枚举 parent 直接且由会话支撑的 subagent——不会加载或恢复任何 Agent。候选是持久 header 携带 `origin: 'subagent'` 的直接 child;该标记只负责枚举分类与粗粒度的通用路由拒绝,不能证明描述符有效、child 可恢复或操作已获授权——身份由投影折叠负责,恢复由 Activation 约定负责。每行的 `mode`/`label` 是已注册 `subagent` projection unit 的值,经三级阶梯供值:存活 child 由注册表水位缓存供值(零日志读取);冷 child 先读可选的投影 checkpoint 缓存(`cachedSnapshot`——过 own-suffix seq 门的身份即定值,own descriptor 一经追加不可变);否则在一次 `query.observeSession()` 冷观察上经注册表折叠(有界并发,每次列表重新计算)。该缓存是纯可选加速层:服务缺席、行里是 `null` 哨兵或 key 缺席、seq 门不过、读取出错,都静默落到权威重折。折叠规则是 `subagent/descriptor` last-wins 且没有失败通道:子 agent 自己的描述符覆盖 fork seed 中祖先的描述符,格式错误或版本不认识的载荷折叠为可序列化的 `null` 哨兵,视同无值。结果是按 `createdAt`、再按 id 排序的 `SubagentListEntry[]`:取到身份即生成带有 `mode: 'one-shot' | 'continuable'` 和 `activity: 'running' | 'inactive'` 的 `child` 条目;可继续条目始终携带 `label`,一次性条目则只在启动调用方提供展示元数据时携带该字段。已定局而折叠无身份的候选生成 `corrupt` diagnostic——缺失、格式错误与版本不认识的描述符有意不再细分(`unsupported` 仍保留在类型中但从不产出);运行中而无身份的候选被省略(描述符落盘前的创建窗口);冷检查失败生成一条 `unavailable` diagnostic 并在下次列表自然重试,因此一个损坏的 sibling 不会隐藏健康 child。`hasChildren` 标记存在持久 subagent origin 的直接后代,读取自同一份合并材料。活动状态只表示逻辑记录是否在 `ctx.sessions` 中存活,而不表示结果或可恢复性。缺少持久化时,枚举退化为仅存活枚举而不是报错——此时冷 child 本就无法恢复。缺少 `ctx.sessionProjections` 注册表时,`listChildren()` 抛出携带错误码 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 的 `SubagentError`,缺少会话存储时则抛出 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`,两者都在任何读取之前检查,因此零 child 的部署同样确定失败;列表工具在插件加载时要求 `ctx.subagents` 与 `ctx.agents`。UI 等服务消费方可以展示两种模式,并为无标签的一次性 child 选择回退展示;面向模型的 `list_agents` 适配器([dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control) 中可单独加载的 `/list-agents` 插件)则只保留可继续条目,并通过在线 Agent 注册表将状态细化为 `running`/`inactive`;`inactive` 包括已加载和仅存于存储的 child,不表示任务完成或结果。枚举不会查询继续执行管理器的 Activation map、Agent 注册表或提供方可用性;`send_message` 仍是消息送达时的权威操作,列表中的运行中可继续 child 仍可能因所有权冲突而拒绝投递。[已归档的列表身份投影记录](../../.agents/notes/archived/architecture/2026-08-06-subagent-list-identity-projection.md)记录了最初的读路径决策。
|
||||
|
||||
`SubagentRuntime.listDescendants(rootSessionId)` 将同一份实时优先语料与基于投影的解释应用到根的完整后代树,并按稳定 pre-order 输出。普通会话和一次性 child 仍作为遍历节点,因此其下的可继续后代仍可发现;只有 `origin: 'subagent'` 的候选会生成条目。每个返回的 child 或 diagnostic 都从枚举所得的持久 header 附加树位置;冷检查在提供身份前还会重新校验完整生命周期:
|
||||
|
||||
|
||||
@@ -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/tool-catalog.md
|
||||
tool-catalog.md: cd7805d3104120a04e21363cb75c6dcb8456f5e0
|
||||
tool-catalog.zh.md: 52752836f49c8fb745a69f10c4f8cd1ef79a871d
|
||||
tool-catalog.md: b133f9ba94276878b380999b3023210a5d627c6a
|
||||
tool-catalog.zh.md: 3f63676e4eb379163352a6c6a590f868e53f6efb
|
||||
@@ -1882,7 +1882,7 @@ Source: [`packages/subagent/tool-subagent-control/src/index.ts`](../packages/sub
|
||||
|
||||
### `list_agents`
|
||||
|
||||
List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now, idle means it is loaded but between turns (it may be waiting on agents it started), and ready means it exists only in storage — resumable, not terminal, and not a result waiting to be collected; a `send_message` steers a running child at its nearest step boundary or starts a turn for an idle or ready child, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics instead of being silently dropped. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.
|
||||
List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now; inactive means no turn is executing, whether the child is loaded or must be resumed. inactive does not describe task completion, success, failure, or waiting for other agents. A `send_message` steers a running child at its nearest step boundary or starts or resumes a turn for an inactive child, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics instead of being silently dropped. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -1904,7 +1904,7 @@ Source: [`packages/subagent/tool-subagent-control/src/list-agents.ts`](../packag
|
||||
|
||||
### `send_message`
|
||||
|
||||
Send a message to a direct continuable child by its agent id. If you are a resident continuable child, you may also target your direct parent. If the target is still working, the message steers its nearest step; if it is idle, the message starts a turn. This call returns no answer from the agent — only confirmation that the message was delivered. A failure means the message was NOT delivered.
|
||||
Send a message to a direct continuable child by its agent id. If you are a resident continuable child, you may also target your direct parent. If the target is still working, the message steers its nearest step; if it is inactive, the message starts or resumes a turn. This call returns no answer from the agent — only confirmation that the message was delivered. A failure means the message was NOT delivered.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -2017,7 +2017,7 @@ Interrupt one teammate's current turn while preserving its pending inbox. Team L
|
||||
"properties": {
|
||||
"target": {
|
||||
"type": "string",
|
||||
"description": "Teammate name."
|
||||
"description": "Teammate target returned by spawn_teammate or list_agents."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -2030,7 +2030,7 @@ Source: [`packages/experimental/tool-agent-team/src/index.ts`](../packages/exper
|
||||
|
||||
### `list_agents`
|
||||
|
||||
List the Lead and every durable teammate with current runtime status.
|
||||
List the Lead and every durable teammate with an addressable target and current availability. inactive means no turn is executing, not a task result. provisioning and failed describe member creation.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -2043,7 +2043,7 @@ Source: [`packages/experimental/tool-agent-team/src/index.ts`](../packages/exper
|
||||
|
||||
### `send_message`
|
||||
|
||||
Send one durable message to another Team member. A running target receives it at the nearest step boundary; an idle target starts a turn; an inactive teammate cold-resumes.
|
||||
Send one durable message to another Team member. A running target receives it at the nearest step boundary; an inactive target starts or resumes a turn.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -2051,7 +2051,7 @@ Send one durable message to another Team member. A running target receives it at
|
||||
"properties": {
|
||||
"target": {
|
||||
"type": "string",
|
||||
"description": "Team member name, or lead."
|
||||
"description": "Member target returned by spawn_teammate or list_agents, including lead."
|
||||
},
|
||||
"message": {
|
||||
"type": "string",
|
||||
@@ -2186,7 +2186,7 @@ List shared tasks, including readiness, owner, revision, blockers, and write-sco
|
||||
},
|
||||
"owner": {
|
||||
"type": "string",
|
||||
"description": "Optional member-name filter; use unowned for tasks without an owner."
|
||||
"description": "Optional member target from spawn_teammate or list_agents, matching ownerName; use unowned for tasks without an owner."
|
||||
},
|
||||
"ready": {
|
||||
"type": "boolean",
|
||||
@@ -2260,7 +2260,7 @@ Compare-and-set a shared task action using the latest revision from team_task_ge
|
||||
},
|
||||
"owner": {
|
||||
"type": "string",
|
||||
"description": "Member name for Lead-only reassign; omit to unassign."
|
||||
"description": "Member target from spawn_teammate or list_agents for Lead-only reassign; omit to unassign."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
||||
@@ -1888,7 +1888,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后,
|
||||
|
||||
### `list_agents`
|
||||
|
||||
按持久 id 和标签列出你的可继续后台 subagent。用它回忆你启动过哪些 subagent,而不是轮询完成情况——subagent 完成时你会被告知。状态来自实时注册表:running 表示 agent 此刻正在工作;idle 表示已加载但处于轮次之间,可能正在等待它启动的 agent;ready 表示它只存在于存储中——可恢复而非终态,也不表示有结果等待收集;`send_message` 会在运行中 child 的最近 step 边界 steer 消息,或为 idle、ready child 启动轮次,且无论处于哪种状态,直接子级都仍可作为 `send_message` 的目标。该快照并非投递承诺;`send_message` 会执行权威检查,仍可能失败。无法读取的子级会作为诊断信息报告,而不会被静默丢弃。`descendants` 作用域会按稳定的前序顺序遍历你下方的整棵树,并为每个条目标注其持久的直接父会话 id 和深度。只有深度为 1 的条目可以使用 `send_message`;更深的条目只能作为 `interrupt_agent` 的候选目标。
|
||||
按持久 id 和标签列出你的可继续后台 subagent。用它回忆你启动过哪些 subagent,而不是轮询完成情况——subagent 完成时你会被告知。状态来自实时注册表:running 表示 agent 此刻正在工作;inactive 表示没有轮次在执行,包括已加载和需要恢复的 child。inactive 不表示任务完成、成功、失败或等待其他 agent。`send_message` 会在运行中 child 的最近 step 边界 steer 消息,或为 inactive child 启动或恢复轮次,且无论处于哪种状态,直接子级都仍可作为 `send_message` 的目标。该快照并非投递承诺;`send_message` 会执行权威检查,仍可能失败。无法读取的子级会作为诊断信息报告,而不会被静默丢弃。`descendants` 作用域会按稳定的前序顺序遍历你下方的整棵树,并为每个条目标注其持久的直接父会话 id 和深度。只有深度为 1 的条目可以使用 `send_message`;更深的条目只能作为 `interrupt_agent` 的候选目标。
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -1910,7 +1910,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后,
|
||||
|
||||
### `send_message`
|
||||
|
||||
根据 agent id 向直接可继续 child 发送消息。如果你是驻留的可继续 child,也可以把自己的直接 parent 作为目标。如果目标仍在工作,消息会 steer 其最近的 step;如果目标处于 idle,消息会启动一个轮次。此调用不会返回该 agent 的答案,只会确认消息已投递。调用失败表示消息**未**投递。
|
||||
根据 agent id 向直接可继续 child 发送消息。如果你是驻留的可继续 child,也可以把自己的直接 parent 作为目标。如果目标仍在工作,消息会 steer 其最近的 step;如果目标处于 inactive,消息会启动或恢复一个轮次。此调用不会返回该 agent 的答案,只会确认消息已投递。调用失败表示消息**未**投递。
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -2023,7 +2023,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后,
|
||||
"properties": {
|
||||
"target": {
|
||||
"type": "string",
|
||||
"description": "Teammate name."
|
||||
"description": "Teammate target returned by spawn_teammate or list_agents."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -2036,7 +2036,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后,
|
||||
|
||||
### `list_agents`
|
||||
|
||||
列出 Lead 与所有持久 teammate,以及各自当前的运行时状态。
|
||||
列出 Lead 与所有持久 teammate,以及可用于寻址的 target 和当前可用状态。inactive 表示没有轮次在执行,不表示任务结果。provisioning 与 failed 描述成员创建状态。
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -2049,7 +2049,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后,
|
||||
|
||||
### `send_message`
|
||||
|
||||
向另一名 Team member 发送一条持久消息。running target 会在最近的步骤边界收到消息;idle target 会启动一个 turn;inactive teammate 会冷恢复。
|
||||
向另一名 Team member 发送一条持久消息。running target 会在最近的步骤边界收到消息;inactive target 会启动或恢复一个 turn。
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -2057,7 +2057,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后,
|
||||
"properties": {
|
||||
"target": {
|
||||
"type": "string",
|
||||
"description": "Team member name, or lead."
|
||||
"description": "Member target returned by spawn_teammate or list_agents, including lead."
|
||||
},
|
||||
"message": {
|
||||
"type": "string",
|
||||
@@ -2192,7 +2192,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后,
|
||||
},
|
||||
"owner": {
|
||||
"type": "string",
|
||||
"description": "Optional member-name filter; use unowned for tasks without an owner."
|
||||
"description": "Optional member target from spawn_teammate or list_agents, matching ownerName; use unowned for tasks without an owner."
|
||||
},
|
||||
"ready": {
|
||||
"type": "boolean",
|
||||
@@ -2266,7 +2266,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后,
|
||||
},
|
||||
"owner": {
|
||||
"type": "string",
|
||||
"description": "Member name for Lead-only reassign; omit to unassign."
|
||||
"description": "Member target from spawn_teammate or list_agents for Lead-only reassign; omit to unassign."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
||||
+1
-1
@@ -87,7 +87,7 @@
|
||||
"check:windows-wine": "bash scripts/wine-windows-gates.sh",
|
||||
"check:ci:windows-blocking": "tsx scripts/run-gates.ts ci-windows-blocking",
|
||||
"check:ci:windows-complete": "tsx scripts/run-gates.ts ci-windows-complete",
|
||||
"check:ci:windows-observational": "tsx scripts/run-gates.ts ci-windows-observational",
|
||||
"check:ci:windows-observational-ready": "tsx scripts/run-gates.ts ci-windows-observational-ready",
|
||||
"check:node-compat": "tsx scripts/run-gates.ts node-compat",
|
||||
"publint": "tsx scripts/publint-all.ts",
|
||||
"doc-typecheck": "npm run build:lib:host && npm run doc-typecheck:contracts-ready",
|
||||
|
||||
@@ -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: 0d5cdd63cb62e1bf51a9dc029721d28459bb306e
|
||||
README.zh.md: 067b7c9eccd7048ce85167c3dcefef9e7219cecf
|
||||
README.md: fef3a6551b517af2ac636687883864525aac70ee
|
||||
README.zh.md: e9ab08c686420c38c11ca2bede2fd00099a247a5
|
||||
@@ -50,9 +50,9 @@ Claimed commands retain their identity and highlight when only their arguments a
|
||||
|
||||
Workspace selection uses `uiWorkspace.openWorkspace` to prepare the target and commit navigation. Draft text and attachments move in its synchronous preparation callback only while that request is current; later navigation or owner disposal leaves the original draft intact.
|
||||
|
||||
The package occupies the root-scoped `main` key `conversation`. Its `main.conversation` shell keeps the strict Session Header outside the optional-Session `conversation.content` Component Factory. The Factory owns the shared body and Composer, reads the current Session through its standard Hook, and exposes strict-Session `views` plus root-scoped `widthControls` local positions. Its default adapter renders the existing `conversation.session` entry, while the main occurrence selects the width handles; an embedded occurrence can replace `views` and omit those handles without rendering the main Header. The shared body and Composer register the queue and Todo docks. The Todo dock uses the shared panel elevation above the composer; its rows use the shared idle, ongoing, and done markers for pending, in-progress, and completed items. `ctx.uiSession.provide()` materializes the Conversation and input sources from the same Session binding and supplies `inputActions` as a stable standard prop.
|
||||
The package occupies the root-scoped `main` key `conversation`. Its `main.conversation` shell keeps the resident `conversation.header` outside the optional-Session `conversation.content` Component Factory. The header hosts root-scoped navigation without a selected Session; titles, actions, and View tabs remain in its strict Session child. The Factory owns the shared body and Composer, reads the current Session through its standard Hook, and exposes strict-Session `views` plus root-scoped `widthControls` local positions. Its default adapter renders the existing `conversation.session` entry, while the main occurrence selects the width handles; an embedded occurrence can replace `views` and omit those handles without rendering the main Header. The shared body and Composer register the queue and Todo docks. The Todo dock uses the shared panel elevation above the composer; its rows use the shared idle, ongoing, and done markers for pending, in-progress, and completed items. `ctx.uiSession.provide()` materializes the Conversation and input sources from the same Session binding and supplies `inputActions` as a stable standard prop.
|
||||
|
||||
A blank Session retains the header's leading and corner controls, including the right-sidebar opener, while hiding its title, actions, utilities, and View tabs. Selecting a Workspace creates the Session needed by these controls; the first message is not required. Without a selected Session, the strict header is absent. Sidebar entries retain their own data and execution prerequisites. A started Session header uses one row when fewer than two Views are available; the tab row reserves space only while rendered.
|
||||
A blank Session retains the header's leading and corner controls, including the right-sidebar opener, while hiding its title, actions, utilities, and View tabs. Selecting a Workspace creates the Session needed by these controls; the first message is not required. Without a selected Session, the strict header is absent; the resident container reserves a 40px drag band on macOS desktop and no empty vertical space on Web, Windows, or Linux. Sidebar entries retain their own data and execution prerequisites. A started Session header uses one row when fewer than two Views are available; the tab row reserves space only while rendered.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -50,9 +50,9 @@ target package 通过 declaration merge 扩展 snapshot 与 Location data map,
|
||||
|
||||
工作区选择使用 `uiWorkspace.openWorkspace` 准备目标并提交导航。草稿文字和附件仅在该请求仍为当前请求时,通过它的同步准备回调搬移;后续导航或所有者释放会保留原草稿。
|
||||
|
||||
本包占据 root 作用域 `main` 中的 `conversation` key。其 `main.conversation` shell 将 strict Session Header 保留在 optional-Session `conversation.content` Component Factory 外。Factory 拥有共享正文与 Composer,通过其标准 Hook 读取当前 Session,并公开 strict-Session `views` 与 root-scoped `widthControls` 两个局部位置。默认 adapter 渲染现有 `conversation.session` entry,主 occurrence 选择宽度拖拽条;嵌入式 occurrence 可以替换 `views`、省略拖拽条,且不渲染主 Header。共享正文与 Composer 注册 queue dock 和 Todo dock。Todo dock 在 composer 上方使用共享面板 elevation;其中的行分别以共享 idle、ongoing 与 done 标记表示待处理、进行中与已完成。`ctx.uiSession.provide()` 从同一个 Session binding 物化 Conversation 与 input source,并将 `inputActions` 作为稳定标准 prop 提供。
|
||||
本包占据 root 作用域 `main` 中的 `conversation` key。其 `main.conversation` 外壳将常驻的 `conversation.header` 放在可选 Session 的 `conversation.content` Component Factory 外。未选中 Session 时,头部仍承载根作用域导航;标题、操作和 View 标签保留在严格 Session 子组件中。Factory 拥有共享正文与 Composer,通过其标准 Hook 读取当前 Session,并公开 strict-Session `views` 与 root-scoped `widthControls` 两个局部位置。默认 adapter 渲染现有 `conversation.session` entry,主 occurrence 选择宽度拖拽条;嵌入式 occurrence 可以替换 `views`、省略拖拽条,且不渲染主 Header。共享正文与 Composer 注册 queue dock 和 Todo dock。Todo dock 在 composer 上方使用共享面板 elevation;其中的行分别以共享 idle、ongoing 与 done 标记表示待处理、进行中与已完成。`ctx.uiSession.provide()` 从同一个 Session binding 物化 Conversation 与 input source,并将 `inputActions` 作为稳定标准 prop 提供。
|
||||
|
||||
blank Session 保留 header 的 leading 与 corner 控件,包括右侧栏展开入口,同时隐藏标题、actions、utilities 和 View tabs。选择 Workspace 会创建这些控件所需的 Session,无需先发送消息。没有选中 Session 时,strict header 不挂载。侧栏各入口仍遵循自身的数据与执行环境要求。 已开始的 Session 在可用 View 少于两个时使用单行标题栏,仅在渲染标签行时保留其高度。
|
||||
blank Session 保留 header 的 leading 与 corner 控件,包括右侧栏展开入口,同时隐藏标题、actions、utilities 和 View tabs。选择 Workspace 会创建这些控件所需的 Session,无需先发送消息。没有选中 Session 时,strict header 不挂载;常驻容器在 macOS 桌面保留 40px 拖拽区域,在 Web、Windows 或 Linux 上不预留空白高度。侧栏各入口仍遵循自身的数据与执行环境要求。 已开始的 Session 在可用 View 少于两个时使用单行标题栏,仅在渲染标签行时保留其高度。
|
||||
|
||||
View 选择规则固定:有效且已注册的持久化选择优先,其次是已注册的 `chat`,否则不渲染 View;绝不选择第一个已注册 View。Shell phase 只组合 Session lifecycle 与 active-target set,不读取任何 target-specific 快照。
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import type { EnterBehaviorRowInjected } from './settings/EnterBehaviorRow.tsx'
|
||||
import { ConversationRoot } from './skeleton/ConversationRoot.tsx'
|
||||
import { ConversationContent } from './skeleton/ConversationContent.tsx'
|
||||
import { ConversationPanel } from './skeleton/ConversationPanel.tsx'
|
||||
import { ConversationHeader } from './skeleton/ConversationHeader.tsx'
|
||||
import { ConversationSession, ConversationSessionHeader } from './skeleton/ConversationSession.tsx'
|
||||
import { InputBar } from './skeleton/InputBar.tsx'
|
||||
import { todoDockEntry } from './skeleton/TodoPanel.tsx'
|
||||
@@ -241,7 +242,7 @@ export function apply(ctx: Context, config: Config = Config({})): void {
|
||||
const registerConversationRoot = () => slots.register({
|
||||
name: 'main.conversation',
|
||||
children: {
|
||||
'conversation.session.header': { kind: 'single', scope: 'session' },
|
||||
'conversation.header': { kind: 'single', scope: 'session-maybe' },
|
||||
},
|
||||
}, ConversationRoot)
|
||||
|
||||
@@ -328,12 +329,19 @@ export function apply(ctx: Context, config: Config = Config({})): void {
|
||||
},
|
||||
}, ConversationSession)
|
||||
|
||||
const registerConversationHeader = () => slots.register({
|
||||
const registerHeader = () => slots.register({
|
||||
name: 'conversation.header',
|
||||
children: {
|
||||
'conversation.header.leading': { kind: 'single', scope: 'root' },
|
||||
'conversation.session.header': { kind: 'single', scope: 'session' },
|
||||
},
|
||||
}, ConversationHeader)
|
||||
|
||||
const registerSessionHeader = () => slots.register({
|
||||
name: 'conversation.session.header',
|
||||
locale: NS,
|
||||
children: {
|
||||
'conversation.session.header.lineage': { kind: 'single', scope: 'session' },
|
||||
'conversation.session.header.leading': { kind: 'single', scope: 'session' },
|
||||
'conversation.session.header.actions': { kind: 'list', scope: 'session' },
|
||||
'conversation.session.header.utilities': { kind: 'list', scope: 'session' },
|
||||
'conversation.session.header.corner': { kind: 'single', scope: 'session' },
|
||||
@@ -444,7 +452,8 @@ export function apply(ctx: Context, config: Config = Config({})): void {
|
||||
yield registerConversationRoot()
|
||||
yield registerConversationContent()
|
||||
yield registerConversationSession()
|
||||
yield registerConversationHeader()
|
||||
yield registerHeader()
|
||||
yield registerSessionHeader()
|
||||
yield registerComposerBar()
|
||||
})
|
||||
|
||||
|
||||
@@ -131,8 +131,17 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
scope: 'session'
|
||||
owner: { view?: string }
|
||||
}
|
||||
/** Resident navigation container, including when no Session is selected. */
|
||||
'conversation.header': { kind: 'single'; scope: 'session-maybe' }
|
||||
/** Strict per-Session title, actions, and View navigation. */
|
||||
'conversation.session.header': { kind: 'single'; scope: 'session' }
|
||||
'conversation.session.header': {
|
||||
kind: 'single'
|
||||
scope: 'session'
|
||||
owner: {
|
||||
/** Parent-owned visibility shared with the header container styling. */
|
||||
hideChrome: boolean
|
||||
}
|
||||
}
|
||||
/** Optional replacement for one Session breadcrumb title. */
|
||||
'conversation.session.header.lineage': {
|
||||
kind: 'single'
|
||||
@@ -151,16 +160,10 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
scope: 'session'
|
||||
owner: ConversationHeaderActionOwnerProps
|
||||
}
|
||||
/**
|
||||
* Leading seat before the Session breadcrumbs, for window-chrome-adjacent
|
||||
* controls (macOS desktop sidebar reopen and New Session while the sidebar
|
||||
* is hidden). The seat is laid out only while its occupant renders
|
||||
* something, and it stays mounted through the blank-session state so a
|
||||
* hidden sidebar always keeps a reopen control on screen.
|
||||
*/
|
||||
'conversation.session.header.leading': {
|
||||
/** Global navigation before the Session title, available without a Session. */
|
||||
'conversation.header.leading': {
|
||||
kind: 'single'
|
||||
scope: 'session'
|
||||
scope: 'root'
|
||||
owner: ConversationHeaderLeadingOwnerProps
|
||||
}
|
||||
/**
|
||||
@@ -275,7 +278,7 @@ export interface ConversationHeaderCornerOwnerProps {
|
||||
children?: never
|
||||
}
|
||||
|
||||
/** The leading seat's occupant derives its state from standard Session props. */
|
||||
/** The leading seat exposes global navigation independently of a Session. */
|
||||
export interface ConversationHeaderLeadingOwnerProps {
|
||||
/** Marker field: the occupant receives no owner-specific values. */
|
||||
children?: never
|
||||
@@ -424,7 +427,7 @@ export interface HeroBrandMarkOwnerProps {
|
||||
/** Full props of the resident optional-Session Conversation shell. */
|
||||
export type ConversationSlotProps =
|
||||
PropsRuntime<'main.conversation'>
|
||||
& PropsRenderSlots<'conversation.session.header'>
|
||||
& PropsRenderSlots<'conversation.header'>
|
||||
& PropsRenderFactories
|
||||
|
||||
/** Inputs shared by main and embedded Conversation content occurrences. */
|
||||
@@ -462,12 +465,16 @@ export type ConversationSessionSlotProps =
|
||||
& PropsStore<ConversationStore>
|
||||
& InjectFace<ConversationSessionInjected>
|
||||
|
||||
/** Full props of the resident navigation header. */
|
||||
export type ConversationHeaderProps =
|
||||
PropsRuntime<'conversation.header'>
|
||||
& PropsRenderSlots<'conversation.header.leading' | 'conversation.session.header'>
|
||||
|
||||
/** Full props of the strict Session header. */
|
||||
export type ConversationSessionHeaderSlotProps =
|
||||
PropsRuntime<'conversation.session.header'>
|
||||
& PropsRenderSlots<
|
||||
'conversation.session.header.lineage'
|
||||
| 'conversation.session.header.leading'
|
||||
| 'conversation.session.header.actions'
|
||||
| 'conversation.session.header.utilities'
|
||||
| 'conversation.session.header.corner'
|
||||
|
||||
@@ -79,6 +79,192 @@ export const zh = {
|
||||
'todo.status.inProgress': '进行中',
|
||||
'todo.status.pending': '待处理',
|
||||
'todo.rowTitle': '更新任务清单',
|
||||
'tool.title.createGoal': '创建目标',
|
||||
'tool.title.getGoal': '查看目标',
|
||||
'tool.title.updateGoal': '更新目标',
|
||||
'tool.title.createSchedule': '创建定时任务',
|
||||
'tool.title.listSchedules': '查看定时任务',
|
||||
'tool.title.deleteSchedule': '删除定时任务',
|
||||
'detail.state': '状态',
|
||||
'detail.todo.completed': '已完成',
|
||||
'detail.todo.in_progress': '进行中',
|
||||
'detail.todo.pending': '待处理',
|
||||
'detail.todo.empty': '任务清单为空',
|
||||
'todo.diff.initial': '首次记录',
|
||||
'todo.diff.compare': '与上次清单相比',
|
||||
'todo.diff.unavailable': '旧清单不可用',
|
||||
'todo.diff.noChanges': '清单没有变化',
|
||||
'todo.diff.added': '新增 {count}',
|
||||
'todo.diff.updated': '更新 {count}',
|
||||
'todo.diff.removed': '移除 {count}',
|
||||
'todo.diff.unchanged': '{count} 项未变化',
|
||||
'todo.diff.addedItem': '新增',
|
||||
'todo.diff.updatedItem': '状态变化',
|
||||
'todo.diff.movedItem': '顺序调整',
|
||||
'todo.diff.removedItem': '移除',
|
||||
'detail.goal.empty': '没有目标',
|
||||
'detail.goal.active': '进行中',
|
||||
'detail.goal.disarmed': '等待继续',
|
||||
'detail.goal.paused': '已暂停',
|
||||
'detail.goal.blocked': '受阻',
|
||||
'detail.goal.complete': '已完成',
|
||||
'detail.goal.rounds': '执行轮次',
|
||||
'detail.goal.reason': '受阻原因',
|
||||
'detail.days': '{count} 天',
|
||||
'detail.hours': '{count} 小时',
|
||||
'detail.minutes': '{count} 分钟',
|
||||
'detail.seconds': '{count} 秒',
|
||||
'detail.schedule.once': '单次',
|
||||
'detail.schedule.every': '每 {interval}',
|
||||
'detail.schedule.when': '计划时间',
|
||||
'detail.schedule.frequency': '重复',
|
||||
'detail.schedule.scheduled': '等待触发',
|
||||
'detail.schedule.overdue': '已到期,等待会话恢复',
|
||||
'detail.schedule.empty': '没有定时任务',
|
||||
'detail.schedule.deleted': '已删除',
|
||||
'detail.schedule.count': '{count} 个定时任务',
|
||||
'tool.title.inspectProviders': '检查提供方',
|
||||
'tool.title.queryRuntime': '查询运行时',
|
||||
'tool.title.inspectPlugins': '检查动态插件',
|
||||
'tool.title.workflow': '运行工作流',
|
||||
'tool.title.ralph': '运行 Ralph',
|
||||
'tool.title.readEvent': '读取事件',
|
||||
'tool.title.searchEvents': '搜索事件',
|
||||
'tool.title.traceEvent': '追踪事件',
|
||||
'tool.title.searchSessions': '搜索会话',
|
||||
'tool.title.traceSession': '追踪会话',
|
||||
'tool.title.listModels': '查看可用模型',
|
||||
'tool.title.subagent': '委派任务',
|
||||
'tool.title.listAgents': '查看代理',
|
||||
'tool.title.sendMessage': '发送消息',
|
||||
'tool.title.interruptAgent': '中断代理',
|
||||
'tool.title.listJobs': '查看后台任务',
|
||||
'tool.title.readJob': '读取任务输出',
|
||||
'tool.title.killJob': '取消后台任务',
|
||||
'tool.title.openTerminal': '创建终端',
|
||||
'tool.title.readTerminal': '读取终端',
|
||||
'tool.title.listTerminals': '查看终端',
|
||||
'tool.title.signalTerminal': '发送终端信号',
|
||||
'tool.title.closeTerminal': '关闭终端',
|
||||
'tool.title.lsp': '查询代码符号',
|
||||
'tool.title.findDefinition': '查找定义',
|
||||
'tool.title.findReferences': '查找引用',
|
||||
'tool.title.findImplementation': '查找实现',
|
||||
'tool.title.hoverSymbol': '查看符号信息',
|
||||
'tool.title.spawnTeammate': '创建队友',
|
||||
'tool.title.createTeamTask': '创建团队任务',
|
||||
'tool.title.getTeamTask': '读取团队任务',
|
||||
'tool.title.updateTeamTask': '更新团队任务',
|
||||
'tool.title.listTeamTasks': '查看团队任务',
|
||||
'tool.title.waitAgent': '等待队友',
|
||||
'detail.recordedResult': '调用结果',
|
||||
'detail.empty': '暂无结果',
|
||||
'detail.none': '无',
|
||||
'detail.yes': '是',
|
||||
'detail.no': '否',
|
||||
'detail.moreInInspect': '另有 {count} 项,可在「查看」中读取',
|
||||
'detail.status.running': '运行中',
|
||||
'detail.status.idle': '空闲',
|
||||
'detail.status.ready': '就绪',
|
||||
'detail.status.inactive': '未运行',
|
||||
'detail.status.provisioning': '准备中',
|
||||
'detail.status.failed': '失败',
|
||||
'detail.status.completed': '已完成',
|
||||
'detail.status.deleted': '已删除',
|
||||
'detail.status.killed': '已取消',
|
||||
'detail.status.accepted': '已接收',
|
||||
'detail.status.queued': '已入队',
|
||||
'detail.status.exited': '已退出',
|
||||
'detail.field.id': 'ID',
|
||||
'detail.field.revision': '版本',
|
||||
'detail.field.platform': '平台',
|
||||
'detail.field.provider': '提供方',
|
||||
'detail.field.model': '模型',
|
||||
'detail.field.role': '角色',
|
||||
'detail.field.context': '上下文',
|
||||
'detail.field.owner': '负责人',
|
||||
'detail.field.ready': '可开始',
|
||||
'detail.field.dependencies': '前置任务',
|
||||
'detail.field.writeScopes': '文件范围',
|
||||
'detail.field.warnings': '提示',
|
||||
'detail.field.diagnostics': '诊断',
|
||||
'detail.field.methods': '方法',
|
||||
'detail.field.inputSchema': '输入 Schema',
|
||||
'detail.field.outputSchema': '输出 Schema',
|
||||
'detail.field.currentPackage': '当前包',
|
||||
'detail.field.nextPackage': '待运行的包',
|
||||
'detail.field.latestRun': '最近运行',
|
||||
'detail.field.packages': '版本包',
|
||||
'detail.field.registrations': '注册项',
|
||||
'detail.field.props': '属性',
|
||||
'detail.field.data': '数据',
|
||||
'detail.field.source': '来源',
|
||||
'detail.field.content': '内容',
|
||||
'detail.field.message': '消息',
|
||||
'detail.field.messageId': '消息 ID',
|
||||
'detail.field.root': '根节点',
|
||||
'detail.field.pid': '进程 ID',
|
||||
'detail.field.type': '类型',
|
||||
'detail.field.time': '时间',
|
||||
'detail.field.seq': '事件序号',
|
||||
'detail.field.turn': '轮次',
|
||||
'detail.field.step': '步骤',
|
||||
'detail.field.callId': '调用 ID',
|
||||
'detail.field.agents': '启动代理数',
|
||||
'detail.field.result': '结果',
|
||||
'detail.field.parent': '父级',
|
||||
'detail.field.depth': '层级',
|
||||
'detail.field.exitCode': '退出码',
|
||||
'detail.field.signal': '信号',
|
||||
'detail.field.previousStatus': '中断前状态',
|
||||
'detail.field.agent': '代理 ID',
|
||||
'detail.field.job': '任务 ID',
|
||||
'detail.field.task': '任务内容',
|
||||
'detail.field.processGroup': '进程组',
|
||||
'detail.field.availability': '可用状态',
|
||||
'detail.field.bestMatch': '最相关事件',
|
||||
'detail.field.target': '目标事件',
|
||||
'detail.field.surface': '记录状态',
|
||||
'detail.agents.count': '{count} 个代理',
|
||||
'detail.jobs.count': '{count} 个后台任务',
|
||||
'detail.terminals.count': '{count} 个终端',
|
||||
'detail.tasks.count': '{count} 个团队任务',
|
||||
'detail.tasks.nextPage': '后面还有任务;下一页位置为 {cursor}',
|
||||
'detail.locations.count': '{count} 个位置',
|
||||
'detail.location': '第 {line} 行,第 {column} 列',
|
||||
'detail.receipt.delivered': '消息已送达',
|
||||
'detail.receipt.interrupt': '已请求中断',
|
||||
'detail.receipt.started': '已启动',
|
||||
'detail.receipt.cancel': '已请求取消',
|
||||
'detail.receipt.alreadyFinished': '任务已结束',
|
||||
'detail.receipt.signal': '信号已发送',
|
||||
'detail.receipt.closed': '已关闭',
|
||||
'detail.receipt.closing': '关闭中',
|
||||
'detail.wait.noProgress': '没有正在运行的队友',
|
||||
'detail.wait.title': '队友状态',
|
||||
'detail.wait.timeout': '等待超时',
|
||||
'detail.wait.changed': '检测到变化',
|
||||
'detail.agent.reply': '代理回复',
|
||||
'detail.models.title': '可用模型',
|
||||
'detail.output.lines': '第 {begin}–{end} 行,共 {total} 行',
|
||||
'detail.output.truncated': '输出已截断',
|
||||
'detail.providers.count': '{count} 个检查提供方',
|
||||
'detail.plugins.count': '{count} 个动态插件',
|
||||
'detail.workflow.script': '工作流脚本',
|
||||
'detail.ralph.reportedComplete': '代理报告完成',
|
||||
'detail.ralph.reportedBlocker': '代理报告受阻',
|
||||
'detail.ralph.limit': '已达到轮次上限',
|
||||
'detail.report.nextSteps': '待完成事项',
|
||||
'detail.trace.replacedBy': '被替换为',
|
||||
'detail.trace.replacementChain': '替换链',
|
||||
'detail.trace.replaces': '替换的事件',
|
||||
'detail.trace.sources': '引用来源',
|
||||
'detail.trace.derived': '派生事件',
|
||||
'detail.trace.ancestors': '祖先会话',
|
||||
'detail.trace.descendants': '后代会话',
|
||||
'detail.matches.count': '{count} 条匹配',
|
||||
'detail.matches.capped': '已达到结果上限,可缩小搜索范围',
|
||||
'detail.event.neighbors': '前后事件',
|
||||
'todo.completed': '{done}/{total} 已完成',
|
||||
'command.attachmentsUnsupported': '/{command} 不接受附件,请先移除附件',
|
||||
'ask.rowTitle': '提问',
|
||||
@@ -244,6 +430,192 @@ export const en = {
|
||||
'todo.status.inProgress': 'In progress',
|
||||
'todo.status.pending': 'Pending',
|
||||
'todo.rowTitle': 'Update to-do list',
|
||||
'tool.title.createGoal': 'Create goal',
|
||||
'tool.title.getGoal': 'View goal',
|
||||
'tool.title.updateGoal': 'Update goal',
|
||||
'tool.title.createSchedule': 'Create reminder',
|
||||
'tool.title.listSchedules': 'List reminders',
|
||||
'tool.title.deleteSchedule': 'Delete reminder',
|
||||
'detail.state': 'Status',
|
||||
'detail.todo.completed': 'Completed',
|
||||
'detail.todo.in_progress': 'In progress',
|
||||
'detail.todo.pending': 'Pending',
|
||||
'detail.todo.empty': 'The to-do list is empty',
|
||||
'todo.diff.initial': 'Initial list',
|
||||
'todo.diff.compare': 'Changes since the previous list',
|
||||
'todo.diff.unavailable': 'Previous list unavailable',
|
||||
'todo.diff.noChanges': 'No changes to the list',
|
||||
'todo.diff.added': '{count} added',
|
||||
'todo.diff.updated': '{count} updated',
|
||||
'todo.diff.removed': '{count} removed',
|
||||
'todo.diff.unchanged': '{count} unchanged',
|
||||
'todo.diff.addedItem': 'Added',
|
||||
'todo.diff.updatedItem': 'Status changed',
|
||||
'todo.diff.movedItem': 'Reordered',
|
||||
'todo.diff.removedItem': 'Removed',
|
||||
'detail.goal.empty': 'No goal',
|
||||
'detail.goal.active': 'Active',
|
||||
'detail.goal.disarmed': 'Awaiting continuation',
|
||||
'detail.goal.paused': 'Paused',
|
||||
'detail.goal.blocked': 'Blocked',
|
||||
'detail.goal.complete': 'Completed',
|
||||
'detail.goal.rounds': 'Rounds',
|
||||
'detail.goal.reason': 'Blocker',
|
||||
'detail.days': '{count} d',
|
||||
'detail.hours': '{count} h',
|
||||
'detail.minutes': '{count} min',
|
||||
'detail.seconds': '{count} s',
|
||||
'detail.schedule.once': 'Once',
|
||||
'detail.schedule.every': 'Every {interval}',
|
||||
'detail.schedule.when': 'Scheduled for',
|
||||
'detail.schedule.frequency': 'Repeat',
|
||||
'detail.schedule.scheduled': 'Scheduled',
|
||||
'detail.schedule.overdue': 'Overdue, awaiting session resume',
|
||||
'detail.schedule.empty': 'No reminders',
|
||||
'detail.schedule.deleted': 'Deleted',
|
||||
'detail.schedule.count': '{count} reminders',
|
||||
'tool.title.inspectProviders': 'Inspect providers',
|
||||
'tool.title.queryRuntime': 'Query runtime',
|
||||
'tool.title.inspectPlugins': 'Inspect plugins',
|
||||
'tool.title.workflow': 'Run workflow',
|
||||
'tool.title.ralph': 'Run Ralph',
|
||||
'tool.title.readEvent': 'Read event',
|
||||
'tool.title.searchEvents': 'Search events',
|
||||
'tool.title.traceEvent': 'Trace event',
|
||||
'tool.title.searchSessions': 'Search sessions',
|
||||
'tool.title.traceSession': 'Trace session',
|
||||
'tool.title.listModels': 'List models',
|
||||
'tool.title.subagent': 'Delegate task',
|
||||
'tool.title.listAgents': 'List agents',
|
||||
'tool.title.sendMessage': 'Send message',
|
||||
'tool.title.interruptAgent': 'Interrupt agent',
|
||||
'tool.title.listJobs': 'List background jobs',
|
||||
'tool.title.readJob': 'Read job output',
|
||||
'tool.title.killJob': 'Cancel background job',
|
||||
'tool.title.openTerminal': 'Open terminal',
|
||||
'tool.title.readTerminal': 'Read terminal',
|
||||
'tool.title.listTerminals': 'List terminals',
|
||||
'tool.title.signalTerminal': 'Signal terminal',
|
||||
'tool.title.closeTerminal': 'Close terminal',
|
||||
'tool.title.lsp': 'Query code symbols',
|
||||
'tool.title.findDefinition': 'Find definition',
|
||||
'tool.title.findReferences': 'Find references',
|
||||
'tool.title.findImplementation': 'Find implementation',
|
||||
'tool.title.hoverSymbol': 'Inspect symbol',
|
||||
'tool.title.spawnTeammate': 'Create teammate',
|
||||
'tool.title.createTeamTask': 'Create team task',
|
||||
'tool.title.getTeamTask': 'Read team task',
|
||||
'tool.title.updateTeamTask': 'Update team task',
|
||||
'tool.title.listTeamTasks': 'List team tasks',
|
||||
'tool.title.waitAgent': 'Wait for teammates',
|
||||
'detail.recordedResult': 'Recorded result',
|
||||
'detail.empty': 'No results',
|
||||
'detail.none': 'None',
|
||||
'detail.yes': 'Yes',
|
||||
'detail.no': 'No',
|
||||
'detail.moreInInspect': '{count} more items available in Inspect',
|
||||
'detail.status.running': 'Running',
|
||||
'detail.status.idle': 'Idle',
|
||||
'detail.status.ready': 'Ready',
|
||||
'detail.status.inactive': 'Inactive',
|
||||
'detail.status.provisioning': 'Provisioning',
|
||||
'detail.status.failed': 'Failed',
|
||||
'detail.status.completed': 'Completed',
|
||||
'detail.status.deleted': 'Deleted',
|
||||
'detail.status.killed': 'Cancelled',
|
||||
'detail.status.accepted': 'Accepted',
|
||||
'detail.status.queued': 'Queued',
|
||||
'detail.status.exited': 'Exited',
|
||||
'detail.field.id': 'ID',
|
||||
'detail.field.revision': 'Revision',
|
||||
'detail.field.platform': 'Platform',
|
||||
'detail.field.provider': 'Provider',
|
||||
'detail.field.model': 'Model',
|
||||
'detail.field.role': 'Role',
|
||||
'detail.field.context': 'Context',
|
||||
'detail.field.owner': 'Owner',
|
||||
'detail.field.ready': 'Ready',
|
||||
'detail.field.dependencies': 'Dependencies',
|
||||
'detail.field.writeScopes': 'Write scopes',
|
||||
'detail.field.warnings': 'Warnings',
|
||||
'detail.field.diagnostics': 'Diagnostics',
|
||||
'detail.field.methods': 'Methods',
|
||||
'detail.field.inputSchema': 'Input schema',
|
||||
'detail.field.outputSchema': 'Output schema',
|
||||
'detail.field.currentPackage': 'Current package',
|
||||
'detail.field.nextPackage': 'Next package',
|
||||
'detail.field.latestRun': 'Latest run',
|
||||
'detail.field.packages': 'Packages',
|
||||
'detail.field.registrations': 'Registrations',
|
||||
'detail.field.props': 'Props',
|
||||
'detail.field.data': 'Data',
|
||||
'detail.field.source': 'Source',
|
||||
'detail.field.content': 'Content',
|
||||
'detail.field.message': 'Message',
|
||||
'detail.field.messageId': 'Message ID',
|
||||
'detail.field.root': 'Root',
|
||||
'detail.field.pid': 'Process ID',
|
||||
'detail.field.type': 'Type',
|
||||
'detail.field.time': 'Time',
|
||||
'detail.field.seq': 'Event sequence',
|
||||
'detail.field.turn': 'Turn',
|
||||
'detail.field.step': 'Step',
|
||||
'detail.field.callId': 'Call ID',
|
||||
'detail.field.agents': 'Agents started',
|
||||
'detail.field.result': 'Result',
|
||||
'detail.field.parent': 'Parent',
|
||||
'detail.field.depth': 'Depth',
|
||||
'detail.field.exitCode': 'Exit code',
|
||||
'detail.field.signal': 'Signal',
|
||||
'detail.field.previousStatus': 'Previous status',
|
||||
'detail.field.agent': 'Agent ID',
|
||||
'detail.field.job': 'Job ID',
|
||||
'detail.field.task': 'Task',
|
||||
'detail.field.processGroup': 'Process group',
|
||||
'detail.field.availability': 'Availability',
|
||||
'detail.field.bestMatch': 'Best match',
|
||||
'detail.field.target': 'Target event',
|
||||
'detail.field.surface': 'Record status',
|
||||
'detail.agents.count': '{count} agents',
|
||||
'detail.jobs.count': '{count} background jobs',
|
||||
'detail.terminals.count': '{count} terminals',
|
||||
'detail.tasks.count': '{count} team tasks',
|
||||
'detail.tasks.nextPage': 'More tasks available; next cursor is {cursor}',
|
||||
'detail.locations.count': '{count} locations',
|
||||
'detail.location': 'Line {line}, column {column}',
|
||||
'detail.receipt.delivered': 'Message delivered',
|
||||
'detail.receipt.interrupt': 'Interrupt requested',
|
||||
'detail.receipt.started': 'Started',
|
||||
'detail.receipt.cancel': 'Cancellation requested',
|
||||
'detail.receipt.alreadyFinished': 'Already finished',
|
||||
'detail.receipt.signal': 'Signal delivered',
|
||||
'detail.receipt.closed': 'Closed',
|
||||
'detail.receipt.closing': 'Closing',
|
||||
'detail.wait.noProgress': 'No active teammates',
|
||||
'detail.wait.title': 'Teammate activity',
|
||||
'detail.wait.timeout': 'Wait timed out',
|
||||
'detail.wait.changed': 'Change detected',
|
||||
'detail.agent.reply': 'Agent response',
|
||||
'detail.models.title': 'Available models',
|
||||
'detail.output.lines': 'Lines {begin}–{end} of {total}',
|
||||
'detail.output.truncated': 'Output truncated',
|
||||
'detail.providers.count': '{count} inspect providers',
|
||||
'detail.plugins.count': '{count} dynamic plugins',
|
||||
'detail.workflow.script': 'Workflow script',
|
||||
'detail.ralph.reportedComplete': 'Worker reported completion',
|
||||
'detail.ralph.reportedBlocker': 'Worker reported a blocker',
|
||||
'detail.ralph.limit': 'Round limit reached',
|
||||
'detail.report.nextSteps': 'Remaining work',
|
||||
'detail.trace.replacedBy': 'Replaced by',
|
||||
'detail.trace.replacementChain': 'Replacement chain',
|
||||
'detail.trace.replaces': 'Replaced events',
|
||||
'detail.trace.sources': 'Source events',
|
||||
'detail.trace.derived': 'Derived events',
|
||||
'detail.trace.ancestors': 'Ancestor sessions',
|
||||
'detail.trace.descendants': 'Descendant sessions',
|
||||
'detail.matches.count': '{count} matches',
|
||||
'detail.matches.capped': 'Result limit reached; narrow the search for more',
|
||||
'detail.event.neighbors': 'Surrounding events',
|
||||
'todo.completed': '{done}/{total} completed',
|
||||
'command.attachmentsUnsupported': '/{command} does not accept attachments; remove them first',
|
||||
'ask.rowTitle': 'Ask question',
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/** Resident conversation navigation and Session-specific header content. */
|
||||
import clsx from 'clsx'
|
||||
import type { ConversationHeaderProps } from '../contract/slots.ts'
|
||||
import { conversationPhase } from '../contract/snapshot.ts'
|
||||
import css from './ConversationRoot.module.css'
|
||||
|
||||
/**
|
||||
* Keeps global navigation available before a Session exists.
|
||||
* @param props - Optional Session sources and authorized header slots.
|
||||
* @returns The persistent header with any selected Session's title and views.
|
||||
*/
|
||||
export function ConversationHeader({ sessionId, useSession, useConversation, renderSlot }: ConversationHeaderProps) {
|
||||
const session = useSession(s => s)
|
||||
const conversation = useConversation(s => s)
|
||||
const blank = session === undefined || conversation === undefined
|
||||
|| (session.blank && conversationPhase(session, conversation) === 'blank')
|
||||
return (
|
||||
<header className={clsx(css.header, blank && css.headerBlank, sessionId === undefined && css.headerSessionless)}>
|
||||
<div className={css.headerLeading} data-conversation-header-leading="">
|
||||
{renderSlot('conversation.header.leading', {})}
|
||||
</div>
|
||||
{sessionId === undefined
|
||||
? <div className={css.titleRow} />
|
||||
: renderSlot('conversation.session.header', { hideChrome: blank })}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -42,7 +42,7 @@ export function ConversationMainPanel(props: ConversationSlotProps) {
|
||||
|
||||
return (
|
||||
<div className={css.root} data-phase={phase}>
|
||||
{sessionId === undefined ? null : renderSlot('conversation.session.header', {})}
|
||||
{renderSlot('conversation.header', {})}
|
||||
{renderFactorySlot('conversation.content', {
|
||||
variant: 'main',
|
||||
phase,
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
title row, the tab strip's 10px margin, a 16px tab line, and its 9px bottom
|
||||
inset — so no row below may grow past its figure. */
|
||||
.header {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
flex: none;
|
||||
box-sizing: border-box;
|
||||
min-height: 76px;
|
||||
@@ -20,7 +22,7 @@
|
||||
border-bottom: 0.5px solid var(--dsw-alias-border-l3);
|
||||
}
|
||||
|
||||
.headerCompact {
|
||||
.header:where(:not(:has(.tabs))) {
|
||||
min-height: 0;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
@@ -36,6 +38,15 @@
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Only the macOS titlebar needs reserved drag space before a Session exists. */
|
||||
:global(html:not([data-platform='darwin'])) .headerSessionless {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
:global(html:not([data-platform='darwin'])) .headerSessionless .titleRow {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* macOS hiddenInset titlebar: the title row doubles as the window drag
|
||||
region; interactive children opt out. */
|
||||
:global([data-platform='darwin']) .titleRow {
|
||||
@@ -52,6 +63,7 @@
|
||||
}
|
||||
|
||||
.titleRow {
|
||||
grid-column: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
@@ -66,21 +78,17 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Leading seat before the breadcrumbs; laid out only while its occupant
|
||||
renders something, and the occupant owns its own trailing gap (a CSS-hidden
|
||||
occupant keeps the seat non-empty, so a seat-owned margin would push the
|
||||
breadcrumbs while nothing shows). */
|
||||
/* The auto grid column collapses with an empty or CSS-hidden occupant.
|
||||
The occupant owns its trailing gap so hidden controls reserve no space. */
|
||||
.headerLeading {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.headerLeading:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.crumbs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -172,6 +180,7 @@
|
||||
/* figma Tab_Group 34:11441: 35px strip, gap 36, pad-left 8, tabs bottom-aligned.
|
||||
Positioned above the header's own paint, so the active bar covers the rule. */
|
||||
.tabs {
|
||||
grid-column: 1 / -1;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
|
||||
@@ -6,7 +6,6 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
ConversationSessionHeaderSlotProps, ConversationSessionSlotProps,
|
||||
} from '../contract/slots.ts'
|
||||
import { conversationPhase } from '../contract/snapshot.ts'
|
||||
import { resolveActiveView } from '../view-selection.ts'
|
||||
import { DefaultConversationViews } from './DefaultConversationViews.tsx'
|
||||
import css from './ConversationRoot.module.css'
|
||||
@@ -57,23 +56,17 @@ function equalBreadcrumbs(left: readonly Breadcrumb[], right: readonly Breadcrum
|
||||
* @returns Session navigation controls, with title and tabs after conversation starts.
|
||||
*/
|
||||
export function ConversationSessionHeader({
|
||||
sessionId, useSession, useSessions, useConversation, useConversationViews, useStore,
|
||||
sessionId, hideChrome, useSessions, useConversationViews, useStore,
|
||||
renderSlot, open, selectView, t,
|
||||
}: ConversationSessionHeaderProps) {
|
||||
const tabs = useConversationViews(value => value)
|
||||
const selectedId = useStore(s => s.view)
|
||||
const active = resolveActiveView(tabs, selectedId)
|
||||
const ancestry = useSessions(s => deriveAncestry(s, sessionId), equalBreadcrumbs)
|
||||
const session = useSession(s => s)
|
||||
const conversation = useConversation(s => s)
|
||||
const hideChrome = session.blank && conversationPhase(session, conversation) === 'blank'
|
||||
const showTabs = !hideChrome && tabs.length > 1
|
||||
return (
|
||||
<header className={clsx(css.header, !showTabs && css.headerCompact, hideChrome && css.headerBlank)}>
|
||||
<>
|
||||
<div className={css.titleRow}>
|
||||
<div className={css.headerLeading} data-conversation-header-leading="">
|
||||
{renderSlot('conversation.session.header.leading', {})}
|
||||
</div>
|
||||
{!hideChrome && (
|
||||
<>
|
||||
<div className={css.titleCluster}>
|
||||
@@ -155,7 +148,7 @@ export function ConversationSessionHeader({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -76,6 +76,8 @@ describe('target-neutral Conversation apply wiring', () => {
|
||||
.toMatchObject({ views: { scope: 'session' } })
|
||||
expect(b.runtime.slots.entries('conversation.session')).toHaveLength(1)
|
||||
expect(b.runtime.slots.entries('conversation.session.header')).toHaveLength(1)
|
||||
expect(b.runtime.slots.spec('conversation.header')).toEqual({ kind: 'single', scope: 'session-maybe' })
|
||||
expect(b.runtime.slots.spec('conversation.header.leading')).toEqual({ kind: 'single', scope: 'root' })
|
||||
expect(b.runtime.slots.entries('conversation.composer.bar')).toHaveLength(1)
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @vitest-environment jsdom
|
||||
import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { GlobalStandardProps, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createContext, useContext, type ReactNode } from 'react'
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
@@ -21,6 +21,7 @@ import { createConversationStore } from '../src/client/stores.ts'
|
||||
import { SessionInputShell } from '../src/client/input/facade.ts'
|
||||
import { en, zh } from '../src/client/locales.ts'
|
||||
import { ConversationContent } from '../src/client/skeleton/ConversationContent.tsx'
|
||||
import { ConversationHeader } from '../src/client/skeleton/ConversationHeader.tsx'
|
||||
import { ConversationMainPanel } from '../src/client/skeleton/ConversationMainPanel.tsx'
|
||||
import { ConversationSession, ConversationSessionHeader } from '../src/client/skeleton/ConversationSession.tsx'
|
||||
import { conversationPhase } from '../src/client/contract/snapshot.ts'
|
||||
@@ -30,7 +31,7 @@ import { InputBar } from '../src/client/skeleton/InputBar.tsx'
|
||||
import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
|
||||
import type {
|
||||
ComposerBarOwnerProps, ConversationContentInputProps, ConversationContentProps,
|
||||
ConversationHeaderLineageOwnerProps, ConversationSessionSlotProps, ConversationSlotProps,
|
||||
ConversationHeaderLineageOwnerProps, ConversationSessionHeaderSlotProps, ConversationSessionSlotProps, ConversationSlotProps,
|
||||
ConversationViewsProps,
|
||||
} from '../src/client/contract/slots.ts'
|
||||
import type { ViewTab } from '../src/client/contract/views.ts'
|
||||
@@ -122,6 +123,8 @@ function mount(
|
||||
workspaceRows: WorkspaceView[] = [{ ...workspace('one'), sessionIds: [SID] }],
|
||||
retargetWorkspace = vi.fn(async (_workspaceId: WorkspaceId) => {}),
|
||||
options: {
|
||||
/** Explicit undefined exercises the shell before a Session is selected. */
|
||||
sessionId?: SessionId | undefined
|
||||
/** When true, mimic overlay:true chain siblings (hidden fallback + takeover). */
|
||||
overlayTakeover?: boolean
|
||||
/** The session list summary's `blank` flag — independent of the snapshot's. */
|
||||
@@ -138,6 +141,7 @@ function mount(
|
||||
viewTabs?: ViewTab[]
|
||||
} = {},
|
||||
) {
|
||||
const sessionId = 'sessionId' in options ? options.sessionId : SID
|
||||
const root = sid('root')
|
||||
const parent = sid('parent')
|
||||
const rootRow = { id: root, displayTitle: 'Root', running: false, retainedBy: {}, blank: false, updatedAt: 1 }
|
||||
@@ -197,9 +201,13 @@ function mount(
|
||||
lineageOwners.push(owner as ConversationHeaderLineageOwnerProps)
|
||||
return opts?.fallback ?? null
|
||||
}
|
||||
if (key === 'conversation.header') {
|
||||
return <ConversationHeader {...runtimeProps} renderSlot={renderSlot as never} />
|
||||
}
|
||||
if (key === 'conversation.session.header') {
|
||||
return (
|
||||
<ConversationSessionHeader
|
||||
hideChrome={(owner as Pick<ConversationSessionHeaderSlotProps, 'hideChrome'>).hideChrome}
|
||||
sessionId={SID}
|
||||
SessionProvider={({ children }) => children}
|
||||
useSession={useSession}
|
||||
@@ -341,16 +349,16 @@ function mount(
|
||||
)) as ConversationContentProps['useFactorySlot']
|
||||
return (
|
||||
<FactoryViewsTestContext.Provider value={common}>
|
||||
<ConversationContent {...({ ...common, ...input, useFactorySlot })} />
|
||||
<ConversationContent {...({ ...common, ...runtimeProps, ...input, useFactorySlot })} />
|
||||
</FactoryViewsTestContext.Provider>
|
||||
)
|
||||
}) as ConversationSlotProps['renderFactorySlot']
|
||||
const props: ConversationSlotProps = {
|
||||
const runtimeProps: PropsRuntime<'main.conversation'> & Pick<ConversationSlotProps, 'SessionProvider'> = {
|
||||
usePanelInfo: selector => selector({ activePanelId: null }),
|
||||
sessionId: SID,
|
||||
sessionId,
|
||||
SessionProvider,
|
||||
useSession,
|
||||
useConversation,
|
||||
useSession: sessionId === undefined ? () => undefined : useSession,
|
||||
useConversation: sessionId === undefined ? () => undefined : useConversation,
|
||||
useSessions: bindSnapshotSelector(sessions),
|
||||
useSessionStatus,
|
||||
useSessionRetainInfo: () => undefined,
|
||||
@@ -359,9 +367,8 @@ function mount(
|
||||
useProjection: (() => undefined),
|
||||
useInput,
|
||||
inputActions,
|
||||
renderSlot,
|
||||
renderFactorySlot,
|
||||
}
|
||||
const props: ConversationSlotProps = { ...runtimeProps, renderSlot, renderFactorySlot }
|
||||
const view = render(<ConversationMainPanel {...props} />)
|
||||
return {
|
||||
view, store, wiring, sink, retargetWorkspace, session, conversation, slotCalls, lineageOwners, seatOwners, open,
|
||||
@@ -389,6 +396,15 @@ describe('Hero chrome', () => {
|
||||
})
|
||||
|
||||
describe('ConversationRoot resident composer', () => {
|
||||
it('keeps global header navigation without selecting a Session', () => {
|
||||
const b = mount(sessionSnapshotOf(), [], undefined, { sessionId: undefined })
|
||||
expect(b.view.container.querySelector('header')).not.toBeNull()
|
||||
expect(b.view.getByTestId('view-conversation.header.leading')).toBeTruthy()
|
||||
expect(b.slotCalls).not.toContain('conversation.session.header')
|
||||
expect(b.view.queryByRole('tablist')).toBeNull()
|
||||
expect(b.view.queryByTestId('view-conversation.session.header.corner')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not redispatch composer child slots for an unrelated Session publication', () => {
|
||||
const b = mount(sessionSnapshotOf())
|
||||
const childKeys = new Set([
|
||||
@@ -494,7 +510,7 @@ describe('ConversationRoot resident composer', () => {
|
||||
expect(host?.contains(seat)).toBe(true)
|
||||
expect(seat?.contains(textarea)).toBe(true)
|
||||
expect(b.slotCalls).toContain('conversation.session.header.lineage')
|
||||
expect(b.slotCalls).toContain('conversation.session.header.leading')
|
||||
expect(b.slotCalls).toContain('conversation.header.leading')
|
||||
expect(b.slotCalls).toContain('conversation.session.header.actions')
|
||||
expect(b.slotCalls).toContain('conversation.session.header.utilities')
|
||||
expect(b.slotCalls).toContain('conversation.session.header.corner')
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-sidebar/README.md
|
||||
README.md: a0b9ffe8ca601fade50ff86fc1921d6b41b84748
|
||||
README.zh.md: 44b08c8ef76ec266c813c8199db8291f47db3d53
|
||||
README.md: 513e9d3151aba64b640475c3cd8c2f993df077db
|
||||
README.zh.md: 69050dc8c530bf4c794d42113f2679fe41891e5f
|
||||
@@ -45,7 +45,7 @@ On Windows Electron, `html[data-windows-titlebar]` fixes the sidebar toggle in t
|
||||
|
||||
### macOS desktop
|
||||
|
||||
Under `html[data-platform='darwin']` (set only by the desktop preload) the expanded column opens with a 52px top strip that clears the hiddenInset traffic lights, carries the collapse toggle, and acts as the window drag region; collapsing hides the column entirely instead of leaving the rail. The package registers `HeaderLeadingControls` into the conversation header's `conversation.session.header.leading` seat — the open-sidebar and New Session controls shown, purely via CSS against the AppFrame-published `data-sidebar-collapsed` attribute, only while the column is hidden. Rationale and the window-integration contract: the [macOS hidden-titlebar Agent Note](../../../.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.md).
|
||||
Under `html[data-platform='darwin']` (set only by the desktop preload) the expanded column opens with a 52px top strip that clears the hiddenInset traffic lights, carries the collapse toggle, and acts as the window drag region; collapsing hides the column entirely instead of leaving the rail. The resident header remains mounted without a selected Session. The package registers `HeaderLeadingControls` into the conversation header's root-scoped `conversation.header.leading` seat — the open-sidebar and New Session controls shown, purely via CSS against the AppFrame-published `data-sidebar-collapsed` attribute, only while the column is hidden. Rationale and the window-integration contract: the [macOS hidden-titlebar Agent Note](../../../.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.md).
|
||||
|
||||
### Scrollbars
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ dsh Web 客户端的侧边栏让用户识别当前构建、启动新会话、将
|
||||
|
||||
### macOS 桌面
|
||||
|
||||
在 `html[data-platform='darwin']`(仅由桌面 preload 设置)下,展开的侧边栏列顶部有一条 52px 的顶部条:避开 hiddenInset 红绿灯、承载收起按钮,并作为窗口拖拽区;收起时整列隐藏而非保留轨道。本包向会话头部的 `conversation.session.header.leading` 座注册 `HeaderLeadingControls`——打开侧边栏与 New Session 两个控件,纯由 CSS 依据 AppFrame 发布的 `data-sidebar-collapsed` 属性仅在列隐藏时显示。设计依据与窗口集成约定见 [macOS 隐藏标题栏 Agent Note](../../../.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.zh.md)。
|
||||
在 `html[data-platform='darwin']`(仅由桌面 preload 设置)下,展开的侧边栏列顶部有一条 52px 的顶部条:避开 hiddenInset 红绿灯、承载收起按钮,并作为窗口拖拽区;收起时整列隐藏而非保留轨道。未选中会话时,常驻头部仍保持挂载。本包向会话头部的根作用域 `conversation.header.leading` 座注册 `HeaderLeadingControls`——打开侧边栏与 New Session 两个控件,纯由 CSS 依据 AppFrame 发布的 `data-sidebar-collapsed` 属性仅在列隐藏时显示。设计依据与窗口集成约定见 [macOS 隐藏标题栏 Agent Note](../../../.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.zh.md)。
|
||||
|
||||
### 滚动条
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** macOS-desktop conversation-header controls for the fully hidden sidebar. */
|
||||
/** macOS-desktop resident-header controls for the fully hidden sidebar. */
|
||||
import {
|
||||
IconNewChatOutlineMedium, IconPanelLeftOutlineRegular, isDarwinDesktop, Tooltip,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
@@ -8,14 +8,14 @@ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { SidebarRootInjected } from './contract/slots.ts'
|
||||
import css from './HeaderLeadingControls.module.css'
|
||||
|
||||
/** Full props of the conversation-header leading occupant. */
|
||||
/** Full props of the resident-header leading occupant. */
|
||||
export type HeaderLeadingControlsProps =
|
||||
PropsRuntime<'conversation.session.header.leading'>
|
||||
PropsRuntime<'conversation.header.leading'>
|
||||
& InjectFace<SidebarRootInjected>
|
||||
& PropsLocale<'sidebar'>
|
||||
|
||||
/**
|
||||
* Sidebar-open and New Session controls in the conversation header's leading
|
||||
* Sidebar-open and New Session controls in the resident header's leading
|
||||
* seat. On macOS desktop a collapsed sidebar hides entirely (no rail), taking
|
||||
* both controls off screen; this occupant puts them back beside the traffic
|
||||
* lights. Mounted whenever the platform matches; visibility rides the
|
||||
|
||||
@@ -89,8 +89,8 @@ export function apply(ctx: ClientContext): void {
|
||||
// Session controls move into the conversation header's leading seat; the
|
||||
// occupant reuses the shell's injected actions and shows itself purely
|
||||
// through CSS against the AppFrame's data-sidebar-collapsed attribute.
|
||||
ctx.slots.inject('conversation.session.header.leading', () => ctx.slots.register({
|
||||
name: 'conversation.session.header.leading',
|
||||
ctx.slots.inject('conversation.header.leading', () => ctx.slots.register({
|
||||
name: 'conversation.header.leading',
|
||||
locale: NS,
|
||||
inject: injectProps,
|
||||
}, HeaderLeadingControls))
|
||||
|
||||
@@ -42,7 +42,7 @@ async function bench(declare = true) {
|
||||
{ name: 'root', children: {
|
||||
'sidebar': { kind: 'single', scope: 'root' },
|
||||
'main': { kind: 'keyed', scope: 'root' },
|
||||
'conversation.session.header.leading': { kind: 'single', scope: 'session' },
|
||||
'conversation.header.leading': { kind: 'single', scope: 'root' },
|
||||
} },
|
||||
SidebarFrame,
|
||||
)
|
||||
@@ -72,7 +72,7 @@ describe('ui-sidebar apply', () => {
|
||||
// Copy rides the standard locale seat, not the inject face.
|
||||
expect(b.slots.entries('sidebar')[0]!.locale).toBe('sidebar')
|
||||
// The header leading occupant reuses the shell's inject face and locale.
|
||||
const leading = b.slots.entries('conversation.session.header.leading')
|
||||
const leading = b.slots.entries('conversation.header.leading')
|
||||
expect(leading).toHaveLength(1)
|
||||
expect(leading[0]!.component).toBe(HeaderLeadingControls)
|
||||
expect(leading[0]!.locale).toBe('sidebar')
|
||||
@@ -145,7 +145,7 @@ describe('ui-sidebar apply', () => {
|
||||
await fiber.await()
|
||||
await fiber.dispose()
|
||||
expect(b.slots.entries('sidebar')).toHaveLength(0)
|
||||
expect(b.slots.entries('conversation.session.header.leading')).toHaveLength(0)
|
||||
expect(b.slots.entries('conversation.header.leading')).toHaveLength(0)
|
||||
expect(b.slots.spec('sidebar.brand.mark')).toBeUndefined()
|
||||
expect(b.slots.spec('sidebar.brand.name')).toBeUndefined()
|
||||
expect(b.slots.spec('sidebar.workspaces')).toBeUndefined()
|
||||
|
||||
@@ -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: 067fa16bad9f6a34b26da3402de5e0a574323500
|
||||
README.zh.md: 9376493b780511c6e412cff4c5b976126c230693
|
||||
README.md: 6a63c14d163742b1818320925248405831bd87b6
|
||||
README.zh.md: e53a97404ae17f0e93242c11dcc0b1f066542c12
|
||||
@@ -68,6 +68,10 @@ Chat diff cards keep nine rows before folding, enough for a file header, one rem
|
||||
|
||||
An Auto denial takes precedence over keyed specialized views. Its generic row preserves the call identity, omits raw arguments, and normalizes the stored reason only for display: trim surrounding whitespace and collapse line separators to spaces, with localized fallback for an empty result. Session and SDK error details keep the original reason.
|
||||
|
||||
Recorded tool details cover goal and schedule tools, Cordis inspection, workflow and Ralph reports, Session event/search/trace queries, agent and teammate controls, background jobs, persistent terminals, and LSP navigation. These expanded bodies read successful logged results, preserve generic input/output for failures or unsupported data, and keep Inspect available. Dates include the viewer's time zone, and statuses reflect the call result rather than current session state. Session traces preserve descendant indentation. LSP results open filesystem paths through the Host callback and display other URIs as text. The browser adapter consumes recorded producer text and JSON; Host service objects and presenter callbacks do not cross into the Client. [Compact tool details](../../../.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.md) records the presentation trade-offs.
|
||||
|
||||
Expanded status dots and labels use static semantic colors. Receipt and job-output headers keep neutral text and omit the status while expanded. An interruption receipt confirms only that interruption was requested.
|
||||
|
||||
The terminal model uses `hasSpillNotice` from the browser-safe `@deepseek-ai/dsh-spill-policy/notice` entry, not an independent UI pattern. The [spill-policy README](../../spill/spill-policy/README.md#shared-notice-ownership) owns notice formatting and recognition. This check conservatively selects generic output; matching text cannot authenticate its source, and replay leaves recorded result bytes untouched.
|
||||
</details>
|
||||
|
||||
|
||||
@@ -68,6 +68,10 @@ Chat diff 卡片在折叠前保留九行,足以容纳文件标题、一对删
|
||||
|
||||
Auto 拒绝优先于按工具名选择的专门视图。其通用行保留调用身份、省略原始参数,并且只在显示时归一化存储的理由:去除首尾空白,把行分隔符折叠为空格,结果为空时使用本地化通用理由。Session 与 SDK 错误详情保留原始理由。
|
||||
|
||||
记录结果的工具详情覆盖目标和定时任务工具、Cordis 检查、workflow 与 Ralph 报告、Session 事件/搜索/轨迹查询、Agent 与 teammate 控制、后台作业、持久终端以及 LSP 导航。展开内容读取成功的记录结果,为失败或不支持的数据保留通用输入/输出,并保留 Inspect。日期包含查看者的时区,状态反映调用结果而非当前会话状态。Session 轨迹保留后代的缩进。LSP 结果通过 Host 回调打开文件系统路径,其他 URI 则显示为文本。浏览器适配器消费已记录的 producer 文本与 JSON;Host service 对象和 presenter 回调不会进入 Client。[紧凑工具详情](../../../.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.zh.md)记录了呈现取舍。
|
||||
|
||||
展开后的状态圆点和文字使用静态语义色。操作回执和任务输出的标题保持中性色,展开时省略标题中的状态。中断回执仅确认已发出中断请求。
|
||||
|
||||
terminal model 使用浏览器安全入口 `@deepseek-ai/dsh-spill-policy/notice` 的 `hasSpillNotice`,而非独立的 UI 匹配规则。[spill-policy README](../../spill/spill-policy/README.zh.md#shared-notice-ownership) 负责提示文本的格式化与识别。该检查保守地选择通用输出;匹配的文本无法证明其来源,回放也不改变已记录的结果字节。
|
||||
</details>
|
||||
|
||||
|
||||
@@ -51,6 +51,9 @@
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-jobs": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-session-query": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-terminal": "workspace:^",
|
||||
"@deepseek-ai/dsh-spill": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
|
||||
@@ -14,6 +14,7 @@ import { fileMutationToolview } from './tool/toolviews/file-mutation-row.tsx'
|
||||
import { readToolview } from './tool/toolviews/read-row.tsx'
|
||||
import { readImageToolview } from './tool/toolviews/read-image-row.tsx'
|
||||
import { searchToolview } from './tool/toolviews/search-row.tsx'
|
||||
import { detailsToolview } from './tool/toolviews/details-row.tsx'
|
||||
import { todoToolview } from './tool/toolviews/todo-row.tsx'
|
||||
import { webToolview } from './tool/toolviews/web-row.tsx'
|
||||
|
||||
@@ -47,5 +48,6 @@ export function apply(ctx: ClientContext): void {
|
||||
ctx.plugin(searchToolview)
|
||||
ctx.plugin(webToolview)
|
||||
ctx.plugin(todoToolview)
|
||||
ctx.plugin(detailsToolview)
|
||||
ctx.plugin(askQuestionToolview)
|
||||
}
|
||||
@@ -10,18 +10,13 @@ import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface SlotMap {
|
||||
/**
|
||||
* Keyed atomic Tool call view, dispatched by the wire Tool name. Register
|
||||
* with `key: '<tool name>'` to own how one tool's calls render inside a
|
||||
* turn — the key domain is open (any wire tool name, including a tool your
|
||||
* own package registered), so there is no compile-time key set to pick
|
||||
* from and a typo simply never renders.
|
||||
* Keyed Tool call view dispatched by wire Tool name. Any name is allowed,
|
||||
* including tools registered by your package. Register with
|
||||
* `key: '<tool name>'`; a typo never renders.
|
||||
*
|
||||
* A key the shipped composition already covers is replaced, not shared;
|
||||
* an unclaimed key falls back to the generic tool row, so registering is
|
||||
* additive for your own tool and a takeover for a shipped one. The owner
|
||||
* passes the call's identity, its frozen running-or-settled node, and the
|
||||
* expansion state (see ToolCallOwnerProps), so the view stays a pure
|
||||
* function of what the turn already knows.
|
||||
* Registering an occupied key replaces its view; unclaimed keys use the
|
||||
* generic row. The owner supplies the call identity and frozen running
|
||||
* or settled node through ToolCallOwnerProps.
|
||||
*/
|
||||
'tool.call.toolview': { kind: 'keyed'; scope: 'session'; owner: ToolCallOwnerProps }
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
.root {
|
||||
margin: 4px 0 4px 4px;
|
||||
border: 0.5px solid var(--dsw-alias-border-l1);
|
||||
border-radius: 12px;
|
||||
background: var(--dsw-alias-markdown-code-block);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font: var(--dsw-font-xs-13);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.item,
|
||||
.empty {
|
||||
margin: 0;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
.item + .item {
|
||||
border-top: 0.5px solid var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.root[data-inspect]:not([data-caption]) > .list > .item,
|
||||
.root[data-inspect]:not([data-caption]) .empty,
|
||||
.root[data-inspect] .caption {
|
||||
padding-inline-end: 88px;
|
||||
}
|
||||
|
||||
.caption {
|
||||
padding: 10px 14px 6px;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.heading {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.status {
|
||||
flex: none;
|
||||
align-self: flex-start;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 18px;
|
||||
font-size: 16px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.pending {
|
||||
box-sizing: border-box;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border: 1px solid var(--dsw-alias-label-tertiary);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.heading:has(.statusText) .text {
|
||||
flex-basis: 12em;
|
||||
}
|
||||
|
||||
.statusText {
|
||||
margin-inline-start: auto;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.previous,
|
||||
.unchanged {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.item[data-change='added'] .status {
|
||||
color: var(--dsw-alias-state-success-primary);
|
||||
}
|
||||
|
||||
.item[data-change='removed'] .status {
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.item[data-change='removed'] .text {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.unchanged {
|
||||
border-top: 0.5px solid var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.unchanged summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 9px 14px;
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.unchanged summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.unchanged[open] summary svg {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.fields {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.heading + .fields {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
grid-template-columns: 5.5em minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.field + .field {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.field dt {
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.field dd {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.badge {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.badge::before {
|
||||
content: '';
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
corner-shape: round;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.badge[data-tone='info'] { color: var(--dsw-alias-state-business-primary); }
|
||||
.badge[data-tone='success'] { color: var(--dsw-alias-state-success-primary); }
|
||||
.badge[data-tone='warning'] { color: var(--dsw-alias-state-warn-primary); }
|
||||
.badge[data-tone='error'] { color: var(--dsw-alias-state-error-primary); }
|
||||
|
||||
.subtitle {
|
||||
margin-top: 3px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 12px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 6px 0;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.lines {
|
||||
margin: 6px 0 0;
|
||||
padding-inline-start: 18px;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.lines li + li { margin-top: 4px; }
|
||||
|
||||
.group {
|
||||
margin-top: 8px;
|
||||
border-top: 0.5px solid var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.group > summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 0 0;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.group > summary::-webkit-details-marker { display: none; }
|
||||
.group[open] > summary svg { transform: rotate(90deg); }
|
||||
.group > summary svg { flex: none; }
|
||||
.group .list { max-height: none; overflow: visible; }
|
||||
.group .item { padding: 9px 0 2px 20px; }
|
||||
|
||||
.prose,
|
||||
.code {
|
||||
max-width: 100%;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.prose { margin-top: 8px; }
|
||||
|
||||
.root .item > .code {
|
||||
margin: 8px 0 0;
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.root .group .item:has(> .code:only-child) {
|
||||
padding: 4px 0 0 20px;
|
||||
}
|
||||
|
||||
.root .group .item > .code:only-child { margin: 0; }
|
||||
|
||||
.root .code [data-code-block-banner] { padding: 4px 10px; }
|
||||
.root .code pre { padding: 6px 10px 8px; }
|
||||
|
||||
.path {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: start;
|
||||
overflow-wrap: anywhere;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.path:hover { text-decoration: underline; }
|
||||
.path:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary); outline-offset: 3px; }
|
||||
@@ -0,0 +1,138 @@
|
||||
/** Compact, read-only fields and lists for recorded Tool results. */
|
||||
import {
|
||||
CodeBlock, MarkdownText, IconCheckOutlineRegular, IconChevronRightOutlineRegular, IconPlayOutlineRegular,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { OpenFileOptions } from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
import { markdownLabels } from '../models/primitive-labels.ts'
|
||||
import css from './ToolDetails.module.css'
|
||||
|
||||
/** One recorded entity, receipt, or collapsible group of related values. */
|
||||
export interface ToolDetailItem {
|
||||
title?: string
|
||||
subtitle?: string
|
||||
description?: string
|
||||
badge?: { label: string; tone: 'neutral' | 'info' | 'success' | 'warning' | 'error' }
|
||||
status?: { value: 'completed' | 'in_progress' | 'pending'; label: string }
|
||||
previousStatus?: string
|
||||
change?: { value: 'added' | 'removed' | 'updated'; label: string }
|
||||
fields: readonly { label: string; value: string }[]
|
||||
lines?: readonly string[]
|
||||
markdown?: string
|
||||
code?: { text: string; language?: string }
|
||||
location?: { path: string; line?: number }
|
||||
groups?: readonly { label: string; items: readonly ToolDetailItem[] }[]
|
||||
}
|
||||
|
||||
/** Localized display data shared by the built-in detail cards. */
|
||||
export interface ToolDetailsModel {
|
||||
items: readonly ToolDetailItem[]
|
||||
summary?: string
|
||||
/** Header text while expanded, omitting a receipt status shown in the body. */
|
||||
expandedSummary?: string
|
||||
empty?: string
|
||||
caption?: string
|
||||
unchanged?: { label: string; items: ToolDetailsModel['items'] }
|
||||
}
|
||||
|
||||
interface DetailContentProps {
|
||||
t: TranslateNS<'conversation'>
|
||||
onOpenFile?: ((path: string, options?: OpenFileOptions) => void) | undefined
|
||||
}
|
||||
|
||||
function DetailItem({ item, t, onOpenFile }: { item: ToolDetailItem } & DetailContentProps) {
|
||||
const status = item.status
|
||||
return (
|
||||
<li className={css.item} data-change={item.change?.value}>
|
||||
{item.title !== undefined && (
|
||||
<div className={css.heading}>
|
||||
{status !== undefined && (
|
||||
<span className={css.status} role="img" aria-label={item.change?.label ?? status.label}>
|
||||
{item.change?.value === 'added' ? '+'
|
||||
: item.change?.value === 'removed' ? '−'
|
||||
: status.value === 'completed' ? <IconCheckOutlineRegular size={14} />
|
||||
: status.value === 'in_progress' ? <IconPlayOutlineRegular size={14} />
|
||||
: <span className={css.pending} />}
|
||||
</span>
|
||||
)}
|
||||
{item.location !== undefined && onOpenFile !== undefined ? (
|
||||
<button className={css.path} type="button" onClick={() => {
|
||||
const location = item.location
|
||||
if (location !== undefined) onOpenFile(location.path, location.line === undefined ? undefined : { line: location.line })
|
||||
}}>{item.title}</button>
|
||||
) : <span className={css.text}>{item.title}</span>}
|
||||
{item.badge !== undefined && <span className={css.badge} data-tone={item.badge.tone}>{item.badge.label}</span>}
|
||||
{status !== undefined && (
|
||||
<span className={css.statusText}>
|
||||
{item.previousStatus !== undefined && (
|
||||
<><span className={css.previous}>{item.previousStatus}</span><span> → </span></>
|
||||
)}
|
||||
<span>{status.label}</span>
|
||||
{item.change?.value === 'updated' && item.previousStatus === undefined && <span> · {item.change.label}</span>}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{item.subtitle !== undefined && <div className={css.subtitle}>{item.subtitle}</div>}
|
||||
{item.description !== undefined && <p className={css.description}>{item.description}</p>}
|
||||
{item.fields.length > 0 && (
|
||||
<dl className={css.fields}>
|
||||
{item.fields.map(field => (
|
||||
<div key={field.label} className={css.field}>
|
||||
<dt>{field.label}</dt>
|
||||
<dd>{field.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
{item.lines !== undefined && <ul className={css.lines}>{item.lines.map((line, index) => <li key={index}>{line}</li>)}</ul>}
|
||||
{item.markdown !== undefined && <div className={css.prose}><MarkdownText text={item.markdown} labels={markdownLabels(t)} /></div>}
|
||||
{item.code !== undefined && (
|
||||
<CodeBlock
|
||||
className={css.code} code={item.code.text} lang={item.code.language}
|
||||
copyLabel={t('copy')} copiedLabel={t('copied')}
|
||||
/>
|
||||
)}
|
||||
{item.groups?.map((group, index) => (
|
||||
<details key={index} className={css.group}>
|
||||
<summary><IconChevronRightOutlineRegular /><span>{group.label}</span></summary>
|
||||
<ul className={css.list}>
|
||||
{group.items.map((child, childIndex) => <DetailItem key={childIndex} item={child} t={t} onOpenFile={onOpenFile} />)}
|
||||
</ul>
|
||||
</details>
|
||||
))}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render recorded values with local disclosures, copy controls, and file navigation.
|
||||
* @param props.model - Localized fields or list items; an empty list uses its empty label.
|
||||
* @param props.hasInspect - Reserve space for the row's upper-right Inspect button.
|
||||
* @param props.t - Conversation dictionary for shared Markdown and code controls.
|
||||
* @param props.onOpenFile - Open a recorded file location in the session workspace.
|
||||
* @returns The expanded detail body.
|
||||
*/
|
||||
export function ToolDetails({ model, hasInspect = false, t, onOpenFile }: {
|
||||
model: ToolDetailsModel
|
||||
hasInspect?: boolean
|
||||
} & DetailContentProps) {
|
||||
return (
|
||||
<div className={css.root} data-inspect={hasInspect || undefined} data-caption={model.caption !== undefined || undefined}>
|
||||
{model.caption !== undefined && <div className={css.caption}>{model.caption}</div>}
|
||||
{model.items.length === 0 ? <p className={css.empty}>{model.empty}</p> : (
|
||||
<ul className={css.list}>
|
||||
{model.items.map((item, index) => <DetailItem key={index} item={item} t={t} onOpenFile={onOpenFile} />)}
|
||||
</ul>
|
||||
)}
|
||||
{model.unchanged !== undefined && (
|
||||
<details className={css.unchanged}>
|
||||
<summary><IconChevronRightOutlineRegular />{model.unchanged.label}</summary>
|
||||
<ul className={css.list}>
|
||||
{model.unchanged.items.map((item, index) => <DetailItem key={index} item={item} t={t} onOpenFile={onOpenFile} />)}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -196,6 +196,17 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.detailsBodyWrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.detailsBodyWrap .inspectButton {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Solid hover fill (a translucent token would let content bleed through). */
|
||||
.inspectButton:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover-solid);
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from '../models/tool-call-model.ts'
|
||||
import type { WebCardModelProps } from '../models/web-card-model.ts'
|
||||
import { AskQuestionCard } from './AskQuestionCard.tsx'
|
||||
import { ToolDetails, type ToolDetailsModel } from './ToolDetails.tsx'
|
||||
import css from './ToolRow.module.css'
|
||||
|
||||
export interface ToolRowProps {
|
||||
@@ -71,6 +72,8 @@ export interface ToolRowProps {
|
||||
loadImage?: MessageImageLoader | undefined
|
||||
search?: SearchCardModel | null | undefined
|
||||
web?: WebCardModelProps | null | undefined
|
||||
/** Read-only fields/list card derived from a successful recorded result. */
|
||||
details?: ToolDetailsModel | null | undefined
|
||||
state: ToolRowState
|
||||
/**
|
||||
* Filesystem path from tool args; when set with onOpenFile, the summary
|
||||
@@ -118,6 +121,7 @@ export function ToolRow({
|
||||
loadImage,
|
||||
search,
|
||||
web,
|
||||
details,
|
||||
state,
|
||||
filePath,
|
||||
filePathLine,
|
||||
@@ -141,9 +145,10 @@ export function ToolRow({
|
||||
const searchBody = search ?? null
|
||||
const webBody = web ?? null
|
||||
const askQuestionBody = askQuestion ?? null
|
||||
const detailsBody = details ?? null
|
||||
const inputRaw = bodyRaw ?? null
|
||||
const outputText = output ?? null
|
||||
const card = askQuestionBody ?? terminalBody ?? diffBody ?? readBody ?? imageBody ?? searchBody ?? webBody
|
||||
const card = askQuestionBody ?? terminalBody ?? diffBody ?? readBody ?? imageBody ?? searchBody ?? webBody ?? detailsBody
|
||||
const expandable = inputRaw !== null || outputText !== null || card !== null
|
||||
const open = expanded && expandable
|
||||
const bodyText = useMemo(
|
||||
@@ -151,7 +156,7 @@ export function ToolRow({
|
||||
[card, inputRaw, open, variant],
|
||||
)
|
||||
const status = stateStatus(state, t)
|
||||
const normalSummary = terminalBody?.description ?? summary
|
||||
const normalSummary = terminalBody?.description ?? (open ? detailsBody?.expandedSummary ?? summary : summary)
|
||||
// A failure keeps its first result line when available and otherwise turns
|
||||
// the ordinary summary red. An interruption turns the tool-owned summary
|
||||
// amber while retaining the business icon and hidden state announcement.
|
||||
@@ -233,7 +238,7 @@ export function ToolRow({
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className={css.bodyWrap}>
|
||||
<div className={clsx(css.bodyWrap, detailsBody !== null && css.detailsBodyWrap)}>
|
||||
{askQuestionBody !== null
|
||||
? <AskQuestionCard card={askQuestionBody} />
|
||||
: terminalBody !== null
|
||||
@@ -287,36 +292,38 @@ export function ToolRow({
|
||||
)
|
||||
: webBody !== null
|
||||
? <WebBlock {...webBody} labels={webLabels} className={css.webBody} />
|
||||
: (
|
||||
<>
|
||||
{variant === 'code' && bodyText !== null && (
|
||||
<div className={css.bodyScroll}>
|
||||
<CodeBlock code={bodyText} lang="typescript" copyLabel={t('copy')} copiedLabel={t('copied')} className={css.codeBody} />
|
||||
</div>
|
||||
)}
|
||||
{(cardBody !== null || outputText !== null) && (
|
||||
<div className={css.ioCard}>
|
||||
{cardBody !== null && (
|
||||
<div className={css.ioSection}>
|
||||
<span className={css.ioLabel}>{t('row.input')}</span>
|
||||
<span className={css.ioText}>{cardBody}</span>
|
||||
</div>
|
||||
)}
|
||||
{cardBody !== null && outputText !== null && (
|
||||
<span className={css.ioDivider} aria-hidden />
|
||||
)}
|
||||
{outputText !== null && (
|
||||
<div className={css.ioSection}>
|
||||
<span className={css.ioLabel}>{t('row.output')}</span>
|
||||
<span className={css.ioText} data-error={state === 'error' || undefined}>
|
||||
{outputText}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
: detailsBody !== null
|
||||
? <ToolDetails model={detailsBody} hasInspect={inspect !== undefined} t={t} onOpenFile={onOpenFile} />
|
||||
: (
|
||||
<>
|
||||
{variant === 'code' && bodyText !== null && (
|
||||
<div className={css.bodyScroll}>
|
||||
<CodeBlock code={bodyText} lang="typescript" copyLabel={t('copy')} copiedLabel={t('copied')} className={css.codeBody} />
|
||||
</div>
|
||||
)}
|
||||
{(cardBody !== null || outputText !== null) && (
|
||||
<div className={css.ioCard}>
|
||||
{cardBody !== null && (
|
||||
<div className={css.ioSection}>
|
||||
<span className={css.ioLabel}>{t('row.input')}</span>
|
||||
<span className={css.ioText}>{cardBody}</span>
|
||||
</div>
|
||||
)}
|
||||
{cardBody !== null && outputText !== null && (
|
||||
<span className={css.ioDivider} aria-hidden />
|
||||
)}
|
||||
{outputText !== null && (
|
||||
<div className={css.ioSection}>
|
||||
<span className={css.ioLabel}>{t('row.output')}</span>
|
||||
<span className={css.ioText} data-error={state === 'error' || undefined}>
|
||||
{outputText}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{inspect !== undefined && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
/** Entity lists and receipts for agent, job, terminal, and language-server tools. */
|
||||
import type { ToolDetailsModel } from '../components/ToolDetails.tsx'
|
||||
import {
|
||||
detailBadge, detailList, detailRecord, inspectionItems,
|
||||
type DetailItem, type DetailTranslate,
|
||||
} from './detail-model-shared.ts'
|
||||
|
||||
const OUTPUT_TRUNCATED = '\n[output truncated]'
|
||||
|
||||
function arg(args: Record<string, unknown>, key: string): string {
|
||||
const value = args[key]
|
||||
return typeof value === 'string' ? value : ''
|
||||
}
|
||||
|
||||
function receipt(title: string, badge: NonNullable<DetailItem['badge']>, t: DetailTranslate, fields: DetailItem['fields'] = [], description?: string): ToolDetailsModel {
|
||||
return {
|
||||
...detailList([{ title, badge, fields, ...(description === undefined ? {} : { description }) }], `${title} · ${badge.label}`, t),
|
||||
expandedSummary: title,
|
||||
}
|
||||
}
|
||||
|
||||
function agentList(text: string, json: unknown, t: DetailTranslate): ToolDetailsModel | null {
|
||||
if (Array.isArray(json)) return detailList(inspectionItems(json, t), t('detail.agents.count', { count: json.length }), t)
|
||||
if (text === '(no subagents)') return detailList([], t('detail.agents.count', { count: 0 }), t)
|
||||
const items: DetailItem[] = []
|
||||
for (const line of text.split('\n')) {
|
||||
const match = /^(\S+) \[([^\]]+)\](?: parent=(\S+) depth=(\d+))?(?: — (.*))?$/u.exec(line)
|
||||
if (match === null) return null
|
||||
const [, id, state, parent, depth, title] = match
|
||||
if (id === undefined || state === undefined) return null
|
||||
items.push({
|
||||
title: title ?? id, ...(title === undefined ? {} : { subtitle: id }),
|
||||
badge: detailBadge(state, t),
|
||||
fields: parent === undefined ? [] : [
|
||||
{ label: t('detail.field.parent'), value: parent },
|
||||
{ label: t('detail.field.depth'), value: depth ?? '' },
|
||||
],
|
||||
})
|
||||
}
|
||||
return detailList(items, t('detail.agents.count', { count: items.length }), t)
|
||||
}
|
||||
|
||||
function jobList(text: string, t: DetailTranslate): ToolDetailsModel | null {
|
||||
if (text === '(no background jobs)') return detailList([], t('detail.jobs.count', { count: 0 }), t)
|
||||
const items: DetailItem[] = []
|
||||
for (const line of text.split('\n')) {
|
||||
const match = /^(\S+) \[([^\]]+)\] (\S+) — (.*)$/u.exec(line)
|
||||
if (match === null) return null
|
||||
const [, id, kind, state, title] = match
|
||||
if (id === undefined || kind === undefined || state === undefined || title === undefined) return null
|
||||
items.push({ title, subtitle: id, badge: detailBadge(state, t), fields: [{ label: t('detail.field.type'), value: kind }] })
|
||||
}
|
||||
return detailList(items, t('detail.jobs.count', { count: items.length }), t)
|
||||
}
|
||||
|
||||
function terminalList(text: string, t: DetailTranslate): ToolDetailsModel | null {
|
||||
if (text === '(no terminal sessions)') return detailList([], t('detail.terminals.count', { count: 0 }), t)
|
||||
const items: DetailItem[] = []
|
||||
for (const line of text.split('\n')) {
|
||||
const match = /^(\S+)(?: \((.*?)\))? \[([^\]]+)\] (running|exited code=(\S+) signal=(\S+))(?: pid=(\d+))?$/u.exec(line)
|
||||
if (match === null) return null
|
||||
const [, id, name, type, state, exitCode, signal, pid] = match
|
||||
if (id === undefined || type === undefined || state === undefined) return null
|
||||
const fields = [{ label: t('detail.field.type'), value: type }]
|
||||
if (pid !== undefined) fields.push({ label: t('detail.field.pid'), value: pid })
|
||||
if (exitCode !== undefined) fields.push({ label: t('detail.field.exitCode'), value: exitCode })
|
||||
if (signal !== undefined && signal !== 'null') fields.push({ label: t('detail.field.signal'), value: signal })
|
||||
items.push({
|
||||
title: name ?? id, ...(name === undefined ? {} : { subtitle: id }),
|
||||
badge: state === 'running' ? detailBadge('running', t) : { label: t('detail.status.exited'), tone: exitCode === '0' ? 'success' : 'neutral' }, fields,
|
||||
})
|
||||
}
|
||||
return detailList(items, t('detail.terminals.count', { count: items.length }), t)
|
||||
}
|
||||
|
||||
function lspDetails(args: Record<string, unknown>, text: string, t: DetailTranslate): ToolDetailsModel | null {
|
||||
const file = arg(args, 'file_path')
|
||||
const operation = arg(args, 'operation')
|
||||
if (file === '' || typeof args.line !== 'number' || typeof args.character !== 'number') return null
|
||||
const source = `${file}:${args.line}:${args.character}`
|
||||
if (operation === 'hover') return detailList([{ title: source, location: { path: file, line: args.line }, markdown: text, fields: [] }], source, t)
|
||||
if (text === 'No results.') return detailList([], t('detail.locations.count', { count: 0 }), t)
|
||||
const items: DetailItem[] = []
|
||||
for (const line of text.split('\n')) {
|
||||
if (line.startsWith('… ')) { items.push({ description: line, fields: [] }); continue }
|
||||
const match = /^(.*):(\d+):(\d+)$/u.exec(line)
|
||||
if (match === null) return null
|
||||
const [, path, row, column] = match
|
||||
if (path === undefined || row === undefined || column === undefined) return null
|
||||
const isUri = /^[a-z][a-z\d+.-]*:/iu.test(path) && !/^[a-z]:[\\/]/iu.test(path)
|
||||
items.push({
|
||||
title: path, subtitle: t('detail.location', { line: row, column }), fields: [],
|
||||
...isUri ? {} : { location: { path, line: Number(row) } },
|
||||
})
|
||||
}
|
||||
const count = items.filter(item => item.title !== undefined).length
|
||||
return detailList(items, `${file} · ${t('detail.locations.count', { count })}`, t)
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive entity lists and operation receipts from supported recorded output formats.
|
||||
* @param name - Wire tool name, including Team-scoped aliases.
|
||||
* @param args - Parsed recorded arguments.
|
||||
* @param text - Successful recorded result text.
|
||||
* @param json - Parsed whole-result JSON, or undefined for non-JSON text.
|
||||
* @param t - Conversation translator.
|
||||
* @returns Compact details, or null when the output format is not recognized.
|
||||
*/
|
||||
export function controlDetails(
|
||||
name: string, args: Record<string, unknown>, text: string, json: unknown, t: DetailTranslate,
|
||||
): ToolDetailsModel | null {
|
||||
const target = arg(args, 'target') || arg(args, 'agent_id') || arg(args, 'sessionId') || arg(args, 'job_id')
|
||||
switch (name) {
|
||||
case 'list_agents': return agentList(text, json, t)
|
||||
case 'job_list': return jobList(text, t)
|
||||
case 'terminal_list': return terminalList(text, t)
|
||||
case 'lsp': return lspDetails(args, text, t)
|
||||
case 'spawn_teammate':
|
||||
if (!detailRecord(json) || !detailRecord(json.member)) return null
|
||||
return detailList(inspectionItems(json.member, t), arg(args, 'name'), t)
|
||||
case 'team_task_create':
|
||||
case 'team_task_get':
|
||||
case 'team_task_update':
|
||||
if (!detailRecord(json) || typeof json.subject !== 'string') return null
|
||||
return detailList(inspectionItems(json, t), json.subject, t)
|
||||
case 'team_task_list':
|
||||
if (!detailRecord(json) || !Array.isArray(json.tasks)) return null
|
||||
if (json.nextCursor !== undefined && typeof json.nextCursor !== 'number') return null
|
||||
return {
|
||||
...detailList(inspectionItems(json.tasks, t), t('detail.tasks.count', { count: json.tasks.length }), t),
|
||||
...(json.nextCursor === undefined ? {} : { caption: t('detail.tasks.nextPage', { cursor: String(json.nextCursor) }) }),
|
||||
}
|
||||
case 'send_message': {
|
||||
const status = detailRecord(json) ? json.status : undefined
|
||||
if (status === 'accepted' || status === 'queued') return receipt(target, {
|
||||
label: t(status === 'queued' ? 'detail.status.queued' : 'detail.receipt.delivered'), tone: status === 'queued' ? 'warning' : 'success',
|
||||
}, t, [], arg(args, 'message'))
|
||||
return text === `message delivered to agent ${target}` ? receipt(target, { label: t('detail.receipt.delivered'), tone: 'success' }, t, [], arg(args, 'message')) : null
|
||||
}
|
||||
case 'interrupt_agent':
|
||||
if (detailRecord(json) && typeof json.previousStatus === 'string') return receipt(target, { label: t('detail.receipt.interrupt'), tone: 'warning' }, t, [{ label: t('detail.field.previousStatus'), value: detailBadge(json.previousStatus, t).label }])
|
||||
return text === `interrupt requested for agent ${target}` ? receipt(target, { label: t('detail.receipt.interrupt'), tone: 'warning' }, t) : null
|
||||
case 'wait_agent':
|
||||
if (!detailRecord(json) || typeof json.timedOut !== 'boolean') return null
|
||||
if (detailRecord(json.noProgress) && typeof json.noProgress.message === 'string') return detailList([{ title: t('detail.wait.noProgress'), description: json.noProgress.message, fields: [] }], t('detail.wait.noProgress'), t)
|
||||
return receipt(t('detail.wait.title'), { label: t(json.timedOut ? 'detail.wait.timeout' : 'detail.wait.changed'), tone: 'neutral' }, t)
|
||||
case 'subagent': {
|
||||
const started = /^started (background subagent job|subagent) (\S+)$/u.exec(text)
|
||||
if (started !== null) return receipt(arg(args, 'prompt'), { label: t('detail.receipt.started'), tone: 'info' }, t, [{ label: t(started[1] === 'subagent' ? 'detail.field.agent' : 'detail.field.job'), value: started[2] ?? '' }])
|
||||
return detailList([{ title: t('detail.agent.reply'), markdown: text, fields: [], groups: [{ label: t('detail.field.task'), items: [{ description: arg(args, 'prompt'), fields: [] }] }] }], t('detail.agent.reply'), t)
|
||||
}
|
||||
case 'list_subagent_models': {
|
||||
const items = text.split('\n').map((line) => {
|
||||
const split = line.indexOf(' — ')
|
||||
return split < 0
|
||||
? { description: line, fields: [] }
|
||||
: { title: line.slice(0, split), description: line.slice(split + 3), fields: [] }
|
||||
})
|
||||
return detailList(items, arg(args, 'model') || arg(args, 'provider') || t('detail.models.title'), t)
|
||||
}
|
||||
case 'job_output': {
|
||||
const match = /\n\[status: ([^,\]\n]+)(?:, ([^\]\n]+))?\]$/u.exec(text)
|
||||
if (match === null || match[1] === undefined) return null
|
||||
const output = text.slice(0, match.index)
|
||||
const truncated = output.endsWith(OUTPUT_TRUNCATED)
|
||||
const code = truncated ? output.slice(0, -OUTPUT_TRUNCATED.length) : output
|
||||
const description = [match[2], truncated ? t('detail.output.truncated') : undefined]
|
||||
.filter((value): value is string => value !== undefined).join(' · ')
|
||||
return {
|
||||
...detailList([{ title: target, badge: detailBadge(match[1], t), fields: [], ...(description === '' ? {} : { description }), code: { text: code } }], `${target} · ${detailBadge(match[1], t).label}`, t),
|
||||
expandedSummary: target,
|
||||
}
|
||||
}
|
||||
case 'job_kill':
|
||||
if (text === `requested cancellation of job ${target}`) return receipt(target, { label: t('detail.receipt.cancel'), tone: 'warning' }, t, [], arg(args, 'reason'))
|
||||
if (text.startsWith(`job ${target} had already finished `)) return receipt(target, { label: t('detail.receipt.alreadyFinished'), tone: 'neutral' }, t)
|
||||
return null
|
||||
case 'terminal_open': {
|
||||
const match = /^started terminal session (\S+)(?: \((.*?)\))? \[type: ([^\]]+)\]\n([\s\S]*)$/u.exec(text)
|
||||
if (match === null || match[1] === undefined || match[3] === undefined || match[4] === undefined) return null
|
||||
return detailList([{ title: match[2] ?? match[1], ...(match[2] === undefined ? {} : { subtitle: match[1] }), badge: { label: t('detail.receipt.started'), tone: 'info' }, fields: [{ label: t('detail.field.type'), value: match[3] }], code: { text: match[4] } }], match[2] ?? match[1], t)
|
||||
}
|
||||
case 'terminal_read': {
|
||||
const match = /\n\[lines: (\d+)-(\d+) of (\d+)\](\n\[output truncated\])?$/u.exec(text)
|
||||
if (match === null) return null
|
||||
return detailList([{ title: target, subtitle: t('detail.output.lines', { begin: match[1] ?? '', end: match[2] ?? '', total: match[3] ?? '' }), fields: [], code: { text: text.slice(0, match.index) }, ...(match[4] === undefined ? {} : { description: t('detail.output.truncated') }) }], target, t)
|
||||
}
|
||||
case 'terminal_signal': {
|
||||
const match = /^delivered (\S+) to foreground process group (\d+)$/u.exec(text)
|
||||
if (match === null || match[1] === undefined || match[2] === undefined) return null
|
||||
return receipt(target, { label: t('detail.receipt.signal'), tone: 'success' }, t, [{ label: t('detail.field.signal'), value: match[1] }, { label: t('detail.field.processGroup'), value: match[2] }])
|
||||
}
|
||||
case 'terminal_close':
|
||||
if (text === `closed terminal session ${target}`) return receipt(target, { label: t('detail.receipt.closed'), tone: 'neutral' }, t)
|
||||
if (text === `terminal session ${target} was already closing`) return receipt(target, { label: t('detail.receipt.closing'), tone: 'neutral' }, t)
|
||||
return null
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/** Recorded-value formatting shared by entity lists and inspection results. */
|
||||
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ToolDetailItem, ToolDetailsModel } from '../components/ToolDetails.tsx'
|
||||
|
||||
/** Translator supplied by the conversation slot. */
|
||||
export type DetailTranslate = TranslateNS<'conversation'>
|
||||
/** A compact detail row derived from durable tool output. */
|
||||
export type DetailItem = ToolDetailItem
|
||||
|
||||
/**
|
||||
* Narrow parsed JSON to an object record.
|
||||
* @param value - Parsed result or argument value.
|
||||
* @returns Whether named fields can be read.
|
||||
*/
|
||||
export function detailRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a recorded string field that must contain visible text.
|
||||
* @param value - Parsed field value.
|
||||
* @returns Whether the field is a non-empty string after trimming.
|
||||
*/
|
||||
export function nonempty(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.trim() !== ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode an entire JSON result without accepting a partial prefix.
|
||||
* @param text - Recorded text.
|
||||
* @returns Parsed JSON, or undefined for non-JSON output.
|
||||
*/
|
||||
export function detailJson(text: string): unknown {
|
||||
try { return JSON.parse(text) }
|
||||
catch {
|
||||
// Text receipts and reports are decoded by their tool-specific adapters.
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
const STATUS_KEYS = {
|
||||
running: 'detail.status.running', idle: 'detail.status.idle', ready: 'detail.status.ready',
|
||||
inactive: 'detail.status.inactive', provisioning: 'detail.status.provisioning',
|
||||
failed: 'detail.status.failed', error: 'detail.status.failed',
|
||||
completed: 'detail.status.completed', complete: 'detail.status.completed', done: 'detail.status.completed',
|
||||
pending: 'detail.todo.pending', in_progress: 'detail.todo.in_progress',
|
||||
deleted: 'detail.status.deleted', killed: 'detail.status.killed',
|
||||
blocked: 'detail.goal.blocked', accepted: 'detail.status.accepted', queued: 'detail.status.queued',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Give a recorded status its localized name and a static semantic color.
|
||||
* @param status - Result status, retained verbatim when the vocabulary is unknown.
|
||||
* @param t - Conversation translator.
|
||||
* @returns A badge that does not imply a live subscription.
|
||||
*/
|
||||
export function detailBadge(status: string, t: DetailTranslate): NonNullable<DetailItem['badge']> {
|
||||
const key = Object.hasOwn(STATUS_KEYS, status) ? STATUS_KEYS[status as keyof typeof STATUS_KEYS] : undefined
|
||||
const tone = ['completed', 'complete', 'done', 'accepted'].includes(status) ? 'success'
|
||||
: ['failed', 'error'].includes(status) ? 'error'
|
||||
: ['blocked', 'killed', 'pending', 'queued', 'inactive'].includes(status) ? 'warning'
|
||||
: ['running', 'in_progress', 'provisioning'].includes(status) ? 'info' : 'neutral'
|
||||
return { label: key === undefined ? status : t(key), tone }
|
||||
}
|
||||
|
||||
const FIELD_KEYS = {
|
||||
id: 'detail.field.id', revision: 'detail.field.revision', platform: 'detail.field.platform',
|
||||
provider: 'detail.field.provider', model: 'detail.field.model', role: 'detail.field.role',
|
||||
context: 'detail.field.context', ownerName: 'detail.field.owner', ready: 'detail.field.ready',
|
||||
blockedBy: 'detail.field.dependencies', writeScopes: 'detail.field.writeScopes',
|
||||
writeScopeWarnings: 'detail.field.warnings', diagnostics: 'detail.field.diagnostics',
|
||||
methods: 'detail.field.methods', inputSchema: 'detail.field.inputSchema', outputSchema: 'detail.field.outputSchema',
|
||||
currentPackageId: 'detail.field.currentPackage', nextPackageId: 'detail.field.nextPackage',
|
||||
latestRun: 'detail.field.latestRun', packages: 'detail.field.packages', registrations: 'detail.field.registrations',
|
||||
props: 'detail.field.props', data: 'detail.field.data', source: 'detail.field.source',
|
||||
arguments: 'row.input', content: 'detail.field.content', message: 'detail.field.message',
|
||||
messageId: 'detail.field.messageId', status: 'detail.state', root: 'detail.field.root',
|
||||
pid: 'detail.field.pid', type: 'detail.field.type', time: 'detail.field.time',
|
||||
seq: 'detail.field.seq', turn: 'detail.field.turn', step: 'detail.field.step', callId: 'detail.field.callId',
|
||||
agentsStarted: 'detail.field.agents', output: 'row.output', result: 'detail.field.result',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Name a known tool field while preserving extension-owned field names.
|
||||
* @param key - Recorded JSON property name.
|
||||
* @param t - Conversation translator.
|
||||
* @returns Localized known label or the original property name.
|
||||
*/
|
||||
export function detailLabel(key: string, t: DetailTranslate): string {
|
||||
const label = Object.hasOwn(FIELD_KEYS, key) ? FIELD_KEYS[key as keyof typeof FIELD_KEYS] : undefined
|
||||
return label === undefined ? key : t(label)
|
||||
}
|
||||
|
||||
function scalar(value: unknown, t: DetailTranslate): string {
|
||||
if (value === null) return t('detail.none')
|
||||
if (typeof value === 'boolean') return t(value ? 'detail.yes' : 'detail.no')
|
||||
return typeof value === 'string' ? value : JSON.stringify(value)
|
||||
}
|
||||
|
||||
const INSPECTION_KEY_ORDER = ['subject', 'title', 'name', 'pluginId', 'packageId', 'id', 'summary'] as const
|
||||
const INSPECTION_DETAIL_KEYS = ['description', 'purpose'] as const
|
||||
const MAX_INSPECTION_ITEMS = 40
|
||||
|
||||
/**
|
||||
* Project open inspection records into readable fields and named disclosures.
|
||||
* @param value - Parsed JSON, including provider-owned extension fields.
|
||||
* @param t - Conversation translator.
|
||||
* @param depth - Current disclosure depth; deeper records remain available as code.
|
||||
* @returns Entity rows preserving the order of visible values.
|
||||
*/
|
||||
export function inspectionItems(value: unknown, t: DetailTranslate, depth = 0): DetailItem[] {
|
||||
if (depth > 4 && value !== null && typeof value === 'object') {
|
||||
return [{ code: { text: JSON.stringify(value, null, 2), language: 'json' }, fields: [] }]
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const items = value.slice(0, MAX_INSPECTION_ITEMS).flatMap(entry => inspectionItems(entry, t, depth + 1))
|
||||
if (value.length > MAX_INSPECTION_ITEMS) items.push({ description: t('detail.moreInInspect', { count: value.length - MAX_INSPECTION_ITEMS }), fields: [] })
|
||||
return items.length === 0 ? [{ description: t('detail.empty'), fields: [] }] : items
|
||||
}
|
||||
if (!detailRecord(value)) return [{ description: scalar(value, t), fields: [] }]
|
||||
const titleKey = INSPECTION_KEY_ORDER.find(key => typeof value[key] === 'string' && value[key] !== '')
|
||||
const descriptionKey = INSPECTION_DETAIL_KEYS.find(key => typeof value[key] === 'string' && value[key] !== '')
|
||||
const title = titleKey === undefined ? undefined : String(value[titleKey])
|
||||
const fields: DetailItem['fields'][number][] = []
|
||||
const groups: NonNullable<DetailItem['groups']>[number][] = []
|
||||
for (const [key, field] of Object.entries(value)) {
|
||||
if (key === titleKey || key === descriptionKey || (key === 'status' && typeof field === 'string')) continue
|
||||
if (key === 'inputSchema' || key === 'outputSchema') {
|
||||
groups.push({
|
||||
label: detailLabel(key, t),
|
||||
items: [{ fields: [], code: { text: JSON.stringify(field, null, 2), language: 'json' } }],
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (Array.isArray(field) && field.length === 0) continue
|
||||
if (key === 'arguments' && typeof field === 'string') {
|
||||
const args = detailJson(field)
|
||||
if (detailRecord(args)) { groups.push({ label: detailLabel(key, t), items: inspectionItems(args, t, depth + 1) }); continue }
|
||||
}
|
||||
if (field !== null && typeof field === 'object') groups.push({ label: detailLabel(key, t), items: inspectionItems(field, t, depth + 1) })
|
||||
else fields.push({ label: detailLabel(key, t), value: scalar(field, t) })
|
||||
}
|
||||
return [{
|
||||
...(title === undefined && typeof value.status !== 'string' ? {} : { title: title ?? t('detail.field.result') }),
|
||||
...(descriptionKey === undefined ? {} : { description: String(value[descriptionKey]) }),
|
||||
...(typeof value.status === 'string' ? { badge: detailBadge(value.status, t) } : {}),
|
||||
fields,
|
||||
...(groups.length === 0 ? {} : { groups }),
|
||||
}]
|
||||
}
|
||||
|
||||
/**
|
||||
* Give a result list consistent historical context and empty-state copy.
|
||||
* @param items - Recorded result rows.
|
||||
* @param summary - Collapsed-row summary.
|
||||
* @param t - Conversation translator.
|
||||
* @returns A complete compact detail model.
|
||||
*/
|
||||
export function detailList(items: readonly DetailItem[], summary: string, t: DetailTranslate): ToolDetailsModel {
|
||||
return { items, summary, caption: t('detail.recordedResult'), empty: t('detail.empty') }
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/** Recorded todo, goal, and schedule values for the compact detail body. */
|
||||
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ToolCallBlock } from './tool-call-model.ts'
|
||||
import type { ToolDetailsModel } from '../components/ToolDetails.tsx'
|
||||
import { parsedToolCall, singleResultText } from './raw-tool-call.ts'
|
||||
import { controlDetails } from './control-details-model.ts'
|
||||
import { inspectionDetails } from './inspection-details-model.ts'
|
||||
import { detailJson, detailRecord, nonempty } from './detail-model-shared.ts'
|
||||
|
||||
type Translate = TranslateNS<'conversation'>
|
||||
type DetailItem = ToolDetailsModel['items'][number]
|
||||
|
||||
function count(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0
|
||||
}
|
||||
|
||||
function formatDate(date: Date, locale: string, fallback: string): string {
|
||||
try {
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit', timeZoneName: 'short',
|
||||
}).format(date)
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the compact todo list from a todo_write call.
|
||||
* @param args - Parsed todo_write arguments.
|
||||
* @param t - Conversation dictionary translator.
|
||||
* @returns Localized todo details, or null for unsupported input.
|
||||
*/
|
||||
export function todosDetail(args: Record<string, unknown>, t: Translate): ToolDetailsModel | null {
|
||||
if (!Array.isArray(args.todos)) return null
|
||||
const items: DetailItem[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const todo of args.todos) {
|
||||
if (!detailRecord(todo) || !nonempty(todo.content)) return null
|
||||
const status = todo.status
|
||||
if (status !== 'completed' && status !== 'in_progress' && status !== 'pending') return null
|
||||
const title = todo.content.trim()
|
||||
if (seen.has(title)) return null
|
||||
seen.add(title)
|
||||
items.push({ title, status: { value: status, label: t(`detail.todo.${status}`) }, fields: [] })
|
||||
}
|
||||
return { items, empty: t('detail.todo.empty') }
|
||||
}
|
||||
|
||||
function goalDetail(value: unknown, t: Translate): ToolDetailsModel | null {
|
||||
if (!detailRecord(value)) return null
|
||||
if (value.goal === null) return { items: [], empty: t('detail.goal.empty') }
|
||||
const goal = value.goal
|
||||
if (!detailRecord(goal) || !nonempty(goal.id) || !nonempty(goal.objective)
|
||||
|| !count(goal.revision) || !count(goal.roundsStarted) || !count(goal.maxGoalRounds)) return null
|
||||
const phase = goal.phase
|
||||
if (phase !== 'active' && phase !== 'paused' && phase !== 'blocked' && phase !== 'complete') return null
|
||||
if (value.activation !== 'armed' && value.activation !== 'disarmed') return null
|
||||
const fields = [
|
||||
{ label: t('detail.state'), value: t(phase === 'active' && value.activation === 'disarmed' ? 'detail.goal.disarmed' : `detail.goal.${phase}`) },
|
||||
{ label: t('detail.goal.rounds'), value: `${goal.roundsStarted} / ${goal.maxGoalRounds}` },
|
||||
]
|
||||
if (goal.blockedReason !== undefined) {
|
||||
if (!detailRecord(goal.blockedReason) || !nonempty(goal.blockedReason.code) || !nonempty(goal.blockedReason.message)) return null
|
||||
fields.push({ label: t('detail.goal.reason'), value: goal.blockedReason.message })
|
||||
}
|
||||
return { items: [{ title: goal.objective, fields }] }
|
||||
}
|
||||
|
||||
function interval(seconds: number, t: Translate): string {
|
||||
if (seconds % 86400 === 0) return t('detail.days', { count: seconds / 86400 })
|
||||
if (seconds % 3600 === 0) return t('detail.hours', { count: seconds / 3600 })
|
||||
if (seconds % 60 === 0) return t('detail.minutes', { count: seconds / 60 })
|
||||
return t('detail.seconds', { count: seconds })
|
||||
}
|
||||
|
||||
function scheduleItem(value: unknown, t: Translate, locale: string): DetailItem | null {
|
||||
if (!detailRecord(value) || !nonempty(value.id) || !nonempty(value.prompt)
|
||||
|| typeof value.scheduledAt !== 'string' || value.deliveryMode !== 'session-local'
|
||||
|| (value.state !== 'scheduled' && value.state !== 'overdue')) return null
|
||||
const date = new Date(value.scheduledAt)
|
||||
if (!Number.isFinite(date.getTime()) || date.toISOString() !== value.scheduledAt) return null
|
||||
let frequency: string
|
||||
switch (value.kind) {
|
||||
case 'at': frequency = t('detail.schedule.once'); break
|
||||
case 'after':
|
||||
if (!count(value.afterSeconds) || value.afterSeconds === 0) return null
|
||||
frequency = t('detail.schedule.once')
|
||||
break
|
||||
case 'every':
|
||||
if (!count(value.everySeconds) || value.everySeconds === 0) return null
|
||||
frequency = t('detail.schedule.every', { interval: interval(value.everySeconds, t) })
|
||||
break
|
||||
default: return null
|
||||
}
|
||||
const dateText = formatDate(date, locale, value.scheduledAt)
|
||||
return {
|
||||
title: value.prompt,
|
||||
fields: [
|
||||
{ label: t('detail.schedule.when'), value: dateText },
|
||||
{ label: t('detail.schedule.frequency'), value: frequency },
|
||||
{ label: t('detail.state'), value: t(`detail.schedule.${value.state}`) },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a supported successful result, retaining generic output on unknown or malformed data.
|
||||
* @param block - Logged root or nested Tool call and its optional result.
|
||||
* @param t - Conversation dictionary translator.
|
||||
* @param locale - Display locale for absolute dates in the viewer's time zone.
|
||||
* @returns Localized detail data, or null for raw input/output.
|
||||
*/
|
||||
export function detailsCardModel(block: ToolCallBlock, t: Translate, locale: string): ToolDetailsModel | null {
|
||||
if (!('kind' in block) || block.isError) return null
|
||||
const call = parsedToolCall(block)
|
||||
if (call === null) return null
|
||||
const text = singleResultText(block)
|
||||
if (text === undefined) return null
|
||||
const value = detailJson(text)
|
||||
const details = controlDetails(call.name, call.args, text, value, t) ?? inspectionDetails(call.name, call.args, text, value, t, locale)
|
||||
if (details !== null) return details
|
||||
if (value === undefined) return null
|
||||
switch (call.name) {
|
||||
case 'create_goal':
|
||||
case 'get_goal':
|
||||
case 'update_goal': return goalDetail(value, t)
|
||||
case 'schedule_create': {
|
||||
const item = scheduleItem(value, t, locale)
|
||||
return item === null ? null : { items: [item] }
|
||||
}
|
||||
case 'schedule_list': {
|
||||
if (!Array.isArray(value)) return null
|
||||
const items: DetailItem[] = []
|
||||
for (const entry of value) {
|
||||
const item = scheduleItem(entry, t, locale)
|
||||
if (item === null) return null
|
||||
items.push(item)
|
||||
}
|
||||
return { items, summary: t('detail.schedule.count', { count: items.length }), empty: t('detail.schedule.empty') }
|
||||
}
|
||||
case 'schedule_delete':
|
||||
if (!detailRecord(value) || !nonempty(value.id) || value.deleted !== true) return null
|
||||
return { items: [{ title: value.id, fields: [{ label: t('detail.state'), value: t('detail.schedule.deleted') }] }] }
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/** Runtime inspection, session queries, and workflow reports from recorded text. */
|
||||
import type { ToolDetailsModel } from '../components/ToolDetails.tsx'
|
||||
import { hasSpillNotice } from '@deepseek-ai/dsh-spill-policy/notice'
|
||||
import {
|
||||
detailJson, detailList, detailRecord, inspectionItems,
|
||||
type DetailItem, type DetailTranslate,
|
||||
} from './detail-model-shared.ts'
|
||||
|
||||
function dateText(value: string | number, locale: string): string {
|
||||
const date = new Date(value)
|
||||
if (!Number.isFinite(date.getTime())) return String(value)
|
||||
try {
|
||||
return new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short' }).format(date)
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
function cordisDetails(name: string, args: Record<string, unknown>, value: unknown, t: DetailTranslate): ToolDetailsModel | null {
|
||||
if (!detailRecord(value)) return null
|
||||
if (name === 'cordis_inspect_list') {
|
||||
if (!Array.isArray(value.providers)) return null
|
||||
return detailList(inspectionItems(value.providers, t), t('detail.providers.count', { count: value.providers.length }), t)
|
||||
}
|
||||
if (name === 'cordis_inspect_query') {
|
||||
if (!('data' in value) || typeof value.provider !== 'string' || typeof value.method !== 'string') return null
|
||||
return detailList(inspectionItems(value.data, t), `${value.provider}.${value.method}`, t)
|
||||
}
|
||||
if (name === 'cordis_inspect_self') {
|
||||
if (Array.isArray(value.plugins)) return detailList(inspectionItems(value.plugins, t), t('detail.plugins.count', { count: value.plugins.length }), t)
|
||||
return detailList(inspectionItems(value, t), String(args.pluginId ?? args.packageId ?? value.mode), t)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function workflowDetails(name: string, args: Record<string, unknown>, text: string, t: DetailTranslate): ToolDetailsModel | null {
|
||||
if (name === 'workflow') {
|
||||
const match = /^workflow "([\s\S]*?)" completed \((\d+) agents?\)\.\nReturn value:\n([\s\S]*)$/u.exec(text)
|
||||
if (match === null || match[1] === undefined || match[2] === undefined || match[3] === undefined) return null
|
||||
const value = detailJson(match[3])
|
||||
if (value === undefined) return null
|
||||
const header: DetailItem = {
|
||||
title: match[1], badge: { label: t('detail.status.completed'), tone: 'success' },
|
||||
fields: [{ label: t('detail.field.agents'), value: match[2] }],
|
||||
...(typeof args.code === 'string' ? { groups: [{ label: t('detail.workflow.script'), items: [{ fields: [], code: { text: args.code, language: 'javascript' } }] }] } : {}),
|
||||
}
|
||||
return detailList([header, ...inspectionItems(value, t)], match[1], t)
|
||||
}
|
||||
const separator = '\nFinal report:\n'
|
||||
const split = text.indexOf(separator)
|
||||
if (split < 0) return null
|
||||
const header = text.slice(0, split)
|
||||
const rounds = /\b(\d+) rounds?\b/u.exec(header)?.[1]
|
||||
const report = detailJson(text.slice(split + separator.length))
|
||||
if (!detailRecord(report) || typeof report.summary !== 'string' || !Array.isArray(report.evidence)
|
||||
|| !report.evidence.every(value => typeof value === 'string') || !Array.isArray(report.nextSteps)
|
||||
|| !report.nextSteps.every(value => typeof value === 'string') || typeof report.blocker !== 'string') return null
|
||||
const badge = header.startsWith('Ralph worker reported completion ')
|
||||
? { label: t('detail.ralph.reportedComplete'), tone: 'success' as const }
|
||||
: header.startsWith('Ralph worker reported a blocker ')
|
||||
? { label: t('detail.ralph.reportedBlocker'), tone: 'warning' as const }
|
||||
: header.startsWith('Ralph reached its ')
|
||||
? { label: t('detail.ralph.limit'), tone: 'warning' as const } : undefined
|
||||
if (badge === undefined) return null
|
||||
const groups: NonNullable<DetailItem['groups']>[number][] = []
|
||||
if (report.nextSteps.length > 0) groups.push({ label: t('detail.report.nextSteps'), items: [{ fields: [], lines: report.nextSteps }] })
|
||||
if (typeof args.objective === 'string') groups.push({ label: t('detail.field.task'), items: [{ fields: [], description: args.objective }] })
|
||||
const fields = rounds === undefined ? [] : [{ label: t('detail.goal.rounds'), value: rounds }]
|
||||
if (report.blocker !== '') fields.push({ label: t('detail.goal.reason'), value: report.blocker })
|
||||
const item = { title: report.summary, badge, fields, ...(report.evidence.length === 0 ? {} : { lines: report.evidence }), groups }
|
||||
return detailList([item], report.summary, t)
|
||||
}
|
||||
|
||||
const TRACE_FIELDS = {
|
||||
Created: 'detail.field.time', Availability: 'detail.field.availability', Parent: 'detail.field.parent',
|
||||
'Best match': 'detail.field.bestMatch', Target: 'detail.field.target',
|
||||
'Replaced by': 'detail.trace.replacedBy', 'Replacement chain': 'detail.trace.replacementChain',
|
||||
'Events replaced by target': 'detail.trace.replaces', 'Events cited directly as sources': 'detail.trace.sources',
|
||||
'Direct derived events': 'detail.trace.derived',
|
||||
} as const
|
||||
|
||||
function textFields(text: string, t: DetailTranslate, locale: string): DetailItem['fields'] {
|
||||
return text.split('\n').flatMap((line) => {
|
||||
const match = /^\s*([^:]+): (.+)$/u.exec(line)
|
||||
if (match === null || match[1] === undefined || match[2] === undefined) return []
|
||||
const key = Object.hasOwn(TRACE_FIELDS, match[1]) ? TRACE_FIELDS[match[1] as keyof typeof TRACE_FIELDS] : undefined
|
||||
if (key === undefined) return []
|
||||
return [{ label: t(key), value: match[1] === 'Created' ? dateText(match[2], locale) : match[2] === 'none' ? t('detail.none') : match[2] }]
|
||||
})
|
||||
}
|
||||
|
||||
function searchDetails(name: string, text: string, t: DetailTranslate, locale: string): ToolDetailsModel | null {
|
||||
if (text === 'No prior session matches found.' || text.endsWith('\n\nNo prior event matches found.')) return detailList([], t('detail.matches.count', { count: 0 }), t)
|
||||
const blocks = text.split(/\n(?=\d+\. )/u).filter(block => /^\d+\. /u.test(block))
|
||||
if (blocks.length === 0) return null
|
||||
const items: DetailItem[] = []
|
||||
for (const block of blocks) {
|
||||
const snippet = /\n\s*Snippet: ([\s\S]*?)(?:\n\nResult cap reached\.|$)/u.exec(block)?.[1]
|
||||
if (name === 'session_search') {
|
||||
const match = /^\d+\. Session (\S+) — (.*)/u.exec(block)
|
||||
if (match === null || match[1] === undefined || match[2] === undefined) return null
|
||||
items.push({
|
||||
title: match[2], subtitle: match[1], ...(snippet === undefined ? {} : { description: snippet.trimEnd() }),
|
||||
fields: textFields(block, t, locale),
|
||||
})
|
||||
} else {
|
||||
const match = /^\d+\. seq (\d+) \| ([^|]+) \| ([^|]+) \| ([^\n]+)/u.exec(block)
|
||||
if (match === null || match[1] === undefined || match[2] === undefined
|
||||
|| match[3] === undefined || match[4] === undefined) return null
|
||||
items.push({ title: snippet?.trimEnd() ?? match[2], subtitle: `${match[2].trim()} · #${match[1]}`, fields: [{ label: t('detail.field.time'), value: dateText(match[4], locale) }, { label: t('detail.field.surface'), value: match[3].trim() }] })
|
||||
}
|
||||
}
|
||||
return {
|
||||
...detailList(items, t('detail.matches.count', { count: items.length }), t),
|
||||
...text.includes('Result cap reached.') ? { caption: t('detail.matches.capped') } : {},
|
||||
}
|
||||
}
|
||||
|
||||
function eventReadDetails(text: string, t: DetailTranslate, locale: string): ToolDetailsModel | null {
|
||||
const match = /^Session (\S+) — ([^\n]*)\nTarget event seq (\d+):\n```json\n([\s\S]*?)\n```([\s\S]*)$/u.exec(text)
|
||||
if (match === null || match[1] === undefined || match[2] === undefined || match[3] === undefined || match[4] === undefined) return null
|
||||
const event = detailJson(match[4])
|
||||
if (!detailRecord(event) || typeof event.type !== 'string' || !detailRecord(event.data)) return null
|
||||
const fields = [{ label: t('detail.field.seq'), value: match[3] }]
|
||||
if (typeof event.time === 'number') fields.push({ label: t('detail.field.time'), value: dateText(event.time, locale) })
|
||||
const groups: NonNullable<DetailItem['groups']>[number][] = []
|
||||
const adjacent = match[5]?.trim()
|
||||
if (adjacent) groups.push({ label: t('detail.event.neighbors'), items: [{ fields: [], description: adjacent }] })
|
||||
return detailList([
|
||||
{ title: event.type, subtitle: `${match[2]} · ${match[1]}`, fields },
|
||||
...inspectionItems(event.data, t),
|
||||
...groups.length === 0 ? [] : [{ fields: [], groups }],
|
||||
], `${event.type} · #${match[3]}`, t)
|
||||
}
|
||||
|
||||
function traceDetails(name: string, text: string, t: DetailTranslate, locale: string): ToolDetailsModel | null {
|
||||
const match = /^Session (\S+) — ([^\n]*)\n([\s\S]*)$/u.exec(text)
|
||||
if (match === null || match[1] === undefined || match[2] === undefined || match[3] === undefined) return null
|
||||
if (name === 'session_event_trace') return detailList([{ title: match[2], subtitle: match[1], fields: textFields(match[3], t, locale) }], match[2], t)
|
||||
const sections = match[3].split('\n\n')
|
||||
const items: DetailItem[] = [{ title: match[2], subtitle: match[1], fields: textFields(sections[0] ?? '', t, locale) }]
|
||||
for (const section of sections.slice(1)) {
|
||||
const firstBreak = section.indexOf('\n')
|
||||
const label = section.startsWith('Ancestors (nearest first):') ? t('detail.trace.ancestors') : section.startsWith('Descendants:') ? t('detail.trace.descendants') : undefined
|
||||
if (label === undefined || firstBreak < 0) return null
|
||||
const body = section.slice(firstBreak + 1)
|
||||
items.push({ title: label, fields: [], ...(body === '- none' || body === '- none (target is a root session)' ? { description: t('detail.none') } : { lines: body.split('\n').map(line => line.replace(/^(\s*)- /u, '$1')) }) })
|
||||
}
|
||||
return detailList(items, match[2], t)
|
||||
}
|
||||
|
||||
/**
|
||||
* Present successful inspection, query, and workflow text as named records.
|
||||
* @param name - Wire tool name.
|
||||
* @param args - Recorded argument object.
|
||||
* @param text - Recorded result text.
|
||||
* @param json - Parsed whole-result JSON, or undefined for non-JSON text.
|
||||
* @param t - Conversation translator.
|
||||
* @param locale - Date display locale.
|
||||
* @returns Structured details, or null when an output format is unknown.
|
||||
*/
|
||||
export function inspectionDetails(
|
||||
name: string, args: Record<string, unknown>, text: string, json: unknown, t: DetailTranslate, locale: string,
|
||||
): ToolDetailsModel | null {
|
||||
if (hasSpillNotice(text)) return null
|
||||
switch (name) {
|
||||
case 'cordis_inspect_list':
|
||||
case 'cordis_inspect_query':
|
||||
case 'cordis_inspect_self': return cordisDetails(name, args, json, t)
|
||||
case 'workflow':
|
||||
case 'ralph': return workflowDetails(name, args, text, t)
|
||||
case 'session_search':
|
||||
case 'session_event_search': return searchDetails(name, text, t, locale)
|
||||
case 'session_event_read': return eventReadDetails(text, t, locale)
|
||||
case 'session_trace':
|
||||
case 'session_event_trace': return traceDetails(name, text, t, locale)
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/** Todo changes relative to the preceding recorded write. */
|
||||
import type { ToolCallBlock } from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ToolDetailsModel } from '../components/ToolDetails.tsx'
|
||||
import type { TodoBaseline } from './todo-history.ts'
|
||||
import { todosDetail } from './details-card-model.ts'
|
||||
import { parsedToolCall } from './raw-tool-call.ts'
|
||||
|
||||
/**
|
||||
* Compare this write with its predecessor in the loaded call history.
|
||||
* @param block - The write being displayed.
|
||||
* @param baseline - List recorded before this call, or undefined when its start is unavailable.
|
||||
* @param hasMore - Whether older unloaded history may contain a preceding list.
|
||||
* @param t - Conversation dictionary translator.
|
||||
* @returns Details and a change summary, or null for generic Tool output.
|
||||
*/
|
||||
export function todoDiffModel(
|
||||
block: ToolCallBlock,
|
||||
baseline: TodoBaseline | undefined,
|
||||
hasMore: boolean,
|
||||
t: TranslateNS<'conversation'>,
|
||||
): { details: ToolDetailsModel; summary: string | null } | null {
|
||||
if (!('kind' in block) || block.isError) return null
|
||||
const call = parsedToolCall(block)
|
||||
const current = call?.name === 'todo_write' ? todosDetail(call.args, t) : null
|
||||
if (current === null) return null
|
||||
const previous: ToolDetailsModel | null = baseline?.todos === undefined ? null : { items: baseline.todos.map(todo => ({
|
||||
title: todo.content, status: { value: todo.status, label: t(`detail.todo.${todo.status}`) }, fields: [],
|
||||
})) }
|
||||
if (baseline === undefined || (previous === null && hasMore)) {
|
||||
return { details: { ...current, caption: t('todo.diff.unavailable') }, summary: null }
|
||||
}
|
||||
const previousByTitle = new Map(previous?.items.map(item => [item.title, item]))
|
||||
const currentTitles = new Set(current.items.map(item => item.title))
|
||||
const retainedPositions = new Map(previous?.items.filter(item => currentTitles.has(item.title)).map((item, index) => [item.title, index]))
|
||||
let retainedIndex = 0
|
||||
const items: ToolDetailsModel['items'][number][] = []
|
||||
const unchanged: ToolDetailsModel['items'][number][] = []
|
||||
let added = 0
|
||||
let updated = 0
|
||||
for (const item of current.items) {
|
||||
const before = previousByTitle.get(item.title)
|
||||
previousByTitle.delete(item.title)
|
||||
if (before === undefined) {
|
||||
added++
|
||||
items.push({ ...item, change: { value: 'added', label: t('todo.diff.addedItem') } })
|
||||
} else {
|
||||
const moved = retainedPositions.get(item.title) !== retainedIndex++
|
||||
const statusChanged = before.status?.value !== item.status?.value
|
||||
if (statusChanged || moved) {
|
||||
updated++
|
||||
items.push({
|
||||
...item,
|
||||
...statusChanged && before.status !== undefined ? { previousStatus: before.status.label } : {},
|
||||
change: { value: 'updated', label: t(statusChanged ? 'todo.diff.updatedItem' : 'todo.diff.movedItem') },
|
||||
})
|
||||
} else {
|
||||
unchanged.push(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const item of previousByTitle.values()) {
|
||||
items.push({ ...item, change: { value: 'removed', label: t('todo.diff.removedItem') } })
|
||||
}
|
||||
const summary = [
|
||||
added > 0 ? t('todo.diff.added', { count: added }) : null,
|
||||
updated > 0 ? t('todo.diff.updated', { count: updated }) : null,
|
||||
previousByTitle.size > 0 ? t('todo.diff.removed', { count: previousByTitle.size }) : null,
|
||||
].filter(part => part !== null).join(' · ')
|
||||
return {
|
||||
summary: summary || t('todo.diff.noChanges'),
|
||||
details: {
|
||||
items,
|
||||
caption: t(previous === null ? 'todo.diff.initial' : 'todo.diff.compare'),
|
||||
empty: current.items.length === 0 && previous === null ? t('detail.todo.empty') : t('todo.diff.noChanges'),
|
||||
...unchanged.length === 0 ? {} : { unchanged: { label: t('todo.diff.unchanged', { count: unchanged.length }), items: unchanged } },
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/** Indexed recorded todo predecessors for root and nested Tool calls. */
|
||||
import type {} from '@deepseek-ai/dsh-tools/types'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {
|
||||
ConversationNodeDefinition, ConversationViewDefinition, ConversationViewNode, TodoItem,
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
|
||||
/** The durable list preceding one Tool invocation; absent before the first loaded write. */
|
||||
export interface TodoBaseline {
|
||||
readonly todos: readonly TodoItem[] | undefined
|
||||
}
|
||||
|
||||
/** Session-owned call lookup, read only through the target's observable snapshot. */
|
||||
export interface TodoHistory {
|
||||
/** @param callId - Root or nested call identity. @returns its recorded predecessor, when the start is loaded. */
|
||||
get(callId: string): TodoBaseline | undefined
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
|
||||
interface ConversationViewSnapshotMap {
|
||||
/** Recorded todo lists preceding each loaded todo call. */
|
||||
'tool-todo-history': TodoHistory
|
||||
}
|
||||
}
|
||||
|
||||
/** Durable writes are indexed independently of Tool success receipts. */
|
||||
export const todoWriteDefinition: ConversationNodeDefinition<readonly TodoItem[]> = {
|
||||
kind: 'tool-todo-write',
|
||||
match: event => event.type === 'todo/write' ? { id: String(event.seq), role: 'start' } : null,
|
||||
start: (_context, match) => {
|
||||
if (match.event.type !== 'todo/write') throw new Error('tool-todo-write requires todo/write')
|
||||
return match.event.data.todos
|
||||
},
|
||||
update: context => context.state,
|
||||
publication: () => 'none',
|
||||
}
|
||||
|
||||
/** Invocation predecessors are repaired by the assembler when older history arrives. */
|
||||
export const todoCallDefinition: ConversationNodeDefinition<TodoBaseline> = {
|
||||
kind: 'tool-todo-call',
|
||||
target: 'tool-todo-history',
|
||||
match: (event) => {
|
||||
if (event.type === 'tool/call' && event.data.name === 'todo_write') {
|
||||
return { id: String(event.data.callId), role: 'start' }
|
||||
}
|
||||
if (event.type === 'tool/ptc-dispatch-start' && event.data.name === 'todo_write') {
|
||||
return { id: String(event.data.subCallId), role: 'start' }
|
||||
}
|
||||
return null
|
||||
},
|
||||
start: (_context, _match, reader) => ({ todos: reader.previous<readonly TodoItem[]>('tool-todo-write')?.state }),
|
||||
update: context => context.state,
|
||||
buildViewNode: context => context.state === undefined ? null : {
|
||||
key: context.key, kind: context.kind, id: context.id, target: 'tool-todo-history', data: context.state,
|
||||
},
|
||||
}
|
||||
|
||||
interface TodoHistoryNode extends ConversationViewNode {
|
||||
readonly data: TodoBaseline
|
||||
}
|
||||
|
||||
/** Incremental lookup snapshots preserve earlier call baselines across later writes. */
|
||||
export const todoHistoryView: ConversationViewDefinition<TodoHistoryNode, TodoHistory> = {
|
||||
target: 'tool-todo-history',
|
||||
create: () => {
|
||||
let calls = new Map<string, TodoBaseline>()
|
||||
return {
|
||||
empty: calls,
|
||||
replace: ({ nodes }) => calls = new Map(nodes.map(node => [node.id, node.data])),
|
||||
apply: ({ upserts }) => {
|
||||
if (upserts.length > 0) {
|
||||
calls = new Map(calls)
|
||||
for (const node of upserts) calls.set(node.id, node.data)
|
||||
}
|
||||
return calls
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the recorded-write index and call predecessor target.
|
||||
* @param ctx - Tool presentation plugin context.
|
||||
*/
|
||||
export function registerTodoHistory(ctx: Context): void {
|
||||
ctx.uiConversation.events.register(todoWriteDefinition)
|
||||
ctx.uiConversation.events.register(todoCallDefinition)
|
||||
ctx.uiConversation.views.register(todoHistoryView)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
/** Keyed recorded-result rows sharing the compact detail body. */
|
||||
import { useMemo } from 'react'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import {
|
||||
IconAgentPresetOutlineRegular, IconBranchOutlineRegular, IconChecklistOutlineRegular, IconClockOutlineRegular,
|
||||
IconCodeOutlineRegular, IconCordisPluginOutlineRegular, IconGoalOutlineRegular, IconSearchOutlineRegular,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ToolCallViewProps } from '../../contract/slots.ts'
|
||||
import { CONVERSATION_NS as NS } from '../../locale.ts'
|
||||
import { ToolRow } from '../components/ToolRow.tsx'
|
||||
import { detailsCardModel } from '../models/details-card-model.ts'
|
||||
import { parsedToolCall } from '../models/raw-tool-call.ts'
|
||||
import { toolRowModel } from '../models/tool-call-model.ts'
|
||||
|
||||
const TITLE_KEYS = {
|
||||
create_goal: 'tool.title.createGoal',
|
||||
get_goal: 'tool.title.getGoal',
|
||||
update_goal: 'tool.title.updateGoal',
|
||||
schedule_create: 'tool.title.createSchedule',
|
||||
schedule_list: 'tool.title.listSchedules',
|
||||
schedule_delete: 'tool.title.deleteSchedule',
|
||||
cordis_inspect_list: 'tool.title.inspectProviders',
|
||||
cordis_inspect_query: 'tool.title.queryRuntime',
|
||||
cordis_inspect_self: 'tool.title.inspectPlugins',
|
||||
workflow: 'tool.title.workflow',
|
||||
ralph: 'tool.title.ralph',
|
||||
session_event_read: 'tool.title.readEvent',
|
||||
session_event_search: 'tool.title.searchEvents',
|
||||
session_event_trace: 'tool.title.traceEvent',
|
||||
session_search: 'tool.title.searchSessions',
|
||||
session_trace: 'tool.title.traceSession',
|
||||
list_subagent_models: 'tool.title.listModels',
|
||||
subagent: 'tool.title.subagent',
|
||||
list_agents: 'tool.title.listAgents',
|
||||
send_message: 'tool.title.sendMessage',
|
||||
interrupt_agent: 'tool.title.interruptAgent',
|
||||
job_list: 'tool.title.listJobs',
|
||||
job_output: 'tool.title.readJob',
|
||||
job_kill: 'tool.title.killJob',
|
||||
terminal_open: 'tool.title.openTerminal',
|
||||
terminal_read: 'tool.title.readTerminal',
|
||||
terminal_list: 'tool.title.listTerminals',
|
||||
terminal_signal: 'tool.title.signalTerminal',
|
||||
terminal_close: 'tool.title.closeTerminal',
|
||||
lsp: 'tool.title.lsp',
|
||||
spawn_teammate: 'tool.title.spawnTeammate',
|
||||
team_task_create: 'tool.title.createTeamTask',
|
||||
team_task_get: 'tool.title.getTeamTask',
|
||||
team_task_update: 'tool.title.updateTeamTask',
|
||||
team_task_list: 'tool.title.listTeamTasks',
|
||||
wait_agent: 'tool.title.waitAgent',
|
||||
} as const
|
||||
|
||||
const LSP_TITLE_KEYS = {
|
||||
goToDefinition: 'tool.title.findDefinition',
|
||||
findReferences: 'tool.title.findReferences',
|
||||
goToImplementation: 'tool.title.findImplementation',
|
||||
hover: 'tool.title.hoverSymbol',
|
||||
} as const
|
||||
|
||||
function detailIcon(toolName: string) {
|
||||
if (toolName.startsWith('schedule_')) return <IconClockOutlineRegular size={14} />
|
||||
if (toolName.endsWith('_goal')) return <IconGoalOutlineRegular size={14} />
|
||||
if (toolName.startsWith('cordis_')) return <IconCordisPluginOutlineRegular />
|
||||
if (toolName.startsWith('terminal_')) return <IconCodeOutlineRegular size={14} />
|
||||
if (toolName.startsWith('session_') || toolName === 'lsp') return <IconSearchOutlineRegular size={14} />
|
||||
if (toolName.startsWith('job_') || toolName.startsWith('team_task_')) return <IconChecklistOutlineRegular />
|
||||
if (toolName === 'workflow' || toolName === 'ralph') return <IconBranchOutlineRegular size={14} />
|
||||
return <IconAgentPresetOutlineRegular size={14} />
|
||||
}
|
||||
|
||||
/**
|
||||
* Present recorded entities, receipts, and report fields in the existing expandable row.
|
||||
* @param props - Tool call, row actions, and locale supplied by the keyed slot.
|
||||
* @returns A Tool row with structured details or generic input/output.
|
||||
*/
|
||||
export function DetailsRow({ toolName, block, cwd, home, openFile, inspect, t }: ToolCallViewProps & PropsLocale<'conversation'>) {
|
||||
const model = toolRowModel(toolName, block, cwd, home)
|
||||
const locale = document.documentElement.lang
|
||||
const details = useMemo(() => detailsCardModel(block, t, locale), [block, t, locale])
|
||||
const operation = toolName === 'lsp' ? parsedToolCall(block)?.args.operation : undefined
|
||||
const titleKey = typeof operation === 'string' && Object.hasOwn(LSP_TITLE_KEYS, operation)
|
||||
? LSP_TITLE_KEYS[operation as keyof typeof LSP_TITLE_KEYS]
|
||||
: Object.hasOwn(TITLE_KEYS, toolName) ? TITLE_KEYS[toolName as keyof typeof TITLE_KEYS] : model.titleKey
|
||||
return (
|
||||
<ToolRow
|
||||
t={t}
|
||||
variant={model.variant}
|
||||
toolName={toolName}
|
||||
icon={detailIcon(toolName)}
|
||||
title={t(titleKey)}
|
||||
summary={details?.summary ?? details?.items[0]?.title ?? details?.empty ?? model.summary}
|
||||
details={details}
|
||||
bodyRaw={model.bodyRaw}
|
||||
output={model.output}
|
||||
errorSummary={model.errorSummary}
|
||||
state={model.state}
|
||||
inspect={inspect}
|
||||
onOpenFile={openFile}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** Register recorded-result details through the standard atomic Tool slot. */
|
||||
export const detailsToolview = {
|
||||
name: 'details-toolview',
|
||||
inject: ['slots'],
|
||||
apply(ctx: Context): void {
|
||||
ctx.slots.inject('tool.call.toolview', function* () {
|
||||
yield ctx.slots.register({ name: 'tool.call.toolview', key: 'create_goal', locale: NS }, DetailsRow)
|
||||
yield ctx.slots.register({ name: 'tool.call.toolview', key: 'get_goal', locale: NS }, DetailsRow)
|
||||
yield ctx.slots.register({ name: 'tool.call.toolview', key: 'update_goal', locale: NS }, DetailsRow)
|
||||
yield ctx.slots.register({ name: 'tool.call.toolview', key: 'schedule_create', locale: NS }, DetailsRow)
|
||||
yield ctx.slots.register({ name: 'tool.call.toolview', key: 'schedule_list', locale: NS }, DetailsRow)
|
||||
yield ctx.slots.register({ name: 'tool.call.toolview', key: 'schedule_delete', locale: NS }, DetailsRow)
|
||||
yield ctx.slots.register({ name: 'tool.call.toolview', key: 'cordis_inspect_list', locale: NS }, DetailsRow)
|
||||
yield ctx.slots.register({ name: 'tool.call.toolview', key: 'cordis_inspect_query', locale: NS }, DetailsRow)
|
||||
yield ctx.slots.register({ name: 'tool.call.toolview', key: 'cordis_inspect_self', locale: NS }, DetailsRow)
|
||||
yield ctx.slots.register({ name: 'tool.call.toolview', key: 'workflow', locale: NS }, DetailsRow)
|
||||
yield ctx.slots.register({ name: 'tool.call.toolview', key: 'ralph', locale: NS }, DetailsRow)
|
||||
yield ctx.slots.register({ name: 'tool.call.toolview', key: 'session_event_read', locale: NS }, DetailsRow)
|
||||
yield ctx.slots.register({ name: 'tool.call.toolview', key: 'session_event_search', locale: NS }, DetailsRow)
|
||||
yield ctx.slots.register({ name: 'tool.call.toolview', key: 'session_event_trace', locale: NS }, DetailsRow)
|
||||
yield ctx.slots.register({ name: 'tool.call.toolview', key: 'session_search', locale: NS }, DetailsRow)
|
||||
yield ctx.slots.register({ name: 'tool.call.toolview', key: 'session_trace', locale: NS }, DetailsRow)
|
||||
yield ctx.slots.register({ name: 'tool.call.toolview', key: 'list_subagent_models', locale: NS }, DetailsRow)
|
||||
yield ctx.slots.register({ name: 'tool.call.toolview', key: 'subagent', locale: NS }, DetailsRow)
|
||||
yield ctx.slots.register({ name: 'tool.call.toolview', key: 'list_agents', locale: NS }, DetailsRow)
|
||||
yield ctx.slots.register({ name: 'tool.call.toolview', key: 'send_message', locale: NS }, DetailsRow)
|
||||
yield ctx.slots.register({ name: 'tool.call.toolview', key: 'interrupt_agent', locale: NS }, DetailsRow)
|
||||
yield ctx.slots.register({ name: 'tool.call.toolview', key: 'job_list', locale: NS }, DetailsRow)
|
||||
yield ctx.slots.register({ name: 'tool.call.toolview', key: 'job_output', locale: NS }, DetailsRow)
|
||||
yield ctx.slots.register({ name: 'tool.call.toolview', key: 'job_kill', locale: NS }, DetailsRow)
|
||||
yield ctx.slots.register({ name: 'tool.call.toolview', key: 'terminal_open', locale: NS }, DetailsRow)
|
||||
yield ctx.slots.register({ name: 'tool.call.toolview', key: 'terminal_read', locale: NS }, DetailsRow)
|
||||
yield ctx.slots.register({ name: 'tool.call.toolview', key: 'terminal_list', locale: NS }, DetailsRow)
|
||||
yield ctx.slots.register({ name: 'tool.call.toolview', key: 'terminal_signal', locale: NS }, DetailsRow)
|
||||
yield ctx.slots.register({ name: 'tool.call.toolview', key: 'terminal_close', locale: NS }, DetailsRow)
|
||||
yield ctx.slots.register({ name: 'tool.call.toolview', key: 'lsp', locale: NS }, DetailsRow)
|
||||
yield ctx.slots.register({ name: 'tool.call.toolview', key: 'spawn_teammate', locale: NS }, DetailsRow)
|
||||
yield ctx.slots.register({ name: 'tool.call.toolview', key: 'team_task_create', locale: NS }, DetailsRow)
|
||||
yield ctx.slots.register({ name: 'tool.call.toolview', key: 'team_task_get', locale: NS }, DetailsRow)
|
||||
yield ctx.slots.register({ name: 'tool.call.toolview', key: 'team_task_update', locale: NS }, DetailsRow)
|
||||
yield ctx.slots.register({ name: 'tool.call.toolview', key: 'team_task_list', locale: NS }, DetailsRow)
|
||||
yield ctx.slots.register({ name: 'tool.call.toolview', key: 'wait_agent', locale: NS }, DetailsRow)
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -1,13 +1,17 @@
|
||||
import { useMemo } from 'react'
|
||||
import { IconChecklistOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { HostObservable, InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ToolCallViewProps } from '../../contract/slots.ts'
|
||||
import { registerTodoHistory, type TodoHistory } from '../models/todo-history.ts'
|
||||
import { todoDiffModel } from '../models/todo-diff-model.ts'
|
||||
import { toolRowModel } from '../models/tool-call-model.ts'
|
||||
import { ToolRow } from '../components/ToolRow.tsx'
|
||||
import { CONVERSATION_NS as NS } from '../../locale.ts'
|
||||
import { planSummary, type PlanItemLike } from './plan-summary.ts'
|
||||
|
||||
type TodoRowProps = ToolCallViewProps & PropsLocale<'conversation'>
|
||||
type TodoHistoryInjected = { hooks: { todoHistory: HostObservable<TodoHistory | undefined> } }
|
||||
type TodoRowProps = ToolCallViewProps & PropsLocale<'conversation'> & InjectFace<TodoHistoryInjected>
|
||||
|
||||
function isItem(value: unknown): value is PlanItemLike {
|
||||
return typeof value === 'object' && value !== null
|
||||
@@ -45,7 +49,10 @@ function summarize(argsRaw: string, t: TodoRowProps['t']): RowSummary | null {
|
||||
}
|
||||
|
||||
/** Summarizes a plan update without presenting a cancelled call as completed. */
|
||||
export function TodoRow({ toolName, block, inspect, t }: TodoRowProps) {
|
||||
export function TodoRow({ toolName, block, inspect, useTodoHistory, useSession, t }: TodoRowProps) {
|
||||
const baseline = useTodoHistory(snapshot => snapshot?.get(block.callId))
|
||||
const hasMore = useSession(snapshot => snapshot.hasMore)
|
||||
const diff = useMemo(() => todoDiffModel(block, baseline, hasMore, t), [block, baseline, hasMore, t])
|
||||
const model = toolRowModel(toolName, block)
|
||||
const argsRaw = ('kind' in block ? block.call?.argsRaw : block.argsRaw) ?? ''
|
||||
const summary = summarize(argsRaw, t) ?? { text: model.summary, extra: 0 }
|
||||
@@ -57,9 +64,11 @@ export function TodoRow({ toolName, block, inspect, t }: TodoRowProps) {
|
||||
icon={<IconChecklistOutlineRegular />}
|
||||
title={t('todo.rowTitle')}
|
||||
summary={summary.text}
|
||||
summarySuffix={summary.extra > 0 ? `+${summary.extra}` : null}
|
||||
summarySuffix={[diff?.summary, summary.extra > 0 ? `+${summary.extra}` : null]
|
||||
.filter((part): part is string => part !== null && part !== undefined).join(' · ') || null}
|
||||
bodyRaw={model.bodyRaw}
|
||||
output={model.output}
|
||||
details={diff?.details}
|
||||
errorSummary={model.errorSummary}
|
||||
state={model.state}
|
||||
inspect={inspect}
|
||||
@@ -70,9 +79,15 @@ export function TodoRow({ toolName, block, inspect, t }: TodoRowProps) {
|
||||
/** Registers the todo conversation row. */
|
||||
export const todoToolview = {
|
||||
name: 'todo-toolview',
|
||||
inject: ['slots'],
|
||||
inject: ['slots', 'uiConversation'],
|
||||
apply(ctx: Context): void {
|
||||
registerTodoHistory(ctx)
|
||||
ctx.slots.inject('tool.call.toolview', () =>
|
||||
ctx.slots.register({ name: 'tool.call.toolview', key: 'todo_write', locale: NS }, TodoRow))
|
||||
ctx.slots.register({
|
||||
name: 'tool.call.toolview', key: 'todo_write', locale: NS,
|
||||
inject: (sessionId): TodoHistoryInjected => ({
|
||||
hooks: { todoHistory: ctx.uiConversation.binding(sessionId).target('tool-todo-history') },
|
||||
}),
|
||||
}, TodoRow))
|
||||
},
|
||||
}
|
||||
@@ -116,7 +116,7 @@ describe('todo_write assembly (product registrations, no outlet twins)', () => {
|
||||
// Keyed toolview registration took the row (summary derived from args).
|
||||
const row = view.container.querySelector('[data-tool="todo_write"]')
|
||||
expect(row).not.toBeNull()
|
||||
expect(row!.textContent).toContain('1/3 已完成 · 实现 fixture 样本')
|
||||
expect(row!.textContent).toContain('新增 3')
|
||||
|
||||
// The plan strip sits in the input dock, fed by the projection
|
||||
// (default-collapsed: the header summary shows; rows appear on expand).
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
// @vitest-environment jsdom
|
||||
/** Detail adapters accept the exact recorded result text and retain safe fallback paths. */
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { en } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts'
|
||||
import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
|
||||
import { statusLine } from '@deepseek-ai/dsh-tool-jobs/src/index.ts'
|
||||
import { presentation } from '@deepseek-ai/dsh-tool-session-query/src/presentation.ts'
|
||||
import { renderList, renderRead, renderSpawn } from '@deepseek-ai/dsh-tool-terminal/src/render.ts'
|
||||
import { formatSpillNotice } from '@deepseek-ai/dsh-spill-policy/notice'
|
||||
import { detailBadge, detailJson, detailList, detailRecord, inspectionItems, nonempty } from '../src/client/tool/models/detail-model-shared.ts'
|
||||
import { detailsCardModel } from '../src/client/tool/models/details-card-model.ts'
|
||||
import { SpillLocator } from '@deepseek-ai/dsh-spill'
|
||||
|
||||
const t = makeTranslate(en, commonEn)
|
||||
|
||||
function output(name: string, text: string, args: Record<string, unknown> = {}): ToolResultNode {
|
||||
return {
|
||||
kind: 'tool-result', seq: 10, time: 2_000, callTime: 1_000, callId: `${name}-1`,
|
||||
call: { name, argsRaw: JSON.stringify(args) }, content: [{ type: 'text', text }], isError: false, subCalls: [],
|
||||
}
|
||||
}
|
||||
|
||||
function details(name: string, text: string, args: Record<string, unknown> = {}) {
|
||||
return detailsCardModel(output(name, text, args), t, 'en')
|
||||
}
|
||||
|
||||
describe('shared detail values', () => {
|
||||
it('narrows JSON, localizes status, and renders scalar and nested records', () => {
|
||||
expect(detailJson('{"ok":true}')).toEqual({ ok: true })
|
||||
expect(detailJson('{')).toBeUndefined()
|
||||
expect(detailRecord({})).toBe(true)
|
||||
expect(detailRecord([])).toBe(false)
|
||||
expect(nonempty(' text ')).toBe(true)
|
||||
expect(nonempty(' ')).toBe(false)
|
||||
expect(detailBadge('completed', t)).toEqual({ label: 'Completed', tone: 'success' })
|
||||
expect(detailBadge('unknown', t)).toEqual({ label: 'unknown', tone: 'neutral' })
|
||||
expect(inspectionItems([null, true, 'x', []], t)).toHaveLength(4)
|
||||
expect(inspectionItems({ title: 'Record', description: 'Description', status: 'running', inputSchema: { type: 'object' }, arguments: '{"x":1}', empty: [], count: 2 }, t)[0]).toMatchObject({
|
||||
title: 'Record', description: 'Description', badge: { label: 'Running' },
|
||||
})
|
||||
expect(detailList([], 'none', t)).toMatchObject({ summary: 'none', caption: 'Recorded result', empty: 'No results' })
|
||||
})
|
||||
|
||||
it('limits nested inspection records and preserves large values as JSON code', () => {
|
||||
const values = Array.from({ length: 41 }, (_, index) => ({ id: String(index) }))
|
||||
expect(inspectionItems(values, t)).toHaveLength(41)
|
||||
expect(inspectionItems({ nested: { deeper: { value: { more: { still: { data: true } } } } } }, t)[0]?.groups).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('control detail adapters', () => {
|
||||
it('uses producer wording for agent, job, terminal, and LSP lists', () => {
|
||||
const agents = details('list_agents', 'a1 [running] — Build\na2 [ready] parent=a1 depth=1 — Review', { scope: 'descendants' })
|
||||
expect(agents?.summary).toBe('2 agents')
|
||||
expect(details('list_agents', '(no subagents)')?.empty).toBe('No results')
|
||||
expect(details('job_list', 'j1 [bash] running — Build')?.items[0]?.fields).toContainEqual({ label: 'Type', value: 'bash' })
|
||||
expect(details('job_list', '(no background jobs)')?.empty).toBe('No results')
|
||||
expect(details('terminal_list', renderList([], 1000))?.empty).toBe('No results')
|
||||
const terminals = [
|
||||
{ sessionId: 'pty-1' as never, type: 'shell', status: { kind: 'running' as const } },
|
||||
{ sessionId: 'pty-2' as never, name: 'done', type: 'shell', pid: 4, status: { kind: 'exited' as const, exitCode: 2, signal: null } },
|
||||
]
|
||||
expect(details('terminal_list', renderList(terminals, 1000))?.items).toHaveLength(2)
|
||||
const locations = details('lsp', 'src/a.ts:2:3\nhttps://example.test/a.ts:4:5\nuntitled:Untitled-1:6:7\nC:\\src\\a.ts:8:9\nD:/lib/a.ts:10:11', { file_path: 'src/main.ts', line: 2, character: 3, operation: 'findReferences' })?.items
|
||||
expect(locations?.map(item => item.location)).toEqual([
|
||||
{ path: 'src/a.ts', line: 2 }, undefined, undefined, { path: 'C:\\src\\a.ts', line: 8 }, { path: 'D:/lib/a.ts', line: 10 },
|
||||
])
|
||||
expect(details('lsp', 'No results.', { file_path: 'src/main.ts', line: 2, character: 3, operation: 'findReferences' })?.empty).toBe('No results')
|
||||
expect(details('lsp', 'hover text', { file_path: 'src/main.ts', line: 2, character: 3, operation: 'hover' })?.items[0]?.markdown).toBe('hover text')
|
||||
})
|
||||
|
||||
it('renders receipts, reports malformed formats, and handles truncation', () => {
|
||||
expect(details('spawn_teammate', '{"member":{"id":"a1","status":"running"}}', { name: 'A' })?.items[0]).toMatchObject({ title: 'a1', badge: { label: 'Running' } })
|
||||
expect(details('team_task_create', '{"id":"task-1","subject":"Review","status":"pending"}')?.summary).toBe('Review')
|
||||
expect(details('team_task_list', '{"tasks":[{"id":"task-1","subject":"Review"}],"nextCursor":3}')?.caption).toBe('More tasks available; next cursor is 3')
|
||||
expect(details('send_message', '{"status":"queued"}', { agent_id: 'a1', message: 'hello' })?.items[0]?.badge?.label).toBe('Queued')
|
||||
expect(details('send_message', 'message delivered to agent a1', { agent_id: 'a1', message: 'hello' })?.summary).toBe('a1 · Message delivered')
|
||||
expect(details('interrupt_agent', '{"previousStatus":"running"}', { agent_id: 'a1' })?.items[0]?.fields).toContainEqual({ label: 'Previous status', value: 'Running' })
|
||||
expect(details('wait_agent', '{"timedOut":true}')?.summary).toBe('Teammate activity · Wait timed out')
|
||||
expect(details('wait_agent', '{"timedOut":false,"noProgress":{"message":"No peer"}}')?.items[0]?.description).toBe('No peer')
|
||||
expect(details('subagent', 'started background subagent job job-1', { prompt: 'Do work' })?.items[0]?.fields).toContainEqual({ label: 'Job ID', value: 'job-1' })
|
||||
expect(details('subagent', 'A useful reply', { prompt: 'Do work' })?.items[0]?.markdown).toBe('A useful reply')
|
||||
expect(details('list_subagent_models', 'deepseek-chat — Fast\nunknown', { model: 'deepseek-chat' })?.items).toHaveLength(2)
|
||||
expect(details('job_kill', 'requested cancellation of job j1', { job_id: 'j1', reason: 'stop' })?.summary).toContain('Cancellation requested')
|
||||
expect(details('job_kill', 'job j1 had already finished completed', { job_id: 'j1' })?.summary).toContain('Already finished')
|
||||
const spawnText = renderSpawn({ sessionId: 'pty-1', type: 'shell', status: { kind: 'running' }, motd: 'ready' }, 1000)
|
||||
expect(details('terminal_open', spawnText)?.items[0]?.code?.text).toBe('ready')
|
||||
const readText = renderRead({ text: 'line', totalLines: 2, lineBegin: 0, lineEnd: 1, truncated: true }, 1000)
|
||||
expect(details('terminal_read', readText, { sessionId: 'pty-1' })?.items[0]?.description).toBe('Output truncated')
|
||||
expect(details('terminal_signal', 'delivered SIGINT to foreground process group 12', { sessionId: 'pty-1' })?.items[0]?.fields).toContainEqual({ label: 'Process group', value: '12' })
|
||||
expect(details('terminal_close', 'closed terminal session pty-1', { sessionId: 'pty-1' })?.summary).toContain('Closed')
|
||||
expect(details('terminal_close', 'terminal session pty-1 was already closing', { sessionId: 'pty-1' })?.summary).toContain('Closing')
|
||||
expect(details('job_output', `hello\n[output truncated]\n${statusLine({ status: 'completed' })}`, { job_id: 'j1' })?.items[0]).toMatchObject({ code: { text: 'hello' }, description: 'Output truncated' })
|
||||
expect(details('job_output', `hello\n${statusLine({ status: 'completed', detail: 'exit code: 0' })}`, { job_id: 'j1' })?.items[0]?.description).toBe('exit code: 0')
|
||||
expect(details('unknown', 'x')).toBeNull()
|
||||
expect(details('team_task_list', '{"tasks":[],"nextCursor":"bad"}')).toBeNull()
|
||||
expect(details('terminal_read', 'bad', { sessionId: 'pty-1' })).toBeNull()
|
||||
expect(details('interrupt_agent', 'interrupt requested for agent a1', { agent_id: 'a1' })?.summary).toContain('Interrupt')
|
||||
expect(details('interrupt_agent', 'unexpected', { agent_id: 'a1' })).toBeNull()
|
||||
expect(details('job_kill', 'unexpected', { job_id: 'j1' })).toBeNull()
|
||||
expect(details('terminal_close', 'unexpected', { sessionId: 'pty-1' })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('inspection detail adapters', () => {
|
||||
const sessionRecord = (id: string, live: boolean, persisted: boolean) => ({
|
||||
header: { id: id as never, createdAt: 1_000, parentSession: undefined }, live, persisted,
|
||||
})
|
||||
|
||||
it('accepts Cordis, workflow, and Ralph producer results', () => {
|
||||
expect(details('cordis_inspect_list', '{"providers":[{"name":"Service","status":"ready"}]}')?.summary).toBe('1 inspect providers')
|
||||
expect(details('cordis_inspect_query', '{"provider":"Service","method":"get","data":{"value":1}}')?.summary).toBe('Service.get')
|
||||
expect(details('cordis_inspect_self', '{"mode":"client","plugins":[]}')?.summary).toBe('0 dynamic plugins')
|
||||
expect(details('cordis_inspect_self', '{"mode":"host","packageId":"pkg","value":1}')?.summary).toBe('host')
|
||||
expect(details('cordis_inspect_self', '{"mode":"host","value":1}', { pluginId: 'plugin-1' })?.summary).toBe('plugin-1')
|
||||
expect(details('workflow', 'workflow "flow" completed (1 agent).\nReturn value:\n{"ok":true}', { code: 'return true' })?.items[0]?.fields).toContainEqual({ label: 'Agents started', value: '1' })
|
||||
const report = JSON.stringify({ summary: 'Done', evidence: ['checked'], nextSteps: ['ship'], blocker: '' }, null, 2)
|
||||
expect(details('ralph', `Ralph worker reported completion after 2 rounds.\nFinal report:\n${report}`, { objective: 'Ship' })?.items[0]).toMatchObject({ title: 'Done', lines: ['checked'] })
|
||||
expect(details('ralph', `Ralph worker reported a blocker after 1 round.\nFinal report:\n${JSON.stringify({ summary: 'Blocked', evidence: [], nextSteps: [], blocker: 'No key' })}`)?.items[0]?.badge?.label).toBe('Worker reported a blocker')
|
||||
expect(details('ralph', `Ralph reached its 3 rounds limit; the worker reported work remaining.\nFinal report:\n${JSON.stringify({ summary: 'Limited', evidence: [], nextSteps: [], blocker: '' })}`)?.items[0]?.badge?.label).toBe('Round limit reached')
|
||||
})
|
||||
|
||||
it('uses the session-query producer presentation for searches, reads, and traces', () => {
|
||||
const session = sessionRecord('s1', true, true)
|
||||
const hit = { ...session, bestMatch: { sessionId: 's1' as never, seq: 2, type: 'user/message', time: 2_000, surface: 'current', snippet: 'needle' } }
|
||||
const title = { text: 'Session one' }
|
||||
const titles = new Map([
|
||||
['s1' as never, title], ['s2' as never, { text: 'Child' }], ['s3' as never, { text: 'Grandchild' }],
|
||||
]) as Parameters<typeof presentation.formatSessionSearch>[1]
|
||||
const sessionSearch = presentation.formatSessionSearch({ items: [hit as never], capped: true }, titles, new Set())
|
||||
expect(details('session_search', sessionSearch)?.items[0]).toMatchObject({ title: 'Session one', subtitle: 's1', description: 'needle' })
|
||||
const eventSearch = presentation.formatEventSearch(
|
||||
's1' as never, title,
|
||||
{ items: [{ sessionId: 's1' as never, seq: 2 as never, type: 'tool/call', time: 2_000, surface: 'current', snippet: 'needle' }], capped: false },
|
||||
)
|
||||
expect(details('session_event_search', eventSearch)?.items[0]?.title).toBe('needle')
|
||||
const trace = presentation.formatSessionTrace(
|
||||
{ target: session as never, ancestors: [], descendants: [], complete: true, root: session as never } as never,
|
||||
[], false, [{ record: sessionRecord('s2', true, true) as never, descendants: [{ record: sessionRecord('s3', true, true) as never, descendants: [] }] }], titles,
|
||||
)
|
||||
const traceItems = details('session_trace', trace)?.items
|
||||
expect(traceItems).toHaveLength(3)
|
||||
expect(traceItems?.[0]).toMatchObject({ title: 'Session one', subtitle: 's1' })
|
||||
expect(traceItems?.[2]?.lines).toEqual([
|
||||
's2 — Child | 1970-01-01T00:00:01.000Z | live, persisted',
|
||||
' s3 — Grandchild | 1970-01-01T00:00:01.000Z | live, persisted',
|
||||
])
|
||||
const eventTrace = presentation.formatEventTrace(
|
||||
's1' as never, title,
|
||||
{
|
||||
session: { id: 's1' as never, createdAt: 1_000 } as never,
|
||||
target: { sessionId: 's1' as never, seq: 2 as never, type: 'tool/call', time: 2_000, surface: 'current' },
|
||||
replacementChain: [], replacedEventSeqs: [], sourceEventSeqs: [], derivedEventSeqs: [],
|
||||
},
|
||||
)
|
||||
expect(details('session_event_trace', eventTrace)?.items[0]?.fields).toContainEqual({ label: 'Target event', value: 'seq 2 | tool/call | current | 1970-01-01T00:00:02.000Z' })
|
||||
const event = { seq: 2, time: 2_000, type: 'tool/call', data: { name: 'x' } }
|
||||
const eventRead = presentation.formatEventRead(
|
||||
's1' as never, title,
|
||||
{
|
||||
session: {} as never, inheritedEventCount: 0 as never, target: event as never,
|
||||
events: [event as never], startSeq: 2 as never, endSeq: 2 as never,
|
||||
},
|
||||
)
|
||||
expect(details('session_event_read', eventRead)?.items[0]?.title).toBe('tool/call')
|
||||
expect(details('session_search', 'No prior session matches found.')?.summary).toBe('0 matches')
|
||||
expect(details('session_event_search', 'Session s1 — Session one\n\nNo prior event matches found.')?.summary).toBe('0 matches')
|
||||
})
|
||||
|
||||
it('retains generic output for malformed or spilled inspection results', () => {
|
||||
expect(details('workflow', 'bad')).toBeNull()
|
||||
expect(details('session_trace', 'Session s1 — title\nCreated: invalid\nAvailability: unavailable\n\nAncestors (nearest first):\n- none (target is a root session)\n\nDescendants:\n- none')?.items[0]?.fields).toContainEqual({ label: 'Time', value: 'invalid' })
|
||||
const notice = formatSpillNotice({ kind: 'exact', count: 100 }, { locator: SpillLocator('/tmp/result.txt'), retrievalHint: 'Read it with read' })
|
||||
expect(details('session_event_trace', `Session s1 — title\nCreated: 1970-01-01T00:00:00.000Z\n\n${notice}`)).toBeNull()
|
||||
expect(details('session_search', 'not a result')).toBeNull()
|
||||
expect(details('session_event_read', 'not a result')).toBeNull()
|
||||
expect(details('workflow', 'workflow "flow" completed (1 agent).\nReturn value:\nnot-json')).toBeNull()
|
||||
expect(details('ralph', 'Ralph worker reported completion after 1 round.\nFinal report:\nnot-json')).toBeNull()
|
||||
expect(details('ralph', 'Ralph worker reported completion after 1 round.\nFinal report:\n{"summary":"x","evidence":[1],"nextSteps":[],"blocker":""}')).toBeNull()
|
||||
expect(details('ralph', 'Ralph worker reported completion after 1 round.\nFinal report:\n{"summary":"x","evidence":[],"nextSteps":[1],"blocker":""}')).toBeNull()
|
||||
expect(details('ralph', 'Unknown result\nFinal report:\n{"summary":"x","evidence":[],"nextSteps":[],"blocker":""}')).toBeNull()
|
||||
expect(details('session_search', '1. Session s1 — title\n Created: 2026-01-01T00:00:00.000Z')).not.toBeNull()
|
||||
expect(details('session_search', '1. Session — title')).toBeNull()
|
||||
expect(details('session_event_search', '1. seq bad | tool/call | current | 2026-01-01T00:00:00.000Z')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('detailsCardModel fallback contract', () => {
|
||||
it('keeps malformed successful domain results generic', () => {
|
||||
expect(detailsCardModel(output('create_goal', 'not-json'), t, 'en')).toBeNull()
|
||||
expect(detailsCardModel(output('schedule_create', '{"id":"s","prompt":"x","kind":"at","scheduledAt":"invalid","state":"scheduled","deliveryMode":"session-local"}'), t, 'en')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps recorded dates readable when the document language is invalid', () => {
|
||||
const scheduledAt = '2026-09-10T09:00:00.000Z'
|
||||
const model = detailsCardModel(output('schedule_create', JSON.stringify({
|
||||
id: 'schedule-1', prompt: 'Review the build', kind: 'at', scheduledAt,
|
||||
state: 'scheduled', deliveryMode: 'session-local',
|
||||
})), t, 'en_US')
|
||||
expect(model?.items[0]?.fields).toContainEqual({ label: 'Scheduled for', value: scheduledAt })
|
||||
const trace = detailsCardModel(output('session_event_trace', 'Session s1 — title\nCreated: 1970-01-01T00:00:00.000Z'), t, 'en_US')
|
||||
expect(trace?.items[0]?.fields).toContainEqual({ label: 'Time', value: '1970-01-01T00:00:00.000Z' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
// @vitest-environment jsdom
|
||||
/** Recorded detail cards, conservative fallback, and standard row interactions. */
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { en } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts'
|
||||
import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
|
||||
import { detailsCardModel, todosDetail } from '../src/client/tool/models/details-card-model.ts'
|
||||
import { DetailsRow, detailsToolview } from '../src/client/tool/toolviews/details-row.tsx'
|
||||
|
||||
const t = makeTranslate(en, commonEn)
|
||||
const goal = { id: 'goal-1', revision: 2, objective: 'Ship compact cards', phase: 'active', roundsStarted: 2, maxGoalRounds: 8 }
|
||||
const schedule = { id: 'schedule-1', prompt: 'Review the build', kind: 'every', everySeconds: 3600, scheduledAt: '2026-09-10T09:00:00.000Z', state: 'scheduled', deliveryMode: 'session-local' }
|
||||
|
||||
function result(name: string, value: unknown, args = '{}'): ToolResultNode {
|
||||
return { kind: 'tool-result', seq: 10, time: 2000, callTime: 1000, callId: 'c1', call: { name, argsRaw: args }, content: [{ type: 'text', text: JSON.stringify(value) }], isError: false, subCalls: [] }
|
||||
}
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('detailsCardModel', () => {
|
||||
it('reads the recorded goal phase and activation, including blockers and absence', () => {
|
||||
const model = (value: unknown) => detailsCardModel(result('get_goal', value), t, 'en')
|
||||
expect(model({ goal, activation: 'disarmed' })?.items[0]).toEqual({ title: 'Ship compact cards', fields: [{ label: 'Status', value: 'Awaiting continuation' }, { label: 'Rounds', value: '2 / 8' }] })
|
||||
expect(model({ goal: { ...goal, phase: 'blocked', blockedReason: { code: 'agent_blocked', message: 'Needs credentials' } }, activation: 'disarmed' })?.items[0]?.fields).toContainEqual({ label: 'Blocker', value: 'Needs credentials' })
|
||||
expect(model({ goal: null })).toEqual({ items: [], empty: 'No goal' })
|
||||
})
|
||||
|
||||
it('keeps all reminders and their recorded state, including one-shot and empty lists', () => {
|
||||
const model = detailsCardModel(result('schedule_list', [schedule, { ...schedule, id: 'schedule-2', kind: 'at', state: 'overdue' }, { ...schedule, id: 'schedule-3', kind: 'after', afterSeconds: 30 }]), t, 'en')
|
||||
expect(model?.items).toHaveLength(3)
|
||||
expect(model?.summary).toBe('3 reminders')
|
||||
expect(model?.items[0]?.fields).toContainEqual({ label: 'Repeat', value: 'Every 1 h' })
|
||||
expect(model?.items[1]?.fields).toContainEqual({ label: 'Status', value: 'Overdue, awaiting session resume' })
|
||||
expect(model?.items[2]?.fields).toContainEqual({ label: 'Repeat', value: 'Once' })
|
||||
expect(detailsCardModel(result('schedule_list', []), t, 'en')).toMatchObject({ summary: '0 reminders', empty: 'No reminders' })
|
||||
expect(detailsCardModel(result('schedule_delete', { id: 'schedule-1', deleted: true }), t, 'en')?.items[0]?.fields).toContainEqual({ label: 'Status', value: 'Deleted' })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['get_goal', { goal: { ...goal, phase: 'unknown' }, activation: 'armed' }],
|
||||
['get_goal', { goal, activation: 'unknown' }],
|
||||
['get_goal', { goal: { ...goal, blockedReason: {} }, activation: 'armed' }],
|
||||
['schedule_create', { ...schedule, scheduledAt: 'invalid' }],
|
||||
['schedule_create', { ...schedule, kind: 'unknown' }],
|
||||
['schedule_create', { ...schedule, everySeconds: -1 }],
|
||||
['schedule_list', [schedule, {}]],
|
||||
['schedule_list', { code: 'corrupt_schedule_log', message: 'The session schedule log is corrupt.' }],
|
||||
['schedule_delete', { id: 'schedule-1', deleted: false, code: 'schedule_not_found' }],
|
||||
['schedule_delete', { code: 'persistence_uncertain', operation: 'delete', id: 'schedule-1', message: 'Persistence is uncertain.' }],
|
||||
['unknown_tool', { goal, activation: 'armed' }],
|
||||
])('retains raw output for unsupported %s results (%j)', (name, value) => {
|
||||
expect(detailsCardModel(result(name, value), t, 'en')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not present an unsuccessful, partial, mixed-content, or window-truncated result as applied', () => {
|
||||
const block = result('create_goal', { goal, activation: 'armed' })
|
||||
expect(detailsCardModel({ ...block, isError: true }, t, 'en')).toBeNull()
|
||||
expect(detailsCardModel({ ...block, call: null }, t, 'en')).toBeNull()
|
||||
expect(detailsCardModel({ ...block, call: { name: 'create_goal', argsRaw: '{' } }, t, 'en')).toBeNull()
|
||||
expect(detailsCardModel({ ...block, content: [{ type: 'text', text: 'partial {' }] }, t, 'en')).toBeNull()
|
||||
expect(detailsCardModel({ ...block, content: [...block.content, { type: 'text', text: 'Extra result' }] }, t, 'en')).toBeNull()
|
||||
expect(detailsCardModel({ callId: 'c1', name: 'create_goal', argsRaw: '{}', turn: 1, step: 1, time: 1000, subCalls: [] }, t, 'en')).toBeNull()
|
||||
expect(detailsCardModel({ ...block, parentCallId: 'parent' }, t, 'en')?.items[0]?.title).toBe(goal.objective)
|
||||
})
|
||||
})
|
||||
|
||||
describe('todosDetail', () => {
|
||||
it('accepts valid to-do lists and uses the tool’s trimmed text', () => {
|
||||
expect(todosDetail({ todos: [{ content: ' Build the page ', status: 'in_progress' }] }, t)?.items[0]).toEqual({ title: 'Build the page', status: { value: 'in_progress', label: 'In progress' }, fields: [] })
|
||||
for (const todos of [[{ content: 'x', status: 'unknown' }], [{ content: '', status: 'pending' }], [null], [{ content: 'x', status: 'pending' }, { content: ' x ', status: 'completed' }]]) {
|
||||
expect(todosDetail({ todos }, t)).toBeNull()
|
||||
}
|
||||
expect(todosDetail({ todos: [] }, t)?.empty).toBe('The to-do list is empty')
|
||||
})
|
||||
})
|
||||
|
||||
describe('DetailsRow', () => {
|
||||
it('expands fields with the keyboard and keeps Inspect available', () => {
|
||||
const inspect = vi.fn()
|
||||
render(<DetailsRow {...{ toolName: 'get_goal', block: result('get_goal', { goal, activation: 'armed' }), inspect, t } as Parameters<typeof DetailsRow>[0]} />)
|
||||
expect(screen.queryByText('Rounds')).toBeNull()
|
||||
fireEvent.keyDown(screen.getByRole('button', { expanded: false }), { key: 'Enter' })
|
||||
expect(screen.getByText('Rounds')).toBeTruthy()
|
||||
expect(screen.getByText('2 / 8')).toBeTruthy()
|
||||
expect(screen.queryByText('Output')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Inspect' }))
|
||||
expect(inspect).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('registers the supported tool names through the scoped keyed slot', () => {
|
||||
const register = vi.fn((_spec: unknown, _component: unknown) => () => undefined)
|
||||
const inject = vi.fn((_name: string, callback: () => Iterable<() => void>) => {
|
||||
for (const dispose of callback()) dispose()
|
||||
})
|
||||
detailsToolview.apply({ slots: { inject, register } } as never)
|
||||
expect(register.mock.calls).toHaveLength(36)
|
||||
expect(register.mock.calls.map(([spec]) => spec)).toContainEqual({ name: 'tool.call.toolview', key: 'cordis_inspect_query', locale: 'conversation' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
/** Recorded todo predecessors follow incremental history repair and nested invocations. */
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ConversationNodeAssembler } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type { SessionLiveEventEntry } from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { en } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts'
|
||||
import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
|
||||
import { todoCallDefinition, todoHistoryView, todoWriteDefinition, type TodoHistory } from '../src/client/tool/models/todo-history.ts'
|
||||
import { todoDiffModel } from '../src/client/tool/models/todo-diff-model.ts'
|
||||
import type { ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
|
||||
const t = makeTranslate(en, commonEn)
|
||||
const first = [{ content: 'Build', status: 'in_progress' }, { content: 'Review', status: 'pending' }, { content: 'Old task', status: 'pending' }] as const
|
||||
const second = [{ content: 'Build', status: 'completed' }, { content: 'Review', status: 'pending' }, { content: 'Publish', status: 'pending' }] as const
|
||||
|
||||
function entry(seq: number, type: string, data: unknown): SessionLiveEventEntry {
|
||||
return { type: 'event', event: { seq, time: 1000 + seq, type, data } as SessionEvent }
|
||||
}
|
||||
|
||||
function call(todos: unknown): ToolResultNode {
|
||||
return { kind: 'tool-result', seq: 20, time: 2000, callTime: 1000, callId: 'second', call: { name: 'todo_write', argsRaw: JSON.stringify({ todos }) }, content: [], isError: false, subCalls: [] }
|
||||
}
|
||||
|
||||
function runtime() {
|
||||
const assembler = new ConversationNodeAssembler(
|
||||
{ entries: () => [todoWriteDefinition, todoCallDefinition], fallbackEntry: () => undefined },
|
||||
{ entries: () => [todoHistoryView] },
|
||||
)
|
||||
assembler.activateTarget('tool-todo-history')
|
||||
return assembler
|
||||
}
|
||||
|
||||
function history(assembler: ConversationNodeAssembler): TodoHistory {
|
||||
assembler.flush()
|
||||
return assembler.snapshot('tool-todo-history') as TodoHistory
|
||||
}
|
||||
|
||||
describe('recorded todo history', () => {
|
||||
it('repairs a missing predecessor on prepend and keeps earlier cards fixed after a later write', () => {
|
||||
const assembler = runtime()
|
||||
assembler.replaceWindow([entry(10, 'tool/call', { callId: 'second', name: 'todo_write', arguments: JSON.stringify({ todos: second }), turn: 1, step: 1 })], true)
|
||||
expect(todoDiffModel(call(second), history(assembler).get('second'), true, t)?.summary).toBeNull()
|
||||
assembler.prepend([entry(5, 'todo/write', { todos: first })], false)
|
||||
const baseline = history(assembler).get('second')
|
||||
expect(todoDiffModel(call(second), baseline, false, t)?.summary).toBe('1 added · 1 updated · 1 removed')
|
||||
assembler.append(entry(11, 'todo/write', { todos: second }))
|
||||
expect(history(assembler).get('second')).toEqual(baseline)
|
||||
assembler.append(entry(12, 'tool/ptc-dispatch-start', { parentCallId: 'root', rootCallId: 'root', subCallId: 'nested', name: 'todo_write', arguments: { todos: [] } }))
|
||||
expect(history(assembler).get('nested')?.todos).toEqual(second)
|
||||
assembler.replaceWindow([], false)
|
||||
expect(history(assembler).get('second')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('shows first-write additions, status transitions, removals, and unchanged items separately', () => {
|
||||
expect(todoDiffModel(call(first), { todos: undefined }, false, t)?.summary).toBe('3 added')
|
||||
const diff = todoDiffModel(call(second), { todos: first }, false, t)
|
||||
expect(diff?.details.items).toMatchObject([
|
||||
{ title: 'Build', previousStatus: 'In progress', change: { value: 'updated' } },
|
||||
{ title: 'Publish', change: { value: 'added' } },
|
||||
{ title: 'Old task', change: { value: 'removed' } },
|
||||
])
|
||||
expect(diff?.details.unchanged?.items.map(item => item.title)).toEqual(['Review'])
|
||||
expect(todoDiffModel(call(second), { todos: second }, false, t)?.summary).toBe('No changes to the list')
|
||||
expect(todoDiffModel(call([]), { todos: undefined }, false, t)?.details.empty).toBe('The to-do list is empty')
|
||||
expect(todoDiffModel({ ...call(first), isError: true }, { todos: undefined }, false, t)).toBeNull()
|
||||
expect(todoDiffModel(call(first), undefined, false, t)?.details.caption).toBe('Previous list unavailable')
|
||||
})
|
||||
|
||||
it('publishes immutable lookup snapshots when the index changes', () => {
|
||||
const view = todoHistoryView.create()
|
||||
const firstSnapshot = view.replace({ nodes: [{ id: 'call-1', data: { todos: first } }] } as never)
|
||||
const secondSnapshot = view.apply({ upserts: [{ id: 'call-1', data: { todos: second } }] } as never)
|
||||
expect(firstSnapshot.get('call-1')?.todos).toEqual(first)
|
||||
expect(secondSnapshot.get('call-1')?.todos).toEqual(second)
|
||||
expect(view.empty.get('call-1')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('distinguishes relative reordering from positions shifted by an insertion', () => {
|
||||
const moved = todoDiffModel(call([first[1], first[0], first[2]]), { todos: first }, false, t)
|
||||
expect(moved?.details.items.map(item => item.change?.label)).toEqual(['Reordered', 'Reordered'])
|
||||
const inserted = todoDiffModel(call([{ content: 'New', status: 'pending' }, ...first]), { todos: first }, false, t)
|
||||
expect(inserted?.summary).toBe('1 added')
|
||||
})
|
||||
})
|
||||
@@ -71,6 +71,8 @@ function rowProps(block: unknown): TodoRowProps {
|
||||
openFile: vi.fn(),
|
||||
sessionId: 's1',
|
||||
useSessions: () => undefined,
|
||||
useTodoHistory: () => undefined,
|
||||
useSession: () => true,
|
||||
t,
|
||||
} as unknown as TodoRowProps
|
||||
}
|
||||
@@ -127,11 +129,14 @@ describe('TodoRow', () => {
|
||||
expect(screen.getByText('todo_write · {"other":1}')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('leading toggle expands the raw args body', () => {
|
||||
it('leading toggle expands a read-only checklist', () => {
|
||||
render(<TodoRow {...rowProps(resultNode(ARGS))} />)
|
||||
fireEvent.click(screen.getByRole('button', { expanded: false }))
|
||||
expect(screen.getByRole('button', { expanded: true })).toBeTruthy()
|
||||
expect(screen.getByText(/搭骨架/)).toBeTruthy()
|
||||
expect(screen.getByText('搭骨架')).toBeTruthy()
|
||||
expect(screen.getAllByRole('listitem')).toHaveLength(3)
|
||||
expect(screen.getByLabelText('进行中')).toBeTruthy()
|
||||
expect(screen.queryByText('输入')).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
@@ -150,11 +155,17 @@ describe('TodoRow', () => {
|
||||
|
||||
it('injects the keyed toolview declaration directly', () => {
|
||||
expect(todoToolview.name).toBe('todo-toolview')
|
||||
expect(todoToolview.inject).toEqual(['slots'])
|
||||
const register = vi.fn(() => () => undefined)
|
||||
expect(todoToolview.inject).toEqual(['slots', 'uiConversation'])
|
||||
const register = vi.fn((_spec: unknown, _component: unknown) => () => undefined)
|
||||
const inject = vi.fn((_name: string, callback: () => () => void) => callback())
|
||||
todoToolview.apply({ slots: { inject, register } } as never)
|
||||
todoToolview.apply({
|
||||
slots: { inject, register },
|
||||
uiConversation: { events: { register: vi.fn() }, views: { register: vi.fn() } },
|
||||
} as never)
|
||||
expect(inject).toHaveBeenCalledWith('tool.call.toolview', expect.any(Function))
|
||||
expect(register).toHaveBeenCalledWith({ name: 'tool.call.toolview', key: 'todo_write', locale: NS }, TodoRow)
|
||||
const [spec, component] = register.mock.calls[0] ?? []
|
||||
expect(spec).toMatchObject({ name: 'tool.call.toolview', key: 'todo_write', locale: NS })
|
||||
expect((spec as { inject?: unknown } | undefined)?.inject).toEqual(expect.any(Function))
|
||||
expect(component).toBe(TodoRow)
|
||||
})
|
||||
})
|
||||
@@ -273,6 +273,7 @@ async function bench(snapshot = historySnapshot(NODES)) {
|
||||
const targetSources: ConversationTargetSources = {
|
||||
chat: createSnapshotStore<ChatSnapshot | undefined>(undefined),
|
||||
trajectory: trajectoryStore,
|
||||
'tool-todo-history': createSnapshotStore<ConversationViewSnapshotMap['tool-todo-history'] | undefined>(undefined),
|
||||
}
|
||||
const binding: ConversationBinding = {
|
||||
snapshot: conversationStore,
|
||||
@@ -395,6 +396,7 @@ function mount(fixture: Awaited<ReturnType<typeof bench>>) {
|
||||
return render(
|
||||
<>
|
||||
<ConversationSessionHeader
|
||||
hideChrome={false}
|
||||
{...standardProps}
|
||||
SessionProvider={({ children }) => children}
|
||||
useStore={bindSnapshotSelector(conversation)}
|
||||
|
||||
@@ -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/experimental/agent-team/README.md
|
||||
README.md: 1dba55aaef5c2f061de85de3b38cf5c644fa90b7
|
||||
README.zh.md: 749f5b9d27ed863639a6dabad80254c9a19727dd
|
||||
README.md: 6e2b55018971ec96802de0e5be042d49b45c674a
|
||||
README.zh.md: b497d3790e43a9bc843e2a6b88c9755b698034b5
|
||||
@@ -60,7 +60,7 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a
|
||||
|
||||
Ask the Lead to create a teammate: give it a unique lowercase name such as `reviewer` and describe its job. A teammate starts fresh with no memory of the Lead's conversation, or as a fork that inherits the Lead's completed turns; the creation request chooses which. Teammate names are permanent — even a teammate whose creation failed keeps its name, and no name is ever reused.
|
||||
|
||||
The roster shows every member with its role (`lead` or `teammate`) and current status: `running`, `idle`, `inactive` (a member that exists but is not loaded), `provisioning`, or `failed`. A member that is not loaded receives its messages when it wakes.
|
||||
The roster shows every member with its role (`lead` or `teammate`) and current status: `running`, `inactive` (no turn is executing, whether loaded or stored), `provisioning`, or `failed`. A member that is not loaded receives its messages when it wakes.
|
||||
|
||||
Only the Lead can create teammates or interrupt them.
|
||||
|
||||
@@ -68,7 +68,7 @@ Only the Lead can create teammates or interrupt them.
|
||||
|
||||
Any member can send a message to any other member or to the Lead. A live member receives it immediately; an offline member's messages queue and arrive when it resumes. Messages are never lost and never delivered twice.
|
||||
|
||||
Every message uses Steer: a running target receives it at the nearest step boundary, an idle target starts a turn, and an inactive teammate cold-resumes. The sender always sees the outcome — accepted by the target inbox, or retained as queued when delivery is temporarily unavailable. A queued message is already safely stored, so it must not be resent.
|
||||
Every message uses Steer: a running target receives it at the nearest step boundary; an inactive target starts a turn if loaded or cold-resumes otherwise. The sender always sees the outcome — accepted by the target inbox, or retained as queued when delivery is temporarily unavailable. A queued message is already safely stored, so it must not be resent.
|
||||
|
||||
### Shared task board
|
||||
|
||||
@@ -198,7 +198,7 @@ These limits describe what a team cannot do yet or what needs special operationa
|
||||
- **One process and one shared checkout** — members share cwd and observe edits immediately; this package provides no worktree, remote member, merge, or filesystem lock.
|
||||
- **Advisory write scopes** — Bash, formatters, code generators, and direct external writers can bypass filesystem version checks; Leads must coordinate ownership and review the final diff.
|
||||
- **Flat immutable roster** — only the Lead creates direct teammates; there is no nested Team, rename, deletion, or name reuse.
|
||||
- **No automatic ownership release** — idle, interruption, process exit, and failed work do not release a task owner.
|
||||
- **No automatic ownership release** — inactivity, interruption, process exit, and failed work do not release a task owner.
|
||||
- **Mailbox is not cross-process exactly-once** — concurrent harness processes over one Team are unsupported.
|
||||
|
||||
<a id="dev-note"></a>
|
||||
|
||||
@@ -60,7 +60,7 @@ kind: "package-reference"
|
||||
|
||||
请 Lead 创建 teammate:给它一个唯一的小写名字(例如 `reviewer`)并描述其职责。teammate 可以 fresh 启动(不携带 Lead 对话的任何记忆),也可以作为 fork 启动(继承 Lead 已完成的轮次);创建请求决定用哪种。teammate 名字是永久的——即使创建失败的 teammate 也保留其名字,任何名字都不会被复用。
|
||||
|
||||
roster 显示每个成员的职责(`lead` 或 `teammate`)与当前状态:`running`、`idle`、`inactive`(存在但未加载的成员)、`provisioning` 或 `failed`。未加载的成员会在唤醒后收到其消息。
|
||||
roster 显示每个成员的职责(`lead` 或 `teammate`)与当前状态:`running`、`inactive`(当前没有执行轮次,包括已加载和仅存储的成员)、`provisioning` 或 `failed`。未加载的成员会在唤醒后收到其消息。
|
||||
|
||||
只有 Lead 可以创建 teammate 或中断它们。
|
||||
|
||||
@@ -68,7 +68,7 @@ roster 显示每个成员的职责(`lead` 或 `teammate`)与当前状态:`
|
||||
|
||||
任何成员都可以向任何其他成员或 Lead 发送消息。live 成员会立即收到;离线成员的消息会排队,并在其恢复后到达。消息不会丢失,也不会重复投递。
|
||||
|
||||
每条消息都使用 Steer:running target 在最近的步骤边界收到消息,idle target 启动一个轮次,inactive teammate 则冷恢复。发送方始终能看到结果——target inbox 已接受,或在投递暂时不可用时保留为 queued。排队的消息已经安全存储,因此绝不能重发。
|
||||
每条消息都使用 Steer:running target 在最近的步骤边界收到消息,inactive target 在已加载时启动一个轮次,否则冷恢复。发送方始终能看到结果——target inbox 已接受,或在投递暂时不可用时保留为 queued。排队的消息已经安全存储,因此绝不能重发。
|
||||
|
||||
### 共享任务板
|
||||
|
||||
@@ -198,7 +198,7 @@ Peer 消息追加在 target 可复用历史前缀之后。冷恢复会先复用
|
||||
- **单进程、共享 checkout**——成员共享 cwd,修改立即可见;本包不提供 worktree、远端成员、merge 或文件锁。
|
||||
- **write scope 仅作提示**——Bash、formatter、代码生成器与直接外部写入可以绕过文件版本检查;Lead 必须协调 owner 并检查最终 diff。
|
||||
- **扁平且不可变的 roster**——只有 Lead 可以创建直接 teammate;不支持嵌套 Team、重命名、删除或名字复用。
|
||||
- **不会自动释放 owner**——idle、interrupt、进程退出与工作失败都不会释放任务 owner。
|
||||
- **不会自动释放 owner**——成员不活动、interrupt、进程退出与工作失败都不会释放任务 owner。
|
||||
- **mailbox 不保证跨进程 exactly-once**——不支持多个 harness 进程并发操作同一 Team。
|
||||
|
||||
<a id="dev-note"></a>
|
||||
|
||||
@@ -221,7 +221,7 @@ export class TeamService extends TypertRemoteService {
|
||||
* @param targetName - durable teammate name.
|
||||
* @returns the target status sampled before cancellation.
|
||||
*/
|
||||
interrupt(caller: Agent, targetName: string): { previousStatus: 'running' | 'idle' | 'inactive' } {
|
||||
interrupt(caller: Agent, targetName: string): { previousStatus: 'running' | 'inactive' } {
|
||||
return this.roster.interrupt(caller, targetName)
|
||||
}
|
||||
|
||||
|
||||
@@ -133,7 +133,7 @@ export class TeamRoster {
|
||||
id: root.id,
|
||||
name: 'lead',
|
||||
role: 'lead',
|
||||
status: root.status,
|
||||
status: availability(root),
|
||||
...root.options.model === undefined ? {} : { model: root.options.model },
|
||||
diagnostics: [],
|
||||
}]
|
||||
@@ -148,7 +148,7 @@ export class TeamRoster {
|
||||
? 'failed'
|
||||
: member.phase === 'provisioning'
|
||||
? 'provisioning'
|
||||
: live?.status ?? 'inactive',
|
||||
: availability(live),
|
||||
description: member.description,
|
||||
provider: member.provider,
|
||||
context: member.context,
|
||||
@@ -201,7 +201,7 @@ export class TeamRoster {
|
||||
* @param targetName - durable teammate name.
|
||||
* @returns the target status sampled before cancellation.
|
||||
*/
|
||||
interrupt(caller: Agent, targetName: string): { previousStatus: 'running' | 'idle' | 'inactive' } {
|
||||
interrupt(caller: Agent, targetName: string): { previousStatus: 'running' | 'inactive' } {
|
||||
const membership = this.membership(caller)
|
||||
if (membership.role !== 'lead') throw new TeamError('only the Team Lead can interrupt teammates', 'TEAM_LEAD_REQUIRED')
|
||||
const state = this.journal.state(membership.root)
|
||||
@@ -209,7 +209,7 @@ export class TeamRoster {
|
||||
if (target.id === membership.root.id) throw new TeamError('the Team Lead cannot interrupt itself', 'TEAM_INVALID_TARGET')
|
||||
const live = this.ctx.agents.get(target.id)
|
||||
if (live === undefined) return { previousStatus: 'inactive' }
|
||||
const previousStatus = live.status
|
||||
const previousStatus = availability(live)
|
||||
this.ctx.subagents.interrupt(target.id, { kind: 'ancestor', agent: caller })
|
||||
return { previousStatus }
|
||||
}
|
||||
@@ -440,7 +440,7 @@ export class TeamRoster {
|
||||
id: member.id,
|
||||
name: member.name,
|
||||
role: 'teammate',
|
||||
status: live?.status ?? 'inactive',
|
||||
status: availability(live),
|
||||
description: member.description,
|
||||
provider: member.provider,
|
||||
context: member.context,
|
||||
@@ -487,3 +487,8 @@ export class TeamRoster {
|
||||
return foldSubagentDescriptor(agent.session.snapshotEvents(agent.session.inheritedEventCount)) !== undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Turn availability is independent of whether the Agent is loaded. */
|
||||
function availability(agent: Agent | undefined): 'running' | 'inactive' {
|
||||
return agent?.status === 'running' ? 'running' : 'inactive'
|
||||
}
|
||||
@@ -59,7 +59,7 @@ export interface TeamMemberView {
|
||||
readonly id: SessionId
|
||||
readonly name: string
|
||||
readonly role: 'lead' | 'teammate'
|
||||
readonly status: 'running' | 'idle' | 'inactive' | 'provisioning' | 'failed'
|
||||
readonly status: 'running' | 'inactive' | 'provisioning' | 'failed'
|
||||
readonly description?: string
|
||||
readonly provider?: string
|
||||
readonly context?: 'fresh' | 'fork'
|
||||
|
||||
@@ -178,7 +178,7 @@ describe('Team identity and provisioning', () => {
|
||||
|
||||
expect(service.listMembers(lead)).toEqual([expect.objectContaining({
|
||||
name: 'lead',
|
||||
status: 'idle',
|
||||
status: 'inactive',
|
||||
diagnostics: [],
|
||||
})])
|
||||
const provisioning = {
|
||||
@@ -220,7 +220,7 @@ describe('Team identity and provisioning', () => {
|
||||
expect((await ctx.sessionPersistence.stat(forked.member.id))?.header.isSeeded).toBe(true)
|
||||
expect((await ctx.sessionPersistence.stat(fresh.member.id))?.header.isSeeded).toBe(false)
|
||||
expect(ctx.agentTeams.listMembers(lead).map(row => [row.name, row.context, row.status])).toEqual([
|
||||
['lead', undefined, 'idle'],
|
||||
['lead', undefined, 'inactive'],
|
||||
['fork-worker', 'fork', 'inactive'],
|
||||
['fresh-worker', 'fresh', 'inactive'],
|
||||
])
|
||||
@@ -878,7 +878,7 @@ describe('Team Remote API', () => {
|
||||
const { ctx, lead } = await setup([])
|
||||
expect(ctx.agentTeams.typertRemote).toMatchObject({ serviceKey: 'agentTeams', namespace: 'agentTeams' })
|
||||
expect(ctx.agentTeams.remoteView(lead)).toEqual({
|
||||
members: [expect.objectContaining({ name: 'lead', role: 'lead', status: 'idle' })],
|
||||
members: [expect.objectContaining({ name: 'lead', role: 'lead', status: 'inactive' })],
|
||||
tasks: [],
|
||||
})
|
||||
|
||||
|
||||
@@ -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/experimental/client-ui-agent-team/README.md
|
||||
README.md: f9609eba7cc156151b7e62bc0a298730f6f258a8
|
||||
README.zh.md: 4e47e162008f567b08debf18adb2423f81d18486
|
||||
README.md: 34e89cfe92db9656cf2fc948e154627c0d5f9407
|
||||
README.zh.md: e01cbbb534282fb2c6731579fc753954bcde314b
|
||||
@@ -29,7 +29,7 @@ Enable this package through [`@deepseek-ai/dsh-experimental-agent-team-profile`]
|
||||
|
||||
### Inspect and navigate the roster
|
||||
|
||||
Opening the panel calls `agentTeams/view`. Roster rows show durable names, runtime status, model, and diagnostics. Provisioning and running members use the shared ongoing loader, idle and inactive members use idle, and failed members use error. Selecting a healthy teammate refreshes the existing direct-child catalog and opens the ordinary `{ parentSessionId, childSessionId, mode: 'continuable' }` address. History and later human prompts continue through the stable addressed-subagent conversation path; this package adds no Team-specific address field.
|
||||
Opening the panel calls `agentTeams/view`. Roster rows show durable names, turn availability, model, and diagnostics. Provisioning and running members use the shared ongoing loader, inactive members use idle, and failed members use error. Selecting a healthy teammate refreshes the existing direct-child catalog and opens the ordinary `{ parentSessionId, childSessionId, mode: 'continuable' }` address. History and later human prompts continue through the stable addressed-subagent conversation path; this package adds no Team-specific address field.
|
||||
|
||||
### Manage the task board
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ kind: "package-reference"
|
||||
|
||||
### 检查并导航 roster
|
||||
|
||||
打开 panel 会调用 `agentTeams/view`。Roster row 展示持久 name、运行时 status、model 与 diagnostics。provisioning 和 running 成员使用共享 ongoing loading,idle 和 inactive 成员使用 idle 灰点,failed 成员使用 error 红点。选择健康 teammate 时,系统刷新既有直接 child catalog,并打开普通的 `{ parentSessionId, childSessionId, mode: 'continuable' }` address。History 与后续人类提示词继续使用稳定 addressed-subagent 会话路径;本包不会添加 Team 专用 address 字段。
|
||||
打开 panel 会调用 `agentTeams/view`。Roster row 展示持久 name、轮次可用状态、model 与 diagnostics。provisioning 和 running 成员使用共享 ongoing loading,inactive 成员使用 idle 灰点,failed 成员使用 error 红点。选择健康 teammate 时,系统刷新既有直接 child catalog,并打开普通的 `{ parentSessionId, childSessionId, mode: 'continuable' }` address。History 与后续人类提示词继续使用稳定 addressed-subagent 会话路径;本包不会添加 Team 专用 address 字段。
|
||||
|
||||
### 管理任务板
|
||||
|
||||
|
||||
@@ -89,7 +89,6 @@ function statusKey(status: TeamTask['status']): TeamKey {
|
||||
function memberStatusKey(status: TeamRosterMember['status']): TeamKey {
|
||||
switch (status) {
|
||||
case 'running': return 'memberStatus.running'
|
||||
case 'idle': return 'memberStatus.idle'
|
||||
case 'inactive': return 'memberStatus.inactive'
|
||||
case 'provisioning': return 'memberStatus.provisioning'
|
||||
case 'failed': return 'memberStatus.failed'
|
||||
@@ -100,7 +99,6 @@ function memberDotState(status: TeamRosterMember['status']): StateDotState {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
case 'provisioning': return 'ongoing'
|
||||
case 'idle':
|
||||
case 'inactive': return 'idle'
|
||||
case 'failed': return 'error'
|
||||
}
|
||||
|
||||
@@ -33,7 +33,6 @@ export const zh = {
|
||||
blocked: '被依赖阻塞',
|
||||
conflict: '任务状态已变化,已重新加载;请检查后重试。',
|
||||
'memberStatus.running': '运行中',
|
||||
'memberStatus.idle': '空闲',
|
||||
'memberStatus.inactive': '未运行',
|
||||
'memberStatus.provisioning': '准备中',
|
||||
'memberStatus.failed': '失败',
|
||||
@@ -75,7 +74,6 @@ export const en = {
|
||||
blocked: 'Blocked by dependencies',
|
||||
conflict: 'Task state changed and was reloaded. Review it before retrying.',
|
||||
'memberStatus.running': 'Running',
|
||||
'memberStatus.idle': 'Idle',
|
||||
'memberStatus.inactive': 'Inactive',
|
||||
'memberStatus.provisioning': 'Provisioning',
|
||||
'memberStatus.failed': 'Failed',
|
||||
|
||||
@@ -56,7 +56,7 @@ async function bench(options: {
|
||||
}
|
||||
const view = {
|
||||
members: [{
|
||||
id: SESSION, name: 'lead', role: 'lead' as const, status: 'idle' as const, diagnostics: [],
|
||||
id: SESSION, name: 'lead', role: 'lead' as const, status: 'inactive' as const, diagnostics: [],
|
||||
}], tasks: [task],
|
||||
}
|
||||
ctx.provide('remote.agentTeams', {
|
||||
@@ -179,7 +179,7 @@ describe('ui-team browser plugin', () => {
|
||||
id: SESSION,
|
||||
name: 'lead',
|
||||
role: 'lead',
|
||||
status: 'idle',
|
||||
status: 'inactive',
|
||||
diagnostics: [],
|
||||
})
|
||||
expect(b.navigation).toEqual([])
|
||||
|
||||
@@ -33,7 +33,7 @@ const task: TeamTask = {
|
||||
}
|
||||
const view: TeamView = {
|
||||
members: [
|
||||
{ id: SESSION, name: 'lead', role: 'lead', status: 'idle', model: 'model-a', diagnostics: [] },
|
||||
{ id: SESSION, name: 'lead', role: 'lead', status: 'inactive', model: 'model-a', diagnostics: [] },
|
||||
{
|
||||
id: 'worker-id' as SessionId,
|
||||
name: 'worker',
|
||||
@@ -95,7 +95,7 @@ describe('TeamAction', () => {
|
||||
const firstLoad = Promise.withResolvers<{ ok: true; value: TeamView }>()
|
||||
const nextView: TeamView = {
|
||||
...view,
|
||||
members: [{ id: nextSession, name: 'lead', role: 'lead', status: 'idle', diagnostics: [] }],
|
||||
members: [{ id: nextSession, name: 'lead', role: 'lead', status: 'inactive', diagnostics: [] }],
|
||||
tasks: [{ ...task, id: 'task-next' as TeamTaskId, subject: 'Next session task' }],
|
||||
}
|
||||
const load = vi.fn((sessionId: SessionId) => sessionId === 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 packages/experimental/tool-agent-team/README.md
|
||||
README.md: 225b9cb90173803ba7c777c5d643e04e47c6c602
|
||||
README.zh.md: 382c60ee0c92c26919d52ae24137913800fb5fa7
|
||||
README.md: bfa3fc7f466d4b7f405231de3de4cf648fc690de
|
||||
README.zh.md: 990e786fd69877af17cef339a696bb5e93d5925c
|
||||
@@ -57,11 +57,11 @@ Try it by asking the Lead model: "create a teammate named reviewer to check the
|
||||
The nine tools group into four capabilities:
|
||||
|
||||
- **Create a teammate** — `spawn_teammate` takes a name, a description, and the initial task; only the Lead can call it.
|
||||
- **Send messages** — `send_message` steers a running member at its nearest step boundary, starts an idle member, and cold-resumes an inactive teammate.
|
||||
- **See and wait** — `list_agents` shows the roster with live status; `wait_agent` waits for the next team change; `interrupt_agent` stops a teammate's current turn (Lead only).
|
||||
- **Send messages** — `send_message` steers a running member at its nearest step boundary, starts or resumes an inactive member.
|
||||
- **See and wait** — `list_agents` returns each member’s `target` and availability; `wait_agent` waits for the next team change; `interrupt_agent` stops a teammate's current turn (Lead only).
|
||||
- **Manage the task board** — `team_task_create`, `team_task_list`, `team_task_get`, and `team_task_update` add, browse, read, and update shared tasks.
|
||||
|
||||
Any member can message any other member and use the task board; only the Lead creates and interrupts teammates. Task updates keep the domain's owner and revision checks, so an outdated edit is rejected instead of overwriting newer work.
|
||||
Creation and listing results identify members by `target`, with no member Session ID. Use that value in message and interrupt calls or the task tools’ `owner` parameter; task `ownerName` uses the same value. `inactive` means no turn is executing, whether the member is loaded or must be resumed; it does not describe task completion or outcome. `provisioning` and `failed` describe member creation. Any member can message any other member and use the task board; only the Lead creates and interrupts teammates. Task updates keep the domain's owner and revision checks, so an outdated edit is rejected instead of overwriting newer work.
|
||||
|
||||
### What success and failure look like
|
||||
|
||||
|
||||
@@ -57,11 +57,11 @@ kind: "package-reference"
|
||||
九个工具分为四类能力:
|
||||
|
||||
- **创建 teammate**——`spawn_teammate` 接收名字、描述与初始任务;只有 Lead 可以调用它。
|
||||
- **发送消息**——`send_message` 在最近的步骤边界对运行中的成员进行 steering(中途引导)、启动空闲成员,并冷恢复非活动 teammate。
|
||||
- **查看与等待**——`list_agents` 显示带实时状态的 roster;`wait_agent` 等待下一次团队变化;`interrupt_agent` 停止 teammate 的当前轮次(仅限 Lead)。
|
||||
- **发送消息**——`send_message` 在最近的步骤边界对运行中的成员进行 steering(中途引导)、启动或恢复非活动成员。
|
||||
- **查看与等待**——`list_agents` 返回各成员的 `target` 与可用状态;`wait_agent` 等待下一次团队变化;`interrupt_agent` 停止 teammate 的当前轮次(仅限 Lead)。
|
||||
- **管理任务板**——`team_task_create`、`team_task_list`、`team_task_get` 与 `team_task_update` 添加、浏览、读取与更新共享任务。
|
||||
|
||||
任何成员都可以给任何其他成员发消息并使用任务板;只有 Lead 可以创建与中断 teammate。任务更新保留领域的 owner 与 revision 校验,因此过期的编辑会被拒绝,而不是覆盖更新的成果。
|
||||
创建和列表结果使用 `target` 标识成员,不包含成员 Session ID。可将该值用于消息和中断调用,或任务工具的 `owner` 参数;任务的 `ownerName` 使用相同值。`inactive` 表示没有轮次在执行,包括已加载和需要恢复的成员;它不表示任务完成或结果。`provisioning` 与 `failed` 描述成员创建状态。任何成员都可以给任何其他成员发消息并使用任务板;只有 Lead 可以创建与中断 teammate。任务更新保留领域的 owner 与 revision 校验,因此过期的编辑会被拒绝,而不是覆盖更新的成果。
|
||||
|
||||
### 成功与失败的表现
|
||||
|
||||
|
||||
Loaded 100 of 191 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user