From fa6bf62a981bd10d0f9ecd234954d4f74bde9bce Mon Sep 17 00:00:00 2001
From: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
Date: Mon, 7 Sep 2026 13:24:04 +0800
Subject: [PATCH 01/22] fix(chat): settle pinned scroll deliveries before
layout growth
---
...ed-scroll-delivery-before-layout.i18n.yaml | 6 +
...07-pinned-scroll-delivery-before-layout.md | 23 ++++
...pinned-scroll-delivery-before-layout.zh.md | 23 ++++
apps/web/tests/chat-scroll-contract.e2e.ts | 1 +
packages/client/ui-chat/README.i18n.yaml | 4 +-
packages/client/ui-chat/README.md | 2 +-
packages/client/ui-chat/README.zh.md | 2 +-
.../ui-chat/src/client/chat/ChatView.tsx | 9 +-
.../ui-chat/tests/chat-view.client.spec.tsx | 122 ++++++++++++++++++
9 files changed, 186 insertions(+), 6 deletions(-)
create mode 100644 .agents/notes/implemented/bug-fix/2026-09-07-pinned-scroll-delivery-before-layout.i18n.yaml
create mode 100644 .agents/notes/implemented/bug-fix/2026-09-07-pinned-scroll-delivery-before-layout.md
create mode 100644 .agents/notes/implemented/bug-fix/2026-09-07-pinned-scroll-delivery-before-layout.zh.md
diff --git a/.agents/notes/implemented/bug-fix/2026-09-07-pinned-scroll-delivery-before-layout.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-07-pinned-scroll-delivery-before-layout.i18n.yaml
new file mode 100644
index 0000000000..ac6b884af9
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-09-07-pinned-scroll-delivery-before-layout.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-07-pinned-scroll-delivery-before-layout.md
+2026-09-07-pinned-scroll-delivery-before-layout.md: 7a00cf653824df13272fcf0cc2baf35b298f170c
+2026-09-07-pinned-scroll-delivery-before-layout.zh.md: d78c388e4e7a8f6f2fa6070149e652e0e25cc358
diff --git a/.agents/notes/implemented/bug-fix/2026-09-07-pinned-scroll-delivery-before-layout.md b/.agents/notes/implemented/bug-fix/2026-09-07-pinned-scroll-delivery-before-layout.md
new file mode 100644
index 0000000000..7a00cf6538
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-09-07-pinned-scroll-delivery-before-layout.md
@@ -0,0 +1,23 @@
+# Agent Note: Settle pinned scroll deliveries before layout changes
+
+Status: implemented
+
+English | [中文](2026-09-07-pinned-scroll-delivery-before-layout.zh.md)
+
+## Problem
+
+A delayed scroll sample compares positions from different layouts. While Chat is pinned, a composer or transcript shrink can move the browser floor; subsequent growth can move the browser position again before `scrollend` or the sampling timer. Deferring follow during that interval leaves the observed-top ledger stale and can classify browser layout movement as reader input, disabling follow without a reader gesture.
+
+## Decision
+
+[ChatView](../../../../packages/client/ui-chat/src/client/chat/ChatView.tsx) samples pinned scroll deliveries synchronously through the same sample operation that clears pending work. This preserves the existing observed-top comparison for genuine reader movement and releases layout follow before further growth. Pinned samples use scroll metrics, not semantic-row geometry; moving away still disarms follow immediately. Away-reader samples remain coalesced at the existing interval or `scrollend`.
+
+## Alternatives considered
+
+**Defer every delivery.** Coalescing reduces geometry work while reading history, but a pinned browser position and its floor must be attributed in the same layout. A longer timeout or retry cannot recover ownership once the stale comparison disarms it.
+
+**Sample every delivery synchronously.** This restores attribution but also repeats semantic-anchor and reading-line measurements throughout an away-reader scroll burst. Only pinned ownership needs the immediate path.
+
+## Consequences
+
+Pinned deliveries incur immediate scroll-metric reads. History reading retains its bounded sampling cadence, and explicit return-to-bottom deliveries clear any pending away sample. [Focused tests](../../../../packages/client/ui-chat/tests/chat-view.client.spec.tsx) cover shrink/regrowth before scrollend, observer growth without row measurements, repinning with a pending sample, timer and scrollend sampling, and unmount cancellation. The [keyless browser scenario](../../../../apps/web/tests/chat-scroll-contract.e2e.ts) covers pinned Send, real scroll-away input, streaming, and tool disclosure across the long transcript.
diff --git a/.agents/notes/implemented/bug-fix/2026-09-07-pinned-scroll-delivery-before-layout.zh.md b/.agents/notes/implemented/bug-fix/2026-09-07-pinned-scroll-delivery-before-layout.zh.md
new file mode 100644
index 0000000000..d78c388e4e
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-09-07-pinned-scroll-delivery-before-layout.zh.md
@@ -0,0 +1,23 @@
+# Agent Note: 在布局变化前处理贴底滚动事件
+
+Status: implemented
+
+[English](2026-09-07-pinned-scroll-delivery-before-layout.md) | 中文
+
+## Problem
+
+延迟的滚动采样会比较来自不同布局的位置。Chat 贴底时,输入框或 transcript(文本记录)收缩可能改变浏览器底部位置;随后的增长又可能在 `scrollend` 或采样定时器触发前改变浏览器位置。在此期间推迟跟随会使已观察顶部位置记录过期,把浏览器布局移动误判为读者输入,在没有读者操作时关闭跟随。
+
+## Decision
+
+[ChatView](../../../../packages/client/ui-chat/src/client/chat/ChatView.tsx) 通过同一个清除待处理工作的采样操作,同步采样贴底滚动事件。该机制保留现有的已观察顶部位置比较来识别真实读者移动,并在后续增长前恢复布局跟随。贴底采样只读取滚动指标,不读取语义行几何;离底移动仍会立即关闭跟随。离底读者的采样仍合并到现有周期或 `scrollend` 时执行。
+
+## Alternatives considered
+
+**延迟所有事件。** 合并采样减少阅读历史时的几何计算,但贴底浏览器位置及其底部必须在同一布局中完成归因。过期比较关闭跟随后,延长超时或重试都无法恢复归属。
+
+**同步采样所有事件。** 这能恢复归因,却也会在离底读者连续滚动时重复测量语义锚点和阅读线。只有贴底归属需要立即处理。
+
+## Consequences
+
+贴底事件会立即读取滚动指标。历史阅读保留有界采样节奏,显式回到底部的滚动事件会清除任何待处理的离底采样。[聚焦测试](../../../../packages/client/ui-chat/tests/chat-view.client.spec.tsx) 覆盖 scrollend 前的收缩与增长、无需行测量的观察器增长、存在待处理采样时重新贴底、定时器与 scrollend 采样,以及卸载取消。[无密钥浏览器场景](../../../../apps/web/tests/chat-scroll-contract.e2e.ts) 覆盖长 transcript 中贴底发送、真实离底输入、流式输出与工具详情展开。
diff --git a/apps/web/tests/chat-scroll-contract.e2e.ts b/apps/web/tests/chat-scroll-contract.e2e.ts
index 3dae7b93ff..d7ee3ae289 100644
--- a/apps/web/tests/chat-scroll-contract.e2e.ts
+++ b/apps/web/tests/chat-scroll-contract.e2e.ts
@@ -660,6 +660,7 @@ describe('web e2e: long Chat scroll contract', () => {
await liveRow.waitFor({ timeout: 15_000 })
expect(await liveRow.getAttribute('data-state')).toBe('running')
await expectBottom(world.page)
+ expect(await world.page.getByRole('button', { name: 'Back to bottom', exact: true }).count()).toBe(0)
await wheelTranscript(world.page, -1_200)
await world.page.getByRole('button', { name: 'Back to bottom', exact: true }).waitFor({ timeout: 10_000 })
diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml
index a2b157652e..80d26dca50 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: 405e5095d75c3e8b752c028bbc187307a9647bb6
-README.zh.md: 7f57bc3480ea0186e89d12e8e443926906677742
+README.md: 34b66da24b35f8cfd4c26da9b759e7991c5cb856
+README.zh.md: a9b7c8fca3e8137950dd02cd5398c11f15344757
diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md
index 405e5095d7..34b66da24b 100644
--- a/packages/client/ui-chat/README.md
+++ b/packages/client/ui-chat/README.md
@@ -46,7 +46,7 @@ Settings → General exposes a persisted `Normal` / `Compact` conversation-displ
## Scroll ownership
-Chat restores semantic anchors across history prepend and renderer remounts. While the reader is pinned to the floor, `ResizeObserver` follows the new floor and selects the latest loaded Turn without reading row geometry. Once the reader moves away, flow-height changes preserve the top position and the reading-line geometry selects the active Turn. Turn-rail previews paint above sticky Markdown code-block banners, while the rail frame remains inside the transcript band above the composer.
+Chat restores semantic anchors across history prepend and renderer remounts. Pinned scroll deliveries update follow ownership immediately, before subsequent layout changes can invalidate their floor; away-reader anchor sampling remains coalesced until the sampling interval or `scrollend`. While the reader is pinned to the floor, `ResizeObserver` follows the new floor and selects the latest loaded Turn without reading row geometry. Once the reader moves away, flow-height changes preserve the top position and the reading-line geometry selects the active Turn. Turn-rail previews paint above sticky Markdown code-block banners, while the rail frame remains inside the transcript band above the composer.
-----
diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md
index 7f57bc3480..a9b7c8fca3 100644
--- a/packages/client/ui-chat/README.zh.md
+++ b/packages/client/ui-chat/README.zh.md
@@ -46,7 +46,7 @@ Chat 会为非空的初始请求、显式消息序列起点、真实 system 字
## 滚动归属
-Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点。读者跟随底部时,`ResizeObserver` 追随新的底部,并且无需读取行几何就选中最后一个已加载 Turn;读者离开底部后,高度变化会保持顶部位置,再由阅读线几何选择活跃 Turn。轮次导航预览位于 Markdown 代码块粘性头栏上方,而导航外框始终处于 composer 上方的 transcript 区域内。
+Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点。贴底滚动事件会立即更新跟随归属,避免后续布局变化使其底部位置失效;离底读者的锚点采样仍合并到采样周期或 `scrollend` 时执行。读者跟随底部时,`ResizeObserver` 追随新的底部,并且无需读取行几何就选中最后一个已加载 Turn;读者离开底部后,高度变化会保持顶部位置,再由阅读线几何选择活跃 Turn。轮次导航预览位于 Markdown 代码块粘性头栏上方,而导航外框始终处于 composer 上方的 transcript 区域内。
-----
diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx
index a8dc57d321..e8b4438266 100644
--- a/packages/client/ui-chat/src/client/chat/ChatView.tsx
+++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx
@@ -579,8 +579,9 @@ export function ChatView({
scheduleActiveTurn()
}
- // Raw scroll events only schedule work. Geometry is sampled at most once
- // per interval, with scrollend providing the final sample for a short burst.
+ // Pinned deliveries must settle before layout growth can invalidate their
+ // floor. Away-reader anchor geometry stays coalesced until the interval or
+ // scrollend; pinned samples read only scroll metrics unless the reader leaves.
useEffect(() => {
const local = listRef.current
/* v8 ignore next -- ref-null guard: effect runs after the list node commits. */
@@ -597,6 +598,10 @@ export function ChatView({
}
const onScroll = (): void => {
scrollSamplePendingRef.current = true
+ if (atBottomRef.current) {
+ sample()
+ return
+ }
sampleTimer ??= window.setTimeout(sample, SCROLL_SAMPLE_INTERVAL_MS)
}
el.addEventListener('scroll', onScroll, { passive: true })
diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx
index 2c8fe220ea..f6b7ec63a9 100644
--- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx
+++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx
@@ -2387,6 +2387,128 @@ describe('ChatView', () => {
expect(scroller.scrollTop).toBe(900)
})
+ it('keeps following when a shrink clamp regrows before scrollend', () => {
+ const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
+ const view = render()
+ const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
+ const metrics = installScrollMetrics(scroller, 1_000, 300)
+ scroller.scrollTop = 700
+ fireEvent.scroll(scroller)
+ fireEvent(scroller, new Event('scrollend'))
+
+ metrics.setLayout(800, 700)
+ fireEvent.scroll(scroller)
+ metrics.setHeight(962)
+ act(() => { h.setSession({ running: true }) })
+ fireEvent(scroller, new Event('scrollend'))
+
+ expect(scroller.scrollTop).toBe(662)
+ expect(view.queryByLabelText('回到底部')).toBeNull()
+ expect(h.chatScroll.read()).toBeNull()
+ })
+
+ it('settles pinned deliveries before observer growth without reading row geometry', () => {
+ let notify: (() => void) | undefined
+ class ResizeObserverStub {
+ constructor(callback: ResizeObserverCallback) {
+ notify = () => { callback([], this as unknown as ResizeObserver) }
+ }
+
+ observe = vi.fn()
+ disconnect = vi.fn()
+ }
+ vi.stubGlobal('ResizeObserver', ResizeObserverStub)
+ const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
+ const view = render()
+ const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
+ const metrics = installScrollMetrics(scroller, 9_931, 300)
+ expect(notify).toBeDefined()
+ scroller.scrollTop = 9_631
+ fireEvent.scroll(scroller)
+ fireEvent(scroller, new Event('scrollend'))
+ const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
+ rect.mockClear()
+ try {
+ metrics.setLayout(9_918, 9_631)
+ fireEvent.scroll(scroller)
+ metrics.setHeight(10_013)
+ act(() => { notify?.() })
+ expect(scroller.scrollTop).toBe(9_713)
+ fireEvent.scroll(scroller)
+ metrics.setHeight(10_093)
+ act(() => { notify?.() })
+ expect(scroller.scrollTop).toBe(9_793)
+ expect(rect).not.toHaveBeenCalled()
+ expect(h.chatScroll.read()).toBeNull()
+ } finally {
+ rect.mockRestore()
+ }
+ })
+
+ it('clears an away sample when a back-to-bottom delivery restores pinned ownership', () => {
+ const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
+ const view = render()
+ const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
+ const metrics = installScrollMetrics(scroller, 1_000, 300)
+ scroller.scrollTop = 700
+ fireEvent.scroll(scroller)
+ scroller.scrollTop = 500
+ fireEvent.scroll(scroller)
+ fireEvent(scroller, new Event('scrollend'))
+ scroller.scrollTop = 400
+ fireEvent.scroll(scroller)
+ fireEvent.click(view.getByLabelText('回到底部'))
+ fireEvent.scroll(scroller)
+ metrics.setHeight(1_200)
+ act(() => { h.setSession({ running: true }) })
+ expect(scroller.scrollTop).toBe(900)
+ expect(h.chatScroll.read()).toBeNull()
+ })
+
+ it('samples away-reader geometry on the interval or scrollend and cancels it on unmount', () => {
+ vi.useFakeTimers()
+ try {
+ const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
+ const view = render()
+ const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
+ installScrollMetrics(scroller, 1_000, 300)
+ scroller.scrollTop = 700
+ fireEvent.scroll(scroller)
+ scroller.scrollTop = 500
+ fireEvent.scroll(scroller)
+ expect(view.getByLabelText('回到底部')).toBeTruthy()
+ const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
+ try {
+ act(() => { vi.advanceTimersByTime(500) })
+ rect.mockClear()
+ scroller.scrollTop = 400
+ fireEvent.scroll(scroller)
+ scroller.scrollTop = 300
+ fireEvent.scroll(scroller)
+ act(() => { vi.advanceTimersByTime(499) })
+ expect(rect).not.toHaveBeenCalled()
+ act(() => { vi.advanceTimersByTime(1) })
+ expect(rect).toHaveBeenCalled()
+ rect.mockClear()
+ scroller.scrollTop = 200
+ fireEvent.scroll(scroller)
+ expect(rect).not.toHaveBeenCalled()
+ fireEvent(scroller, new Event('scrollend'))
+ expect(rect).toHaveBeenCalled()
+ scroller.scrollTop = 100
+ fireEvent.scroll(scroller)
+ view.unmount()
+ rect.mockClear()
+ act(() => { vi.advanceTimersByTime(500) })
+ expect(rect).not.toHaveBeenCalled()
+ } finally {
+ rect.mockRestore()
+ }
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
it('uses the last delivered top when compositor scrolling precedes scroll delivery', () => {
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
const view = render()
From c599ef87c458b28bfc7b15ee6e9e978e1a941677 Mon Sep 17 00:00:00 2001
From: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
Date: Sun, 6 Sep 2026 14:48:09 +0800
Subject: [PATCH 02/22] test(perf): baseline tool-heavy backend workflows
---
...backend-continuation-performance.i18n.yaml | 6 +
...-09-06-backend-continuation-performance.md | 60 ++++++++
...-06-backend-continuation-performance.zh.md | 60 ++++++++
.../agent-continuation/README.i18n.yaml | 6 +
benchmarks/agent-continuation/README.md | 31 ++++
benchmarks/agent-continuation/README.zh.md | 31 ++++
.../agent-continuation.bench.ts | 86 +++++++++++
.../agent-continuation.worker.ts | 144 ++++++++++++++++++
.../child-catalog.worker.ts | 95 ++++++++++++
.../agent-continuation/profile-adapter.ts | 42 +++++
.../profile-continuation.worker.ts | 85 +++++++++++
benchmarks/agent-continuation/workload.ts | 110 +++++++++++++
benchmarks/package.json | 3 +
benchmarks/tsdown.config.ts | 12 ++
pnpm-lock.yaml | 9 ++
15 files changed, 780 insertions(+)
create mode 100644 .agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.i18n.yaml
create mode 100644 .agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.md
create mode 100644 .agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.zh.md
create mode 100644 benchmarks/agent-continuation/README.i18n.yaml
create mode 100644 benchmarks/agent-continuation/README.md
create mode 100644 benchmarks/agent-continuation/README.zh.md
create mode 100644 benchmarks/agent-continuation/agent-continuation.bench.ts
create mode 100644 benchmarks/agent-continuation/agent-continuation.worker.ts
create mode 100644 benchmarks/agent-continuation/child-catalog.worker.ts
create mode 100644 benchmarks/agent-continuation/profile-adapter.ts
create mode 100644 benchmarks/agent-continuation/profile-continuation.worker.ts
create mode 100644 benchmarks/agent-continuation/workload.ts
diff --git a/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.i18n.yaml b/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.i18n.yaml
new file mode 100644
index 0000000000..d6f7815fa1
--- /dev/null
+++ b/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.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/testing/2026-09-06-backend-continuation-performance.md
+2026-09-06-backend-continuation-performance.md: 4c18e440c98135f140c4957a7c5e7c73188c694c
+2026-09-06-backend-continuation-performance.zh.md: 2cf1793faf39ab0c3bc10b1932db15e64961e8b8
diff --git a/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.md b/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.md
new file mode 100644
index 0000000000..4c18e440c9
--- /dev/null
+++ b/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.md
@@ -0,0 +1,60 @@
+# Agent Note: Performance baselines for tool-heavy backend continuation
+
+Status: implemented
+
+English | [中文](2026-09-06-backend-continuation-performance.zh.md)
+
+## Problem
+
+Opening one Session does not measure the repeated cost of preparing model requests after a long tool conversation, executing another tool-heavy turn, or discovering multiple inactive fork children. The [Session-opening gate](2026-09-04-session-open-performance-gate.md) covers first history and activation but deliberately stops before new model work. Its text/reasoning workload also lacks historical tool-call arguments and large tool results.
+
+## Decision
+
+The [agent-continuation benchmark](../../../../benchmarks/agent-continuation/agent-continuation.bench.ts) adds three scenario groups, including a shipped-profile variant, without changing product implementations. They use current-generation Zstandard Sessions authored through production append, stream accumulation, and persistence APIs. A separate seed process creates the deterministic source before measurement; each sample copies that source into its private root and starts a fresh compiled plain-Node worker. No recorded Session, ambient repository, network, private Harness home, or deployed GUI supplies input.
+
+The shared history has 800 completed two-step turns, four tool calls per turn, and 2,048-character tool results: 13,600 events and 5,600 model messages. Each assistant reply carries reasoning, text, and compact streamed records; tool replies additionally carry fragmented arguments. Fixed timestamps and ids describe the seed. Live synthetic replies use the real loop's clocks and ids without overriding process globals.
+
+| Case | Timed operation | Endpoint |
+|---|---|---|
+| Request history | After unmeasured cold resume, deliver 40 sequential text-only turns over the tool-heavy history, then flush | Idle Agent with all 40 model requests completed; reports turn and final-flush time separately |
+| Tool continuation | Cold resume, 20 sequential turns with eight parallel-safe synthetic tool calls and a final reply per turn, then flush | Idle Agent with 40 model requests and 160 completed tool executions; reports resume, turns, and final flush separately |
+| Shipped SDK workflow | Launch built dsh with the sdk-minimal profile, deliver 100 sequential turns with eight real file-view calls per turn, then close the SDK | SDK receives 200 assistant messages and 800 successful file results; includes Loader boot, stdio JSON-RPC, persistence, and shutdown |
+| Child catalog | List 16 inactive seeded fork children twice through the real subagent and Session query services | Two complete healthy catalogs with observations released; each child inherits 80 tool-heavy turns and owns its descriptor after the exact fork cut |
+
+The tool execution pipeline, request preparation, Session projections required by those services, persistence, and catalog observations remain production code. Only the model adapter and bounded tool body are synthetic. The adapter retains a request counter, not request objects, so the fixture cannot manufacture a growing retention cost. Sequential input means each idle interval belongs to the one request delivered by this worker; it does not generalize idle to a per-message completion API under concurrent input.
+
+Five samples report raw wall time, CPU user/system time, peak RSS, endpoint counts, and the minimum, median, and maximum total wall time. Budgets enforce the unrounded median. Continuation additionally measures retained heap against an initialized Host: two explicit GCs separated by an event-loop yield precede and follow the timed operation, while the idle Agent remains reachable. The measured delta therefore includes the resident historical Session and live additions, not just newly appended turns. GC and teardown are outside timing; flush is inside. Request-history retention starts after resume and is diagnostic only. Catalog peak RSS is diagnostic; no retained-heap budget claims to measure already-released child observations.
+
+The parent bounds every child to 60 seconds, checks timeout, signal, exit, and report independently, awaits process close, and removes private roots after failures. Context and Agent teardown run in finally blocks. Seed processes cannot warm the measured process's caches. Filesystem caches are not forcibly evicted: cold means a fresh process, not cold physical storage.
+
+## Calibration evidence
+
+The implementation reference is `925e012340f033f0521e802ba8569ce6dd7ef1ac` on Apple M4 Pro, macOS arm64, Node 24.19.0. Two exclusive five-sample runs use the same seed and no product optimization. Durations below are milliseconds; source expectations round above the observed run medians rather than imposing an unimplemented optimization target.
+
+| Case | Run 1 raw totals | Run 2 raw totals | Medians | Reference expectation | CI budget |
+|---|---|---|---|---:|---:|
+| Request history | 209.134, 210.333, 208.959, 236.355, 238.685 | 222.833, 213.911, 208.089, 211.494, 209.137 | 210.333 / 211.494 | 220 | 550 |
+| Tool continuation | 358.953, 324.790, 318.861, 320.119, 322.896 | 324.280, 321.952, 340.409, 325.470, 324.312 | 322.896 / 324.312 | 340 | 850 |
+| Child catalog | 318.730, 309.006, 311.404, 308.565, 310.105 | 308.670, 310.030, 280.086, 303.084, 284.829 | 310.105 / 303.084 | 320 | 800 |
+
+Continuation retains approximately 22.295 MiB; its source expectation is 23 MiB and its budget is 28.75 MiB. Time expectations use the existing [calibration helper](../../../../benchmarks/support/calibration.ts): 2× shared CI time scale and 1.25× variance headroom. Memory uses only 1.25× headroom. The scale is inherited from the existing lane's calibration, not a new Linux measurement of these cases; CI evidence remains necessary when runner characteristics change. Baseline budgets protect the measured implementation; tighter budgets belong with a measured behavior-preserving fix.
+
+A separate plain-Node request-history CPU profile attributes 132.876 ms of sampled self time to deepFreeze called by buildRequest during a 211.300 ms operation. This identifies repeated traversal of already-frozen history as a focused investigation target, not a proven optimization result. Catalog first/repeat timings remain separate because a second listing still reads body-bearing seeded children after observations are released.
+
+The shipped SDK variant completes 100 turns, 200 requests, and 800 real file reads. Its five-sample smoke totals are 1,521.773, 1,463.465, 1,689.701, 1,365.485, and 1,417.106 ms (median 1,463.465 ms); a full-suite repeat reports 1,596.183, 1,784.536, 2,120.082, 1,405.365, and 1,355.894 ms (median 1,596.183 ms). Its 1,700 ms reference expectation yields a 4,250 ms CI budget. The repeat also slows the unchanged service cases, so it is validation under variable host load rather than evidence to relax their exclusive calibration. The SDK process receives an allowlisted environment and private home/workspace. A 40-second deadline starts SDK shutdown; every path awaits the same memoized close promise before the outer worker’s 60-second deadline. Profile timing includes boot, all turns, and shutdown, reported separately; no parent-process CPU or heap metric is presented as server memory. The adapter does not serialize requests for an external model provider.
+
+## Alternatives considered
+
+**Repeat existing migration and first-open variants.** Rejected: those twelve cases already distinguish read-only preparation from writable publication. These cases use the current generation and begin or continue actual model work, or enumerate a corpus rather than open one Session.
+
+**Measure only deriveMessages.** Rejected: its incremental cache does not include complete request freezing, adapter dispatch, live append, or persistence. Actual sequential requests protect the cost the Agent pays per step.
+
+**Use only unseeded children with warm projection-cache rows.** Rejected: that path bypasses body observations and misses the exact inherited-cut requirement of fork children. The catalog intentionally omits the optional projection cache and reports the seeded fallback path; it does not characterize cache-hit discovery.
+
+**Apply an optimization and its desired budget together with the first measurements.** Rejected: a baseline-only layer remains independently mergeable and records the current workload before attribution or implementation changes. Source constants cannot be overridden by environment variables.
+
+## Consequences
+
+The lane adds four cases in three scenario groups and twenty measured workers, plus two seed processes. The integrated continuation case spans resume through completed model/tool work and durable flush. The shipped SDK workflow additionally includes profile boot, SDK transport, real file tools, and shutdown; only its model adapter is synthetic. It starts a fresh Session because the public SDK prompt API creates rather than resumes stored identities. Neither path includes network model latency, provider-specific request serialization, optional user plugins, compaction, failed tool results, images, cancellation, or browser rendering. Functional tests retain responsibility for event contents, immutable messages, tool semantics, fork lineage, and read-only versus writable side effects; endpoint counts prevent timing a skipped workload without duplicating those assertions.
+
+This note supplements, rather than supersedes, the Session-opening gate's isolation and calibration rationale. No existing active decision is retired.
diff --git a/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.zh.md b/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.zh.md
new file mode 100644
index 0000000000..2cf1793faf
--- /dev/null
+++ b/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.zh.md
@@ -0,0 +1,60 @@
+# Agent Note: 工具密集后端续聊的性能基线
+
+Status: implemented
+
+[English](2026-09-06-backend-continuation-performance.md) | 中文
+
+## 问题
+
+打开一个 Session 不能衡量长工具对话后重复准备模型请求、执行更多工具密集轮次或发现多个非活动 fork 子会话的成本。[Session 打开门禁](2026-09-04-session-open-performance-gate.zh.md)覆盖首屏历史和激活,但有意停在新的模型工作开始前。它的文本与推理负载也不包含历史工具调用参数和大型工具结果。
+
+## 决定
+
+[agent-continuation 基准](../../../../benchmarks/agent-continuation/agent-continuation.bench.ts)增加三个场景组,包含一个已发布 profile 变体,不修改产品实现。它们通过生产追加、流累积和持久化 API 构造当前代际的 Zstandard Session。独立播种进程在测量前生成确定性源数据;每个样本将其复制到私有根目录,并启动新的已编译纯 Node worker。输入不来自录制 Session、环境仓库、网络、私有 Harness 主目录或已部署 GUI。
+
+共享历史包含 800 个已完成的双步骤轮次,每轮四次工具调用,工具结果为 2,048 字符:共 13,600 个事件和 5,600 条模型消息。每条助手回复携带推理、文本和紧凑流记录;请求工具的回复还携带分片参数。播种数据使用固定时间戳和 id。实时合成回复使用真实循环的时钟和 id,不覆盖进程全局状态。
+
+| 用例 | 计时操作 | 终点 |
+|---|---|---|
+| 请求历史 | 在不计时的冷恢复后,向工具密集历史顺序提交 40 个纯文本轮次,然后 flush | 空闲 Agent,已完成全部 40 次模型请求;分别报告轮次和最终 flush 时间 |
+| 工具续聊 | 冷恢复,顺序执行 20 个轮次,每轮八次可安全并行的合成工具调用和一条最终回复,然后 flush | 空闲 Agent,已完成 40 次模型请求和 160 次工具执行;分别报告恢复、轮次和最终 flush 时间 |
+| 已发布 SDK 工作流 | 使用 sdk-minimal profile 启动已构建 dsh,顺序提交 100 个轮次,每轮八次真实文件查看调用,然后关闭 SDK | SDK 收到 200 条助手消息和 800 个成功文件结果;包含 Loader 启动、stdio JSON-RPC、持久化和关闭 |
+| 子会话目录 | 通过真实 subagent 和 Session 查询服务,两次列出 16 个非活动、带种子的 fork 子会话 | 两份完整健康目录,观察已释放;每个子会话继承 80 个工具密集轮次,并在精确 fork 切点后拥有自己的描述符 |
+
+工具执行管线、请求准备、这些服务所需的 Session 投影、持久化和目录观察均保留生产代码。只有模型适配器和有界工具体是合成的。适配器只保留请求计数,不保留请求对象,因此 fixture(测试前置数据)不会制造不断增长的保留成本。顺序输入使每个空闲区间对应此 worker 提交的唯一请求;这不代表并发输入时可以把空闲状态推广为逐消息完成 API。
+
+五个样本报告原始壁钟时间、CPU 用户态/内核态时间、峰值 RSS、终点计数及总壁钟时间的最小值、中位数和最大值。预算约束未经舍入的中位数。续聊还相对已初始化 Host 测量保留堆内存:计时操作前后各执行两次显式 GC,中间让出一次事件循环,空闲 Agent 始终可达。因此该增量包含常驻历史 Session 和实时追加,而不只是新轮次。GC 与资源释放不计时;flush 计时。请求历史的内存基线从恢复后开始,只作诊断。目录峰值 RSS 仅作诊断;没有保留堆预算声称衡量已经释放的子会话观察。
+
+父进程为每个子进程设置 60 秒上限,独立检查超时、信号、退出状态和报告,等待进程关闭,并在失败后删除私有根目录。Context 和 Agent 在 finally 中释放。播种进程无法预热被测进程的缓存。不强制清除文件系统缓存:冷指新进程,不指冷物理存储。
+
+## 校准证据
+
+实现参考为 Apple M4 Pro、macOS arm64、Node 24.19.0 上的 `925e012340f033f0521e802ba8569ce6dd7ef1ac`。两轮独占的五样本运行使用相同播种数据,没有产品优化。下表时间单位为毫秒;源码期望值向上取整至实测各轮中位数以上,而不是施加尚未实现的优化目标。
+
+| 用例 | 第一轮原始总时间 | 第二轮原始总时间 | 中位数 | 参考期望 | CI 预算 |
+|---|---|---|---|---:|---:|
+| 请求历史 | 209.134, 210.333, 208.959, 236.355, 238.685 | 222.833, 213.911, 208.089, 211.494, 209.137 | 210.333 / 211.494 | 220 | 550 |
+| 工具续聊 | 358.953, 324.790, 318.861, 320.119, 322.896 | 324.280, 321.952, 340.409, 325.470, 324.312 | 322.896 / 324.312 | 340 | 850 |
+| 子会话目录 | 318.730, 309.006, 311.404, 308.565, 310.105 | 308.670, 310.030, 280.086, 303.084, 284.829 | 310.105 / 303.084 | 320 | 800 |
+
+续聊保留约 22.295 MiB;源码期望值为 23 MiB,预算为 28.75 MiB。时间期望值使用现有[校准辅助函数](../../../../benchmarks/support/calibration.ts):2× 共享 CI 时间比例和 1.25× 波动余量。内存只使用 1.25× 余量。比例继承现有通道的校准,并非这些用例的新 Linux 实测值;runner 特征变化时仍需 CI 证据。基线预算保护实测实现;更紧预算属于有测量依据且保持行为的修复。
+
+独立的纯 Node 请求历史 CPU profile 在一次 211.300 ms 操作中,将 132.876 ms 采样自身时间归因于 buildRequest 调用的 deepFreeze。这把重复遍历已冻结历史定位为聚焦调查目标,不是已证实的优化结果。目录首次/重复时间分别保留,因为观察释放后第二次列举仍读取带种子子会话的正文。
+
+已发布 SDK 变体完成 100 个轮次、200 次请求和 800 次真实文件读取。五样本 smoke 总时间为 1,521.773、1,463.465、1,689.701、1,365.485 和 1,417.106 ms(中位数 1,463.465 ms);完整套件重复运行报告 1,596.183、1,784.536、2,120.082、1,405.365 和 1,355.894 ms(中位数 1,596.183 ms)。1,700 ms 参考期望对应 4,250 ms CI 预算。重复运行中未改变的服务用例也变慢,因此这是可变主机负载下的验证,不是放宽其独占校准预算的依据。SDK 进程使用白名单环境和私有主目录/工作区。40 秒截止时间启动 SDK 关闭;所有路径等待同一个记忆化 close Promise,并早于外层 worker 的 60 秒截止时间。Profile 时间包含启动、全部轮次和关闭,分别报告;不把父进程 CPU 或堆指标当作服务端内存。适配器不为外部模型服务商序列化请求。
+
+## 考虑过的替代方案
+
+**重复现有迁移和首次打开变体。** 拒绝:现有十二个用例已经区分只读准备与可写发布。这些用例使用当前代际并开始或继续实际模型工作,或者列举语料集合而不是打开单个 Session。
+
+**只测 deriveMessages。** 拒绝:它的增量缓存不包含完整请求冻结、适配器分发、实时追加或持久化。实际顺序请求保护 Agent 每一步支付的成本。
+
+**只使用投影缓存行已预热的无种子子会话。** 拒绝:该路径绕过正文观察,遗漏 fork 子会话的精确继承切点要求。目录用例有意不挂载可选投影缓存,报告带种子的回退路径;它不代表缓存命中的发现过程。
+
+**将优化及其目标预算与首次测量一起应用。** 拒绝:纯基线层可以独立合并,并在归因或实现改变前记录当前负载。环境变量不能覆盖源码常量。
+
+## 后果
+
+通道增加三个场景组中的四个用例、二十个测量 worker 和两个播种进程。集成续聊用例覆盖恢复、完成模型/工具工作及持久化 flush。已发布 SDK 工作流额外包含 profile 启动、SDK 传输、真实文件工具和关闭;只有模型适配器是合成的。它创建新 Session,因为公共 SDK prompt API 创建而非恢复已存储身份。两条路径均不包含网络模型延迟、服务商专属请求序列化、可选用户插件、压缩、失败工具结果、图像、取消或浏览器渲染。功能测试仍负责事件内容、不可变消息、工具语义、fork 谱系以及只读/可写副作用;终点计数防止把跳过的工作当作测量结果,不重复这些断言。
+
+本记录补充而非取代 Session 打开门禁的隔离和校准依据。不退役任何现有活跃决策。
diff --git a/benchmarks/agent-continuation/README.i18n.yaml b/benchmarks/agent-continuation/README.i18n.yaml
new file mode 100644
index 0000000000..b5af858f60
--- /dev/null
+++ b/benchmarks/agent-continuation/README.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 benchmarks/agent-continuation/README.md
+README.md: 96f6915ebe2cfa3b334939e87d03828526026281
+README.zh.md: 5889e7b6d04c99f7eff388f2606c8f6bbb201c86
diff --git a/benchmarks/agent-continuation/README.md b/benchmarks/agent-continuation/README.md
new file mode 100644
index 0000000000..96f6915ebe
--- /dev/null
+++ b/benchmarks/agent-continuation/README.md
@@ -0,0 +1,31 @@
+# Backend continuation benchmarks
+
+English | [中文](README.zh.md)
+
+## Summary
+
+Measure long-history request processing, cold tool-heavy continuation, and repeated discovery of inactive fork children without network services or recorded user data. The SDK variant drives 100 turns and 800 real file reads through the shipped sdk-minimal profile; other cases isolate backend service costs. No case renders a browser.
+
+## Table of Contents
+
+- [Run](#run)
+- [Measurements](#measurements)
+- [Dev Note](#dev-note)
+
+
+
+## Run
+
+From the repository root, build the libraries and workers with `pnpm run build:bench`, then run `pnpm exec vitest run --config vitest.bench.config.ts benchmarks/agent-continuation/agent-continuation.bench.ts`. Do not overlap timing runs with builds or other benchmarks.
+
+The test reports all five fresh-process samples and enforces reviewed median budgets. A failed worker reports its exit, signal, timeout, and stderr; temporary roots are removed even on failure. The required benchmark lane discovers this file automatically.
+
+
+
+## Measurements
+
+[workload.ts](workload.ts) owns synthetic dimensions. [The Agent Note](../../.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.md) owns timing endpoints, calibration evidence, memory interpretation, and exclusions. The model adapter does not perform provider serialization or network calls; integrated cases use synthetic tool bodies, while the SDK profile variant performs real file reads.
+
+## Dev Note
+
+None.
diff --git a/benchmarks/agent-continuation/README.zh.md b/benchmarks/agent-continuation/README.zh.md
new file mode 100644
index 0000000000..5889e7b6d0
--- /dev/null
+++ b/benchmarks/agent-continuation/README.zh.md
@@ -0,0 +1,31 @@
+# 后端续聊基准
+
+[English](README.md) | 中文
+
+## Summary
+
+在不使用网络服务或录制用户数据的情况下,测量长历史请求处理、冷工具密集续聊和重复发现非活动 fork 子会话。SDK 变体通过已发布 sdk-minimal profile 执行 100 个轮次和 800 次真实文件读取;其他用例隔离后端服务成本。所有用例均不渲染浏览器。
+
+## Table of Contents
+
+- [运行](#run)
+- [测量](#measurements)
+- [Dev Note](#dev-note)
+
+
+
+## 运行
+
+在仓库根目录使用 `pnpm run build:bench` 构建库和 worker,然后运行 `pnpm exec vitest run --config vitest.bench.config.ts benchmarks/agent-continuation/agent-continuation.bench.ts`。不要让计时运行与构建或其他基准重叠。
+
+测试报告全部五个新进程样本,并约束经审查的中位数预算。worker 失败时报告退出状态、信号、超时和 stderr;失败时也会删除临时根目录。必需基准通道自动发现此文件。
+
+
+
+## 测量
+
+[workload.ts](workload.ts)拥有合成维度。[Agent Note](../../.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.zh.md)拥有计时终点、校准证据、内存解释和排除项。模型适配器不执行服务商序列化或网络调用;集成用例的合成工具经过真实执行管线,SDK profile 变体则执行真实文件读取。
+
+## Dev Note
+
+无。
diff --git a/benchmarks/agent-continuation/agent-continuation.bench.ts b/benchmarks/agent-continuation/agent-continuation.bench.ts
new file mode 100644
index 0000000000..9fde897549
--- /dev/null
+++ b/benchmarks/agent-continuation/agent-continuation.bench.ts
@@ -0,0 +1,86 @@
+/** Baseline budgets for long-history requests, tool continuation, and fork-child discovery. */
+
+import { cp, mkdir, mkdtemp, rm } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
+import { runBuiltBenchmarkWorker } from '../support/built-worker.ts'
+import { ciTimeBudget, PERFORMANCE_BUDGET_HEADROOM } from '../support/calibration.ts'
+import type { ContinuationReport } from './agent-continuation.worker.ts'
+import type { CatalogReport } from './child-catalog.worker.ts'
+import type { ProfileReport } from './profile-continuation.worker.ts'
+import { WORKLOAD } from './workload.ts'
+
+const ATTEMPTS = 5
+const WORKER_TIMEOUT_MS = 60_000
+/** M4 Pro / Node 24.19 baseline expectations, before shared CI scaling and variance headroom. */
+const EXPECTED_MS = { 'request-history': 220, 'tool-continuation': 340, catalog: 320, 'profile-continuation': 1_700 } as const
+const EXPECTED_RETAINED_HEAP_MB = 23
+const WORKERS = join(import.meta.dirname, '..', '.dsh-build', 'agent-continuation')
+
+type Scenario = keyof typeof EXPECTED_MS
+type Report = ContinuationReport | CatalogReport | ProfileReport
+
+function workerName(scenario: Scenario): string {
+ if (scenario === 'profile-continuation') return 'profile-continuation.worker.js'
+ return scenario === 'catalog' ? 'child-catalog.worker.js' : 'agent-continuation.worker.js'
+}
+
+async function run