Skip to content

fix(qqbot): restore AcpBridge session loading — return input sessionId, patch catch path - #7722

Merged
wenshao merged 1 commit into
QwenLM:mainfrom
Eric-GoodBoy-Tech:fix/session-restore-acpbridge-loadsession
Jul 25, 2026
Merged

fix(qqbot): restore AcpBridge session loading — return input sessionId, patch catch path#7722
wenshao merged 1 commit into
QwenLM:mainfrom
Eric-GoodBoy-Tech:fix/session-restore-acpbridge-loadsession

Conversation

@Eric-GoodBoy-Tech

Copy link
Copy Markdown
Contributor

What this PR does

Two changes to fix QQ Bot session restore after WebSocket reconnect:

  1. AcpBridge.loadSession() returns the input sessionId instead of response.sessionId — ACP's LoadSessionResponse schema deliberately omits sessionId (the client already knows it), so response.sessionId is always undefined.

  2. QQChannel READY catch handler now calls fixRestoredSessions() — the existing workaround that reads the raw sessions.json persist file and patches the router maps with correct session IDs. Previously it only ran in the .then() path, which was never reached after PR feat(channels): Add channel agent bridge abstraction #5978 added validation in restoreSessions().

Why it's needed

The QQ Bot loses all session routing after every WebSocket reconnect when QQ sends 4009 (session timeout). Logs show [SessionRouter] Failed to restore session X: Invalid restored session ID and Ready (0 sessions) on every reconnect. All conversation context is lost until a new message creates a fresh session.

Reviewer Test Plan

How to verify

  1. Start qwen channel start with a QQ Bot channel configured
  2. Send a message to the bot — a session is created
  3. Kill and restart the qwen channel start process (or trigger a QQ WebSocket 4009 reconnect)
  4. Verify the bot resumes with the restored session context

Evidence (Before & After)

Before: After reconnect, logs show:

[SessionRouter] Failed to restore session b1f3a8df-... for key qq-bot:__single__: Invalid restored session ID
[QQ:qq-bot] Ready (0 sessions)

After: The session is restored from the persist file via fixRestoredSessions(), and Ready (N sessions) shows the count of restored sessions.

Tested on

OS Status
🍏 macOS ⚠️ not tested on local env
🪟 Windows N/A
🐧 Linux N/A

Environment

qwen channel start mode (not qwen serve --channel).

Risk & Scope

  • Main risk or tradeoff: fixRestoredSessions() uses type coercion to access private SessionRouter fields — it's already labeled as fragile in the existing JSDoc. But this is the same workaround that was working in PR feat(channel): add QQ Bot (QQ机器人) channel adapter #5202, only now also triggered from the catch path.
  • Not validated / out of scope: qwen serve --channel mode uses DaemonChannelBridge which has a separate loadSession implementation and is unaffected.
  • Breaking changes / migration notes: None.

Linked Issues

Fixes #7721

中文说明

修复 QQ Bot session 在 WebSocket 重连后丢失的问题。两个改动:

  1. AcpBridge.loadSession() 直接返回输入的 sessionId 而非 response.sessionId——ACP 协议的 LoadSessionResponse 设计上不含 sessionId,所以 response.sessionId 一直是 undefined。
  2. QQChannel READY catch handler 新增调用 fixRestoredSessions()——原有的 workaround 会从 sessions.json 文件直接读取正确 sessionId 并修复路由映射。之前它只在 .then() 路径执行,但 PR feat(channels): Add channel agent bridge abstraction #5978restoreSessions().catch() 路径了。

关联 Issue #7721

…d, patch catch path

AcpBridge.loadSession() has been returning response.sessionId since
introduction, but ACP's LoadSessionResponse schema does not include a
sessionId field — so response.sessionId is always undefined.

The QQChannel originally worked around this with fixRestoredSessions()
that reads the raw persist file and patches the router maps. However,
after SessionRouter added validation in PR QwenLM#5978, restoreSessions()
throws on undefined sessionId, and the .catch() handler never called
fixRestoredSessions().

Before PR QwenLM#6457 this was masked by WebSocket RESUME — the QQ-level
session recovery meant restoreSessions() was never reached on reconnect.
PR QwenLM#6457 added tryResume=false on non-1000 close, triggering full
cold-start restore on every reconnect.

Fix:
1. AcpBridge.loadSession() returns sessionId directly (ACP protocol
   design: LoadSessionResponse omits sessionId because client knows it)
2. QQChannel READY catch handler now calls fixRestoredSessions() so
   the workaround runs regardless of restoreSessions() outcome

Fixes QwenLM#7721
@github-actions github-actions Bot added the review/self-reported The linked issue was opened by the PR author (self-reported) label Jul 25, 2026
@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Qwen Triage finished — CI landed green on f3a28c6 and the deferred approval was posted. finalize run

Qwen Triage 已完成 —— f3a28c6 的 CI 全绿,延迟审批已提交。查看 finalize 运行

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓

Problem: observed bug with solid evidence. Issue #7721 documents the root cause chain with logs (Failed to restore session X: Invalid restored session ID, Ready (0 sessions)). I verified against the ACP schema — LoadSessionResponse only defines _meta and modes, no sessionId field — so response.sessionId is indeed always undefined. The regression path through PR #5978 (validation) and PR #6457 (tryResume = false) checks out.

Direction: aligned — this is a straightforward bug fix restoring broken session routing for QQ Bot channels after WebSocket reconnect. No direction concerns.

Size: not applicable (channels package, not core paths).

Approach: minimal and focused — two small changes, both directly needed. Returning the input sessionId matches what DaemonChannelBridge already does (it validates the daemon returned the same ID and returns it). Adding fixRestoredSessions() to the .catch() path is a reasonable safety net so the workaround runs regardless of restoreSessions() outcome. No scope creep.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓

问题:已观测到的 bug,证据充分。Issue #7721 记录了完整的根因链和日志(Failed to restore session X: Invalid restored session IDReady (0 sessions))。已对照 ACP schema 验证——LoadSessionResponse 只定义了 _metamodes,没有 sessionId 字段——所以 response.sessionId 确实始终是 undefined。通过 PR #5978(验证逻辑)和 PR #6457tryResume = false)的回归路径也核实无误。

方向:对齐——这是一个直接的 bug 修复,恢复 QQ Bot 频道在 WebSocket 重连后的 session 路由。无方向性顾虑。

规模:不适用(channels 包,非核心路径)。

方案:最小且聚焦——两处小改动,都是直接必要的。返回输入的 sessionIdDaemonChannelBridge 已有的做法一致(它验证 daemon 返回了相同的 ID 并返回)。在 .catch() 路径添加 fixRestoredSessions() 是合理的安全网,确保无论 restoreSessions() 结果如何,workaround 都会执行。无范围蔓延。

进入代码审查 🔍

Qwen Code · qwen3.8-max-preview

Reviewed at f3a28c6056069c4df1306922fdcdc933b30e3304 · re-run with @qwen-code /triage

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Code Review

Independent proposal: given the root cause (ACP LoadSessionResponse has no sessionId field → response.sessionId is undefinedrestoreSessions() validation rejects), I would (1) return the input sessionId from AcpBridge.loadSession() since the client already knows it, and (2) ensure fixRestoredSessions() runs on both the .then() and .catch() paths. The PR does exactly this.

Findings — no blockers.

The AcpBridge.loadSession() fix is correct. I verified the ACP LoadSessionResponse schema (in schema.json) defines only _meta and modes — no sessionId property. The DaemonChannelBridge already follows this pattern: it validates the daemon returned the same sessionId and returns it. Dropping the unused response variable and returning the input is the right call.

The QQChannel .catch() addition is a sound safety net. With fix #1, restoreSessions() should now succeed (the input sessionId is a valid string, passing the typeof sessionId !== 'string' check), so the .then() path — which already calls fixRestoredSessions() — will run. But if restoreSessions() ever fails for another reason, the workaround now runs from .catch() too. The fixRestoredSessions() method is idempotent (it skips entries that already have a valid sessionId), so calling it from both paths is harmless.

Both consumers of bridge.loadSession() in SessionRouter (line ~287 for on-demand reload and line ~598 for bulk restore) validate the returned sessionId is a non-empty string — the fix satisfies both.

No correctness bugs, security issues, regressions, or convention violations found. The diff is 3 additions / 2 deletions across 2 files — about as minimal as a fix gets.

CI Test Evidence

Final CI results for f3a28c6 (auto-updated by the triage finalize job after CI completed):

Check Conclusion
Classify PR ✅ success
Test (ubuntu-latest, Node 22.x) ✅ success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) ✅ success

One row per check name (latest run); skipped checks omitted; failures sort first. / 每个检查名一行(取最新一次运行),省略 skipped,失败项排在最前。

Ubuntu unit suite is still running; macOS/Windows tests and integration tests are skipped (likely path-filtered — this PR only touches packages/channels/). No failures so far. The Qwen Triage Finalize workflow will update this table and handle deferred approval once CI settles.

Real-scenario testing: N/A (unattended CI run — a maintainer can trigger the isolated @qwen-code /tmux job if TUI-level verification is desired, though this PR affects channel session routing, not the TUI surface).

中文说明

代码审查

独立方案: 根据根因(ACP LoadSessionResponse 没有 sessionId 字段 → response.sessionIdundefinedrestoreSessions() 验证拒绝),我会 (1) 从 AcpBridge.loadSession() 返回输入的 sessionId(客户端已知),(2) 确保 fixRestoredSessions().then().catch() 路径都执行。PR 的做法完全一致。

发现——无阻塞项。

AcpBridge.loadSession() 修复正确。已验证 ACP LoadSessionResponse schema(schema.json)只定义了 _metamodes——没有 sessionId 属性。DaemonChannelBridge 已遵循此模式。QQChannel .catch() 添加是合理的安全网。fixRestoredSessions() 是幂等的(跳过已有有效 sessionId 的条目),从两条路径调用无害。

无正确性 bug、安全问题、回归或规范违反。diff 为 2 个文件 3 行新增 / 2 行删除——尽可能精简。

CI 测试证据

Ubuntu 单元测试仍在运行;macOS/Windows 测试和集成测试已跳过(可能是路径过滤——此 PR 只触及 packages/channels/)。目前无失败。Qwen Triage Finalize 工作流会在 CI 完成后更新此表并处理延迟审批。

真实场景测试:不适用(无人值守 CI 运行)。

Qwen Code · qwen3.8-max-preview

Reviewed at f3a28c6056069c4df1306922fdcdc933b30e3304 · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 5/5 — clean, minimal bug fix with a verified root cause and a solution that matches the existing DaemonChannelBridge pattern.

This is exactly the kind of PR that's easy to review and easy to trust. The root cause is well-documented in #7721 and I confirmed it independently: the ACP LoadSessionResponse schema has no sessionId field, so response.sessionId was always undefined. The two-line fix returns the input sessionId (which the client already knows) and adds the existing fixRestoredSessions() workaround to the .catch() path as a safety net. Both changes are necessary, both are minimal, and neither introduces new risk. The fixRestoredSessions() call is idempotent, so the belt-and-suspenders approach is harmless.

If I had to maintain this in six months, I'd thank the author — the fix is obvious in hindsight, well-scoped, and the issue writeup makes the regression chain clear.

Approval deferred until CI lands green on f3a28c6056069c4df1306922fdcdc933b30e3304 — the Ubuntu unit suite is still running.

中文说明

置信度:5/5 —— 干净、最小化的 bug 修复,根因已验证,方案与现有 DaemonChannelBridge 模式一致。

这个 PR 正是那种容易审查、容易信任的类型。根因在 #7721 中有详细记录,我也独立确认了:ACP LoadSessionResponse schema 没有 sessionId 字段,所以 response.sessionId 始终是 undefined。两行修复返回输入的 sessionId(客户端已知),并在 .catch() 路径添加现有的 fixRestoredSessions() workaround 作为安全网。两处改动都是必要的、最小化的,且不引入新风险。fixRestoredSessions() 调用是幂等的,所以双重保险无害。

审批延迟至 CI 在 f3a28c6056069c4df1306922fdcdc933b30e3304 上全绿——Ubuntu 单元测试仍在运行。

Qwen Code · qwen3.8-max-preview

Reviewed at f3a28c6056069c4df1306922fdcdc933b30e3304 · re-run with @qwen-code /triage

@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. Suggestions are inline.

— qwen3.7-max via Qwen Code /review

Comment on lines 1975 to +1976
.catch(() => {
this.fixRestoredSessions();

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 .then() and .catch() handlers now share four calls — fixRestoredSessions(), finalizeReady(), _checkGroupAllPolicyRequireMention(), and onReady() — duplicated across both paths. A future change to post-restore finalization must be mirrored in two places; if a developer adds a step in one handler but forgets the other, the error path silently diverges from the success path.

Consider extracting the shared calls into .finally():

.then(() => {
  // success-specific: session count logging
})
.catch(() => {
  // error-specific: warning log
})
.finally(() => {
  this.fixRestoredSessions();
  this.finalizeReady();
  this._checkGroupAllPolicyRequireMention();
  onReady();
});

— qwen3.7-max via Qwen Code /review

Comment on lines +206 to +211
await conn.loadSession({
sessionId,
cwd,
mcpServers: [],
});
return response.sessionId;
return sessionId;

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] AcpBridge.loadSession has no unit test — AcpBridge.test.ts has zero matches for loadSession. The very return-value bug this PR fixes (returning response.sessionId which was always undefined) went undetected because the SessionRouter tests mock loadSession at the interface level and never exercise the real implementation. Without a test asserting the return value, a future regression re-introducing return response.sessionId would silently pass all existing tests.

Suggested: add a test that mocks conn.loadSession to return a LoadSessionResponse (no sessionId field) and asserts that bridge.loadSession() returns the input sessionId.

— qwen3.7-max via Qwen Code /review

Comment on lines 1975 to +1976
.catch(() => {
this.fixRestoredSessions();

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 READY cold-start .catch() handler has no test coverage. The success path (.then()) is tested in events.test.ts, but the failure path — where fixRestoredSessions() is newly added — is never exercised. If a future refactor accidentally removes or misplaces this call, no test would detect the regression.

Suggested: add a test that mocks router.restoreSessions() to reject, dispatches a READY with coldStart=true, and asserts that fixRestoredSessions() is called.

— qwen3.7-max via Qwen Code /review

@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 — CI landed green after the review. ✅

@gwinthis

Copy link
Copy Markdown
Collaborator

Review + live ACP protocol verification report (real build, tmux, two-process restart simulation)

Verdict: Fix 1 (return the input sessionId) is correct, necessary, and I proved its premise against the live agent over the real ACP wire. Fix 2 (catch-path fixRestoredSessions()) appears to be a no-op for the failure mode this PR describes — details below.

Fix 1: premise proven on the wire

I built the PR (full build + bundle) and drove the real bundled agent (--acp) over stdio JSON-RPC in tmux, simulating a daemon restart exactly like the QQ reconnect scenario: create session + one prompt in process A, kill A, session/load in fresh process B:

A: session/new -> 39d72790-e819-4e92-88e1-77d7da061e0b
A: prompt stopReason: end_turn
B: RAW session/load result keys: ["modes","models","configOptions"]
B: result.sessionId = undefined

Three independent layers agree:

  • Wire (above): the live session/load result has no sessionId.
  • Type: @agentclientprotocol/sdk LoadSessionResponse = {_meta?, configOptions?, models?, modes?} — no sessionId field.
  • Agent source: acpAgent.loadSession builds its response from modes/models/configOptions only.

So the old return response.sessionId always returned undefined, which SessionRouter.restoreSessions() rejects with exactly the logged Invalid restored session ID. Returning the input sessionId is the correct fix — the client already knows the id, per ACP's design. AcpBridge.test.ts: 32/32 passed.

Fix 2: the catch-path fixRestoredSessions() doesn't reach the described failure

Two factual issues with the stated rationale, both verifiable in the current code:

  1. .then() was reachable all along. restoreSessions() catches per-entry failures inside the loop (failed++, reservation rejected) and resolves {restored, failed} — a failed validation does not reject the promise. The .catch() branch this PR patches only fires on catastrophic errors outside the per-entry loop.
  2. After a modern restore failure there is nothing for fixRestoredSessions() to patch. It only repairs toSession entries whose value is falsy (the legacy "kept an empty session id" state its JSDoc describes). Today's restoreSessions() never writes a falsy entry — on failure the key is simply absent, and persist() then rewrites sessions.json to drop failed entries, removing the very data the repair would read.

So the added call is harmless but inert: with Fix 1 in place the restore succeeds and neither path needs repair; without Fix 1, the repair wouldn't have fired anyway. Suggest either dropping Fix 2 or documenting the actual (legacy-file) state it targets — and adding a small unit test pinning AcpBridge.loadSession()'s return value, since the PR currently adds no tests for the behavior it fixes.

Scope caveat

I could not run a real QQ WebSocket reconnect (no QQ credentials); the restart-shaped ACP probe above covers the same session-restore path (SessionRouter.restoreSessions → bridge.loadSession → validation). DaemonChannelBridge (qwen serve --channel) is untouched, as the PR states.

@wenshao
wenshao added this pull request to the merge queue Jul 25, 2026
Merged via the queue into QwenLM:main with commit c1c9cd9 Jul 25, 2026
89 checks passed
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Released in v0.21.1.

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

Labels

review/self-reported The linked issue was opened by the PR author (self-reported)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(qqbot): session restore broken after bridge restart — AcpBridge.loadSession() returns undefined

4 participants