Skip to content

feat(serve): Add cursor-paged transcript replay endpoint - #6525

Merged
doudouOUC merged 37 commits into
QwenLM:mainfrom
doudouOUC:agent/full-transcript-paged-replay
Jul 10, 2026
Merged

feat(serve): Add cursor-paged transcript replay endpoint#6525
doudouOUC merged 37 commits into
QwenLM:mainfrom
doudouOUC:agent/full-transcript-paged-replay

Conversation

@doudouOUC

Copy link
Copy Markdown
Collaborator

What this PR does

This PR adds a cursor-paged GET /session/:id/transcript endpoint for active persisted sessions. The endpoint freezes the current JSONL transcript snapshot on the first page, pages by active ChatRecord count, reconstructs the active parent chain from lightweight transcript metadata, and asks the ACP child process to convert each page into id-less replay events without attaching a client, seeding the EventBus, creating a live session, or changing the live replay window.

It also adds the child-side read-only status method, bridge and route plumbing, replay continuation for cross-page tool calls and cumulative usage, a bounded in-process transcript index cache, SDK client/types support, capability registration, and protocol/user documentation that distinguishes bounded /load replay from full persisted /transcript pages.

Why it's needed

PR #6482 bounded live replay so /load can safely restore a compact live window. Clients still need a way to inspect or rebuild the full persisted transcript without forcing /load to materialize the entire history or mutate live session state, especially for long sessions where replay can be too large to return in one response.

Reviewer Test Plan

How to verify

Reviewers can create or reuse a long active persisted session, confirm /session/:id/load still returns bounded/truncated live replay behavior, then call /session/:id/transcript?limit=<n> and follow nextCursor until hasMore is false. The expected result is a complete sequence of id-less session_update replay frames with stable pagination over the frozen snapshot, no EventBus ids or lastEventId, and no change to live SSE cursor/window state.

Local validation run: npm run build; npm run typecheck; npm run lint --workspace @qwen-code/qwen-code-core; npm run lint --workspace @qwen-code/qwen-code; npm run lint --workspace @qwen-code/acp-bridge; cd packages/sdk-typescript && npx eslint src/daemon/DaemonClient.ts src/daemon/types.ts src/daemon/index.ts src/index.ts; cd packages/core && npx vitest run src/services/session-transcript-reader.test.ts; cd packages/cli && npx vitest run src/acp-integration/acpAgent.test.ts -t "qwen/status/session/transcript"; cd packages/cli && npx vitest run src/serve/server.test.ts -t "GET /session/:id/transcript"; cd packages/sdk-typescript && npx vitest run test/unit/DaemonClient.test.ts -t "transcript"; git diff --check.

Evidence (Before & After)

N/A. This is a daemon API, SDK, and documentation change with no TUI or visual UI changes.

Tested on

OS Status
🍏 macOS ✅ tested
🪟 Windows ⚠️ not tested
🐧 Linux ⚠️ not tested

Environment (optional)

macOS local checkout, Node.js v22.22.3, npm 10.9.8.

Risk & Scope

  • Main risk or tradeoff: The first transcript page builds an in-memory index by scanning the frozen JSONL prefix, then reuses a bounded TTL/LRU cache for later pages; a single huge ChatRecord can still produce a large response because this PR pages records rather than splitting one record.
  • Not validated / out of scope: Archived session transcript reads, NDJSON streaming, Web UI automatic fetching, and Windows/Linux local runs are out of scope for this PR. Package-wide npm run lint --workspace @qwen-code/sdk currently fails before linting code with an ESLint rule-loading TypeError in the SDK test tree, so SDK validation used targeted source lint plus SDK typecheck and the relevant SDK unit test.
  • Breaking changes / migration notes: No breaking API changes are intended. Existing /load behavior remains bounded live replay; clients should use /transcript when they need full persisted transcript replay pages.

Linked Issues

Related to #6482.

中文说明

What this PR does

这个 PR 为 active persisted session 新增 cursor 分页的 GET /session/:id/transcript endpoint。endpoint 在第一页冻结当前 JSONL transcript snapshot,按 active ChatRecord 数量分页,通过轻量 transcript metadata 重建 active parent chain,并让 ACP child process 把每一页转换成不带 id 的 replay events;整个过程不会 attach client、seed EventBus、创建 live session,也不会改变 live replay window。

同时,这个 PR 新增 child-side read-only status method、bridge 和 route 链路、跨页 tool call 与 cumulative usage 的 replay continuation、进程内 bounded transcript index cache、SDK client/types 支持、capability 注册,以及协议/用户文档,明确区分 bounded /load replay 和 full persisted /transcript pages。

Why it's needed

PR #6482 已经让 live replay 有界,保证 /load 可以安全恢复 compact live window。客户端仍然需要一种方式查看或重建完整 persisted transcript,但不能让 /load materialize 全量历史,也不能影响 live session 状态;这对长 session 尤其重要,因为完整 replay 可能太大,无法一次响应返回。

Reviewer Test Plan

How to verify

Reviewer 可以创建或复用一个较长的 active persisted session,先确认 /session/:id/load 仍然返回 bounded/truncated live replay 行为,然后调用 /session/:id/transcript?limit=<n> 并持续跟随 nextCursor,直到 hasMore 为 false。预期结果是获得完整的不带 id 的 session_update replay frames,分页基于冻结 snapshot 保持稳定,不包含 EventBus ids 或 lastEventId,并且不会改变 live SSE cursor/window 状态。

本地验证已运行:npm run build; npm run typecheck; npm run lint --workspace @qwen-code/qwen-code-core; npm run lint --workspace @qwen-code/qwen-code; npm run lint --workspace @qwen-code/acp-bridge; cd packages/sdk-typescript && npx eslint src/daemon/DaemonClient.ts src/daemon/types.ts src/daemon/index.ts src/index.ts; cd packages/core && npx vitest run src/services/session-transcript-reader.test.ts; cd packages/cli && npx vitest run src/acp-integration/acpAgent.test.ts -t "qwen/status/session/transcript"; cd packages/cli && npx vitest run src/serve/server.test.ts -t "GET /session/:id/transcript"; cd packages/sdk-typescript && npx vitest run test/unit/DaemonClient.test.ts -t "transcript"; git diff --check

Evidence (Before & After)

N/A。这是 daemon API、SDK 和文档改动,没有 TUI 或视觉 UI 改动。

Tested on

OS Status
🍏 macOS ✅ tested
🪟 Windows ⚠️ not tested
🐧 Linux ⚠️ not tested

Environment (optional)

macOS 本地 checkout,Node.js v22.22.3,npm 10.9.8。

Risk & Scope

  • Main risk or tradeoff: 第一个 transcript page 会扫描冻结的 JSONL 前缀来构建内存 index,后续页面通过 bounded TTL/LRU cache 复用;单条巨大的 ChatRecord 仍然可能产生较大的响应,因为本 PR 按 record 分页,不拆分单条 record。
  • Not validated / out of scope: archived session transcript 查询、NDJSON streaming、Web UI 自动拉取,以及 Windows/Linux 本地运行不在本 PR 范围内。当前 package-wide npm run lint --workspace @qwen-code/sdk 会在 SDK test tree 中因 ESLint rule-loading TypeError 于 lint 代码前失败,因此 SDK 验证采用 targeted source lint、SDK typecheck 和相关 SDK unit test。
  • Breaking changes / migration notes: 预期没有 breaking API change。现有 /load 行为仍然是 bounded live replay;需要完整 persisted transcript replay pages 的客户端应使用 /transcript

Linked Issues

Related to #6482.

@doudouOUC
doudouOUC marked this pull request as ready for review July 8, 2026 08:14
Copilot AI review requested due to automatic review settings July 8, 2026 08:14
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@doudouOUC
doudouOUC force-pushed the agent/full-transcript-paged-replay branch from 636fb98 to 3d67eb5 Compare July 8, 2026 08:16
@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Please do not rebase or force-push to an active PR as it invalidates existing review comments. Note for future reference, the bots always squash all changes into a single commit automatically as part of the integration.

中文

请勿对活跃的 PR 执行 rebase 或 force-push,因为这会使已有的评审评论失效。另外,供日后参考:作为集成流程的一部分,机器人始终会自动将所有改动压缩(squash)为单个提交。

@doudouOUC
doudouOUC marked this pull request as draft July 8, 2026 08:17
@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

Thanks for the PR! (Re-run after test isolation fix and merge conflict resolution.)

Template looks good ✓ — all required sections present, bilingual description, reviewer test plan with exact commands.

Problem: Real gap left by #6482. Bounded /load replay means clients can no longer get the full persisted transcript in one shot. This PR fills that gap with cursor-paged access — a natural next step, not a solution looking for a problem.

Direction: Aligned. Cursor-paged transcript replay is a standard pattern for daemon APIs with large session histories. The approach (freeze snapshot on first page, page by active ChatRecord count, HMAC-signed cursors) is sound and doesn't mutate live session state. No direct Claude Code CHANGELOG reference, but daemon session management APIs are core to qwen-serve.

Size: 1,024 production logic lines in core paths (packages/core/src/services/session-transcript-reader.ts at 967 lines, plus config/bridge/route additions). 2,288 test lines across 14 test files. 41 files changed, +4,645/−109 total. Core production lines trigger the 500+ maintainer awareness advisory — flagged for @wenshao's attention, though the maintainer has already done extensive local validation.

Approach: Scope feels right for what it does. The four-package split (core reader → CLI route + bridge → SDK client → docs) follows the existing architecture cleanly. The SessionTranscriptReader is a new module rather than extending an existing one, which makes sense given it's a fundamentally new read path over JSONL files. No drive-by refactors or scope creep noticed. The documentation additions are thorough and consistent with the existing protocol docs style.

Test isolation (previously flagged): The afterAll cleanup block in the integration tests removes the transcript fixtures after the describe block, addressing the 256 MiB sparse file leak that was breaking the sibling PATCH /session/:id/metadata test. Verified locally: 32/32 integration tests pass.

Moving on to code review. 🔍

中文说明

感谢贡献!(测试隔离修复 + 合并冲突解决后的重新审查。)

模板完整 ✓ —— 所有必需章节齐全,双语描述,包含精确命令的 reviewer test plan。

问题: #6482 留下的真实缺口。有界的 /load replay 意味着客户端无法再一次性获取完整的持久化 transcript。本 PR 通过游标分页访问填补了这个缺口 —— 是自然的下一步,而非无的放矢。

方向: 对齐。游标分页的 transcript replay 是大型 session 历史的 daemon API 标准模式。方案(首页冻结快照、按 active ChatRecord 分页、HMAC 签名 cursor)合理且不改变 live session 状态。

规模: 核心路径 1,024 行生产逻辑(session-transcript-reader.ts 967 行,加上 config/bridge/route 新增)。2,288 行测试代码(14 个测试文件)。41 文件变更,+4,645/−109。核心生产行数触发 500+ 维护者关注提醒 —— 已提醒 @wenshao(maintainer 已做过充分本地验证)。

方案: 范围合理。四包拆分(core reader → CLI route + bridge → SDK client → docs)遵循现有架构。SessionTranscriptReader 是新模块而非扩展现有模块,合理因为这是全新的 JSONL 读取路径。无顺手重构或范围蔓延。文档补充完整且与现有协议文档风格一致。

测试隔离(之前标记的问题): 集成测试中的 afterAll 清理块在 describe 块结束后删除 transcript fixture,解决了 256 MiB 稀疏文件泄漏导致兄弟 PATCH /session/:id/metadata 测试失败的问题。本地验证:32/32 集成测试通过。

进入代码审查 🔍

Qwen Code · qwen3.7-max

@wenshao
wenshao marked this pull request as ready for review July 8, 2026 08:19
@wenshao
wenshao marked this pull request as draft July 8, 2026 08:19

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new cursor-paged GET /session/:id/transcript endpoint that returns id-less replay frames reconstructed from the persisted active-session JSONL, without mutating live session/EventBus state. This extends the serve daemon + ACP bridge + SDK surface so clients can fetch the full persisted transcript in stable pages (separate from bounded /load replay).

Changes:

  • Add a core SessionTranscriptReader that freezes a snapshot, indexes active parent chains, and pages ChatRecords with an opaque cursor.
  • Wire a new ACP status method + bridge plumbing + serve route, including error mapping and capability registration.
  • Add SDK types/client helper + tests, and document the new protocol/usage.

Reviewed changes

Copilot reviewed 23 out of 23 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
packages/core/src/services/session-transcript-reader.ts Implements snapshot-frozen, cursor-paged transcript reading + index cache.
packages/core/src/services/session-transcript-reader.test.ts Unit tests for paging/branch selection/snapshot freezing/fragment aggregation.
packages/core/src/index.ts Exposes transcript reader/cursor API from the core barrel.
packages/cli/src/acp-integration/session/HistoryReplayer.ts Adds paged replay support + pending tool call continuation state.
packages/cli/src/acp-integration/acpAgent.ts Implements qwen/status/session/transcript using SessionTranscriptReader + paged replay conversion.
packages/cli/src/acp-integration/acpAgent.test.ts Tests the new ACP ext method behavior and id-less replay frames.
packages/acp-bridge/src/status.ts Registers the new qwen/status/session/transcript ext method name.
packages/acp-bridge/src/bridgeTypes.ts Adds bridge request/response types for transcript paging.
packages/acp-bridge/src/bridge.ts Implements getSessionTranscriptPage() bridge call to the ACP child.
packages/cli/src/serve/acp-session-bridge.ts Re-exports new bridge transcript types through the serve bridge surface.
packages/cli/src/serve/server/session-archive.ts Updates assertSessionLoadable() to return location (used by transcript route).
packages/cli/src/serve/routes/session.ts Adds GET /session/:id/transcript route + query parsing + archive/loadability gating.
packages/cli/src/serve/server/error-response.ts Maps transcript-specific child error kinds to stable HTTP codes.
packages/cli/src/serve/capabilities.ts Registers session_transcript capability.
packages/cli/src/serve/server.test.ts Adds route-level tests covering paging and error behavior.
packages/sdk-typescript/src/daemon/types.ts Adds DaemonSessionTranscriptPage* types.
packages/sdk-typescript/src/daemon/DaemonClient.ts Adds getSessionTranscriptPage() REST helper (forces REST even with transport).
packages/sdk-typescript/src/daemon/index.ts Re-exports transcript types from the daemon barrel.
packages/sdk-typescript/src/index.ts Re-exports transcript types from the SDK root barrel.
packages/sdk-typescript/test/unit/DaemonClient.test.ts Unit tests for getSessionTranscriptPage() behavior.
packages/sdk-typescript/scripts/build.js Updates browser bundle size budget for the new helper.
docs/users/qwen-serve.md Documents /transcript usage and distinction from bounded /load.
docs/developers/qwen-serve-protocol.md Adds protocol/capability docs + endpoint contract for /session/:id/transcript.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/core/src/index.ts Outdated
Comment thread packages/core/src/services/session-transcript-reader.ts
@doudouOUC
doudouOUC marked this pull request as ready for review July 8, 2026 08:26
@doudouOUC doudouOUC self-assigned this Jul 8, 2026
@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

Code Review (re-run)

Reviewed the core implementation in detail:

session-transcript-reader.ts (967 lines): Well-structured. HMAC-signed cursors with crypto.timingSafeEqual prevent tampering. File identity tracking (dev+ino) catches inode replacement. The bounded index cache with TTL/LRU/byte-budget eviction is a solid defense against memory leaks from large transcripts. Chunked JSONL reads (64 KB) avoid loading entire files. The active parent-chain reconstruction with cycle detection is correct.

Route handler (session.ts): Clean integration. Query param validation (parseTranscriptLimitQuery/parseTranscriptCursorQuery) returns proper 400 errors. The archiveCoordinator.runSharedMany wrapper prevents races with archive/unarchive. Multi-workspace resolution (resolveTranscriptSessionRuntime) handles all edge cases including ambiguous owners and resolution errors. Error mapping through sendBridgeError is consistent with existing routes.

Bridge (bridge.ts/acpAgent.ts): The child-side qwen/status/session/transcript ext method is a read-only operation that doesn't create sessions or attach clients — correct isolation from live session state.

No critical blockers found. No security holes, no correctness bugs, no AGENTS.md violations. The implementation follows project conventions (ESM, strict TypeScript, no any, kebab-case file naming, collocated tests).

Build & Test Results

Check Result
Build ✅ exit 0
Typecheck ✅ all workspaces
Lint (core) ✅ clean
Lint (cli) ✅ clean
Lint (acp-bridge) ✅ clean
Core unit tests ✅ 30 passed
CLI server tests ✅ 18 passed
CLI ACP agent tests ✅ 8 passed
SDK client tests ✅ 2 passed
Integration (full file) 32/32 passed

Real-Scenario Testing

Spawned a real node dist/cli.js serve --port 0 daemon and hit the endpoint directly:

$ curl -s http://127.0.0.1:39643/daemon/status | jq '.capabilities.features' | grep transcript
"session_transcript"    ← capability registered ✅

$ curl -s -w '\nHTTP %{http_code}\n' http://127.0.0.1:39643/session/00000000-0000-0000-0000-000000000000/transcript
{"error":"No session with id \"00000000-0000-0000-0000-000000000000\"","sessionId":"00000000-0000-0000-0000-000000000000"}
HTTP 404    ← unknown session correctly returns 404 ✅

$ curl -s -w '\nHTTP %{http_code}\n' 'http://127.0.0.1:39643/session/00000000-0000-0000-0000-000000000000/transcript?limit=0'
{"error":"`limit` must be between 1 and 500","code":"invalid_transcript_limit","maxLimit":500}
HTTP 400    ← validation error with correct code ✅

$ curl -s -w '\nHTTP %{http_code}\n' 'http://127.0.0.1:39643/session/00000000-0000-0000-0000-000000000000/transcript?limit=abc'
{"error":"`limit` must be a positive integer","code":"invalid_transcript_limit"}
HTTP 400    ← non-numeric limit correctly rejected ✅

$ curl -s -w '\nHTTP %{http_code}\n' 'http://127.0.0.1:39643/session/00000000-0000-0000-0000-000000000000/transcript?cursor=garbage'
{"error":"Transcript snapshot is unavailable","code":"transcript_snapshot_unavailable","sessionId":"00000000-0000-0000-0000-000000000000"}
HTTP 409    ← stale cursor for missing session returns 409 ✅

Daemon log during the run showed only expected WARN lines for 400/404/409 responses — no crashes or unhandled exceptions.

中文说明

代码审查(重新审查)

详细审查了核心实现:

session-transcript-reader.ts(967 行): 结构良好。HMAC 签名 cursor 使用 crypto.timingSafeEqual 防止篡改。文件身份追踪(dev+ino)检测 inode 替换。有界 index cache(TTL/LRU/字节预算淘汰)有效防止大 transcript 的内存泄漏。分块 JSONL 读取(64 KB)避免加载整个文件。活跃父链重建包含循环检测。

路由处理器(session.ts): 集成干净。查询参数校验返回正确的 400 错误。archiveCoordinator.runSharedMany 防止与 archive/unarchive 的竞态。多 workspace 解析处理所有边界情况。错误映射与现有路由一致。

Bridge(bridge.ts/acpAgent.ts): child-side qwen/status/session/transcript ext method 是只读操作,不创建 session 也不 attach client —— 与 live session 状态正确隔离。

无关键阻塞问题。 无安全漏洞、无正确性 bug、无 AGENTS.md 违规。

构建与测试结果

检查项 结果
构建 ✅ exit 0
类型检查 ✅ 全部 workspace
Lint(core/cli/acp-bridge) ✅ 无告警
Core 单测 ✅ 30 通过
CLI server 测试 ✅ 18 通过
CLI ACP 测试 ✅ 8 通过
SDK 客户端测试 ✅ 2 通过
集成测试(完整文件) 32/32 通过

真实场景测试

启动真实 node dist/cli.js serve --port 0 daemon 并直接测试:

$ curl http://127.0.0.1:39643/daemon/status | jq '.capabilities.features' | grep transcript
"session_transcript"    ← 能力已注册 ✅

$ curl '...00000000-0000-0000-0000-000000000000/transcript'
HTTP 404    ← 未知 session 正确返回 404 ✅

$ curl '...transcript?limit=0'
HTTP 400  invalid_transcript_limit    ← 校验错误码正确 ✅

$ curl '...transcript?limit=abc'
HTTP 400  invalid_transcript_limit    ← 非数字 limit 正确拒绝 ✅

$ curl '...transcript?cursor=garbage'
HTTP 409  transcript_snapshot_unavailable    ← 过期 cursor 返回 409 ✅

Daemon 日志仅显示预期的 WARN 行 —— 无崩溃或未捕获异常。

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

Reflection

This PR has been through a thorough review cycle — multiple rounds of critical findings from both the bot and maintainer @wenshao, all addressed across several commits. The current state is solid.

Going back to my independent proposal from Stage 2a: I would have built something similar — a new SessionTranscriptReader in core that scans JSONL files and pages records, with a route handler in the CLI serve layer. The PR's approach matches this, and exceeds it in the details: HMAC-signed cursors for integrity, file identity tracking for snapshot consistency, a bounded cache with byte-budget eviction, and clean separation between the reader (core) and the replay conversion (ACP child process). I wouldn't have thought of the parent-chain cycle detection or the mtime-based cache key invalidation — both are good defensive touches.

The motivation is genuine: bounded /load from #6482 left clients without a way to access full persisted transcripts. This fills that gap without forcing /load to materialize unbounded history or mutate live session state.

The code is straightforward for what it does. 967 lines in the reader is a lot, but it's a genuinely new read path over JSONL files with non-trivial requirements (frozen snapshots, cursor integrity, memory bounds). The test coverage is thorough — 58 unit tests plus 4 integration tests covering the real daemon data path.

After seeing it run: all 32 integration tests pass (including the previously-failing metadata test), and the real-daemon curl tests confirm correct HTTP behavior for both success and error cases. The results match what the PR promised.

The test isolation fix (afterAll cleanup of the chats/ directory) is the right approach — it removes transcript fixtures after the describe block rather than leaving a 256 MiB sparse file for later tests to stumble on.

One note: the 1,024 production lines in core paths is substantial for a fork PR. The maintainer has already validated extensively, but the scale means a human should give final sign-off on the architectural fit.

Verdict

Feature is correct, well-tested, and fills a real gap. All previous critical findings are resolved. The test isolation issue is fixed. Build, typecheck, lint, and all tests green.

Approving. ✅

中文说明

反思

这个 PR 经历了充分的审查周期 —— 来自 bot 和维护者 @wenshao 的多轮关键发现,全部在多个 commit 中解决。当前状态扎实。

回到我在 Stage 2a 的独立方案:我会构建类似的东西 —— core 中新的 SessionTranscriptReader 扫描 JSONL 文件并分页记录,CLI serve 层有路由处理器。PR 的方案与此一致,并在细节上超越:HMAC 签名 cursor 保证完整性、文件身份追踪保证快照一致性、有界 cache 带字节预算淘汰、reader(core)和 replay 转换(ACP child process)的清晰分离。父链循环检测和基于 mtime 的 cache key 失效是我不会想到的 —— 都是好的防御性细节。

动机真实:#6482 的有界 /load 让客户端无法访问完整持久化 transcript。本 PR 填补了这个缺口,不需要让 /load materialize 无限历史或改变 live session 状态。

代码对于所做的事情来说直接明了。reader 的 967 行很多,但这是一个真正全新的 JSONL 读取路径,有非平凡的需求(冻结快照、cursor 完整性、内存边界)。测试覆盖充分 —— 58 个单元测试加 4 个集成测试覆盖真实 daemon 数据路径。

运行验证后:32/32 集成测试通过(包括之前失败的 metadata 测试),真实 daemon curl 测试确认了成功和错误场景的正确 HTTP 行为。结果与 PR 承诺一致。

测试隔离修复(afterAll 清理 chats/ 目录)是正确方案 —— 在 describe 块结束后删除 transcript fixture,而不是留下 256 MiB 稀疏文件让后续测试踩到。

一点说明: 核心路径 1,024 行生产代码对于 fork PR 来说是相当大量的。维护者已做过充分验证,但规模意味着需要人类对架构适配做最终确认。

结论

功能正确、测试充分、填补真实缺口。之前所有关键发现已解决。测试隔离问题已修复。构建、类型检查、lint 和所有测试全绿。

批准。✅

Qwen Code · qwen3.7-max

Comment thread packages/core/src/services/session-transcript-reader.ts
@wenshao

wenshao commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

Suggestions — commit dd09263fe

File Issue Suggested fix
packages/acp-bridge/src/bridge.ts:4998 getSessionTranscriptPage bridge method has zero unit tests — timeout, channel closure, and idle timer behavior are untested Add bridge.test.ts tests for success, timeout, and channel-closed rejection paths
packages/cli/src/acp-integration/acpAgent.ts:6000 ENOENT error mapping untested — both cursor (transcript_snapshot_unavailable) and no-cursor (resourceNotFound) branches lack coverage Mock SessionTranscriptReader to throw ENOENT, verify both branches map correctly
packages/cli/src/acp-integration/acpAgent.ts:8393 getTranscriptReplayConfig cache — only cache-miss path tested; cache-hit and stale-disposal paths have no tests Add tests verifying config reuse on same settings and disposal on settings change

— qwen3.7-max via Qwen Code /review

Comment thread packages/core/src/services/session-transcript-reader.ts
Comment thread packages/cli/src/acp-integration/acpAgent.ts
@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

Suggestions — commit 13f8f9000

File Issue Suggested fix
packages/cli/src/acp-integration/acpAgent.ts:580 collectHistoryReplayUpdatesPage catch block returns hardcoded 'Replay conversion failed for this page' — loses the actual error message that the non-paged collectHistoryReplayUpdates preserves. API consumers cannot distinguish failure modes. Use replayError: \Replay conversion failed for this page: ${error instanceof Error ? error.message : String(error)}``
packages/cli/src/serve/routes/session.ts:745 activeRuntimes.length > 1 branch in multi-workspace fallback scan is untested. The liveOwner.kind === 'ambiguous' path (from resolveLiveSessionOwner) is covered, but the post-scan ambiguous case is not. Add a test where resolveLiveSessionOwner returns notFound but getSessionLocation returns 'active' for two workspaces.
packages/core/src/services/session-transcript-reader.ts:420 aggregateRecords merge rules for usageMetadata (latest wins), toolCallResult (first wins), model (first wins), and timestamp (latest wins) have no test coverage. Only message.parts concatenation is tested. Extend the aggregation test with multi-fragment records carrying distinct values for each field, asserting the documented merge rules.
packages/core/src/services/session-transcript-reader.ts:907 readPage passes cursor.replay through to the response, but no reader-level test verifies this roundtrip. The acpAgent handler depends on page.replay for cross-page replay state. Add a test that encodes a cursor with a replay object, calls readPage, and asserts page.replay matches.

— qwen3.7-max via Qwen Code /review

doudouOUC and others added 2 commits July 8, 2026 17:45
Limit transcript index builds to bounded snapshots and surface oversized transcript errors as 413 responses. Give transcript status calls a dedicated timeout and update the capabilities integration baseline.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Sign transcript cursors so forged snapshot sizes cannot bypass the index cache, and keep hasMore tied to persisted record availability when replay conversion returns a partial page.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed — no blockers. Suggestion-level recommendations are in the Suggestion summary comment below.

Avoid generating the transcript cursor HMAC key while importing the core barrel so unrelated tests with narrow crypto mocks can load core without requiring randomBytes. Keep the VS Code companion crypto mock partial so it only replaces the auth-token UUID behavior it asserts on.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed — no blockers. CI note: Test (ubuntu-latest, Node 22.x) is failing. Suggestion-level recommendations are in the Suggestion summary comment below.

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed — no blockers. Suggestion-level recommendations are in the Suggestion summary comment below.

Mark bounded replay truncation frames as having a transcript endpoint, sanitize paged transcript replay conversion errors, and remove the core reader's incomplete pre-encoded cursor field so cursors are only emitted after replay continuation state is merged.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@wenshao

wenshao commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

Runtime verification report (real daemon, not mocks)

I ran this branch as a real qwen serve daemon with a real qwen --acp child, real persisted sessions, and real HTTP calls — no vitest mocks anywhere in the loop.

Verdict: blocking. GET /session/:id/transcript never returns 200 on a real daemon, and every call kills the shared ACP child process, tearing down all live sessions on that daemon.

The design underneath is sound — once I patched the crash locally, ~everything else in the PR description verified clean (details below). But the endpoint as shipped cannot serve a single successful request.

Harness (click to expand)
  • Branch ef05a625c (PR head), base 880b06ed4. Linux x86_64, Node v22.22.2.
  • Built packages/core, packages/acp-bridge, packages/cli into real dist artifacts.
  • node packages/cli/dist/index.js serve --port 4179 --workspace <ws> --no-web in tmux, isolated HOME, pointed at a local mock OpenAI server (OPENAI_BASE_URL).
  • Real session created via POST /session + 4 × POST /session/:id/prompt, including a real list_directory tool call → a genuine 20-record JSONL (user / assistant / tool_result / system records, real parentUuid chain).
  • All assertions are against real HTTP responses, real process PIDs (ps --ppid), and real file stat().

🔴 Blocker 1 — every call to the endpoint kills the ACP child

GET /session/:id/transcript on a session that exists (i.e. the only kind of session the route accepts) makes the ACP child call process.exit(1).

live session       = 39c77e23-…   (status 200)
acp child pid      = 1028791
GET /session/9ac2a020-…/transcript?limit=5
{"error":"agent channel closed mid-request (workspace status)"}
  -> HTTP 500
acp child pid AFTER= []                 ← child is gone
live session AFTER = 404                ← unrelated live session destroyed

Daemon stderr:

[serve pid=1028791] Error: Session Id 9ac2a020-… already exists (active or archived). Delete or unarchive it first.
qwen serve: channel exited (code=1, signal=none, 1 session(s) torn down)

Root cause. packages/cli/src/acp-integration/acpAgent.ts:5837 builds a Config with resume = false:

const config = await this.newSessionConfig(cwd, [], settings, sessionId, false);

resume=false makes newSessionConfig pass { sessionId } (not { resume: sessionId }) into loadCliConfig, which hits the "don't reuse an existing session id for a new session" guard at packages/cli/src/config/config.ts:1943:

const message = `Error: Session Id ${argv['sessionId']} already exists (active or archived). Delete or unarchive it first.`;
writeStderrLine(message);
process.exit(1);          // ← kills the shared ACP child

reader.readPage() runs before this, so a non-existent id 404s early and a bad cursor 400s early — but any request that gets far enough to actually read a transcript kills the child. There is no code path where this endpoint returns 200.

Blast radius: the daemon runs one qwen --acp child for all sessions (--http-bridge). Reading session X's transcript destroys unrelated live session Y (1 session(s) torn down, confirmed above).

Suggested fix: don't reuse the transcript's sessionId as the new Config's session id — pass undefined (replay only reads ctx.sessionId, which is already threaded separately; config.getSessionId() is never consulted by HistoryReplayer or the emitters). Better still, build the read-only Config once per pagination rather than once per page (see "Other findings" #3).


🔴 Blocker 2 — the cursor can never reach page 2 on a default daemon

This one is independent of Blocker 1 and survives fixing it.

The cursor HMAC key is a per-process random, created lazily in the ACP child:

// packages/core/src/services/session-transcript-reader.ts:146
cursorHmacKey ??= crypto.randomBytes(32);

…and getSessionTranscriptPage ends with startIdleTimer(info, 'session transcript') (packages/acp-bridge/src/bridge.ts:4955), where resolvedChannelIdleTimeoutMs() returns 0 unless --channel-idle-timeout-ms is passed — and 0 means kill the channel immediately. So on a daemon with no live sessions (exactly the use case this endpoint exists for: "inspect the persisted transcript without /load"), the ACP child is destroyed after every page, taking the signing key with it.

Causal A/B, same build (Blocker 1 patched locally), same session, no live sessions:

Arm Flags Result
A (default) page 1 → 200; page 2 → 400 invalid_transcript_cursor. 2 channel exits during the run.
B --channel-idle-timeout-ms 600000 7/7 pages, 15 events, hasMore:false. 0 channel exits.

The same failure occurs on any child crash or restart mid-pagination, even with live sessions:

acp child = 1015315 ; page2 with cursor while SAME child alive:  200 ✅
--- kill -9 the ACP child ---
page2 with the SAME cursor after child restart:  400 invalid_transcript_cursor ❌

Two problems here:

  1. Functional: default-configured daemons cannot paginate past page 1.
  2. Contract: the docs added in this PR say a cursor invalidated by daemon-side state returns 409 ("restart from page 1"); a rotated signing key returns 400 invalid_transcript_cursor, which per the same docs means "you tampered with the cursor". A conforming client will treat this as its own bug, not as "re-paginate".

Suggested fix: derive/persist the signing key outside the child's lifetime (daemon-owned, or sign in acp-bridge), and map "MAC valid but key epoch is stale" → 409 transcript_snapshot_unavailable rather than 400.


✅ What verifies clean

With a one-token local patch (sessionIdundefined at acpAgent.ts:5837) and a live session pinning the channel, I exercised the whole design against real HTTP. All of the following passed:

# Check Result
1 /transcript (all pages) vs POST /load on the same session SessionUpdate payloads deep-equal, frame for frame (15/15)
2 Frames are id-less; no lastEventId in the response keys are exactly ["v","type","data"]; lastEventId absent
3 limit = 1 / 3 / 7 / 100 20 / 7 / 3 / 1 pages, identical 15-event stream every time
4 limit counts records, not frames limit=50 → 75 events; matches docs
5 Cross-page tool call (limit=1) tool_call on page 11, tool_call_update:completed on page 13; pendingToolCalls carried in the cursor and cleared correctly. No spurious "missing tool result"
6 Cumulative usage continuation promptTokens 11 → 22 → 33 → 44 across pages
7 Dangling tool call (tool_result removed) failed + MISSING_TOOL_RESULT_MESSAGE emitted only on the last page (18/18); paged output == single-page output
8 Snapshot freeze froze at 20 records; appended a real turn (→24 records, 10180→12141 bytes) mid-pagination; old cursor still yields exactly 15 events, fresh pagination yields 18
9 No live-state mutation persisted session stays 404 on /status before and after; JSONL size:mtime:inode byte-identical; 0 SSE frames delivered to a concurrently-subscribed live session
10 Bounded /load vs full /transcript with --compacted-replay-max-bytes 800: /load → 3 frames + history_truncated (13 dropped); /transcript → all 15
11 409 on snapshot invalidation truncate below frozen size → 409; inode replacement → 409
12 409 session_archived mid-pagination
13 413 transcript_too_large 256 MiB + 1 byte → 413 with snapshotSize/maxBytes, before any scan
14 400 validation matrix limit=0/501/abc/<empty>, cursor=<empty>/not-base64 all 400 with correct code
15 MCP servers are not re-spawned per page 1 spawn at boot, still 1 after 3 pages (workspace transport pool does its job)

Cursor tamper matrix — 9/9 forgeries rejected with 400:

position bumped · snapshotSize forged · fileIdentity.ino forged · replay.cumulativeUsage poisoned · mac removed · mac truncated · base64 byte flipped · sessionId swapped · valid cursor of session A replayed on session B.

Teeth check (neutered hasValidCursorMac()return true, rebuilt, re-ran): the poisoned replay.cumulativeUsage cursor is then accepted with 200, and the forged snapshotSize reaches the index builder. So the HMAC added in a5d5414 is load-bearing — good call, it's the only thing validating cumulativeUsage / pendingToolCalls.


🟡 Other findings (non-blocking)

1. Deleted transcript returns 404, but the new docs promise 409.
qwen-serve-protocol.md says "If the file disappears … the next page returns 409". Observed: 404. The route's assertSessionLoadable() (routes/session.ts:592) runs first and throws SessionNotFoundError, so the child's ENOENT-with-cursor → -32010 branch (acpAgent.ts:5912) is only reachable on a narrow race. Either fix the doc or move the check.

2. forEachLineInSnapshot() is O(n²) in the length of a single record — and the 256 MiB cap is far above what it can serve.
Buffer.concat([pending, buffer]) on every 64 KiB chunk (session-transcript-reader.ts:321) re-copies the accumulated line. Measured cold-index cost, driving the compiled reader directly:

snapshot many records one giant record
4 MiB 38 ms (3,530 rec) 300 ms
8 MiB 45 ms (7,058 rec) 1,126 ms
16 MiB 94 ms (14,108 rec) 4,428 ms
32 MiB 168 ms (28,208 rec) 20,739 ms
64 MiB 57,300 ms

Normal transcripts are fine (~5 ms/MiB, linear). But a single 64 MiB record — a base64 image, a big read_file, a fat shell tool_result — sits right at the new SESSION_TRANSCRIPT_TIMEOUT_MS = 60_000, and SESSION_TRANSCRIPT_MAX_INDEX_BYTES = 256 MiB admits snapshots that would need ~15 minutes. I hit this for real: a 256 MiB snapshot returned 500 … timed out after 60000ms at 60.4 s, and the daemon's next transcript request — a 16 MiB / 13,464-record session that indexes in 94 ms on a freshly restarted daemon — also timed out at 60 s, because the child was still scanning. Milder version, same cause: while a 16 MiB single-record scan was in flight, an unrelated small /transcript went from 22 ms → 982 ms.
Fix: accumulate chunks in an array and only concat at the newline (or scan for 0x0a without concatenating).

3. A full Config is constructed and initialize()d per page.
newSessionConfig() runs extension-cache refresh, hook-system init, tool-registry + MCP discovery on every single page. It's cheap in a bare workspace (~15–20 ms) and MCP connections are pooled (verified), but 190 pages churned ~65 MB RSS in the child (peaks at 254 MB, GC returns to 220 MB — no leak, just churn). A 500-page transcript = 500 Config initializations. Consider building it once and caching it beside the transcript index.

4. The "frozen snapshot" is pinned by (path, dev, ino, size), not by content. An in-place, same-size rewrite inside the frozen prefix is served silently (verified: "turn two""TURN TWO" appears in a later page of an already-started pagination). The index cache key (makeCacheKey) also omits mtime. Harmless for an append-only JSONL — worth a comment rather than a fix.

5. The cursor is signed but not encrypted. It's plain base64url JSON, so pendingToolCalls (callIds, tool names), token counts, and the inode number are readable by any client. Probably fine for a loopback daemon; flagging it since the docs call it "opaque".

6. this.settings = settings inside the transcript handler mutates agent-wide state from a read-only status method.


Why CI didn't catch Blocker 1

Every new test stubs out the thing that crashes:

  • acpAgent.test.ts:5298vi.mocked(loadCliConfig).mockResolvedValue(...), so the argv.sessionId guard never runs.
  • server.test.ts — mocks the bridge, so no child exists.
  • integration-tests/cli/qwen-serve-routes.test.ts — the only real-daemon test, and the diff only adds 'session_transcript' to the capability-string baseline; it never calls the route.

All of the PR's own suites pass on my checkout (core reader 6/6, HistoryReplayer 26/26, acpAgent transcript 3/3, server transcript 12/12) — they just can't see this class of bug.

Highest-value test to add: one real GET /session/:id/transcript assertion in integration-tests/cli/qwen-serve-routes.test.ts against a session created by that same daemon, asserting 200 and that a second page with nextCursor also returns 200. That single test catches both blockers.


Reproduce

# 1. build + run a real daemon (isolated HOME, mock OpenAI at $OPENAI_BASE_URL)
node packages/cli/dist/index.js serve --port 4179 --workspace "$WS" --no-web

# 2. real session + a few turns
SID=$(curl -s -XPOST localhost:4179/session -H 'content-type: application/json' \
      -d '{"sessionScope":"thread"}' | jq -r .sessionId)
curl -s -XPOST localhost:4179/session/$SID/prompt -H 'content-type: application/json' \
      -d '{"prompt":[{"type":"text","text":"hi"}]}'

# 3. Blocker 1 — 500, and the ACP child is gone
DPID=$(ss -tlnp | grep 4179 | grep -o 'pid=[0-9]*' | cut -d= -f2)
ps --ppid $DPID -o pid=          # child alive
curl -s -w ' [%{http_code}]\n' "localhost:4179/session/$SID/transcript?limit=5"
ps --ppid $DPID -o pid=          # child gone; any live session is now 404

# 4. Blocker 2 — after patching (1), on a default daemon with no live session:
#    page 1 -> 200 ; page 2 with nextCursor -> 400 invalid_transcript_cursor
#    add --channel-idle-timeout-ms 600000 and it passes.

Happy to re-run the whole matrix once the two blockers are addressed — the harness is scripted and the rest of the feature already behaves exactly as the PR description claims.

中文版

运行时验证报告(真实 daemon,非 mock)

我把这个分支跑成了真实的 qwen serve daemon + 真实的 qwen --acp 子进程 + 真实持久化 session + 真实 HTTP 调用,整条链路没有任何 vitest mock。

结论:阻塞性问题。GET /session/:id/transcript 在真实 daemon 上永远不会返回 200,而且每次调用都会杀死共享的 ACP 子进程,连带拆掉该 daemon 上所有 live session。

底层设计本身是站得住的 —— 我在本地把 crash 打上补丁之后,PR 描述里声称的行为几乎全部验证通过(见下)。但按当前代码,这个 endpoint 一次成功请求都跑不出来。

验证环境
  • 分支 ef05a625c(PR head),base 880b06ed4。Linux x86_64,Node v22.22.2。
  • 真实构建 packages/corepackages/acp-bridgepackages/clidist 产物。
  • 在 tmux 中运行 node packages/cli/dist/index.js serve --port 4179 --workspace <ws> --no-web,隔离 HOMEOPENAI_BASE_URL 指向本地 mock OpenAI server。
  • 通过 POST /session + 4 次 POST /session/:id/prompt(含一次真实 list_directory 工具调用)生成真实的 20 条记录 JSONL(user / assistant / tool_result / system,真实 parentUuid 链)。
  • 所有断言都基于真实 HTTP 响应、真实进程 PID(ps --ppid)和真实 stat()

🔴 阻塞问题 1 —— 每次调用都会杀死 ACP 子进程

对一个已存在的 session(也就是这个路由唯一接受的输入)调用 GET /session/:id/transcript,会让 ACP 子进程执行 process.exit(1)

live session       = 39c77e23-…   (status 200)
acp child pid      = 1028791
GET /session/9ac2a020-…/transcript?limit=5
{"error":"agent channel closed mid-request (workspace status)"}
  -> HTTP 500
acp child pid AFTER= []                 ← 子进程没了
live session AFTER = 404                ← 无关的 live session 被销毁

daemon stderr:

[serve pid=1028791] Error: Session Id 9ac2a020-… already exists (active or archived). Delete or unarchive it first.
qwen serve: channel exited (code=1, signal=none, 1 session(s) torn down)

根因。 packages/cli/src/acp-integration/acpAgent.ts:5837resume = false 构造 Config:

const config = await this.newSessionConfig(cwd, [], settings, sessionId, false);

resume=falsenewSessionConfigloadCliConfig 传入 { sessionId }(而不是 { resume: sessionId }),于是命中了 packages/cli/src/config/config.ts:1943 的「不允许用已存在的 session id 新建 session」守卫:

const message = `Error: Session Id ${argv['sessionId']} already exists (active or archived). Delete or unarchive it first.`;
writeStderrLine(message);
process.exit(1);          // ← 杀死共享的 ACP 子进程

reader.readPage() 在这之前执行,所以不存在的 id 会提前 404、坏 cursor 会提前 400 —— 但只要请求真的读到了 transcript,就一定会杀掉子进程。这个 endpoint 不存在任何返回 200 的路径。

影响范围:daemon 只有一个 qwen --acp 子进程为所有 session 服务(--http-bridge)。读取 session X 的 transcript 会销毁无关的 live session Y(上面已实测到 1 session(s) torn down)。

建议修复: 不要把 transcript 的 sessionId 复用为新 Config 的 session id,传 undefined 即可(replay 只使用单独传入的 ctx.sessionIdHistoryReplayer 和各 emitter 从不读取 config.getSessionId())。更好的做法是每次分页只构造一次只读 Config,而不是每页一次(见「其他问题」第 3 条)。

🔴 阻塞问题 2 —— 默认配置的 daemon 上,cursor 永远走不到第 2 页

这个问题独立于阻塞问题 1,修掉 1 之后依然存在。

cursor 的 HMAC key 是子进程内的进程级随机值,懒初始化:

// packages/core/src/services/session-transcript-reader.ts:146
cursorHmacKey ??= crypto.randomBytes(32);

getSessionTranscriptPage 结尾会调用 startIdleTimer(info, 'session transcript')packages/acp-bridge/src/bridge.ts:4955),其中 resolvedChannelIdleTimeoutMs() 在未传 --channel-idle-timeout-ms 时返回 0,而 0 的语义是立即杀掉 channel。于是在没有 live session 的 daemon 上(恰恰就是这个 endpoint 的目标场景:「不走 /load 也能查看持久化 transcript」),每取一页 ACP 子进程就被销毁一次,签名密钥随之丢失。

因果 A/B(同一构建,已本地修复阻塞 1;同一 session;无 live session):

参数 结果
A (默认) 第 1 页 → 200;第 2 页 → 400 invalid_transcript_cursor。运行期间 channel 退出 2 次。
B --channel-idle-timeout-ms 600000 7/7 页,15 个事件,hasMore:false。channel 退出 0 次。

即便存在 live session,只要子进程崩溃/重启,同样会复现:

acp child = 1015315 ; 子进程存活时用 cursor 取第 2 页:  200 ✅
--- kill -9 ACP 子进程 ---
子进程重启后用同一个 cursor 取第 2 页:  400 invalid_transcript_cursor ❌

这里有两个问题:

  1. 功能层面: 默认配置的 daemon 无法翻到第 2 页。
  2. 契约层面: 本 PR 新增的文档写明,因 daemon 侧状态导致 cursor 失效应返回 409(「从第 1 页重来」);而密钥轮换返回的是 400 invalid_transcript_cursor,按同一份文档,这个码的含义是*「你篡改了 cursor」*。遵守文档的客户端会把它当成自己的 bug,而不是「重新分页」。

建议修复: 把签名密钥放到子进程生命周期之外(daemon 持有,或改在 acp-bridge 侧签名),并把「MAC 结构合法但密钥 epoch 过期」映射为 409 transcript_snapshot_unavailable 而非 400

✅ 验证通过的部分

在本地打了一处补丁(acpAgent.ts:5837sessionIdundefined)、并用一个 live session 钉住 channel 之后,我对整套设计做了真实 HTTP 验证,以下全部通过:

# 检查项 结果
1 /transcript(全部页)对比同一 session 的 POST /load SessionUpdate 载荷逐帧深度相等(15/15)
2 帧不携带 id;响应无 lastEventId 帧的 key 恰好是 ["v","type","data"];无 lastEventId
3 limit = 1 / 3 / 7 / 100 分别 20 / 7 / 3 / 1 页,每次都是完全相同的 15 事件流
4 limit 计的是 record 而非帧 limit=50 → 75 个事件;与文档一致
5 跨页工具调用(limit=1 tool_call 在第 11 页,tool_call_update:completed 在第 13 页;pendingToolCalls 正确随 cursor 传递并清除。没有误报 "missing tool result"
6 累计 usage 跨页延续 promptTokens 11 → 22 → 33 → 44
7 悬挂工具调用(删掉 tool_result failed + MISSING_TOOL_RESULT_MESSAGE 只在最后一页发出(18/18);分页输出与单页输出一致
8 snapshot 冻结 冻结在 20 条;分页中途真实追加一轮对话(→24 条,10180→12141 字节);旧 cursor 仍只产出 15 个事件,重新分页则得到 18 个
9 不改动 live 状态 持久化 session 调用前后 /status 均为 404;JSONL 的 size:mtime:inode 完全一致;并发订阅的 live session 收到 0 个 SSE 帧
10 有界 /load vs 完整 /transcript --compacted-replay-max-bytes 800 下:/load → 3 帧 + history_truncated(丢弃 13);/transcript → 完整 15 帧
11 snapshot 失效返回 409 截断到小于冻结大小 → 409;inode 被替换 → 409
12 分页中途归档 → 409 session_archived
13 413 transcript_too_large 256 MiB + 1 字节 → 413,带 snapshotSize/maxBytes,且在扫描前返回
14 400 参数校验矩阵 limit=0/501/abc/<空>cursor=<空>/not-base64 全部 400code 正确
15 每页不会重复拉起 MCP server 启动时 1 次,3 页之后仍是 1 次(workspace transport pool 生效)

cursor 篡改矩阵 —— 9/9 全部被 400 拒绝:

position 改大 · 伪造 snapshotSize · 伪造 fileIdentity.ino · 投毒 replay.cumulativeUsage · 删除 mac · 截断 mac · 翻转一个 base64 字节 · 替换 sessionId · 把 session A 的合法 cursor 用在 session B 上。

有效性(teeth)验证:把 hasValidCursorMac() 改成 return true 重新构建后再跑 —— 投毒过的 replay.cumulativeUsage cursor 会被200 接受,伪造的 snapshotSize 也能进到索引构建。说明 a5d5414 加的 HMAC 确实是承重的:它是 cumulativeUsage / pendingToolCalls 唯一的校验手段。这个决定是对的。

🟡 其他问题(非阻塞)

1. 删除 transcript 文件返回 404,但本 PR 新增的文档承诺 409
qwen-serve-protocol.md 写的是「If the file disappears … the next page returns 409」。实测:404。路由里的 assertSessionLoadable()routes/session.ts:592)先执行并抛 SessionNotFoundError,因此子进程侧 ENOENT + cursor → -32010 的分支(acpAgent.ts:5912)只有在极窄的竞态下才可达。要么改文档,要么调整检查顺序。

2. forEachLineInSnapshot() 对单条 record 的长度是 O(n²),而 256 MiB 的上限远超它能承受的范围。
每读一个 64 KiB chunk 就执行一次 Buffer.concat([pending, buffer])session-transcript-reader.ts:321),会把已累积的整行重新拷贝一遍。直接驱动编译后的 reader 实测冷索引耗时:

snapshot 多条 record 单条巨型 record
4 MiB 38 ms(3,530 条) 300 ms
8 MiB 45 ms(7,058 条) 1,126 ms
16 MiB 94 ms(14,108 条) 4,428 ms
32 MiB 168 ms(28,208 条) 20,739 ms
64 MiB 57,300 ms

正常 transcript 没问题(约 5 ms/MiB,线性)。但一条 64 MiB 的 record —— base64 图片、大文件 read_file、体积大的 shell tool_result —— 就正好卡在新增的 SESSION_TRANSCRIPT_TIMEOUT_MS = 60_000 上;而 SESSION_TRANSCRIPT_MAX_INDEX_BYTES = 256 MiB 允许的 snapshot 需要约 15 分钟。我实测触发过:256 MiB 的 snapshot 在 60.4 s 时返回 500 … timed out after 60000ms;紧接着同一 daemon 上的下一个 transcript 请求 —— 一个 16 MiB / 13,464 条 record 的 session,在重启后的 daemon 上索引只要 94 ms —— 同样在 60 s 超时,因为子进程还在扫描。程度更轻但同因的现象:在一个 16 MiB 单 record 扫描进行时,另一个无关的小 /transcript 从 22 ms 劣化到 982 ms。
修法:用数组累积 chunk,只在换行处 concat(或直接扫描 0x0a 而不做拼接)。

3. 每一页都会构造并 initialize() 一个完整的 Config
newSessionConfig() 每页都会跑 extension 缓存刷新、hook system 初始化、tool registry + MCP discovery。在空工作区里很便宜(约 15–20 ms),MCP 连接也确实走了池化(已验证),但 190 页会让子进程 RSS 抖动约 65 MB(峰值 254 MB,GC 后回落到 220 MB —— 不是泄漏,只是 churn)。500 页的 transcript 就是 500 次 Config 初始化。建议构造一次并与 transcript index 一起缓存。

4. 「冻结的 snapshot」是靠 (path, dev, ino, size) 钉住的,不是靠内容。 在冻结前缀内做等长的原地改写会被静默返回(已验证:分页进行中把 "turn two" 改成 "TURN TWO",后续页面里就出现了新内容)。索引缓存 key(makeCacheKey)同样不含 mtime。对 append-only 的 JSONL 无害 —— 加条注释即可,不必改代码。

5. cursor 只做了签名,没有加密。 它就是明文 base64url JSON,任何客户端都能读到 pendingToolCalls(callId、工具名)、token 计数和 inode。对 loopback daemon 大概率无所谓;只是文档把它称作 "opaque",这里提一下。

6. transcript handler 里的 this.settings = settings 让一个只读的 status 方法改写了 agent 级别的全局状态。

为什么 CI 没有拦住阻塞问题 1

新增的每个测试恰好都把会崩的那块 mock 掉了:

  • acpAgent.test.ts:5298 —— vi.mocked(loadCliConfig).mockResolvedValue(...)argv.sessionId 守卫根本不会执行。
  • server.test.ts —— mock 掉了 bridge,压根没有子进程。
  • integration-tests/cli/qwen-serve-routes.test.ts —— 唯一跑真实 daemon 的测试,但 diff 只往 capability 字符串基线里加了 'session_transcript',从未调用该路由。

PR 自带的测试套在我的 checkout 上全部通过(core reader 6/6、HistoryReplayer 26/26、acpAgent transcript 3/3、server transcript 12/12)—— 它们只是看不见这一类 bug。

性价比最高的补测:integration-tests/cli/qwen-serve-routes.test.ts 里,针对同一个 daemon 创建的 session,加一条真实的 GET /session/:id/transcript 断言,同时断言 200 以及nextCursor 取第二页也返回 200。这一个测试就能同时抓住两个阻塞问题。

复现步骤

# 1. 构建并启动真实 daemon(隔离 HOME,$OPENAI_BASE_URL 指向 mock OpenAI)
node packages/cli/dist/index.js serve --port 4179 --workspace "$WS" --no-web

# 2. 真实 session + 几轮对话
SID=$(curl -s -XPOST localhost:4179/session -H 'content-type: application/json' \
      -d '{"sessionScope":"thread"}' | jq -r .sessionId)
curl -s -XPOST localhost:4179/session/$SID/prompt -H 'content-type: application/json' \
      -d '{"prompt":[{"type":"text","text":"hi"}]}'

# 3. 阻塞问题 1 —— 500,且 ACP 子进程消失
DPID=$(ss -tlnp | grep 4179 | grep -o 'pid=[0-9]*' | cut -d= -f2)
ps --ppid $DPID -o pid=          # 子进程存活
curl -s -w ' [%{http_code}]\n' "localhost:4179/session/$SID/transcript?limit=5"
ps --ppid $DPID -o pid=          # 子进程消失;任何 live session 现在都是 404

# 4. 阻塞问题 2 —— 修复 (1) 之后,在无 live session 的默认 daemon 上:
#    第 1 页 -> 200 ;带 nextCursor 的第 2 页 -> 400 invalid_transcript_cursor
#    加上 --channel-idle-timeout-ms 600000 就能通过。

两个阻塞问题修好后我可以把整套矩阵重跑一遍 —— 验证脚本都已经写好了,而且这个特性的其余部分完全符合 PR 描述。

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@wenshao

wenshao commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

🧪 Maintainer local validation — PR #6525

Built this PR from source on macOS and ran the full test plan plus a live end-to-end run against a real qwen serve daemon. The feature itself is correct and well-covered. I did find one test-isolation issue that will fail the CI integration job (test-only, not a product bug) — details + a one-line fix below.

Commit validated: 13f8f90 (qwen: adopt transcript review suggestions (#6525)) · Env: macOS (Darwin 24.6), Node v22.23.1, npm 10.9.8

✅ Build & static checks — all green

Check Command Result
Whitespace git diff --check ✅ clean
Build npm run build ✅ exit 0
Typecheck npm run typecheck ✅ all workspaces
Bundle npm run bundle ✅ exit 0
Lint core / cli / acp-bridge / sdk (targeted) ✅ exit 0

✅ Feature tests — all green

Suite Result
core · session-transcript-reader.test.ts 30 passed
cli · server.test.tsGET /session/:id/transcript 18 passed
cli · acpAgent.test.tsqwen/status/session/transcript 8 passed
cli · HistoryReplayer.test.ts 32 passed
acp-bridge · bridge.test.ts 368 passed
core · config.test.ts (incl. lenientToolWarmup) 371 passed
sdk-typescript · DaemonClient.test.tstranscript 2 passed
integration · qwen-serve-routes.test.ts → transcript block (real daemon, in isolation) 4 passed

✅ Live end-to-end run against a real daemon

Spawned a real node dist/index.js serve process, wrote a persisted JSONL transcript, and drove the endpoint over real HTTP. Cursor pagination and every documented error code behaved exactly per the protocol doc:

### PAGE 1 — GET /session/:id/transcript?limit=1  (HTTP 200)
{ sessionId: "99999999-…-1234567890ab", hasMore: true,
  nextCursor: "eyJ2IjoxLCJzZXNzaW9uSWQiOiI5OTk5…(signed)",
  eventCount: 1, eventTypes: ["session_update"], anyEventBusIds: false }
### PAGE 2 / PAGE 3  → hasMore: true, following nextCursor
### PAGE 4          → hasMore: false        ← pagination terminates
PAGINATION COMPLETE: 4 pages, 4 id-less session_update events, hasMore finally false

### ERR ?limit=0                → 400 invalid_transcript_limit   {maxLimit: 500}
### ERR ?cursor=not-a-cursor    → 400 invalid_transcript_cursor
### ERR unknown session         → 404
### ERR archived session        → 409 session_archived
### ERR oversized snapshot      → 413 transcript_too_large  {snapshotSize, maxBytes}
✅ All real-HTTP behaviors matched the documented contract.

Confirmed against the PR's stated contract: pages carry id-less session_update frames only, no EventBus id/lastEventId, nextCursor is signed/opaque, and hasMore flips to false on the last page over the frozen snapshot.

⚠️ Finding — test isolation breaks the CI integration job (test-only, fixable in 1 line)

Running the whole integration-tests/cli/qwen-serve-routes.test.ts file, the pre-existing sibling test PATCH /session/:id/metadata > updates displayName fails deterministically (3/3 clean isolated runs):

× qwen serve — PATCH /session/:id/metadata > updates displayName 4097ms (retry x2)
  → expected undefined to be 'Integration Test Session'
  Tests  1 failed | 31 passed (32)

Root cause (bisected): the new test rejects oversized transcript snapshots with 413 (line 485) does
truncateSync(filePath, SESSION_TRANSCRIPT_MAX_INDEX_BYTES + 1) → it leaves a 256 MiB sparse JSONL fixture in the shared workspace's chats/ dir and never removes it. The later metadata test calls listWorkspaceSessions(REPO_ROOT), which then churns on that 256 MiB file (~4–5.7 s) and returns the live session without its displayName.

Scenario Result
merge-base main (no transcript block), full file ✅ 28/28
PR HEAD, full file, clean & isolated (×3) ❌ 31/32 — metadata fails 3/3
oversized test + metadata (only these two) ❌ metadata fails (5.7 s)
archived/conflicting (small files) + metadata ✅ both pass (0.7 s)
PR HEAD + one-line cleanup (below), full file (×2) 32/32

Impact: Integration Tests (CLI, No Sandbox) (vitest run --root ./integration-tests cli) runs this whole file, so it will go red deterministically once it runs. Not a product bug — the /transcript endpoint's 413 handling is correct (see live run above); this is purely test hygiene.

Proposed fix (verified → 32/32 green):

       code: 'transcript_too_large',
       maxBytes: SESSION_TRANSCRIPT_MAX_INDEX_BYTES,
     });
+    // Remove the 256MiB sparse fixture so later tests'
+    // listWorkspaceSessions(REPO_ROOT) doesn't choke on it.
+    rmSync(filePath, { force: true });
   });
 });

(Equivalent alternatives: scope the transcript fixtures to a dedicated cwd instead of REPO_ROOT, or clean the chats/ dir in an afterEach.)

Verdict

Feature: LGTM — solid coverage, live HTTP matches the documented contract, build/typecheck/lint green. One blocker before merge: the 1-line test cleanup above, otherwise the integration CI job fails deterministically.

🇨🇳 中文版本(点击展开)

🧪 Maintainer 本地验证 — PR #6525

我在本地(macOS)从源码构建了该 PR,运行了完整的测试计划,并对真实的 qwen serve daemon 做了端到端实测功能本身是正确的、覆盖充分。 但我发现了一个会导致 CI 集成测试任务失败的测试隔离问题(仅测试代码,不是产品 bug)—— 详情和一行修复见下文。

验证的 commit: 13f8f90qwen: adopt transcript review suggestions (#6525))· 环境: macOS (Darwin 24.6),Node v22.23.1,npm 10.9.8

✅ 构建与静态检查 —— 全部通过

检查项 命令 结果
空白字符 git diff --check ✅ 干净
构建 npm run build ✅ exit 0
类型检查 npm run typecheck ✅ 全部 workspace
打包 npm run bundle ✅ exit 0
Lint core / cli / acp-bridge / sdk(定向) ✅ exit 0

✅ 功能测试 —— 全部通过

测试套件 结果
core · session-transcript-reader.test.ts 30 通过
cli · server.test.tsGET /session/:id/transcript 18 通过
cli · acpAgent.test.tsqwen/status/session/transcript 8 通过
cli · HistoryReplayer.test.ts 32 通过
acp-bridge · bridge.test.ts 368 通过
core · config.test.ts(含 lenientToolWarmup 371 通过
sdk-typescript · DaemonClient.test.tstranscript 2 通过
集成 · qwen-serve-routes.test.ts → transcript 块(真实 daemon,单独运行) 4 通过

✅ 对真实 daemon 的端到端实测

启动了真实的 node dist/index.js serve 进程,写入持久化 JSONL transcript,并通过真实 HTTP 驱动 endpoint。游标分页和每一个文档记录的错误码都完全符合协议文档:

### PAGE 1 — GET /session/:id/transcript?limit=1  (HTTP 200)
{ sessionId: "99999999-…-1234567890ab", hasMore: true,
  nextCursor: "eyJ2IjoxLCJzZXNzaW9uSWQiOiI5OTk5…(signed)",
  eventCount: 1, eventTypes: ["session_update"], anyEventBusIds: false }
### PAGE 2 / PAGE 3  → hasMore: true,跟随 nextCursor
### PAGE 4          → hasMore: false        ← 分页结束
PAGINATION COMPLETE:4 页,4 条无 id 的 session_update 事件,hasMore 最终为 false

### ERR ?limit=0                → 400 invalid_transcript_limit   {maxLimit: 500}
### ERR ?cursor=not-a-cursor    → 400 invalid_transcript_cursor
### ERR 未知 session            → 404
### ERR 已归档 session          → 409 session_archived
### ERR 超大快照                → 413 transcript_too_large  {snapshotSize, maxBytes}
✅ 所有真实 HTTP 行为都与文档契约一致。

已确认符合契约:每页只有无 id 的 session_update 帧,不含 EventBus 的 id/lastEventIdnextCursor 为签名/不透明字符串,hasMore 在冻结快照最后一页翻转为 false

⚠️ 发现 —— 测试隔离问题会导致 CI 集成任务失败(仅测试代码,一行可修)

运行整个 integration-tests/cli/qwen-serve-routes.test.ts 文件时,已存在的兄弟测试 PATCH /session/:id/metadata > updates displayName稳定失败(3/3 干净隔离运行)

× qwen serve — PATCH /session/:id/metadata > updates displayName 4097ms (retry x2)
  → expected undefined to be 'Integration Test Session'
  Tests  1 failed | 31 passed (32)

根因(已二分定位): 新增测试 rejects oversized transcript snapshots with 413(第 485 行)执行了
truncateSync(filePath, SESSION_TRANSCRIPT_MAX_INDEX_BYTES + 1),在共享 workspace 的 chats/ 目录里留下一个 256 MiB 的稀疏 JSONL 文件且从不清理。后面的 metadata 测试调用 listWorkspaceSessions(REPO_ROOT),就会去读这个 256 MiB 文件(约 4–5.7 秒),最终返回的 live session 没有 displayName

场景 结果
merge-base main(无 transcript 块)完整文件 ✅ 28/28
PR HEAD 完整文件,干净隔离(×3) ❌ 31/32 —— metadata 3/3 失败
oversized 测试 + metadata(只跑这两个) ❌ metadata 失败(5.7 秒)
archived/conflicting(小文件)+ metadata ✅ 都通过(0.7 秒)
PR HEAD + 一行清理(见下)完整文件(×2) 32/32

影响: Integration Tests (CLI, No Sandbox)vitest run --root ./integration-tests cli)会跑整个文件,所以一旦运行就会稳定变红这不是产品 bug —— /transcript endpoint 的 413 处理是正确的(见上面的实测);纯粹是测试卫生问题。

建议修复(已验证 → 32/32 通过):

       code: 'transcript_too_large',
       maxBytes: SESSION_TRANSCRIPT_MAX_INDEX_BYTES,
     });
+    // 删除 256MiB 稀疏文件,避免后续测试的
+    // listWorkspaceSessions(REPO_ROOT) 被它拖垮。
+    rmSync(filePath, { force: true });
   });
 });

(等价方案:把 transcript 的 fixture 写到一个专用 cwd 而不是 REPO_ROOT,或在 afterEach 里清理 chats/ 目录。)

结论

功能:LGTM —— 覆盖充分,真实 HTTP 行为符合文档契约,构建/类型检查/lint 全绿。合并前有一个阻塞项: 上面这一行测试清理,否则集成 CI 任务会稳定失败。

@wenshao

wenshao commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /resolve

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

Qwen Code resolved the merge conflicts and pushed the branch update.

Merge conflict resolution summary — PR #6525

Base branch

main (merged origin/main into PR branch)

Conflicted file

packages/cli/src/serve/routes/session.ts

Conflict description

Both branches added new helper functions at the same insertion point (after sendSessionOrganizationError):

  • PR branch (HEAD) added four transcript-replay helpers:

    • parseTranscriptLimitQuery — validates ?limit= query param for cursor-paged transcript endpoint
    • parseTranscriptCursorQuery — validates ?cursor= query param
    • transcriptSnapshotUnavailableError — creates a typed error for unavailable transcript snapshots
    • shouldPreserveTranscriptResolutionError — identifies errors that should be surfaced to callers
  • main (origin/main) added one approval-mode helper:

    • parseOptionalApprovalMode — parses an optional approvalMode field from request body

Resolution

Kept both sides. The functions are independent additions that serve different features (transcript pagination vs. approval mode override) and are all referenced by route handlers later in the file:

  • parseTranscriptLimitQuery → line 1242
  • parseTranscriptCursorQuery → line 1244
  • transcriptSnapshotUnavailableError → line 694
  • shouldPreserveTranscriptResolutionError → lines 734, 738, 772
  • parseOptionalApprovalMode → lines 889, 1004

All required imports (SESSION_TRANSCRIPT_MAX_LIMIT, SessionArchivedError, SessionConflictError, SessionNotFoundError, APPROVAL_MODES, ApprovalMode) were already present in the file's import block.

Commit

chore(serve): resolve merge conflict with main in session routes

wenshao
wenshao previously approved these changes Jul 10, 2026

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed — no blockers. Suggestions are inline.

— qwen3.7-max via Qwen Code /review

Comment thread packages/core/src/services/session-transcript-reader.test.ts Outdated
Comment thread packages/core/src/services/session-transcript-reader.test.ts
…#6525)

The transcript-paging integration suite wrote ~6 persisted chats/*.jsonl sessions into the daemon's project dir and never removed them. Because vitest runs a file's suites sequentially, those leftover sessions widened a pre-existing race in the later 'PATCH /session/:id/metadata > updates displayName' test (a freshly-created session can exist on disk but not yet appear in the listWorkspaceSessions page), making it fail deterministically in the no-AK smoke run. Add an afterAll to the transcript suite that removes the project chats/ dir, restoring a clean session list for subsequent suites. Verified: full no-AK suite now passes 43/43 across repeated runs.
…wenLM#6525)

The record() helper derived the ISO timestamp seconds from text.length, producing invalid values (e.g. 00:00:013) once a record's text reached 10+ chars — harmless today only because no test asserted startTime. Replace it with a monotonic base+offset timestamp (always valid, strictly increasing). Also assert the previously-unchecked required SessionTranscriptRecordPage fields (sessionId, filePath, startTime, lastUpdated); the strict-ISO checks on startTime/lastUpdated guard against the timestamp-helper class of bug.

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed — no blockers. Suggestions are inline.

— qwen3.7-max via Qwen Code /review

return base;
}

async function readAggregatedRecords(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] readAggregatedRecords performs sequential readSegmentRecords calls for each UUID in the page. Since these reads are independent (no data dependency between UUIDs), they can be parallelized with Promise.all, reducing wall-clock time on high-latency storage from O(N) sequential reads to roughly a single read plus aggregation overhead.

Suggested change
async function readAggregatedRecords(
async function readAggregatedRecords(
index: TranscriptIndex,
uuids: string[],
): Promise<ChatRecord[]> {
const handle = await fsp.open(index.filePath, 'r');
try {
const results = await Promise.all(
uuids.map(async (uuid) => {
const entry = index.byUuid.get(uuid);
if (!entry) return null;
const physicalRecords: ChatRecord[] = [];
for (const segment of entry.segments) {
physicalRecords.push(
...(await readSegmentRecords(handle, index.filePath, segment, uuid)),
);
}
return physicalRecords.length > 0
? aggregateRecords(physicalRecords)
: null;
}),
);
return results.filter((r): r is ChatRecord => r !== null);
} finally {
await handle.close();
}
}

— qwen3.7-max via Qwen Code /review

}
this.transcriptReplayConfigCache.clear();
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] When a cache entry with an in-flight pending promise is replaced by a new entry (settings changed between concurrent calls), the old pending Config is disposed in the current !== entry branch. However, disposeTranscriptReplayConfig catches disposal failures at debug level only — if the tool registry's stop() fails, resources (child processes, sockets from MCP transport) leak silently. For a long-running daemon, this could compound over time.

Consider attaching a fire-and-forget cleanup directly to the promise rather than relying on the post-await check:

void pending.then(config => {
  if (this.transcriptReplayConfigCache.get(key) !== entry) {
    this.disposeTranscriptReplayConfig(config);
  }
}).catch(() => {});

— qwen3.7-max via Qwen Code /review

): Promise<Config> {
const key = path.resolve(cwd);
const cached = this.transcriptReplayConfigCache.get(key);
if (cached?.settings === settings) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Coalesced callers sharing the same pending promise can both enter the staleness check when settings change mid-load. The first continuation to run disposes the Config; the second returns the same (now disposed) Config to its caller. That caller then runs transcript replay against a Config whose tool registry has been stopped.

Consider ensuring only one continuation owns the disposal path — e.g., by wrapping the shared promise so each caller gets its own .then() chain, or by tracking which caller is responsible for cleanup.

— qwen3.7-max via Qwen Code /review

expect(bridge.sessionTranscriptCalls).toHaveLength(0);
});

it.each(['501', '0', 'abc', '-1', '1&limit=2'])(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The it.each block tests 501, 0, abc, -1, and 1&limit=2 as invalid, but doesn't test that limit=1 and limit=500 (the valid boundaries) are accepted. An explicit boundary test would guard against off-by-one errors in the validation logic.

— qwen3.7-max via Qwen Code /review

);
}

it('returns a paged transcript and does not expose EventBus cursors', async () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] No test verifies the default behavior when no limit query parameter is provided. The route's parseTranscriptLimitQuery returns undefined when no limit is present, which is a distinct code path. Consider adding a test that sends GET /session/:id/transcript without a limit param and asserts the bridge receives no limit field.

— qwen3.7-max via Qwen Code /review

});
});

it('does not evict cached indexes when a new index exceeds the byte budget alone', async () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The byte-budget cache eviction is only tested in the negative direction (an oversized new index doesn't evict). There is no positive test where adding a moderate-size index causes the total to exceed the budget and the oldest entry IS evicted. The while (getIndexCacheBytes() > getIndexCacheMaxBytes()) loop is a distinct code path that is never exercised.

— qwen3.7-max via Qwen Code /review

events: BridgeEvent[];
nextCursor?: string;
hasMore: boolean;
startTime?: string;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] startTime and lastUpdated are typed as optional (string | undefined), but the implementation in acpAgent.ts always sets them from page.startTime / page.lastUpdated (which are required fields on SessionTranscriptRecordPage). Consider making them required in the interface to match the actual contract, or document why they might be absent.

— qwen3.7-max via Qwen Code /review

@@ -212,6 +215,7 @@ export async function assertSessionLoadable(
if (location === 'conflict') {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] assertSessionLoadable changed its return type from Promise<void> to Promise<SessionLocation>. All 3 existing callers (load route, export route, dispatch) ignore the return value, so they are unaffected — but the type change is untested. Consider adding a test that asserts the return value for completeness.

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot qwen-code-ci-bot added category/core Core engine and logic daemon scope/session-management Session state and persistence type/feature-request New feature or enhancement request labels Jul 10, 2026

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, looks ready to ship. ✅

All previous critical findings resolved. Build, typecheck, lint green. 32/32 integration tests pass (test isolation fix verified). Real-daemon endpoint tests confirm correct HTTP behavior for all documented error codes.

The session_transcript capability fills the gap left by bounded /load from #6482. HMAC-signed cursors, file identity tracking, and bounded index cache are solid defensive measures. Clean four-package architecture.

One advisory note: 1,024 production lines in core paths — maintainer @wenshao has already validated extensively.

@doudouOUC
doudouOUC added this pull request to the merge queue Jul 10, 2026
Merged via the queue into QwenLM:main with commit 38384ae Jul 10, 2026
25 of 26 checks passed
@wenshao

wenshao commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

✅ Local verification report (maintainer)

Built and exercised this PR end-to-end on the PR head. Build, typecheck, lint, every targeted unit suite, the real-daemon integration suite, and a live curl walkthrough all pass — no code defects found, the endpoint behaves exactly as documented. LGTM for merge.

Environment — commit 1ea2bcbc7 · branch agent/full-transcript-paged-replay · macOS (Darwin 24.6) · Node v22.23.1 · npm 10.9.8. Every check is hermetic (no LLM / network): the transcript route reads persisted JSONL and reconstructs id-less replay frames.

What I ran

Check Result
npm run build + npm run bundle
npm run typecheck (all workspaces)
Lint — core · cli · acp-bridge · sdk (targeted) ✅ clean
git diff --check ✅ no whitespace errors
core · session-transcript-reader.test.ts ✅ 30/30
cli · acpAgent.test.ts -t "qwen/status/session/transcript" ✅ 8
cli · server.test.ts -t "GET /session/:id/transcript" ✅ 18
cli · HistoryReplayer.test.ts (replayPage, cross-page tool calls) ✅ 32/32
acp-bridge · bridge.test.ts (getSessionTranscriptPage) ✅ 374/374
sdk · DaemonClient.test.ts -t "transcript" ✅ 2
core · config.test.ts (lenientToolWarmup) ✅ 1
integration · real daemon qwen-serve-routes.test.ts 32/32

Live daemon walkthrough

Started a real qwen serve daemon against a hand-written 6-record persisted transcript (3 user/assistant turns) and drove the route over HTTP.

Cursor paginationlimit=2, following nextCursor to the end:

# /capabilities → "session_transcript" advertised ✔

── PAGE 1 ──  events=2  hasMore=true
   [user_message_chunk]  What files are in this project?
   [agent_message_chunk] This project contains a README and a src/ directory.
── PAGE 2 ──  events=2  hasMore=true
   [user_message_chunk]  Summarize the README for me.
   [agent_message_chunk] The README documents the qwen-serve daemon HTTP API.
── PAGE 3 ──  events=2  hasMore=false
   [user_message_chunk]  Thanks — one more: what port does it use by default?
   [agent_message_chunk] The daemon listens on 127.0.0.1:4170 by default.
──────────────
TOTAL replay frames: 6   (all id-less session_update · no lastEventId · correct order ✔)

Guarantees & error contract (HTTP status → body code):

frozen snapshot   append 2 records after page 1, then resume the cursor
                  → appended records excluded; walk still returns only the original 6 ✔
409 conflict      truncate the file after the cursor was issued
                  → HTTP 409  transcript_snapshot_unavailable ✔
413 too large     frozen snapshot > 256 MiB indexing cap
                  → HTTP 413  transcript_too_large (maxBytes 268435456) ✔
400 limit         ?limit=0 · ?limit=501 · ?limit=abc
                  → HTTP 400  invalid_transcript_limit ✔
400 cursor        garbage cursor · valid base64url with a forged HMAC signature
                  → HTTP 400  invalid_transcript_cursor ✔  (signed-cursor tamper check works)
404 / 401         unknown session → 404 · missing bearer token → 401 ✔

Side-effect-free — after every call above:

GET /daemon/status  →  liveSessions.active = 0
GET /session/:id/status  →  404

This confirms the core promise: /transcript does not attach a client, seed the EventBus, create a live session, or change the live SSE replay window — it is cleanly separate from /load.

One note for reviewers running this locally (not a defect)

The integration suite executes the esbuild bundle at dist/cli.js (via TEST_CLI_PATH). If you run it after only npm run build — which rebuilds the per-package dist/ but not the root bundle — it silently exercises stale code and the route 404s. Run npm run bundle first (CI already does this). With a freshly built bundle the suite is a clean 32/32.


🇨🇳 中文版本

✅ 本地验证报告(维护者)

我在 PR 最新 commit 上对本 PR 做了完整的端到端验证。构建、类型检查、lint、所有针对性单元测试、真实 daemon 集成测试,以及一次实时 curl 走查全部通过——未发现代码缺陷,endpoint 行为与文档完全一致。 同意合并。

环境 — commit 1ea2bcbc7 · 分支 agent/full-transcript-paged-replay · macOS (Darwin 24.6) · Node v22.23.1 · npm 10.9.8。所有检查都是自包含的(不依赖 LLM / 网络):transcript 路由直接读取持久化的 JSONL 并重建不带 id 的 replay frames。

执行的检查

检查项 结果
npm run build + npm run bundle
npm run typecheck(全部 workspace)
Lint — core · cli · acp-bridge · sdk(针对性) ✅ 干净
git diff --check ✅ 无空白字符问题
core · session-transcript-reader.test.ts ✅ 30/30
cli · acpAgent.test.ts -t "qwen/status/session/transcript" ✅ 8
cli · server.test.ts -t "GET /session/:id/transcript" ✅ 18
cli · HistoryReplayer.test.tsreplayPage,跨页 tool call) ✅ 32/32
acp-bridge · bridge.test.tsgetSessionTranscriptPage ✅ 374/374
sdk · DaemonClient.test.ts -t "transcript" ✅ 2
core · config.test.tslenientToolWarmup ✅ 1
集成 · 真实 daemon qwen-serve-routes.test.ts 32/32

实时 daemon 走查

启动了一个真实的 qwen serve daemon,使用一个手写的 6 条记录持久化 transcript(3 组 user/assistant 对话),并通过 HTTP 驱动该路由。

Cursor 分页limit=2,跟随 nextCursor 直到结束:

# /capabilities → 已广告 "session_transcript" ✔

── PAGE 1 ──  events=2  hasMore=true
   [user_message_chunk]  What files are in this project?
   [agent_message_chunk] This project contains a README and a src/ directory.
── PAGE 2 ──  events=2  hasMore=true
   [user_message_chunk]  Summarize the README for me.
   [agent_message_chunk] The README documents the qwen-serve daemon HTTP API.
── PAGE 3 ──  events=2  hasMore=false
   [user_message_chunk]  Thanks — one more: what port does it use by default?
   [agent_message_chunk] The daemon listens on 127.0.0.1:4170 by default.
──────────────
TOTAL replay frames: 6   (全部为不带 id 的 session_update · 无 lastEventId · 顺序正确 ✔)

语义保证与错误契约(HTTP 状态码 → body code):

frozen snapshot   第 1 页之后追加 2 条记录,再用 cursor 继续翻页
                  → 追加的记录被排除;整个走查仍然只返回原始的 6 条 ✔
409 conflict      cursor 签发后截断文件
                  → HTTP 409  transcript_snapshot_unavailable ✔
413 too large     冻结 snapshot 超过 256 MiB 索引上限
                  → HTTP 413  transcript_too_large(maxBytes 268435456)✔
400 limit         ?limit=0 · ?limit=501 · ?limit=abc
                  → HTTP 400  invalid_transcript_limit ✔
400 cursor        非法 cursor · 合法 base64url 但伪造 HMAC 签名
                  → HTTP 400  invalid_transcript_cursor ✔(签名 cursor 的防篡改校验有效)
404 / 401         未知 session → 404 · 缺少 bearer token → 401 ✔

无副作用 — 以上所有调用之后:

GET /daemon/status  →  liveSessions.active = 0
GET /session/:id/status  →  404

这验证了核心承诺:/transcript 不会 attach client、不会 seed EventBus、不会创建 live session、也不会改变 live SSE replay window——与 /load 完全解耦。

给本地复现的 reviewer 的一点提示(非缺陷)

集成测试通过 TEST_CLI_PATH 执行 dist/cli.js 这个 esbuild bundle。如果只跑了 npm run build(它只重建各 package 的 dist/重建根 bundle)就跑集成测试,会静默地测到旧代码,路由返回 404。需要先跑 npm run bundle(CI 已经这么做)。用新构建的 bundle,测试是干净的 32/32。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

category/core Core engine and logic daemon scope/session-management Session state and persistence type/feature-request New feature or enhancement request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants