fix(web): show system prompts in chat

Render reconstructable system prompts at each request-series boundary, preserve series declarations through pre-step wrappers, and keep the presentation and replay snapshots aligned across clients.
This commit is contained in:
07akioni
2026-08-25 14:44:08 +08:00
parent 5e868ef2c6
commit 61b65d3147
149 changed files with 4445 additions and 299 deletions
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md
2026-07-05-reconstructable-requests.md: 3f49ba71a6b98a84b05530c900e902b0cf9f6449
2026-07-05-reconstructable-requests.zh.md: 7b8a9df65b60f975bc3ae60b2c1b0c3a8cc22e95
2026-07-05-reconstructable-requests.md: 3786de02d06c0b6c094297ae89ac3f84053e408d
2026-07-05-reconstructable-requests.zh.md: 851045aca7dababd0da859f3b04b721c65382fc3
@@ -22,9 +22,9 @@ Prefix-cache stability is corollary #1, not the headline: an append-only log pro
**Messages.** `Session.deriveMessages()` is cached: each surface entry is projected exactly once, when first seen, through the public per-event function `deriveEventMessage(event)`; a surface rewrite (a compaction `replace``SurfaceManager.replaceGeneration`) rebuilds. Callers get a fresh array per call over shared, deep-frozen messages: mutating logged history through a projection is unrepresentable (it throws), replacing the old clone-per-call isolation. External reconstructors fold the same public function over a log prefix, so no two paths can disagree.
`EpochHeader` records the request's non-history state: call config, rendered system prompt, and tool schemas, with empty values canonicalized to absence. `request/header` always writes a full snapshot: the first loop instance uses reason `initial`, later instances use `resume`, and an in-instance change uses `change`. `foldRequestHeader` selects the latest snapshot. Legacy `request/header-delta` events and the removed `fallback` reason are rejected when appended or loaded.
`EpochHeader` records the request's non-history state: call config, rendered system prompt, and tool schemas, with empty values canonicalized to absence. Adapter-supplied effort and token defaults retain their `adapterDefaults` provenance; a Web model selection restored from the log omits an adapter-owned effort so the next resolution cannot reclassify the same effective config as an explicit selection and a false change. `request/header` always writes a full snapshot: the first loop instance uses reason `initial`, later instances use `resume`, an in-instance change uses `change`, and an unchanged envelope beginning an explicitly declared message series or following a surface replacement uses `series`. A `change` snapshot carries `startsSeries: true` when the changed request also starts a series, preserving the two independent facts without a duplicate header. Ordinary append-only later Turns, further same-series Steps, and retries inherit the latest snapshot. `foldRequestHeader` selects the latest snapshot. Legacy `request/header-delta` events and the removed `fallback` reason are rejected when appended or loaded.
Each proposed step first claims its inbox batch and runs `agent/pre-step`. Rejection opens no step; enter opens `step/start` and records the final message batch as `user/message` events. The step then assembles the system prompt and tools, while `agent/request` may replace only the frozen call-config seed. The loop records the owed full header snapshot, builds `GenerateOptions` from derived messages and that header, and deep-freezes it while leaving `AbortSignal` live. The first call config starts from explicit `AgentOptions`, preserving fork overrides and resume reconfiguration; later calls start from the folded header.
Each proposed step first claims its inbox batch and runs `agent/pre-step`. Rejection opens no step; enter opens `step/start`, records the final message batch as `user/message` events, and may use `startsRequestSeries: true` to declare a distinct series. The step then assembles the system prompt and tools, while `agent/request` may replace only the frozen call-config seed. The loop records the owed initial, resume, change, or series full snapshot, builds `GenerateOptions` from derived messages and that header, and deep-freezes it while leaving `AbortSignal` live. The first call config starts from explicit `AgentOptions`, preserving fork overrides and resume reconfiguration; later calls start from the folded header.
**The open step is the reconstruction boundary.** Its entered `user/message` batch and any newly written `request/header` precede request dispatch. Injection after the atomic claim joins a later request, while a listener that must affect this request returns messages through `agent/pre-step`. Header reconstruction selects the step's `request/header`, or carries the prior snapshot when no new header is written.
@@ -42,6 +42,7 @@ Like MiniCode, the conversation advances append-only and resets only when model-
- **Detect-and-report** (compare consecutive requests, warn on divergence): catches violations after the fact; a violating request is still constructible and ships. Rejected for interface-level unrepresentability.
- **Event-driven assembly** (re-render only on change signals): a missed-signal bug class — a tool registered mid-session emits `tools/change`, not `system-prompt/change`, and a third-party provider may emit nothing. Per-step render + value compare is robust with zero signal discipline.
- **A custom header-delta codec** (system line edits, name-keyed tool edits, whole config/prefix replacements): reduced repeated bytes but duplicated the representation and its diff/apply/fallback machinery. Full snapshots retain one replay representation.
- **A lightweight series marker referencing the previous header**: reduced repeated prompt and tool bytes, but a window beginning at that marker could not render or reconstruct the request without fetching its predecessor. A self-contained full snapshot preserves one representation for persistence, partial history, and snapshot pinning.
- **Narrative changed-field lists on header snapshots**: derivable by comparing consecutive snapshots. The `reason` remains because an instance boundary is not derivable from the snapshot values.
## Consequences
@@ -52,5 +53,5 @@ Like MiniCode, the conversation advances append-only and resets only when model-
- `agent/pre-step` is the current-request message channel; direct inbox mutation is the eventual later-request channel.
- Tool-result trimming needs no new mechanism: a logged single-entry surface replace (`start === end`) carrying a trimmed `tool/result` under the same `callId` — compaction-family, replay-correct, cache-bust batched by the same pressure logic.
- Unreadable referenced attachment objects still fail model requests; [automatic attachment quarantine](../../proposed/bug-fix/2026-08-20-attachment-read-quarantine.md) records the proposed recovery without weakening byte-exact reconstruction.
- Session logs grow one `request/header` snapshot per loop instance plus snapshots on real changes. This is larger than a delta codec but small beside chunk-heavy logs and retains one replay representation. `SESSION_FORMAT_VERSION` stays `0`; legacy delta events are rejected rather than migrated.
- Snapshot expected outputs changed once (every transcript gains its header events); the fs-writing fixtures are stored in the normalized authored form with cwd-relative tool arguments, because replay only round-trips cwd-independent argument paths.
- Session logs grow one `request/header` snapshot per loop instance, real change, and later model-message series. Repeating the full system prompt and tool catalog is larger than a delta codec but small beside chunk-heavy logs and retains one self-contained replay representation. `SESSION_FORMAT_VERSION` stays `0`; legacy delta events are rejected rather than migrated.
- Snapshot fixtures include each repeated series header. Keyless refresh owns those deterministic log changes, while the snapshot harness pins prompt and tool sidecars only for the initial and actual change revisions and reuses the current revision for `series` snapshots. Filesystem-writing fixtures remain in normalized authored form with cwd-relative tool arguments because replay only round-trips cwd-independent argument paths.
@@ -22,9 +22,9 @@ Status: implemented
**消息。** `Session.deriveMessages()` 带缓存:每个 surface 条目在首次出现时通过公开的逐事件函数 `deriveEventMessage(event)` 精确投影一次;surface 重写(压缩的 `replace`,即 `SurfaceManager.replaceGeneration`)触发重建。调用方每次获得一个新数组,底层是共享的深度冻结消息:通过投影变异已记录的历史是不可表达的(会抛异常),取代了旧的逐次调用克隆隔离。外部重建器对日志前缀折叠同一个公开函数,因此不可能有两条路径产生分歧。
`EpochHeader` 记录请求的非历史状态:调用配置、渲染后的系统提示词和工具 schema,空值规范化为缺失。`request/header` 始终写入完整快照:首个循环实例使用 reason `initial`,后续实例使用 `resume`,实例内变更使用 `change``foldRequestHeader` 选择最新快照。旧的 `request/header-delta` 事件和已移除的 `fallback` reason 在追加或加载时都会被拒绝。
`EpochHeader` 记录请求的非历史状态:调用配置、渲染后的系统提示词和工具 schema,空值规范化为缺失。适配器提供的推理强度与 token 默认值会保留其 `adapterDefaults` 来源信息;Web 从日志恢复模型选择时会省略适配器持有的推理强度,因此下一次解析不会把相同的有效配置重新归类为显式选择并产生虚假变更。`request/header` 始终写入完整快照:首个循环实例使用 reason `initial`,后续实例使用 `resume`,实例内变更使用 `change`,内容未变的封装显式开启消息序列或跟随表层替换时使用 `series`。如果发生变化的请求同时开启序列,`change` 快照会携带 `startsSeries: true`,无需重复 header 即可保留这两个独立事实。普通的仅追加后续 Turn、同一序列内后续的 Step 与重试沿用最新快照`foldRequestHeader` 选择最新快照。旧的 `request/header-delta` 事件和已移除的 `fallback` reason 在追加或加载时都会被拒绝。
每个拟议步骤先领取其 inbox 批次,再运行 `agent/pre-step`。reject 不打开步骤;enter 打开 `step/start`把最终消息批次记录为 `user/message` 事件。随后步骤组装系统提示词与工具,`agent/request` 只能替换冻结的调用配置种子。循环记录所需的完整 header 快照,从派生消息与该 header 构建 `GenerateOptions`,对其深度冻结但保持 `AbortSignal` 活跃。首次调用配置从显式的 `AgentOptions` 出发,保留 fork 覆盖和恢复重配置;后续调用从折叠后的 header 出发。
每个拟议步骤先领取其 inbox 批次,再运行 `agent/pre-step`。reject 不打开步骤;enter 打开 `step/start`,把最终消息批次记录为 `user/message` 事件,并可使用 `startsRequestSeries: true` 声明独立序列。随后步骤组装系统提示词与工具,`agent/request` 只能替换冻结的调用配置种子。循环记录所需的 initial、resume、change 或 series 完整快照,从派生消息与该 header 构建 `GenerateOptions`,对其深度冻结但保持 `AbortSignal` 活跃。首次调用配置从显式的 `AgentOptions` 出发,保留 fork 覆盖和恢复重配置;后续调用从折叠后的 header 出发。
**已打开步骤是重建边界。** 进入步骤的 `user/message` 批次与任何新写入的 `request/header` 都位于请求分派之前。原子领取后发生的注入加入后续请求;必须影响本次请求的监听器则通过 `agent/pre-step` 返回消息。header 重建选择该步骤的 `request/header`,或在无新 header 写入时沿用前一个快照。
@@ -42,6 +42,7 @@ Status: implemented
- **检测并报告**(比较连续请求,发散时告警):事后捕获违规;违规请求仍可构造并发出。因违规必须在接口层面不可表达而否决。
- **事件驱动组装**(仅在变更信号时重新渲染):存在漏信号的 bug 类别——会话中途注册的工具发出 `tools/change` 而非 `system-prompt/change`,第三方提供方可能什么都不发。逐步骤渲染加值比较在零信号纪律下即可稳健工作。
- **自定义 header-delta 编解码器**(系统行编辑、按名称键控的工具编辑、完整配置/前缀替换):减少了重复字节,却复制了表示及其 diff/apply/fallback 机制。完整快照只保留一种回放表示。
- **引用前一个 header 的轻量 series 标记**:减少重复的提示词与工具字节,但从该标记开始的窗口若不再读取前序,就无法渲染或重建请求。自包含的完整快照让持久化、局部历史和快照固定共用一种表示。
- **Header 快照上的叙事性变更字段列表**:可以通过比较连续快照推导。`reason` 仍保留,因为实例边界无法从快照值推导。
## 后果
@@ -52,5 +53,5 @@ Status: implemented
- `agent/pre-step` 是当前请求的消息通道;直接修改 inbox 则是最终进入后续请求的通道。
- 工具结果裁剪无需新机制:一个已记录的单条目 surface replace`start === end`),携带同一 `callId` 下裁剪后的 `tool/result`——属压缩家族,回放正确,缓存失效由相同的压力逻辑批量处理。
- 无法读取的被引用附件对象仍会让模型请求失败;[附件自动隔离](../../proposed/bug-fix/2026-08-20-attachment-read-quarantine.zh.md)记录了不削弱字节精确重建的拟议恢复方案。
- 会话日志每个循环实例增长一个 `request/header` 快照,并在真正变更时增加快照。它比 delta 编解码器更大,但相对分片密集型日志仍然很小,并保留一种回放表示。`SESSION_FORMAT_VERSION` 保持 `0`;旧的 delta 事件被拒绝而非迁移。
- 快照预期输出变更一次(每个 transcript(文本记录)增加其 header 事件);写入文件系统的 fixture(测试前置数据)以规范化的撰写形式存储,工具参数使用 cwd 相对路径,因为回放只对 cwd 无关的参数路径做往返。
- 会话日志会为每个循环实例、真实变更和后续模型消息序列增加一个 `request/header` 快照。重复完整系统提示词与工具目录比 delta 编解码器更大,但相对分片密集型日志仍然很小,并保留一种自包含的回放表示。`SESSION_FORMAT_VERSION` 保持 `0`;旧的 delta 事件被拒绝而非迁移。
- 快照 fixture 包含每个重复的 series header。无密钥 refresh 负责这些确定性日志变化;快照 harness 只为 initial 与真实 change 修订固定提示词和工具 sidecar,并让 `series` 快照复用当前修订。写入文件系统的 fixture 继续以规范化的撰写形式存储,工具参数使用 cwd 相对路径,因为回放只对 cwd 无关的参数路径做往返。
@@ -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: e6c0e790a361265870a04ee63301b9f11940c648
2026-08-09-client-conversation-node-assembly.zh.md: 702ddba0019e125d3976727f841db775276b3b77
2026-08-09-client-conversation-node-assembly.md: ea2505d4a72f483a9df6fcd78d7e5c9a96b02f5c
2026-08-09-client-conversation-node-assembly.zh.md: b87f127d753cadf2805ed5cd948fc58ad01830aa
@@ -260,6 +260,7 @@ Page size, the number of history loads, and RAF coalescing affect only when evid
| Next-turn Inbox / `inbox-next-turn` | Splice Event seq | Each `agent/inbox/spliced` targeting next-turn | None | Apply the current splice to the pending/claimed instantaneous state from `reader.previous(ownKind)` |
| Next-step Inbox / `inbox-next-step` | Splice Event seq | Each `agent/inbox/spliced` targeting next-step | None | Build the same per-instruction instantaneous state; Message reads its claimed set |
| 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 |
| 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 |
@@ -276,6 +277,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 |
| 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 |
@@ -288,6 +290,8 @@ Page size, the number of history loads, and RAF coalescing affect only when evid
Inbox demonstrates that every Event can be a start-only instantaneous-state Context; not every business requires a start/update pair. Reader links each state to the prior same-kind Context instead of inventing a lifecycle ID for the entire Inbox.
Request Prompt demonstrates shared pure interpretation without shared target State: Chat and Trajectory call `inspectRequestPrompt()` from their own Definitions. The function canonicalizes the full header and classifies model-visible system/tool differences; each target then chooses its own output. Chat materializes every header carrying a non-empty system field, including `series` snapshots that repeat an unchanged header for an explicitly declared series or a post-replacement request, while Trajectory retains the complete request fact and its change classification. Ordinary append-only later Turns do not write another unchanged header. The first header in a Step follows the provider envelope rather than the header Event position: step one uses the owning Turn start and later steps use their Step start, placing the system field before the request's user-role messages; a later header in the same Step stays at its own Event after the surface rewrite that began the new series. When the preceding header is outside a partial window, a non-`initial` header stays at its own Event until prepend supplies that predecessor. Every header is a full snapshot, so a first loaded `resume`, `change`, or `series` header can render its system field without fabricating a comparison to unloaded history.
Retry, Assistant, and Turn Tail demonstrate independent claims on one Event. Each Definition updates only its own State and produces its own atomic Chat Node.
Assistant, Turn Tail, and Deliverables demonstrate layered Location data composition. Assistant writes `assistant-step` data for each Step; Turn Tail derives `turn-tail` data from those Step values; Deliverables independently maintains `deliverables` data for the same Turn. Consumers read only declaration-merged keys, do not scan another business's Nodes, and cannot obtain the provider's Context State.
@@ -260,6 +260,7 @@ Chat `order` 的结构性变化仍可能重排当前可见 key;纯 data 更新
| Next-turn Inbox / `inbox-next-turn` | splice Event seq | 每条目标为 next-turn 的 `agent/inbox/spliced` | 无 | 从 `reader.previous(ownKind)` 的 pending/claimed 瞬间态应用当前 splice |
| Next-step Inbox / `inbox-next-step` | splice Event seq | 每条目标为 next-step 的 `agent/inbox/spliced` | 无 | 同样形成逐指令瞬间态,claimed 集合供 Message 读取 |
| 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 |
| 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 和手动压缩证据 |
@@ -276,6 +277,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 生成中断表现 |
| Tool | 默认 immediate | 一个递归 `tool-call` root,包含全部 `subCalls` | result-only 历史窗口可 fallbackrunning→settled 保持 key |
| Command | 默认 immediate | 普通 `command` 或集成 `manual-compaction` | checkpoint 到达可改变 anchor,但不改变 Context key |
@@ -288,6 +290,8 @@ Chat `order` 的结构性变化仍可能重排当前可见 key;纯 data 更新
Inbox 展示了“每条 Event 都是一个 start-only 瞬间态 Context”,不是所有业务都需要 start/update 配对。它通过 Reader 与前一个同 kind Context 形成连续 fold,而非给整个 Inbox 人工制造生命周期 ID。
Request Prompt 展示了如何在不共享 target State 的前提下共用纯解释逻辑:Chat 与 Trajectory 各自在自己的 Definition 中调用 `inspectRequestPrompt()`。该函数规范化完整 header,并判定面向模型的 system/tool 差异;随后每个 target 自行选择产物。Chat 会物化每条带非空 system 字段的 header,包括为显式声明的序列或表层替换后的请求重复未变 header 的 `series` 快照;Trajectory 则保留完整请求事实及其变化分类。普通的仅追加后续 Turn 不会再次写入未变 header。一个 Step 中的首条 header 遵循提供方信封,而不是 header Event 位置:step one 使用所属 Turn start,后续 step 使用各自的 Step start,把 system 字段放到该请求的 user-role 消息之前;同一 Step 的后续 header 保留在开启新序列的表层改写之后。部分窗口未包含前序 header 时,非 `initial` header 会保留在自身 Event,直到 prepend 补入该前序 header。每条 header 都是完整快照,因此已加载窗口中的首条 `resume``change``series` header 无需凭空构造与未加载历史的比较,也能渲染其 system 字段。
Retry、Assistant 和 Turn Tail 展示了同一 Event 被多个 Definition 独立认领。每个 Definition 只更新自己的 State,最终分别生成原子 Chat Node。
Assistant、Turn Tail 和 Deliverables 展示了 Location data 的分层组合。Assistant 负责写好每个 Step 的 `assistant-step` dataTurn Tail 从这些 Step values 计算 `turn-tail` dataDeliverables 独立维护同一 Turn 的 `deliverables` data。消费者只读取声明合并后的 key,不扫描其他业务 Node,也不取得提供方的 Context State。
@@ -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-17-web-system-prompt-opaque-body.md
2026-08-17-web-system-prompt-opaque-body.md: 9b1992dd8eb1062a3b35666b332178985aa91653
2026-08-17-web-system-prompt-opaque-body.zh.md: 6f2fbba9d3f286e8c3073197a144d347f732cbc1
@@ -0,0 +1,29 @@
# Agent Note: System prompt expands into the opaque context body
Status: implemented
English | [中文](2026-08-17-web-system-prompt-opaque-body.zh.md)
## Problem
The Chat `System prompt` row shares `DisclosureRow` chrome with context injection and needs an expanded body for the request's system field. Rendering that field as Markdown would restyle it — headings, emphasis, lists — so a reader would see a rendered document the model never received. Context injection already solves the same job with a 141px code-block scrollport and `<pre>` text that keeps the bytes and line breaks the model read, so the row needs that presentation, not a second one.
## Decision
`SystemPromptRow` mounts the same expanded body as an opaque context injection. It reuses `ContextInjectionRow.module.css` for the 141px Figma 10:2482 scrollport and renders the durable `request/header` system string through `OpaqueBody` as one text block, so the disclosure shows model-facing text with its real line breaks and the same 20_000-character display bound. The row stays collapsed by default and still has no streaming path. It does not grow a producer label, form marker, or source-field list: the system field is one joined string on the header, not a sourced `user/message`.
## Alternatives considered
**Render settled Markdown in a card-styled body.** The chrome could match, but Markdown rewrites what the model read. A heading or bold span is a different document from the request bytes.
**Split the joined system string into snapshot sections.** The durable header stores only the assembled text. Inventing section boundaries in the client would attribute prose the log does not name, and a resumed or foreign header could not reconstruct them.
**Render through `ContextInjectionRow` itself.** That row is for sourced user-role messages: it titles a role, shows a producer, and chooses a form body. The system field is a different durable fact and has none of those fields.
## Consequences
The two disclosures now share one expanded-body chrome and one text presentation, so a later change to the 141px scrollport or the opaque bound applies to both. The cost is that a long system prompt scrolls inside 141px instead of 360px, and Markdown markup in the prompt stays visible as characters.
## Testing
`packages/client/ui-chat/tests/system-prompt-row.client.spec.tsx` expands and collapses the row and pins the opaque `[data-context-text]` bytes, including Markdown markers that must not become a heading. `apps/web/tests/replay-round-trip.e2e.ts` still opens the assembled disclosure and reads the persona line from that body.
@@ -0,0 +1,29 @@
# Agent Note: System prompt expands into the opaque context body
Status: implemented
[English](2026-08-17-web-system-prompt-opaque-body.md) | 中文
## Problem
Chat 的 `系统提示词` 行和上下文注入共用 `DisclosureRow` 外壳,其展开内容区需要呈现请求的 system 字段。如果把该字段渲染成 Markdown——标题、强调、列表——读者看到的将是模型从未收到的排版文档。上下文注入已经用 141px 代码块滚动区和保留模型所见字节与换行的 `<pre>` 文本解决了同一件事,因此该行需要复用这一呈现,而不是再造一套。
## Decision
`SystemPromptRow` 展开后挂载与不透明上下文注入相同的内容区。它复用 `ContextInjectionRow.module.css` 的 Figma 10:2482 的 141px 滚动区,并把持久 `request/header` 的 system 字符串作为一块文本交给 `OpaqueBody`,因此展开后看到的是带真实换行的模型可见文本,以及相同的 20_000 字符显示上限。该行默认折叠,仍然没有流式路径。它不增加生产者标签、form 标记或 source 字段列表:system 字段是 header 上的一段拼接字符串,不是带 source 的 `user/message`
## Alternatives considered
**在卡片式内容区里渲染结算后的 Markdown。** 外壳可以对齐,但 Markdown 会改写模型读到的内容。标题或加粗是另一份文档,不是请求里的字节。
**把拼接后的 system 字符串拆成 snapshot 分段。** 持久 header 只保存组装后的文本。客户端臆造分段边界会把日志未命名的正文归到某个子系统,恢复或外来 header 也无法重建这些分段。
**直接走 `ContextInjectionRow`。** 那一行面向带 source 的 user-role 消息:它标角色、显示生产者,并按 form 选内容区。system 字段是另一件持久事实,没有这些字段。
## Consequences
两处展开现在共用同一套内容区外壳和同一套文本展示,因此之后改 141px 滚动区或不透明显示上限会同时作用到两边。代价是较长的系统提示词在 141px 而不是 360px 内滚动,提示词里的 Markdown 标记会以字符形式可见。
## Testing
`packages/client/ui-chat/tests/system-prompt-row.client.spec.tsx` 会展开并折叠该行,并钉住不透明 `[data-context-text]` 字节,包括不得变成标题的 Markdown 标记。`apps/web/tests/replay-round-trip.e2e.ts` 仍会打开组装后的展开行,并从该内容区读出 persona 行。
@@ -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-12-simplify-session-log-representation.md
2026-07-12-simplify-session-log-representation.md: 3efb531d3c0822d7444d1270eac4da2617c12447
2026-07-12-simplify-session-log-representation.zh.md: 8c05ac6512a8aa8e7fefc56ba410bc81dc3be277
2026-07-12-simplify-session-log-representation.md: a0c86b66af78b4c94991d38656f03609297e2314
2026-07-12-simplify-session-log-representation.zh.md: ceaf90236a47c141e3a4bb2cc78d415b3b9ac2ba
@@ -18,7 +18,7 @@ The implementation retains append and replacement `sourceEventSeqs`, the `tool/c
`SurfaceManager.nodes` is a `readonly number[]` of event sequences; the public `SurfaceNode` shape, node links, and seq-to-node map are removed. The internal replace-generation signal remains. The complete `foldSurface()` read used by session-query returns the same number-array representation plus replacement metadata without making the incremental manager retain history. Tool-pairing balance and compaction use event sequences and surface positions; the compact-owned per-cut balance cache does not depend on node links.
Request headers use canonical full snapshots only. Initial and resume anchors remain full snapshots even when unchanged; an in-instance change appends another full `request/header` with reason `change`. The delta event, codec types, diff/apply helpers, and codec-only `fallback` reason are removed. Request reconstruction selects the latest snapshot.
Request headers use canonical full snapshots only. Initial and resume anchors remain full snapshots even when unchanged; an in-instance change appends another full `request/header` with reason `change`; and an unchanged envelope beginning an explicitly declared message series or following a surface replacement appends a full snapshot with reason `series`. Ordinary append-only later Turns, further Steps, and retries in that model-message series inherit the latest snapshot. The delta event, codec types, diff/apply helpers, and codec-only `fallback` reason are removed. Request reconstruction selects the latest snapshot.
`SESSION_FORMAT_VERSION` remains pinned at `0`, so seed, append, and persistence-load validation explicitly reject old v0 `request/header-delta` events and full snapshots carrying the removed `fallback` reason. There is no compatibility fold or migration. JSONL and SQLite tests pin this fail-loud boundary, and the ACP snapshot harness represents legitimate mid-session changes as full pinned headers and full readable prompts.
@@ -18,7 +18,7 @@ Status: implemented
`SurfaceManager.nodes` 是由事件序号组成的 `readonly number[]`;公共 `SurfaceNode` 形状、node 链接和 seq-to-node map 均已移除。内部替换 generation 信号保留。session-query 使用的完整 `foldSurface()` 读取会返回相同的数字数组表示和替换元数据,而无需让增量 manager 保留历史。工具配对 balance 和压缩(compaction)使用事件序号与 surface 位置;由 compact 拥有的每个切点的 balance cache 不依赖 node 链接。
请求头只使用规范的完整快照。初始与恢复锚点即使没有变化也仍是完整快照;实例内变化会追加另一个完整 `request/header`reason 为 `change`。delta 事件、codec 类型、diff/apply 辅助函数,以及仅供 codec 使用的 `fallback` reason 均已移除。请求重建选择最新快照。
请求头只使用规范的完整快照。初始与恢复锚点即使没有变化也仍是完整快照;实例内变化会追加另一个完整 `request/header`reason 为 `change`;未变的信封显式开启消息序列或跟随 surface 替换时,会追加 reason 为 `series` 的完整快照。普通的仅追加后续 Turn、同一模型消息序列内的后续 Step 与重试沿用最新快照。delta 事件、codec 类型、diff/apply 辅助函数,以及仅供 codec 使用的 `fallback` reason 均已移除。请求重建选择最新快照。
`SESSION_FORMAT_VERSION` 仍固定为 `0`,因此 seed、追加和持久化加载验证会显式拒绝旧 v0 `request/header-delta` 事件,以及携带已删除 `fallback` reason 的完整快照。不存在兼容性 fold 或迁移。JSONL 与 SQLite 测试固定了这一失败即报错的边界;ACPAgent Client Protocol)快照 harness 则把合法的会话中途变更表示为固定的完整请求头和完整可读提示词。
@@ -45,12 +45,13 @@
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
{"type":"step/start","data":{"turn":2,"step":1}}
{"type":"user/message","data":{"content":[{"type":"text","text":"<goal_round>\nObjective: \"Finish the ACP goal-round-driver snapshot proof\"\nRound: 1/2\n\nContinue working toward the objective in this same session. Treat the current workspace, tool results, and durable session state as authoritative; inspect them instead of assuming earlier narration is still current. Make concrete progress and verify the result. Before claiming completion, gather evidence that the whole objective is achieved, read the current goal, and mark it complete. If work remains, leave the goal active for the next round. Follow the configured goal-tool policy before reporting a blocker.\n</goal_round>"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":1},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"series"}}
{"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
{"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":0,"text":"GOAL ROUND ONE"}}}
{"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"GOAL ROUND ONE"}}}}
{"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":40,"outputTokens":3}}}}
{"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"GOAL ROUND ONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":40,"outputTokens":3}},"sourceEventSeqs":[46,47,48,49,50],"surfaceOp":"append"}
{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"GOAL ROUND ONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":40,"outputTokens":3}},"sourceEventSeqs":[47,48,49,50,51],"surfaceOp":"append"}
{"type":"step/end","data":{"turn":2,"step":1}}
{"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}}}
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"<goal_round>\nObjective: \"Finish the ACP goal-round-driver snapshot proof\"\nRound: 2/2\n\nContinue working toward the objective in this same session. Treat the current workspace, tool results, and durable session state as authoritative; inspect them instead of assuming earlier narration is still current. Make concrete progress and verify the result. Before claiming completion, gather evidence that the whole objective is achieved, read the current goal, and mark it complete. If work remains, leave the goal active for the next round. Follow the configured goal-tool policy before reporting a blocker.\n</goal_round>"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":2},"role":"user","id":"{{sessionId}}"}]}}
@@ -58,9 +59,10 @@
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
{"type":"step/start","data":{"turn":3,"step":1}}
{"type":"user/message","data":{"content":[{"type":"text","text":"<goal_round>\nObjective: \"Finish the ACP goal-round-driver snapshot proof\"\nRound: 2/2\n\nContinue working toward the objective in this same session. Treat the current workspace, tool results, and durable session state as authoritative; inspect them instead of assuming earlier narration is still current. Make concrete progress and verify the result. Before claiming completion, gather evidence that the whole objective is achieved, read the current goal, and mark it complete. If work remains, leave the goal active for the next round. Follow the configured goal-tool policy before reporting a blocker.\n</goal_round>"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":2},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"series"}}
{"type":"assistant/chunk","data":{"turn":3,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
{"type":"assistant/chunk","data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":0,"text":"partial"}}}
{"type":"assistant/message","data":{"turn":3,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"partial"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"interrupted":true},"sourceEventSeqs":[59,60],"surfaceOp":"append"}
{"type":"assistant/message","data":{"turn":3,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"partial"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"interrupted":true},"sourceEventSeqs":[61,62],"surfaceOp":"append"}
{"type":"step/end","data":{"turn":3,"step":1}}
{"type":"turn/end","data":{"turn":3,"reason":{"kind":"aborted","reason":{"kind":"user"}}}}
{"type":"goal/change","data":{"kind":"goal/change","version":1,"operation":"pause","goal":{"id":"goal-{{sessionId}}","revision":2,"objective":"Finish the ACP goal-round-driver snapshot proof","phase":"paused","maxGoalRounds":2},"roundsStarted":2,"createdAt":0,"updatedAt":0}}
@@ -35,15 +35,16 @@
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
{"type":"step/start","data":{"turn":2,"step":1}}
{"type":"user/message","data":{"content":[{"type":"text","text":"<goal_round>\nObjective: \"Finish the ACP goal wrap-up snapshot proof\"\nRound: 1/2\n\nContinue working toward the objective in this same session. Treat the current workspace, tool results, and durable session state as authoritative; inspect them instead of assuming earlier narration is still current. Make concrete progress and verify the result. Before claiming completion, gather evidence that the whole objective is achieved, read the current goal, and mark it complete. If work remains, leave the goal active for the next round. Follow the configured goal-tool policy before reporting a blocker.\n</goal_round>"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":1},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"series"}}
{"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
{"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_goal_complete","name":"update_goal","argumentsDelta":"{\"goal_id\":\"goal-{{sessionId}}\",\"revision\":1,\"action\":\"complete\"}"}}}
{"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_complete","name":"update_goal","arguments":"{\"goal_id\":\"goal-{{sessionId}}\",\"revision\":1,\"action\":\"complete\"}"}}}}
{"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":40,"outputTokens":9}}}}
{"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_goal_complete","name":"update_goal","arguments":"{\"goal_id\":\"goal-{{sessionId}}\",\"revision\":1,\"action\":\"complete\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":40,"outputTokens":9}},"sourceEventSeqs":[36,37,38,39,40],"surfaceOp":"append"}
{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_goal_complete","name":"update_goal","arguments":"{\"goal_id\":\"goal-{{sessionId}}\",\"revision\":1,\"action\":\"complete\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":40,"outputTokens":9}},"sourceEventSeqs":[37,38,39,40,41],"surfaceOp":"append"}
{"type":"tool/call","data":{"turn":2,"step":1,"callId":"call_goal_complete","name":"update_goal","arguments":"{\"goal_id\":\"goal-{{sessionId}}\",\"revision\":1,\"action\":\"complete\"}"}}
{"type":"goal/change","data":{"kind":"goal/change","version":1,"operation":"complete","goal":{"id":"goal-{{sessionId}}","revision":2,"objective":"Finish the ACP goal wrap-up snapshot proof","phase":"complete","maxGoalRounds":2},"roundsStarted":1,"createdAt":0,"updatedAt":0}}
{"type":"tool/result","data":{"turn":2,"step":1,"message":{"source":{"kind":"tool","callId":"call_goal_complete"},"content":[{"type":"tool-result","toolCallId":"call_goal_complete","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":2,\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"phase\":\"complete\",\"roundsStarted\":1,\"maxGoalRounds\":2},\"activation\":\"disarmed\"}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[42],"surfaceOp":"append"}
{"type":"tool/result","data":{"turn":2,"step":1,"message":{"source":{"kind":"tool","callId":"call_goal_complete"},"content":[{"type":"tool-result","toolCallId":"call_goal_complete","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":2,\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"phase\":\"complete\",\"roundsStarted\":1,\"maxGoalRounds\":2},\"activation\":\"disarmed\"}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[43],"surfaceOp":"append"}
{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"<goal_complete>\nObjective: \"Finish the ACP goal wrap-up snapshot proof\"\nThe goal is marked complete and this autonomous run is ending. Write the closing message to the user now: state the outcome, summarize what was done and how it was verified, and point to the concrete results (files, commits, or other artifacts). Report only what earlier rounds and tool results in this session actually establish; when a detail is not in the session, say so instead of inventing it. Note anything the user should review or do next. Address the user directly. Do not call any more tools in this run; further work waits for the user's next instruction.\n</goal_complete>"}],"source":{"kind":"plugin","plugin":"tool-goal","form":"notice","summary":"complete: Finish the ACP goal wrap-up snapshot proof"},"role":"user","id":"{{sessionId}}"}]}}
{"type":"step/end","data":{"turn":2,"step":1}}
{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}}
@@ -54,6 +55,6 @@
{"type":"assistant/chunk","data":{"turn":2,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"GOAL WRAP-UP: the snapshot objective is achieved and this closing message reaches the user."}}}}
{"type":"assistant/chunk","data":{"turn":2,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":52,"outputTokens":14}}}}
{"type":"assistant/chunk","data":{"turn":2,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","data":{"turn":2,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"GOAL WRAP-UP: the snapshot objective is achieved and this closing message reaches the user."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":52,"outputTokens":14}},"sourceEventSeqs":[50,51,52,53,54],"surfaceOp":"append"}
{"type":"assistant/message","data":{"turn":2,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"GOAL WRAP-UP: the snapshot objective is achieved and this closing message reaches the user."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":52,"outputTokens":14}},"sourceEventSeqs":[51,52,53,54,55],"surfaceOp":"append"}
{"type":"step/end","data":{"turn":2,"step":2}}
{"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}}}
@@ -330,6 +330,11 @@ describe('web e2e: continuous conversation grown through the composer', () => {
expect(scaffold.ctx.agents.get(sessionId)?.session.events.filter(event => (
event.type === 'turn/end' && event.data.reason.kind === 'completed'
))).toHaveLength(TURN_COUNT)
expect(sessionEvents.flatMap(event =>
event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial'])
await expect.poll(() => page.getByRole('button', { name: 'System prompt' }).count(), {
timeout: 10_000,
}).toBe(1)
expect(specs.at(-1)?.prompt.length).toBeGreaterThan(4_000)
expect(sessionEvents.filter(event => (
event.type === 'assistant/chunk' && event.data.turn === TURN_COUNT
@@ -20,6 +20,10 @@
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- button "System prompt":
- img
- img
- text: System prompt
- button "Context injection webhook github webhook handled by review-pr-when-ready":
- img
- img
@@ -9,6 +9,10 @@
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- button "System prompt":
- img
- img
- text: System prompt
- text: /user-invoke-demo and confirm the fixture wiring {{clock}}
- button "Copy":
- img
@@ -9,6 +9,10 @@
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- button "System prompt":
- img
- img
- text: System prompt
- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}}
- button "Copy":
- img
@@ -9,6 +9,10 @@
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- button "System prompt":
- img
- img
- text: System prompt
- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}}
- button "Copy":
- img
@@ -151,6 +151,11 @@ describe('web e2e: Goal keeps one assistant action row per completed turn', () =
expect(sessionEvents.flatMap(event => event.type === 'turn/end' ? [event.data.turn] : []))
.toEqual([1, 2])
expect(goalRounds(sessionEvents)).toEqual([1, 2])
expect(sessionEvents.flatMap(event =>
event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial', 'series'])
await expect.poll(() => page.getByRole('button', { name: 'System prompt' }).count(), {
timeout: 15_000,
}).toBe(2)
const branchButtons = page.getByRole('button', { name: 'Branch into a new conversation' })
await expect.poll(() => branchButtons.count(), { timeout: 15_000 }).toBe(2)
expect(await branchButtons.evaluateAll(buttons => buttons.map(button => button.getAttribute('aria-disabled'))))
+19
View File
@@ -150,6 +150,25 @@ describe('web e2e: fresh round trip through the real assembly', () => {
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
})
it.skipIf(MODE === 'record')('renders the system prompt as a collapsed expandable disclosure', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-round-trip-system-prompt'))
const disclosure = page.getByRole('button', { name: 'System prompt', exact: true })
const body = page.locator('[data-system-prompt-body]')
await expect.poll(() => disclosure.count(), { timeout: 10_000 }).toBe(1)
await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('false')
expect(await body.count()).toBe(0)
await disclosure.click()
await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('true')
const opaque = body.locator('[data-context-text]')
await expect.poll(() => opaque.count(), { timeout: 5_000 }).toBe(1)
expect(await opaque.textContent()).toContain('You are an AI agent powered by DeepSeek Harness.')
await disclosure.click()
await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('false')
await expect.poll(() => body.count()).toBe(0)
})
it.skipIf(MODE === 'record')('expands and collapses the reasoning fold from its click target', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-round-trip-think'))
// Interaction over the REAL wire-delivered transcript (the fixture-client
@@ -9,6 +9,10 @@
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- button "System prompt":
- img
- img
- text: System prompt
- text: Stream one TypeScript fence for the highlighting snapshot. {{clock}}
- button "Copy":
- img
+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/agent-lifecycle.md
agent-lifecycle.md: 30509e17ce24ff2d078f86b6cc2a24b77ae3e4fa
agent-lifecycle.zh.md: 693824913b2b9fcb627591a98804778a09e968a6
agent-lifecycle.md: 9d1b66888e35d840c95ee9f2bd589dad3aac66f6
agent-lifecycle.zh.md: f1648792fa15495f878ae2ec362bb760ccf2dc22
+1 -1
View File
@@ -75,7 +75,7 @@ The `assistant/message` event records every successful provider call, including
`dsh-compaction-basic` uses `agent/pre-step` for pressure before request derivation and `agent/request-error` only for canonical context overflow. Once either trigger qualifies, optional tool-result pruning runs before summary selection. Recovery works between the closed failed step and failed turn close, and opens a fresh retry turn only when pruning or summarization advances the surface replacement generation; otherwise the original request error remains authoritative.
The returned `agent/pre-step` decision is authoritative; listeners wrapping `next()` preserve downstream messages unless replacement is intentional. Steering and injected context pass through the same waterfall after a later claim operation takes their next-step batch.
The returned `agent/pre-step` decision is authoritative; listeners wrapping `next()` preserve downstream messages and `startsRequestSeries` unless replacement is intentional. Steering and injected context pass through the same waterfall after a later claim operation takes their next-step batch.
SDK users that need replayable transcript data should consume `session/event`; `agent/*` is the live coordination API for queue/status, prompt interception, request construction, steering, continuation, and errors.
+1 -1
View File
@@ -77,7 +77,7 @@ sequenceDiagram
`dsh-compaction-basic` 在派生请求之前通过 `agent/pre-step` 处理压力,而 `agent/request-error` 仅用于规范的上下文溢出。任一触发条件满足后,系统都会先执行可选的工具结果剪枝,再选择摘要。恢复发生在失败步骤结束之后、失败轮次结束之前;只有当剪枝或摘要生成推进了 surface replacement generation 时,系统才会开启一个全新的重试轮次,否则仍以原始请求错误为准。
以返回的 `agent/pre-step` 决策为准;通过包装 `next()` 的监听器会保留下游消息,除非有意替换这些消息。steering(中途引导)和注入的上下文在后续的认领操作取得其下一步骤批次后,会经过同一 waterfall(瀑布式事件)。
以返回的 `agent/pre-step` 决策为准;通过包装 `next()` 的监听器会保留下游消息`startsRequestSeries`,除非有意替换。steering(中途引导)和注入的上下文在后续的认领操作取得其下一步骤批次后,会经过同一 waterfall(瀑布式事件)。
需要可回放 transcript(文本记录)数据的 SDK 用户应当消费 `session/event``agent/*` 是用于队列与状态、提示词拦截、请求构造、steering、继续执行和错误处理的实时协调接口。
+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/architecture.md
architecture.md: add615c252948adab8db7dec7059f94b8f45e52c
architecture.zh.md: 48baf14d6a29e5ef77e6e47f4d9fa9adc4e9e748
architecture.md: c6e01b8c30486d292694cbc26836e83522e3e760
architecture.zh.md: 21d60d0c962097ee6853bf7a3831a2c0b727e9c9
+2 -2
View File
@@ -79,7 +79,7 @@ A **step** is one model request plus the tools it calls. A **turn** is zero or m
turn/start
claim next-step input plus one queued message
assemble prompt sections + tool schemas
-> agent/pre-step reject | enter(messages)
-> agent/pre-step reject | enter(messages, startsRequestSeries?)
reject, or a first enter rewritten empty -> close the turn with no step
step/start
append entered messages as user/message
@@ -96,7 +96,7 @@ turn/end
Input reaches the driver through one inbox. Some messages wake it immediately; injected context waits in the inbox until another message does.
`agent/pre-step` decides what the model sees. Listeners may rewrite the claimed messages or reject them outright; a rejected or empty first claim still closes a durable turn that spent no step, so the log records the attempt. Each step reads the prompt sections and tool schemas that plugins registered.
`agent/pre-step` decides what the model sees. Listeners may rewrite the claimed messages or reject them outright; a rejected or empty first claim still closes a durable turn that spent no step, so the log records the attempt. An enter decision may also set `startsRequestSeries` to begin a distinct model-message series: the loop then logs a fresh `request/header` (reason `series`, or `change` carrying `startsSeries: true` when the envelope changed too). A listener that rebuilds a downstream enter decision must spread it (`{ ...decision, messages }`) so the declaration survives. Each step reads the prompt sections and tool schemas that plugins registered.
Details: the [sequence diagram](agent-lifecycle.md), the [tool pipeline](tool-execution-pipeline.md), and [cancellation and error recovery](subsystems/core.md#the-agent-handle).
+2 -2
View File
@@ -83,7 +83,7 @@ Python SDK 遵循相同的应用架构。其运行时 wheel 把普通 `dsh` CLI
turn/start
claim next-step input plus one queued message
assemble prompt sections + tool schemas
-> agent/pre-step reject | enter(messages)
-> agent/pre-step reject | enter(messages, startsRequestSeries?)
reject, or a first enter rewritten empty -> close the turn with no step
step/start
append entered messages as user/message
@@ -100,7 +100,7 @@ turn/end
输入通过同一个 inbox 到达驱动器。有些消息会立即唤醒它;注入的上下文会留在 inbox 中,直到另一条消息将其唤醒。
`agent/pre-step` 决定模型看到什么。监听器可以改写已领取的消息,也可以直接拒绝它们;首次领取被拒绝或被改写为空时,仍会关闭一个不含步骤的持久轮次,因此日志会记录这次尝试。每个步骤读取插件注册的提示词片段和工具 schema。
`agent/pre-step` 决定模型看到什么。监听器可以改写已领取的消息,也可以直接拒绝它们;首次领取被拒绝或被改写为空时,仍会关闭一个不含步骤的持久轮次,因此日志会记录这次尝试。enter 决策还可以设置 `startsRequestSeries` 来开启独立的模型消息序列:loop 会随之记录一个新的 `request/header`(原因为 `series`,或在封装同时变化时为携带 `startsSeries: true``change`)。重建下游 enter 决策的监听器必须展开它(`{ ...decision, messages }`),该声明才能存活。每个步骤读取插件注册的提示词片段和工具 schema。
详情见[时序图](agent-lifecycle.zh.md)、[工具流水线](tool-execution-pipeline.zh.md)和[取消与错误恢复](subsystems/core.zh.md#the-agent-handle)。
+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: 586316e90992447d45ce2b0f0d67c306689f95cb
event-producer-consumer.zh.md: 4aebaa2f10e4975df238639a1dac40694f50bed2
event-producer-consumer.md: de2a94abb5e4d16433eae71e34e329fcf0042ede
event-producer-consumer.zh.md: 7a9e825750213b2d0a67d9c022bffe031194c8ba
+12 -12
View File
@@ -9,18 +9,18 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| --- | --- | --- | --- | --- |
| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:183`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
| `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:23`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` |
| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:161`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:170`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:292`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) |
| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:199`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) |
| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:207`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) |
| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:188`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:233`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) |
| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:246`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) |
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:262`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) |
| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:219`](../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:180`](../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:280`](../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) |
| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:166`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:175`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:297`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) |
| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:204`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) |
| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:212`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) |
| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:193`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:238`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) |
| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:251`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) |
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:267`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) |
| `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` |
+12 -12
View File
@@ -11,18 +11,18 @@
| --- | --- | --- | --- | --- |
| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:183`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
| `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:23`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` |
| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:161`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:170`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) |
| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:197`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) |
| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:205`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) |
| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:186`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:231`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) |
| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:244`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) |
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:260`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) |
| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:217`](../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:178`](../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:278`](../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) |
| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:166`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:175`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:297`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) |
| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:204`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) |
| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:212`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) |
| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:193`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:238`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) |
| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:251`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) |
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:267`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) |
| `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` |
+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/persistence-catalog.md
persistence-catalog.md: 893ffef71be98afe2356419dcb6ca0d871f26649
persistence-catalog.zh.md: e34ce2b4b67746f9ce79f3d61add5e7f59e1aa22
persistence-catalog.md: 12558eeadc009b498c9a178cfcc79116bf1b7c2b
persistence-catalog.zh.md: f855d6969aa2dcade159ac8d6549e5f0350a7f0f
+19 -14
View File
@@ -90,7 +90,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
}[T]
```
Sources: [`packages/core/session/src/types.ts:321`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:328`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:357`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:389`](../packages/core/session/src/types.ts)
Sources: [`packages/core/session/src/types.ts:328`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:335`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:364`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:396`](../packages/core/session/src/types.ts)
## Events
@@ -215,7 +215,7 @@ Source: [`packages/interaction/user-approval/src/index.ts:32`](../packages/inter
Types: [StreamChunk](subsystems/llm-streaming.md)
Source: [`packages/core/session/src/types.ts:249`](../packages/core/session/src/types.ts)
Source: [`packages/core/session/src/types.ts:251`](../packages/core/session/src/types.ts)
<a id="assistantmessage--surface"></a>
@@ -237,7 +237,7 @@ Source: [`packages/core/session/src/types.ts:249`](../packages/core/session/src/
Types: [TokenUsage](subsystems/llm-streaming.md)
Source: [`packages/core/session/src/types.ts:260`](../packages/core/session/src/types.ts)
Source: [`packages/core/session/src/types.ts:262`](../packages/core/session/src/types.ts)
### `command/*`
@@ -563,7 +563,7 @@ Source: [`packages/plan/plan-mode/src/index.ts:53`](../packages/plan/plan-mode/s
'request/context': RequestContext
```
Source: [`packages/core/session/src/types.ts:294`](../packages/core/session/src/types.ts)
Source: [`packages/core/session/src/types.ts:301`](../packages/core/session/src/types.ts)
<a id="requestheader--log-only"></a>
@@ -574,10 +574,15 @@ Source: [`packages/core/session/src/types.ts:294`](../packages/core/session/src/
* Full header for the next request, appended inside its step before dispatch.
* It is log-only; the latest snapshot reconstructs the request header.
*/
'request/header': { header: EpochHeader; reason: RequestHeaderReason }
'request/header': {
header: EpochHeader
reason: RequestHeaderReason
/** A changed header also begins a distinct model-message series. */
startsSeries?: true
}
```
Source: [`packages/core/session/src/types.ts:289`](../packages/core/session/src/types.ts)
Source: [`packages/core/session/src/types.ts:291`](../packages/core/session/src/types.ts)
### `sandbox/*`
@@ -652,7 +657,7 @@ Source: [`packages/schedule/schedule/src/types.ts:219`](../packages/schedule/sch
'session/end-seed': Record<string, never>
```
Source: [`packages/core/session/src/types.ts:317`](../packages/core/session/src/types.ts)
Source: [`packages/core/session/src/types.ts:324`](../packages/core/session/src/types.ts)
<a id="sessiontitle--log-only"></a>
@@ -712,7 +717,7 @@ Source: [`packages/session/session-log-deepseek/src/types.ts:26`](../packages/se
'step/end': { turn: number; step: number }
```
Source: [`packages/core/session/src/types.ts:239`](../packages/core/session/src/types.ts)
Source: [`packages/core/session/src/types.ts:241`](../packages/core/session/src/types.ts)
<a id="stepstart--log-only"></a>
@@ -723,7 +728,7 @@ Source: [`packages/core/session/src/types.ts:239`](../packages/core/session/src/
'step/start': { turn: number; step: number }
```
Source: [`packages/core/session/src/types.ts:237`](../packages/core/session/src/types.ts)
Source: [`packages/core/session/src/types.ts:239`](../packages/core/session/src/types.ts)
### `subagent/*`
@@ -851,7 +856,7 @@ Source: [`packages/todo/tool-todo/src/types.ts:31`](../packages/todo/tool-todo/s
Types: [CallId](subsystems/core.md)
Source: [`packages/core/session/src/types.ts:266`](../packages/core/session/src/types.ts)
Source: [`packages/core/session/src/types.ts:268`](../packages/core/session/src/types.ts)
<a id="toolcode-dispatch--log-only"></a>
@@ -926,7 +931,7 @@ Source: [`packages/core/tools/src/types.ts:40`](../packages/core/tools/src/types
}
```
Source: [`packages/core/session/src/types.ts:278`](../packages/core/session/src/types.ts)
Source: [`packages/core/session/src/types.ts:280`](../packages/core/session/src/types.ts)
### `tool-workflow/*`
@@ -1006,7 +1011,7 @@ Source: [`packages/workflow/tool-workflow/src/types.ts:47`](../packages/workflow
Types: [TurnEndReason](subsystems/session.md)
Source: [`packages/core/session/src/types.ts:235`](../packages/core/session/src/types.ts)
Source: [`packages/core/session/src/types.ts:237`](../packages/core/session/src/types.ts)
<a id="turnstart--log-only"></a>
@@ -1022,7 +1027,7 @@ Source: [`packages/core/session/src/types.ts:235`](../packages/core/session/src/
'turn/start': { turn: number }
```
Source: [`packages/core/session/src/types.ts:226`](../packages/core/session/src/types.ts)
Source: [`packages/core/session/src/types.ts:228`](../packages/core/session/src/types.ts)
### `user/*`
@@ -1041,7 +1046,7 @@ Source: [`packages/core/session/src/types.ts:226`](../packages/core/session/src/
'user/message': UserMessage
```
Source: [`packages/core/session/src/types.ts:247`](../packages/core/session/src/types.ts)
Source: [`packages/core/session/src/types.ts:249`](../packages/core/session/src/types.ts)
### `web/*`
+6 -1
View File
@@ -576,7 +576,12 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
* Full header for the next request, appended inside its step before dispatch.
* It is log-only; the latest snapshot reconstructs the request header.
*/
'request/header': { header: EpochHeader; reason: RequestHeaderReason }
'request/header': {
header: EpochHeader
reason: RequestHeaderReason
/** A changed header also begins a distinct model-message series. */
startsSeries?: true
}
```
来源:[`packages/core/session/src/types.ts:289`](../packages/core/session/src/types.ts)
+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/core.md
core.md: c53bb94fa5918c3a91ee9aedbb2416d0b101b240
core.zh.md: 93ee45fb88cc100eb77673f2b70e86483c7ed29f
core.md: d3564b6d50e0087be25f5dd1abc7b19507fd1c21
core.zh.md: 0f7e49141e27b18ec8b75e944460d3ac04a88c0a
+6 -1
View File
@@ -224,7 +224,12 @@ It returns a `PreStepDecision`. Reject opens no step. Enter supplies the complet
/** Whether and with which messages the loop enters a proposed step. */
type PreStepDecision =
| { kind: 'reject' }
| { kind: 'enter'; messages: UserMessage[] }
| {
kind: 'enter'
messages: UserMessage[]
/** Start a distinct model-message series before this step's admitted messages. */
startsRequestSeries?: true
}
```
`agent/request-error` runs after a failed model step closes and before its turn closes. Listeners can repair durable state or await policy work while the failed turn's signal is still live. A handling listener returns `{ kind: 'retry' }` without calling `next()`; the default `undefined` leaves the failure terminal.
+6 -1
View File
@@ -232,7 +232,12 @@ pre-step 决策使用与持久 user-role 输入相同、带标识的 `UserMessag
/** Whether and with which messages the loop enters a proposed step. */
type PreStepDecision =
| { kind: 'reject' }
| { kind: 'enter'; messages: UserMessage[] }
| {
kind: 'enter'
messages: UserMessage[]
/** Start a distinct model-message series before this step's admitted messages. */
startsRequestSeries?: true
}
```
`agent/request-error` 在失败的模型步骤关闭之后、其轮次关闭之前运行。listener 可以在失败轮次的 signal 仍然存活时修复持久状态或 await 策略工作。处理该错误的 listener 返回 `{ kind: 'retry' }` 且不调用 `next()`;默认的 `undefined` 会让失败保持终态。
+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/session.md
session.md: 23b3f8535ac432c297595bdf621cad5cecf717d4
session.zh.md: ad73efb2d1ec8a2a7df3463518f172103f107296
session.md: dc0f823cbc529b64d1f19abb3a07ffd39f849904
session.zh.md: 640d3ee279f2fde140a5a89ee614f4db21485171
+7 -2
View File
@@ -94,7 +94,12 @@ interface SessionEventMap {
* Full header for the next request, appended inside its step before dispatch.
* It is log-only; the latest snapshot reconstructs the request header.
*/
'request/header': { header: EpochHeader; reason: RequestHeaderReason }
'request/header': {
header: EpochHeader
reason: RequestHeaderReason
/** A changed header also begins a distinct model-message series. */
startsSeries?: true
}
/**
* Route metadata for the next request, logged only when the route or capacity
* changes. It does not participate in request reconstruction or header equality.
@@ -132,7 +137,7 @@ interface SessionEventMap {
### The request header event: `request/header`
The request envelope — the `EpochHeader` (call config + markers for adapter-supplied defaults + rendered system prompt + assembled tool schemas) — is logged session state, so every conversation request is a pure function of the log (the reconstructability Agent Note). A full `request/header` snapshot with reason `'initial'` or `'resume'` records each loop-instance boundary; a later changed request records another full snapshot with reason `'change'`. `foldRequestHeader(events)` reconstructs the header by selecting the latest snapshot. The event is not a `SurfaceEventType`: it produces no LLM message.
The request envelope — the `EpochHeader` (call config + markers for adapter-supplied defaults + rendered system prompt + assembled tool schemas) — is logged session state, so every conversation request is a pure function of the log (the reconstructability Agent Note). A full `request/header` snapshot with reason `'initial'` or `'resume'` records each loop-instance boundary; a changed request appends a snapshot with reason `'change'`; and an unchanged envelope beginning an explicitly declared message series or following a surface replacement appends a snapshot with reason `'series'`. A changed snapshot carries `startsSeries: true` when that request also begins a series. Ordinary append-only later Turns, further Steps, and retries in the same model-message series inherit the latest snapshot. `foldRequestHeader(events)` reconstructs the header by selecting the latest snapshot. The event is not a `SurfaceEventType`: it produces no LLM message.
```ts type-equiv
/**
+7 -2
View File
@@ -94,7 +94,12 @@ interface SessionEventMap {
* Full header for the next request, appended inside its step before dispatch.
* It is log-only; the latest snapshot reconstructs the request header.
*/
'request/header': { header: EpochHeader; reason: RequestHeaderReason }
'request/header': {
header: EpochHeader
reason: RequestHeaderReason
/** A changed header also begins a distinct model-message series. */
startsSeries?: true
}
/**
* Route metadata for the next request, logged only when the route or capacity
* changes. It does not participate in request reconstruction or header equality.
@@ -132,7 +137,7 @@ interface SessionEventMap {
### 请求头事件:`request/header`
请求信封(即 `EpochHeader`:调用配置 + 适配器所提供默认值的标记 + 渲染后的系统提示词 + 已组装的工具 schema)会作为会话状态写入日志,因此每个对话请求都是日志的纯函数(见可重建性 Agent Note)。带有 reason `'initial'``'resume'` 的完整 `request/header` 快照记录每个 agent loop 实例的边界;之后请求发生变化时,系统会以 reason `'change'` 记录另一份完整快照。`foldRequestHeader(events)` 通过选择最新快照重建请求头。该事件不是 `SurfaceEventType`,不产生 LLM 消息。
请求信封(即 `EpochHeader`:调用配置 + 适配器所提供默认值的标记 + 渲染后的系统提示词 + 已组装的工具 schema)会作为会话状态写入日志,因此每个对话请求都是日志的纯函数(见可重建性 Agent Note)。带有 reason `'initial'``'resume'` 的完整 `request/header` 快照记录每个 agent loop 实例的边界;请求变化时会追加 reason `'change'` 的快照;未变的信封显式开启消息序列或跟随 surface 替换时,会追加 reason 为 `'series'` 的快照。如果发生变化的快照所属请求同时开启序列,它会携带 `startsSeries: true`。普通的仅追加后续 Turn,以及同一模型消息序列内的后续 Step 与重试沿用最新快照。`foldRequestHeader(events)` 通过选择最新快照重建请求头。该事件不是 `SurfaceEventType`,不产生 LLM 消息。
```ts type-equiv
/**
+9 -3
View File
@@ -291,12 +291,18 @@ export class ApiSessionAgentController {
const selection: InstalledSelection = {
get current(): AgentModelSelection {
if (picked !== undefined) return picked
const logged = agent.session.requestHeader()?.config
if (logged === undefined) return defaultModel.currentSelection()
const loggedHeader = agent.session.requestHeader()
if (loggedHeader === undefined) return defaultModel.currentSelection()
const logged = loggedHeader.config
return {
provider: logged.provider,
model: logged.model,
...(logged.reasoningEffort === undefined ? {} : { reasoningEffort: logged.reasoningEffort }),
// An effort the adapter defaulted is not a conversation choice: restoring
// it as one would make an unchanged default read as a request change.
...(logged.reasoningEffort === undefined
|| loggedHeader.adapterDefaults?.reasoningEffort === true
? {}
: { reasoningEffort: logged.reasoningEffort }),
}
},
set current(next: AgentModelSelection) {
@@ -12,13 +12,14 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
import AttachmentStore from '@deepseek-ai/dsh-attachment'
import LlmRuntime, { LlmAdapter, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import type {
GenerateOptions, LlmCallConfig, LlmModelInfo, LlmModelReasoningInfo, LlmProviderInfo,
LlmResolvedModelInfo, StreamChunk,
GenerateOptions, LlmCallConfig, LlmCallConfigAdapterDefaults, LlmModelInfo,
LlmModelReasoningInfo, LlmProviderInfo, LlmResolvedModelInfo, StreamChunk,
UserMessage,
} from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import type { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionPromptRequest, SessionRequestId } from '../src/types.ts'
import { ApiSessionAgentController } from '../src/agent.ts'
import { buildModelCatalog } from '../src/catalog.ts'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol'
@@ -86,6 +87,7 @@ async function harness(logged?: {
provider: string
model: string
reasoningEffort?: ReasoningEffortId
adapterDefaults?: LlmCallConfigAdapterDefaults
}): Promise<{
ctx: Context
agent: Agent
@@ -121,7 +123,11 @@ async function harness(logged?: {
]))
const session = ctx.sessions.create()
if (logged !== undefined) {
session.append('request/header', { header: { config: logged }, reason: 'initial' })
const { adapterDefaults, ...config } = logged
session.append('request/header', {
header: { config, ...adapterDefaults === undefined ? {} : { adapterDefaults } },
reason: 'initial',
})
}
const agent = {
id: session.id,
@@ -488,6 +494,23 @@ describe('Web session model selection', () => {
await ctx.fiber.dispose()
})
it('does not reinterpret an adapter-owned reasoning default as an explicit Web selection', async () => {
const { ctx, agent } = await harness({
provider: 'deepseek-official',
model: 'deepseek-chat',
reasoningEffort: ReasoningEffortId('high'),
adapterDefaults: { reasoningEffort: true },
})
createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'duplicate', model: 'same' }),
cwd: '/tmp',
})
expect(new ApiSessionAgentController(ctx).selectionFor(agent).current)
.toEqual({ provider: 'deepseek-official', model: 'deepseek-chat' })
await ctx.fiber.dispose()
})
it('saves an accepted selection as the default and survives a storage failure', async () => {
const { ctx, sessionId } = await harness()
const saved: unknown[] = []
+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/client/ui-chat/README.md
README.md: 5253cb95b0e5c0b89c32646e2ae2915936d35288
README.zh.md: 8cd2d0d581a0493892aed23f42ebc0c229a0bc17
README.md: ef9dc65de0d6b990fd0066c387518dc932bd4d2e
README.zh.md: c4de06b18077485d7d65734b9bb38ff7745a4d67
+4
View File
@@ -4,6 +4,10 @@ English | [中文](README.zh.md)
The browser Chat target for Conversation assembly. It registers Chat event definitions and snapshot construction, supplies `useChat`, renders transcript nodes and details, and owns Chat-specific stores, actions, localization, and scroll restoration; historical image URLs resolve through the Conversation-owned per-session cache (`ctx.uiConversation.imageUrl`).
## System prompt row
Chat contributes a `System prompt` row for a non-empty initial or resumed request, an explicit series start, or an actual system-field change; same-series config-only or tool-only changes, tool steps, and retries do not duplicate it. Chat places the first header in a step at that request's message boundary — turn start for step one, step start thereafter — before the user-role messages sent with the request, matching the provider envelope's system-before-messages order; when the preceding header is outside a partial window, a non-initial header stays at its own Event and renders conservatively until prepend supplies that predecessor. The row stays collapsed by default and mounts the complete prompt in the same 141px code-block body as an opaque context injection — model-facing text with its real line breaks, not Markdown — only while expanded; it has no streaming path. Systemless headers produce no row.
## Model Experience
None, as this package renders logged conversation state in the browser and registers nothing model-facing.
+4
View File
@@ -4,6 +4,10 @@
Conversation 组装的浏览器 Chat target。本包注册 Chat event definition 与 snapshot 构造、提供 `useChat`、渲染 transcript node 和详情,并拥有 Chat 专属 store、action、本地化与滚动位置恢复;历史图片 URL 通过 Conversation 持有的按会话缓存(`ctx.uiConversation.imageUrl`)解析。
## 系统提示词行
Chat 会为非空的初始或恢复请求、显式序列起点,或 system 字段真实变化贡献一行 `系统提示词`;同一序列内仅配置变化或仅工具变化、工具 step 和重试不会重复该行。Chat 会把一个 step 中的首条 header 放在该请求的消息边界——step one 使用 turn start,其余 step 使用 step start——位于该请求发送的 user-role 消息之前,与提供方信封「system 在 messages 之前」的顺序一致;部分窗口未包含前序 header 时,非 initial header 会保留在自身 Event 并保守渲染,直到 prepend 补入前序 header。该行默认折叠,仅在展开期间把完整提示词挂到与不透明上下文注入相同的 141px 代码块内容区——保留模型所见真实换行的模型可见文本,而非 Markdown;它没有流式路径。无系统提示词的 header 不生成行。
## 模型体验
无,因为本包在浏览器中渲染已记录的对话状态,不注册任何面向模型的内容。
@@ -13,6 +13,7 @@ import { formatRunDuration } from './message-chrome.ts'
import css from './ChatView.module.css'
const FOLLOW_THRESHOLD = 24
const MAX_PAGING_ANCHOR_PROBES = 64
/** Active column host when present; otherwise the view-local scroller. */
function scrollerOf(from: HTMLElement): HTMLElement {
@@ -26,7 +27,7 @@ interface PagingAnchor {
top: number
}
/** Find an already-rendered settled row without interpolating a selector. */
/** Find an already-rendered row without interpolating a selector. */
function anchorElement(list: HTMLElement, key: string): HTMLElement | null {
for (const row of list.querySelectorAll<HTMLElement>('[data-chat-anchor-key]')) {
if (row.dataset.chatAnchorKey === key) return row
@@ -45,17 +46,24 @@ function pagingAnchor(list: HTMLElement, scrollport: HTMLElement): HTMLElement |
const viewport = scrollport.getBoundingClientRect()
const composer = scrollport.querySelector<HTMLElement>('[data-composer-seat]')
const visibleBottom = composer?.getBoundingClientRect().top ?? viewport.bottom
// Scroll events are hot: hit-test a few points through the stretched flow
// rows before considering the full mounted set. The fallback keeps jsdom
// and pre-layout states deterministic; a virtualizer naturally bounds it.
// Scroll events are hot: walk down one hit-test line and stop at the first
// hit row with layout before considering the full mounted set. Starting at the
// viewport edge preserves the reader's leading row when a later row is
// inserted between already-visible messages. The fallback keeps jsdom and
// pre-layout states deterministic; a virtualizer naturally bounds it.
if (typeof document.elementsFromPoint === 'function' && visibleBottom > viewport.top) {
const content = list.getBoundingClientRect()
const left = Math.max(viewport.left, content.left)
const right = Math.min(viewport.right, content.right)
const x = left + Math.max(0, right - left) / 2
const height = visibleBottom - viewport.top
const points = [1, Math.min(32, height / 3), height / 2, Math.max(1, height - 1)]
for (const offset of points) {
let probes = 0
for (
let offset = 1;
offset < height && probes < MAX_PAGING_ANCHOR_PROBES;
offset = offset === 1 ? 16 : offset + 16
) {
probes++
for (const element of document.elementsFromPoint(x, viewport.top + offset)) {
const row = element instanceof HTMLElement
? element.closest<HTMLElement>('[data-chat-anchor-key]')
@@ -1,4 +1,5 @@
/* Figma 10:2482: 24px Tool calls header, 4px gap, 141px code block cap. */
/* Figma 10:2482: 24px Tool calls header, 4px gap, 141px code block cap.
SystemPromptRow reuses this sheet so both disclosures share one body. */
.root {
min-width: 0;
@@ -1,6 +1,6 @@
import { useState } from 'react'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import { DisclosureRow, IconBrowseOutline16, ReferenceIcon } from '@deepseek-ai/dsh-client-ui-primitives'
import { DisclosureRow, IconContextInjectionOutline16, ReferenceIcon } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ContextMessageNode } from '../contract/snapshot.ts'
import { contextBody } from './ContextBody.tsx'
import css from './ContextInjectionRow.module.css'
@@ -39,7 +39,7 @@ export function ContextInjectionRow({ content, source, provenance, form, t }: Co
className={css.root}
icon={provenance.role === 'recall'
? <span data-context-recall-icon><ReferenceIcon kind="session" /></span>
: <IconBrowseOutline16 size={14} />}
: <IconContextInjectionOutline16 size={14} />}
chevronClassName={css.chevron}
title={t(provenance.role === 'recall' ? 'message.contextRecall' : 'message.contextInjection')}
collapsedContent={provenance.label === null ? undefined : (
@@ -0,0 +1,47 @@
import { memo, useState } from 'react'
import type { ChatNodeViewProps, ChatViewSlotProps } from '../contract/slots.ts'
import { DisclosureRow, IconBrowseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import { OpaqueBody } from './ContextBody.tsx'
import css from './ContextInjectionRow.module.css'
/** Props for one complete system prompt disclosure. */
export interface SystemPromptRowProps {
/** Complete model-visible prompt text. */
text: string
/** The owning view's locale seat. */
t: ChatViewSlotProps['t']
}
/**
* Render one complete system prompt as a collapsed disclosure whose expanded
* body is the same opaque context chrome: 141px code-block scrollport and
* model-facing text with its real line breaks.
* @param props - Complete prompt text and the locale seat.
* @returns The system-prompt disclosure row.
*/
export function SystemPromptRow({ text, t }: SystemPromptRowProps) {
const [open, setOpen] = useState(false)
return (
<DisclosureRow
className={css.root}
icon={<IconBrowseOutline16 size={14} />}
chevronClassName={css.chevron}
title={t('message.systemPrompt')}
open={open}
expandable
expandOnRowClick
onToggle={() => { setOpen(value => !value) }}
>
<div className={css.body} data-system-prompt-body>
<OpaqueBody content={[{ type: 'text', text }]} source={null} t={t} />
</div>
</DisclosureRow>
)
}
/** System-prompt keyed Chat renderer. */
export const SystemPromptNodeView = memo(function SystemPromptNodeView({
node, t,
}: Pick<ChatNodeViewProps<'system-prompt'>, 'node' | 't'>) {
return <SystemPromptRow text={node.data.text} t={t} />
})
@@ -7,6 +7,7 @@ import {
TurnMaxTokensNodeView, UnknownNodeView, UserMessageNodeView,
} from './MessageItem.tsx'
import { TurnTailNodeView } from './TurnTailNodeView.tsx'
import { SystemPromptNodeView } from './SystemPromptRow.tsx'
/**
* Register this package's business renderers behind the keyed Chat Node seat.
@@ -19,6 +20,8 @@ export function registerChatNodeRenderers(ctx: Context): void {
{ name: 'conversation.chat.node', key: 'steering', locale: NS }, UserMessageNodeView))
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register(
{ name: 'conversation.chat.node', key: 'context', locale: NS }, ContextMessageNodeView))
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register(
{ name: 'conversation.chat.node', key: 'system-prompt', locale: NS }, SystemPromptNodeView))
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register(
{ name: 'conversation.chat.node', key: 'assistant-step', locale: NS }, AssistantNodeView))
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({
@@ -305,6 +305,8 @@ function legacyContribution(raw: ChatConversationViewNode): LegacyContribution {
running: null,
}
case 'turn-tail':
case 'system-prompt':
// These known Chat rows intentionally make no legacy timeline contribution.
return EMPTY_CONTRIBUTION
default:
return EMPTY_CONTRIBUTION
@@ -6,6 +6,7 @@ import { registerCompactionConversationNode } from './compaction.ts'
import { registerUnknownConversationFallback } from './fallback.ts'
import { registerInboxConversationNodes } from './inbox.ts'
import { registerMessageConversationNode } from './message.ts'
import { registerRequestPromptConversationNode } from './request-prompt.ts'
import { registerRetryConversationNode } from './retry.ts'
import { registerToolConversationNode } from './tool.ts'
import { registerTurnErrorConversationNode } from './turn-error.ts'
@@ -19,6 +20,7 @@ import { registerTurnTailConversationNode } from './turn-tail.ts'
export function registerConversationNodes(ctx: Context): void {
registerInboxConversationNodes(ctx)
registerMessageConversationNode(ctx)
registerRequestPromptConversationNode(ctx)
registerAssistantConversationNode(ctx)
registerToolConversationNode(ctx)
registerCommandConversationNode(ctx)
@@ -0,0 +1,87 @@
import type { Context } from '@deepseek-ai/cordis'
import type {
ConversationMatch, ConversationNodeDefinition, RequestPromptInspector,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import { chatNode } from './common.ts'
declare module '../contract/chat-nodes.ts' {
interface ChatNodeDataMap {
/** Complete system prompt rendered for one model request. */
'system-prompt': { readonly text: string }
}
}
interface RequestPromptState extends ReturnType<RequestPromptInspector> {
readonly anchorSeq: number
readonly showsPrompt: boolean
readonly turn?: number
readonly step?: number
}
/** Place a request's system field at the start of its visible message series. */
function requestPromptAnchor(
match: ConversationMatch,
previous: Readonly<RequestPromptState> | undefined,
isInitial: boolean,
): number {
if (match.location.kind !== 'step') return match.event.seq
if (previous === undefined && !isInitial) return match.event.seq
if (previous?.turn === match.location.turn.turn
&& previous.step === match.location.step.step) return match.event.seq
return match.location.step.step === 1
? match.location.turn.start?.seq ?? match.location.step.start?.seq ?? match.event.seq
: match.location.step.start?.seq ?? match.event.seq
}
/**
* Request-header prompt Definition for the Chat target.
* @param inspect - the shared prompt interpretation, supplied by the
* uiConversation service (a client bundle cannot value-import it).
* @returns the Chat request-prompt Definition.
*/
export function requestPromptDefinition(inspect: RequestPromptInspector): ConversationNodeDefinition<RequestPromptState> {
return {
kind: 'request-prompt',
target: 'chat',
match: event => event.type === 'request/header'
? { id: String(event.seq), role: 'start' }
: null,
start: (_context, match, reader) => {
if (match.event.type !== 'request/header') {
throw new Error('request-prompt start requires request/header')
}
const previous = reader.previous<RequestPromptState>('request-prompt')?.state
const location = match.location.kind === 'step'
? { turn: match.location.turn.turn, step: match.location.step.step }
: {}
const inspection = inspect(previous?.prompt, match.event)
const change = inspection.change?.kind
return {
anchorSeq: requestPromptAnchor(match, previous, match.event.data.reason === 'initial'),
showsPrompt: previous === undefined
|| match.event.data.reason !== 'change'
|| match.event.data.startsSeries === true
|| change === 'system'
|| change === 'system-and-tools',
...location,
...inspection,
}
},
update: context => context.state,
buildViewNode: (context) => {
const state = context.state
if (state === undefined || !state.showsPrompt || state.prompt.system === '') return null
return chatNode(context, 'system-prompt', state.anchorSeq, { text: state.prompt.system })
},
}
}
/**
* Register model-request system prompts in the Chat flow.
* @param ctx - Owning UI Conversation context.
*/
export function registerRequestPromptConversationNode(ctx: Context): void {
ctx.uiConversation.events.register(requestPromptDefinition(
(previous, event) => ctx.uiConversation.inspectRequestPrompt(previous, event),
))
}
@@ -5,6 +5,7 @@ export type {} from './conversation-nodes/command.ts'
export type {} from './conversation-nodes/compaction.ts'
export type {} from './conversation-nodes/fallback.ts'
export type {} from './conversation-nodes/message.ts'
export type {} from './conversation-nodes/request-prompt.ts'
export type {} from './conversation-nodes/retry.ts'
export type {} from './conversation-nodes/tool.ts'
export type {} from './conversation-nodes/turn-error.ts'
@@ -33,6 +33,7 @@ export const zh = {
'fileOpen.folderTitle': '无法打开文件夹',
'fileOpen.folderUnknown': '无法打开此文件夹',
'message.extraBlock': '附加内容块',
'message.systemPrompt': '系统提示词',
'message.contextInjection': '上下文注入',
'message.contextRecall': '跨会话召回',
'message.referenceSummary': '引用会话 · {labels}',
@@ -119,6 +120,7 @@ export const en = {
'fileOpen.folderTitle': 'Couldnt open folder',
'fileOpen.folderUnknown': 'Couldnt open this folder',
'message.extraBlock': 'Extra content block',
'message.systemPrompt': 'System prompt',
'message.contextInjection': 'Context injection',
'message.contextRecall': 'Session recall',
'message.referenceSummary': 'Referenced session · {labels}',
@@ -452,6 +452,39 @@ describe('ChatView', () => {
expect(scroller.scrollTop).toBe(590) // latest 90 + the anchored row's 500px prepend shift
})
it('bounds no-anchor hit testing before using the mounted-row fallback', () => {
const originalHitTest = Object.getOwnPropertyDescriptor(document, 'elementsFromPoint')
const hitTest = vi.fn((): Element[] => [])
Object.defineProperty(document, 'elementsFromPoint', {
configurable: true,
value: hitTest,
})
try {
const h = makeHarness({ nodes: [user(1, 'visible row')] })
const view = render(<h.ChatView {...h.props} />)
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
const anchor = view.container.querySelector('[data-chat-anchor-key="fixture:user:1"]') as HTMLElement
installScrollMetrics(scroller, 4_000, 2_000)
vi.spyOn(scroller, 'getBoundingClientRect').mockReturnValue({
top: 0, bottom: 2_000, left: 0, right: 1_000,
} as DOMRect)
vi.spyOn(anchor, 'getBoundingClientRect').mockReturnValue({
top: 100, bottom: 140, left: 0, right: 1_000,
} as DOMRect)
readerScroll(scroller, 100)
expect(hitTest).toHaveBeenCalledTimes(64)
expect(h.chatScroll.read()?.anchorKey).toBe('fixture:user:1')
} finally {
if (originalHitTest !== undefined) {
Object.defineProperty(document, 'elementsFromPoint', originalHitTest)
} else {
Reflect.deleteProperty(document, 'elementsFromPoint')
}
}
})
it('renders the fixture main line as independently keyed business nodes', () => {
const h = makeHarness({
nodes: [user(1, 'do the thing'), assistant(2, 'running tools'), toolResult(3, 'a'), toolResult(4, 'b')],
@@ -15,6 +15,8 @@ import { compactionDefinition } from '../src/client/conversation-nodes/compactio
import { unknownFallbackDefinition } from '../src/client/conversation-nodes/fallback.ts'
import { nextStepInboxDefinition, nextTurnInboxDefinition } from '../src/client/conversation-nodes/inbox.ts'
import { messageDefinition } from '../src/client/conversation-nodes/message.ts'
import { inspectRequestPrompt } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { requestPromptDefinition } from '../src/client/conversation-nodes/request-prompt.ts'
import { retryDefinition } from '../src/client/conversation-nodes/retry.ts'
import { toolDefinition } from '../src/client/conversation-nodes/tool.ts'
import { turnErrorDefinition } from '../src/client/conversation-nodes/turn-error.ts'
@@ -28,6 +30,7 @@ const DEFINITIONS: readonly ConversationNodeDefinition[] = [
nextTurnInboxDefinition,
nextStepInboxDefinition,
messageDefinition,
requestPromptDefinition(inspectRequestPrompt),
assistantDefinition,
toolDefinition,
commandDefinition,
@@ -121,6 +124,18 @@ function toolResult(callId: string, text: string, isError = false) {
}
describe('built-in conversation node Definitions', () => {
it('rejects an unrelated event passed directly to the request-prompt start', () => {
const input = at(1, 'turn/start', { turn: 1 })
const invalidStart = {
...input,
role: 'start' as const,
location: { kind: 'session' as const },
}
expect(() => requestPromptDefinition(inspectRequestPrompt).start({} as never, invalidStart, {} as never))
.toThrow('request-prompt start requires request/header')
})
it('keeps ordinary command-only history inactive for the Conversation shell', () => {
const value = assembler([
at(1, 'command/run', {
@@ -560,6 +575,223 @@ describe('built-in conversation node Definitions', () => {
})
})
it('materializes series starts and system changes but not same-series config or tool changes', () => {
const tools = [{ name: 'read', description: 'Read', parameters: { type: 'object' } }]
const expandedTools = [...tools, { name: 'write', description: 'Write', parameters: { type: 'object' } }]
const value = assembler([
at(1, 'request/header', {
reason: 'initial',
header: { config: { provider: 'fake', model: 'fake' }, system: '# Initial', tools },
}),
at(2, 'request/header', {
reason: 'change',
header: {
config: { provider: 'fake', model: 'fake' },
system: '# Initial',
tools: expandedTools,
},
}),
at(3, 'request/header', {
reason: 'change',
header: {
config: { provider: 'fake', model: 'fake', maxTokens: 1_024 },
system: '# Initial',
tools: expandedTools,
},
}),
at(4, 'request/header', {
reason: 'change',
startsSeries: true,
header: {
config: { provider: 'fake', model: 'fake', maxTokens: 2_048 },
system: '# Initial',
tools: expandedTools,
},
}),
at(5, 'request/header', {
reason: 'resume',
header: {
config: { provider: 'fake', model: 'fake', maxTokens: 2_048 },
system: '# Initial',
tools: expandedTools,
},
}),
at(6, 'request/header', {
reason: 'change',
header: {
config: { provider: 'fake', model: 'fake', maxTokens: 2_048 },
system: '# Updated',
tools: expandedTools,
},
}),
])
const prompts = snapshot(value).nodes.values()
.filter(candidate => candidate.kind === 'system-prompt')
expect(prompts.map(prompt => ({ anchorSeq: prompt.anchorSeq, data: prompt.data }))).toEqual([
{ anchorSeq: 1, data: { text: '# Initial' } },
{ anchorSeq: 4, data: { text: '# Initial' } },
{ anchorSeq: 5, data: { text: '# Initial' } },
{ anchorSeq: 6, data: { text: '# Updated' } },
])
const windowed = assembler([
at(10, 'request/header', {
reason: 'resume',
header: { config: { provider: 'fake', model: 'fake' }, system: '# Resumed prompt' },
}),
], true)
const systemless = assembler([
at(20, 'request/header', {
reason: 'initial',
header: { config: { provider: 'fake', model: 'fake' } },
}),
])
expect(node(snapshot(windowed), 'system-prompt')?.data).toEqual({ text: '# Resumed prompt' })
expect(node(snapshot(systemless), 'system-prompt')).toBeUndefined()
windowed.prepend([
at(5, 'request/header', {
reason: 'initial',
header: { config: { provider: 'fake', model: 'fake' }, system: '# Original prompt' },
}),
], false)
windowed.flush()
const restored = snapshot(windowed)
const restoredPrompts = restored.order.flatMap((key) => {
const candidate = restored.nodes.get(key)
return candidate?.kind === 'system-prompt' ? [candidate] : []
})
expect(restoredPrompts.map(prompt => prompt.data)).toEqual([
{ text: '# Original prompt' },
{ text: '# Resumed prompt' },
])
})
it('orders the system field before the request messages while preserving message order', () => {
const value = assembler([
at(1, 'turn/start', { turn: 1 }),
at(2, 'step/start', { turn: 1, step: 1 }),
at(3, 'user/message', textMessage('direct-user', 'prompt'), { surfaceOp: 'append' }),
at(4, 'user/message', {
...textMessage('runtime-context', 'runtime facts'),
source: { kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt', form: 'snapshot' },
}, { surfaceOp: 'append' }),
at(5, 'request/header', {
reason: 'initial',
header: { config: { provider: 'fake', model: 'fake' }, system: '# System' },
}),
])
const current = snapshot(value)
expect(current.order.map(key => current.nodes.get(key)?.kind)).toEqual([
'system-prompt',
'user',
'context',
])
expect(node(current, 'system-prompt')?.anchorSeq).toBe(1)
})
it('keeps an append-only later user turn in the existing system-prompt series', () => {
const value = assembler([
at(1, 'turn/start', { turn: 1 }),
at(2, 'step/start', { turn: 1, step: 1 }),
at(3, 'user/message', textMessage('first-user', 'first'), { surfaceOp: 'append' }),
at(4, 'request/header', {
reason: 'initial',
header: { config: { provider: 'fake', model: 'fake' }, system: '# System' },
}),
at(5, 'step/end', { turn: 1, step: 1 }),
at(6, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
at(7, 'turn/start', { turn: 2 }),
at(8, 'step/start', { turn: 2, step: 1 }),
at(9, 'user/message', textMessage('second-user', 'second'), { surfaceOp: 'append' }),
])
const current = snapshot(value)
const ordered = current.order.flatMap((key) => {
const candidate = current.nodes.get(key)
return candidate?.kind === 'system-prompt' || candidate?.kind === 'user' ? [candidate] : []
})
expect(ordered.map(candidate => candidate.kind)).toEqual(['system-prompt', 'user', 'user'])
})
it('keeps windowed non-initial headers at their event until prepend supplies the preceding header', () => {
const reasons = ['change', 'resume', 'series'] as const
for (const reason of reasons) {
const windowedSystem = reason === 'series' ? '# Original' : '# Windowed'
const windowed = assembler([
at(5, 'turn/start', { turn: 2 }),
at(6, 'step/start', { turn: 2, step: 1 }),
at(7, 'user/message', textMessage(`second-user-${reason}`, 'second'), { surfaceOp: 'append' }),
at(8, 'request/header', {
reason,
header: { config: { provider: 'fake', model: 'fake' }, system: windowedSystem },
}),
], true)
expect(node(snapshot(windowed), 'system-prompt')?.anchorSeq).toBe(8)
windowed.prepend([
at(1, 'turn/start', { turn: 1 }),
at(2, 'step/start', { turn: 1, step: 1 }),
at(3, 'user/message', textMessage(`first-user-${reason}`, 'first'), { surfaceOp: 'append' }),
at(4, 'request/header', {
reason: 'initial',
header: { config: { provider: 'fake', model: 'fake' }, system: '# Original' },
}),
], false)
windowed.flush()
const restored = snapshot(windowed)
const prompts = restored.order.flatMap((key) => {
const candidate = restored.nodes.get(key)
return candidate?.kind === 'system-prompt' ? [candidate] : []
})
expect(prompts.map(prompt => prompt.anchorSeq)).toEqual([1, 5])
}
})
it('repeats an unchanged system prompt after a surface rewrite and before an explicit later series', () => {
const value = assembler([
at(1, 'turn/start', { turn: 1 }),
at(2, 'step/start', { turn: 1, step: 1 }),
at(3, 'user/message', textMessage('first-user', 'first'), { surfaceOp: 'append' }),
at(4, 'request/header', {
reason: 'initial',
header: { config: { provider: 'fake', model: 'fake' }, system: '# Same' },
}),
at(5, 'user/message', {
...textMessage('compacted', 'summary'),
source: { kind: 'plugin', plugin: 'compact' },
}, { surfaceOp: { op: 'replace', start: 3, end: 3 } }),
at(6, 'request/header', {
reason: 'series',
header: { config: { provider: 'fake', model: 'fake' }, system: '# Same' },
}),
at(7, 'step/end', { turn: 1, step: 1 }),
at(8, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
at(9, 'turn/start', { turn: 2 }),
at(10, 'step/start', { turn: 2, step: 1 }),
at(11, 'user/message', textMessage('second-user', 'second'), { surfaceOp: 'append' }),
at(12, 'request/header', {
reason: 'series',
header: { config: { provider: 'fake', model: 'fake' }, system: '# Same' },
}),
])
const current = snapshot(value)
const ordered = current.order.flatMap((key) => {
const candidate = current.nodes.get(key)
return candidate?.kind === 'system-prompt' || candidate?.kind === 'user' ? [candidate] : []
})
expect(ordered.map(candidate => candidate?.kind)).toEqual([
'system-prompt', 'user', 'system-prompt', 'system-prompt', 'user',
])
expect(ordered.filter(candidate => candidate?.kind === 'system-prompt')
.map(candidate => candidate?.anchorSeq)).toEqual([1, 6, 9])
})
it('associates each direct message with its immediately following session recall', () => {
const value = assembler([
at(1, 'user/message', textMessage('citing-research', '@Research notes what changed?'), { surfaceOp: 'append' }),
@@ -0,0 +1,44 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import type { ChatNode } from '../src/client/contract/chat-nodes.ts'
import { SystemPromptNodeView } from '../src/client/chat/SystemPromptRow.tsx'
import { en } from '../src/client/locale.ts'
afterEach(cleanup)
describe('SystemPromptNodeView', () => {
it('mounts the opaque context body only while its row is expanded', () => {
const text = '# Agent rules\n\n- Read first\n- **Act carefully**'
const node: ChatNode<'system-prompt'> = {
key: 'request-prompt:1',
kind: 'system-prompt',
id: '1',
target: 'chat',
anchorSeq: 1,
location: { kind: 'unresolved' },
visibility: 'visible',
data: { text },
}
const { container } = render(<SystemPromptNodeView
node={node}
t={makeTranslate(en)}
/>)
const disclosure = screen.getByRole('button', { name: 'System prompt' })
expect(disclosure.getAttribute('aria-expanded')).toBe('false')
expect(container.querySelector('[data-system-prompt-body]')).toBeNull()
expect(container.querySelector('[data-context-text]')).toBeNull()
fireEvent.click(disclosure)
expect(disclosure.getAttribute('aria-expanded')).toBe('true')
expect(container.querySelector('[data-system-prompt-body]')).not.toBeNull()
expect(container.querySelector('[data-context-text]')?.textContent).toBe(text)
expect(screen.queryByRole('heading', { name: 'Agent rules' })).toBeNull()
fireEvent.click(disclosure)
expect(disclosure.getAttribute('aria-expanded')).toBe('false')
expect(container.querySelector('[data-system-prompt-body]')).toBeNull()
})
})
@@ -1,4 +1,5 @@
import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm/types'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type {
AssistantProvenanceView, AssistantRequestConfig,
} from './records.ts'
@@ -29,6 +30,61 @@ export interface RequestPromptChange {
previous?: ConversationPromptSnapshot
}
/** Canonical prompt snapshot and any model-visible change introduced by one request header. */
export interface RequestPromptInspection {
/** Complete prompt state recorded by the header. */
prompt: ConversationPromptSnapshot
/** System/tool change relative to the preceding loaded header. */
change?: RequestPromptChange
}
/**
* The {@link inspectRequestPrompt} signature as a value seam: Chat and
* Trajectory Definitions receive it from the uiConversation service because a
* client bundle cannot value-import another plugin's module.
*/
export type RequestPromptInspector = (
previous: ConversationPromptSnapshot | undefined,
event: SessionEvent<'request/header'>,
) => RequestPromptInspection
/**
* Canonicalize one request header and classify its model-visible prompt change.
* @param previous - Prompt from the preceding loaded request header, when available.
* @param event - Durable full request header to inspect.
* @returns The canonical prompt and an initial/system/tool change when it can be established.
*/
export function inspectRequestPrompt(
previous: ConversationPromptSnapshot | undefined,
event: SessionEvent<'request/header'>,
): RequestPromptInspection {
const header = event.data.header
const rawTools: unknown = header.tools
const prompt: ConversationPromptSnapshot = {
config: header.config,
system: header.system ?? '',
tools: Array.isArray(rawTools) ? rawTools as readonly ToolSchema[] : [],
}
if (previous === undefined && event.data.reason !== 'initial') return { prompt }
const systemChanged = previous !== undefined && previous.system !== prompt.system
const toolsChanged = previous !== undefined
&& JSON.stringify(previous.tools) !== JSON.stringify(prompt.tools)
if (previous !== undefined && !systemChanged && !toolsChanged) return { prompt }
return {
prompt,
change: {
seq: event.seq,
time: event.time,
kind: previous === undefined
? 'initial'
: systemChanged && toolsChanged
? 'system-and-tools'
: systemChanged ? 'system' : 'tools',
...(previous === undefined ? {} : { previous }),
},
}
}
/** Lifecycle fields shared by ordinary generation and compaction requests. */
interface RequestViewBase {
/** Sequence that opened the operation represented by this request. */
@@ -14,6 +14,8 @@ import type {
ConversationViewSnapshotStore,
} from '../contract/conversation.ts'
import type { ConversationSnapshot } from '../contract/snapshot.ts'
import type { ConversationPromptSnapshot, RequestPromptInspection } from '../contract/request-inspection.ts'
import { inspectRequestPrompt } from '../contract/request-inspection.ts'
import { ConversationNodeAssembler } from './assembler.ts'
import { ConversationEventRegistry } from './event-registry.ts'
import { HistoricalImageCache } from './historical-images.ts'
@@ -217,6 +219,23 @@ export class UiConversation extends Service {
return this.images.resolve(sessionId, attachment)
}
/**
* Canonicalize one `request/header` event against the previous prompt state.
*
* A pure interpretation shared by the Chat and Trajectory Definitions, exposed
* as a service method because cross-plugin value imports are forbidden in
* client bundles.
* @param previous - prompt recorded by the preceding loaded header, if any.
* @param event - the `request/header` session event to interpret.
* @returns the canonical prompt snapshot and any model-visible change.
*/
inspectRequestPrompt(
previous: ConversationPromptSnapshot | undefined,
event: SessionEvent<'request/header'>,
): RequestPromptInspection {
return inspectRequestPrompt(previous, event)
}
private drop(record: BindingRecord, releaseScope: boolean): void {
if (this.bindings.get(record.source.sessionId) !== record) return
this.bindings.delete(record.source.sessionId)
@@ -28,8 +28,9 @@ export type {
ContextProvenanceView, ContextRole, KnownContextForm,
} from './contract/context-provenance.ts'
export type {
ConversationPromptSnapshot, RequestInspectionSnapshot, RequestPromptChange, RequestView,
ConversationPromptSnapshot, RequestInspectionSnapshot, RequestPromptChange, RequestPromptInspection, RequestPromptInspector, RequestView,
} from './contract/request-inspection.ts'
export { inspectRequestPrompt } from './contract/request-inspection.ts'
export type { ConversationStoreState, ConversationViewRequest, ViewTab } from './contract/views.ts'
export { ConversationNodeAssembler } from './conversation/assembler.ts'
@@ -0,0 +1,86 @@
import { describe, expect, it } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import { inspectRequestPrompt } from '../src/client/contract/request-inspection.ts'
const CONFIG = { provider: 'test', model: 'test' }
function header(
seq: number,
reason: SessionEvent<'request/header'>['data']['reason'],
value: SessionEvent<'request/header'>['data']['header'],
): SessionEvent<'request/header'> {
return {
type: 'request/header',
seq,
time: 1_700_000_000_000 + seq,
data: { reason, header: value },
}
}
describe('inspectRequestPrompt', () => {
it('classifies the first complete header as the initial prompt', () => {
expect(inspectRequestPrompt(undefined, header(1, 'initial', {
config: CONFIG,
system: '# System\n\nFollow instructions.',
tools: [{ name: 'read', description: 'Read a file', parameters: { type: 'object' } }],
}))).toMatchObject({
prompt: {
config: CONFIG,
system: '# System\n\nFollow instructions.',
tools: [{ name: 'read' }],
},
change: { seq: 1, time: 1_700_000_000_001, kind: 'initial' },
})
})
it('suppresses a resume header when the earlier prompt is outside the loaded window', () => {
expect(inspectRequestPrompt(undefined, header(2, 'resume', {
config: CONFIG,
system: 'same prompt',
}))).toEqual({
prompt: { config: CONFIG, system: 'same prompt', tools: [] },
})
})
it('classifies system, tool, and combined changes against the previous prompt', () => {
const initial = inspectRequestPrompt(undefined, header(1, 'initial', {
config: CONFIG,
system: 'first',
tools: [{ name: 'read', description: 'Read', parameters: { type: 'object' } }],
})).prompt
const system = inspectRequestPrompt(initial, header(2, 'change', {
config: CONFIG,
system: 'second',
tools: [...initial.tools],
}))
const tools = inspectRequestPrompt(system.prompt, header(3, 'change', {
config: CONFIG,
system: 'second',
tools: [{ name: 'write', description: 'Write', parameters: { type: 'object' } }],
}))
const combined = inspectRequestPrompt(tools.prompt, header(4, 'change', {
config: CONFIG,
system: 'third',
tools: [],
}))
expect(system.change?.kind).toBe('system')
expect(tools.change?.kind).toBe('tools')
expect(combined.change?.kind).toBe('system-and-tools')
expect(combined.change?.previous).toBe(tools.prompt)
})
it('omits a change when the prompt and tools are unchanged', () => {
const previous = inspectRequestPrompt(undefined, header(1, 'initial', {
config: CONFIG,
system: 'same',
})).prompt
expect(inspectRequestPrompt(previous, header(2, 'resume', {
config: { ...CONFIG, maxTokens: 1_024 },
system: 'same',
}))).toEqual({
prompt: { config: { ...CONFIG, maxTokens: 1_024 }, system: 'same', tools: [] },
})
})
})
@@ -402,6 +402,22 @@ export const IconBrowseOutline16 = ({ size = 16, className }: IconProps) => (
</svg>
)
/** ic_ds_context_injection_outline_16 (figma extract): browse document frame with an open top and an arrow dropping in. */
export const IconContextInjectionOutline16 = ({ size = 16, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M11.9512 1.13281C12.401 1.20666 12.8093 1.34164 13.1738 1.60645C13.4282 1.79137 13.6521 2.01609 13.8369 2.27051C14.1574 2.71187 14.2892 3.21614 14.3506 3.78223C14.4105 4.33532 14.4102 5.02658 14.4102 5.87305V10.0273C14.4102 10.8738 14.4105 11.5651 14.3506 12.1182C14.2892 12.6843 14.1574 13.1885 13.8369 13.6299C13.652 13.8843 13.4282 14.109 13.1738 14.2939C12.7324 14.6146 12.2273 14.7462 11.6611 14.8076C11.1081 14.8675 10.4166 14.8672 9.57031 14.8672H6.43164C5.58533 14.8672 4.89387 14.8675 4.34082 14.8076C3.77474 14.7463 3.27046 14.6144 2.8291 14.2939C2.57453 14.109 2.35003 13.8844 2.16504 13.6299C1.84444 13.1885 1.71272 12.6844 1.65137 12.1182C1.59147 11.5651 1.5918 10.8738 1.5918 10.0273V5.87305C1.5918 5.02655 1.59146 4.33533 1.65137 3.78223C1.71272 3.21606 1.84443 2.71191 2.16504 2.27051C2.35003 2.01596 2.57453 1.79141 2.8291 1.60645C3.19332 1.34202 3.60062 1.20669 4.0498 1.13281V2.56445C3.87191 2.61154 3.74906 2.66836 3.65137 2.73926C3.51583 2.83777 3.3964 2.95726 3.29785 3.09277C3.1794 3.25581 3.09143 3.4856 3.04297 3.93262C2.9931 4.39287 2.99219 4.99529 2.99219 5.87305V10.0273C2.99219 10.905 2.99312 11.5075 3.04297 11.9678C3.09142 12.4147 3.17943 12.6446 3.29785 12.8076C3.3964 12.9431 3.51583 13.0626 3.65137 13.1611C3.81441 13.2795 4.04437 13.3676 4.49121 13.416C4.95142 13.4658 5.55411 13.4668 6.43164 13.4668H9.57031C10.4479 13.4668 11.0505 13.4659 11.5107 13.416C11.9576 13.3675 12.1876 13.2796 12.3506 13.1611C12.4861 13.0626 12.6056 12.9431 12.7041 12.8076C12.8224 12.6446 12.9106 12.4146 12.959 11.9678C13.0088 11.5075 13.0098 10.905 13.0098 10.0273V5.87305C13.0098 4.99532 13.0088 4.39286 12.959 3.93262C12.9105 3.48579 12.8225 3.2558 12.7041 3.09277C12.6056 2.95727 12.4861 2.83778 12.3506 2.73926C12.2527 2.66816 12.1296 2.61064 11.9512 2.56348V1.13281Z"
fill="currentColor"
/>
<path d="M9.32227 11.4141H4.95508V10.2148H9.32227V11.4141Z" fill="currentColor" />
<path d="M11.0439 8.90039H4.95508V7.70117H11.0439V8.90039Z" fill="currentColor" />
<path
d="M8.59961 3.75781L9.70996 2.64746L10.5586 3.49609L8.49512 5.55957C8.22173 5.83266 7.77816 5.83285 7.50488 5.55957L5.44141 3.49512L6.28906 2.64746L7.40039 3.75781V1.09668H8.59961V3.75781Z"
fill="currentColor"
/>
</svg>
)
/** ic_ds_link_outline_14 */
export const IconLinkOutline14 = ({ size = 14, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -16,8 +16,8 @@ const icons = Object.fromEntries(
const iconNames = Object.keys(icons)
describe('ic_ds_ icon set', () => {
it('exports the full icon set (46 deepsuite + 20 figma extracts + four product glyphs outside those sets)', () => {
expect(iconNames.length).toBe(70)
it('exports the full icon set (46 deepsuite + 21 figma extracts + four product glyphs outside those sets)', () => {
expect(iconNames.length).toBe(71)
})
it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', (name) => {
@@ -1,79 +1,55 @@
import type { Context } from '@deepseek-ai/cordis'
import type {
ConversationMatch, ConversationNodeDefinition, ConversationPromptSnapshot, RequestPromptChange,
ConversationNodeDefinition, RequestPromptInspector,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import { trajectoryNode } from './trajectory-definition-common.ts'
import type { TrajectoryRequestHeaderState } from './trajectory-contract.ts'
function requestPrompt(match: ConversationMatch): ConversationPromptSnapshot {
if (match.event.type !== 'request/header') {
throw new Error('trajectory-request-header start requires request/header')
}
const header = match.event.data.header
const tools: unknown = header.tools
/**
* Request-header fact Definition for the Trajectory target.
* @param inspect - the shared prompt interpretation, supplied by the
* uiConversation service (a client bundle cannot value-import it).
* @returns the Trajectory request-header Definition.
*/
function trajectoryRequestHeaderDefinition(inspect: RequestPromptInspector): ConversationNodeDefinition<TrajectoryRequestHeaderState> {
return {
config: header.config,
system: header.system ?? '',
tools: Array.isArray(tools) ? tools as ConversationPromptSnapshot['tools'] : [],
kind: 'trajectory-request-header',
target: 'trajectory',
match: event => event.type === 'request/header'
? { id: String(event.seq), role: 'start' }
: null,
start: (_context, match, reader) => {
if (match.event.type !== 'request/header') {
throw new Error('trajectory-request-header start requires request/header')
}
const previous = reader.previous<TrajectoryRequestHeaderState>('trajectory-request-header')
?.state.prompt
const { prompt, change } = inspect(previous, match.event)
return {
seq: match.event.seq,
time: match.event.time,
prompt,
location: match.location,
...(change === undefined ? {} : { change }),
}
},
update: context => context.state,
buildViewNode: context => context.state === undefined
? null
: trajectoryNode(context, context.state.seq, {
kind: 'request-header',
header: context.state,
}),
}
}
function promptChange(
previous: ConversationPromptSnapshot | undefined,
prompt: ConversationPromptSnapshot,
match: ConversationMatch,
): RequestPromptChange | undefined {
if (match.event.type !== 'request/header') return undefined
if (previous === undefined && match.event.data.reason !== 'initial') return undefined
const systemChanged = previous !== undefined && previous.system !== prompt.system
const toolsChanged = previous !== undefined
&& JSON.stringify(previous.tools) !== JSON.stringify(prompt.tools)
if (previous !== undefined && !systemChanged && !toolsChanged) return undefined
return {
seq: match.event.seq,
time: match.event.time,
kind: previous === undefined
? 'initial'
: systemChanged && toolsChanged
? 'system-and-tools'
: systemChanged ? 'system' : 'tools',
...(previous === undefined ? {} : { previous }),
}
}
const trajectoryRequestHeaderDefinition: ConversationNodeDefinition<TrajectoryRequestHeaderState> = {
kind: 'trajectory-request-header',
target: 'trajectory',
match: event => event.type === 'request/header'
? { id: String(event.seq), role: 'start' }
: null,
start: (_context, match, reader) => {
const prompt = requestPrompt(match)
const previous = reader.previous<TrajectoryRequestHeaderState>('trajectory-request-header')
?.state.prompt
const change = promptChange(previous, prompt, match)
return {
seq: match.event.seq,
time: match.event.time,
prompt,
location: match.location,
...(change === undefined ? {} : { change }),
}
},
update: context => context.state,
buildViewNode: context => context.state === undefined
? null
: trajectoryNode(context, context.state.seq, {
kind: 'request-header',
header: context.state,
}),
}
/**
* Register Trajectory request-header facts.
*
* @param ctx - Plugin context receiving the Definition.
*/
export function registerTrajectoryRequestHeaderDefinition(ctx: Context): void {
ctx.uiConversation.events.register(trajectoryRequestHeaderDefinition)
ctx.uiConversation.events.register(trajectoryRequestHeaderDefinition(
(previous, event) => ctx.uiConversation.inspectRequestPrompt(previous, event),
))
}
@@ -1,7 +1,7 @@
import type { Context } from '@deepseek-ai/cordis'
import type {
AssistantMessageNode, ConversationNode, ConversationPromptSnapshot, ConversationViewBuilder,
ConversationViewDefinition, RequestView, ToolCallBlock,
ConversationViewDefinition, RequestPromptChange, RequestView, ToolCallBlock,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import { COMPACTION_INTERRUPTED_ERROR } from './copy-codes.ts'
import type {
@@ -34,26 +34,38 @@ function headerStepKey(header: TrajectoryRequestHeaderState): string | undefined
: undefined
}
interface StepHeaders {
/** Latest full request snapshot in the step. */
latest: TrajectoryRequestHeaderState
/** Latest actual prompt change in the step, retained across a later series snapshot. */
change?: RequestPromptChange
}
function headerFor(
request: AssistantRequest,
headersByStep: ReadonlyMap<string, TrajectoryRequestHeaderState>,
headersByStep: ReadonlyMap<string, StepHeaders>,
previous: TrajectoryRequestHeaderState | undefined,
): TrajectoryRequestHeaderState | undefined {
): StepHeaders | undefined {
return headersByStep.get(stepKey(request.turn, request.step))
?? (previous !== undefined && previous.seq < request.startSeq ? previous : undefined)
?? (previous !== undefined && previous.seq < request.startSeq
? {
latest: previous,
...(previous.change === undefined ? {} : { change: previous.change }),
}
: undefined)
}
function applyHeader(
request: AssistantRequest,
header: TrajectoryRequestHeaderState | undefined,
header: StepHeaders | undefined,
includeChange: boolean,
): AssistantRequest {
return header === undefined
? request
: {
...request,
prompt: header.prompt,
requestConfig: header.prompt.config,
prompt: header.latest.prompt,
requestConfig: header.latest.prompt.config,
...(includeChange && header.change !== undefined ? { promptChange: header.change } : {}),
}
}
@@ -174,11 +186,18 @@ export class TrajectorySnapshotBuilder implements ConversationViewBuilder<
}
private snapshot(): TrajectorySnapshot {
const headersByStep = new Map<string, TrajectoryRequestHeaderState>()
const headersByStep = new Map<string, StepHeaders>()
for (const contribution of this.contributions) {
if (contribution.data.kind !== 'request-header') continue
const key = headerStepKey(contribution.data.header)
if (key !== undefined) headersByStep.set(key, contribution.data.header)
if (key === undefined) continue
const previous = headersByStep.get(key)
headersByStep.set(key, {
latest: contribution.data.header,
...(contribution.data.header.change !== undefined
? { change: contribution.data.header.change }
: previous?.change === undefined ? {} : { change: previous.change }),
})
}
const finalized: ConversationNode[] = []
const eventLocations = new Map<number, TrajectoryConversationViewNode['location']>()
@@ -213,13 +232,14 @@ export class TrajectorySnapshotBuilder implements ConversationViewBuilder<
const header = data.request === undefined
? undefined
: headerFor(data.request, headersByStep, previousHeader)
if (data.node !== undefined) finalized.push(withRequestConfig(data.node, header?.prompt))
if (data.node !== undefined) finalized.push(withRequestConfig(data.node, header?.latest.prompt))
if (data.partial !== null) partial = data.partial
if (data.request !== undefined) {
const includeChange = header?.change !== undefined
&& !consumedPromptChanges.has(header.seq)
const change = header?.change
const includeChange = change !== undefined
&& !consumedPromptChanges.has(change.seq)
requests.push(applyHeader(data.request, header, includeChange))
if (includeChange) consumedPromptChanges.add(header.seq)
if (includeChange) consumedPromptChanges.add(change.seq)
}
continue
}
@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'
import type {
ConversationEventInput, ConversationNodeDefinition, ConversationViewDefinition,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import { ConversationNodeAssembler } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { ConversationNodeAssembler, inspectRequestPrompt } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { registerTrajectoryAssistantDefinition } from '../src/client/trajectory-assistant-definition.ts'
import { registerTrajectoryCompactionDefinitions } from '../src/client/trajectory-compaction-definition.ts'
import type { TrajectorySnapshot } from '../src/client/trajectory-contract.ts'
@@ -21,6 +21,7 @@ const registrationContext = {
return () => {}
},
},
inspectRequestPrompt,
},
} as unknown as Context
@@ -109,6 +109,65 @@ describe('TrajectorySnapshotBuilder', () => {
: undefined)).toEqual(['initial', undefined])
})
it('retains a same-step prompt change when a later series header supplies the latest snapshot', () => {
const initial = {
config: { provider: 'test', model: 'test' },
system: 'initial prompt',
tools: [],
}
const changed = { ...initial, system: 'changed prompt' }
const nodes: TrajectoryConversationViewNode[] = [
contribution('header:initial', 2, {
kind: 'request-header',
header: {
seq: 2,
time: 2,
prompt: initial,
change: { seq: 2, time: 2, kind: 'initial' },
location: { kind: 'session' },
},
}),
contribution('assistant:1', 3, {
kind: 'assistant',
partial: null,
request: assistantRequest(3, 1),
}),
contribution('header:change', 5, {
kind: 'request-header',
header: {
seq: 5,
time: 5,
prompt: changed,
change: { seq: 5, time: 5, kind: 'system', previous: initial },
location: stepLocation(1, 2),
},
}),
contribution('header:series', 6, {
kind: 'request-header',
header: {
seq: 6,
time: 6,
prompt: changed,
location: stepLocation(1, 2),
},
}),
contribution('assistant:2', 7, {
kind: 'assistant',
partial: null,
request: assistantRequest(7, 2),
}),
]
const snapshot = new TrajectorySnapshotBuilder().replace({ nodes })
expect(snapshot.requests.map(request => request.purpose === 'assistant'
? request.prompt?.system
: undefined)).toEqual(['initial prompt', 'changed prompt'])
expect(snapshot.requests.map(request => request.purpose === 'assistant'
? request.promptChange?.seq
: undefined)).toEqual([2, 5])
})
it('indexes exact step headers and the active tool schema without backward scans', () => {
const basePrompt = {
config: { provider: 'test', model: 'base' },
@@ -344,7 +344,7 @@ export function apply(ctx: Context, config: Config): void {
// precedes it and the driver-appended runtime context follows it.
const lastClaimedIndex = decision.messages.findLastIndex(message => messages.includes(message))
const entered = decision.messages.toSpliced(lastClaimedIndex + 1, 0, desired)
return { kind: 'enter', messages: entered }
return { ...decision, messages: entered }
})
ctx.on('tools/result', (exec: ToolExecution, result: ToolExecutionResult) => {
@@ -107,7 +107,7 @@ export class SessionReferenceResolver extends TypertRemoteService {
const decision = await next()
if (decision.kind === 'reject') return decision
return {
kind: 'enter',
...decision,
messages: await this.prepareDirectMessages(agent, decision.messages, signal),
}
}, { prepend: true })
+1 -1
View File
@@ -196,7 +196,7 @@ export function apply(ctx: Context, config: Config): void {
browser,
)
return {
kind: 'enter',
...decision,
messages: [
...decision.messages,
createUserMessage({
+1 -1
View File
@@ -234,7 +234,7 @@ export function apply(ctx: Context, config: Config): void {
if (previous !== undefined && previous.state === state) return decision
const text = renderReading(location, turn)
return {
kind: 'enter',
...decision,
messages: [
createUserMessage({
content: [{ type: 'text', text }],
+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/core/agent-loop/README.md
README.md: 1b233ae1203171930ef5b58de93ec67381ec4918
README.zh.md: 81af654072f23c5280e2e14bc891972b5e1f37d5
README.md: 8b35b970aac93ac3c20fe570c79c3524abbe079f
README.zh.md: 4a11d81e6cbdbce1c1e7997785a2cf4456609171
+1 -1
View File
@@ -66,7 +66,7 @@ The driver owns one agent for its lifetime and runs inside `ctx.agents.withIniti
Every provider call that reaches a successful finish appends exactly one `assistant/message` completion anchor, including content-less calls and `max-tokens` finishes. The anchor records the assembled content as-is, lists the exact chunk seqs in `sourceEventSeqs` (`[]` for a stream with no chunks), and includes usage when available; empty content stays out of derived message history. A turn cancellation that interrupts streaming also appends an `interrupted: true` anchor when non-empty text or reasoning has reached the user. The anchor cites those chunk seqs and places the rendered prefix in derived message history, so the next request contains what the user saw. Undispatched tool calls are omitted, and an empty or tool-only stream produces no anchor; provider failures still commit no assistant content ([decision](../../../.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.md)).
After `agent/request` returns a provider/model call config, the loop asks `ctx.llm.prepareCall()` to validate adapter-owned fields and materialize configured reasoning-effort and output-token defaults under the active turn signal. The prepared call retains the exact adapter registration across this asynchronous resolution, `request/header` logging, and terminal dispatch, so HMR cannot mix one adapter's capability result with another adapter's request. The header records the effective config and which fields came from the adapter. Before the next waterfall, the loop removes those marked fields from the proposal so the current exact route rematerializes its own defaults; unmarked explicit settings persist across steps and route changes. A route with no registered adapter preserves the proposed config so an `llm/stream` listener can own and short-circuit it; unhandled terminal dispatch still fails with `NO_ADAPTER`. A new loop instance follows the same adapter-default marker rule when resuming.
After `agent/request` returns a provider/model call config, the loop asks `ctx.llm.prepareCall()` to validate adapter-owned fields and materialize configured reasoning-effort and output-token defaults under the active turn signal. The prepared call retains the exact adapter registration across this asynchronous resolution, `request/header` logging, and terminal dispatch, so HMR cannot mix one adapter's capability result with another adapter's request. The header records the effective config and which fields came from the adapter. The loop appends a full snapshot for its first request, for a changed header, and when an unchanged header begins an explicitly declared message series or the first request after a surface replacement. A changed header that also begins a series carries `startsSeries: true`; further same-series Steps, ordinary later Turns, and retries with an unchanged header inherit the latest snapshot. Before the next waterfall, the loop removes adapter-marked fields from the proposal so the current exact route rematerializes its own defaults; unmarked explicit settings persist across steps and route changes. A route with no registered adapter preserves the proposed config so an `llm/stream` listener can own and short-circuit it; unhandled terminal dispatch still fails with `NO_ADAPTER`. A new loop instance follows the same adapter-default marker rule and appends a `resume` snapshot.
Plugin failure ends the current turn, not the loop. Final adapter selection, dispatch, and iteration failures arrive from `ctx.llm` as terminal error or aborted finishes and enter `agent/request-error`; middleware, result processing, tools, and other extension failures remain thrown and close directly. Recovery receives request coordinates, immutable provider facts, the immutable retry policy captured by the prepared adapter registration, and the turn signal; the policy is absent when middleware owns an unprepared route. A handling listener returns `{ kind: 'retry' }`; an unhandled failure is terminal. AgentLoop owns one cancellation signal for the current admission or turn. An effective `cancel(cause)` clears pending work unless `keepInbox` is set and cooperatively aborts that signal; idle cancellation is a no-op. Waking input that lands after the abort fires but before the activity converges to idle is latched (`wakeRequested`) and replayed at the driver's own convergence boundary, so it runs without a further waking send; a `disposed` cancel never latches, and a wake submitted while already idle always opens its turn boundary (status shows a transient `idle → running → idle` pair even when the message was cleared). Durable `turn/end` records `aborted` for `user` and `parent`, while disposal records `disposed`; undispatched model tool calls receive synthetic `tool/call` and `ABORTED_BEFORE_DISPATCH` result pairs. The cancellation cause changes reporting, not how result context finalized after cancellation is handled. Disposal waits for signal-ignoring work before registry removal. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) and the [cancel-convergence wake latch](../../../.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.md) own the lifecycle and race contract.
+1 -1
View File
@@ -66,7 +66,7 @@ interface Config {
每次提供方调用成功结束时,都会恰好追加一个 `assistant/message` 完成锚点,包括无内容调用和以 `max-tokens` 结束的调用。该锚点原样记录组装后的内容,在 `sourceEventSeqs` 中列出确切的分片 seq(流没有分片时为 `[]`),并在用量可用时包含用量;空内容不会进入派生消息历史。轮次取消打断流式输出时,如果非空文本或推理内容已送达用户,循环也会追加一个带 `interrupted: true` 的锚点。该锚点引用对应的分片 seq,并把已渲染的前缀放入派生消息历史,使下一次请求包含用户看到的内容。未分派的工具调用会被省略,空流或只包含工具调用的流不会生成锚点;提供方故障也不提交 assistant 内容([决策](../../../.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.zh.md))。
`agent/request` 返回提供方/模型调用配置后,循环会调用 `ctx.llm.prepareCall()`,在活跃轮次信号的控制下校验由适配器负责的字段,并填入配置的推理(reasoning)强度和输出 token 默认值。准备完成的调用会在这次异步解析、`request/header` 日志记录和最终分派期间保留同一项确切的适配器注册,因此 HMR(热模块替换)不会把某个适配器的能力解析结果与另一适配器的请求混用。请求 header 会记录生效配置以及哪些字段来自适配器。下一次 waterfall(瀑布式事件)前,循环会从提议中移除这些带标记字段,使当前精确路由重新填入自身默认值;未带标记的显式设置会跨步骤和路由变化保留。没有已注册适配器的路由会保留原定配置,使 `llm/stream` 监听器可以接管并短路该请求;最终分派仍会以 `NO_ADAPTER` 拒绝未得到处理的路由。新循环实例在恢复时会遵循同一套适配器默认值标记规则。
`agent/request` 返回提供方/模型调用配置后,循环会调用 `ctx.llm.prepareCall()`,在活跃轮次信号的控制下校验由适配器持有的字段,并填入配置的推理(reasoning)强度和输出 token 默认值。准备完成的调用会在这次异步解析、`request/header` 日志记录和最终分派期间保留同一项确切的适配器注册,因此 HMR(热模块替换)不会把某个适配器的能力解析结果与另一适配器的请求混用。请求 header 会记录生效配置以及哪些字段来自适配器。循环会为实例的首个请求、发生变化的 header,以及显式声明的新消息序列或表层替换后的首个请求中内容未变的 header 追加完整快照。如果变化的 header 同时开启序列,它会携带 `startsSeries: true`;同一序列内 header 未变的后续 Step、普通后续 Turn 与重试继承最新快照。下一次 waterfall(瀑布式事件)前,循环会从提议中移除由适配器标记字段,使当前精确路由重新填入自身默认值;未带标记的显式设置会跨步骤和路由变化保留。没有已注册适配器的路由会保留原定配置,使 `llm/stream` 监听器可以接管并短路该请求;最终分派仍会以 `NO_ADAPTER` 拒绝未得到处理的路由。新循环实例在恢复时会遵循同一套适配器默认值标记规则,并追加 `resume` 快照
插件失败会结束当前轮次,而不是结束循环。最终适配器选择、分发与迭代失败会以终止错误或中止结束的形式由 `ctx.llm` 传来,并进入 `agent/request-error`;middleware、结果处理、工具及其他扩展失败仍会抛出并直接关闭轮次。恢复逻辑会接收请求坐标、不可变的提供方事实、准备完成的适配器注册所捕获的不可变重试策略以及轮次信号;middleware 接管未准备路由时,该策略缺失。处理失败的监听器返回 `{ kind: 'retry' }`;未被处理的失败是终态。AgentLoop 为当前准入操作或轮次拥有一个取消信号。有效的 `cancel(cause)` 在未设置 `keepInbox` 时清除待处理工作,并以协作方式中止该信号;空闲取消是空操作。abort 触发后、活动收敛到空闲前到达的唤醒输入会被锁存(`wakeRequested`),并在 driver 自身的收敛边界重放,无需再发一条唤醒 send 即可执行;`disposed` 取消从不锁存,而 agent 已处于空闲时发送的唤醒总是打开自己的 turn 边界(即使消息已被清除,状态也会显示瞬态 `idle → running → idle` 对)。持久 `turn/end``user``parent` 记录 `aborted`dispose 则记录 `disposed`;未分发的模型工具调用会收到合成的 `tool/call``ABORTED_BEFORE_DISPATCH` 结果对。取消原因只影响报告方式,不影响如何处理在取消后完成终结的结果上下文。dispose 会等待忽略信号的工作完成,然后才从注册表移除。[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md)与[取消收敛窗口唤醒锁存](../../../.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.zh.md)规定生命周期与竞态约定。
+32 -5
View File
@@ -49,7 +49,12 @@ type StepEndReason = Extract<TurnEndReason, { kind: 'completed' | 'max-tokens' }
type PreparedStep =
| { kind: 'reject' }
| { kind: 'enter'; messages: UserMessage[]; assembly: PromptAssembly }
| {
kind: 'enter'
messages: UserMessage[]
startsRequestSeries?: true
assembly: PromptAssembly
}
/** Remove adapter-derived values before plugins propose the next request config. */
function requestProposal(header: EpochHeader): LlmCallConfig {
@@ -75,6 +80,8 @@ export class ReactLoopAgent implements Agent {
/** Whether this loop instance has appended its initial/resume request anchor. */
private requestHeaderLogged = false
/** Surface generation of the preceding built request. */
private requestSurfaceGeneration: number | undefined
private readonly runtimeContext: RuntimeContextProjection
constructor(
@@ -284,7 +291,7 @@ export class ReactLoopAgent implements Agent {
}
// max-tokens is sticky: once any step hits the ceiling, later steps
// that complete normally must not downgrade the turn outcome.
const stepEnd = await this.step(decision.assembly)
const stepEnd = await this.step(decision.assembly, decision.startsRequestSeries === true)
// max-tokens stays sticky: a later completed step must not
// downgrade the turn outcome.
if (turnEnds === null || turnEnds.kind !== 'max-tokens') turnEnds = stepEnd
@@ -329,7 +336,7 @@ export class ReactLoopAgent implements Agent {
return true
}
private async step(assembly: PromptAssembly): Promise<StepEndReason | null> {
private async step(assembly: PromptAssembly, startsRequestSeries: boolean): Promise<StepEndReason | null> {
/* v8 ignore next -- private callers establish the running phase before executing a step */
if (this.phase.kind !== 'running') throw new Error(`agent "${this.id}": step outside running phase`)
const { turn, step, abort: { signal } } = this.phase
@@ -337,9 +344,18 @@ export class ReactLoopAgent implements Agent {
const system = renderPrompt(assembly)
while (true) {
const surfaceGeneration = this.session.surface.replaceGeneration
const { request, preparedCall } = await this.buildRequest(
turn, step, assembly.tools, system, this.session.deriveMessages(), signal,
turn,
step,
assembly.tools,
system,
this.session.deriveMessages(),
startsRequestSeries,
surfaceGeneration,
signal,
)
startsRequestSeries = false
const assembler = new BlockAssembler()
const chunkSeqs: number[] = []
try {
@@ -429,6 +445,8 @@ export class ReactLoopAgent implements Agent {
tools: GenerateOptions['tools'] & object,
system: string,
boundaryMessages: Message[],
startsRequestSeries: boolean,
surfaceGeneration: number,
signal: AbortSignal,
): Promise<{ request: GenerateOptions; preparedCall?: PreparedLlmCall }> {
const { session } = this
@@ -482,12 +500,21 @@ export class ReactLoopAgent implements Agent {
...tools.length > 0 ? { tools } : {},
})
const baseline = this.session.requestHeader()
const startsSeries = startsRequestSeries
|| this.requestSurfaceGeneration !== surfaceGeneration
if (!this.requestHeaderLogged) {
this.session.append('request/header', { header, reason: baseline === undefined ? 'initial' : 'resume' })
this.requestHeaderLogged = true
} else if (baseline === undefined || !headerEquals(baseline, header)) {
this.session.append('request/header', { header, reason: 'change' })
this.session.append('request/header', {
header,
reason: 'change',
...startsSeries ? { startsSeries: true } : {},
})
} else if (startsSeries) {
this.session.append('request/header', { header, reason: 'series' })
}
this.requestSurfaceGeneration = surfaceGeneration
const contextWindow = preparedCall?.context?.contextWindow
const requestContext: RequestContext = {
+2 -1
View File
@@ -435,7 +435,8 @@ describe('agent loop', () => {
await waitForIdle(ctx, agent)
expect(contextEvents()).toHaveLength(3)
expect(adapter.requests.map(request => request.system)).toEqual(Array(5).fill(adapter.requests[0]?.system))
expect(agent.session.events.filter(event => event.type === 'request/header')).toHaveLength(1)
expect(agent.session.events.flatMap(event =>
event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial'])
})
it('re-emits unchanged runtime context when a surface replacement removed the retained snapshot', async () => {
@@ -96,6 +96,8 @@ describe('agent/request-error', () => {
expect.objectContaining({ mode: 'normal' }),
])
expect(statuses).toEqual(['running', 'idle'])
expect(agent.session.events.flatMap(event =>
event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial'])
})
it('lets cancellation win over a retry action', async () => {
@@ -109,6 +109,91 @@ describe('request stability across the loop', () => {
expect(adapter.requests).toHaveLength(2)
expectPrefixExtension(adapter.requests[0]!, adapter.requests[1]!)
expect(agent.session.events.flatMap(event =>
event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial'])
})
it('starts a new request series only when the admitted step explicitly asks for one', async () => {
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
ctx.on('agent/pre-step', async ({ turn }, next) => {
const decision = await next()
return decision.kind === 'enter' && turn === 2
? { ...decision, startsRequestSeries: true }
: decision
})
send(agent, 'first')
await waitForIdle(ctx, agent)
send(agent, 'second series')
await waitForIdle(ctx, agent)
expectPrefixExtension(adapter.requests[0]!, adapter.requests[1]!)
expect(agent.session.events.flatMap(event =>
event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial', 'series'])
})
it('retains the explicit series boundary when that request also changes its header', async () => {
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
ctx.on('agent/pre-step', async ({ turn }, next) => {
const decision = await next()
return decision.kind === 'enter' && turn === 2
? { ...decision, startsRequestSeries: true }
: decision
})
ctx.on('agent/request', async ({ turn }, next) => {
const config = await next()
return turn === 2 ? { ...config, maxTokens: 1_024 } : config
})
send(agent, 'first')
await waitForIdle(ctx, agent)
send(agent, 'second series')
await waitForIdle(ctx, agent)
expectPrefixExtension(adapter.requests[0]!, adapter.requests[1]!)
expect(agent.session.events.flatMap(event => event.type === 'request/header'
? [{ reason: event.data.reason, startsSeries: event.data.startsSeries }]
: [])).toEqual([
{ reason: 'initial', startsSeries: undefined },
{ reason: 'change', startsSeries: true },
])
})
it('keeps the series declaration when an outer listener rebuilds the enter decision', async () => {
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
// Context-appending wrapper in the tool-cordis / session-reference shape:
// it rebuilds the downstream decision, so it must spread it to keep fields
// it does not own — a bare `{ kind: 'enter', messages }` drops the series.
ctx.on('agent/pre-step', async (_payload, next) => {
const decision = await next()
if (decision.kind === 'reject') return decision
const appended = createUserMessage({
content: [{ type: 'text', text: 'appended reference context' }],
source: { kind: 'plugin', plugin: 'outer-wrapper' },
})
return { ...decision, messages: [...decision.messages, appended] }
}, { prepend: true })
ctx.on('agent/pre-step', async ({ turn }, next) => {
const decision = await next()
return decision.kind === 'enter' && turn === 2
? { ...decision, startsRequestSeries: true }
: decision
})
send(agent, 'first')
await waitForIdle(ctx, agent)
send(agent, 'second series')
await waitForIdle(ctx, agent)
expectPrefixExtension(adapter.requests[0]!, adapter.requests[1]!)
expect(agent.session.events.flatMap(event =>
event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial', 'series'])
})
it('logs adapter defaults, supports per-turn effort changes, and restores the effective value', async () => {
@@ -407,6 +492,10 @@ describe('request stability across the loop', () => {
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
ctx.on('agent/request', async ({ turn }, next) => {
const config = await next()
return turn === 2 ? { ...config, maxTokens: 1_024 } : config
})
send(agent, 'first')
await waitForIdle(ctx, agent)
@@ -426,11 +515,49 @@ describe('request stability across the loop', () => {
const second = adapter.requests[1]!
// The rewritten history: summary replaces turn 1's user+assistant pair.
expect(second.messages[0]!.content.some(b => b.type === 'text' && b.text.includes('[summary of turn 1]'))).toBe(true)
// No header event beyond the anchor: the replace is itself in the log.
expect(agent.session.events.filter(e => e.type === 'request/header')).toHaveLength(1)
expect(agent.session.events.flatMap(event => event.type === 'request/header'
? [{ reason: event.data.reason, startsSeries: event.data.startsSeries }]
: [])).toEqual([
{ reason: 'initial', startsSeries: undefined },
{ reason: 'change', startsSeries: true },
])
})
it('a real system-prompt change is a full changed-header snapshot; a stable prompt logs nothing', async () => {
it('starts a new request series when compaction rewrites a retry in the same step', async () => {
const adapter = new MockAdapter([
() => { throw new LlmError('request is too large', 'CONTEXT_LENGTH') },
textResponse('recovered'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('same-step-compaction'), {
provider: 'mock',
model: 'mock',
})
ctx.on('agent/request-error', async ({ agent: subject }) => {
const first = subject.session.surface.nodes[0]
if (first === undefined) throw new Error('request has no surface message to compact')
subject.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: '[summary for retry]' }],
source: { kind: 'plugin', plugin: 'test-compact' },
}), {
surfaceOp: { op: 'replace', start: first, end: first },
sourceEventSeqs: [first],
})
return { kind: 'retry' }
})
send(agent, 'first series')
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(2)
expect(adapter.requests[1]?.messages[0]?.content).toContainEqual({
type: 'text', text: '[summary for retry]',
})
expect(agent.session.events.flatMap(event =>
event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial', 'series'])
})
it('a real system-prompt change is a full changed-header snapshot; a stable new turn reuses it', async () => {
const adapter = new MockAdapter([textResponse('one'), textResponse('two'), textResponse('three')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
@@ -439,8 +566,8 @@ describe('request stability across the loop', () => {
await waitForIdle(ctx, agent)
send(agent, 'second')
await waitForIdle(ctx, agent)
// Identical assembly re-rendered per step is NOT a change.
expect(agent.session.events.filter(e => e.type === 'request/header')).toHaveLength(1)
expect(agent.session.events.flatMap(event =>
event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial'])
ctx.systemPrompt.section({ name: 'extra', order: 2, text: 'new guidance' })
send(agent, 'third')
@@ -556,9 +683,10 @@ describe('request stability across the loop', () => {
send(agent, 'second')
await waitForIdle(ctx, agent)
// No changed snapshot was logged (nothing really changed), and the session's own
// fold is immutable state.
expect(agent.session.events.filter(e => e.type === 'request/header')).toHaveLength(1)
// The second turn reuses the same series and header; the session's own
// fold remains immutable state.
expect(agent.session.events.flatMap(event =>
event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial'])
expect(Object.isFrozen(agent.session.requestHeader())).toBe(true)
expect(adapter.requests[1]!.temperature).toBeUndefined()
})
+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/core/agent/README.md
README.md: 70b396d787de5d95332c379ff20ab92c64065857
README.zh.md: fee72f3cd1fb456ae639d6444fe3fe914c41220a
README.md: b79a1e7270eaf5b50a05059ecbea760c0888bc1e
README.zh.md: aa4b6a471711a94665b171e06da638fc86c7a39c
+1 -1
View File
@@ -52,7 +52,7 @@ The lifecycle edges have two important local caveats. `agent/created` runs after
Most interception points are cooperative waterfalls. `agent/pre-step` receives a payload carrying the subject `agent`, the exclusive claimed `UserMessage[]`, and the proposed `turn`, `step`, and cancellation `signal`; its batch may be empty when tools already require another request. Agent-scoped turn extension points carry their explicit `AbortSignal` in the payload; the remaining turn-scoped extension points receive it through their request value. Listeners may cooperate with a signal but must not retain it as authority over another turn. `agent/request-error` is the failed-model-request recovery waterfall: it receives request coordinates, normalized failure facts, the serving registration's retry policy when available, and the signal. A listener returns `{ kind: 'retry' }` without calling `next()` when it owns recovery. `agent/turn-stopping` runs before an otherwise completed turn closes. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns signal lifetime; the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way) owns scoped dispatch and terminal settlement.
`PreStepDecision` is either `{ kind: 'reject' }` or `{ kind: 'enter', messages }`. The enter branch is the complete identified, frozen batch for the proposed step. A listener that wraps downstream entry preserves that batch unless it intentionally replaces it; additions follow the waterfall's natural return order. Claiming already removed the offered messages from the inbox, so rejection does not retain them. Messages inserted after the claim remain pending for a later boundary.
`PreStepDecision` is either `{ kind: 'reject' }` or `{ kind: 'enter', messages, startsRequestSeries? }`. The enter branch is the complete identified, frozen batch for the proposed step. `startsRequestSeries: true` declares that this admitted batch begins a distinct model-message series; ordinary follow-ups leave it absent. A listener that wraps downstream entry preserves both that declaration and the batch unless it intentionally replaces either one; additions follow the waterfall's natural return order. Claiming already removed the offered messages from the inbox, so rejection does not retain them. Messages inserted after the claim remain pending for a later boundary.
Inbox live notifications are deliberately per-message and minimal: `agent/inbox/inserted { message }`, `agent/inbox/claimed { message, turn }`, and `agent/inbox/discarded { message }`. They complement the durable `agent/inbox/spliced` projection without adding another lifecycle envelope.
+1 -1
View File
@@ -54,7 +54,7 @@ Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供,
大多数拦截点都是协作式 waterfall(瀑布式事件)。`agent/pre-step` 接收一个 payload,携带主体 `agent`、独占的已领取 `UserMessage[]` 以及拟进入的 `turn``step` 与取消 `signal`;当工具已经要求继续请求时,该批次可以为空。agent 作用域轮次扩展点在 payload 中携带显式 `AbortSignal`;其余轮次作用域扩展点通过其请求值接收它。监听器可以配合信号,但不得将它保留为控制另一轮次的权限。`agent/request-error` 是失败模型请求的恢复 waterfall:它接收请求坐标、规范化失败事实、可用时提供服务的注册项重试策略以及信号。拥有恢复权的监听器返回 `{ kind: 'retry' }` 且不调用 `next()``agent/turn-stopping` 在本可完成的轮次关闭前运行。信号生命周期由[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md)拥有;作用域分发与终止结算由 [agent 作用域 runtime 设计 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md#three-execution-boundaries-are-deliberately-one-way)拥有。
`PreStepDecision` 要么是 `{ kind: 'reject' }`,要么是 `{ kind: 'enter', messages }`。enter 分支是拟进入步骤的完整、带标识且冻结的批次。包装下游 enter 的监听器会保留该批次,除非有意替换;新增消息遵循 waterfall 的自然返回顺序。领取操作已经把候选消息从 inbox 删除,因此 reject 不会保留它们;领取后插入的消息仍等待后续边界。
`PreStepDecision` 要么是 `{ kind: 'reject' }`,要么是 `{ kind: 'enter', messages, startsRequestSeries? }`。enter 分支是拟进入步骤的完整、带标识且冻结的批次。`startsRequestSeries: true` 声明该接纳批次会开启一个独立的模型消息序列;普通 follow-up 不设置它。包装下游 enter 的监听器会同时保留该声明和消息批次,除非有意替换其中一项;新增消息遵循 waterfall 的自然返回顺序。领取操作已经把候选消息从 inbox 删除,因此 reject 不会保留它们;领取后插入的消息仍等待后续边界。
inbox 的实时通知刻意采用逐消息的最小载荷:`agent/inbox/inserted { message }``agent/inbox/claimed { message, turn }``agent/inbox/discarded { message }`。它们补充持久 `agent/inbox/spliced` 投影,但不引入另一层生命周期封套。
+6 -1
View File
@@ -55,7 +55,12 @@ export type AgentStatus = 'idle' | 'running'
/** Whether and with which messages the loop enters a proposed step. */
export type PreStepDecision =
| { kind: 'reject' }
| { kind: 'enter'; messages: UserMessage[] }
| {
kind: 'enter'
messages: UserMessage[]
/** Start a distinct model-message series before this step's admitted messages. */
startsRequestSeries?: true
}
/** Action returned by a listener that owns model-request recovery. */
export type RequestErrorAction = { kind: 'retry' } | undefined
+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/core/session/README.md
README.md: 9f0b4023e897f66ec1bcbc22e908ab2bf1c0d2cc
README.zh.md: dcee2380802c6b7e416366a9388256f9b1d02091
README.md: 0e3cdcb1e0135cda4d1ac469a0cbc2c2f44c3d94
README.zh.md: 383777227fa5903c0e7285d31e8d70ee9ebb1eab
+1 -1
View File
@@ -60,7 +60,7 @@ This package owns ordered surface projection, replacement validation, replay, an
### Request-header reconstruction (`request-header.ts`)
`request/header` records a full canonical snapshot of the non-history request envelope with reason `initial`, `resume`, or `change`. Its optional `adapterDefaults` map marks effective `reasoningEffort` or `maxTokens` values materialized by exact-model resolution, allowing the next request proposal to distinguish them from explicit conversation settings. `foldRequestHeader()` selects the latest snapshot; legacy delta events and the removed `fallback` reason are rejected. See the [reconstructable-requests Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md).
`request/header` records a full canonical snapshot of the non-history request envelope with reason `initial`, `resume`, `change`, or `series`. `series` repeats an unchanged envelope when `agent/pre-step` explicitly starts a distinct model-message series or a surface replacement changes the model's message list; when that boundary coincides with an envelope change, the `change` snapshot carries `startsSeries: true` so both facts survive. Ordinary append-only later turns remain in the current series. Same-series steps and retries with an unchanged envelope keep using the latest snapshot. Repeating the complete system prompt and tool catalog grows the log linearly with message series, but keeps every header self-contained for partial-window rendering and exact request reconstruction; a lightweight reference marker would require predecessor availability and a second replay representation. Its optional `adapterDefaults` map marks effective `reasoningEffort` or `maxTokens` values materialized by exact-model resolution, allowing the next request proposal to distinguish them from explicit conversation settings. `foldRequestHeader()` selects the latest snapshot; legacy delta events and the removed `fallback` reason are rejected. See the [reconstructable-requests Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md).
A `user/message` stores the complete `UserMessage` directly, including the identity created before inbox routing or step entry. It renders its `content` verbatim whether it is a direct human prompt, a synthetic injection, or an entered goal round; its typed `source` is the only channel that tells them apart and carries any domain-specific durable facts. `assistant/message` and `tool/result` likewise store complete message values. Turn execution remains enclosed by `turn/start` and `turn/end`; `agent.inject()` queues input until a later pre-step claims it and returns it in an enter decision.
+1 -1
View File
@@ -60,7 +60,7 @@
### 请求头重建(`request-header.ts`
`request/header` 记录非历史请求封装的完整规范快照,其原因为 `initial``resume``change`。其可选 `adapterDefaults` 映射会标记由精确模型解析填入的生效 `reasoningEffort``maxTokens` 值,使下一次请求提议能够将它们与显式对话设置区分开。`foldRequestHeader()` 选择最新快照;旧版增量事件和已移除的 `fallback` 原因会被拒绝。详见[可重建请求 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md)。
`request/header` 记录非历史请求封装的完整规范快照,其原因为 `initial``resume``change``series`。当 `agent/pre-step` 显式开启独立的模型消息序列,或表层替换改变模型消息列表时,`series` 会重复记录内容未变的封装;如果该边界与封装变化同时发生,`change` 快照会携带 `startsSeries: true`,从而同时保留这两个事实。普通的仅追加后续 turn 仍属于当前序列。同一序列内封装未变的 step 和重试继续使用最新快照。重复完整系统提示词和工具目录会使日志随消息序列线性增长,但能让每个 header 自包含,以支持局部窗口渲染和精确请求重建;轻量引用标记则会要求前序始终可用,并引入第二种回放表示。其可选 `adapterDefaults` 映射会标记由精确模型解析填入的生效 `reasoningEffort``maxTokens` 值,使下一次请求提议能够将它们与显式对话设置区分开。`foldRequestHeader()` 选择最新快照;旧版增量事件和已移除的 `fallback` 原因会被拒绝。详见[可重建请求 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md)。
`user/message` 会直接存储完整的 `UserMessage`,其中包括收件箱路由或进入步骤前创建的标识。无论它是直接人类提示词、合成注入,还是已进入的 Goal Round,都会原样呈现其 `content`;带类型的 `source` 是区分三者的唯一通道,并携带各领域专有的持久事实。`assistant/message``tool/result` 也会存储完整的消息值。轮次执行仍由 `turn/start``turn/end` 包围;`agent.inject()` 会把输入排队,直到后续某次 pre-step 领取它,并在 enter 决策中返回它。
+10 -3
View File
@@ -206,9 +206,11 @@ export interface RequestContext {
* Why a `request/header` snapshot was appended: `'initial'` the log's first
* header (a new conversation); `'resume'` a loop instance's first request
* over a log that already has header events (process restart, fork seed);
* `'change'` a later request used a different header.
* `'change'` a later request used a different header, with `startsSeries`
* preserving a coincident series boundary; `'series'` an unchanged header
* began an explicitly distinct message series or followed a surface replacement.
*/
export type RequestHeaderReason = 'initial' | 'resume' | 'change'
export type RequestHeaderReason = 'initial' | 'resume' | 'change' | 'series'
/**
* The merge-extensible, append-only source of truth for an agent interaction.
@@ -286,7 +288,12 @@ export interface SessionEventMap {
* Full header for the next request, appended inside its step before dispatch.
* It is log-only; the latest snapshot reconstructs the request header.
*/
'request/header': { header: EpochHeader; reason: RequestHeaderReason }
'request/header': {
header: EpochHeader
reason: RequestHeaderReason
/** A changed header also begins a distinct model-message series. */
startsSeries?: true
}
/**
* Route metadata for the next request, logged only when the route or capacity
* changes. It does not participate in request reconstruction or header equality.
@@ -287,7 +287,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
'useProjection: UseProjection',
'useTrajectory: UseTrajectory',
],
keyDomain: 'fixed by the owner\'s key table { [Kind in ChatNodeKind]: { node: ChatNode<Kind> } }, already taken: assistant-step, command, command-input, compaction, context, manual-compaction, model-retry, steering, tool-call, turn-error, turn-max-tokens, turn-tail, unknown, user, workflow-run',
keyDomain: 'fixed by the owner\'s key table { [Kind in ChatNodeKind]: { node: ChatNode<Kind> } }, already taken: assistant-step, command, command-input, compaction, context, manual-compaction, model-retry, steering, system-prompt, tool-call, turn-error, turn-max-tokens, turn-tail, unknown, user, workflow-run',
hookContext: 'string',
slotInject: 'ChatNodeTurnDataInjected',
declaredBy: 'an entry in \'conversation.view\' (client-ui-chat), so it exists while that entry is mounted',
@@ -295,6 +295,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
'client-ui-chat UserMessageNodeView key \'user\'',
'client-ui-chat UserMessageNodeView key \'steering\'',
'client-ui-chat ContextMessageNodeView key \'context\'',
'client-ui-chat SystemPromptNodeView key \'system-prompt\'',
'client-ui-chat AssistantNodeView key \'assistant-step\'',
'client-ui-chat CommandNodeView key \'command\'',
'client-ui-chat ManualCompactionNodeView key \'manual-compaction\'',
@@ -4171,7 +4171,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'PreStepDecision',
declaration: 'export type PreStepDecision = {\n kind: \'reject\';\n} | {\n kind: \'enter\';\n messages: UserMessage[];\n};',
declaration: 'export type PreStepDecision = {\n kind: \'reject\';\n} | {\n kind: \'enter\';\n messages: UserMessage[];\n startsRequestSeries?: true;\n};',
},
{
name: 'PreToolDecision',
@@ -4259,7 +4259,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'RequestHeaderReason',
declaration: 'export type RequestHeaderReason = \'initial\' | \'resume\' | \'change\';',
declaration: 'export type RequestHeaderReason = \'initial\' | \'resume\' | \'change\' | \'series\';',
},
{
name: 'RequestImageAttachment',
@@ -4463,7 +4463,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SessionEventMap',
declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': UserMessage;\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n interrupted?: true;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n \'request/header\': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n \'request/context\': RequestContext;\n \'session/end-seed\': Record<string, never>;\n}',
declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': UserMessage;\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n interrupted?: true;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n \'request/header\': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n startsSeries?: true;\n };\n \'request/context\': RequestContext;\n \'session/end-seed\': Record<string, never>;\n}',
},
{
name: 'SessionEventMetadataFilter',
+1 -1
View File
@@ -398,7 +398,7 @@ export function apply(ctx: Context): void {
source: { kind: 'plugin', plugin: name, form: 'instructions' },
})
})
return { kind: 'enter', messages: [...decision.messages, ...contexts] }
return { ...decision, messages: [...decision.messages, ...contexts] }
})
}
@@ -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/goal/goal-round-driver/README.md
README.md: 34b11714b8ccf574549567f33b80204f3c0dde6a
README.zh.md: be41c12258214aa8aa5323c73ec144642fb121ff
README.md: b11fba9beaa50edf2627dc62b5910f3802efb10d
README.zh.md: edbf46d344a8b3d6ffdaac36d5e58a64ed305ac0
+1 -1
View File
@@ -21,7 +21,7 @@ The plugin has no tunable configuration. `maxGoalRounds` belongs to the goal def
## Round contract
When an exact live agent is idle with an active, armed goal and remaining capacity, the driver first checkpoints pending goal mutations, then reserves `roundsStarted + 1` for the current `{ goalId, revision }`. It queues one `<goal_round>` prompt with `GoalMessageSource`. The `agent/pre-step` listener verifies the complete claimed record and current goal both before and after downstream listeners; only an entered `user/message` increments `roundsStarted`. A reservation rejected as stale does not consume the round number.
When an exact live agent is idle with an active, armed goal and remaining capacity, the driver first checkpoints pending goal mutations, then reserves `roundsStarted + 1` for the current `{ goalId, revision }`. It queues one `<goal_round>` prompt with `GoalMessageSource`. The `agent/pre-step` listener verifies the complete claimed record and current goal both before and after downstream listeners; an accepted round sets `startsRequestSeries: true`, so that boundary is logged as `series` for an unchanged header or `startsSeries: true` on a coincident `change`. Chat renders the header before the round message to match the provider envelope order. Only an entered `user/message` increments `roundsStarted`. A reservation rejected as stale does not consume the round number.
`MessageId` identifies the reserved message through durable inbox insertion and claim; it does not identify a turn result. Human messages do not consume the goal cap. If human work enters the inbox before a reservation or joins its pending batch, automatic work yields until the agent becomes idle; a pending automatic prompt in a mixed batch is rejected and re-reserved only after that checkpoint.
+1 -1
View File
@@ -21,7 +21,7 @@
## Round 约定
当对应的活跃 agent(智能体)实例处于 idle 状态,且目标 phase 为 active、已启用续行并有剩余容量时,驱动器先为待处理 goal 变更创建检查点,再预留 `roundsStarted + 1`,对应当前 `{ goalId, revision }`。它会排入一条 `<goal_round>` 提示词,并携带 `GoalMessageSource``agent/pre-step` 监听器会在下游监听器前后验证完整的已领取记录与当前 goal;只有进入步骤的 `user/message` 才会增加 `roundsStarted`。因陈旧而被拒绝的预留不会消耗 Round 编号。
当对应的活跃 agent(智能体)实例处于 idle 状态,且目标 phase 为 active、已启用续行并有剩余容量时,驱动器先为待处理 goal 变更创建检查点,再预留 `roundsStarted + 1`,对应当前 `{ goalId, revision }`。它会排入一条 `<goal_round>` 提示词,并携带 `GoalMessageSource``agent/pre-step` 监听器会在下游监听器前后验证完整的已领取记录与当前 goal;接纳的 Round 会设置 `startsRequestSeries: true`,因此未变化的 header 以 `series` 记录该边界,而同时发生的 `change` 则携带 `startsSeries: true`。Chat 会把该 header 渲染在 Round 消息之前,以匹配提供方信封顺序。只有进入步骤的 `user/message` 才会增加 `roundsStarted`。因陈旧而被拒绝的预留不会消耗 Round 编号。
`MessageId` 通过持久 inbox 插入和领取来标识预留消息;它不标识轮次结果。人类消息不消耗 goal 上限。如果人类工作在预留前进入 inbox,或加入预留的待处理批次,自动工作会让行,直到 agent 进入 idle;混合批次中的待处理自动提示词会被拒绝,只有在该检查点之后才重新预留。
+1 -1
View File
@@ -410,7 +410,7 @@ export function apply(ctx: Context): void {
requestDrive(state)
return { kind: 'reject' }
}
return decision
return { ...decision, startsRequestSeries: true }
})
// Loading a lifecycle driver over existing agents never inherits hidden
@@ -207,6 +207,8 @@ describe('same-session goal driving', () => {
expect(rounds).toEqual([1, 2])
expect(requestText(test.adapter.requests[0]!)).toContain('Round: 1/2')
expect(requestText(test.adapter.requests[1]!)).toContain('Round: 2/2')
expect(test.agent.session.events.flatMap(event =>
event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial', 'series'])
})
it('never adopts activation from an already-live driver and waits for explicit resume', async () => {
@@ -325,6 +327,8 @@ describe('same-session goal driving', () => {
expect(requestText(test.adapter.requests[0]!)).toContain('human goes first')
expect(requestText(test.adapter.requests[0]!)).not.toContain('<goal_round>')
expect(requestText(test.adapter.requests[1]!)).toContain('<goal_round>')
expect(test.agent.session.events.flatMap(event =>
event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial', 'series'])
})
it('makes a reserved round stale when a listener queues human work behind it', async () => {
@@ -229,7 +229,7 @@ export function apply(ctx: Context, config: Config): void {
const ours = contextFrom(merged)
if (!ours || downstream.kind !== 'enter') return downstream
return {
kind: 'enter',
...downstream,
messages: [...downstream.messages, ours],
}
})
+1 -1
View File
@@ -216,7 +216,7 @@ export function apply(ctx: Context, config: Config): void {
const ours = contextFrom(merged)
if (!ours || downstream.kind !== 'enter') return downstream
return {
kind: 'enter',
...downstream,
messages: [...downstream.messages, ours],
}
})
+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/host/apiproxy/README.md
README.md: 69826d76b437ea482655d91a930310185079414c
README.zh.md: 26d132a9a8e48c0833e6145b139226d765cb9709
README.md: c96532a673d6b2e53ff1d55cf8a3ae4f7aac756f
README.zh.md: 4f5747a8fdcb96208ca454ddd8df603de094af71
+1 -1
View File
@@ -8,7 +8,7 @@ The API gateway shared by every client consists of the TypeScript API contract (
`ApiProxyService` consumes `ctx.agentDefaultModel`; it does not own a provider/model config or settings section. The shared service registers `{provider, model, reasoningEffort?}` under `agent-default-model`: the base bundle's composition entry is the lower layer and `settings.yaml` layers the user's choice over it.
A session resolves its model selection from three tiers on every access: a selection made in this process, otherwise the session's latest logged `request/header`, otherwise this default. A session that has run a turn derives its selection from its log, while a blank session observes a default saved after it was created.
A session resolves its model selection from three tiers on every access: a selection made in this process, otherwise the session's latest logged `request/header`, otherwise this default. A session that has run a turn derives its selection from its log, while a blank session observes a default saved after it was created. A logged reasoning effort marked as an adapter default remains absent from the restored selection, so the next model resolution does not promote that default into an explicit choice or record a false header change.
`session.selectModel` saves an accepted switch as the deployment default; there is no separate gesture. It stores the resolved `ModelSelection`, including an adapter-materialized default effort. The complete-section write clears a stored effort when the selected model has none. A storage failure is logged without undoing the session selection. A deployment with no settings provider keeps the composition entry and the switch remains session-local.
+1 -1
View File
@@ -8,7 +8,7 @@
`ApiProxyService` 消费 `ctx.agentDefaultModel`;它不持有提供方/模型配置或 Settings 分节。共享服务在 `agent-default-model` 下注册 `{provider, model, reasoningEffort?}`base 组合包的组合条目是底层,`settings.yaml` 把用户选择叠加其上。
会话每次访问时都按三级解析模型选择:本进程内作出的选择,其次是该会话日志中最新的 `request/header`,最后是这个默认值。已经跑过一轮的会话从自己的日志推导选择,空白会话则能观察到创建之后保存的默认值。
会话每次访问时都按三级解析模型选择:本进程内作出的选择,其次是该会话日志中最新的 `request/header`,最后是这个默认值。已经跑过一轮的会话从自己的日志推导选择,空白会话则能观察到创建之后保存的默认值。若日志中的推理强度被标记为适配器默认值,恢复的选择仍不包含该强度,因此下一次模型解析不会把这个默认值提升为显式选择,也不会记录虚假的 header 变更。
`session.selectModel` 会把接受的切换保存为部署默认值;没有单独的选择动作。它存储已解析的 `ModelSelection`,包括适配器实体化的默认推理(reasoning)强度。完整分节写入会在所选模型没有推理强度时清除已存值。存储失败只记日志,不会撤销会话选择。没有设置提供方的部署保留组合条目,切换只对当前会话生效。

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