diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-large-history-pagination-call-stack.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-04-large-history-pagination-call-stack.i18n.yaml new file mode 100644 index 0000000000..96fb51ced0 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-04-large-history-pagination-call-stack.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-08-04-large-history-pagination-call-stack.md +2026-08-04-large-history-pagination-call-stack.md: 28c22121123a227c507c506683ae727d238d98bd +2026-08-04-large-history-pagination-call-stack.zh.md: 57dde9bdc0a4aa52e1af024eb606bf9258430fa7 diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-large-history-pagination-call-stack.md b/.agents/notes/implemented/bug-fix/2026-08-04-large-history-pagination-call-stack.md new file mode 100644 index 0000000000..28c2212112 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-04-large-history-pagination-call-stack.md @@ -0,0 +1,27 @@ +# Agent Note: Large history provenance is scanned without argument expansion + +Status: implemented + +English | [中文](2026-08-04-large-history-pagination-call-stack.zh.md) + +## Problem + +A finalized assistant message can reference hundreds of thousands of streamed chunks through `sourceEventSeqs`. History pagination found the message group's first event with `Math.min(event.seq, ...sourceEventSeqs)`, so a valid session could exceed the JavaScript engine's function-argument limit and make `session.history` fail with HTTP 500. + +## Decision + +Pagination scans `sourceEventSeqs` and updates the earliest sequence number one element at a time. The algorithm remains linear in the provenance size and preserves the existing page boundary: a page starts before all recorded sources of its oldest included message. + +A regression test rejects multi-argument minimum calls and verifies that every provenance event remains on the page with its finalized message. This exercises the failure mechanism without making the default test suite allocate a production-sized chunk stream. + +## Alternatives considered + +- **Raise the JavaScript stack or argument limit** — rejected: the limit is engine- and deployment-dependent, and array expansion still makes valid history depend on an unrelated runtime ceiling. +- **Truncate `sourceEventSeqs` during pagination** — rejected: this could cut a page inside a message and violate replay grouping. +- **Cap streamed chunk count at the provider boundary** — rejected: providers may legitimately emit long streams, and pagination must handle every valid session representation. + +## Consequences + +- Large provenance arrays no longer make history pagination throw solely because of their length. +- Pagination semantics and wire responses are unchanged. +- This does not bound the byte size of a history page or the browser cost of replaying it; those performance concerns remain separate from the server-side call-stack failure. diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-large-history-pagination-call-stack.zh.md b/.agents/notes/implemented/bug-fix/2026-08-04-large-history-pagination-call-stack.zh.md new file mode 100644 index 0000000000..57dde9bdc0 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-04-large-history-pagination-call-stack.zh.md @@ -0,0 +1,27 @@ +# Agent Note: 大规模历史记录的溯源信息通过扫描处理,不做参数展开 + +Status: implemented + +[English](2026-08-04-large-history-pagination-call-stack.md) | 中文 + +## 问题 + +一条已定稿的 assistant 消息可以通过 `sourceEventSeqs` 引用数十万个流式分片。历史记录分页使用 `Math.min(event.seq, ...sourceEventSeqs)` 查找消息组的首个事件,因此,有效会话可能超出 JavaScript 引擎的函数参数数量上限,导致 `session.history` 以 HTTP 500 失败。 + +## 决策 + +分页逻辑逐项扫描 `sourceEventSeqs`,每次使用一个元素更新最早的序号。该算法的复杂度相对溯源信息规模仍为线性,并保留现有的页面边界:页面起点位于其所含最早消息的所有已记录来源之前。 + +回归测试会拒绝以多个参数调用取最小值的做法,并验证每个溯源事件都会与其已定稿消息保留在同一页中。这既覆盖了故障机制,也避免默认测试套件分配生产规模的分片流。 + +## 考虑过的替代方案 + +- **提高 JavaScript 栈或参数上限**:不予采纳,因为该上限取决于引擎和部署环境,而且数组展开仍会让有效历史记录受制于无关的运行时上限。 +- **在分页时截断 `sourceEventSeqs`**:不予采纳,因为这可能会从消息中间切分页面,破坏回放分组。 +- **在提供方边界限制流式分片数量**:不予采纳,因为提供方可能会合理地产生长流,而分页必须处理每一种有效的会话表示。 + +## 后果 + +- 大型溯源数组不再仅因长度而使历史记录分页抛出异常。 +- 分页语义与协议响应保持不变。 +- 本决策不限制历史记录页面的字节大小,也不限制浏览器回放该页面的开销;这两项性能问题仍与服务端调用栈故障分开处理。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index bda99362b6..7c6abbf271 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -302,7 +302,12 @@ function paginate( if (!MESSAGE_TYPES.has(event.type) || !isAppendSurfaceEvent(event)) continue count++ const sources = (event as { sourceEventSeqs?: number[] }).sourceEventSeqs - const groupStart = sources !== undefined && sources.length > 0 ? Math.min(event.seq, ...sources) : event.seq + let groupStart = event.seq + if (sources !== undefined) { + for (const source of sources) { + if (source < groupStart) groupStart = source + } + } if (count >= maxMessages) { cut = groupStart break diff --git a/packages/host/apiproxy/tests/api-proxy-view.spec.ts b/packages/host/apiproxy/tests/api-proxy-view.spec.ts index 3d756b7da8..6955b9416c 100644 --- a/packages/host/apiproxy/tests/api-proxy-view.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-view.spec.ts @@ -7,7 +7,7 @@ * turn/end cleared it. */ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' @@ -285,6 +285,45 @@ describe('mux live view computation', () => { expect(page.map(event => event.seq)).toEqual(page.map((_event, index) => third.seq + index)) }) + it('paginates a message with many provenance sources without variadic argument expansion', async () => { + const { ctx } = await harness() + const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const session = ctx.sessions.create() + ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) + session.append('turn/start', { turn: 1 }) + const sources = Array.from({ length: 128 }, (_unused, index) => session.append('assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'text-delta', index, text: 'x' }, + }).seq) + const message = session.append('assistant/message', { + turn: 1, + step: 1, + message: createMessage({ + role: 'assistant', + content: [{ type: 'text', text: 'x'.repeat(sources.length) }], + source: { kind: 'model', provider: 'p', model: 'm' }, + }), + }, { surfaceOp: 'append', sourceEventSeqs: sources }) + + const scalarMin = Math.min + const min = vi.spyOn(Math, 'min').mockImplementation((...values) => { + if (values.length > 2) throw new RangeError('variadic minimum rejected by regression harness') + return scalarMin(...values) + }) + try { + const response = await api.sessions.history({ + rpcId: RpcId('t-hist-large-provenance'), + payload: { sessionId: session.id, maxMessages: 1 }, + }) + if (!response.result.ok) throw new Error('unreachable') + expect(response.result.value.events.map(entry => entry.event.seq)).toEqual([...sources, message.seq]) + expect(response.result.value.hasMore).toBe(true) + } finally { + min.mockRestore() + } + }) + it('drops a disposed session from the live open-call table (result after dispose gets no view)', async () => { const { ctx } = await harness() const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })