From 522acc74324e6c529f4a9a18e2c6781d0b88b6be Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:48:56 +0800 Subject: [PATCH] test: update conversation assembly coverage and docs --- ...7-19-gui-web-client-architecture.i18n.yaml | 4 +- .../2026-07-19-gui-web-client-architecture.md | 27 +- ...26-07-19-gui-web-client-architecture.zh.md | 29 +- ...ient-tool-presentation-ownership.i18n.yaml | 4 +- ...8-08-client-tool-presentation-ownership.md | 101 +- ...8-client-tool-presentation-ownership.zh.md | 103 +- ...ranscript-log-ordered-projection.i18n.yaml | 4 +- ...0-web-transcript-log-ordered-projection.md | 2 +- ...eb-transcript-log-ordered-projection.zh.md | 2 +- apps/web/tests/built-boot.snapshot.ts | 1 - .../tests/chat-continuous-conversation.e2e.ts | 20 +- apps/web/tests/chat-long-interactions.e2e.ts | 49 +- apps/web/tests/search-card.snapshot.ts | 4 +- apps/web/tests/seeded-history.e2e.ts | 17 +- apps/web/tests/skill-user-invoke.e2e.ts | 2 + .../snapshots/code-mode-round/session.jsonl | 8 +- apps/web/tests/todo-row.snapshot.ts | 2 +- docs/config-catalog.md | 4 +- docs/event-producer-consumer.md | 2 +- docs/module-graph.md | 249 ++--- docs/persistence-catalog.md | 54 +- docs/subsystems/commands.i18n.yaml | 4 +- docs/subsystems/commands.md | 6 +- docs/subsystems/commands.zh.md | 6 +- docs/subsystems/compaction.i18n.yaml | 4 +- docs/subsystems/compaction.md | 11 +- docs/subsystems/compaction.zh.md | 11 +- docs/subsystems/tools.i18n.yaml | 4 +- docs/subsystems/tools.md | 9 +- docs/subsystems/tools.zh.md | 9 +- .../advanced-toolchain/session.jsonl | 4 +- .../snapshots/both-mode-turn/session.jsonl | 4 +- .../snapshots/code-mode-turn/session.jsonl | 8 +- .../code-mode-workspace-context/session.jsonl | 4 +- .../cordis-inspect-jsdoc/session.jsonl | 2 +- .../empty-response-retry/session.jsonl | 19 +- .../advanced-toolchain/session.jsonl | 4 +- .../stream-json.expected.jsonl | 4 +- .../compaction-recovery/session.jsonl | 20 +- .../stream-json.expected.jsonl | 8 +- .../provider-retry/stream-json.expected.jsonl | 19 +- packages/bash/bash-env/tests/bash-env.spec.ts | 1 + .../client/connection/src/client/fixture.ts | 48 +- packages/client/runtime/README.i18n.yaml | 4 +- packages/client/runtime/README.md | 10 +- packages/client/runtime/README.zh.md | 10 +- .../client/runtime/tests/client-apply.spec.ts | 29 +- .../tests/compact-checkpoint-pin.spec.ts | 52 - .../tests/conversation-assembler.spec.ts | 892 ++++++++++++++++++ .../tests/conversation-registry.spec.ts | 126 +++ packages/client/runtime/tests/event-script.ts | 4 +- .../client/runtime/tests/queue-store.spec.ts | 1 - packages/client/runtime/tests/session.spec.ts | 808 ++++------------ .../runtime/tests/transcript-adapter.spec.ts | 567 ----------- packages/client/test-runtime/src/fixtures.ts | 2 + packages/client/test-runtime/src/index.ts | 8 +- .../ui-conversation/tests/chat-apply.spec.tsx | 10 +- .../tests/chat-branch-tails.spec.tsx | 161 ++-- .../tests/chat-snapshot-fixture.ts | 295 ++++++ .../ui-conversation/tests/chat-stats.spec.tsx | 28 +- .../ui-conversation/tests/chat-view.spec.tsx | 487 ++++------ .../conversation-node-definitions.spec.ts | 720 ++++++++++++++ .../tests/gate-branch-tails.spec.tsx | 20 +- .../ui-conversation/tests/input-bar.spec.tsx | 5 +- .../tests/input-matrix.spec.tsx | 5 +- .../tests/input-scenarios.spec.tsx | 5 +- .../ui-conversation/tests/queue-dock.spec.tsx | 4 +- .../ui-conversation/tests/skeleton.spec.tsx | 5 +- .../tests/produced-files.spec.tsx | 284 ++++-- .../client/ui-slots/tests/surface.spec.ts | 11 + .../client/ui-slots/tests/type-chain.spec.tsx | 60 +- .../ui-tool/tests/assembly-surfaces.spec.tsx | 3 +- .../ui-tool/tests/chat-code-subcalls.spec.tsx | 10 +- .../client/ui-tool/tests/diff-card.spec.tsx | 7 +- .../client/ui-tool/tests/read-card.spec.tsx | 7 +- .../client/ui-tool/tests/search-card.spec.tsx | 7 +- .../ui-tool/tests/terminal-card.spec.tsx | 7 +- .../ui-tool/tests/tool-call-tree.spec.tsx | 15 +- .../ui-tool/tests/tool-details-render.tsx | 45 +- .../ui-tool/tests/toolview-slot.spec.tsx | 3 +- .../client/ui-tool/tests/web-card.spec.tsx | 7 +- .../web-react/tests/scoped-slots.spec.tsx | 77 +- .../tests/command-compact.spec.ts | 24 +- .../tests/loader-composition.spec.ts | 30 +- .../compact-basic/tests/compact-basic.spec.ts | 8 +- .../tests/manual-compact.spec.ts | 47 +- .../compact/compact/tests/compact.spec.ts | 15 +- .../compact/compact/tests/invariant.spec.ts | 194 +++- .../tests/workspace-context.spec.ts | 6 +- packages/core/tools/tests/code-mode.spec.ts | 5 +- packages/core/tools/tests/invariant.spec.ts | 105 ++- .../agent-spine-demo/tests/agent-core.spec.ts | 1 + .../llm/llm-retry/tests/invariant.spec.ts | 73 +- .../llm/llm-retry/tests/persistence.spec.ts | 2 + packages/llm/llm-retry/tests/retry.spec.ts | 2 + .../context-breakdown-projection.spec.ts | 2 + .../tests/token-usage-projection.spec.ts | 2 + .../tool-cordis/src/api-catalog.ts | 16 +- .../llm-replay/tests/llm-replay.spec.ts | 16 +- pnpm-lock.yaml | 18 +- scripts/gen-cordis-catalog.ts | 1 + 101 files changed, 3970 insertions(+), 2295 deletions(-) delete mode 100644 packages/client/runtime/tests/compact-checkpoint-pin.spec.ts create mode 100644 packages/client/runtime/tests/conversation-assembler.spec.ts create mode 100644 packages/client/runtime/tests/conversation-registry.spec.ts delete mode 100644 packages/client/runtime/tests/transcript-adapter.spec.ts create mode 100644 packages/client/ui-conversation/tests/chat-snapshot-fixture.ts create mode 100644 packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml index b7e331c934..f0cc648a10 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md -2026-07-19-gui-web-client-architecture.md: 7567ac3cb8b1a580e145f8f0da49b6ec371a35bd -2026-07-19-gui-web-client-architecture.zh.md: 9b682febf1bd1aad1a767b8b2db4e83dd993cf2f +2026-07-19-gui-web-client-architecture.md: 5e927405e9012b5a56d82da8fcf600ca4aa1bd5b +2026-07-19-gui-web-client-architecture.zh.md: 4294af099dce4e1345e625694a054661483f8d12 diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md index 7567ac3cb8..5e927405e9 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md @@ -44,35 +44,35 @@ Implementation homes: registry core and the props-share types in `packages/clien A service is a plugin's only API surface toward other plugins (UI components and injection faces are not APIs; a plugin nobody calls mounts no service — ui-trajectory is the minimal-plugin exemplar: no ctx service, only view-slot registrations). The roster: `ctx.connection` (api client + stream handles), `ctx.slots` (registry wrapper emitting `slots/changed`, render entry, renderer installation contract), `ctx.sessions` (list store, current-session state, scope tree), `ctx.loader`, `ctx.theme`, `ctx.i18n`, `ctx.layout` (cross-plugin view navigation), `ctx.conversation` (send/cancel/startSession). Viewing state that used to live in service stores (panel widths, selection, drafts) now lives in entry-declared stores per the [slot system standard](2026-07-22-slot-type-chain-implementation.md). -There is no registration model besides slots — the former view and tool rings both dissolved into it. Conversation views are entries of the `'conversation.view'` list slot ui-conversation declares, tab metadata rides the registration options (`id`/`order`/`label`), and per-view chrome lives inside the view components themselves. Tool presentation crosses one explicit package boundary: Runtime projects Code Dispatch topology into each root's recursive `subCalls`; ui-conversation places that ordered root into the single `'conversation.chat.tool'` seat without interpreting Tool names or topology; ui-tool renders the supplied tree and declares the keyed/session `'tool.call.toolview'` child slot. The key space stays runtime-open (SlotMap declares slots, never keys), and roots and descendants dispatch by `entryKey: toolName` with `GenericToolCard` as the fallback. Business packages register atomic views through `ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: '', inject? }, Row))`; the declaration is the load and reload dependency ([decision](2026-08-05-slot-declaration-injection.md)). ui-conversation separately delegates the selected call's details body through `'conversation.details.tool'`, so ui-tool's card models remain the single presentation owner without making conversation import Tool components. +There is no component registration model besides slots — the former view and tool rings both dissolved into it. Conversation views are entries of the `'conversation.view'` list slot ui-conversation declares, tab metadata rides the registration options (`id`/`order`/`label`), and per-view chrome lives inside the view components themselves. Final Chat business Nodes dispatch through the keyed/session `'conversation.chat.node'` slot; ui-tool owns its `tool-call` entry, recursively renders the supplied `subCalls`, and declares the keyed/session `'tool.call.toolview'` child slot. The key space stays runtime-open (SlotMap declares slots, never keys), and roots and descendants dispatch by `entryKey: toolName` with `GenericToolCard` as the fallback. Business packages register atomic views through `ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: '' }, Row))`; the declaration is the load and reload dependency ([decision](2026-08-05-slot-declaration-injection.md)). ui-conversation separately delegates the selected call's details body through `'conversation.details.tool'`, so ui-tool's card models remain the single presentation owner without making conversation import Tool components. The target-neutral event and view registries are data assembly seams rather than parallel component registries ([decision](2026-08-09-client-conversation-node-assembly.md)). **Scope addressing** mirrors the host's agent-scope idiom: services are root singletons whose methods take no sessionId — they read the caller's scope mark (`scopeOf(ctx)`). Inside a session scope, `ctx.conversation.send('hi', 'queue')` targets that session; cross-session calls re-target by switching ctx (`ctx.sessions.scope(id)!.conversation.send(...)`); calling a scoped method from root ctx throws. Client session scopes are minted like host agent scopes (a no-op plugin fiber + a scope-key extend), built lazily on first viewing and torn down only when the session is removed and unwatched — host-session death alone does not tear a scope (it freezes into a read-only viewport). ## The data object layer (`packages/client/runtime/src/client/sessions/`) -Frames enter, snapshots exit, the projection sits between — React-free (zero React imports, grep-assertable): +Frames enter, snapshots exit, the Conversation assembler sits between — React-free (zero React imports, grep-assertable): ``` -mux/host 帧(ConnectionController 泵入,sinks 注入) +mux/host frames (ConnectionController pump, injected sinks) │ ▼ SessionManager.handleMuxEnvelope / handleHostEnvelope - │ 带 sessionId 的帧只投已存在实例(审批/问答 requested 例外:进 pendingBuffers 缓冲) + │ session frames target existing instances (requested waits buffer) ▼ -Session.handleMuxEnvelope ──► events 窗口(seq 连续升序) - │ │ 定稿事件 │ chunk - │ ▼ ▼ - │ TranscriptAdapter PartialAccumulator - │ (→ nodes) (→ partial) +Session.handleMuxEnvelope ──► contiguous Event window + │ │ replace / prepend / append + │ ▼ + │ ConversationNodeAssembler + │ Definitions -> Contexts -> view builders ▼ Notifier 微任务合批 ──► ConversationSnapshot 缓存 ──uSES──► 组件 ``` - **Session** (session.ts): lazily built, resident — once created it keeps eating frames in the background, so switching away and back renders instantly. Operations: `prompt`/`cancel` (RPC passthrough; failures land in the snapshot's `promptError`), `open` (pull the tail history page, idempotent), `loadOlder` (upward paging, reentry-guarded), `resync` (reconnect = clear the window and rerun open). Subscription: `subscribe`/`getSnapshot` (always the cached reference) — `implements ObservableSnapshot`, with `useSelector = bindSnapshotSelector(this)` attached at construction, so a Session is directly a uSES source. Frame dispatch is one switch: `session/event` frames dedup by seq (the only dedup key), buffer while open is in flight, otherwise append + incremental projection; open/stitch merges the live buffer by seq and backfills once if `subscribed.lastSeq` outruns the window tail. -- **ConversationSnapshot** (conversation.ts): the immutable snapshot contract — `nodes` (the human transcript, log-ordered), `partial`, `runningCalls`, `pending`, `running`, `removed`, `openState`, `hasMore`, `promptError` and kin. **Reference discipline** (the premise of memo and uSES): the top-level object is fresh on every change; an unchanged nodes projection keeps the same array reference, while a changed flow returns a new array that reuses unchanged element references; unchanged substructures reuse the previous snapshot's references. +- **ConversationSnapshot** (conversation.ts): the top-level immutable snapshot contract. `chat` contains structural `order`, an identity-stable keyed Node reader, Turn/Step indexes, and the timeline; `nodes`, `partial`, `runningCalls`, `turnTimings`, and `turnEnds` are the compatibility slice for unmigrated Trajectory consumers. Pending interactions, queue, running, removal, open state, paging, and prompt errors remain Session facts. **Reference discipline** (the premise of memo and uSES): unchanged substructures and Node values keep their references; one business update replaces only the corresponding key's value unless its order or Location changes. React still subscribes to the Session as the sole observable source, while the framework-provided `useSession(selector)` isolates Node and Location aggregate updates. - **SessionManager** (manager.ts): instance cluster + frame entry + the session list. sessionId-bearing frames go only to existing instances (a mux broadcast must not instantiate every session); approval/question `requested` frames are the exception — they never land in history, so they buffer in `pendingBuffers` and replay on instantiation. - **Notifier** (notifier.ts): two channels chosen by change source. `markDirty()` (default; frame-driven changes always) batches per microtask — N changes, one notification, one re-render; the flush rebuilds the snapshot cache before notifying. `notifyNow()` (only direct echoes of user gestures) rebuilds and notifies in the same tick — controlled inputs roll the DOM back and jump the caret if their echo defers to a microtask. Frame-driven code using notifyNow collapses batching back to per-frame renders; banned. -- **TranscriptAdapter / PartialAccumulator**: the transcript is the append-origin surface projected in log order (`isAppendSurfaceEvent` from `@deepseek-ai/dsh-session/surface`) plus one marker per landed compaction checkpoint — never the model surface, which shadows replaced ranges and would erase conversation the reader already saw. Node order is seq-monotonic by construction, so there is no core `seq === index` assertion to satisfy and no degradation branch. Chunks contribute no node (O(1) skip): the accumulator folds StreamChunks into `AssistantBlock[]`, a delta swapping only that block's reference, and the finalizing message discards the accumulator in the same batch (no flicker on promotion). Cost model: one chunk = one string concatenation + a dirty mark; an unsubscribed Session under a frame storm costs only the mark. +- **ConversationNodeAssembler** (`runtime/src/client/conversation/`): the Session-owned incremental engine runs independently registered Definitions over raw events. `match(event)` selects `(kind, id)` without Context scans; start/update build Definition state; engine-computed Locations carry Turn/Step closure; backward Context reads record dependencies repaired by later prepends; `buildViewNode(target)` materializes only dirty Contexts. The Chat builder preserves structural order and per-key value identity, `useSession` selectors isolate consumption, and Assistant token publication coalesces to one animation frame. The [Conversation Node decision](2026-08-09-client-conversation-node-assembly.md) owns assembly, while [Tool presentation ownership](2026-08-08-client-tool-presentation-ownership.md) owns recursive Tool rendering. - **ConnectionController** (in `packages/client/connection`): opens the mux/host streams, pumps with for-await, reconnects with exponential backoff (500ms doubling to 10s, jitter, unlimited) behind a generation fence; sinks are injected one-way (the Controller does not know Session). Reconnect = rebuild: `onConnected` → list refresh + per-open-session resync. The object layer faces only `IApiClient`; Web carriage uses HTTP POST for the two client→server quadrants and [one WebSocket per logical stream](2026-08-04-websocket-downlink-carrier.md) for the two server→client quadrants, while the client class family remains the layering RFC's territory. ## The React face (`packages/client/web-react`) @@ -95,6 +95,7 @@ src/client/ contract/ shared slot and cross-domain types service.ts cross-domain orchestration skeleton/ conversation shell and details host + conversation-nodes/ independently registered business Definitions and Chat builder chat/ ordered conversation view input/ composer state machine queue/ queued-message presentation @@ -109,13 +110,13 @@ Domain implementation files never import a sibling domain; shared surfaces route - **A new UI feature** = a new plugin package: declare `dshClient` (+ `inject` topology) in package.json, write the browser half under `src/client/` (apply mounts services/stores and registers slots), keep the node half an empty apply unless there is host logic, build with the shared preset. Add the plugin to the host config; the manifest and loading follow automatically. - **A new slot**: see the [slot system standard RFC](2026-07-22-slot-type-chain-implementation.md) — merge the contract into `SlotMap`, declare it in the parent entry's `children`, render through the auto-injected `renderSlot` prop. Never export components globally. -- **Consuming a new frame type**: sessionId-bearing → a branch in Session's dispatch switch; host-level → the Manager routing table; if the UI needs it, a `ConversationSnapshot` field with the reference discipline kept. +- **Consuming a new frame type**: transport-only session frames → Session's dispatch switch; host-level frames → the Manager routing table; logged conversation business events → a Definition plus a keyed view renderer, without a Session business branch. - **Where does this state live**: business data (events, streaming, pending) → always the object layer; what the parent knows → owner props at the renderSlot site; private to one component (scroll, search text, expansion) → component state; shared across entries or surviving remounts (selection, drafts, panel widths) → an entry-declared store ([slot system standard](2026-07-22-slot-type-chain-implementation.md)). - **Notification channel**: frame-driven/async = `markDirty` batching; direct user-gesture echo whose controlled input needs the same tick = `notifyNow`. ## Consequences -Token streams no longer shake the render tree: a frame storm costs unsubscribed sessions one dirty bit and the subscribed view one batched re-render per microtask (raf-batched for frame-driven stores). UI features load, fail, and get disabled as independent plugins — one crashing slot entry blacks out one card, one failed bundle fails loud before the UI flips in. The accepted costs: the loader/module-table machinery is bespoke infrastructure the team owns end to end; the one-flip boot (no progressive rendering) trades first-paint granularity for assembly simplicity; and the dual type programs make "which aggregate sees this file" a question developers occasionally have to answer. +Token streams no longer shake the render tree: Assistant chunks update one business Context and publish its keyed Node at most once per animation frame; unrelated rows' selector results retain their references, so those rows do not re-render. UI features load, fail, and get disabled as independent plugins — one crashing slot entry blacks out one card, one failed bundle fails loud before the UI flips in. The accepted costs: the loader/module-table machinery is bespoke infrastructure the team owns end to end; the one-flip boot (no progressive rendering) trades first-paint granularity for assembly simplicity; and the dual type programs make "which aggregate sees this file" a question developers occasionally have to answer. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md index 9b682febf1..4294af099d 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md @@ -44,35 +44,35 @@ slot 体系有自己的 RFC——[slot 体系标准](2026-07-22-slot-type-chain- 服务是插件对其他插件的唯一 API 面(UI 组件与注入面都不是 API;无人调用的插件不挂服务——ui-trajectory 即最小插件样板:无 ctx 服务,只做视图坑注册)。名册:`ctx.connection`(api client + 流句柄)、`ctx.slots`(注册表包装层,发 `slots/changed`,渲染入口,渲染器安装约定)、`ctx.sessions`(列表 store、当前会话状态、scope 树)、`ctx.loader`、`ctx.theme`、`ctx.i18n`、`ctx.layout`(跨插件视图导航)、`ctx.conversation`(send/cancel/startSession)。过去住在服务 store 里的观看态(面板宽、选中、草稿)现按 [slot 体系标准](2026-07-22-slot-type-chain-implementation.md) 住 entry 声明的 store。 -slot 之外不存在第二种注册模型——原视图环与工具环都已溶解进来。会话视图即 ui-conversation 声明的 `'conversation.view'` list 坑的 entry,tab 元数据随注册 options(`id`/`order`/`label`)走,per-view chrome 住视图组件自身。Tool 展示跨越一条显式包边界:运行时把 Code Dispatch 拓扑投影进每个 root 递归的 `subCalls`;ui-conversation 把这个已排序 root 放进 single `'conversation.chat.tool'` seat,不解释 Tool 名称或拓扑;ui-tool 渲染传入的树,并声明 keyed/session 的 `'tool.call.toolview'` 子 slot。key 空间仍在运行时开放(SlotMap 声明 slot、从不声明 key),root 与任意深度的后代都按 `entryKey: toolName` 分发,以 `GenericToolCard` 兜底。业务包通过 `ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: '', inject? }, Row))` 注册原子视图;声明本身就是加载与重载依赖([决策](2026-08-05-slot-declaration-injection.md))。ui-conversation 还通过 `'conversation.details.tool'` 委托选中调用的详情正文,使 ui-tool 的 card model 保持为唯一展示所有者,同时避免 conversation 导入 Tool 组件。 +slot 之外不存在第二种组件注册模型——原视图环与工具环都已溶解进来。会话视图即 ui-conversation 声明的 `'conversation.view'` list 坑 entry,tab 元数据随注册 options(`id`/`order`/`label`)走,per-view chrome 住视图组件自身。最终 Chat 业务 Node 通过 keyed/session `'conversation.chat.node'` slot 分发;ui-tool 拥有其中的 `tool-call` entry,递归渲染传入的 `subCalls`,并声明 keyed/session `'tool.call.toolview'` 子 slot。key 空间仍在运行时开放(SlotMap 声明 slot、从不声明 key),root 与任意深度的后代都按 `entryKey: toolName` 分发,以 `GenericToolCard` 兜底。业务包通过 `ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: '' }, Row))` 注册原子视图;声明本身就是加载与重载依赖([决策](2026-08-05-slot-declaration-injection.md))。ui-conversation 还通过 `'conversation.details.tool'` 委托 selected call 的详情正文,使 ui-tool 的 card model 保持为唯一展示所有者,同时避免 conversation 导入 Tool 组件。与 target 无关的事件和 view registry 是数据组装缝,不是平行组件注册表([决策](2026-08-09-client-conversation-node-assembly.md))。 **scope 寻址**与 host 侧 agent scope 惯例同构:服务是 root 单例,方法不收 sessionId——它们读调用方 ctx 上的 scope 标(`scopeOf(ctx)`)。在会话 scope 内,`ctx.conversation.send('hi', 'queue')` 自动打到该会话;跨会话调用换 ctx 定向(`ctx.sessions.scope(id)!.conversation.send(...)`);从 root ctx 直接调 scoped 方法即 throw。client 会话 scope 的铸造方式与 host agent scope 相同(no-op 插件 fiber + scope 键 extend),首次观看时惰性建,只有会话被移除且无人观看才拆——仅 host 会话死亡不拆 scope(冻结为只读视窗)。 ## 数据对象层(`packages/client/runtime/src/client/sessions/`) -帧从这里进、快照从这里出、fold 坐在中间——React-free(零 React import,grep 可断言): +帧从这里进、快照从这里出、Conversation assembler 坐在中间——React-free(零 React import,grep 可断言): ``` -mux/host 帧(ConnectionController 泵入,sinks 注入) +mux/host frames (ConnectionController pump, injected sinks) │ ▼ SessionManager.handleMuxEnvelope / handleHostEnvelope - │ 带 sessionId 的帧只投已存在实例(审批/问答 requested 例外:进 pendingBuffers 缓冲) + │ session frames target existing instances (requested waits buffer) ▼ -Session.handleMuxEnvelope ──► events 窗口(seq 连续升序) - │ │ 定稿事件 │ chunk - │ ▼ ▼ - │ TranscriptAdapter PartialAccumulator - │ (→ nodes) (→ partial) +Session.handleMuxEnvelope ──► contiguous Event window + │ │ replace / prepend / append + │ ▼ + │ ConversationNodeAssembler + │ Definitions -> Contexts -> view builders ▼ Notifier 微任务合批 ──► ConversationSnapshot 缓存 ──uSES──► 组件 ``` - **Session**(session.ts):懒建、常驻——建成后在后台持续吃帧,切走切回秒显。操作面:`prompt`/`cancel`(RPC 透传;失败落进快照的 `promptError`)、`open`(拉尾页 history,幂等)、`loadOlder`(向上翻页,防重入)、`resync`(重连 = 清窗口重跑 open)。订阅面:`subscribe`/`getSnapshot`(恒返缓存引用)——`implements ObservableSnapshot`,构造时挂 `useSelector = bindSnapshotSelector(this)`,Session 本身就是 uSES 源。帧分发是一个 switch:`session/event` 帧按 seq 去重(唯一去重键),open 在途时缓冲,否则追加 + 增量投影;open/缝合按 seq 合并 live 缓冲并去重,`subscribed.lastSeq` 超出窗口尾则回补一次。 -- **ConversationSnapshot**(conversation.ts):不可变快照约定——`nodes`(人类对话记录,日志序)、`partial`、`runningCalls`、`pending`、`running`、`removed`、`openState`、`hasMore`、`promptError` 等。**引用纪律**(memo 与 uSES 的前提):顶层对象每变必新;未变化的 nodes 投影保持同一数组引用,消息流变化时返回新数组并复用未变化的元素引用;未变的子结构复用上一快照的引用。 +- **ConversationSnapshot**(conversation.ts):顶层不可变快照契约。`chat` 包含结构化 `order`、identity 稳定的 keyed Node reader、Turn/Step index 和 timeline;`nodes`、`partial`、`runningCalls`、`turnTimings`、`turnEnds` 是未迁移 Trajectory 消费者使用的兼容 slice。pending interaction、queue、running、removed、open state、paging 和 prompt error 仍是 Session 信息。**引用纪律**(memo 与 uSES 的前提):未变化的子结构和 Node value 保持引用;单个业务更新只替换对应 key 的 value,除非它的顺序或 Location 发生变化。React 仍只订阅 Session 这一处 observable source,并由框架提供的 `useSession(selector)` 隔离 Node 与 Location 聚合更新。 - **SessionManager**(manager.ts):实例簇 + 帧总入口 + 会话列表。带 sessionId 的帧只投已存在实例(mux 广播不得把每个会话都实例化);例外是审批/问答 `requested` 帧——它们不落 history、open 无法回补,故缓冲进 `pendingBuffers`,实例化时回放。 - **Notifier**(notifier.ts):两条通知通道,按变更来源取用。`markDirty()`(默认;帧驱动一律用它)按微任务合批——N 次变更、一次通知、一次重渲染;flush 先重建快照缓存再通知。`notifyNow()`(仅用户手势的直接回响)同 tick 重建并通知——受控输入的回响若延到微任务,DOM 会回滚、光标跳尾。帧驱动代码用 notifyNow 会让合批塌回逐帧渲染;禁。 -- **TranscriptAdapter / PartialAccumulator**:对话记录是按日志顺序投影的 append 来源 surface(`@deepseek-ai/dsh-session/surface` 的 `isAppendSurfaceEvent`),外加每次落地的压缩检查点一个标记——绝不用模型 surface,后者遮蔽被替换的范围,会抹掉读者已经看过的对话。节点顺序天然按 seq 单调,因此既无核心 `seq === index` 断言需要满足,也没有降级分支。分片不贡献任何节点(O(1) 跳过):累积器把 StreamChunk 折叠成 `AssistantBlock[]`,一次增量只换该块引用;定稿消息到达即在同一批内弃掉累积器(提升无闪烁)。成本模型:一个分片 = 一次字符串拼接 + 一个脏标记;帧风暴下未订阅的 Session 只花那个标记。 +- **ConversationNodeAssembler**(`runtime/src/client/conversation/`):Session 拥有的增量引擎在原始事件上运行各自独立注册的 Definition。`match(event)` 无须扫描 Context 即可选出 `(kind, id)`;start/update 构造 Definition state;引擎计算的 Location 携带 Turn/Step 关闭信息;向前查询 Context 时记录依赖,并由后续 prepend 修复;`buildViewNode(target)` 只物化 dirty Context。Chat builder 保留结构顺序和 per-key value identity,`useSession` selector 负责消费隔离,Assistant token 发布则合并到每个 animation frame 一次。[Conversation Node 决策](2026-08-09-client-conversation-node-assembly.md)拥有组装边界,[Tool 展示所有权](2026-08-08-client-tool-presentation-ownership.md)拥有 Tool 递归渲染。 - **ConnectionController**(在 `packages/client/connection`):开 mux/host 双流、for-await 泵入,代际围栏之内指数退避重连(500ms 翻倍至 10s 封顶、抖动、无限重试);sinks 单向注入(Controller 不认识 Session)。重连 = 重建:`onConnected` → 列表刷新 + 各已打开会话 resync。对象层只面向 `IApiClient`;Web 承载以 HTTP POST 载两个 client→server 象限、以[每逻辑流一条 WebSocket](2026-08-04-websocket-downlink-carrier.md)载两个 server→client 象限,客户端类族归分层 RFC 属地。 ## React 面(`packages/client/web-react`) @@ -95,6 +95,7 @@ src/client/ contract/ shared slot and cross-domain types service.ts cross-domain orchestration skeleton/ conversation shell and details host + conversation-nodes/ independently registered business Definitions and Chat builder chat/ ordered conversation view input/ composer state machine queue/ queued-message presentation @@ -108,14 +109,14 @@ src/client/ ## 怎么开发 - **新 UI 功能** = 新插件包:package.json 声明 `dshClient`(+ `inject` 拓扑),浏览器半边写在 `src/client/`(apply 挂服务/建 store、注册 slot),无 host 逻辑时 node 半边保持空 apply,用共享预设构建。把插件加进 host 配置;manifest 与装载随之自动跟上。 -- **新 slot**:见 [slot 体系标准 RFC](2026-07-22-slot-type-chain-implementation.md)——约定合并进 `SlotMap`,在父 entry 的 `children` 里声明,经自动注入的 `renderSlot` prop 渲染。永不全局导出组件。 -- **消费新帧类型**:带 sessionId → Session 分发 switch 加一个分支;host 级 → Manager 路由表;UI 需要时给 `ConversationSnapshot` 加字段并守住引用纪律。 +- **新 slot**:见 [slot 体系标准 RFC](2026-07-22-slot-type-chain-implementation.md)——契约合并进 `SlotMap`,在父 entry 的 `children` 里声明,经自动注入的 `renderSlot` prop 渲染。永不全局导出组件。 +- **消费新帧类型**:纯传输 session frame → Session 分发 switch;host 级 frame → Manager 路由表;已记录的 conversation 业务事件 → Definition 加 keyed view renderer,不增加 Session 业务分支。 - **状态住哪**:业务数据(事件、流式、待答)→ 永远对象层;父知道的 → renderSlot 现场的 owner props;单组件私有(滚动、搜索词、展开集)→ 组件状态;跨 entry 共享或跨重挂载存活(选中、草稿、面板宽)→ entry 声明的 store([slot 体系标准](2026-07-22-slot-type-chain-implementation.md))。 - **通知通道**:帧驱动/异步 = `markDirty` 合批;受控输入需要同 tick 的用户手势直接回响 = `notifyNow`。 ## Consequences -token 流不再震荡渲染树:帧风暴对未订阅会话只花一个脏位,对被订阅视图每微任务一次合批重渲染(帧驱动 store 走 raf 合批)。UI 功能以独立插件的粒度装载、失败、停用——一个崩溃的 slot 注册项只黑一张卡,一个装载失败的 bundle 在 UI 切入之前大声报错。接受的代价:loader/模块表机件是团队端到端自持的定制基建;一次成型启动(无渐进渲染)用首屏粒度换装配简单;双类型 program 让「这个文件归哪个聚合」成为开发者偶尔要回答的问题。 +token 流不再震荡渲染树:Assistant chunk 只更新一个业务 Context,每 animation frame 最多发布一次对应 keyed Node;无关行的 selector 结果保持原引用,因此不会重渲染。UI 功能以独立插件的粒度装载、失败、停用——一个崩溃的 slot 注册项只黑一张卡,一个装载失败的 bundle 在 UI 切入之前大声报错。接受的代价:loader/模块表机件是团队端到端自持的定制基建;一次成型启动(无渐进渲染)用首屏粒度换装配简单;双类型 program 让「这个文件归哪个聚合」成为开发者偶尔要回答的问题。 ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.i18n.yaml index 12e27bf58c..5cb66115e8 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md -2026-08-08-client-tool-presentation-ownership.md: f12a116c34064186b6be1496e1071025221817c4 -2026-08-08-client-tool-presentation-ownership.zh.md: f5f7d71f1e05b27855d29acc223b340c8d89f1d8 +2026-08-08-client-tool-presentation-ownership.md: 3feefc3cfbe538024b8610394b9f170c423556e8 +2026-08-08-client-tool-presentation-ownership.zh.md: 031975fdebf5f72a19396f16b086a3abdcbca172 diff --git a/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md b/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md index f12a116c34..3feefc3cfb 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md +++ b/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md @@ -6,100 +6,63 @@ English | [中文](2026-08-08-client-tool-presentation-ownership.zh.md) ## Problem -The Client Runtime already projects Tool calls into a stable lifecycle: it pairs call/result events by `callId`, preserves running and settled forms, and indexes Code Dispatch children by their root call. The chat view nevertheless owned the entire presentation stack. It placed root calls in ChatFlow, composed each root with its subcalls, dispatched every atomic call by Tool name, carried the generic fallback and card models, registered first-party Tool views, and reused those models in the details panel. +Client Runtime already paired Tool call/result events by `callId` and could recover root/subcall topology from Code Dispatch events, but the Chat view also owned Tool placement in the conversation flow, recursive call-tree composition, Tool-name dispatch, the Generic fallback, card models, and first-party Tool renderers. `ui-conversation` therefore had to interpret every business Tool name; moving individual React components did not change that ownership, and removing atomic renderers left subcalls without a presentation owner. -That ownership made `ui-conversation` interpret business Tool names and made subcalls an orphaned concern if an atomic Tool view moved elsewhere. A business package such as `ui-skill` could register a row, but it still depended on conversation's Tool-specific composition contract. Adding Tool-specific Session projection would duplicate a data model the Runtime already owns, while moving only individual React components would leave the composition and model coupling in place. +Tool presentation needed an independent owner without adding a second registry beside Client slots or making every atomic Tool renderer understand root/subcall structure. ## Decision -Tool is a first-class Client UI concept with one presentation owner, `@deepseek-ai/dsh-client-ui-tool`. Runtime normalizes Code Dispatch into recursive `ToolCallBlock` values: every root or child owns its next level through `subCalls`, and `ConversationSnapshot` exposes no separate parent-to-children map. +Tool is a first-class Client UI presentation concept. `@deepseek-ai/dsh-client-ui-tool` owns root/subcall composition, atomic renderer dispatch by wire Tool name, the Generic fallback, card models, and details output. Business plugins register only their atomic Tool renderers and do not modify conversation or Session. -“First-class concept” describes UI ownership only; it adds no Runtime data kind. `ConversationNode` remains the transcript projection, `ChatFlowItem` remains the render unit produced when conversation sorts and groups nodes, `ToolCallBlock` remains the standard data for one call, and `ToolCallTree` only composes root/subcall presentation within Tool. Command continues to render through the separate `'conversation.chat.commandview'` seat and does not become Tool. +Conversation data assembly follows the later [Conversation business-node decision](2026-08-09-client-conversation-node-assembly.md). The `ui-conversation` Tool Definition pairs root call/result Session Events, folds Code Dispatch edges into recursive `ToolCallBlock.subCalls`, and emits one stable `tool-call` Chat Node. This data responsibility handles only official Tool identity and topology; it does not interpret presentation for concrete Tool names. -`ui-conversation` owns ordered placement. `deriveChatFlow()` still decides where a settled Tool group appears, and `ChatView` still appends running calls, maintains scroll anchors and selection, and supplies host actions. For each root call it renders the single/session `'conversation.chat.tool'` seat with the root block, selected call id, session cwd, and open-file/inspect callbacks. It does not read Code Dispatch children, branch on Tool names, or import Tool-specific views and card models. +[`ChatView`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx) only places generic [`ChatNodeSeat`](../../../../packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx) entries in Chat snapshot `order`. A Seat dispatches `'conversation.chat.node'` by `node.kind`; [`ui-tool`](../../../../packages/client/ui-tool/src/client/apply.ts) registers the `tool-call` entry, and [`ToolCallTree`](../../../../packages/client/ui-tool/src/client/tool/ToolCallTree.tsx) recursively traverses the root block. Every root or child level dispatches through the same keyed/session `'tool.call.toolview'` child slot with `entryKey: toolName`, falling back to `GenericToolCard` when no registration exists. -`ui-tool` occupies that whole-Tool seat. `ToolCallTree` recursively walks the root block's `subCalls` and routes every level through one keyed/session `'tool.call.toolview'` child slot using `entryKey: toolName`. An absent business registration renders `GenericToolCard`. It neither reads Session nor maintains a second call topology. +A business Tool plugin receives one standard `ToolCallBlock`, identity, workspace cwd, and host actions; it does not read Session, Context, or the Conversation assembler. Skill remains an ordinary Tool and uses the same keyed-slot registration path as other business Tools. -Business plugins register only atomic views against `'tool.call.toolview'`. Their owner payload is the standard Tool call block plus identity, cwd, and host actions; it carries no Session projector or conversation service. Skill remains an ordinary Tool and `ui-skill` registers the `skill` key through this slot. Existing first-party views live in `ui-tool` until a business package has a reason to own one independently. - -The details panel is a second Tool presentation site but not a call-tree owner. `ui-conversation` delegates its selected output body through the single/session `'conversation.details.tool'` seat; `ui-tool` renders the card-aware output and the seat fallback preserves raw result text when the plugin is absent. Card models therefore have one production owner without introducing a reverse implementation import. - -The Runtime remains the authority for Tool lifecycle and call topology. Code Dispatch is an official top-level concept because it changes parent/child identity; a private `ToolCallTree` shares one fold between live and history paths and projects its index into standard recursive call blocks. Ordinary Tool business differences stay at the keyed presentation contract, and this package boundary adds no Tool projector/fold registry. +The details panel is a second Tool presentation point, not the call-tree owner. `ui-conversation` locates the selected call and delegates its output body through `'conversation.details.tool'`; `ui-tool` reuses the card model, while the conversation fallback retains raw result text when the plugin is absent. ## Runtime and render path -This boundary starts at the Client's `ConversationSnapshot`; the full render path is: - ```text -ConversationSnapshot.nodes - -> deriveChatFlow() - -> settled tool-group positions ----+ - | -ConversationSnapshot.runningCalls | - -> ChatView flow tail ---------------+-> ToolSeat - -> conversation.chat.tool - -> ToolCallTree - -> root ToolCallBlock - `- subCalls[] (recursive) - -> tool.call.toolview(entryKey = toolName) - |- registered atomic view - `- GenericToolCard fallback +Session Event window + -> Tool Definition -> tool-call Chat Node (recursive ToolCallBlock) + -> ChatView -> ChatNodeSeat(entryKey = tool-call) + -> ToolCallTree + -> root/subCalls[] recursion + -> tool.call.toolview(entryKey = toolName) + |- registered atomic view + `- GenericToolCard fallback ``` -Runtime's [`ToolCallTree`](../../../../packages/client/runtime/src/client/sessions/tool-call-tree.ts) privately indexes child lifecycles by parent callId and is shared by the live [`Session.buildSnapshot()`](../../../../packages/client/runtime/src/client/sessions/session.ts) and historical [`projectConversationHistory()`](../../../../packages/client/runtime/src/client/session-history/history-fold.ts) paths. It recursively projects children onto root `ToolCallBlock` values and copies only the owning ancestor path when a child changes. Unchanged siblings, other roots, and snapshot references with no Tool-topology change stay stable so React selectors and memoization can skip unrelated updates. Tool UI consumes this unified tree without repeating call/result pairing, historical replay, or cache indexing. +## Ownership boundary -[`ChatView`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx) reruns [`deriveChatFlow()`](../../../../packages/client/ui-conversation/src/client/chat/chat-flow.ts) only when the `nodes` reference changes. It groups consecutive settled Tool results into a `tool-group`, while running root calls append at the flow tail. Both paths ultimately enter the same `ToolSeat`, so settled and running forms share the whole-Tool seat. Selection is passed only to the root containing that call, and `ToolCallTree` then renders recursively within that local tree. - -## Code and responsibility boundaries - -| Owner | Primary code | Owns | Explicitly does not own | -|---|---|---|---| -| Client Runtime | [`Session`](../../../../packages/client/runtime/src/client/sessions/session.ts), [`ToolCallTree`](../../../../packages/client/runtime/src/client/sessions/tool-call-tree.ts), [`history-fold.ts`](../../../../packages/client/runtime/src/client/session-history/history-fold.ts) | call/result pairing, running/settled lifecycle, recursive parent/child tree, snapshot structural sharing | Business views selected by Tool name | -| `ui-conversation` | [`chat-flow.ts`](../../../../packages/client/ui-conversation/src/client/chat/chat-flow.ts), [`ChatView.tsx`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx), [`slots.ts`](../../../../packages/client/ui-conversation/src/client/contract/slots.ts) | ChatFlow order, settled groups, running tail, scroll anchors, selection and host actions, whole-Tool seat declaration | subcall composition, `toolName` dispatch, Generic fallback, Tool card models | -| `ui-tool` | [`apply.ts`](../../../../packages/client/ui-tool/src/client/apply.ts), [`ToolCallTree.tsx`](../../../../packages/client/ui-tool/src/client/tool/ToolCallTree.tsx), [`slots.ts`](../../../../packages/client/ui-tool/src/client/contract/slots.ts) | root/subcall composition, atomic keyed dispatch, Generic fallback, Tool card models and built-in Tool views | ChatFlow ordering, Session Event fold | -| Business Tool plugins | [`ui-skill` registration example](../../../../packages/client/ui-skill/src/client/index.ts) | Atomic views for one or more wire Tool names | root/subcall placement and lifecycle pairing | -| Details path | [`DetailsPanel.tsx`](../../../../packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx), [`ToolDetails.tsx`](../../../../packages/client/ui-tool/src/client/tool/ToolDetails.tsx) | selected-call lookup, card-aware output, and raw fallback | chat call-tree composition | - -## Slot and owner contract - -A slot declaration also constrains render ownership. The conversation chat entry declares `'conversation.chat.tool'` through `children`, so only `ChatView` places the whole-Tool seat. When `ui-tool` registers that seat, its `children` declares `'tool.call.toolview'`, so only `ToolCallTree` renders the atomic Tool seat. Business plugins register keyed entries only; they neither participate in root/subcall composition nor establish a registry parallel to slots. - -The whole seat's `ToolTreeOwnerProps` carries the root `callId`, `toolName`, `ToolCallBlock`, `selectedCallId`, session `cwd`, `openFile(path)`, and `inspectCall(callId)`. `ToolCallTree` converts either a root or child into the same `ToolCallOwnerProps` and narrows inspect to a callback for that call. The atomic owner carries no `ReactNode`, Cordis `Context`, Session service, or projector; a business view consumes only one standard call block and host actions. - -The seat filler also preserves the conversation DOM contract on every root and child wrapper: `data-chat-anchor-key="call:"`, `data-chat-call-id`, and `data-selected="true"` on the selected call. `ChatView` consumes the anchor key to restore prepend/paging position; the Tool owner emits it because it alone composes child wrappers. - -Business plugins use one registration shape: - -```text -ctx.slots.inject('tool.call.toolview', () => - ctx.slots.register({ - name: 'tool.call.toolview', - key: '', - }, BusinessToolRow)) -``` - -`ui-tool`'s [`apply()`](../../../../packages/client/ui-tool/src/client/apply.ts) registers the whole-Tool renderer, details renderer, and existing built-in atomic views. An existing independent business package can move only its keyed registration, as `ui-skill` does, without changing `ui-conversation` or Session. - -## Details path - -[`DetailsPanel`](../../../../packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx) locates the selected call recursively in `nodes` and `runningCalls` through their `subCalls`, and it owns input arguments, empty states, and panel lifecycle. It passes only `{ block, cwd }` to `'conversation.details.tool'`; [`ToolDetails`](../../../../packages/client/ui-tool/src/client/tool/ToolDetails.tsx) reuses Tool card models to render the output. When `ui-tool` is absent, a settled call falls back to raw result text and a running call shows conversation's running fallback, so details never imports the Tool implementation in reverse. +| Owner | Owns | Explicitly does not own | +|---|---|---| +| Client Runtime Conversation engine | Context identity, Location, history replay, view Node publication | Tool event meaning, call tree, Tool renderer | +| `ui-conversation` Tool Definition | call/result pairing, Code Dispatch topology, running/settled/interrupted `ToolCallBlock`, Chat ordering anchor | Tool-name dispatch, card models, recursive React structure | +| `ui-conversation` Chat view | keyed Node order, scroll anchors, selection, and host actions | Tool lifecycle, subcall composition, atomic Tool renderers | +| `ui-tool` | root/subcall recursive rendering, atomic keyed dispatch, fallback, card models, and details output | Session Event fold, Chat ordering | +| Business Tool plugin | atomic renderers for one or more wire Tool names | root/subcall placement, lifecycle pairing, Session projectors | ## Verification -Test ownership follows production ownership. `ui-conversation` tests install a local whole-Tool seat probe and assert only ChatFlow placement, owner payload, and host contracts such as selection, open-file, and inspect; they do not import `ui-tool` production code or test helpers. `ui-tool` tests mount a real conversation host and verify root/subcall composition, keyed dispatch, generic fallback, concrete Tool UI, and plugin lifecycle. +`ui-conversation` tests pin the Tool Definition's call/result pairing, Code Dispatch, interruption, and running-to-settled keyed identity without importing production `ui-tool` renderers. `ui-tool` tests mount the real conversation host and pin root/subcall recursion, keyed dispatch, Generic fallback, selection, details, and concrete Tool cards. Assembled Web tests cover the path with both plugins loaded. ## Alternatives considered -**Keep atomic Tool slots under every conversation view.** Rejected: each view would have to reproduce root/subcall composition, and a Tool registration would be isolated by view even though its business meaning is Tool-wide. A whole-Tool seat preserves view-owned placement while giving the call tree one owner. This supersedes the per-view placement selected by the earlier [toolview dissolution](2026-07-23-toolview-dissolution.md), while retaining its keyed-slot and no-parallel-registry decisions. +**Keep atomic Tool slots under every conversation view.** Rejected: every view would repeat root/subcall composition and Tool registration would split by view. The whole Tool renderer occupies one business Node slot in a view, while Tool owns atomic dispatch. -**Move only the Tool React components and card models.** Rejected: `ChatView` would still own Tool-name dispatch and Code Dispatch composition, so the dependency would change file paths without changing responsibility. +**Move only Tool React components and card models.** Rejected: conversation would still dispatch by Tool name and recurse through subcalls, so file movement would not create an ownership boundary. -**Add business-specific Session projectors or folds.** Rejected: ordinary Tool views consume the standard call block already reconstructed by Runtime. A second registry would create two authorities for call identity and historical replay. Only a feature that changes logged topology or lifecycle earns a Runtime-level extension. +**Create a Tool-specific projector/fold registry.** Rejected: the general Conversation assembler already owns Context identity, history windows, and publication. A second Runtime registry would create two lifecycle authorities. -**Make each atomic Tool view render its own subcalls recursively.** Rejected: the atomic registrant receives one Tool call and should not know whether it is a root or child. Recursive root/child composition belongs centrally to `ui-tool`'s `ToolCallTree`. +**Let every atomic Tool renderer recurse through its subcalls.** Rejected: an atomic registrant should understand one Tool call without knowing whether it is a root or child. `ToolCallTree` handles recursive structure once. -**Import `ui-tool` components directly from `ui-conversation`.** Rejected: it would reverse the intended feature direction and make Tool presentation mandatory. Declared slots retain lifecycle ownership, fallback behavior, and independent plugin loading. +**Let `ui-conversation` import `ui-tool` components directly.** Rejected: this would reverse the feature dependency and make Tool presentation mandatory. Slots preserve independent loading, lifecycle, and fallback behavior. ## Consequences -`ui-conversation` becomes independent of Tool-name business presentation while retaining ChatFlow, selection, and host interaction responsibilities. Root calls and subcalls cannot drift onto different dispatch paths, and business packages can own atomic Tool presentation without Session changes. The cost is one new Client package and two cross-package slot contracts; `ui-tool` also deliberately depends on conversation's declared seats and locale namespace. The assembled Web bundle therefore mounts `ui-tool`; omitting it leaves chat Tool seats empty while the details seat keeps its raw-result fallback, without changing Session reconstruction. +`ui-conversation` no longer depends on presentation for concrete Tool names, and root and subcalls cannot drift onto different dispatch paths. Business packages can independently own atomic Tool renderers; if `ui-tool` is absent, Conversation data assembly remains valid, Chat Nodes use the generic fallback, and details retain raw results. + +The cost is an explicit dependency from `ui-tool` on the business Node slot and locale namespace declared by conversation, plus one Tool-specific child slot. Tool Definition remains in `ui-conversation` because this change does not split packages; it can later move through the Conversation registry seam without changing the presentation ownership recorded here. diff --git a/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.zh.md b/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.zh.md index f5f7d71f1e..031975fdeb 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.zh.md @@ -6,100 +6,63 @@ Status: implemented ## Problem -Client Runtime 已经把 Tool 调用投影成稳定的生命周期:它按 `callId` 配对 call/result 事件,保留 running 与 settled 两种形态,并按 root call 索引 Code Dispatch 子调用。但 chat view 仍拥有整套展示链路:它在 ChatFlow 中放置 root call,把每个 root 与 subcall 编排在一起,按 Tool 名称分发每个原子调用,携带通用 fallback 与 card model,注册第一方 Tool view,并在 details panel 中复用这些 model。 +Client Runtime 已经按 `callId` 配对 Tool call/result,并能从 Code Dispatch 事件恢复 root/subcall 拓扑,但 Chat view 曾同时拥有 Tool 在对话流中的放置、递归调用树、按 Tool 名称分发、Generic fallback、card model 和第一方 Tool renderer。`ui-conversation` 因此必须解释每个业务 Tool 名称;只移动单个 React 组件不会改变这层所有权,移走原子 renderer 后 subcall 也会成为无主逻辑。 -这种所有权迫使 `ui-conversation` 解释业务 Tool 名称;一旦原子 Tool view 被迁走,subcall 就会成为无主的遗留关注点。`ui-skill` 等业务包虽能注册一行视图,仍依赖 conversation 的 Tool 专属编排约定。增加 Tool 专属 Session projection 会重复 Runtime 已拥有的数据模型,而只移动单个 React 组件则会把编排与 model 耦合留在原地。 +Tool presentation 需要一个独立所有者,同时不能建立与 Client slot 平行的第二套注册表,也不能让每个原子 Tool renderer 自己理解 root/subcall 结构。 ## Decision -Tool 成为 Client UI 的一级概念,并由 `@deepseek-ai/dsh-client-ui-tool` 统一拥有展示。Runtime 将 Code Dispatch 规范化为递归 `ToolCallBlock`:每个 root 或 child 通过自己的 `subCalls` 拥有下一层调用,`ConversationSnapshot` 不再公开单独的 parent-to-children map。 +Tool 是 Client UI 的一级展示概念,由 `@deepseek-ai/dsh-client-ui-tool` 统一拥有 root/subcall 编排、按 wire Tool name 的原子 renderer 分发、Generic fallback、card model 和 details output。业务插件只注册自己的原子 Tool renderer,不修改 conversation 或 Session。 -这里的“一级概念”只描述 UI 所有权,不增加 Runtime 数据种类。`ConversationNode` 仍是 transcript projection,`ChatFlowItem` 仍是 conversation 对节点进行排序与分组后得到的渲染单元,`ToolCallBlock` 仍是单次调用的标准数据,而 `ToolCallTree` 只负责 Tool 内部的 root/subcall 展示编排。Command 继续通过独立的 `'conversation.chat.commandview'` 席位渲染,不并入 Tool。 +Conversation 数据组装遵循后续的 [Conversation 业务节点决策](2026-08-09-client-conversation-node-assembly.md)。`ui-conversation` 的 Tool Definition 从 Session Event 配对 root call/result,把 Code Dispatch edge fold 成递归 `ToolCallBlock.subCalls`,并生成一个稳定的 `tool-call` Chat Node;这里的数据职责只处理官方 Tool identity 和拓扑,不解释具体 Tool 名称的展示。 -`ui-conversation` 拥有有序放置。`deriveChatFlow()` 仍决定 settled Tool group 在哪里出现,`ChatView` 仍追加 running call、维护滚动 anchor 与 selection,并提供宿主动作。对于每个 root call,它使用 root block、selected call id、session cwd 以及 open-file/inspect 回调渲染 single/session 的 `'conversation.chat.tool'` 席位。它不读取 Code Dispatch child、不按 Tool 名称分支,也不导入 Tool 专属 view 或 card model。 +[`ChatView`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx) 只按 Chat snapshot 的 `order` 放置通用 [`ChatNodeSeat`](../../../../packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx)。Seat 以 `node.kind` 分发 `'conversation.chat.node'`;[`ui-tool`](../../../../packages/client/ui-tool/src/client/apply.ts) 注册 `tool-call` entry,并由 [`ToolCallTree`](../../../../packages/client/ui-tool/src/client/tool/ToolCallTree.tsx) 递归遍历 root block。每一层 root 或 child 都通过同一个 keyed/session `'tool.call.toolview'` 子 slot 以 `entryKey: toolName` 分发,缺少注册时渲染 `GenericToolCard`。 -`ui-tool` 占据这个整体 Tool 席位。`ToolCallTree` 直接递归遍历 root block 的 `subCalls`,并让每一层调用都通过同一个 keyed/session 的 `'tool.call.toolview'` 子 slot,以 `entryKey: toolName` 分发。业务未注册时渲染 `GenericToolCard`。它不读取 Session,也不维护第二份调用拓扑。 +业务 Tool 插件接收一个标准 `ToolCallBlock`、identity、workspace cwd 和宿主动作,不读取 Session、Context 或 Conversation assembler。Skill 仍是普通 Tool;它和其他业务 Tool 使用同一 keyed slot 注册路径。 -业务插件只对 `'tool.call.toolview'` 注册原子 view。其 owner payload 是标准 Tool call block 加 identity、cwd 与宿主动作,不携带 Session projector 或 conversation service。Skill 仍是普通 Tool,`ui-skill` 通过该 slot 注册 `skill` key。现有第一方 view 暂留在 `ui-tool`,直到某个业务包确有理由独立拥有它。 +details panel 是第二个 Tool 展示点,但不是调用树所有者。`ui-conversation` 定位 selected call,并通过 `'conversation.details.tool'` 委托 output body;`ui-tool` 复用 card model,插件缺席时 conversation fallback 保留 raw result text。 -details panel 是第二个 Tool 展示点,但不是调用树所有者。`ui-conversation` 通过 single/session 的 `'conversation.details.tool'` 席位委托 selected output body;`ui-tool` 渲染能够识别 card 的输出,插件缺席时由席位 fallback 保留 raw result text。因此 card model 只有一个生产代码所有者,也不需要引入反向实现依赖。 - -Runtime 仍是 Tool 生命周期与调用拓扑的权威。Code Dispatch 作为官方顶级概念改变 parent/child identity;私有 `ToolCallTree` 对 live 与 history 共用同一套 fold,并把索引投影成标准递归 call block。普通 Tool 业务差异停留在 keyed 展示约定,这个包边界不会增加 Tool projector/fold registry。 - -## Runtime 与渲染链路 - -这项边界从 Client 的 `ConversationSnapshot` 开始,完整渲染链路如下: +## Runtime and render path ```text -ConversationSnapshot.nodes - -> deriveChatFlow() - -> settled tool-group positions ----+ - | -ConversationSnapshot.runningCalls | - -> ChatView flow tail ---------------+-> ToolSeat - -> conversation.chat.tool - -> ToolCallTree - -> root ToolCallBlock - `- subCalls[] (recursive) - -> tool.call.toolview(entryKey = toolName) - |- registered atomic view - `- GenericToolCard fallback +Session Event window + -> Tool Definition -> tool-call Chat Node (recursive ToolCallBlock) + -> ChatView -> ChatNodeSeat(entryKey = tool-call) + -> ToolCallTree + -> root/subCalls[] recursion + -> tool.call.toolview(entryKey = toolName) + |- registered atomic view + `- GenericToolCard fallback ``` -Runtime 的 [`ToolCallTree`](../../../../packages/client/runtime/src/client/sessions/tool-call-tree.ts) 私下按 parent callId 索引 child lifecycle,并供 Live [`Session.buildSnapshot()`](../../../../packages/client/runtime/src/client/sessions/session.ts) 与历史 [`projectConversationHistory()`](../../../../packages/client/runtime/src/client/session-history/history-fold.ts) 共用。它把 children 递归投影到 root `ToolCallBlock`,child 变化时只复制所属祖先路径;未变化的 sibling、其他 root,以及没有 Tool 拓扑变化的 snapshot 引用保持稳定,供 React selector 与 memo 跳过无关更新。Tool UI 直接消费这两个路径统一后的树,不重复 call/result 配对、历史 replay 或缓存索引。 +## Ownership boundary -[`ChatView`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx) 只在 `nodes` 引用变化时重新执行 [`deriveChatFlow()`](../../../../packages/client/ui-conversation/src/client/chat/chat-flow.ts),把连续 settled Tool result 合为 `tool-group`;running root call 则追加在 flow tail。两条路径最终都进入同一个 `ToolSeat`,因此 settled/running 形态共享整体 Tool 席位。selection 只传给包含该 call 的 root,`ToolCallTree` 再沿该 root 的局部树递归渲染。 - -## 代码与职责边界 - -| 所有者 | 主要代码 | 拥有的责任 | 明确不拥有 | -|---|---|---|---| -| Client Runtime | [`Session`](../../../../packages/client/runtime/src/client/sessions/session.ts)、[`ToolCallTree`](../../../../packages/client/runtime/src/client/sessions/tool-call-tree.ts)、[`history-fold.ts`](../../../../packages/client/runtime/src/client/session-history/history-fold.ts) | call/result 配对、running/settled 生命周期、递归 parent/child 树、snapshot 结构共享 | Tool 名称对应的业务视图 | -| `ui-conversation` | [`chat-flow.ts`](../../../../packages/client/ui-conversation/src/client/chat/chat-flow.ts)、[`ChatView.tsx`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx)、[`slots.ts`](../../../../packages/client/ui-conversation/src/client/contract/slots.ts) | ChatFlow 顺序、settled group、running tail、scroll anchor、selection 与宿主动作、整体 Tool 席位声明 | subcall 组合、按 `toolName` 分发、Generic fallback、Tool card model | -| `ui-tool` | [`apply.ts`](../../../../packages/client/ui-tool/src/client/apply.ts)、[`ToolCallTree.tsx`](../../../../packages/client/ui-tool/src/client/tool/ToolCallTree.tsx)、[`slots.ts`](../../../../packages/client/ui-tool/src/client/contract/slots.ts) | root/subcall 组合、原子 keyed dispatch、Generic fallback、Tool card model 与内置 Tool view | ChatFlow 排序、Session Event fold | -| 业务 Tool 插件 | [`ui-skill` 注册例](../../../../packages/client/ui-skill/src/client/index.ts) | 一个或多个 wire Tool name 的原子 view | root/subcall 位置与生命周期配对 | -| details 路径 | [`DetailsPanel.tsx`](../../../../packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx)、[`ToolDetails.tsx`](../../../../packages/client/ui-tool/src/client/tool/ToolDetails.tsx) | selected call 定位、card-aware output 与 raw fallback | chat 调用树编排 | - -## Slot 与 owner 约定 - -slot 声明同时限定渲染所有权。conversation chat entry 通过 `children` 声明 `'conversation.chat.tool'`,因此只有 `ChatView` 放置整体 Tool 席位;`ui-tool` 注册该席位时再通过 `children` 声明 `'tool.call.toolview'`,因此只有 `ToolCallTree` 渲染原子 Tool 席位。业务插件只注册 keyed entry,不参与 root/subcall 编排,也不建立与 slot 平行的 registry。 - -整体席位的 `ToolTreeOwnerProps` 携带 root `callId`、`toolName`、`ToolCallBlock`、`selectedCallId`、session `cwd`、`openFile(path)` 与 `inspectCall(callId)`。`ToolCallTree` 把 root 或 child 转成相同的 `ToolCallOwnerProps`,并把 inspect 收窄成当前 call 的回调。原子 owner 不携带 `ReactNode`、Cordis `Context`、Session service 或 projector;业务 view 只消费一个标准调用块和宿主动作。 - -席位填充方还要在每个 root 和 child wrapper 上保留 conversation DOM 约定:`data-chat-anchor-key="call:"`、`data-chat-call-id`,以及 selected call 上的 `data-selected="true"`。`ChatView` 用 anchor key 恢复 prepend/paging 位置;child wrapper 由 Tool owner 独自编排,因此这些属性也由它输出。 - -业务插件遵循同一个注册形态: - -```text -ctx.slots.inject('tool.call.toolview', () => - ctx.slots.register({ - name: 'tool.call.toolview', - key: '', - }, BusinessToolRow)) -``` - -`ui-tool` 的 [`apply()`](../../../../packages/client/ui-tool/src/client/apply.ts) 注册整体 Tool renderer、details renderer 与现有内置原子 view;已有独立业务包可以像 `ui-skill` 一样只迁走自己的 keyed 注册,无需改动 `ui-conversation` 或 Session。 - -## Details 路径 - -[`DetailsPanel`](../../../../packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx) 在 `nodes` 与 `runningCalls` 的递归 `subCalls` 中定位 selected call,并拥有 input 参数、空态和面板生命周期。它只把 `{ block, cwd }` 交给 `'conversation.details.tool'`;[`ToolDetails`](../../../../packages/client/ui-tool/src/client/tool/ToolDetails.tsx) 复用 Tool card model 渲染 output。`ui-tool` 缺席时,settled call 回退为 raw result text,running call 显示 conversation 的 running fallback,因此 details 不反向导入 Tool 实现。 +| 所有者 | 拥有 | 明确不拥有 | +|---|---|---| +| Client Runtime Conversation engine | Context identity、Location、历史重放、view Node 发布 | Tool 事件含义、调用树、Tool renderer | +| `ui-conversation` Tool Definition | call/result 配对、Code Dispatch 拓扑、running/settled/interrupted `ToolCallBlock`、Chat 排序 anchor | Tool 名称分发、card model、递归 React 结构 | +| `ui-conversation` Chat view | keyed Node 顺序、scroll anchor、selection 与宿主动作 | Tool lifecycle、subcall 组合、原子 Tool renderer | +| `ui-tool` | root/subcall 递归渲染、原子 keyed dispatch、fallback、card model 与 details output | Session Event fold、Chat 排序 | +| 业务 Tool 插件 | 一个或多个 wire Tool name 的原子 renderer | root/subcall 位置、生命周期配对、Session projector | ## Verification -测试归属跟随生产所有权。`ui-conversation` 的测试安装本地整体 Tool 席位替身,只验证 ChatFlow 位置、owner payload 与 selection、open-file、inspect 等宿主约定;它们不导入 `ui-tool` 的生产实现或测试 helper。`ui-tool` 的测试挂载真实 conversation 宿主,验证 root/subcall 编排、keyed dispatch、generic fallback、具体 Tool UI 与插件生命周期。 +`ui-conversation` 测试固定 Tool Definition 的 call/result、Code Dispatch、interruption 和 running-to-settled keyed identity,不导入 `ui-tool` 的生产 renderer。`ui-tool` 测试挂载真实 conversation 宿主,固定 root/subcall 递归、keyed dispatch、Generic fallback、selection、details 和具体 Tool card。组装后的 Web 测试覆盖两侧插件共同装载的路径。 ## Alternatives considered -**在每个 conversation view 下保留原子 Tool slot。** 拒绝:每个 view 都必须重复 root/subcall 编排,而且 Tool 注册会按 view 隔离,即使它的业务语义本应是 Tool 级。整体 Tool 席位保留 view 对放置位置的所有权,同时让调用树只有一个所有者。它取代了早期 [toolview 溶解](2026-07-23-toolview-dissolution.md)所选择的 per-view 放置方式,但保留 keyed slot 与不设平行 registry 的决策。 +**在每个 conversation view 下保留原子 Tool slot。** 拒绝:每个 view 都要重复 root/subcall 编排,Tool 注册也会按 view 分裂。整个 Tool renderer 占据 view 的一个业务 Node slot,原子分发由 Tool 自己拥有。 -**只移动 Tool React 组件与 card model。** 拒绝:`ChatView` 仍会拥有 Tool 名称分发与 Code Dispatch 编排,只是改变文件路径,没有改变责任。 +**只移动 Tool React 组件与 card model。** 拒绝:conversation 仍会按 Tool 名称分发并递归 subcall,文件位置变化不产生所有权边界。 -**增加业务专属 Session projector 或 fold。** 拒绝:普通 Tool view 消费 Runtime 已重建的标准 call block。第二套 registry 会为 call identity 与历史 replay 建立两个权威。只有会改变日志拓扑或生命周期的能力才应获得 Runtime 级扩展。 +**为 Tool 建立专属 projector/fold registry。** 拒绝:通用 Conversation assembler 已拥有 Context identity、历史窗口和发布;第二个 Runtime registry 会制造生命周期的双重权威。 -**让每个原子 Tool view 递归渲染自己的 subcall。** 拒绝:原子注册方只接收一个 Tool call,不应知道自己是 root 还是 child。递归 root/child 编排统一归 `ui-tool` 的 `ToolCallTree`。 +**让每个原子 Tool renderer 递归自己的 subcall。** 拒绝:原子注册方只应理解一个 Tool call,不应知道自己是 root 还是 child。递归结构统一由 `ToolCallTree` 处理。 -**让 `ui-conversation` 直接导入 `ui-tool` 组件。** 拒绝:这会反转预期的 feature 依赖方向,并把 Tool 展示变成必选能力。声明式 slot 能保留生命周期所有权、fallback 行为与独立插件装载。 +**让 `ui-conversation` 直接导入 `ui-tool` 组件。** 拒绝:这会反转 feature 依赖并把 Tool 展示变成必选能力。slot 保留独立装载、生命周期和 fallback。 ## Consequences -`ui-conversation` 不再依赖 Tool 名称对应的业务展示,同时保留 ChatFlow、selection 与宿主交互责任。root call 与 subcall 不会漂移到不同分发路径,业务包无需修改 Session 即可拥有原子 Tool 展示。代价是新增一个 Client package 与两份跨包 slot 约定;`ui-tool` 也明确依赖 conversation 声明的席位与 locale namespace。因此组装后的 Web bundle 会挂载 `ui-tool`;省略该插件时,chat Tool 席位为空,details 席位则保留 raw-result fallback,且 Session 重建不受影响。 +`ui-conversation` 不再依赖 Tool 名称对应的业务展示,root 与 subcall 也不会漂移到不同分发路径。业务包可以独立拥有原子 Tool renderer;`ui-tool` 缺席时,Conversation 数据组装仍然成立,Chat Node 使用通用 fallback,details 保留 raw result。 + +代价是 `ui-tool` 明确依赖 conversation 声明的业务 Node slot 和 locale namespace,并拥有一个 Tool 专属子 slot。Tool Definition 暂时位于 `ui-conversation`,因为本次没有拆 package;它以后可以沿 Conversation registry seam 移动,而不会改变本 Note 规定的展示所有权。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml index 231502dcb7..1ea341085d 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md -2026-07-30-web-transcript-log-ordered-projection.md: e261ccba597f27149a9527f99b93454dfe5fe5fb -2026-07-30-web-transcript-log-ordered-projection.zh.md: 5684a8157bb7e1b23cd868fa13ab201ffd07a274 +2026-07-30-web-transcript-log-ordered-projection.md: feefc951118c8511237c931a13070e1f7db1fd16 +2026-07-30-web-transcript-log-ordered-projection.zh.md: 711e57b29742171d1861f25e63019b64356b4cad diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md index e261ccba59..feefc95111 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md @@ -39,7 +39,7 @@ const COMPACT_PLUGIN: typeof COMPACT_CHECKPOINT_SOURCE.plugin = 'compact' Renaming the Service Definition's plugin id is now a compile error in the client: `TS2322: Type '"compact"' is not assignable to type '"compaction"'`. The import must stay **type-only** — a value import of any `@deepseek-ai` package that is neither a platform module nor an inline-safe wire layer is rejected by the client purity gate (`packages/client/tsdown.client.ts`), whose own message records that type-only imports are erased and never reach it. A type-only leaf import needs both a `tsconfig.base.json` `paths` entry and `{"path": "../../compact/compact"}` in `packages/client/runtime/tsconfig.json` `references`: composite `rootDir` rules apply to erased imports as well, and without the reference the diagnostic is `TS6059`/`TS6307`. -`packages/client/runtime/tests/compact-checkpoint-pin.spec.ts` stays as the behavioral half, driving the adapter with a checkpoint built from the canonical **value**. The test value-imports the cordis-free `@deepseek-ai/dsh-compact/checkpoint` leaf and deliberately never loads the compact package root or the host-side `Context` merges reachable through it. +`packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts` is the behavioral half, driving the compaction Definition with checkpoint and provenance records and proving that an older page can fill missing summary data. The Definition's type-only leaf import keeps the client isolated from the compact package root and the host-side `Context` merges reachable through it. The divergence from the terminal is therefore narrow: both frontends recognize a checkpoint from the same declaration — the terminal value-imports `isCompactCheckpointSource` host-side, where no gate applies, and the client pins the type. diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md index 5684a8157b..711e57b297 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md @@ -39,7 +39,7 @@ const COMPACT_PLUGIN: typeof COMPACT_CHECKPOINT_SOURCE.plugin = 'compact' 重命名 Service Definition 的插件 id 现在会在客户端产生编译错误:`TS2322: Type '"compact"' is not assignable to type '"compaction"'`。该导入必须保持**仅类型**——任何既非平台模块又非 inline-safe wire 层的 `@deepseek-ai` 包值导入都会被客户端纯度门禁(`packages/client/tsdown.client.ts`)拒绝,而它自己的报错信息就记录着仅类型导入会被擦除、永不抵达该门禁。仅类型的叶子导入同时需要 `tsconfig.base.json` 的一条 `paths` 条目和 `packages/client/runtime/tsconfig.json` `references` 中的 `{"path": "../../compact/compact"}`:composite 的 `rootDir` 规则同样适用于被擦除的导入,缺少该引用时的诊断是 `TS6059`/`TS6307`。 -`packages/client/runtime/tests/compact-checkpoint-pin.spec.ts` 作为行为侧的另一半保留,用由权威**值**构造的检查点驱动适配器。该测试以值导入方式从不含 cordis 的 `@deepseek-ai/dsh-compact/checkpoint` 叶子路径取得该值,并刻意不加载 compact 包根或经由它可达的宿主侧 `Context` 合并。 +`packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts` 是行为侧的另一半,用检查点与溯源记录驱动压缩 Definition,并证明后续加载的旧分页可以补齐缺失的摘要数据。Definition 仅类型导入该叶子路径,使客户端继续与 compact 包根及经由它可达的宿主侧 `Context` 合并隔离。 因此与终端的分歧很窄:两个前端都从同一份声明识别检查点——终端在宿主侧值导入 `isCompactCheckpointSource`(那里不适用任何门禁),客户端钉住类型。 diff --git a/apps/web/tests/built-boot.snapshot.ts b/apps/web/tests/built-boot.snapshot.ts index 80661a0b13..be719bda15 100644 --- a/apps/web/tests/built-boot.snapshot.ts +++ b/apps/web/tests/built-boot.snapshot.ts @@ -38,7 +38,6 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn await waitFor(() => { expect(document.querySelector('[data-sample="bash"]')).not.toBeNull() }, { timeout: 10_000 }) - // Resolve the resident approval so the ordinary composer bar (which owns // ContextMeter) resumes without replacing the session shell. This minimal // boot graph intentionally does not mount the separate question UI plugin. diff --git a/apps/web/tests/chat-continuous-conversation.e2e.ts b/apps/web/tests/chat-continuous-conversation.e2e.ts index 61701c1cdc..a7b659dab2 100644 --- a/apps/web/tests/chat-continuous-conversation.e2e.ts +++ b/apps/web/tests/chat-continuous-conversation.e2e.ts @@ -12,6 +12,7 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { CallId, type StreamChunk } from '@deepseek-ai/dsh-llm' import type { ReplayEntry, ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import { conversationContextKey } from '@deepseek-ai/dsh-client-runtime/client' import { launchWebScaffold, watchConsole, @@ -160,6 +161,14 @@ function toolResultText(event: Extract): .join('') } +function messageKey(event: SessionEvent<'user/message'>): string { + return conversationContextKey('input-message', String(event.data.id)) +} + +function assistantKey(event: SessionEvent<'assistant/message'>): string { + return conversationContextKey('assistant-step', `${event.data.turn}:${event.data.step}`) +} + describe('web e2e: continuous conversation grown through the composer', () => { let browser: Browser let page: Page @@ -222,6 +231,11 @@ describe('web e2e: continuous conversation grown through the composer', () => { const settled = scaffold.whenTurnSettled(60_000) await page.getByRole('button', { name: 'Send message', exact: true }).click() await page.getByText(spec.userMarker, { exact: false }).last().waitFor({ timeout: 15_000 }) + await expect.poll(() => sessionEvents.slice(eventStart).some(event => ( + event.type === 'user/message' + && event.data.source.kind === 'user' + && userText(event).includes(spec.userMarker) + )), { timeout: 15_000 }).toBe(true) const echoedUser = sessionEvents.slice(eventStart).find( (event): event is SessionEvent<'user/message'> => ( event.type === 'user/message' @@ -230,7 +244,7 @@ describe('web e2e: continuous conversation grown through the composer', () => { ), ) if (echoedUser === undefined) throw new Error(`turn ${String(spec.index)} has no user echo event`) - const userRow = page.locator(`[data-chat-anchor-key="node:${String(echoedUser.seq)}"]`) + const userRow = page.locator(`[data-chat-anchor-key="${messageKey(echoedUser)}"]`) await expect.poll(() => userRow.count(), { timeout: 10_000 }).toBe(1) expect(await userRow.getAttribute('data-chat-flow-kind')).toBe('user') expect(await userRow.textContent()).toContain(spec.userMarker) @@ -274,9 +288,9 @@ describe('web e2e: continuous conversation grown through the composer', () => { expect(turnEnds[0]?.data).toEqual({ turn: spec.index, reason: { kind: 'completed' } }) expect(chunks).toHaveLength(spec.deltas.length + (spec.callId === undefined ? 4 : 9)) - const assistantRow = page.locator(`[data-chat-anchor-key="node:${String(finalAssistants[0]!.seq)}"]`) + const assistantRow = page.locator(`[data-chat-anchor-key="${assistantKey(finalAssistants[0]!)}"]`) await expect.poll(() => assistantRow.count(), { timeout: 10_000 }).toBe(1) - expect(await assistantRow.getAttribute('data-chat-flow-kind')).toBe('assistant') + expect(await assistantRow.getAttribute('data-chat-flow-kind')).toBe('assistant-step') expect(await assistantRow.textContent()).toContain(spec.doneMarker) const calls = turnEvents.filter((event): event is SessionEvent<'tool/call'> => event.type === 'tool/call') diff --git a/apps/web/tests/chat-long-interactions.e2e.ts b/apps/web/tests/chat-long-interactions.e2e.ts index b9bebbd897..cd1fcb2fa7 100644 --- a/apps/web/tests/chat-long-interactions.e2e.ts +++ b/apps/web/tests/chat-long-interactions.e2e.ts @@ -10,6 +10,7 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import type { StreamChunk } from '@deepseek-ai/dsh-llm' import type { ReplayEntry, ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay' import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' +import { conversationContextKey } from '@deepseek-ai/dsh-client-runtime/client' import { createChatScrollFixture } from './chat-scroll-fixture.ts' import { launchWebScaffold, @@ -115,6 +116,18 @@ function requiredEvent( return event } +function messageKey(event: SessionEvent<'user/message'>): string { + return conversationContextKey('input-message', String(event.data.id)) +} + +function assistantKey(event: SessionEvent<'assistant/message'>): string { + return conversationContextKey('assistant-step', `${event.data.turn}:${event.data.step}`) +} + +function turnTailKey(turn: number): string { + return conversationContextKey('turn-tail', String(turn)) +} + describe('web e2e: long Chat interaction contract', () => { let browser: Browser let page: Page @@ -176,8 +189,10 @@ describe('web e2e: long Chat interaction contract', () => { const expectedUserText = textContent(branchUserEvent.data.content) await wheelUntilMounted(page, `[data-chat-call-id="${TARGET_CALL_2}"]`, -1_100) - const toolUserRow = page.locator(`[data-chat-anchor-key="node:${String(toolUserEvent.seq)}"]`) - const toolAssistantRow = page.locator(`[data-chat-anchor-key="node:${String(toolAssistantEvent.seq)}"]`) + const toolUserKey = messageKey(toolUserEvent) + const toolAssistantKey = assistantKey(toolAssistantEvent) + const toolUserRow = page.locator(`[data-chat-anchor-key="${toolUserKey}"]`) + const toolAssistantRow = page.locator(`[data-chat-anchor-key="${toolAssistantKey}"]`) const call1 = page.locator(`[data-chat-call-id="${TARGET_CALL_1}"]`) const call2 = page.locator(`[data-chat-call-id="${TARGET_CALL_2}"]`) @@ -186,28 +201,27 @@ describe('web e2e: long Chat interaction contract', () => { expect(await call1.count()).toBe(1) expect(await call2.count()).toBe(1) expect(await toolUserRow.getAttribute('data-chat-flow-kind')).toBe('user') - expect(await toolAssistantRow.getAttribute('data-chat-flow-kind')).toBe('assistant') + expect(await toolAssistantRow.getAttribute('data-chat-flow-kind')).toBe('assistant-step') expect(await toolUserRow.textContent()).toContain(toolUserMarker) expect(await toolAssistantRow.textContent()).toContain(toolAssistantMarker) expect(await call1.textContent()).toContain(toolMarker1) expect(await call2.textContent()).toContain(toolMarker2) const expectedOrder = [ - `node:${String(toolUserEvent.seq)}`, - `call:${TARGET_CALL_1}`, - `call:${TARGET_CALL_2}`, - `node:${String(toolAssistantEvent.seq)}`, + toolUserKey, + conversationContextKey('tool-call', TARGET_CALL_1), + conversationContextKey('tool-call', TARGET_CALL_2), + toolAssistantKey, ] const actualOrder = await page.locator('[data-chat-anchor-key]').evaluateAll((rows, keys) => ( rows.map(row => (row as HTMLElement).dataset.chatAnchorKey) .filter((key): key is string => key !== undefined && keys.includes(key)) ), expectedOrder) expect(actualOrder).toEqual(expectedOrder) - const groupKeys = await Promise.all([call1, call2].map(row => row.evaluate(element => ( - element.closest('[data-chat-flow-kind="tool-group"]')?.dataset.chatFlowKey ?? null + const toolKinds = await Promise.all([call1, call2].map(row => row.evaluate(element => ( + element.closest('[data-chat-flow-kind]')?.dataset.chatFlowKind ?? null )))) - expect(groupKeys[0]).not.toBeNull() - expect(groupKeys[1]).toBe(groupKeys[0]) + expect(toolKinds).toEqual(['tool-call', 'tool-call']) const summary1 = call1.locator('[data-sample="bash"]') const summary2 = call2.locator('[data-sample="bash"]') @@ -219,9 +233,12 @@ describe('web e2e: long Chat interaction contract', () => { expect(await summary1.getAttribute('aria-expanded')).toBe('false') await call2.getByText(`${toolMarker2} output line 12`, { exact: true }).waitFor({ timeout: 10_000 }) - await wheelUntilMounted(page, `[data-chat-anchor-key="node:${String(branchUserEvent.seq)}"]`, -1_100) - const userRow = page.locator(`[data-chat-anchor-key="node:${String(branchUserEvent.seq)}"]`) - const assistantRow = page.locator(`[data-chat-anchor-key="node:${String(branchAssistantEvent.seq)}"]`) + const branchUserKey = messageKey(branchUserEvent) + const branchAssistantKey = assistantKey(branchAssistantEvent) + await wheelUntilMounted(page, `[data-chat-anchor-key="${branchUserKey}"]`, -1_100) + const userRow = page.locator(`[data-chat-anchor-key="${branchUserKey}"]`) + const assistantRow = page.locator(`[data-chat-anchor-key="${branchAssistantKey}"]`) + const turnTailRow = page.locator(`[data-chat-anchor-key="${turnTailKey(BRANCH_TURN)}"]`) expect(await userRow.textContent()).toContain(branchUserMarker) expect(await assistantRow.textContent()).toContain(branchAssistantMarker) await page.context().grantPermissions(['clipboard-read', 'clipboard-write']) @@ -230,8 +247,8 @@ describe('web e2e: long Chat interaction contract', () => { await expect.poll(() => page.evaluate(() => navigator.clipboard.readText()), { timeout: 5_000 }) .toBe(expectedUserText) - await assistantRow.hover() - await assistantRow.getByRole('button', { name: 'Branch into a new conversation', exact: true }).click() + await turnTailRow.hover() + await turnTailRow.getByRole('button', { name: 'Branch into a new conversation', exact: true }).click() await expect.poll( () => scaffold.ctx.agents.list().find(agent => agent.session.header.parentSession === SessionId(SESSION_ID)), { timeout: 15_000 }, diff --git a/apps/web/tests/search-card.snapshot.ts b/apps/web/tests/search-card.snapshot.ts index 8e6322c4af..8ac775be1d 100644 --- a/apps/web/tests/search-card.snapshot.ts +++ b/apps/web/tests/search-card.snapshot.ts @@ -2,7 +2,7 @@ // Assembled search-card snapshot: boots the real built workspace client bundles // through AppWebEntry's ModuleLoader path against the keyless // FixtureApiClient transport (no API key, no model round), opens the fixture -// session, and pins the search card the `grep` turn (fixture turn 66) renders in +// session, and pins the search card the `grep` turn (fixture turn 67) renders in // the assembled application. The built-boot smoke proves the graph boots but // carries no behavior assertions by contract; this is the assembled-output check // that a broken SearchRow registration or a dropped card would fail — the @@ -51,7 +51,7 @@ describe('assembled search card', () => { const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) fireEvent.click(await within(tree).findByText('Fixture 历史会话')) // Wait for chat content to reach the fixture's later turns (the bash sample - // is turn 65, the grep card turn 66). + // is turn 66, the grep card turn 67). await waitFor(() => { expect(document.querySelector('[data-sample="bash"]')).not.toBeNull() }, { timeout: 10_000 }) diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 072a322fb6..8a9ee26866 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -91,11 +91,15 @@ function withCompaction(raw: string, meter: TokenMeterService): string { return taken } const commandId = 'cmd-seeded-manual-compact' + const compactionId = 'compact-seeded-manual-compact' at({ type: 'command/run', data: { commandId, name: 'compact', args: '', source: { kind: 'user' } }, }) - const startSeq = at({ type: 'compact/start', data: { turn: null } }) + const startSeq = at({ + type: 'compact/start', + data: { compactionId, sourceCommandId: commandId, turn: null }, + }) // Load-bearing exactness: the projections subtract this count verbatim, so // it must equal what the host's fold prices for these nodes. The estimator // prices message CONTENT only, so a minimal wrapper per storage shape is @@ -124,6 +128,8 @@ function withCompaction(raw: string, meter: TokenMeterService): string { const summarySeq = at({ type: 'compact/summary', data: { + compactionId, + sourceCommandId: commandId, summary: [{ type: 'text', text: '## Cold resume compact summary\n\n- The exact summary remains available.', @@ -142,12 +148,17 @@ function withCompaction(raw: string, meter: TokenMeterService): string { type: 'text', text: 'Model-only compact checkpoint.', }], - source: { kind: 'plugin', plugin: 'compact' }, + source: { + kind: 'plugin', plugin: 'compact', compactionId, sourceCommandId: commandId, + }, }, surfaceOp: { op: 'replace', start: first, end: last }, sourceEventSeqs: [startSeq, summarySeq, ...surfaceSeqs], }) - at({ type: 'compact/end', data: { turn: null } }) + at({ + type: 'compact/end', + data: { compactionId, sourceCommandId: commandId, turn: null }, + }) at({ type: 'command/done', data: { diff --git a/apps/web/tests/skill-user-invoke.e2e.ts b/apps/web/tests/skill-user-invoke.e2e.ts index 302aa062b0..8555ef7499 100644 --- a/apps/web/tests/skill-user-invoke.e2e.ts +++ b/apps/web/tests/skill-user-invoke.e2e.ts @@ -109,6 +109,7 @@ describe.skipIf(MODE === 'record')('web e2e: user-explicit skill invocation thro { timeout: 10_000 }, ).toBe(1) + const settled = scaffold.whenTurnSettled() await composer.fill(`/${SKILL_NAME} ${ARGS_TEXT}`) await composer.press('Enter') @@ -135,6 +136,7 @@ describe.skipIf(MODE === 'record')('web e2e: user-explicit skill invocation thro // The injection started a turn; the replay adapter answers it. await page.getByText('USER_INVOKE_REPLY', { exact: false }).first().waitFor({ timeout: 20_000 }) + await settled const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) diff --git a/apps/web/tests/snapshots/code-mode-round/session.jsonl b/apps/web/tests/snapshots/code-mode-round/session.jsonl index 9ff7af0110..09e2432e94 100644 --- a/apps/web/tests/snapshots/code-mode-round/session.jsonl +++ b/apps/web/tests/snapshots/code-mode-round/session.jsonl @@ -14,10 +14,10 @@ {"type":"assistant/chunk","seq":204,"time":1785013633104,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":205,"time":1785013633108,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to write a single `run_code` program that:\n1. Runs bash to echo \"CODE_ROUND_OK\"\n2. Tries to read a file \"missing.txt\" and catches the error\n3. Returns an object with both outcomes\n4. They also want me to reply \"DONE\" and stop after\n\nLet me write this program."},{"type":"tool-call","id":"call_00_6VNoF1gDSerTBKoCfYSH3765","name":"run_code","arguments":"{\"description\": \"Run bash echo and catch missing file read\", \"code\": \"\\nconst bashResult = await tools.bash({\\n command: \\\"echo CODE_ROUND_OK\\\",\\n description: \\\"Echo CODE_ROUND_OK\\\"\\n});\\n\\nlet readError = null;\\ntry {\\n await tools.read({ file_path: \\\"missing.txt\\\" });\\n} catch (e) {\\n readError = {\\n toolName: e.toolName,\\n message: e.message\\n };\\n}\\n\\nreturn { bash: bashResult.stdout.text.trim(), readError };\\n\"}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":8082,"outputTokens":235,"cacheReadTokens":384,"reasoningTokens":75}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204],"surfaceOp":"append"} {"type":"tool/call","seq":206,"time":1785013633108,"data":{"turn":1,"step":1,"callId":"call_00_6VNoF1gDSerTBKoCfYSH3765","name":"run_code","arguments":"{\"description\": \"Run bash echo and catch missing file read\", \"code\": \"\\nconst bashResult = await tools.bash({\\n command: \\\"echo CODE_ROUND_OK\\\",\\n description: \\\"Echo CODE_ROUND_OK\\\"\\n});\\n\\nlet readError = null;\\ntry {\\n await tools.read({ file_path: \\\"missing.txt\\\" });\\n} catch (e) {\\n readError = {\\n toolName: e.toolName,\\n message: e.message\\n };\\n}\\n\\nreturn { bash: bashResult.stdout.text.trim(), readError };\\n\"}"}} -{"type":"tool/code-dispatch-start","seq":207,"time":1785013633173,"data":{"parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:1","name":"bash","arguments":{"command":"echo CODE_ROUND_OK","description":"Echo CODE_ROUND_OK"}}} -{"type":"tool/code-dispatch","seq":208,"time":1785013633196,"data":{"parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:1","name":"bash","arguments":{"command":"echo CODE_ROUND_OK","description":"Echo CODE_ROUND_OK"},"isError":false,"content":[{"type":"text","text":"CODE_ROUND_OK\n"}]}} -{"type":"tool/code-dispatch-start","seq":209,"time":1785013633197,"data":{"parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:2","name":"read","arguments":{"file_path":"missing.txt"}}} -{"type":"tool/code-dispatch","seq":210,"time":1785013633198,"data":{"parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:2","name":"read","arguments":{"file_path":"missing.txt"},"isError":true,"content":[{"type":"text","text":"Error: cannot read \"{{cwd}}/workspace/missing.txt\": not found"}]}} +{"type":"tool/code-dispatch-start","seq":207,"time":1785013633173,"data":{"rootCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:1","name":"bash","arguments":{"command":"echo CODE_ROUND_OK","description":"Echo CODE_ROUND_OK"}}} +{"type":"tool/code-dispatch","seq":208,"time":1785013633196,"data":{"rootCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:1","name":"bash","arguments":{"command":"echo CODE_ROUND_OK","description":"Echo CODE_ROUND_OK"},"isError":false,"content":[{"type":"text","text":"CODE_ROUND_OK\n"}]}} +{"type":"tool/code-dispatch-start","seq":209,"time":1785013633197,"data":{"rootCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:2","name":"read","arguments":{"file_path":"missing.txt"}}} +{"type":"tool/code-dispatch","seq":210,"time":1785013633198,"data":{"rootCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:2","name":"read","arguments":{"file_path":"missing.txt"},"isError":true,"content":[{"type":"text","text":"Error: cannot read \"{{cwd}}/workspace/missing.txt\": not found"}]}} {"type":"tool/result","seq":211,"time":1785013633201,"data":{"turn":1,"step":1,"callId":"call_00_6VNoF1gDSerTBKoCfYSH3765","content":[{"type":"text","text":"{\n \"bash\": \"CODE_ROUND_OK\",\n \"readError\": {\n \"toolName\": \"read\",\n \"message\": \"cannot read \\\"{{cwd}}/workspace/missing.txt\\\": not found\"\n }\n}"}],"isError":false},"sourceEventSeqs":[206],"surfaceOp":"append"} {"type":"step/end","seq":212,"time":1785013633204,"data":{"turn":1,"step":1}} {"type":"step/start","seq":213,"time":1785013633207,"data":{"turn":1,"step":2}} diff --git a/apps/web/tests/todo-row.snapshot.ts b/apps/web/tests/todo-row.snapshot.ts index c05057dddc..c5ded143b3 100644 --- a/apps/web/tests/todo-row.snapshot.ts +++ b/apps/web/tests/todo-row.snapshot.ts @@ -2,7 +2,7 @@ // Assembled todo snapshot: boots the real built `packages/client/*/lib/ // client.js` bundles through AppWebEntry's ModuleLoader path against the // keyless FixtureApiClient transport, opens the fixture session, and pins the -// two surfaces the fixture's parallel plan (turn 71, two items `in_progress`) +// two surfaces the fixture's parallel plan (turn 72, two items `in_progress`) // reaches — the `todo_write` tool row and the dock's plan strip. // // The row is pinned as three separate fields on purpose. `summary=` is the diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 5f3bc744b2..22e21edc0b 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -917,7 +917,7 @@ Requires: `agents` export type Config = Readonly> ``` -Source: [`packages/llm/llm-retry/src/index.ts:46`](../packages/llm/llm-retry/src/index.ts) +Source: [`packages/llm/llm-retry/src/index.ts:33`](../packages/llm/llm-retry/src/index.ts) ## `@deepseek-ai/dsh-lsp-local` @@ -2325,7 +2325,7 @@ export interface Config { export type ToolPresentationMode = 'native' | 'code' | 'both' ``` -Source: [`packages/core/tools/src/index.ts:616`](../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:623`](../packages/core/tools/src/index.ts) ## `@deepseek-ai/dsh-typert-loader` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 5b36725402..a8263b70c1 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -21,7 +21,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/status` | `emit` | [`packages/core/agent/src/types.ts:177`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy`, [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), `server` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:277`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/index.ts:30`](../packages/interaction/user-approval/src/index.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | -| `commands/change` | `emit` | [`packages/interaction/commands/src/index.ts:172`](../packages/interaction/commands/src/index.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `apiproxy` | +| `commands/change` | `emit` | [`packages/interaction/commands/src/index.ts:174`](../packages/interaction/commands/src/index.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `apiproxy` | | `credentials/updated` | `emit` | [`packages/credentials/credentials/src/index.ts:67`](../packages/credentials/credentials/src/index.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | `apiproxy`, [`credentials`](../packages/credentials/credentials) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:66`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | diff --git a/docs/module-graph.md b/docs/module-graph.md index e7d6b47e70..767ebfcfde 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -436,9 +436,6 @@ flowchart TD pkg_fs --> pkg_sandbox pkg_skill_badge --> pkg_invariants pkg_skill_badge --> pkg_skill - pkg_compact --> pkg_invariants - pkg_compact --> pkg_llm - pkg_compact --> pkg_session pkg_web_fetch_local --> pkg_invariants pkg_web_fetch_local --> pkg_timeout pkg_web_fetch_local --> pkg_web @@ -502,15 +499,11 @@ flowchart TD pkg_session_projection --> pkg_invariants pkg_session_projection --> pkg_session pkg_llm_retry --> pkg_agent + pkg_llm_retry --> pkg_brand pkg_llm_retry --> pkg_invariants pkg_llm_retry --> pkg_llm pkg_llm_retry --> pkg_session pkg_llm_retry --> pkg_timeout - pkg_token_meter --> pkg_compact - pkg_token_meter --> pkg_invariants - pkg_token_meter --> pkg_llm - pkg_token_meter --> pkg_session - pkg_token_meter --> pkg_session_projection pkg_agent_default_model --> pkg_agent pkg_agent_default_model --> pkg_invariants pkg_agent_default_model --> pkg_llm @@ -550,10 +543,6 @@ flowchart TD pkg_hook_protocol --> pkg_bash pkg_hook_protocol --> pkg_invariants pkg_hook_protocol --> pkg_session - pkg_llm_replay --> pkg_compact - pkg_llm_replay --> pkg_invariants - pkg_llm_replay --> pkg_llm - pkg_llm_replay --> pkg_session pkg_loader_smoke --> pkg_agent pkg_loader_smoke --> pkg_invariants pkg_loader_smoke --> pkg_llm @@ -674,14 +663,11 @@ flowchart TD pkg_fs_sandbox --> pkg_invariants pkg_fs_sandbox --> pkg_sandbox pkg_fs_sandbox --> pkg_sandbox_policy - pkg_command_compact --> pkg_commands - pkg_command_compact --> pkg_compact - pkg_command_compact --> pkg_invariants - pkg_compact_tool_result_prune --> pkg_compact - pkg_compact_tool_result_prune --> pkg_invariants - pkg_compact_tool_result_prune --> pkg_llm - pkg_compact_tool_result_prune --> pkg_session - pkg_compact_tool_result_prune --> pkg_token_meter + pkg_compact --> pkg_brand + pkg_compact --> pkg_commands + pkg_compact --> pkg_invariants + pkg_compact --> pkg_llm + pkg_compact --> pkg_session pkg_session_query --> pkg_brand pkg_session_query --> pkg_invariants pkg_session_query --> pkg_llm @@ -703,13 +689,6 @@ flowchart TD pkg_headless --> pkg_invariants pkg_headless --> pkg_llm pkg_headless --> pkg_session - pkg_client_ui_conversation --> pkg_client_locale - pkg_client_ui_conversation --> pkg_client_runtime - pkg_client_ui_conversation --> pkg_client_ui_primitives - pkg_client_ui_conversation --> pkg_client_ui_slash - pkg_client_ui_conversation --> pkg_client_ui_slots - pkg_client_ui_conversation --> pkg_invariants - pkg_client_ui_conversation --> pkg_token_meter pkg_command_feedback --> pkg_commands pkg_command_feedback --> pkg_invariants pkg_command_feedback --> pkg_session @@ -742,6 +721,11 @@ flowchart TD pkg_tasks_local --> pkg_invariants pkg_tasks_local --> pkg_tasks pkg_tasks_local --> pkg_timeout + pkg_token_meter --> pkg_compact + pkg_token_meter --> pkg_invariants + pkg_token_meter --> pkg_llm + pkg_token_meter --> pkg_session + pkg_token_meter --> pkg_session_projection pkg_agent_loop --> pkg_agent pkg_agent_loop --> pkg_invariants pkg_agent_loop --> pkg_llm @@ -790,13 +774,9 @@ flowchart TD pkg_tool_skill --> pkg_llm pkg_tool_skill --> pkg_skill pkg_tool_skill --> pkg_tools - pkg_compact_basic --> pkg_agent - pkg_compact_basic --> pkg_compact - pkg_compact_basic --> pkg_compact_tool_result_prune - pkg_compact_basic --> pkg_invariants - pkg_compact_basic --> pkg_llm - pkg_compact_basic --> pkg_session - pkg_compact_basic --> pkg_token_meter + pkg_command_compact --> pkg_commands + pkg_command_compact --> pkg_compact + pkg_command_compact --> pkg_invariants pkg_subagent --> pkg_agent pkg_subagent --> pkg_brand pkg_subagent --> pkg_invariants @@ -857,33 +837,10 @@ flowchart TD pkg_agent_loop_testkit --> pkg_session pkg_agent_loop_testkit --> pkg_system_prompt pkg_agent_loop_testkit --> pkg_tools - pkg_client_ui_command --> pkg_client_connection - pkg_client_ui_command --> pkg_client_locale - pkg_client_ui_command --> pkg_client_runtime - pkg_client_ui_command --> pkg_client_ui_conversation - pkg_client_ui_command --> pkg_client_ui_primitives - pkg_client_ui_command --> pkg_client_ui_slash - pkg_client_ui_command --> pkg_client_ui_slots - pkg_client_ui_command --> pkg_invariants - pkg_client_ui_deliverables --> pkg_client_locale - pkg_client_ui_deliverables --> pkg_client_runtime - pkg_client_ui_deliverables --> pkg_client_ui_conversation - pkg_client_ui_deliverables --> pkg_client_ui_slots - pkg_client_ui_deliverables --> pkg_invariants - pkg_client_ui_goal --> pkg_api_remotes - pkg_client_ui_goal --> pkg_client_locale - pkg_client_ui_goal --> pkg_client_runtime - pkg_client_ui_goal --> pkg_client_ui_conversation - pkg_client_ui_goal --> pkg_client_ui_primitives - pkg_client_ui_goal --> pkg_client_ui_slots - pkg_client_ui_goal --> pkg_goal - pkg_client_ui_goal --> pkg_invariants - pkg_client_ui_tool --> pkg_client_locale - pkg_client_ui_tool --> pkg_client_runtime - pkg_client_ui_tool --> pkg_client_ui_conversation - pkg_client_ui_tool --> pkg_client_ui_primitives - pkg_client_ui_tool --> pkg_client_ui_slots - pkg_client_ui_tool --> pkg_invariants + pkg_llm_replay --> pkg_compact + pkg_llm_replay --> pkg_invariants + pkg_llm_replay --> pkg_llm + pkg_llm_replay --> pkg_session pkg_session_reference --> pkg_agent pkg_session_reference --> pkg_compact pkg_session_reference --> pkg_invariants @@ -990,6 +947,11 @@ flowchart TD pkg_tool_pwsh --> pkg_system_prompt pkg_tool_pwsh --> pkg_tasks pkg_tool_pwsh --> pkg_tools + pkg_compact_tool_result_prune --> pkg_compact + pkg_compact_tool_result_prune --> pkg_invariants + pkg_compact_tool_result_prune --> pkg_llm + pkg_compact_tool_result_prune --> pkg_session + pkg_compact_tool_result_prune --> pkg_token_meter pkg_subagent_acp --> pkg_agent pkg_subagent_acp --> pkg_invariants pkg_subagent_acp --> pkg_llm @@ -1038,50 +1000,14 @@ flowchart TD pkg_web_app --> pkg_bash_env pkg_web_app --> pkg_invariants pkg_web_app --> pkg_system_prompt - pkg_client_ui_model --> pkg_client_connection - pkg_client_ui_model --> pkg_client_locale - pkg_client_ui_model --> pkg_client_runtime - pkg_client_ui_model --> pkg_client_ui_command - pkg_client_ui_model --> pkg_client_ui_conversation - pkg_client_ui_model --> pkg_client_ui_primitives - pkg_client_ui_model --> pkg_client_ui_slash - pkg_client_ui_model --> pkg_client_ui_slots - pkg_client_ui_model --> pkg_invariants - pkg_client_ui_permission --> pkg_client_connection - pkg_client_ui_permission --> pkg_client_locale - pkg_client_ui_permission --> pkg_client_runtime - pkg_client_ui_permission --> pkg_client_schema_form - pkg_client_ui_permission --> pkg_client_ui_command - pkg_client_ui_permission --> pkg_client_ui_primitives - pkg_client_ui_permission --> pkg_client_ui_slash - pkg_client_ui_permission --> pkg_client_ui_slots - pkg_client_ui_permission --> pkg_invariants - pkg_client_ui_permission --> pkg_permission - pkg_client_ui_plan --> pkg_client_connection - pkg_client_ui_plan --> pkg_client_locale - pkg_client_ui_plan --> pkg_client_runtime - pkg_client_ui_plan --> pkg_client_ui_conversation - pkg_client_ui_plan --> pkg_client_ui_primitives - pkg_client_ui_plan --> pkg_client_ui_slots - pkg_client_ui_plan --> pkg_invariants - pkg_client_ui_plan --> pkg_plan_mode - pkg_client_ui_skill --> pkg_client_connection - pkg_client_ui_skill --> pkg_client_locale - pkg_client_ui_skill --> pkg_client_runtime - pkg_client_ui_skill --> pkg_client_ui_primitives - pkg_client_ui_skill --> pkg_client_ui_slash - pkg_client_ui_skill --> pkg_client_ui_slots - pkg_client_ui_skill --> pkg_client_ui_tool - pkg_client_ui_skill --> pkg_invariants - pkg_client_ui_subagent --> pkg_client_locale - pkg_client_ui_subagent --> pkg_client_runtime - pkg_client_ui_subagent --> pkg_client_ui_conversation - pkg_client_ui_subagent --> pkg_client_ui_primitives - pkg_client_ui_subagent --> pkg_client_ui_slash - pkg_client_ui_subagent --> pkg_client_ui_slots - pkg_client_ui_subagent --> pkg_invariants - pkg_client_ui_subagent --> pkg_subagent - pkg_client_ui_subagent --> pkg_token_meter + pkg_client_ui_conversation --> pkg_client_locale + pkg_client_ui_conversation --> pkg_client_runtime + pkg_client_ui_conversation --> pkg_client_ui_primitives + pkg_client_ui_conversation --> pkg_client_ui_slash + pkg_client_ui_conversation --> pkg_client_ui_slots + pkg_client_ui_conversation --> pkg_compact + pkg_client_ui_conversation --> pkg_invariants + pkg_client_ui_conversation --> pkg_token_meter pkg_sdk_protocol --> pkg_invariants pkg_sdk_protocol --> pkg_llm pkg_sdk_protocol --> pkg_session @@ -1105,6 +1031,14 @@ flowchart TD pkg_workflow_workerthread --> pkg_subagent pkg_workflow_workerthread --> pkg_tools pkg_workflow_workerthread --> pkg_workflow + pkg_compact_basic --> pkg_agent + pkg_compact_basic --> pkg_commands + pkg_compact_basic --> pkg_compact + pkg_compact_basic --> pkg_compact_tool_result_prune + pkg_compact_basic --> pkg_invariants + pkg_compact_basic --> pkg_llm + pkg_compact_basic --> pkg_session + pkg_compact_basic --> pkg_token_meter pkg_subagent_codex --> pkg_invariants pkg_subagent_codex --> pkg_llm pkg_subagent_codex --> pkg_sdk_protocol @@ -1120,6 +1054,50 @@ flowchart TD pkg_subagent_spawn --> pkg_invariants pkg_subagent_spawn --> pkg_subagent pkg_subagent_spawn --> pkg_subagent_inprocess + pkg_client_ui_command --> pkg_client_connection + pkg_client_ui_command --> pkg_client_locale + pkg_client_ui_command --> pkg_client_runtime + pkg_client_ui_command --> pkg_client_ui_conversation + pkg_client_ui_command --> pkg_client_ui_primitives + pkg_client_ui_command --> pkg_client_ui_slash + pkg_client_ui_command --> pkg_client_ui_slots + pkg_client_ui_command --> pkg_invariants + pkg_client_ui_deliverables --> pkg_client_locale + pkg_client_ui_deliverables --> pkg_client_runtime + pkg_client_ui_deliverables --> pkg_client_ui_conversation + pkg_client_ui_deliverables --> pkg_client_ui_slots + pkg_client_ui_deliverables --> pkg_invariants + pkg_client_ui_goal --> pkg_api_remotes + pkg_client_ui_goal --> pkg_client_locale + pkg_client_ui_goal --> pkg_client_runtime + pkg_client_ui_goal --> pkg_client_ui_conversation + pkg_client_ui_goal --> pkg_client_ui_primitives + pkg_client_ui_goal --> pkg_client_ui_slots + pkg_client_ui_goal --> pkg_goal + pkg_client_ui_goal --> pkg_invariants + pkg_client_ui_plan --> pkg_client_connection + pkg_client_ui_plan --> pkg_client_locale + pkg_client_ui_plan --> pkg_client_runtime + pkg_client_ui_plan --> pkg_client_ui_conversation + pkg_client_ui_plan --> pkg_client_ui_primitives + pkg_client_ui_plan --> pkg_client_ui_slots + pkg_client_ui_plan --> pkg_invariants + pkg_client_ui_plan --> pkg_plan_mode + pkg_client_ui_subagent --> pkg_client_locale + pkg_client_ui_subagent --> pkg_client_runtime + pkg_client_ui_subagent --> pkg_client_ui_conversation + pkg_client_ui_subagent --> pkg_client_ui_primitives + pkg_client_ui_subagent --> pkg_client_ui_slash + pkg_client_ui_subagent --> pkg_client_ui_slots + pkg_client_ui_subagent --> pkg_invariants + pkg_client_ui_subagent --> pkg_subagent + pkg_client_ui_subagent --> pkg_token_meter + pkg_client_ui_tool --> pkg_client_locale + pkg_client_ui_tool --> pkg_client_runtime + pkg_client_ui_tool --> pkg_client_ui_conversation + pkg_client_ui_tool --> pkg_client_ui_primitives + pkg_client_ui_tool --> pkg_client_ui_slots + pkg_client_ui_tool --> pkg_invariants pkg_agent_spine_demo --> pkg_agent pkg_agent_spine_demo --> pkg_agent_loop pkg_agent_spine_demo --> pkg_bash_env @@ -1161,6 +1139,33 @@ flowchart TD pkg_subagent_dsh_sdk --> pkg_session pkg_subagent_dsh_sdk --> pkg_subagent pkg_subagent_dsh_sdk --> pkg_subprocess + pkg_client_ui_model --> pkg_client_connection + pkg_client_ui_model --> pkg_client_locale + pkg_client_ui_model --> pkg_client_runtime + pkg_client_ui_model --> pkg_client_ui_command + pkg_client_ui_model --> pkg_client_ui_conversation + pkg_client_ui_model --> pkg_client_ui_primitives + pkg_client_ui_model --> pkg_client_ui_slash + pkg_client_ui_model --> pkg_client_ui_slots + pkg_client_ui_model --> pkg_invariants + pkg_client_ui_permission --> pkg_client_connection + pkg_client_ui_permission --> pkg_client_locale + pkg_client_ui_permission --> pkg_client_runtime + pkg_client_ui_permission --> pkg_client_schema_form + pkg_client_ui_permission --> pkg_client_ui_command + pkg_client_ui_permission --> pkg_client_ui_primitives + pkg_client_ui_permission --> pkg_client_ui_slash + pkg_client_ui_permission --> pkg_client_ui_slots + pkg_client_ui_permission --> pkg_invariants + pkg_client_ui_permission --> pkg_permission + pkg_client_ui_skill --> pkg_client_connection + pkg_client_ui_skill --> pkg_client_locale + pkg_client_ui_skill --> pkg_client_runtime + pkg_client_ui_skill --> pkg_client_ui_primitives + pkg_client_ui_skill --> pkg_client_ui_slash + pkg_client_ui_skill --> pkg_client_ui_slots + pkg_client_ui_skill --> pkg_client_ui_tool + pkg_client_ui_skill --> pkg_invariants pkg_acp_demo --> pkg_acp pkg_acp_demo --> pkg_agent_spine_demo pkg_acp_demo --> pkg_app_boot @@ -1238,7 +1243,6 @@ flowchart TD | [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`subprocess`](../packages/subprocess/subprocess) | | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`invariants`](../packages/support/invariants), [`skill`](../packages/skill/skill) | -| [`compact`](../packages/compact/compact) | `compact` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`web-fetch-local`](../packages/web/web-fetch-local) | `web` | [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | | [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`web`](../packages/web/web) | | [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`web`](../packages/web/web) | @@ -1255,8 +1259,7 @@ flowchart TD | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`session-projection`](../packages/session/session-projection) | `session` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | -| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | -| [`token-meter`](../packages/llm/token-meter) | `llm` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | +| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings) | | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`type-meta`](../packages/typert/type-meta) | | [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | @@ -1267,7 +1270,6 @@ flowchart TD | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`web`](../packages/web/web) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/support/invariants), [`spill`](../packages/spill/spill) | | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | -| [`llm-replay`](../packages/support/llm-replay) | `support` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | @@ -1295,19 +1297,18 @@ flowchart TD | [`goal-session`](../packages/goal/goal-session) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | | [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | -| [`command-compact`](../packages/compact/command-compact) | `compact` | [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants) | -| [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | `compact` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | +| [`compact`](../packages/compact/compact) | `compact` | [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-title`](../packages/session/session-title) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/interaction/user-approval) | | [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`typert-registry`](../packages/typert/registry) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`token-meter`](../packages/llm/token-meter) | | [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`permission`](../packages/interaction/permission) | `interaction` | [`bash`](../packages/bash/bash), [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | | [`session-title-llm`](../packages/session/session-title-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`timeout`](../packages/util/timeout) | | [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) | +| [`token-meter`](../packages/llm/token-meter) | `llm` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`bash-env`](../packages/bash/bash-env) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | @@ -1315,7 +1316,7 @@ flowchart TD | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | -| [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | +| [`command-compact`](../packages/compact/command-compact) | `compact` | [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants) | | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-web`](../packages/web/tool-web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | | [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) | @@ -1325,10 +1326,7 @@ flowchart TD | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | -| [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`llm-replay`](../packages/support/llm-replay) | `support` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | | [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | @@ -1347,6 +1345,7 @@ flowchart TD | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-pwsh`](../packages/bash/tool-pwsh) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | +| [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | `compact` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | @@ -1355,20 +1354,26 @@ flowchart TD | [`tool-subagent-report`](../packages/subagent/tool-subagent-report) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`system-prompt`](../packages/core/system-prompt) | -| [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-permission`](../packages/client/ui-permission) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-command`](../packages/client/ui-command), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`permission`](../packages/interaction/permission) | -| [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) | -| [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-tool`](../packages/client/ui-tool), [`invariants`](../packages/support/invariants) | -| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | +| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`token-meter`](../packages/llm/token-meter) | | [`sdk-protocol`](../packages/scaffold/protocol) | `scaffold` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`repository-plugin`](../packages/self-modification/repository-plugin) | `self-modification` | [`invariants`](../packages/support/invariants), [`mcp-client`](../packages/mcp/mcp-client), [`paths`](../packages/util/paths), [`skill-local`](../packages/skill/skill-local) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | +| [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`subagent-codex`](../packages/subagent/subagent-codex) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/scaffold/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | +| [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | +| [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) | +| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | +| [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`bash-env`](../packages/bash/bash-env), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks-local`](../packages/tasks/tasks-local), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`jsonrpc`](../packages/scaffold/server) | `scaffold` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/scaffold/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`sdk-client`](../packages/scaffold/client) | `scaffold` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/scaffold/protocol), [`session`](../packages/core/session) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/scaffold/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | +| [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-permission`](../packages/client/ui-permission) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-command`](../packages/client/ui-command), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`permission`](../packages/interaction/permission) | +| [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-tool`](../packages/client/ui-tool), [`invariants`](../packages/support/invariants) | | [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/acp/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/boot/app-boot), [`invariants`](../packages/support/invariants), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 614f6afbeb..76487ca3d4 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -212,7 +212,7 @@ Source: [`packages/core/session/src/types.ts:245`](../packages/core/session/src/ } ``` -Source: [`packages/interaction/commands/src/index.ts:151`](../packages/interaction/commands/src/index.ts) +Source: [`packages/interaction/commands/src/index.ts:153`](../packages/interaction/commands/src/index.ts) #### `command/run` — log-only @@ -230,7 +230,7 @@ Source: [`packages/interaction/commands/src/index.ts:151`](../packages/interacti 'command/run': { commandId: CommandId; name: string; args?: string; source: CommandSource } ``` -Source: [`packages/interaction/commands/src/index.ts:144`](../packages/interaction/commands/src/index.ts) +Source: [`packages/interaction/commands/src/index.ts:146`](../packages/interaction/commands/src/index.ts) ### `compact/*` @@ -241,10 +241,10 @@ Source: [`packages/interaction/commands/src/index.ts:144`](../packages/interacti * Marks the end of a compaction — log-only, releases the lock. Its owner * matches `compact/start`; `error` records an unsuccessful attempt. */ -'compact/end': { turn: number | null; error?: string } +'compact/end': { compactionId: CompactionId; sourceCommandId?: CommandId; turn: number | null; error?: string } ``` -Source: [`packages/compact/compact/src/types.ts:65`](../packages/compact/compact/src/types.ts) +Source: [`packages/compact/compact/src/types.ts:69`](../packages/compact/compact/src/types.ts) #### `compact/prune` — log-only @@ -268,7 +268,7 @@ Source: [`packages/compact/compact/src/types.ts:65`](../packages/compact/compact } ``` -Source: [`packages/compact/compact/src/types.ts:75`](../packages/compact/compact/src/types.ts) +Source: [`packages/compact/compact/src/types.ts:79`](../packages/compact/compact/src/types.ts) #### `compact/start` — log-only @@ -278,10 +278,10 @@ Source: [`packages/compact/compact/src/types.ts:75`](../packages/compact/compact * `compact/end`. A numbered owner is strictly enclosed by that open turn; * `null` identifies a standalone manual transaction between turns. */ -'compact/start': { turn: number | null } +'compact/start': { compactionId: CompactionId; sourceCommandId?: CommandId; turn: number | null } ``` -Source: [`packages/compact/compact/src/types.ts:19`](../packages/compact/compact/src/types.ts) +Source: [`packages/compact/compact/src/types.ts:21`](../packages/compact/compact/src/types.ts) #### `compact/summary` — log-only @@ -296,6 +296,8 @@ Source: [`packages/compact/compact/src/types.ts:19`](../packages/compact/compact * before it (`compact/prune` documents the shared protocol). */ 'compact/summary': { + compactionId: CompactionId + sourceCommandId?: CommandId summary: ContentBlock[] shadowedRange: { start: number; end: number } shadowedSeqs: number[] @@ -331,7 +333,7 @@ Source: [`packages/compact/compact/src/types.ts:19`](../packages/compact/compact Types: [ContentBlock](subsystems/core.md) · [TokenUsage](subsystems/llm-streaming.md) -Source: [`packages/compact/compact/src/types.ts:29`](../packages/compact/compact/src/types.ts) +Source: [`packages/compact/compact/src/types.ts:31`](../packages/compact/compact/src/types.ts) ### `feedback/*` @@ -412,29 +414,19 @@ Source: [`packages/hooks/hook-protocol/src/types.ts:31`](../packages/hooks/hook- ```ts persistence-catalog /** Durable, non-surface record of one provider-routed retry scheduled after a failed request attempt. */ -'llm/retry': { - turn: number - step: number - provider: string - mode: 'normal' - policyKey: string - retry: number - maxRetries: number - delayMs: number - failure: LlmFailure -} | { - turn: number - step: number - provider: string - mode: 'always' - policyKey: string - retry: number - delayMs: number - failure: LlmFailure -} +'llm/retry': LlmRetryEventData ``` -Source: [`packages/llm/llm-retry/src/index.ts:17`](../packages/llm/llm-retry/src/index.ts) +Source: [`packages/llm/llm-retry/src/index.ts:20`](../packages/llm/llm-retry/src/index.ts) + +#### `llm/retry-started` — log-only + +```ts persistence-catalog +/** Durable transition written after a retry wait succeeds and before the next request attempt starts. */ +'llm/retry-started': LlmRetryStartedEventData +``` + +Source: [`packages/llm/llm-retry/src/index.ts:22`](../packages/llm/llm-retry/src/index.ts) ### `permission/*` @@ -656,7 +648,7 @@ Source: [`packages/core/session/src/types.ts:251`](../packages/core/session/src/ * before returning), so its execution-enclosure relation holds by * construction. */ -'tool/code-dispatch': { parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown; isError: boolean; content: ContentBlock[] } +'tool/code-dispatch': { rootCallId: CallId; parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown; isError: boolean; content: ContentBlock[] } ``` Types: [CallId](subsystems/core.md) · [ContentBlock](subsystems/core.md) @@ -679,7 +671,7 @@ Source: [`packages/core/tools/src/code-mode.ts:49`](../packages/core/tools/src/c * with `tool/code-dispatch` by `subCallId` (timing = the two events' * `time` fields). */ -'tool/code-dispatch-start': { parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown } +'tool/code-dispatch-start': { rootCallId: CallId; parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown } ``` Types: [CallId](subsystems/core.md) diff --git a/docs/subsystems/commands.i18n.yaml b/docs/subsystems/commands.i18n.yaml index 5bc05d2916..9466165680 100644 --- a/docs/subsystems/commands.i18n.yaml +++ b/docs/subsystems/commands.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/commands.md -commands.md: c71c19b8a3f94ba468e6871d5658c4eca1c6276d -commands.zh.md: 3f1df0e8aa40524160c8f770793b6064dd5988ae +commands.md: 7f4e4e87d7206be3e46c7076ee5e8ab6a7589f9a +commands.zh.md: 0f8ff31bca5f62824912f53aee8dbc32db4c331b diff --git a/docs/subsystems/commands.md b/docs/subsystems/commands.md index c71c19b8a3..7f4e4e87d7 100644 --- a/docs/subsystems/commands.md +++ b/docs/subsystems/commands.md @@ -49,6 +49,8 @@ The adapter owns cancellation and passes the exact target agent. `rawInput` begi ```ts type-equiv /** Invocation passed to one registered command handler. */ interface CommandInvocation { + /** Pairing id already written to this invocation's `command/run` event. */ + readonly commandId: CommandId /** Exact agent whose human-facing surface received the command. */ readonly agent: Agent /** Exact text following the registered command name, including separator whitespace. */ @@ -159,7 +161,7 @@ async execute( agent: Agent, line: string, signal: AbortSignal, ): Promise @@ -181,5 +183,5 @@ A command was registered or unregistered. This is an unfiltered registry notific 'commands/change'(): void ``` -Source: [`packages/interaction/commands/src/index.ts:172`](../../packages/interaction/commands/src/index.ts) +Source: [`packages/interaction/commands/src/index.ts:174`](../../packages/interaction/commands/src/index.ts) diff --git a/docs/subsystems/commands.zh.md b/docs/subsystems/commands.zh.md index 3f1df0e8aa..0f8ff31bca 100644 --- a/docs/subsystems/commands.zh.md +++ b/docs/subsystems/commands.zh.md @@ -49,6 +49,8 @@ interface CommandDefinition { ```ts type-equiv /** Invocation passed to one registered command handler. */ interface CommandInvocation { + /** Pairing id already written to this invocation's `command/run` event. */ + readonly commandId: CommandId /** Exact agent whose human-facing surface received the command. */ readonly agent: Agent /** Exact text following the registered command name, including separator whitespace. */ @@ -159,7 +161,7 @@ async execute( agent: Agent, line: string, signal: AbortSignal, ): Promise @@ -181,5 +183,5 @@ A command was registered or unregistered. This is an unfiltered registry notific 'commands/change'(): void ``` -Source: [`packages/interaction/commands/src/index.ts:172`](../../packages/interaction/commands/src/index.ts) +Source: [`packages/interaction/commands/src/index.ts:174`](../../packages/interaction/commands/src/index.ts) diff --git a/docs/subsystems/compaction.i18n.yaml b/docs/subsystems/compaction.i18n.yaml index 6445fa5d5c..fd8dbdd1b5 100644 --- a/docs/subsystems/compaction.i18n.yaml +++ b/docs/subsystems/compaction.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/compaction.md -compaction.md: 942cc740db6b7006952bfc33a39ea9826d673e61 -compaction.zh.md: c5b65a95f9c0db8b02912c68ef93edce2ceb7c43 +compaction.md: 1aca48d677a83a488d9590edad3f8a68ca662b70 +compaction.zh.md: e2cfabc0891126d9f2165e47dffcf485f4a8a339 diff --git a/docs/subsystems/compaction.md b/docs/subsystems/compaction.md index 942cc740db..1aca48d677 100644 --- a/docs/subsystems/compaction.md +++ b/docs/subsystems/compaction.md @@ -29,6 +29,10 @@ What a successful compaction returns to its caller: the bookkeeping-event seqs, ```ts type-equiv /** Result of a successful compaction operation. */ interface CompactionResult { + /** Stable identity shared by this compaction's complete durable lifecycle. */ + compactionId: CompactionId + /** Human command that initiated this compaction, when it was manual. */ + sourceCommandId?: CommandId /** The seq of the appended `compact/start` event. */ startSeq: number /** The seq of the appended `compact/summary` event. */ @@ -155,13 +159,14 @@ abstract compactIfNeeded( agent: CompactAgentContext, trigger: CompactionTrigger * * @param agent - idle agent whose durable history should be compacted. * @param signal - cancellation scoped to this compaction request. + * @param sourceCommandId - initiating command identity for a manual compaction. * @returns the compaction result, or `null` when no safe useful range exists. * @throws {@link ManualCompactionError} for expected busy, agent-cancellation, * changed-span, summarization/shrink, commit-stage, or persistence failures; * an aborted request preserves its exact abort reason. Failed attempts remain * visible in the log. */ -abstract compactNow( agent: ManualCompactAgentContext, signal: AbortSignal, ): Promise +abstract compactNow( agent: ManualCompactAgentContext, signal: AbortSignal, sourceCommandId?: CommandId, ): Promise /** * Forcibly compact a range of surface nodes into a single summary node. @@ -184,7 +189,9 @@ abstract compactNow( agent: ManualCompactAgentContext, signal: AbortSignal, ): P abstract compactRegion( start: number, end: number, agent: CompactAgentContext, signal?: AbortSignal, ): Promise ``` -Source: [`packages/compact/compact/src/index.ts:93`](../../packages/compact/compact/src/index.ts) +Types: [CommandId](commands.md) + +Source: [`packages/compact/compact/src/index.ts:96`](../../packages/compact/compact/src/index.ts) diff --git a/docs/subsystems/compaction.zh.md b/docs/subsystems/compaction.zh.md index c5b65a95f9..e2cfabc089 100644 --- a/docs/subsystems/compaction.zh.md +++ b/docs/subsystems/compaction.zh.md @@ -29,6 +29,10 @@ ```ts type-equiv /** Result of a successful compaction operation. */ interface CompactionResult { + /** Stable identity shared by this compaction's complete durable lifecycle. */ + compactionId: CompactionId + /** Human command that initiated this compaction, when it was manual. */ + sourceCommandId?: CommandId /** The seq of the appended `compact/start` event. */ startSeq: number /** The seq of the appended `compact/summary` event. */ @@ -155,13 +159,14 @@ abstract compactIfNeeded( agent: CompactAgentContext, trigger: CompactionTrigger * * @param agent - idle agent whose durable history should be compacted. * @param signal - cancellation scoped to this compaction request. + * @param sourceCommandId - initiating command identity for a manual compaction. * @returns the compaction result, or `null` when no safe useful range exists. * @throws {@link ManualCompactionError} for expected busy, agent-cancellation, * changed-span, summarization/shrink, commit-stage, or persistence failures; * an aborted request preserves its exact abort reason. Failed attempts remain * visible in the log. */ -abstract compactNow( agent: ManualCompactAgentContext, signal: AbortSignal, ): Promise +abstract compactNow( agent: ManualCompactAgentContext, signal: AbortSignal, sourceCommandId?: CommandId, ): Promise /** * Forcibly compact a range of surface nodes into a single summary node. @@ -184,7 +189,9 @@ abstract compactNow( agent: ManualCompactAgentContext, signal: AbortSignal, ): P abstract compactRegion( start: number, end: number, agent: CompactAgentContext, signal?: AbortSignal, ): Promise ``` -Source: [`packages/compact/compact/src/index.ts:93`](../../packages/compact/compact/src/index.ts) +Types: [CommandId](commands.md) + +Source: [`packages/compact/compact/src/index.ts:96`](../../packages/compact/compact/src/index.ts) diff --git a/docs/subsystems/tools.i18n.yaml b/docs/subsystems/tools.i18n.yaml index 42f73824cf..d015cdb638 100644 --- a/docs/subsystems/tools.i18n.yaml +++ b/docs/subsystems/tools.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/tools.md -tools.md: 87910dfbb86e9db0f0545bc2fe1135c4aefb2e69 -tools.zh.md: d312090b722de3fd1b8efc7703eafdf7f3c38682 +tools.md: f06db58b9b52fcb2c325ff813406cd2dc460ea7b +tools.zh.md: cca4bf9faa870d9414d95f785b67e0393e469cdd diff --git a/docs/subsystems/tools.md b/docs/subsystems/tools.md index 87910dfbb8..f06db58b9b 100644 --- a/docs/subsystems/tools.md +++ b/docs/subsystems/tools.md @@ -184,6 +184,11 @@ type ToolExecutionToken = symbol & { readonly [toolExecutionTokenBrand]: true } */ interface ToolExecutionInput { readonly callId: CallId + /** + * Root model-requested call owning this execution tree. Callers omit it for + * a root execution; nested dispatchers propagate the enclosing value. + */ + readonly rootCallId?: CallId readonly name: string /** Losslessly JSON-serializable parsed arguments (tools validate their own schema). */ readonly arguments: unknown @@ -280,6 +285,8 @@ interface CodeDispatchLog { * observers run. */ interface ToolExecution extends ToolExecutionInput { + /** Root model-requested call, resolved for every root and nested execution. */ + readonly rootCallId: CallId /** Registry-assigned identity shared with nested calls only as their opaque `parent` token. */ readonly token: ToolExecutionToken } @@ -547,7 +554,7 @@ async execute(exec: ToolExecutionInput): Promise Types: [ScopeKey](scope.md) -Source: [`packages/core/tools/src/index.ts:739`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:746`](../../packages/core/tools/src/index.ts) diff --git a/docs/subsystems/tools.zh.md b/docs/subsystems/tools.zh.md index d312090b72..cca4bf9faa 100644 --- a/docs/subsystems/tools.zh.md +++ b/docs/subsystems/tools.zh.md @@ -184,6 +184,11 @@ type ToolExecutionToken = symbol & { readonly [toolExecutionTokenBrand]: true } */ interface ToolExecutionInput { readonly callId: CallId + /** + * Root model-requested call owning this execution tree. Callers omit it for + * a root execution; nested dispatchers propagate the enclosing value. + */ + readonly rootCallId?: CallId readonly name: string /** Losslessly JSON-serializable parsed arguments (tools validate their own schema). */ readonly arguments: unknown @@ -280,6 +285,8 @@ interface CodeDispatchLog { * observers run. */ interface ToolExecution extends ToolExecutionInput { + /** Root model-requested call, resolved for every root and nested execution. */ + readonly rootCallId: CallId /** Registry-assigned identity shared with nested calls only as their opaque `parent` token. */ readonly token: ToolExecutionToken } @@ -547,7 +554,7 @@ async execute(exec: ToolExecutionInput): Promise Types: [ScopeKey](scope.md) -Source: [`packages/core/tools/src/index.ts:739`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:746`](../../packages/core/tools/src/index.ts) diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl index f4169abe78..d6935b6c98 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl @@ -25,8 +25,8 @@ {"type":"assistant/chunk","seq":23,"time":1785730458465,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":24,"time":1785730458465,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"e3061430-3f2d-4dd8-a3ee-c0fde800547d"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} {"type":"tool/call","seq":25,"time":1785730458465,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}} -{"type":"tool/code-dispatch-start","seq":26,"time":1785730458517,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"}}} -{"type":"tool/code-dispatch","seq":27,"time":1785730458518,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"},"isError":false,"content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}]}} +{"type":"tool/code-dispatch-start","seq":26,"time":1785730458517,"data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"}}} +{"type":"tool/code-dispatch","seq":27,"time":1785730458518,"data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"},"isError":false,"content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}]}} {"type":"tool/result","seq":28,"time":1785730458520,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-code"},"content":[{"type":"tool-result","toolCallId":"advanced-code","content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}],"isError":false}],"role":"user","id":"4dce223d-0097-4ac2-a717-d1c430240cef"}},"sourceEventSeqs":[25],"surfaceOp":"append"} {"type":"step/end","seq":29,"time":1785730458520,"data":{"turn":1,"step":2}} {"type":"step/start","seq":30,"time":1785730458527,"data":{"turn":1,"step":3}} diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl b/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl index a1b26a5af4..7490f9487f 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl @@ -18,8 +18,8 @@ {"type":"assistant/chunk","seq":103,"time":1785730479356,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":104,"time":1785730479356,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to call the run_code tool with a TypeScript program that runs `echo BOTH_OK` via `tools.bash` and returns its output."},{"type":"tool-call","id":"call_00_Era4M5eh79bvNOIey5q90401","name":"run_code","arguments":"{\"code\": \"const result = await tools.bash({ command: \\\"echo BOTH_OK\\\", description: \\\"Print BOTH_OK\\\" });\\nreturn result.stdout.text;\", \"description\": \"Run echo BOTH_OK via tools.bash\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"5f1cf31c-fd73-42fc-805d-a14d91228bd9"},"usage":{"inputTokens":10400,"outputTokens":130,"cacheReadTokens":0,"reasoningTokens":34}},"sourceEventSeqs":[9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103],"surfaceOp":"append"} {"type":"tool/call","seq":105,"time":1785730479356,"data":{"turn":1,"step":1,"callId":"call_00_Era4M5eh79bvNOIey5q90401","name":"run_code","arguments":"{\"code\": \"const result = await tools.bash({ command: \\\"echo BOTH_OK\\\", description: \\\"Print BOTH_OK\\\" });\\nreturn result.stdout.text;\", \"description\": \"Run echo BOTH_OK via tools.bash\"}"}} -{"type":"tool/code-dispatch-start","seq":106,"time":1785730479411,"data":{"parentCallId":"call_00_Era4M5eh79bvNOIey5q90401","subCallId":"call_00_Era4M5eh79bvNOIey5q90401:code:1","name":"bash","arguments":{"command":"echo BOTH_OK","description":"Print BOTH_OK"}}} -{"type":"tool/code-dispatch","seq":107,"time":1785730479421,"data":{"parentCallId":"call_00_Era4M5eh79bvNOIey5q90401","subCallId":"call_00_Era4M5eh79bvNOIey5q90401:code:1","name":"bash","arguments":{"command":"echo BOTH_OK","description":"Print BOTH_OK"},"isError":false,"content":[{"type":"text","text":"BOTH_OK\n"}]}} +{"type":"tool/code-dispatch-start","seq":106,"time":1785730479411,"data":{"rootCallId":"call_00_Era4M5eh79bvNOIey5q90401","parentCallId":"call_00_Era4M5eh79bvNOIey5q90401","subCallId":"call_00_Era4M5eh79bvNOIey5q90401:code:1","name":"bash","arguments":{"command":"echo BOTH_OK","description":"Print BOTH_OK"}}} +{"type":"tool/code-dispatch","seq":107,"time":1785730479421,"data":{"rootCallId":"call_00_Era4M5eh79bvNOIey5q90401","parentCallId":"call_00_Era4M5eh79bvNOIey5q90401","subCallId":"call_00_Era4M5eh79bvNOIey5q90401:code:1","name":"bash","arguments":{"command":"echo BOTH_OK","description":"Print BOTH_OK"},"isError":false,"content":[{"type":"text","text":"BOTH_OK\n"}]}} {"type":"tool/result","seq":108,"time":1785730479423,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_Era4M5eh79bvNOIey5q90401"},"content":[{"type":"tool-result","toolCallId":"call_00_Era4M5eh79bvNOIey5q90401","content":[{"type":"text","text":"BOTH_OK\n"}],"isError":false}],"role":"user","id":"028e19dd-dcfc-4a67-a6e4-c9fa19716ea3"}},"sourceEventSeqs":[105],"surfaceOp":"append"} {"type":"step/end","seq":109,"time":1785730479423,"data":{"turn":1,"step":1}} {"type":"step/start","seq":110,"time":1785730479431,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl b/examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl index 24e1525c79..a05477498a 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl @@ -18,10 +18,10 @@ {"type":"assistant/chunk","seq":187,"time":1785730477079,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":188,"time":1785730477079,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to write a single run_code program that:\n1. Calls bash tool twice: `echo CODE_ONE` and `echo CODE_TWO`\n2. console.log exactly `captured output`\n3. Return the two outputs joined with a plus sign\n\nLet me write this."},{"type":"tool-call","id":"call_00_UiQPVqoELyzBZCY5pm1z7875","name":"run_code","arguments":"{\"code\": \"\\nconst out1 = await tools.bash({command: \\\"echo CODE_ONE\\\", description: \\\"Print CODE_ONE\\\"});\\nconst out2 = await tools.bash({command: \\\"echo CODE_TWO\\\", description: \\\"Print CODE_TWO\\\"});\\nconsole.log(\\\"captured output\\\");\\nconst text1 = out1.stdout.text.trim();\\nconst text2 = out2.stdout.text.trim();\\nreturn text1 + \\\"+\\\" + text2;\\n\", \"description\": \"Run two echo commands and join outputs\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"59e638d7-2aa2-48a2-ae0e-5833b1152ce6"},"usage":{"inputTokens":6152,"outputTokens":214,"cacheReadTokens":0,"reasoningTokens":60}},"sourceEventSeqs":[9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187],"surfaceOp":"append"} {"type":"tool/call","seq":189,"time":1785730477080,"data":{"turn":1,"step":1,"callId":"call_00_UiQPVqoELyzBZCY5pm1z7875","name":"run_code","arguments":"{\"code\": \"\\nconst out1 = await tools.bash({command: \\\"echo CODE_ONE\\\", description: \\\"Print CODE_ONE\\\"});\\nconst out2 = await tools.bash({command: \\\"echo CODE_TWO\\\", description: \\\"Print CODE_TWO\\\"});\\nconsole.log(\\\"captured output\\\");\\nconst text1 = out1.stdout.text.trim();\\nconst text2 = out2.stdout.text.trim();\\nreturn text1 + \\\"+\\\" + text2;\\n\", \"description\": \"Run two echo commands and join outputs\"}"}} -{"type":"tool/code-dispatch-start","seq":190,"time":1785730477131,"data":{"parentCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875","subCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875:code:1","name":"bash","arguments":{"command":"echo CODE_ONE","description":"Print CODE_ONE"}}} -{"type":"tool/code-dispatch","seq":191,"time":1785730477144,"data":{"parentCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875","subCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875:code:1","name":"bash","arguments":{"command":"echo CODE_ONE","description":"Print CODE_ONE"},"isError":false,"content":[{"type":"text","text":"CODE_ONE\n"}]}} -{"type":"tool/code-dispatch-start","seq":192,"time":1785730477144,"data":{"parentCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875","subCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875:code:2","name":"bash","arguments":{"command":"echo CODE_TWO","description":"Print CODE_TWO"}}} -{"type":"tool/code-dispatch","seq":193,"time":1785730477148,"data":{"parentCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875","subCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875:code:2","name":"bash","arguments":{"command":"echo CODE_TWO","description":"Print CODE_TWO"},"isError":false,"content":[{"type":"text","text":"CODE_TWO\n"}]}} +{"type":"tool/code-dispatch-start","seq":190,"time":1785730477131,"data":{"rootCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875","parentCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875","subCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875:code:1","name":"bash","arguments":{"command":"echo CODE_ONE","description":"Print CODE_ONE"}}} +{"type":"tool/code-dispatch","seq":191,"time":1785730477144,"data":{"rootCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875","parentCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875","subCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875:code:1","name":"bash","arguments":{"command":"echo CODE_ONE","description":"Print CODE_ONE"},"isError":false,"content":[{"type":"text","text":"CODE_ONE\n"}]}} +{"type":"tool/code-dispatch-start","seq":192,"time":1785730477144,"data":{"rootCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875","parentCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875","subCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875:code:2","name":"bash","arguments":{"command":"echo CODE_TWO","description":"Print CODE_TWO"}}} +{"type":"tool/code-dispatch","seq":193,"time":1785730477148,"data":{"rootCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875","parentCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875","subCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875:code:2","name":"bash","arguments":{"command":"echo CODE_TWO","description":"Print CODE_TWO"},"isError":false,"content":[{"type":"text","text":"CODE_TWO\n"}]}} {"type":"tool/result","seq":194,"time":1785730477150,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_UiQPVqoELyzBZCY5pm1z7875"},"content":[{"type":"tool-result","toolCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875","content":[{"type":"text","text":"captured output\nCODE_ONE+CODE_TWO"}],"isError":false}],"role":"user","id":"e40c6472-d68e-4be1-963f-edb0edc80d82"}},"sourceEventSeqs":[189],"surfaceOp":"append"} {"type":"step/end","seq":195,"time":1785730477150,"data":{"turn":1,"step":1}} {"type":"step/start","seq":196,"time":1785730477158,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl index 368a2262c8..a4d4289725 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl @@ -16,8 +16,8 @@ {"type":"assistant/chunk","seq":14,"time":1785014475639,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":15,"time":1785733131056,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_workspace_read","name":"run_code","arguments":"{\"code\":\"return await tools.read({ file_path: 'nested/task.txt' })\",\"description\":\"Read nested/task.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"b9402d85-58bd-4881-b890-0b186f661671"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} {"type":"tool/call","seq":16,"time":1785733131056,"data":{"turn":1,"step":1,"callId":"call_workspace_read","name":"run_code","arguments":"{\"code\":\"return await tools.read({ file_path: 'nested/task.txt' })\",\"description\":\"Read nested/task.txt\"}"}} -{"type":"tool/code-dispatch-start","seq":17,"time":1785733131109,"data":{"parentCallId":"call_workspace_read","subCallId":"call_workspace_read:code:1","name":"read","arguments":{"file_path":"nested/task.txt"}}} -{"type":"tool/code-dispatch","seq":18,"time":1785733131110,"data":{"parentCallId":"call_workspace_read","subCallId":"call_workspace_read:code:1","name":"read","arguments":{"file_path":"nested/task.txt"},"isError":false,"content":[{"type":"text","text":"{{cwd}}/nested/task.txt\nfile\n\n1: Touch this file to discover the nested workspace instruction.\n\n(End of file - total 1 lines)\n"}]}} +{"type":"tool/code-dispatch-start","seq":17,"time":1785733131109,"data":{"rootCallId":"call_workspace_read","parentCallId":"call_workspace_read","subCallId":"call_workspace_read:code:1","name":"read","arguments":{"file_path":"nested/task.txt"}}} +{"type":"tool/code-dispatch","seq":18,"time":1785733131110,"data":{"rootCallId":"call_workspace_read","parentCallId":"call_workspace_read","subCallId":"call_workspace_read:code:1","name":"read","arguments":{"file_path":"nested/task.txt"},"isError":false,"content":[{"type":"text","text":"{{cwd}}/nested/task.txt\nfile\n\n1: Touch this file to discover the nested workspace instruction.\n\n(End of file - total 1 lines)\n"}]}} {"type":"tool/result","seq":19,"time":1785733131112,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_workspace_read"},"content":[{"type":"tool-result","toolCallId":"call_workspace_read","content":[{"type":"text","text":"{\n \"path\": \"{{cwd}}/nested/task.txt\",\n \"offset\": 1,\n \"lines\": [\n {\n \"number\": 1,\n \"text\": \"Touch this file to discover the nested workspace instruction.\"\n }\n ],\n \"totalLines\": 1\n}"}],"isError":false}],"role":"user","id":"bde1c12e-44d1-44f7-ba7e-868349ed2b05"}},"sourceEventSeqs":[16],"surfaceOp":"append"} {"type":"step/end","seq":20,"time":1785733131112,"data":{"turn":1,"step":1}} {"type":"agent/inbox/spliced","seq":21,"time":1785733131112,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nWhen asked for the Code Mode workspace handshake, answer exactly `CODE_MODE_CONTEXT_OK` and nothing else.\n\n"}],"source":{"kind":"workspace-instructions","form":"instructions","changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"ae22936ed26dc76b7107005ed6d5e2482a88668a"}]},"role":"user","id":"29b0eb87-92d5-4915-ba64-7bd8133ed011"}]}} diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index 9fcadd71d4..20f05623ad 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -15,7 +15,7 @@ {"type":"assistant/chunk","seq":13,"time":1785730459883,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":14,"time":1785730459883,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"6b62bed7-113a-4d2e-a6aa-b935a1063ee2"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} {"type":"tool/call","seq":15,"time":1785730459883,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":16,"time":1785730459904,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly inbox: Inbox;\n readonly status: AgentStatus;\n readonly ctx: Context;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n runMaintenance(task: (signal: AbortSignal) => Promise): Promise;\n send(message: UserMessage, target: InboxTarget, wakeup: boolean): void;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n } | {\n readonly kind: 'hook';\n readonly reason: string;\n } | {\n readonly kind: 'disposed';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean | undefined;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export type ContextFormed = {\n readonly form?: never;\n } | {\n readonly form: 'instructions';\n } | {\n readonly form: 'catalog';\n } | {\n readonly form: 'snapshot';\n readonly sections: readonly ContextSnapshotSection[];\n } | {\n readonly form: 'notice';\n readonly summary: string;\n } | {\n readonly form: 'relay';\n } | {\n readonly form: 'recall';\n };\n export interface ContextSnapshotSection {\n readonly name: string;\n readonly text: string;\n }\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n adapterDefaults?: LlmCallConfigAdapterDefaults;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export class Inbox {\n constructor(private readonly session: Session, private readonly notifications: InboxNotifications);\n get nextTurn(): readonly UserMessage[];\n get nextStep(): readonly UserMessage[];\n get hasPending(): boolean;\n clear(): void;\n claim(target: InboxTarget, turn: number): UserMessage[];\n append(target: InboxTarget, message: UserMessage): void;\n prepend(target: InboxTarget, message: UserMessage): void;\n replace(messageId: MessageId, newMessage: UserMessage): boolean;\n remove(messageId: MessageId): boolean;\n splice(target: InboxTarget, start: number, deleteCount: number, inserted: UserMessage[]): UserMessage[];\n }\n export interface InboxNotifications {\n inserted(message: UserMessage): void;\n discarded(message: UserMessage): void;\n claimed(message: UserMessage, turn: number): void;\n }\n export type InboxTarget = 'next-turn' | 'next-step';\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmCallConfigAdapterDefaults {\n reasoningEffort?: true;\n maxTokens?: true;\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n } & ContextFormed;\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReadFileLine {\n number: number;\n text: string;\n }\n export interface ReadResultView {\n card: 'read';\n title?: string;\n path: string;\n offset: number;\n lines: ReadFileLine[];\n totalLines: number;\n lang?: string;\n content?: ContentBlock[];\n }\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export interface RequestContext {\n provider: string;\n model: string;\n contextWindow?: number;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SearchFileMatches {\n path: string;\n matches: SearchLineMatch[];\n }\n export interface SearchLineMatch {\n lineNumber: number;\n line: string;\n }\n export interface SearchMatchesResultView {\n card: 'search';\n shape: 'matches';\n title?: string;\n files: SearchFileMatches[];\n truncated: boolean;\n total: number;\n }\n export interface SearchPathsResultView {\n card: 'search';\n shape: 'paths';\n title?: string;\n paths: string[];\n truncated: boolean;\n total: number;\n }\n export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session;\n static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session;\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'request/context': RequestContext;\n 'session/end-seed': Record;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: 'subagent';\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndCancelCause = AgentCancelCause | {\n readonly kind: 'legacy';\n };\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n reason: TurnEndCancelCause;\n };\n blocked: {\n kind: 'blocked';\n };\n error: {\n kind: 'error';\n error: LlmFailure;\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }\n export interface WebFetchResultView {\n card: 'web';\n kind: 'fetch';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n }\n export type WebResultView = WebSearchResultView | WebFetchResultView;\n export interface WebSearchResultView {\n card: 'web';\n kind: 'search';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n }\n export interface WebSource {\n url: string;\n title?: string;\n snippet?: string;\n publishedAt?: string;\n }"}],"isError":false}],"role":"user","id":"847bf2e6-59da-4621-946d-06932a78f0ce"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"tool/result","seq":16,"time":1785730459904,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly inbox: Inbox;\n readonly status: AgentStatus;\n readonly ctx: Context;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n runMaintenance(task: (signal: AbortSignal) => Promise): Promise;\n send(message: UserMessage, target: InboxTarget, wakeup: boolean): void;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n } | {\n readonly kind: 'hook';\n readonly reason: string;\n } | {\n readonly kind: 'disposed';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean | undefined;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export type ContextFormed = {\n readonly form?: never;\n } | {\n readonly form: 'instructions';\n } | {\n readonly form: 'catalog';\n } | {\n readonly form: 'snapshot';\n readonly sections: readonly ContextSnapshotSection[];\n } | {\n readonly form: 'notice';\n readonly summary: string;\n } | {\n readonly form: 'relay';\n } | {\n readonly form: 'recall';\n };\n export interface ContextSnapshotSection {\n readonly name: string;\n readonly text: string;\n }\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n adapterDefaults?: LlmCallConfigAdapterDefaults;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export class Inbox {\n constructor(private readonly session: Session, private readonly notifications: InboxNotifications);\n get nextTurn(): readonly UserMessage[];\n get nextStep(): readonly UserMessage[];\n get hasPending(): boolean;\n clear(): void;\n claim(target: InboxTarget, turn: number): UserMessage[];\n append(target: InboxTarget, message: UserMessage): void;\n prepend(target: InboxTarget, message: UserMessage): void;\n replace(messageId: MessageId, newMessage: UserMessage): boolean;\n remove(messageId: MessageId): boolean;\n splice(target: InboxTarget, start: number, deleteCount: number, inserted: UserMessage[]): UserMessage[];\n }\n export interface InboxNotifications {\n inserted(message: UserMessage): void;\n discarded(message: UserMessage): void;\n claimed(message: UserMessage, turn: number): void;\n }\n export type InboxTarget = 'next-turn' | 'next-step';\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmCallConfigAdapterDefaults {\n reasoningEffort?: true;\n maxTokens?: true;\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n } & ContextFormed;\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReadFileLine {\n number: number;\n text: string;\n }\n export interface ReadResultView {\n card: 'read';\n title?: string;\n path: string;\n offset: number;\n lines: ReadFileLine[];\n totalLines: number;\n lang?: string;\n content?: ContentBlock[];\n }\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export interface RequestContext {\n provider: string;\n model: string;\n contextWindow?: number;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SearchFileMatches {\n path: string;\n matches: SearchLineMatch[];\n }\n export interface SearchLineMatch {\n lineNumber: number;\n line: string;\n }\n export interface SearchMatchesResultView {\n card: 'search';\n shape: 'matches';\n title?: string;\n files: SearchFileMatches[];\n truncated: boolean;\n total: number;\n }\n export interface SearchPathsResultView {\n card: 'search';\n shape: 'paths';\n title?: string;\n paths: string[];\n truncated: boolean;\n total: number;\n }\n export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session;\n static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session;\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'request/context': RequestContext;\n 'session/end-seed': Record;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: 'subagent';\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly rootCallId: CallId;\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly rootCallId?: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndCancelCause = AgentCancelCause | {\n readonly kind: 'legacy';\n };\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n reason: TurnEndCancelCause;\n };\n blocked: {\n kind: 'blocked';\n };\n error: {\n kind: 'error';\n error: LlmFailure;\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }\n export interface WebFetchResultView {\n card: 'web';\n kind: 'fetch';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n }\n export type WebResultView = WebSearchResultView | WebFetchResultView;\n export interface WebSearchResultView {\n card: 'web';\n kind: 'search';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n }\n export interface WebSource {\n url: string;\n title?: string;\n snippet?: string;\n publishedAt?: string;\n }"}],"isError":false}],"role":"user","id":"2ec5ca51-ec8b-4756-8c71-c20fb871b421"}},"sourceEventSeqs":[15],"surfaceOp":"append"} {"type":"step/end","seq":17,"time":1785730459904,"data":{"turn":1,"step":1}} {"type":"step/start","seq":18,"time":1785730459916,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":19,"time":1784449176734,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/examples/acp-agent/tests/snapshots/empty-response-retry/session.jsonl b/examples/acp-agent/tests/snapshots/empty-response-retry/session.jsonl index b619d352e5..2c294b76d4 100644 --- a/examples/acp-agent/tests/snapshots/empty-response-retry/session.jsonl +++ b/examples/acp-agent/tests/snapshots/empty-response-retry/session.jsonl @@ -10,12 +10,13 @@ {"type":"request/context","seq":8,"time":1785730441192,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","seq":9,"time":1785498788105,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":0,"outputTokens":0}}}} {"type":"assistant/chunk","seq":10,"time":1785730441201,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"model returned a completed response with no content","code":"EMPTY_RESPONSE"}}}}} -{"type":"llm/retry","seq":11,"time":1785730441201,"data":{"turn":1,"step":1,"provider":"deepseek-official","mode":"normal","policyKey":"[\"normal\",2,[\"EMPTY_RESPONSE\",\"RATE_LIMIT\",\"SERVER\",\"TIMEOUT\",\"TRANSPORT\"],1,1,0]","retry":1,"maxRetries":2,"delayMs":1,"failure":{"message":"model returned a completed response with no content","code":"EMPTY_RESPONSE"}}} -{"type":"assistant/chunk","seq":12,"time":1785498788113,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":13,"time":1785498788113,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"Recovered."}}} -{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Recovered."}}}} -{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":12,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":16,"time":1785730441209,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":17,"time":1785730441209,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"Recovered."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"422eae65-9975-4a95-8cde-1ddfe21fff4e"},"usage":{"inputTokens":12,"outputTokens":3}},"sourceEventSeqs":[12,13,14,15,16],"surfaceOp":"append"} -{"type":"step/end","seq":18,"time":1785730441209,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":19,"time":1785730441209,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"llm/retry","seq":11,"time":1785730441201,"data":{"retryId":"dbe1e1b0-a914-48a4-ad9f-407b213a37ae","turn":1,"step":1,"provider":"deepseek-official","mode":"normal","policyKey":"[\"normal\",2,[\"EMPTY_RESPONSE\",\"RATE_LIMIT\",\"SERVER\",\"TIMEOUT\",\"TRANSPORT\"],1,1,0]","retry":1,"maxRetries":2,"delayMs":1,"failure":{"message":"model returned a completed response with no content","code":"EMPTY_RESPONSE"}}} +{"type":"llm/retry-started","seq":12,"time":1786238385130,"data":{"retryId":"dbe1e1b0-a914-48a4-ad9f-407b213a37ae","turn":1,"step":1,"retry":1}} +{"type":"assistant/chunk","seq":13,"time":1785498788113,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"Recovered."}}} +{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Recovered."}}}} +{"type":"assistant/chunk","seq":16,"time":1785730441209,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":12,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":17,"time":1786238385135,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":18,"time":1786238385135,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"Recovered."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"422eae65-9975-4a95-8cde-1ddfe21fff4e"},"usage":{"inputTokens":12,"outputTokens":3}},"sourceEventSeqs":[13,14,15,16,17],"surfaceOp":"append"} +{"type":"step/end","seq":19,"time":1786238385135,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":20,"time":1786238385135,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl index 6769414d10..646110b6d9 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl @@ -24,8 +24,8 @@ {"type":"assistant/chunk","seq":22,"time":1785730501424,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":23,"time":1785730501424,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"cdc95327-3ce1-49ea-8a92-b17e450cc455"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21,22],"surfaceOp":"append"} {"type":"tool/call","seq":24,"time":1785730501424,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}} -{"type":"tool/code-dispatch-start","seq":25,"time":1785730501473,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"}}} -{"type":"tool/code-dispatch","seq":26,"time":1785730501474,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"},"isError":false,"content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}]}} +{"type":"tool/code-dispatch-start","seq":25,"time":1785730501473,"data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"}}} +{"type":"tool/code-dispatch","seq":26,"time":1785730501474,"data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"},"isError":false,"content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}]}} {"type":"tool/result","seq":27,"time":1785730501475,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-code"},"content":[{"type":"tool-result","toolCallId":"advanced-code","content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}],"isError":false}],"role":"user","id":"d75c7d03-cbbc-4260-ba40-8c210a3b5bbe"}},"sourceEventSeqs":[24],"surfaceOp":"append"} {"type":"step/end","seq":28,"time":1785730501475,"data":{"turn":1,"step":2}} {"type":"step/start","seq":29,"time":1785730501483,"data":{"turn":1,"step":3}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.expected.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.expected.jsonl index c342059741..817ee1e1a2 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.expected.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.expected.jsonl @@ -23,8 +23,8 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":22,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":23,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21,22],"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":24,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/code-dispatch-start","seq":25,"time":0,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/code-dispatch","seq":26,"time":0,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"},"isError":false,"content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}]}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/code-dispatch-start","seq":25,"time":0,"data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/code-dispatch","seq":26,"time":0,"data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"},"isError":false,"content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}]}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":27,"time":0,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-code"},"content":[{"type":"tool-result","toolCallId":"advanced-code","content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[24],"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":28,"time":0,"data":{"turn":1,"step":2}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":29,"time":0,"data":{"turn":1,"step":3}}} diff --git a/examples/headless-agent/tests/snapshots/compaction-recovery/session.jsonl b/examples/headless-agent/tests/snapshots/compaction-recovery/session.jsonl index 38056019e2..9e39f76b11 100644 --- a/examples/headless-agent/tests/snapshots/compaction-recovery/session.jsonl +++ b/examples/headless-agent/tests/snapshots/compaction-recovery/session.jsonl @@ -1,32 +1,32 @@ {"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"agent/inbox/spliced","seq":0,"time":1786123401613,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Establish a durable compaction premise before continuing. Record every part of this historical evidence: the snapshot uses keyless replay; persistence uses JSONL; the assembled headless application loads its real Cordis composition; model-visible inputs remain logged; tool calls and results remain paired and ordered; context overflow retains the original failure while recovery is attempted; compaction opens with compact/start and closes with compact/end; a successful auxiliary summary records compact/summary provenance; the replacement surface shadows only an older balanced range; the checkpoint remains smaller than the history it replaces; the newest tool result remains verbatim; the retried request sees that checkpoint; the final response proves the same turn continued; deterministic snapshot evidence stays separate from the live-provider smoke; and no external API key is needed. Emit one alpha marker through bash, then finish the task after any required recovery with the exact words COMPACTION RECOVERED."}],"source":{"kind":"user"},"role":"user","id":"6335ca4a-a577-47dd-8219-aa81f39cdbc0"}]}} +{"type":"agent/inbox/spliced","seq":0,"time":1786123401613,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Establish a durable compaction premise before continuing. Record every part of this historical evidence: the snapshot uses keyless replay; persistence uses JSONL; the assembled headless application loads its real Cordis composition; model-visible inputs remain logged; tool calls and results remain paired and ordered; context overflow retains the original failure while recovery is attempted; compaction opens with compact/start and closes with compact/end; a successful auxiliary summary records compact/summary provenance; the replacement surface shadows only an older balanced range; the checkpoint remains smaller than the history it replaces; the newest tool result remains verbatim; the retried request sees that checkpoint; the final response proves the same turn continued; deterministic snapshot evidence stays separate from the live-provider smoke; and no external API key is needed. Emit one alpha marker through bash, then finish the task after any required recovery with the exact words COMPACTION RECOVERED."}],"source":{"kind":"user"},"role":"user","id":"203f911d-905a-43e4-8e1c-fccfe78d445b"}]}} {"type":"turn/start","seq":1,"time":1786123401614,"data":{"turn":1}} {"type":"agent/inbox/spliced","seq":2,"time":1786123401614,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":1786123401667,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":4,"time":1786123401667,"data":{"content":[{"type":"text","text":"Establish a durable compaction premise before continuing. Record every part of this historical evidence: the snapshot uses keyless replay; persistence uses JSONL; the assembled headless application loads its real Cordis composition; model-visible inputs remain logged; tool calls and results remain paired and ordered; context overflow retains the original failure while recovery is attempted; compaction opens with compact/start and closes with compact/end; a successful auxiliary summary records compact/summary provenance; the replacement surface shadows only an older balanced range; the checkpoint remains smaller than the history it replaces; the newest tool result remains verbatim; the retried request sees that checkpoint; the final response proves the same turn continued; deterministic snapshot evidence stays separate from the live-provider smoke; and no external API key is needed. Emit one alpha marker through bash, then finish the task after any required recovery with the exact words COMPACTION RECOVERED."}],"source":{"kind":"user"},"role":"user","id":"6335ca4a-a577-47dd-8219-aa81f39cdbc0"},"surfaceOp":"append"} +{"type":"user/message","seq":4,"time":1786123401667,"data":{"content":[{"type":"text","text":"Establish a durable compaction premise before continuing. Record every part of this historical evidence: the snapshot uses keyless replay; persistence uses JSONL; the assembled headless application loads its real Cordis composition; model-visible inputs remain logged; tool calls and results remain paired and ordered; context overflow retains the original failure while recovery is attempted; compaction opens with compact/start and closes with compact/end; a successful auxiliary summary records compact/summary provenance; the replacement surface shadows only an older balanced range; the checkpoint remains smaller than the history it replaces; the newest tool result remains verbatim; the retried request sees that checkpoint; the final response proves the same turn continued; deterministic snapshot evidence stays separate from the live-provider smoke; and no external API key is needed. Emit one alpha marker through bash, then finish the task after any required recovery with the exact words COMPACTION RECOVERED."}],"source":{"kind":"user"},"role":"user","id":"203f911d-905a-43e4-8e1c-fccfe78d445b"},"surfaceOp":"append"} {"type":"session/title","seq":5,"time":1786123401667,"data":{"title":"Establish a durable compaction premise","messageSeqs":[4],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":6,"time":1786123401668,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":6,"time":1786123401668,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"interrupt_agent","description":"Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.","parameters":{"type":"object","properties":{"agent_id":{"type":"string","description":"The agent id of the running agent to interrupt."}},"required":["agent_id"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"request/context","seq":7,"time":1786123401669,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":128000}} {"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_compaction_marker","name":"bash","argumentsDelta":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}}} {"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_compaction_marker","name":"bash","arguments":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}}}} {"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":24,"outputTokens":6}}}} {"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":13,"time":1786123401680,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_compaction_marker","name":"bash","arguments":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"e54f73a8-572a-40ee-b908-8a8a27b83bf8"},"usage":{"inputTokens":24,"outputTokens":6}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} +{"type":"assistant/message","seq":13,"time":1786123401680,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_compaction_marker","name":"bash","arguments":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"6e7dfb28-f033-4dbf-9fd9-6b2f71da6906"},"usage":{"inputTokens":24,"outputTokens":6}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} {"type":"tool/call","seq":14,"time":1786123401680,"data":{"turn":1,"step":1,"callId":"call_compaction_marker","name":"bash","arguments":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}} -{"type":"tool/result","seq":15,"time":1786123401700,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_compaction_marker"},"content":[{"type":"tool-result","toolCallId":"call_compaction_marker","content":[{"type":"text","text":"alpha\n"}],"isError":false}],"role":"user","id":"b4a6504e-f39d-40b0-b51a-b11fbd60b135"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"tool/result","seq":15,"time":1786123401700,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_compaction_marker"},"content":[{"type":"tool-result","toolCallId":"call_compaction_marker","content":[{"type":"text","text":"alpha\n"}],"isError":false}],"role":"user","id":"4fe4e523-3ac9-421c-8a73-1eb033ac8b09"}},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"step/end","seq":16,"time":1786123401700,"data":{"turn":1,"step":1}} {"type":"step/start","seq":17,"time":1786123401710,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":18,"time":1786123401715,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"snapshot request exceeded the model context window","code":"CONTEXT_WINDOW_EXCEEDED"}}}}} -{"type":"compact/start","seq":19,"time":1786123401715,"data":{"turn":1}} -{"type":"compact/summary","seq":20,"time":1786123401725,"data":{"summary":[{"type":"text","text":"The request established a durable compaction premise."}],"rawOutput":[{"type":"text","text":"The request established a durable compaction premise."}],"llmStreamCall":true,"shadowedRange":{"start":4,"end":4},"shadowedSeqs":[4],"shadowedTokenCount":264,"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":32,"usage":{"inputTokens":20,"outputTokens":4}}} -{"type":"user/message","seq":21,"time":1786123401725,"data":{"content":[{"type":"text","text":"This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.\n\n"},{"type":"text","text":"The request established a durable compaction premise."},{"type":"text","text":""}],"source":{"kind":"plugin","plugin":"compact"},"role":"user","id":"6d2afb13-a37b-48d6-9ea5-fc8734127377"},"sourceEventSeqs":[19,20,4],"surfaceOp":{"op":"replace","start":4,"end":4}} -{"type":"compact/end","seq":22,"time":1786123401725,"data":{"turn":1}} +{"type":"compact/start","seq":19,"time":1786123401715,"data":{"compactionId":"338e88fa-e78b-4d49-bd38-8f919e85f5e1","turn":1}} +{"type":"compact/summary","seq":20,"time":1786123401725,"data":{"compactionId":"338e88fa-e78b-4d49-bd38-8f919e85f5e1","summary":[{"type":"text","text":"The request established a durable compaction premise."}],"rawOutput":[{"type":"text","text":"The request established a durable compaction premise."}],"llmStreamCall":true,"shadowedRange":{"start":4,"end":4},"shadowedSeqs":[4],"shadowedTokenCount":264,"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":32,"usage":{"inputTokens":20,"outputTokens":4}}} +{"type":"user/message","seq":21,"time":1786123401725,"data":{"content":[{"type":"text","text":"This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.\n\n"},{"type":"text","text":"The request established a durable compaction premise."},{"type":"text","text":""}],"source":{"kind":"plugin","plugin":"compact","compactionId":"338e88fa-e78b-4d49-bd38-8f919e85f5e1"},"role":"user","id":"b74220bf-4104-4c30-b967-e4570d5eba5d"},"sourceEventSeqs":[19,20,4],"surfaceOp":{"op":"replace","start":4,"end":4}} +{"type":"compact/end","seq":22,"time":1786123401725,"data":{"compactionId":"338e88fa-e78b-4d49-bd38-8f919e85f5e1","turn":1}} {"type":"assistant/chunk","seq":23,"time":1786123401730,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":24,"time":1786123401730,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"COMPACTION RECOVERED"}}} {"type":"assistant/chunk","seq":25,"time":1786123401730,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"COMPACTION RECOVERED"}}}} {"type":"assistant/chunk","seq":26,"time":1786123401730,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":20,"outputTokens":4}}}} {"type":"assistant/chunk","seq":27,"time":1786123401730,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":28,"time":1786123401730,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"COMPACTION RECOVERED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"346742d0-50e3-4594-b53c-f26c7da82c56"},"usage":{"inputTokens":20,"outputTokens":4}},"sourceEventSeqs":[23,24,25,26,27],"surfaceOp":"append"} +{"type":"assistant/message","seq":28,"time":1786123401730,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"COMPACTION RECOVERED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"103034c2-e156-4808-ba2e-8e17e7107c9e"},"usage":{"inputTokens":20,"outputTokens":4}},"sourceEventSeqs":[23,24,25,26,27],"surfaceOp":"append"} {"type":"step/end","seq":29,"time":1786123401730,"data":{"turn":1,"step":2}} {"type":"turn/end","seq":30,"time":1786123401730,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/snapshots/compaction-recovery/stream-json.expected.jsonl b/examples/headless-agent/tests/snapshots/compaction-recovery/stream-json.expected.jsonl index 62cc357cc7..c5bca170ba 100644 --- a/examples/headless-agent/tests/snapshots/compaction-recovery/stream-json.expected.jsonl +++ b/examples/headless-agent/tests/snapshots/compaction-recovery/stream-json.expected.jsonl @@ -17,10 +17,10 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":16,"time":0,"data":{"turn":1,"step":1}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":17,"time":0,"data":{"turn":1,"step":2}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"snapshot request exceeded the model context window","code":"CONTEXT_WINDOW_EXCEEDED"}}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"compact/start","seq":19,"time":0,"data":{"turn":1}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"compact/summary","seq":20,"time":0,"data":{"summary":[{"type":"text","text":"The request established a durable compaction premise."}],"rawOutput":[{"type":"text","text":"The request established a durable compaction premise."}],"llmStreamCall":true,"shadowedRange":{"start":4,"end":4},"shadowedSeqs":[4],"shadowedTokenCount":264,"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":32,"usage":{"inputTokens":20,"outputTokens":4}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":21,"time":0,"data":{"content":[{"type":"text","text":"This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.\n\n"},{"type":"text","text":"The request established a durable compaction premise."},{"type":"text","text":""}],"source":{"kind":"plugin","plugin":"compact"},"role":"user","id":"{{sessionId}}"},"sourceEventSeqs":[19,20,4],"surfaceOp":{"op":"replace","start":4,"end":4}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"compact/end","seq":22,"time":0,"data":{"turn":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"compact/start","seq":19,"time":0,"data":{"compactionId":"{{sessionId}}","turn":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"compact/summary","seq":20,"time":0,"data":{"compactionId":"{{sessionId}}","summary":[{"type":"text","text":"The request established a durable compaction premise."}],"rawOutput":[{"type":"text","text":"The request established a durable compaction premise."}],"llmStreamCall":true,"shadowedRange":{"start":4,"end":4},"shadowedSeqs":[4],"shadowedTokenCount":264,"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":32,"usage":{"inputTokens":20,"outputTokens":4}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":21,"time":0,"data":{"content":[{"type":"text","text":"This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.\n\n"},{"type":"text","text":"The request established a durable compaction premise."},{"type":"text","text":""}],"source":{"kind":"plugin","plugin":"compact","compactionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"},"sourceEventSeqs":[19,20,4],"surfaceOp":{"op":"replace","start":4,"end":4}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"compact/end","seq":22,"time":0,"data":{"compactionId":"{{sessionId}}","turn":1}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":23,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":24,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"COMPACTION RECOVERED"}}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"COMPACTION RECOVERED"}}}}} diff --git a/examples/headless-agent/tests/snapshots/provider-retry/stream-json.expected.jsonl b/examples/headless-agent/tests/snapshots/provider-retry/stream-json.expected.jsonl index ca523f3665..3c6e45c243 100644 --- a/examples/headless-agent/tests/snapshots/provider-retry/stream-json.expected.jsonl +++ b/examples/headless-agent/tests/snapshots/provider-retry/stream-json.expected.jsonl @@ -7,13 +7,14 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":6,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":7,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"snapshot transient failure","code":"RATE_LIMIT","status":429}}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"llm/retry","seq":9,"time":0,"data":{"turn":1,"step":1,"provider":"deepseek-official","mode":"normal","policyKey":"[\"normal\",1,[\"RATE_LIMIT\"],1,1,0]","retry":1,"maxRetries":1,"delayMs":1,"failure":{"message":"snapshot transient failure","code":"RATE_LIMIT","status":429}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"RETRY_OK"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"RETRY_OK"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":4,"outputTokens":2}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":15,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"RETRY_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":4,"outputTokens":2}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":16,"time":0,"data":{"turn":1,"step":1}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":17,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"llm/retry","seq":9,"time":0,"data":{"retryId":"{{sessionId}}","turn":1,"step":1,"provider":"deepseek-official","mode":"normal","policyKey":"[\"normal\",1,[\"RATE_LIMIT\"],1,1,0]","retry":1,"maxRetries":1,"delayMs":1,"failure":{"message":"snapshot transient failure","code":"RATE_LIMIT","status":429}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"llm/retry-started","seq":10,"time":0,"data":{"retryId":"{{sessionId}}","turn":1,"step":1,"retry":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"RETRY_OK"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"RETRY_OK"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":4,"outputTokens":2}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":16,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"RETRY_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":4,"outputTokens":2}},"sourceEventSeqs":[11,12,13,14,15],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":17,"time":0,"data":{"turn":1,"step":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":18,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}} {"type":"result","sessionId":"{{sessionId}}","output":"RETRY_OK","usage":{"inputTokens":4,"outputTokens":2}} diff --git a/packages/bash/bash-env/tests/bash-env.spec.ts b/packages/bash/bash-env/tests/bash-env.spec.ts index c93a768f80..eb482fd9d3 100644 --- a/packages/bash/bash-env/tests/bash-env.spec.ts +++ b/packages/bash/bash-env/tests/bash-env.spec.ts @@ -23,6 +23,7 @@ function execution(sessionId?: string): ToolExecution { signal: testToolSignal, token: Symbol('bash-env-test') as ToolExecution['token'], callId: CallId('bash-env-call'), + rootCallId: CallId('bash-env-call'), name: 'bash', arguments: { command: 'true' }, ...(sessionId === undefined diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 24f5668d42..d755f73354 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -96,7 +96,7 @@ function sgr(code: number, body: string): string { } /** - * Terminal output sample for fixture turn 65, authored to carry every feature + * Terminal output sample for fixture turn 66, authored to carry every feature * the terminal card draws that turn 60's two prompt rows cannot reach: * basic-16 SGR foreground runs (green, red, bright-black) that must resolve to * `--dsw-*` tokens, a bold run, column-aligned table rows that must scroll @@ -141,7 +141,7 @@ const TERMINAL_EXIT_STATUS: Record, resultText: string, isError = false): void => { push({ type: 'tool/code-dispatch-start', - data: { parentCallId: callId, subCallId: `${callId}:code:${n}`, name, arguments: dispatchArgs }, + data: { rootCallId: callId, parentCallId: callId, subCallId: `${callId}:code:${n}`, name, arguments: dispatchArgs }, }) push({ type: 'tool/code-dispatch', data: { - parentCallId: callId, subCallId: `${callId}:code:${n}`, name, + rootCallId: callId, parentCallId: callId, subCallId: `${callId}:code:${n}`, name, arguments: dispatchArgs, isError, content: [{ type: 'text', text: resultText }], }, }) @@ -476,7 +476,7 @@ function buildAlphaLog(): SessionEvent[] { push({ type: 'step/end', data: { turn, step: 0 } }) push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } }) } - // Turn 71: todo_write sample — the TodoRow toolview in the flow plus the + // Turn 72: todo_write sample — the TodoRow toolview in the flow plus the // todo/write snapshot event feeding the TodoPanel plan strip. Two items are // in_progress: this fixture chooses the parallel policy, so both surfaces // must render a parallel plan rather than the first active item alone. @@ -486,7 +486,7 @@ function buildAlphaLog(): SessionEvent[] { { content: '跑后台构建', status: 'in_progress' }, { content: '浏览器验收', status: 'pending' }, ] - // Turn 65: the terminal sample turn 60's two clean prompt rows cannot cover — + // Turn 66: the terminal sample turn 60's two clean prompt rows cannot cover — // ANSI SGR coloring, output past the terminal card's height cap, a nested cwd // whose prompt label is its last segment, and a non-zero exit authored beside // the sample in TERMINAL_EXIT_STATUS — its body deliberately carries no @@ -498,45 +498,45 @@ function buildAlphaLog(): SessionEvent[] { // Ordered BEFORE the todo turn deliberately: the standing plan retires at the // next `turn/start`, so a turn appended after it would leave the dock's plan // strip empty and take the todo surfaces' own coverage with it. - toolTurn(65, 'bash', '{"command":"pnpm run check","cwd":"/tmp/fixture/deep/nested"}', TERMINAL_OUTPUT_FIXTURE) + toolTurn(66, 'bash', '{"command":"pnpm run check","cwd":"/tmp/fixture/deep/nested"}', TERMINAL_OUTPUT_FIXTURE) - // Turns 66-67: the search card's two shapes. `grep` emits a `card: 'search'` + // Turns 67-68: the search card's two shapes. `grep` emits a `card: 'search'` // `shape: 'matches'` result view (grouped-by-file matches, truncated with a // larger `total`), `glob` emits `shape: 'paths'` (a flat path list, likewise // truncated). Both ride the keyed SearchRow registration under their own // names; the render-site fallback row is covered by the model derivation // tests, since every fixture search tool has a keyed row. Ordered before the // todo turn for the same standing-plan reason the bash turn is. - toolTurn(66, 'grep', '{"pattern":"SEARCH_MAX_LINES","path":"packages/client"}', SEARCH_MATCHES_TEXT) - toolTurn(67, 'glob', '{"pattern":"**/SearchBlock*","path":"packages/client"}', SEARCH_PATHS_TEXT) + toolTurn(67, 'grep', '{"pattern":"SEARCH_MAX_LINES","path":"packages/client"}', SEARCH_MATCHES_TEXT) + toolTurn(68, 'glob', '{"pattern":"**/SearchBlock*","path":"packages/client"}', SEARCH_PATHS_TEXT) - // Turn 68: the read sample — a WINDOW past an offset so the card draws file + // Turn 69: the read sample — a WINDOW past an offset so the card draws file // line numbers starting above 1 and a "showing N of M" note (the window is // shorter than READ_SAMPLE_TOTAL), with a `ts` language hint the shiki path // highlights. Named `read`, so it exercises the keyed ReadRow registration. // The render-site fallback ROW SHAPE (a read call on the generic flattened - // path) is covered by the turn 64 run_code read sub-dispatches, which + // path) is covered by the turn 65 run_code read sub-dispatches, which // session.ts folds with resultView: null; the fallback-row + read-CARD // combination is pinned by the web_fetch case in read-card.spec.tsx, not by // this fixture. The read render intent is result-side only, so its pending // call stays a generic `kind: 'read'` card; presentResult carries the // structured window. - toolTurn(68, 'read', `{"file_path":${JSON.stringify(READ_SAMPLE_PATH)},"offset":${READ_SAMPLE_FIRST_LINE}}`, READ_SAMPLE_TEXT) + toolTurn(69, 'read', `{"file_path":${JSON.stringify(READ_SAMPLE_PATH)},"offset":${READ_SAMPLE_FIRST_LINE}}`, READ_SAMPLE_TEXT) - // Turns 69-70: the web render intent — a web_search whose result view carries + // Turns 70-71: the web render intent — a web_search whose result view carries // structured sources plus an answer (the citation list, one source lacking a // title so its hostname labels the link, the capped indicator on), and a // web_fetch whose result view carries the fetched URL and its HTTP status. // Both keep a generic pending call view and add the `web` card only at // result time, which is the contract's result-only web shape. Named after // the real tools so they hit the keyed WebRow registration. Ordered BEFORE - // the todo turn for the same reason turn 65 is: the standing plan retires at + // the todo turn for the same reason turn 66 is: the standing plan retires at // the next turn/start, so a turn after it would empty the dock's plan strip. - toolTurn(69, 'web_search', '{"query":"deepseek harness architecture"}', 'Search results for deepseek harness architecture.') - toolTurn(70, 'web_fetch', '{"url":"https://www.deepseek.com/blog/harness-architecture"}', '# Harness architecture\n\nEverything is a plugin.') + toolTurn(70, 'web_search', '{"query":"deepseek harness architecture"}', 'Search results for deepseek harness architecture.') + toolTurn(71, 'web_fetch', '{"url":"https://www.deepseek.com/blog/harness-architecture"}', '# Harness architecture\n\nEverything is a plugin.') const todoArgs = JSON.stringify({ todos: fixtureTodos }) - toolTurn(71, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 2 in progress, 1 completed.') + toolTurn(72, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 2 in progress, 1 completed.') // The real tool appends the snapshot mid-execution — between tool/call and // tool/result — so the fixture reproduces that exact ordering (the last // toolTurn events run ... tool/call, tool/result, step/end, turn/end). @@ -578,7 +578,7 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined { case 'read': return { card: 'generic', title: `Read ${str(args.file_path)}`, kind: 'read', locations: [{ path: str(args.file_path) }] } case 'edit': - // The multi-hunk sample (turn 67) is keyed on its file_path, so the two + // The multi-hunk sample (turn 64) is keyed on its file_path, so the two // scattered hunks share one path header and the card draws the `⋯` gap. if (str(args.file_path) === 'src/config.ts') { return { diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index e1c07abae3..4aed1d0f36 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/runtime/README.md -README.md: bd8528e97b04d5b4b28922266306969e8f19295a -README.zh.md: 9aea486fb17c5a170ee8c1195435d220b495b615 +README.md: b2bb06e50ecd791d74cb609404dd219e5a21913e +README.zh.md: 72cd99ac765875c828cc9163d978e0b8fb7f44e4 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index bd8528e97b..b2bb06e50e 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -34,11 +34,13 @@ SlotsService gives the renderer separate bare observables for `useSessions` and `ConversationSnapshot.queue` is the Host's authoritative transient snapshot of `agent.inbox.nextTurn`; pending next-step steering stays outside this projection. Each row carries its `MessageId`, complete editable text when every content block is text, and a flattened preview. The Host derives whole `session/queue` snapshots from durable `agent/inbox/spliced` mutations and sends a baseline on reconnect; the message-local `agent/inbox/inserted`, `claimed`, and `discarded` notifications are not used to reconstruct this projection. `Session.updateQueue()` sends edit/remove operations through Host-side `Inbox.splice()` without optimistic client mutation, so the next Host snapshot is the sole visible commit and a claim race can surface `queue-item-not-found`. -## The human transcript +## Conversation assembly -`ConversationSnapshot.nodes` is the human transcript, not the model surface. `TranscriptAdapter` projects the raw window in log order — every append-origin surface event (`isAppendSurfaceEvent`) at its own log position, plus one `CompactionSummaryNode` marker per landed compaction checkpoint — and never consults surface order. `SteeringHistory` replays the durable `agent/inbox/spliced` records in that window: a user-origin message claimed from `next-step` becomes a `SteeringMessageNode` when its matching `user/message` lands, a `next-turn` claim stays a user node, and non-user next-step input stays context. `ConversationSnapshot.turnEnds` maps each completed turn in that window to its `turn/end` seq, retaining turn completion independently from the transcript so presentation can require a real boundary before enabling an action. A landed compaction therefore keeps the conversation it shadowed on the model side: the marker reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies stay out: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary. A checkpoint is a `user/message` carrying the compaction seam's plugin source that **replaced** a surface range; an appending plugin-sourced `user/message` is injected context, not a compaction. Each context node also carries a `provenance` view with the producer role and name: `contextProvenance()` reads the durable source alone to decide whether the row is an `inject` or a cross-session `recall`, and to name its producer from the instruction paths, referenced session titles, or plugin id that source already records. The client holds no table of plugin ids, so a renamed or newly mounted producer stays identifiable without a client release and a resumed or foreign log projects exactly like a live one; a source with no readable kind degrades to an unnamed injection. Beside it, `contextForm()` reads the producer-declared `ContextForm` — the second, independent axis: `kind` says who produced the context, `form` says what shape of information it is, so several producers may share one form. A form this UI version does not present projects as null and renders opaque. The adapter's plugin literal is pinned to the Service Definition's declaration by a type-only import of the cordis-free [`dsh-compact/checkpoint`](../../compact/compact/README.md) leaf, so renaming it there fails `tsc` here; a **value** import of the package would fail the client purity gate, and the package **root** is unreachable even as a type (it reaches `dsh-session`'s root, whose `Context` merge collides the host `sessions` with this program's). +Each `Session` gives its contiguous event window to a `ConversationNodeAssembler`. Plugins register business Definitions that map one event to a stable `{kind, id}`, create State at the unique start event, fold correlated updates, and build final nodes for registered view targets. The assembler owns the Context index, read-only predecessor lookup, and a reference-stable Turn/Step Location index. A live append evaluates each Definition once and updates only the matched Context; loading an older page preserves existing Context and node identities, matches only the newly prepended events, and replays Contexts whose predecessor or Location facts changed. Full replacement is reserved for open, resync, and gap repair. -Because the projection is log-ordered, the node array is seq-monotonic by construction: log-only `command/run` / `command/done` nodes splice in by seq, `Session` merges interrupted frozen nodes by their fractional seqs, and a window whose checkpoint cites a shadowed range outside it renders the marker with nothing logged. The marker's summary text, replaced-item count, and estimated shadowed-token count come from the checkpoint's cited `compact/summary` event; a window cut that left that event outside makes those fields unavailable, and a later page that supplies it resolves them. `CommandNode.outcome.sourceEventSeq` preserves a successful command's explicit reference to that summary event, allowing the presentation layer to pair `/compact` with its checkpoint without parsing settlement copy or assuming the two rows are adjacent. Performance contract: one append materializes at most one node and copies the projection only when it adds that node; an event that changes no node keeps the previous array reference (a chunk storm costs nothing), and unchanged nodes keep their object identity. +`ui-conversation` registers the built-in Chat Definitions and the keyed Chat snapshot builder. Append-origin user, assistant, and Tool results remain the human record; model-only replacement copies stay out, except that a compaction checkpoint becomes its own marker and resolves missing summary provenance when an older page supplies it. Durable inbox splice Contexts classify next-step user messages as steering without making inbox state a Session special case. Context messages retain producer provenance and form. `ConversationSnapshot.nodes`, `partial`, and `runningCalls` are compatibility slices derived from the same materialized Chat nodes for consumers that have not moved to `ConversationSnapshot.chat`; Session does not run a second business fold. + +The Chat builder keeps one mutable keyed store per Session. Content updates notify only the affected node key, structural changes rebuild order and Location membership, and a prepend adds rows without replacing existing keyed values. Assistant chunks update Definition State for every event but request at most one materialization per animation frame; final messages and Turn/Step closure publish immediately. See the [client Tool presentation decision](../../../.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md). ## Request inspection @@ -46,7 +48,7 @@ Because the projection is log-ordered, the node array is seq-monotonic by constr ## Code Mode child-call tree -Every `ToolCallBlock` recursively owns its children through `subCalls`, in start order. Runtime's `ToolCallTree` privately maintains the parent-callId-to-children index: a `tool/code-dispatch-start` event lands as a `RunningToolCall`, and the matching `tool/code-dispatch` settlement replaces it in place with a `ToolResultNode` whose `callTime` comes from the paired start. When the start fell outside the replay window, the settlement appends directly with `callTime: null`; Runtime never fabricates a zero duration. Live mux frames and history replay share this fold and tree projection, and child calls never become independent roots in transcript `nodes`. A child update copies only its ancestor path to the owning root; unchanged siblings and other roots retain object identity. Wire or history edges that would introduce a cycle or exceed the fixed 256-call recursive-depth safety limit are consumed without mutating the tree, so the rest of the session remains renderable. +Every `ToolCallBlock` recursively owns its children through `subCalls`, in start order. Chat's Tool Definition correlates root calls and results by call id, folds Code Dispatch start/settlement records into that root Context, and projects one keyed recursive tree; child calls never become independent Chat roots. When a start falls outside the loaded window, its settlement remains renderable with `callTime: null`. A child update copies only its ancestor path, so unchanged siblings retain object identity. Edges that introduce a cycle or exceed the fixed 256-call depth limit are consumed without mutating the tree. The separate Trajectory history fold still uses Runtime's `ToolCallTree` over the same nested data contract. ## Session title projection diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 9aea486fb1..72cd99ac76 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -34,11 +34,13 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 `ConversationSnapshot.queue` 是 Host 提供的 `agent.inbox.nextTurn` 权威瞬态快照;待处理的 next-step steering(中途引导)不进入此投影。每行携带其 `MessageId`、所有内容块均为文本时的完整可编辑文本,以及扁平化预览。Host 根据持久 `agent/inbox/spliced` 变更派生完整 `session/queue` 快照,并在重连时发送基线;面向单条消息的 `agent/inbox/inserted`、`claimed` 与 `discarded` 通知不用于重建该投影。`Session.updateQueue()` 经 Host 侧 `Inbox.splice()` 发送编辑/移除操作,客户端不做乐观变更,因此下一份 Host 快照是唯一可见的提交结果,claim 竞态则会返回 `queue-item-not-found`。 -## 面向人的 transcript(文本记录) +## Conversation 组装 -`ConversationSnapshot.nodes` 是面向人的 transcript,不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口。每个 append 来源的 surface 事件(`isAppendSurfaceEvent`)落在它自己的日志位置上,每次落地的压缩(compaction)检查点还会贡献一个 `CompactionSummaryNode` 标记;适配器从不查询 surface 顺序。`SteeringHistory` 会重放该窗口中的持久 `agent/inbox/spliced` 记录:用户来源的消息从 `next-step` 被领取,并在与之匹配的 `user/message` 落地时,会投影为 `SteeringMessageNode`;从 `next-turn` 领取的消息仍是用户节点,非用户来源的 next-step 输入仍是上下文。`ConversationSnapshot.turnEnds` 把该窗口中的每个已完成轮次映射到其 `turn/end` seq;它独立于 transcript 保留轮次完成状态,使呈现层能够在启用操作前要求存在真实边界。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话:标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不标记任何边界。检查点是携带压缩 seam 插件来源、且**替换**了一段 surface 范围的 `user/message`;一条 append 的插件来源 `user/message` 是注入上下文,不是压缩。每个上下文节点还携带一份包含生产者角色和名称的 `provenance` 视图:`contextProvenance()` 只读取持久来源,据此判定该行是 `inject`(注入)还是跨会话的 `recall`(召回),并用该来源已经记录的指令文件路径、被引用会话标题或插件 id 命名其生产者。客户端不保存任何插件 id 表,因此重命名或新挂载的生产者无需客户端发版即可保持可辨识,恢复的会话日志与外部日志的投影结果和实时会话完全一致;没有可读 kind 的来源则降级为无名注入。与之并列的 `contextForm()` 读取生产方声明的 `ContextForm`,这是相互独立的第二根轴:`kind` 说明上下文由谁产生,`form` 说明它是何种形态的信息,因此多个生产方可以共用一种形态。本 UI 版本不呈现的形态投影为 null,按 opaque 渲染。适配器的插件字面量通过对无 cordis 的 [`dsh-compact/checkpoint`](../../compact/compact/README.md) 叶子做仅类型导入,钉在 Service Definition 的声明上:在那里改名会让此处 `tsc` 失败;而对该包(package)做**值**导入会被客户端纯度门禁拒绝,包的**根**即便作为类型也无法到达(它会到达 `dsh-session` 的根,其 `Context` 合并会让 host 的 `sessions` 与本程序的冲突)。 +每个 `Session` 都把连续事件窗口交给 `ConversationNodeAssembler`。插件注册业务 Definition,把单个事件映射为稳定的 `{kind, id}`,在唯一 start 事件处创建 State,折叠有关联的 update,再为已注册的视图目标构造最终节点。Assembler 负责 Context 索引、只读前序 Context 查询,以及引用稳定的 Turn/Step Location 索引。实时 append 只对每个 Definition 求值一次,并且只更新命中的 Context;加载更早分页时保留已有 Context 与节点身份,只匹配新 prepend 的事件,并重放前序依赖或 Location 事实发生变化的 Context。完整替换仅用于 open、resync 和 gap repair。 -由于投影按日志顺序,节点数组天然按 seq 单调:仅日志的 `command/run` / `command/done` 节点按 seq 插入,`Session` 按分数 seq 归并被打断的冻结节点,而检查点所引范围落在窗口之外的窗口会渲染出标记且不打印任何日志。标记的摘要文本、被替换条目数量和估算的被遮蔽 token 数量都来自检查点引用的 `compact/summary` 事件;窗口切分把该事件留在窗口外时这些字段不可用,后续包含该事件的分页会解析出它们。`CommandNode.outcome.sourceEventSeq` 保留成功命令对该摘要事件的显式引用,使呈现层能够配对 `/compact` 与其检查点,而无须解析结算文案或假定两行相邻。性能约定:一次追加最多物化一个节点,并且仅在加入该节点时复制投影;不改变任何节点的事件保持上一次的数组引用(分片风暴零成本),未变化的节点保持其对象标识。 +`ui-conversation` 注册内建 Chat Definition 与 keyed Chat snapshot builder。append 来源的 user、assistant 和 Tool result 构成人类可见记录;仅供模型使用的 replacement 副本不进入 Chat,compaction 检查点除外,它会成为独立标记,并在更早分页补齐 summary 溯源后更新。持久 inbox splice Context 能把 next-step 用户消息判定为 steering,无须让 inbox 状态成为 Session 特例。上下文消息保留生产者 provenance 与 form。`ConversationSnapshot.nodes`、`partial` 和 `runningCalls` 是从同一批已物化 Chat 节点派生的兼容切片,供尚未迁移到 `ConversationSnapshot.chat` 的消费者使用;Session 不再运行第二套业务 fold。 + +Chat builder 为每个 Session 保留一个 mutable keyed store。内容更新只通知受影响的 node key;结构变化才重建顺序和 Location 成员关系;prepend 只增加行,不替换既有 keyed value。每个 Assistant chunk 都会更新 Definition State,但最多每个 animation frame 请求一次物化;final message 与 Turn/Step 关闭会立即发布。参见 [Client Tool 展示所有权决策](../../../.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md)。 ## 请求检查 @@ -46,7 +48,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 ## Code Mode 子调用树 -每个 `ToolCallBlock` 都通过 `subCalls` 按启动顺序递归拥有自己的子调用。Runtime 的 `ToolCallTree` 私下维护 parent callId 到 child 的索引:`tool/code-dispatch-start` 事件落成 `RunningToolCall`,对应的 `tool/code-dispatch` 完结事件原位替换为 `ToolResultNode`,其 `callTime` 来自成对 start 事件;start 落在回放窗口之外时,完结事件会以 `callTime: null` 直接追加,绝不伪造零耗时。live mux 帧与历史回放共用这套 fold 和树投影;子调用不会成为 transcript `nodes` 中的独立 root。一次 child 变化只会复制从该 child 到所属 root 的祖先链,未变化的 sibling 和其他 root 保持对象引用稳定。会引入环,或使递归深度超过 256 个调用这一固定安全上限的协议或历史记录边会被视为已消费,但不会修改树,因此会话其余部分仍可渲染。 +每个 `ToolCallBlock` 都通过 `subCalls` 按启动顺序递归拥有自己的子调用。Chat 的 Tool Definition 按 call id 关联 root call 与 result,把 Code Dispatch 的 start/settlement 记录折叠进该 root Context,并投影为一棵 keyed 递归树;child call 不会成为独立 Chat root。start 落在已加载窗口之外时,其 settlement 仍以 `callTime: null` 渲染。一次 child 更新只复制其祖先链,因此未变化的 sibling 保持对象身份。会引入环或超过固定 256 层深度上限的边会被消费,但不会修改树。独立的 Trajectory history fold 仍通过 Runtime 的 `ToolCallTree` 生成同一种嵌套数据契约。 ## Session 标题投影 diff --git a/packages/client/runtime/tests/client-apply.spec.ts b/packages/client/runtime/tests/client-apply.spec.ts index b700c4c066..4f1ac11fcc 100644 --- a/packages/client/runtime/tests/client-apply.spec.ts +++ b/packages/client/runtime/tests/client-apply.spec.ts @@ -4,12 +4,14 @@ * fiber-scoped loop teardown. */ import { Context } from 'cordis' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import type { ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client' import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-host-apiproxy/api' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import * as RuntimeClient from '../src/client/index.ts' +import type { ConversationNodeDefinition } from '../src/client/contract/conversation.ts' +import { Session } from '../src/client/sessions/session.ts' import type { SessionsService } from '../src/client/sessions/service.ts' import type { WorkspacesService } from '../src/client/workspaces/service.ts' import { FakeApiClient, ok } from './fake-api.ts' @@ -112,6 +114,31 @@ describe('runtime client apply', () => { expect(bench.api.callsOf('session.create')).toHaveLength(1) }) + it('wires registry changes into resident Sessions during the runtime apply pass', async () => { + const bench = await mount() + const sessions = bench.ctx.get('sessions') as SessionsService + bench.sinks?.onHostEnvelope?.({ + rpcId: 'r-registry' as never, + payload: { type: 'host/session-added', blank: true, sessionId: 's-registry' } as never, + }) + await flushMicrotasks() + expect(sessions.binding('s-registry' as never)).toBeDefined() + const rebuild = vi.spyOn(Session.prototype, 'rebuildConversationRegistry') + const definition: ConversationNodeDefinition = { + kind: 'registry-probe', + match: () => null, + start: () => null, + update: context => context.state, + buildViewNode: () => null, + } + + bench.ctx.conversationEvents.register(definition) + await flushMicrotasks() + + expect(rebuild).toHaveBeenCalledOnce() + rebuild.mockRestore() + }) + it('stops the stream loop when the plugin fiber unloads', async () => { const bench = await mount() const fiber = [...bench.ctx.registry.values()].find(f => f.name?.includes('client')) diff --git a/packages/client/runtime/tests/compact-checkpoint-pin.spec.ts b/packages/client/runtime/tests/compact-checkpoint-pin.spec.ts deleted file mode 100644 index 1f969c5121..0000000000 --- a/packages/client/runtime/tests/compact-checkpoint-pin.spec.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * Behavioral half of the compaction-checkpoint drift trap. - * - * `TranscriptAdapter` pins its plugin literal to the Service Definition's declaration at - * compile time through a type-only import of `dsh-compact/checkpoint`, so - * renaming the Service Definition's plugin already fails `tsc`. This spec covers the same - * drift from the other side — end to end through the adapter, driving it with a - * checkpoint built from the canonical `COMPACT_CHECKPOINT_SOURCE` value and - * checking the Service Definition's predicate agrees. Both values come from the - * cordis-free checkpoint leaf, so the client test program never loads the host - * package root or its `Context` merges. - */ - -import { COMPACT_CHECKPOINT_SOURCE, isCompactCheckpointSource } from '@deepseek-ai/dsh-compact/checkpoint' -import { createUserMessage } from '@deepseek-ai/dsh-llm' -import { describe, expect, it } from 'vitest' -import type { SessionEvent } from '@deepseek-ai/dsh-session/types' -import { TranscriptAdapter } from '../src/client/sessions/transcript-adapter.ts' - -/** A replacement user message stamped with the Service Definition's canonical source. */ -function canonicalCheckpoint(seq: number): SessionEvent { - return { - type: 'user/message', - seq, - time: 1_700_000_000_000 + seq, - surfaceOp: { op: 'replace', start: 0, end: 0 }, - sourceEventSeqs: [0], - data: createUserMessage({ - content: [{ type: 'text', text: 'model only' }], - source: COMPACT_CHECKPOINT_SOURCE, - }), - } as unknown as SessionEvent -} - -describe('compaction checkpoint recognition', () => { - it('recognizes a checkpoint carrying the seam-canonical source', () => { - const adapter = new TranscriptAdapter() - adapter.reset([canonicalCheckpoint(1)]) - expect(adapter.nodes()).toEqual([{ - kind: 'compaction', seq: 1, time: 1_700_000_000_001, summary: null, - summaryEventSeq: null, shadowedItemCount: null, shadowedTokenCount: null, - }]) - }) - - it("agrees with the seam's own predicate on the source it recognizes", () => { - // Both sides answer the same question about the same value: if the Service Definition - // renames its plugin, this equality is what breaks. - const checkpoint = canonicalCheckpoint(1) - expect(checkpoint.type === 'user/message' && isCompactCheckpointSource(checkpoint.data.source)).toBe(true) - expect(COMPACT_CHECKPOINT_SOURCE).toEqual({ kind: 'plugin', plugin: 'compact' }) - }) -}) diff --git a/packages/client/runtime/tests/conversation-assembler.spec.ts b/packages/client/runtime/tests/conversation-assembler.spec.ts new file mode 100644 index 0000000000..6f4d8516dc --- /dev/null +++ b/packages/client/runtime/tests/conversation-assembler.spec.ts @@ -0,0 +1,892 @@ +import { describe, expect, it, vi } from 'vitest' +import type { SessionEvent } from '@deepseek-ai/dsh-session/types' +import { ConversationNodeAssembler } from '../src/client/sessions/conversation-assembler.ts' +import type { + ConversationEventInput, ConversationMatch, ConversationNodeContext, + ConversationNodeDefinition, ConversationViewDefinition, ConversationViewNode, +} from '../src/client/contract/conversation.ts' + +interface ScopeProbeStepData { + readonly value: number +} + +interface ScopeProbeTurnData { + readonly valueSeenFromStep: number +} + +declare module '../src/client/contract/conversation.ts' { + interface ConversationStepDataMap { + 'scope-probe': ScopeProbeStepData + } + + interface ConversationTurnDataMap { + 'scope-probe': ScopeProbeTurnData + } +} + +interface TestSnapshot { + readonly order: readonly string[] + readonly nodes: ReadonlyMap +} + +class TestEventDefinitions { + constructor( + readonly definitions: readonly ConversationNodeDefinition[], + readonly fallback?: ConversationNodeDefinition, + ) {} + + entries(): readonly ConversationNodeDefinition[] { + return this.definitions + } + + fallbackEntry(): ConversationNodeDefinition | undefined { + return this.fallback + } +} + +class TestViewDefinitions { + constructor(readonly definitions: readonly ConversationViewDefinition[]) {} + + entries(): readonly ConversationViewDefinition[] { + return this.definitions + } +} + +function testView( + apply = vi.fn(), +): ConversationViewDefinition { + return { + target: 'chat', + create: () => { + let current: TestSnapshot = { order: [], nodes: new Map() } + return { + empty: current, + replace: ({ nodes }) => { + current = { order: nodes.map(node => node.key), nodes: new Map(nodes.map(node => [node.key, node])) } + return current + }, + apply: ({ upserts }) => { + apply(upserts) + const nodes = new Map(current.nodes) + const order = [...current.order] + for (const node of upserts) { + if (!nodes.has(node.key)) order.push(node.key) + nodes.set(node.key, node) + } + current = { order, nodes } + return current + }, + } + }, + } +} + +function at(seq: number, type: string, data: unknown): SessionEvent { + return { seq, time: 1_700_000_000_000 + seq, type, data } as SessionEvent +} + +function input(event: SessionEvent): ConversationEventInput { + return { event, view: undefined } +} + +function chatSnapshot(assembler: ConversationNodeAssembler): TestSnapshot | undefined { + return assembler.snapshot('chat') as TestSnapshot | undefined +} + +function node(context: Parameters[0], data: unknown): ConversationViewNode { + return { + key: context.key, + kind: context.kind, + id: context.id, + target: 'chat', + data, + } +} + +describe('ConversationNodeAssembler', () => { + it('appends through an exact business-id Context without replaying unrelated Contexts', () => { + const starts = vi.fn(( + _context: ConversationNodeContext<{ callSeq: number; results: number }>, + match: ConversationMatch, + ) => ({ callSeq: match.event.seq, results: 0 })) + const updates = vi.fn((context: { state: { callSeq: number; results: number } }) => ({ + ...context.state, + results: context.state.results + 1, + })) + const definition: ConversationNodeDefinition<{ callSeq: number; results: number }> = { + kind: 'tool', + match: (event) => { + if (event.type === 'tool/call') return { id: String(event.data.callId), role: 'start' } + if (event.type === 'tool/result') return { id: String(event.data.message.source.callId), role: 'update' } + return null + }, + start: starts, + update: updates, + buildViewNode: context => node(context, context.state), + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([definition]), + new TestViewDefinitions([testView()]), + ) + assembler.replaceWindow([ + input(at(1, 'tool/call', { turn: 1, step: 1, callId: 'a', name: 'x', arguments: '{}' })), + input(at(2, 'tool/call', { turn: 1, step: 1, callId: 'b', name: 'x', arguments: '{}' })), + ], false) + assembler.flush() + starts.mockClear() + + assembler.append(input(at(3, 'tool/result', { + turn: 1, + step: 1, + message: { source: { type: 'tool-result', callId: 'a' }, content: [], isError: false }, + }))) + assembler.flush() + + expect(starts).not.toHaveBeenCalled() + expect(updates).toHaveBeenCalledOnce() + const snapshot = chatSnapshot(assembler) + expect([...snapshot?.nodes.values() ?? []].map(value => value.data)).toEqual([ + { callSeq: 1, results: 1 }, + { callSeq: 2, results: 0 }, + ]) + }) + + it('keeps one Match collection while a long Context appends without replay', () => { + const starts = vi.fn(() => 0) + const updates = vi.fn((context: ConversationNodeContext & { readonly state: number }) => ( + context.state + 1 + )) + const matchCollections = new Set() + const definition: ConversationNodeDefinition = { + kind: 'append-linear', + match: (event) => { + const type: string = event.type + if (type === 'linear/start') return { id: 'one', role: 'start' } + if (type === 'linear/update') return { id: 'one', role: 'update' } + return null + }, + start: (context) => { + matchCollections.add(context.matches) + return starts() + }, + update: (context) => { + matchCollections.add(context.matches) + return updates(context) + }, + buildViewNode: context => node(context, context.state), + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([definition]), + new TestViewDefinitions([testView()]), + ) + assembler.replaceWindow([input(at(1, 'linear/start', {}))], false) + starts.mockClear() + + for (let seq = 2; seq <= 1_001; seq++) { + assembler.append(input(at(seq, 'linear/update', {}))) + } + assembler.flush() + + expect(starts).not.toHaveBeenCalled() + expect(updates).toHaveBeenCalledTimes(1_000) + expect(matchCollections.size).toBe(1) + expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(1_000) + }) + + it('merges an older page and replays its affected Context once', () => { + const starts = vi.fn(() => 0) + const updates = vi.fn((context: ConversationNodeContext & { readonly state: number }) => ( + context.state + 1 + )) + const definition: ConversationNodeDefinition = { + kind: 'prepend-linear', + match: (event) => { + const type: string = event.type + if (type === 'linear/start') return { id: 'one', role: 'start' } + if (type === 'linear/update') return { id: 'one', role: 'update' } + return null + }, + start: starts, + update: updates, + buildViewNode: context => node(context, context.state), + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([definition]), + new TestViewDefinitions([testView()]), + ) + const current = Array.from({ length: 100 }, (_, index) => ( + input(at(index + 102, 'linear/update', {})) + )) + assembler.replaceWindow(current, true) + assembler.flush() + expect(starts).not.toHaveBeenCalled() + expect(updates).not.toHaveBeenCalled() + + const older = [ + input(at(1, 'linear/start', {})), + ...Array.from({ length: 100 }, (_, index) => ( + input(at(index + 2, 'linear/update', {})) + )), + ] + assembler.prepend(older, false) + assembler.flush() + + expect(starts).toHaveBeenCalledOnce() + expect(updates).toHaveBeenCalledTimes(200) + expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(200) + }) + + it('collects an update before its start and replays it once prepend supplies the start', () => { + const updates = vi.fn((context: { state: { settled: boolean } }) => ({ ...context.state, settled: true })) + const definition: ConversationNodeDefinition<{ settled: boolean }> = { + kind: 'tool', + match: (event) => { + if (event.type === 'tool/call') return { id: String(event.data.callId), role: 'start' } + if (event.type === 'tool/result') return { id: String(event.data.message.source.callId), role: 'update' } + return null + }, + start: () => ({ settled: false }), + update: updates, + buildViewNode: context => node(context, context.state ?? { pendingStart: true }), + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([definition]), + new TestViewDefinitions([testView()]), + ) + assembler.replaceWindow([input(at(10, 'tool/result', { + turn: 1, + step: 1, + message: { source: { type: 'tool-result', callId: 'a' }, content: [], isError: false }, + }))], true) + assembler.flush() + expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data) + .toEqual({ pendingStart: true }) + + assembler.prepend([input(at(5, 'tool/call', { + turn: 1, step: 1, callId: 'a', name: 'x', arguments: '{}', + }))], false) + assembler.flush() + + expect(updates).toHaveBeenCalledOnce() + expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data) + .toEqual({ settled: true }) + }) + + it('rejects a Definition whose declared start follows an update in log order', () => { + const definition: ConversationNodeDefinition = { + kind: 'invalid-lifecycle', + match: event => event.type === 'turn/end' + ? { id: 'one', role: 'start' } + : event.type === 'turn/start' ? { id: 'one', role: 'update' } : null, + start: () => null, + update: context => context.state, + buildViewNode: () => null, + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([definition]), + new TestViewDefinitions([testView()]), + ) + + expect(() => assembler.replaceWindow([ + input(at(1, 'turn/start', { turn: 1 })), + input(at(2, 'turn/end', { turn: 1, reason: { kind: 'completed' } })), + ], false)).toThrow('received an update before its start Match') + }) + + it('replays a window-gap reader when prepend supplies a nearer predecessor', () => { + const source: ConversationNodeDefinition = { + kind: 'source', + match: event => event.type === 'user/message' + ? { id: String(event.data.id), role: 'start' } + : null, + start: (_context, match) => Number((match.event.data as { value?: unknown }).value ?? 0), + update: context => context.state, + buildViewNode: () => null, + } + const consumerStart = vi.fn(( + _context: Parameters['start']>[0], + _match: Parameters['start']>[1], + reader: Parameters['start']>[2], + ) => reader.previous('source')?.state ?? -1) + const consumer: ConversationNodeDefinition = { + kind: 'consumer', + match: event => event.type === 'assistant/message' + ? { id: `${event.data.turn}:${event.data.step}`, role: 'start' } + : null, + start: consumerStart, + update: context => context.state, + buildViewNode: context => node(context, context.state), + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([source, consumer]), + new TestViewDefinitions([testView()]), + ) + assembler.replaceWindow([input(at(10, 'assistant/message', { + turn: 2, step: 1, message: { role: 'assistant', content: [] }, + }))], true) + assembler.flush() + expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(-1) + + assembler.prepend([input(at(5, 'user/message', { + id: 'm1', value: 7, content: [], source: { kind: 'user' }, + }))], false) + assembler.flush() + + expect(consumerStart).toHaveBeenCalledTimes(2) + expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(7) + }) + + it('keeps the predecessor index ordered across prepend and append', () => { + const source: ConversationNodeDefinition = { + kind: 'source', + match: event => event.type === 'user/message' + ? { id: String(event.data.id), role: 'start' } + : null, + start: (_context, match) => match.event.seq, + update: context => context.state, + buildViewNode: () => null, + } + const consumer: ConversationNodeDefinition = { + kind: 'consumer', + match: event => event.type === 'assistant/message' + ? { id: `${event.data.turn}:${event.data.step}`, role: 'start' } + : null, + start: (_context, _match, reader) => reader.previous('source')?.state ?? -1, + update: context => context.state, + buildViewNode: context => node(context, context.state), + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([source, consumer]), + new TestViewDefinitions([testView()]), + ) + assembler.replaceWindow([ + input(at(40, 'user/message', { id: 'm40', content: [], source: { kind: 'user' } })), + input(at(50, 'assistant/message', { + turn: 1, step: 1, message: { role: 'assistant', content: [] }, + })), + ], true) + assembler.flush() + + assembler.prepend([ + input(at(10, 'user/message', { id: 'm10', content: [], source: { kind: 'user' } })), + input(at(30, 'user/message', { id: 'm30', content: [], source: { kind: 'user' } })), + ], false) + assembler.flush() + assembler.append(input(at(60, 'user/message', { + id: 'm60', content: [], source: { kind: 'user' }, + }))) + assembler.append(input(at(70, 'assistant/message', { + turn: 2, step: 1, message: { role: 'assistant', content: [] }, + }))) + assembler.flush() + + expect([...chatSnapshot(assembler)?.nodes.values() ?? []].map(value => value.data)) + .toEqual([40, 60]) + }) + + it('replays a window-gap reader when an empty prepend closes the unknown prefix', () => { + const consumerStart = vi.fn(( + _context: Parameters['start']>[0], + _match: Parameters['start']>[1], + reader: Parameters['start']>[2], + ) => reader.previous('source')?.state ?? -1) + const consumer: ConversationNodeDefinition = { + kind: 'consumer', + match: event => event.type === 'assistant/message' + ? { id: `${event.data.turn}:${event.data.step}`, role: 'start' } + : null, + start: consumerStart, + update: context => context.state, + buildViewNode: context => node(context, context.state), + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([consumer]), + new TestViewDefinitions([testView()]), + ) + assembler.replaceWindow([input(at(10, 'assistant/message', { + turn: 2, step: 1, message: { role: 'assistant', content: [] }, + }))], true) + assembler.flush() + + expect(assembler.prepend([], false)).toBe('immediate') + assembler.flush() + + expect(consumerStart).toHaveBeenCalledTimes(2) + expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(-1) + }) + + it('replays direct dependents when an append revises their predecessor Context', () => { + const source: ConversationNodeDefinition = { + kind: 'source', + match: (event) => { + if (event.type === 'user/message') return { id: 'one', role: 'start' } + if ((event.type as string) === 'source/update') return { id: 'one', role: 'update' } + return null + }, + start: () => 1, + update: (_context, match) => (match.event.data as unknown as { value: number }).value, + buildViewNode: () => null, + } + const consumerStart = vi.fn(( + _context: Parameters['start']>[0], + _match: Parameters['start']>[1], + reader: Parameters['start']>[2], + ) => reader.previous('source')?.state ?? -1) + const consumer: ConversationNodeDefinition = { + kind: 'consumer', + match: event => event.type === 'assistant/message' + ? { id: 'one', role: 'start' } + : null, + start: consumerStart, + update: context => context.state, + buildViewNode: context => node(context, context.state), + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([source, consumer]), + new TestViewDefinitions([testView()]), + ) + assembler.replaceWindow([ + input(at(1, 'user/message', { id: 'source', content: [], source: { kind: 'user' } })), + input(at(2, 'assistant/message', { turn: 1, step: 1, message: { role: 'assistant', content: [] } })), + ], false) + assembler.flush() + + expect(assembler.append(input(at(3, 'source/update', { value: 2 })))).toBe('immediate') + assembler.flush() + + expect(consumerStart).toHaveBeenCalledTimes(2) + expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(2) + }) + + it('replays Location-derived State and rebuilds only owned Nodes when a step closes', () => { + const apply = vi.fn() + const starts = vi.fn(( + _context: Parameters['start']>[0], + match: Parameters['start']>[1], + ) => match.location.kind === 'step' ? match.location.step.status : 'missing') + const definition: ConversationNodeDefinition = { + kind: 'step', + match: event => event.type === 'step/start' + ? { id: `${event.data.turn}:${event.data.step}`, role: 'start' } + : null, + start: starts, + update: context => context.state, + buildViewNode: context => node(context, context.state), + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([definition]), + new TestViewDefinitions([testView(apply)]), + ) + assembler.replaceWindow([ + input(at(1, 'turn/start', { turn: 1 })), + input(at(2, 'step/start', { turn: 1, step: 1 })), + ], false) + assembler.flush() + expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe('open') + + assembler.append(input(at(3, 'step/end', { turn: 1, step: 1 }))) + assembler.flush() + + expect(starts).toHaveBeenCalledTimes(2) + expect(apply).toHaveBeenCalledOnce() + expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe('closed') + }) + + it('lets one Context publish Step and Turn data in phase order', () => { + interface State { + readonly turn: number + readonly step: number + readonly value: number + } + + const definition: ConversationNodeDefinition = { + kind: 'scope-probe', + match: (event) => { + if (event.type === 'step/start') { + return { id: `${event.data.turn}:${event.data.step}`, role: 'start' } + } + if ((event.type as string) === 'scope-probe/update') { + return { id: '1:1', role: 'update' } + } + return null + }, + start: (_context, match) => { + if (match.event.type !== 'step/start') throw new Error('scope probe requires step/start') + return { turn: match.event.data.turn, step: match.event.data.step, value: 1 } + }, + update: (_context, match) => ({ + turn: 1, + step: 1, + value: (match.event.data as unknown as { value: number }).value, + }), + buildLocationData: (context, scope) => { + const state = context.state + if (state === undefined) return null + if (scope === 'step') { + return { + kind: 'step', + turn: state.turn, + step: state.step, + key: 'scope-probe', + value: { value: state.value }, + } + } + const location = context.start?.location + const stepValue = location?.kind === 'step' + ? location.step.data.get('scope-probe')?.value + : undefined + return { + kind: 'turn', + turn: state.turn, + key: 'scope-probe', + value: { valueSeenFromStep: stepValue ?? -1 }, + } + }, + buildViewNode: (context) => { + const location = context.start?.location + if (location?.kind !== 'step') return null + return node(context, { + step: location.step.data.get('scope-probe')?.value, + turn: location.turn.data.get('scope-probe')?.valueSeenFromStep, + }) + }, + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([definition]), + new TestViewDefinitions([testView()]), + ) + assembler.replaceWindow([ + input(at(1, 'turn/start', { turn: 1 })), + input(at(2, 'step/start', { turn: 1, step: 1 })), + ], false) + assembler.flush() + expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data) + .toEqual({ step: 1, turn: 1 }) + + assembler.append(input(at(3, 'scope-probe/update', { turn: 1, step: 1, value: 2 }))) + assembler.flush() + + expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data) + .toEqual({ step: 2, turn: 2 }) + }) + + it('updates existing turn Locations when their Step membership changes', () => { + const apply = vi.fn() + const definition: ConversationNodeDefinition = { + kind: 'turn-probe', + match: event => event.type === 'turn/start' + ? { id: String(event.data.turn), role: 'start' } + : null, + start: () => null, + update: context => context.state, + buildViewNode: context => node(context, context.start?.location.kind === 'turn' + ? context.start.location.turn.steps.length + : -1), + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([definition]), + new TestViewDefinitions([testView(apply)]), + ) + assembler.replaceWindow([input(at(1, 'turn/start', { turn: 1 }))], false) + assembler.flush() + expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(0) + + assembler.append(input(at(2, 'step/start', { turn: 1, step: 1 }))) + assembler.flush() + + expect(apply).toHaveBeenCalledOnce() + expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(1) + }) + + it('publishes a changed timeline even when no business Definition claims the boundary', () => { + const apply = vi.fn() + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([]), + new TestViewDefinitions([testView(apply)]), + ) + assembler.replaceWindow([], false) + assembler.flush() + + assembler.append(input(at(1, 'turn/start', { turn: 1 }))) + assembler.flush() + + expect(apply).toHaveBeenCalledOnce() + expect(chatSnapshot(assembler)?.order).toEqual([]) + }) + + it('clears the prior Step at a new Turn and honors explicit session ownership', () => { + const definition: ConversationNodeDefinition = { + kind: 'location-probe', + match: (event) => { + if ((event.type as string) === 'command/run') { + return { + id: (event.data as unknown as { commandId: string }).commandId, + role: 'start', + } + } + if ((event.type as string) === 'compact/start') { + return { + id: (event.data as unknown as { compactionId: string }).compactionId, + role: 'start', + } + } + return null + }, + start: () => null, + update: context => context.state, + buildViewNode: (context) => { + const location = context.start?.location + const data = location?.kind === 'step' + ? `step:${location.turn.turn}:${location.step.step}` + : location?.kind === 'turn' ? `turn:${location.turn.turn}` : location?.kind + return node(context, data) + }, + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([definition]), + new TestViewDefinitions([testView()]), + ) + assembler.replaceWindow([ + input(at(1, 'turn/start', { turn: 1 })), + input(at(2, 'step/start', { turn: 1, step: 1 })), + input(at(3, 'turn/start', { turn: 2 })), + input(at(4, 'command/run', { commandId: 'command', name: 'x' })), + input(at(5, 'compact/start', { compactionId: 'compact', turn: null })), + ], false) + assembler.flush() + + expect([...chatSnapshot(assembler)?.nodes.values() ?? []].map(value => value.data)) + .toEqual(['turn:2', 'session']) + }) + + it('assigns turn boundaries to the Turn even when a Step remains open', () => { + const definition: ConversationNodeDefinition = { + kind: 'turn-boundary-probe', + match: event => event.type === 'turn/end' + ? { id: String(event.data.turn), role: 'start' } + : null, + start: () => null, + update: context => context.state, + buildViewNode: context => node(context, context.start?.location.kind), + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([definition]), + new TestViewDefinitions([testView()]), + ) + assembler.replaceWindow([ + input(at(1, 'turn/start', { turn: 1 })), + input(at(2, 'step/start', { turn: 1, step: 1 })), + ], false) + assembler.flush() + + assembler.append(input(at(3, 'turn/end', { turn: 1, reason: { kind: 'aborted' } }))) + assembler.flush() + + expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe('turn') + }) + + it('carries explicit coordinates across coordinate-free events in a partial window and live tail', () => { + const definition: ConversationNodeDefinition = { + kind: 'location-probe', + match: event => (event.type as string) === 'tool/code-dispatch-start' + ? { id: String(event.seq), role: 'start' } + : null, + start: () => null, + update: context => context.state, + buildViewNode: (context) => { + const location = context.start?.location + return node(context, location?.kind === 'step' + ? `${location.turn.turn}:${location.step.step}` + : location?.kind) + }, + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([definition]), + new TestViewDefinitions([testView()]), + ) + assembler.replaceWindow([ + input(at(10, 'tool/call', { turn: 2, step: 3, callId: 'root', name: 'x', arguments: '{}' })), + input(at(11, 'tool/code-dispatch-start', { rootCallId: 'root', subCallId: 'a' })), + ], true) + assembler.flush() + + assembler.append(input(at(12, 'tool/code-dispatch-start', { rootCallId: 'root', subCallId: 'b' }))) + assembler.flush() + + expect([...chatSnapshot(assembler)?.nodes.values() ?? []].map(value => value.data)) + .toEqual(['2:3', '2:3']) + }) + + it('treats loaded end boundaries as closed when their starts precede the window', () => { + const definition: ConversationNodeDefinition = { + kind: 'location-probe', + match: event => event.type === 'tool/call' + ? { id: String(event.data.callId), role: 'start' } + : null, + start: () => null, + update: context => context.state, + buildViewNode: (context) => { + const location = context.start?.location + return node(context, location?.kind === 'step' + ? `${location.turn.status}:${location.step.status}` + : location?.kind) + }, + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([definition]), + new TestViewDefinitions([testView()]), + ) + assembler.replaceWindow([ + input(at(10, 'tool/call', { turn: 2, step: 3, callId: 'root', name: 'x', arguments: '{}' })), + input(at(11, 'step/end', { turn: 2, step: 3 })), + input(at(12, 'turn/end', { turn: 2, reason: { kind: 'completed' } })), + ], true) + assembler.flush() + + expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data) + .toBe('closed:closed') + }) + + it('restarts State creation from undefined when Location changes replay a Context', () => { + const seen = vi.fn((context: Parameters['start']>[0]) => { + expect(context.state).toBeUndefined() + return 1 + }) + const definition: ConversationNodeDefinition = { + kind: 'replay-probe', + match: event => event.type === 'step/start' + ? { id: `${event.data.turn}:${event.data.step}`, role: 'start' } + : null, + start: seen, + update: context => context.state, + buildViewNode: context => node(context, context.state), + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([definition]), + new TestViewDefinitions([testView()]), + ) + assembler.replaceWindow([input(at(1, 'step/start', { turn: 1, step: 1 }))], false) + assembler.flush() + + assembler.append(input(at(2, 'step/end', { turn: 1, step: 1 }))) + assembler.flush() + + expect(seen).toHaveBeenCalledTimes(2) + }) + + it('does not invoke the fallback when an ordinary non-rendering Definition claims an event', () => { + const fallbackStart = vi.fn(() => 'fallback') + const claimed: ConversationNodeDefinition = { + kind: 'claimed', + match: event => (event.type as string) === 'command/run' ? { id: 'claimed', role: 'start' } : null, + start: () => null, + update: context => context.state, + buildViewNode: () => null, + } + const fallback: ConversationNodeDefinition = { + kind: 'fallback', + match: event => ({ id: String(event.seq), role: 'start' }), + start: fallbackStart, + update: context => context.state, + buildViewNode: context => node(context, context.state), + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([claimed], fallback), + new TestViewDefinitions([testView()]), + ) + assembler.replaceWindow([input(at(1, 'command/run', { commandId: 'one', name: 'x' }))], false) + assembler.flush() + + expect(fallbackStart).not.toHaveBeenCalled() + expect(chatSnapshot(assembler)?.order).toEqual([]) + }) + + it('rejects withdrawing a previously materialized Node during an incremental update', () => { + const definition: ConversationNodeDefinition = { + kind: 'toggle', + match: (event) => { + if ((event.type as string) === 'command/run') return { id: 'one', role: 'start' } + if ((event.type as string) === 'toggle/hide') return { id: 'one', role: 'update' } + return null + }, + start: () => true, + update: () => false, + buildViewNode: context => context.state === true ? node(context, true) : null, + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([definition]), + new TestViewDefinitions([testView()]), + ) + assembler.replaceWindow([input(at(1, 'command/run', { commandId: 'one', name: 'x' }))], false) + assembler.flush() + expect(chatSnapshot(assembler)?.order).toHaveLength(1) + + assembler.append(input(at(2, 'toggle/hide', {}))) + expect(() => assembler.flush()).toThrow(/withdrew materialized target "chat"/) + + expect(chatSnapshot(assembler)?.order).toHaveLength(1) + }) + + it('fails loud when a Definition returns undefined State', () => { + const startUndefined: ConversationNodeDefinition = { + kind: 'undefined-start', + match: event => (event.type as string) === 'command/run' ? { id: 'one', role: 'start' } : null, + start: () => undefined, + update: context => context.state, + buildViewNode: () => null, + } + const startAssembler = new ConversationNodeAssembler( + new TestEventDefinitions([startUndefined]), + new TestViewDefinitions([testView()]), + ) + expect(() => startAssembler.replaceWindow([ + input(at(1, 'command/run', { commandId: 'one', name: 'x' })), + ], false)).toThrow(/Definition "undefined-start" returned undefined from start/) + + const updateUndefined: ConversationNodeDefinition = { + kind: 'undefined-update', + match: (event) => { + if ((event.type as string) === 'command/run') return { id: 'one', role: 'start' } + if ((event.type as string) === 'command/done') return { id: 'one', role: 'update' } + return null + }, + start: () => true, + update: () => undefined as never, + buildViewNode: context => node(context, context.state), + } + const updateAssembler = new ConversationNodeAssembler( + new TestEventDefinitions([updateUndefined]), + new TestViewDefinitions([testView()]), + ) + updateAssembler.replaceWindow([ + input(at(1, 'command/run', { commandId: 'one', name: 'x' })), + ], false) + expect(() => updateAssembler.append( + input(at(2, 'command/done', { commandId: 'one', kind: 'success' })), + )).toThrow(/Definition "undefined-update" returned undefined from update/) + }) + + it('rejects a duplicate start before mutating the existing Context', () => { + const definition: ConversationNodeDefinition = { + kind: 'single-start', + match: event => (event.type as string) === 'command/run' ? { id: 'one', role: 'start' } : null, + start: (_context, match) => match.event.seq, + update: context => context.state, + buildViewNode: context => node(context, context.state), + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([definition]), + new TestViewDefinitions([testView()]), + ) + assembler.replaceWindow([ + input(at(1, 'command/run', { commandId: 'one', name: 'x' })), + ], false) + assembler.flush() + + expect(() => assembler.append( + input(at(2, 'command/run', { commandId: 'two', name: 'x' })), + )).toThrow(/received more than one start Match/) + assembler.flush() + expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(1) + }) +}) diff --git a/packages/client/runtime/tests/conversation-registry.spec.ts b/packages/client/runtime/tests/conversation-registry.spec.ts new file mode 100644 index 0000000000..19cbdf17f9 --- /dev/null +++ b/packages/client/runtime/tests/conversation-registry.spec.ts @@ -0,0 +1,126 @@ +import { Context } from 'cordis' +import { describe, expect, it, vi } from 'vitest' +import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' +import { ConversationEventRegistry } from '../src/client/conversation/event-registry.ts' +import { ConversationViewRegistry } from '../src/client/conversation/view-registry.ts' +import type { + ConversationNodeDefinition, ConversationViewDefinition, ConversationViewNode, +} from '../src/client/contract/conversation.ts' +import { Session } from '../src/client/sessions/session.ts' +import { SessionsService } from '../src/client/sessions/service.ts' +import { FakeApiClient, ok } from './fake-api.ts' + +function eventDefinition(kind: string): ConversationNodeDefinition { + return { + kind, + match: () => null, + start: () => null, + update: context => context.state, + buildViewNode: () => null, + } +} + +function viewDefinition(target: string): ConversationViewDefinition { + return { + target, + create: () => ({ + empty: null, + replace: () => null, + apply: () => null, + }), + } +} + +async function bootRegistries(): Promise<{ + ctx: Context + events: ConversationEventRegistry + views: ConversationViewRegistry +}> { + const ctx = new Context() + await ctx.plugin(ConversationEventRegistry).await() + await ctx.plugin(ConversationViewRegistry).await() + const events = ctx.get('conversationEvents') as ConversationEventRegistry + const views = ctx.get('conversationViews') as ConversationViewRegistry + return { ctx, events, views } +} + +describe('Conversation registries', () => { + it('rejects duplicate Event Definitions and disposes an ordinary registration once', async () => { + const { events } = await bootRegistries() + const definition = eventDefinition('message') + const dispose = events.register(definition) + + expect(events.entries()).toEqual([definition]) + expect(() => events.register(eventDefinition('message'))).toThrow(/already registered/) + + dispose() + dispose() + expect(events.entries()).toEqual([]) + }) + + it('rejects a duplicate fallback and clears it through its idempotent disposer', async () => { + const { events } = await bootRegistries() + const fallback = eventDefinition('unknown') + const dispose = events.registerFallback(fallback) + + expect(events.fallbackEntry()).toBe(fallback) + expect(() => events.registerFallback(eventDefinition('other'))).toThrow(/already registered/) + + dispose() + dispose() + expect(events.fallbackEntry()).toBeUndefined() + }) + + it('rejects duplicate view targets and disposes a view registration once', async () => { + const { views } = await bootRegistries() + const definition = viewDefinition('chat') + const dispose = views.register(definition) + + expect(views.entries()).toEqual([definition]) + expect(() => views.register(viewDefinition('chat'))).toThrow(/already registered/) + + dispose() + dispose() + expect(views.entries()).toEqual([]) + }) + + it('removes Event, fallback, and view contributions with their caller fiber', async () => { + const { ctx, events, views } = await bootRegistries() + const feature = ctx.inject(['conversationEvents', 'conversationViews'], (featureCtx) => { + featureCtx.conversationEvents.register(eventDefinition('message')) + featureCtx.conversationEvents.registerFallback(eventDefinition('unknown')) + featureCtx.conversationViews.register(viewDefinition('chat')) + }) + await feature.await() + + expect(events.entries()).toHaveLength(1) + expect(events.fallbackEntry()).toBeDefined() + expect(views.entries()).toHaveLength(1) + + await feature.dispose() + expect(events.entries()).toEqual([]) + expect(events.fallbackEntry()).toBeUndefined() + expect(views.entries()).toEqual([]) + }) + + it('coalesces registry changes into one rebuild of every resident Session', async () => { + const { ctx, events, views } = await bootRegistries() + const api = new FakeApiClient() + const sessionId = 'resident' as SessionId + api.onList = () => Promise.resolve(ok({ + items: [{ sessionId, updatedAt: 1, running: false, blank: true }], + }) as never) + const sessions = new SessionsService(ctx, api) + await sessions.refresh() + await Promise.resolve() + sessions.scope(sessionId) + const rebuild = vi.spyOn(Session.prototype, 'rebuildConversationRegistry') + + events.register(eventDefinition('message')) + views.register(viewDefinition('chat')) + await Promise.resolve() + + expect(rebuild).toHaveBeenCalledOnce() + rebuild.mockRestore() + }) +}) diff --git a/packages/client/runtime/tests/event-script.ts b/packages/client/runtime/tests/event-script.ts index de72d5b351..299e16785d 100644 --- a/packages/client/runtime/tests/event-script.ts +++ b/packages/client/runtime/tests/event-script.ts @@ -54,12 +54,12 @@ export const ev = { codeDispatchStart: (seq: number, parentCallId: string, n: number, name: string, args: unknown): SessionEvent => at(seq, { type: 'tool/code-dispatch-start', - data: { parentCallId, subCallId: `${parentCallId}:code:${n}`, name, arguments: args }, + data: { rootCallId: parentCallId, parentCallId, subCallId: `${parentCallId}:code:${n}`, name, arguments: args }, }), codeDispatch: (seq: number, parentCallId: string, n: number, name: string, args: unknown, body: string, isError = false): SessionEvent => at(seq, { type: 'tool/code-dispatch', - data: { parentCallId, subCallId: `${parentCallId}:code:${n}`, name, arguments: args, isError, content: text(body) }, + data: { rootCallId: parentCallId, parentCallId, subCallId: `${parentCallId}:code:${n}`, name, arguments: args, isError, content: text(body) }, }), stepEnd: (seq: number, turn: number, step = 0): SessionEvent => at(seq, { type: 'step/end', data: { turn, step } }), diff --git a/packages/client/runtime/tests/queue-store.spec.ts b/packages/client/runtime/tests/queue-store.spec.ts index 55e0929c30..1b6b721d25 100644 --- a/packages/client/runtime/tests/queue-store.spec.ts +++ b/packages/client/runtime/tests/queue-store.spec.ts @@ -158,7 +158,6 @@ describe('queue snapshot intake', () => { type: 'session/event', sessionId: SID, event: durable, }) expect(session.getSnapshot().queue.map(item => item.id)).toEqual(['s-second']) - expect(session.getSnapshot().nodes.filter(node => node.kind === 'user')).toHaveLength(1) session.handleMuxEnvelope(rid('env-reused-id'), queueFrame([ { id: 's-later', body: '', placement: 'steering', message }, diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index 834f37d434..def97002f0 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -8,15 +8,17 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' -import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' import { Session } from '../src/client/sessions/session.ts' +import type { + ChatConversationViewNode, ChatLocationNodeIndex, ChatNodeStore, ChatSnapshot, + ConversationEventInput, ConversationNode, ConversationNodeDefinition, + ConversationRuntime, ConversationSnapshot, ConversationTimelineSnapshot, + ConversationViewDefinition, +} from '../src/client/index.ts' import { FakeApiClient, deferred, err, ok } from './fake-api.ts' import { entries, ev, plainTurn } from './event-script.ts' -const at = (seq: number, e: Record): SessionEvent => - ({ seq, time: 1_700_000_000_000 + seq, ...e }) as unknown as SessionEvent - const SID = 'fk-s1' as SessionId const PARENT = 'fk-parent' as SessionId @@ -24,8 +26,142 @@ afterEach(() => { vi.unstubAllGlobals() }) +const EMPTY: readonly never[] = [] + +interface TestEventState extends ConversationEventInput {} + +class TestNodeStore implements ChatNodeStore { + private readonly nodes = new Map() + private cache: readonly ChatConversationViewNode[] = EMPTY + + get(key: string): ChatConversationViewNode | undefined { + return this.nodes.get(key) + } + + values(): readonly ChatConversationViewNode[] { + return this.cache + } + + replace(nodes: readonly ChatConversationViewNode[]): void { + this.nodes.clear() + for (const node of nodes) this.nodes.set(node.key, node) + this.cache = [...this.nodes.values()] + } + + upsert(nodes: readonly ChatConversationViewNode[]): void { + if (nodes.length === 0) return + for (const node of nodes) this.nodes.set(node.key, node) + this.cache = [...this.nodes.values()] + } +} + +const TEST_LOCATIONS: ChatLocationNodeIndex = { + getTurn: () => EMPTY, + getStep: () => EMPTY, +} + +function testLegacy( + nodes: readonly ChatConversationViewNode[], + timeline: ConversationTimelineSnapshot, +): ChatSnapshot['legacy'] { + const legacyNodes = nodes.flatMap((node): ConversationNode[] => { + const event = (node.data as TestEventState).event + if (event.type === 'user/message') return [{ kind: 'user', seq: event.seq } as ConversationNode] + if (event.type === 'assistant/message') return [{ kind: 'assistant', seq: event.seq } as ConversationNode] + return [] + }) + const turnTimings = new Map() + const turnEnds = new Map() + for (const turn of timeline.turns.values()) { + if (turn.start !== undefined) { + turnTimings.set(turn.turn, turn.end === undefined + ? { startTime: turn.start.time } + : { startTime: turn.start.time, endTime: turn.end.time }) + } + if (turn.end !== undefined) turnEnds.set(turn.turn, turn.end.seq) + } + return { nodes: legacyNodes, turnTimings, turnEnds, partial: null, runningCalls: EMPTY } +} + +function testViewDefinition(): ConversationViewDefinition { + return { + target: 'chat', + create: () => { + const store = new TestNodeStore() + let current: ChatSnapshot = { + order: EMPTY, + nodes: store, + locations: TEST_LOCATIONS, + timeline: { turnOrder: EMPTY, turns: new Map() }, + legacy: testLegacy(EMPTY, { turnOrder: EMPTY, turns: new Map() }), + } + const build = (timeline: ConversationTimelineSnapshot): ChatSnapshot => { + const nodes = [...store.values()].sort((left, right) => left.anchorSeq - right.anchorSeq) + current = { + order: nodes.map(node => node.key), + nodes: store, + locations: TEST_LOCATIONS, + timeline, + legacy: testLegacy(nodes, timeline), + } + return current + } + return { + empty: current, + replace: ({ nodes, timeline }) => { + store.replace(nodes) + return build(timeline) + }, + apply: ({ upserts, timeline }) => { + store.upsert(upserts) + return build(timeline) + }, + } + }, + } +} + +const TEST_EVENT_DEFINITION: ConversationNodeDefinition = { + kind: 'runtime-test-event', + match: event => ({ id: String(event.seq), role: 'start' }), + start: (_context, match) => ({ event: match.event, view: match.view }), + update: context => context.state, + publication: match => match.event.type === 'assistant/chunk' ? 'animation-frame' : 'immediate', + buildViewNode: (context, target) => { + if (target !== 'chat' || context.state === undefined || context.start === undefined) return null + return { + key: context.key, + kind: 'runtime-test-event', + id: context.id, + target: 'chat', + anchorSeq: context.start.event.seq, + location: context.start.location, + visibility: 'visible', + data: context.state, + } + }, +} + +const TEST_CONVERSATION: ConversationRuntime = { + events: { + entries: () => [TEST_EVENT_DEFINITION], + fallbackEntry: () => undefined, + } as unknown as ConversationRuntime['events'], + views: { + entries: () => [testViewDefinition()], + } as unknown as ConversationRuntime['views'], +} + function makeSession(api = new FakeApiClient()): { api: FakeApiClient; session: Session } { - return { api, session: new Session(SID, api) } + return { api, session: new Session(SID, api, { conversation: TEST_CONVERSATION }) } +} + +function chatEvents(snapshot: ConversationSnapshot): readonly TestEventState[] { + return snapshot.chat.order.map(key => snapshot.chat.nodes.get(key)?.data as TestEventState) +} + +function chatSeqs(snapshot: ConversationSnapshot): number[] { + return chatEvents(snapshot).map(item => item.event.seq) } function histResponse(events: SessionEvent[], hasMore = false) { @@ -34,6 +170,14 @@ function histResponse(events: SessionEvent[], hasMore = false) { } describe('open', () => { + it('keeps a bare Session blank until an authoritative lifecycle signal arrives', () => { + const { session } = makeSession() + expect(session.getSnapshot()).toMatchObject({ blank: true, composerPhase: 'blank' }) + + session.handleRunning(true) + expect(session.getSnapshot()).toMatchObject({ blank: false, composerPhase: 'active' }) + }) + it('installs the tail page: cold → loading → open with window and nodes in place', async () => { const { api, session } = makeSession() const page = plainTurn(10, 3, '问', '答') @@ -115,74 +259,28 @@ describe('live event path', () => { expect(session.getSnapshot().nodes).toEqual(before.nodes) }) - it('materializes a command node from live lifecycle frames and reproduces it from a history window', async () => { - // Live path: run mints an executing node, done settles it in the flow. - const { session } = await opened() - const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } - feed(ev.commandRun(6, 'cmd-live', 'plan')) - let command = session.getSnapshot().nodes.at(-1) - expect(command).toMatchObject({ kind: 'command', name: 'plan', args: '', outcome: null }) - feed(ev.commandDone(7, 'cmd-live', 'success', '已进入 plan mode')) - command = session.getSnapshot().nodes.at(-1) - expect(command).toMatchObject({ kind: 'command', seq: 6, outcome: { kind: 'success', text: '已进入 plan mode' } }) - - // Replay path (refresh): the same pair inside the history window folds identically. - const replayed = await opened([ - ...plainTurn(0, 0, 'a', 'b'), - ev.commandRun(6, 'cmd-live', 'plan'), - ev.commandDone(7, 'cmd-live', 'success', '已进入 plan mode'), - ]) - expect(replayed.session.getSnapshot().nodes.at(-1)).toMatchObject({ - kind: 'command', seq: 6, name: 'plan', outcome: { kind: 'success', text: '已进入 plan mode' }, - }) - }) - - it('command lifecycle rows alone keep the composer blank (hero survives a /permission or /plan switch)', async () => { - // A fresh session whose only window content is a command pair (plus the - // knob events a /permission switch appends — not surface-eligible, so - // they never become nodes) stays phase 'blank': selecting a preset from - // the hero must not enter the conversation view. + it('keeps the authoritative host blank bit across unrelated log events', async () => { const { session } = await opened([]) + session.handleBlank(true) expect(session.getSnapshot().composerPhase).toBe('blank') const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } feed(ev.commandRun(0, 'cmd-perm', 'permission', ' danger-full-access')) feed(ev.commandDone(1, 'cmd-perm', 'success', 'preset danger-full-access')) const snapshot = session.getSnapshot() - expect(snapshot.nodes.at(-1)).toMatchObject({ kind: 'command', name: 'permission' }) + expect(chatSeqs(snapshot)).toEqual([0, 1]) expect(snapshot.composerPhase).toBe('blank') }) - it('accumulates chunks into partial, then finalize swaps partial out as the node lands', async () => { - const { session } = await opened() - const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } - feed(ev.turnStart(6, 1)) - feed(ev.user(7, '流式问')) - feed(ev.chunkStart(8, 1)) - feed(ev.chunkText(9, 1, '半截')) - let snapshot = session.getSnapshot() - expect(snapshot.partial).toMatchObject({ turn: 1, blocks: [{ kind: 'text', text: '半截' }] }) - feed(ev.chunkText(10, 1, '回复')) - expect(session.getSnapshot().partial?.blocks).toEqual([{ kind: 'text', text: '半截回复' }]) - feed(ev.assistant(11, 1, '半截回复')) - feed(ev.turnEnd(12, 1)) - snapshot = session.getSnapshot() - expect(snapshot.partial).toBeNull() - const last = snapshot.nodes.at(-1) - expect(last).toMatchObject({ kind: 'assistant', blocks: [{ kind: 'text', text: '半截回复' }] }) - expect((last as { interrupted?: true }).interrupted).toBeUndefined() - }) - - it('publishes cumulative chunks once per frame and lets finalization supersede the pending frame', async () => { + it('publishes animation-frame Definitions once per frame and lets an immediate event supersede the pending frame', async () => { const frames: FrameRequestCallback[] = [] vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { frames.push(callback) return frames.length }) const { session } = await opened() - const published: Array = [] + const published: number[][] = [] session.subscribe(() => { - const block = session.getSnapshot().partial?.blocks[0] - published.push(block?.kind === 'text' ? block.text : null) + published.push(chatSeqs(session.getSnapshot())) }) const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) @@ -195,356 +293,46 @@ describe('live event path', () => { expect(frames).toHaveLength(1) frames.shift()!(0) - expect(published).toEqual(['累计']) + expect(published).toEqual([[0, 1, 2, 3, 4, 5, 6, 7, 8]]) feed(ev.chunkText(9, 1, '完成')) feed(ev.assistant(10, 1, '累计完成')) await Promise.resolve() - expect(published).toEqual(['累计', null]) + expect(published).toEqual([ + [0, 1, 2, 3, 4, 5, 6, 7, 8], + [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + ]) frames.shift()!(0) - expect(published).toEqual(['累计', null]) + expect(published).toHaveLength(2) }) - it('retracts the failed-attempt partial and starts the retry on new chunk evidence', async () => { - const { session } = await opened() - const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } - const retryTurn = [ - ev.turnStart(6, 1), - ev.user(7, '请重试'), - ev.stepStart(8, 1), - ev.chunkStart(9, 1), - ev.chunkText(10, 1, '不完整回复'), - ev.retry(11, 1, 0, 1, 2, 450, '连接被重置'), - ev.chunkStart(12, 1), - ev.assistant(13, 1, '完整回复'), - ev.stepEnd(14, 1), - ev.turnEnd(15, 1), - ] - for (const event of retryTurn.slice(0, 6)) feed(event) - - let snapshot = session.getSnapshot() - expect(snapshot.partial).toBeNull() - expect(snapshot.nodes.at(-1)).toMatchObject({ - kind: 'model-retry', - retryState: 'scheduled', - turn: 1, - step: 0, - provider: 'fake', - mode: 'normal', - policyKey: 'fake-normal', - retry: 1, - maxRetries: 2, - delayMs: 450, - failure: { code: 'TRANSPORT', message: '连接被重置' }, - }) - expect(JSON.stringify(snapshot.nodes)).not.toContain('不完整回复') - - for (const event of retryTurn.slice(6)) feed(event) - snapshot = session.getSnapshot() - expect(snapshot.nodes.slice(-2).map(node => node.kind)).toEqual(['model-retry', 'assistant']) - expect(snapshot.nodes.some(node => node.kind === 'turn-error')).toBe(false) - expect(snapshot.nodes.at(-2)).toMatchObject({ kind: 'model-retry', retryState: 'started' }) - expect(snapshot.nodes.at(-1)).toMatchObject({ kind: 'assistant', blocks: [{ kind: 'text', text: '完整回复' }] }) - const retryStart = retryTurn.find(event => event.type === 'turn/start') - if (retryStart?.type !== 'turn/start') throw new Error('test fixture must include the retried turn start') - const retryEnd = retryTurn.find(event => - event.type === 'turn/end' && event.data.turn === retryStart.data.turn) - if (retryEnd?.type !== 'turn/end') throw new Error('test fixture must complete the retry turn') - expect(snapshot.turnTimings.get(retryStart.data.turn)).toEqual({ - startTime: retryStart.time, - endTime: retryEnd.time, - }) - - const replay = makeSession() - replay.api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ...retryTurn]) - await replay.session.open() - expect(replay.session.getSnapshot().nodes).toEqual(snapshot.nodes) - expect(replay.session.getSnapshot().turnTimings).toEqual(snapshot.turnTimings) - expect(replay.session.getSnapshot().partial).toBeNull() - }) - - it('projects unretried terminal failures at turn/end and reproduces them from history', async () => { - const { session } = await opened() - const feed = (event: SessionEvent) => { - session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) + it('publishes a timeline-only boundary even when no Definition claims the event', async () => { + const api = new FakeApiClient() + api.onHistory = () => histResponse([]) + const conversation: ConversationRuntime = { + events: { + entries: () => [], + fallbackEntry: () => undefined, + } as unknown as ConversationRuntime['events'], + views: { + entries: () => [testViewDefinition()], + } as unknown as ConversationRuntime['views'], } - const failedTurns = [ - ev.turnStart(6, 1), - ev.user(7, '鉴权失败'), - ev.stepStart(8, 1), - at(9, { - type: 'turn/end', - data: { turn: 1, reason: { kind: 'error', error: { - code: 'AUTH', - message: 'Authentication Fails, Your api key: sk-preview-secret is invalid', - }, - }, - }, - }), - ev.turnStart(10, 2), - ev.user(11, '内部失败'), - ev.stepStart(12, 2, 1), - at(13, { - type: 'turn/end', - data: { turn: 2, reason: { kind: 'error', error: { message: 'plugin exploded', code: 'UNKNOWN' } } }, - }), - ] - for (const event of failedTurns) feed(event) + const session = new Session(SID, api, { conversation }) + await session.open() + const snapshots: ConversationSnapshot[] = [] + session.subscribe(() => { snapshots.push(session.getSnapshot()) }) - const errors = session.getSnapshot().nodes.filter(node => node.kind === 'turn-error') - expect(errors).toMatchObject([ - { seq: 9, turn: 1, step: 0, code: 'AUTH', message: 'API key is invalid' }, - // Every failed turn carries a structured failure; unstructured errors - // flatten to the UNKNOWN code. - { seq: 13, turn: 2, step: 1, code: 'UNKNOWN', message: 'plugin exploded' }, - ]) - - const replay = makeSession() - replay.api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ...failedTurns]) - await replay.session.open() - expect(replay.session.getSnapshot().nodes).toEqual(session.getSnapshot().nodes) - }) - - it('rejects retry payloads outside the producer contract without retracting the current partial', async () => { - const { session } = await opened() - const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } - feed(ev.turnStart(6, 1)) - feed(ev.chunkStart(7, 1)) - feed(ev.chunkText(8, 1, '仍在生成')) - const valid = { - turn: 1, step: 0, - provider: 'fake', mode: 'normal', policyKey: 'fake-normal', - retry: 1, maxRetries: 2, delayMs: 500, - failure: { code: 'TRANSPORT', message: 'temporary failure' }, - } - const invalid = [ - { ...valid, turn: Number.MAX_SAFE_INTEGER + 1 }, - { ...valid, step: Number.MAX_SAFE_INTEGER + 1 }, - { ...valid, provider: '' }, - { ...valid, policyKey: '' }, - { ...valid, retry: Number.MAX_SAFE_INTEGER + 1 }, - { ...valid, maxRetries: Number.MAX_SAFE_INTEGER + 1 }, - { ...valid, delayMs: -1 }, - { ...valid, delayMs: Number.POSITIVE_INFINITY }, - { ...valid, delayMs: MAX_TIMER_DELAY_MS + 1 }, - { ...valid, failure: { ...valid.failure, message: '' } }, - { ...valid, failure: { ...valid.failure, code: '' } }, - { ...valid, failure: { ...valid.failure, status: '429' } }, - { ...valid, failure: { ...valid.failure, status: 99 } }, - { ...valid, failure: { ...valid.failure, status: 429.5 } }, - { ...valid, failure: { ...valid.failure, status: 600 } }, - { ...valid, failure: { ...valid.failure, providerRetryAfterMs: 0 } }, - { ...valid, failure: { ...valid.failure, providerRetryAfterMs: Number.POSITIVE_INFINITY } }, - { ...valid, failure: { ...valid.failure, requestId: 1 } }, - { ...valid, failure: { ...valid.failure, requestId: '' } }, - ] - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) - try { - for (const [index, data] of invalid.entries()) { - feed(at(9 + index, { type: 'llm/retry', data })) - } - expect(session.getSnapshot().partial?.blocks).toEqual([{ kind: 'text', text: '仍在生成' }]) - expect(session.getSnapshot().nodes.filter(node => node.kind === 'model-retry')).toEqual([]) - expect(errorSpy).toHaveBeenCalledTimes(invalid.length) - expect(errorSpy).toHaveBeenCalledWith('[web-runtime] ignored malformed llm/retry event at seq 9') - } finally { - errorSpy.mockRestore() - } - }) - - it('accepts complete retry payloads at the producer field boundaries', async () => { - const { session } = await opened() - session.handleMuxEnvelope('r' as never, { + session.handleMuxEnvelope('timeline' as never, { type: 'session/event', sessionId: SID, - event: at(6, { - type: 'llm/retry', - data: { - turn: Number.MAX_SAFE_INTEGER, - step: Number.MAX_SAFE_INTEGER, - provider: 'fake', - mode: 'normal', - policyKey: 'fake-normal', - retry: Number.MAX_SAFE_INTEGER, - maxRetries: Number.MAX_SAFE_INTEGER, - delayMs: MAX_TIMER_DELAY_MS, - failure: { - code: 'RATE_LIMIT', - message: 'provider busy', - status: 599, - providerRetryAfterMs: Number.MIN_VALUE, - requestId: 'req-1', - }, - }, - }), + event: ev.turnStart(0, 1), }) - expect(session.getSnapshot().nodes.at(-1)).toMatchObject({ - kind: 'model-retry', - retryState: 'scheduled', - retry: Number.MAX_SAFE_INTEGER, - delayMs: MAX_TIMER_DELAY_MS, - failure: { status: 599, providerRetryAfterMs: Number.MIN_VALUE, requestId: 'req-1' }, - }) - }) + await Promise.resolve() - it('projects always-mode retries and rejects mode-specific maximums or unknown modes', async () => { - const { session } = await opened() - const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) - try { - feed(at(6, { - type: 'llm/retry', - data: { - turn: 1, step: 0, - provider: 'fake', mode: 'always', policyKey: 'fake-always', - retry: 3, delayMs: 500, - failure: { code: 'TRANSPORT', message: 'retry forever' }, - }, - })) - expect(session.getSnapshot().nodes.at(-1)).toMatchObject({ - kind: 'model-retry', - retryState: 'scheduled', - mode: 'always', - retry: 3, - }) - - feed(at(7, { - type: 'llm/retry', - data: { - turn: 2, step: 0, - provider: 'fake', mode: 'always', policyKey: 'fake-always', - retry: 4, maxRetries: 4, delayMs: 500, - failure: { code: 'TRANSPORT', message: 'unexpected maximum' }, - }, - })) - feed(at(8, { - type: 'llm/retry', - data: { - turn: 2, step: 0, - provider: 'fake', mode: 'sometimes', policyKey: 'fake-unknown', - retry: 4, delayMs: 500, - failure: { code: 'TRANSPORT', message: 'unknown mode' }, - }, - })) - expect(session.getSnapshot().nodes.filter(node => node.kind === 'model-retry')).toHaveLength(1) - expect(errorSpy).toHaveBeenCalledTimes(2) - } finally { - errorSpy.mockRestore() - } - }) - - it.each(['aborted', 'disposed'] as const)( - 'marks a scheduled retry as cancelled when its failed turn receives the %s cause', - async (reason) => { - const { session } = await opened() - const feed = (event: SessionEvent) => { - session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) - } - feed(ev.turnStart(6, 1)) - feed(ev.retry(7, 1)) - expect(session.getSnapshot().nodes.at(-1)).toMatchObject({ - kind: 'model-retry', - retryState: 'scheduled', - }) - feed(ev.turnEnd(8, 1, reason)) - expect(session.getSnapshot().nodes.at(-1)).toMatchObject({ - kind: 'model-retry', - retryState: 'cancelled', - }) - }, - ) - - it('marks a scheduled retry as started when its failed turn ends with an error', async () => { - const { session } = await opened() - const feed = (event: SessionEvent) => { - session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) - } - feed(ev.turnStart(6, 1)) - feed(ev.retry(7, 1)) - feed(at(8, { - type: 'turn/end', - data: { turn: 1, reason: { kind: 'error', error: { message: 'retry failed', code: 'UNKNOWN' } } }, - })) - - expect(session.getSnapshot().nodes.at(-1)).toMatchObject({ - kind: 'model-retry', - retryState: 'started', - }) - }) - - it('freezes an unfinalized partial into an interrupted node on turn/end (cancel path)', async () => { - const { session } = await opened() - const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } - feed(ev.turnStart(6, 1)) - feed(ev.user(7, '要被打断的')) - feed(ev.chunkStart(8, 1)) - feed(ev.chunkText(9, 1, '说到一半')) - feed(ev.turnEnd(10, 1, 'aborted')) // no assistant/message ever arrives - const snapshot = session.getSnapshot() - expect(snapshot.partial).toBeNull() - expect(snapshot.turnEnds.get(1)).toBe(10) - const frozen = snapshot.nodes.at(-1) - expect(frozen).toMatchObject({ kind: 'assistant', interrupted: true, blocks: [{ kind: 'text', text: '说到一半' }] }) - // Ordered inside the flow: after the user message (seq 7), before any later turn. - expect((frozen as { seq: number }).seq).toBeGreaterThan(7) - }) - - it('tracks tool calls in runningCalls and converts orphans to interrupted tool-result cards on turn/end', async () => { - const { session } = await opened() - const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } - feed(ev.turnStart(6, 1)) - feed(ev.toolCall(7, 1, 'c1', 'echo', '{"a":1}')) - expect(session.getSnapshot().runningCalls).toMatchObject([{ callId: 'c1', name: 'echo' }]) - feed(ev.toolResult(8, 1, 'c1', 'ECHO')) - expect(session.getSnapshot().runningCalls).toEqual([]) - // Second call never resolves: turn/end freezes it as an error card. - feed(ev.toolCall(9, 1, 'c2', 'slow_tool', '{}')) - feed(ev.turnEnd(10, 1, 'aborted')) - const snapshot = session.getSnapshot() - expect(snapshot.runningCalls).toEqual([]) - expect(snapshot.nodes.at(-1)).toMatchObject({ - kind: 'tool-result', callId: 'c2', isError: true, error: { code: 'interrupted' }, - }) - }) - - it('keeps compacted history and adds one marker, live and on replay alike', async () => { - // A landed compaction must not erase conversation the reader already saw: - // the shadowed messages stay at their own log positions and the checkpoint - // contributes one marker after them. - const { session } = await opened() - const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } - feed(ev.compactSummary(6, '压缩摘要', 1, 3)) - feed(ev.compactCheckpoint(7, 6, 1, 3)) - const live = session.getSnapshot().nodes - expect(live.map(n => [n.kind, n.seq])).toEqual([['user', 1], ['assistant', 3], ['compaction', 7]]) - expect(live.at(-1)).toMatchObject({ kind: 'compaction', summary: '压缩摘要' }) - - const replayed = await opened([ - ...plainTurn(0, 0, 'a', 'b'), - ev.compactSummary(6, '压缩摘要', 1, 3), - ev.compactCheckpoint(7, 6, 1, 3), - ]) - expect(replayed.session.getSnapshot().nodes).toEqual(live) - }) - - it('merges an interrupted frozen node by seq into the log-ordered transcript', async () => { - // The transcript array is seq-monotonic, so the frozen node's fractional - // seq lands it exactly where it happened — including after a compaction - // checkpoint whose own seq is higher than the range it shadowed. - const { session } = await opened() - const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } - feed(ev.compactSummary(6, '压缩摘要', 1, 3)) - feed(ev.compactCheckpoint(7, 6, 1, 3)) - feed(ev.turnStart(8, 1)) - feed(ev.user(9, '压缩后的提问')) - feed(ev.chunkStart(10, 1)) - feed(ev.chunkText(11, 1, '说到一半')) - feed(ev.turnEnd(12, 1, 'aborted')) - expect(session.getSnapshot().nodes.map(n => n.kind)).toEqual([ - 'user', 'assistant', 'compaction', 'user', 'assistant', - ]) - expect(session.getSnapshot().nodes.at(-1)).toMatchObject({ interrupted: true }) + expect(snapshots).toHaveLength(1) + expect(snapshots[0]?.chat.timeline.turns.get(1)?.status).toBe('open') }) it('repairs a seq gap by repulling the tail page instead of appending a hole', async () => { @@ -578,11 +366,7 @@ describe('paging', () => { expect(snapshot.nodes.map(n => n.seq)).toEqual([1, 3, 7, 9]) }) - it('renders a page whose checkpoint shadows seqs below the window head, logging nothing', async () => { - // Pagination no longer spends maxMessages quota on replacement copies, so a - // page can carry a compaction checkpoint whose surfaceOp.start lies outside - // the window. The old surface fold rejected that range and degraded with a - // console error; the log-ordered transcript has no range to resolve. + it('installs a page without interpreting business replacement metadata', async () => { const { api, session } = makeSession() api.onHistory = () => histResponse([ ev.compactSummary(80, '窗外范围的摘要', 3, 40), @@ -594,8 +378,7 @@ describe('paging', () => { await session.open() const snapshot = session.getSnapshot() expect(snapshot.openState).toBe('open') - expect(snapshot.nodes.map(n => [n.kind, n.seq])).toEqual([['compaction', 81], ['user', 82]]) - expect(snapshot.nodes[0]).toMatchObject({ summary: '窗外范围的摘要' }) + expect(chatSeqs(snapshot)).toEqual([80, 81, 82]) expect(errorSpy).not.toHaveBeenCalled() } finally { errorSpy.mockRestore() @@ -712,6 +495,7 @@ describe('prompt and cancel errors', () => { it('sends content through session.prompt; composerPhase steps blank → engaging synchronously at send entry', async () => { const { api, session } = makeSession() + session.handleBlank(true) // The blank → engaging edge fires before the RPC settles: the first-send // flow reads the phase on the session area's first frame to keep the // guidance hero from flashing back in. @@ -730,6 +514,7 @@ describe('prompt and cancel errors', () => { it('business failure lands in promptError with op=send; the phase stays engaging (retry, no hero bounce)', async () => { const { api, session } = makeSession() + session.handleBlank(true) api.onPrompt = () => Promise.resolve(err({ code: 'agent-busy', message: 'busy', details: { reason: 'x' } })) const result = await session.prompt([{ type: 'text', text: '失败的' }], 'queue') expect(result.ok).toBe(false) @@ -969,33 +754,6 @@ describe('remaining branches', () => { } }) - it('freezes only content-bearing partials; a content-free partial is dropped outright', async () => { - const { api, session } = makeSession() - api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) - await session.open() - const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } - feed(ev.turnStart(6, 1)) - feed(ev.chunkStart(7, 1)) // empty text block only, no delta - feed(ev.turnEnd(8, 1, 'aborted')) - const snapshot = session.getSnapshot() - expect(snapshot.partial).toBeNull() - expect(snapshot.nodes.filter(n => n.kind === 'assistant' && (n as { interrupted?: true }).interrupted)).toEqual([]) - }) - - it('turn/end sweeps only same-turn open calls; other turns keep running', async () => { - const { api, session } = makeSession() - api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) - await session.open() - const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } - feed(ev.turnStart(6, 1)) - feed(ev.toolCall(7, 1, 'turn1-call', 'echo', '{}')) - feed(ev.toolCall(8, 2, 'turn2-call', 'echo', '{}')) // stray call attributed to a later turn - feed(ev.turnEnd(9, 1, 'aborted')) - const snapshot = session.getSnapshot() - expect(snapshot.runningCalls.map(c => c.callId)).toEqual(['turn2-call']) - expect(snapshot.nodes.at(-1)).toMatchObject({ kind: 'tool-result', callId: 'turn1-call', isError: true }) - }) - it('doOpen transport throw of a stale generation is swallowed (generation guard in catch)', async () => { const { api, session } = makeSession() const stale = deferred>>() @@ -1065,28 +823,13 @@ describe('remaining branches', () => { expect(session.getSnapshot().nodes.map(n => n.seq)).toEqual([7, 9]) }) - it('successful cancel leaves no promptError; tool/result for an unknown callId is a no-op', async () => { + it('successful cancel leaves no promptError', async () => { const { api, session } = makeSession() api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) await session.open() const result = await session.cancel() expect(result.ok).toBe(true) expect(session.getSnapshot().promptError).toBeNull() - const callsBefore = session.getSnapshot().runningCalls - session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.toolResult(6, 0, 'never-called', 'x') }) - expect(session.getSnapshot().runningCalls).toBe(callsBefore) // callsRev untouched: same reference - }) - - it('freezes a tool-call-only partial (visible through the non-text arm)', async () => { - const { api, session } = makeSession() - api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) - await session.open() - const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } - feed(ev.turnStart(6, 1)) - feed(at(7, { type: 'assistant/chunk', data: { turn: 1, step: 0, chunk: { type: 'tool-call-delta', index: 0, id: 'c1', name: 'echo', argumentsDelta: '{' } } })) - feed(ev.turnEnd(8, 1, 'aborted')) - const frozen = session.getSnapshot().nodes.at(-1) - expect(frozen).toMatchObject({ kind: 'assistant', interrupted: true, blocks: [{ kind: 'tool-call', callId: 'c1' }] }) }) it('dispose is a reserved no-op on resident instances', () => { @@ -1094,7 +837,7 @@ describe('remaining branches', () => { expect(() => { session.dispose() }).not.toThrow() }) - it('carries mux-frame views into runningCalls and tool-result nodes, and history-entry views through open', async () => { + it('carries history-entry and mux-frame views into the business-neutral Event input', async () => { const { api, session } = makeSession() const callView = { for: 'call', view: { card: 'generic', title: '历史卡' } } api.onHistory = () => Promise.resolve(ok({ @@ -1107,21 +850,23 @@ describe('remaining branches', () => { modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, })) await session.open() - expect(session.getSnapshot().nodes.at(-1)).toMatchObject({ - kind: 'tool-result', callView: { title: '历史卡' }, resultView: { title: '历史果' }, - }) - // Live path: the frame's view slot reaches runningCalls, then the result node. + expect(chatEvents(session.getSnapshot()).slice(-2).map(item => item.view)).toEqual([ + callView, + { for: 'result', view: { card: 'generic', title: '历史果' } }, + ]) session.handleMuxEnvelope('rv1' as never, { type: 'session/event', sessionId: SID, event: ev.toolCall(8, 2, 'l1', 'write', '{}'), view: { for: 'call', view: { card: 'generic', title: '直播卡' } }, } as never) - expect(session.getSnapshot().runningCalls).toMatchObject([{ callId: 'l1', callView: { title: '直播卡' } }]) + expect(chatEvents(session.getSnapshot()).at(-1)?.view).toEqual({ + for: 'call', view: { card: 'generic', title: '直播卡' }, + }) session.handleMuxEnvelope('rv2' as never, { type: 'session/event', sessionId: SID, event: ev.toolResult(9, 2, 'l1', 'ok'), view: { for: 'result', view: { card: 'generic', title: '直播果' } }, } as never) - expect(session.getSnapshot().nodes.at(-1)).toMatchObject({ - kind: 'tool-result', callView: { title: '直播卡' }, resultView: { title: '直播果' }, + expect(chatEvents(session.getSnapshot()).at(-1)?.view).toEqual({ + for: 'result', view: { card: 'generic', title: '直播果' }, }) }) }) @@ -1177,163 +922,27 @@ describe('resync', () => { }) -describe('nested run_code sub-dispatches', () => { - const subCallsOf = (session: Session, callId: string) => { - const snapshot = session.getSnapshot() - const running = snapshot.runningCalls.find(call => call.callId === callId) - if (running !== undefined) return running.subCalls - for (const node of snapshot.nodes) { - if (node.kind === 'tool-result' && node.callId === callId) return node.subCalls - } - return undefined - } - - it('a start event lands as a running-shaped sub-call and its settle replaces it in place', async () => { - const { api, session } = makeSession() - api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答')) - await session.open() - const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } - feed(ev.turnStart(6, 1)) - feed(ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"1","description":"d"}')) - feed(ev.codeDispatchStart(8, 'p1', 1, 'bash', { command: 'sleep' })) - feed(ev.codeDispatchStart(9, 'p1', 2, 'read', { path: 'a.txt' })) - const live = subCallsOf(session, 'p1') - expect(live).toHaveLength(2) - // Running shape (no 'kind'): the exact RunningToolCall form native rows use. - expect(live?.[0]).toMatchObject({ callId: 'p1:code:1', name: 'bash', argsRaw: '{"command":"sleep"}' }) - expect(live?.[0] !== undefined && 'kind' in live[0]).toBe(false) - // Settle out of order (parallel run): #2 first — replaces in place, keeping start order. - feed(ev.codeDispatch(10, 'p1', 2, 'read', { path: 'a.txt' }, 'alpha')) - const mixed = subCallsOf(session, 'p1') - expect(mixed?.map(sub => 'kind' in sub)).toEqual([false, true]) - expect(mixed?.[1]).toMatchObject({ callId: 'p1:code:2', content: [{ type: 'text', text: 'alpha' }] }) - // The settle carries the paired start's time as callTime (duration source). - feed(ev.codeDispatch(11, 'p1', 1, 'bash', { command: 'sleep' }, 'done')) - const settled = subCallsOf(session, 'p1') - expect(settled?.map(sub => 'kind' in sub)).toEqual([true, true]) - expect(settled?.[0]).toMatchObject({ callId: 'p1:code:1', callTime: 1_700_000_000_008 }) - }) - - it('indexes live tool/code-dispatch events under their parent as native-shaped result nodes', async () => { - const { api, session } = makeSession() - api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答')) - await session.open() - const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } - feed(ev.turnStart(6, 1)) - feed(ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"return 1","description":"跑一个程序"}')) - feed(ev.codeDispatch(8, 'p1', 1, 'bash', { command: 'ls', description: '列目录' }, 'demo.txt')) - feed(ev.codeDispatch(9, 'p1', 2, 'read', { path: 'a.txt' }, 'Error: ENOENT', true)) - const subs = subCallsOf(session, 'p1') - expect(subs).toHaveLength(2) - expect(subs?.[0]).toMatchObject({ - kind: 'tool-result', callId: 'p1:code:1', - call: { name: 'bash', argsRaw: '{"command":"ls","description":"列目录"}' }, - // The settle event carries no start time: callTime stays null (never a - // fabricated zero-duration). - callTime: null, - isError: false, content: [{ type: 'text', text: 'demo.txt' }], - }) - expect(subs?.[1]).toMatchObject({ callId: 'p1:code:2', isError: true }) - // No paired start in the window: duration is UNKNOWN (null), never a - // fabricated zero-duration span. - expect(subs?.[0]).toMatchObject({ callTime: null }) - // Sub-dispatches never join the surface flow. - expect(session.getSnapshot().nodes.some(n => n.kind === 'tool-result' && n.callId.includes(':code:'))).toBe(false) - }) - - it('rebuilds the same nested tree from a history window (replay parity)', async () => { - const { api, session } = makeSession() - api.onHistory = () => histResponse([ - ...plainTurn(0, 0, '问', '答'), - ev.turnStart(6, 1), - ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"return 1","description":"跑一个程序"}'), - ev.codeDispatchStart(8, 'p1', 1, 'run_code', { code: 'return tools.read({ path: "a.txt" })' }), - ev.codeDispatch(9, 'p1:code:1', 1, 'read', { path: 'a.txt' }, 'alpha'), - ev.codeDispatch(10, 'p1', 1, 'run_code', { code: 'return tools.read({ path: "a.txt" })' }, 'alpha'), - ev.toolResult(11, 1, 'p1', '{"done":true}'), - ev.turnEnd(12, 1), - ]) - await session.open() - const subs = subCallsOf(session, 'p1') - expect(subs).toHaveLength(1) - expect(subs?.[0]).toMatchObject({ - callId: 'p1:code:1', - call: { name: 'run_code' }, - subCalls: [{ callId: 'p1:code:1:code:1', call: { name: 'read' } }], - }) - }) - - it('keeps an unaffected root reference and path-copies it on a new child', async () => { - const { api, session } = makeSession() - api.onHistory = () => histResponse(plainTurn(0, 0, '稳', '定')) - await session.open() - const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } - feed(ev.turnStart(6, 1)) - feed(ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"1","description":"d"}')) - feed(ev.codeDispatch(8, 'p1', 1, 'bash', { command: 'ls' }, 'x')) - const before = session.getSnapshot() - const beforeRoot = before.runningCalls.find(call => call.callId === 'p1')! - feed(ev.chunkStart(9, 1)) - feed(ev.chunkText(10, 1, '流式')) - const after = session.getSnapshot() - const afterRoot = after.runningCalls.find(call => call.callId === 'p1')! - expect(afterRoot).toBe(beforeRoot) - feed(ev.codeDispatch(11, 'p1', 2, 'read', { path: 'a' }, 'y')) - const changedRoot = session.getSnapshot().runningCalls.find(call => call.callId === 'p1')! - expect(changedRoot).not.toBe(afterRoot) - expect(changedRoot.subCalls[0]).toBe(afterRoot.subCalls[0]) - expect(changedRoot.subCalls).toHaveLength(2) - }) - - it('path-copies only the owning branch when a nested child changes', async () => { - const { api, session } = makeSession() - api.onHistory = () => histResponse(plainTurn(0, 0, '树', '结构')) - await session.open() - const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } - feed(ev.turnStart(6, 1)) - feed(ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"1","description":"first"}')) - feed(ev.toolCall(8, 1, 'p2', 'run_code', '{"code":"2","description":"second"}')) - feed(ev.codeDispatch(9, 'p1', 1, 'run_code', { code: 'nested' }, 'child')) - feed(ev.codeDispatch(10, 'p1', 2, 'read', { path: 'sibling' }, 'sibling')) - feed(ev.codeDispatch(11, 'p2', 1, 'bash', { command: 'pwd' }, 'root two')) - const before = session.getSnapshot() - const beforeFirst = before.runningCalls.find(call => call.callId === 'p1')! - const beforeSecond = before.runningCalls.find(call => call.callId === 'p2')! - const beforeChild = beforeFirst.subCalls[0]! - const beforeSibling = beforeFirst.subCalls[1]! - - feed(ev.codeDispatch(12, 'p1:code:1', 1, 'read', { path: 'nested' }, 'leaf')) - const after = session.getSnapshot() - const afterFirst = after.runningCalls.find(call => call.callId === 'p1')! - const afterSecond = after.runningCalls.find(call => call.callId === 'p2')! - - expect(afterFirst).not.toBe(beforeFirst) - expect(afterSecond).toBe(beforeSecond) - expect(afterFirst.subCalls[0]).not.toBe(beforeChild) - expect(afterFirst.subCalls[1]).toBe(beforeSibling) - expect(afterFirst.subCalls[0]?.subCalls).toMatchObject([ - { callId: 'p1:code:1:code:1', call: { name: 'read' } }, - ]) - }) -}) - describe('reference stability (the memo contract)', () => { it('keeps unchanged node references across an append and swaps the snapshot object', async () => { const { api, session } = makeSession() api.onHistory = () => histResponse(plainTurn(0, 0, '稳', '定')) await session.open() const before = session.getSnapshot() + const firstKey = before.chat.order[0]! + const secondKey = before.chat.order[1]! + const first = before.chat.nodes.get(firstKey) + const second = before.chat.nodes.get(secondKey) session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.user(6, '追加') }) const after = session.getSnapshot() expect(after).not.toBe(before) // top-level swap on change - expect(after.nodes[0]).toBe(before.nodes[0]) // untouched nodes keep identity - expect(after.nodes[1]).toBe(before.nodes[1]) - expect(after.nodes).toHaveLength(3) + expect(after.chat.nodes.get(firstKey)).toBe(first) + expect(after.chat.nodes.get(secondKey)).toBe(second) + expect(after.chat.order).toHaveLength(7) // No change → same snapshot reference. expect(session.getSnapshot()).toBe(after) }) - it('keeps untouched substructure arrays identical across unrelated changes (revision counters)', async () => { + it('keeps unrelated Session arrays and settled Chat Nodes stable across Event updates', async () => { const { api, session } = makeSession() api.onHistory = () => histResponse(plainTurn(0, 0, '底', '座')) await session.open() @@ -1343,20 +952,19 @@ describe('reference stability (the memo contract)', () => { feed(ev.toolCall(8, 1, 'c1', 'echo', '{}')) session.handleMuxEnvelope('ra' as never, { type: 'approval/requested', sessionId: SID, approvalId: 'ap1' as never, toolName: 'rm' }) const before = session.getSnapshot() - // A chunk storm touches partial/nodes only: unrelated projections keep identity. + const settledKey = before.chat.order[0]! + const settledNode = before.chat.nodes.get(settledKey) feed(ev.chunkStart(9, 1)) feed(ev.chunkText(10, 1, '与工具无关的流式')) const after = session.getSnapshot() expect(after).not.toBe(before) expect(after.runningCalls).toBe(before.runningCalls) expect(after.pending).toBe(before.pending) - expect(after.turnTimings).toBe(before.turnTimings) - expect(after.turnEnds).toBe(before.turnEnds) - // And a mutation on the tracked domain swaps that array. + expect(after.chat.nodes.get(settledKey)).toBe(settledNode) feed(ev.toolResult(11, 1, 'c1', 'ECHO')) const resolved = session.getSnapshot() - expect(resolved.runningCalls).not.toBe(after.runningCalls) expect(resolved.pending).toBe(after.pending) + expect(resolved.chat.nodes.get(settledKey)).toBe(settledNode) feed(ev.assistant(12, 1, '完成')) expect(session.getSnapshot()).not.toBe(resolved) }) diff --git a/packages/client/runtime/tests/transcript-adapter.spec.ts b/packages/client/runtime/tests/transcript-adapter.spec.ts deleted file mode 100644 index b161046f84..0000000000 --- a/packages/client/runtime/tests/transcript-adapter.spec.ts +++ /dev/null @@ -1,567 +0,0 @@ -/** - * TranscriptAdapter over the raw append-only window: log-ordered projection of - * append-origin events, one marker per landed compaction, replacement copies - * hidden, command-lifecycle folding, node/array identity, call pairing, and - * host-provided wire views. - */ - -import { createUserMessage, CallId, createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm' -import { describe, expect, it } from 'vitest' -import type { SessionEvent } from '@deepseek-ai/dsh-session/types' -import { TranscriptAdapter } from '../src/client/sessions/transcript-adapter.ts' -import { ev, plainTurn } from './event-script.ts' - -const at = (seq: number, e: Record): SessionEvent => - ({ seq, time: 1_700_000_000_000 + seq, ...e }) as unknown as SessionEvent - -/** A `compact/summary` event (log-only, no surfaceOp). */ -function compactSummary(seq: number, summary: unknown = [{ type: 'text', text: '# 摘要\n\n保留事实' }]): SessionEvent { - return at(seq, { - type: 'compact/summary', - data: { - summary, - shadowedRange: { start: 1, end: 3 }, - shadowedSeqs: [1, 3], - shadowedTokenCount: 100, - provider: 'fake', - model: 'compact-1', - }, - }) -} - -/** The replacement user message a compaction backend lands (the checkpoint). */ -function checkpoint( - seq: number, - summarySeq: number, - { start = 1, end = 3, sourceEventSeqs = [summarySeq, start, end] }: { - start?: number - end?: number - sourceEventSeqs?: number[] - } = {}, -): SessionEvent { - return at(seq, { - type: 'user/message', - surfaceOp: { op: 'replace', start, end }, - sourceEventSeqs, - data: createUserMessage({ - content: [{ type: 'text', text: 'model only' }], - source: { kind: 'plugin', plugin: 'compact' }, - }), - }) -} - -describe('TranscriptAdapter', () => { - it('projects a window starting past seq 0 at its own log positions', () => { - const adapter = new TranscriptAdapter() - adapter.reset(plainTurn(100, 5, '偏移问', '偏移答')) - expect(adapter.nodes().map(n => [n.kind, n.seq])).toEqual([['user', 101], ['assistant', 103]]) - }) - - it('appends incrementally keeping old node references (materialize-once identity)', () => { - const adapter = new TranscriptAdapter() - adapter.reset(plainTurn(0, 0, 'a', 'b')) - const first = adapter.nodes() - adapter.append(ev.user(6, '追加')) - const second = adapter.nodes() - expect(second).toHaveLength(3) - expect(second[0]).toBe(first[0]) - expect(second[1]).toBe(first[1]) - expect(second).not.toBe(first) // a real change swaps the array - }) - - it('keeps the array reference across a chunk storm and swaps it when a node lands', () => { - const adapter = new TranscriptAdapter() - adapter.reset(plainTurn(0, 0, 'a', 'b')) - const settled = adapter.nodes() - adapter.append(ev.chunkStart(6, 1)) - expect(adapter.nodes()).toBe(settled) - adapter.append(ev.chunkText(7, 1, '流式')) - expect(adapter.nodes()).toBe(settled) - adapter.append(ev.assistant(8, 1, '流式完成')) - const finalized = adapter.nodes() - expect(finalized).not.toBe(settled) - expect(finalized.at(-1)).toMatchObject({ kind: 'assistant', seq: 8 }) - }) - - it('materializes every append-origin variant with field mapping', () => { - const adapter = new TranscriptAdapter() - const steering = createUserMessage({ - content: [{ type: 'text', text: '插话' }], - source: { kind: 'user' }, - }) - adapter.reset([ - ev.user(0, '用户'), - ev.assistant(1, 0, '助手'), - at(2, { type: 'agent/inbox/spliced', data: { - target: 'next-step', start: 0, inserted: [steering], - } }), - at(3, { type: 'agent/inbox/spliced', data: { - target: 'next-step', start: 0, removedCount: 1, inserted: [], - } }), - at(4, { type: 'user/message', surfaceOp: 'append', data: steering }), - at(5, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({ - content: [{ type: 'text', text: '上下文' }], source: { kind: 'plugin', plugin: 'p' }, - }) }), - ev.toolCall(6, 0, 'c1', 'echo', '{"x":1}'), - ev.toolResult(7, 0, 'c1', '结果'), - ]) - const nodes = adapter.nodes() - expect(nodes.map(n => n.kind)).toEqual(['user', 'assistant', 'steering', 'context', 'tool-result']) - expect(nodes.find(n => n.kind === 'steering')).toMatchObject({ messageId: steering.id }) - expect(nodes.find(n => n.kind === 'tool-result')).toMatchObject({ - callId: 'c1', call: { name: 'echo', argsRaw: '{"x":1}' }, isError: false, - }) - }) - - it('identifies steering on the live append path', () => { - const adapter = new TranscriptAdapter() - const steering = createUserMessage({ - content: [{ type: 'text', text: 'live steer' }], - source: { kind: 'user' }, - }) - adapter.reset([]) - adapter.append(at(0, { type: 'agent/inbox/spliced', data: { - target: 'next-step', start: 0, inserted: [steering], - } })) - adapter.append(at(1, { type: 'agent/inbox/spliced', data: { - target: 'next-step', start: 0, removedCount: 1, inserted: [], - } })) - adapter.append(at(2, { type: 'user/message', surfaceOp: 'append', data: steering })) - expect(adapter.nodes()).toMatchObject([{ kind: 'steering', messageId: steering.id }]) - }) - - it('does not mark queued, canceled, or non-user next-step messages as steering', () => { - const adapter = new TranscriptAdapter() - const queued = createUserMessage({ content: [{ type: 'text', text: 'queued' }], source: { kind: 'user' } }) - const canceled = createUserMessage({ content: [{ type: 'text', text: 'canceled' }], source: { kind: 'user' } }) - const context = createUserMessage({ - content: [{ type: 'text', text: 'context' }], - source: { kind: 'plugin', plugin: 'test' }, - }) - adapter.reset([ - at(0, { type: 'agent/inbox/spliced', data: { - target: 'next-turn', start: 0, inserted: [queued], - } }), - at(1, { type: 'agent/inbox/spliced', data: { - target: 'next-turn', start: 0, removedCount: 1, inserted: [], - } }), - at(2, { type: 'user/message', surfaceOp: 'append', data: queued }), - at(3, { type: 'agent/inbox/spliced', data: { - target: 'next-step', start: 0, inserted: [canceled], - } }), - at(4, { type: 'agent/inbox/spliced', data: { - target: 'next-step', start: 0, removedCount: 1, inserted: [], outcome: 'canceled', - } }), - at(5, { type: 'user/message', surfaceOp: 'append', data: canceled }), - at(6, { type: 'agent/inbox/spliced', data: { - target: 'next-step', start: 0, inserted: [context], - } }), - at(7, { type: 'agent/inbox/spliced', data: { - target: 'next-step', start: 0, removedCount: 1, inserted: [], - } }), - at(8, { type: 'user/message', surfaceOp: 'append', data: context }), - ]) - expect(adapter.nodes().map(node => node.kind)).toEqual(['user', 'user', 'context']) - }) - - it('materializes a skill-invocation injection as a named instructions context', () => { - const adapter = new TranscriptAdapter() - adapter.reset([ - at(0, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({ - content: [{ type: 'text', text: '/hidden-demo check the fixture' }], - source: { kind: 'user' }, - }) }), - at(1, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({ - content: [{ type: 'text', text: 'body' }], - source: { kind: 'skill-invocation', name: 'hidden-demo', form: 'instructions' } as never, - }) }), - ]) - const nodes = adapter.nodes() - // The gesture stays a user bubble; the injected body folds to a context - // row named after the skill, presented as instructions. - expect(nodes.map(node => node.kind)).toEqual(['user', 'context']) - expect(nodes[1]).toMatchObject({ - provenance: { role: 'inject', label: 'hidden-demo' }, - form: 'instructions', - }) - }) - - it('skips events core does not call surface-eligible, marker or not', () => { - // The transcript is the append-origin surface, so log-only events (a chunk, - // a turn boundary, a `compact/*` record) and a future type core - // has not admitted contribute no node. - const adapter = new TranscriptAdapter() - adapter.reset([ - ev.turnStart(0, 1), - at(1, { type: 'notice/message', surfaceOp: 'append', data: { note: 1 } }), - compactSummary(2), - ev.user(3, '唯一的一条'), - ev.turnEnd(4, 1), - ]) - expect(adapter.nodes().map(n => [n.kind, n.seq])).toEqual([['user', 3]]) - }) - - describe('compaction markers', () => { - it('keeps the original messages and full tool output, hiding replacement copies', () => { - const adapter = new TranscriptAdapter() - adapter.reset([ - ev.user(0, '原始问题'), - ev.assistant(1, 0, '原始回答'), - ev.toolCall(4, 0, 'c1', 'echo', '{}'), - ev.toolResult(5, 0, 'c1', '完整工具输出'), - // A pruned tool/result copy: rewrites one node for the model, marks nothing. - at(6, { type: 'tool/result', surfaceOp: { op: 'replace', start: 5, end: 5 }, sourceEventSeqs: [5], data: { - turn: 0, step: 0, - message: createToolResultMessage({ callId: CallId('c1'), content: [{ type: 'text', text: '已裁剪' }], isError: false }), - } }), - compactSummary(7), - checkpoint(8, 7, { start: 1, end: 5, sourceEventSeqs: [7, 1, 5] }), - // A regenerated assistant/message: also a silent model-only rewrite. - at(9, { type: 'assistant/message', surfaceOp: { op: 'replace', start: 8, end: 8 }, sourceEventSeqs: [8], data: { - turn: 0, step: 0, - message: createMessage({ - role: 'assistant', - content: [{ type: 'text', text: '通用 replacement 副本' }], - source: { kind: 'model', ...{ provider: 'x', model: 'copy' } }, - }), - } }), - ]) - const nodes = adapter.nodes() - expect(nodes.map(n => [n.kind, n.seq])).toEqual([ - ['user', 0], ['assistant', 1], ['tool-result', 5], ['compaction', 8], - ]) - expect(nodes[2]).toMatchObject({ kind: 'tool-result', content: [{ type: 'text', text: '完整工具输出' }] }) - expect(nodes[3]).toMatchObject({ kind: 'compaction', summary: '# 摘要\n\n保留事实' }) - }) - - it('adds one marker per landed compaction, in log order', () => { - const adapter = new TranscriptAdapter() - adapter.reset([ - ev.user(0, 'a'), - compactSummary(1, [{ type: 'text', text: 'first' }]), - checkpoint(2, 1, { start: 0, end: 0, sourceEventSeqs: [1, 0] }), - ev.user(3, 'b'), - compactSummary(4, [{ type: 'text', text: 'second' }]), - checkpoint(5, 4, { start: 2, end: 3, sourceEventSeqs: [4, 2, 3] }), - ]) - expect(adapter.nodes().filter(n => n.kind === 'compaction')).toEqual([ - { - kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: 'first', - summaryEventSeq: 1, shadowedItemCount: 2, shadowedTokenCount: 100, - }, - { - kind: 'compaction', seq: 5, time: 1_700_000_000_005, summary: 'second', - summaryEventSeq: 4, shadowedItemCount: 2, shadowedTokenCount: 100, - }, - ]) - }) - - it('renders the marker when the shadowed range is outside the window and logs nothing', () => { - // The pagination hole A1 left open: quota is no longer spent on - // replacement copies, so a page can carry a checkpoint whose - // surfaceOp.start lies below the window head. The old surface fold threw - // on the missing range and degraded with a console error; a log-ordered - // projection has no range to resolve. - const adapter = new TranscriptAdapter() - const noise = { error: console.error, warn: console.warn } - const logged: unknown[] = [] - console.error = (...args: unknown[]) => logged.push(args) - console.warn = (...args: unknown[]) => logged.push(args) - try { - adapter.reset([ - compactSummary(80, [{ type: 'text', text: '窗外范围' }]), - checkpoint(81, 80, { start: 3, end: 40, sourceEventSeqs: [80, 3, 40] }), - ev.user(82, '压缩后的新问题'), - ]) - expect(adapter.nodes().map(n => [n.kind, n.seq])).toEqual([['compaction', 81], ['user', 82]]) - expect(adapter.nodes()[0]).toMatchObject({ summary: '窗外范围' }) - } finally { - console.error = noise.error - console.warn = noise.warn - } - expect(logged).toEqual([]) - }) - - it('treats an APPENDING plugin-sourced user/message as injected context, not a compaction', () => { - // A session-reference card carries the same plugin source shape; only the - // replacement marker makes an event a checkpoint. - const adapter = new TranscriptAdapter() - adapter.reset([ - at(0, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({ - content: [{ type: 'text', text: '注入的上下文' }], - source: { kind: 'plugin', plugin: 'compact', form: 'instructions' }, - }) }), - ]) - expect(adapter.nodes()).toMatchObject([{ - kind: 'context', - seq: 0, - provenance: { role: 'inject', label: 'compact' }, - form: 'instructions', - }]) - }) - - it('ignores a foreign plugin s replacement user/message', () => { - const adapter = new TranscriptAdapter() - adapter.reset([ - ev.user(0, '保留'), - at(1, { type: 'user/message', surfaceOp: { op: 'replace', start: 0, end: 0 }, sourceEventSeqs: [0], data: createUserMessage({ - content: [{ type: 'text', text: '别的插件重写' }], - source: { kind: 'plugin', plugin: 'not-compact' }, - }) }), - ]) - expect(adapter.nodes().map(n => [n.kind, n.seq])).toEqual([['user', 0]]) - }) - - it.each([ - ['absent summary event', undefined], - ['text-less summary blocks', compactSummary(1, [{ type: 'image', data: 'nope' }])], - ['a whitespace-only summary', compactSummary(1, [{ type: 'text', text: ' ' }])], - ['an empty summary array', compactSummary(1, [])], - ['a non-array summary', compactSummary(1, 'plain string')], - ])('degrades %s to a non-expandable marker', (_label, summary) => { - const adapter = new TranscriptAdapter() - adapter.reset([ - ...(summary === undefined ? [] : [summary]), - checkpoint(2, 1, { start: 0, end: 0, sourceEventSeqs: [1, 0] }), - ]) - expect(adapter.nodes()).toMatchObject([ - { kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: null }, - ]) - }) - - it('keeps the text of a mixed-block summary, skipping the blocks it cannot render', () => { - // ContentBlock is merge-extensible and the payload type is ContentBlock[], - // so a non-text block must not discard recoverable text beside it. - const adapter = new TranscriptAdapter() - adapter.reset([ - compactSummary(1, [{ type: 'text', text: '可用摘要' }, { type: 'image', data: 'nope' }]), - checkpoint(2, 1, { start: 0, end: 0, sourceEventSeqs: [1, 0] }), - ]) - expect(adapter.nodes()).toEqual([ - { - kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: '可用摘要', - summaryEventSeq: 1, shadowedItemCount: 2, shadowedTokenCount: 100, - }, - ]) - }) - - it('leaves the summary null when the checkpoint cites no source events', () => { - const adapter = new TranscriptAdapter() - adapter.reset([at(2, { - type: 'user/message', - surfaceOp: { op: 'replace', start: 0, end: 0 }, - data: createUserMessage({ - content: [{ type: 'text', text: 'x' }], - source: { kind: 'plugin', plugin: 'compact' }, - }), - })]) - expect(adapter.nodes()).toEqual([{ - kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: null, - summaryEventSeq: null, shadowedItemCount: null, shadowedTokenCount: null, - }]) - }) - - it('skips a cited non-summary seq before reaching the summary event', () => { - const adapter = new TranscriptAdapter() - adapter.reset([ - ev.user(0, '被压缩的问题'), - at(1, { type: 'compact/start', data: { turn: 0 } }), - compactSummary(2, [{ type: 'text', text: '第三个来源才是摘要' }]), - checkpoint(3, 2, { start: 0, end: 0, sourceEventSeqs: [1, 2, 0] }), - ]) - expect(adapter.nodes().at(-1)).toMatchObject({ kind: 'compaction', summary: '第三个来源才是摘要' }) - }) - - it('resolves the summary once an older page supplies the cited summary event', () => { - const adapter = new TranscriptAdapter() - const landed = checkpoint(8, 7, { start: 0, end: 0, sourceEventSeqs: [7, 0] }) - adapter.reset([landed]) - expect(adapter.nodes()[0]).toMatchObject({ kind: 'compaction', summary: null }) - adapter.reset([compactSummary(7, [{ type: 'text', text: '分页补齐的摘要' }]), landed]) - expect(adapter.nodes()[0]).toMatchObject({ kind: 'compaction', summary: '分页补齐的摘要' }) - }) - - it('creates the marker on the live append path', () => { - const adapter = new TranscriptAdapter() - adapter.reset(plainTurn(0, 0, 'a', 'b')) - adapter.append(compactSummary(6, [{ type: 'text', text: '直播摘要' }])) - adapter.append(checkpoint(7, 6, { start: 1, end: 3, sourceEventSeqs: [6, 1, 3] })) - const nodes = adapter.nodes() - // The compacted history is still there; the marker is one more row after it. - expect(nodes.map(n => [n.kind, n.seq])).toEqual([['user', 1], ['assistant', 3], ['compaction', 7]]) - expect(nodes.at(-1)).toMatchObject({ kind: 'compaction', seq: 7, summary: '直播摘要' }) - }) - }) - - it('returns call:null for a tool-result whose call fell outside the window', () => { - const adapter = new TranscriptAdapter() - adapter.reset([ev.toolResult(50, 3, 'outside-call', '孤儿结果')]) - expect(adapter.nodes()[0]).toMatchObject({ kind: 'tool-result', callId: 'outside-call', call: null }) - }) - - it('materializes a tool-result error field when present', () => { - const adapter = new TranscriptAdapter() - adapter.reset([ - at(0, { type: 'tool/result', surfaceOp: 'append', data: { - turn: 0, step: 0, - message: createToolResultMessage({ callId: CallId('c1'), content: [], isError: true }), - error: { name: 'Boom', code: 'boom' }, - } }), - ]) - expect(adapter.nodes()[0]).toMatchObject({ kind: 'tool-result', isError: true, error: { code: 'boom' } }) - }) - - it('attaches wire views to the materialized result node', () => { - const adapter = new TranscriptAdapter() - const callView = { for: 'call' as const, view: { card: 'terminal' as const, command: 'ls' } } - const resultView = { for: 'result' as const, view: { card: 'generic' as const, title: '完成' } } - adapter.reset([ - ev.toolCall(0, 1, 'c1', 'bash', '{"cmd":"ls"}'), - ev.toolResult(1, 1, 'c1', 'listing'), - ], [callView, resultView] as never) - expect(adapter.nodes().find(n => n.kind === 'tool-result')).toMatchObject({ - callView: { card: 'terminal' }, resultView: { card: 'generic', title: '完成' }, - }) - }) - - it('attaches views on the live append path and defaults to null without views', () => { - const adapter = new TranscriptAdapter() - adapter.reset(plainTurn(0, 0, 'a', 'b')) // no views argument - adapter.append(ev.toolCall(6, 1, 'c2', 'echo', '{}'), { for: 'call', view: { card: 'generic', title: '回声' } } as never) - adapter.append(ev.toolResult(7, 1, 'c2', 'ok')) // no view on the result - expect(adapter.nodes().find(n => n.kind === 'tool-result')).toMatchObject({ - callView: { title: '回声' }, resultView: null, - }) - }) - - it('leaves callView null when the paired call fell outside the window (cross-page break)', () => { - const adapter = new TranscriptAdapter() - const resultView = { for: 'result' as const, view: { card: 'generic' as const, title: '孤儿' } } - adapter.reset([ev.toolResult(50, 3, 'outside', '窗外配对')], [resultView] as never) - expect(adapter.nodes()[0]).toMatchObject({ - kind: 'tool-result', call: null, callView: null, resultView: { title: '孤儿' }, - }) - }) - - describe('command lifecycle nodes', () => { - it('folds a run/done pair into one settled node merged into flow order by seq', () => { - const adapter = new TranscriptAdapter() - adapter.reset([ - ev.user(0, '先说话'), - ev.commandRun(1, 'cmd-1', 'plan'), - ev.commandDone(2, 'cmd-1', 'success', '已进入 plan mode'), - ev.assistant(3, 0, '然后回答'), - ]) - const nodes = adapter.nodes() - expect(nodes.map(n => [n.kind, n.seq])).toEqual([['user', 0], ['command', 1], ['assistant', 3]]) - expect(nodes[1]).toMatchObject({ - kind: 'command', commandId: 'cmd-1', name: 'plan', args: '', - outcome: { kind: 'success', text: '已进入 plan mode' }, - }) - }) - - it('renders a run with no done as still executing (outcome null)', () => { - const adapter = new TranscriptAdapter() - adapter.reset([ev.commandRun(0, 'cmd-2', 'goal', ' ship it')]) - expect(adapter.nodes()[0]).toMatchObject({ kind: 'command', name: 'goal', args: ' ship it', outcome: null }) - }) - - it('represents command input omitted by the host as null', () => { - const adapter = new TranscriptAdapter() - adapter.reset([ev.commandRunWithoutInput(0, 'cmd-private', 'feedback')]) - expect(adapter.nodes()[0]).toMatchObject({ - kind: 'command', name: 'feedback', args: null, outcome: null, - }) - }) - - it('soft-falls a done-only window into a node built from the done (cross-window cut)', () => { - const adapter = new TranscriptAdapter() - adapter.reset([ev.commandDone(80, 'cmd-3', 'error', '失败了')]) - expect(adapter.nodes()[0]).toMatchObject({ - kind: 'command', seq: 80, commandId: 'cmd-3', name: null, args: null, - outcome: { kind: 'error', text: '失败了' }, - }) - }) - - it('settles a live-appended done in place, keeping the node at the run seq', () => { - const adapter = new TranscriptAdapter() - adapter.reset(plainTurn(0, 0, 'q', 'a')) - adapter.append(ev.commandRun(6, 'cmd-4', 'clear')) - const running = adapter.nodes().find(n => n.kind === 'command') - expect(running).toMatchObject({ outcome: null }) - adapter.append(ev.commandDone(7, 'cmd-4')) - const settled = adapter.nodes().find(n => n.kind === 'command') - expect(settled).toMatchObject({ seq: 6, outcome: { kind: 'success' } }) - // Settlement replaced the node object rather than mutating the published one. - expect(settled).not.toBe(running) - }) - - it('tails command nodes whose seq is past every transcript node', () => { - const adapter = new TranscriptAdapter() - adapter.reset([ev.user(0, '问'), ev.commandRun(1, 'cmd-tail', 'plan')]) - expect(adapter.nodes().map(n => n.kind)).toEqual(['user', 'command']) - }) - - it('preserves the domain-event link for the UI to fold a /compact row into its marker', () => { - const adapter = new TranscriptAdapter() - adapter.reset([ - ev.user(0, '压缩前的问题'), - ev.commandRun(1, 'cmd-compact', 'compact'), - compactSummary(2, [{ type: 'text', text: '手动压缩摘要' }]), - checkpoint(3, 2, { start: 0, end: 0, sourceEventSeqs: [2, 0] }), - ev.commandDone(4, 'cmd-compact', 'success', '已压缩', 2), - ]) - const nodes = adapter.nodes() - expect(nodes.map(n => [n.kind, n.seq])).toEqual([['user', 0], ['command', 1], ['compaction', 3]]) - expect(nodes[1]).toMatchObject({ - name: 'compact', - outcome: { kind: 'success', text: '已压缩', sourceEventSeq: 2 }, - }) - expect(nodes[2]).toMatchObject({ kind: 'compaction', summaryEventSeq: 2 }) - }) - }) - - describe('assistant timing', () => { - const base = 1_700_000_000_000 - - it('derives step timing across a window rebuild (start + first token + completion)', () => { - const adapter = new TranscriptAdapter() - adapter.reset([ - ev.turnStart(0, 0), - ev.user(1, '问'), - ev.stepStart(2, 0), - ev.chunkStart(3, 0), - ev.chunkText(4, 0, '答'), - ev.chunkText(5, 0, '案'), - ev.assistant(6, 0, '答案'), - ev.turnEnd(7, 0), - ]) - const assistant = adapter.nodes().find(n => n.kind === 'assistant') - expect(assistant).toMatchObject({ - timing: { stepStartTime: base + 2, firstTokenTime: base + 4, completedTime: base + 6 }, - }) - }) - - it('derives the same timing on the live append path, first token winning once', () => { - const adapter = new TranscriptAdapter() - adapter.reset([ev.user(0, '问')]) - adapter.append(ev.stepStart(1, 0)) - adapter.append(ev.chunkText(2, 0, '首')) - adapter.append(ev.chunkText(3, 0, '次')) - adapter.append(ev.assistant(4, 0, '首次')) - const assistant = adapter.nodes().find(n => n.kind === 'assistant') - expect(assistant).toMatchObject({ - timing: { stepStartTime: base + 1, firstTokenTime: base + 2, completedTime: base + 4 }, - }) - }) - - it('soft-falls to null boundaries when the step opening fell outside the window', () => { - const adapter = new TranscriptAdapter() - adapter.reset([ev.assistant(100, 0, '被切窗的答案')]) - const assistant = adapter.nodes().find(n => n.kind === 'assistant') - expect(assistant).toMatchObject({ - timing: { stepStartTime: null, firstTokenTime: null, completedTime: base + 100 }, - }) - }) - }) -}) diff --git a/packages/client/test-runtime/src/fixtures.ts b/packages/client/test-runtime/src/fixtures.ts index ea01893346..7f65a0b5a3 100644 --- a/packages/client/test-runtime/src/fixtures.ts +++ b/packages/client/test-runtime/src/fixtures.ts @@ -2,6 +2,7 @@ import type { ConversationSnapshot, ISession, SessionId, SessionSummary, WorkspaceListState, } from '@deepseek-ai/dsh-client-runtime/client' +import { EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-runtime/client' /** * Fixture overrides for the session behavior face: any subset of the @@ -45,6 +46,7 @@ export interface SessionFixture { export function conversationSnapshot(sessionId: SessionId): ConversationSnapshot { return { sessionId, + chat: EMPTY_CHAT_SNAPSHOT, nodes: [], turnTimings: new Map(), turnEnds: new Map(), diff --git a/packages/client/test-runtime/src/index.ts b/packages/client/test-runtime/src/index.ts index 3005c061e7..0b8c732683 100644 --- a/packages/client/test-runtime/src/index.ts +++ b/packages/client/test-runtime/src/index.ts @@ -22,7 +22,9 @@ import { act, render, within } from '@testing-library/react' import type { RenderResult } from '@testing-library/react' import type { queries } from '@testing-library/dom' import type { BoundFunctions } from '@testing-library/dom' -import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import { + ConversationEventRegistry, ConversationViewRegistry, SlotsService, +} from '@deepseek-ai/dsh-client-runtime/client' import { createSlotRenderer } from '@deepseek-ai/dsh-client-web-react' import type { ChildrenDecl, ComposedProps, OwnerOf, SlotComponent, SlotMap, SlotRendererHost, StoreInstanceLike, @@ -142,7 +144,7 @@ export class TestRoot { */ async declare( children: D, - frame: SlotComponent & keyof SlotMap & string, undefined, object>>, + frame: SlotComponent & keyof SlotMap & string, undefined, object>>, ): Promise { await this.stabilize(() => { // Erased hop (same pattern as SlotsService's own implementation arm); @@ -218,6 +220,8 @@ export class SlotTestRuntime { const ctx = new Context() const fiber = ctx.plugin(SlotsService) await fiber.await() + await ctx.plugin(ConversationEventRegistry).await() + await ctx.plugin(ConversationViewRegistry).await() return new SlotTestRuntime(ctx, ctx.get('slots') as SlotsService) } diff --git a/packages/client/ui-conversation/tests/chat-apply.spec.tsx b/packages/client/ui-conversation/tests/chat-apply.spec.tsx index 00001275d2..3481ee758e 100644 --- a/packages/client/ui-conversation/tests/chat-apply.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-apply.spec.tsx @@ -53,7 +53,7 @@ describe('apply wiring', () => { await b.runtime.dispose() }) - it('registers the chat view as the first ring entry, declaring the whole-Tool seat', async () => { + it('registers the chat view and its keyed business-node seat', async () => { const b = await bench() const entries = b.slots.entries('conversation.view') expect(entries.map(e => e.options.id)).toEqual(['chat']) @@ -62,7 +62,7 @@ describe('apply wiring', () => { expect(entries[0]?.options.order).toBe(0) // Declaring is claiming: the chat entry's registration put the hole on // the ledger with the contract's kind/scope. - expect(b.slots.spec('conversation.chat.tool')).toEqual({ kind: 'single', scope: 'session' }) + expect(b.slots.spec('conversation.chat.node')).toEqual({ kind: 'keyed', scope: 'session' }) await b.runtime.dispose() }) @@ -95,7 +95,7 @@ describe('apply wiring', () => { // file-mutation registrant claims both write and edit for the diff card; the // one search row registers under both grep and glob; the web rows register // one component under both web tool names. - expect(b.slots.entries('conversation.chat.tool')).toHaveLength(0) + expect(b.slots.entries('conversation.chat.node').map(entry => entry.options.key)).not.toContain('tool-call') // Stats stick with the composer (not inside ChatView). expect(b.slots.entries('conversation.composer.dock').map(e => e.options.id)).toEqual(['stats']) await b.runtime.dispose() @@ -108,8 +108,8 @@ describe('apply wiring', () => { // The declared ring collapses with its declaring entry, and the chat // entry's keyed hole (with the sample's registration) collapses with it. expect(b.slots.entries('conversation.view')).toHaveLength(0) - expect(b.slots.entries('conversation.chat.tool')).toHaveLength(0) - expect(b.slots.spec('conversation.chat.tool')).toBeUndefined() + expect(b.slots.entries('conversation.chat.node')).toHaveLength(0) + expect(b.slots.spec('conversation.chat.node')).toBeUndefined() expect(b.slots.entries('details')).toHaveLength(0) expect(b.slots.entries('settings.general.item')).toHaveLength(0) expect(b.runtime.ctx.get('conversation')).toBeUndefined() diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index 0362c3ee4b..6037405336 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -10,13 +10,21 @@ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' +import type { + ChatConversationViewNode, ConversationNode, +} from '@deepseek-ai/dsh-client-runtime/client' +import type { ChatNodeViewProps } from '../src/client/contract/slots.ts' import { formatMessageClock, msUntilNextLocalMidnight, startOfLocalDay, } from '../src/client/chat/message-chrome.ts' -import { MessageItem, type MessageItemProps } from '../src/client/chat/MessageItem.tsx' +import { + CompactionNodeView, ContextMessageNodeView, RetryNodeView, UnknownNodeView, + UserMessageNodeView, +} from '../src/client/chat/MessageItem.tsx' import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx' import { StatsLine, type StatsLineProps } from '../src/client/chat/StatsLine.tsx' import { zh } from '../src/client/locales.ts' +import { chatSnapshotFixture } from './chat-snapshot-fixture.ts' /** jsdom has no ResizeObserver; StatsLine watches its row for ellipsis truncation through one. */ class ResizeObserverStub { @@ -33,7 +41,44 @@ afterEach(() => { }) // Mirrors the real lookup chain (conversation namespace, then common). -const t: MessageItemProps['t'] = makeTranslate(zh, commonZh) +const t: ChatNodeViewProps['t'] = makeTranslate(zh, commonZh) +const RETRY_ID = 'retry-fixture' as Extract['retryId'] + +interface MessageItemProps { + readonly node: ConversationNode + readonly t: ChatNodeViewProps['t'] +} + +/** Legacy-node fixture adapter for the independently registered renderers. */ +function MessageItem({ node, t: translate }: MessageItemProps) { + const kind = node.kind === 'assistant' ? 'assistant-step' : node.kind + const viewNode: ChatConversationViewNode = { + key: `fixture:${node.kind}:${node.seq}`, + kind, + id: String(node.seq), + target: 'chat', + anchorSeq: node.seq, + location: { kind: 'session' }, + visibility: 'visible', + data: node.kind === 'model-retry' ? { attempts: [node], current: node } : node, + } + const props = { node: viewNode, t: translate } as ChatNodeViewProps + switch (node.kind) { + case 'user': + case 'steering': + return } /> + case 'context': + return } /> + case 'compaction': + return } /> + case 'model-retry': + return } /> + case 'unknown': + return } /> + default: + throw new Error(`unsupported MessageItem fixture kind: ${node.kind}`) + } +} describe('MessageItem arms', () => { it('user bubbles expose clock / copy and neither branch nor edit; copy writes the text', () => { @@ -727,9 +772,9 @@ describe('MessageItem arms', () => { const view = render( { view.rerender( { view.rerender( { view.rerender( { view.rerender( { expect(view.getByRole('status').textContent).toBe('模型请求重试已取消(1/2) · 4s') }) - it('synchronizes the countdown when an inactive retry becomes active at the one-second floor', () => { - vi.useFakeTimers() - vi.setSystemTime(10_000) - const node = { - kind: 'model-retry', - seq: 5, - time: 10_000, - retryState: 'scheduled', - turn: 1, - step: 0, - provider: 'mock', - mode: 'normal', - policyKey: 'mock-normal', - retry: 1, - maxRetries: 2, - delayMs: 5_000, - failure: { code: 'TRANSPORT', message: '连接被重置' }, - } as const - const view = render() - expect(view.getByRole('status').textContent).toBe('等待重试模型请求(1/2) · 5s') - - act(() => { vi.advanceTimersByTime(4_200) }) - view.rerender() - expect(view.getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 1s') - }) - }) describe('formatMessageClock', () => { @@ -932,84 +954,13 @@ describe('small branch tails', () => { expect(view.getByText('one-liner')).toBeTruthy() }) - it('finalized content messages expose copy / branch / clock; Think-only and streaming omit them', () => { - const writeText = vi.fn().mockResolvedValue(undefined) - Object.defineProperty(navigator, 'clipboard', { - configurable: true, - value: { writeText }, - }) - const now = new Date() - const time = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 14, 24).getTime() - const onFork = vi.fn() - const settled = render( - , - ) - expect(settled.getByText('14:24')).toBeTruthy() - expect(settled.getByRole('button', { name: '复制' })).toBeTruthy() - expect(settled.getByRole('button', { name: '在新对话中分支' })).toBeTruthy() - fireEvent.click(settled.getByRole('button', { name: '复制' })) - expect(writeText).toHaveBeenCalledWith('answer body') - fireEvent.click(settled.getByRole('button', { name: '在新对话中分支' })) - expect(onFork).toHaveBeenCalledWith(3) - settled.unmount() - - const thinkOnly = render( - , - ) - expect(thinkOnly.queryByRole('button', { name: '复制' })).toBeNull() - expect(thinkOnly.queryByText('14:24')).toBeNull() - thinkOnly.unmount() - - const streaming = render( - , - ) - expect(streaming.queryByRole('button', { name: '复制' })).toBeNull() - expect(streaming.queryByText('14:24')).toBeNull() - }) - - it('keeps an unavailable branch focusable and explains why without sending a fork', () => { - const onFork = vi.fn() - render( - , - ) - const branch = screen.getByRole('button', { name: '在新对话中分支' }) as HTMLButtonElement - expect(branch.disabled).toBe(false) - expect(branch.getAttribute('aria-disabled')).toBe('true') - const reasonId = branch.getAttribute('aria-describedby') - expect(reasonId).not.toBeNull() - expect(document.getElementById(reasonId!)?.textContent).toBe('仅可从已完成轮次的最后一条消息分支') - fireEvent.click(branch) - expect(onFork).not.toHaveBeenCalled() - fireEvent.focus(branch) - expect(screen.getByRole('tooltip').textContent).toBe('仅可从已完成轮次的最后一条消息分支') - }) - it('StatsLine omits the cache-hit segment when no input accounting exists at all', () => { // Cache hit is null only when all three prompt buckets are zero (pure // output accounting) — any billed input makes it a real 0%. - const snap = { - nodes: [{ kind: 'assistant', seq: 1, turn: 1, step: 1, blocks: [], usage: { outputTokens: 10 } }], - } + const nodes = [{ + kind: 'assistant', seq: 1, time: 1_000, turn: 1, step: 1, blocks: [], usage: { outputTokens: 10 }, + }] as const + const snap = { chat: chatSnapshotFixture({ nodes }), nodes } const source = { getSnapshot: () => snap, subscribe: () => () => {} } const view = render( (left: readonly T[], right: readonly T[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]) +} + +function nodeSource(node: ChatConversationViewNode): unknown { + if (node.kind === 'assistant-step') { + const data = node.data as ReturnType + return data.finalNode ?? data.blocks + } + if (node.kind === 'tool-call') return (node.data as { readonly root: ToolCallBlock }).root + if (node.kind === 'model-retry') return (node.data as { readonly current: unknown }).current + if (node.kind === 'turn-tail') return (node.data as { readonly seq: number }).seq + return node.data +} + +class FixtureNodeStore implements ChatNodeStore { + private byKey = new Map() + private list: readonly ChatConversationViewNode[] = EMPTY + + get(key: string): ChatConversationViewNode | undefined { + return this.byKey.get(key) + } + + values(): readonly ChatConversationViewNode[] { + return this.list + } + + replace(candidates: readonly ChatConversationViewNode[]): void { + const next = new Map() + const list = candidates.map((candidate) => { + const previous = this.byKey.get(candidate.key) + const node = previous !== undefined + && previous.kind === candidate.kind + && previous.anchorSeq === candidate.anchorSeq + && previous.visibility === candidate.visibility + && nodeSource(previous) === nodeSource(candidate) + ? previous + : candidate + next.set(node.key, node) + return node + }) + this.byKey = next + this.list = sameValues(this.list, list) ? this.list : list + } +} + +class FixtureLocationIndex implements ChatLocationNodeIndex { + private turns = new Map() + + getTurn(turn: number): readonly string[] { + return this.turns.get(turn) ?? EMPTY + } + + getStep(): readonly string[] { + return EMPTY + } + + replace(next: ReadonlyMap): void { + const stable = new Map() + for (const [turn, keys] of next) { + const previous = this.turns.get(turn) ?? EMPTY + stable.set(turn, sameValues(previous, keys) ? previous : keys) + } + this.turns = stable + } +} + +class FixtureTurnDataStore implements ConversationLocationDataStore { + private readonly values = new Map() + + get( + key: Key, + ): Readonly | undefined { + return this.values.get(key) as Readonly | undefined + } + + set(key: Key, value: ConversationTurnDataMap[Key]): void { + this.values.set(key, value) + } +} + +function assistantData(node: AssistantMessageNode) { + return { + status: node.interrupted === true ? 'interrupted' as const : 'settled' as const, + turn: node.turn, + step: node.step, + blocks: node.blocks, + time: node.time, + finalNode: node, + } +} + +function settledNode( + node: ConversationNode, + turns: ReadonlyMap, +): ChatConversationViewNode { + const turn = 'turn' in node && typeof node.turn === 'number' ? turns.get(node.turn) : undefined + const base = { + key: `fixture:${node.kind}:${node.seq}`, + id: String(node.seq), + target: 'chat' as const, + anchorSeq: node.seq, + location: turn === undefined + ? { kind: 'session' as const } + : { kind: 'turn' as const, turn }, + visibility: 'visible' as const, + } + switch (node.kind) { + case 'assistant': + return { ...base, kind: 'assistant-step', data: assistantData(node) } + case 'tool-result': + return { ...base, key: `fixture:tool:${node.callId}`, kind: 'tool-call', data: { root: node } } + case 'model-retry': + return { ...base, key: 'fixture:model-retry', kind: 'model-retry', data: { attempts: [node], current: node } } + default: + return { ...base, kind: node.kind, data: node } + } +} + +/** Build the canonical Chat fixture corresponding to one legacy test slice. */ +export function chatSnapshotFixture(input: { + readonly nodes?: readonly ConversationNode[] + readonly partial?: PartialAssistant | null + readonly runningCalls?: readonly RunningToolCall[] + readonly turnTimings?: LegacyConversationSlice['turnTimings'] + readonly turnEnds?: LegacyConversationSlice['turnEnds'] +} = {}, previous?: ChatSnapshot): ChatSnapshot { + const legacy: LegacyConversationSlice = { + nodes: input.nodes ?? EMPTY, + partial: input.partial ?? null, + runningCalls: input.runningCalls ?? EMPTY, + turnTimings: input.turnTimings ?? new Map(), + turnEnds: input.turnEnds ?? new Map(), + } + const turnNumbers = new Set([...legacy.turnTimings.keys(), ...legacy.turnEnds.keys()]) + for (const node of legacy.nodes) { + if ('turn' in node && typeof node.turn === 'number') turnNumbers.add(node.turn) + } + if (legacy.partial !== null) turnNumbers.add(legacy.partial.turn) + for (const call of legacy.runningCalls) turnNumbers.add(call.turn) + const turns = new Map() + const turnData = new Map() + for (const turn of [...turnNumbers].sort((left, right) => left - right)) { + const timing = legacy.turnTimings.get(turn) + const endSeq = legacy.turnEnds.get(turn) + const data = new FixtureTurnDataStore() + turnData.set(turn, data) + turns.set(turn, { + turn, + start: timing === undefined ? undefined : { + type: 'turn/start', seq: Math.max(0, (endSeq ?? 1) - 1), time: timing.startTime, turn, + } as never, + end: timing?.endTime === undefined || endSeq === undefined ? undefined : { + type: 'turn/end', seq: endSeq, time: timing.endTime, turn, reason: 'completed', + } as never, + status: endSeq === undefined ? 'open' : 'closed', + steps: EMPTY, + data, + }) + } + const linkedCompactions = new Set() + const nodes = legacy.nodes.flatMap((node): ChatConversationViewNode[] => { + if (node.kind === 'command' && node.name === 'compact') { + const sourceSeq = node.outcome?.kind === 'success' ? node.outcome.sourceEventSeq : undefined + const candidates = sourceSeq === undefined + ? [] + : legacy.nodes.filter((candidate): candidate is CompactionSummaryNode => + candidate.kind === 'compaction' && candidate.summaryEventSeq === sourceSeq) + const compaction = candidates.length === 1 ? candidates[0] : undefined + if (node.outcome === null || compaction !== undefined) { + if (compaction !== undefined) linkedCompactions.add(compaction) + const base = settledNode(node, turns) + return [{ + ...base, + key: `fixture:manual-compaction:${node.commandId}`, + kind: 'manual-compaction', + anchorSeq: compaction?.seq ?? node.seq, + data: { command: node, compaction: compaction ?? null }, + }] + } + } + if (node.kind === 'compaction' && linkedCompactions.has(node)) return [] + return [settledNode(node, turns)] + }) + if (legacy.partial !== null) { + const turn = turns.get(legacy.partial.turn) + nodes.push({ + key: `fixture:assistant:${legacy.partial.turn}:${legacy.partial.step}`, + id: `${legacy.partial.turn}:${legacy.partial.step}`, + target: 'chat', + kind: 'assistant-step', + anchorSeq: Number.MAX_SAFE_INTEGER - 1, + location: turn === undefined ? { kind: 'session' } : { kind: 'turn', turn }, + visibility: 'visible', + data: { + status: 'running', + turn: legacy.partial.turn, + step: legacy.partial.step, + blocks: legacy.partial.blocks, + time: 0, + }, + }) + } + for (const call of legacy.runningCalls) { + const turn = turns.get(call.turn) + nodes.push({ + key: `fixture:tool:${call.callId}`, + id: call.callId, + target: 'chat', + kind: 'tool-call', + anchorSeq: Number.MAX_SAFE_INTEGER, + location: turn === undefined ? { kind: 'session' } : { kind: 'turn', turn }, + visibility: 'visible', + data: { root: call }, + }) + } + for (const [turnNumber, endSeq] of legacy.turnEnds) { + const turn = turns.get(turnNumber) + const dataStore = turnData.get(turnNumber) + if (turn === undefined || dataStore === undefined) continue + const closing = legacy.nodes + .filter((candidate): candidate is AssistantMessageNode => candidate.kind === 'assistant' + && candidate.turn === turnNumber + && candidate.blocks.some(block => block.kind === 'text' && block.text.trim() !== '')) + .map(assistantData) + .at(-1) ?? null + const preceding = nodes.findLast((candidate) => { + const location = candidate.location + return (location.kind === 'turn' || location.kind === 'step') + && location.turn.turn === turnNumber + }) + const metrics = deriveTurnMetrics(legacy.nodes).get(turnNumber) + const tailData = { + turn: turnNumber, + seq: endSeq, + time: turn.end?.time ?? 0, + closing, + branchUnavailable: closing === null + || preceding?.kind !== 'assistant-step' + || (preceding.data as ReturnType).finalNode.seq !== closing.finalNode.seq, + ...metrics?.ttftMs === undefined ? {} : { ttftMs: metrics.ttftMs }, + ...metrics?.tokensPerSecond === undefined ? {} : { tokensPerSecond: metrics.tokensPerSecond }, + } + dataStore.set('turn-tail', tailData) + nodes.push({ + key: `fixture:turn-tail:${turnNumber}`, + id: String(turnNumber), + target: 'chat', + kind: 'turn-tail', + anchorSeq: endSeq, + location: { kind: 'turn', turn }, + visibility: 'visible', + data: tailData, + }) + } + const store = previous?.nodes instanceof FixtureNodeStore ? previous.nodes : new FixtureNodeStore() + store.replace(nodes) + const byKey = new Map(store.values().map(node => [node.key, node])) + const nextOrder = nodes.map(node => node.key) + const order = previous !== undefined && sameValues(previous.order, nextOrder) ? previous.order : nextOrder + const byTurn = new Map() + for (const turn of turns.keys()) { + byTurn.set(turn, order.filter((key) => { + const location = byKey.get(key)?.location + return location?.kind === 'turn' && location.turn.turn === turn + || location?.kind === 'step' && location.turn.turn === turn + })) + } + const locations = previous?.locations instanceof FixtureLocationIndex + ? previous.locations + : new FixtureLocationIndex() + locations.replace(byTurn) + const timeline = previous !== undefined + && previous.legacy.turnTimings === legacy.turnTimings + && previous.legacy.turnEnds === legacy.turnEnds + ? previous.timeline + : { turnOrder: [...turns.keys()], turns } + return { + order, + nodes: store, + locations, + timeline, + legacy, + } +} diff --git a/packages/client/ui-conversation/tests/chat-stats.spec.tsx b/packages/client/ui-conversation/tests/chat-stats.spec.tsx index 78fbf43c5e..959a91c3ac 100644 --- a/packages/client/ui-conversation/tests/chat-stats.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-stats.spec.tsx @@ -13,6 +13,7 @@ import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { StatsLine, contextOccupancy, deriveStats, formatDuration, formatTokens, type StatsLineProps } from '../src/client/chat/StatsLine.tsx' import { en, zh } from '../src/client/locales.ts' +import { chatSnapshotFixture } from './chat-snapshot-fixture.ts' // Mirrors the real lookup chain (conversation namespace, then common). const t: StatsLineProps['t'] = makeTranslate(zh, commonZh) @@ -42,18 +43,39 @@ const assistant = (seq: number, turn: number, usage?: unknown): AssistantMessage function snapshotBase(): ConversationSnapshot { return { - sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], + sessionId: SID, chat: chatSnapshotFixture(), + nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, } } function makeSource(init?: Partial) { - let snap: ConversationSnapshot = { ...snapshotBase(), ...init } + const initial = { ...snapshotBase(), ...init } + let snap: ConversationSnapshot = { + ...initial, + chat: init?.chat ?? chatSnapshotFixture({ + nodes: initial.nodes, + partial: initial.partial, + runningCalls: initial.runningCalls, + turnTimings: initial.turnTimings, + turnEnds: initial.turnEnds, + }), + } const subs = new Set<() => void>() return { set: (next: Partial) => { - snap = { ...snap, ...next } + const merged = { ...snap, ...next } + snap = { + ...merged, + chat: next.chat ?? (next.nodes === undefined ? snap.chat : chatSnapshotFixture({ + nodes: merged.nodes, + partial: merged.partial, + runningCalls: merged.runningCalls, + turnTimings: merged.turnTimings, + turnEnds: merged.turnEnds, + })), + } for (const fn of [...subs]) fn() }, source: { diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 9bb043df15..296dd85966 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -4,24 +4,33 @@ // ObservableSnapshot fake, no wire or Tool presentation plugin. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { Profiler } from 'react' import { act, cleanup, fireEvent, render, within } from '@testing-library/react' +import { useEffect } from 'react' import type { AssistantMessageNode, CommandNode, CompactionSummaryNode, ConversationNode, ConversationSnapshot, - ModelRetryNode, RunningToolCall, SessionId, SessionListState, ToolResultNode, TurnErrorNode, + ModelRetryNode, RunningToolCall, SessionId, SessionListState, ToolCallBlock, ToolResultNode, TurnErrorNode, UserMessageNode, WorkspaceListState, } from '@deepseek-ai/dsh-client-runtime/client' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import { createSnapshotStore, PendingWait } from '@deepseek-ai/dsh-client-runtime/client' import { RpcId } from '@deepseek-ai/dsh-client-connection/client' -import type { ChatViewSlotProps, SelectionTarget, ToolTreeOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { + ChatNode, ChatNodeOwnerProps, ChatNodeViewProps, ChatViewSlotProps, SelectionTarget, UseChatNodeTurnData, +} from '@deepseek-ai/dsh-client-ui-conversation/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { createChatStore } from '../src/client/stores.ts' import { ChatView } from '../src/client/chat/ChatView.tsx' import { zh } from '../src/client/locales.ts' -import { assistantActionsSeqs, assistantBranchSeqs, deriveChatFlow, flowKeys, runningTurnStartTime } from '../src/client/chat/chat-flow.ts' +import { AssistantNodeView } from '../src/client/chat/AssistantNodeView.tsx' +import { CommandNodeView, ManualCompactionNodeView } from '../src/client/chat/CommandNodeView.tsx' +import { + CompactionNodeView, ContextMessageNodeView, RetryNodeView, TurnErrorNodeView, + UnknownNodeView, UserMessageNodeView, +} from '../src/client/chat/MessageItem.tsx' +import { TurnTailNodeView } from '../src/client/chat/TurnTailNodeView.tsx' import { formatRunDuration } from '../src/client/chat/message-chrome.ts' +import { chatSnapshotFixture } from './chat-snapshot-fixture.ts' afterEach(() => { cleanup() @@ -34,10 +43,11 @@ beforeEach(() => { }) const SID = 's1' as SessionId +type RoutedChatNodeOwner = ChatNodeOwnerProps & { readonly node: ChatNode } function snapshotBase(): ConversationSnapshot { return { - sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], + sessionId: SID, chat: chatSnapshotFixture(), nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, } @@ -45,11 +55,21 @@ function snapshotBase(): ConversationSnapshot { /** Scripted snapshot source: set() swaps the top-level object like the real Session. */ function makeSource(init?: Partial) { - let snap: ConversationSnapshot = { ...snapshotBase(), ...init } + const initial = { ...snapshotBase(), ...init } + let snap: ConversationSnapshot = { + ...initial, + chat: init?.chat ?? chatSnapshotFixture(initial), + } const subs = new Set<() => void>() return { set: (next: Partial) => { - snap = { ...snap, ...next } + const merged = { ...snap, ...next } + snap = { + ...merged, + chat: Object.hasOwn(next, 'chat') && next.chat !== undefined + ? next.chat + : chatSnapshotFixture(merged, snap.chat), + } for (const fn of [...subs]) fn() }, source: { @@ -73,7 +93,8 @@ const assistant = (seq: number, text: string, turn = 1): AssistantMessageNode => kind: 'assistant', seq, time: seq * 1_000, turn, step: 1, blocks: [{ kind: 'text', text }], }) const retry = (seq: number): ModelRetryNode => ({ - kind: 'model-retry', seq, time: seq * 1_000, turn: 1, step: 0, + kind: 'model-retry', retryId: 'chat-view-retry' as ModelRetryNode['retryId'], + seq, time: seq * 1_000, turn: 1, step: 0, retryState: 'scheduled', provider: 'mock', mode: 'normal', policyKey: 'mock-normal', retry: 1, maxRetries: 2, delayMs: 450, @@ -139,25 +160,97 @@ function makeHarness(init?: Partial) { // production; the view reads it through the PropsStore useStore share). const chat = createChatStore().create() const t = makeTranslate(zh, commonZh) - const toolOwners: ToolTreeOwnerProps[] = [] - const renderSlot = ((key: string, owner: object, opts?: { fallback?: React.ReactNode }) => { - if (key !== 'conversation.chat.tool') return opts?.fallback ?? null - const tool = owner as ToolTreeOwnerProps - toolOwners.push(tool) - // Tool providers own their subtree. The host double carries only the - // semantic anchor required by ChatView's prepend-position contract. - return ( -
- {tool.toolName || '(unnamed)'}:{tool.callId} -
+ const toolOwners: Array<{ + callId: string + toolName: string + block: ToolCallBlock + selectedCallId: string | undefined + openFile: ChatNodeOwnerProps['openFile'] + inspectCall: ChatNodeOwnerProps['inspectCall'] + }> = [] + const renderCommandSlot = ((_key: string, _owner: object, opts?: { fallback?: React.ReactNode }) => + opts?.fallback ?? null) as unknown as React.ComponentProps['renderSlot'] + const renderTurnTail = ((_key: string, _owner: object) => null) as unknown as + React.ComponentProps['renderSlotChain'] + const renderTurnTailSlot = (() => null) as unknown as + React.ComponentProps['renderSlot'] + const renderSlot = ((key: string, owner: object, opts?: { + fallback?: React.ReactNode + hookContext?: unknown + }) => { + if (key !== 'conversation.chat.node') return opts?.fallback ?? null + const nodeOwner = owner as RoutedChatNodeOwner + const nodeKey = opts?.hookContext as string | undefined + const useTurnData = ((dataKey: string) => props.useSession((snapshot) => { + const location = nodeKey === undefined ? undefined : snapshot.chat.nodes.get(nodeKey)?.location + return location?.kind === 'turn' || location?.kind === 'step' + ? location.turn.data.get(dataKey as never) + : undefined + })) as UseChatNodeTurnData + const nodeProps = (): ChatNodeViewProps => ( + { ...props, ...nodeOwner, useTurnData } as unknown as ChatNodeViewProps ) + switch (nodeOwner.node.kind) { + case 'user': + case 'steering': + return ()} /> + case 'context': + return ()} /> + case 'assistant-step': + return ()} /> + case 'command': + return ( + ()} + renderSlot={renderCommandSlot} + SessionProvider={props.SessionProvider} + /> + ) + case 'manual-compaction': + return ()} /> + case 'compaction': + return ()} /> + case 'model-retry': + return ()} /> + case 'turn-error': + return ()} /> + case 'turn-tail': + return ( + ()} + renderSlot={renderTurnTailSlot} + renderSlotChain={renderTurnTail} + SessionProvider={props.SessionProvider} + /> + ) + case 'unknown': + return ()} /> + case 'tool-call': { + const block = (nodeOwner.node.data as { readonly root: ToolCallBlock }).root + const toolName = 'kind' in block ? block.call?.name ?? '' : block.name + const tool = { + callId: block.callId, + toolName, + block, + selectedCallId: nodeOwner.selectedCallId, + openFile: nodeOwner.openFile, + inspectCall: nodeOwner.inspectCall, + } + toolOwners.push(tool) + return ( +
+ {tool.toolName || '(unnamed)'}:{tool.callId} +
+ ) + } + default: + return opts?.fallback ?? null + } }) as unknown as ChatViewSlotProps['renderSlot'] - const renderSlotChain = ((_key: string, _owner: object, opts?: { fallback?: React.ReactNode }) => - opts?.fallback ?? null) as unknown as ChatViewSlotProps['renderSlotChain'] // SessionProvider seat arrives with the session-scope child declaration; // ChatView never invokes it (render-prop pass-through stub). const SessionProviderStub: ChatViewSlotProps['SessionProvider'] = ({ children }) => <>{children(SID)} @@ -172,7 +265,6 @@ function makeHarness(init?: Partial) { useStore: bindSnapshotSelector(chat), actions: chat.actions, renderSlot, - renderSlotChain, SessionProvider: SessionProviderStub, openDetails, openFile, @@ -218,139 +310,7 @@ function installScrollMetrics(element: HTMLElement, initialHeight: number, clien } } -describe('chat-flow derivation', () => { - it('groups consecutive tool results and keeps stable keys', () => { - const nodes: ConversationNode[] = [ - user(1, 'hi'), assistant(2, 'let me look'), toolResult(3, 'a'), toolResult(4, 'b'), - assistant(5, 'found'), toolResult(6, 'c'), - ] - const items = deriveChatFlow(nodes) - expect(items.map(i => i.kind)).toEqual(['node', 'node', 'tool-group', 'node', 'tool-group']) - const group = items[2]! - expect(group.kind === 'tool-group' && group.results.map(r => r.callId)).toEqual(['a', 'b']) - expect(flowKeys(items)).toBe('n1|n2|g3|n5|g6') - expect(flowKeys(deriveChatFlow([...nodes, toolResult(7, 'd')]))).toBe('n1|n2|g3|n5|g6') - }) - - it('reuses one stable row for consecutive retry turns', () => { - const first = retry(2) - const second = { ...retry(3), turn: 2, retry: 2 } - const initial = deriveChatFlow([user(1, 'try'), first]) - const updated = deriveChatFlow([user(1, 'try'), first, second]) - expect(flowKeys(initial)).toBe('n1|n2') - expect(flowKeys(updated)).toBe('n1|n2') - expect(updated).toHaveLength(2) - expect(updated[1]?.kind === 'node' && updated[1].node).toBe(second) - }) - - it('folds a successful /compact lifecycle into its explicitly linked checkpoint', () => { - const running = command({ - seq: 1, - commandId: 'cmd-compact' as CommandNode['commandId'], - name: 'compact', - outcome: null, - }) - expect(flowKeys(deriveChatFlow([user(0, 'before'), running]))).toBe('n0|ccmd-compact') - - const settled = { - ...running, - outcome: { kind: 'success' as const, text: 'Compacted 16 history items.', sourceEventSeq: 3 }, - } - const checkpoint = compaction({ seq: 4, summaryEventSeq: 3 }) - const items = deriveChatFlow([user(0, 'before'), settled, user(2, 'injected while compacting'), checkpoint]) - expect(flowKeys(items)).toBe('n0|n2|ccmd-compact') - expect(items.at(-1)).toEqual({ - kind: 'command-compaction', - key: 'ccmd-compact', - command: settled, - compaction: checkpoint, - }) - }) - - it('does not split adjacent tool results around a folded /compact command', () => { - const folded = command({ - seq: 2, - commandId: 'cmd-compact' as CommandNode['commandId'], - name: 'compact', - outcome: { kind: 'success', sourceEventSeq: 4 }, - }) - const items = deriveChatFlow([ - toolResult(1, 'a'), - folded, - toolResult(3, 'b'), - compaction({ seq: 5, summaryEventSeq: 4 }), - ]) - expect(flowKeys(items)).toBe('g1|ccmd-compact') - expect( - items[0]?.kind === 'tool-group' && items[0].results.map(result => result.callId), - ).toEqual(['a', 'b']) - }) - - it('keeps automatic, unlinked, and ambiguously linked compactions as separate rows', () => { - const automatic = compaction({ seq: 2, summaryEventSeq: 1 }) - expect(flowKeys(deriveChatFlow([automatic]))).toBe('n2') - - const first = command({ - seq: 3, - commandId: 'cmd-a' as CommandNode['commandId'], - name: 'compact', - outcome: { kind: 'success', sourceEventSeq: 9 }, - }) - const second = command({ - seq: 4, - commandId: 'cmd-b' as CommandNode['commandId'], - name: 'compact', - outcome: { kind: 'success', sourceEventSeq: 9 }, - }) - const ambiguous = compaction({ seq: 10, summaryEventSeq: 9 }) - expect(flowKeys(deriveChatFlow([first, second, ambiguous]))).toBe('ccmd-a|ccmd-b|n10') - - const sole = command({ - seq: 11, - commandId: 'cmd-sole' as CommandNode['commandId'], - name: 'compact', - outcome: { kind: 'success', sourceEventSeq: 12 }, - }) - const duplicateA = compaction({ seq: 13, summaryEventSeq: 12 }) - const duplicateB = compaction({ seq: 14, summaryEventSeq: 12 }) - expect(flowKeys(deriveChatFlow([sole, duplicateA, duplicateB]))).toBe('ccmd-sole|n13|n14') - }) - - it('skips render-nothing assistant nodes so tool runs stay one group', () => { - // A tool-call-only step message (and blank text/reasoning) renders nothing: - // it must not split the run into two groups with an empty line between. - const headsOnly: AssistantMessageNode = { - kind: 'assistant', seq: 4, time: 4_000, turn: 1, step: 2, - blocks: [{ kind: 'tool-call', callId: 'b', name: 'read', argsRaw: '{}' }, { kind: 'text', text: ' \n' }, { kind: 'reasoning', text: '' }], - } - const items = deriveChatFlow([toolResult(3, 'a'), headsOnly, toolResult(5, 'b')]) - expect(flowKeys(items)).toBe('g3') - const group = items[0]! - expect(group.kind === 'tool-group' && group.results.map(r => r.callId)).toEqual(['a', 'b']) - // Interrupted and visible-content nodes still render (已停止 marker / prose). - expect(flowKeys(deriveChatFlow([toolResult(3, 'a'), { ...headsOnly, interrupted: true }, toolResult(5, 'b')]))).toBe('g3|n4|g5') - expect(flowKeys(deriveChatFlow([toolResult(3, 'a'), assistant(4, 'found'), toolResult(5, 'b')]))).toBe('g3|n4|g5') - }) - - it('assistantActionsSeqs keeps only the last content assistant per completed turn', () => { - const thinkOnly: AssistantMessageNode = { - kind: 'assistant', seq: 3, time: 3_000, turn: 1, step: 2, - blocks: [{ kind: 'reasoning', text: 'planning' }], - } - const nodes: ConversationNode[] = [ - user(1, 'hi'), - assistant(2, 'looking', 1), - thinkOnly, - toolResult(4, 'a'), - assistant(5, 'done', 1), - user(6, 'again'), - assistant(7, 'second turn', 2), - ] - expect([...assistantActionsSeqs(nodes, new Map([[1, 5], [2, 7]]))].sort((a, b) => a - b)).toEqual([5, 7]) - // Turn 2 is still producing steps: its latest narration owns nothing, and - // the settled turn 1 keeps its seat. - expect([...assistantActionsSeqs(nodes, new Map([[1, 5]]))]).toEqual([5]) - }) +describe('Chat node rendering', () => { it('threads the injected file-mention vocabulary into the closing prose only', () => { const wrote = (seq: number, callId: string, path: string): ToolResultNode => ({ @@ -391,17 +351,6 @@ describe('chat-flow derivation', () => { expect(h.openFile).toHaveBeenCalledWith('for-seq-4/site/report.html') }) - it('runningTurnStartTime selects the latest turn/start without a turn/end', () => { - expect(runningTurnStartTime(new Map([ - [1, { startTime: 1_000, endTime: 5_000 }], - [2, { startTime: 6_000 }], - ]))).toBe(6_000) - expect(runningTurnStartTime(new Map([ - [1, { startTime: 1_000, endTime: 5_000 }], - [2, { startTime: 6_000, endTime: 9_000 }], - ]))).toBeNull() - }) - it('formatRunDuration localizes units and floors partial seconds', () => { const t = makeTranslate(zh, commonZh) expect(formatRunDuration(0, t)).toBe('0秒') @@ -410,24 +359,6 @@ describe('chat-flow derivation', () => { expect(formatRunDuration(125_000, t)).toBe('2分05秒') }) - it('assistantBranchSeqs keeps only content-assistant tails; user/steering tails own no branch', () => { - const interruptedThink: AssistantMessageNode = { - kind: 'assistant', seq: 4.1, time: 4_100, turn: 1, step: 2, - blocks: [{ kind: 'reasoning', text: 'bad path' }], interrupted: true, - } - const nodes: ConversationNode[] = [ - user(1, 'first'), - assistant(2, 'answer before tools'), - toolResult(3, 'a'), - interruptedThink, - user(6, 'second'), - assistant(7, 'clean tail', 2), - user(10, 'user-only tail'), - user(13, 'steering tail'), - ] - const seqs = assistantBranchSeqs(nodes, new Map([[1, 5], [2, 8], [3, 11], [4, 14]])) - expect([...seqs]).toEqual([7]) - }) }) describe('ChatView', () => { @@ -444,8 +375,8 @@ describe('ChatView', () => { const h = makeHarness({ nodes: [user(9, 'first visible'), user(10, 'next visible')], hasMore: true }) const view = render() const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement - const first = view.container.querySelector('[data-chat-flow-key="n9"]') as HTMLDivElement - const next = view.container.querySelector('[data-chat-flow-key="n10"]') as HTMLDivElement + const first = view.container.querySelector('[data-chat-flow-key="fixture:user:9"]') as HTMLDivElement + const next = view.container.querySelector('[data-chat-flow-key="fixture:user:10"]') as HTMLDivElement let firstTop = 100 let nextTop = 300 vi.spyOn(scroller, 'getBoundingClientRect').mockImplementation( @@ -472,7 +403,7 @@ describe('ChatView', () => { expect(scroller.scrollTop).toBe(590) // latest 90 + the anchored row's 500px prepend shift }) - it('renders the fixture main line: bubble, narration, grouped tool rows', () => { + it('renders the fixture main line as independently keyed business nodes', () => { const h = makeHarness({ nodes: [user(1, 'do the thing'), assistant(2, 'running tools'), toolResult(3, 'a'), toolResult(4, 'b')], }) @@ -485,14 +416,18 @@ describe('ChatView', () => { key: row.getAttribute('data-chat-flow-key'), kind: row.getAttribute('data-chat-flow-kind'), }))).toEqual([ - { key: 'n1', kind: 'user' }, - { key: 'n2', kind: 'assistant' }, - { key: 'g3', kind: 'tool-group' }, + { key: 'fixture:user:1', kind: 'user' }, + { key: 'fixture:assistant:2', kind: 'assistant-step' }, + { key: 'fixture:tool:a', kind: 'tool-call' }, + { key: 'fixture:tool:b', kind: 'tool-call' }, ]) expect([...view.container.querySelectorAll('[data-chat-call-id]')].map(row => row.getAttribute('data-chat-call-id'))) .toEqual(['a', 'b']) expect([...view.container.querySelectorAll('[data-chat-anchor-key]')].map(row => row.getAttribute('data-chat-anchor-key'))) - .toEqual(['node:1', 'node:2', 'call:a', 'call:b']) + .toEqual([ + 'fixture:user:1', 'fixture:assistant:2', + 'fixture:tool:a', 'call:a', 'fixture:tool:b', 'call:b', + ]) }) it('renders Host-pending steering at the flow tail and hands off to the durable node', () => { @@ -559,14 +494,13 @@ describe('ChatView', () => { act(() => { h.set({ running: false, turnEnds: new Map([[1, 3]]) }) }) - // The completed turn's transcript tail is the steering bubble, not the - // narration, so the assistant's branch action stays unavailable and the - // steering bubble still offers none. + // The Turn Tail belongs to the closed Turn, independently of a later + // steering bubble's placement in the Chat list. const branchButtons = view.getAllByRole('button', { name: '在新对话中分支' }) expect(branchButtons).toHaveLength(1) - expect(branchButtons[0]!.getAttribute('aria-disabled')).toBe('true') + expect(branchButtons[0]!.getAttribute('aria-disabled')).toBeNull() fireEvent.click(branchButtons[0]!) - expect(h.forkAt).not.toHaveBeenCalled() + expect(h.forkAt).toHaveBeenCalledWith(1) }) it('keeps a later pending occurrence visible when it reuses a durable MessageId', () => { @@ -607,7 +541,7 @@ describe('ChatView', () => { expect(within(disclosure).getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 1s') act(() => { - h.set({ nodes: [user(1, 'try'), retryNode, nextRetry] }) + h.set({ nodes: [user(1, 'try'), nextRetry] }) }) expect(within(disclosure).getAllByRole('status')).toHaveLength(1) expect(view.container.querySelector('details')).toBe(disclosure) @@ -617,7 +551,6 @@ describe('ChatView', () => { h.set({ nodes: [ user(1, 'try'), - retryNode, { ...nextRetry, retryState: 'started' }, context, assistant(5, 'done'), @@ -760,7 +693,7 @@ describe('ChatView', () => { turnEnds: new Map([[1, 2]]), }) const view = render() - // One scope per message row; the CSS reveal keys off this attribute. + // The user row and the settled assistant's Turn Tail each own one clock scope. expect(view.container.querySelectorAll('[data-time-hover-root]')).toHaveLength(2) }) @@ -787,7 +720,7 @@ describe('ChatView', () => { expect(h.forkAt.mock.calls).toEqual([[2]]) }) - it('keeps branch visible but unavailable when tool and interrupted Think follow the response', () => { + it('keeps final content actions but disables branch when Tool and interrupted Think follow it', () => { const interruptedThink: AssistantMessageNode = { kind: 'assistant', seq: 4.1, time: 4_100, turn: 1, step: 2, blocks: [{ kind: 'reasoning', text: 'bad path' }], interrupted: true, @@ -843,19 +776,13 @@ describe('ChatView', () => { expect(view.container.querySelectorAll('h1')).toHaveLength(2) }) - it('streaming partial frames re-render only the tail (Profiler count)', () => { + it('streaming partial frames update the tail without replacing a sibling Tool row', () => { const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'old answer'), toolResult(3, 'a')], }) - let renders = 0 - const counting = ( - { renders += 1 }}> - - - ) - const view = render(counting) - const before = renders - const beforeHtml = view.container.querySelector('[class*="toolGroup"]')!.innerHTML + const view = render() + const tool = view.getByTestId('tool-seat-a') + const beforeHtml = tool.innerHTML act(() => { h.set({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: 'streaming…' }] } }) }) @@ -863,9 +790,8 @@ describe('ChatView', () => { h.set({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: 'streaming… more' }] } }) }) expect(view.getByText('streaming… more')).toBeTruthy() - // Each chunk commits exactly one profiler pass (the tail), never a full-tree storm. - expect(renders - before).toBe(2) - expect(view.container.querySelector('[class*="toolGroup"]')!.innerHTML).toBe(beforeHtml) + expect(view.getByTestId('tool-seat-a')).toBe(tool) + expect(tool.innerHTML).toBe(beforeHtml) }) it('streaming leaves neighbor tool rows and history items at zero re-renders', () => { @@ -875,8 +801,9 @@ describe('ChatView', () => { // Count renderSlot invocations: the memo boundary holds when CallRow does // not re-render, so the row's renderSlot call count freezes during chunks. let rowRenders = 0 - h.props.renderSlot = ((key: string, _owner: object) => { - if (key !== 'conversation.chat.tool') return null + h.props.renderSlot = ((key: string, owner: object) => { + if (key !== 'conversation.chat.node' + || (owner as RoutedChatNodeOwner).node.kind !== 'tool-call') return null rowRenders += 1 return
}) @@ -908,6 +835,54 @@ describe('ChatView', () => { expect(view.getByRole('status').textContent).toBe('Deep diving...') }) + it('keeps the Tool renderer mounted when a running call settles into log order', () => { + const mounted = vi.fn() + const unmounted = vi.fn() + function StatefulToolNode({ node }: { readonly node: ChatNode<'tool-call'> }) { + useEffect(() => { + mounted() + return () => { unmounted() } + }, []) + const root = (node.data as { readonly root: ToolCallBlock }).root + return ( +
+ {root.callId} +
+ ) + } + + const h = makeHarness({ + nodes: [user(1, 'q'), assistant(4, 'later')], + runningCalls: [runningCall('r1')], + running: true, + }) + h.props.renderSlot = ((key: string, owner: object, opts?: { fallback?: React.ReactNode }) => { + const routed = owner as RoutedChatNodeOwner + return key === 'conversation.chat.node' && routed.node.kind === 'tool-call' + ? + : opts?.fallback ?? null + }) as ChatViewSlotProps['renderSlot'] + const view = render() + const tool = view.getByTestId('stateful-tool') + const row = view.container.querySelector('[data-chat-flow-key="fixture:tool:r1"]') + expect(tool.dataset.state).toBe('running') + expect(mounted).toHaveBeenCalledTimes(1) + + act(() => { + h.set({ + nodes: [user(1, 'q'), toolResult(3, 'r1'), assistant(4, 'later')], + runningCalls: [], + running: false, + }) + }) + + expect(view.getByTestId('stateful-tool')).toBe(tool) + expect(view.container.querySelector('[data-chat-flow-key="fixture:tool:r1"]')).toBe(row) + expect(tool.dataset.state).toBe('settled') + expect(mounted).toHaveBeenCalledTimes(1) + expect(unmounted).not.toHaveBeenCalled() + }) + it('the running clock uses turn/start, ignores steering, and stays out of the live region', () => { const startTime = Date.now() - 125_000 const trigger: UserMessageNode = { ...user(1, 'go'), time: startTime + 1 } @@ -932,7 +907,7 @@ describe('ChatView', () => { expect(status.textContent).toMatch(/^Deep diving\.\.\.2分0\d秒$/) }) - it('hands each ordered root call to the whole-Tool slot', () => { + it('hands each ordered root call to the keyed business-node slot', () => { const block = toolResult(3, 'a') const h = makeHarness({ nodes: [block] }) const calls: { key: string; owner: object; entryKey?: string }[] = [] @@ -943,14 +918,14 @@ describe('ChatView', () => { render() expect(calls).toHaveLength(1) expect(calls[0]).toMatchObject({ - key: 'conversation.chat.tool', - owner: { callId: 'a', toolName: 'bash', selectedCallId: undefined }, + key: 'conversation.chat.node', + owner: { node: { kind: 'tool-call' }, selectedCallId: undefined }, + entryKey: 'tool-call', }) - const owner = calls[0]?.owner as ToolTreeOwnerProps - expect(owner.block).toBe(block) + const owner = calls[0]?.owner as RoutedChatNodeOwner + expect((owner.node.data as { readonly root: ToolCallBlock }).root).toBe(block) expect(owner.openFile).toBe(h.openFile) expect(owner.inspectCall).toBe(h.inspectCall) - expect(calls[0]?.entryKey).toBeUndefined() }) it('prepend preserves a semantic row; a trailing user node force-scrolls', () => { @@ -960,7 +935,7 @@ describe('ChatView', () => { // jsdom has no layout: fake the metrics the anchor math reads. Object.defineProperty(scroller, 'scrollHeight', { value: 1000, writable: true }) Object.defineProperty(scroller, 'clientHeight', { value: 400, writable: true }) - const anchored = view.container.querySelector('[data-chat-flow-key="n5"]') as HTMLDivElement + const anchored = view.container.querySelector('[data-chat-flow-key="fixture:user:5"]') as HTMLDivElement let anchoredTop = 100 vi.spyOn(anchored, 'getBoundingClientRect').mockImplementation( () => ({ top: anchoredTop, bottom: anchoredTop + 40 } as DOMRect), @@ -977,60 +952,6 @@ describe('ChatView', () => { expect(scroller.scrollTop).toBe(1600) }) - it('uses stable call identity when a prepend changes the tool-group key amid unrelated growth', () => { - const h = makeHarness({ nodes: [toolResult(5, 'late')], hasMore: true }) - const view = render() - const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement - let prepended = false - const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { - if (this.dataset.chatAnchorKey === 'call:late') { - const top = prepended ? 400 : 100 - return { top, bottom: top + 40 } as DOMRect - } - return { top: 0, bottom: 200 } as DOMRect - }) - try { - Object.defineProperty(scroller, 'scrollHeight', { value: 700, writable: true }) - Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true }) - readerScroll(scroller, 80) - fireEvent.click(view.getByText('加载更早')) - // Total height grows by 500, but only 300 belongs before the call row. - Object.defineProperty(scroller, 'scrollHeight', { value: 1_200, writable: true }) - prepended = true - act(() => { h.set({ nodes: [toolResult(4, 'early'), toolResult(5, 'late')] }) }) - expect(scroller.scrollTop).toBe(380) - } finally { - rect.mockRestore() - } - }) - - it('uses the latest retry identity when prepending an earlier retry changes the flow key', () => { - const h = makeHarness({ nodes: [retry(5)], hasMore: true }) - const view = render() - const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement - let prepended = false - const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { - if (this.dataset.chatAnchorKey === 'node:5') { - const top = prepended ? 400 : 100 - return { top, bottom: top + 40 } as DOMRect - } - return { top: 0, bottom: 200 } as DOMRect - }) - try { - Object.defineProperty(scroller, 'scrollHeight', { value: 700, writable: true }) - Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true }) - readerScroll(scroller, 80) - fireEvent.click(view.getByText('加载更早')) - Object.defineProperty(scroller, 'scrollHeight', { value: 1_200, writable: true }) - prepended = true - act(() => { h.set({ nodes: [retry(4), retry(5)] }) }) - expect(scroller.scrollTop).toBe(380) - expect(view.container.querySelector('[data-chat-flow-key="n4"][data-chat-anchor-key="node:5"]')).not.toBeNull() - } finally { - rect.mockRestore() - } - }) - it('back-to-bottom cancels an in-flight paging anchor', () => { const h = makeHarness({ nodes: [user(9, 'late')], hasMore: true }) const view = render() @@ -1177,7 +1098,7 @@ describe('ChatView', () => { () => ({ top: 0, bottom: 500 } as DOMRect), ) const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { - if (this.dataset.chatAnchorKey === 'node:1') { + if (this.dataset.chatAnchorKey === 'fixture:user:1') { return { top: anchorTop, bottom: anchorTop + 40 } as DOMRect } return { top: 0, bottom: 40 } as DOMRect @@ -1216,12 +1137,12 @@ describe('ChatView', () => { }) document.body.appendChild(host) const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { - if (this.dataset.chatAnchorKey === 'node:1') return { top: 300, bottom: 340 } as DOMRect + if (this.dataset.chatAnchorKey === 'fixture:user:1') return { top: 300, bottom: 340 } as DOMRect return { top: 0, bottom: 500 } as DOMRect }) try { const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] }) - h.chatScroll.save({ anchorKey: 'node:1', anchorTop: 80, scrollTop: 1_400 }) + h.chatScroll.save({ anchorKey: 'fixture:user:1', anchorTop: 80, scrollTop: 1_400 }) const view = render(, { container: host }) expect(host.scrollTop).toBe(1_500) expect(h.chatScroll.read()).toBeNull() diff --git a/packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts b/packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts new file mode 100644 index 0000000000..3ae906087e --- /dev/null +++ b/packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts @@ -0,0 +1,720 @@ +import { describe, expect, it } from 'vitest' +import type { + ChatConversationViewNode, ChatSnapshot, ConversationEventInput, + ConversationNodeDefinition, ConversationViewDefinition, +} from '@deepseek-ai/dsh-client-runtime/client' +import { ConversationNodeAssembler } from '@deepseek-ai/dsh-client-runtime/client' +import { assistantDefinition } from '../src/client/conversation-nodes/assistant.ts' +import { chatViewDefinition } from '../src/client/conversation-nodes/chat-snapshot-builder.ts' +import { commandDefinition } from '../src/client/conversation-nodes/command.ts' +import { compactionDefinition } from '../src/client/conversation-nodes/compaction.ts' +import { unknownFallbackDefinition } from '../src/client/conversation-nodes/fallback.ts' +import { nextStepInboxDefinition, nextTurnInboxDefinition } from '../src/client/conversation-nodes/inbox.ts' +import { messageDefinition } from '../src/client/conversation-nodes/message.ts' +import { retryDefinition } from '../src/client/conversation-nodes/retry.ts' +import { toolDefinition } from '../src/client/conversation-nodes/tool.ts' +import { turnErrorDefinition } from '../src/client/conversation-nodes/turn-error.ts' +import { turnTailDefinition } from '../src/client/conversation-nodes/turn-tail.ts' +import type { + AssistantChatData, ManualCompactionChatData, RetryChatData, ToolChatData, +} from '../src/client/contract/chat-nodes.ts' + +const DEFINITIONS: readonly ConversationNodeDefinition[] = [ + nextTurnInboxDefinition, + nextStepInboxDefinition, + messageDefinition, + assistantDefinition, + toolDefinition, + commandDefinition, + compactionDefinition, + retryDefinition, + turnErrorDefinition, + turnTailDefinition, +] + +class TestEventDefinitions { + entries(): readonly ConversationNodeDefinition[] { + return DEFINITIONS + } + + fallbackEntry(): ConversationNodeDefinition { + return unknownFallbackDefinition + } +} + +class TestViewDefinitions { + entries(): readonly ConversationViewDefinition[] { + return [chatViewDefinition] + } +} + +function at( + seq: number, + type: string, + data: unknown, + extra: Record = {}, +): ConversationEventInput { + return { + event: { + seq, + time: 1_700_000_000_000 + seq, + type, + data, + ...extra, + } as unknown as ConversationEventInput['event'], + view: undefined, + } +} + +function assembler(entries: readonly ConversationEventInput[] = [], hasMore = false): ConversationNodeAssembler { + const value = new ConversationNodeAssembler(new TestEventDefinitions(), new TestViewDefinitions()) + value.replaceWindow(entries, hasMore) + value.flush() + return value +} + +function snapshot(value: ConversationNodeAssembler): ChatSnapshot { + const current = value.snapshot('chat') as ChatSnapshot | undefined + if (current === undefined) throw new Error('chat view was not registered') + return current +} + +function node(value: ChatSnapshot, kind: string): ChatConversationViewNode | undefined { + return value.nodes.values().find(candidate => candidate.kind === kind) +} + +function textMessage(id: string, text: string) { + return { + id, + role: 'user', + content: [{ type: 'text', text }], + source: { kind: 'user' }, + } +} + +function assistantMessage(id: string, text: string) { + return { + id, + role: 'assistant', + content: [{ type: 'text', text }], + source: { kind: 'model', provider: 'fake', model: 'fake' }, + } +} + +function toolResult(callId: string, text: string) { + return { + id: `result-${callId}`, + role: 'user', + source: { kind: 'tool', callId }, + content: [{ + type: 'tool-result', + toolCallId: callId, + content: [{ type: 'text', text }], + isError: false, + }], + } +} + +describe('built-in conversation node Definitions', () => { + it('keeps one keyed Assistant node while streaming settles and materializes interruption from Location', () => { + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'step/start', { turn: 1, step: 1 }), + at(3, 'assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'text-delta', index: 0, text: 'streaming' }, + }), + ]) + const runningSnapshot = snapshot(value) + const running = node(runningSnapshot, 'assistant-step') + expect(running?.data).toMatchObject({ status: 'running', blocks: [{ kind: 'text', text: 'streaming' }] }) + const order = runningSnapshot.order + + value.append(at(4, 'assistant/message', { + turn: 1, + step: 1, + message: assistantMessage('assistant-1', 'settled'), + }, { surfaceOp: 'append' })) + value.flush() + + const settledSnapshot = snapshot(value) + const settled = node(settledSnapshot, 'assistant-step') + expect(settled?.key).toBe(running?.key) + expect(settledSnapshot.order).toBe(order) + expect(settled?.data).toMatchObject({ status: 'settled', blocks: [{ kind: 'text', text: 'settled' }] }) + + const interruptedValue = assembler([ + at(10, 'turn/start', { turn: 2 }), + at(11, 'step/start', { turn: 2, step: 1 }), + at(12, 'assistant/chunk', { + turn: 2, + step: 1, + chunk: { type: 'text-delta', index: 0, text: 'partial' }, + }), + at(13, 'step/end', { turn: 2, step: 1 }), + ]) + const interrupted = node(snapshot(interruptedValue), 'assistant-step') + expect(interrupted?.data).toMatchObject({ status: 'interrupted' }) + expect((interrupted?.data as AssistantChatData).finalNode?.interrupted).toBe(true) + + const hiddenValue = assembler([ + at(20, 'turn/start', { turn: 3 }), + at(21, 'step/start', { turn: 3, step: 1 }), + at(22, 'llm/retry', { + retryId: 'retry-hidden', + turn: 3, + step: 1, + provider: 'fake', + mode: 'normal', + policyKey: 'fake-normal', + retry: 1, + maxRetries: 2, + delayMs: 10, + failure: { code: 'TRANSPORT', message: 'temporary' }, + }), + ]) + expect(node(snapshot(hiddenValue), 'assistant-step')).toBeUndefined() + + const toolOnlyValue = assembler([ + at(30, 'turn/start', { turn: 4 }), + at(31, 'step/start', { turn: 4, step: 1 }), + at(32, 'assistant/chunk', { + turn: 4, + step: 1, + chunk: { type: 'tool-call-delta', index: 0, id: 'call-1', name: 'read', argumentsDelta: '' }, + }), + at(33, 'assistant/message', { + turn: 4, + step: 1, + message: { + ...assistantMessage('assistant-tool-only', ''), + content: [{ type: 'tool-call', id: 'call-1', name: 'read', arguments: '{}' }], + }, + }, { surfaceOp: 'append' }), + ]) + const toolOnlySnapshot = snapshot(toolOnlyValue) + expect(toolOnlySnapshot.order).toEqual([]) + expect(node(toolOnlySnapshot, 'assistant-step')?.visibility).toBe('hidden') + expect(toolOnlySnapshot.legacy.nodes).toMatchObject([{ + kind: 'assistant', + seq: 33, + timing: { firstTokenTime: 1_700_000_000_032 }, + }]) + + const interruptedToolOnlyValue = assembler([ + at(35, 'turn/start', { turn: 5 }), + at(36, 'step/start', { turn: 5, step: 1 }), + at(37, 'assistant/chunk', { + turn: 5, + step: 1, + chunk: { type: 'tool-call-delta', index: 0, id: 'call-2', name: 'read', argumentsDelta: '' }, + }), + at(38, 'step/end', { turn: 5, step: 1 }), + ]) + const interruptedToolOnly = node(snapshot(interruptedToolOnlyValue), 'assistant-step') + expect(interruptedToolOnly?.visibility).toBe('visible') + expect(interruptedToolOnly?.data).toMatchObject({ status: 'interrupted' }) + + const retryTimingValue = assembler([ + at(50, 'turn/start', { turn: 6 }), + at(51, 'step/start', { turn: 6, step: 1 }), + at(52, 'assistant/chunk', { + turn: 6, + step: 1, + chunk: { type: 'text-delta', index: 0, text: 'first attempt' }, + }), + at(53, 'llm/retry', { + retryId: 'retry-timing', turn: 6, step: 1, provider: 'fake', mode: 'normal', + policyKey: 'fake-normal', retry: 1, maxRetries: 2, delayMs: 10, + failure: { code: 'TRANSPORT', message: 'temporary' }, + }), + at(54, 'assistant/chunk', { + turn: 6, + step: 1, + chunk: { type: 'text-delta', index: 0, text: 'second attempt' }, + }), + at(55, 'assistant/message', { + turn: 6, + step: 1, + message: assistantMessage('assistant-retried', 'done'), + }, { surfaceOp: 'append' }), + ]) + const retryTiming = (node(snapshot(retryTimingValue), 'assistant-step')?.data as AssistantChatData).finalNode + expect(retryTiming?.timing?.firstTokenTime).toBe(1_700_000_000_052) + + const partialWindow = assembler([ + at(40, 'assistant/chunk', { + turn: 5, + step: 2, + chunk: { type: 'text-delta', index: 0, text: 'loaded partial' }, + }), + at(41, 'step/end', { turn: 5, step: 2 }), + ], true) + const recovered = node(snapshot(partialWindow), 'assistant-step') + expect(recovered?.data).toMatchObject({ + status: 'interrupted', + blocks: [{ kind: 'text', text: 'loaded partial' }], + }) + }) + + it('keeps one keyed Tool node from running through settlement and replays nested dispatch after prepend', () => { + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'step/start', { turn: 1, step: 1 }), + at(3, 'tool/call', { turn: 1, step: 1, callId: 'root', name: 'code', arguments: '{}' }), + ]) + const runningSnapshot = snapshot(value) + const running = node(runningSnapshot, 'tool-call') + expect((running?.data as ToolChatData).root).toMatchObject({ callId: 'root', name: 'code' }) + const order = runningSnapshot.order + + value.append(at(4, 'tool/result', { + turn: 1, + step: 1, + message: toolResult('root', 'done'), + }, { surfaceOp: 'append' })) + value.flush() + + const settledSnapshot = snapshot(value) + const settled = node(settledSnapshot, 'tool-call') + expect(settled?.key).toBe(running?.key) + expect(settledSnapshot.order).toBe(order) + expect((settled?.data as ToolChatData).root).toMatchObject({ kind: 'tool-result', callId: 'root' }) + + const history = assembler([ + at(14, 'tool/code-dispatch-start', { + rootCallId: 'history-root', + parentCallId: 'history-root', + subCallId: 'child', + name: 'read', + arguments: { path: 'README.md' }, + }), + at(15, 'tool/code-dispatch', { + rootCallId: 'history-root', + parentCallId: 'history-root', + subCallId: 'child', + name: 'read', + arguments: { path: 'README.md' }, + isError: false, + content: [{ type: 'text', text: 'contents' }], + }), + at(16, 'tool/result', { + turn: 2, + step: 1, + message: toolResult('history-root', 'root done'), + }, { surfaceOp: 'append' }), + ], true) + const before = node(snapshot(history), 'tool-call') + expect((before?.data as ToolChatData).root.subCalls).toMatchObject([ + { kind: 'tool-result', callId: 'child', call: { name: 'read' } }, + ]) + + history.prepend([ + at(10, 'turn/start', { turn: 2 }), + at(11, 'step/start', { turn: 2, step: 1 }), + at(13, 'tool/call', { + turn: 2, + step: 1, + callId: 'history-root', + name: 'code', + arguments: '{}', + }), + ], false) + history.flush() + + const after = node(snapshot(history), 'tool-call') + expect(after?.key).toBe(before?.key) + expect((after?.data as ToolChatData).root.subCalls).toMatchObject([ + { kind: 'tool-result', callId: 'child', call: { name: 'read' } }, + ]) + + const firstChild = (after?.data as ToolChatData).root.subCalls[0] + history.append(at(17, 'tool/code-dispatch-start', { + rootCallId: 'history-root', + parentCallId: 'history-root', + subCallId: 'second-child', + name: 'write', + arguments: { path: 'out.txt' }, + })) + history.flush() + const withSecondChild = node(snapshot(history), 'tool-call') + expect((withSecondChild?.data as ToolChatData).root.subCalls[0]).toBe(firstChild) + }) + + it('prepends an older turn without replacing already materialized nodes', () => { + const value = assembler([ + at(20, 'turn/start', { turn: 2 }), + at(21, 'user/message', textMessage('newer-user', 'newer'), { surfaceOp: 'append' }), + at(22, 'step/start', { turn: 2, step: 1 }), + at(23, 'assistant/message', { + turn: 2, + step: 1, + message: assistantMessage('newer-assistant', 'newer answer'), + }, { surfaceOp: 'append' }), + at(24, 'step/end', { turn: 2, step: 1 }), + at(25, 'turn/end', { turn: 2, reason: { kind: 'completed' } }), + ], true) + const before = snapshot(value) + const existing = before.nodes.get(before.order.find(key => before.nodes.get(key)?.kind === 'assistant-step') ?? '') + const store = before.nodes + + value.prepend([ + at(10, 'turn/start', { turn: 1 }), + at(11, 'user/message', textMessage('older-user', 'older'), { surfaceOp: 'append' }), + at(12, 'step/start', { turn: 1, step: 1 }), + at(13, 'assistant/message', { + turn: 1, + step: 1, + message: assistantMessage('older-assistant', 'older answer'), + }, { surfaceOp: 'append' }), + at(14, 'step/end', { turn: 1, step: 1 }), + at(15, 'turn/end', { turn: 1, reason: { kind: 'completed' } }), + ], false) + value.flush() + + const after = snapshot(value) + expect(after.nodes).toBe(store) + expect(after.nodes.get(existing?.key ?? '')).toBe(existing) + expect(after.order).toHaveLength(before.order.length + 3) + expect(after.order.map(key => after.nodes.get(key)?.kind)).toEqual([ + 'user', 'assistant-step', 'turn-tail', + 'user', 'assistant-step', 'turn-tail', + ]) + }) + + it('appends a later turn without replacing nodes from the completed turn', () => { + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'user/message', textMessage('first-user', 'first'), { surfaceOp: 'append' }), + at(3, 'step/start', { turn: 1, step: 1 }), + at(4, 'assistant/message', { + turn: 1, + step: 1, + message: assistantMessage('first-assistant', 'first answer'), + }, { surfaceOp: 'append' }), + at(5, 'step/end', { turn: 1, step: 1 }), + at(6, 'turn/end', { turn: 1, reason: { kind: 'completed' } }), + ]) + const before = snapshot(value) + const oldOrder = before.order + const oldNodes = oldOrder.map(key => before.nodes.get(key)) + + value.append(at(7, 'turn/start', { turn: 2 })) + value.append(at(8, 'user/message', textMessage('second-user', 'second'), { surfaceOp: 'append' })) + value.flush() + + const after = snapshot(value) + expect(after.nodes).toBe(before.nodes) + expect(after.order.slice(0, oldOrder.length)).toEqual(oldOrder) + expect(oldOrder.map(key => after.nodes.get(key))).toEqual(oldNodes) + expect(after.order.map(key => after.nodes.get(key)?.kind)).toEqual([ + 'user', 'assistant-step', 'turn-tail', 'user', + ]) + }) + + it('replays inbox predecessors after prepend and reclassifies the dependent message as steering', () => { + const value = assembler([ + at(3, 'user/message', textMessage('steer-1', 'change direction'), { surfaceOp: 'append' }), + ], true) + const before = node(snapshot(value), 'user') + expect(before).toBeDefined() + + value.prepend([ + at(1, 'agent/inbox/spliced', { + target: 'next-step', + start: 0, + inserted: [textMessage('steer-1', 'change direction')], + }), + at(2, 'agent/inbox/spliced', { + target: 'next-step', + start: 0, + removedCount: 1, + inserted: [], + }), + ], false) + value.flush() + + const after = node(snapshot(value), 'steering') + expect(after?.key).toBe(before?.key) + expect(after?.data).toMatchObject({ kind: 'steering', messageId: 'steer-1' }) + expect(node(snapshot(value), 'user')).toBeUndefined() + }) + + it('classifies appended producer context from durable source metadata', () => { + const value = assembler([ + at(1, 'user/message', { + ...textMessage('skill-context', 'follow these instructions'), + source: { kind: 'skill-invocation', name: 'demo-skill', form: 'instructions' }, + }, { surfaceOp: 'append' }), + ]) + + expect(node(snapshot(value), 'context')?.data).toMatchObject({ + kind: 'context', + provenance: { role: 'inject', label: 'demo-skill' }, + form: 'instructions', + }) + }) + + it('keeps replacement copies out of Chat business nodes', () => { + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'step/start', { turn: 1, step: 1 }), + at(3, 'user/message', { + ...textMessage('replacement-user', 'model-only context'), + source: { kind: 'plugin', plugin: 'foreign' }, + }, { surfaceOp: { op: 'replace', start: 1, end: 1 } }), + at(4, 'assistant/message', { + turn: 1, + step: 1, + message: assistantMessage('replacement-assistant', 'rewritten answer'), + }, { surfaceOp: { op: 'replace', start: 2, end: 2 } }), + at(5, 'tool/call', { turn: 1, step: 1, callId: 'root', name: 'read', arguments: '{}' }), + at(6, 'tool/result', { + turn: 1, + step: 1, + message: toolResult('root', 'pruned result'), + }, { surfaceOp: { op: 'replace', start: 3, end: 3 } }), + ]) + + const current = snapshot(value) + expect(node(current, 'user')).toBeUndefined() + expect(node(current, 'context')).toBeUndefined() + expect(node(current, 'assistant-step')).toBeUndefined() + expect((node(current, 'tool-call')?.data as ToolChatData).root).not.toHaveProperty('kind') + }) + + it('assembles retry chains and keeps manual and automatic compaction ownership separate', () => { + const retry = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'step/start', { turn: 1, step: 1 }), + at(3, 'llm/retry', { + retryId: 'retry-1', + turn: 1, + step: 1, + provider: 'fake', + mode: 'normal', + policyKey: 'fake-normal', + retry: 1, + maxRetries: 2, + delayMs: 10, + failure: { code: 'TRANSPORT', message: 'first' }, + }), + at(4, 'llm/retry-started', { retryId: 'retry-1', turn: 1, step: 1, retry: 1 }), + at(5, 'llm/retry', { + retryId: 'retry-1', + turn: 1, + step: 1, + provider: 'fake', + mode: 'normal', + policyKey: 'fake-normal', + retry: 2, + maxRetries: 2, + delayMs: 20, + failure: { code: 'TRANSPORT', message: 'second' }, + }), + at(6, 'step/end', { turn: 1, step: 1 }), + at(7, 'turn/end', { + turn: 1, + reason: { kind: 'error', error: { code: 'TRANSPORT', message: 'failed' } }, + }), + ]) + const retryNode = node(snapshot(retry), 'model-retry') + const retryData = retryNode?.data as RetryChatData + expect(retryData.attempts.map(attempt => attempt.retryState)).toEqual(['started', 'cancelled']) + expect(node(snapshot(retry), 'turn-error')).toBeUndefined() + + const compactions = assembler([ + at(10, 'command/run', { + commandId: 'command-1', + name: 'compact', + source: { kind: 'user' }, + }), + at(11, 'compact/start', { + compactionId: 'manual-1', + sourceCommandId: 'command-1', + turn: null, + }), + at(12, 'compact/summary', { + compactionId: 'manual-1', + sourceCommandId: 'command-1', + summary: [{ type: 'text', text: 'manual summary' }], + shadowedSeqs: [1, 2], + shadowedTokenCount: 100, + }), + at(13, 'user/message', { + ...textMessage('manual-checkpoint', 'checkpoint'), + source: { + kind: 'plugin', + plugin: 'compact', + compactionId: 'manual-1', + sourceCommandId: 'command-1', + }, + }, { surfaceOp: { op: 'replace', start: 1, end: 2 } }), + at(14, 'compact/end', { + compactionId: 'manual-1', + sourceCommandId: 'command-1', + turn: null, + }), + at(15, 'command/done', { + commandId: 'command-1', + kind: 'success', + sourceEventSeq: 12, + }), + at(20, 'compact/start', { compactionId: 'automatic-1', turn: null }), + at(21, 'compact/summary', { + compactionId: 'automatic-1', + summary: [{ type: 'text', text: 'automatic summary' }], + shadowedSeqs: [3, 4], + shadowedTokenCount: 200, + }), + at(22, 'user/message', { + ...textMessage('automatic-checkpoint', 'checkpoint'), + source: { kind: 'plugin', plugin: 'compact', compactionId: 'automatic-1' }, + }, { surfaceOp: { op: 'replace', start: 3, end: 4 } }), + at(23, 'compact/end', { compactionId: 'automatic-1', turn: null }), + ]) + + const manual = node(snapshot(compactions), 'manual-compaction') + expect((manual?.data as ManualCompactionChatData).compaction).toMatchObject({ + summary: 'manual summary', + summaryEventSeq: 12, + }) + const automatic = node(snapshot(compactions), 'compaction') + expect(automatic?.data).toMatchObject({ summary: 'automatic summary', summaryEventSeq: 21 }) + expect(snapshot(compactions).nodes.values().filter(candidate => candidate.kind === 'compaction')).toHaveLength(1) + }) + + it('fills a landed compaction marker when an older page supplies its summary', () => { + const value = assembler([ + at(13, 'user/message', { + ...textMessage('checkpoint', 'checkpoint'), + source: { kind: 'plugin', plugin: 'compact', compactionId: 'compact-1' }, + }, { surfaceOp: { op: 'replace', start: 1, end: 8 } }), + ], true) + const before = node(snapshot(value), 'compaction') + expect(before?.data).toMatchObject({ summary: null, summaryEventSeq: null }) + + value.prepend([ + at(9, 'compact/start', { compactionId: 'compact-1', turn: null }), + at(10, 'compact/summary', { + compactionId: 'compact-1', + summary: [ + { type: 'text', text: 'older ' }, + { type: 'image', data: 'ignored' }, + { type: 'text', text: 'summary' }, + ], + shadowedSeqs: [1, 2, 3], + shadowedTokenCount: 42, + }), + ], false) + value.flush() + + const after = node(snapshot(value), 'compaction') + expect(after?.key).toBe(before?.key) + expect(after?.data).toMatchObject({ + summary: 'older summary', + summaryEventSeq: 10, + shadowedItemCount: 3, + shadowedTokenCount: 42, + }) + }) + + it('suppresses a turn error when the loaded tail contains only a later retry attempt', () => { + const value = assembler([ + at(5, 'llm/retry', { + retryId: 'retry-paged', + turn: 1, + step: 1, + provider: 'fake', + mode: 'normal', + policyKey: 'fake-normal', + retry: 2, + maxRetries: 2, + delayMs: 20, + failure: { code: 'TRANSPORT', message: 'second' }, + }), + at(6, 'step/end', { turn: 1, step: 1 }), + at(7, 'turn/end', { + turn: 1, + reason: { kind: 'error', error: { code: 'TRANSPORT', message: 'failed' } }, + }), + ], true) + + expect(node(snapshot(value), 'model-retry')).toBeUndefined() + expect(node(snapshot(value), 'turn-error')).toBeUndefined() + + value.prepend([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'step/start', { turn: 1, step: 1 }), + at(3, 'llm/retry', { + retryId: 'retry-paged', + turn: 1, + step: 1, + provider: 'fake', + mode: 'normal', + policyKey: 'fake-normal', + retry: 1, + maxRetries: 2, + delayMs: 10, + failure: { code: 'TRANSPORT', message: 'first' }, + }), + at(4, 'llm/retry-started', { + retryId: 'retry-paged', turn: 1, step: 1, retry: 1, + }), + ], false) + value.flush() + + const retry = node(snapshot(value), 'model-retry') + expect((retry?.data as RetryChatData).attempts).toHaveLength(2) + expect(node(snapshot(value), 'turn-error')).toBeUndefined() + }) + + it('preserves nested Tools and manual compaction evidence when their start events are outside the window', () => { + const value = assembler([ + at(12, 'tool/code-dispatch-start', { + rootCallId: 'root', parentCallId: 'root', subCallId: 'child', name: 'read_file', arguments: { path: 'a' }, + }), + at(13, 'tool/code-dispatch', { + rootCallId: 'root', parentCallId: 'root', subCallId: 'child', name: 'read_file', arguments: { path: 'a' }, + isError: false, content: [{ type: 'text', text: 'child result' }], + }), + at(14, 'tool/result', { + turn: 1, + step: 1, + message: toolResult('root', 'root result'), + }, { surfaceOp: 'append' }), + at(20, 'compact/summary', { + compactionId: 'manual-1', + sourceCommandId: 'command-1', + summary: [{ type: 'text', text: 'manual summary' }], + shadowedSeqs: [1, 2], + shadowedTokenCount: 100, + }), + at(21, 'user/message', { + ...textMessage('manual-checkpoint', 'checkpoint'), + source: { + kind: 'plugin', + plugin: 'compact', + compactionId: 'manual-1', + sourceCommandId: 'command-1', + }, + }, { surfaceOp: { op: 'replace', start: 1, end: 2 } }), + at(22, 'command/done', { + commandId: 'command-1', + kind: 'success', + sourceEventSeq: 20, + }), + ], true) + + const tool = node(snapshot(value), 'tool-call') + const root = (tool?.data as ToolChatData).root + expect(root.subCalls).toHaveLength(1) + expect(root.subCalls[0]).toMatchObject({ callId: 'child', kind: 'tool-result' }) + const manual = node(snapshot(value), 'manual-compaction') + expect((manual?.data as ManualCompactionChatData)).toMatchObject({ + command: { commandId: 'command-1', name: 'compact', outcome: { kind: 'success' } }, + compaction: { summary: 'manual summary', summaryEventSeq: 20 }, + }) + }) +}) diff --git a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx index 3afe471d50..ff3a0f1598 100644 --- a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { cleanup, render } from '@testing-library/react' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { createSnapshotStore, EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-runtime/client' import type { UseSession } from '@deepseek-ai/dsh-client-web-react' import type { ConversationSnapshot, SessionId, SessionListState, WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client' import type { SessionProviderComponent } from '@deepseek-ai/dsh-client-ui-slots' @@ -15,6 +15,7 @@ import { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/ch import { StatsLine } from '../src/client/chat/StatsLine.tsx' import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx' import { zh } from '../src/client/locales.ts' +import { chatSnapshotFixture } from './chat-snapshot-fixture.ts' // Mirrors the real lookup chain (conversation namespace, then common). const t: AssistantMarkdownProps['t'] = makeTranslate(zh, commonZh) @@ -40,14 +41,15 @@ const SessionProviderStub: SessionProviderComponent = ({ children }) => children /** Observe the owner currency without importing the Tool details renderer. */ function renderToolDetailsProbe(owners?: DetailsToolOwnerProps[]): DetailsSlotProps['renderSlot'] { return (_key, owner) => { - owners?.push(owner as DetailsToolOwnerProps) + owners?.push(owner as unknown as DetailsToolOwnerProps) return
} } function snapshotBase(): ConversationSnapshot { return { - sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], + sessionId: SID, chat: EMPTY_CHAT_SNAPSHOT, + nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, } @@ -69,11 +71,16 @@ describe('render branch tails', () => { it('StatsLine counts window nodes but drops every token group without a projection', () => { // Node `usage` is deliberately ignored: billing rides the durable // tokenUsage projection, so an absent projection leaves counts only. + const nodes = [ + { kind: 'assistant', seq: 1, time: 1, turn: 1, step: 1, blocks: [] }, + { kind: 'assistant', seq: 2, time: 2, turn: 1, step: 2, blocks: [], usage: { inputTokens: 4, outputTokens: 6 } }, + { kind: 'assistant', seq: 3, time: 3, turn: 2, step: 1, blocks: [], usage: { inputTokens: 5 } }, + ] as const const snap = { + ...snapshotBase(), + chat: chatSnapshotFixture({ nodes }), nodes: [ - { kind: 'assistant', seq: 1, turn: 1, step: 1, blocks: [] }, - { kind: 'assistant', seq: 2, turn: 1, step: 2, blocks: [], usage: { inputTokens: 4, outputTokens: 6 } }, - { kind: 'assistant', seq: 3, turn: 2, step: 1, blocks: [], usage: { inputTokens: 5 } }, + ...nodes, ], } const source = { getSnapshot: () => snap, subscribe: () => () => {} } @@ -146,6 +153,7 @@ describe('render branch tails', () => { }], }], }] + snap.chat = chatSnapshotFixture({ runningCalls: snap.runningCalls }) const chat = createChatStore().create() chat.actions.select({ turnSeq: 9, callId: 'p1:code:1:code:1', toolName: 'read' } satisfies SelectionTarget) const emptyList = createSnapshotStore( diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index 1e7c3a1ba1..e9076fa994 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -7,7 +7,7 @@ import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { act, cleanup, fireEvent, render } from '@testing-library/react' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { createSnapshotStore, EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-runtime/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import type { ClientContext, ConversationSnapshot, SessionId } from '@deepseek-ai/dsh-client-runtime/client' @@ -35,7 +35,8 @@ const SID = 's1' as SessionId function snapshotOf(overrides: Partial = {}): ConversationSnapshot { return { - sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], + sessionId: SID, chat: EMPTY_CHAT_SNAPSHOT, + nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, diff --git a/packages/client/ui-conversation/tests/input-matrix.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.spec.tsx index f197528a2b..65fa5705ca 100644 --- a/packages/client/ui-conversation/tests/input-matrix.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.spec.tsx @@ -8,7 +8,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render } from '@testing-library/react' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { createSnapshotStore, EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-runtime/client' import type { ClientContext, ConversationSnapshot, SessionId } from '@deepseek-ai/dsh-client-runtime/client' import type { SubmitOutcome } from '@deepseek-ai/dsh-client-ui-slash/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' @@ -26,7 +26,8 @@ const SID = 's1' as SessionId /** Standard-props InputBar mount over a real shell (the composer-bar entry shape). */ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled?: boolean }) { const session = createSnapshotStore({ - sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], + sessionId: SID, chat: EMPTY_CHAT_SNAPSHOT, + nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: over?.running ?? false, composerPhase: 'active', removed: over?.disabled ?? false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, diff --git a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx index aae5bb4818..94d348d17a 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx @@ -11,7 +11,7 @@ import { Context } from 'cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render } from '@testing-library/react' -import { SessionsService } from '@deepseek-ai/dsh-client-runtime/client' +import { EMPTY_CHAT_SNAPSHOT, SessionsService } from '@deepseek-ai/dsh-client-runtime/client' import { SlashService } from '@deepseek-ai/dsh-client-ui-slash/client' import type { ClientSessionContext, CommandClaim, PickOutcome, SubmitOutcome } from '@deepseek-ai/dsh-client-ui-slash/client' import { FakeApiClient, ok } from '../../runtime/tests/fake-api.ts' @@ -112,7 +112,8 @@ async function scopedBench(register?: (slash: SlashService) => void) { actx.on('slash/input-consume-token', req => shell.consumeToken(req.guard) ? true : undefined) const wiring = shell const sessionStore = createSnapshotStore({ - sessionId, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], + sessionId, chat: EMPTY_CHAT_SNAPSHOT, + nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, diff --git a/packages/client/ui-conversation/tests/queue-dock.spec.tsx b/packages/client/ui-conversation/tests/queue-dock.spec.tsx index 826abaf846..31a6423f87 100644 --- a/packages/client/ui-conversation/tests/queue-dock.spec.tsx +++ b/packages/client/ui-conversation/tests/queue-dock.spec.tsx @@ -6,6 +6,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react' import { useSyncExternalStore } from 'react' +import { EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationSnapshot, QueuedMessage, SessionId, SessionListState, } from '@deepseek-ai/dsh-client-runtime/client' @@ -32,7 +33,8 @@ function row(id: string, text: string | null, preview = text ?? '[image]'): Queu function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot { return { - sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], + sessionId: SID, chat: EMPTY_CHAT_SNAPSHOT, + nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue, running: true, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, } diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index 70b6d96710..bbabbaeb0c 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render } from '@testing-library/react' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { createSnapshotStore, EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationSnapshot, SessionId, SessionListState, WorkspaceId, WorkspaceListState, WorkspaceView, } from '@deepseek-ai/dsh-client-runtime/client' @@ -70,7 +70,8 @@ const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState => function conversationSnapshot(overrides: Partial = {}): ConversationSnapshot { return { - sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], + sessionId: SID, chat: EMPTY_CHAT_SNAPSHOT, + nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, diff --git a/packages/client/ui-deliverables/tests/produced-files.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.spec.tsx index 3bcaffe605..b7ca73a79f 100644 --- a/packages/client/ui-deliverables/tests/produced-files.spec.tsx +++ b/packages/client/ui-deliverables/tests/produced-files.spec.tsx @@ -8,15 +8,22 @@ import { Context } from 'cordis' import { cleanup, fireEvent, render } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import { + ConversationEventRegistry, ConversationNodeAssembler, SlotsService, +} from '@deepseek-ai/dsh-client-runtime/client' import type { - AssistantMessageNode, ConversationNode, ToolResultNode, UserMessageNode, + ConversationEventInput, ConversationLocationDataStore, ConversationNodeDefinition, + ConversationTimelineSnapshot, ConversationTurnDataMap, ConversationViewDefinition, + ConversationViewNode, ToolResultNode, TurnLocation, } from '@deepseek-ai/dsh-client-runtime/client' import { apply as applyLocale } from '@deepseek-ai/dsh-client-locale/client' -import type { ChatFileMentions } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { ChatFileMentions, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { ProducedFiles } from '../src/client/ProducedFiles.tsx' -import { basename, producedFileMentions, producedForClosing, selectProducedFiles } from '../src/client/turn-deliverables.ts' +import { + basename, deliverablesDefinition, producedFileMentions, producedForClosing, selectProducedFiles, + type DeliverablesTurnData, +} from '../src/client/turn-deliverables.ts' import { apply, inject } from '../src/client/index.ts' import { apply as applyNode } from '../src/index.ts' import { apply as applyInvariant } from '../src/invariant.ts' @@ -24,101 +31,187 @@ import { zh } from '../src/client/locales.ts' afterEach(cleanup) -const user = (seq: number, text: string): UserMessageNode => ({ - kind: 'user', - seq, - time: seq * 1000, - content: [{ type: 'text', text }] as never, - source: null, +class TestTurnDataStore implements ConversationLocationDataStore { + private readonly values = new Map() + + get( + key: Key, + ): Readonly | undefined { + return this.values.get(key) as Readonly | undefined + } + + set(key: Key, value: ConversationTurnDataMap[Key]): void { + this.values.set(key, value) + } +} + +const turnLocation = (turn: number, deliverables?: DeliverablesTurnData): TurnLocation => { + const data = new TestTurnDataStore() + if (deliverables !== undefined) data.set('deliverables', deliverables) + return { turn, start: undefined, end: undefined, status: 'closed', steps: [], data } +} + +const produced = (...values: ReadonlyArray): DeliverablesTurnData => ({ + produced: values.map(([seq, path]) => ({ seq, path })), }) -const assistant = (seq: number, text: string, turn = 1): AssistantMessageNode => ({ - kind: 'assistant', seq, time: seq * 1_000, turn, step: 1, blocks: [{ kind: 'text', text }], -}) -const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode => ({ - kind: 'tool-result', seq, time: seq * 1_000, callId, - call: { name, argsRaw: `{"command":"cmd-${callId}","description":"run ${callId}"}` }, - callTime: seq * 1_000 - 500, - content: [], isError: false, callView: null, resultView: null, subCalls: [], -}) -const wrote = (seq: number, callId: string, ...paths: string[]): ToolResultNode => ({ - ...toolResult(seq, callId, 'write'), - callView: { + +function tailOwner( + data: DeliverablesTurnData | undefined, + seq: number, + openFile: (path: string) => void = () => {}, + turn = 1, +): TurnTailOwnerProps { + return { seq, openFile, turn: turnLocation(turn, data) } +} + +interface TimelineSnapshot { + readonly timeline: ConversationTimelineSnapshot +} + +class TestEventDefinitions { + entries(): readonly ConversationNodeDefinition[] { return [deliverablesDefinition] } + fallbackEntry(): undefined { return undefined } +} + +class TestViewDefinitions { + entries(): readonly ConversationViewDefinition[] { return [timelineViewDefinition] } +} + +const timelineViewDefinition: ConversationViewDefinition = { + target: 'test', + create: () => { + let current: TimelineSnapshot = { timeline: { turnOrder: [], turns: new Map() } } + return { + empty: current, + replace: ({ timeline }) => (current = { timeline }), + apply: ({ timeline }) => (current = { timeline }), + } + }, +} + +function at( + seq: number, + type: string, + data: unknown, + view?: ConversationEventInput['view'], +): ConversationEventInput { + return { + event: { + seq, time: seq * 1_000, type, data, + ...(type === 'tool/result' ? { surfaceOp: 'append' } : {}), + } as ConversationEventInput['event'], + view, + } +} + +function call( + seq: number, + callId: string, + view: ToolResultNode['callView'], + turn = 1, +): ConversationEventInput { + return at( + seq, + 'tool/call', + { turn, step: 1, callId, name: 'fixture', arguments: '{}' }, + { for: 'call', view: view ?? { card: 'generic', title: 'fixture' } }, + ) +} + +function result(seq: number, callId: string, isError = false, turn = 1): ConversationEventInput { + return at(seq, 'tool/result', { + turn, + step: 1, + message: { + source: { type: 'tool-result', callId }, + content: [{ type: 'tool-result', content: [], isError }], + }, + }) +} + +function diff(...paths: string[]): ToolResultNode['callView'] { + return { card: 'diff', title: `Write ${paths[0] ?? ''}`, diffs: paths.map(path => ({ path, oldText: null, newText: 'x' })), locations: paths.map(path => ({ path })), - }, -}) + } +} -describe('producedForClosing derivation', () => { - it('attributes each turn’s written files to the assistant that closes it', () => { - const nodes: ConversationNode[] = [ - user(1, 'build it'), - assistant(2, 'writing', 1), - wrote(3, 'a', 'out/index.html'), - // Same file touched twice in one turn is one deliverable, in first-seen order. - wrote(4, 'b', 'out/app.css', 'out/index.html'), - // A read is not a deliverable; a failed write has no file to open. - { ...toolResult(5, 'c', 'read'), callView: { card: 'generic', title: 'Read x', locations: [{ path: 'x.ts' }] } }, - { ...wrote(6, 'd', 'out/broken.html'), isError: true }, - assistant(7, 'done', 1), - user(8, 'again'), - assistant(9, 'second turn', 2), - ] - expect(producedForClosing(nodes, 7)).toEqual(['out/index.html', 'out/app.css']) - expect(selectProducedFiles({ nodes, seq: 7, openFile: () => {} })).toEqual(['out/index.html', 'out/app.css']) - expect(selectProducedFiles({ nodes, seq: 9, openFile: () => {} })).toBeNull() - // A turn that produced nothing yields the empty list, and so does an - // anchor the window does not contain. - expect(producedForClosing(nodes, 9)).toEqual([]) - expect(producedForClosing([user(1, 'hi'), assistant(2, 'hello', 1)], 2)).toEqual([]) - expect(producedForClosing(nodes, 999)).toEqual([]) +function edit(path: string): ToolResultNode['callView'] { + return { card: 'generic', title: `insert ${path}`, kind: 'edit', locations: [{ path }] } +} + +function assembler(entries: readonly ConversationEventInput[], hasMore = false): ConversationNodeAssembler { + const value = new ConversationNodeAssembler(new TestEventDefinitions(), new TestViewDefinitions()) + value.replaceWindow(entries, hasMore) + value.flush() + return value +} + +function deliverablesOf(value: ConversationNodeAssembler, turn = 1): Readonly | undefined { + const snapshot = value.snapshot('test') as TimelineSnapshot + return snapshot.timeline.turns.get(turn)?.data.get('deliverables') +} + +describe('produced-file Turn data', () => { + it('deduplicates paths in first-seen order and stops at the closing Assistant seq', () => { + const data = produced( + [3, 'out/index.html'], + [4, 'out/app.css'], + [4, 'out/index.html'], + [8, 'after.txt'], + ) + expect(producedForClosing(data, 6)).toEqual(['out/index.html', 'out/app.css']) + expect(selectProducedFiles(tailOwner(data, 6))).toEqual(['out/index.html', 'out/app.css']) + expect(producedForClosing(undefined)).toEqual([]) + expect(selectProducedFiles(tailOwner(undefined, 9, () => {}, 2))).toBeNull() }) + it('folds successful diff and generic-edit calls while ignoring reads, failures, and missing locations', () => { + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + call(2, 'write', diff('out/index.html', 'out/app.css')), + result(3, 'write'), + call(4, 'edit', edit('notes.md')), + result(5, 'edit'), + call(6, 'read', { card: 'generic', title: 'Read', locations: [{ path: 'input.txt' }] }), + result(7, 'read'), + call(8, 'failed', diff('broken.txt')), + result(9, 'failed', true), + call(10, 'locationless', { card: 'diff', title: 'Write', diffs: [] }), + result(11, 'locationless'), + ]) - it('counts a generic edit and never spills across the turn boundary', () => { - const inserted = (seq: number, callId: string, path: string): ToolResultNode => ({ - ...toolResult(seq, callId, 'str_replace_editor'), - // str_replace_editor's insert mutates behind a generic card, so the - // discriminant is the render intent, not the card shape alone. - callView: { card: 'generic', title: `insert ${path}`, kind: 'edit', locations: [{ path }] }, - }) - const nodes: ConversationNode[] = [ - user(1, 'insert a line'), - inserted(2, 'i', 'notes.md'), - assistant(3, 'inserted', 1), - // Turn 2 mutates and then ends with no content text (interrupted, or its - // last text preceded the tool): its paths must not ride into turn 3. - user(4, 'now rewrite it'), - wrote(5, 'w', 'leaked.txt'), - user(6, 'and again'), - wrote(7, 'w2', 'notes.md'), - assistant(8, 'done', 3), - ] - expect(producedForClosing(nodes, 3)).toEqual(['notes.md']) - // Turn 3 lists only its own file — and the dedup set did not suppress the - // rewrite of a path an earlier turn already touched. - expect(producedForClosing(nodes, 8)).toEqual(['notes.md']) - expect(producedForClosing(nodes, 8)).not.toContain('leaked.txt') + expect(producedForClosing(deliverablesOf(value))).toEqual([ + 'out/index.html', 'out/app.css', 'notes.md', + ]) }) - it('resets on a turn-number change and skips turnless, viewless, and locationless nodes', () => { - const nodes: ConversationNode[] = [ - user(1, 'go'), - // A turnless surface node neither tracks nor resets the boundary. - { kind: 'unknown', seq: 1.5, time: 1_500, type: 'x', data: null }, - wrote(2, 'w', 'turn-one.txt'), - // A view-less result (window truncation) and cards without locations - // contribute nothing rather than crashing the walk. - toolResult(3, 'plain'), - { ...toolResult(4, 'nl', 'write'), callView: { card: 'diff', title: 'Write', diffs: [] } }, - { ...toolResult(5, 'ge', 'str_replace_editor'), callView: { card: 'generic', title: 'insert', kind: 'edit' } }, - assistant(6, 'mid narration', 1), - // Turn number advances with no user message in the window (truncated - // history): the accumulator must reset all the same. - assistant(7, 'closing', 2), - ] - expect(producedForClosing(nodes, 6)).toEqual(['turn-one.txt']) - expect(producedForClosing(nodes, 7)).toEqual([]) + it('replays a tail page once prepend supplies its missing Turn start', () => { + const value = assembler([ + call(10, 'late', diff('history.txt')), + result(11, 'late'), + ], true) + expect(deliverablesOf(value)).toBeUndefined() + + value.prepend([at(1, 'turn/start', { turn: 1 })], false) + value.flush() + expect(producedForClosing(deliverablesOf(value))).toEqual(['history.txt']) + }) + + it('extends the same Turn data incrementally on live append', () => { + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + call(2, 'first', diff('first.txt')), + result(3, 'first'), + ]) + const first = deliverablesOf(value) + expect(producedForClosing(first)).toEqual(['first.txt']) + + value.append(call(4, 'second', diff('second.txt'))) + value.append(result(5, 'second')) + value.flush() + expect(producedForClosing(deliverablesOf(value))).toEqual(['first.txt', 'second.txt']) }) }) @@ -190,6 +283,7 @@ describe('plugin registration', () => { it('registers the tail entry and fiber disposal removes it', async () => { const ctx = new Context() await ctx.plugin(SlotsService).await() + await ctx.plugin(ConversationEventRegistry).await() // The owning view's child declaration, stood up by a bench root entry. ctx.slots.register({ name: 'root', @@ -204,17 +298,17 @@ describe('plugin registration', () => { // The prose face is live while the plugin is: a produced turn yields a // resolver whose matches open through the owner-supplied opener. const opened: string[] = [] - const owner = { - nodes: [user(1, 'go'), wrote(2, 'w', 'site/report.html'), assistant(3, 'done', 1)], - seq: 3, - openFile: (path: string) => { opened.push(path) }, - } + const owner = tailOwner( + produced([2, 'site/report.html']), + 3, + (path) => { opened.push(path) }, + ) const service = (ctx as unknown as { get(name: string): ChatFileMentions | undefined }).get('chatFileMentions') const mentions = service?.forClosing(owner) mentions?.resolve('report.html')?.open() expect(opened).toEqual(['site/report.html']) // A turn that produced nothing yields no vocabulary at all. - expect(service?.forClosing({ ...owner, nodes: [user(1, 'hi'), assistant(2, 'ok', 1)], seq: 2 })).toBeUndefined() + expect(service?.forClosing(tailOwner(undefined, 2))).toBeUndefined() await fiber.dispose() expect(ctx.slots.entries('conversation.chat.turnTail')).toHaveLength(0) diff --git a/packages/client/ui-slots/tests/surface.spec.ts b/packages/client/ui-slots/tests/surface.spec.ts index 12a5eeba15..e85aa324e1 100644 --- a/packages/client/ui-slots/tests/surface.spec.ts +++ b/packages/client/ui-slots/tests/surface.spec.ts @@ -7,6 +7,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotMap { 'surface.a': { kind: 'single'; scope: 'root' } 'surface.b': { kind: 'single'; scope: 'root' } + 'surface.injected': { kind: 'single'; scope: 'root'; inject: { token: string } } } } @@ -28,6 +29,16 @@ describe('dynamic-key escape hatch', () => { expect(core.spec('surface.b')).toBeUndefined() }) + it('records the parent-declared Slot inject on the runtime spec', () => { + const core = new SlotCore() + const inject = { token: 'shared' } + core.register({ + name: 'root', + children: { 'surface.injected': { kind: 'single', scope: 'root', inject } }, + }, Comp as never) + expect(core.spec('surface.injected')?.inject).toBe(inject) + }) + it('entries/getVersion on an untouched key return the frozen empty array and 0', () => { const core = new SlotCore() expect(core.entries('surface.b')).toHaveLength(0) diff --git a/packages/client/ui-slots/tests/type-chain.spec.tsx b/packages/client/ui-slots/tests/type-chain.spec.tsx index b3a6aec873..64277edccc 100644 --- a/packages/client/ui-slots/tests/type-chain.spec.tsx +++ b/packages/client/ui-slots/tests/type-chain.spec.tsx @@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest' import type { ReactNode } from 'react' import type { - BoundActions, DefineStore, PropsRenderSlots, PropsRuntime, PropsStore, SlotComponent, + BoundActions, DefineStore, PropsRenderSlots, PropsRuntime, PropsStore, SlotComponent, SlotHookFactory, } from '@deepseek-ai/dsh-client-ui-slots' import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots' @@ -19,6 +19,12 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { 'chain.frame': { kind: 'single'; scope: 'root' } 'chain.side': { kind: 'single'; scope: 'root'; owner: { collapsed: boolean; width: number } } 'chain.conv': { kind: 'single'; scope: 'session' } + 'chain.context': { + kind: 'single' + scope: 'session' + hookContext: string + inject: ContextInjected + } 'chain.tools': { kind: 'keyed'; scope: 'session' } 'chain.takeover': { kind: 'chain'; scope: 'session'; owner: { items: readonly Item[] } } } @@ -62,6 +68,23 @@ type ConvProps = & PropsStore & { send: (t: string) => void } +interface TurnDataMap { tail: string; files: string } +type UseTurnData = (key: Key) => TurnDataMap[Key] | undefined +interface ContextInjected { + hooks: { + turnData: SlotHookFactory<'chain.context', UseTurnData> + } +} +type ContextProps = PropsRuntime<'chain.context'> +const CONTEXT_INJECT: ContextInjected = { + hooks: { + turnData: (_standard, hookContext) => { + const id: string = hookContext + return key => id === '' ? undefined : ({ tail: 'tail', files: 'files' })[key] + }, + }, +} + // Component fixtures (never rendered; the register call sites are the test). declare function Frame(props: FrameProps): ReactNode declare function Conv(props: ConvProps): ReactNode @@ -72,6 +95,8 @@ declare function NoDecl(props: PropsRuntime<'chain.frame'> & PropsRenderSlots<'c declare function Blind(props: PropsRuntime<'chain.frame'>): ReactNode declare function WrongStore(props: PropsRuntime<'chain.conv'> & PropsStore>): ReactNode declare function Needs(props: PropsRuntime<'chain.conv'> & { send: (t: string) => void }): ReactNode +declare function ContextOwner(props: PropsRuntime<'chain.frame'> & PropsRenderSlots<'chain.context'>): ReactNode +declare function ContextReader(props: ContextProps): ReactNode declare function Takeover(props: PropsRuntime<'chain.takeover'> & { matched: Item }): ReactNode declare function WideTakeover(props: PropsRuntime<'chain.takeover'> & { matched: Item | string }): ReactNode declare function NarrowTakeover(props: PropsRuntime<'chain.takeover'> & { matched: { kind: 'q'; id: string; extra: number } }): ReactNode @@ -144,6 +169,24 @@ describe('terminal-design type chain', () => { chainSlots.renderSlotChain('chain.takeover', { items: [] }, { fallback: null }) chainSlots.renderSlot('chain.conv', {}) + // A parent registration declares the Slot inject once; every child + // entry receives the same custom Hook, bound to official standard props + // and each render occurrence's opaque context. + core.register({ + name: 'chain.frame', + children: { + 'chain.context': { kind: 'single', scope: 'session', inject: CONTEXT_INJECT }, + }, + }, ContextOwner) + core.register({ name: 'chain.context' }, ContextReader) + const contextProps: ContextProps = null as never + const tail: string | undefined = contextProps.useTurnData('tail') + const contextSlots: PropsRenderSlots<'chain.context'> = null as never + contextSlots.renderSlot('chain.context', {}, { + hookContext: 'turn:1', + }) + void tail + // ── negatives ────────────────────────────────────────────────── // children spec must match the SlotMap entry. core.register({ @@ -151,6 +194,11 @@ describe('terminal-design type chain', () => { // @ts-expect-error chain.conv is session-scoped in SlotMap children: { 'chain.conv': { kind: 'single', scope: 'root' } }, }, (() => null) as SlotComponent) + core.register({ + name: 'chain.frame', + // @ts-expect-error chain.context requires its Slot-level inject declaration + children: { 'chain.context': { kind: 'single', scope: 'session' } }, + }, ContextOwner) // renderSlot key set ⊄ children declaration. // @ts-expect-error component renderSlot keys exceed the declaration @@ -219,6 +267,16 @@ describe('terminal-design type chain', () => { // @ts-expect-error key not in this render share fp.renderSlot('chain.tools', {}) + // Contextual hooks preserve both the business key and value type. + // @ts-expect-error unknown Turn-data key + contextProps.useTurnData('other') + // @ts-expect-error a contextual slot requires its occurrence context + contextSlots.renderSlot('chain.context', {}) + // @ts-expect-error hookContext is the slot-declared string + const _wrongContextFactory: SlotHookFactory<'chain.context', UseTurnData> = + (_standard, _hookContext: number) => () => undefined + void _wrongContextFactory + // baked actions strip the draft parameter. acts.setDraft('x') // @ts-expect-error wrong payload type diff --git a/packages/client/ui-tool/tests/assembly-surfaces.spec.tsx b/packages/client/ui-tool/tests/assembly-surfaces.spec.tsx index 9cbdfe3f1d..8f4fb384b0 100644 --- a/packages/client/ui-tool/tests/assembly-surfaces.spec.tsx +++ b/packages/client/ui-tool/tests/assembly-surfaces.spec.tsx @@ -8,6 +8,7 @@ import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import { SlotTestRuntime, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' import { apply as applyConversation, inject as injectConversation } from '@deepseek-ai/dsh-client-ui-conversation/client' import { apply as applyTool, inject as injectTool } from '../src/client/apply.ts' +import { toolChatSnapshot } from './tool-details-render.tsx' // The service reads its initial locale from the browser; these specs assert // the shipped Chinese copy, so they state the browser they assume. @@ -74,7 +75,7 @@ async function bench(nodes: ToolResultNode[]) { await runtime.sessions.add({ id: SID, summary: { title: 'S', displayTitle: 'S', cwd: '/proj' }, - snapshot: { nodes }, + snapshot: { nodes, chat: toolChatSnapshot(nodes) }, session: { loadOlder: vi.fn(), prompt: vi.fn(async () => ({ ok: true, value: { accepted: true } })), diff --git a/packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx index 6c0bb956bd..3b70851a02 100644 --- a/packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx @@ -11,7 +11,9 @@ import { Context } from 'cordis' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render } from '@testing-library/react' -import { createSnapshotStore, SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import { + ConversationEventRegistry, ConversationViewRegistry, createSnapshotStore, SlotsService, +} from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolCallBlock, ToolResultNode, WorkspaceListState, @@ -21,6 +23,7 @@ import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import { apply as applyConversation, inject as injectConversation } from '@deepseek-ai/dsh-client-ui-conversation/client' import { apply as applyTool, inject as injectTool } from '../src/client/apply.ts' +import { toolChatSnapshot } from './tool-details-render.tsx' const SID = 's1' as SessionId @@ -75,7 +78,8 @@ function snapshotWith( const nestedNodes = nodes.map(node => ({ ...node, subCalls })) const nestedRunningCalls = runningCalls.map(call => ({ ...call, subCalls })) return { - sessionId: SID, nodes: nestedNodes, turnTimings: new Map(), turnEnds: new Map(), partial: null, + sessionId: SID, chat: toolChatSnapshot(nestedNodes, nestedRunningCalls), + nodes: nestedNodes, turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: nestedRunningCalls, pending: [], queue: [], running: runningCalls.length > 0, composerPhase: 'active', removed: false, openState: 'open', openError: null, @@ -97,6 +101,8 @@ async function bench(snapshot: ConversationSnapshot) { const ctx = new Context() const slotsFiber = ctx.plugin(SlotsService) await slotsFiber.await() + await ctx.plugin(ConversationEventRegistry).await() + await ctx.plugin(ConversationViewRegistry).await() const slots = ctx.get('slots') as SlotsService const session = createSnapshotStore(snapshot) diff --git a/packages/client/ui-tool/tests/diff-card.spec.tsx b/packages/client/ui-tool/tests/diff-card.spec.tsx index 7713bedaec..0cea47b572 100644 --- a/packages/client/ui-tool/tests/diff-card.spec.tsx +++ b/packages/client/ui-tool/tests/diff-card.spec.tsx @@ -20,7 +20,7 @@ import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/cli import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx' import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/DetailsPanel.tsx' import { FileMutationRow, fileMutationToolview } from '../src/client/tool/toolviews/file-mutation-row.tsx' -import { renderToolDetails, SessionProviderStub } from './tool-details-render.tsx' +import { renderToolDetails, SessionProviderStub, toolChatSnapshot } from './tool-details-render.tsx' import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' afterEach(cleanup) @@ -342,8 +342,11 @@ describe('DetailsPanel diff Output section', () => { } function snapshot(over: Partial = {}): ConversationSnapshot { + const nodes = over.nodes ?? [] + const runningCalls = over.runningCalls ?? [] return { - sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], + sessionId: SID, chat: over.chat ?? toolChatSnapshot(nodes, runningCalls), + nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, ...over, diff --git a/packages/client/ui-tool/tests/read-card.spec.tsx b/packages/client/ui-tool/tests/read-card.spec.tsx index 739a787a03..f74bbae6be 100644 --- a/packages/client/ui-tool/tests/read-card.spec.tsx +++ b/packages/client/ui-tool/tests/read-card.spec.tsx @@ -24,7 +24,7 @@ import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/t import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/DetailsPanel.tsx' import { ReadRow, readToolview } from '../src/client/tool/toolviews/read-row.tsx' -import { renderToolDetails, SessionProviderStub } from './tool-details-render.tsx' +import { renderToolDetails, SessionProviderStub, toolChatSnapshot } from './tool-details-render.tsx' afterEach(cleanup) @@ -288,8 +288,11 @@ describe('DetailsPanel Output section (read)', () => { } function snapshot(over: Partial = {}): ConversationSnapshot { + const nodes = over.nodes ?? [] + const runningCalls = over.runningCalls ?? [] return { - sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], + sessionId: SID, chat: over.chat ?? toolChatSnapshot(nodes, runningCalls), + nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, ...over, diff --git a/packages/client/ui-tool/tests/search-card.spec.tsx b/packages/client/ui-tool/tests/search-card.spec.tsx index 51cab2d440..0016318b90 100644 --- a/packages/client/ui-tool/tests/search-card.spec.tsx +++ b/packages/client/ui-tool/tests/search-card.spec.tsx @@ -23,7 +23,7 @@ import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/cli import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx' import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/DetailsPanel.tsx' import { SearchRow, searchToolview } from '../src/client/tool/toolviews/search-row.tsx' -import { renderToolDetails, SessionProviderStub } from './tool-details-render.tsx' +import { renderToolDetails, SessionProviderStub, toolChatSnapshot } from './tool-details-render.tsx' /** SearchRow now composes ToolRow, so its props include the locale `t` seat. */ type SearchRowProps = Parameters[0] @@ -404,8 +404,11 @@ describe('DetailsPanel Output section (search)', () => { } function snapshot(over: Partial = {}): ConversationSnapshot { + const nodes = over.nodes ?? [] + const runningCalls = over.runningCalls ?? [] return { - sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], + sessionId: SID, chat: over.chat ?? toolChatSnapshot(nodes, runningCalls), + nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, ...over, diff --git a/packages/client/ui-tool/tests/terminal-card.spec.tsx b/packages/client/ui-tool/tests/terminal-card.spec.tsx index c9e2875a34..0dcb59d818 100644 --- a/packages/client/ui-tool/tests/terminal-card.spec.tsx +++ b/packages/client/ui-tool/tests/terminal-card.spec.tsx @@ -20,7 +20,7 @@ import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/cli import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx' import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/DetailsPanel.tsx' import { BashRow } from '../src/client/tool/toolviews/bash-sample.tsx' -import { renderToolDetails, SessionProviderStub } from './tool-details-render.tsx' +import { renderToolDetails, SessionProviderStub, toolChatSnapshot } from './tool-details-render.tsx' import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' type BashRowProps = Parameters[0] @@ -479,8 +479,11 @@ describe('DetailsPanel Output section', () => { } function snapshot(over: Partial = {}): ConversationSnapshot { + const nodes = over.nodes ?? [] + const runningCalls = over.runningCalls ?? [] return { - sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], + sessionId: SID, chat: over.chat ?? toolChatSnapshot(nodes, runningCalls), + nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, ...over, diff --git a/packages/client/ui-tool/tests/tool-call-tree.spec.tsx b/packages/client/ui-tool/tests/tool-call-tree.spec.tsx index 393b9f8f46..44f4231802 100644 --- a/packages/client/ui-tool/tests/tool-call-tree.spec.tsx +++ b/packages/client/ui-tool/tests/tool-call-tree.spec.tsx @@ -29,12 +29,21 @@ function props( return { useSession, renderSlot, - callId: block.callId, - toolName: block.call?.name ?? '', - block, + node: { + key: `tool:${block.callId}`, + kind: 'tool-call', + id: block.callId, + target: 'chat', + anchorSeq: block.seq, + location: { kind: 'session' }, + visibility: 'visible', + data: { root: block }, + }, selectedCallId, openFile: vi.fn(), inspectCall: vi.fn(), + forkAt: vi.fn(), + fileMentions: vi.fn(), t, } as unknown as ToolTreeProps } diff --git a/packages/client/ui-tool/tests/tool-details-render.tsx b/packages/client/ui-tool/tests/tool-details-render.tsx index 0aeb3c5321..b2b2cd9ac7 100644 --- a/packages/client/ui-tool/tests/tool-details-render.tsx +++ b/packages/client/ui-tool/tests/tool-details-render.tsx @@ -1,5 +1,7 @@ /** Test adapter for the production conversation.details.tool registration. */ -import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { + ChatConversationViewNode, ChatSnapshot, ConversationNode, RunningToolCall, SessionId, +} from '@deepseek-ai/dsh-client-runtime/client' import type { SessionProviderComponent, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' import type { DetailsSlotProps, DetailsToolOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/src/client/contract/slots.ts' import { ToolDetails } from '../src/client/tool/ToolDetails.tsx' @@ -7,6 +9,45 @@ import { ToolDetails } from '../src/client/tool/ToolDetails.tsx' /** Framework session-area seat used by direct DetailsPanel tests. */ export const SessionProviderStub: SessionProviderComponent = ({ children }) => children('s1' as SessionId) +/** Build the canonical Chat slice consumed by Tool rows and details tests. */ +export function toolChatSnapshot( + settled: readonly ConversationNode[] = [], + running: readonly RunningToolCall[] = [], +): ChatSnapshot { + const roots = [...settled.filter(node => node.kind === 'tool-result'), ...running] + const nodes: ChatConversationViewNode[] = roots.map(root => ({ + key: `tool:${root.callId}`, + kind: 'tool-call', + id: root.callId, + target: 'chat', + anchorSeq: 'kind' in root ? root.seq : Number.MAX_SAFE_INTEGER, + location: { kind: 'session' }, + visibility: 'visible', + data: { root }, + })) + const byKey = new Map(nodes.map(node => [node.key, node])) + const empty: readonly string[] = [] + return { + order: nodes.map(node => node.key), + nodes: { + get: key => byKey.get(key), + values: () => nodes, + }, + locations: { + getTurn: () => empty, + getStep: () => empty, + }, + timeline: { turnOrder: [], turns: new Map() }, + legacy: { + nodes: settled, + runningCalls: running, + partial: null, + turnTimings: new Map(), + turnEnds: new Map(), + }, + } +} + /** * Bind ui-tool's details renderer to the conversation slot callback shape. * @param t - conversation locale seat used by Tool cards. @@ -16,7 +57,7 @@ export function renderToolDetails(t: TranslateNS<'conversation'>): DetailsSlotPr return (_key, owner) => { // PropsRenderSlots keeps its key generic even for this one-key share; // recover the concrete owner selected by the adapter's fixed slot. - const details = owner as DetailsToolOwnerProps + const details = owner as unknown as DetailsToolOwnerProps return } } diff --git a/packages/client/ui-tool/tests/toolview-slot.spec.tsx b/packages/client/ui-tool/tests/toolview-slot.spec.tsx index c927cc30ea..f1a9df3392 100644 --- a/packages/client/ui-tool/tests/toolview-slot.spec.tsx +++ b/packages/client/ui-tool/tests/toolview-slot.spec.tsx @@ -18,6 +18,7 @@ import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import { apply as applyConversation, inject as injectConversation } from '@deepseek-ai/dsh-client-ui-conversation/client' import { apply as applyTool, inject as injectTool } from '@deepseek-ai/dsh-client-ui-tool/client' import type { ToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client' +import { toolChatSnapshot } from './tool-details-render.tsx' const SID = 's1' as SessionId @@ -71,7 +72,7 @@ async function bench(nodes: ToolResultNode[]) { await runtime.sessions.add({ id: SID, summary: { title: 'S', displayTitle: 'S' }, - snapshot: { nodes }, + snapshot: { nodes, chat: toolChatSnapshot(nodes) }, session: { loadOlder: vi.fn(), prompt: vi.fn(async () => ({ ok: true, value: { accepted: true } })), diff --git a/packages/client/ui-tool/tests/web-card.spec.tsx b/packages/client/ui-tool/tests/web-card.spec.tsx index 7856ae9d6f..44f9e05090 100644 --- a/packages/client/ui-tool/tests/web-card.spec.tsx +++ b/packages/client/ui-tool/tests/web-card.spec.tsx @@ -23,7 +23,7 @@ import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/cli import { GenericToolCard } from '../src/client/tool/toolviews/GenericToolCard.tsx' import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/DetailsPanel.tsx' import { WebRow, webToolview } from '../src/client/tool/toolviews/web-row.tsx' -import { renderToolDetails, SessionProviderStub } from './tool-details-render.tsx' +import { renderToolDetails, SessionProviderStub, toolChatSnapshot } from './tool-details-render.tsx' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' @@ -234,8 +234,11 @@ describe('DetailsPanel web Output section', () => { } function snapshot(over: Partial = {}): ConversationSnapshot { + const nodes = over.nodes ?? [] + const runningCalls = over.runningCalls ?? [] return { - sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], + sessionId: SID, chat: over.chat ?? toolChatSnapshot(nodes, runningCalls), + nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, ...over, diff --git a/packages/client/web-react/tests/scoped-slots.spec.tsx b/packages/client/web-react/tests/scoped-slots.spec.tsx index 16334ff7c7..0688d409b9 100644 --- a/packages/client/web-react/tests/scoped-slots.spec.tsx +++ b/packages/client/web-react/tests/scoped-slots.spec.tsx @@ -90,6 +90,7 @@ function makeHost() { const provide = observable(absentInfo) let currentId: string | undefined const infos = new Map() + const sessionSources = new Map>>() const bump = (key: string) => { versions.set(key, (versions.get(key) ?? 0) + 1) @@ -160,17 +161,24 @@ function makeHost() { bump(key) } }, - addSession: (id: string): SessionProvideInfo => { + addSession: (id: string, initial: unknown = { sid: id }): SessionProvideInfo => { // Bare source per bundle (identity-stable): the machinery binds useSession from it. + const session = observable(initial) const info: SessionProvideInfo = { sessionId: id, - hooks: { session: { getSnapshot: () => ({ sid: id }), subscribe: () => () => {} } }, + hooks: { session }, props: {}, } + sessionSources.set(id, session) infos.set(id, info) if (currentId === id) provide.set(info) return info }, + setSession: (id: string, snapshot: unknown) => { + const source = sessionSources.get(id) + if (source === undefined) throw new Error(`unknown test session: ${id}`) + source.set(snapshot) + }, } } @@ -635,6 +643,71 @@ describe('standard-kit synthesis', () => { expect(props['sessionId']).toBe('s1') }) + it('binds only function-valued inject hooks to the standard kit and render occurrence context', () => { + const h = makeHost() + const turnDataFactory = vi.fn((standard: AnyProps, turn: unknown) => (key: string) => { + const useSession = standard['useSession'] as (selector: (snapshot: unknown) => unknown) => unknown + return useSession((snapshot) => { + const value = snapshot as { turns: Record> } + return value.turns[turn as number]?.[key] + }) + }) + const sessionSpec: DeclaredSpec = { + kind: 'single', scope: 'session', inject: { hooks: { turnData: turnDataFactory } }, + } + h.declare('k.session', sessionSpec) + h.addSession('s1', { + turns: { 1: { tail: 'one' }, 2: { tail: 'two' } }, + unrelated: 0, + }) + const hooks = new Map unknown>>() + let renders = 0 + h.add('k.session', { + component: ({ label, useTurnData }: { label: string; useTurnData: (key: string) => unknown }) => { + renders += 1 + const seen = hooks.get(label) ?? [] + seen.push(useTurnData) + hooks.set(label, seen) + return {String(useTurnData('tail'))} + }, + }) + const { view } = mountRoot(h, { 'k.session': sessionSpec }, renderSlot => ( + {() => <> + {renderSlot('k.session', { label: 'one' }, { hookContext: 1 })} + {renderSlot('k.session', { label: 'two' }, { hookContext: 2 })} + } + + )) + act(() => { h.current.set('s1') }) + expect(view.container.textContent).toBe('onetwo') + expect(renders).toBe(2) + + // A session publication whose selected contextual value is identical is + // filtered by the framework-bound selector. + act(() => { + h.setSession('s1', { + turns: { 1: { tail: 'one' }, 2: { tail: 'two' } }, + unrelated: 1, + }) + }) + expect(renders).toBe(2) + + // Updating the selected contextual value re-renders through the returned + // custom Hook; the factory itself and its Hook identity stay stable. + act(() => { + h.setSession('s1', { + turns: { 1: { tail: 'updated' }, 2: { tail: 'two' } }, + unrelated: 1, + }) + }) + expect(view.container.textContent).toBe('updatedtwo') + expect(renders).toBe(3) + expect(hooks.get('one')![1]).toBe(hooks.get('one')![0]) + expect(hooks.get('two')).toHaveLength(1) + expect(hooks.get('one')![0]).not.toBe(hooks.get('two')![0]) + expect(turnDataFactory).toHaveBeenCalledTimes(2) + }) + it('hands the SessionProvider seat to entries declaring a session-scope child', () => { const h = makeHost() h.declare('k.session', SINGLE_SESSION) diff --git a/packages/compact/command-compact/tests/command-compact.spec.ts b/packages/compact/command-compact/tests/command-compact.spec.ts index 6922778a26..0c419d6799 100644 --- a/packages/compact/command-compact/tests/command-compact.spec.ts +++ b/packages/compact/command-compact/tests/command-compact.spec.ts @@ -4,6 +4,7 @@ import Loader from '@cordisjs/plugin-loader' import type { Agent } from '@deepseek-ai/dsh-agent' import CommandService, { type CommandResult } from '@deepseek-ai/dsh-commands' import { + CompactionId, CompactService, ManualCompactionError, type CompactAgentContext, @@ -14,7 +15,10 @@ import { import { Session, SessionId } from '@deepseek-ai/dsh-session' import * as commandCompact from '@deepseek-ai/dsh-command-compact' +const COMPACTION_ID = CompactionId('command-compact-test') + const RESULT: CompactionResult = { + compactionId: COMPACTION_ID, startSeq: 1, summarySeq: 2, endSeq: 3, @@ -45,18 +49,28 @@ class StubCompactService extends CompactService { override compactNow( agent: ManualCompactAgentContext, signal: AbortSignal, + sourceCommandId?: Parameters[2], ): Promise { this.calls.push({ agent, signal }) if (this.operation !== undefined) return this.operation() return this.failure === undefined - ? Promise.resolve(this.result === null ? null : this.appendResult(agent, this.result)) + ? Promise.resolve(this.result === null ? null : this.appendResult(agent, this.result, sourceCommandId)) // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- exercise arbitrary backend rejection values. : Promise.reject(this.failure) } - private appendResult(agent: ManualCompactAgentContext, result: CompactionResult): CompactionResult { - agent.session.append('compact/start', { turn: null }) + private appendResult( + agent: ManualCompactAgentContext, + result: CompactionResult, + sourceCommandId: Parameters[2], + ): CompactionResult { + const provenance = { + compactionId: result.compactionId, + ...sourceCommandId === undefined ? {} : { sourceCommandId }, + } + agent.session.append('compact/start', { ...provenance, turn: null }) agent.session.append('compact/summary', { + ...provenance, summary: result.summary, shadowedRange: result.shadowedRange, shadowedSeqs: result.shadowedSeqs, @@ -64,8 +78,8 @@ class StubCompactService extends CompactService { provider: 'command-test', model: 'command-test', }) - agent.session.append('compact/end', { turn: null }) - return result + agent.session.append('compact/end', { ...provenance, turn: null }) + return { ...result, ...provenance } } } diff --git a/packages/compact/command-compact/tests/loader-composition.spec.ts b/packages/compact/command-compact/tests/loader-composition.spec.ts index 5a5d37d8b1..5f7fd0b346 100644 --- a/packages/compact/command-compact/tests/loader-composition.spec.ts +++ b/packages/compact/command-compact/tests/loader-composition.spec.ts @@ -9,6 +9,7 @@ import Include from '@cordisjs/plugin-include' import type { Agent } from '@deepseek-ai/dsh-agent' import CommandService from '@deepseek-ai/dsh-commands' import { + CompactionId, CompactService, type CompactAgentContext, type CompactionResult, @@ -18,7 +19,10 @@ import { import * as commandCompact from '@deepseek-ai/dsh-command-compact' import { Session, SessionId } from '@deepseek-ai/dsh-session' +const COMPACTION_ID = CompactionId('loader-command-compact-test') + const RESULT: CompactionResult = { + compactionId: COMPACTION_ID, startSeq: 1, summarySeq: 2, endSeq: 3, @@ -44,9 +48,15 @@ class LoaderCompactService extends CompactService { override compactNow( agent: ManualCompactAgentContext, _signal: AbortSignal, + sourceCommandId?: Parameters[2], ): Promise { - agent.session.append('compact/start', { turn: null }) + const provenance = { + compactionId: RESULT.compactionId, + ...sourceCommandId === undefined ? {} : { sourceCommandId }, + } + agent.session.append('compact/start', { ...provenance, turn: null }) agent.session.append('compact/summary', { + ...provenance, summary: RESULT.summary, shadowedRange: RESULT.shadowedRange, shadowedSeqs: RESULT.shadowedSeqs, @@ -54,8 +64,8 @@ class LoaderCompactService extends CompactService { provider: 'loader-test', model: 'loader-test', }) - agent.session.append('compact/end', { turn: null }) - return Promise.resolve(RESULT) + agent.session.append('compact/end', { ...provenance, turn: null }) + return Promise.resolve({ ...RESULT, ...provenance }) } } @@ -132,11 +142,17 @@ describe('command-compact real Loader composition', () => { }, { type: 'compact/start', - data: { turn: null }, + data: { + compactionId: COMPACTION_ID, + sourceCommandId: execution.commandId, + turn: null, + }, }, { type: 'compact/summary', data: { + compactionId: COMPACTION_ID, + sourceCommandId: execution.commandId, summary: RESULT.summary, shadowedRange: RESULT.shadowedRange, shadowedSeqs: RESULT.shadowedSeqs, @@ -147,7 +163,11 @@ describe('command-compact real Loader composition', () => { }, { type: 'compact/end', - data: { turn: null }, + data: { + compactionId: COMPACTION_ID, + sourceCommandId: execution.commandId, + turn: null, + }, }, { type: 'command/done', diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 996d5d61e1..f97aaf52b9 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -4,7 +4,7 @@ import BasicCompactService from '@deepseek-ai/dsh-compact-basic' import type { BasicCompactConfig } from '@deepseek-ai/dsh-compact-basic' import { selectCompactableRange } from '@deepseek-ai/dsh-compact-basic/src/region.ts' import type { SummarizationInput, SummaryResult } from '@deepseek-ai/dsh-compact-basic/src/summarizer.ts' -import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact' +import { CompactionId, toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact' import { resolveCompactSpec, resolveConfig, @@ -945,7 +945,10 @@ describe('compaction region transaction', () => { )).rejects.toThrow(/no open turn/) const locked = conversation(1) - locked.append('compact/start', { turn: 2 }) + locked.append('compact/start', { + compactionId: CompactionId('locked-compaction'), + turn: 2, + }) const lockedNodes = locked.surface.nodes await expect(compact.compactRegion( lockedNodes[0]!, @@ -1623,6 +1626,7 @@ describe('automatic listener and loader composition', () => { const compact = new TestCompactService(ctx) const session = conversation(2) const fakeResult: CompactionResult = { + compactionId: CompactionId('fake-compaction'), startSeq: 1, summarySeq: 2, endSeq: 3, diff --git a/packages/compact/compact-basic/tests/manual-compact.spec.ts b/packages/compact/compact-basic/tests/manual-compact.spec.ts index 7544c3223b..0599d3f364 100644 --- a/packages/compact/compact-basic/tests/manual-compact.spec.ts +++ b/packages/compact/compact-basic/tests/manual-compact.spec.ts @@ -3,13 +3,14 @@ import { Context } from 'cordis' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import InvariantService from '@deepseek-ai/dsh-invariants' +import { CommandId } from '@deepseek-ai/dsh-commands/brand' import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant' import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant' import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant' import * as CompactInvariant from '@deepseek-ai/dsh-compact/invariant' import * as CompactBasicInvariant from '@deepseek-ai/dsh-compact-basic/invariant' import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic' -import { isCompactCheckpointSource, ManualCompactionError } from '@deepseek-ai/dsh-compact' +import { CompactionId, isCompactCheckpointSource, ManualCompactionError } from '@deepseek-ai/dsh-compact' import type { CompactionResult } from '@deepseek-ai/dsh-compact' import { createAssistantMessage, @@ -23,7 +24,7 @@ import type { StreamChunk, TokenUsage, } from '@deepseek-ai/dsh-llm' -import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import LlmService from '@deepseek-ai/dsh-llm' import TokenMeterService from '@deepseek-ai/dsh-token-meter' import type { Agent } from '@deepseek-ai/dsh-agent' @@ -394,22 +395,35 @@ describe('compactNow transaction and failure classification', () => { const { compact, flushes } = detachedService() const session = closedConversation(2, 7) const agent = fakeAgent(session, () => () => undefined) + const commandId = CommandId('manual-compact-command') - const result = await compact.compactNow(agent, SIGNAL) + const result = await compact.compactNow(agent, SIGNAL, commandId) expect(result).not.toBeNull() + expect(result?.sourceCommandId).toBe(commandId) expect(flushes()).toBe(1) expect(session.events.filter(event => event.type === 'turn/start').at(-1)?.data.turn).toBe(7) - expect(session.events.findLast(event => event.type === 'compact/start')?.data) - .toEqual({ turn: null }) - expect(session.events.findLast(event => event.type === 'compact/end')?.data) - .toEqual({ turn: null }) + const start = session.events.findLast(event => event.type === 'compact/start') + const summaryEvent = session.events.findLast(event => event.type === 'compact/summary') + const checkpoint = session.events.findLast( + (event): event is SessionEvent<'user/message'> => event.type === 'user/message' + && isCompactCheckpointSource(event.data.source), + ) + const end = session.events.findLast(event => event.type === 'compact/end') + const correlated = { compactionId: result?.compactionId, sourceCommandId: commandId } + expect(start?.data).toEqual({ ...correlated, turn: null }) + expect(summaryEvent?.data.sourceCommandId).toBe(commandId) + expect(checkpoint?.data.source).toMatchObject(correlated) + expect(end?.data).toEqual({ ...correlated, turn: null }) }) it('reports a live unmatched bracket as busy without summarizing', async () => { const { compact } = detachedService() const session = closedConversation(2) - session.append('compact/start', { turn: null }) + session.append('compact/start', { + compactionId: CompactionId('live-manual-compaction'), + turn: null, + }) const agent = fakeAgent(session, () => () => undefined) const error = await rejection(() => compact.compactNow(agent, SIGNAL)) @@ -421,7 +435,10 @@ describe('compactNow transaction and failure classification', () => { it('ignores an unmatched bracket inherited before a later end-seed marker', async () => { const { compact } = detachedService() const original = closedConversation(2) - original.append('compact/start', { turn: null }) + original.append('compact/start', { + compactionId: CompactionId('stale-manual-compaction'), + turn: null, + }) const reloaded = Session.create(SessionId('stale-orphan'), [...original.events]) const boundary = reloaded.events.findLast(event => event.type === 'session/end-seed') const orphan = reloaded.events.find(event => event.type === 'compact/start') @@ -435,7 +452,10 @@ describe('compactNow transaction and failure classification', () => { it('scans a stale orphan independently of later repaired turn state', async () => { const { compact } = detachedService() const original = closedConversation(2) - original.append('compact/start', { turn: null }) + original.append('compact/start', { + compactionId: CompactionId('reloaded-manual-compaction'), + turn: null, + }) original.append('turn/start', { turn: 3 }) original.append('turn/end', { turn: 3, reason: { kind: 'interrupted' } }) const reloaded = Session.create(SessionId('reloaded-orphan'), [...original.events]) @@ -664,7 +684,7 @@ describe('compactNow transaction and failure classification', () => { expect(result).not.toBeNull() expect(session.events.some(event => event.type === 'turn/start')).toBe(false) expect(session.events.find(event => event.type === 'compact/start')?.data) - .toEqual({ turn: null }) + .toEqual({ compactionId: result?.compactionId, turn: null }) }) it('classifies a durability failure after the standalone bracket committed', async () => { @@ -676,8 +696,9 @@ describe('compactNow transaction and failure classification', () => { expect((await rejection(compact.compactNow(agent, SIGNAL))).code).toBe('persistence') vi.restoreAllMocks() expect(session.events.some(event => event.type === 'compact/summary')).toBe(true) - expect(session.events.findLast(event => event.type === 'compact/end')?.data) - .toEqual({ turn: null }) + const start = session.events.findLast(event => event.type === 'compact/start') + const end = session.events.findLast(event => event.type === 'compact/end') + expect(end?.data).toEqual({ compactionId: start?.data.compactionId, turn: null }) }) it('lets a pre-aborted signal win before reservation, measurement, or summarization', async () => { diff --git a/packages/compact/compact/tests/compact.spec.ts b/packages/compact/compact/tests/compact.spec.ts index 7e6915354f..74e2bd0270 100644 --- a/packages/compact/compact/tests/compact.spec.ts +++ b/packages/compact/compact/tests/compact.spec.ts @@ -2,8 +2,9 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { - COMPACT_CHECKPOINT_SOURCE, + CompactionId, CompactService, + compactCheckpointSource, isCompactCheckpointSource, } from '@deepseek-ai/dsh-compact' import type { CompactionResult, CompactionTrigger } from '@deepseek-ai/dsh-compact' @@ -52,9 +53,11 @@ class StubCompactService extends CompactService { const endIndex = surface.indexOf(end) if (startIndex < 0 || endIndex < startIndex) throw new Error('stub compact range is invalid') const shadowedSeqs = surface.slice(startIndex, endIndex + 1) + const compactionId = CompactionId('stub-compaction') // Minimal stub honoring the lock + log-only event contract. - const startEvent = session.append('compact/start', { turn: 0 }) + const startEvent = session.append('compact/start', { compactionId, turn: 0 }) const summaryEvent = session.append('compact/summary', { + compactionId, summary, shadowedRange: { start, end }, shadowedSeqs, @@ -64,13 +67,14 @@ class StubCompactService extends CompactService { }) session.append('user/message', createUserMessage({ content: summary, - source: COMPACT_CHECKPOINT_SOURCE, + source: compactCheckpointSource(compactionId), }), { surfaceOp: { op: 'replace', start, end }, sourceEventSeqs: [startEvent.seq, summaryEvent.seq, ...shadowedSeqs], }) - const endEvent = session.append('compact/end', { turn: 0 }) + const endEvent = session.append('compact/end', { compactionId, turn: 0 }) return { + compactionId, startSeq: startEvent.seq, summarySeq: summaryEvent.seq, endSeq: endEvent.seq, @@ -139,7 +143,8 @@ describe('CompactService seam', () => { expect(result.shadowedSeqs).toEqual([original.seq]) const checkpoint = session.events.find(event => event.type === 'user/message' && isCompactCheckpointSource(event.data.source)) - expect(checkpoint?.type === 'user/message' && checkpoint.data.source).toEqual(COMPACT_CHECKPOINT_SOURCE) + expect(checkpoint?.type === 'user/message' && checkpoint.data.source) + .toEqual(compactCheckpointSource(result.compactionId)) expect(isCompactCheckpointSource({ kind: 'plugin', plugin: 'other' })).toBe(false) expect(isCompactCheckpointSource({ kind: 'user' })).toBe(false) expect(session.events.filter(e => e.type.startsWith('compact/')).map(e => e.type)) diff --git a/packages/compact/compact/tests/invariant.spec.ts b/packages/compact/compact/tests/invariant.spec.ts index c5a68c9de6..1637c16179 100644 --- a/packages/compact/compact/tests/invariant.spec.ts +++ b/packages/compact/compact/tests/invariant.spec.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import { CompactionId, compactCheckpointSource } from '@deepseek-ai/dsh-compact' import * as CompactInvariant from '@deepseek-ai/dsh-compact/invariant' +import { CommandId } from '@deepseek-ai/dsh-commands/brand' import InvariantService from '@deepseek-ai/dsh-invariants' async function setup(): Promise { @@ -12,7 +15,13 @@ async function setup(): Promise { return ctx } +const TEST_COMPACTION_ID = CompactionId('test-compaction') +const NEXT_COMPACTION_ID = CompactionId('next-test-compaction') +const TEST_COMMAND_ID = CommandId('test-command') +const NEXT_COMMAND_ID = CommandId('next-test-command') + const summary = (overrides: Record = {}) => ({ + compactionId: TEST_COMPACTION_ID, summary: [{ type: 'text' as const, text: 'short' }], shadowedRange: { start: 2, end: 4 }, shadowedSeqs: [2, 3, 4], @@ -31,33 +40,33 @@ describe('compaction invariants', () => { const ctx = await setup() const success = ctx.sessions.create() startTurn(success) - success.append('compact/start', { turn: 1 }) + success.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 }) success.append('compact/summary', summary()) - success.append('compact/end', { turn: 1 }) + success.append('compact/end', { compactionId: TEST_COMPACTION_ID, turn: 1 }) const failed = ctx.sessions.create() startTurn(failed, 2) - failed.append('compact/start', { turn: 2 }) - failed.append('compact/end', { turn: 2, error: 'provider failed' }) + failed.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 2 }) + failed.append('compact/end', { compactionId: TEST_COMPACTION_ID, turn: 2, error: 'provider failed' }) }) it('accepts standalone successful and failed compaction lifecycles between turns', async () => { const ctx = await setup() const success = ctx.sessions.create() - success.append('compact/start', { turn: null }) + success.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: null }) success.append('compact/summary', summary()) - success.append('compact/end', { turn: null }) + success.append('compact/end', { compactionId: TEST_COMPACTION_ID, turn: null }) const failed = ctx.sessions.create() - failed.append('compact/start', { turn: null }) - failed.append('compact/end', { turn: null, error: 'provider failed' }) + failed.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: null }) + failed.append('compact/end', { compactionId: TEST_COMPACTION_ID, turn: null, error: 'provider failed' }) }) it('clears an inherited open compaction trace at end-seed during replay', async () => { const ctx = new Context() await ctx.plugin(SessionStore) const source = Session.create(SessionId('stale-compaction-source')) - source.append('compact/start', { turn: null }) + source.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: null }) const replayed = ctx.sessions.create(SessionId('stale-compaction-replay'), { seed: source.events, }) @@ -68,8 +77,8 @@ describe('compaction invariants', () => { await ctx.plugin(CompactInvariant) expect(() => { - replayed.append('compact/start', { turn: null }) - replayed.append('compact/end', { turn: null, error: 'new attempt failed' }) + replayed.append('compact/start', { compactionId: NEXT_COMPACTION_ID, turn: null }) + replayed.append('compact/end', { compactionId: NEXT_COMPACTION_ID, turn: null, error: 'new attempt failed' }) }).not.toThrow() }) @@ -78,7 +87,7 @@ describe('compaction invariants', () => { await ctx.plugin(SessionStore) const source = Session.create(SessionId('stale-numbered-compaction-source')) startTurn(source) - source.append('compact/start', { turn: 1 }) + source.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 }) const replayed = ctx.sessions.create(SessionId('stale-numbered-compaction-replay'), { seed: source.events, }) @@ -98,7 +107,7 @@ describe('compaction invariants', () => { const ctx = new Context() await ctx.plugin(SessionStore) const source = Session.create(SessionId('stale-repaired-compaction-source')) - source.append('compact/start', { turn: null }) + source.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: null }) startTurn(source) source.append('turn/end', { turn: 1, reason: { kind: 'interrupted' } }) const replayed = ctx.sessions.create(SessionId('stale-repaired-compaction-replay'), { @@ -124,10 +133,10 @@ describe('compaction invariants', () => { const ctx = new Context() await ctx.plugin(SessionStore) const source = Session.create(SessionId('closed-nested-compaction-source')) - source.append('compact/start', { turn: null }) + source.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: null }) startTurn(source) source.append('turn/end', { turn: 1, reason: { kind: 'interrupted' } }) - source.append('compact/end', { turn: null, error: 'failed after crossing turn' }) + source.append('compact/end', { compactionId: TEST_COMPACTION_ID, turn: null, error: 'failed after crossing turn' }) const replayed = ctx.sessions.create(SessionId('closed-nested-compaction-replay'), { seed: source.events, }) @@ -143,10 +152,14 @@ describe('compaction invariants', () => { await ctx.plugin(SessionStore) const session = ctx.sessions.create() session.append('turn/start', { turn: 1 }) - session.append('compact/start', { turn: 1 }) + session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 }) await ctx.plugin(InvariantService) await ctx.plugin(CompactInvariant) - expect(() => session.append('compact/end', { turn: 1, error: 'resume failed' })).not.toThrow() + expect(() => session.append('compact/end', { + compactionId: TEST_COMPACTION_ID, + turn: 1, + error: 'resume failed', + })).not.toThrow() session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) }) @@ -162,7 +175,8 @@ describe('compaction invariants', () => { type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 }, }) ctx.emit('session/event', session, { - type: 'compact/start', seq: 2, time: 2, data: { turn: 1 }, + type: 'compact/start', seq: 2, time: 2, + data: { compactionId: TEST_COMPACTION_ID, turn: 1 }, }) }).not.toThrow() }) @@ -170,28 +184,30 @@ describe('compaction invariants', () => { it('rejects compaction outside or for a different open turn', async () => { const ctx = await setup() const session = ctx.sessions.create() - expect(() => session.append('compact/start', { turn: 1 })).toThrow(/outside any open turn/) + expect(() => session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 })) + .toThrow(/outside any open turn/) startTurn(session) - expect(() => session.append('compact/start', { turn: 2 })).toThrow(/but open turn is 1/) + expect(() => session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 2 })) + .toThrow(/but open turn is 1/) }) it('rejects a standalone bracket while a turn is open and a numbered bracket between turns', async () => { const ctx = await setup() const open = ctx.sessions.create() startTurn(open) - expect(() => open.append('compact/start', { turn: null })) + expect(() => open.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: null })) .toThrow(/standalone but turn 1 is open/) const idle = ctx.sessions.create() - expect(() => idle.append('compact/start', { turn: 1 })) + expect(() => idle.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 })) .toThrow(/outside any open turn/) }) it('attributes a nested standalone start to the standalone owner', async () => { const ctx = await setup() const session = ctx.sessions.create() - session.append('compact/start', { turn: null }) - expect(() => session.append('compact/start', { turn: null })) + session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: null }) + expect(() => session.append('compact/start', { compactionId: NEXT_COMPACTION_ID, turn: null })) .toThrow(/standalone compaction is still compacting/) }) @@ -201,7 +217,7 @@ describe('compaction invariants', () => { const session = ctx.sessions.create() startTurn(session) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - session.append('compact/start', { turn: 1 }) + session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 }) await ctx.plugin(InvariantService) await expect(ctx.plugin(CompactInvariant).then(() => undefined)).rejects.toThrow(/outside any open turn/) }) @@ -209,10 +225,10 @@ describe('compaction invariants', () => { it('rejects turn boundaries that cross live standalone or numbered compaction brackets', async () => { const ctx = await setup() const standalone = ctx.sessions.create() - standalone.append('compact/start', { turn: null }) + standalone.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: null }) expect(() => { startTurn(standalone) }) .toThrow(/turn\/start cannot cross an open standalone compaction/) - standalone.append('compact/end', { turn: null, error: 'cancelled' }) + standalone.append('compact/end', { compactionId: TEST_COMPACTION_ID, turn: null, error: 'cancelled' }) expect(() => { startTurn(standalone) standalone.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) @@ -220,53 +236,151 @@ describe('compaction invariants', () => { const numbered = ctx.sessions.create() startTurn(numbered) - numbered.append('compact/start', { turn: 1 }) + numbered.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 }) expect(() => numbered.append( 'turn/end', { turn: 1, reason: { kind: 'completed' } }, )).toThrow(/turn\/end cannot cross an open compaction for turn 1/) - numbered.append('compact/end', { turn: 1, error: 'cancelled' }) + numbered.append('compact/end', { compactionId: TEST_COMPACTION_ID, turn: 1, error: 'cancelled' }) expect(() => numbered.append( 'turn/end', { turn: 1, reason: { kind: 'completed' } }, )).not.toThrow() }) + it('rejects a replacement checkpoint for another compaction transaction', async () => { + const ctx = await setup() + const session = ctx.sessions.create() + const original = session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'original' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + startTurn(session) + session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 }) + session.append('compact/summary', summary()) + + expect(() => session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'checkpoint' }], + source: compactCheckpointSource(NEXT_COMPACTION_ID), + }), { + surfaceOp: { op: 'replace', start: original.seq, end: original.seq }, + sourceEventSeqs: [original.seq], + })).toThrow(/compaction checkpoint id .* does not match compact\/start id/) + }) + + it('requires checkpoint provenance to name an open transaction', async () => { + const ctx = await setup() + const withoutStart = ctx.sessions.create() + const original = withoutStart.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'original' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + expect(() => withoutStart.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'checkpoint' }], + source: compactCheckpointSource(TEST_COMPACTION_ID), + }), { + surfaceOp: { op: 'replace', start: original.seq, end: original.seq }, + sourceEventSeqs: [original.seq], + })).toThrow(/no matching compact\/start/) + + const emptyCommand = ctx.sessions.create() + const replaced = emptyCommand.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'original' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + startTurn(emptyCommand) + emptyCommand.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 }) + expect(() => emptyCommand.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'checkpoint' }], + source: compactCheckpointSource(TEST_COMPACTION_ID, CommandId('')), + }), { + surfaceOp: { op: 'replace', start: replaced.seq, end: replaced.seq }, + sourceEventSeqs: [replaced.seq], + })).toThrow(/checkpoint sourceCommandId must be a non-empty string/) + }) + it.each([ + ['empty start id', (session: ReturnType) => { + session.append('compact/start', { compactionId: CompactionId(''), turn: 1 }) + }, /compact\/start compactionId must be a non-empty string/], + ['empty start source command id', (session: ReturnType) => { + session.append('compact/start', { + compactionId: TEST_COMPACTION_ID, + sourceCommandId: CommandId(''), + turn: 1, + }) + }, /compact\/start sourceCommandId must be a non-empty string/], ['summary without start', (session: ReturnType) => { session.append('compact/summary', summary()) }, /no matching compact\/start/], ['nested start', (session: ReturnType) => { - session.append('compact/start', { turn: 1 }) - session.append('compact/start', { turn: 2 }) + session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 }) + session.append('compact/start', { compactionId: NEXT_COMPACTION_ID, turn: 2 }) }, /still compacting/], ['repeated summary', (session: ReturnType) => { - session.append('compact/start', { turn: 1 }) + session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 }) session.append('compact/summary', summary()) session.append('compact/summary', summary()) }, /repeated within one compaction/], + ['summary for another compaction', (session: ReturnType) => { + session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 }) + session.append('compact/summary', summary({ compactionId: NEXT_COMPACTION_ID })) + }, /compact\/summary id .* does not match compact\/start id/], + ['summary for another source command', (session: ReturnType) => { + session.append('compact/start', { + compactionId: TEST_COMPACTION_ID, + sourceCommandId: TEST_COMMAND_ID, + turn: 1, + }) + session.append('compact/summary', summary({ sourceCommandId: NEXT_COMMAND_ID })) + }, /compact\/summary sourceCommandId .* does not match compact\/start sourceCommandId/], ['empty shadow set', (session: ReturnType) => { - session.append('compact/start', { turn: 1 }) + session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 }) session.append('compact/summary', summary({ shadowedSeqs: [] })) }, /shadowedSeqs must be non-empty/], ['wrong endpoints', (session: ReturnType) => { - session.append('compact/start', { turn: 1 }) + session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 }) session.append('compact/summary', summary({ shadowedRange: { start: 1, end: 4 } })) }, /shadowedRange must match/], ['invalid token count', (session: ReturnType) => { - session.append('compact/start', { turn: 1 }) + session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 }) session.append('compact/summary', summary({ shadowedTokenCount: -1 })) }, /non-negative safe integer/], ['end without start', (session: ReturnType) => { - session.append('compact/end', { turn: 1, error: 'failed' }) + session.append('compact/end', { compactionId: TEST_COMPACTION_ID, turn: 1, error: 'failed' }) }, /no matching compact\/start/], ['wrong end turn', (session: ReturnType) => { - session.append('compact/start', { turn: 1 }) - session.append('compact/end', { turn: 2, error: 'failed' }) + session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 }) + session.append('compact/end', { compactionId: TEST_COMPACTION_ID, turn: 2, error: 'failed' }) }, /does not match/], + ['end for another compaction', (session: ReturnType) => { + session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 }) + session.append('compact/end', { compactionId: NEXT_COMPACTION_ID, turn: 1, error: 'failed' }) + }, /compact\/end id .* does not match compact\/start id/], + ['end missing the source command', (session: ReturnType) => { + session.append('compact/start', { + compactionId: TEST_COMPACTION_ID, + sourceCommandId: TEST_COMMAND_ID, + turn: 1, + }) + session.append('compact/end', { compactionId: TEST_COMPACTION_ID, turn: 1, error: 'failed' }) + }, /compact\/end sourceCommandId .* does not match compact\/start sourceCommandId/], + ['empty end source command id', (session: ReturnType) => { + session.append('compact/start', { + compactionId: TEST_COMPACTION_ID, + sourceCommandId: TEST_COMMAND_ID, + turn: 1, + }) + session.append('compact/end', { + compactionId: TEST_COMPACTION_ID, + sourceCommandId: CommandId(''), + turn: 1, + error: 'failed', + }) + }, /compact\/end sourceCommandId must be a non-empty string/], ['success without summary', (session: ReturnType) => { - session.append('compact/start', { turn: 1 }) - session.append('compact/end', { turn: 1 }) + session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 }) + session.append('compact/end', { compactionId: TEST_COMPACTION_ID, turn: 1 }) }, /requires one compact\/summary/], ])('rejects %s', async (_name, action, message) => { const ctx = await setup() diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index fcdcd7c422..585397d861 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -196,11 +196,15 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent { } function stubToolExecution( - input: Omit & { token?: ToolExecutionToken }, + input: Omit & { + token?: ToolExecutionToken + rootCallId?: ToolExecution['rootCallId'] + }, ): ToolExecution { return { token: input.token ?? Symbol('workspace-context-test-execution') as ToolExecutionToken, ...input, + rootCallId: input.rootCallId ?? input.callId, } } diff --git a/packages/core/tools/tests/code-mode.spec.ts b/packages/core/tools/tests/code-mode.spec.ts index 06993467e8..2f3454b03c 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -785,11 +785,11 @@ describe('the run_code dispatch bridge', () => { const dispatches = events.filter(event => event.type === 'tool/code-dispatch') expect(dispatches.map(event => event.data)).toEqual([ { - parentCallId: 'call-1', subCallId: 'call-1:code:1', name: 'echo', + rootCallId: 'call-1', parentCallId: 'call-1', subCallId: 'call-1:code:1', name: 'echo', arguments: { value: 'one' }, isError: false, content: [{ type: 'text', text: 'echo:one' }], }, { - parentCallId: 'call-1', subCallId: 'call-1:code:2', name: 'echo', + rootCallId: 'call-1', parentCallId: 'call-1', subCallId: 'call-1:code:2', name: 'echo', arguments: { value: 'two' }, isError: false, content: [{ type: 'text', text: 'echo:two' }], }, ]) @@ -1526,6 +1526,7 @@ describe('the run_code dispatch bridge', () => { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) session.append('tool/code-dispatch', { + rootCallId: CallId('p1'), parentCallId: CallId('p1'), subCallId: CallId('p1:code:1'), name: 'echo', diff --git a/packages/core/tools/tests/invariant.spec.ts b/packages/core/tools/tests/invariant.spec.ts index 80ae299da7..8a60c41331 100644 --- a/packages/core/tools/tests/invariant.spec.ts +++ b/packages/core/tools/tests/invariant.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { scopeTarget } from '@deepseek-ai/dsh-scope' import { CallId } from '@deepseek-ai/dsh-llm' -import SessionStore from '@deepseek-ai/dsh-session' +import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' import type { ToolExecution, ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools' import * as ToolsInvariant from '@deepseek-ai/dsh-tools/invariant' import InvariantService from '@deepseek-ai/dsh-invariants' @@ -24,6 +24,7 @@ const execution = (overrides: Partial = {}): ToolExecution => ({ arguments: Object.freeze({ text: 'hi' }), ...overrides, signal: overrides.signal ?? testToolSignal, + rootCallId: overrides.rootCallId ?? overrides.callId ?? CallId('call-1'), }) const outcome = (): ToolExecutionResult => Object.freeze({ @@ -92,6 +93,7 @@ describe('tool-pipeline invariants', () => { const ctx = await setup() const session = ctx.sessions.create() const data = { + rootCallId: CallId('parent'), parentCallId: CallId('parent'), subCallId: CallId('child'), name: 'echo', @@ -103,12 +105,112 @@ describe('tool-pipeline invariants', () => { session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) }) + it('does not commit a rejected dispatch edge into the root index', async () => { + const ctx = await setup() + const session = ctx.sessions.create() + expect(() => session.append('tool/code-dispatch-start', { + rootCallId: CallId('rejected-root'), + parentCallId: CallId('rejected-root'), + subCallId: CallId('reused-child'), + name: 'echo', + arguments: {}, + })).toThrow(/outside any open turn/) + + session.append('turn/start', { turn: 1 }) + expect(() => session.append('tool/code-dispatch-start', { + rootCallId: CallId('accepted-root'), + parentCallId: CallId('accepted-root'), + subCallId: CallId('reused-child'), + name: 'echo', + arguments: {}, + })).not.toThrow() + }) + + it('rejects a nested code dispatch that changes its parent chain root before append', async () => { + const ctx = await setup() + const session = ctx.sessions.create() + session.append('turn/start', { turn: 1 }) + session.append('tool/code-dispatch-start', { + rootCallId: CallId('root'), + parentCallId: CallId('root'), + subCallId: CallId('child'), + name: 'run_code', + arguments: {}, + }) + session.append('tool/code-dispatch-start', { + rootCallId: CallId('root'), + parentCallId: CallId('child'), + subCallId: CallId('grandchild'), + name: 'echo', + arguments: {}, + }) + + expect(() => session.append('tool/code-dispatch-start', { + rootCallId: CallId('another-root'), + parentCallId: CallId('child'), + subCallId: CallId('invalid-grandchild'), + name: 'echo', + arguments: {}, + })).toThrow(/parentCallId child does not belong to rootCallId another-root/) + expect(session.events.some(event => event.type === 'tool/code-dispatch-start' + && String(event.data.subCallId) === 'invalid-grandchild')).toBe(false) + }) + + it('requires non-empty dispatch identities and keeps one subcall on one root', async () => { + const ctx = await setup() + const session = ctx.sessions.create() + session.append('turn/start', { turn: 1 }) + expect(() => session.append('tool/code-dispatch-start', { + rootCallId: CallId(''), + parentCallId: CallId('root'), + subCallId: CallId('child'), + name: 'echo', + arguments: {}, + })).toThrow(/must carry non-empty rootCallId/) + + session.append('tool/code-dispatch-start', { + rootCallId: CallId('root'), + parentCallId: CallId('root'), + subCallId: CallId('child'), + name: 'echo', + arguments: {}, + }) + expect(() => session.append('tool/code-dispatch-start', { + rootCallId: CallId('other-root'), + parentCallId: CallId('other-root'), + subCallId: CallId('child'), + name: 'echo', + arguments: {}, + })).toThrow(/changed rootCallId for subCallId child/) + }) + + it('indexes dispatch records emitted for a bare session', async () => { + const ctx = await setup() + const session = Session.create(SessionId('bare-dispatch-session')) + session.append('turn/start', { turn: 1 }) + expect(() => { + ctx.emit('session/event', session as never, { + type: 'tool/code-dispatch-start', + seq: 1, + time: 1, + data: { + rootCallId: CallId('root'), + parentCallId: CallId('root'), + subCallId: CallId('child'), + name: 'echo', + arguments: {}, + }, + } as never) + }).not.toThrow() + }) + it('replays enclosed code-dispatch records on late registration', async () => { const ctx = new Context() await ctx.plugin(SessionStore) const session = ctx.sessions.create() session.append('turn/start', { turn: 1 }) session.append('tool/code-dispatch', { + rootCallId: CallId('parent'), parentCallId: CallId('parent'), subCallId: CallId('child'), name: 'echo', @@ -125,6 +227,7 @@ describe('tool-pipeline invariants', () => { const ctx = new Context() await ctx.plugin(SessionStore) ctx.sessions.create().append('tool/code-dispatch-start', { + rootCallId: CallId('parent'), parentCallId: CallId('parent'), subCallId: CallId('child'), name: 'echo', diff --git a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts index 3224487b60..5757e5f245 100644 --- a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts +++ b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts @@ -574,6 +574,7 @@ describe('dsh-agent-spine-demo bundle', () => { signal: testToolSignal, token: Symbol('agent-core-dsh-home-test') as ToolExecution['token'], callId: CallId('agent-core-dsh-home'), + rootCallId: CallId('agent-core-dsh-home'), name: 'bash', arguments: { command: 'true' }, } diff --git a/packages/llm/llm-retry/tests/invariant.spec.ts b/packages/llm/llm-retry/tests/invariant.spec.ts index 1c478b226b..33ac3a19fa 100644 --- a/packages/llm/llm-retry/tests/invariant.spec.ts +++ b/packages/llm/llm-retry/tests/invariant.spec.ts @@ -5,6 +5,7 @@ import { createUserMessage, ProviderRequestId } from '@deepseek-ai/dsh-llm' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import InvariantService from '@deepseek-ai/dsh-invariants' import * as RetryInvariant from '@deepseek-ai/dsh-llm-retry/invariant' +import { RetryId } from '@deepseek-ai/dsh-llm-retry/brand' import { providerForOpenStep } from '../src/history.ts' async function setup(): Promise { @@ -38,6 +39,7 @@ function appendRetryTurn(session: Session, turn: number) { const failure = { message: 'provider busy', code: 'RATE_LIMIT', status: 429 } const normal = { + retryId: RetryId('normal-retry-chain'), provider: 'mock', mode: 'normal' as const, policyKey: 'normal-policy', @@ -47,6 +49,7 @@ const normal = { failure, } const always = { + retryId: RetryId('always-retry-chain'), provider: 'mock', mode: 'always' as const, policyKey: 'always-policy', @@ -130,6 +133,7 @@ describe('llm-retry invariants', () => { }) it.each([ + ['empty-retry-id', { ...normal, retryId: RetryId('') }, /retryId must be a non-empty string/], ['retry-zero', { ...normal, retry: 0 }, /positive safe integer/], ['retry-fraction', { ...normal, retry: 1.5 }, /positive safe integer/], ['max-zero', { ...normal, maxRetries: 0 }, /positive safe maxRetries/], @@ -212,10 +216,65 @@ describe('llm-retry invariants', () => { reset.append('step/end', { turn: 1, step: 1 }) reset.append('step/start', { turn: 1, step: 2 }) expect(() => { - reset.append('llm/retry', { turn: 1, step: 2, ...normal }) + reset.append('llm/retry', { + turn: 1, + step: 2, + ...normal, + retryId: RetryId('reset-step-2-retry-chain'), + }) }).not.toThrow() }) + it('keeps one retry identity per provider-policy chain', async () => { + const ctx = await setup() + const changed = openStep(ctx, 'retry-invariant-changed-chain-id') + changed.append('llm/retry', { turn: 1, step: 1, ...normal }) + expect(() => changed.append('llm/retry', { + turn: 1, + step: 1, + ...normal, + retry: 2, + retryId: RetryId('changed-retry-chain'), + })).toThrow(/must preserve retryId/) + + const reused = openStep(ctx, 'retry-invariant-reused-chain-id') + reused.append('llm/retry', { turn: 1, step: 1, ...normal }) + expect(() => reused.append('llm/retry', { + turn: 1, + step: 1, + ...always, + retryId: normal.retryId, + })).toThrow(/already owned by another chain/) + }) + + it('validates retry-started correlation and uniqueness', async () => { + const ctx = await setup() + const empty = openStep(ctx, 'retry-started-empty-id') + expect(() => empty.append('llm/retry-started', { + retryId: RetryId(''), turn: 1, step: 1, retry: 1, + })).toThrow(/retryId must be a non-empty string/) + + const missing = openStep(ctx, 'retry-started-missing-schedule') + expect(() => missing.append('llm/retry-started', { + retryId: RetryId('missing-retry-chain'), turn: 1, step: 1, retry: 1, + })).toThrow(/pairs no prior scheduled attempt/) + + const mismatch = openStep(ctx, 'retry-started-location-mismatch') + mismatch.append('llm/retry', { turn: 1, step: 1, ...normal }) + expect(() => mismatch.append('llm/retry-started', { + retryId: normal.retryId, turn: 2, step: 1, retry: 1, + })).toThrow(/turn\/step must match/) + + const repeated = openStep(ctx, 'retry-started-repeated') + repeated.append('llm/retry', { turn: 1, step: 1, ...normal }) + repeated.append('llm/retry-started', { + retryId: normal.retryId, turn: 1, step: 1, retry: 1, + }) + expect(() => repeated.append('llm/retry-started', { + retryId: normal.retryId, turn: 1, step: 1, retry: 1, + })).toThrow(/repeats one scheduled attempt/) + }) + it('starts a fresh retry chain after incomplete predecessor boundaries', async () => { const ctx = new Context() await ctx.plugin(SessionStore) @@ -261,4 +320,16 @@ describe('llm-retry invariants', () => { await ctx.plugin(InvariantService) await expect(ctx.plugin(RetryInvariant)).rejects.toThrow(/inside an open turn/) }) + + it('accepts a scheduled and started attempt on late registration', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const session = openStep(ctx, 'retry-invariant-late-started') + session.append('llm/retry', { turn: 1, step: 1, ...normal }) + session.append('llm/retry-started', { + retryId: normal.retryId, turn: 1, step: 1, retry: 1, + }) + await ctx.plugin(InvariantService) + await expect(ctx.plugin(RetryInvariant)).resolves.toBeDefined() + }) }) diff --git a/packages/llm/llm-retry/tests/persistence.spec.ts b/packages/llm/llm-retry/tests/persistence.spec.ts index 36a1fb5dbe..3a605bd40e 100644 --- a/packages/llm/llm-retry/tests/persistence.spec.ts +++ b/packages/llm/llm-retry/tests/persistence.spec.ts @@ -6,6 +6,7 @@ import { Context } from 'cordis' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import SessionPersistenceSqlite from '@deepseek-ai/dsh-session-persistence-sqlite' +import { RetryId } from '@deepseek-ai/dsh-llm-retry/brand' import type {} from '../src/index.ts' const dirs: string[] = [] @@ -39,6 +40,7 @@ describe.each(['jsonl', 'sqlite'] as const)('%s retry-event persistence', (kind) reason: 'initial', }) const event = session.append('llm/retry', { + retryId: RetryId(`retry-${kind}-chain`), turn: 1, step: 1, provider: 'mock', diff --git a/packages/llm/llm-retry/tests/retry.spec.ts b/packages/llm/llm-retry/tests/retry.spec.ts index ac0ed687fa..7268c19ec6 100644 --- a/packages/llm/llm-retry/tests/retry.spec.ts +++ b/packages/llm/llm-retry/tests/retry.spec.ts @@ -191,7 +191,9 @@ describe('provider-routed retry policy', () => { agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) const event = await scheduled + expect(event.data.retryId).toEqual(expect.any(String)) expect(event.data).toEqual({ + retryId: event.data.retryId, turn: 1, step: 1, provider: 'mock', diff --git a/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts b/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts index 20cb2cc819..9251eef2c0 100644 --- a/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts +++ b/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts @@ -10,6 +10,7 @@ import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import TokenMeterService from '@deepseek-ai/dsh-token-meter' import type { ContextBreakdownProjection } from '@deepseek-ai/dsh-token-meter/client' +import { CompactionId } from '@deepseek-ai/dsh-compact/brand' import { contextBreakdownProjectionDefinition } from '../src/breakdown-projection.ts' import { estimateContent, @@ -59,6 +60,7 @@ function appendSummaryMeter(ctx: Context, session: Session, start: number, end: const endIdx = nodes.findIndex(node => node.seq === end) const shadowed = nodes.slice(startIdx, endIdx + 1) session.append('compact/summary', { + compactionId: CompactionId('context-breakdown-summary'), summary: [{ type: 'text', text: 'summary' }], shadowedRange: { start, end }, shadowedSeqs: shadowed.map(node => node.seq), diff --git a/packages/llm/token-meter/tests/token-usage-projection.spec.ts b/packages/llm/token-meter/tests/token-usage-projection.spec.ts index 0307b96f46..65771fc877 100644 --- a/packages/llm/token-meter/tests/token-usage-projection.spec.ts +++ b/packages/llm/token-meter/tests/token-usage-projection.spec.ts @@ -7,6 +7,7 @@ import type { Session } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import TokenMeterService from '@deepseek-ai/dsh-token-meter' import type { ContextPressureProjection, TokenUsageProjection } from '@deepseek-ai/dsh-token-meter/client' +import { CompactionId } from '@deepseek-ai/dsh-compact/brand' const ZERO: TokenUsageProjection = { uncachedInputTokens: 0, @@ -81,6 +82,7 @@ function appendSummaryMeter(ctx: Context, session: Session, start: number, end: const endIdx = nodes.findIndex(node => node.seq === end) const shadowed = nodes.slice(startIdx, endIdx + 1) session.append('compact/summary', { + compactionId: CompactionId('token-usage-summary'), summary: [{ type: 'text', text: 'summary' }], shadowedRange: { start, end }, shadowedSeqs: shadowed.map(node => node.seq), diff --git a/packages/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts index ef97c945c9..b52000f554 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -277,8 +277,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ jsDoc: '/**\n * Consider automatic compaction for one explicit trigger. Pressure policy\n * uses the latest durable routed request, while context-overflow policy may\n * force a useful balanced reduction even below the normal threshold. Return\n * `null` when no safe range can be compacted. A single oversized retained\n * unit or request envelope cannot be repaired through surface compaction.\n *\n * @param agent - agent context owning the session surface and routing options.\n * @param trigger - normal pressure or provider-confirmed context overflow.\n * @param signal - cancellation signal; model-backed implementations must forward it.\n * @returns the compaction result, or `null` if no compaction was needed.\n */', }, { - signature: 'abstract compactNow( agent: ManualCompactAgentContext, signal: AbortSignal, ): Promise', - jsDoc: '/**\n * Explicitly compact useful history even below automatic pressure thresholds.\n * Implementations synchronously start an idle task before any asynchronous\n * work, select a useful range without writing on a no-op, then\n * append a standalone `compact/start` before summarization. That durable\n * marker is the compaction lock until one `compact/end` attempt. Later waking\n * prompts remain accepted in FIFO order and start only after the optional\n * durability checkpoint and idle-task settlement. Context injected while the\n * summary runs may sit between the marker pair; only the selected span must\n * remain stable.\n *\n * @param agent - idle agent whose durable history should be compacted.\n * @param signal - cancellation scoped to this compaction request.\n * @returns the compaction result, or `null` when no safe useful range exists.\n * @throws {@link ManualCompactionError} for expected busy, agent-cancellation,\n * changed-span, summarization/shrink, commit-stage, or persistence failures;\n * an aborted request preserves its exact abort reason. Failed attempts remain\n * visible in the log.\n */', + signature: 'abstract compactNow( agent: ManualCompactAgentContext, signal: AbortSignal, sourceCommandId?: CommandId, ): Promise', + jsDoc: '/**\n * Explicitly compact useful history even below automatic pressure thresholds.\n * Implementations synchronously start an idle task before any asynchronous\n * work, select a useful range without writing on a no-op, then\n * append a standalone `compact/start` before summarization. That durable\n * marker is the compaction lock until one `compact/end` attempt. Later waking\n * prompts remain accepted in FIFO order and start only after the optional\n * durability checkpoint and idle-task settlement. Context injected while the\n * summary runs may sit between the marker pair; only the selected span must\n * remain stable.\n *\n * @param agent - idle agent whose durable history should be compacted.\n * @param signal - cancellation scoped to this compaction request.\n * @param sourceCommandId - initiating command identity for a manual compaction.\n * @returns the compaction result, or `null` when no safe useful range exists.\n * @throws {@link ManualCompactionError} for expected busy, agent-cancellation,\n * changed-span, summarization/shrink, commit-stage, or persistence failures;\n * an aborted request preserves its exact abort reason. Failed attempts remain\n * visible in the log.\n */', }, { signature: 'abstract compactRegion( start: number, end: number, agent: CompactAgentContext, signal?: AbortSignal, ): Promise', @@ -1841,7 +1841,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'CommandInvocation', - declaration: 'export interface CommandInvocation {\n readonly agent: Agent;\n readonly rawInput: string;\n readonly signal: AbortSignal;\n}', + declaration: 'export interface CommandInvocation {\n readonly commandId: CommandId;\n readonly agent: Agent;\n readonly rawInput: string;\n readonly signal: AbortSignal;\n}', }, { name: 'CommandResult', @@ -1851,9 +1851,13 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'CompactAgentContext', declaration: 'export interface CompactAgentContext {\n session: Session;\n options: {\n provider?: string;\n model?: string;\n };\n}', }, + { + name: 'CompactionId', + declaration: 'export type CompactionId = Branded<\'CompactionId\'>;', + }, { name: 'CompactionResult', - declaration: 'export interface CompactionResult {\n startSeq: number;\n summarySeq: number;\n endSeq: number;\n summary: ContentBlock[];\n shadowedRange: {\n start: number;\n end: number;\n };\n shadowedSeqs: number[];\n shadowedTokenCount: number;\n}', + declaration: 'export interface CompactionResult {\n compactionId: CompactionId;\n sourceCommandId?: CommandId;\n startSeq: number;\n summarySeq: number;\n endSeq: number;\n summary: ContentBlock[];\n shadowedRange: {\n start: number;\n end: number;\n };\n shadowedSeqs: number[];\n shadowedTokenCount: number;\n}', }, { name: 'CompactionTrigger', @@ -3065,7 +3069,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ToolExecution', - declaration: 'export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n}', + declaration: 'export interface ToolExecution extends ToolExecutionInput {\n readonly rootCallId: CallId;\n readonly token: ToolExecutionToken;\n}', }, { name: 'ToolExecutionFailure', @@ -3073,7 +3077,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ToolExecutionInput', - declaration: 'export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n}', + declaration: 'export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly rootCallId?: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n}', }, { name: 'ToolExecutionMode', diff --git a/packages/support/llm-replay/tests/llm-replay.spec.ts b/packages/support/llm-replay/tests/llm-replay.spec.ts index efdab32824..0276199e3e 100644 --- a/packages/support/llm-replay/tests/llm-replay.spec.ts +++ b/packages/support/llm-replay/tests/llm-replay.spec.ts @@ -4,6 +4,7 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { CompactionId } from '@deepseek-ai/dsh-compact/brand' import LlmService, { CallId, createUserMessage, GenerateOptions, LlmAdapter, StreamChunk } from '@deepseek-ai/dsh-llm' import { type ReplayEntry, @@ -35,6 +36,8 @@ const TEXT_CHUNKS: StreamChunk[] = [ { type: 'finish', reason: { kind: 'stop' } }, ] +const COMPACTION_ID = CompactionId('replay-compaction') + /** Build a minimal session-JSONL string: a header line + the given events. */ function sessionJsonl(events: SessionEvent[], header?: { id?: string; createdAt?: number; seedLength?: number }): string { const headerLine = JSON.stringify({ @@ -194,12 +197,18 @@ describe('deriveReplayScript', () => { let seq = 1 const events: SessionEvent[] = [ ...overflow.map(chunk => chunkEvent(seq++, 1, 2, chunk)), - { type: 'compact/start', seq: seq++, time: 0, data: { turn: 1 } }, + { + type: 'compact/start', + seq: seq++, + time: 0, + data: { compactionId: COMPACTION_ID, turn: 1 }, + }, { type: 'compact/summary', seq: seq++, time: 0, data: { + compactionId: COMPACTION_ID, summary: rawOutput, rawOutput, llmStreamCall: true, @@ -227,6 +236,7 @@ describe('deriveReplayScript', () => { seq: 1, time: 0, data: { + compactionId: COMPACTION_ID, summary: [{ type: 'text', text: 'template result' }], shadowedRange: { start: 1, end: 1 }, shadowedSeqs: [1], @@ -246,6 +256,7 @@ describe('deriveReplayScript', () => { seq: 1, time: 0, data: { + compactionId: COMPACTION_ID, summary: [block], rawOutput: [block], shadowedRange: { start: 1, end: 1 }, @@ -267,6 +278,7 @@ describe('deriveReplayScript', () => { seq: 1, time: 0, data: { + compactionId: COMPACTION_ID, summary: [{ type: 'text', text: 'missing source events' }], llmStreamCall: true, shadowedRange: { start: 1, end: 1 }, @@ -289,6 +301,7 @@ describe('deriveReplayScript', () => { seq: 1, time: 0, data: { + compactionId: COMPACTION_ID, summary: [block], rawOutput: [block], llmStreamCall: true, @@ -343,6 +356,7 @@ describe('deriveReplayScript', () => { seq: 2, time: 0, data: { + compactionId: COMPACTION_ID, summary: [{ type: 'text', text: 'external checkpoint' }], shadowedRange: { start: 1, end: 1 }, shadowedSeqs: [1], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da3703e5ff..f1ee272761 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1504,9 +1504,6 @@ importers: '@deepseek-ai/dsh-commands': specifier: workspace:^ version: link:../../interaction/commands - '@deepseek-ai/dsh-compact': - specifier: workspace:^ - version: link:../../compact/compact '@deepseek-ai/dsh-host-apiproxy': specifier: workspace:^ version: link:../../host/apiproxy @@ -1680,6 +1677,9 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots + '@deepseek-ai/dsh-compact': + specifier: workspace:^ + version: link:../../compact/compact '@deepseek-ai/dsh-goal': specifier: workspace:^ version: link:../../goal/goal @@ -2670,6 +2670,12 @@ importers: packages/compact/compact: devDependencies: + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-commands': + specifier: workspace:^ + version: link:../../interaction/commands '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -2704,6 +2710,9 @@ importers: '@deepseek-ai/dsh-agent-loop-testkit': specifier: workspace:^ version: link:../../support/agent-loop-testkit + '@deepseek-ai/dsh-commands': + specifier: workspace:^ + version: link:../../interaction/commands '@deepseek-ai/dsh-compact': specifier: workspace:^ version: link:../compact @@ -4391,6 +4400,9 @@ importers: '@deepseek-ai/dsh-agent-loop-testkit': specifier: workspace:^ version: link:../../support/agent-loop-testkit + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index fffde3ceb8..51fdc06f61 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -278,6 +278,7 @@ export const LINK_MAP: Readonly> = { CreateGoalResult: 'goal.md', CommandDefinition: 'commands.md', CommandDescriptor: 'commands.md', + CommandId: 'commands.md', CommandResult: 'commands.md', CommandSurface: 'commands.md', LlmAdapter: 'llm-streaming.md',