fix(web): avoid repeated system prompt on resume

This commit is contained in:
Dudu-0223
2026-09-03 14:45:01 +08:00
parent 0c99ae8557
commit 6ad01cbd8d
12 changed files with 117 additions and 26 deletions
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-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
@@ -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.
@@ -278,7 +278,7 @@ Chat `order` 的结构性变化仍可能重排当前可见 key;纯 data 更新
|---|---|---|---|
| Inbox | `none` | 不生成 Node | prepend 补前序 splice 时沿 Reader 链重算 next-step ID statenext-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 为 RAFfinal immediate,纯 usage/finish 为 none | 同 key `assistant-step`,状态为 running/settled/interrupted | scalar 与 packed reducer 等价;缺 `step/start` 可先用 Matches fallbackLocation close 生成中断表现 |
| Tool | 默认 immediate | 一个递归 `tool-call` root,包含全部 `subCalls` | result-only 历史窗口可 fallbackrunning→settled 保持 key |
| Command | 默认 immediate | 普通 `command` 或集成 `manual-compaction` | checkpoint 到达可改变 anchor,但不改变 Context key |
@@ -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。
@@ -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
@@ -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.
@@ -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 只渲染一个`系统提示词`控件。
+16 -3
View File
@@ -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<string, unknown>): { seq: number; time: number } & Record<string, unknown> => ({
@@ -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
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-chat/README.md
README.md: 79214bf2feb8384cecbb152aa31af162dc6dfdde
README.zh.md: 674959e1974c74abba75f179eff50fe8488458fc
README.md: 5a557f2a2125bddfe18631359665d6fa5e998458
README.zh.md: 2fc241ad71e35eb54afcf4dde38c361248848d5f
+1 -1
View File
@@ -25,7 +25,7 @@ The browser Chat target for Conversation assembly. It registers Chat event defin
<a id="system-prompt-row"></a>
## 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.
-----
+1 -1
View File
@@ -25,7 +25,7 @@ Conversation 组装的浏览器 Chat target。本包注册 Chat event definition
<a id="system-prompt-row"></a>
## 系统提示词行
Chat 会为每个非空的初始或恢复请求、显式消息序列起点真实 system 字段变化显示一行默认折叠的`系统提示词`同一序列内仅配置或仅工具变化、工具步骤与重试不会重复该行。该行位于请求的用户消息之前,与提供方 envelope 顺序一致;展开后显示保留原始换行的精确模型可见文本。历史窗口不完整时,非初始 header 会保守显示,直到前一页到达;没有系统提示词的 header 不创建该行。
Chat 会为非空的初始请求、显式消息序列起点真实 system 字段变化,或前序 header 尚未进入已加载历史窗口的非初始请求显示一行默认折叠的`系统提示词`。前序 header 到达后,内容未变的 resume 不会重复该行;同一序列内仅配置或仅工具变化、工具步骤与重试不会重复。该行位于请求的用户消息之前,与提供方 envelope 顺序一致;展开后显示保留原始换行的精确模型可见文本。没有系统提示词的 header 不创建该行。
-----
@@ -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' },
)
},
}
}
@@ -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' } },
])
})