Merge commit '882fb242ad930f15617e95cfb04e5c2225d70772' into codex/dsh-sdk-minimal-diagnostics

This commit is contained in:
pku-xht
2026-08-25 20:57:46 +08:00
217 changed files with 5391 additions and 1254 deletions
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/archived/feature/2026-08-18-product-subagent-failure-facts.md
2026-08-18-product-subagent-failure-facts.md: b1d80cf66172ac67d38dbad873fa4cbd970a775c
2026-08-18-product-subagent-failure-facts.zh.md: df4b14b4a243f7768240678b8d434c7aef7d48a7
@@ -1,6 +1,7 @@
# Agent Note: Product subagents expose bounded structured failure facts
Status: implemented
Archived: 2026-08-21
English | [中文](2026-08-18-product-subagent-failure-facts.zh.md)
@@ -1,6 +1,7 @@
# Agent Note: 产品 subagent 公开有界结构化失败事实
Status: implemented
Archived: 2026-08-21
[English](2026-08-18-product-subagent-failure-facts.md) | 中文
+3
View File
@@ -295,6 +295,9 @@
"feature/2026-08-11-web-export-command-and-dialog.i18n.yaml": "sha256:db7d523a2a1f82a86f532661bd2953ee8538d971d91f886e4bd4e0d88f7226b2",
"feature/2026-08-11-web-export-command-and-dialog.md": "sha256:ec44b47589ca7924018dc24f7fa73379a97b8f053d9e8ccce2aebb600230e47b",
"feature/2026-08-11-web-export-command-and-dialog.zh.md": "sha256:ad28e67d397c87300cfe1705ba3d206cc4d054e07f5647c095c718ac8cf4ec98",
"feature/2026-08-18-product-subagent-failure-facts.i18n.yaml": "sha256:0aa7a873fdd878ee7f4b0a850ecf16d7b652b4f85de979acb7efcdf90883b6c1",
"feature/2026-08-18-product-subagent-failure-facts.md": "sha256:f7e05703c44106359798e6e4b76e442a4107b62ff0363554382d4767e4806788",
"feature/2026-08-18-product-subagent-failure-facts.zh.md": "sha256:19d2619fb5b5c6e40305dd82432d837357afa433ab735504ec204a2c25582ce6",
"feature/2026-08-18-web-home-path-tilde.i18n.yaml": "sha256:f151e3e3514f59784fc646c2feb3075dc954c65110d48c2cc482ad486fc0b86f",
"feature/2026-08-18-web-home-path-tilde.md": "sha256:8c7ecf120ff8c81826160acab5fc906a2a0a14213bcd2958343cfea47328d68e",
"feature/2026-08-18-web-home-path-tilde.zh.md": "sha256:3486c5b42aed5bcadf12c62c5e1e6cf7c1b493fc1085ad7d154cdf2ec34076cc",
@@ -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-26-packed-chunk-rows-by-default.md
2026-07-26-packed-chunk-rows-by-default.md: 141c9a32a07b5cb4885a21b419df30d45dc1061b
2026-07-26-packed-chunk-rows-by-default.zh.md: 30088b8640efa783380a3fe083ed82efdad9e5c8
2026-07-26-packed-chunk-rows-by-default.md: 14da6b3cbe650e80118e7c960c96bf618acd1e48
2026-07-26-packed-chunk-rows-by-default.zh.md: f62a8e52a67adc960ac3150552594b4f061e6205
@@ -18,7 +18,7 @@ Reading is unconditional and layout-blind. Packed, unpacked, and mixed files loa
### Logical events and physical rows
Packing stays at the `dsh-session` storage seam through `packChunkRuns()` and `decodeStorageRecord()`. The encoder recognizes exact delta-event shapes, preserves unrecognized events verbatim, and packs only runs of at least three. A packed row is storage vocabulary, not a `SessionEventMap` member: it never enters `Session.events` or fires `session/event`.
The JSONL packing path stays at the `dsh-session` storage seam through `packChunkRuns()` and `decodeStorageRecord()`. The encoder recognizes exact delta-event shapes, preserves unrecognized events verbatim, and packs only runs of at least three. A packed row is encoding vocabulary, not a `SessionEventMap` member: it never enters `Session.events` or fires `session/event`. The [packed session-history transport decision](2026-08-15-packed-session-history-transport.md) reuses this vocabulary for a bounded lossless wire interval without changing those event semantics.
The JSONL backend packs each durable append batch. Raw `compression: 'none'` and default Zstandard framing carry the same logical storage records; selecting raw mode for reviewable fixtures does not disable packing. Repository replay readers and normalizers decode the shared row format instead of maintaining snapshot-specific codecs.
@@ -18,7 +18,7 @@ JSONL 存储 seam 可以在不改变逻辑日志的情况下减少这部分封
### 逻辑事件与物理行
打包保留在 `dsh-session` 的存储 seam,并通过 `packChunkRuns()``decodeStorageRecord()` 实现。编码器识别精确的增量事件形态,原样保留无法识别的事件,并且只打包至少包含 3 个事件的连续段。打包行属于存储词汇,不是 `SessionEventMap` 成员:它绝不会进入 `Session.events`,也不会触发 `session/event`
JSONL 打包路径保留在 `dsh-session` 的存储 seam,并通过 `packChunkRuns()``decodeStorageRecord()` 实现。编码器识别精确的增量事件形态,原样保留无法识别的事件,并且只打包至少包含 3 个事件的连续段。打包行属于编码词汇,不是 `SessionEventMap` 成员:它绝不会进入 `Session.events`,也不会触发 `session/event`[打包会话历史传输决策](2026-08-15-packed-session-history-transport.zh.md)会为有界的无损协议区间复用该词汇,而不改变这些事件语义。
JSONL 后端会打包每个持久追加批次。原始模式 `compression: 'none'` 与默认 Zstandard 帧承载相同的逻辑存储记录;为使 fixture 便于评审而选择原始模式,不会禁用打包。仓库中的回放读取器和规范化器会解码共享行格式,而不维护快照专用编解码器。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md
2026-08-09-client-conversation-node-assembly.md: ea2505d4a72f483a9df6fcd78d7e5c9a96b02f5c
2026-08-09-client-conversation-node-assembly.zh.md: b87f127d753cadf2805ed5cd948fc58ad01830aa
2026-08-09-client-conversation-node-assembly.md: 12069d129227cce13eb5f9f39e636921d4bf9efa
2026-08-09-client-conversation-node-assembly.zh.md: 957c2b291293761ff2417f60093b7962bb75bbdf
@@ -8,13 +8,13 @@ English | [中文](2026-08-09-client-conversation-node-assembly.zh.md)
Client Session owned transport windows, connection state, and pending interactions while also interpreting Assistant, Tool, message, command, compaction, retry, and turn-tail events in a centralized transcript fold. Adding one business node required changes to Session switches, history replay, indexes, caches, and React grouping; business identity, state evolution, and final presentation had no independent owner.
The old path also placed running Assistant and Tool values outside the finalized flow. They entered the log-ordered node list only after settlement, so their React parent changed and remounted them even when the business ID and `key` remained stable. Full history loads, older prepends, live appends, and token streaming used separate update paths, leaving reference stability and local recomputation dependent on specialized caches spread across the client.
Without target-neutral assembly, running Assistant and Tool values sit outside the finalized flow and enter the log-ordered node list only after settlement. Their React parent then changes and remounts them even when the business ID and `key` remain stable. Separate update paths for full history loads, older prepends, live appends, and token streaming also make reference stability and local recomputation depend on specialized caches spread across the client.
Business events also use different correlation models. Tool has call IDs, Assistant correlates by turn and step, Compaction has its own lifecycle and checkpoint, and an Inbox splice represents one instantaneous state in a sequence. Keeping all these distinctions in one fold would make every business change pass through a global lookup and invalidate unrelated caches.
## Decision
Client Runtime provides a target-neutral Conversation Node assembly engine. Business plugins register Event Definitions, and view plugins register per-Session View Builders. `ui-conversation` registers the first built-in Definitions and the `chat` builder; Session only submits the current contiguous Event window to the engine and publishes its snapshot instead of interpreting individual conversation businesses.
Client Runtime provides a target-neutral Conversation Node assembly engine. Business plugins register Event Definitions, and view plugins register per-Session View Builders. `ui-conversation` registers the first built-in Definitions and the `chat` builder; Session only submits the current contiguous `SessionEventLikeEntry` window to the engine and publishes its snapshot instead of interpreting individual conversation businesses. The entry's outer discriminator distinguishes standard and packed records, while both carry an aligned inner `SessionEventLike` for Definition dispatch.
This Note retains the derivation, business-by-business validation, responsibilities, algorithms, and trade-offs that remain relevant after implementation.
@@ -22,20 +22,20 @@ This Note retains the derivation, business-by-business validation, responsibilit
| Layer | Durable responsibility | Explicitly does not own |
|---|---|---|
| Session | Maintain the contiguous Event window, distinguish replace, prepend, and append, and schedule snapshot notifications | Interpret Tool, Assistant, Compaction, or other business events |
| Session | Maintain the contiguous logical-event window, distinguish replace, prepend, and scalar append, and schedule snapshot notifications | Interpret Tool, Assistant, Compaction, or other business events |
| Event Registry | Retain the unique-`kind` Definitions and sole fallback under Cordis lifecycles | Store one Session's Context or State |
| Assembler | Match Events and maintain Contexts, Locations, dependencies, and the publication dirty set | Interpret business State fields or Chat ordering |
| Assembler | Match standard events or packed runs and maintain Contexts, Locations, dependencies, and the publication dirty set | Interpret business State fields or Chat ordering |
| Node Definition | Define one business object's identity, State transitions, Location data, and target Node | Create Contexts, mutate another business's State, or scan all Contexts |
| View Builder | Incrementally organize final target Nodes into that view's snapshot | Reinterpret raw Session Events |
| View Builder | Incrementally organize final target Nodes into that view's snapshot | Reinterpret `SessionEventLike` inputs |
| React renderer | Render renderer-owned data by the final Node's `kind` and read business data from the current Node's Location | Pair business Events, scan global Nodes, or decide business lifecycle state |
Registry contributions are Cordis effects. Removing a Definition causes a low-frequency registry rebuild for existing Sessions; ordinary business Events do not change the Registry or rebuild every business type.
### Overall `ConversationNodeDefinition` contract
Each [`ConversationNodeDefinition`](../../../../packages/client/ui-conversation/src/client/contract/conversation.ts) independently owns one business object's conversion from Events to State and final view Nodes. A Definition's `kind` is its unique Registry name and the namespace for its business IDs.
Each [`ConversationNodeDefinition`](../../../../packages/client/ui-conversation/src/client/contract/conversation.ts) independently owns one business object's conversion from `SessionEventLike` inputs to State and final view Nodes. A Definition's `kind` is its unique Registry name and the namespace for its business IDs.
One Event may be claimed by several ordinary Definitions. For example, an Assistant Event updates both the Assistant Node and Turn Tail, while a Retry Event updates Retry, Assistant, and Turn Tail. The Assembler asks the fallback only when every ordinary Definition returns `null`.
One input may be claimed by several ordinary Definitions. For example, an Assistant event or packed run updates both the Assistant Node and Turn Tail, while a Retry Event updates Retry, Assistant, and Turn Tail. The Assembler asks the fallback only when every ordinary Definition returns `null`.
A Definition holds no mutable business data across Sessions. Each Session's Assembler isolates that Session's Contexts, State, dependencies, and View Builders.
@@ -49,9 +49,9 @@ Each `(kind, id)` has at most one start Match. A second start fails immediately;
#### `match(event)`
`match(event)` reads only the current raw `SessionEvent` and returns `{ id, role: 'start' | 'update' }` or `null`. It cannot access a Context, history, a Reader, a Location, or the view envelope.
`match(event)` reads only the current `SessionEventLike` and returns `{ id, role: 'start' | 'update' }` or `null`. It cannot access a Context, history, a Reader, a Location, or the view envelope. A `chunkrow/*` event can only be an update; the Assembler rejects it as a start, and `start()` receives a `ConversationStartMatch` containing a standard `SessionEvent`.
This restriction makes one Event's routing cost depend only on the number of registered Definitions. The Assembler never scans a Definition's historical Contexts to decide which one owns an update.
This restriction makes one scalar event or packed run's routing cost depend only on the number of registered Definitions. The Assembler never scans a Definition's historical Contexts to decide which one owns an update.
Start, result, resource, checkpoint, and business-owned terminal Events must carry or directly imply the same ID. If one Event cannot yield that ID, its producer extends the Event protocol; the Client does not guess from the "nearest unfinished object."
@@ -59,9 +59,9 @@ The `role` describes the State lifecycle, not visibility. A start may produce a
#### `ConversationMatch`
After a successful match, the Assembler combines the raw Event, optional wire presentation view, `role`, and engine-computed `location` into a read-only `ConversationMatch`.
After a successful match, the Assembler combines the standard or packed event, `role`, and engine-computed `location` into a read-only `ConversationMatch`. A packed run remains one Match and retains its fragment and timestamp-gap arrays.
A Context's `matches` always remain in ascending Event `seq` order, not network arrival or pagination ingestion order. If a tail page supplies a result before an older page supplies its call, the final Match order still places the call before the result.
A Context's `matches` always remain in ascending first-`seq` order, not network arrival or pagination ingestion order. The Session journal has already rejected overlapping logical ranges. If a tail page supplies a result before an older page supplies its call, the final Match order still places the call before the result.
Location can change when prepend fills a boundary or append closes one. The Assembler replaces the affected Matches' read-only Locations and replays the Context; business code does not retain an old Location copy as authority.
@@ -71,8 +71,8 @@ Location can change when prepend fills a boundary or append closes one. The Asse
|---|---|---|
| `key` | Assembler | Stable final identity derived from `kind + id` |
| `kind` / `id` | Definition + Assembler | Current business namespace and business ID |
| `matches` | Assembler | Complete business evidence loaded in the current window and sorted by `seq` |
| `start` | Assembler | Unique start Match, or `undefined` before it loads |
| `matches` | Assembler | Complete scalar and packed business evidence loaded in the current window and sorted by first `seq` |
| `start` | Assembler | Unique scalar start Match, or `undefined` before it loads |
| `state` | Returned by Definition, held by Assembler | Most recent `start`/`update` return value, or `undefined` before initialization |
| `current` | Assembler | Most recently materialized Node or `null` for each target |
@@ -108,7 +108,7 @@ Dependencies point strictly from earlier starts to later starts, so transitive r
#### `update(context, match)`
`update()` handles a post-start Match that `match()` has already routed exactly to the current `(kind, id)`. It does not decide which Context owns the Event.
`update()` handles a post-start scalar or packed Match that `match()` has already routed exactly to the current `(kind, id)`. It does not decide which Context owns the input. A Definition that consumes Assistant deltas folds each matching `chunkrow/*` value as one batch without constructing member events.
The Assembler invokes `update()` in ascending `seq` order. A live tail update can apply incrementally; any non-tail insertion, newly loaded start, or invalidated dependency causes a complete replay from `start()`.
@@ -126,9 +126,9 @@ The Assembler does not use State reference equality to decide publication or pro
| `animation-frame` | Coalesce high-frequency updates into materialization on the next frame |
| `none` | Do not schedule a flush for this Match; retain its State and dirty marker |
Omitting `publication()` means `immediate`. Assistant token deltas use `animation-frame`, invisible Inbox Contexts use `none`, and finals, dependency replays, and Location boundaries publish the latest result through an immediate path.
Omitting `publication()` means `immediate`. Assistant token deltas and packed runs use `animation-frame`, invisible Inbox Contexts use `none`, and finals, dependency replays, and Location boundaries publish the latest result through an immediate path.
Every delta within a frame still executes update. Only `buildViewNode()`, View Builder work, and React snapshot notification are coalesced; no tokens are lost.
Every live delta within a frame still executes `update()`, while one historical packed run executes one batch `update()`. Only `buildViewNode()`, View Builder work, and React snapshot notification are coalesced; no fragments are lost.
#### `buildLocationData(context, scope)`
@@ -160,7 +160,7 @@ IDs are never reused. Completed Contexts remain in the current window, providing
### Location is a first-class engine fact
[`ConversationLocationIndex`](../../../../packages/client/ui-conversation/src/client/conversation/location-index.ts) maps Events to Locations from `turn/start`, `step/start`, explicit turn and step payloads, `step/end`, and `turn/end`.
[`ConversationLocationIndex`](../../../../packages/client/ui-conversation/src/client/conversation/location-index.ts) maps standard events and packed runs to Locations from `turn/start`, `step/start`, explicit turn and step payloads, `step/end`, and `turn/end`. All members of a row share its turn, step, block index, and delta kind, so the row needs one Location entry at its first `seq`.
Location has four shapes: `session`, `turn`, `step`, and `unresolved`. Turns and Steps each carry `open`, `closed`, or `unknown` status plus any loaded start and end Events.
@@ -168,31 +168,31 @@ Each Turn and Step also carries a reference-stable Location data store. A Defini
`unresolved` means the current history window lacks sufficient preceding boundaries; it does not mean session-level. When older prepend supplies those boundaries, the index corrects Match Locations and replays only Contexts that own those seqs.
An appended ordinary Event only inherits current coordinates, while an appended boundary recalculates only its owning Turn. Prepend rebuilds Location facts from the expanded contiguous window, but reference-stability logic retains unchanged Turn and Step objects.
An appended standard Event only inherits current coordinates, while an appended boundary recalculates only its owning Turn. Prepend rebuilds Location facts from the contiguous `SessionEventLikeEntry` window, but reference-stability logic retains unchanged Turn and Step objects.
The Assembler also passes a reference-stable timeline to each View Builder. Businesses do not separately maintain turn order, step lists, last-step values, or boundary Maps.
## Three Event-window paths
## Three input-window paths
"Backward history scanning" describes the UI loading pages from the newest tail toward the Session beginning; it does not mean a Definition executes `update()` in reverse. Regardless of history API order or page-loading direction, the Assembler canonicalizes each current window and each fresh page in ascending `seq` order.
"Backward history scanning" describes the UI loading pages from the newest tail toward the Session beginning; it does not mean a Definition executes `update()` in reverse. The Session journal validates each record's logical range before publication. Regardless of page-loading direction, the Assembler orders every accepted standard event or packed run by its first `seq`.
| Scenario | Input range | Context and State handling | View Builder |
|---|---|---|---|
| Initial history tail or resync | Current complete contiguous window | Clear and rebuild all Contexts in ascending `seq` order | `replace()` |
| Load one older-history page | Only deduplicated fresh Events before the window | Retain existing Context identity, then add Matches, Locations, dependencies, and local replays | `apply(upserts)` |
| Initial history tail or resync | Current complete contiguous logical window | Clear and rebuild all Contexts in ascending first-`seq` order | `replace()` |
| Load one older-history page | Only range-validated fresh standard events or packed runs before the window | Retain existing Context identity, then add Matches, Locations, dependencies, and local replays | `apply(upserts)` |
| Live append | One contiguous tail Event | Match Definitions and update only the exact IDs; boundaries affect only their owning Turn | `apply(upserts)` |
### Initial history tail and logical backward scanning
1. `Session.open()` loads the latest tail page and passes its contiguous History Entries to `replaceWindow(entries, hasMore)`.
1. `Session.open()` loads the latest tail page and passes its contiguous `SessionEventLike` entries to `replaceWindow(entries, hasMore)`.
2. `replaceWindow` clears old Contexts, start-seq indexes, seq reverse indexes, Reader dependencies, and the input Map.
3. It sorts every entry by Event `seq` and stores the resulting current window.
3. It sorts every entry by its first logical `seq` and stores the resulting current window.
4. LocationIndex rebuilds Turn and Step facts for that window.
5. The Assembler visits Events in ascending order and invokes every ordinary Definition's `match(event)`.
5. The Assembler visits standard events and packed runs in ascending order and invokes every ordinary Definition's `match(event)`.
6. Each result gets or creates its `(kind, id)` Context and enters that Context's ordered Match array.
7. A start runs `start()`; a tail update on initialized State runs `update()` directly.
8. If the page contains only a result or resource and omits its start, the ID still creates a Context and collects Matches, while State remains `undefined`.
9. After matching all Events, the Assembler rechecks Reader dependencies so earlier instantaneous states in the same window stabilize before later consumers read them.
9. After matching all inputs, the Assembler rechecks Reader dependencies so earlier instantaneous states in the same window stabilize before later consumers read them.
10. Every Context becomes dirty, and the next flush fully rebuilds Location data in Step→Turn order before invoking `buildViewNode()` for every target.
11. Some businesses return `null` without a start; Compaction, Command, Tool result, and Turn Error can construct fallback Nodes from sufficient update evidence.
12. Each View Builder receives the complete Node set and timeline and establishes the initial snapshot through `replace()`.
@@ -206,12 +206,12 @@ If an update with the same ID is genuinely earlier than the start in log order,
### Prepending a newly loaded older page
1. `Session.loadOlder()` requests the immediately preceding page using the current `baseSeq` and first verifies continuity between the page tail and current window.
2. Session prepends the raw Event and view arrays to its own window and passes only that page to `assembler.prepend(entries, hasMore)`.
3. The Assembler removes seqs that overlap the current window, then sorts the fresh page internally in ascending order.
2. Session prepends the accepted standard or packed entries to its own window and passes only that page to `assembler.prepend(entries, hasMore)`.
3. The journal has already removed complete duplicate ranges and rejected partial overlaps; the Assembler sorts the fresh page by first `seq`.
4. Existing Contexts, State, current Nodes, and View Builder instances remain intact.
5. LocationIndex rebuilds facts over the expanded complete input and reports seqs whose Location identity actually changed.
5. LocationIndex rebuilds facts over the extended complete input and reports seqs whose Location identity actually changed.
6. Contexts owning those seqs update their Match Locations and replay from start; unrelated Contexts do not join Location replay.
7. Fresh Events run Definition matchers and enter existing or new Contexts by stable ID.
7. Fresh standard events and packed runs enter existing or new Contexts through the same Definition matcher and stable ID.
8. If the new page supplies a pending Context's start, that Context initializes from the start and then applies every already-collected update in ascending order.
9. If the page establishes a nearer Reader predecessor, changes a predecessor revision, or removes a window gap, the consumer recomputes from `start()`.
10. Reader dependencies propagate replay toward later start seqs; no Event is applied in reverse within the propagation batch.
@@ -226,7 +226,7 @@ Reader gap repair is the largest algorithmic difference between prepend and ordi
### Forward live append
1. Session accepts only a live Event immediately after the current tail seq; it deduplicates overlap and runs tail-page repair before accepting a gap.
1. Session accepts only a standard live Event immediately after the current logical tail seq; it deduplicates overlap and runs tail-page repair before accepting a gap.
2. A non-boundary Event enters the current Turn and Step coordinates incrementally; a boundary Event updates Location facts for its owning Turn.
3. The Assembler invokes `match()` once on every ordinary Definition for this Event and scans no Definition's Context set.
4. Each successful result directly locates one Context through `(kind, id)`.
@@ -249,7 +249,7 @@ All three paths preserve the same invariants: Context Matches are seq-ordered, S
`replaceWindow` is the low-frequency complete replacement for initial open, resync, gap repair, and registry changes; it does not implement ordinary load older. Both `prepend` and `append` retain existing Builder and Context identity.
Page size, the number of history loads, and RAF coalescing affect only when evidence arrives or publishes. They do not change final Context State and Nodes for an equal Event window.
Page size, record packing, the number of history loads, and RAF coalescing affect only when evidence arrives or publishes. They do not change final Context State and Nodes for equal logical evidence.
## How built-in businesses use Definitions
@@ -261,7 +261,7 @@ Page size, the number of history loads, and RAF coalescing affect only when evid
| Next-step Inbox / `inbox-next-step` | Splice Event seq | Each `agent/inbox/spliced` targeting next-step | None | Build the same per-instruction instantaneous state; Message reads its claimed set |
| Message / `input-message` | Message ID | Append-surface `user/message` | None | Use source for a context message, or read the nearest next-step Inbox to distinguish user from steering |
| Request Prompt / `request-prompt` | Header Event seq | Each `request/header` | None | Read the preceding Request Prompt through Reader, retain the full prompt state, and classify system/tool changes |
| Assistant / `assistant-step` | `turn:step` | `step/start` | `assistant/chunk`, final `assistant/message`, and same-step Retry | Aggregate blocks, usage, first-token time, final evidence, and retry-hidden state, then publish same-key Step data |
| Assistant / `assistant-step` | `turn:step` | `step/start` | Scalar or packed `assistant/chunk`, final `assistant/message`, and same-step Retry | Aggregate blocks, usage, first-token time, final evidence, and retry-hidden state, then publish same-key Step data |
| Tool / `tool-call` | Root call ID | Root `tool/call` | Root result and Code Dispatch start/result | Aggregate the root, children, and parent Map; Dispatch Events route exactly through `rootCallId` |
| Command / `command` | Command ID | `command/run` | `command/done` and compact lifecycle/checkpoint Events carrying a source command ID | Aggregate command outcome and manual-compaction evidence |
| Automatic Compaction / `compaction` | Compaction ID | `compaction/start` without a source command ID | Summary, end, and replacement checkpoint | Aggregate summary/checkpoint; sufficient checkpoint evidence supports fallback without a start |
@@ -278,7 +278,7 @@ Page size, the number of history loads, and RAF coalescing affect only when evid
| Inbox | `none` | No Node | Recompute instantaneous states along the Reader chain when prepend supplies earlier splices |
| Message | Immediate by default | `user`, `steering`, or `context` | Window-gap repair can reclassify the same message key |
| Request Prompt | Immediate by default | One `system-prompt` for every header carrying a non-empty system field | A step's first header anchors before its request messages; a later same-step series anchors after its surface rewrite; prepend of the preceding header can correct a partial-window anchor |
| Assistant | RAF for chunks, immediate for final, none for pure usage/finish | Same-key `assistant-step` with running/settled/interrupted status | Matches support fallback without `step/start`; Location close produces interruption presentation |
| Assistant | RAF for scalar chunks and packed runs, immediate for final, none for pure usage/finish | Same-key `assistant-step` with running/settled/interrupted status | Scalar and packed reducers are equivalent; Matches support fallback without `step/start`; Location close produces interruption presentation |
| Tool | Immediate by default | One recursive `tool-call` root containing all `subCalls` | A result-only history window supports fallback; running→settled retains its key |
| Command | Immediate by default | Ordinary `command` or integrated `manual-compaction` | Checkpoint arrival may change the anchor without changing the Context key |
| Compaction | Immediate by default | `compaction` marker | A checkpoint may render before start; an older start triggers forward replay |
@@ -326,20 +326,20 @@ Slot-level contextual Hooks and entry-owned `inject.hooks` remain independent pa
The standard `useSession` remains available to every session-scoped slot renderer. `useTurnData()` narrows the common read path rather than acting as a permission sandbox. Whole-window statistics or arbitrary object indexes may still read the Session snapshot explicitly, but they are not modeled as current-Node Turn data.
Assistant streaming to final and Tool running to settled update only one Seat's data and necessary ordering properties. They no longer move from a tail running container into finalized flow, so settlement does not reset component-local State.
Assistant streaming to final and Tool running to settled stay in one Seat while updating its data and necessary ordering properties. Settlement therefore does not reset component-local State through a parent move.
When business logic deliberately changes a materialized Node to hidden, it leaves visible order and remounts when visible again. This is explicit business withdrawal of presentation, distinct from the stable-Seat guarantee for running→settled.
The concrete Tool renderer remains governed by the [`ui-tool ownership decision`](2026-08-08-client-tool-presentation-ownership.md). Tool Definition supplies recursive root/subcall data, and `ui-tool` dispatches concrete presentation by the Tool-name keyed slot.
Trajectory registers its own target and business Definitions against the same Assembler and Session event window as Chat. Its target builder preserves the stage-oriented read model without consuming the Chat Builder's legacy slice or running an independent history fold. The Chat Builder retains its legacy slice for StatsLine and the top-level public compatibility fields; target-specific Definitions do not change the shared Context, Reader, or Location contracts.
Trajectory registers its own target and business Definitions against the same Assembler and `SessionEventLikeEntry` window as Chat. Its target builder preserves the stage-oriented read model without consuming the Chat Builder's legacy slice or running an independent history fold. Chat and Trajectory keep independent scalar and packed Assistant reducers; target-specific Definitions do not change the shared Context, Reader, or Location contracts.
The target-specific Trajectory Definitions, retained stage model, Steering adaptation, complexity bounds, and presentation hot paths are owned by the [Trajectory Context assembly decision](2026-08-11-trajectory-conversation-context-assembly.md).
## Runtime and render path
```text
Session Event window
SessionEventLike window
-> ConversationNodeAssembler
-> Definition.match(event) -> (kind, id, start/update)
-> Context matches + State + Location
@@ -361,7 +361,7 @@ Slot type/runtime tests pin required parent-provided common inject, the `hookCon
Assembled Web snapshots, GUI tests, and browser scenarios cover the real plugin graph. Browser evidence compares Assistant streaming→settled, Bash running→settled, and Code Mode root + nested subcalls against master layout.
History-path tests cover complete replace, non-overlapping prepend, overlapping-seq deduplication, empty-page `hasMore` convergence, and live append. Equal Event windows ingested through different paths produce equal business State and final Nodes.
History-path tests cover complete replace, non-overlapping prepend, complete-range deduplication, partial-overlap rejection, empty-page `hasMore` convergence, and scalar live append. Scalar and packed representations of the same Assistant history produce equal Chat and Trajectory State, timing boundaries, and final Nodes; one packed run remains one Match through replace, prepend, Location replay, and registry rebuild.
## Alternatives considered
@@ -377,6 +377,8 @@ History-path tests cover complete replace, non-overlapping prepend, overlapping-
**Define a reverse State fold for backward history scanning.** Rejected: every business would maintain two inverse algorithms, and deletion, non-invertible aggregation, and cross-Context dependencies would be difficult to keep equivalent. Ordered Matches followed by forward replay from start preserve one business meaning.
**Add a separate chunk-run matcher and update lifecycle.** Rejected: a second Definition path would duplicate dispatch, replay, publication, and Context types. `ChunkRowEvent` uses the existing `match(event)` and `update(context, match)` lifecycle while making packed handling explicit through its `chunkrow/*` discriminant.
**Make Inbox a first-class engine concept or one window-wide Context.** Rejected: Inbox is ordinary business State and does not belong in the generic engine. Per-splice instantaneous State plus a strictly backward Reader supports prepend, append, and Message lookup together.
**Register specialized query methods for cross-business reads.** Rejected: consumers would still depend on provider APIs, and each new relationship would expand a central interface. Reader exposes a named kind's read-only predecessor Context; the provider writes useful State and the consumer interprets it.
@@ -399,14 +401,14 @@ A new business node can register its matcher, State transitions, optional Locati
Host business packages declaration-merge their durable Event members into `@deepseek-ai/dsh-session/types`, while Client Definitions type-only import the corresponding business package `/types` subpaths. Augmenting the declaring interface rather than a re-export barrel gives the independent Host and Client TypeScript programs the same Event narrowing without pulling Host runtime into the Client graph.
Initial tail, older prepend, and live append share one set of Context invariants. Missing starts, Reader window gaps, unknown Locations, and high-frequency deltas are explicit engine states and require no direction-specific business cache.
Initial tail, older prepend, and live append share one set of Context invariants. Missing starts, Reader window gaps, unknown Locations, and packed high-frequency deltas are explicit engine states and require no direction-specific business cache.
Append does not scan historical Contexts; prepend replays only Contexts whose Matches, Locations, or Reader answers actually changed. A structural Chat change may still recompute visible order and indexes, but does not rerun unrelated business folds or replace unchanged Node identity.
Separating State updates from publication cadence folds every Assistant delta while materializing at most once per animation frame. Step or Turn close and final Events can immediately publish the latest State.
Separating State updates from publication cadence folds every live Assistant delta and each historical packed run while materializing at most once per animation frame. Step or Turn close and final Events can immediately publish the latest State.
Steps and Turns become stable homes for cross-business aggregates. Turn Tail and Deliverables no longer depend on renderers scanning global Nodes; slot-level `useTurnData()` narrows common reads to the current Node's Turn and uses selector equality to isolate unrelated updates.
Steps and Turns are stable homes for cross-business aggregates. Turn Tail and Deliverables derive their values without renderer scans of global Nodes; slot-level `useTurnData()` narrows common reads to the current Node's Turn and uses selector equality to isolate unrelated updates.
The cost is new Runtime contracts for Registry, Assembler, Location data, dependency replay, and per-target Builders, plus parent-owned common inject and per-occurrence `hookContext` in UI Slots. Definition authors must understand stable IDs, unique starts, forward replay, Step→Turn publication order, read-only Reader access, and the prohibition on Node withdrawal.
The cost is new Runtime contracts for Registry, Assembler, Location data, dependency replay, and per-target Builders, plus parent-owned common inject and per-occurrence `hookContext` in UI Slots. Definitions that consume Assistant deltas also maintain equivalent scalar and packed update branches. Definition authors must understand stable IDs, unique scalar starts, forward replay, Step→Turn publication order, read-only Reader access, and the prohibition on Node withdrawal.
`useTurnData()` does not revoke the standard `useSession` capability from session-scoped renderers, so this boundary relies on API guidance and tests rather than capability isolation. Registry changes remain low-frequency full rebuilds; the Chat Builder still maintains a legacy slice for StatsLine and the top-level public fields, while Trajectory owns target-specific Definitions and a Builder over the shared Session window. Built-in Definitions remain in their respective UI packages, and these compatibility boundaries do not return business interpretation to Session.
@@ -8,13 +8,13 @@ Status: implemented
Client Session 既维护传输窗口、连接状态和待处理交互,也在中心化 transcript fold 中解释 Assistant、Tool、消息、命令、压缩、重试及 turn tail 等业务事件。每增加一种业务节点,都要修改 Session 的 switch、历史 replay、索引、缓存和 React 分组;业务 identity、状态演进与最终展示没有独立所有者。
旧链路还把运行中的 Assistant 和 Tool 放在 finalized flow 之外。它们结算后才进入按日志排序的节点列表,因此 React parent 改变,即使业务 ID 和 `key` 不变也会重新挂载。全量历史加载、older prepend、实时 append 与 token streaming 分别走不同更新路径,使引用稳定和局部重算只能各处特化缓存维持
缺少 target-neutral assembly 时,运行中的 Assistant 和 Tool 会位于 finalized flow 之外结算后才进入按日志排序的节点列表React parent 因而改变,即使业务 ID 和 `key` 稳定也会重新挂载。全量历史加载、older prepend、实时 append 与 token streaming 分别走不同更新路径,引用稳定和局部重算只能依赖各处特化缓存。
业务事件之间的关联方式并不统一。Tool 有 call IDAssistant 以 turn/step 关联,Compaction 有独立生命周期和 checkpointInbox splice 则表示一个连续状态的瞬间。把这些差异继续塞进统一 fold,会让任一业务变化都经过全局查表并使无关缓存失效。
## 决策
Client Runtime 提供 target-neutral 的 Conversation Node 组装引擎,业务插件注册 Event Definition,视图插件注册 per-Session View Builder。`ui-conversation` 注册第一批内建 Definition 和 `chat` builderSession 只负责把当前连续事件窗口送入引擎并发布它的 snapshot,不解释具体 conversation 业务。
Client Runtime 提供 target-neutral 的 Conversation Node 组装引擎,业务插件注册 Event Definition,视图插件注册 per-Session View Builder。`ui-conversation` 注册第一批内建 Definition 和 `chat` builderSession 只负责把当前连续 `SessionEventLikeEntry` window 送入引擎并发布它的 snapshot不解释具体 conversation 业务。entry 的外层 discriminator 区分标准与 packed record,两者都携带字段对齐的内部 `SessionEventLike`,供 Definition dispatch。
本 Note 保留实现后仍有价值的方案推导、逐业务适配、职责、算法和取舍。
@@ -22,20 +22,20 @@ Client Runtime 提供 target-neutral 的 Conversation Node 组装引擎,业务
| 层 | 长期职责 | 明确不负责 |
|---|---|---|
| Session | 维护连续 Event 窗口,区分 replace、prependappend,调度 snapshot 通知 | 解释 Tool、Assistant、Compaction 等业务事件 |
| Session | 维护连续逻辑 event window,区分 replace、prepend 与 scalar append,调度 snapshot 通知 | 解释 Tool、Assistant、Compaction 等业务事件 |
| Event Registry | 按 Cordis 生命周期保存唯一 `kind` 的 Definition 和唯一 fallback | 保存某个 Session 的 Context 或 State |
| Assembler | 匹配 Event,维护 Context、Location、依赖和发布脏集 | 理解业务 State 字段或 Chat 排序 |
| Assembler | 匹配标准 event 或 packed run,维护 Context、Location、依赖和发布脏集 | 理解业务 State 字段或 Chat 排序 |
| Node Definition | 定义一个业务对象的 identity、State 演进、Location data 和 target Node | 创建 Context、修改别的业务 State 或扫描全部 Context |
| View Builder | 把最终 target Node 增量整理成该视图的 snapshot | 重新解释原始 Session Event |
| View Builder | 把最终 target Node 增量整理成该视图的 snapshot | 重新解释 `SessionEventLike` input |
| React renderer | 按最终 Node 的 `kind` 展示 renderer-owned data,并读取当前 Node 所属 Location 的只读业务 data | 配对业务 Event、扫描全局 Nodes 或决定业务生命周期 |
Registry 注册是 Cordis effectDefinition 卸载会触发现有 Session 的低频 registry rebuild。普通业务 Event 不改变 Registry,也不会因此重建全部业务类型。
### `ConversationNodeDefinition` 总体契约
每个 [`ConversationNodeDefinition`](../../../../packages/client/ui-conversation/src/client/contract/conversation.ts) 独立拥有一种业务对象从 Event 到 State 和最终 view Node 的转换。Definition 的 `kind` 是 Registry 内唯一名称,也是业务 ID 的命名空间。
每个 [`ConversationNodeDefinition`](../../../../packages/client/ui-conversation/src/client/contract/conversation.ts) 独立拥有一种业务对象从 `SessionEventLike` input 到 State 和最终 view Node 的转换。Definition 的 `kind` 是 Registry 内唯一名称,也是业务 ID 的命名空间。
同一个 Event 可以被多个普通 Definition 认领。例如一条 Assistant Event 同时更新 Assistant Node 和 Turn Tail;一条 Retry Event 同时更新 Retry、Assistant 和 Turn Tail。Assembler 只有在全部普通 Definition 都返回 `null` 时才询问 fallback。
同一个 input 可以被多个普通 Definition 认领。例如一条 Assistant event 或 packed run 同时更新 Assistant Node 和 Turn Tail;一条 Retry Event 同时更新 Retry、Assistant 和 Turn Tail。Assembler 只有在全部普通 Definition 都返回 `null` 时才询问 fallback。
Definition 不持有跨 Session 的可变业务数据。每个 Session 的 Context、State、依赖和 View Builder 都由该 Session 的 Assembler 隔离持有。
@@ -49,9 +49,9 @@ Assembler 使用 `conversationContextKey(kind, id)` 组合无碰撞 key;不同
#### `match(event)`
`match(event)` 只读取当前原始 `SessionEvent`,返回 `{ id, role: 'start' | 'update' }``null`。它拿不到 Context、历史、Reader、Location 或 view envelope。
`match(event)` 只读取当前 `SessionEventLike`,返回 `{ id, role: 'start' | 'update' }``null`。它拿不到 Context、历史、Reader、Location 或 view envelope。`chunkrow/*` event 只能作为 updateAssembler 会拒绝 packed start`start()` 接收的 `ConversationStartMatch` 只包含标准 `SessionEvent`
这项限制使单条 Event 的路由成本只随已注册 Definition 数量增长。Assembler 不会为了判断一条 update 属于谁而遍历该 Definition 的历史 Context。
这项限制使单条 scalar event 或 packed run 的路由成本只随已注册 Definition 数量增长。Assembler 不会为了判断一条 update 属于谁而遍历该 Definition 的历史 Context。
start、result、resource、checkpoint 及业务自有终止 Event 必须携带或可直接推导同一 ID。若单个 Event 不能算出 ID,生产 Event 的协议负责补足关联字段,Client 不通过“最近一个未完成对象”猜测。
@@ -59,9 +59,9 @@ start、result、resource、checkpoint 及业务自有终止 Event 必须携带
#### `ConversationMatch`
匹配成功后,Assembler 把原始 Event、可选的 wire presentation view`role` 和引擎计算的 `location` 组成只读 `ConversationMatch`
匹配成功后,Assembler 把标准或 packed event`role` 和引擎计算的 `location` 组成只读 `ConversationMatch`一个 packed run 始终只占一个 Match,并保留 fragment 与 timestamp-gap 数组。
Context 的 `matches` 永远按 Event `seq` 升序保存,而不是按网络到达或分页摄入顺序保存。历史尾页先出现 result、older 页后出现 call 时,最终 Match 顺序仍然是 call 在前、result 在后。
Context 的 `matches` 永远按 `seq` 升序保存,而不是按网络到达或分页摄入顺序保存。Session journal 已经拒绝逻辑 range 重叠。历史尾页先出现 result、older 页后出现 call 时,最终 Match 顺序仍然是 call 在前、result 在后。
Location 可以随 prepend 补齐边界或 append 关闭边界而改变。Assembler 替换受影响 Match 的只读 Location 并 replay Context;业务不把旧 Location 副本当权威保存。
@@ -71,8 +71,8 @@ Location 可以随 prepend 补齐边界或 append 关闭边界而改变。Assemb
|---|---|---|
| `key` | Assembler | `kind + id` 的稳定最终 identity |
| `kind` / `id` | Definition + Assembler | 当前业务命名空间和业务 ID |
| `matches` | Assembler | 当前窗口已收集且按 `seq` 排序的完整业务证据 |
| `start` | Assembler | 唯一 start Match;尚未加载时为 `undefined` |
| `matches` | Assembler | 当前窗口已收集且按 `seq` 排序的完整 scalar 与 packed 业务证据 |
| `start` | Assembler | 唯一 scalar start Match;尚未加载时为 `undefined` |
| `state` | Definition 返回、Assembler 持有 | 最近一次 `start`/`update` 返回值;未初始化时为 `undefined` |
| `current` | Assembler | 各 target 最近一次 materialize 的 Node 或 `null` |
@@ -108,7 +108,7 @@ Reader 每次查询都记录 `{ key, revision, windowGap }` 依赖。命中前
#### `update(context, match)`
`update()` 只处理已经由 `match()` 精确路由到当前 `(kind, id)` 的 post-start Match。它不判断 Event 属于哪个 Context。
`update()` 只处理已经由 `match()` 精确路由到当前 `(kind, id)` 的 post-start scalar 或 packed Match。它不判断 input 属于哪个 Context。消费 Assistant delta 的 Definition 会把每个匹配的 `chunkrow/*` 值作为一个 batch fold,而不构造成员 event。
Assembler 按 `seq` 升序调用 `update()`。实时尾部 update 可以直接增量应用;任何非尾部证据插入、start 补齐或依赖失效都会从 `start()` 完整 replay。
@@ -126,9 +126,9 @@ Assembler 不以 State 引用相等判断是否需要发布或传播。每次成
| `animation-frame` | 把多条高频更新合并到下一帧 materialize |
| `none` | 本 Match 不主动安排 flushState 和 dirty 标记仍被保留 |
省略 `publication()` 等于 `immediate`。Assistant token delta 使用 `animation-frame`,不可见 Inbox Context 使用 `none`final、依赖 replay 和 Location 边界会以 immediate 路径发布最新结果。
省略 `publication()` 等于 `immediate`。Assistant token delta 与 packed run 使用 `animation-frame`,不可见 Inbox Context 使用 `none`final、依赖 replay 和 Location 边界会以 immediate 路径发布最新结果。
一帧内的每条 delta 仍执行 update;合并的只是 `buildViewNode()`、View Builder 和 React snapshot 通知,不会丢失 token。
一帧内的每条 live delta 仍执行 `update()`,一个历史 packed run 则执行一次 batch `update()`;合并的只是 `buildViewNode()`、View Builder 和 React snapshot 通知,不会丢失 fragment
#### `buildLocationData(context, scope)`
@@ -160,7 +160,7 @@ ID 不复用,完成的 Context 继续存在于当前窗口,既提供稳定
### Location 是一级引擎事实
[`ConversationLocationIndex`](../../../../packages/client/ui-conversation/src/client/conversation/location-index.ts) 根据 `turn/start``step/start`、显式 turn/step payload、`step/end``turn/end` 建立 Event 到 Location 的映射。
[`ConversationLocationIndex`](../../../../packages/client/ui-conversation/src/client/conversation/location-index.ts) 根据 `turn/start``step/start`、显式 turn/step payload、`step/end``turn/end` 建立标准 event 与 packed run 到 Location 的映射。同一 row 的成员共享 turn、step、block index 与 delta kind,因此只需以首 `seq` 建立一条 Location entry。
Location 有 `session``turn``step``unresolved` 四种形状。Turn/Step 各自带 `open``closed``unknown` 状态,以及已加载的 start/end Event。
@@ -168,31 +168,31 @@ Location 有 `session`、`turn`、`step` 和 `unresolved` 四种形状。Turn/St
`unresolved` 表示当前历史窗口缺少足够前序边界,不等于 session-level。older prepend 补入边界后,索引修正 Match Location,并只 replay 拥有这些 seq 的 Context。
Append 普通 Event 只继承当前坐标;append 边界只重算所属 Turn。Prepend 会基于扩展后的完整连续窗口重建 Location facts,但引用稳定逻辑保留未变化 Turn/Step 对象。
Append 标准 Event 只继承当前坐标;append 边界只重算所属 Turn。Prepend 会基于连续 `SessionEventLikeEntry` window 重建 Location facts,但引用稳定逻辑保留未变化 Turn/Step 对象。
Assembler 还把 reference-stable timeline 交给 View Builder。业务不重复维护 turn order、step list、last step 或边界 Map。
## 三种事件窗口链路
## 三种 input window 链路
“历史反扫”描述 UI 从最新尾页向 Session 起点逐页加载的方向,不表示 Definition 逆序执行 `update()`无论历史 API 返回顺序或页面加载方向如何,Assembler 对每个当前窗口和每个 fresh page 都按 `seq` 升序 canonicalize
“历史反扫”描述 UI 从最新尾页向 Session 起点逐页加载的方向,不表示 Definition 逆序执行 `update()`Session journal 会在发布前校验每条 record 的逻辑 range;无论分页加载方向如何,Assembler 都按每个已接受标准 event 或 packed run 的首 `seq` 排序
| 场景 | 输入范围 | Context/State 处理 | View Builder |
|---|---|---|---|
| 初始历史尾页或 resync | 当前完整连续窗口 | 清空并按 `seq` 正序重建全部 Context | `replace()` |
| 加载一页 older history | 只传更早且去重后的 fresh Events | 保留现有 Context identity,补 Match、Location 和依赖后局部 replay | `apply(upserts)` |
| 初始历史尾页或 resync | 当前完整连续逻辑窗口 | 清空并按 `seq` 正序重建全部 Context | `replace()` |
| 加载一页 older history | 只传通过 range 校验的更早标准 event 或 packed run | 保留现有 Context identity,补 Match、Location 和依赖后局部 replay | `apply(upserts)` |
| 实时 append | 一条连续尾部 Event | 只匹配 Definitions 并精确更新命中 ID,边界只影响所属 Turn | `apply(upserts)` |
### 初始历史尾页与逻辑反扫
1. `Session.open()` 拉取最新 tail page,并把连续 History Entries 交给 `replaceWindow(entries, hasMore)`
1. `Session.open()` 拉取最新 tail page,并把连续 `SessionEventLike` entry 交给 `replaceWindow(entries, hasMore)`
2. `replaceWindow` 清空旧 Context、start-seq 索引、seq 反向索引、Reader 依赖和输入 Map。
3. 全部 entries 按 Event `seq` 升序排序并写入当前窗口。
3. 全部 entry 按首个逻辑 `seq` 升序排序并写入当前窗口。
4. LocationIndex 对这个窗口重建 Turn/Step facts。
5. Assembler 按升序 Event 逐条调用每个普通 Definition 的 `match(event)`
5. Assembler 按升序访问标准 event 与 packed run,并逐条调用每个普通 Definition 的 `match(event)`
6. 每个命中结果按 `(kind, id)` 取得或创建 Context,并把 Match 插入该 Context 的有序数组。
7. 遇到 start 时执行 `start()`;已有 State 的尾部 update 直接执行 `update()`
8. 当前页只含 result/resource 而缺 start 时,Context 仍会按 ID 创建并收集 Matches,但 State 保持 `undefined`
9. 全部 Event 匹配后,Assembler 复查 Reader 依赖,使同一窗口内较早瞬间态先稳定、较晚消费者再读取它。
9. 全部 input 匹配后,Assembler 复查 Reader 依赖,使同一窗口内较早瞬间态先稳定、较晚消费者再读取它。
10. 所有 Context 标记 dirty,下一次 flush 先按 Step→Turn 完整重建 Location data,再对每个 target 调用 `buildViewNode()`
11. 某些业务在缺 start 时返回 `null`Compaction、Command、Tool result 或 Turn Error 等可根据充分 update 证据构造 fallback Node。
12. 每个 View Builder 收到完整 Node 集和 timeline,通过 `replace()` 建立初始 snapshot。
@@ -206,12 +206,12 @@ Assembler 还把 reference-stable timeline 交给 View Builder。业务不重复
### 新 older 分页的 prepend
1. `Session.loadOlder()` 以当前 `baseSeq` 拉取紧邻前页,并先验证页尾与当前窗口连续。
2. Session 把 raw Event/view 数组 prepend 到自己的窗口,只把这一页传给 `assembler.prepend(entries, hasMore)`
3. Assembler 按 seq 去掉与当前窗口重叠的 Events,再把 fresh page 内部升序排列
2. Session 把已接受的标准或 packed entry prepend 到自己的窗口,只把这一页传给 `assembler.prepend(entries, hasMore)`
3. Journal 已经丢弃完整重复 range 并拒绝部分重叠;Assembler 再按首 `seq` 排列 fresh page。
4. 已存在的 Context、State、current Nodes 和 View Builder 实例不清空。
5. LocationIndex 用扩展后的完整输入重建 facts,并报告 Location identity 真正变化的 seq。
6. 拥有这些 seq 的 Context 更新 Match Location,并从 start replay;无关 Context 不参与 Location replay。
7. fresh Events 逐条执行 Definition matcher,并按稳定 ID 入已有或新 Context 的有序 Matches
7. fresh 标准 event 与 packed run 通过同一 Definition matcher稳定 ID 入已有或新 Context。
8. 新页补出 pending Context 的 start 时,该 Context 从 start 初始化,再正序应用已经收集的所有 updates。
9. 新页建立更近的 Reader predecessor、改变 predecessor revision 或消除 window gap 时,消费者从 `start()` 重算。
10. Reader 依赖沿 start seq 向后传递 replay;同一传播批次不会把 Event 逆序应用。
@@ -226,7 +226,7 @@ Reader gap 修复是 prepend 与普通 append 最大的算法差异。新页不
### 正向实时 append
1. Session 只接受紧邻当前 tail seq 的 live Event;重叠 seq 去重,出现 gap 时先走 tail-page repair。
1. Session 只接受紧邻当前逻辑 tail seq 的标准 live Event;重叠去重,出现 gap 时先走 tail-page repair。
2. 非边界 Event 增量写入当前 Turn/Step 坐标;边界 Event 更新所属 Turn 的 Location facts。
3. Assembler 对这一个 Event 的每个普通 Definition 调用一次 `match()`,不会遍历任何 Definition 的 Context 集合。
4. 每个命中结果通过 `(kind, id)` 直接定位一个 Context。
@@ -249,7 +249,7 @@ Chat `order` 的结构性变化仍可能重排当前可见 key;纯 data 更新
`replaceWindow` 是初始打开、resync、gap repair 和 registry 变化的低频完整替换,不用于实现普通 load older。`prepend``append` 都保留现有 Builder 和 Context identity。
分页页宽、历史加载次数和 RAF 合批只影响何时得到更多证据或何时发布,不改变窗口证据相同时的最终 Context State 与 Node。
分页页宽、record packing、历史加载次数和 RAF 合批只影响何时得到更多证据或何时发布,不改变逻辑证据相同时的最终 Context State 与 Node。
## 内建业务如何使用 Definition
@@ -261,7 +261,7 @@ Chat `order` 的结构性变化仍可能重排当前可见 key;纯 data 更新
| Next-step Inbox / `inbox-next-step` | splice Event seq | 每条目标为 next-step 的 `agent/inbox/spliced` | 无 | 同样形成逐指令瞬间态,claimed 集合供 Message 读取 |
| Message / `input-message` | message ID | append-surface `user/message` | 无 | 根据 source 生成 context message,或读取最近 next-step Inbox 判断 user/steering |
| Request Prompt / `request-prompt` | header Event seq | 每条 `request/header` | 无 | 通过 Reader 读取前一条 Request Prompt,保留完整 prompt 状态,并判定 system/tool 变化 |
| Assistant / `assistant-step` | `turn:step` | `step/start` | `assistant/chunk`、final `assistant/message`、同 step Retry | 聚合 blocks、usage、首 token 时间、final 和 retry 隐藏状态,并发布同 key Step data |
| Assistant / `assistant-step` | `turn:step` | `step/start` | scalar 或 packed `assistant/chunk`、final `assistant/message`、同 step Retry | 聚合 blocks、usage、首 token 时间、final 和 retry 隐藏状态,并发布同 key Step data |
| Tool / `tool-call` | root call ID | root `tool/call` | root result、Code Dispatch start/result | 聚合 root、children 和 parent MapDispatch Event 用 `rootCallId` 精确路由 |
| Command / `command` | command ID | `command/run` | `command/done`、带 source command ID 的 compact lifecycle/checkpoint | 聚合 command outcome 和手动压缩证据 |
| Automatic Compaction / `compaction` | compaction ID | 无 source command ID 的 `compaction/start` | summary、end、replacement checkpoint | 聚合 summary/checkpointcheckpoint 足够时可在缺 start 下 fallback |
@@ -278,7 +278,7 @@ Chat `order` 的结构性变化仍可能重排当前可见 key;纯 data 更新
| Inbox | `none` | 不生成 Node | prepend 补前序 splice 时沿 Reader 链重算瞬间态 |
| Message | 默认 immediate | `user``steering``context` | window gap 修复可让同一 message key 重新分类 |
| Request Prompt | 默认 immediate | 每条带非空 system 字段的 header 都生成一个 `system-prompt` | Step 首条 header 锚定在请求消息之前;同 step 后续序列锚定在表层改写之后;prepend 补入前序 header 后可纠正部分窗口的锚点 |
| Assistant | chunk 为 RAFfinal immediate,纯 usage/finish 为 none | 同 key `assistant-step`,状态为 running/settled/interrupted | 缺 `step/start` 可先用 Matches fallbackLocation close 生成中断表现 |
| Assistant | scalar chunk 与 packed run 为 RAFfinal immediate,纯 usage/finish 为 none | 同 key `assistant-step`,状态为 running/settled/interrupted | scalar 与 packed reducer 等价;`step/start` 可先用 Matches fallbackLocation close 生成中断表现 |
| Tool | 默认 immediate | 一个递归 `tool-call` root,包含全部 `subCalls` | result-only 历史窗口可 fallbackrunning→settled 保持 key |
| Command | 默认 immediate | 普通 `command` 或集成 `manual-compaction` | checkpoint 到达可改变 anchor,但不改变 Context key |
| Compaction | 默认 immediate | `compaction` marker | checkpoint 可先展示,older 补 start 后正序 replay |
@@ -326,20 +326,20 @@ Slot-level contextual Hook 与 entry-owned `inject.hooks` 是两条独立路径
标准 `useSession` 仍属于所有 session-scoped slot renderer 的公开能力,`useTurnData()` 是收窄常见读取方式而不是权限沙箱。全窗口统计或任意对象索引仍可显式使用 Session snapshot;它们不能伪装成“当前 Node 的 Turn data”。
Assistant streaming 到 final、Tool running 到 settled 只更新同一个 Seat data 和必要的排序属性,不再从末尾 running container 移入 finalized flow,因此组件内部 State 不因结算自动归零
Assistant streaming 到 final、Tool running 到 settled 始终留在同一个 Seat,只更新 data 和必要的排序属性。结算不会因跨 parent 移动而重置组件内部 State。
业务主动把已发布 Node 改成 hidden 时,它会退出 visible order,恢复 visible 时会重新 mount。这是明确的业务撤显语义,与 running→settled 的稳定 Seat 保证不同。
具体 Tool renderer 仍由 [`ui-tool ownership decision`](2026-08-08-client-tool-presentation-ownership.zh.md) 约束。Tool Definition 只交付递归 root/subcall data`ui-tool` 再按 Tool name keyed slot 分发具体表现。
Trajectory 针对与 Chat 相同的 Assembler 和 Session 事件窗口注册自己的 target 与业务 Definition。它的 target builder 保留 stage-oriented read model,既不消费 Chat Builder 的 legacy slice,也不运行独立 history fold。Chat Builder 为 StatsLine 和顶层公共兼容字段保留 legacy slicetarget 专属 Definition 不改变共享的 Context、Reader 或 Location 契约。
Trajectory 针对与 Chat 相同的 Assembler 和 `SessionEventLikeEntry` window 注册自己的 target 与业务 Definition。它的 target builder 保留 stage-oriented read model,既不消费 Chat Builder 的 legacy slice,也不运行独立 history fold。Chat 与 Trajectory 分别维护独立的 scalar 和 packed Assistant reducertarget 专属 Definition 不改变共享的 Context、Reader 或 Location 契约。
target 专属 Trajectory Definition、保留的 stage model、Steering 适配、复杂度上界与表现层热点由 [Trajectory Context 组装决策](2026-08-11-trajectory-conversation-context-assembly.zh.md)负责。
## 运行时与渲染链路
```text
Session Event window
SessionEventLike window
-> ConversationNodeAssembler
-> Definition.match(event) -> (kind, id, start/update)
-> Context matches + State + Location
@@ -361,7 +361,7 @@ Slot type/runtime tests 固定父注册必须提供声明的 common inject、`ho
Assembled Web snapshot、GUI 和浏览器场景覆盖真实 plugin graph。浏览器证据比较 Assistant streaming→settled、Bash running→settled 以及 Code Mode root + nested subcalls 与 master 的布局。
历史链路验证同时覆盖完整 replace、非重叠 prepend、重叠 seq 去重、空页 `hasMore` 收敛和 live append。相同 Event 窗口通过不同摄入路径得到相同业务 State 与最终 Node
历史链路验证同时覆盖完整 replace、非重叠 prepend、完整 range 去重、部分重叠拒绝、空页 `hasMore` 收敛和 scalar live append。相同 Assistant 历史的 scalar 与 packed 表示产生相同 Chat/Trajectory State、timing boundary 与最终 Node;一个 packed run 在 replace、prepend、Location replay 与 registry rebuild 中始终只保留一个 Match
## 考虑过的替代方案
@@ -377,6 +377,8 @@ Assembled Web snapshot、GUI 和浏览器场景覆盖真实 plugin graph。浏
**为历史反扫定义逆向 State fold。** 拒绝:每个业务都要维护互为逆运算的两套逻辑,删除、非可逆聚合和跨 Context 依赖很难保持一致。统一 Matches 后从 start 正序 replay 只有一套业务语义。
**增加独立的 chunk-run matcher 与 update lifecycle。** 拒绝:第二条 Definition 路径会重复 dispatch、replay、publication 与 Context 类型。`ChunkRowEvent` 使用既有 `match(event)``update(context, match)` lifecycle,并通过 `chunkrow/*` discriminator 明确标记 packed 处理。
**把 Inbox 做成引擎一级公民或一个窗口级 Context。** 拒绝:Inbox 是普通业务状态,不应污染通用引擎;逐 splice 瞬间态加严格前序 Reader 同时支持 prepend、append 和 Message 查询。
**给跨业务查询注册特化 query method。** 拒绝:消费者仍要依赖提供方 API,新增关系会扩张中心接口。Reader 暴露指定 kind 的只读前序 Context,由提供方写好 State、消费者读懂 State。
@@ -395,18 +397,18 @@ Assembled Web snapshot、GUI 和浏览器场景覆盖真实 plugin graph。浏
## 后果
新增业务节点可以局部注册自己的 matcher、State 转换、可选 Location data、最终 target Node 和 renderer不再修改 Session 的业务 switch。`ChatNodeDataMap` 和 Location data maps 允许业务 package 通过 declaration merging 合入强类型 data;所有相关 Event 仍须暴露可单 Event 推导的稳定 ID。
新增业务节点可以局部注册自己的 matcher、State 转换、可选 Location data、最终 target Node 和 renderer无需修改 Session 的业务 switch。`ChatNodeDataMap` 和 Location data maps 允许业务 package 通过 declaration merging 合入强类型 data;所有相关 Event 仍须暴露可单 Event 推导的稳定 ID。
Host 业务 package 把自己的持久 Event 成员 declaration-merge 到 `@deepseek-ai/dsh-session/types`Client Definition 则通过对应业务 package 的 `/types` 子路径进行 type-only import。增强实际声明接口而不是重导出 barrel,使 Host 和 Client 的独立 TypeScript Program 都能获得相同的 Event narrowing,同时不把 Host runtime 带入 Client 图。
初始尾页、older prepend 和 live append 共享一套 Context 不变量。缺 start、Reader window gap、Location unknown 以及高频 delta 都是引擎明确表达的状态,不需要业务另建方向相关 cache。
初始尾页、older prepend 和 live append 共享一套 Context 不变量。缺 start、Reader window gap、Location unknown 以及 packed 高频 delta 都是引擎明确表达的状态,不需要业务另建方向相关 cache。
Append 不扫描历史 Contextprepend 只 replay Match、Location 或 Reader 答案真正受影响的 Context。Chat 结构变化仍可能重算 visible order 和索引,但不会重跑无关业务 fold 或替换未变化 Node identity。
State 更新与发布频率分离后,Assistant 每条 delta 被 fold,同时每 animation frame 最多 materialize 一次。step/turn close 和 final 可立即发布最新 State。
State 更新与发布频率分离后,Assistant 每条 live delta 与每个历史 packed run 都会被 fold,同时每 animation frame 最多 materialize 一次。step/turn close 和 final 可立即发布最新 State。
Step/Turn 成为业务间共享聚合的稳定宿主。Turn Tail 和 Deliverables 不再依赖 renderer 扫描全局 NodesSlot-level `useTurnData()` 把常见读取限制到当前 Node 所属 Turn,并通过 selector equality 隔离无关更新。
Step/Turn 业务间共享聚合的稳定宿主。Turn Tail 和 Deliverables 无需由 renderer 扫描全局 Nodes 即可派生值Slot-level `useTurnData()` 把常见读取限制到当前 Node 所属 Turn,并通过 selector equality 隔离无关更新。
代价是 Runtime 新增 Registry、Assembler、Location data、依赖重放和 per-target Builder 契约,UI Slots 也新增 parent-owned common inject 与 per-occurrence `hookContext`。Definition 作者必须理解稳定 ID、唯一 start、正序 replay、Step→Turn 发布顺序、只读 Reader 和 Node 不撤回规则。
代价是 Runtime 新增 Registry、Assembler、Location data、依赖重放和 per-target Builder 契约,UI Slots 也新增 parent-owned common inject 与 per-occurrence `hookContext`消费 Assistant delta 的 Definition 还需要维护等价的 scalar 与 packed update 分支。Definition 作者必须理解稳定 ID、唯一 scalar start、正序 replay、Step→Turn 发布顺序、只读 Reader 和 Node 不撤回规则。
`useTurnData()` 不撤销 session-scoped renderer 的标准 `useSession`,因此该边界依靠 API 引导和测试,而不是能力隔离。Registry 变化仍是低频完整 rebuildChat Builder 继续为 StatsLine 和顶层公共字段维护 legacy sliceTrajectory 则在共享 Session 窗口上拥有 target 专属 Definition 与 Builder。内建 Definition 分别留在所属 UI package;这些兼容边界不把业务解释权交还给 Session。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md
2026-08-10-product-subagent-providers-in-shared-host.md: eca1d5b6b9b0e46b39c6c2ef382bfc261d014e44
2026-08-10-product-subagent-providers-in-shared-host.zh.md: 374a3ef72e298bb621fd71d306a4fca16c8000b4
2026-08-10-product-subagent-providers-in-shared-host.md: a998408c4dd01ba8f7ab09eab453a963beb855a4
2026-08-10-product-subagent-providers-in-shared-host.zh.md: cce746399b7ec553d145441cc3d51df3e051ca9b
@@ -14,9 +14,9 @@ The placement decision must preserve two independent facts. Loading a provider m
Product providers remain process-scoped host-plane registrations. The [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md) supersedes only this note's former base-bundle installation choice: production `dsh-base` neither depends on nor mounts them. A Profile that opts in installs the selected provider Bundle; its patch mounts the default instance, and the Profile may mount additional named instances on the host plane. The [named-instance decision](../feature/2026-08-18-product-subagent-named-instances.md) owns each row's registry identity: both products accept multiple unique `providerName` values while preserving `codex` and `claude-code` as their defaults. Loading either plugin only registers a dormant backend; the corresponding Codex or Claude process starts on the first actual delegation call. Agent Presets independently contribute ordinary `dsh-tool-subagent` rows whose `provider` and `toolName` values expose exactly the configured instances needed by one agent without changing the Host registry.
Each provider package owns its directly installable Bundle patch and private product runtime. This note continues to own process-wide Host placement whenever either provider is installed. The provider-contract note continues to own each product protocol, result mapping, cancellation, process-tree lifecycle, and evidence tiers. The [named-instance decision](../feature/2026-08-18-product-subagent-named-instances.md) owns the optional Claude Code model and other per-instance configuration. The [Agent Preset architecture](2026-08-03-per-session-agent-presets.md) continues to own the Host/Agent split, preset authoring, and the rule that edits affect only newly composed sessions.
Each provider package owns its directly installable Bundle patch and private product runtime. This note continues to own process-wide Host placement whenever either provider is installed. The provider-contract note continues to own each product protocol, result mapping, cancellation, process-tree lifecycle, and evidence tiers. The [named-instance decision](../feature/2026-08-18-product-subagent-named-instances.md) owns both optional product models and other per-instance configuration. The [Agent Preset architecture](2026-08-03-per-session-agent-presets.md) continues to own the Host/Agent split, preset authoring, and the rule that edits affect only newly composed sessions.
Each Bundle delegates executable selection to its package-owned product runtime: the Codex package runs its declared wrapper, while the Claude Code package lets its pinned Agent SDK select the private native executable. Neither provider consults or falls back to a host product command. Profile loading creates no product state, probes no version or authentication, and may supply each mounted Provider instance's deployment configuration, including an optional opaque model where supported and the product-specific `permissionMode` values owned by the [non-interactive permissions decision](../feature/2026-08-15-product-subagent-noninteractive-permissions.md), without moving those choices into an Agent Preset or model-facing tool. Missing platform payloads and product failures remain local to the attempted delegation.
Each Bundle delegates executable selection to its package-owned product runtime: the Codex package runs its declared wrapper, while the Claude Code package lets its pinned Agent SDK select the private native executable. Neither provider consults or falls back to a host product command. Profile loading creates no product state, probes no version or authentication, and may supply each mounted Provider instance's deployment configuration, including an optional opaque model and the product-specific `permissionMode` values owned by the [non-interactive permissions decision](../feature/2026-08-15-product-subagent-noninteractive-permissions.md), without moving those choices into an Agent Preset or model-facing tool. Missing platform payloads and product failures remain local to the attempted delegation.
## Verification
@@ -14,9 +14,9 @@ Status: implemented
产品提供方仍是进程级的 host plane(宿主平面)注册。[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.zh.md)只取代本说明原先由 base bundle 安装提供方的选择:生产 `dsh-base` 既不依赖也不挂载它们。选择产品集成的 Profile 会安装目标提供方 Bundle;其 patch 挂载默认实例,而 Profile 可以在 host plane 挂载更多命名实例。[命名实例决策](../feature/2026-08-18-product-subagent-named-instances.zh.md)负责每个配置项的注册身份:两个产品都接受多个唯一的 `providerName`,同时保留 `codex``claude-code` 作为默认值。加载任一插件只会注册一个休眠后端;对应的 Codex 或 Claude 进程直到第一次实际委派调用时才启动。Agent Preset 通过普通 `dsh-tool-subagent` 配置项的 `provider``toolName` 准确公开单个 agent 所需的已配置实例,而无需更改 Host 注册表。
每个提供方包都拥有可直接安装的 Bundle patch 与私有产品运行时。本说明继续负责每个已安装提供方的进程级 Host 放置。提供方约定说明继续负责每个产品的协议、结果映射、取消、进程树生命周期与证据层级。[命名实例决策](../feature/2026-08-18-product-subagent-named-instances.zh.md)负责可选 Claude Code 模型及其他逐实例配置。[Agent Preset 架构](2026-08-03-per-session-agent-presets.zh.md)继续负责宿主与 agent 的划分、preset 创作,以及改动只影响新组装会话的规则。
每个提供方包都拥有可直接安装的 Bundle patch 与私有产品运行时。本说明继续负责每个已安装提供方的进程级 Host 放置。提供方约定说明继续负责每个产品的协议、结果映射、取消、进程树生命周期与证据层级。[命名实例决策](../feature/2026-08-18-product-subagent-named-instances.zh.md)负责两个可选产品模型及其他逐实例配置。[Agent Preset 架构](2026-08-03-per-session-agent-presets.zh.md)继续负责宿主与 agent 的划分、preset 创作,以及改动只影响新组装会话的规则。
每个 Bundle 都把可执行文件选择交给包自有的产品运行时:Codex 包运行自身声明的 wrapperClaude Code 包则让锁定的 Agent SDK 选择私有原生可执行文件。两个提供方都不会查询或回退宿主产品命令。加载 Profile 不会创建产品状态、探测版本或测试身份验证;它可以提供每个已挂载 Provider 实例的部署配置,包括产品支持时可选的不透明模型,以及由[非交互权限决策](../feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md)负责的产品专属 `permissionMode` 值,但不会把这些选择移入 Agent Preset 或面向模型的工具。平台载荷缺失和产品故障仍局限于发生问题的那次委派。
每个 Bundle 都把可执行文件选择交给包自有的产品运行时:Codex 包运行自身声明的 wrapperClaude Code 包则让锁定的 Agent SDK 选择私有原生可执行文件。两个提供方都不会查询或回退宿主产品命令。加载 Profile 不会创建产品状态、探测版本或测试身份验证;它可以提供每个已挂载 Provider 实例的部署配置,包括可选的不透明模型,以及由[非交互权限决策](../feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md)负责的产品专属 `permissionMode` 值,但不会把这些选择移入 Agent Preset 或面向模型的工具。平台载荷缺失和产品故障仍局限于发生问题的那次委派。
## 验证
@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.md
2026-08-18-product-subagent-failure-facts.md: e47fc3246ce20e988cd693b2eb04f785d37bcb27
2026-08-18-product-subagent-failure-facts.zh.md: 5efd15e0e22d38b0100a96fc020ba3671fd257ed
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-15-packed-session-history-transport.md
2026-08-15-packed-session-history-transport.md: 01e36509b7ad2c878ae4ea04c3a10f029e1b8f3d
2026-08-15-packed-session-history-transport.zh.md: 590385dcfac901ab01e472ee75e766e51bf4b001
@@ -0,0 +1,61 @@
# Agent Note: Carry packed chunk rows through session history
Status: implemented
English | [中文](2026-08-15-packed-session-history-transport.zh.md)
## Problem
`session.page` and the opening `session.follow` snapshot serve a bounded logical Session-event interval to remote clients. Provider streams can place hundreds of thousands of token-sized `assistant/chunk` events in one incomplete tail. Expanding every persisted row and then serializing every logical event repeats the same envelope on the wire. Expanding a packed response at the Client boundary recreates the same event objects, journal entries, Location indexing, Definition matches, and State updates before conversation replay can finish.
The transport must remain lossless. Session sequence numbers are pagination and reconnect evidence; exact fragment boundaries and timestamps remain useful to diagnostics and non-UI API consumers; live streaming, durable export, replay, and model-history derivation continue to require the canonical event stream. Browser presentation does not require one allocated event object and one Definition callback per historical fragment when a Definition can fold the lossless run directly.
## Decision
History pages and follow opening snapshots carry `records: SessionHistoryRecord[]`. An ordinary record is `{ type: 'event', event: SessionWireEvent }`; consecutive same-block Assistant delta events use `{ type: 'chunks', event: ChunkRowEvent }` and the shared lossless codec from [the packed JSONL decision](2026-07-26-packed-chunk-rows-by-default.md). The Host constructs the event-shaped value once when it packs the selected page. Its `type` is `chunkrow/text-chunks`, `chunkrow/reasoning-chunks`, or `chunkrow/tool-call-chunks`; `seq` and `time` identify the first member, while `data` retains the original fragment and timestamp-gap arrays. The explicit outer discriminator selects the record class without interpreting that detailed chunk kind. The page is selected from logical events before packing, so message-aligned pagination remains independent of physical persistence layout.
The generated Remote decoder validates the response fields. `SessionEventStream` passes the original wire records to `RemoteJournalStream` and supplies each record's inclusive logical sequence range: an event covers `[event.seq, event.seq]`, while a row covers `[event.seq, event.seq + memberCount - 1]`. The journal checks page continuity, pagination joins, reconnect repair, complete duplicates, partial overlaps, and live-event deduplication before publishing records. The durable address in the page request selects either an ordinary Session or an authorized direct subagent child without a second history protocol.
The Client narrows the accepted `SessionHistoryRecord[]` to `SessionEventLikeEntry[]` without allocating replacement entries. The outer `type` remains available to the journal, Session, and assembler; both variants carry an inner value with aligned `type`, `seq`, `time`, and `data` fields. `ChunkRowEvent` is Client history data, not a durable Session event: it is absent from `SessionEventMap`, `Session.events`, and `session/event`.
Conversation accepts the same `{ type, event }` entries retained by Session. Definitions receive the inner `SessionEventLike`: `match()` and `update()` accept standard or packed values, while `start()` accepts only a standard `SessionEvent`; the assembler uses the outer discriminator to reject a packed start. Chat Assistant, Turn Tail, and Trajectory Assistant handle the three packed tags in their existing reducers. One row therefore remains one Client entry, Conversation input, and Match, while those reducers preserve scalar replay's final blocks, tool-call fields, first-token time, first-visible boundary, retry behavior, and interruption state.
Live `session.follow` frames remain individual events and use the scalar path, so visible streaming cadence is unchanged. Session persistence, raw export, replay, model-history derivation, and the canonical in-memory log are unchanged.
## Measured result
A production-sized private session sample was measured without retaining or committing its content. Its tail page contained 416,756 logical events. The lossless packed response used 696 top-level records, including 116 packed rows.
| Representation | Top-level records | JSON bytes | gzip bytes | Brotli bytes |
| --- | ---: | ---: | ---: | ---: |
| Raw logical events | 416,756 | 69,433,638 | 4,190,226 | 1,972,998 |
| Completed-step projection candidate | 228,129 | 38,427,209 | 2,324,688 | 957,350 |
| Lossless packed history | 696 | 6,362,724 | 1,154,206 | 528,145 |
Packing reduced uncompressed JSON by 90.8% relative to raw logical events and by 83.4% relative to the lossy completed-step projection candidate. Brotli output was 73.2% smaller than raw and 44.8% smaller than that projection candidate. These figures describe this sample rather than a protocol guarantee; savings scale with the length and regularity of delta runs.
One-to-one Client retention keeps the same sample at 696 history entries and Conversation inputs instead of restoring 416,756 event entries. A local synthetic benchmark run measured Client parse, validation, retention, and two-Definition fold at 4,682.11 ms for scalar input and 276.10 ms for packed input, with sampled additional V8 heap peaks of 612,523,344 and 199,436,928 bytes respectively. These machine-dependent values are observations rather than thresholds.
The opt-in `packages/client/ui-conversation/tests/history-transport.perf.client.ts` benchmark constructs the same logical-event, ordinary-event, and delta-run cardinalities from synthetic content. `DSH_SNAPSHOT=replay pnpm exec vitest run --config vitest.web.perf.config.ts packages/client/ui-conversation/tests/history-transport.perf.client.ts` reports wire sizes, Host/client timing, uncompressed chunked Node loopback transfer medians, combined synthetic API-wait/UI-ready timing, and sampled additional V8 heap peaks under `HISTORY_TRANSPORT_PERF_RESULT`; a second inventory reports batch-fold medians for 10,000-, 20,000-, and 40,000-member whitespace-prefix runs under `HISTORY_WHITESPACE_PREFIX_PERF_RESULT`. The combined timing starts from an in-memory event array and omits cold persistence reads, the production API bridge and RPC envelope, and Chromium scheduling, so it is comparative inventory rather than production wall-clock latency. Heap measurements force garbage collection before three runs and report the median peak observed after each major Host construction/serialization or Client parse/validation/retention/fold stage, relative to the same initialized benchmark state; they do not measure process RSS, external or ArrayBuffer memory, or transients within a sampled stage. The manual performance inventory does not run in CI and carries no machine-dependent timing or memory assertions; structural assertions pin the fixture cardinalities, one Client input per wire record, and identical final state—including delta count and last-delta sequence—from its two-consumer Assistant fold fixture.
## Alternatives considered
**Discard completed-step chunks on the Host.** This lowers logical event count but makes transport semantics depend on the current transcript policy, removes exact evidence from all consumers, and still sends every retained incomplete-step token as a separate envelope. The measured packed response is smaller while remaining lossless.
**Expand each packed row before the Session object layer.** This preserves one callback per historical delta but recreates the browser allocation, indexing, and fold costs that packed transport can avoid. Consumers that require scalar events can still call `decodeStorageRecord()` explicitly.
**Put the raw row under a distinct `.chunks` payload.** This forces downstream consumers either to retain two payload field names or to allocate an aligned wrapper before assembly. The shared `.event` field preserves fast outer classification and one inner Definition path.
**Rely on HTTP content encoding.** gzip and Brotli reduce bytes on the network but do not remove repeated JSON parsing, validation, allocation, indexing, and fold work.
**Page directly over physical persistence rows.** This could also avoid logical expansion in a cold Host read, but page cuts depend on append-origin messages and replacement provenance rather than backend row boundaries. The current decision keeps the API independent of JSONL, SQLite, and future persistence layouts.
**Return only assembled Assistant snapshots.** The [assembled-messages-only rejection](../../rejected/simplification/2026-06-20-assembled-assistant-messages-only.md) remains applicable: event families outside finalized messages carry user-visible and diagnostic state, and incomplete steps need their actual accumulated chunks.
## Consequences
History responses preserve every logical event while reducing wire bytes, Host response serialization and heap, browser JSON parsing and validation, Client entry allocation, and Conversation dispatch for long delta runs. The journal validates logical ranges before publication, so packed records neither create false gaps nor hide partial overlap. Direct `session.page` consumers must switch on `SessionHistoryRecord.type` and explicitly expand `record.event.data` when they require one event per member.
Cold persisted history is still decoded into the complete logical `SessionEvent[]` before the Host selects and repacks a page. This decision therefore improves transport and browser work, not the Host's cold-read decode memory. Eliminating that expansion requires a persistence-neutral message-boundary index or a separate streaming page reader and remains a distinct optimization.
The default Client history path exposes `SessionEventLike`, so consumers that require only canonical durable events must remain on Host `Session.events`, `session/event`, or an explicit decode path. A Definition that consumes Assistant deltas maintains equivalent scalar and packed branches. Scalar deltas already received live remain scalar in the current window; online replacement with a packed row is separate work, while reopen and reconnect install packed history.
@@ -0,0 +1,61 @@
# Agent Note: 在会话历史中传输打包分片行
Status: implemented
[English](2026-08-15-packed-session-history-transport.md) | 中文
## 问题
`session.page``session.follow` opening snapshot 会向远程 Client 提供一段有界的逻辑 Session event 区间。提供方流可能在一个未完成尾部中产生数十万个 token 大小的 `assistant/chunk` 事件。先展开每条持久化行,再序列化每个逻辑事件,会在协议中重复相同 envelope。在 Client 边界展开 packed response 还会重新创建同样数量的 event object、journal entry、Location index、Definition match 和 State update,拖慢 conversation replay。
传输必须保持无损。Session seq 是分页与重连证据;精确 fragment 边界和时间戳对诊断与非 UI API 消费方仍然有用;实时流式传输、持久导出、回放与模型历史派生仍然需要规范事件流。当 Definition 可以直接 fold 无损 run 时,浏览器表现并不需要为每个历史 fragment 分配一个 event object 并执行一次 Definition callback。
## 决策
历史页与 follow opening snapshot 携带 `records: SessionHistoryRecord[]`。普通 record 为 `{ type: 'event', event: SessionWireEvent }`;连续且属于同一 block 的 Assistant delta event 使用[打包 JSONL 决策](2026-07-26-packed-chunk-rows-by-default.zh.md)中的共享无损 codec,表示为 `{ type: 'chunks', event: ChunkRowEvent }`。Host 在打包已选页面时只构造一次 event-shaped value。其 `type``chunkrow/text-chunks``chunkrow/reasoning-chunks``chunkrow/tool-call-chunks``seq``time` 表示首成员,`data` 保留原 fragment 与 timestamp-gap 数组。显式外层 discriminator 无需解释详细 chunk kind 即可选择 record 类别。系统先从逻辑 event 中选择页面,再执行打包,因此按消息对齐的分页不依赖物理持久化布局。
生成的 Remote decoder 会校验响应字段。`SessionEventStream` 把原始 wire record 交给 `RemoteJournalStream`,并提供每条 record 的逻辑 seq 闭区间:event 覆盖 `[event.seq, event.seq]`row 覆盖 `[event.seq, event.seq + memberCount - 1]`。Journal 在发布 record 前检查页面连续性、分页拼接、重连修复、完整重复、部分重叠和实时 event 去重。页面请求中的 durable address 既可选择普通 Session,也可选择已授权的 direct subagent child,无需第二套历史协议。
Client 不分配替换 entry,直接把已接受的 `SessionHistoryRecord[]` 收窄为 `SessionEventLikeEntry[]`。外层 `type` 会一直保留到 journal、Session 与 assembler;两个分支都携带字段对齐的内部值,其中包含 `type``seq``time``data``ChunkRowEvent` 是 Client 历史数据,不是持久 Session event:它不会进入 `SessionEventMap``Session.events``session/event`
Conversation 接受 Session 保留的同一组 `{ type, event }` entry。Definition 接收内部 `SessionEventLike``match()``update()` 接受标准或 packed value`start()` 只接受标准 `SessionEvent`assembler 使用外层 discriminator 拒绝 packed start。Chat Assistant、Turn Tail 和 Trajectory Assistant 在既有 reducer 中处理三种 packed tag。一条 row 因此始终只对应一个 Client entry、Conversation input 与 Match,而这些 reducer 会保留 scalar replay 的最终 block、tool-call 字段、首 token 时间、首个可见边界、retry 行为和 interruption 状态。
实时 `session.follow` frame 仍是单个 event 并走 scalar 路径,因此可见 streaming cadence 不变。Session persistence、原始导出、回放、模型历史派生与规范内存日志均不改变。
## 测量结果
测量使用了一份生产规模的私有会话样本,未保留或签入其内容。其尾页包含 416,756 个逻辑事件。无损打包响应使用 696 条顶层记录,其中包含 116 条打包行。
| 表示 | 顶层记录数 | JSON 字节 | gzip 字节 | Brotli 字节 |
| --- | ---: | ---: | ---: | ---: |
| 原始逻辑事件 | 416,756 | 69,433,638 | 4,190,226 | 1,972,998 |
| 已完成步骤投影候选 | 228,129 | 38,427,209 | 2,324,688 | 957,350 |
| 无损打包历史 | 696 | 6,362,724 | 1,154,206 | 528,145 |
与原始逻辑事件相比,打包使未压缩 JSON 减少 90.8%;与有损的已完成步骤投影候选相比减少 83.4%。Brotli 输出相对原始形式减少 73.2%,相对该投影候选减少 44.8%。这些数字描述该样本,并非协议保证;收益随 delta run 的长度与规律性变化。
一对一 Client 保留使同一规模样本保持为 696 个 history entry 与 Conversation input,而不会恢复成 416,756 个 event entry。一次本地合成 benchmark 观测到:Client parse、validation、retention 与双 Definition fold 在 scalar input 下耗时 4,682.11 ms,在 packed input 下耗时 276.10 ms;采样额外 V8 heap 峰值分别为 612,523,344 与 199,436,928 字节。这些依赖机器的数值是观测结果,不是门槛。
可选运行的 `packages/client/ui-conversation/tests/history-transport.perf.client.ts` benchmark 使用合成内容构造相同的逻辑 event 数、普通 event 数与 delta run 数。`DSH_SNAPSHOT=replay pnpm exec vitest run --config vitest.web.perf.config.ts packages/client/ui-conversation/tests/history-transport.perf.client.ts` 会在 `HISTORY_TRANSPORT_PERF_RESULT` 下报告 wire 体积、Host/Client 计时、未压缩且采用 chunked response 的 Node loopback 传输中位数、组合后的合成 API 等待/UI 就绪时间,以及采样的额外 V8 heap 峰值;第二组清单会在 `HISTORY_WHITESPACE_PREFIX_PERF_RESULT` 下报告 10,000、20,000 与 40,000 个成员 whitespace-prefix run 的 batch fold 中位数。组合计时从内存 event 数组开始,不包含冷持久化读取、生产 API bridge 与 RPC envelope,也不包含 Chromium 调度,因此它是对比清单,而非生产环境 wall-clock 延迟。Heap 测量会在三次运行前强制执行垃圾回收,并相对于相同的已初始化 benchmark 状态,报告 Host 构造/序列化或 Client 解析/校验/保留/fold 各主要阶段之后所观察峰值的中位数;该指标不测量进程 RSS、external 或 ArrayBuffer 内存,也可能遗漏单个采样阶段内部的瞬态峰值。CI 不执行这组手动性能用例,其中也没有依赖机器性能的耗时或内存断言;结构断言固定 fixture 规模、每条 wire record 对应一个 Client input,以及双消费方 Assistant fold fixture 的一致最终状态,包括 delta 数量与末个 delta seq。
## 曾考虑的替代方案
**在 Host 丢弃已完成步骤的分片。** 这会减少逻辑事件数,但会让传输语义取决于当前 transcript 策略,从所有消费方移除精确证据,同时仍把保留的未完成步骤 token 逐个装入信封。实测打包响应在保持无损的同时更小。
**在进入 Session 对象层前展开每条 packed row。** 这会保留每个历史 delta 一次 callback 的语义,但也会重新产生 packed transport 原本可以避免的浏览器分配、索引和 fold 成本。确实需要 scalar event 的消费方仍可显式调用 `decodeStorageRecord()`
**把原始 row 放在独立的 `.chunks` payload 下。** 这会迫使下游消费方保留两种 payload 字段名,或在进入 assembly 前分配字段对齐的包装层。共享 `.event` 字段既保留快速外层分类,也保留一条内部 Definition 路径。
**只依赖 HTTP 内容编码。** gzip 与 Brotli 会减少网络字节,但不会移除重复的 JSON 解析、校验、分配、索引与 fold 工作。
**直接按物理持久化行分页。** 这还可以避免冷 Host 读取时的逻辑展开,但页面切分取决于追加来源消息与替换 provenance,而不是后端行边界。当前决策让 API 保持对 JSONL、SQLite 与未来持久化布局的独立性。
**只返回组装后的 Assistant 快照。** [仅保留组装消息的否决记录](../../rejected/simplification/2026-06-20-assembled-assistant-messages-only.zh.md)仍然适用:final message 之外的事件族承载用户可见状态与诊断状态,未完成步骤也需要其实际累计分片。
## 后果
历史响应保留每个逻辑 event,同时减少长 delta run 的 wire 字节、Host 响应序列化与 heap、浏览器 JSON 解析与校验、Client entry 分配,以及 Conversation dispatch。Journal 在发布前校验逻辑 range,因此 packed record 既不会产生伪 gap,也不会隐藏部分重叠。直接调用 `session.page` 的消费方必须按 `SessionHistoryRecord.type` 分支;需要逐 member event 时再显式展开 `record.event.data`
冷持久历史仍会先解码成完整的逻辑 `SessionEvent[]`,Host 再选择页面并重新打包。因此,本决策改善的是传输与浏览器工作,不是 Host 冷读取的解码内存。消除该展开需要提供方无关的消息边界索引或单独的流式页面读取器,属于另一项优化。
默认 Client 历史路径公开 `SessionEventLike`,因此只接受规范持久 event 的消费方必须继续使用 Host `Session.events``session/event` 或显式 decode 路径。消费 Assistant delta 的 Definition 需要维护等价的 scalar 与 packed 分支。当前窗口已经实时接收的 scalar delta 仍保持 scalar;在线替换为 packed row 属于另一项工作,reopen 与 reconnect 则安装 packed 历史。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md
2026-08-04-claude-code-and-codex-subagent-backends.md: 8af99c9171e073e5901390fed29047b350fe4924
2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 5abc0b16077bcf0be7835b2cf687a1709b53fc81
2026-08-04-claude-code-and-codex-subagent-backends.md: 04b8fcb9c3fd899a485a12ce9c002b8ab5d6bbb3
2026-08-04-claude-code-and-codex-subagent-backends.zh.md: de9248ed7c82266bd6083020ab32190a3dfd8d1f
@@ -12,7 +12,7 @@ The product integrations must not become second owners for task text, cwd, cance
## Decision
The harness publishes two sibling one-shot provider packages whose default registry names are `codex` and `claude-code`. This note owns their product protocols, result mapping, and process lifecycle; the [named-instance decision](2026-08-18-product-subagent-named-instances.md) owns Profile-selected provider identity, optional instance model where supported, and static tool binding; the [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md) owns their independent optional Bundles and host-plane placement; the [product one-shot background decision](2026-08-12-product-subagent-one-shot-background-tasks.md) owns the model-visible scheduling choice; the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md) owns each product Provider's Profile-selected mode and safe permission decisions; and the [minimal-diagnostics decision](../simplification/2026-08-21-product-subagent-minimal-diagnostics.md) owns coarse product action categories while the [structured failure-facts decision](2026-08-18-product-subagent-failure-facts.md) continues to describe Codex's current detailed categories. Both packages accept multiple named instances. Loading either provider starts no product process, and each tool accepts only a standalone text task; product and instance selection remain deployment configuration.
The harness publishes two sibling one-shot provider packages whose default registry names are `codex` and `claude-code`. This note owns their product protocols, result mapping, and process lifecycle; the [named-instance decision](2026-08-18-product-subagent-named-instances.md) owns Profile-selected provider identity, optional instance model, and static tool binding; the [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md) owns their independent optional Bundles and host-plane placement; the [product one-shot background decision](2026-08-12-product-subagent-one-shot-background-tasks.md) owns the model-visible scheduling choice; the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md) owns each product Provider's Profile-selected mode and safe permission decisions; and the [minimal-diagnostics decision](../simplification/2026-08-21-product-subagent-minimal-diagnostics.md) owns coarse product action categories. Both packages accept multiple named instances. Loading either provider starts no product process, and each tool accepts only a standalone text task; product and instance selection remain deployment configuration.
Both providers report `inheritsParentContext: false`, advertise no optional start capabilities, and pass the parent Session cwd without copying the parent conversation. Their documented tools use `backgroundMode: 'one-shot'` and `maxDepth: 'provider-managed'`: the consumer keeps foreground collection as the default and may place the same run in the generic Job runtime, while recursion policy stays with the out-of-process product. Every call creates a fresh product process and a non-resumable product conversation. `ctx.subagents` owns named-request resolution and paired lifecycle events; `dsh-tool-subagent` owns model-visible scheduling and foreground-versus-Job adaptation; `ctx.jobs` and `dsh-tool-jobs` own Job ids, state, output, controls, notices, and parent-owner cancellation; each product provider owns native result mapping, while `dsh-subprocess` owns credential scrubbing, process-tree termination, and whole-tree exit observation.
@@ -34,17 +34,17 @@ configured tool -> dsh-tool-subagent -> ctx.subagents -> product provider -> pro
## Codex provider
`@deepseek-ai/dsh-subagent-codex` registers a Profile-selected provider name that defaults to `codex`, resolves the `codex` bin declared by its pinned `@openai/codex@0.147.0` package, and starts that wrapper through the current Node executable with `app-server --stdio`. The wrapper selects the private native platform payload; the provider neither resolves nor falls back to a host `codex`. Its public configuration contains a non-empty `providerName`, an explicit `env` overlay, a positive finite `disposeGraceMs` no greater than the repository's shared `MAX_TIMER_DELAY_MS`, and a three-value native `permissionMode` that defaults to `never`. Each named instance retains those resolved values for its own runs. Installation, login, `CODEX_HOME`, model selection, base URL, and product-session settings remain native Codex or deployment responsibilities; the selected mode owns only the thread approval/reviewer/sandbox fields described by the non-interactive permissions decision.
`@deepseek-ai/dsh-subagent-codex` registers a Profile-selected provider name that defaults to `codex`, resolves the `codex` bin declared by its pinned `@openai/codex@0.149.1` package, and starts that wrapper through the current Node executable with `app-server --stdio`. The wrapper selects the private native platform payload; the provider neither resolves nor falls back to a host `codex`. Its public configuration contains a non-empty `providerName`, an optional non-empty `model`, an explicit `env` overlay, a positive finite `disposeGraceMs` no greater than the repository's shared `MAX_TIMER_DELAY_MS`, and a three-value native `permissionMode` that defaults to `never`. Each named instance retains those resolved values for its own runs. An explicit model is passed unchanged on every ephemeral `thread/start`; omission leaves native Codex settings authoritative. Installation, login, `CODEX_HOME`, model discovery or fallback, base URL, and product-session settings remain native Codex or deployment responsibilities; the selected mode owns only the thread approval/reviewer/sandbox fields described by the non-interactive permissions decision.
Before publication, the provider validates a non-empty text-only task, starts the managed app-server in the parent workspace, completes `initialize``initialized`, maps the resolved mode into official `thread/start` fields, and creates an `ephemeral: true` thread. The fixed app-server argv contains no mode or task text. The published run owns exactly one `turn/start`; its thread and turn ids remain private and are never persisted in the parent Session.
Before publication, the provider validates a non-empty text-only task, starts the managed app-server in the parent workspace, completes `initialize``initialized`, maps the optional model and resolved mode into official `thread/start` fields, and creates an `ephemeral: true` thread. The fixed app-server argv contains no model, mode, or task text. The published run owns exactly one `turn/start`; its thread and turn ids remain private and are never persisted in the parent Session.
`turn/completed` is the authoritative remote terminal fact. The latest `agentMessage` with `phase: "final_answer"` wins, and that selected message must contain nonblank text. When the product emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback and must likewise be nonblank; commentary never replaces either answer. The [structured failure-facts decision](2026-08-18-product-subagent-failure-facts.md) owns Codex error-info categories, HTTP status, lifecycle stages, process outcomes, and stop-reason preservation. Local cancellation remains `aborted` without a failure diagnostic.
`turn/completed` is the authoritative remote terminal fact. The latest `agentMessage` with `phase: "final_answer"` wins, and that selected message must contain nonblank text. When the product emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback and must likewise be nonblank; commentary never replaces either answer. The [minimal-diagnostics decision](../simplification/2026-08-21-product-subagent-minimal-diagnostics.md) owns Codex action categories, HTTP status, lifecycle stages, process outcomes, and stop-reason preservation. Local cancellation remains `aborted` without a failure diagnostic.
For command and file approvals, the unattended wire selects a non-approval decision offered by the request, preferring `cancel`; the stable 0.147.0 request shape without an offered-decision list falls back to `decline`. It grants no requested permissions for the turn, answers user-input requests with no answers, and declines MCP elicitation. It records safe categories for those requests, declined command/file items, and `sandboxError`. Codex emits some early `never` rejections and sandbox violations only on structured stderr, so the Provider pipes and forwards stderr unchanged while matching two fixed signatures in a bounded per-run tail; raw stderr never enters the diagnostic. A request with no legal unattended response, or any unknown server request, fails the run instead of waiting for a user interface the provider does not supply.
For command and file approvals, the unattended wire selects a non-approval decision offered by the request, preferring `cancel`; the stable 0.149.1 request shape without an offered-decision list falls back to `decline`. It grants no requested permissions for the turn, answers user-input requests with no answers, and declines MCP elicitation. It records safe categories for those requests, declined command/file items, and structured `sandboxError` terminals. Product stderr is forwarded unchanged to the Host but is neither classified nor copied into the diagnostic. A request with no legal unattended response, or any unknown server request, fails the run instead of waiting for a user interface the provider does not supply.
An unpublished startup failure closes the wire, terminates the acquired process tree, waits for exit, detaches the stderr observer, and then rejects `start()` with its fixed operation stage. Published disposal best-effort interrupts a known turn, closes the wire, ends stdin, invokes the shared termination escalation, waits for whole-tree exit, and detaches the observer. Independent cleanup failure reports `teardown`; when startup and rollback both fail, the aggregate's top message retains both safe stage lines while the underlying causes remain internal.
Codex 0.147.0 speaks the Responses protocol, while DeepSeek's public OpenAI-compatible endpoint speaks Chat Completions. The credentialed Codex e2e therefore uses a loopback-only, test-private bridge for one no-tool nonce request: real Codex sends Responses to the bridge, the bridge forwards the received bearer credential and extracted task to the fixed official DeepSeek endpoint, and it wraps the real text in the minimal Responses SSE lifecycle. The bridge is neither a production proxy nor evidence that Codex connects to DeepSeek Chat Completions natively.
Codex 0.149.1 speaks the Responses protocol, while DeepSeek's public OpenAI-compatible endpoint speaks Chat Completions. The credentialed Codex e2e therefore uses a loopback-only, test-private bridge for one no-tool nonce request: real Codex sends Responses to the bridge, the bridge forwards the received bearer credential and extracted task to the fixed official DeepSeek endpoint, and it wraps the real text in the minimal Responses SSE lifecycle. The bridge is neither a production proxy nor evidence that Codex connects to DeepSeek Chat Completions natively.
## Claude Code provider
@@ -62,7 +62,7 @@ The credentialed Claude Code e2e uses the official DeepSeek Claude Code contract
Each product owns branch-complete package tests, a required keyless real-product spec, a Loader composition e2e, and a credentialed DeepSeek e2e. The keyless product tier uses the exact official distribution under test, a non-empty fake product key, an isolated temporary workspace and product home, and a loopback fixed-answer model. Missing product requests, wrong authentication, altered task text, a non-exact answer, a skipped real product, or a surviving managed handle fails the required test. The Codex Loader fixture exposes two named Codex instances and tools; the Claude Code Loader fixture exposes the default Codex tool plus two named Claude Code instances and tools. Both fixtures include generic Job controls and start neither product process. The credentialed tier starts the same production provider and real product with a runtime-only key, requires a unique nonce from the fixed official DeepSeek service, and proves quiescence again; it self-skips only when a local operator supplied no key, while trusted CI preflights the secret.
The Codex evidence pins `@openai/codex@0.147.0`, `codex-cli 0.147.0`, and all six optional platform aliases. Its real-product spec observes the package-local wrapper argv, exact Bearer key, original task, byte-exact final answer, native permission modes, explicit dangerous-bypass writing in suite-owned temporary storage, and wrapper/native whole-tree exit. An isolated wrapper fixture proves missing-payload failure without host fallback, two named instances retain separate environments and modes, and production never resolves a host `codex` from `PATH`. The [structured failure-facts decision](2026-08-18-product-subagent-failure-facts.md) owns schema, failure, process-outcome, and final presentation evidence.
The Codex evidence pins `@openai/codex@0.149.1`, `codex-cli 0.149.1`, and all six optional platform aliases. Its generated schema proves optional `ThreadStartParams.model`; the real-product spec observes omitted-model inheritance, two explicit instance models, the package-local wrapper argv, exact Bearer key, original task, byte-exact final answer, native permission modes, explicit dangerous-bypass writing in suite-owned temporary storage, and wrapper/native whole-tree exit. An isolated wrapper fixture proves missing-payload failure without host fallback, named instances retain separate models, environments, and modes, and production never resolves a host `codex` from `PATH`. The [minimal-diagnostics decision](../simplification/2026-08-21-product-subagent-minimal-diagnostics.md) owns failure, process-outcome, and final presentation evidence.
The Codex credentialed e2e registers the production provider, starts the same real app-server, and requests one random nonce through the test-private bridge described above. It fixes the external endpoint and model, stores no credential or request payload, requires exactly one completed upstream response, compares the trimmed product answer byte-for-byte with the nonce, and waits for every managed handle to exit.
@@ -12,7 +12,7 @@ Status: implemented
## 决策
harness 交付两个同级的一次性提供方包,其默认注册名称分别为 `codex``claude-code`。本说明负责它们的产品协议、结果映射和进程生命周期;[命名实例决策](2026-08-18-product-subagent-named-instances.zh.md)负责 Profile 选择的提供方身份、支持时的可选实例模型与静态工具绑定;[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.zh.md)负责各自独立的可选 Bundle 与 host plane(宿主平面)放置;[产品一次性后台任务决策](2026-08-12-product-subagent-one-shot-background-tasks.zh.md)负责模型可见的调度选择;[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.zh.md)负责各产品提供方的 Profile 模式选择与安全权限决定;[最小诊断决策](../simplification/2026-08-21-product-subagent-minimal-diagnostics.zh.md)负责粗粒度产品行动类别,而[结构化失败事实决策](2026-08-18-product-subagent-failure-facts.zh.md)继续描述 Codex 当前的详细类别。两个包都接受多个命名实例。加载任一提供方都不会启动产品进程,而且每个工具只接受独立文本任务;产品与实例选择仍属于部署配置。
harness 交付两个同级的一次性提供方包,其默认注册名称分别为 `codex``claude-code`。本说明负责它们的产品协议、结果映射和进程生命周期;[命名实例决策](2026-08-18-product-subagent-named-instances.zh.md)负责 Profile 选择的提供方身份、可选实例模型与静态工具绑定;[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.zh.md)负责各自独立的可选 Bundle 与 host plane(宿主平面)放置;[产品一次性后台任务决策](2026-08-12-product-subagent-one-shot-background-tasks.zh.md)负责模型可见的调度选择;[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.zh.md)负责各产品提供方的 Profile 模式选择与安全权限决定;[最小诊断决策](../simplification/2026-08-21-product-subagent-minimal-diagnostics.zh.md)负责粗粒度产品行动类别。两个包都接受多个命名实例。加载任一提供方都不会启动产品进程,而且每个工具只接受独立文本任务;产品与实例选择仍属于部署配置。
这两个提供方都报告 `inheritsParentContext: false`,不声明任何可选的启动能力,并传递父会话 cwd,但不会复制父级对话。文档所示的工具使用 `backgroundMode: 'one-shot'``maxDepth: 'provider-managed'`:消费方默认在前台收集结果,也可把同一次运行放入通用 Job 运行时,而递归策略仍由进程外产品负责。每次调用都会创建一个全新的产品进程和一次不可续接的产品对话。`ctx.subagents` 负责具名请求解析与成对生命周期事件;`dsh-tool-subagent` 负责模型可见的调度以及前台与 Job 适配;`ctx.jobs``dsh-tool-jobs` 负责 Job id、状态、输出、控制、通知与父级 owner 取消;各产品提供方负责原生结果映射,`dsh-subprocess` 则负责凭证清洗、进程树终止以及整棵进程树的退出观测。
@@ -34,17 +34,17 @@ configured tool -> dsh-tool-subagent -> ctx.subagents -> product provider -> pro
## Codex 提供方
`@deepseek-ai/dsh-subagent-codex` 注册由 Profile 选择、默认值为 `codex` 的提供方名称,解析锁定的 `@openai/codex@0.147.0` 包所声明的 `codex` bin,并使用当前 Node 可执行文件加 `app-server --stdio` 启动该 wrapper。Wrapper 会选择私有原生平台载荷;提供方既不解析也不回退宿主 `codex`。其公开配置包含非空的 `providerName`、显式的 `env` 覆盖项、须为正有限值且不得大于仓库共享 `MAX_TIMER_DELAY_MS``disposeGraceMs`,以及默认使用 `never` 的三值原生 `permissionMode`。每个命名实例会为自己的运行保留这些已解析值。安装、登录、`CODEX_HOME`、模型选择、基础 URL 和产品会话设置仍由 Codex 原生机制或部署环境负责;所选模式只拥有非交互权限决策中描述的线程 approvalreviewersandbox 字段。
`@deepseek-ai/dsh-subagent-codex` 注册由 Profile 选择、默认值为 `codex` 的提供方名称,解析锁定的 `@openai/codex@0.149.1` 包所声明的 `codex` bin,并使用当前 Node 可执行文件加 `app-server --stdio` 启动该 wrapper。Wrapper 会选择私有原生平台载荷;提供方既不解析也不回退宿主 `codex`。其公开配置包含非空的 `providerName`可选的非空 `model`显式的 `env` 覆盖项、须为正有限值且不得大于仓库共享 `MAX_TIMER_DELAY_MS``disposeGraceMs`,以及默认使用 `never` 的三值原生 `permissionMode`。每个命名实例会为自己的运行保留这些已解析值。显式模型会原样传给每个临时 `thread/start`;省略时仍以 Codex 原生设置为权威。安装、登录、`CODEX_HOME`、模型发现或 fallback、基础 URL 和产品会话设置仍由 Codex 原生机制或部署环境负责;所选模式只拥有非交互权限决策中描述的线程 approvalreviewersandbox 字段。
发布前,提供方会验证非空的纯文本任务,在父级工作区中启动受管的 app-server,完成 `initialize``initialized` 握手,把已解析模式映射为官方 `thread/start` 字段,并创建一个 `ephemeral: true` 线程。固定 app-server argv 不包含模式或任务文本。已发布的运行只拥有一次 `turn/start`;其线程 ID 与轮次 ID 保持私有,绝不会持久化到父会话。
发布前,提供方会验证非空的纯文本任务,在父级工作区中启动受管的 app-server,完成 `initialize``initialized` 握手,把可选模型与已解析模式映射为官方 `thread/start` 字段,并创建一个 `ephemeral: true` 线程。固定 app-server argv 不包含模型、模式或任务文本。已发布的运行只拥有一次 `turn/start`;其线程 ID 与轮次 ID 保持私有,绝不会持久化到父会话。
`turn/completed` 是权威的远端终止事实。以最后一条带有 `phase: "final_answer"``agentMessage` 为准,且选中的消息必须包含非空白文本。若产品没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退,该消息也必须包含非空白文本;过程说明绝不会取代上述任一答案。[结构化失败事实决策](2026-08-18-product-subagent-failure-facts.zh.md)负责 Codex error-info 类别、HTTP status、生命周期阶段、进程结果与终止原因保持。本地取消仍是 `aborted` 且不附带失败诊断。
`turn/completed` 是权威的远端终止事实。以最后一条带有 `phase: "final_answer"``agentMessage` 为准,且选中的消息必须包含非空白文本。若产品没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退,该消息也必须包含非空白文本;过程说明绝不会取代上述任一答案。[最小诊断决策](../simplification/2026-08-21-product-subagent-minimal-diagnostics.zh.md)负责 Codex 行动类别、HTTP status、生命周期阶段、进程结果与终止原因保持。本地取消仍是 `aborted` 且不附带失败诊断。
对于命令与文件审批,无人值守的协议连接会从请求给出的决策选项中选择一项不予批准的决策,并优先选择 `cancel`;稳定的 0.147.0 请求形态没有决策选项列表,因此回退到 `decline`。它不授予该轮次请求的任何权限,不向用户输入请求提供任何答案,并拒绝 MCP elicitation。它会记录这些请求、被拒绝的命令/文件 item 与 `sandboxError` 的安全类别。Codex 的部分早期 `never` 拒绝和 sandbox violation 只写入结构化 stderr,因此提供方会 pipe 并原样转发 stderr,同时在每次运行的有界尾部中匹配两个固定签名;原始 stderr 绝不会进入诊断。若请求在无人值守模式下没有合法响应,或是未知服务器请求,此次运行就会失败,而不会等待本提供方没有提供的用户界面。
对于命令与文件审批,无人值守的协议连接会从请求给出的决策选项中选择一项不予批准的决策,并优先选择 `cancel`;稳定的 0.149.1 请求形态没有决策选项列表,因此回退到 `decline`。它不授予该轮次请求的任何权限,不向用户输入请求提供任何答案,并拒绝 MCP elicitation。它会记录这些请求、被拒绝的命令/文件 item 与结构化 `sandboxError` 终态的安全类别。产品 stderr 会原样转发给 Host,但既不会被分类,也不会复制进诊断。若请求在无人值守模式下没有合法响应,或是未知服务器请求,此次运行就会失败,而不会等待本提供方没有提供的用户界面。
若启动在发布前失败,提供方会关闭协议连接、终止已获取的进程树、等待其退出、移除 stderr observer,然后用固定操作阶段拒绝 `start()`。对已发布的运行执行资源释放时,提供方会尽力中断已知轮次、关闭协议连接、结束标准输入、调用共享的逐级终止机制,等待整棵进程树退出,并移除 observer。独立清理失败会报告 `teardown`;启动与回滚同时失败时,聚合的顶层消息会保留两条安全阶段说明,而底层 cause 仍只在内部可见。
Codex 0.147.0 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端点使用 Chat Completions。因此,带密钥 Codex e2e 会采用一个仅限回环、仅供测试内部使用的桥接层来处理一次不使用工具的随机数请求:真实 Codex 将 Responses 发送到桥接层,桥接层把收到的 Bearer 凭据与提取出的任务转发到固定的 DeepSeek 官方端点,再将真实文本包装进最小化的 Responses SSEServer-Sent Events)生命周期。该桥接层既不是生产代理,也不能作为 Codex 原生连接 DeepSeek Chat Completions 的证据。
Codex 0.149.1 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端点使用 Chat Completions。因此,带密钥 Codex e2e 会采用一个仅限回环、仅供测试内部使用的桥接层来处理一次不使用工具的随机数请求:真实 Codex 将 Responses 发送到桥接层,桥接层把收到的 Bearer 凭据与提取出的任务转发到固定的 DeepSeek 官方端点,再将真实文本包装进最小化的 Responses SSEServer-Sent Events)生命周期。该桥接层既不是生产代理,也不能作为 Codex 原生连接 DeepSeek Chat Completions 的证据。
## Claude Code 提供方
@@ -62,7 +62,7 @@ Codex 0.147.0 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端
每个产品都负责覆盖所有分支的包测试、一项必跑的无密钥真实产品测试、一项 Loader 组合 e2e 和一项带密钥 DeepSeek e2e。无密钥产品层级使用被测的确切官方发行版、非空的伪产品密钥、隔离的临时工作区与产品主目录,以及能返回固定答案的回环模型。产品请求缺失、身份验证错误、任务文本被改动、答案不完全一致、真实产品被跳过或受管句柄仍存活,都会使这项必跑测试失败。Codex Loader fixture 会公开两个命名 Codex 实例与工具;Claude Code Loader fixture 会公开默认 Codex 工具以及两个命名 Claude Code 实例与工具。两个 fixture 都包含通用 Job 控制工具,而且不会启动任何产品进程。带密钥层级会使用仅在运行时提供的密钥启动同一生产提供方与真实产品,要求从固定的 DeepSeek 官方服务取得唯一随机数,并再次证明完全停稳;仅当本地操作者未提供密钥时才会自行跳过,而受信任的 CI 会预检该 secret。
Codex 证据会锁定 `@openai/codex@0.147.0``codex-cli 0.147.0` 与六个平台 alias。其真实产品测试会观测包内 wrapper argv、确切的 Bearer 密钥、原始任务、逐字节完全一致的最终回答、原生权限模式、测试拥有临时存储中的显式危险绕过写入,以及 wrapper/原生整棵进程树退出。独立 wrapper fixture 会证明载荷缺失时不回退宿主命令,两个命名实例会保留彼此独立的环境与模式,生产环境也不会从 `PATH` 解析宿主 `codex`。[结构化失败事实决策](2026-08-18-product-subagent-failure-facts.zh.md)负责 schema、失败、进程结果与最终呈现证据。
Codex 证据会锁定 `@openai/codex@0.149.1``codex-cli 0.149.1` 与六个平台 alias。生成 schema 会证明可选的 `ThreadStartParams.model`;真实产品测试会观测省略模型继承、两个显式实例模型、包内 wrapper argv、确切的 Bearer 密钥、原始任务、逐字节完全一致的最终回答、原生权限模式、测试拥有临时存储中的显式危险绕过写入,以及 wrapper/原生整棵进程树退出。独立 wrapper fixture 会证明载荷缺失时不回退宿主命令,命名实例会保留彼此独立的模型、环境与模式,生产环境也不会从 `PATH` 解析宿主 `codex`。[最小诊断决策](../simplification/2026-08-21-product-subagent-minimal-diagnostics.zh.md)负责失败、进程结果与最终呈现证据。
带密钥 Codex e2e 会注册生产提供方,启动同样的真实 app-server,并通过上述测试专用桥接层请求一个随机数。该测试固定外部端点与模型,不存储任何凭据或请求载荷,要求上游恰好完成一次响应,将去除首尾空白后的产品答案与该随机数逐字节比较,并等待所有受管句柄退出。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md
2026-08-15-product-subagent-noninteractive-permissions.md: f9fe1e0af4f65b83a53127a64f6475861a13f0a1
2026-08-15-product-subagent-noninteractive-permissions.zh.md: 2acb1bf095a23022fc761d254f0e515e1acf1588
2026-08-15-product-subagent-noninteractive-permissions.md: 8ce71cbf156d3e22a22fa8ad526b838ec1a5effe
2026-08-15-product-subagent-noninteractive-permissions.zh.md: e6471b2ce1c8ebbb767254df2426969a3da36f78
@@ -32,7 +32,7 @@ Every query disables `AskUserQuestion`. Non-bypass permission callbacks deny ins
### Codex
Codex defaults to `never` and accepts the three native non-interactive modes exposed by Codex 0.147.0. The Provider starts the fixed app-server command, then maps the selected mode into official `thread/start` fields because CLI-global permission flags do not configure threads created later by an app-server client:
Codex defaults to `never` and accepts the three native non-interactive modes exposed by Codex 0.149.1. The Provider starts the fixed app-server command, then maps the selected mode into official `thread/start` fields because CLI-global permission flags do not configure threads created later by an app-server client:
| Value | `thread/start` fields | Native behavior |
| --- | --- | --- |
@@ -40,13 +40,13 @@ Codex defaults to `never` and accepts the three native non-interactive modes exp
| `approve-for-me` | `approvalPolicy: on-request`, `approvalsReviewer: auto_review`, `sandbox: workspace-write` | Route permission requests through Codex automatic review. |
| `dangerously-bypass-approvals-and-sandbox` | `approvalPolicy: never`, `sandbox: danger-full-access` | Skip approval and sandbox enforcement. |
The Provider overrides only those thread fields. `CODEX_HOME`, project configuration, model/provider selection, MCP, hooks, skills, authentication, and sandbox facts not selected by the mode remain native Codex state. The wire still denies any unexpected approval, permission, user-input, or MCP request rather than opening a dynamic allow path.
The Provider overrides only those permission and sandbox fields. An optional instance-level model is a separate direct `thread/start` override; `CODEX_HOME`, project configuration, model-provider selection, MCP, hooks, skills, authentication, and sandbox facts not selected by the mode remain native Codex state. The wire still denies any unexpected approval, permission, user-input, or MCP request rather than opening a dynamic allow path.
### Failure diagnostic
`SubagentResult` carries an optional `diagnostic` for provider-authored, non-assistant failure detail. A Provider removes tool inputs, file contents, environment values, credentials, and raw protocol payloads before producing it. The shared out-of-process result boundary limits the complete text to 4096 UTF-8 bytes and marks truncation without splitting a character. The [minimal-diagnostics decision](../simplification/2026-08-21-product-subagent-minimal-diagnostics.md) owns Claude Code's non-permission action categories, while the [structured failure-facts decision](2026-08-18-product-subagent-failure-facts.md) continues to own Codex's current categories; both retain lifecycle stages and process outcomes in the same field.
`SubagentResult` carries an optional `diagnostic` for provider-authored, non-assistant failure detail. A Provider removes tool inputs, file contents, environment values, credentials, and raw protocol payloads before producing it. The shared out-of-process result boundary limits the complete text to 4096 UTF-8 bytes and marks truncation without splitting a character. The [minimal-diagnostics decision](../simplification/2026-08-21-product-subagent-minimal-diagnostics.md) owns both products' non-permission action categories, lifecycle stages, HTTP facts, and process outcomes carried by the same field.
Each product's permission fact contains only the effective mode, request category, unattended decision, and a fixed safe reason. Claude Code derives those facts from SDK callbacks and `permission_denied` messages. Codex derives them from app-server requests, declined items, `sandboxError`, and two fixed permission signatures in a bounded stderr tail; raw stderr is still forwarded to the Host but never copied into the diagnostic. Both Providers place their structured failure line before the latest contributing permission fact. A successful result returns only the strict final answer; local cancellation remains `aborted` without permission detail; an unpublished startup failure still rejects `start()`. The Provider never adds either diagnostic fact to assistant output, structured output, or `subagent/end.lastAssistantMessage`.
Each product's permission fact contains only the effective mode, request category, unattended decision, and a fixed safe reason. Claude Code derives those facts from SDK callbacks and `permission_denied` messages. Codex derives them from app-server requests, declined items, and structured `sandboxError` terminals. Raw stderr is forwarded to the Host but is neither classified nor copied into the diagnostic. Both Providers place their failure line before the latest contributing permission fact. A successful result returns only the strict final answer; local cancellation remains `aborted` without permission detail; an unpublished startup failure still rejects `start()`. The Provider never adds either diagnostic fact to assistant output, structured output, or `subagent/end.lastAssistantMessage`.
The foreground consumer presents the stop-reason headline, then the optional diagnostic, then any partial assistant output. The one-shot background adapter stores the same diagnostic beside the stop reason in the failed Job detail. Providers that omit the field retain their previous behavior.
@@ -63,7 +63,7 @@ The foreground consumer presents the stop-reason headline, then the optional dia
## Verification
Package tests pin every allowed and rejected Config value, the exact SDK and app-server field mappings, dangerous confirmations, unattended terminal responses, diagnostic sanitization and UTF-8 bound, successful-result omission, concurrent-run isolation, foreground ordering, Job detail, stderr observer disposal, and process cleanup. The real Claude Agent SDK 0.3.241 and Claude Code 2.1.241 fixture proves its safe default, restricted denial, explicit bypass, and whole-tree quiescence. The real Codex app-server fixture proves that thread-level `never` overrides ambient `on-request`, automatic review starts, dangerous bypass writes only inside suite-owned temporary storage, fixed stderr signatures produce safe diagnostics, and the wrapper/native tree exits. Loader composition proves non-default modes can be published without starting either product, and the keyless ACP snapshot records each product's failure diagnostic through foreground and Job presentation while the model-facing product tool schemas contain no permission parameter.
Package tests pin every allowed and rejected Config value, the exact SDK and app-server field mappings, dangerous confirmations, unattended terminal responses, diagnostic sanitization and UTF-8 bound, successful-result omission, concurrent-run isolation, foreground ordering, Job detail, stderr observer disposal, and process cleanup. The real Claude Agent SDK 0.3.241 and Claude Code 2.1.241 fixture proves its safe default, restricted denial, explicit bypass, and whole-tree quiescence. The real Codex 0.149.1 app-server fixture proves that thread-level `never` overrides ambient `on-request`, automatic review starts, dangerous bypass writes only inside suite-owned temporary storage, a rejected escalation leaves no side effect or raw command or path in the diagnostic, stderr remains Host-only, and the wrapper/native tree exits. Loader composition proves non-default modes can be published without starting either product, and the keyless ACP snapshot records each product's failure diagnostic through foreground and Job presentation while the model-facing product tool schemas contain no permission parameter.
## Alternatives considered
@@ -32,7 +32,7 @@ Claude Code 默认使用 `dontAsk`,而且只接受锁定版本 Agent SDK 支
### Codex
Codex 默认使用 `never`,并接受 Codex 0.147.0 公开的三种原生非交互模式。提供方启动固定的 app-server 命令,再把所选模式映射为官方 `thread/start` 字段,因为 CLI 全局权限 flag 不会配置之后由 app-server 客户端创建的线程:
Codex 默认使用 `never`,并接受 Codex 0.149.1 公开的三种原生非交互模式。提供方启动固定的 app-server 命令,再把所选模式映射为官方 `thread/start` 字段,因为 CLI 全局权限 flag 不会配置之后由 app-server 客户端创建的线程:
| 值 | `thread/start` 字段 | 原生行为 |
| --- | --- | --- |
@@ -40,13 +40,13 @@ Codex 默认使用 `never`,并接受 Codex 0.147.0 公开的三种原生非交
| `approve-for-me` | `approvalPolicy: on-request``approvalsReviewer: auto_review``sandbox: workspace-write` | 由 Codex 自动评审权限请求。 |
| `dangerously-bypass-approvals-and-sandbox` | `approvalPolicy: never``sandbox: danger-full-access` | 跳过审批与 sandbox。 |
提供方只覆盖这些线程字段。`CODEX_HOME`、项目配置、模型provider 选择、MCP、hook、skill、身份验证,以及模式未选择的 sandbox 事实仍属于 Codex 原生状态。wire 仍会拒绝任何意外到达的审批、权限、用户输入或 MCP 请求,而不会开放动态 allow 通道。
提供方只覆盖这些权限与 sandbox 字段。可选的实例级模型是独立的直接 `thread/start` 覆盖;`CODEX_HOME`、项目配置、模型 provider 选择、MCP、hook、skill、身份验证,以及模式未选择的 sandbox 事实仍属于 Codex 原生状态。wire 仍会拒绝任何意外到达的审批、权限、用户输入或 MCP 请求,而不会开放动态 allow 通道。
### 失败诊断
`SubagentResult` 携带可选的 `diagnostic`,用于提供方产生且不属于 assistant 内容的失败说明。提供方在生成它之前会排除工具输入、文件内容、环境值、凭证与原始协议载荷。共享的进程外结果边界会把完整文本限制在 4096 个 UTF-8 字节以内,并在不切断字符的前提下标记截断。[最小诊断决策](../simplification/2026-08-21-product-subagent-minimal-diagnostics.zh.md)负责 Claude Code 的非权限行动类别,[结构化失败事实决策](2026-08-18-product-subagent-failure-facts.zh.md)继续负责 Codex 的当前类别;二者都在同一字段中保留生命周期阶段与进程结果。
`SubagentResult` 携带可选的 `diagnostic`,用于提供方产生且不属于 assistant 内容的失败说明。提供方在生成它之前会排除工具输入、文件内容、环境值、凭证与原始协议载荷。共享的进程外结果边界会把完整文本限制在 4096 个 UTF-8 字节以内,并在不切断字符的前提下标记截断。[最小诊断决策](../simplification/2026-08-21-product-subagent-minimal-diagnostics.zh.md)负责两个产品通过同一字段承载的非权限行动类别、生命周期阶段、HTTP 事实与进程结果。
每个产品的权限事实都只包含有效模式、请求类别、无人值守决定与固定的安全原因。Claude Code 从 SDK 回调和 `permission_denied` 消息取得这些事实。Codex 从 app-server 请求、被拒绝的 item`sandboxError` 与每次运行有界 stderr 尾部中的两个固定权限签名取得事实原始 stderr 仍会转发给 Host,但绝不会复制进诊断。两个提供方都会把结构化失败行放在最新参与失败的权限事实之前。成功结果只返回严格的最终答案;本地取消仍以 `aborted` 结算且不附带权限说明;未发布的启动失败仍会拒绝 `start()`。提供方绝不会把任一诊断事实写入 assistant 输出、结构化输出或 `subagent/end.lastAssistantMessage`
每个产品的权限事实都只包含有效模式、请求类别、无人值守决定与固定的安全原因。Claude Code 从 SDK 回调和 `permission_denied` 消息取得这些事实。Codex 从 app-server 请求、被拒绝的 item 与结构化 `sandboxError` 终态取得事实原始 stderr 仍会转发给 Host,但既不会被分类,也绝不会复制进诊断。两个提供方都会把失败行放在最新参与失败的权限事实之前。成功结果只返回严格的最终答案;本地取消仍以 `aborted` 结算且不附带权限说明;未发布的启动失败仍会拒绝 `start()`。提供方绝不会把任一诊断事实写入 assistant 输出、结构化输出或 `subagent/end.lastAssistantMessage`
前台消费方依次呈现终止原因标题、可选诊断和任何部分 assistant 输出。一次性后台适配器会在失败 Job 的 detail 中,把同一诊断与终止原因一起保存。没有填写该字段的提供方保持原有行为。
@@ -63,7 +63,7 @@ Codex 默认使用 `never`,并接受 Codex 0.147.0 公开的三种原生非交
## Verification
包测试固定所有允许与拒绝的 Config 值、准确的 SDK 与 app-server 字段映射、危险确认、无人值守终态、诊断脱敏与 UTF-8 上限、成功结果不携带诊断、并发运行隔离、前台顺序、Job detail、stderr observer 释放和进程清理。真实 Claude Agent SDK 0.3.241 与 Claude Code 2.1.241 fixture 证明其安全默认、受限拒绝、显式 bypass 与整棵进程树完全停稳。真实 Codex app-server fixture 证明线程级 `never` 覆盖环境中的 `on-request`、自动评审可以启动、危险绕过只在测试拥有的临时存储中写入、固定 stderr 签名产生安全诊断,而且 wrappernative 进程树会退出。Loader 组装证明非默认模式可以在不启动任一产品的情况下发布;无密钥 ACP snapshot 则记录每个产品的失败诊断如何经过前台与 Job 呈现,同时面向模型的产品工具 schema 不包含权限参数。
包测试固定所有允许与拒绝的 Config 值、准确的 SDK 与 app-server 字段映射、危险确认、无人值守终态、诊断脱敏与 UTF-8 上限、成功结果不携带诊断、并发运行隔离、前台顺序、Job detail、stderr observer 释放和进程清理。真实 Claude Agent SDK 0.3.241 与 Claude Code 2.1.241 fixture 证明其安全默认、受限拒绝、显式 bypass 与整棵进程树完全停稳。真实 Codex 0.149.1 app-server fixture 证明线程级 `never` 覆盖环境中的 `on-request`、自动评审可以启动、危险绕过只在测试拥有的临时存储中写入、被拒绝的提权不会留下副作用且诊断不含原始命令或路径、stderr 只供 Host 观测,而且 wrappernative 进程树会退出。Loader 组装证明非默认模式可以在不启动任一产品的情况下发布;无密钥 ACP snapshot 则记录每个产品的失败诊断如何经过前台与 Job 呈现,同时面向模型的产品工具 schema 不包含权限参数。
## Alternatives considered
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-18-product-subagent-named-instances.md
2026-08-18-product-subagent-named-instances.md: fd4f95dc9de0cc03a95f8f1dcbd6dcded258dc5f
2026-08-18-product-subagent-named-instances.zh.md: 1d8395a21824501c5f26bdbec9c9d7f25d3bf20f
2026-08-18-product-subagent-named-instances.md: 749267e311cbcdebc7a6b8ad84e046fe21acf6fd
2026-08-18-product-subagent-named-instances.zh.md: d39fdd46a30aa665715d782162ddaaaac3a31f3e
@@ -6,17 +6,17 @@ English | [中文](2026-08-18-product-subagent-named-instances.zh.md)
## Problem
A Profile can mount one Cordis plugin package in multiple rows, but the Codex and Claude Code product providers previously registered every row under one fixed product name. A second row therefore failed as a duplicate before its distinct model where supported, permission mode, environment, or process-release settings could become usable. Deriving an implicit name from those settings would create a second identity rule, while choosing a provider during a tool call would let model input select deployment authority.
A Profile can mount one Cordis plugin package in multiple rows, but the Codex and Claude Code product providers previously registered every row under one fixed product name. A second row therefore failed as a duplicate before its distinct model, permission mode, environment, or process-release settings could become usable. Deriving an implicit name from those settings would create a second identity rule, while choosing a provider during a tool call would let model input select deployment authority.
The existing subagent registry already owns unique provider names, reversible registration, lifecycle events, and holder-owned published runs. The existing `dsh-tool-subagent` configuration already binds one provider name to one model-visible tool name. Product providers need to expose the missing Profile-owned identity without adding another registry or selection protocol.
## Decision
Each product provider Config owns a non-empty `providerName`; the defaults remain `codex` and `claude-code`. The resolved name is fixed when the plugin row loads and becomes the Provider object's `name`; registration, lookup, lifecycle events, run logs, and HMR removal therefore use the same value. Each mounted row retains its own `permissionMode`, `env`, `disposeGraceMs`, and run resources. Claude Code rows also retain an optional non-empty `model`; an explicit value is fixed for every query from that instance, while omission leaves native settings authoritative.
Each product provider Config owns a non-empty `providerName`; the defaults remain `codex` and `claude-code`. The resolved name is fixed when the plugin row loads and becomes the Provider object's `name`; registration, lookup, lifecycle events, run logs, and HMR removal therefore use the same value. Each mounted row retains its own optional non-empty `model`, `permissionMode`, `env`, `disposeGraceMs`, and run resources. An explicit model is fixed for every query or thread from that instance, while omission leaves the native product settings authoritative.
Profiles may mount multiple Codex or Claude Code rows when every row uses a distinct `providerName`. Each `dsh-tool-subagent` row continues to bind its existing `provider` field to that exact name and exposes an independently configured `toolName`. Tool calls carry no provider selector, alias, or permission input. A duplicate provider name fails through the existing `DUPLICATE_PROVIDER` path and leaves the first registration intact.
Removing one provider row blocks new starts and removes only tools bound to that name. Runs already published by the removed instance remain owned by their holders and settle or dispose independently. Sibling instances remain registered and keep their own configured model where supported, environment, native permission mode, cancellation controller, product process, and cleanup grace.
Removing one provider row blocks new starts and removes only tools bound to that name. Runs already published by the removed instance remain owned by their holders and settle or dispose independently. Sibling instances remain registered and keep their own configured model, environment, native permission mode, cancellation controller, product process, and cleanup grace.
### Ownership and lifecycle
@@ -25,11 +25,11 @@ Removing one provider row blocks new starts and removes only tools bound to that
| Provider instance name | Product Provider Config | One immutable registry name per mounted row, with the existing default when omitted |
| Name uniqueness and lifecycle events | `ctx.subagents` | Duplicate registration fails; disposal removes only the matching name |
| Model-visible tool name and binding | `dsh-tool-subagent` Config | One static tool resolves one configured provider name |
| Model where supported, permission, environment, and process cleanup | One Provider instance | Concurrent runs and sibling instances do not share deployment configuration or run resources |
| Model, permission, environment, and process cleanup | One Provider instance | Concurrent runs and sibling instances do not share deployment configuration or run resources |
## Verification
Both product packages pin their default and custom names, empty-name rejection, duplicate rollback, actual-name diagnostics, two concurrent instances with different permission modes, environments, and cleanup grace, cancellation isolation, and removal of one instance while its published run remains valid. Claude Code additionally pins empty-model rejection, omitted-model inheritance, two explicit models, and exact per-query isolation through the real SDK/CLI fixture. Public Loader compositions mount two rows and two distinct tools for each product without starting either product, while keyless ACP snapshots pin the four-tool combined roster and the absence of dynamic provider or model parameters.
Both product packages pin their default and custom names, empty-name and empty-model rejection, duplicate rollback, actual-name diagnostics, two concurrent instances with different models, permission modes, environments, and cleanup grace, cancellation isolation, and removal of one instance while its published run remains valid. Official product fixtures prove omitted-model inheritance and exact per-query or per-thread model isolation. Public Loader compositions mount two rows and two distinct tools for each product without starting either product, while keyless ACP snapshots pin the four-tool combined roster and the absence of dynamic provider or model parameters.
## Alternatives considered
@@ -43,6 +43,6 @@ Both product packages pin their default and custom names, empty-name rejection,
## Consequences
A Profile can expose several Codex and Claude Code tools backed by separate native permission modes and environments, and Claude Code instances may additionally fix separate models, while existing configurations continue to resolve `codex` and `claude-code`. Provider and tool names remain independent configuration facts, so changing one requires updating the binding that refers to it.
A Profile can expose several Codex and Claude Code tools backed by separate models, native permission modes, and environments while existing configurations continue to resolve `codex` and `claude-code`. Provider and tool names remain independent configuration facts, so changing one requires updating the binding that refers to it.
The design adds no runtime renaming, model-visible provider or model selector, generated tool name, persistent instance directory, shared process pool, model discovery, fallback, or compatibility alias. Correct multi-instance configurations require unique provider names and unique tool names; duplicate tool-name waiting remains a separate limitation.
@@ -6,17 +6,17 @@ Status: implemented
## 问题
Profile 可以用多个配置项挂载同一个 Cordis 插件包,但 Codex 与 Claude Code 产品提供方此前会把每个配置项都注册到一个固定产品名称下。因此,第二个配置项会在其支持时使用的独立模型、权限模式、环境或进程释放设置可用前因名称重复而失败。根据这些设置隐式派生名称会建立第二套身份规则,而在工具调用期间选择提供方会让模型输入决定部署权限。
Profile 可以用多个配置项挂载同一个 Cordis 插件包,但 Codex 与 Claude Code 产品提供方此前会把每个配置项都注册到一个固定产品名称下。因此,第二个配置项会在其独立模型、权限模式、环境或进程释放设置可用前因名称重复而失败。根据这些设置隐式派生名称会建立第二套身份规则,而在工具调用期间选择提供方会让模型输入决定部署权限。
现有 subagent 注册表已经拥有提供方名称唯一性、可逆注册、生命周期事件和由持有方拥有的已发布运行。现有 `dsh-tool-subagent` 配置也已经把一个提供方名称绑定到一个模型可见工具名称。产品提供方只需公开缺失的 Profile 所有身份,无需增加另一套注册表或选择协议。
## 决策
每个产品提供方 Config 都拥有非空的 `providerName`;默认值仍分别为 `codex``claude-code`。插件配置项加载时会固定解析后的名称,并把它作为 Provider 对象的 `name`;注册、查找、生命周期事件、运行日志和 HMR(热模块替换)移除因此使用同一个值。每个已挂载配置项保留自己的 `permissionMode``env``disposeGraceMs` 和运行资源。Claude Code 配置项还会保留可选的非空 `model`;显式值为该实例的每次 query 固定,省略时仍以原生设置为权威。
每个产品提供方 Config 都拥有非空的 `providerName`;默认值仍分别为 `codex``claude-code`。插件配置项加载时会固定解析后的名称,并把它作为 Provider 对象的 `name`;注册、查找、生命周期事件、运行日志和 HMR(热模块替换)移除因此使用同一个值。每个已挂载配置项保留自己的可选非空 `model``permissionMode``env``disposeGraceMs` 和运行资源。显式模型会为该实例的每次 query 或线程固定,省略时仍以原生产品设置为权威。
当每个配置项使用不同的 `providerName` 时,Profile 可以挂载多个 Codex 或 Claude Code 配置项。每个 `dsh-tool-subagent` 配置项继续用已有的 `provider` 字段绑定这个准确名称,并公开独立配置的 `toolName`。工具调用不携带提供方选择器、别名或权限输入。重复提供方名称沿用现有 `DUPLICATE_PROVIDER` 路径失败,而且不会替换第一个注册项。
移除一个提供方配置项会阻止新的启动,并且只移除绑定到该名称的工具。该实例已经发布的运行仍由其持有方拥有,并会独立结算或 dispose(资源释放)。兄弟实例继续保持注册,并保留各自支持时配置的模型、环境、原生权限模式、取消控制器、产品进程和清理宽限期。
移除一个提供方配置项会阻止新的启动,并且只移除绑定到该名称的工具。该实例已经发布的运行仍由其持有方拥有,并会独立结算或 dispose(资源释放)。兄弟实例继续保持注册,并保留各自配置的模型、环境、原生权限模式、取消控制器、产品进程和清理宽限期。
### 所有权与生命周期
@@ -25,11 +25,11 @@ Profile 可以用多个配置项挂载同一个 Cordis 插件包,但 Codex 与
| 提供方实例名称 | 产品提供方 Config | 每个已挂载配置项拥有一个不可变注册名称;省略时使用现有默认值 |
| 名称唯一性与生命周期事件 | `ctx.subagents` | 重复注册失败;资源释放只移除匹配名称 |
| 模型可见工具名称与绑定 | `dsh-tool-subagent` Config | 一个静态工具解析一个已配置的提供方名称 |
| 支持时的模型、权限、环境与进程清理 | 一个提供方实例 | 并发运行与兄弟实例不共享部署配置或运行资源 |
| 模型、权限、环境与进程清理 | 一个提供方实例 | 并发运行与兄弟实例不共享部署配置或运行资源 |
## 验证
两个产品包测试都会固定默认与自定义名称、空名称拒绝、重复注册回滚、实际名称诊断、使用不同权限模式、环境与清理宽限期的两个并发实例、取消隔离,以及移除一个实例后其已发布运行仍然有效。Claude Code 还会固定空模型拒绝、省略模型继承、两个显式模型,以及通过真实 SDK/CLI fixture 证明逐 query 精确隔离。公共 Loader 组合会为每个产品挂载两个配置项与两个不同工具,而且不启动任一产品;无密钥 ACP 快照固定最终四工具组合,并证明没有动态提供方或模型参数。
两个产品包测试都会固定默认与自定义名称、空名称和空模型拒绝、重复注册回滚、实际名称诊断、使用不同模型、权限模式、环境与清理宽限期的两个并发实例、取消隔离,以及移除一个实例后其已发布运行仍然有效。官方产品 fixture 会证明省略模型继承,以及逐 query 或逐线程的准确模型隔离。公共 Loader 组合会为每个产品挂载两个配置项与两个不同工具,而且不启动任一产品;无密钥 ACP 快照固定最终四工具组合,并证明没有动态提供方或模型参数。
## 考虑过的替代方案
@@ -43,6 +43,6 @@ Profile 可以用多个配置项挂载同一个 Cordis 插件包,但 Codex 与
## 结果
Profile 可以公开多个由不同原生权限模式与环境支持的 Codex 与 Claude Code 工具,Claude Code 实例还可分别固定模型,而现有配置仍会解析为 `codex``claude-code`。提供方名称与工具名称继续是彼此独立的配置事实,因此修改其中一项时必须同时更新引用它的绑定。
Profile 可以公开多个由不同模型、原生权限模式与环境支持的 Codex 与 Claude Code 工具,而现有配置仍会解析为 `codex``claude-code`。提供方名称与工具名称继续是彼此独立的配置事实,因此修改其中一项时必须同时更新引用它的绑定。
本设计不增加运行时改名、模型可见的提供方或模型选择器、自动生成的工具名称、持久实例目录、共享进程池、模型发现、fallback 或兼容别名。正确的多实例配置要求提供方名称与工具名称都保持唯一;重复工具名称的等待问题仍是独立限制。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-24-route-priced-image-request-pressure.md
2026-08-24-route-priced-image-request-pressure.md: 45a29211730474369607ed5fb933f380d640bf27
2026-08-24-route-priced-image-request-pressure.zh.md: cf005a3ee343edf5774d554a4ec78cb876703774
@@ -0,0 +1,39 @@
# Agent Note: Route-priced image request pressure
Status: implemented
English | [中文](2026-08-24-route-priced-image-request-pressure.zh.md)
## Problem
The token meter priced an `ImageBlock` as the structural JSON of its durable reference — roughly forty tokens — while a DeepSeek request image costs up to 384 visual tokens, so an image-dense session could carry hundreds of thousands of unbilled estimated tokens. Provider usage anchors only completed requests: the first multimodal request, images added after the anchor, and offload-set changes all fed automatic compaction a pressure figure that was wrong by orders of magnitude, triggering it far too late (context overflow) or, after a route change, too early. The [version-one simplification](../simplification/2026-07-29-simplify-web-image-input-v1.md) had deliberately rejected a provider-neutral tile formula and deferred visual pricing until a provider-aware estimator had a concrete consumer.
## Decision
Compaction pressure is now priced by the routed model's own request projection. `LlmAdapter.imageRequestPricing(provider, model)` is an optional synchronous hook returning an `LlmImageRequestPricing` for one exact route, resolved through `ctx.llm.imageRequestPricing()`; the base adapter declares none and unknown providers degrade to `undefined`, never throw. Each ordered image occurrence resolves to an `LlmImageRequestPrice`: the provider's visual tokens for a retained image plus the model-visible text the wire actually carries (request-preview handle, offload placeholder, or text-only substitution), with the text left to the caller's own estimator so no provider fixes a text tokenization.
The DeepSeek adapter implements the hook from its connection snapshot (`request-pricing.ts`): uncatalogued and text-only models price every occurrence as its `textOnlyImageText` substitution; image-capable models reproduce the serializer's first-stage oldest-first offload through the shared `offloadedImagePrefixCount()`, build handle and placeholder text through the same execution-world access resolution the serializer uses, and price retained images at their `requestImageDimensions` projection with `deepSeekImageTokens()` — a verbatim port of the provider's published v4 vision calculator (14px patches, 3:1 downsampling, 384-token cap, minimum-pixel scale-up, 8:1 width clamp), priced at the worst-case pad-to-4 alignment. The pure geometry moved from `attachment-local` to `dsh-attachment` so provider and pricing share it.
The token meter's surface fold stores route-neutral facts per node — the fixed-heuristic price, the image-free price, and the durable image occurrences — and `measure()` prices the surface under the effective envelope's route on every call. The anchor holds its raw materials (surface snapshot, provider-output price, usage) instead of a precomputed baseline, so a matching header reprices both the anchor and the current surface under one route and the signed delta compares like with like; the usage-versus-estimated choice happens per measurement against the route-priced anchor. Public `TokenSurfaceNode` carries both `tokens` (route-priced; read by trigger, retention, range selection, and the summary-shrink comparison) and `heuristicTokens` (fixed; the shadow-price protocol's unit, so `compaction/summary` and `compaction/prune` stay consistent with the O(1) projection fold's own appends). The `contextPressure` and `contextBreakdown` projections deliberately stay on the fixed heuristic.
The test-support replay adapter declares a flat per-model `imageRequestTokens` so keyless assembled scenarios exercise the seam; the `image-compaction` ACP snapshot proves six inline images push the second turn's pre-step measurement over an automatic threshold that the text-only heuristic stays under, and that the triggered compaction shadows the image message at its heuristic price.
## Alternatives considered
**Price images inside the provider-neutral estimator.** Rejected by the [version-one note](../simplification/2026-07-29-simplify-web-image-input-v1.md) and still wrong: visual pricing varies by provider, model, detail mode, and preprocessing, and a hard-coded figure would look authoritative on routes it does not describe. The hook keeps every constant in the adapter that owns the route.
**Correct pressure only from provider usage.** Usage cannot price the first multimodal request, an image added after the anchor, or a changed offload set — exactly the cases that made compaction fire too late. Usage stays the anchor for completed requests; the route projection prices the increment.
**Reproduce the full serialization pipeline, including prepared-version bytes and the base64 fallback budgets.** The second-stage offload depends on encoded request bytes that only exist after asynchronous image preparation. The pricing reproduces the deterministic first stage from durable byte lengths; a fallback request can only offload more and cost less, so the estimate stays conservative without I/O in a synchronous hook.
**Route-price the shadow-price protocol too.** Logged `shadowedTokenCount` feeds the O(1) projection fold, whose appends are priced by the fixed heuristic; pricing replacements by route would make the persisted running total drift. Keeping the protocol on `heuristicTokens` preserves the fold's by-construction agreement.
**Fold route pricing into the meter's replay state.** A fold keyed to one route would have to replay on every route change and could not answer a `requestHeader` override for a different model. Storing route-neutral node facts and pricing at `measure()` keeps replay single-pass and measurement O(surface), which the contract already promises.
## Consequences
Automatic compaction now triggers on the pressure the routed model's next request will actually carry: image-dense DeepSeek sessions compact before overflow instead of after it, text-only routes charge substitution text instead of phantom visual tokens, and offloaded images cost their placeholder. The worst-case alignment pad overprices an image by at most three tokens, and the unreproduced base64-fallback budgets can only overprice — both errors are conservative; an execution-world access path that changes between pricing and the request shifts a descriptor's text price by its own length, and provider usage remains the authoritative anchor once a request completes. The published v4 calculator constants live in `llm-deepseek` alone; if the provider revises its vision projection, that one module and its pinned vectors are the change site. Measurement cost gains one pricing resolution and one image-occurrence walk per call, still O(surface).
## Testing
Formula vectors in `image-tokens.spec.ts` pin the published calculator's outputs, including the aspect-clamp, scale-up floor, one-column solver, odd-grid trim, and second-pass convergence cases, cross-checked against the reference implementation over a dimension grid and 50,000-point fuzz during development. `request-pricing.spec.ts` covers text-only substitution, the low-detail preset, and count- and byte-driven offload boundaries. Token-meter specs cover the first multimodal estimate, post-anchor image deltas over usage, text-only repricing under a header override, pricer-less neutrality, occurrence-count mismatch, and nested tool-result images. Compaction specs prove trigger, retention, range selection, and the summary-shrink comparison read the route price while the logged shadow price stays heuristic, including a summary that only route-priced shrink accepts. Access-resolution threading is covered at the pricing function and the adapter override. The keyless `image-compaction` ACP snapshot exercises the assembled application end to end.
@@ -0,0 +1,39 @@
# Agent Note: 按路由定价的图片请求压力
Status: implemented
[English](2026-08-24-route-priced-image-request-pressure.md) | 中文
## Problem
token 计量服务把 `ImageBlock` 按其持久引用的 JSON 结构计价,约四十个 token,而一张 DeepSeek 请求图片最多消耗 384 个视觉 token,因此图片密集的会话可能携带数十万个未计入估算的 token。provider usage 只锚定已完成的请求:首次多模态请求、锚点之后新增的图片、offload 集合的变化,都会让自动 compaction 拿到数量级错误的压力值,触发得过晚(上下文溢出)或在路由切换后过早。[版本一简化](../simplification/2026-07-29-simplify-web-image-input-v1.zh.md)曾有意否决 provider-neutral 的 tile 公式,把视觉定价推迟到 provider-aware 估算器出现具体消费方之时。
## Decision
compaction 压力现在按路由模型自身的请求投影定价。`LlmAdapter.imageRequestPricing(provider, model)` 是可选的同步钩子,为一条确切路由返回 `LlmImageRequestPricing`,经 `ctx.llm.imageRequestPricing()` 解析;基类不声明定价,未注册的 provider 降级为 `undefined` 而绝不抛出。每个按序的图片出现处解析为一个 `LlmImageRequestPrice`:保留图片的提供方视觉 token,加上线上实际携带的模型可见文本(请求预览句柄、offload 占位文本或纯文本替换),文本交由调用方自己的估算器计价,避免任何提供方固定一种文本 token 化。
DeepSeek 适配器基于连接快照实现该钩子(`request-pricing.ts`):未编目和纯文本模型把每个出现处按其 `textOnlyImageText` 替换计价;支持图片的模型通过共享的 `offloadedImagePrefixCount()` 复现序列化器第一阶段的最旧优先 offload,经序列化器同一套执行环境访问解析构建句柄与占位文本,并按 `requestImageDimensions` 投影尺寸用 `deepSeekImageTokens()` 为保留图片计价,后者是提供方公布的 v4 视觉计算器的逐句移植(14px patch、3:1 降采样、384 token 上限、最小像素放大、8:1 宽度钳制),按最坏的 pad-to-4 对齐计价。纯几何函数从 `attachment-local` 上移到 `dsh-attachment`,供提供方与定价共享。
token 计量服务的表层 fold 为每个节点存储与路由无关的事实:固定启发式价格、去图价格与持久图片出现处;`measure()` 在每次调用时按生效 envelope 的路由为表层定价。锚点保存原始材料(表层快照、提供方输出价格、usage)而非预先计算的基线,因此匹配的标头会把锚点与当前表层放在同一路由下重新定价,带符号 delta 的比较口径一致;usage 与估算的选择在每次计量时针对路由定价锚点做出。公开的 `TokenSurfaceNode` 同时携带 `tokens`(路由定价;触发、保留、选段与摘要收缩比较读取它)和 `heuristicTokens`(固定值;影子价协议的计量单位,使 `compaction/summary``compaction/prune` 与 O(1) 投影 fold 自身的追加保持一致)。`contextPressure``contextBreakdown` 投影有意保持固定启发式规则。
test-support 的回放适配器按模型声明固定的 `imageRequestTokens`,让 keyless 装配场景走通这条 seam`image-compaction` ACP 快照证明六张内联图片把第二轮 pre-step 计量推过自动阈值,而纯文本启发式保持在阈值之下,且被触发的 compaction 按启发式价格遮蔽了图片消息。
## Alternatives considered
**在 provider-neutral 估算器里为图片定价。** 已被[版本一 note](../simplification/2026-07-29-simplify-web-image-input-v1.zh.md)否决且依然错误:视觉定价随提供方、模型、细节档位与预处理而不同,写死的数字在它不描述的路由上会显得权威却错误。钩子把每个常量留在拥有该路由的适配器里。
**只用 provider usage 校正压力。** usage 无法为首次多模态请求、锚点后新增图片或变化的 offload 集合定价,而这些正是让 compaction 触发过晚的情形。usage 仍是已完成请求的锚点;增量由路由投影定价。
**复现完整序列化管线,包括请求版本字节与 base64 回退预算。** 第二阶段 offload 依赖异步图片准备之后才存在的编码字节。定价复现由持久字节长度决定的确定性第一阶段;回退请求只会 offload 更多、花费更少,因此估算在同步无 I/O 的钩子里保持保守。
**让影子价协议也按路由定价。** 记录的 `shadowedTokenCount` 供 O(1) 投影 fold 消费,而该 fold 的追加按固定启发式计价;替换若按路由定价会让持久化的累计值漂移。协议保持在 `heuristicTokens` 上,维持 fold 的构造性一致。
**把路由定价并入计量服务的回放状态。** 绑定单一路由的 fold 在路由每次变化时都得重放,也无法回答指向另一模型的 `requestHeader` 覆盖。存储与路由无关的节点事实并在 `measure()` 时定价,保持单遍回放与契约已承诺的 O(surface) 计量。
## Consequences
自动 compaction 现在按路由模型下一次请求实际携带的压力触发:图片密集的 DeepSeek 会话在溢出之前而非之后压缩,纯文本路由收取替换文本而非幻影视觉 token,被 offload 的图片按占位文本计费。最坏对齐 pad 对单图最多多计三个 token,未复现的 base64 回退预算只会多计——两种误差都偏保守;执行环境访问路径若在定价与请求之间变化,只会按其自身长度改变描述文本的价格,请求完成后 provider usage 仍是权威锚点。公布的 v4 计算器常量只存在于 `llm-deepseek`;提供方若修订其视觉投影,改动点就是这一个模块与其钉死的向量。每次计量多一次定价解析与一次图片出现处遍历,仍为 O(surface)。
## Testing
`image-tokens.spec.ts` 的公式向量钉死公布计算器的输出,覆盖宽高比钳制、放大下限、单列求解、奇数网格裁剪与第二遍收敛的用例,开发期间与参考实现在尺寸网格及五万点模糊测试上对拍。`request-pricing.spec.ts` 覆盖纯文本替换、低细节预设以及数量与字节驱动的 offload 边界。token-meter 测试覆盖首次多模态估算、usage 之上的锚后图片 delta、标头覆盖下的纯文本重定价、无定价器时的中性行为、出现处数量不匹配与嵌套工具结果图片。compaction 测试证明触发、保留、选段与摘要收缩比较读取路由价格而记录的影子价保持启发式,包括一个只有路由定价收缩才接受的摘要。访问解析的传递在定价函数与适配器覆写两处都有覆盖。keyless 的 `image-compaction` ACP 快照端到端验证装配后的应用。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md
2026-08-08-native-windows-pull-request-ci.md: 24f1a5fa0c72255f678b837c910eb779445dee27
2026-08-08-native-windows-pull-request-ci.zh.md: 805974192577b835b565bd42688c574473ac63bf
2026-08-08-native-windows-pull-request-ci.md: dad23f1a49393fbfc3c0798407ed9abe21ff3df2
2026-08-08-native-windows-pull-request-ci.zh.md: 0d81b160fd40ae0a26569351bca6e631aab9a8de
@@ -32,7 +32,7 @@ Windows durable JSONL paths keep drive roots in native spelling and apply the ex
Post-boot profile watcher setup proceeds only while the root fiber and Loader are both live. A concurrent setup error is contained only when the same invocation's recorded signal already owns shutdown; unrelated HMR failures remain loud. The [process-shutdown controller](../bug-fix/2026-08-03-cli-signal-shutdown-escalation.md) lets a successful one-shot completion drain Node's remaining handles after root disposal, while teardown failure, deadline, and signal escalation retain forced exit. The vendored Include serializes debounced writes, retries only transient access or busy failures with bounded backoff, and observes every timer rejection. A terminal persistence failure remains on the queue and is rethrown to the teardown owner, while successful teardown drains the latest write.
Shiki disables lazy TextMate-regex compilation and warms each boot grammar before user content enters the unchanged per-line tokenization budget, so scheduler contention cannot publish a partial highlighted stream. The Codex real-product fixture is pinned to stable 0.147.0 schemas and selects an actually advertised command tool and argument shape, preserving the provider-owned protocol while proving unattended rejection and whole-tree exit on each host.
Shiki disables lazy TextMate-regex compilation and warms each boot grammar before user content enters the unchanged per-line tokenization budget, so scheduler contention cannot publish a partial highlighted stream. The Codex real-product fixture is pinned to stable 0.149.1 schemas and selects an actually advertised command tool and argument shape, preserving the provider-owned protocol while proving unattended rejection and whole-tree exit on each host.
## Alternatives considered
@@ -32,7 +32,7 @@ Windows 的持久 JSONL 路径会保留驱动器根目录的原生写法,并
启动后,只有根 fiber 与 Loader 均处于活跃状态时,系统才会继续设置 profile watcher。只有当同一次调用所记录的信号已取得关闭流程所有权时,系统才会隔离并发设置错误;无关 HMR 故障仍会响亮失败。[进程关闭控制器](../bug-fix/2026-08-03-cli-signal-shutdown-escalation.zh.md)会在根级 dispose 成功后让单次任务的正常完成流程排空 Node 剩余句柄,同时让拆卸失败、截止时间到期和信号升级继续强制退出。vendored Include 会串行化防抖写入,只对瞬时访问或忙碌故障执行有界退避重试,并确保每个由计时器触发的拒绝都得到观察。持久化最终失败后,该故障会保留在队列中,并重新抛给拆卸责任方;成功拆卸则会排空最新写入。
Shiki 会禁用 TextMate 正则的延迟编译,并在用户内容进入保持不变的逐行 tokenization(词元化)预算前预热每种启动语法,从而避免调度器争用发布不完整的高亮流。Codex 真实产品 fixture 固定使用稳定版 0.147.0 schema,并选择实际提供的命令工具与对应参数形态;这样既保留由提供方负责的协议,也能在每种宿主上证明无人值守拒绝和整棵进程树退出。
Shiki 会禁用 TextMate 正则的延迟编译,并在用户内容进入保持不变的逐行 tokenization(词元化)预算前预热每种启动语法,从而避免调度器争用发布不完整的高亮流。Codex 真实产品 fixture 固定使用稳定版 0.149.1 schema,并选择实际提供的命令工具与对应参数形态;这样既保留由提供方负责的协议,也能在每种宿主上证明无人值守拒绝和整棵进程树退出。
## 曾考虑的替代方案
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-29-simplify-web-image-input-v1.md
2026-07-29-simplify-web-image-input-v1.md: e7847dc2ae18f48146cb2b686dcda64beebf2350
2026-07-29-simplify-web-image-input-v1.zh.md: 47f6bb5face5b896b5184f7198eef7d99ee25afe
2026-07-29-simplify-web-image-input-v1.md: f13abfda3be890f80a8ff852acbc68118b91941c
2026-07-29-simplify-web-image-input-v1.zh.md: c92974efe5f84da5b91c09ea4acf820e7853ca7d
@@ -32,6 +32,6 @@ The attachment seam exposes its limits plus storage-free `validateImage`, `saveI
## Consequences
The feature retains the two batch limits and one storage-free validation method required by multi-image prompts, while removing unrelated public fields, lifecycle operations, policy snapshots, and route-assembly branches. Provider/model selection remains composition or profile configuration. Pre-request token pressure may undercount visual input until a provider-aware estimator is designed, while reported usage remains exact.
The feature retains the two batch limits and one storage-free validation method required by multi-image prompts, while removing unrelated public fields, lifecycle operations, policy snapshots, and route-assembly branches. Provider/model selection remains composition or profile configuration. Pre-request token pressure keeps the structural heuristic only on routes without declared image pricing; the [route-priced estimator](../feature/2026-08-24-route-priced-image-request-pressure.md) supplies the provider-aware figure, and reported usage remains exact.
Reintroducing any removed surface requires a concrete consumer and its failure, lifecycle, replay, and testing contract rather than compatibility with this pre-release shape.
@@ -32,6 +32,6 @@ Status: implemented
## 后果
该功能保留了多图片提示词所需的两个批次上限和一个不触碰存储的校验方法,同时移除了无关的公开字段、生命周期操作、策略快照和路由组装分支。提供方/模型选择仍属于组合或 profile 配置。在设计出提供方感知型估算器之前,请求前的 token 压力计算可能少计视觉输入,而上报的用量仍保持精确。
该功能保留了多图片提示词所需的两个批次上限和一个不触碰存储的校验方法,同时移除了无关的公开字段、生命周期操作、策略快照和路由组装分支。提供方/模型选择仍属于组合或 profile 配置。请求前的 token 压力只在未声明图片定价的路由上保留结构启发式;[按路由定价的估算器](../feature/2026-08-24-route-priced-image-request-pressure.zh.md)提供提供方感知的数值,上报的用量仍保持精确。
重新引入任何已移除表面时,都必须有具体消费方,并为其定义失败、生命周期、回放和测试契约,而不是为了兼容这一预发布形态。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md
2026-08-12-production-dsh-excludes-product-subagent-providers.md: aaa6ddc70a0320c85d0d784441dfc82d74831a90
2026-08-12-production-dsh-excludes-product-subagent-providers.zh.md: 37b861ad879995ac7dbd91903cf92332a83f9d92
2026-08-12-production-dsh-excludes-product-subagent-providers.md: 551cf86bd0d1901e5ad8e33588b7bf274cb2c8fe
2026-08-12-production-dsh-excludes-product-subagent-providers.zh.md: 611c6192c501f6289911c18c36ca21a31c57e2cd
@@ -16,7 +16,7 @@ The two Bundles remain independent. The Codex Bundle owns the pinned official wr
## Verification
Package tests pin both Bundle manifests, published patches, exact self-provider rows, and product runtime dependencies. Claude coverage pins Agent SDK 0.3.241, Claude Code 2.1.241, all eight platform packages, SDK-selected execution, and missing-payload failure without host fallback. Codex coverage pins wrapper 0.147.0, all six platform aliases, package-declared execution, native descendant quiescence, and the same missing-payload behavior. Workspace validation derives each published patch from its Bundle declaration rather than a package catalog. Package/base assertions plus actual pnpm production evidence prove the default and selected-product dependency boundaries, while real Bundle-patch and Agent-Preset composition covers none, either product, both, the tool-grant intersection, later-Session adoption, and zero startup processes.
Package tests pin both Bundle manifests, published patches, exact self-provider rows, and product runtime dependencies. Claude coverage pins Agent SDK 0.3.241, Claude Code 2.1.241, all eight platform packages, SDK-selected execution, and missing-payload failure without host fallback. Codex coverage pins wrapper 0.149.1, all six platform aliases, package-declared execution, native descendant quiescence, and the same missing-payload behavior. Workspace validation derives each published patch from its Bundle declaration rather than a package catalog. Package/base assertions plus actual pnpm production evidence prove the default and selected-product dependency boundaries, while real Bundle-patch and Agent-Preset composition covers none, either product, both, the tool-grant intersection, later-Session adoption, and zero startup processes.
## Alternatives considered
@@ -16,7 +16,7 @@ Status: implemented
## 验证
包测试会固定两个 Bundle 的 manifest、发布 patch、准确的自身提供方行与产品运行时依赖。Claude 覆盖会固定 Agent SDK 0.3.241、Claude Code 2.1.241、八个平台包、SDK 所选执行路径,以及载荷缺失时不回退宿主命令的失败。Codex 覆盖会固定 wrapper 0.147.0、六个平台 alias、包声明的执行路径、原生后代进程停稳,以及同样的载荷缺失行为。工作区验证会从 Bundle 声明派生每份发布 patch,而非维护包目录。包与 base 断言加上实际 pnpm 生产证据会证明默认与所选产品的依赖边界;真实 Bundle patch 与 Agent Preset 组装则覆盖未安装、任一单包、双包、工具授权交集、后续 Session 采纳以及零启动进程。
包测试会固定两个 Bundle 的 manifest、发布 patch、准确的自身提供方行与产品运行时依赖。Claude 覆盖会固定 Agent SDK 0.3.241、Claude Code 2.1.241、八个平台包、SDK 所选执行路径,以及载荷缺失时不回退宿主命令的失败。Codex 覆盖会固定 wrapper 0.149.1、六个平台 alias、包声明的执行路径、原生后代进程停稳,以及同样的载荷缺失行为。工作区验证会从 Bundle 声明派生每份发布 patch,而非维护包目录。包与 base 断言加上实际 pnpm 生产证据会证明默认与所选产品的依赖边界;真实 Bundle patch 与 Agent Preset 组装则覆盖未安装、任一单包、双包、工具授权交集、后续 Session 采纳以及零启动进程。
## 考虑过的替代方案
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-21-product-subagent-minimal-diagnostics.md
2026-08-21-product-subagent-minimal-diagnostics.md: a4d51c325738e6e3e6c74561deb877c9bca3c016
2026-08-21-product-subagent-minimal-diagnostics.zh.md: 409b548e0e6169479cec892d349dc8c969b24637
2026-08-21-product-subagent-minimal-diagnostics.md: b99ed487911a7ac9bf7c0751ea02290ff92cad6a
2026-08-21-product-subagent-minimal-diagnostics.zh.md: 46f948334df0268a095f8cb34ab1a7707eee811b
@@ -26,7 +26,20 @@ Claude Code maps Agent SDK results into five categories:
The diagnostic also retains the derived `query-start`, `query-run`, `process`, or `teardown` stage and independently observed exit code and signal. A contributing permission decision follows the failure line. Successful completion and local cancellation expose no failure diagnostic, and original SDK text remains only on the internal cause chain and in Host observation.
Codex currently retains the version-pinned categories documented by the [structured failure-facts decision](../feature/2026-08-18-product-subagent-failure-facts.md). The two providers may adopt the minimal mapping independently because neither shares a public error enum or asks a consumer to branch on category text.
Codex maps app-server failures into eight categories:
| Category | Safe input |
| --- | --- |
| `limit` | Context, session-budget, or usage limits |
| `access-policy` | Authentication, cyber-policy, product-policy, or sandbox failures |
| `service` | Overload or internal service failures |
| `transport` | HTTP and response-stream connection failures or exhausted attempts |
| `product-error` | Invalid requests, rollback, active-turn, or other product failures |
| `invalid-result` | A completed turn without a nonblank final answer |
| `process` | The managed app-server exits before another terminal result |
| `unknown` | Startup, teardown, malformed protocol values, or failures without a more specific safe fact |
The Codex diagnostic retains `initialize`, `thread-start`, `turn-start`, `turn`, `process`, or `teardown`, plus applicable numeric HTTP status and independently observed exit code and signal. `contextWindowExceeded` still maps the shared stop reason to `max-tokens`; every other category remains `error`. Only structured protocol facts contribute permission detail. Product stderr is Host-only observation and is neither classified nor copied into the result.
### Ownership and lifecycle
@@ -40,7 +53,7 @@ Codex currently retains the version-pinned categories documented by the [structu
## Verification
Claude Code package tests cover every coarse category, all four stages, unknown structured values, permission ordering, raw-text exclusion, success and cancellation omission, concurrent-run isolation, and independent exit code and signal fields. The real Agent SDK 0.3.241 and Claude Code 2.1.241 fixture produces an actual max-turns limit, process failure, permission denial, strict final answer, cancellation, and whole-tree quiescence. Loader and keyless product compositions continue to expose static tools without a diagnostic parser or model-visible category input.
Claude Code package tests cover every coarse category, all four stages, unknown structured values, permission ordering, raw-text exclusion, success and cancellation omission, concurrent-run isolation, and independent exit code and signal fields. The real Agent SDK 0.3.241 and Claude Code 2.1.241 fixture produces an actual max-turns limit, process failure, permission denial, strict final answer, cancellation, and whole-tree quiescence. Codex package tests cover every coarse category, all six stages, applicable HTTP status, structured permission ordering, stderr exclusion, success and cancellation omission, concurrency, and cleanup aggregation. The real 0.149.1 app-server fixture produces service, product-error, process, final-answer, model-isolation, cancellation, and quiescence evidence. Loader and keyless product compositions continue to expose static tools without a diagnostic parser or model-visible category input.
## Alternatives considered
@@ -54,6 +67,6 @@ Claude Code package tests cover every coarse category, all four stages, unknown
## Consequences
Claude Code upgrades no longer require a model-visible promise for every SDK error subtype. Parents still distinguish a limit, general product failure, invalid result, managed process exit, and unknown failure while retaining stage, permission, and process facts.
Product runtime upgrades no longer require a model-visible promise for every SDK or app-server error member. Parents still distinguish limits, access and policy restrictions, service and transport failures, general product failures, invalid results, managed process exits, and unknown failures while retaining applicable stage, permission, HTTP, and process facts.
The diagnostic remains safe display text rather than a recovery protocol. This change adds no raw error forwarding, fallback model, automatic retry, product session persistence, public structured result field, or dynamic provider and model selection.
@@ -26,7 +26,20 @@ Claude Code 会把 Agent SDK 结果映射为五种类别:
诊断还会保留派生的 `query-start``query-run``process``teardown` 阶段,以及分别观测到的退出码与信号。参与失败的权限决定会跟在失败行之后。成功完成与本地取消不公开失败诊断,原始 SDK 文本只留在内部 cause 链和 Host 观测中。
Codex 当前仍保留[结构化失败事实决策](../feature/2026-08-18-product-subagent-failure-facts.zh.md)记录的锁定版本类别。两个提供方可以分别采用最小映射,因为它们既不共享公共错误枚举,也不要求消费方根据类别文本分支。
Codex 会把 app-server 失败映射为八种类别:
| 类别 | 安全输入 |
| --- | --- |
| `limit` | 上下文、会话预算或用量限制 |
| `access-policy` | 身份验证、网络安全策略、产品策略或 sandbox 失败 |
| `service` | 过载或内部服务失败 |
| `transport` | HTTP 与响应 stream 连接失败或尝试耗尽 |
| `product-error` | 无效请求、回滚、活动轮次或其他产品失败 |
| `invalid-result` | 已完成轮次没有非空白最终答案 |
| `process` | 受管 app-server 在其他终态结果前退出 |
| `unknown` | 启动、清理、格式错误的协议值,或没有更具体安全事实的失败 |
Codex 诊断会保留 `initialize``thread-start``turn-start``turn``process``teardown`,以及适用的数值 HTTP status 和分别观测到的退出码与信号。`contextWindowExceeded` 仍把共享终止原因映射为 `max-tokens`;其他所有类别仍使用 `error`。只有结构化协议事实会贡献权限说明。产品 stderr 只供 Host 观测,既不会被分类,也不会复制进结果。
### 所有权与生命周期
@@ -40,7 +53,7 @@ Codex 当前仍保留[结构化失败事实决策](../feature/2026-08-18-product
## Verification
Claude Code 包测试覆盖全部粗粒度类别、四个阶段、未知结构化值、权限顺序、原始文本排除、成功与取消时省略、并发运行隔离,以及彼此独立的退出码与信号字段。真实 Agent SDK 0.3.241 与 Claude Code 2.1.241 fixture 会产生实际 max-turns 限制、进程失败、权限拒绝、严格最终答案、取消与整棵进程树完全停稳。Loader 与无密钥产品组合继续公开静态工具,不增加诊断解析器或模型可见类别输入。
Claude Code 包测试覆盖全部粗粒度类别、四个阶段、未知结构化值、权限顺序、原始文本排除、成功与取消时省略、并发运行隔离,以及彼此独立的退出码与信号字段。真实 Agent SDK 0.3.241 与 Claude Code 2.1.241 fixture 会产生实际 max-turns 限制、进程失败、权限拒绝、严格最终答案、取消与整棵进程树完全停稳。Codex 包测试覆盖全部粗粒度类别、六个阶段、适用 HTTP status、结构化权限顺序、stderr 排除、成功与取消时省略、并发及清理聚合。真实 0.149.1 app-server fixture 会产生服务、产品错误、进程、最终答案、模型隔离、取消与完全停稳证据。Loader 与无密钥产品组合继续公开静态工具,不增加诊断解析器或模型可见类别输入。
## Alternatives considered
@@ -54,6 +67,6 @@ Claude Code 包测试覆盖全部粗粒度类别、四个阶段、未知结构
## Consequences
Claude Code 升级不再要求为每个 SDK 错误 subtype 提供模型可见承诺。父级仍可区分限制、一般产品失败、无效结果、受管进程退出与未知失败,并保留阶段、权限与进程事实。
产品运行时升级不再要求为每个 SDK 或 app-server 错误成员提供模型可见承诺。父级仍可区分限制、访问与策略限制、服务与传输失败、一般产品失败、无效结果、受管进程退出与未知失败,并保留适用的阶段、权限、HTTP 与进程事实。
诊断仍是安全展示文本,而不是恢复协议。本改动不增加原始错误转发、fallback 模型、自动重试、产品会话持久化、公共结构化结果字段或动态提供方与模型选择。
+32 -11
View File
@@ -11,6 +11,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { setTimeout as delay } from 'node:timers/promises'
import { fileURLToPath } from 'node:url'
import { decodeStorageRecord } from '@deepseek-ai/dsh-session/chunk-rows'
import { describe, expect, it } from 'vitest'
import WebSocket from 'ws'
@@ -59,15 +60,23 @@ interface WorkspaceBaseline {
}
interface HistoryPage {
events: Array<{
event: {
type: string
data: unknown
}
}>
records: Array<
| { type: 'event'; event: HistoryEvent }
| { type: 'chunks'; event: HistoryChunkEvent }
>
hasMore: boolean
}
interface HistoryEvent {
type: string
data: unknown
}
interface HistoryChunkEvent extends HistoryEvent {
seq: number
time: number
}
interface ProcessObservation {
readonly ready: Promise<string>
readonly text: () => string
@@ -255,10 +264,10 @@ async function history(baseUrl: string, sessionId: string): Promise<HistoryPage>
{ request: { address: { kind: 'session', sessionId }, maxMessages: 100 } },
value => isRecord(value)
&& value.type === 'snapshot'
&& Array.isArray(value.events)
&& Array.isArray(value.records)
&& typeof value.hasMore === 'boolean',
)
return { events: frame.events as HistoryPage['events'], hasMore: frame.hasMore as boolean }
return { records: frame.records as HistoryPage['records'], hasMore: frame.hasMore as boolean }
}
/** Poll a public observation until it satisfies the test's behavior predicate. */
@@ -294,7 +303,7 @@ async function eventually<T>(
/** Return every text block from durable assistant messages. */
function assistantText(page: HistoryPage): string {
const text: string[] = []
for (const { event } of page.events) {
for (const event of historyEvents(page)) {
if (event.type !== 'assistant/message' || !isRecord(event.data) || !isRecord(event.data.message)) continue
const content = event.data.message.content
if (!Array.isArray(content)) continue
@@ -305,6 +314,18 @@ function assistantText(page: HistoryPage): string {
return text.join('\n')
}
/** Expand lossless history records for assertions over the public event stream. */
function historyEvents(page: HistoryPage): HistoryEvent[] {
return page.records.flatMap(record => record.type === 'event'
? [record.event]
: decodeStorageRecord({
type: record.event.type.replace(/^chunkrow\//u, ''),
seq0: record.event.seq,
time0: record.event.time,
data: record.event.data,
}))
}
/** Stop the spawned CLI through its normal signal path, escalating only on a stuck teardown. */
async function stop(child: ChildProcess): Promise<void> {
if (child.exitCode !== null) return
@@ -410,7 +431,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('GitHub webhook through the real
'webhook provenance, title, and permission events',
async () => await history(baseUrl, sessionId),
(page) => {
const events = page.events.map(item => item.event)
const events = historyEvents(page)
const title = events.find(event => event.type === 'session/title')
const permission = events.find(event =>
event.type === 'permission/preset'
@@ -429,7 +450,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('GitHub webhook through the real
},
30_000,
)
const webhookMessage = admitted.events.map(item => item.event)
const webhookMessage = historyEvents(admitted)
.find(event => event.type === 'user/message'
&& isRecord(event.data)
&& isRecord(event.data.source)
+16
View File
@@ -134,6 +134,16 @@ describe('web e2e: Cordis tools use their owned cards', () => {
await input.fill(STOP_PROMPT)
await input.press('Enter')
await stopTurnSettled
await expect.poll(() => {
const stop = sessionEvents.find(
(event): event is Extract<SessionEvent, { type: 'tool/call' }> =>
event.type === 'tool/call' && event.data.name === 'cordis_stop',
)
return stop !== undefined && sessionEvents.some(
event => event.type === 'tool/result'
&& String(event.data.message.source.callId) === String(stop.data.callId),
)
}, { timeout: 15_000 }).toBe(true)
if (MODE === 'record') {
assertCompleteCordisLifecycle(sessionEvents)
await expect.poll(() => page.getByText('CORDIS_UI_DONE', { exact: true }).count(), { timeout: 15_000 })
@@ -181,6 +191,12 @@ describe('web e2e: Cordis tools use their owned cards', () => {
it.skipIf(MODE === 'record')('matches the conversation aria golden', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-cordis-aria'))
console.log('MODEL_TRACE', { modelChanges, frameCount: modelFrames.length, modelFrames })
// Final Assistant text precedes turn/end. Three footers prove every turn
// reached the render state covered by the ARIA golden.
await expect.poll(
() => page.getByRole('button', { name: 'Branch into a new conversation', exact: true }).count(),
{ timeout: 15_000 },
).toBe(3)
await page.locator('[data-conversation-scroll]').evaluate((host) => { host.scrollTop = host.scrollHeight })
await expect.poll(
async () => page.getByRole('button', { name: 'Back to bottom', exact: true }).count(),
+20 -7
View File
@@ -180,17 +180,26 @@ async function sessionCursor(baseUrl: string, sessionId: string): Promise<number
}
interface HistoryPage {
events: { event: { type: string; data: unknown } }[]
records: (
| { type: 'event'; event: HistoryEvent }
| { type: 'chunks'; event: HistoryEvent }
)[]
hasMore: boolean
}
interface HistoryEvent {
type: string
data: unknown
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
function providerTitle(page: HistoryPage): string | undefined {
for (let index = page.events.length - 1; index >= 0; index--) {
const event = page.events[index]!.event
const events = page.records.flatMap(record => record.type === 'event' ? [record.event] : [])
for (let index = events.length - 1; index >= 0; index--) {
const event = events[index] as HistoryEvent
if (event.type !== 'session/title' || !isRecord(event.data)) continue
const source = event.data.source
if (typeof event.data.title === 'string' && isRecord(source) && source.kind === 'provider') {
@@ -201,7 +210,9 @@ function providerTitle(page: HistoryPage): string | undefined {
}
function hasAssistantMarker(page: HistoryPage, marker: string): boolean {
return page.events.some(({ event }) => {
return page.records.some((record) => {
if (record.type !== 'event') return false
const { event } = record
if (event.type !== 'assistant/message' || !isRecord(event.data) || !isRecord(event.data.message)) return false
const content = event.data.message.content
if (!Array.isArray(content)) return false
@@ -556,16 +567,18 @@ describe('dsh web keyless CLI smoke', () => {
return hasAssistantMarker(page, recoveredMarker)
}, { timeout: 20_000 }).toBe(true)
if (page === undefined) throw new Error('retry history was not observed')
const retry = page.events.find(({ event }) => event.type === 'llm/retry')?.event
const retry = page.records.find(record => (
record.type === 'event' && record.event.type === 'llm/retry'
))
expect(mainAttempts).toBe(2)
expect(retry?.data).toMatchObject({
expect(retry?.type === 'event' ? retry.event.data : undefined).toMatchObject({
turn: 1,
step: 1,
retry: 1,
maxRetries: 5,
failure: { code: 'TRANSPORT' },
})
expect(JSON.stringify(page.events)).toContain('WEB_RETRY_DISCARDED')
expect(JSON.stringify(page.records)).toContain('WEB_RETRY_DISCARDED')
} finally {
const closed = child.exitCode === null
? new Promise<void>((resolveClose) => { child.once('close', () => { resolveClose() }) })
+2 -2
View File
@@ -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/config-catalog.md
config-catalog.md: 550b35bba85cb582b2af76f5d91b4a0a7c4c2f70
config-catalog.zh.md: 4927710e285d1af8b0c7e930b26ce7881cf958b2
config-catalog.md: ac22d6b660a975f1252c3ce852d932f9c6f7d3c4
config-catalog.zh.md: 1357f546281c0ee78674cbb1773937f9bdeeeaee
+14 -3
View File
@@ -964,7 +964,7 @@ export interface DeepSeekCatalogModel {
Depends on: [`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts)
Source: [`packages/llm/llm-deepseek/src/index.ts:117`](../packages/llm/llm-deepseek/src/index.ts)
Source: [`packages/llm/llm-deepseek/src/index.ts:124`](../packages/llm/llm-deepseek/src/index.ts)
<a id="deepseek-aidsh-llm-pi-ai"></a>
@@ -1286,6 +1286,15 @@ export interface ReplayModelConfig {
* omit one, so replay reconstructs the request header a live catalog produced.
*/
defaultMaxTokens?: number
/**
* Optional flat visual-token price the replay route declares for every
* retained request image, so keyless scenarios exercise route-priced
* request pressure; each occurrence is priced at this value plus its
* request-preview handle text. Requires {@link inputModalities} to include
* `image` — a text-only route never sends visual tokens. Absent declares
* no image pricing.
*/
imageRequestTokens?: number
/** Optional reasoning-effort ids the replay route accepts, in display order. */
reasoningEfforts?: string[]
/**
@@ -1298,7 +1307,7 @@ export interface ReplayModelConfig {
Depends on: [`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts)
Source: [`packages/test-support/llm-replay/src/index.ts:892`](../packages/test-support/llm-replay/src/index.ts)
Source: [`packages/test-support/llm-replay/src/index.ts:914`](../packages/test-support/llm-replay/src/index.ts)
<a id="deepseek-aidsh-llm-retry"></a>
@@ -2308,10 +2317,12 @@ Source: [`packages/subagent/subagent-claude-code/src/index.ts:38`](../packages/s
Requires: `subagents` · `subprocess`
```ts config-catalog
/** Deployment-owned permission, environment, and process-release settings. */
/** Deployment-owned model, permission, environment, and process-release settings. */
export interface Config {
/** Provider name on `ctx.subagents` (default `codex`). */
providerName?: string
/** Native Codex model fixed for this instance; omitted to inherit Codex settings. */
model?: string
/**
* Explicit environment entries layered over the subprocess seam's
* credential-scrubbed parent environment.
+13 -2
View File
@@ -1288,6 +1288,15 @@ export interface ReplayModelConfig {
* omit one, so replay reconstructs the request header a live catalog produced.
*/
defaultMaxTokens?: number
/**
* Optional flat visual-token price the replay route declares for every
* retained request image, so keyless scenarios exercise route-priced
* request pressure; each occurrence is priced at this value plus its
* request-preview handle text. Requires {@link inputModalities} to include
* `image` — a text-only route never sends visual tokens. Absent declares
* no image pricing.
*/
imageRequestTokens?: number
/** Optional reasoning-effort ids the replay route accepts, in display order. */
reasoningEfforts?: string[]
/**
@@ -1300,7 +1309,7 @@ export interface ReplayModelConfig {
依赖:[`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts)
来源:[`packages/test-support/llm-replay/src/index.ts:892`](../packages/test-support/llm-replay/src/index.ts)
来源:[`packages/test-support/llm-replay/src/index.ts:914`](../packages/test-support/llm-replay/src/index.ts)
<a id="deepseek-aidsh-llm-retry"></a>
@@ -2310,10 +2319,12 @@ export type ClaudeCodePermissionMode = typeof CLAUDE_CODE_PERMISSION_MODES[numbe
需要:`subagents` · `subprocess`
```ts config-catalog
/** Deployment-owned permission, environment, and process-release settings. */
/** Deployment-owned model, permission, environment, and process-release settings. */
export interface Config {
/** Provider name on `ctx.subagents` (default `codex`). */
providerName?: string
/** Native Codex model fixed for this instance; omitted to inherit Codex settings. */
model?: string
/**
* Explicit environment entries layered over the subprocess seam's
* credential-scrubbed parent environment.
+2 -2
View File
@@ -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/event-producer-consumer.md
event-producer-consumer.md: baeddb7b0171b4347fa1748e0adf87df5c15e4d0
event-producer-consumer.zh.md: baee416d8c0bf1b4102f839cdcd98654d0acd63e
event-producer-consumer.md: b38d919b0586a116e163d8be3eb2c33fcfc6d67d
event-producer-consumer.zh.md: fd36b3ebf40d5467f4b0849d972a7925838dee65
+6 -6
View File
@@ -21,11 +21,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:482`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:462`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:489`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:468`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:475`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:503`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:483`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:510`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:489`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:496`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
@@ -43,7 +43,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:58`](../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-observation-policy`](../packages/fs/fs-observation-policy) |
| `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` |
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:65`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:66`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
| `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-telemetry`](../packages/session/session-telemetry), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:64`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `session-controller`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) |
+6 -6
View File
@@ -23,11 +23,11 @@
| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:482`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:462`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:489`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:468`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:475`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:503`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:483`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:510`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:489`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:496`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
@@ -45,7 +45,7 @@
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:58`](../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-observation-policy`](../packages/fs/fs-observation-policy) |
| `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` |
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:65`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:66`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
| `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-telemetry`](../packages/session/session-telemetry), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:64`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `session-controller`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) |
+1 -1
View File
@@ -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/persistence-catalog.md
persistence-catalog.md: 12558eeadc009b498c9a178cfcc79116bf1b7c2b
persistence-catalog.md: 7c63cd8dfdb181fac09cf3ce2e4bcec96d2bd59c
persistence-catalog.zh.md: f855d6969aa2dcade159ac8d6549e5f0350a7f0f
+1 -1
View File
@@ -512,7 +512,7 @@ Source: [`packages/llm/llm-retry/src/types.ts:11`](../packages/llm/llm-retry/src
'model/selection': ModelSelection
```
Source: [`packages/api/session-controller/src/types.ts:39`](../packages/api/session-controller/src/types.ts)
Source: [`packages/api/session-controller/src/types.ts:40`](../packages/api/session-controller/src/types.ts)
### `permission/*`
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/conversation.md
conversation.md: d26abf73292faacdf3a4186819c4738d1de0270e
conversation.zh.md: 7fff9e0433b0022c75a35d3885398f241818a21d
conversation.md: 28a7b3d497182f2f560f2b54f89fcfd3091af673
conversation.zh.md: 7cf98fe2b18d9fd53e5f49f48330a5585e04c54d
+14 -11
View File
@@ -2,18 +2,18 @@
English | [中文](conversation.zh.md)
Conversation is the target-neutral assembly layer between a Client Session event window and browser views. [`ui-conversation`](../../packages/client/ui-conversation/README.md) owns the event and view registries, one identity-stable binding per `SessionBinding`, Turn/Step locations, incremental Context assembly, target sources, the shared shell, and input orchestration. Target packages such as [`ui-chat`](../../packages/client/ui-chat/README.md) and [`ui-trajectory`](../../packages/client/ui-trajectory/README.md) own their Definitions, final snapshots, and rendering.
Conversation is the target-neutral assembly layer between a Client `SessionEventLikeEntry` window and browser views. [`ui-conversation`](../../packages/client/ui-conversation/README.md) owns the event and view registries, one identity-stable binding per `SessionBinding`, Turn/Step locations, incremental Context assembly, target sources, the shared shell, and input orchestration. Target packages such as [`ui-chat`](../../packages/client/ui-chat/README.md) and [`ui-trajectory`](../../packages/client/ui-trajectory/README.md) own their Definitions, final snapshots, and rendering.
This page defines the data model and the extension path for a business-owned Conversation node. The [Web Client architecture](web-client.md) places the subsystem between Client models and Slots; the [Conversation Node assembly decision](../../.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md) owns its rationale.
## Data model and ownership
The Session Controller owns the contiguous loaded event window. `ui-conversation` observes that existing source and converts each entry to `{ event, view? }`; it never opens a second history stream. One `ConversationNodeAssembler` per Session applies every registered Definition and publishes an independent source for each registered view target.
The Session Controller owns the contiguous loaded logical-event window. Each `SessionEventLikeEntry` is either `{ type: 'event', event: SessionEvent }` or `{ type: 'chunks', event: ChunkRowEvent }`; both inner events expose `type`, `seq`, `time`, and `data`. `ui-conversation` passes these entries to the assembler without opening a second history stream, converting records, or expanding packed members. One `ConversationNodeAssembler` per Session applies every registered Definition and publishes an independent source for each registered view target.
| Concept | Owner and purpose |
|---|---|
| Event Definition | A business package matches one event at a time, correlates it by stable `(kind, id)`, folds deterministic State, and optionally materializes one target node. |
| Context | The engine-owned ordered Matches and current State for one `(kind, id)`. Update-only evidence may remain pending until pagination supplies its unique start. |
| Event Definition | A business package matches one standard event or packed Assistant run at a time, correlates it by stable `(kind, id)`, folds deterministic State, and optionally materializes one target node. |
| Context | The engine-owned ordered Matches and current State for one `(kind, id)`. A packed run occupies one update Match; update-only evidence may remain pending until pagination supplies its unique scalar start. |
| Location | The engine-owned Session, Turn, or Step coordinates derived from durable boundary events. Definitions may publish typed data onto one Turn or Step. |
| View Definition | A target package creates one incremental builder per Session and owns the final snapshot type for that target. |
| View | A Slot entry such as Chat or Trajectory reads only its target snapshot and renders target-owned nodes. |
@@ -36,6 +36,8 @@ Use the producer-owned branded id type across the process boundary. Put the `Ses
Incremental events are supported. Prefer whole-value checkpoints when the producer can emit them cheaply, because they remain useful when the start is outside the loaded window. Each delta must carry the stable id and produce deterministic State when replayed in ascending log `seq`; it must not depend on live-only memory. If the current history window contains only updates, the assembler keeps a pending Context and builds no State until an older page supplies the start. If the product must render before the start is loaded, a terminal or checkpoint event must carry enough whole fallback state for the Definition to build that result directly; do not recover it by scanning unrelated events.
Historical runs of consecutive same-block `assistant/chunk` deltas arrive as `chunkrow/text-chunks`, `chunkrow/reasoning-chunks`, or `chunkrow/tool-call-chunks`. Their top-level `seq` and `time` identify the first logical member, and their `data` retains each fragment and timestamp gap. These Client-only events can only be updates; `start()` receives a standard `SessionEvent`. A Definition that consumes Assistant deltas handles the relevant packed tags in the same `match()` and `update()` methods, while other Definitions return `null` without expanding the run.
## Definition and typed Chat payload
The example keeps the producer declarations and client contribution in one block so the complete relationship is visible. In a package family, keep the branded id and `SessionEventMap` declaration with the event producer, and keep the Definition, Chat data merge, and renderer in the client plugin.
@@ -208,7 +210,7 @@ export function apply(ctx: ClientContext): void {
}
```
`match(event)` is an identity extractor, not a fold: it receives only the current event and returns the Definition-local id and lifecycle role. After a match, the assembler locates the Context by `(kind, id)` and calls `start` once or `update` with the current State. Both functions return the State that the engine adopts; returning a new immutable value is preferred, but a function that mutates and returns the same object has the same adoption semantics.
`match(event)` is an identity extractor, not a fold: it receives only the current `SessionEventLike` and returns the Definition-local id and lifecycle role. After a match, the assembler locates the Context by `(kind, id)` and calls `start` once for a standard event or `update` for a standard or packed event. Both functions return the State that the engine adopts; returning a new immutable value is preferred, but a function that mutates and returns the same object has the same adoption semantics.
`buildLocationData(context, scope)` optionally publishes Definition-owned data onto an engine-owned Turn or Step. Use declaration merging to give each key a precise value type. Another Node in the same Location can consume that value through its constrained slot hook, such as `useTurnData(key)`, without receiving the Session or scanning `snapshot.chat.nodes`.
@@ -222,17 +224,17 @@ The assembler records that dependency. If an older prepend later supplies a near
## Window update paths
History may be requested from the tail backward one page at a time, but every accepted page is normalized into ascending `seq` before State replay.
History may be requested from the tail backward one page at a time. The Session journal validates non-overlapping logical sequence ranges first; the Assembler then orders accepted inputs by their first `seq` before State replay.
| Path | Engine work | Definition-visible behavior |
|---|---|---|
| Replace on open, resync, or gap repair | Rebuild the loaded window, match every event once per Definition, then replay each started Context | `start`, followed by its updates in ascending `seq`; pending update-only Contexts remain without State |
| Prepend one older page | Match only fresh older events, merge them into Contexts by `(kind, id)`, preserve existing keyed nodes, and replay only affected Contexts and dependencies | A newly found start activates its collected updates; a changed Location or predecessor may rerun the Context |
| Append one live event | Call each Definition's `match` once, look up the matched Context by key, and update only that Context | One `update` and one requested publication for a matching post-start event; no existing Context scan |
| Replace on open, resync, or gap repair | Rebuild the loaded window, match every standard event or packed run once per Definition, then replay each started Context | `start`, followed by its updates in ascending logical `seq`; pending update-only Contexts remain without State |
| Prepend one older page | Match only fresh older inputs, merge them into Contexts by `(kind, id)`, preserve existing keyed nodes, and replay only affected Contexts and dependencies | A newly found scalar start activates its collected scalar and packed updates; a changed Location or predecessor may rerun the Context |
| Append one live event | Call each Definition's `match` once, look up the matched Context by key, and update only that Context | One scalar `update` and one requested publication for a matching post-start event; no existing Context scan |
With `D` registered Definitions, one incoming event performs `D` current-event matches and constant-time Context-key lookup after a match. Definition code must preserve that property: do not traverse the complete event window, every Context, `context.matches`, or the rendered Node collection on the normal append path. Use State for accumulated facts, Location data for same-Turn/Step sharing, and `reader.previous()` for indexed predecessor dependencies.
With `D` registered Definitions, one incoming scalar event or packed run performs `D` current-input matches and constant-time Context-key lookup after a match. Definition code must preserve that property: do not traverse the complete event window, every Context, `context.matches`, or the rendered Node collection on the normal append path. Use State for accumulated facts, Location data for same-Turn/Step sharing, and `reader.previous()` for indexed predecessor dependencies.
`publication` controls when changed State is materialized. Use `immediate` for structural or terminal changes, `animation-frame` for high-frequency visible deltas, and `none` when the State change feeds only a later publication. The engine still applies every update in log order; cadence only coalesces view publication.
`publication` controls when changed State is materialized. Use `immediate` for structural or terminal changes, `animation-frame` for high-frequency visible deltas, and `none` when the State change feeds only a later publication. The engine applies every scalar update in log order and every packed run in one batch update; cadence only coalesces view publication.
## Verification obligations
@@ -244,5 +246,6 @@ Add focused tests that establish these outcomes:
4. Prepending an older page adds earlier rows without replacing existing keyed Node values whose data did not change.
5. Repeated visible deltas preserve `context.key` and publish at most once per animation frame when requested.
6. The keyed renderer consumes `node.data` and constrained Location hooks only; it does not scan the Session event window, Contexts, or Chat Nodes.
7. Scalar and packed Assistant history produce the same final State, timing boundaries, and target snapshot, while one packed run remains one Match through replace, prepend, Location replay, and registry rebuild.
Use [`packages/client/ui-chat/src/client/conversation-nodes/assistant.ts`](../../packages/client/ui-chat/src/client/conversation-nodes/assistant.ts) for streaming and interruption, [`inbox.ts`](../../packages/client/ui-chat/src/client/conversation-nodes/inbox.ts) plus [`message.ts`](../../packages/client/ui-chat/src/client/conversation-nodes/message.ts) for predecessor queries, and [`packages/client/ui-deliverables`](../../packages/client/ui-deliverables) for a Definition that publishes Turn data without creating its own Node.
+14 -11
View File
@@ -2,18 +2,18 @@
[English](conversation.md) | 中文
Conversation 是 Client Session event window 与浏览器 view 之间的 target-neutral assembly 层。[`ui-conversation`](../../packages/client/ui-conversation/README.zh.md)拥有 event 与 view registry、每个 `SessionBinding` 对应的 identity-stable binding、Turn/Step Location、增量 Context assembly、target source、共享 shell 与输入编排。[`ui-chat`](../../packages/client/ui-chat/README.zh.md)和 [`ui-trajectory`](../../packages/client/ui-trajectory/README.zh.md)等 target 包拥有各自的 Definition、最终 snapshot 与渲染。
Conversation 是 Client `SessionEventLikeEntry` window 与浏览器 view 之间的 target-neutral assembly 层。[`ui-conversation`](../../packages/client/ui-conversation/README.zh.md)拥有 event 与 view registry、每个 `SessionBinding` 对应的 identity-stable binding、Turn/Step Location、增量 Context assembly、target source、共享 shell 与输入编排。[`ui-chat`](../../packages/client/ui-chat/README.zh.md)和 [`ui-trajectory`](../../packages/client/ui-trajectory/README.zh.md)等 target 包拥有各自的 Definition、最终 snapshot 与渲染。
本文定义数据模型与业务自有 Conversation node 的扩展路径。[Web Client 架构](web-client.zh.md)说明该子系统在 Client model 与 Slots 之间的位置;[Conversation Node 组装决策](../../.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md)记录其设计理由。
## 数据模型与所有权
Session Controller 拥有连续的已加载 event window。`ui-conversation` 观察这一个现有 source,并把每个 entry 转换为 `{ event, view? }`;它绝不另开一条 history stream。每个 Session 对应一个 `ConversationNodeAssembler`,它应用所有已注册 Definition,并为每个已注册 view target 发布独立 source。
Session Controller 拥有连续的已加载逻辑 event window。每个 `SessionEventLikeEntry` 都是 `{ type: 'event', event: SessionEvent }``{ type: 'chunks', event: ChunkRowEvent }`;两种内部 event 都公开 `type``seq``time``data``ui-conversation` 把这些 entry 直接交给 assembler不另开 history stream、不转换 record,也不展开 packed member。每个 Session 对应一个 `ConversationNodeAssembler`,它应用所有已注册 Definition,并为每个已注册 view target 发布独立 source。
| 概念 | Owner 与用途 |
|---|---|
| Event Definition | 业务包一次匹配一条 event,以稳定 `(kind, id)` 关联事件、折叠确定性 State,并可选择 materialize 一个 target node。 |
| Context | Engine 为一个 `(kind, id)` 拥有的有序 Match 与当前 State。只有 update 的证据可以保持 pending,直到分页补齐其唯一 start。 |
| Event Definition | 业务包一次匹配一条标准 event 或一个 packed Assistant run,以稳定 `(kind, id)` 关联输入、折叠确定性 State,并可选择 materialize 一个 target node。 |
| Context | Engine 为一个 `(kind, id)` 拥有的有序 Match 与当前 State。一个 packed run 只占一个 update Match只有 update 的证据可以保持 pending,直到分页补齐其唯一 scalar start。 |
| Location | Engine 根据持久 boundary event 推导的 Session、Turn 或 Step 坐标。Definition 可以向一个 Turn 或 Step 发布类型化数据。 |
| View Definition | Target 包为每个 Session 创建一个增量 builder,并拥有该 target 的最终 snapshot 类型。 |
| View | Chat 或 Trajectory 等 Slot entry 只读取自身 target snapshot,并渲染 target 自有 node。 |
@@ -36,6 +36,8 @@ Chat 与 Trajectory 可以识别同一个持久 event family,但各自保留
系统支持增量事件。如果生产方能以较低成本发出 whole-value checkpoint,应优先采用,因为 start 位于已加载窗口之外时它仍可直接使用。每条 delta 都必须携带稳定 id,并且按照日志 `seq` 升序回放时能够确定性地产生 State;它不能依赖只存在于实时内存中的状态。如果当前历史窗口只有 update,Assembler 会保留一个 pending Context,并在更早分页补齐 start 前不构造 State。如果产品必须在 start 尚未加载时渲染,terminal 或 checkpoint 事件就必须携带足够的完整 fallback 状态,让 Definition 能直接构造结果;不要通过扫描无关事件恢复它。
连续且属于同一 block 的历史 `assistant/chunk` delta 会以 `chunkrow/text-chunks``chunkrow/reasoning-chunks``chunkrow/tool-call-chunks` 到达。顶层 `seq``time` 表示首个逻辑成员,`data` 保留每个 fragment 与 timestamp gap。这些 Client-only event 只能充当 update`start()` 只接收标准 `SessionEvent`。消费 Assistant delta 的 Definition 在同一组 `match()``update()` 方法里处理相关 packed tag,其他 Definition 直接返回 `null`,无需展开该 run。
## Definition 与类型化 Chat payload
为了完整展示关联关系,下面把生产方声明和 Client 贡献写在同一个代码块里。实际的包族中,branded id 与 `SessionEventMap` 声明留在事件生产方,Definition、Chat data 合并与 renderer 留在 Client 插件。
@@ -208,7 +210,7 @@ export function apply(ctx: ClientContext): void {
}
```
`match(event)` 是身份提取器,不是 fold:它只能收到当前事件,并返回 Definition 内部 id 与生命周期角色。命中后,Assembler 通过 `(kind, id)` 定位 Context,再调用一次 `start`,或把当前 State 交给 `update`。两个函数都必须返回引擎随后采用的 State;推荐返回新的 immutable value,但函数原地修改后返回同一对象时,采用语义也相同。
`match(event)` 是身份提取器,不是 fold:它只能收到当前 `SessionEventLike`,并返回 Definition 内部 id 与生命周期角色。命中后,Assembler 通过 `(kind, id)` 定位 Context;标准 event 可触发一次 `start`,标准或 packed event 可把当前 State 交给 `update`。两个函数都必须返回引擎随后采用的 State;推荐返回新的 immutable value,但函数原地修改后返回同一对象时,采用语义也相同。
`buildLocationData(context, scope)` 可以把 Definition 拥有的数据发布到引擎拥有的 Turn 或 Step 上。通过 declaration merging 为每个 key 指定精确 value 类型。同一 Location 内的另一个 Node 可以使用受限 slot hook(例如 `useTurnData(key)`)读取该值,无须取得 Session,也无须扫描 `snapshot.chat.nodes`
@@ -222,17 +224,17 @@ Assembler 会记录这项依赖。如果后续 older prepend 带来了更近的
## Window 更新路径
历史可能从尾部开始一页一页向前请求,但每个已接收分页都会先按 `seq` 升序归一化,再进入 State 回放。
历史可能从尾部开始一页一页向前请求。Session journal 先校验互不重叠的逻辑 seq range,Assembler 再按每个已接受 input 的首 `seq` 排序并进入 State 回放。
| 路径 | 引擎工作 | Definition 可观察到的行为 |
|---|---|---|
| open、resync 或 gap repair 时 replace | 重建已加载窗口,每条事件对每个 Definition 匹配一次,再回放每个已有 start 的 Context | 先执行 `start`,再按 `seq` 升序执行其 update;只有 update 的 pending Context 仍没有 State |
| prepend 一页更早历史 | 只匹配新增的更早事件,按 `(kind, id)` 合并进 Context,保留现有 keyed node,并只重放受影响的 Context 与依赖 | 新发现的 start 会激活已收集 updateLocation 或前序依赖变化也可能重跑 Context |
| append 一条实时事件 | 每个 Definition 各调用一次 `match`,按 key 查找命中的 Context,只更新该 Context | 对 start 之后的匹配事件执行一次 `update` 并请求一次发布;不扫描已有 Context |
| open、resync 或 gap repair 时 replace | 重建已加载窗口,每条标准 event 或 packed run 对每个 Definition 匹配一次,再回放每个已有 start 的 Context | 先执行 `start`,再按逻辑 `seq` 升序执行其 update;只有 update 的 pending Context 仍没有 State |
| prepend 一页更早历史 | 只匹配新增的更早 input,按 `(kind, id)` 合并进 Context,保留现有 keyed node,并只重放受影响的 Context 与依赖 | 新发现的 scalar start 会激活已收集的 scalar 与 packed updateLocation 或前序依赖变化也可能重跑 Context |
| append 一条实时事件 | 每个 Definition 各调用一次 `match`,按 key 查找命中的 Context,只更新该 Context | 对 start 之后的匹配事件执行一次 scalar `update` 并请求一次发布;不扫描已有 Context |
注册 `D` 个 Definition 时,一条新事件会进行 `D` 次仅当前事件匹配;命中后的 Context key 查询是常数时间。Definition 代码必须维持这个性质:正常 append 热路径不得遍历完整事件窗口、所有 Context、`context.matches` 或已渲染 Node 集合。累计事实放进 State,同 Turn/Step 共享信息放进 Location data,有索引的前序依赖使用 `reader.previous()`
注册 `D` 个 Definition 时,一条新 scalar event 或 packed run 会进行 `D` 次仅当前 input 匹配;命中后的 Context key 查询是常数时间。Definition 代码必须维持这个性质:正常 append 热路径不得遍历完整事件窗口、所有 Context、`context.matches` 或已渲染 Node 集合。累计事实放进 State,同 Turn/Step 共享信息放进 Location data,有索引的前序依赖使用 `reader.previous()`
`publication` 控制发生 State 变更后何时物化。结构或 terminal 变化使用 `immediate`,高频可见 delta 使用 `animation-frame`,只为后续发布积累 State 时使用 `none`。引擎仍会按日志顺序应用每条 update;该选项只合并视图发布频率。
`publication` 控制发生 State 变更后何时物化。结构或 terminal 变化使用 `immediate`,高频可见 delta 使用 `animation-frame`,只为后续发布积累 State 时使用 `none`。引擎按日志顺序应用每条 scalar update,并用一次 batch update 应用一个 packed run;该选项只合并视图发布频率。
## 验证要求
@@ -244,5 +246,6 @@ Assembler 会记录这项依赖。如果后续 older prepend 带来了更近的
4. prepend 更早分页只增加更早的行;数据未变化的既有 keyed Node value 不被替换。
5. 重复的可见 delta 保持 `context.key`,并在请求 `animation-frame` 时每帧最多发布一次。
6. keyed renderer 只消费 `node.data` 与受限 Location hook,不扫描 Session 事件窗口、Context 或 Chat Node。
7. scalar 与 packed Assistant 历史产生相同的最终 State、timing boundary 和 target snapshot;一个 packed run 在 replace、prepend、Location replay 与 registry rebuild 中始终只保留一个 Match。
流式与中断处理可参考 [`packages/client/ui-chat/src/client/conversation-nodes/assistant.ts`](../../packages/client/ui-chat/src/client/conversation-nodes/assistant.ts),前序查询可参考 [`inbox.ts`](../../packages/client/ui-chat/src/client/conversation-nodes/inbox.ts) 与 [`message.ts`](../../packages/client/ui-chat/src/client/conversation-nodes/message.ts),只发布 Turn data 而不创建自有 Node 的例子见 [`packages/client/ui-deliverables`](../../packages/client/ui-deliverables)。
+2 -2
View File
@@ -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/llm-streaming.md
llm-streaming.md: 29efabd2b01659bdf2cc798ceadb4bb495e1731e
llm-streaming.zh.md: 21ad56e526b9a507644b436b41ad063c5310b2ce
llm-streaming.md: 73e8dc4a6a5b5408a5c85dcbeac4cfa2108a9ddd
llm-streaming.zh.md: 7f98029f35100ec9c72f55c509f20b6709315f47
+59
View File
@@ -236,6 +236,44 @@ interface LlmFailure {
}
```
## Request-image pricing
An adapter whose provider charges visual tokens for request images declares per-route pricing by overriding `LlmAdapter.imageRequestPricing`, and `ctx.llm.imageRequestPricing(provider, model)` resolves it synchronously for consumers. The token meter resolves the routed model's pricing on every measurement so compaction pressure, retention, and range selection price image history as the routed request actually sends it; the DeepSeek adapter reproduces its own request projection (per-model pixel budget, oldest-first offload) and prices retained images with the published v4 vision accounting, while provider usage remains the authoritative anchor for completed requests.
```ts type-equiv
/**
* Request price of one ordered image occurrence under one exact model route's
* request projection. Every occurrence resolves to the pair the wire actually
* carries: provider visual tokens for a retained image, plus the model-visible
* text sent with or instead of it (request-preview handle, offload placeholder,
* or text-only substitution). The caller prices `text` with its own text
* estimator so provider pricing never fixes a text tokenization.
*/
interface LlmImageRequestPrice {
/** Provider visual tokens for the retained request image; 0 when only text represents this occurrence. */
visualTokens: number
/** Model-visible text sent for this occurrence, to be priced by the caller's text estimator. */
text: string
}
```
```ts type-equiv
/**
* Provider-side request-image pricing for one exact model route. Implemented
* by adapters whose provider charges visual tokens; consumers (the token
* meter) resolve it synchronously per measurement, so implementations must not
* perform I/O.
*/
interface LlmImageRequestPricing {
/**
* Price every image occurrence of one request projection.
* @param images - durable image references in request order, one entry per occurrence.
* @returns one price per occurrence, aligned by index with `images`.
*/
priceImages(images: readonly ImageAttachmentRef[]): readonly LlmImageRequestPrice[]
}
```
## The adapter contract
Every adapter MUST obey these, and every consumer may rely on them:
@@ -736,6 +774,16 @@ declare abstract class LlmAdapter {
* @returns a resolved policy, or `undefined` to use the normal defaults.
*/
providerRetryPolicy(_provider: string): ResolvedRetryPolicy | undefined;
/**
* Resolve provider-side request-image pricing for one exact model route.
* The default declares none, so consumers fall back to their own neutral
* estimate. Implementations must answer synchronously without I/O; the
* token meter resolves this per measurement.
* @param _provider - a route passed to `registerAdapter()` for this instance.
* @param _model - exact model id passed to {@link GenerateOptions.model}.
* @returns route-owned image pricing, or `undefined` when the route declares none.
*/
imageRequestPricing(_provider: string, _model: string): LlmImageRequestPricing | undefined;
/**
* List models this adapter can currently advertise for one owned provider.
* The result is advisory: an adapter may accept unlisted model ids, and
@@ -883,6 +931,17 @@ async discoverModels( settingsNs: string, request: LlmModelDiscoveryRequest, ):
*/
providerRetryPolicy(provider: string): ResolvedRetryPolicy
/**
* Resolve provider-side request-image pricing for one exact route, or
* `undefined` when the provider is unregistered or declares none. Unknown
* providers degrade to `undefined` rather than throwing because callers
* price durable history whose route may no longer be mounted.
* @param provider - provider route named by a request header.
* @param model - exact model id named by the same header.
* @returns the owning adapter's image pricing for the route, when declared.
*/
imageRequestPricing(provider: string, model: string): LlmImageRequestPricing | undefined
/**
* Discover models advertised by one registered provider. Catalog membership
* is advisory and never changes routing or request validation.
+59
View File
@@ -238,6 +238,44 @@ interface LlmFailure {
}
```
## 请求图片定价
提供方对请求图片收取视觉 token 的适配器通过覆写 `LlmAdapter.imageRequestPricing` 声明按路由的定价,消费方经 `ctx.llm.imageRequestPricing(provider, model)` 同步解析。token 计量服务在每次计量时解析路由模型的定价,使 compaction 的压力、保留与选段都按路由请求实际发送的形式为图片历史计价;DeepSeek 适配器复现自身的请求投影(按模型的像素预算、最旧优先 offload),并用官方公布的 v4 视觉计量为保留图片定价,已完成请求仍以 provider usage 为权威锚点。
```ts type-equiv
/**
* Request price of one ordered image occurrence under one exact model route's
* request projection. Every occurrence resolves to the pair the wire actually
* carries: provider visual tokens for a retained image, plus the model-visible
* text sent with or instead of it (request-preview handle, offload placeholder,
* or text-only substitution). The caller prices `text` with its own text
* estimator so provider pricing never fixes a text tokenization.
*/
interface LlmImageRequestPrice {
/** Provider visual tokens for the retained request image; 0 when only text represents this occurrence. */
visualTokens: number
/** Model-visible text sent for this occurrence, to be priced by the caller's text estimator. */
text: string
}
```
```ts type-equiv
/**
* Provider-side request-image pricing for one exact model route. Implemented
* by adapters whose provider charges visual tokens; consumers (the token
* meter) resolve it synchronously per measurement, so implementations must not
* perform I/O.
*/
interface LlmImageRequestPricing {
/**
* Price every image occurrence of one request projection.
* @param images - durable image references in request order, one entry per occurrence.
* @returns one price per occurrence, aligned by index with `images`.
*/
priceImages(images: readonly ImageAttachmentRef[]): readonly LlmImageRequestPrice[]
}
```
## 适配器约定
每个适配器必须遵守以下规则,每个消费方可以依赖它们:
@@ -742,6 +780,16 @@ declare abstract class LlmAdapter {
* @returns a resolved policy, or `undefined` to use the normal defaults.
*/
providerRetryPolicy(_provider: string): ResolvedRetryPolicy | undefined;
/**
* Resolve provider-side request-image pricing for one exact model route.
* The default declares none, so consumers fall back to their own neutral
* estimate. Implementations must answer synchronously without I/O; the
* token meter resolves this per measurement.
* @param _provider - a route passed to `registerAdapter()` for this instance.
* @param _model - exact model id passed to {@link GenerateOptions.model}.
* @returns route-owned image pricing, or `undefined` when the route declares none.
*/
imageRequestPricing(_provider: string, _model: string): LlmImageRequestPricing | undefined;
/**
* List models this adapter can currently advertise for one owned provider.
* The result is advisory: an adapter may accept unlisted model ids, and
@@ -889,6 +937,17 @@ async discoverModels( settingsNs: string, request: LlmModelDiscoveryRequest, ):
*/
providerRetryPolicy(provider: string): ResolvedRetryPolicy
/**
* Resolve provider-side request-image pricing for one exact route, or
* `undefined` when the provider is unregistered or declares none. Unknown
* providers degrade to `undefined` rather than throwing because callers
* price durable history whose route may no longer be mounted.
* @param provider - provider route named by a request header.
* @param model - exact model id named by the same header.
* @returns the owning adapter's image pricing for the route, when declared.
*/
imageRequestPricing(provider: string, model: string): LlmImageRequestPricing | undefined
/**
* Discover models advertised by one registered provider. Catalog membership
* is advisory and never changes routing or request validation.
+2 -2
View File
@@ -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/token-meter.md
token-meter.md: b8b2add194cbafafc250c6fc15b23e246d87d9c1
token-meter.zh.md: a1366d0d1d113c0a7df77b5b3bc53c9121fe9ae3
token-meter.md: 9c4a1e4b95ffd84f65f7a73e208be245378a3301
token-meter.zh.md: d9e2e7f773041ccb6d1e4c3cc4d81a342db0cc01
+25 -10
View File
@@ -19,14 +19,14 @@ interface TokenMeasurement {
readonly surfaceDeltaTokens: number
/** Non-negative current request-and-response pressure. */
readonly totalTokens: number
/** Total heuristic tokens across the current surface. */
/** Total route-priced request tokens across the current surface; equals the sum of the node prices. */
readonly surfaceTokens: number
/** Current surface nodes in positional head-to-tail order. */
readonly nodes: readonly TokenSurfaceNode[]
}
```
`baseline.kind === 'usage'` means the latest successful provider call has the same canonical request envelope and its total is no lower than that call's full heuristic anchor. `estimated` means no reusable conservative usage anchor exists, so the service priced the complete envelope and surface with its fixed heuristic. A later successful request replaces the earlier anchor; signed `surfaceDeltaTokens` preserves growth and shrinkage relative to a matching anchor. `totalTokens` remains request-and-response pressure, while `surfaceTokens` is the surface-only heuristic total and equals the sum of the node prices.
Every measurement resolves the effective envelope's routed provider/model to that route's declared request-image pricing through `ctx.llm`, so image occurrences are priced as the visual tokens plus model-visible text the request actually sends; routes and compositions without declared pricing keep the fixed heuristic. `baseline.kind === 'usage'` means the latest successful provider call has the same canonical request envelope and its total is no lower than that call's full route-priced anchor. `estimated` means no reusable conservative usage anchor exists, so the service priced the complete envelope and surface itself. A later successful request replaces the earlier anchor; signed `surfaceDeltaTokens` preserves growth and shrinkage relative to a matching anchor, repricing both sides under the same route. `totalTokens` remains request-and-response pressure, while `surfaceTokens` is the surface-only route-priced total and equals the sum of the node prices.
## `TokenSurfaceNode`
@@ -35,8 +35,19 @@ interface TokenMeasurement {
interface TokenSurfaceNode {
/** Durable sequence number of the surface event. */
readonly seq: number
/** Heuristic tokens for the exact message projected by this node. */
/**
* Request-pressure tokens for the exact message projected by this node under
* the measured route: image occurrences carry the route's declared visual
* price when the routed adapter declares one, and the fixed heuristic
* otherwise. Trigger, retention, and range selection all read this price.
*/
readonly tokens: number
/**
* Fixed-heuristic tokens for the same message, independent of any route.
* The shadow-price protocol prices replacements with this value so the O(1)
* projection fold stays in agreement with its own appends.
*/
readonly heuristicTokens: number
}
```
@@ -60,14 +71,18 @@ Replay owner for one service-wide estimator and isolated per-session folds.
/**
* Measure current request pressure and surface through the durable tail.
*
* Provider usage is reused only when the latest successful call's canonical
* request envelope matches `requestHeader` and its total is no lower than
* that call's full heuristic anchor; otherwise the complete envelope and
* surface are heuristically repriced.
* The effective envelope's routed provider/model selects the request-image
* pricing every node is priced under: a route whose adapter declares image
* pricing charges each retained image its visual tokens plus its
* model-visible text, while other routes keep the fixed heuristic. Provider
* usage is reused only when the latest successful call's canonical request
* envelope matches `requestHeader` and its total is no lower than that
* call's full route-priced anchor; otherwise the complete envelope and
* surface are repriced.
*
* `requestHeader` affects request pressure only; surface fields always
* describe the current session surface. Every call clones those positional
* nodes, so measurement is O(surface).
* `requestHeader` replaces the latest logged envelope for pressure and node
* pricing; the node set always describes the current session surface. Every
* call clones those positional nodes, so measurement is O(surface).
*
* @param session - session to replay through its current durable tail.
* @param requestHeader - optional effective request envelope replacing the latest logged header.
+25 -10
View File
@@ -19,14 +19,14 @@ interface TokenMeasurement {
readonly surfaceDeltaTokens: number
/** Non-negative current request-and-response pressure. */
readonly totalTokens: number
/** Total heuristic tokens across the current surface. */
/** Total route-priced request tokens across the current surface; equals the sum of the node prices. */
readonly surfaceTokens: number
/** Current surface nodes in positional head-to-tail order. */
readonly nodes: readonly TokenSurfaceNode[]
}
```
`baseline.kind === 'usage'` 表示最近一次成功的提供方调用具有相同的规范请求 envelope,且该调用的总量不低于其完整启发式锚点。`estimated` 表示不存在可复用的保守 usage 锚点,因此服务使用固定启发式规则对完整信封和表层定价。后续成功请求会替换早先的锚点;有符号的 `surfaceDeltaTokens` 会保留相对于匹配锚点的增长与缩减。`totalTokens` 仍表示请求与响应压力,`surfaceTokens` 则是仅针对表层的启发式总量,等于所有节点价格之和。
每次计量都会通过 `ctx.llm` 把生效信封的路由 provider/model 解析为该路由声明的请求图片定价,因此图片出现处按请求实际发送的视觉 token 加模型可见文本计价;未声明定价的路由与组合保持固定启发式规则。`baseline.kind === 'usage'` 表示最近一次成功的提供方调用具有相同的规范请求 envelope,且该调用的总量不低于其完整路由定价锚点。`estimated` 表示不存在可复用的保守 usage 锚点,因此服务自行对完整信封和表层定价。后续成功请求会替换早先的锚点;有符号的 `surfaceDeltaTokens` 会保留相对于匹配锚点的增长与缩减,且两侧按同一路由重新定价`totalTokens` 仍表示请求与响应压力,`surfaceTokens` 则是表层的路由定价总量,等于所有节点价格之和。
## `TokenSurfaceNode`
@@ -35,8 +35,19 @@ interface TokenMeasurement {
interface TokenSurfaceNode {
/** Durable sequence number of the surface event. */
readonly seq: number
/** Heuristic tokens for the exact message projected by this node. */
/**
* Request-pressure tokens for the exact message projected by this node under
* the measured route: image occurrences carry the route's declared visual
* price when the routed adapter declares one, and the fixed heuristic
* otherwise. Trigger, retention, and range selection all read this price.
*/
readonly tokens: number
/**
* Fixed-heuristic tokens for the same message, independent of any route.
* The shadow-price protocol prices replacements with this value so the O(1)
* projection fold stays in agreement with its own appends.
*/
readonly heuristicTokens: number
}
```
@@ -60,14 +71,18 @@ Replay owner for one service-wide estimator and isolated per-session folds.
/**
* Measure current request pressure and surface through the durable tail.
*
* Provider usage is reused only when the latest successful call's canonical
* request envelope matches `requestHeader` and its total is no lower than
* that call's full heuristic anchor; otherwise the complete envelope and
* surface are heuristically repriced.
* The effective envelope's routed provider/model selects the request-image
* pricing every node is priced under: a route whose adapter declares image
* pricing charges each retained image its visual tokens plus its
* model-visible text, while other routes keep the fixed heuristic. Provider
* usage is reused only when the latest successful call's canonical request
* envelope matches `requestHeader` and its total is no lower than that
* call's full route-priced anchor; otherwise the complete envelope and
* surface are repriced.
*
* `requestHeader` affects request pressure only; surface fields always
* describe the current session surface. Every call clones those positional
* nodes, so measurement is O(surface).
* `requestHeader` replaces the latest logged envelope for pressure and node
* pricing; the node set always describes the current session surface. Every
* call clones those positional nodes, so measurement is O(surface).
*
* @param session - session to replay through its current durable tail.
* @param requestHeader - optional effective request envelope replacing the latest logged header.
+2 -2
View File
@@ -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/web-client.md
web-client.md: a06f6aaf45a482437b0509e65b6ee8332ec35a44
web-client.zh.md: 09f25bc45369a34ebf32c7a0b2b995c6732c9b29
web-client.md: e67da4980af881e426f2bc031c6ec49cc1df2857
web-client.zh.md: c5e9904226de66381a54bcb8a4783a062d70750f
+7 -7
View File
@@ -2,7 +2,7 @@
English | [中文](web-client.zh.md)
The Web Client is a browser-side Cordis application assembled from independently loaded plugins. Its architecture has four reusable foundations: [Client Modules](client-modules.md) loads the plugin graph, the [API Gateway](../api-gateway.md) provides typed Host communication, [Slots](slots.md) composes React UI, and [Conversation](conversation.md) turns a Session event window into target-owned views. This page connects those systems and defines where Client models and feature packages belong.
The Web Client is a browser-side Cordis application assembled from independently loaded plugins. Its architecture has four reusable foundations: [Client Modules](client-modules.md) loads the plugin graph, the [API Gateway](../api-gateway.md) provides typed Host communication, [Slots](slots.md) composes React UI, and [Conversation](conversation.md) turns a Session history window into target-owned views. This page connects those systems and defines where Client models and feature packages belong.
## Layers and ownership
@@ -12,7 +12,7 @@ The Web Client is a browser-side Cordis application assembled from independently
| Transport and API assembly | `client/connection`, `api/gateway`, `api/remotes` | Establish a Client generation, expose generated `ctx.remote` methods and streams, forward selected Cordis events, and carry cancellation and results. |
| Client models | `api/session-controller/client`, `api/workspace-controller/client` | Maintain React-free mirrors of Host state, resolve stream/unary races, own object identities and subscriptions, and expose narrow command services. |
| UI adapters | `client/ui-session`, `client/ui-workspace` | Convert model observables into root or Session-scoped standard Slot sources without taking ownership of business state. |
| Conversation data | `client/ui-conversation`, target packages such as `ui-chat` and `ui-trajectory` | Assemble durable Session events into independent target snapshots and own the shared conversation shell and input flow. |
| Conversation data | `client/ui-conversation`, target packages such as `ui-chat` and `ui-trajectory` | Assemble standard events and compact historical Assistant runs into independent target snapshots and own the shared conversation shell and input flow. |
| Composition and rendering | `client/ui-slots`, `client/ui-renderer`, `client/ui-layout`, feature UI packages | Declare extension locations, derive component props, bind observables to React hooks, and mount the final tree. |
The dependency direction is Host state → Remote transport → Client model → UI adapter → Conversation or presentation → Slots → React. User actions travel back through callbacks that close over an injected Client service or generated Remote namespace. A presentation component never receives Cordis `ctx`, a transport object, or another feature plugin's implementation.
@@ -41,9 +41,9 @@ Each API controller package owns a paired Host and Client face. The Host side ow
- `ClientSessions` provides `ctx.sessions`, owns Session scopes and stable `SessionBinding` objects, and projects the selected list state.
- `SessionManager` owns the list baseline, live list/control updates, lazy Session instances, queues, projection stores, subagent catalogs, and conflict ordering between pulls and later updates.
- Each `Session` owns one contiguous event window, paging, follow, prompt/control state, and the observable snapshot consumed by adapters.
- Each `Session` owns one contiguous logical-event window represented by `SessionEventLikeEntry` values, paging, follow, prompt/control state, and the observable snapshot consumed by adapters.
The durable event path opens `follow()`, whose first frame contains the current header, tail page, cursor, and complete projection baseline. Each physical generation atomically replaces the retained window from that snapshot; live events then append by sequence. `page()` is reserved for older history and gap repair. The transient control stream starts every generation with a complete baseline and then applies queue, job, and projection updates.
The durable event path opens `follow()`, whose first frame contains the current header, tail page, cursor, and complete projection baseline. History records have an explicit `event` or `chunks` discriminator and an aligned inner `event`; the journal validates each inclusive logical sequence range before the Client retains the records as `SessionEventLikeEntry` values without per-record conversion. Each physical generation atomically replaces the retained window from that snapshot; standard live events then append by sequence. `page()` is reserved for older history and gap repair. The transient control stream starts every generation with a complete baseline and then applies queue, job, and projection updates.
### Workspaces
@@ -55,7 +55,7 @@ This pairing is not a second source of business truth. Host controllers decide d
`ui-session` installs the `session` scope adapter and publishes `useSessions`, `useSession`, `sessionId`, and `useProjection`. Domain adapters add further standard sources without putting React hooks on the model objects.
`ui-conversation` binds once to each `SessionBinding.eventSource`. Its event registry correlates raw durable events into stable business Contexts, and its view registry materializes target snapshots. `ui-chat` and `ui-trajectory` register separate Definitions and builders: they may interpret the same event family, but they do not import or share each other's final display model. The shell selects a registered view and passes its snapshot through standard hooks and Slots. [Conversation](conversation.md) defines Context identity, replay, Location data, target builders, and keyed renderers.
`ui-conversation` binds once to each `SessionBinding.eventSource`. Its event registry correlates standard events and Client-only `chunkrow/*` history events into stable business Contexts, and its view registry materializes target snapshots. Packed runs stay single inputs and Matches through replay; Chat Assistant, Trajectory Assistant, and Turn Tail are the built-in Definitions that interpret them. `ui-chat` and `ui-trajectory` register separate Definitions and builders: they may interpret the same event family, but they do not import or share each other's final display model. The shell selects a registered view and passes its snapshot through standard hooks and Slots. [Conversation](conversation.md) defines Context identity, replay, Location data, target builders, and keyed renderers.
`ui-slots` provides the typed registry and lifecycle ledger; `ui-renderer` is the only package that binds bare observables through `useSyncExternalStore`, owns React contexts, and renders the root tree. Feature components receive framework hooks, owner props, store actions, and explicit injection through their derived props. [Web Client Slots](slots.md) lists those inputs, extension APIs, and the current Slot hierarchy.
@@ -63,7 +63,7 @@ This pairing is not a second source of business truth. Host controllers decide d
| Path | Sequence |
|---|---|
| durable Session display | Host Session log → Remote `follow` plus `page` → Client `Session` event window → Conversation Contexts → target snapshot (`chat`, `trajectory`, or another registered target) → Slot view → React |
| durable Session display | Host Session log → packed Remote `follow`/`page` history → Client `SessionEventLikeEntry` window → Conversation Contexts → target snapshot (`chat`, `trajectory`, or another registered target) → Slot view → React |
| transient Session control | Host control baseline → Remote snapshot stream → `SessionManager` queue/job/projection stores → Session and list snapshots → standard hooks → components |
| Workspace state | Host Workspace baseline and increments → `ClientWorkspaceModel``ctx.workspaces.list``useWorkspaces` → sidebar, hero, and navigation entries |
| scoped interaction | Host Cordis waterfall → API Remotes `$events``ctx.remote.$on()` on the Session Context → owning UI package → result or `next()` |
@@ -75,7 +75,7 @@ Physical and logical recovery are separate. Gateway mux restores the physical We
Recovery follows the data's semantics:
- A durable Session journal replaces its window from every generation's opening snapshot; `page()` supplies older history and repairs any later sequence gap.
- A durable Session journal validates logical sequence ranges and replaces its window from every generation's opening snapshot; `page()` supplies older history and repairs any later range gap.
- Session control and Workspace streams retain the last published value while disconnected, then atomically replace it from a fresh opening baseline.
- Ordinary forwarded notifications are not replayed. Stateful domains need a baseline, cursor, or explicit query; scoped waterfalls retain their own request lifetime.
+7 -7
View File
@@ -2,7 +2,7 @@
[English](web-client.md) | 中文
Web Client 是由独立加载插件组装而成的浏览器侧 Cordis 应用。它有四个可复用底座:[Client Modules](client-modules.zh.md) 加载插件图,[API Gateway](../api-gateway.zh.md) 提供类型化 Host 通信,[Slots](slots.zh.md) 组合 React UI[Conversation](conversation.zh.md) 把 Session 事件窗口变成各 target 自有的视图。本文串联这些系统,并规定 Client model 与功能包各自所在的位置。
Web Client 是由独立加载插件组装而成的浏览器侧 Cordis 应用。它有四个可复用底座:[Client Modules](client-modules.zh.md) 加载插件图,[API Gateway](../api-gateway.zh.md) 提供类型化 Host 通信,[Slots](slots.zh.md) 组合 React UI[Conversation](conversation.zh.md) 把 Session 历史窗口变成各 target 自有的视图。本文串联这些系统,并规定 Client model 与功能包各自所在的位置。
## 分层与所有权
@@ -12,7 +12,7 @@ Web Client 是由独立加载插件组装而成的浏览器侧 Cordis 应用。
| 传输与 API assembly | `client/connection``api/gateway``api/remotes` | 建立 Client generation,公开生成的 `ctx.remote` method 与 stream,转发选定的 Cordis event,并承载取消和结果。 |
| Client model | `api/session-controller/client``api/workspace-controller/client` | 维护不依赖 React 的 Host 状态镜像,处理 stream/unary 竞态,拥有对象 identity 与订阅,并公开收窄的 command service。 |
| UI adapter | `client/ui-session``client/ui-workspace` | 把 model observable 转换为 root 或 Session scope 的标准 Slot source,不接管业务状态所有权。 |
| Conversation 数据 | `client/ui-conversation``ui-chat``ui-trajectory` 等 target package | 把持久 Session event 组装成相互独立的 target snapshot,并拥有共享的 Conversation shell 与输入流程。 |
| Conversation 数据 | `client/ui-conversation``ui-chat``ui-trajectory` 等 target package | 把标准 event 与紧凑的 Assistant 历史批次组装成相互独立的 target snapshot,并拥有共享的 Conversation shell 与输入流程。 |
| 组合与渲染 | `client/ui-slots``client/ui-renderer``client/ui-layout`、各 UI 功能包 | 声明扩展位置、推导组件 props、把 observable 绑定成 React hook,并挂载最终组件树。 |
依赖方向是 Host 状态 → Remote 传输 → Client model → UI adapter → Conversation 或 presentation → Slots → React。用户操作通过 callback 反向进入注入的 Client service 或生成的 Remote namespace。Presentation component 绝不接收 Cordis `ctx`、transport object 或其他功能插件的实现。
@@ -41,9 +41,9 @@ Connection 拥有 request correlation、`/api` carrier、trust check、Host desc
- `ClientSessions` 提供 `ctx.sessions`,拥有 Session scope 与稳定的 `SessionBinding` object,并投影选中的 list state。
- `SessionManager` 拥有 list baseline、实时 list/control update、惰性 Session instance、queue、projection store、subagent catalog,以及 pull 与后到 update 之间的冲突顺序。
- 每个 `Session` 拥有一段连续 event window、pagination、follow、prompt/control state 与供 adapter 消费的 observable snapshot。
- 每个 `Session` 拥有一段`SessionEventLikeEntry` value 表示的连续逻辑 event window、pagination、follow、prompt/control state 与供 adapter 消费的 observable snapshot。
持久 event 路径打开 `follow()`,其首帧包含当前 header、tail page、cursor 与完整 projection baseline。每个物理 generation 都根据该 snapshot 原子替换保留窗口,随后按 seq append 实时 event。`page()` 只用于更早历史与 gap repair。瞬态 control stream 每代以完整 baseline 开始,随后应用 queue、job 与 projection update。
持久 event 路径打开 `follow()`,其首帧包含当前 header、tail page、cursor 与完整 projection baseline。历史 record 带有显式 `event``chunks` 判别字段和字段对齐的内部 `event`journal 先校验每条 record 的逻辑 seq 闭区间,Client 再直接把这些 record 保留为 `SessionEventLikeEntry`,无需逐 record 转换。每个物理 generation 都根据该 snapshot 原子替换保留窗口,随后按 seq append 标准实时 event。`page()` 只用于更早历史与 gap repair。瞬态 control stream 每代以完整 baseline 开始,随后应用 queue、job 与 projection update。
### Workspaces
@@ -55,7 +55,7 @@ Connection 拥有 request correlation、`/api` carrier、trust check、Host desc
`ui-session` 安装 `session` scope adapter,并提供 `useSessions``useSession``sessionId``useProjection`。领域 adapter 可以继续添加标准 source,但不会把 React hook 放进 model object。
`ui-conversation` 对每个 `SessionBinding.eventSource` 只绑定一次。它的 event registry 把原始持久 event 关联成稳定的业务 Contextview registry 则 materialize target snapshot。`ui-chat``ui-trajectory` 分别注册自己的 Definition 和 builder:它们可以解释同一 event family,但不会导入或共享彼此的最终 display model。Shell 选择一个已注册 view,再通过标准 hook 与 Slot 交付其 snapshot。[Conversation](conversation.zh.md)定义 Context identity、replay、Location data、target builder 与 keyed renderer。
`ui-conversation` 对每个 `SessionBinding.eventSource` 只绑定一次。它的 event registry 把标准 event 与 Client-only `chunkrow/*` 历史 event 关联成稳定的业务 Contextview registry 则 materialize target snapshot。packed run 在 replay 全程保持为单个 input 与 MatchChat Assistant、Trajectory Assistant 和 Turn Tail 是解释它的三个内建 Definition。`ui-chat``ui-trajectory` 分别注册自己的 Definition 和 builder:它们可以解释同一 event family,但不会导入或共享彼此的最终 display model。Shell 选择一个已注册 view,再通过标准 hook 与 Slot 交付其 snapshot。[Conversation](conversation.zh.md)定义 Context identity、replay、Location data、target builder 与 keyed renderer。
`ui-slots` 提供类型化 registry 与 lifecycle ledger`ui-renderer` 是唯一通过 `useSyncExternalStore` 绑定裸 observable、拥有 React context 并渲染 root tree 的包。功能 component 通过推导出的 props 接收 framework hook、owner prop、store action 与显式 injection。[Web Client Slots](slots.zh.md)列出这些输入、扩展 API 与当前 Slot 层级。
@@ -63,7 +63,7 @@ Connection 拥有 request correlation、`/api` carrier、trust check、Host desc
| 路径 | 顺序 |
|---|---|
| 持久 Session 展示 | Host Session log → Remote `follow``page` → Client `Session` event window → Conversation Context → target snapshot`chat``trajectory` 或其他已注册 target)→ Slot view → React |
| 持久 Session 展示 | Host Session log → packed Remote `follow`/`page` 历史 → Client `SessionEventLikeEntry` window → Conversation Context → target snapshot`chat``trajectory` 或其他已注册 target)→ Slot view → React |
| 瞬态 Session control | Host control baseline → Remote snapshot stream → `SessionManager` queue/job/projection store → Session 与 list snapshot → 标准 hook → component |
| Workspace 状态 | Host Workspace baseline 与 increment → `ClientWorkspaceModel``ctx.workspaces.list``useWorkspaces` → sidebar、hero 与 navigation entry |
| scoped interaction | Host Cordis waterfall → API Remotes `$events` → Session Context 上的 `ctx.remote.$on()` → 所属 UI 包 → result 或 `next()` |
@@ -75,7 +75,7 @@ Connection 拥有 request correlation、`/api` carrier、trust check、Host desc
恢复方式由数据语义决定:
- 持久 Session journal 根据每个 generation 的 opening snapshot 替换窗口;`page()` 提供更早历史并修复后续 seq gap。
- 持久 Session journal 校验逻辑 seq range,并根据每个 generation 的 opening snapshot 替换窗口;`page()` 提供更早历史并修复后续 range gap。
- Session control 与 Workspace stream 在断开期间保留最后一次发布的值,再用新的 opening baseline 原子替换。
- 普通 forwarded notification 不会 replay。需要可靠恢复的 stateful domain 必须提供 baseline、cursor 或显式 queryscoped waterfall 保留自身的 request lifetime。
+8
View File
@@ -91,6 +91,14 @@
]
},
"packages/client/ui-conversation": {
"entry": [
"tests/**/*.spec.{ts,tsx}",
"tests/**/*.perf.client.ts"
],
"project": [
"src/**/*.{ts,tsx}",
"tests/**/*.{ts,tsx}"
],
"ignoreDependencies": [
"@deepseek-ai/dsh-client-ui-workspace"
]
+2 -2
View File
@@ -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/api/gateway/README.md
README.md: 2546b0c4e54ea106c9203ce419027fe8253c7ca5
README.zh.md: 9281519cda422137c6fd08ff6680ba0d57902913
README.md: 1c3860ec836bcbefd26b04dca76b56955847c755
README.zh.md: ca3d9f75d5f226a21c44ba59b1befcbe5f321bac
+1 -1
View File
@@ -24,7 +24,7 @@ Host composition can register one application event source through `registerRemo
Each unary call validates positional inputs, constructs the descriptor's exact named `args`, and sends it through `ctx.connection.rpc.call('/api', endpoint, ...)`. A generated stream method returns an `AsyncIterable` and opens one logical stream through an in-process Connection carrier when available, otherwise through the shared Gateway WebSocket. Generated cancellation-aware methods accept a final optional `AbortSignal`; the Client combines it with the contribution mount lifetime before invoking the carrier. Unary results and every stream item are validated before reaching application code. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls and streams, and makes retained method handles reject.
`ctx.remote.$stream()` returns a single-consumer `RemoteStream` spanning physical carrier generations. It permits one immediate retry while the Host remains available, otherwise waits for the next connected Host generation, and annotates each item with its physical generation. The domain consumer validates and accepts each generation's opening value; business and protocol failures remain terminal. `RemoteSnapshotStream` adds one opening snapshot followed by deltas, while `RemoteJournalStream` adds follow-before-page opening, cursor deduplication, pagination, reconnect catch-up, and gap repair. Disposing any stream cancels its requests and resolves after the active iterator is fully stopped.
`ctx.remote.$stream()` returns a single-consumer `RemoteStream` spanning physical carrier generations. It permits one immediate retry while the Host remains available, otherwise waits for the next connected Host generation, and annotates each item with its physical generation. The domain consumer validates and accepts each generation's opening value; business and protocol failures remain terminal. `RemoteSnapshotStream` adds one opening snapshot followed by deltas. `RemoteJournalStream` adds follow-before-page opening, pagination, reconnect catch-up, and gap repair over domain-defined inclusive entry ranges; it removes complete duplicates and rejects gaps, inverted ranges, and partial overlaps. Disposing any stream cancels its requests and resolves after the active iterator is fully stopped.
`ctx.remote.$on()` subscribes to one forwarded Host event. Its legal keys are exactly the Host assembly's forwarding selection, and the listener type is the owning package's own Cordis `Events` declaration, so no second signature can drift from it. Each subscription belongs to the calling fiber and disappears with it. The Client Remote service registers the `$events` pump as a Connection generation source when it activates, whether or not any `$on` listener exists. Browsers use Remote mux, while in-process compositions use `connection.rpc.open`; the `ready` item and `host.describe` jointly establish a Connection generation. Carrier failure, Remote stream failure, unexpected normal completion, a non-ready opening item, or a malformed event item ends that generation and lets Connection reopen it after backoff. Ordinary notifications run in registration order and isolate listener failures. Agent-scoped waterfalls let a listener return a result, call `next()`, or reject; Gateway returns that outcome through the existing HTTP unary carrier.
+1 -1
View File
@@ -24,7 +24,7 @@ Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source
每次一元调用都会校验位置参数,构造与描述符完全匹配的具名 `args`,再通过 `ctx.connection.rpc.call('/api', endpoint, ...)` 发送。生成的流方法返回 `AsyncIterable`,并在进程内 Connection 载体可用时通过它打开逻辑流,否则通过共享的 Gateway WebSocket 打开。生成的支持取消的方法接受最后一个可选 `AbortSignal`;Client 会在调用载体前将它与贡献项的挂载生命周期合并。一元结果和每个流项都经过校验后才会交给应用代码。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用与流,并使外部仍持有的方法句柄在调用时返回拒绝。
`ctx.remote.$stream()` 返回跨越多个物理载体代次的单消费方 `RemoteStream`。Host 仍在线时,它允许一次立即重试;Host 离线时,它等待下一代连接,并为每个流项标注物理代次。领域消费方校验并接受各代次的 opening value;业务与协议错误仍然终止流。`RemoteSnapshotStream` 在此之上规定每代由一个 opening snapshot 和后续 delta 组成`RemoteJournalStream` 提供 follow-before-page、cursor 去重、分页、重连追赶与缺口修复。dispose 任一种 stream 都会取消其请求,并在活动 iterator 完全停止后完成。
`ctx.remote.$stream()` 返回跨越多个物理载体代次的单消费方 `RemoteStream`。Host 仍在线时,它允许一次立即重试;Host 离线时,它等待下一代连接,并为每个流项标注物理代次。领域消费方校验并接受各代次的 opening value;业务与协议错误仍然终止流。`RemoteSnapshotStream` 在此之上规定每代由一个 opening snapshot 和后续 delta 组成`RemoteJournalStream` 基于领域提供的 entry 闭区间提供 follow-before-page、分页、重连追赶与缺口修复;它丢弃完整重复项,并拒绝缺口、倒置区间和部分重叠。dispose 任一种 stream 都会取消其请求,并在活动 iterator 完全停止后完成。
`ctx.remote.$on()` 订阅一条被转发的 Host 事件。它的合法键恰好等于 Host 装配声明的转发选择,listener 类型就是事件所属包自己的 Cordis `Events` 声明,因此不存在会与之漂移的第二份签名。每个订阅归属发起调用的 fiber,并随该 fiber 一起消失。Client Remote 服务激活时就把 `$events` pump 注册为 Connection generation source,因此即使当前无 `$on` 订阅,它也会在 Connection 循环启动时打开。浏览器使用 Remote mux,进程内组合使用 `connection.rpc.open``ready` 项与 `host.describe` 共同建立一个 Connection generation。物理 carrier 失败、Remote stream error、意外正常结束、非 ready 首项或畸形事件项都会终止该 generation,由 Connection 退避后重开。普通通知按注册顺序运行并隔离 listener 失败;Agent-scoped waterfall 允许 listener 返回结果、调用 `next()` 或拒绝,Gateway 再通过现有 HTTP 一元载体回送该结果。
@@ -50,8 +50,10 @@ export interface RemoteJournalStreamOptions<Page, Entry, Cursor> {
readonly entries: (page: Page) => readonly Entry[]
/** Read whether an older page exists. */
readonly hasMore: (page: Page) => boolean
/** Read one entry's durable cursor. */
readonly cursor: (entry: Entry) => Cursor
/** Read the inclusive first durable cursor covered by one entry. */
readonly first: (entry: Entry) => Cursor
/** Read the inclusive final cursor, which must not precede the first. */
readonly last: (entry: Entry) => Cursor
/** Compare two cursors. */
readonly compare: (left: Cursor, right: Cursor) => number
/** Test whether the right cursor immediately follows the left cursor. */
@@ -175,15 +177,15 @@ export abstract class RemoteJournalStream<Page, Entry, Cursor, PageRequest = voi
const before = this.firstCursor
const accepted = before === undefined
? [...entries]
: entries.filter(entry => this.options.compare(this.options.cursor(entry), before) < 0)
: entries.filter(entry => this.options.compare(this.options.first(entry), before) < 0)
const tail = accepted.at(-1)
if (tail !== undefined && before !== undefined
&& !this.options.follows(this.options.cursor(tail), before)) {
&& !this.options.follows(this.options.last(tail), before)) {
this.options.publish({ type: 'prepend', page, entries: [], hasMore: false })
throw new Error(`${this.options.name} history page is discontinuous`)
}
const first = accepted[0]
if (first !== undefined) this.firstCursor = this.options.cursor(first)
if (first !== undefined) this.firstCursor = this.options.first(first)
this.options.publish({
type: 'prepend',
page,
@@ -268,7 +270,7 @@ export abstract class RemoteJournalStream<Page, Entry, Cursor, PageRequest = voi
const entries = [...this.options.entries(page)]
this.assertPage(entries)
const first = entries[0]
this.firstCursor = first === undefined ? undefined : this.options.cursor(first)
this.firstCursor = first === undefined ? undefined : this.options.first(first)
this.lastCursor = cursor
this.setResumeCursor(cursor)
this.options.publish({
@@ -284,10 +286,13 @@ export abstract class RemoteJournalStream<Page, Entry, Cursor, PageRequest = voi
item: JournalStreamItem<Page, Entry, Cursor>,
iterator: AsyncIterator<JournalStreamItem<Page, Entry, Cursor>>,
): Promise<void> {
const cursor = this.options.cursor(entry)
const { first, last: cursor } = this.entryRange(entry)
const last = this.lastCursor as Cursor
if (this.options.compare(cursor, last) <= 0) return
if (!this.options.follows(last, cursor)) {
if (this.options.compare(first, last) <= 0) {
throw new Error(`${this.options.name} emitted a partially overlapping entry`)
}
if (!this.options.follows(last, first)) {
const request = this.repairPageRequest()
const superseded = await this.replaceThrough(
request,
@@ -302,7 +307,7 @@ export abstract class RemoteJournalStream<Page, Entry, Cursor, PageRequest = voi
}
return
}
if (this.firstCursor === undefined) this.firstCursor = cursor
if (this.firstCursor === undefined) this.firstCursor = first
this.lastCursor = cursor
this.setResumeCursor(cursor)
this.options.publish({ type: 'append', entry })
@@ -349,7 +354,7 @@ export abstract class RemoteJournalStream<Page, Entry, Cursor, PageRequest = voi
}
const first = entries[0]
/* v8 ignore next -- a successful positive-cursor replacement page cannot be empty. */
this.firstCursor = first === undefined ? undefined : this.options.cursor(first)
this.firstCursor = first === undefined ? undefined : this.options.first(first)
this.lastCursor = this.tailCursor(entries)
this.setResumeCursor(this.lastCursor)
this.options.publish({
@@ -435,16 +440,21 @@ export abstract class RemoteJournalStream<Page, Entry, Cursor, PageRequest = voi
private mergeReplacement(page: Page, queued: readonly Entry[]): Entry[] | undefined {
const entries = [...this.options.entries(page)]
this.assertPage(entries)
for (const entry of queued) this.entryRange(entry)
const sorted = [...queued].sort((left, right) => (
this.options.compare(this.options.cursor(left), this.options.cursor(right))
this.options.compare(this.options.first(left), this.options.first(right))
))
let tail = this.tailCursor(entries)
for (const entry of sorted) {
const cursor = this.options.cursor(entry)
if (this.options.compare(cursor, tail) <= 0) continue
if (!this.options.follows(tail, cursor)) return undefined
const first = this.options.first(entry)
const last = this.options.last(entry)
if (this.options.compare(last, tail) <= 0) continue
if (this.options.compare(first, tail) <= 0) {
throw new Error(`${this.options.name} replacement contains a partially overlapping entry`)
}
if (!this.options.follows(tail, first)) return undefined
entries.push(entry)
tail = cursor
tail = last
}
return entries
}
@@ -452,7 +462,7 @@ export abstract class RemoteJournalStream<Page, Entry, Cursor, PageRequest = voi
private maxCursor(cursor: Cursor, entries: readonly Entry[]): Cursor {
let result = cursor
for (const entry of entries) {
const candidate = this.options.cursor(entry)
const candidate = this.options.last(entry)
if (this.options.compare(candidate, result) > 0) result = candidate
}
return result
@@ -495,22 +505,32 @@ export abstract class RemoteJournalStream<Page, Entry, Cursor, PageRequest = voi
private tailCursor(entries: readonly Entry[]): Cursor {
const tail = entries.at(-1)
return tail === undefined ? this.options.emptyCursor : this.options.cursor(tail)
return tail === undefined ? this.options.emptyCursor : this.options.last(tail)
}
private assertPage(entries: readonly Entry[]): void {
const iterator = entries[Symbol.iterator]()
const first = iterator.next()
if (first.done) return
let previous = first.value
let previousRange = this.entryRange(first.value)
for (const entry of iterator) {
if (!this.options.follows(this.options.cursor(previous), this.options.cursor(entry))) {
const range = this.entryRange(entry)
if (!this.options.follows(previousRange.last, range.first)) {
throw new Error(`${this.options.name} page contains discontinuous entries`)
}
previous = entry
previousRange = range
}
}
private entryRange(entry: Entry): { readonly first: Cursor; readonly last: Cursor } {
const first = this.options.first(entry)
const last = this.options.last(entry)
if (this.options.compare(first, last) > 0) {
throw new Error(`${this.options.name} entry has an inverted cursor range`)
}
return { first, last }
}
private assertPageThrough(page: Page, through: Cursor): void {
const tail = this.tailCursor(this.options.entries(page))
if (this.options.compare(tail, through) !== 0) {
@@ -12,6 +12,7 @@ import {
interface Entry {
readonly seq: number
readonly lastSeq?: number
}
interface Page {
@@ -51,12 +52,20 @@ const AVAILABLE_CONNECTION = {
const entries = (...seqs: number[]): Entry[] => seqs.map(seq => ({ seq }))
const rangedEntry = (first: number, last: number): Entry => ({ seq: first, lastSeq: last })
const page = (marker: string, seqs: number[], hasMore = false): Page => ({
entries: entries(...seqs),
hasMore,
marker,
})
const rangedPage = (marker: string, values: Entry[], hasMore = false): Page => ({
entries: values,
hasMore,
marker,
})
const STREAM_FACTORY = {
$stream<Item>(options: RemoteStreamOptions<Item>): RemoteStream<Item> {
return new RemoteStream(AVAILABLE_CONNECTION, options)
@@ -80,7 +89,8 @@ class FixtureJournal extends RemoteJournalStream<Page, Entry, number, PageReques
emptyCursor: -1,
entries: value => value.entries,
hasMore: value => value.hasMore,
cursor: entry => entry.seq,
first: entry => entry.seq,
last: entry => entry.lastSeq ?? entry.seq,
compare: (left, right) => left - right,
follows: (left, right) => right === left + 1,
publish: (change) => { changes.push(change) },
@@ -196,6 +206,40 @@ function controlledFactory(
}
describe('RemoteJournalStream', () => {
it('replaces from pages whose entries cover contiguous cursor ranges', async () => {
const snapshot = rangedPage(
'ranged',
[rangedEntry(0, 2), rangedEntry(3, 5)],
true,
)
const fixture = journalFixture(
[{ frames: [opened(5, snapshot)], hold: true }],
[],
)
await fixture.journal.open({})
expect(fixture.changes).toEqual([{
type: 'replace',
page: snapshot,
entries: snapshot.entries,
hasMore: true,
}])
await fixture.journal.dispose()
})
it('rejects an inverted cursor range', async () => {
const fixture = journalFixture(
[{ frames: [opened(2, rangedPage('inverted', [rangedEntry(3, 2)]))], hold: true }],
[],
)
await expect(fixture.journal.open({})).rejects.toThrow(
'fixture journal entry has an inverted cursor range',
)
expect(fixture.changes).toEqual([])
})
it('opens from the follow snapshot, removes overlap, appends live entries, and prepends history', async () => {
const fixture = journalFixture(
[{
@@ -288,6 +332,67 @@ describe('RemoteJournalStream', () => {
await followed.journal.dispose()
})
it('prepends at the first cursor and rejects a partially overlapping ranged entry', async () => {
const initial = rangedPage('initial', [rangedEntry(4, 6)], true)
const older = rangedPage('older', [rangedEntry(0, 3)])
const fixture = journalFixture(
[{ frames: [opened(6, initial)], hold: true }],
[older],
)
await fixture.journal.open({})
await fixture.journal.prepend({ before: 4 })
expect(fixture.pageCursors).toEqual([6])
expect(fixture.changes.at(-1)).toEqual({
type: 'prepend', page: older, entries: older.entries, hasMore: false,
})
await fixture.journal.dispose()
const overlap = rangedPage('overlap', [rangedEntry(0, 4)], true)
const overlapping = journalFixture(
[{ frames: [opened(6, initial)], hold: true }],
[overlap],
)
await overlapping.journal.open({})
await expect(overlapping.journal.prepend({ before: 4 })).rejects.toThrow(
'history page is discontinuous',
)
expect(overlapping.changes.at(-1)).toEqual({
type: 'prepend', page: overlap, entries: [], hasMore: false,
})
await overlapping.journal.dispose()
})
it('deduplicates complete ranged entries and rejects partial live overlap', async () => {
const initial = rangedPage('initial', [rangedEntry(0, 2)])
const fixture = journalFixture(
[{
frames: [
opened(2, initial),
{ type: 'entry', entry: rangedEntry(0, 2) },
{ type: 'entry', entry: rangedEntry(3, 5) },
{ type: 'entry', entry: rangedEntry(5, 7) },
],
hold: true,
}],
[],
)
await fixture.journal.open({})
await vi.waitFor(() => { expect(fixture.failed).toHaveBeenCalledOnce() })
expect(fixture.changes).toHaveLength(2)
expect(fixture.changes.at(-1)).toEqual({
type: 'append', entry: rangedEntry(3, 5),
})
expect(fixture.failed.mock.calls[0]?.[0]).toMatchObject({
message: 'fixture journal emitted a partially overlapping entry',
})
await fixture.journal.dispose()
})
it('repairs a replacement generation through one tail page and drops replay overlap', async () => {
const lost = new RemoteStreamCarrierError('carrier lost')
const fixture = journalFixture(
@@ -392,6 +497,26 @@ describe('RemoteJournalStream', () => {
await fixture.journal.dispose()
})
it('reports a page failure during live-gap repair', async () => {
const fixture = journalFixture(
[{
frames: [
opened(0, page('initial', [0])),
{ type: 'entry', entry: { seq: 2 } },
],
hold: true,
}],
[() => Promise.reject(new Error('repair page failed'))],
)
await fixture.journal.open({})
await vi.waitFor(() => { expect(fixture.failed).toHaveBeenCalledOnce() })
expect(fixture.failed.mock.calls[0]?.[0]).toMatchObject({ message: 'repair page failed' })
expect(fixture.changes).toHaveLength(1)
await fixture.journal.dispose()
})
it('replaces a superseded live-gap repair with the next generation', async () => {
const gap = Promise.withResolvers<ScriptedFrame>()
const fixture = journalFixture(
@@ -543,6 +668,39 @@ describe('RemoteJournalStream', () => {
await fixture.journal.dispose()
})
it('rejects a partially overlapping ranged entry queued during repair', async () => {
const firstLive = Promise.withResolvers<ScriptedFrame>()
const secondLive = Promise.withResolvers<ScriptedFrame>()
const secondConsumed = Promise.withResolvers<undefined>()
const repair = Promise.withResolvers<Page>()
const fixture = journalFixture(
[{
frames: [
opened(1, page('initial', [0, 1])),
firstLive.promise,
secondLive.promise,
],
hold: true,
afterFrame: (index) => { if (index === 2) secondConsumed.resolve(undefined) },
}],
[repair.promise],
)
await fixture.journal.open({})
firstLive.resolve({ type: 'entry', entry: rangedEntry(3, 5) })
await vi.waitFor(() => { expect(fixture.pageCursors).toEqual([5]) })
secondLive.resolve({ type: 'entry', entry: rangedEntry(5, 7) })
await secondConsumed.promise
repair.resolve(rangedPage('repair', [rangedEntry(0, 2), rangedEntry(3, 5)]))
await vi.waitFor(() => { expect(fixture.failed).toHaveBeenCalledOnce() })
expect(fixture.changes).toHaveLength(1)
expect(fixture.failed.mock.calls[0]?.[0]).toMatchObject({
message: 'fixture journal replacement contains a partially overlapping entry',
})
await fixture.journal.dispose()
})
it('rejects when queued entries advance beyond the second repair page', async () => {
const firstLive = Promise.withResolvers<ScriptedFrame>()
const secondLive = Promise.withResolvers<ScriptedFrame>()
@@ -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/api/session-controller/README.md
README.md: 7631e1623f90f9349eca78bc76d46505d13d2e0e
README.zh.md: 7a733b45b1cdbb17096d1e76bb25b54d3bdc0e06
README.md: 510f336c76dd80ed01bdd2bd4a364106f418831f
README.zh.md: 4503a8f9d0bcfc7f669f00cbc4db69e0d3efd3cd
+2 -2
View File
@@ -4,11 +4,11 @@ English | [中文](README.zh.md)
`@deepseek-ai/dsh-api-session-controller` owns the Host `ctx.sessionController` service and the generated Client `ctx.remote.session` namespace. It serves Session list, search, creation, model selection, rename, fork, prompt, attachment, queue, cancellation, message-aligned history, live log following, and Host-wide control state.
History pages and follow event frames carry only raw `SessionWireEvent` values. Tool arguments, result content, failures, and `tool/result.data.meta` pass through unchanged; the controller does not resolve a Tool definition, run a presenter, or attach UI data.
History pages and follow opening snapshots carry a discriminated `SessionHistoryRecord`. Both variants use `{ type, event }`: `type: 'event'` carries one raw `SessionWireEvent`, while `type: 'chunks'` carries one lossless `ChunkRowEvent` for consecutive same-block `assistant/chunk` deltas. Both inner values expose `type`, `seq`, `time`, and `data`, so the Client retains each accepted record as one `SessionEventLikeEntry` without record-by-record conversion. A packed event's `seq` and `time` identify its first member, and `data` retains the fragment and timestamp-gap arrays. Live follow frames remain individual `event` records. Tool arguments, result content, failures, and `tool/result.data.meta` pass through unchanged; the controller does not resolve a Tool definition, run a presenter, or attach UI data.
Each endpoint states its activation policy. List, search, attachment, history pages, and log following can inspect persistence without activating an Agent; queue mutation and cancellation require the corresponding live state; model, rename, and prompt commands may explicitly resume an ordinary Session. Create and fork are the only operations that create a new Agent. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces.
The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events.
The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. Ordinary records cover `[event.seq, event.seq]`; packed rows cover `[event.seq, event.seq + memberCount - 1]`. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events.
## Model Experience
+2 -2
View File
@@ -4,11 +4,11 @@
`@deepseek-ai/dsh-api-session-controller` 拥有 Host 的 `ctx.sessionController` 服务和生成的 Client `ctx.remote.session` namespace。它提供 Session 列表、搜索、创建、模型选择、重命名、fork、prompt、附件、queue、取消、按消息对齐的历史、live 日志跟随和 Host 范围 control 状态。
历史页与 follow event frame 只携带原始 `SessionWireEvent`。工具参数、结果内容、失败信息和 `tool/result.data.meta` 原样通过;controller 不解析 Tool definition、不运行 presenter,也不附加 UI 数据。
历史页与 follow opening snapshot 携带带判别字段的 `SessionHistoryRecord`。两个分支都使用 `{ type, event }``type: 'event'` 携带一个原始 `SessionWireEvent``type: 'chunks'` 则携带一个由连续且属于同一 block 的 `assistant/chunk` delta 组成的无损 `ChunkRowEvent`。两种内部值都公开 `type``seq``time``data`,因此 Client 无需逐 record 转换,就能把每条已接受 record 保留为一个 `SessionEventLikeEntry`。packed event 的 `seq``time` 表示首成员,`data` 保留 fragment 与 timestamp-gap 数组。实时 follow frame 继续携带单个 `event` record。工具参数、结果内容、失败信息和 `tool/result.data.meta` 原样通过;controller 不解析 Tool definition、不运行 presenter,也不附加 UI 数据。
每个 endpoint 都声明自己的激活策略。列表、搜索、附件、历史页和日志跟随可以在不激活 Agent 的情况下检查 persistencequeue 变更和取消要求对应 live 状态仍然存在;模型、重命名和 prompt 命令可以显式恢复普通 Session。只有 create 和 fork 会创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。
Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace``prepend``append` 变更,并通过 tail page 修复重连或 seq 缺口。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。
Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace``prepend``append` 变更,并通过 tail page 修复重连或 seq 缺口。普通 record 覆盖 `[event.seq, event.seq]`packed row 覆盖 `[event.seq, event.seq + memberCount - 1]`业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。
## 模型体验
@@ -1,10 +1,22 @@
/** Observable contiguous Session event window consumed by domain assemblers. */
import { notifySubscribers, type ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
import type { SessionEventEntry } from '../../types.ts'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type { ChunkRowEvent } from '../../types.ts'
/** Standard Session event or compact historical Assistant run. */
export type SessionEventLike = SessionEvent | ChunkRowEvent
/** Client history entry retaining its coarse transport discriminator. */
export type SessionEventLikeEntry =
| { readonly type: 'event'; readonly event: SessionEvent }
| { readonly type: 'chunks'; readonly event: ChunkRowEvent }
/** Scalar live entry accepted by append-only Client paths. */
export type SessionLiveEventEntry = Extract<SessionEventLikeEntry, { readonly type: 'event' }>
interface EventWindowLeaf {
readonly kind: 'leaf'
readonly entries: readonly SessionEventEntry[]
readonly entries: readonly SessionEventLikeEntry[]
readonly length: number
}
@@ -17,7 +29,7 @@ interface EventWindowConcat {
type EventWindowNode = EventWindowLeaf | EventWindowConcat
function leaf(entries: readonly SessionEventEntry[]): EventWindowLeaf {
function leaf(entries: readonly SessionEventLikeEntry[]): EventWindowLeaf {
return { kind: 'leaf', entries, length: entries.length }
}
@@ -25,9 +37,9 @@ function concat(left: EventWindowNode, right: EventWindowNode): EventWindowConca
return { kind: 'concat', left, right, length: left.length + right.length }
}
function materialize(node: EventWindowNode): readonly SessionEventEntry[] {
function materialize(node: EventWindowNode): readonly SessionEventLikeEntry[] {
if (node.kind === 'leaf') return node.entries
const entries = new Array<SessionEventEntry>(node.length)
const entries = new Array<SessionEventLikeEntry>(node.length)
const pending: EventWindowNode[] = [node]
let index = 0
while (pending.length > 0) {
@@ -50,7 +62,7 @@ function windowSnapshot(
revision: number,
change: SessionEventChange,
): SessionEventWindow {
let entries: readonly SessionEventEntry[] | undefined
let entries: readonly SessionEventLikeEntry[] | undefined
return {
get entries() {
entries ??= materialize(node)
@@ -64,13 +76,13 @@ function windowSnapshot(
/** Exact delta that produced the latest event-window revision. */
export type SessionEventChange =
| { readonly kind: 'replace'; readonly entries: readonly SessionEventEntry[] }
| { readonly kind: 'prepend'; readonly entries: readonly SessionEventEntry[] }
| { readonly kind: 'append'; readonly entries: readonly SessionEventEntry[] }
| { readonly kind: 'replace'; readonly entries: readonly SessionEventLikeEntry[] }
| { readonly kind: 'prepend'; readonly entries: readonly SessionEventLikeEntry[] }
| { readonly kind: 'append'; readonly entries: readonly SessionLiveEventEntry[] }
/** Current contiguous event window and its latest synchronous delta. */
export interface SessionEventWindow {
readonly entries: readonly SessionEventEntry[]
readonly entries: readonly SessionEventLikeEntry[]
readonly hasMore: boolean
readonly revision: number
readonly change: SessionEventChange
@@ -108,7 +120,7 @@ export class MutableSessionEventSource implements SessionEventSource {
* @param entries - complete window.
* @param hasMore - whether older history remains.
*/
replace(entries: readonly SessionEventEntry[], hasMore: boolean): void {
replace(entries: readonly SessionEventLikeEntry[], hasMore: boolean): void {
this.window = leaf(entries)
this.publish(hasMore, { kind: 'replace', entries })
}
@@ -118,7 +130,7 @@ export class MutableSessionEventSource implements SessionEventSource {
* @param entries - newly loaded older entries.
* @param hasMore - whether still older history remains.
*/
prepend(entries: readonly SessionEventEntry[], hasMore: boolean): void {
prepend(entries: readonly SessionEventLikeEntry[], hasMore: boolean): void {
this.window = concat(leaf(entries), this.window)
this.publish(hasMore, { kind: 'prepend', entries })
}
@@ -127,7 +139,7 @@ export class MutableSessionEventSource implements SessionEventSource {
* Append one contiguous live entry.
* @param entry - live tail entry.
*/
append(entry: SessionEventEntry): void {
append(entry: SessionLiveEventEntry): void {
const entries = [entry]
this.window = concat(this.window, leaf(entries))
this.publish(this.snapshot.hasMore, {
@@ -43,7 +43,14 @@ export type {
export type { ISession, ProjectionsFace, SessionFace } from './contract/session.ts'
export type { ISessions } from './contract/sessions.ts'
export { MutableSessionEventSource } from './contract/events.ts'
export type { SessionEventChange, SessionEventSource, SessionEventWindow } from './contract/events.ts'
export type {
SessionEventChange,
SessionEventLike,
SessionEventLikeEntry,
SessionEventSource,
SessionEventWindow,
SessionLiveEventEntry,
} from './contract/events.ts'
export type {
OpenState,
PromptError,
@@ -0,0 +1,39 @@
/** Client range access and type narrowing for aligned Session history records. */
import type {
SessionHistoryRecord,
} from '../../types.ts'
import type { SessionEventLikeEntry } from '../contract/events.ts'
/**
* Narrow aligned wire records to their Client event types without allocation.
* @param records - validated history transport records.
* @returns the same record array with typed inner events.
*/
export function historyEntries(
records: readonly SessionHistoryRecord[],
): readonly SessionEventLikeEntry[] {
return records as unknown as readonly SessionEventLikeEntry[]
}
/**
* Read the first logical sequence represented by one wire record.
* @param record - validated scalar event or packed Assistant delta run.
* @returns inclusive first Session sequence.
*/
export function historyRecordFirstSeq(record: SessionHistoryRecord): number {
return record.event.seq
}
/**
* Read the final logical sequence represented by one wire record.
* @param record - validated scalar event or packed Assistant delta run.
* @returns inclusive final Session sequence.
*/
export function historyRecordLastSeq(record: SessionHistoryRecord): number {
if (record.type === 'event') return record.event.seq
const length = record.event.type === 'chunkrow/tool-call-chunks'
? record.event.data.args.length
: record.event.data.texts.length
return record.event.seq + length - 1
}
@@ -7,7 +7,7 @@ import type {
IApiClient, SubagentAddress,
} from '@deepseek-ai/dsh-client-connection/client'
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import {
SessionEventStream,
sessionStreamFailure,
@@ -18,7 +18,6 @@ import type {
QueueAction,
SessionAddress,
SessionControlFrame,
SessionEventEntry,
SessionQueuedItem,
SessionRequestId,
SessionError,
@@ -30,6 +29,9 @@ import type {
OpenState, PromptError, SessionSnapshot,
} from '../contract/snapshot.ts'
import { MutableSessionEventSource } from '../contract/events.ts'
import type {
SessionEventLikeEntry, SessionLiveEventEntry,
} from '../contract/events.ts'
import { Notifier } from './notifier.ts'
import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
import type { SessionRemotes } from './remotes.ts'
@@ -72,7 +74,6 @@ export interface SessionOptions {
*/
export class Session implements SessionFace {
// ---- Window and derived state (all private; the snapshot is the only read API) ----
private eventWindow: SessionEvent[] = []
private baseSeq = 0
private hasMore = false
private openState: OpenState = 'cold'
@@ -400,7 +401,6 @@ export class Session implements SessionFace {
this.openPromise = null
this.openState = 'cold'
this.openError = null
this.eventWindow = []
this.baseSeq = 0
this.notifier.markDirty()
await this.open()
@@ -575,28 +575,25 @@ export class Session implements SessionFace {
}
/** Replace the complete contiguous window and apply page-owned projection metadata. */
private installWindow(entries: readonly SessionEventEntry[], hasMore: boolean, projections?: ProjectionsBaseline): void {
this.eventWindow = entries.map(entry => entry.event as SessionEvent)
this.baseSeq = this.eventWindow[0]?.seq ?? 0
private installWindow(entries: readonly SessionEventLikeEntry[], hasMore: boolean, projections?: ProjectionsBaseline): void {
this.baseSeq = entries[0]?.event.seq ?? 0
this.hasMore = hasMore
if (this.eventWindow.some(event => event.type === 'turn/start')) this.firstPromptPendingTurn = false
if (entries.some(entry => entry.event.type === 'turn/start')) this.firstPromptPendingTurn = false
if (projections !== undefined) this.projections.seed(projections)
this.eventSource.replace(entries, hasMore)
this.notifier.markDirty()
}
/** Prepend one stream-validated history page. */
private prependWindow(entries: readonly SessionEventEntry[], hasMore: boolean): void {
this.eventWindow = [...entries.map(entry => entry.event as SessionEvent), ...this.eventWindow]
this.baseSeq = this.eventWindow[0]?.seq ?? 0
private prependWindow(entries: readonly SessionEventLikeEntry[], hasMore: boolean): void {
this.baseSeq = entries[0]?.event.seq ?? this.baseSeq
this.hasMore = hasMore
this.eventSource.prepend(entries, hasMore)
}
/** Append one stream-validated live event. */
private appendLive(entry: SessionEventEntry): boolean {
const event = entry.event as SessionEvent
this.eventWindow.push(event)
private appendLive(entry: SessionLiveEventEntry): boolean {
const event = entry.event
const awaitingFirstTurn = this.firstPromptPendingTurn
if (event.type === 'turn/start') this.firstPromptPendingTurn = false
const queueChanged = this.queueMirror.acceptDurable(event)
@@ -14,11 +14,17 @@ import {
import type {
SessionAddress,
SessionControlFrame,
SessionEventEntry,
SessionHistoryRecord,
SessionPage,
SessionPageRequest,
SessionProjectionBaseline,
} from '../types.ts'
import {
historyEntries,
historyRecordFirstSeq,
historyRecordLastSeq,
} from './sessions/history-records.ts'
import type { SessionEventLikeEntry, SessionLiveEventEntry } from './contract/events.ts'
export {
SESSION_SEARCH_RESULT_LIMIT,
@@ -37,7 +43,33 @@ interface SessionJournalPage extends SessionPage {
}
/** One complete publication from the Session journal stream. */
export type SessionJournalChange = RemoteJournalChange<SessionJournalPage, SessionEventEntry>
export type SessionJournalChange =
| {
readonly type: 'replace' | 'prepend'
readonly page: SessionJournalPage
readonly entries: readonly SessionEventLikeEntry[]
readonly hasMore: boolean
}
| { readonly type: 'append'; readonly entry: SessionLiveEventEntry }
function toSessionJournalChange(
change: RemoteJournalChange<SessionJournalPage, SessionHistoryRecord>,
): SessionJournalChange {
switch (change.type) {
case 'replace':
case 'prepend':
return { ...change, entries: historyEntries(change.entries) }
case 'append': {
if (change.entry.type !== 'event') {
throw new Error('session live stream emitted a packed history record')
}
return {
type: 'append',
entry: change.entry as unknown as SessionLiveEventEntry,
}
}
}
}
type SessionControlBaselineFrame = Extract<SessionControlFrame, { type: 'baseline' }>
type SessionControlDeltaFrame = Exclude<SessionControlFrame, SessionControlBaselineFrame>
@@ -100,7 +132,7 @@ export function createSessionControlStream(
/** Gateway-owned event journal bound to one ordinary or direct-subagent Session address. */
export class SessionEventStream extends RemoteJournalStream<
SessionJournalPage,
SessionEventEntry,
SessionHistoryRecord,
number,
ClientSessionPageRequest
> {
@@ -117,12 +149,13 @@ export class SessionEventStream extends RemoteJournalStream<
super(remote, {
name: 'session event stream',
emptyCursor: -1,
entries: page => page.events,
entries: page => page.records,
hasMore: page => page.hasMore,
cursor: entry => entry.event.seq,
first: historyRecordFirstSeq,
last: historyRecordLastSeq,
compare: (left, right) => left - right,
follows: (left, right) => right === left + 1,
publish: options.publish,
publish: (change) => { options.publish(toSessionJournalChange(change)) },
...(options.carrierFailed === undefined
? {}
: { carrierFailed: options.carrierFailed }),
@@ -134,7 +167,7 @@ export class SessionEventStream extends RemoteJournalStream<
protected override async * follow(
request: ClientSessionPageRequest,
signal: AbortSignal,
): AsyncIterable<RemoteJournalFrame<SessionEventEntry, number, SessionJournalPage>> {
): AsyncIterable<RemoteJournalFrame<SessionHistoryRecord, number, SessionJournalPage>> {
for await (const frame of this.remote.session.follow({
address: this.address,
...(request.maxMessages === undefined ? {} : { maxMessages: request.maxMessages }),
@@ -144,15 +177,14 @@ export class SessionEventStream extends RemoteJournalStream<
type: 'opened',
cursor: frame.cursor,
page: {
events: frame.events,
records: frame.records,
hasMore: frame.hasMore,
projections: frame.projections,
},
}
continue
}
const { type: _type, ...entry } = frame
yield { type: 'entry', entry }
yield { type: 'entry', entry: frame }
}
}
+35 -4
View File
@@ -2,15 +2,18 @@
import type { Context } from '@deepseek-ai/cordis'
import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session'
import { isChunkRow, packChunkRuns, type ChunkRow } from '@deepseek-ai/dsh-session/chunk-rows'
import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query'
import type {} from '@deepseek-ai/dsh-subagent'
import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol'
import type {
SessionAddress,
SessionChunkRun,
SessionEventEntry,
SessionFollowRequest,
SessionFollowFrame,
SessionHistoryRecord,
SessionPage,
SessionPageRequest,
SessionProjectionBaseline,
@@ -68,9 +71,9 @@ export class SessionHistoryController {
request.maxMessages ?? DEFAULT_MAX_MESSAGES,
request.throughSeq,
)
const entries = page.events.map(entryFor)
const records = pageRecords(page.events)
return {
events: entries,
records,
hasMore: page.hasMore,
}
}
@@ -128,7 +131,7 @@ export class SessionHistoryController {
type: 'snapshot',
header: source.header,
cursor,
events: page.events.map(entryFor),
records: pageRecords(page.events),
hasMore: page.hasMore,
projections: source.projections === undefined
? { asOfSeq: cursor, values: {} }
@@ -155,7 +158,7 @@ export class SessionHistoryController {
reject('internal', `session event stream skipped seq ${String(nextSeq)}`, {})
}
nextSeq++
yield { type: 'event', ...entryFor(item) }
yield entryFor(item)
}
} finally {
this.closeFollowers.delete(close)
@@ -313,7 +316,35 @@ function paginate(
function entryFor(event: SessionEvent): SessionEventEntry {
return {
type: 'event',
// Session.append validates and freezes event data as JSON before publication.
event: event as unknown as SessionWireEvent,
}
}
function chunkEntryFor(row: ChunkRow): SessionChunkRun {
switch (row.type) {
case 'text-chunks':
return {
type: 'chunks',
event: { type: 'chunkrow/text-chunks', seq: row.seq0, time: row.time0, data: row.data },
}
case 'reasoning-chunks':
return {
type: 'chunks',
event: { type: 'chunkrow/reasoning-chunks', seq: row.seq0, time: row.time0, data: row.data },
}
case 'tool-call-chunks':
return {
type: 'chunks',
event: { type: 'chunkrow/tool-call-chunks', seq: row.seq0, time: row.time0, data: row.data },
}
}
}
/** Encode one bounded logical page without changing its pagination cut. */
function pageRecords(events: readonly SessionEvent[]): SessionHistoryRecord[] {
return packChunkRuns(events).map(record => isChunkRow(record)
? chunkEntryFor(record)
: entryFor(record))
}
+24 -3
View File
@@ -6,6 +6,7 @@ import type {
import type { Branded } from '@deepseek-ai/dsh-brand'
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { ChunkRow } from '@deepseek-ai/dsh-session/chunk-rows'
import type { JsonValue, SessionHeader, SessionId, SurfaceOp } from '@deepseek-ai/dsh-session/types'
import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
import type { JobId } from '@deepseek-ai/dsh-jobs/brand'
@@ -361,9 +362,29 @@ export type SessionAddress =
/** One raw Session event in the Remote journal. */
export interface SessionEventEntry {
readonly type: 'event'
readonly event: SessionWireEvent
}
/** Event-shaped wire representation of one packed chunk row. */
export type ChunkRowEvent = {
[Kind in ChunkRow['type']]: {
readonly type: `chunkrow/${Kind}`
readonly seq: number
readonly time: number
readonly data: Extract<ChunkRow, { readonly type: Kind }>['data']
}
}[ChunkRow['type']]
/** One lossless run of consecutive Assistant delta events in a history page. */
export interface SessionChunkRun {
readonly type: 'chunks'
readonly event: ChunkRowEvent
}
/** One history-page record: a raw event or a packed Assistant delta run. */
export type SessionHistoryRecord = SessionEventEntry | SessionChunkRun
/** Session event wire form; durable readers own recognition of merge-extensible event names. */
export interface SessionWireEvent {
readonly type: string
@@ -392,7 +413,7 @@ export interface SessionFollowRequest {
/** One contiguous backwards page of a Session log. */
export interface SessionPage {
readonly events: readonly SessionEventEntry[]
readonly records: readonly SessionHistoryRecord[]
readonly hasMore: boolean
}
@@ -402,11 +423,11 @@ export type SessionFollowFrame =
readonly type: 'snapshot'
readonly header: SessionHeader
readonly cursor: number
readonly events: readonly SessionEventEntry[]
readonly records: readonly SessionHistoryRecord[]
readonly hasMore: boolean
readonly projections: SessionProjectionBaseline
}
| ({ readonly type: 'event' } & SessionEventEntry)
| SessionEventEntry
/** One pending inbox occurrence in the authoritative queue snapshot. */
export interface SessionQueuedItem {
@@ -1,16 +1,17 @@
import type { SessionEventEntry } from '@deepseek-ai/dsh-api-session-controller/types'
import { describe, expect, it, vi } from 'vitest'
import { MutableSessionEventSource } from '../src/client/contract/events.ts'
import {
MutableSessionEventSource, type SessionLiveEventEntry,
} from '../src/client/contract/events.ts'
import { transportResult } from '../src/client/contract/result.ts'
function entry(seq: number): SessionEventEntry {
function entry(seq: number): SessionLiveEventEntry {
return {
type: 'event',
event: {
type: 'fixture/event',
type: 'turn/start',
seq,
time: seq,
data: { seq },
ignorable: true,
data: { turn: seq },
},
}
}
@@ -5,6 +5,11 @@ import {
// host emits; only the fields the object layer reads).
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type {
SessionEventEntry,
SessionPage,
SessionWireEvent,
} from '../src/types.ts'
/** One text content block (local helper). */
const text = (t: string): ContentBlock[] => [{ type: 'text', text: t }]
@@ -143,6 +148,14 @@ export function plainTurn(startSeq: number, turn: number, ask: string, answer: s
}
/** Wrap raw events in the journal envelope returned by history. */
export function entries(events: readonly SessionEvent[]): { event: SessionEvent }[] {
return events.map(event => ({ event }))
export function entries(events: readonly SessionEvent[]): SessionEventEntry[] {
return events.map(event => ({ type: 'event', event: event as unknown as SessionWireEvent }))
}
/** Build one view-less history response value. */
export function historyValue(events: readonly SessionEvent[], hasMore = false): SessionPage {
return {
records: entries(events),
hasMore,
}
}
@@ -28,6 +28,7 @@ import {
} from '@deepseek-ai/dsh-api-gateway/client'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
import type { SessionRemotes } from '../src/client/sessions/remotes.ts'
import { historyRecordLastSeq } from '../src/client/sessions/history-records.ts'
const AVAILABLE_STREAM_CONNECTION = {
hostDescription: {
@@ -137,7 +138,7 @@ export class FakeApiClient implements IApiClient {
onFork: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId }))
onHistory: (payload: { sessionId: SessionId; throughSeq?: number; beforeSeq?: number; maxMessages?: number })
=> Promise<RpcResponse<SessionPage & { readonly projections?: SessionProjectionBaseline }>> =
() => Promise.resolve(ok({ events: [], hasMore: false }))
() => Promise.resolve(ok({ records: [], hasMore: false }))
onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onAttachment: (payload: unknown) => Promise<RpcResponse<{ attachment: { attachmentId: never; mediaType: 'image/png'; bytes: number; width: number; height: number }; data: string }>> =
@@ -439,7 +440,8 @@ export class FakeApiClient implements IApiClient {
ok: true,
value: {
...result.value,
events: result.value.events.filter(entry => entry.event.seq <= request.throughSeq),
records: result.value.records
.filter(record => historyRecordLastSeq(record) <= request.throughSeq),
},
}
}
@@ -467,7 +469,8 @@ export class FakeApiClient implements IApiClient {
)
}
const page = response.result.value
const cursor = this.followCursor ?? page.events.at(-1)?.event.seq ?? -1
const tail = page.records.at(-1)
const cursor = this.followCursor ?? (tail === undefined ? -1 : historyRecordLastSeq(tail))
yield {
type: 'snapshot',
header: {
@@ -479,7 +482,7 @@ export class FakeApiClient implements IApiClient {
: {}),
},
cursor,
events: page.events.filter(entry => entry.event.seq <= cursor),
records: page.records.filter(record => historyRecordLastSeq(record) <= cursor),
hasMore: page.hasMore,
projections: page.projections ?? { asOfSeq: cursor, values: {} },
}
@@ -0,0 +1,78 @@
/** Packed history records become one event-shaped Client value per wire record. */
import { describe, expect, it } from 'vitest'
import { CallId } from '@deepseek-ai/dsh-llm/brand'
import type { SessionHistoryRecord } from '../src/types.ts'
import {
historyEntries,
historyRecordFirstSeq,
historyRecordLastSeq,
} from '../src/client/sessions/history-records.ts'
describe('Session history record projection', () => {
it('retains an ordinary event and its point cursor', () => {
const ordinary: SessionHistoryRecord = {
type: 'event',
event: { type: 'turn/start', seq: 7, time: 1, data: { turn: 1 } },
}
const records = [ordinary]
const [entry] = historyEntries(records)
expect(historyEntries(records)).toBe(records)
expect(entry).toBe(ordinary)
expect(historyRecordFirstSeq(ordinary)).toBe(7)
expect(entry?.event.time).toBe(1)
expect(historyRecordLastSeq(ordinary)).toBe(7)
})
it('retains one packed text row without copying or reshaping it', () => {
const packed: SessionHistoryRecord = {
type: 'chunks',
event: {
type: 'chunkrow/text-chunks',
seq: 11,
time: 20,
data: { turn: 1, step: 2, index: 0, dt: [1, 2, 3], texts: ['a', 'b', 'c', 'd'] },
},
}
const [entry] = historyEntries([packed])
if (entry?.type !== 'chunks') throw new Error('expected packed history entry')
const { event } = entry
expect(entry).toBe(packed)
expect(event).toBe(packed.event)
expect(historyRecordFirstSeq(packed)).toBe(11)
expect(event.time).toBe(20)
expect(historyRecordLastSeq(packed)).toBe(14)
})
it('preserves a packed tool-call row and optional-name absence', () => {
const packed: SessionHistoryRecord = {
type: 'chunks',
event: {
type: 'chunkrow/tool-call-chunks',
seq: 20,
time: 200,
data: {
turn: 2,
step: 4,
index: 1,
id: CallId('call-1'),
dt: [2, 3],
args: ['', '{"x":', '1}'],
},
},
}
const [entry] = historyEntries([packed])
if (entry?.type !== 'chunks') throw new Error('expected packed history entry')
const { event } = entry
if (event.type !== 'chunkrow/tool-call-chunks') throw new Error('expected packed history event')
expect(event).toBe(packed.event)
expect(Object.hasOwn(event.data, 'name')).toBe(false)
expect(historyRecordLastSeq(packed)).toBe(22)
})
})
@@ -759,7 +759,7 @@ describe('connected generation', () => {
it('refreshes query baselines without rebuilding independently resumed Session sources', async () => {
const api = new FakeApiClient()
api.onHistory = () => Promise.resolve(ok({
events: entries(plainTurn(0, 0, 'a', 'b')) as never[],
records: entries(plainTurn(0, 0, 'a', 'b')) as never[],
hasMore: false,
modelSelection: { provider: 'deepseek-official', model: 'deepseek-chat' },
}))
@@ -105,7 +105,7 @@ describe('Session tail-page seeding', () => {
const api = new FakeApiClient()
const session = new Session(SID, api, fakeRemote(api))
api.onHistory = () => Promise.resolve(ok({
events: entries(plainTurn(0, 0, '问', '答')) as never[], hasMore: false,
records: entries(plainTurn(0, 0, '问', '答')) as never[], hasMore: false,
projections: { asOfSeq: 5, values: { 'test/marks': { marks: ['from-baseline'] } } },
} as never))
await session.open()
@@ -116,7 +116,7 @@ describe('Session tail-page seeding', () => {
const api = new FakeApiClient()
const session = new Session(SID, api, fakeRemote(api))
api.onHistory = () => Promise.resolve(ok({
events: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false,
records: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false,
projections: { asOfSeq: 5, values: { 'test/marks': { marks: ['baseline'] } } },
} as never))
await session.open()
@@ -128,7 +128,7 @@ describe('Session tail-page seeding', () => {
it('treats a blockless response as no reset: pushed values survive', async () => {
const api = new FakeApiClient()
const session = new Session(SID, api, fakeRemote(api))
api.onHistory = () => Promise.resolve(ok({ events: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false }))
api.onHistory = () => Promise.resolve(ok({ records: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false }))
await session.open()
session.projections.apply('test/marks', { marks: ['pushed'] }, 9)
await session.resync()
@@ -390,7 +390,7 @@ describe('cold history recovery view', () => {
maxMessages: 10,
})
if (!history.ok) throw new Error('history failed')
expect(history.value.events.map(entry => entry.event)).toMatchInlineSnapshot(`
expect(history.value.records.map(record => record.event)).toMatchInlineSnapshot(`
[
{
"data": {
@@ -564,7 +564,8 @@ describe('subagent ownership fence', () => {
},
throughSeq: 3,
}, new AbortController().signal)
expect(history.events.map(entry => entry.event.type)).toEqual(events.map(event => event.type))
expect(history.records.map(record => record.event.type))
.toEqual(events.map(event => event.type))
expect(ctx.agents.get(sessionId)).toBeUndefined()
const prompt = await remote.prompt(promptRequest({
@@ -4,10 +4,16 @@ import { describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import SessionStore from '@deepseek-ai/dsh-session'
import { decodeStorageRecord, type ChunkRow } from '@deepseek-ai/dsh-session/chunk-rows'
import { CallId, createMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import { SessionHistoryController } from '@deepseek-ai/dsh-api-session-controller/src/history.ts'
import type { SessionFollowFrame } from '@deepseek-ai/dsh-api-session-controller/types'
import type {
ChunkRowEvent,
SessionFollowFrame,
SessionPage,
SessionWireEvent,
} from '@deepseek-ai/dsh-api-session-controller/types'
import { createSessionTestRemote, installSessionReadTestServices } from './test-remote.ts'
/** Append a production-shaped human prompt to the session surface. */
@@ -77,6 +83,24 @@ async function openFollow(
return { [Symbol.asyncIterator]: () => iterator }
}
/** Expand packed page records for assertions over the logical journal. */
function pageEvents(page: SessionPage): SessionWireEvent[] {
return page.records.flatMap(record => record.type === 'event'
? [record.event]
: decodeStorageRecord(chunkRow(record.event)).map(event => event as unknown as SessionWireEvent))
}
function chunkRow(event: ChunkRowEvent): ChunkRow {
switch (event.type) {
case 'chunkrow/text-chunks':
return { type: 'text-chunks', seq0: event.seq, time0: event.time, data: event.data }
case 'chunkrow/reasoning-chunks':
return { type: 'reasoning-chunks', seq0: event.seq, time0: event.time, data: event.data }
case 'chunkrow/tool-call-chunks':
return { type: 'tool-call-chunks', seq0: event.seq, time0: event.time, data: event.data }
}
}
describe('Session history raw journal', () => {
it('follows raw tool events and preserves result metadata without a Tools service', async () => {
const { ctx } = await harness()
@@ -169,10 +193,10 @@ describe('Session history raw journal', () => {
})
expect(response.ok).toBe(true)
if (!response.ok) throw new Error('unreachable')
expect(response.value.events).toEqual([
{ event: start },
{ event: call },
{ event: result },
expect(response.value.records).toEqual([
{ type: 'event', event: start },
{ type: 'event', event: call },
{ type: 'event', event: result },
])
})
@@ -210,7 +234,7 @@ describe('Session history raw journal', () => {
maxMessages: 2,
})
if (!response.ok) throw new Error('unreachable')
const page = response.value.events.map(entry => entry.event)
const page = pageEvents(response.value)
// Two append-origin messages fill the page even though a replacement copy of
// the same event type sits in the window: the copy is model-only.
const messages = page.filter(event => event.type === 'user/message' || event.type === 'assistant/message')
@@ -230,10 +254,10 @@ describe('Session history raw journal', () => {
const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } })
session.append('turn/start', { turn: 1 })
const sources = Array.from({ length: 128 }, (_unused, index) => session.append('assistant/chunk', {
const sources = Array.from({ length: 128 }, () => session.append('assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'text-delta', index, text: 'x' },
chunk: { type: 'text-delta', index: 0, text: 'x' },
}).seq)
const message = session.append('assistant/message', {
turn: 1,
@@ -257,13 +281,71 @@ describe('Session history raw journal', () => {
maxMessages: 1,
})
if (!response.ok) throw new Error('unreachable')
expect(response.value.events.map(entry => entry.event.seq)).toEqual([...sources, message.seq])
expect(pageEvents(response.value).map(event => event.seq)).toEqual([...sources, message.seq])
expect(response.value.records.filter(record => record.type === 'chunks')).toHaveLength(1)
expect(response.value.hasMore).toBe(true)
} finally {
min.mockRestore()
}
})
it('encodes reasoning and tool-call runs as aligned chunk events', async () => {
const { ctx } = await harness()
const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } })
const reasoning = [0, 1, 2].map(index => session.append('assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'reasoning-delta', index: 0, text: `r${String(index)}` },
}))
const callId = CallId('packed-call')
const toolCall = [0, 1, 2].map(index => session.append('assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'tool-call-delta', index: 1, id: callId, argumentsDelta: `a${String(index)}` },
}))
const response = await remote.page({
address: { kind: 'session', sessionId: session.id },
throughSeq: session.seq - 1,
})
if (!response.ok) throw new Error('unreachable')
expect(response.value.records).toEqual([
{
type: 'chunks',
event: {
type: 'chunkrow/reasoning-chunks',
seq: reasoning[0]?.seq,
time: reasoning[0]?.time,
data: {
turn: 1,
step: 1,
index: 0,
dt: reasoning.slice(1).map((event, index) => event.time - (reasoning[index]?.time ?? 0)),
texts: ['r0', 'r1', 'r2'],
},
},
},
{
type: 'chunks',
event: {
type: 'chunkrow/tool-call-chunks',
seq: toolCall[0]?.seq,
time: toolCall[0]?.time,
data: {
turn: 1,
step: 1,
index: 1,
id: callId,
dt: toolCall.slice(1).map((event, index) => event.time - (toolCall[index]?.time ?? 0)),
args: ['a0', 'a1', 'a2'],
},
},
},
])
await ctx.fiber.dispose()
})
it('follows a result after turn/end without reading the addressed Session log', async () => {
const { ctx } = await harness()
const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } })
@@ -273,11 +355,17 @@ describe('Session history raw journal', () => {
const iterator = stream[Symbol.asyncIterator]()
session.append('turn/start', { turn: 1 })
await expect(iterator.next()).resolves.toMatchObject({ value: { event: { type: 'turn/start' } } })
await expect(iterator.next()).resolves.toMatchObject({
value: { type: 'event', event: { type: 'turn/start' } },
})
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-late'), name: 'term', arguments: '{"cmd":"tail"}' })
await expect(iterator.next()).resolves.toMatchObject({ value: { event: { type: 'tool/call' } } })
await expect(iterator.next()).resolves.toMatchObject({
value: { type: 'event', event: { type: 'tool/call' } },
})
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await expect(iterator.next()).resolves.toMatchObject({ value: { event: { type: 'turn/end' } } })
await expect(iterator.next()).resolves.toMatchObject({
value: { type: 'event', event: { type: 'turn/end' } },
})
const events = vi.spyOn(session, 'events', 'get').mockImplementation(() => {
throw new Error('live result rescanned Session history')
})
@@ -148,11 +148,12 @@ describe('session.history projections block', () => {
ctx.sessionProjections.register(lastUserUnit())
seedMessages(session, 3)
const snapshot = await opening(remote(ctx), session.id)
const { events, projections } = snapshot
const { records, projections } = snapshot
expect(projections.asOfSeq).toBe(session.seq - 1)
expect(projections.values['test/last-user']).toEqual({ text: 'm2' })
// asOfSeq IS the window tail: the last served event carries it.
expect(events.at(-1)?.event.seq).toBe(projections.asOfSeq)
const last = records.at(-1)
expect(last?.event.seq).toBe(projections.asOfSeq)
})
it('returns a complete current replacement cut on each follow generation', async () => {
@@ -162,7 +163,7 @@ describe('session.history projections block', () => {
const snapshot = await opening(remote(ctx), session.id)
expect(snapshot.events.map(entry => entry.event.seq)).toEqual([0, 1])
expect(snapshot.records.map(record => record.event.seq)).toEqual([0, 1])
expect(snapshot.projections.asOfSeq).toBe(1)
expect(snapshot.projections.values).toEqual(
expect.objectContaining({ 'test/last-user': { text: 'm1' } }),
@@ -175,7 +176,7 @@ describe('session.history projections block', () => {
const snapshot = await opening(remote(ctx), session.id)
expect(snapshot.events).toEqual([])
expect(snapshot.records).toEqual([])
expect(snapshot.projections.asOfSeq).toBe(-1)
expect(snapshot.projections.values).toEqual(
expect.objectContaining({ 'test/last-user': null }),
@@ -6,7 +6,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
import { Session, type SessionOptions } from '../src/client/sessions/session.ts'
import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.client.ts'
import { entries, ev, plainTurn } from './event-script.client.ts'
import { entries, ev, historyValue, plainTurn } from './event-script.client.ts'
const SID = 'fk-s1' as SessionId
const PARENT = 'fk-parent' as SessionId
@@ -41,8 +41,7 @@ function eventSeqs(session: Session): number[] {
}
function histResponse(events: SessionEvent[], hasMore = false) {
// History returns raw journal envelopes around each event.
return Promise.resolve(ok({ events: entries(events) as never[], hasMore }))
return Promise.resolve(ok(historyValue(events, hasMore)))
}
describe('Session open', () => {
@@ -106,7 +105,7 @@ describe('Session open', () => {
follow(api, ev.user(16, '插进来的')),
]
gate.resolve(ok({
events: entries(page) as never[],
records: entries(page) as never[],
hasMore: false,
modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
}))
@@ -227,7 +226,7 @@ describe('paging', () => {
const first = session.loadOlder()
const second = session.loadOlder()
gate.resolve(ok({
events: entries(plainTurn(0, 0, 'a', 'b')) as never[],
records: entries(plainTurn(0, 0, 'a', 'b')) as never[],
hasMore: false,
modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
}))
@@ -571,7 +570,7 @@ describe('remaining branches', () => {
api.onHistory = () => histResponse(plainTurn(6, 1, '新', '代'))
const resynced = session.resync()
stale.resolve(ok({
events: entries(plainTurn(0, 0, '旧', '代')) as never[],
records: entries(plainTurn(0, 0, '旧', '代')) as never[],
hasMore: false,
modelSelection: { provider: 'deepseek-official', model: 'stale' },
})) // success, but its generation is gone
@@ -590,7 +589,7 @@ describe('remaining branches', () => {
api.onHistory = () => histResponse(plainTurn(6, 1, 'c', 'd'))
const resynced = session.resync() // bumps the generation
repairPull.resolve(ok({
events: entries(plainTurn(0, 0, '旧', '页')) as never[],
records: entries(plainTurn(0, 0, '旧', '页')) as never[],
hasMore: false,
modelSelection: { provider: 'deepseek-official', model: 'stale' },
})) // repair result: stale, dropped
@@ -617,25 +616,25 @@ describe('remaining branches', () => {
const historyCall = ev.toolCall(6, 1, 'h1', 'bash', '{"cmd":"pwd"}')
const historyResult = ev.toolResult(7, 1, 'h1', 'done')
api.onHistory = () => Promise.resolve(ok({
events: [
records: [
...entries(plainTurn(0, 0, 'a', 'b')),
{ event: historyCall },
{ event: historyResult },
{ type: 'event', event: historyCall },
{ type: 'event', event: historyResult },
] as never[],
hasMore: false,
modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
}))
await session.open()
expect(windowEntries(session).slice(-2)).toEqual([
{ event: historyCall },
{ event: historyResult },
{ type: 'event', event: historyCall },
{ type: 'event', event: historyResult },
])
const liveCall = ev.toolCall(8, 2, 'l1', 'write', '{"file_path":"a.ts"}')
await follow(api, liveCall)
expect(windowEntries(session).at(-1)).toEqual({ event: liveCall })
expect(windowEntries(session).at(-1)).toEqual({ type: 'event', event: liveCall })
const liveResult = ev.toolResult(9, 2, 'l1', 'ok')
await follow(api, liveResult)
expect(windowEntries(session).at(-1)).toEqual({ event: liveResult })
expect(windowEntries(session).at(-1)).toEqual({ type: 'event', event: liveResult })
})
})
@@ -669,7 +668,7 @@ describe('resync', () => {
])
expect(session.eventSource.getSnapshot()).toBe(oldWindow)
replacement.resolve(ok({
events: entries(plainTurn(10, 2, '终', '页')) as never[],
records: entries(plainTurn(10, 2, '终', '页')) as never[],
hasMore: false,
modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
}))
@@ -274,7 +274,7 @@ describe('Agent scope disposal lifecycle', () => {
createdAt: 0,
},
cursor: -1,
events: [],
records: [],
hasMore: false,
projections: { asOfSeq: -1, values: {} },
} as const,
@@ -343,7 +343,7 @@ describe('Agent scope disposal lifecycle', () => {
type: 'snapshot',
header: { version: 0, id: sessionId, createdAt: 0 },
cursor: -1,
events: [],
records: [],
hasMore: false,
projections: { asOfSeq: -1, values: {} },
} as const,
@@ -19,6 +19,7 @@ import type {
SessionEventEntry,
SessionFollowFrame,
SessionFollowRequest,
SessionHistoryRecord,
SessionPage,
SessionPageRequest,
} from '../src/types.ts'
@@ -36,16 +37,28 @@ const AVAILABLE_CONNECTION = {
}
function entry(seq: number): SessionEventEntry {
return { event: { type: 'turn/start', seq, time: seq, data: { turn: seq } } }
return { type: 'event', event: { type: 'turn/start', seq, time: seq, data: { turn: seq } } }
}
function page(events: readonly SessionEventEntry[], hasMore = false): SessionPage {
return { events, hasMore }
function chunks(seq0: number): SessionHistoryRecord {
return {
type: 'chunks',
event: {
type: 'chunkrow/text-chunks',
seq: seq0,
time: seq0,
data: { turn: 1, step: 1, index: 0, texts: ['a', 'b', 'c'], dt: [1, 1] },
},
}
}
function page(records: readonly SessionHistoryRecord[], hasMore = false): SessionPage {
return { records, hasMore }
}
function snapshot(
cursor: number,
events: readonly SessionEventEntry[],
records: readonly SessionHistoryRecord[],
hasMore = false,
): SessionFollowFrame {
return {
@@ -56,7 +69,7 @@ function snapshot(
createdAt: 0,
},
cursor,
events,
records,
hasMore,
projections: { asOfSeq: cursor, values: {} },
}
@@ -123,13 +136,60 @@ class ScriptedSessionRemote implements SessionTransportRemote {
}
describe('Session Client stream adapters', () => {
it('validates a packed logical range before publishing one compact Client entry', async () => {
const row = chunks(1)
const remote = new ScriptedSessionRemote(
[{ frames: [snapshot(4, [entry(0), row, entry(4)]), entry(5)], hold: true }],
[],
)
const changes: SessionJournalChange[] = []
const stream = new SessionEventStream(sessionClient(remote), ADDRESS, {
publish: (change) => { changes.push(change) },
failed: vi.fn(),
})
await stream.open({})
await vi.waitFor(() => { expect(changes).toHaveLength(2) })
expect(changes[0]).toMatchObject({
type: 'replace',
entries: [
entry(0),
row,
entry(4),
],
})
expect(changes[0]?.type === 'replace' ? changes[0].entries[1] : undefined).toBe(row)
expect(changes[1]).toEqual({ type: 'append', entry: entry(5) })
await stream.dispose()
})
it('rejects a packed record emitted by the live follow path', async () => {
const failed = vi.fn()
const remote = new ScriptedSessionRemote(
[{ frames: [snapshot(-1, []), chunks(0) as SessionFollowFrame], hold: true }],
[],
)
const stream = new SessionEventStream(sessionClient(remote), ADDRESS, {
publish: vi.fn(),
failed,
})
await stream.open({})
await vi.waitFor(() => { expect(failed).toHaveBeenCalledOnce() })
expect(failed.mock.calls[0]?.[0]).toMatchObject({
message: 'session live stream emitted a packed history record',
})
await stream.dispose()
})
it('binds an event journal to one address and publishes replace, append, and prepend changes', async () => {
const remote = new ScriptedSessionRemote(
[{
frames: [
snapshot(3, [entry(2), entry(3)], true),
{ type: 'event', ...entry(3) },
{ type: 'event', ...entry(4) },
entry(3),
entry(4),
],
hold: true,
}],
@@ -165,7 +225,7 @@ describe('Session Client stream adapters', () => {
const remote = new ScriptedSessionRemote(
[
{
frames: [snapshot(1, [entry(0), entry(1)]), { type: 'event', ...entry(2) }],
frames: [snapshot(1, [entry(0), entry(1)]), entry(2)],
terminal: lost,
},
{ frames: [snapshot(4, [entry(0), entry(1), entry(2), entry(3), entry(4)])], hold: true },
@@ -221,7 +281,7 @@ describe('Session Client stream adapters', () => {
it('repairs a live gap without adding an absent message limit', async () => {
const remote = new ScriptedSessionRemote(
[{ frames: [snapshot(0, [entry(0)]), { type: 'event', ...entry(2) }], hold: true }],
[{ frames: [snapshot(0, [entry(0)]), entry(2)], hold: true }],
[{ ok: true, value: page([entry(0), entry(1), entry(2)]) }],
)
const changes: SessionJournalChange[] = []
@@ -87,7 +87,7 @@ describe('SessionHistoryController', () => {
{ address: { kind: 'session', sessionId: session.id }, throughSeq: 1 },
new AbortController().signal,
)
expect(page.events.map(entry => entry.event.seq)).toEqual([0, 1])
expect(page.records.map(entry => entry.event.seq)).toEqual([0, 1])
abort.abort()
expect(await iterator.next()).toMatchObject({ done: true })
@@ -137,7 +137,11 @@ describe('SessionHistoryController', () => {
value: {
type: 'snapshot',
cursor: 2,
events: [{ event: { seq: 0 } }, { event: { seq: 1 } }, { event: { seq: 2 } }],
records: [
{ type: 'event', event: { seq: 0 } },
{ type: 'event', event: { seq: 1 } },
{ type: 'event', event: { seq: 2 } },
],
},
})
session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
@@ -200,7 +204,12 @@ describe('SessionHistoryController', () => {
await expect(opening).resolves.toMatchObject({
done: false,
value: {
type: 'snapshot', cursor: 1, events: [{ event: { seq: 0 } }, { event: { seq: 1 } }],
type: 'snapshot',
cursor: 1,
records: [
{ type: 'event', event: { seq: 0 } },
{ type: 'event', event: { seq: 1 } },
],
},
})
expect(attached.id).toBe(sessionId)
@@ -301,7 +310,7 @@ describe('SessionHistoryController', () => {
await expect(iterator.next()).resolves.toMatchObject({ done: false, value: { type: 'snapshot', cursor: -1 } })
await expect(transport.page({
address: { kind: 'session', sessionId: session.id }, throughSeq: -1,
}, signal())).resolves.toMatchObject({ events: [], hasMore: false })
}, signal())).resolves.toMatchObject({ records: [], hasMore: false })
abort.abort()
await expect(iterator.next()).resolves.toMatchObject({ done: true })
})
@@ -377,7 +386,9 @@ describe('SessionHistoryController', () => {
await expect(transport.page({
address: { kind: 'subagent', parentSessionId, childSessionId, mode: 'continuable' },
throughSeq: 0,
}, signal)).resolves.toMatchObject({ events: [{ event: { type: 'subagent/descriptor' } }] })
}, signal)).resolves.toMatchObject({
records: [{ type: 'event', event: { type: 'subagent/descriptor' } }],
})
await expect(transport.page({
address: {
kind: 'subagent',
@@ -505,7 +516,9 @@ describe('SessionHistoryController', () => {
await expect(ordinaryBench.transport.page({
address: { kind: 'session', sessionId: ordinaryId },
throughSeq: 0,
}, signal())).resolves.toMatchObject({ events: [{ event: { seq: 0 } }] })
}, signal())).resolves.toMatchObject({
records: [{ type: 'event', event: { seq: 0 } }],
})
const parentSessionId = SessionId('cold-parent')
const childSessionId = SessionId('cold-child')
@@ -618,12 +631,13 @@ describe('SessionHistoryController', () => {
const page = await transport.page({
address: { kind: 'session', sessionId: session.id }, throughSeq: replacement.seq, maxMessages: 2,
}, signal())
expect(page.events.map(entry => entry.event.seq)).toEqual([3, 4, 5, replacement.seq])
expect(page.records.map(entry => entry.event.seq))
.toEqual([3, 4, 5, replacement.seq])
expect(page.hasMore).toBe(true)
const before = await transport.page({
address: { kind: 'session', sessionId: session.id }, throughSeq: replacement.seq, beforeSeq: 3, maxMessages: 1,
}, signal())
expect(before.events.map(entry => entry.event.seq)).toEqual([2])
expect(before.records.map(entry => entry.event.seq)).toEqual([2])
})
it('keeps cited source events in the page that owns their appended message', async () => {
@@ -637,7 +651,7 @@ describe('SessionHistoryController', () => {
const page = await transport.page({
address: { kind: 'session', sessionId: session.id }, throughSeq: 1, maxMessages: 1,
}, signal())
expect(page.events.map(entry => entry.event.seq)).toEqual([0, 1])
expect(page.records.map(entry => entry.event.seq)).toEqual([0, 1])
expect(page.hasMore).toBe(false)
})
@@ -22,7 +22,7 @@ export { canPassThroughNormalization, normalizeImage } from './normalization.ts'
export type { NormalizedImage, NormalizationPolicy } from './normalization.ts'
export { commitPreparedImageFile, prepareImageFile, readImageFile, saveImageFile, validateImageFile } from './store.ts'
export type { PreparedImageFile } from './store.ts'
export { readRequestImageFile, requestImageDimensions, requestImageVariantId } from './request-image.ts'
export { readRequestImageFile, requestImageVariantId } from './request-image.ts'
/** Default maximum encoded bytes for one submitted image; oversized sources are refused, not shrunk. */
export const DEFAULT_MAX_IMAGE_BYTES = 20 * 1024 * 1024
@@ -1,10 +1,9 @@
/** Deterministic provider-independent image normalization. */
import sharp, { type Sharp } from 'sharp'
import { AttachmentError } from '@deepseek-ai/dsh-attachment'
import { AttachmentError, requestImageDimensions } from '@deepseek-ai/dsh-attachment'
import type { ImageMediaType } from '@deepseek-ai/dsh-attachment'
import { encodeFirstWithinLimit, encodingLadder, isExhaustedEncoding } from './encoding.ts'
import { requestImageDimensions } from './request-image.ts'
import { detectImage, encodedAlphaIsCompatible } from './image.ts'
import type { DetectedImage } from './image.ts'
@@ -4,7 +4,7 @@ import { createHash, randomUUID } from 'node:crypto'
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import sharp, { type Sharp } from 'sharp'
import { AttachmentError, ImageVariantId } from '@deepseek-ai/dsh-attachment'
import { AttachmentError, ImageVariantId, requestImageDimensions } from '@deepseek-ai/dsh-attachment'
import type {
ImageMediaType,
ImageAttachmentRef,
@@ -39,38 +39,6 @@ function digest(value: string | Uint8Array): string {
return createHash('sha256').update(value).digest('hex')
}
/**
* Compute aspect-preserving integer dimensions within a hard total-pixel budget.
* @param width - positive source width.
* @param height - positive source height.
* @param maxPixels - positive width-times-height cap.
* @returns inward-rounded dimensions; small images are not enlarged.
*/
export function requestImageDimensions(
width: number,
height: number,
maxPixels: number,
): { width: number; height: number } {
const scale = Math.min(1, Math.sqrt(maxPixels / (width * height)))
if (scale === 1) return { width, height }
if (width >= height) {
let projectedWidth = Math.max(1, Math.floor(width * scale))
let projectedHeight = Math.max(1, Math.round(projectedWidth * height / width))
while (projectedWidth * projectedHeight > maxPixels && projectedWidth > 1) {
projectedWidth -= 1
projectedHeight = Math.max(1, Math.round(projectedWidth * height / width))
}
return { width: projectedWidth, height: projectedHeight }
}
let projectedHeight = Math.max(1, Math.floor(height * scale))
let projectedWidth = Math.max(1, Math.round(projectedHeight * width / height))
while (projectedWidth * projectedHeight > maxPixels && projectedHeight > 1) {
projectedHeight -= 1
projectedWidth = Math.max(1, Math.round(projectedHeight * width / height))
}
return { width: projectedWidth, height: projectedHeight }
}
function checkedInteger(value: number, name: string): number {
if (!Number.isSafeInteger(value) || value <= 0) {
throw new AttachmentError(`${name} must be a positive integer.`, 'INVALID_ATTACHMENT_REF')
@@ -5,7 +5,7 @@ import { Context } from '@deepseek-ai/cordis'
import sharp from 'sharp'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { CompressionLimiter } from '../src/compression-limiter.ts'
import LocalAttachmentStore, { requestImageDimensions } from '../src/index.ts'
import LocalAttachmentStore from '../src/index.ts'
const homes: string[] = []
@@ -42,34 +42,6 @@ afterEach(async () => {
await Promise.all(homes.splice(0).map(home => rm(home, { recursive: true, force: true })))
})
describe('request image dimensions', () => {
it.each([
[4096, 4096, 800, 800],
[4096, 2048, 1130, 565],
[3840, 2160, 1066, 600],
[320, 240, 320, 240],
])('projects %sx%s under 640,000 pixels as %sx%s', (width, height, expectedWidth, expectedHeight) => {
const projected = requestImageDimensions(width, height, 640_000)
expect(projected).toEqual({
width: expectedWidth,
height: expectedHeight,
})
expect(projected.width * projected.height).toBeLessThanOrEqual(640_000)
})
it('projects a portrait within the same total-pixel budget', () => {
const projected = requestImageDimensions(2160, 3840, 640_000)
expect(projected).toEqual({ width: 600, height: 1066 })
expect(projected.width * projected.height).toBeLessThanOrEqual(640_000)
})
it('rounds a portrait inward when integer aspect rounding crosses the pixel cap', () => {
expect(requestImageDimensions(2, 4, 5)).toEqual({ width: 1, height: 2 })
})
})
describe('local request-image cache', () => {
it('passes through an in-budget attachment and composes ordered request reads', async () => {
const attachments = await store()
@@ -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/attachment/attachment/README.md
README.md: 21030a492464aae528d4c06b4b72d4a94c0a2603
README.zh.md: 0540996f99b3250331e567e174264cf7da8aa474
README.md: 976bfc82a4cf8a626259ffcddabcbead8ba03154
README.zh.md: bc33293b34295250c332d6111fb0d52e506c47db
+1 -1
View File
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
The durable attachment seam. `ctx.attachments` validates and durably commits a provider-independent normalized image, then returns a serializable `ImageAttachmentRef`; consumers never persist browser paths, object URLs, provider URLs, local storage paths, or base64 in session events.
Unsent composer images remain browser-owned temporary drafts. `validateImage` runs the complete admission policy without persisting. `saveImages` owns batch count and aggregate-byte limits, prepares every normalized attachment before publishing any member, then commits in order and returns references only after the complete batch succeeds. A later storage failure returns no partial references, although an earlier immutable content-addressed object may remain unreachable until reference-aware garbage collection exists. `AttachmentError.code` uses the closed `AttachmentErrorCode` string union. Its `ImageAdmissionErrorCode` subset marks caller-correctable image-input failures; `isImageAdmissionError` recognizes that subset at runtime so each protocol adapter can map its own error vocabulary. `saveImage` commits one accepted image before any model-visible session event is published and returns its `ImageAttachmentRef`. When normalization reduces the raster, the reference records the orientation-applied input size in `originalDimensions`. `readImage` verifies the normalized attachment against its logged metadata. `readImageRequest` deterministically derives a route-sized request version whose identity covers the attachment id, transform version, pixel and byte budgets, and encoder settings. `imageHostPath` optionally exposes the provider-owned object's absolute host path; it makes no claim that the current model tools can read that path. An LLM consumer combines this location with the mounted filesystem's execution-world mapping when it serializes a request. That current access path remains separate from the request version and its `variantId`. Callers compose ordered batches with `Promise.all(refs.map(...))`; the local implementation still bounds compression through its instance limiter, cache, and singleflight. Callers may cancel reads and projections; implementations preserve cancellation instead of translating it into a storage failure.
Unsent composer images remain browser-owned temporary drafts. `validateImage` runs the complete admission policy without persisting. `saveImages` owns batch count and aggregate-byte limits, prepares every normalized attachment before publishing any member, then commits in order and returns references only after the complete batch succeeds. A later storage failure returns no partial references, although an earlier immutable content-addressed object may remain unreachable until reference-aware garbage collection exists. `AttachmentError.code` uses the closed `AttachmentErrorCode` string union. Its `ImageAdmissionErrorCode` subset marks caller-correctable image-input failures; `isImageAdmissionError` recognizes that subset at runtime so each protocol adapter can map its own error vocabulary. `saveImage` commits one accepted image before any model-visible session event is published and returns its `ImageAttachmentRef`. When normalization reduces the raster, the reference records the orientation-applied input size in `originalDimensions`. `readImage` verifies the normalized attachment against its logged metadata. `readImageRequest` deterministically derives a route-sized request version whose identity covers the attachment id, transform version, pixel and byte budgets, and encoder settings. The pure `requestImageDimensions` export computes that projection's aspect-preserving dimensions from a total-pixel budget, so providers and request pricing share one geometry. `imageHostPath` optionally exposes the provider-owned object's absolute host path; it makes no claim that the current model tools can read that path. An LLM consumer combines this location with the mounted filesystem's execution-world mapping when it serializes a request. That current access path remains separate from the request version and its `variantId`. Callers compose ordered batches with `Promise.all(refs.map(...))`; the local implementation still bounds compression through its instance limiter, cache, and singleflight. Callers may cancel reads and projections; implementations preserve cancellation instead of translating it into a storage failure.
`admitEncodedImages(attachments, images)` is the shared wire entry used by every RPC endpoint that accepts browser uploads (the session prompt endpoint and the command executor): it enforces canonical base64 on every member, then delegates batch admission — limits, validation, ordered commit — to `saveImages`. The base64 upload form is `EncodedImageAttachment`, exported from `@deepseek-ai/dsh-attachment/types` so wire contracts can reference it.
+1 -1
View File
@@ -4,7 +4,7 @@
持久附件服务边界。`ctx.attachments` 校验并持久提交提供方无关的规范化图片,随后返回可序列化的 `ImageAttachmentRef`;消费方绝不会在会话事件中持久保存浏览器路径、对象 URL、提供方 URL、本地存储路径或 base64。
未发送的输入区图片仍是由浏览器持有的临时草稿。`validateImage` 运行完整准入策略但不执行持久化。`saveImages` 负责批次图片数量和总字节限制,在发布任何成员前准备全部规范化附件,然后按顺序提交,并且只在完整批次成功后返回引用。后续存储失败不会返回部分引用,但较早写入的不可变内容寻址对象可能保持不可达,直至具备按引用感知的垃圾回收。`AttachmentError.code` 使用封闭的 `AttachmentErrorCode` 字符串联合类型。其 `ImageAdmissionErrorCode` 子集标记可由调用方修正的图片输入失败;`isImageAdmissionError` 在运行时识别该子集,使每个协议适配器可以映射自己的错误词汇。`saveImage` 会在发布任何模型可见的会话事件前提交一张已接受的图片,并直接返回 `ImageAttachmentRef`。规范化过程缩小图片时,引用会通过 `originalDimensions` 记录应用方向后的输入尺寸。`readImage` 根据已记录的元数据校验规范化附件。`readImageRequest` 确定性派生路由所需的请求版本,其身份覆盖附件 ID、变换策略版本、像素和字节预算及编码参数。`imageHostPath` 可以给出提供方所持对象的绝对宿主路径,但不保证当前模型工具能够读取它。LLM 消费方在序列化请求时将这个位置与当前文件系统提供的执行环境映射组合起来。解析出的访问路径独立于请求版本及其 `variantId`。调用方通过 `Promise.all(refs.map(...))` 组合有序批次,本地实现仍通过实例级限流器、缓存和 singleflight 限制压缩并发。调用方可以取消读取和投影;实现保留取消结果,不把它转换为存储失败。
未发送的输入区图片仍是由浏览器持有的临时草稿。`validateImage` 运行完整准入策略但不执行持久化。`saveImages` 负责批次图片数量和总字节限制,在发布任何成员前准备全部规范化附件,然后按顺序提交,并且只在完整批次成功后返回引用。后续存储失败不会返回部分引用,但较早写入的不可变内容寻址对象可能保持不可达,直至具备按引用感知的垃圾回收。`AttachmentError.code` 使用封闭的 `AttachmentErrorCode` 字符串联合类型。其 `ImageAdmissionErrorCode` 子集标记可由调用方修正的图片输入失败;`isImageAdmissionError` 在运行时识别该子集,使每个协议适配器可以映射自己的错误词汇。`saveImage` 会在发布任何模型可见的会话事件前提交一张已接受的图片,并直接返回 `ImageAttachmentRef`。规范化过程缩小图片时,引用会通过 `originalDimensions` 记录应用方向后的输入尺寸。`readImage` 根据已记录的元数据校验规范化附件。`readImageRequest` 确定性派生路由所需的请求版本,其身份覆盖附件 ID、变换策略版本、像素和字节预算及编码参数。纯函数导出 `requestImageDimensions` 按总像素预算计算该投影的保持宽高比尺寸,使提供方与请求定价共享同一套几何计算。`imageHostPath` 可以给出提供方所持对象的绝对宿主路径,但不保证当前模型工具能够读取它。LLM 消费方在序列化请求时将这个位置与当前文件系统提供的执行环境映射组合起来。解析出的访问路径独立于请求版本及其 `variantId`。调用方通过 `Promise.all(refs.map(...))` 组合有序批次,本地实现仍通过实例级限流器、缓存和 singleflight 限制压缩并发。调用方可以取消读取和投影;实现保留取消结果,不把它转换为存储失败。
`admitEncodedImages(attachments, images)` 是每个接受浏览器上传的 RPC 端点(会话 prompt 端点与命令执行器)共用的 wire 入口:它对每个成员强制执行规范 base64,随后把批量准入——限额、校验、有序提交——委托给 `saveImages`。base64 上传形式为 `EncodedImageAttachment`,从 `@deepseek-ai/dsh-attachment/types` 导出,供 wire 契约引用。
@@ -15,6 +15,7 @@ export { AttachmentId, ImageVariantId } from './brand.ts'
export { AttachmentError, isImageAdmissionError } from './error.ts'
export type { AttachmentErrorCode, ImageAdmissionErrorCode } from './error.ts'
export { admitEncodedImages } from './admission.ts'
export { requestImageDimensions } from './request-projection.ts'
export type {
AttachmentId as AttachmentIdType,
EncodedImageAttachment,

Some files were not shown because too many files have changed in this diff Show More