From 6ad01cbd8dec96add20b832f5482a99e20a2fbe5 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 3 Sep 2026 14:45:01 +0800 Subject: [PATCH 1/7] fix(web): avoid repeated system prompt on resume --- ...lient-conversation-node-assembly.i18n.yaml | 4 +-- ...08-09-client-conversation-node-assembly.md | 4 +-- ...09-client-conversation-node-assembly.zh.md | 4 +-- ...ers-do-not-repeat-system-prompts.i18n.yaml | 6 ++++ ...me-headers-do-not-repeat-system-prompts.md | 31 +++++++++++++++++++ ...headers-do-not-repeat-system-prompts.zh.md | 31 +++++++++++++++++++ apps/web/tests/message-actions.e2e.ts | 19 ++++++++++-- packages/client/ui-chat/README.i18n.yaml | 4 +-- packages/client/ui-chat/README.md | 2 +- packages/client/ui-chat/README.zh.md | 2 +- .../conversation-nodes/request-prompt.ts | 15 +++++++-- ...nversation-node-definitions.client.spec.ts | 21 +++++++------ 12 files changed, 117 insertions(+), 26 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.md create mode 100644 .agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.zh.md diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml index 23f634a3fe..6fd9783b6f 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md -2026-08-09-client-conversation-node-assembly.md: 0aac5056e2cbe22359f8064b1bf4aa0b015a35c8 -2026-08-09-client-conversation-node-assembly.zh.md: b06a92113f91e6297da986866dce097b11bab45f +2026-08-09-client-conversation-node-assembly.md: b8af1f96892afcbe88d6bc125283e16aeee87e18 +2026-08-09-client-conversation-node-assembly.zh.md: 466a4d1f2f5589ee86b1289aeb6174837f9ef20b diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md index 0aac5056e2..b8af1f9689 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md @@ -278,7 +278,7 @@ Page size, record packing, the number of history loads, and RAF coalescing affec |---|---|---|---| | Inbox | `none` | No Node | Recompute next-step ID state along the Reader chain when prepend supplies earlier splices; next-turn creates no Chat Context | | Message | Immediate by default | `user`, `steering`, or `context` | Window-gap repair can reclassify the same message key | -| Request Prompt | Immediate by default | One `system-prompt` for every header carrying a non-empty system field | A step's first header anchors before its request messages; a later same-step series anchors after its surface rewrite; prepend of the preceding header can correct a partial-window anchor | +| Request Prompt | Immediate by default | One non-empty `system-prompt` for the initial request, each explicit series, or a real system change | A step's first header anchors before its request messages; prepend can hide a conservatively rendered resume after its preceding header proves the system unchanged | | Assistant | RAF for scalar chunks and packed runs, immediate for final, none for pure usage/finish | Same-key `assistant-step` with running/settled/interrupted status | Scalar and packed reducers are equivalent; Matches support fallback without `step/start`; Location close produces interruption presentation | | Tool | Immediate by default | One recursive `tool-call` root containing all `subCalls` | A result-only history window supports fallback; running→settled retains its key | | Command | Immediate by default | Ordinary `command` or integrated `manual-compaction` | Checkpoint arrival may change the anchor without changing the Context key | @@ -291,7 +291,7 @@ Page size, record packing, the number of history loads, and RAF coalescing affec Inbox demonstrates that every Event can be a start-only instantaneous-state Context; not every business requires a start/update pair. Reader links each next-step state to the prior same-kind Context instead of inventing a lifecycle ID for the entire Inbox. The state itself shares immutable pending splice nodes and one current claimed-batch Set, while unconsumed next-turn input remains outside Conversation because no Chat or Trajectory classification reads it. -Request Prompt demonstrates shared pure interpretation without shared target State: Chat and Trajectory call `inspectRequestPrompt()` from their own Definitions. The function canonicalizes the full header and classifies model-visible system/tool differences; each target then chooses its own output. Chat materializes every header carrying a non-empty system field, including `series` snapshots that repeat an unchanged header for an explicitly declared series or a post-replacement request, while Trajectory retains the complete request fact and its change classification. Ordinary append-only later Turns do not write another unchanged header. The first header in a Step follows the provider envelope rather than the header Event position: step one uses the owning Turn start and later steps use their Step start, placing the system field before the request's user-role messages; a later header in the same Step stays at its own Event after the surface rewrite that began the new series. When the preceding header is outside a partial window, a non-`initial` header stays at its own Event until prepend supplies that predecessor. Every header is a full snapshot, so a first loaded `resume`, `change`, or `series` header can render its system field without fabricating a comparison to unloaded history. +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 a non-empty initial system field, a real system change, and each `series` snapshot that explicitly begins a message series or follows a surface replacement. An unchanged `resume` remains in Trajectory and reconstruction state but does not repeat the visible Chat row once its predecessor is loaded. 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 and renders conservatively. Prepending an identical predecessor hides an unchanged resume without withdrawing its stable Node key; a real change remains visible. 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 ([resume presentation decision](../bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.md)). 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. diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md index b06a92113f..466a4d1f2f 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md @@ -278,7 +278,7 @@ Chat `order` 的结构性变化仍可能重排当前可见 key;纯 data 更新 |---|---|---|---| | Inbox | `none` | 不生成 Node | prepend 补前序 splice 时沿 Reader 链重算 next-step ID state;next-turn 不创建 Chat Context | | Message | 默认 immediate | `user`、`steering` 或 `context` | window gap 修复可让同一 message key 重新分类 | -| Request Prompt | 默认 immediate | 每条带非空 system 字段的 header 都生成一个 `system-prompt` | Step 首条 header 锚定在请求消息之前;同 step 后续序列锚定在表层改写之后;prepend 补入前序 header 后可纠正部分窗口的锚点 | +| Request Prompt | 默认 immediate | 初始请求、每个显式序列或真实 system 变化各生成一个非空 `system-prompt` | Step 首条 header 锚定在请求消息之前;prepend 补入前序 header 并证明 system 未变后,可隐藏此前保守渲染的 resume | | Assistant | scalar chunk 与 packed run 为 RAF,final immediate,纯 usage/finish 为 none | 同 key `assistant-step`,状态为 running/settled/interrupted | scalar 与 packed reducer 等价;缺 `step/start` 可先用 Matches fallback;Location close 生成中断表现 | | Tool | 默认 immediate | 一个递归 `tool-call` root,包含全部 `subCalls` | result-only 历史窗口可 fallback;running→settled 保持 key | | Command | 默认 immediate | 普通 `command` 或集成 `manual-compaction` | checkpoint 到达可改变 anchor,但不改变 Context key | @@ -291,7 +291,7 @@ Chat `order` 的结构性变化仍可能重排当前可见 key;纯 data 更新 Inbox 展示了“每条 Event 都是一个 start-only 瞬间态 Context”,不是所有业务都需要 start/update 配对。每个 next-step state 通过 Reader 与前一个同 kind Context 形成连续 fold,而非给整个 Inbox 人工制造生命周期 ID。state 自身共享不可变 pending splice 节点和一个当前 claimed-batch Set;未消费的 next-turn input 不进入 Conversation,因为 Chat 与 Trajectory 都不读取它来分类。 -Request Prompt 展示了如何在不共享 target State 的前提下共用纯解释逻辑:Chat 与 Trajectory 各自在自己的 Definition 中调用 `inspectRequestPrompt()`。该函数规范化完整 header,并判定面向模型的 system/tool 差异;随后每个 target 自行选择产物。Chat 会物化每条带非空 system 字段的 header,包括为显式声明的序列或表层替换后的请求重复未变 header 的 `series` 快照;Trajectory 则保留完整请求事实及其变化分类。普通的仅追加后续 Turn 不会再次写入未变 header。一个 Step 中的首条 header 遵循提供方信封,而不是 header Event 位置:step one 使用所属 Turn start,后续 step 使用各自的 Step start,把 system 字段放到该请求的 user-role 消息之前;同一 Step 的后续 header 保留在开启新序列的表层改写之后。部分窗口未包含前序 header 时,非 `initial` header 会保留在自身 Event,直到 prepend 补入该前序 header。每条 header 都是完整快照,因此已加载窗口中的首条 `resume`、`change` 或 `series` header 无需凭空构造与未加载历史的比较,也能渲染其 system 字段。 +Request Prompt 展示了如何在不共享 target State 的前提下共用纯解释逻辑:Chat 与 Trajectory 各自在自己的 Definition 中调用 `inspectRequestPrompt()`。该函数规范化完整 header,并判定面向模型的 system/tool 差异;随后每个 target 自行选择产物。Chat 会物化非空的初始 system 字段、真实 system 变化,以及每个显式开启消息序列或紧随表层替换的 `series` 快照。未变化的 `resume` 会留在 Trajectory 和重建状态中,但前序 header 已加载时不会重复可见的 Chat 行。普通的仅追加后续 Turn 不会再次写入未变 header。一个 Step 中的首条 header 遵循提供方信封,而不是 header Event 位置:step one 使用所属 Turn start,后续 step 使用各自的 Step start,把 system 字段放到该请求的 user-role 消息之前;同一 Step 的后续 header 保留在开启新序列的表层改写之后。部分窗口未包含前序 header 时,非 `initial` header 会保留在自身 Event 并保守渲染。prepend 补入相同的前序 header 后,内容未变的 resume 会隐藏但不撤回其稳定 Node key;真实变化仍然可见。每条 header 都是完整快照,因此已加载窗口中的首条 `resume`、`change` 或 `series` header 无需凭空构造与未加载历史的比较,也能渲染其 system 字段([resume 展示决策](../bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.zh.md))。 Retry、Assistant 和 Turn Tail 展示了同一 Event 被多个 Definition 独立认领。每个 Definition 只更新自己的 State,最终分别生成原子 Chat Node。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.i18n.yaml new file mode 100644 index 0000000000..cb85b7af4a --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.i18n.yaml @@ -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/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.md +2026-09-03-resume-headers-do-not-repeat-system-prompts.md: 63acf56998276cdaff400186b7c33761eaf63788 +2026-09-03-resume-headers-do-not-repeat-system-prompts.zh.md: 9c09148f74042b70ef03fb92f1d3a2986a055da9 diff --git a/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.md b/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.md new file mode 100644 index 0000000000..63acf56998 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.md @@ -0,0 +1,31 @@ +# Agent Note: Resume headers do not repeat system prompts + +Status: implemented + +English | [中文](2026-09-03-resume-headers-do-not-repeat-system-prompts.zh.md) + +## Problem + +Forking a Session copies the source history into the child. The child's first model request then records a `request/header` with reason `resume`, even when its system field is identical to the preceding copied header. Chat treated every resume header as a new display point, so continuing the fork showed a second `System prompt` row and suggested that the system prompt had been injected twice. The provider request still carried the system field once; the duplicate existed only in Chat presentation. + +## Decision + +The durable resume header remains unchanged because it records the request boundary needed for exact Session reconstruction. Chat now compares that full header with the preceding loaded Request Prompt and displays a non-empty system prompt only for the initial request, an explicit message-series start, or a real system-field change. An unchanged resume does not create a visible repetition. + +A partial history window may begin with a non-initial header and lack the predecessor needed for comparison. Chat renders that system prompt conservatively. If prepend later supplies an identical predecessor, the existing request-prompt Node becomes hidden instead of being withdrawn; its key and page-lifetime anchor stay stable. A different system field remains visible. + +Trajectory continues to expose every request header and its classified changes. The change is limited to Chat presentation and does not alter provider requests, Session events, or reconstruction. + +## Alternatives considered + +**Omit unchanged resume headers from the Session log.** Rejected: resume is a real request boundary, and removing it would make exact reconstruction depend on process history that the durable log does not contain. + +**Special-case only forked Sessions.** Rejected: an ordinary process resume has the same presentation semantics, and the request headers already contain the system fields needed for a direct comparison. + +**Keep the duplicate row as a lifecycle marker.** Rejected: `System prompt` describes model-visible request content, so using it to mark a loop restart incorrectly implies another prompt injection. Request lifecycle evidence remains available in Trajectory. + +## Consequences + +Continuing a fork or resuming a process with an unchanged system field leaves one visible `System prompt` row for the current message series. Explicit series starts and real system changes still repeat the row. A partial window can initially show a conservative row and hide it after older history loads, while retaining the same materialized Node. + +The unit regression covers initial, series, unchanged resume, system-change, and prepend cases. The Web recorded-session scenario contains an unchanged resume header and asserts that the settled Chat renders exactly one `System prompt` control. diff --git a/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.zh.md b/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.zh.md new file mode 100644 index 0000000000..9c09148f74 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.zh.md @@ -0,0 +1,31 @@ +# Agent Note: Resume header 不重复系统提示词 + +Status: implemented + +[English](2026-09-03-resume-headers-do-not-repeat-system-prompts.md) | 中文 + +## 问题 + +fork Session 会把源会话历史复制到子会话。即使 system 字段与前一条被复制的 header 完全相同,子会话的第一个模型请求仍会记录一条 reason 为 `resume` 的 `request/header`。Chat 把每条 resume header 都视作新的展示点,因此继续 fork 会显示第二行`系统提示词`,让人误以为系统提示词被注入了两次。提供方请求实际仍只携带一次 system 字段;重复仅存在于 Chat 展示中。 + +## 决策 + +持久化 resume header 保持不变,因为它记录了精确重建 Session 所需的请求边界。Chat 现在会把完整 header 与前一条已加载 Request Prompt 比较,只在初始请求、显式消息序列起点或真实 system 字段变化时显示非空系统提示词。内容未变的 resume 不创建可见的重复行。 + +部分历史窗口可能以非初始 header 开头,因缺少前序 header 而无法比较。Chat 会保守渲染该系统提示词。如果 prepend 随后补入相同的前序 header,既有 request-prompt Node 会转为隐藏而不是被撤回;其 key 和页面生命周期内的 anchor 保持稳定。不同的 system 字段仍然可见。 + +Trajectory 会继续展示每一条请求 header 及其变化分类。本次改动仅限 Chat 展示,不改变提供方请求、Session event 或重建行为。 + +## 考虑过的替代方案 + +**从 Session log 省略未变化的 resume header。** 否决:resume 是真实的请求边界,移除后精确重建将依赖持久日志未记录的进程历史。 + +**只对 fork Session 做特殊处理。** 否决:普通进程恢复具有相同的展示语义,请求 header 已经包含可直接比较的 system 字段。 + +**把重复行保留为生命周期标记。** 否决:`系统提示词`描述模型可见的请求内容,用它标记 loop 重启会错误暗示再次注入提示词。请求生命周期证据仍可在 Trajectory 中查看。 + +## 后果 + +继续 fork 或在 system 字段未变时恢复进程,当前消息序列只保留一行可见的`系统提示词`。显式序列起点与真实 system 变化仍会重复该行。部分窗口起初可以显示保守行,并在更早历史加载后将其隐藏,同时保留同一个已物化 Node。 + +单元回归覆盖初始请求、显式序列、未变化 resume、system 变化与 prepend 场景。Web 录制会话场景包含一条未变化的 resume header,并断言稳定后的 Chat 只渲染一个`系统提示词`控件。 diff --git a/apps/web/tests/message-actions.e2e.ts b/apps/web/tests/message-actions.e2e.ts index c29d9cf977..629ed8dc8a 100644 --- a/apps/web/tests/message-actions.e2e.ts +++ b/apps/web/tests/message-actions.e2e.ts @@ -1,7 +1,8 @@ // Web e2e scenario: message IconActions + clocks. Cold-seeds a deterministic -// completed-turn-tail fork case (zero model calls) and pins the settled -// conversation aria after the footers are focus-revealed — the surface package -// jsdom tests cannot substitute for (docs/testing.md snapshot rule). +// completed-turn-tail fork case with an unchanged resume header (zero model +// calls) and pins the settled conversation aria after the footers are +// focus-revealed — the surface package jsdom tests cannot substitute for +// (docs/testing.md snapshot rule). import { mkdir, readFile, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -55,6 +56,10 @@ function completedTailFixture(raw: string): string { } return event }) + const inheritedHeader = kept.findLast(event => event.type === 'request/header') + if (inheritedHeader?.type !== 'request/header') { + throw new Error('borrowed recording has no request header') + } let seq = kept.length let time = (kept.at(-1)?.time ?? -1) + 1 const at = (event: Record): { seq: number; time: number } & Record => ({ @@ -68,6 +73,7 @@ function completedTailFixture(raw: string): string { at({ type: 'turn/start', data: { turn: 2 } }), at({ type: 'user/message', data: { id: '00000000-0000-4000-9000-000000000201', role: 'user', content: [{ type: 'text', text: SECOND_PROMPT }], source: { kind: 'user', rpcId: '{{rpcId}}' } }, surfaceOp: 'append' }), at({ type: 'step/start', data: { turn: 2, step: 1 } }), + at({ type: 'request/header', data: { header: inheritedHeader.data.header, reason: 'resume' } }), at({ type: 'assistant/message', data: { turn: 2, step: 1, message: { id: '00000000-0000-4000-9000-000000000202', role: 'assistant', content: [{ type: 'text', text: 'DONE' }], source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' } } }, sourceEventSeqs: [], surfaceOp: 'append' }), at({ type: 'step/end', data: { turn: 2, step: 1 } }), at({ type: 'turn/end', data: { turn: 2, reason: { kind: 'completed' } } }), @@ -89,6 +95,9 @@ describe('web e2e: message IconActions and clocks on settled history', () => { await writeFile(join(sessionCwd, 'b.txt'), 'beta\n') const raw = completedTailFixture(await readFile(SEED, 'utf8')) expect(fixtureUserPrompts(raw), 'adapted seed must carry both prompts').toEqual([PROMPT, SECOND_PROMPT]) + expect(parseSeedFixture(raw).events.flatMap(event => event.type === 'request/header' + ? [event.data.reason] + : []), 'adapted seed must carry an unchanged resume header').toEqual(['initial', 'resume']) await seedSession(scaffold, raw, SEED_ID) browser = await chromium.launch() page = await newEnglishPage(browser) @@ -112,6 +121,10 @@ describe('web e2e: message IconActions and clocks on settled history', () => { await sessionRow.click() await expect.poll(() => page.getByText(MID_TURN_TEXT, { exact: true }).count(), { timeout: 15_000 }).toBe(1) await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBe(1) + await expect.poll( + () => page.getByRole('button', { name: 'System prompt', exact: true }).count(), + { timeout: 10_000 }, + ).toBe(1) // Focus-reveal the footers (hover:hover keeps them opacity-hidden until // hover/focus-within). Branch renders only under assistant answers — user diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 8183f95981..c086da700b 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-chat/README.md -README.md: 79214bf2feb8384cecbb152aa31af162dc6dfdde -README.zh.md: 674959e1974c74abba75f179eff50fe8488458fc +README.md: 5a557f2a2125bddfe18631359665d6fa5e998458 +README.zh.md: 2fc241ad71e35eb54afcf4dde38c361248848d5f diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 79214bf2fe..5a557f2a21 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -25,7 +25,7 @@ The browser Chat target for Conversation assembly. It registers Chat event defin ## System prompt row -Chat shows a collapsed `System prompt` row for each non-empty initial or resumed request, explicit message-series start, or real system-field change. It does not repeat the row for same-series config-only or tool-only changes, tool steps, or retries. The row appears before that request's user messages, matching the provider envelope, and expands to the exact model-visible text with its original line breaks. A partial history window renders a non-initial header conservatively until the preceding page arrives; a header without a system prompt creates no row. +Chat shows a collapsed `System prompt` row for a non-empty initial request, explicit message-series start, real system-field change, or non-initial request whose preceding header is outside the loaded history window. Once that predecessor is available, an unchanged resume does not repeat the row; same-series config-only or tool-only changes, tool steps, and retries also create no repetition. The row appears before that request's user messages, matching the provider envelope, and expands to the exact model-visible text with its original line breaks. A header without a system prompt creates no row. ----- diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 674959e197..2fc241ad71 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -25,7 +25,7 @@ Conversation 组装的浏览器 Chat target。本包注册 Chat event definition ## 系统提示词行 -Chat 会为每个非空的初始或恢复请求、显式消息序列起点或真实 system 字段变化显示一行默认折叠的`系统提示词`。同一序列内仅配置或仅工具变化、工具步骤与重试不会重复该行。该行位于请求的用户消息之前,与提供方 envelope 顺序一致;展开后显示保留原始换行的精确模型可见文本。历史窗口不完整时,非初始 header 会保守显示,直到前一页到达;没有系统提示词的 header 不创建该行。 +Chat 会为非空的初始请求、显式消息序列起点、真实 system 字段变化,或前序 header 尚未进入已加载历史窗口的非初始请求显示一行默认折叠的`系统提示词`。前序 header 到达后,内容未变的 resume 不会重复该行;同一序列内仅配置或仅工具变化、工具步骤与重试也不会重复。该行位于请求的用户消息之前,与提供方 envelope 顺序一致;展开后显示保留原始换行的精确模型可见文本。没有系统提示词的 header 不创建该行。 ----- diff --git a/packages/client/ui-chat/src/client/conversation-nodes/request-prompt.ts b/packages/client/ui-chat/src/client/conversation-nodes/request-prompt.ts index 2cf518924b..e41c3ec334 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/request-prompt.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/request-prompt.ts @@ -78,7 +78,7 @@ export function requestPromptDefinition(inspect: RequestPromptInspector): Conver match.event.data.reason === 'initial', ), showsPrompt: previous === undefined - || match.event.data.reason !== 'change' + || match.event.data.reason === 'series' || match.event.data.startsSeries === true || change === 'system' || change === 'system-and-tools', @@ -89,8 +89,17 @@ export function requestPromptDefinition(inspect: RequestPromptInspector): Conver 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 }) + if (state === undefined) return null + const current = context.current.get('chat') as ChatNode | null | undefined + const visible = state.showsPrompt && state.prompt.system !== '' + if (!visible && current?.kind !== 'system-prompt') return null + return chatNode( + context, + 'system-prompt', + state.anchorSeq, + { text: state.prompt.system }, + { visibility: visible ? 'visible' : 'hidden' }, + ) }, } } diff --git a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts index eeb430ed0a..b8303a3a08 100644 --- a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts +++ b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts @@ -1327,7 +1327,7 @@ describe('built-in conversation node Definitions', () => { }) }) - it('materializes series starts and system changes but not same-series config or tool changes', () => { + it('materializes series starts and system changes but not unchanged resumes, 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([ @@ -1383,7 +1383,6 @@ describe('built-in conversation node Definitions', () => { 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' } }, ]) @@ -1405,18 +1404,20 @@ describe('built-in conversation node Definitions', () => { windowed.prepend([ at(5, 'request/header', { reason: 'initial', - header: { config: { provider: 'fake', model: 'fake' }, system: '# Original prompt' }, + header: { config: { provider: 'fake', model: 'fake' }, system: '# Resumed 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' }, + const restoredPrompts = restored.nodes.values() + .filter(candidate => candidate.kind === 'system-prompt') + expect(restoredPrompts.map(prompt => ({ + anchorSeq: prompt.anchorSeq, + visibility: prompt.visibility, + data: prompt.data, + })).sort((left, right) => left.anchorSeq - right.anchorSeq)).toEqual([ + { anchorSeq: 5, visibility: 'visible', data: { text: '# Resumed prompt' } }, + { anchorSeq: 10, visibility: 'hidden', data: { text: '# Resumed prompt' } }, ]) }) From ed8cdafe3f75fc1c7fc8593e9cadce63513b2bb0 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 3 Sep 2026 14:56:40 +0800 Subject: [PATCH 2/7] test(web): update resumed conversation golden --- .../web/subagent-conversation/ui-expanded.expected.md | 7 +------ snapshots/web/subagent-conversation/ui.expected.md | 7 +------ 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/snapshots/web/subagent-conversation/ui-expanded.expected.md b/snapshots/web/subagent-conversation/ui-expanded.expected.md index 2cbe5b1fa8..ca781fae9d 100644 --- a/snapshots/web/subagent-conversation/ui-expanded.expected.md +++ b/snapshots/web/subagent-conversation/ui-expanded.expected.md @@ -47,12 +47,7 @@ - button "Ran for {{duration}}": - img - text: Ran for {{duration}} -- text: {{clock}} -- button "System prompt": - - img - - img - - text: System prompt -- text: Now give the same explanation to a human reader. {{clock}} +- text: {{clock}} Now give the same explanation to a human reader. {{clock}} - button "Copy": - img - button "Thought for a while" [expanded]: diff --git a/snapshots/web/subagent-conversation/ui.expected.md b/snapshots/web/subagent-conversation/ui.expected.md index 38b587e257..1515150d46 100644 --- a/snapshots/web/subagent-conversation/ui.expected.md +++ b/snapshots/web/subagent-conversation/ui.expected.md @@ -39,12 +39,7 @@ - button "Ran for {{duration}}": - img - text: Ran for {{duration}} -- text: {{clock}} -- button "System prompt": - - img - - img - - text: System prompt -- text: Now give the same explanation to a human reader. {{clock}} +- text: {{clock}} Now give the same explanation to a human reader. {{clock}} - button "Copy": - img - button "Thought for a while": From 477ff9d5e3bdcde581712dab95ac57f3938f3e58 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 3 Sep 2026 16:13:45 +0800 Subject: [PATCH 3/7] fix(agent-loop): preserve resumed series boundaries --- ...ers-do-not-repeat-system-prompts.i18n.yaml | 4 +-- ...me-headers-do-not-repeat-system-prompts.md | 8 ++--- ...headers-do-not-repeat-system-prompts.zh.md | 8 ++--- docs/architecture.i18n.yaml | 4 +-- docs/architecture.md | 2 +- docs/architecture.zh.md | 2 +- docs/persistence-catalog.i18n.yaml | 4 +-- docs/persistence-catalog.md | 2 +- docs/persistence-catalog.zh.md | 2 +- docs/subsystems/session.i18n.yaml | 4 +-- docs/subsystems/session.md | 4 +-- docs/subsystems/session.zh.md | 4 +-- ...nversation-node-definitions.client.spec.ts | 12 ++++++- packages/core/agent-loop/README.i18n.yaml | 4 +-- packages/core/agent-loop/README.md | 2 +- packages/core/agent-loop/README.zh.md | 2 +- packages/core/agent-loop/src/agent.ts | 7 +++- .../tests/request-reconstruction.spec.ts | 32 +++++++++++++++++++ packages/core/session/README.i18n.yaml | 4 +-- packages/core/session/README.md | 2 +- packages/core/session/README.zh.md | 2 +- packages/core/session/src/types.ts | 12 +++---- 22 files changed, 87 insertions(+), 40 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.i18n.yaml index cb85b7af4a..85ce1c4e1b 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.md -2026-09-03-resume-headers-do-not-repeat-system-prompts.md: 63acf56998276cdaff400186b7c33761eaf63788 -2026-09-03-resume-headers-do-not-repeat-system-prompts.zh.md: 9c09148f74042b70ef03fb92f1d3a2986a055da9 +2026-09-03-resume-headers-do-not-repeat-system-prompts.md: 67e0048051e7842ef629868cb5c006f3b52dcd26 +2026-09-03-resume-headers-do-not-repeat-system-prompts.zh.md: a6d3b7ff628ed23c614a728f8e231ee8ed8e1bce diff --git a/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.md b/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.md index 63acf56998..67e0048051 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.md +++ b/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.md @@ -10,11 +10,11 @@ Forking a Session copies the source history into the child. The child's first mo ## Decision -The durable resume header remains unchanged because it records the request boundary needed for exact Session reconstruction. Chat now compares that full header with the preceding loaded Request Prompt and displays a non-empty system prompt only for the initial request, an explicit message-series start, or a real system-field change. An unchanged resume does not create a visible repetition. +The durable resume header records the request boundary needed for exact Session reconstruction. When the first admitted request of a resumed loop explicitly begins a distinct message series, the loop preserves that fact as `startsSeries: true` on the resume snapshot. Chat compares the full header with the preceding loaded Request Prompt and displays a non-empty system prompt only for the initial request, an explicit message-series start, or a real system-field change. An unchanged ordinary resume does not create a visible repetition. A partial history window may begin with a non-initial header and lack the predecessor needed for comparison. Chat renders that system prompt conservatively. If prepend later supplies an identical predecessor, the existing request-prompt Node becomes hidden instead of being withdrawn; its key and page-lifetime anchor stay stable. A different system field remains visible. -Trajectory continues to expose every request header and its classified changes. The change is limited to Chat presentation and does not alter provider requests, Session events, or reconstruction. +Trajectory continues to expose every request header and its classified changes. Provider requests and reconstruction stay unchanged; the only Session-event difference is the existing `startsSeries` marker on an explicitly declared resumed-series boundary. ## Alternatives considered @@ -26,6 +26,6 @@ Trajectory continues to expose every request header and its classified changes. ## Consequences -Continuing a fork or resuming a process with an unchanged system field leaves one visible `System prompt` row for the current message series. Explicit series starts and real system changes still repeat the row. A partial window can initially show a conservative row and hide it after older history loads, while retaining the same materialized Node. +Continuing a fork or resuming a process with an unchanged system field leaves one visible `System prompt` row for the current message series. Explicit series starts, including the first request of a resumed loop, and real system changes still repeat the row. A partial window can initially show a conservative row and hide it after older history loads, while retaining the same materialized Node. -The unit regression covers initial, series, unchanged resume, system-change, and prepend cases. The Web recorded-session scenario contains an unchanged resume header and asserts that the settled Chat renders exactly one `System prompt` control. +The unit regressions cover initial, series, unchanged resume, resumed-series, system-change, and prepend cases. The Web recorded-session scenario contains an unchanged resume header and asserts that the settled Chat renders exactly one `System prompt` control. diff --git a/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.zh.md b/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.zh.md index 9c09148f74..a6d3b7ff62 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.zh.md @@ -10,11 +10,11 @@ fork Session 会把源会话历史复制到子会话。即使 system 字段与 ## 决策 -持久化 resume header 保持不变,因为它记录了精确重建 Session 所需的请求边界。Chat 现在会把完整 header 与前一条已加载 Request Prompt 比较,只在初始请求、显式消息序列起点或真实 system 字段变化时显示非空系统提示词。内容未变的 resume 不创建可见的重复行。 +持久化 resume header 记录精确重建 Session 所需的请求边界。恢复后的 loop 首次接纳的请求显式开启独立消息序列时,loop 会在 resume 快照上以 `startsSeries: true` 保留该事实。Chat 会把完整 header 与前一条已加载 Request Prompt 比较,只在初始请求、显式消息序列起点或真实 system 字段变化时显示非空系统提示词。内容未变的普通 resume 不创建可见的重复行。 部分历史窗口可能以非初始 header 开头,因缺少前序 header 而无法比较。Chat 会保守渲染该系统提示词。如果 prepend 随后补入相同的前序 header,既有 request-prompt Node 会转为隐藏而不是被撤回;其 key 和页面生命周期内的 anchor 保持稳定。不同的 system 字段仍然可见。 -Trajectory 会继续展示每一条请求 header 及其变化分类。本次改动仅限 Chat 展示,不改变提供方请求、Session event 或重建行为。 +Trajectory 会继续展示每一条请求 header 及其变化分类。提供方请求与重建保持不变;Session event 的唯一差异,是显式声明的恢复后序列边界会携带已有的 `startsSeries` 标记。 ## 考虑过的替代方案 @@ -26,6 +26,6 @@ Trajectory 会继续展示每一条请求 header 及其变化分类。本次改 ## 后果 -继续 fork 或在 system 字段未变时恢复进程,当前消息序列只保留一行可见的`系统提示词`。显式序列起点与真实 system 变化仍会重复该行。部分窗口起初可以显示保守行,并在更早历史加载后将其隐藏,同时保留同一个已物化 Node。 +继续 fork 或在 system 字段未变时恢复进程,当前消息序列只保留一行可见的`系统提示词`。显式序列起点(包括恢复后 loop 的首次请求)与真实 system 变化仍会重复该行。部分窗口起初可以显示保守行,并在更早历史加载后将其隐藏,同时保留同一个已物化 Node。 -单元回归覆盖初始请求、显式序列、未变化 resume、system 变化与 prepend 场景。Web 录制会话场景包含一条未变化的 resume header,并断言稳定后的 Chat 只渲染一个`系统提示词`控件。 +单元回归覆盖初始请求、显式序列、未变化 resume、恢复后序列、system 变化与 prepend 场景。Web 录制会话场景包含一条未变化的 resume header,并断言稳定后的 Chat 只渲染一个`系统提示词`控件。 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 14c3c8ad07..ad8d878013 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: 902fd7b53fe7493127da68f9a8f381a33b8edc18 -architecture.zh.md: 2890b32b5db1ac30f6eafef47b019a03c6acedd4 +architecture.md: cd0c5dbd91ece3ad7786ac6b0a3dac9dd25c8d17 +architecture.zh.md: 7fd56267a542e43e6743380ca9c0dc9973a76457 diff --git a/docs/architecture.md b/docs/architecture.md index 902fd7b53f..cd0c5dbd91 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. 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. +`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`; `change` carrying `startsSeries: true` when the envelope changed too; or `resume` carrying that field when the loop instance's first request begins the series). 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). diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 2890b32b5d..7fd56267a5 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -100,7 +100,7 @@ turn/end 输入通过同一个 inbox 到达驱动器。有些消息会立即唤醒它;注入的上下文会留在 inbox 中,直到另一条消息将其唤醒。 -`agent/pre-step` 决定模型看到什么。监听器可以改写已领取的消息,也可以直接拒绝它们;首次领取被拒绝或被改写为空时,仍会关闭一个不含步骤的持久轮次,因此日志会记录这次尝试。enter 决策还可以设置 `startsRequestSeries` 来开启独立的模型消息序列:loop 会随之记录一个新的 `request/header`(原因为 `series`,或在封装同时变化时为携带 `startsSeries: true` 的 `change`)。重建下游 enter 决策的监听器必须展开它(`{ ...decision, messages }`),该声明才能存活。每个步骤读取插件注册的提示词片段和工具 schema。 +`agent/pre-step` 决定模型看到什么。监听器可以改写已领取的消息,也可以直接拒绝它们;首次领取被拒绝或被改写为空时,仍会关闭一个不含步骤的持久轮次,因此日志会记录这次尝试。enter 决策还可以设置 `startsRequestSeries` 来开启独立的模型消息序列:loop 会随之记录一个新的 `request/header`(原因为 `series`;在 envelope 同时变化时为携带 `startsSeries: true` 的 `change`;循环实例的首次请求开启该序列时则为携带此字段的 `resume`)。重建下游 enter 决策的监听器必须展开它(`{ ...decision, messages }`),该声明才能存活。每个步骤读取插件注册的提示词片段和工具 schema。 详情见[时序图](agent-lifecycle.zh.md)、[工具流水线](tool-execution-pipeline.zh.md)和[取消与错误恢复](subsystems/core.zh.md#the-agent-handle)。 diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index b6e2f8ac7f..94dc9d6f0f 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/persistence-catalog.md -persistence-catalog.md: 1c0c6919987c691b82c4639aff0f779c95dca83c -persistence-catalog.zh.md: 4cc8ba5b7fc76708a80285013ebcbb03fe3e8e4a +persistence-catalog.md: 8412ff7bcf4b02ab6e4625e29c8a04ac5fa5e9ec +persistence-catalog.zh.md: 0d4244a6cbe7ac07bfebe82d926ff22b6ab163f8 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 1c0c691998..8412ff7bcf 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -577,7 +577,7 @@ Source: [`packages/core/session/src/types.ts:341`](../packages/core/session/src/ 'request/header': { header: EpochHeader reason: RequestHeaderReason - /** A changed header also begins a distinct model-message series. */ + /** A `change` or `resume` snapshot also begins a distinct model-message series. */ startsSeries?: true } ``` diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 4cc8ba5b7f..0d4244a6cb 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -579,7 +579,7 @@ export type SessionEvent = { 'request/header': { header: EpochHeader reason: RequestHeaderReason - /** A changed header also begins a distinct model-message series. */ + /** A `change` or `resume` snapshot also begins a distinct model-message series. */ startsSeries?: true } ``` diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index f9653cecea..6ba223ba0b 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session.md -session.md: 48a80ccb92af774db2f5bedea8e2e00043932d31 -session.zh.md: df5cfd631a15bfe015c766842956cc0c344c2ccd +session.md: b4d5ef6bd65260c7165e2cbfd40ec59d9c552b2a +session.zh.md: 6011f5f76c166e5e7284789371fdaa258bd56819 diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index 48a80ccb92..b4d5ef6bd6 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -97,7 +97,7 @@ interface SessionEventMap { 'request/header': { header: EpochHeader reason: RequestHeaderReason - /** A changed header also begins a distinct model-message series. */ + /** A `change` or `resume` snapshot also begins a distinct model-message series. */ startsSeries?: true } /** @@ -137,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 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. +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 resume snapshot carries `startsSeries: true` when that request's admitted step explicitly begins a distinct series. A changed request appends a snapshot with reason `'change'`; an unchanged envelope beginning a later 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 /** diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index df5cfd631a..6011f5f76c 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -97,7 +97,7 @@ interface SessionEventMap { 'request/header': { header: EpochHeader reason: RequestHeaderReason - /** A changed header also begins a distinct model-message series. */ + /** A `change` or `resume` snapshot also begins a distinct model-message series. */ startsSeries?: true } /** @@ -137,7 +137,7 @@ interface SessionEventMap { ### 请求头事件:`request/header` -请求信封(即 `EpochHeader`:调用配置 + 适配器所提供默认值的标记 + 渲染后的系统提示词 + 已组装的工具 schema)会作为会话状态写入日志,因此每个对话请求都是日志的纯函数(见可重建性 Agent Note)。带有 reason `'initial'` 或 `'resume'` 的完整 `request/header` 快照记录每个 agent loop 实例的边界;请求变化时会追加 reason 为 `'change'` 的快照;未变的信封显式开启消息序列或跟随 surface 替换时,会追加 reason 为 `'series'` 的快照。如果发生变化的快照所属请求同时开启序列,它会携带 `startsSeries: true`。普通的仅追加后续 Turn,以及同一模型消息序列内的后续 Step 与重试沿用最新快照。`foldRequestHeader(events)` 通过选择最新快照重建请求头。该事件不是 `SurfaceEventType`,不产生 LLM 消息。 +请求信封(即 `EpochHeader`:调用配置 + 适配器所提供默认值的标记 + 渲染后的系统提示词 + 已组装的工具 schema)会作为会话状态写入日志,因此每个对话请求都是日志的纯函数(见可重建性 Agent Note)。带有 reason `'initial'` 或 `'resume'` 的完整 `request/header` 快照记录每个 agent loop 实例的边界;恢复请求的已接纳步骤显式开启独立序列时,`resume` 快照携带 `startsSeries: true`。请求变化时会追加 reason 为 `'change'` 的快照;未变的信封后续显式开启消息序列或跟随 surface 替换时,会追加 reason 为 `'series'` 的快照。如果发生变化的快照所属请求同时开启序列,它会携带 `startsSeries: true`。普通的仅追加后续 Turn,以及同一模型消息序列内的后续 Step 与重试沿用最新快照。`foldRequestHeader(events)` 通过选择最新快照重建请求头。该事件不是 `SurfaceEventType`,不产生 LLM 消息。 ```ts type-equiv /** diff --git a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts index b8303a3a08..7695f95127 100644 --- a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts +++ b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts @@ -1362,6 +1362,7 @@ describe('built-in conversation node Definitions', () => { }), at(5, 'request/header', { reason: 'resume', + startsSeries: true, header: { config: { provider: 'fake', model: 'fake', maxTokens: 2_048 }, system: '# Initial', @@ -1369,6 +1370,14 @@ describe('built-in conversation node Definitions', () => { }, }), at(6, 'request/header', { + reason: 'resume', + header: { + config: { provider: 'fake', model: 'fake', maxTokens: 2_048 }, + system: '# Initial', + tools: expandedTools, + }, + }), + at(7, 'request/header', { reason: 'change', header: { config: { provider: 'fake', model: 'fake', maxTokens: 2_048 }, @@ -1383,7 +1392,8 @@ describe('built-in conversation node Definitions', () => { expect(prompts.map(prompt => ({ anchorSeq: prompt.anchorSeq, data: prompt.data }))).toEqual([ { anchorSeq: 1, data: { text: '# Initial' } }, { anchorSeq: 4, data: { text: '# Initial' } }, - { anchorSeq: 6, data: { text: '# Updated' } }, + { anchorSeq: 5, data: { text: '# Initial' } }, + { anchorSeq: 7, data: { text: '# Updated' } }, ]) const windowed = assembler([ diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml index 7541d9a310..6123b650a0 100644 --- a/packages/core/agent-loop/README.i18n.yaml +++ b/packages/core/agent-loop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/agent-loop/README.md -README.md: 5e4ab0a741f0b7dae821e52f1a9b0a1faa90ceed -README.zh.md: ef47f897978c8254242075a559f56faa2dceca19 +README.md: cb5e9b18eb44e7e91ff11750b5aad3bfc5a29140 +README.zh.md: c1439f704d405ccfc785036c5372fc17582d3819 diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 5e4ab0a741..cb5e9b18eb 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -88,7 +88,7 @@ The package is the one concrete implementation of the public `Agent` contract. I ### Request headers and adapter defaults -After `agent/request`, `ctx.llm.prepareCall()` validates adapter-owned fields and resolves reasoning-effort and output-token defaults under the active turn signal. The loop retains that exact adapter through resolution, `request/header` logging, and dispatch. It writes a full header for the first request, a changed envelope, an explicit message-series start, a request after surface replacement, and resume; unchanged steps, retries, and ordinary later turns in the same series inherit the latest header. Before the next waterfall, the loop removes adapter-default fields so the current route resolves them again, while explicit settings persist. An unhandled route still fails with `NO_ADAPTER`. +After `agent/request`, `ctx.llm.prepareCall()` validates adapter-owned fields and resolves reasoning-effort and output-token defaults under the active turn signal. The loop retains that exact adapter through resolution, `request/header` logging, and dispatch. It writes a full header for the first request, a changed envelope, an explicit message-series start, a request after surface replacement, and resume; a resume snapshot carries `startsSeries: true` when its admitted step explicitly begins a distinct series. Unchanged steps, retries, and ordinary later turns in the same series inherit the latest header. Before the next waterfall, the loop removes adapter-default fields so the current route resolves them again, while explicit settings persist. An unhandled route still fails with `NO_ADAPTER`. ### Source map diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md index ef47f89797..c1439f704d 100644 --- a/packages/core/agent-loop/README.zh.md +++ b/packages/core/agent-loop/README.zh.md @@ -88,7 +88,7 @@ const handle = await ctx.agents.create({ ### 请求 header 与适配器默认值 -`agent/request` 返回后,`ctx.llm.prepareCall()` 会在活跃轮次信号下校验适配器持有的字段,并解析推理强度和输出 token 默认值。循环会在解析、`request/header` 记录与分派期间保留同一个适配器。循环会为首次请求、变化的 envelope、显式消息序列起点、表层替换后的请求及恢复写入完整 header;同一序列内内容未变的步骤、重试与普通后续轮次继承最新 header。下一次 waterfall 前,循环移除适配器默认字段,使当前路由重新解析它们;显式设置则保留。未处理的路由仍以 `NO_ADAPTER` 失败。 +`agent/request` 返回后,`ctx.llm.prepareCall()` 会在活跃轮次信号下校验适配器持有的字段,并解析推理强度和输出 token 默认值。循环会在解析、`request/header` 记录与分派期间保留同一个适配器。循环会为首次请求、变化的 envelope、显式消息序列起点、表层替换后的请求及恢复写入完整 header;恢复快照的已接纳步骤显式开启独立序列时,该快照携带 `startsSeries: true`。同一序列内内容未变的步骤、重试与普通后续轮次继承最新 header。下一次 waterfall 前,循环移除适配器默认字段,使当前路由重新解析它们;显式设置则保留。未处理的路由仍以 `NO_ADAPTER` 失败。 ### 源码地图 diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 1b8448e693..4e49501585 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -505,7 +505,12 @@ export class ReactLoopAgent implements Agent { const startsSeries = startsRequestSeries || this.requestSurfaceGeneration !== surfaceGeneration if (!this.requestHeaderLogged) { - this.session.append('request/header', { header, reason: baseline === undefined ? 'initial' : 'resume' }) + const reason = baseline === undefined ? 'initial' : 'resume' + this.session.append('request/header', { + header, + reason, + ...reason === 'resume' && startsRequestSeries ? { startsSeries: true } : {}, + }) this.requestHeaderLogged = true } else if (baseline === undefined || !headerEquals(baseline, header)) { this.session.append('request/header', { diff --git a/packages/core/agent-loop/tests/request-reconstruction.spec.ts b/packages/core/agent-loop/tests/request-reconstruction.spec.ts index 93459584a7..0e0dd87447 100644 --- a/packages/core/agent-loop/tests/request-reconstruction.spec.ts +++ b/packages/core/agent-loop/tests/request-reconstruction.spec.ts @@ -660,11 +660,43 @@ describe('request stability across the loop', () => { const snapshots = agent2.session.snapshotEvents().filter(e => e.type === 'request/header') expect(snapshots).toHaveLength(2) expect(snapshots[1]?.data.reason).toBe('resume') + expect(snapshots[1]?.data.startsSeries).toBeUndefined() // Identical header across the restart: byte-identical continuation. expect(adapter2.requests[0]!.system).toEqual(adapter.requests[0]!.system) expectPrefixExtension(adapter.requests[0]!, adapter2.requests[0]!) }) + it('retains an explicit series boundary on the first request of a resumed loop', async () => { + const adapter = new MockAdapter([textResponse('one')]) + const ctx = await harness(adapter) + const agent = await ctx.agentLoop.create(SessionId('series-gen1'), { provider: 'mock', model: 'mock' }) + send(agent, 'first') + await waitForIdle(ctx, agent) + + const adapter2 = new MockAdapter([textResponse('two')]) + const ctx2 = await harness(adapter2) + ctx2.on('agent/pre-step', async (_payload, next) => { + const decision = await next() + return decision.kind === 'enter' + ? { ...decision, startsRequestSeries: true } + : decision + }) + const handle = await ctx2.agents.create({ + sessionId: SessionId('series-gen2'), + seed: agent.session.snapshotEvents(), + agentOptions: { provider: 'mock', model: 'mock' }, + }) + send(handle.agent, 'second series') + await waitForIdle(ctx2, handle.agent) + + expect(handle.agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' + ? [{ reason: event.data.reason, startsSeries: event.data.startsSeries }] + : [])).toEqual([ + { reason: 'initial', startsSeries: undefined }, + { reason: 'resume', startsSeries: true }, + ]) + }) + it('a delegating listener cannot mutate the seed through next() — the fold stays log-true', async () => { const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(adapter) diff --git a/packages/core/session/README.i18n.yaml b/packages/core/session/README.i18n.yaml index 71c2828f9c..9d76cb4db4 100644 --- a/packages/core/session/README.i18n.yaml +++ b/packages/core/session/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/session/README.md -README.md: f5cf910854203021a619cc786dfd13705927ffc1 -README.zh.md: 385d7fd63a6e4dec9c23c9d38a352942d7dbc7f9 +README.md: 96eed6773c248266429666cdc065e68f0f9efb09 +README.zh.md: 1faa6bf713c8a9f16e8dc85d50cdff33657e6a96 diff --git a/packages/core/session/README.md b/packages/core/session/README.md index f5cf910854..96eed6773c 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -81,7 +81,7 @@ The package is built on event sourcing: a `Session` is an append-only log of typ ### Request headers -`request/header` stores a full canonical snapshot of the non-history request envelope with reason `initial`, `resume`, `change`, or `series`. An explicit message-series start or a surface replacement writes a `series` snapshot when the envelope is unchanged; a simultaneous change uses `startsSeries: true`. Same-series steps, retries, and ordinary later turns inherit the latest snapshot. `adapterDefaults` distinguishes values resolved by the adapter from explicit settings, and `foldRequestHeader()` selects the latest snapshot. This self-contained record supports partial-window rendering and exact reconstruction at the cost of growth per message series; the [reconstructable-requests Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md) owns the detail. +`request/header` stores a full canonical snapshot of the non-history request envelope with reason `initial`, `resume`, `change`, or `series`. A loop instance's first request over existing history uses `resume` and carries `startsSeries: true` when its admitted step explicitly begins a distinct message series. A later explicit message-series start or surface replacement writes a `series` snapshot when the envelope is unchanged; a simultaneous change uses `startsSeries: true`. Same-series steps, retries, and ordinary later turns inherit the latest snapshot. `adapterDefaults` distinguishes values resolved by the adapter from explicit settings, and `foldRequestHeader()` selects the latest snapshot. This self-contained record supports partial-window rendering and exact reconstruction at the cost of growth per message series; the [reconstructable-requests Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md) owns the detail. ### Source map diff --git a/packages/core/session/README.zh.md b/packages/core/session/README.zh.md index 385d7fd63a..1faa6bf713 100644 --- a/packages/core/session/README.zh.md +++ b/packages/core/session/README.zh.md @@ -81,7 +81,7 @@ session.deriveMessages() // the derived model history ### 请求 header -`request/header` 存储非历史请求 envelope 的完整规范快照,原因为 `initial`、`resume`、`change` 或 `series`。显式消息序列起点或表层替换会在 envelope 不变时写入 `series` 快照;同时发生变化时使用 `startsSeries: true`。同一序列内的步骤、重试与普通后续轮次继承最新快照。`adapterDefaults` 区分由适配器解析的值与显式设置,`foldRequestHeader()` 选择最新快照。这种自包含记录以每个消息序列增加存储为代价,支持局部窗口渲染与精确重建;细节由[可重建请求 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md)负责。 +`request/header` 存储非历史请求 envelope 的完整规范快照,原因为 `initial`、`resume`、`change` 或 `series`。循环实例基于既有历史发出的首次请求使用 `resume`;其已接纳步骤显式开启独立消息序列时,快照携带 `startsSeries: true`。后续显式消息序列起点或表层替换会在 envelope 不变时写入 `series` 快照;同时发生变化时使用 `startsSeries: true`。同一序列内的步骤、重试与普通后续轮次继承最新快照。`adapterDefaults` 区分由适配器解析的值与显式设置,`foldRequestHeader()` 选择最新快照。这种自包含记录以每个消息序列增加存储为代价,支持局部窗口渲染与精确重建;细节由[可重建请求 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md)负责。 ### 源码地图 diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index c27cd4d98e..40516c7b2f 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -244,11 +244,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, with `startsSeries` - * preserving a coincident series boundary; `'series'` — an unchanged header - * began an explicitly distinct message series or followed a surface replacement. + * header (a new conversation); `'resume'` — a loop instance's first request over + * existing header history (process restart, fork seed), with `startsSeries` + * preserving an explicit series boundary; `'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' | 'series' @@ -331,7 +331,7 @@ export interface SessionEventMap { 'request/header': { header: EpochHeader reason: RequestHeaderReason - /** A changed header also begins a distinct model-message series. */ + /** A `change` or `resume` snapshot also begins a distinct model-message series. */ startsSeries?: true } /** From f1e7dbce5535e62dcd3c914f25067d6639074072 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Fri, 4 Sep 2026 12:08:16 +0800 Subject: [PATCH 4/7] fix(web): keep resume prompt dedupe client-side --- ...ers-do-not-repeat-system-prompts.i18n.yaml | 4 +-- ...me-headers-do-not-repeat-system-prompts.md | 8 ++--- ...headers-do-not-repeat-system-prompts.zh.md | 8 ++--- docs/architecture.i18n.yaml | 4 +-- docs/architecture.md | 2 +- docs/architecture.zh.md | 2 +- docs/persistence-catalog.i18n.yaml | 4 +-- docs/persistence-catalog.md | 2 +- docs/persistence-catalog.zh.md | 2 +- docs/subsystems/session.i18n.yaml | 4 +-- docs/subsystems/session.md | 4 +-- docs/subsystems/session.zh.md | 4 +-- ...nversation-node-definitions.client.spec.ts | 12 +------ packages/core/agent-loop/README.i18n.yaml | 4 +-- packages/core/agent-loop/README.md | 2 +- packages/core/agent-loop/README.zh.md | 2 +- packages/core/agent-loop/src/agent.ts | 7 +--- .../tests/request-reconstruction.spec.ts | 32 ------------------- packages/core/session/README.i18n.yaml | 4 +-- packages/core/session/README.md | 2 +- packages/core/session/README.zh.md | 2 +- packages/core/session/src/types.ts | 12 +++---- 22 files changed, 40 insertions(+), 87 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.i18n.yaml index 85ce1c4e1b..cb85b7af4a 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.md -2026-09-03-resume-headers-do-not-repeat-system-prompts.md: 67e0048051e7842ef629868cb5c006f3b52dcd26 -2026-09-03-resume-headers-do-not-repeat-system-prompts.zh.md: a6d3b7ff628ed23c614a728f8e231ee8ed8e1bce +2026-09-03-resume-headers-do-not-repeat-system-prompts.md: 63acf56998276cdaff400186b7c33761eaf63788 +2026-09-03-resume-headers-do-not-repeat-system-prompts.zh.md: 9c09148f74042b70ef03fb92f1d3a2986a055da9 diff --git a/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.md b/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.md index 67e0048051..63acf56998 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.md +++ b/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.md @@ -10,11 +10,11 @@ Forking a Session copies the source history into the child. The child's first mo ## Decision -The durable resume header records the request boundary needed for exact Session reconstruction. When the first admitted request of a resumed loop explicitly begins a distinct message series, the loop preserves that fact as `startsSeries: true` on the resume snapshot. Chat compares the full header with the preceding loaded Request Prompt and displays a non-empty system prompt only for the initial request, an explicit message-series start, or a real system-field change. An unchanged ordinary resume does not create a visible repetition. +The durable resume header remains unchanged because it records the request boundary needed for exact Session reconstruction. Chat now compares that full header with the preceding loaded Request Prompt and displays a non-empty system prompt only for the initial request, an explicit message-series start, or a real system-field change. An unchanged resume does not create a visible repetition. A partial history window may begin with a non-initial header and lack the predecessor needed for comparison. Chat renders that system prompt conservatively. If prepend later supplies an identical predecessor, the existing request-prompt Node becomes hidden instead of being withdrawn; its key and page-lifetime anchor stay stable. A different system field remains visible. -Trajectory continues to expose every request header and its classified changes. Provider requests and reconstruction stay unchanged; the only Session-event difference is the existing `startsSeries` marker on an explicitly declared resumed-series boundary. +Trajectory continues to expose every request header and its classified changes. The change is limited to Chat presentation and does not alter provider requests, Session events, or reconstruction. ## Alternatives considered @@ -26,6 +26,6 @@ Trajectory continues to expose every request header and its classified changes. ## Consequences -Continuing a fork or resuming a process with an unchanged system field leaves one visible `System prompt` row for the current message series. Explicit series starts, including the first request of a resumed loop, and real system changes still repeat the row. A partial window can initially show a conservative row and hide it after older history loads, while retaining the same materialized Node. +Continuing a fork or resuming a process with an unchanged system field leaves one visible `System prompt` row for the current message series. Explicit series starts and real system changes still repeat the row. A partial window can initially show a conservative row and hide it after older history loads, while retaining the same materialized Node. -The unit regressions cover initial, series, unchanged resume, resumed-series, system-change, and prepend cases. The Web recorded-session scenario contains an unchanged resume header and asserts that the settled Chat renders exactly one `System prompt` control. +The unit regression covers initial, series, unchanged resume, system-change, and prepend cases. The Web recorded-session scenario contains an unchanged resume header and asserts that the settled Chat renders exactly one `System prompt` control. diff --git a/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.zh.md b/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.zh.md index a6d3b7ff62..9c09148f74 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.zh.md @@ -10,11 +10,11 @@ fork Session 会把源会话历史复制到子会话。即使 system 字段与 ## 决策 -持久化 resume header 记录精确重建 Session 所需的请求边界。恢复后的 loop 首次接纳的请求显式开启独立消息序列时,loop 会在 resume 快照上以 `startsSeries: true` 保留该事实。Chat 会把完整 header 与前一条已加载 Request Prompt 比较,只在初始请求、显式消息序列起点或真实 system 字段变化时显示非空系统提示词。内容未变的普通 resume 不创建可见的重复行。 +持久化 resume header 保持不变,因为它记录了精确重建 Session 所需的请求边界。Chat 现在会把完整 header 与前一条已加载 Request Prompt 比较,只在初始请求、显式消息序列起点或真实 system 字段变化时显示非空系统提示词。内容未变的 resume 不创建可见的重复行。 部分历史窗口可能以非初始 header 开头,因缺少前序 header 而无法比较。Chat 会保守渲染该系统提示词。如果 prepend 随后补入相同的前序 header,既有 request-prompt Node 会转为隐藏而不是被撤回;其 key 和页面生命周期内的 anchor 保持稳定。不同的 system 字段仍然可见。 -Trajectory 会继续展示每一条请求 header 及其变化分类。提供方请求与重建保持不变;Session event 的唯一差异,是显式声明的恢复后序列边界会携带已有的 `startsSeries` 标记。 +Trajectory 会继续展示每一条请求 header 及其变化分类。本次改动仅限 Chat 展示,不改变提供方请求、Session event 或重建行为。 ## 考虑过的替代方案 @@ -26,6 +26,6 @@ Trajectory 会继续展示每一条请求 header 及其变化分类。提供方 ## 后果 -继续 fork 或在 system 字段未变时恢复进程,当前消息序列只保留一行可见的`系统提示词`。显式序列起点(包括恢复后 loop 的首次请求)与真实 system 变化仍会重复该行。部分窗口起初可以显示保守行,并在更早历史加载后将其隐藏,同时保留同一个已物化 Node。 +继续 fork 或在 system 字段未变时恢复进程,当前消息序列只保留一行可见的`系统提示词`。显式序列起点与真实 system 变化仍会重复该行。部分窗口起初可以显示保守行,并在更早历史加载后将其隐藏,同时保留同一个已物化 Node。 -单元回归覆盖初始请求、显式序列、未变化 resume、恢复后序列、system 变化与 prepend 场景。Web 录制会话场景包含一条未变化的 resume header,并断言稳定后的 Chat 只渲染一个`系统提示词`控件。 +单元回归覆盖初始请求、显式序列、未变化 resume、system 变化与 prepend 场景。Web 录制会话场景包含一条未变化的 resume header,并断言稳定后的 Chat 只渲染一个`系统提示词`控件。 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index ad8d878013..14c3c8ad07 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: cd0c5dbd91ece3ad7786ac6b0a3dac9dd25c8d17 -architecture.zh.md: 7fd56267a542e43e6743380ca9c0dc9973a76457 +architecture.md: 902fd7b53fe7493127da68f9a8f381a33b8edc18 +architecture.zh.md: 2890b32b5db1ac30f6eafef47b019a03c6acedd4 diff --git a/docs/architecture.md b/docs/architecture.md index cd0c5dbd91..902fd7b53f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. An enter decision may also set `startsRequestSeries` to begin a distinct model-message series: the loop then logs a fresh `request/header` (reason `series`; `change` carrying `startsSeries: true` when the envelope changed too; or `resume` carrying that field when the loop instance's first request begins the series). 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. +`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). diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 7fd56267a5..2890b32b5d 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -100,7 +100,7 @@ turn/end 输入通过同一个 inbox 到达驱动器。有些消息会立即唤醒它;注入的上下文会留在 inbox 中,直到另一条消息将其唤醒。 -`agent/pre-step` 决定模型看到什么。监听器可以改写已领取的消息,也可以直接拒绝它们;首次领取被拒绝或被改写为空时,仍会关闭一个不含步骤的持久轮次,因此日志会记录这次尝试。enter 决策还可以设置 `startsRequestSeries` 来开启独立的模型消息序列:loop 会随之记录一个新的 `request/header`(原因为 `series`;在 envelope 同时变化时为携带 `startsSeries: true` 的 `change`;循环实例的首次请求开启该序列时则为携带此字段的 `resume`)。重建下游 enter 决策的监听器必须展开它(`{ ...decision, messages }`),该声明才能存活。每个步骤读取插件注册的提示词片段和工具 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)。 diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 94dc9d6f0f..b6e2f8ac7f 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/persistence-catalog.md -persistence-catalog.md: 8412ff7bcf4b02ab6e4625e29c8a04ac5fa5e9ec -persistence-catalog.zh.md: 0d4244a6cbe7ac07bfebe82d926ff22b6ab163f8 +persistence-catalog.md: 1c0c6919987c691b82c4639aff0f779c95dca83c +persistence-catalog.zh.md: 4cc8ba5b7fc76708a80285013ebcbb03fe3e8e4a diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 8412ff7bcf..1c0c691998 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -577,7 +577,7 @@ Source: [`packages/core/session/src/types.ts:341`](../packages/core/session/src/ 'request/header': { header: EpochHeader reason: RequestHeaderReason - /** A `change` or `resume` snapshot also begins a distinct model-message series. */ + /** A changed header also begins a distinct model-message series. */ startsSeries?: true } ``` diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 0d4244a6cb..4cc8ba5b7f 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -579,7 +579,7 @@ export type SessionEvent = { 'request/header': { header: EpochHeader reason: RequestHeaderReason - /** A `change` or `resume` snapshot also begins a distinct model-message series. */ + /** A changed header also begins a distinct model-message series. */ startsSeries?: true } ``` diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index 6ba223ba0b..f9653cecea 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session.md -session.md: b4d5ef6bd65260c7165e2cbfd40ec59d9c552b2a -session.zh.md: 6011f5f76c166e5e7284789371fdaa258bd56819 +session.md: 48a80ccb92af774db2f5bedea8e2e00043932d31 +session.zh.md: df5cfd631a15bfe015c766842956cc0c344c2ccd diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index b4d5ef6bd6..48a80ccb92 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -97,7 +97,7 @@ interface SessionEventMap { 'request/header': { header: EpochHeader reason: RequestHeaderReason - /** A `change` or `resume` snapshot also begins a distinct model-message series. */ + /** A changed header also begins a distinct model-message series. */ startsSeries?: true } /** @@ -137,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 resume snapshot carries `startsSeries: true` when that request's admitted step explicitly begins a distinct series. A changed request appends a snapshot with reason `'change'`; an unchanged envelope beginning a later 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. +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 /** diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index 6011f5f76c..df5cfd631a 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -97,7 +97,7 @@ interface SessionEventMap { 'request/header': { header: EpochHeader reason: RequestHeaderReason - /** A `change` or `resume` snapshot also begins a distinct model-message series. */ + /** A changed header also begins a distinct model-message series. */ startsSeries?: true } /** @@ -137,7 +137,7 @@ interface SessionEventMap { ### 请求头事件:`request/header` -请求信封(即 `EpochHeader`:调用配置 + 适配器所提供默认值的标记 + 渲染后的系统提示词 + 已组装的工具 schema)会作为会话状态写入日志,因此每个对话请求都是日志的纯函数(见可重建性 Agent Note)。带有 reason `'initial'` 或 `'resume'` 的完整 `request/header` 快照记录每个 agent loop 实例的边界;恢复请求的已接纳步骤显式开启独立序列时,`resume` 快照携带 `startsSeries: true`。请求变化时会追加 reason 为 `'change'` 的快照;未变的信封后续显式开启消息序列或跟随 surface 替换时,会追加 reason 为 `'series'` 的快照。如果发生变化的快照所属请求同时开启序列,它会携带 `startsSeries: true`。普通的仅追加后续 Turn,以及同一模型消息序列内的后续 Step 与重试沿用最新快照。`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 /** diff --git a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts index 7695f95127..b8303a3a08 100644 --- a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts +++ b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts @@ -1362,7 +1362,6 @@ describe('built-in conversation node Definitions', () => { }), at(5, 'request/header', { reason: 'resume', - startsSeries: true, header: { config: { provider: 'fake', model: 'fake', maxTokens: 2_048 }, system: '# Initial', @@ -1370,14 +1369,6 @@ describe('built-in conversation node Definitions', () => { }, }), at(6, 'request/header', { - reason: 'resume', - header: { - config: { provider: 'fake', model: 'fake', maxTokens: 2_048 }, - system: '# Initial', - tools: expandedTools, - }, - }), - at(7, 'request/header', { reason: 'change', header: { config: { provider: 'fake', model: 'fake', maxTokens: 2_048 }, @@ -1392,8 +1383,7 @@ describe('built-in conversation node Definitions', () => { 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: 7, data: { text: '# Updated' } }, + { anchorSeq: 6, data: { text: '# Updated' } }, ]) const windowed = assembler([ diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml index 6123b650a0..7541d9a310 100644 --- a/packages/core/agent-loop/README.i18n.yaml +++ b/packages/core/agent-loop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/agent-loop/README.md -README.md: cb5e9b18eb44e7e91ff11750b5aad3bfc5a29140 -README.zh.md: c1439f704d405ccfc785036c5372fc17582d3819 +README.md: 5e4ab0a741f0b7dae821e52f1a9b0a1faa90ceed +README.zh.md: ef47f897978c8254242075a559f56faa2dceca19 diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index cb5e9b18eb..5e4ab0a741 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -88,7 +88,7 @@ The package is the one concrete implementation of the public `Agent` contract. I ### Request headers and adapter defaults -After `agent/request`, `ctx.llm.prepareCall()` validates adapter-owned fields and resolves reasoning-effort and output-token defaults under the active turn signal. The loop retains that exact adapter through resolution, `request/header` logging, and dispatch. It writes a full header for the first request, a changed envelope, an explicit message-series start, a request after surface replacement, and resume; a resume snapshot carries `startsSeries: true` when its admitted step explicitly begins a distinct series. Unchanged steps, retries, and ordinary later turns in the same series inherit the latest header. Before the next waterfall, the loop removes adapter-default fields so the current route resolves them again, while explicit settings persist. An unhandled route still fails with `NO_ADAPTER`. +After `agent/request`, `ctx.llm.prepareCall()` validates adapter-owned fields and resolves reasoning-effort and output-token defaults under the active turn signal. The loop retains that exact adapter through resolution, `request/header` logging, and dispatch. It writes a full header for the first request, a changed envelope, an explicit message-series start, a request after surface replacement, and resume; unchanged steps, retries, and ordinary later turns in the same series inherit the latest header. Before the next waterfall, the loop removes adapter-default fields so the current route resolves them again, while explicit settings persist. An unhandled route still fails with `NO_ADAPTER`. ### Source map diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md index c1439f704d..ef47f89797 100644 --- a/packages/core/agent-loop/README.zh.md +++ b/packages/core/agent-loop/README.zh.md @@ -88,7 +88,7 @@ const handle = await ctx.agents.create({ ### 请求 header 与适配器默认值 -`agent/request` 返回后,`ctx.llm.prepareCall()` 会在活跃轮次信号下校验适配器持有的字段,并解析推理强度和输出 token 默认值。循环会在解析、`request/header` 记录与分派期间保留同一个适配器。循环会为首次请求、变化的 envelope、显式消息序列起点、表层替换后的请求及恢复写入完整 header;恢复快照的已接纳步骤显式开启独立序列时,该快照携带 `startsSeries: true`。同一序列内内容未变的步骤、重试与普通后续轮次继承最新 header。下一次 waterfall 前,循环移除适配器默认字段,使当前路由重新解析它们;显式设置则保留。未处理的路由仍以 `NO_ADAPTER` 失败。 +`agent/request` 返回后,`ctx.llm.prepareCall()` 会在活跃轮次信号下校验适配器持有的字段,并解析推理强度和输出 token 默认值。循环会在解析、`request/header` 记录与分派期间保留同一个适配器。循环会为首次请求、变化的 envelope、显式消息序列起点、表层替换后的请求及恢复写入完整 header;同一序列内内容未变的步骤、重试与普通后续轮次继承最新 header。下一次 waterfall 前,循环移除适配器默认字段,使当前路由重新解析它们;显式设置则保留。未处理的路由仍以 `NO_ADAPTER` 失败。 ### 源码地图 diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 4e49501585..1b8448e693 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -505,12 +505,7 @@ export class ReactLoopAgent implements Agent { const startsSeries = startsRequestSeries || this.requestSurfaceGeneration !== surfaceGeneration if (!this.requestHeaderLogged) { - const reason = baseline === undefined ? 'initial' : 'resume' - this.session.append('request/header', { - header, - reason, - ...reason === 'resume' && startsRequestSeries ? { startsSeries: true } : {}, - }) + 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', { diff --git a/packages/core/agent-loop/tests/request-reconstruction.spec.ts b/packages/core/agent-loop/tests/request-reconstruction.spec.ts index 0e0dd87447..93459584a7 100644 --- a/packages/core/agent-loop/tests/request-reconstruction.spec.ts +++ b/packages/core/agent-loop/tests/request-reconstruction.spec.ts @@ -660,43 +660,11 @@ describe('request stability across the loop', () => { const snapshots = agent2.session.snapshotEvents().filter(e => e.type === 'request/header') expect(snapshots).toHaveLength(2) expect(snapshots[1]?.data.reason).toBe('resume') - expect(snapshots[1]?.data.startsSeries).toBeUndefined() // Identical header across the restart: byte-identical continuation. expect(adapter2.requests[0]!.system).toEqual(adapter.requests[0]!.system) expectPrefixExtension(adapter.requests[0]!, adapter2.requests[0]!) }) - it('retains an explicit series boundary on the first request of a resumed loop', async () => { - const adapter = new MockAdapter([textResponse('one')]) - const ctx = await harness(adapter) - const agent = await ctx.agentLoop.create(SessionId('series-gen1'), { provider: 'mock', model: 'mock' }) - send(agent, 'first') - await waitForIdle(ctx, agent) - - const adapter2 = new MockAdapter([textResponse('two')]) - const ctx2 = await harness(adapter2) - ctx2.on('agent/pre-step', async (_payload, next) => { - const decision = await next() - return decision.kind === 'enter' - ? { ...decision, startsRequestSeries: true } - : decision - }) - const handle = await ctx2.agents.create({ - sessionId: SessionId('series-gen2'), - seed: agent.session.snapshotEvents(), - agentOptions: { provider: 'mock', model: 'mock' }, - }) - send(handle.agent, 'second series') - await waitForIdle(ctx2, handle.agent) - - expect(handle.agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' - ? [{ reason: event.data.reason, startsSeries: event.data.startsSeries }] - : [])).toEqual([ - { reason: 'initial', startsSeries: undefined }, - { reason: 'resume', startsSeries: true }, - ]) - }) - it('a delegating listener cannot mutate the seed through next() — the fold stays log-true', async () => { const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(adapter) diff --git a/packages/core/session/README.i18n.yaml b/packages/core/session/README.i18n.yaml index 9d76cb4db4..71c2828f9c 100644 --- a/packages/core/session/README.i18n.yaml +++ b/packages/core/session/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/session/README.md -README.md: 96eed6773c248266429666cdc065e68f0f9efb09 -README.zh.md: 1faa6bf713c8a9f16e8dc85d50cdff33657e6a96 +README.md: f5cf910854203021a619cc786dfd13705927ffc1 +README.zh.md: 385d7fd63a6e4dec9c23c9d38a352942d7dbc7f9 diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 96eed6773c..f5cf910854 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -81,7 +81,7 @@ The package is built on event sourcing: a `Session` is an append-only log of typ ### Request headers -`request/header` stores a full canonical snapshot of the non-history request envelope with reason `initial`, `resume`, `change`, or `series`. A loop instance's first request over existing history uses `resume` and carries `startsSeries: true` when its admitted step explicitly begins a distinct message series. A later explicit message-series start or surface replacement writes a `series` snapshot when the envelope is unchanged; a simultaneous change uses `startsSeries: true`. Same-series steps, retries, and ordinary later turns inherit the latest snapshot. `adapterDefaults` distinguishes values resolved by the adapter from explicit settings, and `foldRequestHeader()` selects the latest snapshot. This self-contained record supports partial-window rendering and exact reconstruction at the cost of growth per message series; the [reconstructable-requests Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md) owns the detail. +`request/header` stores a full canonical snapshot of the non-history request envelope with reason `initial`, `resume`, `change`, or `series`. An explicit message-series start or a surface replacement writes a `series` snapshot when the envelope is unchanged; a simultaneous change uses `startsSeries: true`. Same-series steps, retries, and ordinary later turns inherit the latest snapshot. `adapterDefaults` distinguishes values resolved by the adapter from explicit settings, and `foldRequestHeader()` selects the latest snapshot. This self-contained record supports partial-window rendering and exact reconstruction at the cost of growth per message series; the [reconstructable-requests Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md) owns the detail. ### Source map diff --git a/packages/core/session/README.zh.md b/packages/core/session/README.zh.md index 1faa6bf713..385d7fd63a 100644 --- a/packages/core/session/README.zh.md +++ b/packages/core/session/README.zh.md @@ -81,7 +81,7 @@ session.deriveMessages() // the derived model history ### 请求 header -`request/header` 存储非历史请求 envelope 的完整规范快照,原因为 `initial`、`resume`、`change` 或 `series`。循环实例基于既有历史发出的首次请求使用 `resume`;其已接纳步骤显式开启独立消息序列时,快照携带 `startsSeries: true`。后续显式消息序列起点或表层替换会在 envelope 不变时写入 `series` 快照;同时发生变化时使用 `startsSeries: true`。同一序列内的步骤、重试与普通后续轮次继承最新快照。`adapterDefaults` 区分由适配器解析的值与显式设置,`foldRequestHeader()` 选择最新快照。这种自包含记录以每个消息序列增加存储为代价,支持局部窗口渲染与精确重建;细节由[可重建请求 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md)负责。 +`request/header` 存储非历史请求 envelope 的完整规范快照,原因为 `initial`、`resume`、`change` 或 `series`。显式消息序列起点或表层替换会在 envelope 不变时写入 `series` 快照;同时发生变化时使用 `startsSeries: true`。同一序列内的步骤、重试与普通后续轮次继承最新快照。`adapterDefaults` 区分由适配器解析的值与显式设置,`foldRequestHeader()` 选择最新快照。这种自包含记录以每个消息序列增加存储为代价,支持局部窗口渲染与精确重建;细节由[可重建请求 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md)负责。 ### 源码地图 diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 40516c7b2f..c27cd4d98e 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -244,11 +244,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 - * existing header history (process restart, fork seed), with `startsSeries` - * preserving an explicit series boundary; `'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. + * 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, 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' | 'series' @@ -331,7 +331,7 @@ export interface SessionEventMap { 'request/header': { header: EpochHeader reason: RequestHeaderReason - /** A `change` or `resume` snapshot also begins a distinct model-message series. */ + /** A changed header also begins a distinct model-message series. */ startsSeries?: true } /** From 01a4cba9427a743754d497ace1fd0d73c147d9e6 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Fri, 4 Sep 2026 12:09:45 +0800 Subject: [PATCH 5/7] docs(web): describe client-only resume handling --- ...9-03-resume-headers-do-not-repeat-system-prompts.i18n.yaml | 4 ++-- .../2026-09-03-resume-headers-do-not-repeat-system-prompts.md | 4 ++-- ...26-09-03-resume-headers-do-not-repeat-system-prompts.zh.md | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.i18n.yaml index cb85b7af4a..d7871098ba 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.md -2026-09-03-resume-headers-do-not-repeat-system-prompts.md: 63acf56998276cdaff400186b7c33761eaf63788 -2026-09-03-resume-headers-do-not-repeat-system-prompts.zh.md: 9c09148f74042b70ef03fb92f1d3a2986a055da9 +2026-09-03-resume-headers-do-not-repeat-system-prompts.md: de5e509c9e57af35bc36c24004457f02b63fd99d +2026-09-03-resume-headers-do-not-repeat-system-prompts.zh.md: 008e25d8d507aac8fbaec7230bad433732f0efd0 diff --git a/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.md b/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.md index 63acf56998..de5e509c9e 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.md +++ b/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.md @@ -10,11 +10,11 @@ Forking a Session copies the source history into the child. The child's first mo ## Decision -The durable resume header remains unchanged because it records the request boundary needed for exact Session reconstruction. Chat now compares that full header with the preceding loaded Request Prompt and displays a non-empty system prompt only for the initial request, an explicit message-series start, or a real system-field change. An unchanged resume does not create a visible repetition. +The durable resume header records the request boundary needed for exact Session reconstruction. Chat compares that full header with the preceding loaded Request Prompt and displays a non-empty system prompt only for the initial request, an explicit message-series start, or a real system-field change. An unchanged resume does not create a visible repetition. A partial history window may begin with a non-initial header and lack the predecessor needed for comparison. Chat renders that system prompt conservatively. If prepend later supplies an identical predecessor, the existing request-prompt Node becomes hidden instead of being withdrawn; its key and page-lifetime anchor stay stable. A different system field remains visible. -Trajectory continues to expose every request header and its classified changes. The change is limited to Chat presentation and does not alter provider requests, Session events, or reconstruction. +Trajectory exposes every request header and its classified changes. Chat presentation does not alter provider requests, Session events, or reconstruction. ## Alternatives considered diff --git a/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.zh.md b/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.zh.md index 9c09148f74..008e25d8d5 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.zh.md @@ -10,11 +10,11 @@ fork Session 会把源会话历史复制到子会话。即使 system 字段与 ## 决策 -持久化 resume header 保持不变,因为它记录了精确重建 Session 所需的请求边界。Chat 现在会把完整 header 与前一条已加载 Request Prompt 比较,只在初始请求、显式消息序列起点或真实 system 字段变化时显示非空系统提示词。内容未变的 resume 不创建可见的重复行。 +持久化 resume header 记录精确重建 Session 所需的请求边界。Chat 会把完整 header 与前一条已加载 Request Prompt 比较,只在初始请求、显式消息序列起点或真实 system 字段变化时显示非空系统提示词。内容未变的 resume 不创建可见的重复行。 部分历史窗口可能以非初始 header 开头,因缺少前序 header 而无法比较。Chat 会保守渲染该系统提示词。如果 prepend 随后补入相同的前序 header,既有 request-prompt Node 会转为隐藏而不是被撤回;其 key 和页面生命周期内的 anchor 保持稳定。不同的 system 字段仍然可见。 -Trajectory 会继续展示每一条请求 header 及其变化分类。本次改动仅限 Chat 展示,不改变提供方请求、Session event 或重建行为。 +Trajectory 会展示每一条请求 header 及其变化分类。Chat 展示不会改变提供方请求、Session event 或重建行为。 ## 考虑过的替代方案 From f0ab6a60546b5fd970ac94dcedbd479f92e237b3 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Fri, 4 Sep 2026 16:31:34 +0800 Subject: [PATCH 6/7] fix(ui-chat): retain skill names on message upserts --- apps/web/tests/skill-user-invoke.e2e.ts | 9 +++++---- .../conversation-nodes/chat-snapshot-builder.ts | 17 ++++++++++------- .../tests/skill-name-projector.client.spec.ts | 10 ++++++++++ 3 files changed, 25 insertions(+), 11 deletions(-) diff --git a/apps/web/tests/skill-user-invoke.e2e.ts b/apps/web/tests/skill-user-invoke.e2e.ts index e9b6891b7b..4d033f2b8f 100644 --- a/apps/web/tests/skill-user-invoke.e2e.ts +++ b/apps/web/tests/skill-user-invoke.e2e.ts @@ -114,12 +114,14 @@ describe.skipIf(MODE === 'record')('web e2e: user-explicit skill invocation thro const settled = scaffold.whenTurnSettled() await composer.fill(`/${SKILL_NAME} ${ARGS_TEXT}`) await composer.press('Enter') + await settled // The gesture stays an ordinary user bubble (decorated /name token plus // the trailing text), ahead of the injected context. - const bubble = page.locator('[data-ref-chip="skill"]').first() - await bubble.waitFor({ timeout: 15_000 }) - expect(await bubble.textContent()).toBe(`/${SKILL_NAME}`) + await expect.poll( + () => page.locator('[data-ref-chip="skill"]').allTextContents(), + { timeout: 15_000 }, + ).toContain(`/${SKILL_NAME}`) // The rendered body arrives as a context-injection row named after the // skill. Context plus the final answer contributes no summary count, so @@ -127,7 +129,6 @@ describe.skipIf(MODE === 'record')('web e2e: user-explicit skill invocation thro const injectionFlow = page.locator('[data-chat-flow-kind="context"]').filter({ hasText: SKILL_NAME }) await injectionFlow.waitFor({ state: 'attached', timeout: 15_000 }) await page.getByText('USER_INVOKE_REPLY', { exact: false }).first().waitFor({ timeout: 20_000 }) - await settled const process = page.getByRole('button', { name: 'Thought for a while', exact: true }) await process.waitFor({ state: 'visible', timeout: 10_000 }) await expandOwningTurnProcess(page, injectionFlow) diff --git a/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts index 34f544c4ff..ed03133d10 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts @@ -565,10 +565,10 @@ function sameSlashEntry(left: SlashEntry, right: SlashEntry): boolean { * tokens its own text carries. * * The index holds only messages, skill injections, and boundaries, ordered by - * `anchorSeq`. An apply re-reads just the batches around the Nodes whose - * classification changed and never scans the store, so an assistant - * streaming frame costs nothing here (the append hot path never scans the - * Chat Nodes). + * `anchorSeq`. An apply re-reads just the batches around classification + * changes and direct-message upserts and never scans the store, so an + * assistant streaming frame costs nothing here (the append hot path never + * scans the Chat Nodes). */ export class SkillNameProjector { private readonly entries = new Map() @@ -601,8 +601,8 @@ export class SkillNameProjector { } /** - * Fold one incremental upsert set: re-read only the batches around the - * Nodes whose classification changed. + * Fold one incremental upsert set: re-read only the batches around changed + * classifications and direct-message upserts. * @param upserts - the changed Nodes. * @param store - the resident Nodes, read by key for the messages of an affected batch. * @returns the upserts plus any resident message whose names changed. @@ -616,7 +616,10 @@ export class SkillNameProjector { const next = slashEntryOf(node) const previous = this.entries.get(node.key) if (previous !== undefined) { - if (next !== null && sameSlashEntry(previous, next)) continue + if (next !== null && sameSlashEntry(previous, next)) { + if (next.kind === 'message') dirty.push(next.seq) + continue + } this.remove(previous) dirty.push(previous.seq) } diff --git a/packages/client/ui-chat/tests/skill-name-projector.client.spec.ts b/packages/client/ui-chat/tests/skill-name-projector.client.spec.ts index 0be8dca394..09eff68e3b 100644 --- a/packages/client/ui-chat/tests/skill-name-projector.client.spec.ts +++ b/packages/client/ui-chat/tests/skill-name-projector.client.spec.ts @@ -66,6 +66,16 @@ describe('SkillNameProjector', () => { expect(store.valuesCalls).toBe(0) }) + it('retains batch names when the same message is upserted', () => { + const projector = new SkillNameProjector() + const replaced = projector.replace([user(2, '/demo go'), skill(5, 'demo'), assistant(6)]) + const store = storeOf(replaced) + const out = projector.apply([user(2, '/demo revised')], store) + expect(out.map(node => node.key)).toEqual(['user:2']) + expect(names(out[0])).toEqual(['demo']) + expect(store.valuesCalls).toBe(0) + }) + it('a late skill injection updates only the direct messages of its batch', () => { const projector = new SkillNameProjector() const replaced = projector.replace([user(2, '/demo go'), assistant(6), user(10, 'unrelated')]) From eb60b74f4719cfea15318ede3249878646bcd26e Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Fri, 4 Sep 2026 16:43:09 +0800 Subject: [PATCH 7/7] test(web): wait for seeded agent attachment --- apps/web/tests/chat-long-interactions.e2e.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/web/tests/chat-long-interactions.e2e.ts b/apps/web/tests/chat-long-interactions.e2e.ts index d3726046cc..17e639814c 100644 --- a/apps/web/tests/chat-long-interactions.e2e.ts +++ b/apps/web/tests/chat-long-interactions.e2e.ts @@ -173,6 +173,10 @@ describe('web e2e: long Chat interaction contract', () => { it.skipIf(MODE === 'record')('keeps heterogeneous rows and their actions bound to exact semantic identities', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-chat-long-interactions')) + await expect.poll( + () => scaffold.ctx.agents.get(SessionId(SESSION_ID)) !== undefined, + { timeout: 10_000 }, + ).toBe(true) const source = scaffold.ctx.agents.get(SessionId(SESSION_ID)) if (source === undefined) throw new Error('seeded long-history agent is not attached')