Skip to content

fix(session): preserve source titles when branching - #9764

Merged
wenshao merged 9 commits into
QwenLM:mainfrom
water-in-stone:codex/fix-session-fork-title
Aug 25, 2026
Merged

fix(session): preserve source titles when branching#9764
wenshao merged 9 commits into
QwenLM:mainfrom
water-in-stone:codex/fix-session-fork-title

Conversation

@water-in-stone

Copy link
Copy Markdown
Collaborator

What this PR does

This PR makes branched sessions inherit the source session's picker display name instead of falling back to a UUID fragment. It normalizes existing legacy (Branch) and numeric fork suffixes, allocates the lowest available Title(N) name across custom-title and prompt-backed display names, forwards the active Web Shell name with branch requests, applies the numeric convention to CLI branching, and preserves explicit side-task names unchanged.

Why it's needed

Sessions whose visible name came from their first prompt could produce a random-looking, UUID-derived title after branching. Keeping branches in the same numbered name family makes copied sessions recognizable and avoids reusing an existing branch display name.

Reviewer Test Plan

How to verify

  1. Open a session whose picker name comes from its first prompt rather than a custom title, branch from an eligible completed Assistant response, and confirm the new session is named Title(1) using the source picker name as Title.
  2. Create or retain sessions named Title(1) and Title(2), branch the source again, and confirm the next session is named Title(3) while the source session remains unchanged.
  3. Branch from an already numbered session and confirm the result uses the first free suffix in the original name family instead of producing a nested name such as Title(1)(1).
  4. Create a side task with an explicit name such as Side task(2) and confirm that exact name is preserved.

Evidence (Before & After)

Before

image

After

image

Tested on

OS Status
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

Environment (optional)

Risk & Scope

  • Main risk or tradeoff: Collision lookup now reads prompt records for sessions without custom titles, which adds bounded work when branching in workspaces with large session catalogs.
  • Not validated / out of scope: The reviewed commit records no runtime, browser, manual, or platform-specific validation results.
  • Breaking changes / migration notes: Future branch titles change from the legacy (Branch) convention to numeric (N) suffixes; the commit does not migrate existing session titles and identifies no API or data migration.

Linked Issues

Closes #9762

What this PR does

本 PR 让分支会话继承源会话在会话选择器中的展示名称,而不是回退为 UUID 片段。它会归一化已有的旧式 (Branch) 和数字 fork 后缀,在自定义标题与基于首条 prompt 的展示名称中选择最小可用的 Title(N) 名称,通过 Web Shell 的分支请求传递当前会话名称,将数字编号约定应用于 CLI 分支,并保持显式 side task 名称不变。

Why it's needed

当会话的可见名称来自首条 prompt 时,分支后的标题可能退化为看起来随机的 UUID 派生名称。让分支保持在同一个编号名称族中,可以使复制出的会话易于识别,并避免复用已存在的分支展示名称。

Reviewer Test Plan

How to verify

  1. 打开一个 picker 名称来自首条 prompt 而非自定义标题的会话,从一个符合条件且已完成的 Assistant 回复创建分支,并确认新会话以源 picker 名称作为 Title,命名为 Title(1)
  2. 创建或保留名为 Title(1)Title(2) 的会话,再次从源会话创建分支,确认新会话命名为 Title(3),且源会话保持不变。
  3. 从已经带编号的会话创建分支,确认结果使用原名称族中的首个空闲编号,而不是生成 Title(1)(1) 之类的嵌套名称。
  4. 使用 Side task(2) 之类的显式名称创建 side task,并确认该名称被原样保留。

Evidence (Before & After)

审阅的 commit 未提供截图、录屏或运行时日志。

Tested on

OS Status
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

Environment (optional)

Risk & Scope

  • 主要风险或权衡:对于没有自定义标题的会话,冲突检查现在会读取 prompt 记录;在会话目录很大的工作区中创建分支时,这会增加有上限的额外工作。
  • 未验证或不在范围内:审阅的 commit 未记录运行时、浏览器、手工或特定平台的验证结果。
  • 破坏性变更或迁移说明:未来的分支标题将从旧式 (Branch) 约定改为数字 (N) 后缀;该 commit 不会迁移已有会话标题,也未发现 API 或数据迁移。

Linked Issues

Closes #9762


Forked sessions could fall back to a UUID fragment when the source
name came from its first prompt rather than a custom title.

- Forward the active picker name through Web Shell branch requests
- Resolve prompt-backed display names and include them in collision scans
- Allocate the first free numeric suffix and normalize nested forks
- Preserve explicit side-task names and add regression coverage
@github-actions github-actions Bot added the review/self-reported The linked issue was opened by the PR author (self-reported) label Aug 23, 2026
@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

Qwen Triage finishedview run. See the stage comments in this thread for the result.

Qwen Triage 已完成 —— 查看运行。结果见本线程中的各阶段评论。

@water-in-stone

Copy link
Copy Markdown
Collaborator Author

@qwen-code-ci-bot please take a look

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

Re-run at head 1359f61 — gate re-checked after the review loop converged (round 7, no new findings). Findings below reflect the current diff.

Template ✓ — all sections present, bilingual, with before/after screenshots.

Problem: observed bug, verified twice over. Linked issue #9762 with screenshots, and the maintainer's two-arm local run reproduced it exactly on the merge-base arm (dddddddd (Branch) for a prompt-named source). The old code fell back to sessionId.slice(0, 8) — a UUID fragment — whenever the source's visible name came from its first prompt.

Direction: aligned. Session naming is an actively maintained surface (design doc updated in the same PR), with no auth/sandbox/telemetry/public-contract exposure. The round-1 web-shell contract change (client sending a name) was reverted in 8aac1c3; the daemon now derives the name itself and packages/web-shell is untouched — a cleaner shape.

Size: core paths touched (packages/core/src/services/sessionService.ts, cross-package into cli). 170 production lines (sessionService.ts 93, acpAgent.ts 58, useBranchCommand.ts 19) vs 442 test lines vs 8 doc lines — under every threshold, and it's a fix, so no Tier 1 concern.

Approach: minimal. The fallback chain (explicit name → normalized custom title → picker display name → last-resort fallback), stripping generated suffixes before re-suffixing, and making the collision scan see what the picker sees are all needed for the stated goal; I don't see a materially simpler path. Both round-1 blockers are resolved: the CLI /branch path is fixed here (useBranchCommand.ts), and the web-shell request-contract change is gone. No drive-by edits.

Risk: ⚠️ packages/cli/src/acp-integration/acpAgent.ts matches the revert-correlated acp-integration path — full review depth applies and CI evidence is required before approval (carried in the Stage 2 comment). The sponsored @qwen-code /verify run was triggered by the maintainer and was still in flight at review time.

Moving on to code review. 🔍

中文说明

在 head 1359f61 上重跑 —— 评审循环已收敛(第 7 轮无新发现)后重新过门。以下结论针对当前 diff。

模板完整 ✓ —— 各部分齐全,中英双语,附 before/after 截图。

问题:已观测到的 bug,且被双重验证。关联 issue #9762 附截图;维护者双臂本地实测在 merge-base 臂上精确复现(以首条 prompt 命名的源会话分支出 dddddddd (Branch))。旧代码在源会话可见名称来自首条 prompt 时回退到 sessionId.slice(0, 8)(UUID 片段)。

方向:对齐。会话命名是持续维护的界面(同 PR 更新了设计文档),不触及 auth/sandbox/telemetry/公共契约。第 1 轮中的 web-shell 契约改动(客户端传名称)已在 8aac1c3 回退;现由 daemon 自行推导名称,packages/web-shell 未被触及 —— 形态更干净。

规模:触及核心路径(packages/core/src/services/sessionService.ts,跨包到 cli)。170 行生产代码(sessionService.ts 93、acpAgent.ts 58、useBranchCommand.ts 19),442 行测试,8 行文档 —— 低于所有阈值,且为 fix 类型,无 Tier 1 问题。

方案:最小化。回退链(显式名 → 归一化后的自定义标题 → picker 展示名 → 最后兜底)、重新加后缀前先剥掉已生成的后缀、让冲突扫描与 picker 所见一致 —— 均为目标所必需;没有更简路径。第 1 轮的两个阻塞项均已解决:CLI /branch 路径在本 PR 修复(useBranchCommand.ts),web-shell 请求契约改动已移除。无顺手改动。

风险:⚠️ packages/cli/src/acp-integration/acpAgent.ts 命中与 revert 相关的 acp-integration 高风险路径 —— 需完整评审深度,且批准前必须有 CI 证据(见 Stage 2 评论)。维护者已触发赞助 @qwen-code /verify 运行,评审时仍在进行中。

进入代码审查。🔍

Qwen Code · qwen3.8-max

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

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

Code review (head 1359f61)

My independent proposal before reading the diff — resolve the source's picker display name (customTitle || firstPrompt) on both branch routes, make the collision scan see what the picker sees, and switch to a lowest-free numeric suffix — is exactly the shape this PR takes, so the review looked for holes, not direction. Checked carefully at this head:

  • Fallback chain (both routes): explicit name → normalized custom title → SessionService.getSessionDisplayName() → last-resort fallback (session-id prefix on the daemon route, first prompt on CLI /branch). The divergence is deliberate and documented in the design doc and both call sites; the new method is only awaited when both earlier sources are empty, and the tests pin that laziness.
  • Anchors verified against the tree: SESSION_FILE_PATTERN carries no g flag (so .test in getSessionDisplayName has no lastIndex statefulness bug), MAX_PROMPT_SCAN_LINES = 10 bounds the per-file head read, and normalizeDerivedBranchTitle reaches both importers through the existing export * from './services/sessionService.js' barrel — no missing export.
  • Unbounded-looking loop: the old code capped at 99 candidates then fell back to Date.now(); the new for (let n = 1; ; n++) terminates in at most taken.size + 1 iterations because taken is a finite set from one bounded directory scan. Strictly simpler, and case-insensitive comparison plus project scoping are preserved.
  • Consumer map: findSessionTitlesByPrefix has exactly one production consumer (computeUniqueBranchTitle), whose two callers — the ACP branch route and useBranchCommand — are both updated with tests. The new SessionService.getSessionDisplayName agrees with the picker's own resolver in packages/webui/.../mappers.ts (maintainer-checked on all three name shapes), so it is not a second, drifting definition of the display name.
  • The disclosed cost is real and bounded: untitled sessions now cost one ≤10-line head read during the collision scan instead of a cheap skip. Inherent to matching picker semantics, runs only on branch; the maintainer measured it at ~+200 ms median over a real 265-session catalog and called it acceptable (his measurement, not mine).

No critical findings, no convention violations. Carried-over non-blocking notes from the review rounds — none reproduced as bugs, recorded so they aren't silently dropped: the ACP route reads the custom title without the flush() the CLI path does (maintainer tried 6 tight-race attempts without reproducing a stale read); Draft (1) vs Draft(1) — the suffix stripper requires a non-space before (, which reads deliberate since generated suffixes never carry one; the PR description still mentions the reverted Web Shell name-forwarding; archived transcripts stay outside the scan (pre-existing).

Testing evidence — this PR's own CI at the reviewed commit

Unattended run — I don't execute PR code; this is the PR's CI at 1359f61, fetched once via the API. Everything that ran is green: 24 successes, 0 failures. The macOS/Windows unit-matrix legs and Integration Tests (CLI, No Sandbox) are skipped on this fork PR (job gating) — disclosed, not papered over. The maintainer's two-arm report ran those suites locally at this exact head (sessionService 190/190, useBranchCommand + acpAgent 513/513, eslint, tsc clean — his numbers, attributed, not independently re-run here).

Check Conclusion
Test (ubuntu-latest, Node 22.x) ✅ success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) ✅ success
Serve A/B (ubuntu-latest, Node 22.x) ✅ success
Real daemon E2E / Java 11 ✅ success
Desktop Shell (ubuntu-22.04) ✅ success
Desktop Shell (windows-2022) ✅ success
ubuntu-latest / Java 11 ✅ success
ubuntu-latest / Java 17 ✅ success
ubuntu-latest / Java 21 ✅ success
macos-latest / Java 21 ✅ success
windows-latest / Java 21 ✅ success
Classify PR ✅ success
Dependency CVE audit ✅ success
Secret scan (TruffleHog) ✅ success
Test (macos-latest, Node 22.x) ⏭️ skipped
Test (windows-latest, Node 22.x) ⏭️ skipped
Integration Tests (CLI, No Sandbox) ⏭️ skipped

The central claim is behavioural — branches land on the lowest free Title(N) of the source's picker-visible name family — and the unit suite is mock-based, so CI alone does not pin it. That gap is already covered from two sides: the maintainer's two-arm real-environment run at this exact head passed the reviewer test plan 4/4 against the real daemon, CLI, and browser, including adversarial probes (empty/blank titles, legacy (Branch) tokens, 3 concurrent branches, a dense Dense(1..120) family), and the sponsored @qwen-code /verify run he triggered was still in flight at review time — worth reading its report before merge as the independent confirmation of the same claim.

中文说明

代码审查(head 1359f61

我在读 diff 前独立拟定的方案 —— 在两条分支路径上解析源会话的 picker 展示名(customTitle || firstPrompt)、让冲突扫描与 picker 所见一致、改用最小可用数字后缀 —— 与本 PR 的形态完全一致,因此审查重点是找漏洞而非方向。在该 head 上仔细核对过:

  • 回退链(两条路径):显式 name → 归一化后的自定义标题 → SessionService.getSessionDisplayName() → 最后兜底(daemon 路径用 session-id 前缀,CLI /branch 用首条 prompt)。该差异是有意的,设计文档与两处调用点均有说明;新方法仅在前两个来源都为空时才被 await,且有测试钉住这一惰性。
  • 锚点已对照代码树核实SESSION_FILE_PATTERNg 标志(getSessionDisplayName 中的 .test 不存在 lastIndex 状态性 bug);MAX_PROMPT_SCAN_LINES = 10 限定每文件头部读取;normalizeDerivedBranchTitle 经由现有 export * from './services/sessionService.js' 桶导出触达两个导入方 —— 无遗漏导出。
  • 看似无界的循环:旧代码上限 99 个候选后回退 Date.now();新的 for (let n = 1; ; n++) 最多迭代 taken.size + 1 次,因为 taken 来自一次有界目录扫描的有限集。严格更简单,大小写不敏感比较与项目作用域均保留。
  • 消费者图谱findSessionTitlesByPrefix 唯一生产消费者是 computeUniqueBranchTitle,其两个调用方(ACP 分支路由与 useBranchCommand)均已连同测试更新。新的 SessionService.getSessionDisplayNamepackages/webui/.../mappers.ts 中 picker 自身的解析器一致(维护者已按三种名称形态核对),不是第二份漂移的展示名定义。
  • 已披露的成本真实且有界:无标题会话在冲突扫描中从廉价跳过变为一次 ≤10 行的头部读取。这是匹配 picker 语义的固有成本,仅在分支时发生;维护者在真实 265 会话目录上实测约 +200 ms(中位数)并认为可接受(为其测量结果,非本评审复测)。

无关键发现,无规范违反。评审各轮遗留的非阻塞事项 —— 均未复现为 bug,记录在案以免遗漏:ACP 路由读取自定义标题前未像 CLI 路径那样 flush()(维护者 6 次紧凑竞态尝试未复现陈旧读);Draft (1)Draft(1) —— 后缀剥离要求 ( 前为非空白字符,因生成的后缀从不带空格,读起来是有意的;PR 描述仍提及已回退的 Web Shell 名称转发;归档(archive/)transcript 仍在扫描范围外(既有行为)。

测试证据 —— 被审 commit 上本 PR 自己的 CI

无人值守运行 —— 不执行 PR 代码;以上为 1359f61 上本 PR 的 CI,经 API 一次性拉取。凡运行的全部通过:24 项成功、0 项失败。macOS/Windows 单测矩阵与 Integration Tests (CLI, No Sandbox) 在该 fork PR 上被跳过(任务门控)—— 如实披露,不掩盖。维护者的双臂报告在本 head 上本地跑过这些套件(sessionService 190/190、useBranchCommand + acpAgent 513/513、eslint、tsc 通过 —— 为其数字,注明出处,本评审未独立复跑)。

核心断言是行为性的 —— 分支落在源会话 picker 可见名称族中最小可用的 Title(N) —— 而单测基于 mock,仅凭 CI 无法钉住。该缺口已从两侧覆盖:维护者在本 head 上的双臂真实环境运行对真实 daemon、CLI 与浏览器通过了审阅测试计划 4/4,含对抗性探测(空/空白标题、旧式 (Branch) 词元、3 并发分支、稠密的 Dense(1..120) 名称族);他触发的赞助 @qwen-code /verify 运行评审时仍在进行中 —— 合并前值得阅读其报告,作为对同一断言的独立确认。

Qwen Code · qwen3.8-max

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

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — solid and now fully evidenced at this head; the fifth point is withheld for named non-blocking nits (the un-flushed ACP title read, the stale PR description), not for doubt.

Stepping back: this PR went through seven review rounds and came out the other side genuinely better, not just bigger. Both round-1 blockers were fixed properly — the CLI /branch path got the same treatment as the daemon route, and the web-shell request-contract change was reverted outright rather than patched, leaving the daemon as the single place names are derived. The diff at this head is exactly the minimal set the goal needs, the convention change ((Branch)Title(N)) is applied consistently across both clients, the tests, and the design doc, and I didn't find a simpler path it missed. The strongest signal isn't mine, though: the maintainer built a two-arm real-environment setup at this exact commit, passed the reviewer test plan 4/4, stress-tested it adversarially, quantified the one real cost (~+200 ms branch in a heavy catalog, judged acceptable), and approved. CI is green on everything that ran.

The remaining notes are hygiene, not blockers — recorded in the Stage 2 comment so they aren't silently dropped.

Verdict: approve. No PR CI runs are pending on this commit, so the approval is posted now, pinned to it. This approval supersedes my earlier change requests, which stood on older heads (rounds 1, 2, 5, 6) and have since been addressed.

中文说明

置信度:4/5 —— 扎实,且在本 head 上证据齐全;保留最后 1 分是给点名的非阻塞事项(ACP 路径未 flush 的标题读取、过时的 PR 描述),而非存疑。

退一步看:这个 PR 历经七轮评审,是真的变得更好,而不是变得更大。第 1 轮的两个阻塞项都被妥善修复 —— CLI /branch 获得了与 daemon 路径一致的处理;web-shell 请求契约改动被整体回退而非打补丁,使名称推导收敛在 daemon 一处。当前 head 的 diff 恰是目标所需的最小集合,命名约定变更((Branch)Title(N))在两个客户端、测试与设计文档中一致落地,我也没有找到被遗漏的更简路径。不过最强的信号不来自我:维护者在该 commit 上搭了双臂真实环境,通过审阅测试计划 4/4,做了对抗性压测,量化了唯一的真实成本(重目录工作区分支约 +200 ms,判定可接受),并已批准。凡是运行的 CI 项目全绿。

其余事项属合并卫生而非阻塞 —— 已记录在 Stage 2 评论,以免被悄悄丢弃。

结论:批准。本 commit 上没有进行中的 PR CI 运行,故现在发布批准,并钉在该 commit 上。此批准取代我早前的修改请求 —— 那些请求停留在旧 head(第 1、2、5、6 轮)上,相关问题均已解决。

Qwen Code · qwen3.8-max

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

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

🖼️ web-shell visual preview

Rendered against a mock daemon (no real backend): the PR base vs this PR head 7f24b47. Only screenshots that changed are shown (flows below, if any, are head-only) — refreshes on every push.

Screenshots · before / after

⚠️ No preview: one or more scenarios failed to render on this head — see the workflow run. This is not "no visual change" — a scenario that times out or throws produces no image. Fix the failing scenario (or a genuine regression it caught) and the preview returns on the next push.

Full-resolution recordings (.webm) are attached to the workflow run.

Qwen Code · web-shell visuals

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

🩺 serve daemon A/B

Built the PR base vs this PR head 1359f61, drove a fixed endpoint set against each, and diffed the JSON responses. Only fields that changed are shown.

No response changes against the PR base across 12 scenario(s).

Qwen Code · serve A/B

Comment thread packages/cli/src/ui/hooks/useBranchCommand.ts
Comment thread packages/web-shell/client/App.tsx Outdated
Comment thread packages/cli/src/acp-integration/acpAgent.ts Outdated
Comment thread packages/cli/src/acp-integration/acpAgent.ts Outdated
Comment thread packages/cli/src/acp-integration/acpAgent.ts Outdated
Comment thread packages/cli/src/acp-integration/acpAgent.ts
Comment thread packages/core/src/services/sessionService.ts Outdated
Comment thread packages/core/src/services/sessionService.ts Outdated

@doudouOUC doudouOUC 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.

审阅摘要

本次审阅验证了 PR #9764 的当前快照 fa1630ee

变更概述

将分支会话标题从旧式 (Branch) / (Branch N) 约定改为数字 (N) 后缀格式。核心变更包括:

  • 新增 normalizeBranchBaseName 归一化基础名称(ACP 路径)
  • 新增 getSessionDisplayName 方法,在自定义标题缺失时回退到首条 prompt
  • 重写 findSessionTitlesByPrefix 以支持基于 prompt 的展示名称
  • 简化 computeUniqueBranchTitle,移除 MAX_BRANCH_COLLISION_SCAN 上限
  • Web Shell 的 App.tsx 向分支请求传递 sessionDisplayName

现有发现(均仍有效)

2 个 Critical 阻塞项:

  1. CLI /branch 路径未修复useBranchCommand.ts:170 仍只从首条 prompt 派生基础名,忽略自定义标题。const baseName = name ?? deriveFirstPrompt(provisional.conversation.messages); 应添加 outgoingRecording?.getCurrentCustomTitle() 作为中间回退。

  2. Playwright 冒烟 e2e 未更新web-shell.smoke.spec.ts:129 断言 toEqual({ atRecordId }),但新代码发送 { name, atRecordId }。PR 的 CI 失败 web-shell E2E Smoke 证实了这一点。

6 个 Suggestion:

  1. normalizeBranchBaseName 会剥离用户显式提供的 (N) 后缀
  2. 旧式 (Branch) 剥离分支无测试覆盖
  3. CLI 路径未应用相同的名称归一化
  4. UUID 回退路径丢失了测试覆盖
  5. getSessionDisplayName 的错误路径未测试
  6. 展示名称的获取逻辑在 getSessionDisplayNamefindSessionTitlesByPrefix 中重复实现

结论

COMMENT — 2 个 Critical 阻塞项仍存在,且 web-shell E2E Smoke CI 检查失败。变更方向正确,核心逻辑稳健,但在关闭 #9762 前需修复上述两个 Critical 阻塞项。

Review found that CLI and ACP still derived branch names differently,
while the Web Shell smoke test asserted the previous request body.

- Preserve explicit names while normalizing only derived title suffixes
- Reuse Core display-name and title helpers across branch entry points
- Cover legacy, fallback, and isolation paths and update the smoke E2E
yiliang114
yiliang114 previously approved these changes Aug 23, 2026

@yiliang114 yiliang114 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. Verified the naming machinery and the round-1 follow-ups at head 7f24b47:

  • Allocation terminates and matches the picker: computeUniqueBranchTitle builds taken from one prefix scan whose display-name derivation mirrors the picker (customTitle, else first prompt, project-ownership-checked), compares case-insensitively, and iterates Title(n) from 1 — the set is finite so the loop always ends, and legacy Title (Branch) names (space before paren) can't collide with the new Title(n) family.
  • normalizeDerivedBranchTitle strips (Branch)/(Branch N) and a trailing non-space (n), so re-branching a numbered session stays in the original family instead of nesting Title(1)(1); explicit side-task names pass through unchanged, and getSessionDisplayName guards the session-id pattern before any file read.
  • The round-1 Criticals are addressed: the CLI /branch path now consumes the same core functions (computeUniqueBranchTitle + normalizeDerivedBranchTitle), so both clients share one convention and one recipe; the new name request field is additive (an older daemon ignores it and falls back to its own naming) and the smoke spec pins the new contract end to end.

One minor, non-blocking edge: the requested-name path skips normalization when no atRecordId accompanies the name (baseName stays as-requested), so a source titled Foo(2) could yield Foo(2)(1) on that route — the primary Web Shell flow always branches from a response record, where normalization applies, so this is latent only.

CI note: two checks fail on head for reasons unrelated to this change — Test (ubuntu) fails in agents/team/test-utils/coordination-harness.test.ts (message routing), a file this PR doesn't touch; the web-shell E2E Smoke dies at CHECKOUT with EACCES removing a leftover scratch dir from another PR's review workspace (9748) on the shared runner. Neither is caused by this diff; worth a rerun once the runner dir is cleared.

@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.

Not explored to full depth (tool budget reached): "agent 5": executing the changed vitest suites (review worktree has no node_modules / dist ; npm ci + npm run build + test runs would exceed the tool budget) — findin….

中文说明

未探索到全部深度(达到工具调用预算):"agent 5"executing the changed vitest suites (review worktree has no node_modules / dist ; npm ci + npm run build + test runs would exceed the tool budget) — findin…

— qwen3.8-max via Qwen Code /review (v0.22.0)

Comment on lines +12019 to +12024
const baseName =
requestedName === undefined
? (derivedBaseName ?? sessionId.slice(0, 8))
: requestedName === sourceDisplayName
? (derivedBaseName ?? requestedName)
: requestedName;

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.

[Critical] normalizeDerivedBranchTitle() can return an empty string, and ?? does not fall back on '' — so this loses the empty-result guard the deleted deriveForkBaseName had (stripped && stripped.length > 0 ? stripped : sessionId.slice(0, 8)). A session renamed to exactly (Branch) or (Branch 2) (renameSession accepts any non-empty string ≤200 chars) normalizes to '', then derivedBaseName ?? sessionId.slice(0, 8) keeps '' and computeUniqueBranchTitle('') titles the new branch literally (1), where the pre-refactor code fell back to the session-id base. The same gap exists at two sibling sites: the side-task path in this file persists an empty custom_title via forkSession (whose guard is title !== undefined) and returns { title: '', displayName: '' } to the client, and useBranchCommand.ts skips its deriveFirstPrompt fallback and also yields (1).

Witness (scratch-tree probe against the real exported functions at this commit):

NORM("(Branch)") = ""   NORM("(Branch 2)") = ""
HEAD branch-path baseName = "" -> final title "(1)"    with || fallback -> "550e8400(1)"
HEAD side-task title passed to forkSession = "" (record gets written)    with || fallback -> "550e8400"
HEAD CLI final title = "(1)"    pre-refactor CLI -> "help me fix the login bug(1)"

The probe flips when the implied one-line fix is applied. Treat an empty normalization result as absent — (derivedBaseName || sessionId.slice(0, 8)) here and (derivedBaseName || requestedName) in the equality branch — or make normalizeDerivedBranchTitle return string | undefined so the existing ?? fallbacks engage at all three sites.

中文说明

normalizeDerivedBranchTitle() 可能返回空字符串,而 ?? 不会对 '' 回退——因此丢失了被删除的 deriveForkBaseName 原本具有的空结果守卫(stripped && stripped.length > 0 ? stripped : sessionId.slice(0, 8))。将会话重命名为恰好 (Branch)(Branch 2)renameSession 接受任意非空且 ≤200 字符的字符串)后,归一化结果为 '',随后 derivedBaseName ?? sessionId.slice(0, 8) 保留 ''computeUniqueBranchTitle('') 会把新分支命名为字面量 (1);而重构前的代码会回退到 sessionId 前缀。同样的缺口还存在于另外两处:本文件的 side-task 路径会通过 forkSession(其守卫是 title !== undefined)持久化一个空的 custom_title 并向客户端返回 { title: '', displayName: '' }useBranchCommand.ts 则会跳过 deriveFirstPrompt 回退,同样产生 (1)

见证(在本提交的临时树中对真实导出函数执行的探针):归一化 "(Branch)"/"(Branch 2)" 均得到 "";当前分支路径 baseName 为 "",最终标题 "(1)",改用 || 回退后为 "550e8400(1)";side-task 路径传给 forkSession 的 title 为 ""(记录会被写入),修复后为 "550e8400";CLI 最终标题 "(1)",重构前为 "help me fix the login bug(1)"。应用隐含的一行修复后探针结果翻转。

建议:把空的归一化结果视为缺失——此处用 (derivedBaseName || sessionId.slice(0, 8))、相等分支用 (derivedBaseName || requestedName)——或让 normalizeDerivedBranchTitle 返回 string | undefined,使现有 ?? 回退在三处都生效。

— qwen3.8-max via Qwen Code /review (v0.22.0)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Thanks, confirmed and fixed in 8aac1c3abf. normalizeDerivedBranchTitle now returns string | undefined, so exact (Branch) and (Branch 2) results engage the existing session-id fallback instead of reaching computeUniqueBranchTitle as an empty base. Added ordinary-branch regressions expecting 550e8400(1). Core 190/190 and ACP 464/464 pass.

Comment on lines +12064 to +12069
if (title === undefined) {
const sourceCustomTitle = recording?.getCurrentCustomTitle();
title = sourceCustomTitle
? normalizeDerivedBranchTitle(sourceCustomTitle)
: sessionId.slice(0, 8);
}

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.

[Critical] Same empty-normalization gap as the branch path above, with a worse outcome here: when the source's custom title is exactly (Branch)/(Branch 2), normalizeDerivedBranchTitle(sourceCustomTitle) returns '' and the truthiness check on sourceCustomTitle passes it through, so forkSession is called with title: '' — its guard is title !== undefined, so it persists an empty custom_title record and the route returns { title: '', displayName: '' } to the client. The deleted deriveForkBaseName fell back to sessionId.slice(0, 8) in exactly this case. Suggested fix: title = (sourceCustomTitle ? normalizeDerivedBranchTitle(sourceCustomTitle) : '') || sessionId.slice(0, 8); — or the shared string | undefined return type on the normalizer.

中文说明

与上方分支路径相同的空归一化缺口,此处后果更重:当源会话的自定义标题恰好是 (Branch)/(Branch 2) 时,normalizeDerivedBranchTitle(sourceCustomTitle) 返回 '',而对 sourceCustomTitle 的真值判断会让它直接通过,于是 forkSession 被以 title: '' 调用——其守卫是 title !== undefined,因此会持久化一条空的 custom_title 记录,路由向客户端返回 { title: '', displayName: '' }。被删除的 deriveForkBaseName 在这种情况下会回退到 sessionId.slice(0, 8)。建议修复:title = (sourceCustomTitle ? normalizeDerivedBranchTitle(sourceCustomTitle) : '') || sessionId.slice(0, 8);——或让归一化函数统一返回 string | undefined

— qwen3.8-max via Qwen Code /review (v0.22.0)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 8aac1c3abf. The shared normalizer now returns undefined for an empty result, and the side-task path falls back to sessionId.slice(0, 8) before calling forkSession. Added a regression proving (Branch) produces non-empty title/displayName 550e8400 and never persists an empty custom title.

Comment on lines +174 to +176
(sourceCustomTitle
? normalizeDerivedBranchTitle(sourceCustomTitle)
: deriveFirstPrompt(provisional.conversation.messages));

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.

[Critical] Third site of the empty-normalization gap: if the source's custom title is exactly (Branch)/(Branch 2), normalizeDerivedBranchTitle(sourceCustomTitle) returns '', the truthiness check on sourceCustomTitle accepts it, and the deriveFirstPrompt fallback is skipped — computeUniqueBranchTitle('') then names the branch literally (1). Pre-refactor this path always fell back to the non-empty first-prompt base. Suggested fix: (sourceCustomTitle ? normalizeDerivedBranchTitle(sourceCustomTitle) : '') || deriveFirstPrompt(provisional.conversation.messages) — or the shared string | undefined return type on the normalizer so ?? engages.

中文说明

空归一化缺口的第三处:若源会话的自定义标题恰好是 (Branch)/(Branch 2)normalizeDerivedBranchTitle(sourceCustomTitle) 返回 '',对 sourceCustomTitle 的真值判断会接受它,从而跳过 deriveFirstPrompt 回退——computeUniqueBranchTitle('') 会把分支命名为字面量 (1)。重构前该路径总是回退到非空的首条 prompt 基础名。建议修复:(sourceCustomTitle ? normalizeDerivedBranchTitle(sourceCustomTitle) : '') || deriveFirstPrompt(provisional.conversation.messages)——或让归一化函数统一返回 string | undefined 以使 ?? 回退生效。

— qwen3.8-max via Qwen Code /review (v0.22.0)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 8aac1c3abf. CLI /branch now treats an empty normalized custom title as absent and falls back through the source picker display name or the existing first-prompt fallback. Exact (Branch) and (Branch 2) cases now produce help me fix the login bug(1) in the focused fixture; the full CLI file passes 35/35.

Comment on lines +12065 to +12068
const sourceCustomTitle = recording?.getCurrentCustomTitle();
title = sourceCustomTitle
? normalizeDerivedBranchTitle(sourceCustomTitle)
: sessionId.slice(0, 8);

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] This diff deletes the shared deriveForkBaseName helper and inlines two divergent copies of the fork base-name derivation in the same case block — this side-task copy and the branch-path copy ~40 lines above. The side-task copy silently omits the branch path's persisted-display-name fallback and its requestedName === sourceDisplayName normalization, with no comment saying the omission is deliberate. This is a present-day divergence, not only future maintenance: with no live title but a persisted one, the branch fork derives from the persisted display name while the side-task fork falls back to the UUID fragment; an echoed display name is normalized on one path and kept verbatim on the other. The next change to the derivation rule must land in both blocks — miss one and the two surfaces re-fragment the picker naming this PR exists to unify. Consider extracting one small shared helper (branch path layers its extra logic on top), or stating at this site that the divergence is intentional.

中文说明

本 diff 删除了共享的 deriveForkBaseName 辅助函数,并在同一个 case 块中内联了两份行为不一致的 fork 基础名推导——即此处的 side-task 副本与上方约 40 行的分支路径副本。side-task 副本悄悄省略了分支路径的持久化展示名回退以及 requestedName === sourceDisplayName 归一化,且没有注释说明这是有意为之。这是当下就存在的分歧,而不仅是未来的维护风险:当没有活动标题但存在持久化标题时,分支 fork 从持久化展示名推导,而 side-task fork 回退到 UUID 片段;被回显的展示名在一条路径上被归一化,在另一条路径上被原样保留。下次修改推导规则时必须同时改两处——漏掉一处,两个入口就会重新割裂本 PR 要统一的命名族。建议抽取一个小的共享辅助函数(分支路径在其上叠加额外逻辑),或在此处注释说明分歧是有意的。

— qwen3.8-max via Qwen Code /review (v0.22.0)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Thanks for raising this. I kept the side-task persisted-display fallback intentionally unchanged in 8aac1c3abf: side tasks retain their pre-existing explicit name -> live recorder title -> UUID semantics and separate session_source: side_task identity, while ordinary branch naming follows picker semantics. Both paths still share the Core derived-title normalizer, including the empty-result fix. A configurable helper spanning the two different fallback policies would add abstraction without changing the requested behavior.

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.

Decline upheld in this round's triage. The two derivations stay separate by design: side tasks keep explicit name → normalized recorder title → session-id prefix and their own side_task identity, while ordinary branches follow picker display-name semantics. Both paths already share the Core derived-title normalizer (including the empty-result fallback fixed in 8aac1c3), so the recognition grammar cannot fragment again; a helper spanning two different fallback policies would add abstraction without changing behavior. Leaving this thread open with the recorded reason.

中文说明

本轮分诊维持拒绝(Decline)。两份推导有意保持分离:side task 保留 显式名称 → 归一化后的记录器标题 → session-id 前缀 链及其独立的 side_task 身份,普通分支则遵循选择器展示名语义。两条路径已共享 Core 的派生标题归一化函数(含 8aac1c3 修复的空结果回退),识别语法不会再次割裂;一个横跨两种不同回退策略的辅助函数只会增加抽象而不改变行为。线程保持打开并附已记录的理由。

Comment on lines +12005 to +12006
const resolveSourceDisplayName =
requestedName === undefined || atRecordId !== undefined;

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 daemon distinguishes a user-chosen name from the client echoing the source's display name only via exact string equality here, and only when atRecordId is set — an undocumented cross-client contract. Two concrete costs: (1) a genuinely explicit branch name identical to the source display name (e.g. an ACP client calling sessionBranch with name: 'Source session(2)' against a source titled Source session(2)) is silently re-normalized to Source session(1), contradicting the sentence this same diff adds to the design doc ("Explicitly requested names remain unchanged before suffix allocation"); (2) Web Shell always echoes sessionDisplayName as name, so any future client change to the echoed string (trimming, truncation, a stale catalog entry) breaks the equality and re-introduces stacked suffixes Source session(2)(1) — the exact regression this PR removes. The smoke test cannot catch this because it mocks the daemon and asserts only the request body. Prefer making the protocol unambiguous at the producer (Web Shell omits name when the user didn't provide one), then reduce this to requestedName === undefined ? derived : requestedName; if daemon-side robustness for echoing clients is the intent, document it here and reconcile the design-doc sentence.

中文说明

daemon 仅靠此处的精确字符串相等来区分“用户选择的名字”与“客户端回显的源会话展示名”,且只在设置了 atRecordId 时生效——这是一条未文档化的跨客户端契约。两个具体代价:(1) 与源展示名恰好相同的显式分支名(例如 ACP 客户端对名为 Source session(2) 的源会话以 name: 'Source session(2)' 调用 sessionBranch)会被悄悄重归一化为 Source session(1),与本 diff 同时新增的设计文档语句(“显式请求的名称在后缀分配前保持不变”)矛盾;(2) Web Shell 总是把 sessionDisplayName 作为 name 回显,因此未来任何对被回显字符串的改动(裁剪、截断、过期的目录项)都会破坏相等判断,重新引入嵌套后缀 Source session(2)(1)——正是本 PR 要消除的回归。冒烟测试抓不住这一点,因为它 mock 了 daemon 且只断言请求体。建议在生产端消除歧义(用户未提供名字时 Web Shell 不发送 name),然后把此处简化为 requestedName === undefined ? derived : requestedName;若本意是兼容会回显的客户端,请在此处注明并同步修正设计文档语句。

— qwen3.8-max via Qwen Code /review (v0.22.0)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 8aac1c3abf by removing the ambiguous echo contract. Historical Web Shell response branching no longer sends name; the daemon derives the source picker name when no name is supplied and treats every supplied name as explicit. The App tests and Chromium smoke now pin { atRecordId }, and an explicit historical Source session(2) remains Source session(2)(1).

Comment on lines +2784 to +2787
const displayName = await this.readSessionDisplayNameFromFile(
filePath,
titleInfo,
);

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 broadened collision scan now performs a sequential ≤10-record head-read + JSON parse + project-membership check for every untitled session file (up to MAX_FILES_TO_PROCESS = 10000) before the prefix filter can run; untitled files used to be skipped after the cheap title tail-read. Measured in a scratch-tree probe over 2000 untitled fixture files: 258.6 / 215.8 / 211.1 ms per full scan → ~0.1 ms/file warm lower bound → ~1.06 s projected at the 10,000-file cap, on every branch in dense projects (real first records are larger than the ~300-char fixture, so this is a floor). The cost is inherent to the new picker-parity semantics, but nothing pins the per-file budget — the existing perf test in useBranchCommand.test.ts asserts scan call count only, so a future edit can quietly widen the per-file read. Consider extending that pin (or this function's docstring, whose old text advertised skipping prompt extraction) to assert at most one bounded head-read per untitled file.

中文说明

放宽后的冲突扫描现在会对每一个无自定义标题的会话文件(上限 MAX_FILES_TO_PROCESS = 10000)顺序执行 ≤10 条记录的头部读取 + JSON 解析 + 项目归属检查,之后才能运行前缀过滤;此前无标题文件在廉价的标题尾读之后即被跳过。在临时树中对 2000 个无标题 fixture 文件的探针实测:整轮扫描 258.6 / 215.8 / 211.1 ms → 约 0.1 ms/文件的热态下限 → 在 10,000 文件上限下推算约 1.06 s,且密集项目中每次分支都会发生(真实首条记录比约 300 字符的 fixture 更大,因此这只是下限)。该成本是新的“与选择器一致”语义的固有代价,但每文件的读取预算没有被钉住——useBranchCommand.test.ts 中现有的性能测试只断言扫描调用次数,未来的改动可能悄悄放宽每文件读取。建议扩展该断言(或本函数的 docstring——旧文案曾声明跳过 prompt 提取),断言每个无标题文件至多一次有上限的头部读取。

— qwen3.8-max via Qwen Code /review (v0.22.0)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Addressed in 8aac1c3abf. The Core contract now documents one head read bounded by MAX_PROMPT_SCAN_LINES for each untitled candidate, and the prompt-only fixture asserts the exact per-file call budget [[file, 10]]. This pins the existing bounded cost without adding caching or concurrency machinery.

Comment on lines +2638 to +2642
try {
return await this.readSessionDisplayNameFromFile(filePath);
} catch {
return undefined;
}

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] getSessionDisplayName is tested on its happy paths plus the empty-file and other-project cases, but the catch that downgrades read failures to undefined and the SESSION_FILE_PATTERN guard above it have no test — removing the try/catch would ship green. That matters because acpAgent.ts awaits this method inside the branch extMethod whose only catch maps BranchPointInvalidError; any other throw rejects the whole branch request. If the source's chats file does not exist or is transiently unreadable (branching a live session before its first record is persisted, or concurrent cleanup), unguarded jsonl.readLines throws ENOENT and the branch fails outright instead of degrading to the UUID-fragment base. Two cases would pin it: a sessionId with no <sessionId>.jsonl on disk resolves to undefined (does not throw), and an id failing SESSION_FILE_PATTERN (e.g. 'not-a-session') resolves to undefined.

中文说明

getSessionDisplayName 已有正常路径及空文件、其他项目情形的测试,但把读取失败降级为 undefinedcatch 以及其上方的 SESSION_FILE_PATTERN 守卫没有测试——删除该 try/catch 也能全绿通过。这很重要,因为 acpAgent.ts 在分支 extMethod 中 await 该方法,而该处唯一的 catch 只映射 BranchPointInvalidError;其他任何抛错都会使整个分支请求被拒绝。如果源会话的聊天记录文件不存在或暂时不可读(在首条记录落盘前对活动会话分支、或并发清理),未加保护的 jsonl.readLines 会抛 ENOENT,分支将直接失败而不是降级为 UUID 前缀基础名。两个用例即可钉住:磁盘上不存在 <sessionId>.jsonl 的 sessionId 应解析为 undefined(不抛错);不满足 SESSION_FILE_PATTERN 的 id(如 'not-a-session')应解析为 undefined

— qwen3.8-max via Qwen Code /review (v0.22.0)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Addressed in 8aac1c3abf. Added formal tests for both a valid but missing session UUID and invalid id not-a-session; both resolve to undefined without throwing. Existing empty-file and foreign-project cases remain covered. The full Core test file passes 190/190.

Comment on lines +173 to +176
name ??
(sourceCustomTitle
? normalizeDerivedBranchTitle(sourceCustomTitle)
: deriveFirstPrompt(provisional.conversation.messages));

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] For untitled source sessions this CLI fallback still uses deriveFirstPrompt (whitespace collapsed, sliced to 100 chars, no ellipsis) while the daemon path this diff adds uses picker display-name semantics (extractFirstPromptFromRecordstruncatePromptForDisplay: raw whitespace/newlines, 200 code points + ...) — contradicting the design-doc rule this diff adds ("Use the source session's picker display name (customTitle || prompt) as the base") and the PR's cross-client alignment goal. Witness (scratch-tree probe, same untitled source with a multi-line >100-char pasted error block): the daemon arm produced the raw-newline title truncated at the 185-char slice, while the CLI arm produced "Error: Cannot read properties of undefined (reading 'map') at UserList.render (src/components/UserLi(1)" — the equality assertion failed on baseline and passed once the CLI arm fell back to getSessionDisplayName. The CLI fork title also mismatches the CLI's own picker, which renders the raw form via extractFirstPromptFromRecords, and the two different strings are invisible to each other's collision scan. Consider routing the CLI fallback through the same picker-display-name extraction, or documenting the CLI's divergence in the design-doc section this diff adds.

中文说明

对无自定义标题的源会话,此处的 CLI 回退仍使用 deriveFirstPrompt(空白折叠、截断到 100 字符、无省略号),而本 diff 新增的 daemon 路径使用选择器展示名语义(extractFirstPromptFromRecordstruncatePromptForDisplay:保留原始空白/换行、200 码点 + ...)——这与本 diff 新增的设计文档规则(“使用源会话的选择器展示名(customTitle || prompt)作为基础名”)及 PR 的跨客户端对齐目标矛盾。见证(临时树探针,同一个多行且超过 100 字符的粘贴报错块作为首条 prompt):daemon 侧产生保留换行、按 185 字符截断的标题,CLI 侧产生折叠后的 "Error: ... UserLi(1)"——基线下标题相等断言失败,令 CLI 侧回退到 getSessionDisplayName 后通过。CLI 分支标题与 CLI 自己的选择器也不一致(选择器经 extractFirstPromptFromRecords 渲染原始形式),且两个不同的字符串互相看不见对方的冲突扫描。建议让 CLI 回退复用相同的选择器展示名提取,或在本 diff 新增的设计文档章节中明确记录 CLI 的差异。

— qwen3.8-max via Qwen Code /review (v0.22.0)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 8aac1c3abf. For an untitled source, CLI /branch now calls SessionService.getSessionDisplayName(oldSessionId) and uses that picker-compatible value before the local first-prompt degradation fallback. Added a multiline >100-character regression that preserves the picker display name; CLI 35/35 and the target Chromium smoke both pass.

Round-two review found empty normalized titles could bypass fallbacks,
while client-echoed names made explicit-name semantics ambiguous.

- Treat empty derived titles as absent across branch entry points
- Stop Web Shell name echoing and align CLI fallback with the picker
- Pin missing-session, invalid-id, and bounded-scan behavior
@water-in-stone

Copy link
Copy Markdown
Collaborator Author

@qwen-code-ci-bot /takeover

@yiliang114 yiliang114 added the autofix/takeover Summon the autofix loop to manage this PR (remove to release; needs triage+) label Aug 24, 2026
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤝 Takeover engaged: the autofix loop now manages this PR — it will address new review feedback and resolve base conflicts until the label is removed or the round cap is reached. Remove the autofix/takeover label (or comment @qwen-code /takeover stop) to release.

中文说明

🤝 已接管:autofix 循环现在管理此 PR —— 将持续处理新的评审反馈与 base 冲突,直到移除标签或达到轮次上限。移除 autofix/takeover 标签(或评论 @qwen-code /takeover stop)即可释放。

@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.

Deferred under the convergence posture (round 3, not a blocker) — recorded, not requested in this round:

  • packages/core/src/services/sessionService.ts:3065 — [probe] Title slice counts UTF-16 units and can split a surrogate pair fed by the new display-name path

Convergence: round 3 posted 3 inline comment(s), 3 of them reported for the first time; the previous round posted 8 (8 new). Findings keep coming back to the same files: packages/cli/src/acp-integration/acpAgent.ts (findings in round 2; 2 more now). A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. No Critical finding is open on this round, so merging and moving the remaining Suggestion threads to a follow-up issue is available as an ending — a merged pull request cannot diverge further. (Observation only — nothing was withheld from this review because of this observation.)

中文说明

已审查——无阻断问题。 建议见行内评论。

收敛姿态下延后(第 3 轮,非阻断)——已记录,本轮不要求修改:共 1 条(原文未翻译,列表见上方英文部分)。

收敛情况:第 3 轮发布了 3 条行内评论,其中 3 条是首次提出;上一轮发布了 8 条(其中 8 条首次提出)。发现反复回到同一批文件:packages/cli/src/acp-integration/acpAgent.ts(第 2 轮已出过发现,本轮又有 2 条)。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。本轮没有未决的 Critical,因此"合入后把剩余 Suggestion 线程转到后续 issue"是一个可选的结束方式——已合入的 PR 不会继续发散。(仅为观察——本轮评审未因此扣留任何内容。)

— qwen3.8-max via Qwen Code /review (v0.22.0)

Comment on lines +625 to 632
normalizeDerivedBranchTitle: vi.fn(
(baseName: string) =>
baseName
.trim()
.replace(/\s*\(Branch(?:\s+\d+)?\)$/, '')
.replace(/(\S)\(\d+\)$/, '$1')
.trim() || undefined,
),

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] This mock copies the real normalizeDerivedBranchTitle regex body verbatim instead of reusing the implementation — the same vi.mock factory already passes other pure helpers through importOriginal (e.g. parseInvocationContext, emptyGoalSnapshot). The adjacent computeUniqueBranchTitle stub deliberately simplifies to ${baseName}(1) for determinism, but this mock simplifies nothing — it duplicates. This PR just rewrote the normalization scheme once ( (Branch N)(N)); the next such change will update sessionService.ts without this mock, and the ACP route tests will silently keep exercising the stale regex: expectations like 'Source session(2)''Source session(1)' would validate the mock's old behaviour and stay green while the shipped function behaves differently — the integration tests would endorse a normalization that no longer exists.

Suggested change
normalizeDerivedBranchTitle: vi.fn(
(baseName: string) =>
baseName
.trim()
.replace(/\s*\(Branch(?:\s+\d+)?\)$/, '')
.replace(/(\S)\(\d+\)$/, '$1')
.trim() || undefined,
),
normalizeDerivedBranchTitle: vi.fn(
(await importOriginal<typeof import('@qwen-code/qwen-code-core')>())
.normalizeDerivedBranchTitle,
),
中文说明

这个 mock 逐字复制了真实 normalizeDerivedBranchTitle 的正则实现,而不是复用真实实现——同一个 vi.mock 工厂已经通过 importOriginal 引入了其他纯函数(如 parseInvocationContextemptyGoalSnapshot)。旁边的 computeUniqueBranchTitle 桩是为了确定性而刻意简化为 ${baseName}(1),但这个 mock 没有任何简化——只是重复实现。本 PR 刚刚重写过一次归一化方案( (Branch N)(N));下次此类变更只会更新 sessionService.ts 而不会更新这个 mock,ACP 路由测试会继续悄悄验证过期的正则:像 'Source session(2)''Source session(1)' 这样的断言仍会通过,但验证的是 mock 的旧行为,而真实函数的行为已经不同——集成测试将为一个已不存在的归一化方案背书。

— qwen3.8-max via Qwen Code /review (v0.22.0)

Comment on lines +12062 to +12064
title = sourceCustomTitle
? (normalizeDerivedBranchTitle(sourceCustomTitle) ??
sessionId.slice(0, 8))

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 side-task path's derived-title branch — no explicit name and the source custom title normalizes to a non-empty result — has no test, so the normalization applied there is unpinned. The only three sessionSideTask tests are: an explicit name (custom title never consulted), custom title '(Branch)' (normalizes to empty → session-id fallback), and the atRecordId rejection. A regression reverting this path to the pre-PR semantics — strip only (Branch…), keep a Title(N) suffix, i.e. the old deriveForkBaseName behaviour — passes all three existing tests (the '(Branch)' case falls back to 550e8400 under both old and new semantics), yet a user creating a side task from a session titled My Project(2) without a name would get My Project(2) instead of My Project.

Witness (scratch-tree mutant probe at this commit):

mutant (pre-PR deriveForkBaseName semantics): side-task tests still green 4/4
probe (custom title 'My Project(2)', no name):
  real code -> forkSession title: "My Project"
  mutant    -> expected "title": "My Project", received "My Project(2)"

Add one case alongside the existing ones: no name, getCurrentCustomTitle returns 'My Project(2)', expect forkSession called with expect.objectContaining({ title: 'My Project' }).

中文说明

side-task 路径中“推导出的标题”分支——未提供显式 name 且源会话自定义标题归一化结果非空——没有测试,该处应用的归一化因此未被钉住。现有 sessionSideTask 测试只有三个:显式名称(完全不查询自定义标题)、自定义标题为 '(Branch)'(归一化为空 → 回退到 session-id)、以及拒绝 atRecordId。若该路径回归到 PR 之前的语义——只剥离 (Branch…)、保留 Title(N) 后缀(即旧的 deriveForkBaseName 行为)——三个现有测试全部仍能通过('(Branch)' 用例在新旧语义下都回退为 550e8400),但从名为 My Project(2) 的会话不带名称创建 side task 的用户会得到 My Project(2) 而不是 My Project

见证(在本提交的临时树中执行的变异探针):变异体(PR 前的 deriveForkBaseName 语义)下 side-task 测试仍为 4/4 全绿;探针用例(自定义标题 'My Project(2)'、无 name)结果翻转——真实代码持久化 title: "My Project",变异体持久化 title: "My Project(2)"

建议在现有用例旁补充一个:不提供 namegetCurrentCustomTitle 返回 'My Project(2)',断言 forkSession 被以 expect.objectContaining({ title: 'My Project' }) 调用。

— qwen3.8-max via Qwen Code /review (v0.22.0)

Comment on lines +12019 to +12020
const baseName =
requestedName ?? derivedBaseName ?? sessionId.slice(0, 8);

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 normalizeDerivedBranchTitle(sourceCustomTitle) returns undefined, the empty-result fallback diverges between clients: this ACP/daemon route (used by Web Shell) falls back to the session-id prefix, while CLI /branch falls back to the first prompt — the same source session yields different branch titles depending on which client branches it, undercutting the cross-client alignment this PR exists for. Concretely: a source session manually renamed to exactly (Branch) or (Branch 2), branched without an explicit name, becomes 550e8400(1) via Web Shell but help me fix the login bug(1) via CLI — here persistedDisplayName is only fetched when sourceCustomTitle === undefined, so a custom title that normalizes to empty never reaches the prompt fallback. Both outcomes are pinned by this PR's own tests ('(Branch)'550e8400(1) in acpAgent.test.ts; ['(Branch)', 'help me fix the login bug(1)'] in useBranchCommand.test.ts), so the divergence is observable and persistent. The trigger is rare (a title that is exactly a legacy/generated suffix token), but a maintainer comparing the two clients sees unexplained different titles for identical operations. Either align the chains — when normalization returns undefined, fall back to the source's first prompt here as well (note getSessionDisplayName won't serve this directly since it prefers customTitle, the very value that normalized to empty) — or document the divergence as intentional at both sites and in the design-doc section this diff adds.

中文说明

normalizeDerivedBranchTitle(sourceCustomTitle) 返回 undefined 时,空结果回退在两个客户端之间不一致:此 ACP/daemon 路由(Web Shell 使用)回退到 session-id 前缀,而 CLI /branch 回退到首条 prompt——同一个源会话仅因创建分支的客户端不同就会得到不同的分支标题,削弱了本 PR 要实现的跨客户端一致性。具体地:将一个手动重命名为恰好 (Branch)(Branch 2) 的源会话不带名称创建分支,经 Web Shell 得到 550e8400(1),经 CLI 却得到 help me fix the login bug(1)——此处 persistedDisplayName 仅在 sourceCustomTitle === undefined 时才获取,因此归一化为空的自定义标题永远到不了 prompt 回退。两种结果都被本 PR 自己的测试钉住(acpAgent.test.ts'(Branch)'550e8400(1)useBranchCommand.test.ts['(Branch)', 'help me fix the login bug(1)']),因此该分歧可观察且会长期存在。触发条件罕见(标题恰好是旧式/生成的后缀记号),但维护者对比两个客户端时会看到相同操作产生无法解释的不同标题。建议要么对齐两条链——当归一化返回 undefined 时此处同样回退到源会话的首条 prompt(注意 getSessionDisplayName 无法直接满足,因为它优先取 customTitle,而那正是归一化为空的值)——要么在两处代码及本 diff 新增的设计文档章节中明确记录该分歧是有意的。

— qwen3.8-max via Qwen Code /review (v0.22.0)

@qwen-code-dev-bot

qwen-code-dev-bot commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

⚠️ AutoFix round 5 ended without publishing a reportview run.

中文说明

⚠️ AutoFix 第 5 轮结束但未发布报告 —— 查看运行

…vergence

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

Copy link
Copy Markdown
Collaborator

🔀 Base updated: red check(s) [review-pr] pass on current main — merged current main via update-branch; CI will re-run.

中文说明

🔀 已更新 base:红色检查 [review-pr] 在当前 main 上通过 —— 已通过 update-branch 合入当前 main,CI 将重新运行。

@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.

Partially reviewed — gaps disclosed.

Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

Deferred under the convergence posture (round 5, not a blocker) — recorded, not requested in this round:

  • packages/core/src/services/sessionService.ts:3076 — [review] User-authored (N) custom titles are stripped as if they were generated fork suffixes
  • packages/core/src/services/sessionService.test.ts:6089 — [review] The $ end-anchor in the numeric-suffix strip regex is pinned by no test
  • packages/cli/src/ui/hooks/useBranchCommand.test.ts:392 — [review] The CLI hook's display-name-fetch guard is never asserted
中文说明

仅完成部分审查,审查缺口已披露。

未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。

收敛姿态下延后(第 5 轮,非阻断)——已记录,本轮不要求修改:共 3 条(原文未翻译,列表见上方英文部分)。

— qwen3.8-max via Qwen Code /review (v0.22.0)

Comment on lines +11094 to +11095
const baseName =
requestedName ?? derivedBaseName ?? sessionId.slice(0, 8);

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.

[Critical] R4-1: When the source session's current custom title is exactly the empty string, this derivation chain leaks it all the way to computeUniqueBranchTitle, producing the degenerate branch title (1). '' is not undefined, so persistedDisplayName is never fetched (the guard is sourceCustomTitle === undefined); sourceCustomTitle ?? persistedDisplayName keeps ''; the falsy ternary yields derivedBaseName = ''; and requestedName ?? '' ?? sessionId.slice(0, 8) keeps '' because ?? only falls through on null/undefined — so the fork is announced, persisted, and listed as (1) instead of the documented session-id-prefix fallback 550e8400(1). An empty title is writable through the shipped qwen/control/session/title control route (validation is string-type + max-length only, so '' passes and recordCustomTitle('') persists it) and through load-path hydration of a custom_title: "" record. The deleted deriveForkBaseName guarded exactly this case, and the sibling side-task route (truthy check + ?? sessionId.slice(0, 8)) and CLI /branch (truthy ternary falls through to deriveFirstPrompt) still handle '' correctly — the defect is isolated to this route. A whitespace-only display name from getSessionDisplayName also trims to empty inside the allocator through the same missing guard.

Witness (scratch-tree probe against the real route at this commit):

PR (unfixed): expect title '550e8400(1)' -> received { "title": "(1)" }
core probe: computeUniqueBranchTitle('') => "(1)", prefix "("
PR + suggested fix: same probe => '550e8400(1)'; full acpAgent.test.ts 475/475 pass

Normalize the derived base the way the requested name already is:

const baseName =
  requestedName ??
  (derivedBaseName?.trim() || undefined) ??
  sessionId.slice(0, 8);

and apply the same trim-to-undefined treatment to sourceDisplayName in useBranchCommand.ts. Add the missing sourceTitle: ''550e8400(1) regression row to the ACP title table.

中文说明

当源会话当前的自定义标题恰好为空字符串时,这条派生链会把它一路泄漏到 computeUniqueBranchTitle,产生退化分支标题 (1)'' 不是 undefined,因此 persistedDisplayName 不会被获取(守卫是 sourceCustomTitle === undefined);sourceCustomTitle ?? persistedDisplayName 保留 '';falsy 三元分支使 derivedBaseName = '';而 requestedName ?? '' ?? sessionId.slice(0, 8) 因为 ?? 只对 null/undefined 回退,仍保留 ''——于是新分支被公告、持久化并显示为 (1),而不是文档约定的 session-id 前缀回退 550e8400(1)。空标题可以通过已发布的 qwen/control/session/title 控制路由写入(其校验仅为字符串类型 + 最大长度,'' 可通过并被 recordCustomTitle('') 持久化),也可在加载路径中由 custom_title: "" 记录水合而来。被删除的 deriveForkBaseName 恰好守卫了这种情况,且同文件的 side-task 路由(真值判断 + ?? sessionId.slice(0, 8))与 CLI /branch(falsy 三元回退到 deriveFirstPrompt)至今仍正确处理 ''——缺口仅限于本路由。来自 getSessionDisplayName 的纯空白展示名也会经由同一缺失的守卫在分配器内被 trim 为空。

见证(在本提交的临时树中对真实路由执行的探针):当前代码期望标题 '550e8400(1)',实际收到 { "title": "(1)" };核心探针 computeUniqueBranchTitle('') 返回 "(1)",前缀为 "(";应用建议修复后同一探针返回 '550e8400(1)',且 acpAgent.test.ts 全量 475/475 通过。

建议按请求名称已有的归一化方式同样处理派生基础名(如上代码),并对 useBranchCommand.ts 中的 sourceDisplayName 做同样的 trim 转 undefined 处理;同时在 ACP 标题表中补充 sourceTitle: ''550e8400(1) 的回归用例行。

— qwen3.8-max via Qwen Code /review (v0.22.0)

base, remove an existing generated fork suffix, and append the lowest available
numeric suffix: `Title(1)`, `Title(2)`, and so on. Explicitly requested names
remain unchanged before suffix allocation. If a custom title normalizes to
nothing (it was exactly a suffix token), no picker name survives: the daemon

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] R4-2: This parenthetical misdescribes the fallback trigger: normalizeDerivedBranchTitle('(2)') returns '(2)' (the numeric-strip regex (\S)\(\d+\)$ requires a non-whitespace char before the paren, and '(2)' has none), so a title that is exactly a numeric suffix token never normalizes to nothing — only exactly (Branch) / (Branch N) titles do. The daemon/CLI fallback divergence this paragraph documents is therefore unreachable for the numeric token class the sentence names. Verified by executing the shipped helper at this commit: ('(2)')'(2)', ('(Branch)')undefined, ('(Branch 2)')undefined.

Suggested change
nothing (it was exactly a suffix token), no picker name survives: the daemon
nothing (it was exactly a legacy `(Branch)` or `(Branch N)` token), no picker name survives: the daemon
中文说明

此处的括号说明错误描述了回退触发条件:normalizeDerivedBranchTitle('(2)') 返回 '(2)'(数字剥离正则 (\S)\(\d+\)$ 要求括号前有一个非空白字符,而 '(2)' 没有),因此恰好是数字后缀词的标题永远不会归一化为空——只有恰好是 (Branch) / (Branch N) 的标题才会。所以本段所描述的 daemon/CLI 回退分歧,对于该句所指的数字后缀词类是不可达的。已在本提交上执行随代码发布的助手函数验证:('(2)')'(2)'('(Branch)')undefined('(Branch 2)')undefined。建议将括号内文字改为“恰好是旧式 (Branch)(Branch N) 词”,或修改归一化函数使纯数字标题也触发回退。

— qwen3.8-max via Qwen Code /review (v0.22.0)

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 AutoFix ran out of time before finishing (timeout (2700000ms)) (attempt 3/100) — it will retry on the next scan.

⚠️ This change was NOT pushed — any commit referenced below was made only in the runner workspace and has been discarded. What the agent reported:
Qwen failed during address-review: timeout (2700000ms).

See the Qwen Autofix agent step logs for model/tool output.

中文说明

🤖 AutoFix 在完成前耗尽了时间(timeout (2700000ms))(第 3/100 次尝试)—— 将在下次扫描时重试。

Run log: https://github.com/QwenLM/qwen-code/actions/runs/32723225818


🧠 Handled by Qwen Code · model/模型 qwen3.8-max

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🔀 Base updated: red check(s) [Test (ubuntu-latest, Node 22.x)] pass on current main — merged current main via update-branch; CI will re-run.

中文说明

🔀 已更新 base:红色检查 [Test (ubuntu-latest, Node 22.x)] 在当前 main 上通过 —— 已通过 update-branch 合入当前 main,CI 将重新运行。

@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.

Partially reviewed — gaps disclosed.

Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

Deferred under the convergence posture (round 6, not a blocker) — recorded, not requested in this round:

  • packages/cli/src/acp-integration/acpAgent.ts:1148 — [review] normalizeRequestedBranchName's empty-after-trim branch is unpinned by any test; a whitespace-only requested name mutant survives the whole suite
  • docs/design/web-shell/assistant-response-session-branching.md:707 — [review] R4-2 still stands — the parenthetical misdescribes the fallback trigger: normalizeDerivedBranchTitle('(2)') returns '(2)', so a purely numeric suffix token never n…
中文说明

仅完成部分审查,审查缺口已披露。

未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。

收敛姿态下延后(第 6 轮,非阻断)——已记录,本轮不要求修改:共 2 条(原文未翻译,列表见上方英文部分)。

— qwen3.8-max via Qwen Code /review (v0.22.0)

Comment on lines +11094 to +11095
const baseName =
requestedName ?? derivedBaseName ?? sessionId.slice(0, 8);

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.

[Critical] R4-1: When the source session's current custom title is exactly the empty string, this derivation chain leaks it all the way to computeUniqueBranchTitle, producing the degenerate branch title (1). '' is not undefined, so persistedDisplayName is never fetched (the guard is sourceCustomTitle === undefined); sourceCustomTitle ?? persistedDisplayName keeps ''; the falsy ternary yields derivedBaseName = ''; and requestedName ?? '' ?? sessionId.slice(0, 8) keeps '' because ?? only falls through on null/undefined — so the fork is announced, persisted, and listed as (1) instead of the documented session-id-prefix fallback 550e8400(1). An empty title is writable through the shipped qwen/control/session/title control route (validation is string-type + max-length only, so '' passes and recordCustomTitle('') persists it) and through load-path hydration of a custom_title: "" record. The sibling side-task route (truthy check + ?? sessionId.slice(0, 8)) and CLI /branch (truthy ternary falling through to the display-name / first-prompt chain) still handle '' correctly — the defect is isolated to this route.

Witness (round-5 scratch-tree probe against the real route; the anchored code is unchanged since, and the mechanism re-traces at this commit):

PR (unfixed): expect title '550e8400(1)' -> received { "title": "(1)" }
core probe: computeUniqueBranchTitle('') => "(1)", prefix "("

Normalize the derived base the way the requested name already is:

const baseName =
  requestedName ??
  (derivedBaseName?.trim() || undefined) ??
  sessionId.slice(0, 8);
中文说明

当源会话当前的自定义标题恰好为空字符串时,这条派生链会把它一路泄漏到 computeUniqueBranchTitle,产生退化分支标题 (1)'' 不是 undefined,因此 persistedDisplayName 不会被获取(守卫是 sourceCustomTitle === undefined);sourceCustomTitle ?? persistedDisplayName 保留 '';falsy 三元分支使 derivedBaseName = '';而 requestedName ?? '' ?? sessionId.slice(0, 8) 因为 ?? 只对 null/undefined 回退,仍保留 ''——于是新分支被公告、持久化并显示为 (1),而不是文档约定的 session-id 前缀回退 550e8400(1)。空标题可以通过已发布的 qwen/control/session/title 控制路由写入(校验仅为字符串类型 + 最大长度,'' 可通过并被 recordCustomTitle('') 持久化),也可在加载路径中由 custom_title: "" 记录水合而来。同文件的 side-task 路由(真值判断 + ?? sessionId.slice(0, 8))与 CLI /branch(falsy 三元回退到展示名/首条 prompt 链)至今仍正确处理 ''——缺口仅限于本路由。

见证(第 5 轮临时树中对真实路由执行的探针;锚定代码此后未变,且该机制在本提交重新追踪成立):期望标题 '550e8400(1)',实际收到 { "title": "(1)" };核心探针 computeUniqueBranchTitle('') 返回 "(1)",前缀为 "("

建议按请求名称已有的归一化方式同样处理派生基础名(如上代码)。

— qwen3.8-max via Qwen Code /review (v0.22.0)

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 Addressed the latest review feedback (round 4/100). What changed, and what I pushed back on: · 已处理最新评审反馈(第 4/100 轮)。改动内容与我反驳保留之处如下:

Autofix address-review summary — PR #9764

Commit: 1359f61411 fix(session): fall back on empty branch-title bases (on codex/fix-session-fork-title). No base-conflict merge was requested (--conflict false), and none was performed.

Feedback triage and decisions

Finding Severity Decision
[rc:3843155011] R4-1 — empty-string custom title leaks into computeUniqueBranchTitle, producing the degenerate branch title (1) on the daemon sessionBranch route Critical Resolved in code. Reproduced first, then fixed at root cause.
[rc:3845396768] R4-1 duplicate of the same defect Critical Resolved in code by the same fix.
[rc:3843155018] R4-2 — design-doc parenthetical misdescribes the fallback trigger Suggestion Resolved. Applied the suggested wording.
[rv:5007451330], [rv:5010148424] — CHANGES_REQUESTED, "Partially reviewed — gaps disclosed" Review state No actionable defect named beyond the inline findings above; their deferred lists are explicitly "recorded, not requested in this round" (convergence posture). See "Disclosed" below.
[ic:5393477368] — review-pipeline transient failure notice Info No action possible; transient infra notice.

Reproduction (before any fix)

Per the source-blind verification rule, the defect was reproduced on the pre-round code before changing anything:

  • Added the sourceTitle: '' and whitespace-only persistedDisplayName rows to the ACP title table, and blank-display-name tests to the /branch hook suite, then ran them against the unfixed code:
    • acpAgent.test.ts: expected 550e8400(1), received { "title": "(1)" } for sourceTitle: '', and " (1)" for the whitespace-only display name — exactly the leak the finding describes (?? does not fall through on '', so the empty base reaches computeUniqueBranchTitle and the empty prefix yields (1)).
    • useBranchCommand.test.ts: expected 'help me fix the login bug(1)' (first-prompt fallback), received "(1)".

Changes

  • packages/cli/src/acp-integration/acpAgent.ts — the sessionBranch route now normalizes the derived base the same way the requested name already is: requestedName ?? (derivedBaseName?.trim() || undefined) ?? sessionId.slice(0, 8). Empty/whitespace bases fall back to the session-id prefix (550e8400(1)), matching the documented behavior and the sibling side-task route.
  • packages/cli/src/ui/hooks/useBranchCommand.ts — the same trim-to-undefined treatment applied to sourceDisplayName, so a blank picker display name falls back to the first prompt instead of producing (1).
  • Comments above both blocks updated to describe the fallback triggers accurately (empty / whitespace-only / exactly a legacy (Branch) or (Branch N) token), which also fixes the same "suffix token" misdescription R4-2 identified in the doc.
  • packages/cli/src/acp-integration/acpAgent.test.ts — two regression rows: sourceTitle: ''550e8400(1); whitespace-only persistedDisplayName550e8400(1).
  • packages/cli/src/ui/hooks/useBranchCommand.test.ts — regression tests for '' and ' ' display names → first-prompt fallback.
  • docs/design/web-shell/assistant-response-session-branching.md — R4-2: the parenthetical now reads "it was exactly a legacy (Branch) or (Branch N) token". Verified by inspection of the shipped regex (\S)\(\d+\)$ (requires a non-whitespace char before the paren), so a purely numeric token like (2) never normalizes to nothing.

Mutation probes

  • Removing the new guard in acpAgent.ts (reverting to requestedName ?? derivedBaseName ?? ...) makes the two new table rows FAIL ((1) and (1)); restoring it returns them to green.
  • Removing the trim in useBranchCommand.ts makes both blank-display-name tests FAIL; restoring it returns them to green.

Both new guards are therefore witnessed by tests that fail without them.

Disclosed (not run / not requested)

  • Integration Tests (CLI, No Sandbox) — the gap disclosed by [rv:5007451330]/[rv:5010148424]. Not run this round: the touched behavior (daemon sessionBranch route and the /branch hook) is exercised directly by the focused unit suites above, not only through the bundled CLI/integration harness, and the round carried a budget warning (a previous round exhausted its time budget). The workflow's independent CI remains the final gate for this check.
  • The convergence-posture deferred items listed inside the two review bodies (user-authored (N) title stripping at sessionService.ts:3076, the unanchored $ end-anchor, the unasserted display-name-fetch guard, and normalizeRequestedBranchName's empty-after-trim branch) are explicitly "recorded, not requested in this round" and were left untouched.

Verification

  • Reproduction pre-fix (packages/cli): npx vitest run src/acp-integration/acpAgent.test.ts -t "derives the branch title from" — 2 failed / 11 passed (the two new rows fail as expected); npx vitest run src/ui/hooks/useBranchCommand.test.ts -t "picker display name is blank" — 2 failed (expected, received "(1)")
  • Mutation probe 1 (acpAgent guard removed): same title-table run — 2 failed; guard restored — green
  • Mutation probe 2 (hook trim removed): same hook run — 2 failed; trim restored — green
  • npx vitest run src/ui/hooks/useBranchCommand.test.ts (packages/cli) — 37 passed
  • npx vitest run src/acp-integration/acpAgent.test.ts (packages/cli) — 476 passed
  • npm run build — passed (exit 0)
  • npm run typecheck — passed (exit 0)
  • npm run lint — passed (exit 0)
  • npx prettier --check on all five touched files — passed (one style fix applied to useBranchCommand.ts via prettier --write; doc paragraph re-wrapped to the file's 80-column style)
  • Integration tests — not run; disclosed above
中文说明

Autofix address-review 摘要 — PR #9764

提交:1359f61411 fix(session): fall back on empty branch-title bases(位于 codex/fix-session-fork-title 分支)。未要求合并 base 冲突(--conflict false),也未执行任何合并。

反馈分类与处理决定

发现 严重程度 决定
[rc:3843155011] R4-1 — 空字符串自定义标题泄漏进 computeUniqueBranchTitle,在 daemon sessionBranch 路由上产生退化分支标题 (1) Critical 已在代码中解决。 先复现,再从根因修复。
[rc:3845396768] R4-1 同一缺陷的重复项 Critical 已在代码中解决(同一修复覆盖)。
[rc:3843155018] R4-2 — 设计文档中的括号说明错误描述了回退触发条件 Suggestion 已解决。 采纳了建议的措辞。
[rv:5007451330]、[rv:5010148424] — CHANGES_REQUESTED,“仅完成部分审查,缺口已披露” 审查状态 除上述 inline 发现外未指出可执行的具体缺陷;其延后列表明确标注“已记录,本轮不要求修改”(收敛姿态)。见下方“已披露”部分。
[ic:5393477368] — 审查流水线瞬时失败通知 信息 无法处理;瞬时基础设施通知。

复现(修复前)

按照来源无关的验证规则,在修改任何代码之前先在修复前的代码上复现了该缺陷:

  • 向 ACP 标题表新增 sourceTitle: '' 行与纯空白 persistedDisplayName 行,并向 /branch hook 测试新增空白展示名用例,然后在未修复代码上运行:
    • acpAgent.test.ts:期望 550e8400(1)sourceTitle: '' 实际收到 { "title": "(1)" },纯空白展示名实际收到 " (1)" —— 与发现描述完全一致(?? 不会对 '' 回退,空基础名直达 computeUniqueBranchTitle,空前缀产生 (1))。
    • useBranchCommand.test.ts:期望 'help me fix the login bug(1)'(首条 prompt 回退),实际收到 "(1)"

变更内容

  • packages/cli/src/acp-integration/acpAgent.tssessionBranch 路由现在按请求名称已有的归一化方式同样处理派生基础名:requestedName ?? (derivedBaseName?.trim() || undefined) ?? sessionId.slice(0, 8)。空/纯空白基础名回退到 session-id 前缀(550e8400(1)),与文档约定及同文件 side-task 路由行为一致。
  • packages/cli/src/ui/hooks/useBranchCommand.ts — 对 sourceDisplayName 应用同样的 trim 转 undefined 处理,使空白的选择器展示名回退到首条 prompt,而不是产生 (1)
  • 两处代码块上方的注释已更新为准确描述回退触发条件(空 / 纯空白 / 恰好是旧式 (Branch)(Branch N) 词),同时修正了 R4-2 在文档中指出的同类“后缀词”错误描述。
  • packages/cli/src/acp-integration/acpAgent.test.ts — 新增两个回归行:sourceTitle: ''550e8400(1);纯空白 persistedDisplayName550e8400(1)
  • packages/cli/src/ui/hooks/useBranchCommand.test.ts — 新增 ''' ' 展示名的回归测试 → 首条 prompt 回退。
  • docs/design/web-shell/assistant-response-session-branching.md — R4-2:括号说明改为“恰好是旧式 (Branch)(Branch N) 词”。已通过检查随代码发布的正则 (\S)\(\d+\)$ 验证(要求括号前有一个非空白字符),因此纯数字词如 (2) 永远不会归一化为空。

变异探针

  • 移除 acpAgent.ts 中的新守卫(还原为 requestedName ?? derivedBaseName ?? ...)会使两个新表行失败(FAIL,得到 (1) (1));恢复后回到绿色。
  • 移除 useBranchCommand.ts 中的 trim 会使两个空白展示名测试失败;恢复后回到绿色。

因此两个新守卫均有“缺失即失败”的测试见证。

已披露(未运行 / 本轮不要求)

  • Integration Tests (CLI, No Sandbox) — 即 [rv:5007451330]/[rv:5010148424] 披露的缺口。本轮未运行:本次触及的行为(daemon sessionBranch 路由与 /branch hook)已由上述聚焦单元测试直接覆盖,并非只能通过捆绑后的 CLI/集成测试运行,且本轮带有预算警告(此前一轮耗尽时间预算)。工作流的独立 CI 仍是该检查的最终关口。
  • 两条审查正文中列出的收敛姿态延后项(sessionService.ts:3076 的用户自拟 (N) 标题被剥离、未锚定的 $ 尾锚点、未被断言的展示名获取守卫、normalizeRequestedBranchName 的 trim 后为空分支)均明确标注“已记录,本轮不要求修改”,未做改动。

验证

  • 修复前复现(packages/cli):npx vitest run src/acp-integration/acpAgent.test.ts -t "derives the branch title from" — 2 失败 / 11 通过(两个新行按预期失败);npx vitest run src/ui/hooks/useBranchCommand.test.ts -t "picker display name is blank" — 2 失败(预期内,收到 "(1)"
  • 变异探针 1(移除 acpAgent 守卫):同一标题表运行 — 2 失败;恢复守卫后 — 绿色
  • 变异探针 2(移除 hook trim):同一 hook 运行 — 2 失败;恢复后 — 绿色
  • npx vitest run src/ui/hooks/useBranchCommand.test.ts(packages/cli)— 37 通过
  • npx vitest run src/acp-integration/acpAgent.test.ts(packages/cli)— 476 通过
  • npm run build — 通过(exit 0)
  • npm run typecheck — 通过(exit 0)
  • npm run lint — 通过(exit 0)
  • npx prettier --check(全部 5 个触及文件)— 通过(其中 useBranchCommand.tsprettier --write 修正一处风格;文档段落已按该文件 80 列风格重新折行)
  • 集成测试 — 未运行;已在上方披露

Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。

Re-review when you have a moment. After round 100 this bot stops and leaves the PR for a human. · 有空请复审;第 100 轮后本 bot 停止并将 PR 交给人工。


🧠 Handled by Qwen Code · model/模型 qwen3.8-max

@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.

Partially reviewed — gaps disclosed.

Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

Deferred under the convergence posture (round 7, not a blocker) — recorded, not requested in this round:

  • packages/cli/src/acp-integration/acpAgent.ts:11142 — [review] Side-task route's falsy-custom-title fallback branch has no test; a mutation survives the whole suite
  • packages/cli/src/acp-integration/acpAgent.ts:1149 — [review] normalizeRequestedBranchName's blank/non-string rejection is unpinned by any test
  • packages/cli/src/ui/hooks/useBranchCommand.ts:150 — [review] The CLI hook's getSessionDisplayName lazy-read guard has no negative assertion
中文说明

仅完成部分审查,审查缺口已披露。

未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。

收敛姿态下延后(第 7 轮,非阻断)——已记录,本轮不要求修改:共 3 条(原文未翻译,列表见上方英文部分)。

— qwen3.8-max via Qwen Code /review (v0.22.0)

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 Reviewed the latest feedback — no changes needed. Why, point by point: · 已审阅最新反馈——无需改动。逐点说明原因如下:

Autofix round: no action taken (PR #9764)

No code changes were made this round and no commit was created. The branch remains at 1359f61411.

Triage of this round's feedback

  • Review [rv:5011260063] (COMMENTED) — the automated reviewer reported "Partially reviewed — gaps disclosed" and requested no changes:
    • Disclosed gap: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally. This is an informational disclosure about the review's own coverage, not an action request. All checks that did run are green: 46 checks total, every one SUCCESS or SKIPPED, none failing or persisting red.
    • The review's three test-coverage findings — the side-task route's falsy-custom-title fallback branch in acpAgent.ts having no test, normalizeRequestedBranchName's blank/non-string rejection being unpinned by any test, and the CLI hook's getSessionDisplayName lazy-read guard in useBranchCommand.ts having no negative assertion — were explicitly deferred by the reviewer under its round-7 convergence posture: "recorded, not requested in this round". They are non-Critical coverage suggestions; under this repository's after-round-5 policy (land only Critical fixes — correctness, security, data loss, regressions — and defer remaining Suggestions), the deferral is upheld. The reviewer already recorded all three in the PR thread, so nothing is silently dropped.
  • Inline comments: none.
  • Issue-level comments: none.
  • Failed checks: none.
  • Still-red checks: none.

Outcome

Nothing was requested and nothing was actionable this round: no reproducible Required defect claim, no CHANGES_REQUESTED item, no failed or persisting check. No Required or Optional item needed a reproduction probe because no finding claimed current behavior is wrong. No build/test commands were run because no code changed; the pushed commit is unchanged and its CI checks are green as listed above.

中文说明

Autofix 本轮:未执行任何操作(PR #9764

本轮未做任何代码修改,也未创建任何提交。分支保持在 1359f61411

本轮反馈的分类处理

  • 审查 [rv:5011260063](COMMENTED)——自动审查者报告"部分审查完成,缺口已披露",未请求任何修改:
    • 已披露的缺口:build-and-test — Integration Tests (CLI, No Sandbox) 在 CI 中被跳过,其测试套件也未在本地运行。这是关于审查自身覆盖范围的信息性披露,并非行动请求。实际运行的检查全部为绿色:共 46 项检查,每一项均为 SUCCESSSKIPPED,没有失败或持续飘红的检查。
    • 该审查提出的三条测试覆盖问题——acpAgent.ts 中 side-task 路由的 falsy 自定义标题回退分支没有测试、normalizeRequestedBranchName 对空白/非字符串输入的拒绝未被任何测试固定、useBranchCommand.ts 中 CLI hook 的 getSessionDisplayName 延迟读取守卫没有反向断言——已被审查者依据其第 7 轮收敛姿态明确延后:"已记录,本轮不要求修改"。这些均为非 Critical 的覆盖建议;按照本仓库第 5 轮之后的策略(只落地 Critical 修复——正确性、安全性、数据丢失、回归——其余 Suggestions 延后处理),该延后决定予以维持。审查者已将这三条全部记录在 PR 帖子中,因此不会被悄悄丢弃。
  • 行内评论: 无。
  • Issue 级评论: 无。
  • 失败的检查: 无。
  • 持续飘红的检查: 无。

结论

本轮没有任何请求,也没有任何可操作项:没有可复现的 Required 缺陷主张,没有 CHANGES_REQUESTED 条目,没有失败或持续未通过的检查。由于没有任何发现声称当前行为是错误的,因此无需对 Required 或 Optional 条目做复现探测。由于没有代码变更,未运行任何构建/测试命令;已推送的提交保持不变,其 CI 检查如上所列全部为绿色。

Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。


🧠 Handled by Qwen Code · model/模型 qwen3.8-max

@wenshao

wenshao commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Local real-environment verification — PR #9764

I built a two-arm local environment and ran the branch flows for real (no mocks, no page.route) to have something concrete for the merge decision.

Arms — PR head 1359f614 vs merge base 65c2bb01, each bundled with node esbuild.config.jsdist/cli.js.
git diff 65c2bb01..1359f614 -- packages/web-shell packages/webui packages/sdk-typescript packages/acp-bridge is empty, so the browser arm isolates the daemon/core change exactly.

What was actually driven

Layer Setup
Browser real Chromium → packages/web-shell vite dev server → QWEN_DAEMON_URL → real qwen serve daemon → real transcripts on disk
Daemon one qwen serve per arm, isolated QWEN_HOME, driven over the real HTTP API (POST /session, /prompt, /branch, /side-task, PATCH /metadata)
CLI real interactive TUI in tmux (130×40), real keystrokes: prompt → /rename/branch
Core real SessionService against real on-disk *.jsonl, including a copy of a real 265-session catalog (126 MB, 263 untitled)
Model scripted OpenAI-compatible server, identical bytes to both arms, so nothing model-dependent can differ

Verdict: the fix does what it says on Linux. Reviewer test plan 4/4 pass, and I found one quantified cost plus four non-blocking notes.


1. Web Shell — the reported bug reproduces on main and is gone on the PR

Web Shell before/after

Identical scripted sequence on both arms: one prompt 创建 MR 描述生成 Skill, then branch ×3 from the source, once from a branch, plus a custom-title session and a legacy (Branch 2) session.

Scenario main 65c2bb01 PR 1359f614
source picker name (prompt-backed) 创建 MR 描述生成 Skill 创建 MR 描述生成 Skill
branch #1 dddddddd (Branch) 创建 MR 描述生成 Skill(1)
branch #2 dddddddd (Branch 2) 创建 MR 描述生成 Skill(2)
branch #3 dddddddd (Branch 3) 创建 MR 描述生成 Skill(3)
branch of a branch dddddddd (Branch 4) 创建 MR 描述生成 Skill(4) — no nesting
side task, explicit Side task(2) Side task(2) Side task(2) — preserved
source with custom title My custom title (Branch) My custom title(1)
source with legacy Legacy title (Branch 2) Legacy title (Branch) Legacy title(1)

Test plan ①②③④ all pass, and the source session is unchanged in every case.

2. CLI /branch — the custom title is now honoured

TUI before/after

Same keystrokes on both arms. The on-screen announcement is identical, so the durable oracle is the custom_title record written into the fork's JSONL: on main the fork ignores the session's custom title and re-derives from the first prompt, on the PR it inherits the picker name.

3. Cross-client consistency (this is the part I most wanted to confirm)

Same untitled source, one 150-character first prompt, branched once from each client:

Client main 65c2bb01 PR 1359f614
CLI /branch …and add int (Branch) (cut at 100 chars) …timeout path in the gateway(1) (153 chars)
Web Shell / daemon 11119765 (Branch) (UUID fragment) …timeout path in the gateway(1) (153 chars)

On main the same session gets two different branch names depending on which client you use. On the PR both clients produce the byte-identical title. I also checked the new getSessionDisplayName against what the picker itself resolves — they agree on all three shapes (custom title, prompt-backed, and "first prompt beyond the 10-line head window" → both show no name), so the helper is not a second, drifting definition of the display name.

4. Adversarial probes (all against the real daemon)

Probe Result
Branch fired the instant the first turn goes idle, ×6 … first prompt(1) every time — no read-before-flush fallback observed
3 concurrent branches of the same source (1) (2) (3), zero duplicate names
Source title (Branch) / (Branch 2) falls back to session-id prefix → 97640200(1), as documented
Source title "" / " " (bot finding R4-1) ffff9764(1)fixed at head, no degenerate (1)
Source title 42(7) 42(1) — generated suffix stripped
440-char first prompt title 188 chars (≤ SESSION_TITLE_MAX_LENGTH), no lone surrogate
Dense family Dense(1..120) Dense(121); the now-unbounded for (let n = 1; ; n++) is bounded by the finite taken set

5. The cost the PR flags, measured

findSessionTitlesByPrefix now head-reads every untitled session file instead of skipping it after the cheap title tail-read.

Catalog main PR
computeUniqueBranchTitle over a copy of a real 265-session catalog (126 MB, 263 untitled), warm cache 33 ms median 207 ms median
synthetic untitled catalogs, 200 records each — 50 / 200 / 500 sessions 4 / 14 / 37 ms 21 / 86 / 192 ms
end-to-end POST /session/:id/branch on the real daemon, 287 transcripts on disk 64 ms median 261 ms median

So a branch in a heavy workspace costs roughly +200 ms once, inside the source session's history-mutation lock. I think that is acceptable for a one-shot user action, and worth writing down.

One thing worth knowing if anyone tries to optimise this later: over the same 265 files, readLines(f, 1) takes 121 ms and readLines(f, 10) takes 144 ms — the cost is the per-file stream open, not the 10-line budget, so narrowing the head window would only recover ~15%. Reusing the picker's existing listing would be the only real lever, and that is clearly out of scope here.

6. Non-blocking notes

  1. The PR description is stale. It still says the change "forwards the active Web Shell name with branch requests", but the final diff does not touch packages/web-shell — that commit-1 change was reverted in 8aac1c3 in favour of the daemon deriving the name itself. Worth fixing the description before merge so the commit message that lands is accurate.
  2. flush() asymmetry. CLI /branch does await outgoingRecording?.flush() before calling getSessionDisplayName; the ACP branch route reads it with no flush. I could not reproduce a stale read in 6 tight-race attempts, but a one-line await recording.flush() would make the two routes symmetric for free.
  3. Draft (1) vs Draft(1). normalizeDerivedBranchTitle's numeric alternative is /(\S)\(\d+\)$/, which requires a non-space character before (, while the legacy alternative tolerates leading whitespace. Measured: Draft(1)Draft(2) (same family), Draft (1)Draft (1)(1) (new nested family). That reads deliberate — only generated suffixes are stripped, and generated ones never have the space — but the design doc's "remove an existing generated fork suffix" line does not say so, and item ③ of the test plan describes Title(1)(1) as the thing to avoid.
  4. Archived transcripts are outside the scan (archive/ is skipped by SESSION_FILE_PATTERN), so unarchiving can surface a duplicate name. Same on main, just noting it is not fixed here.

7. Gates

  • packages/core sessionService.test.ts — 190/190 pass
  • packages/cli useBranchCommand.test.ts + acpAgent.test.ts — 513/513 pass
  • eslint --max-warnings 0 on all six changed files — clean
  • tsc --noEmit -p packages/core — clean
  • PR CI — 22 pass / 0 fail

Tested on

OS Status
🍏 macOS ✅ (author)
🪟 Windows ⚠️
🐧 Linux ✅ (this run)

LGTM from my side — happy to merge once the description in note 1 is corrected.

中文说明

PR #9764 本地真实环境验证

我在本地搭了双臂环境,把分支流程真跑了一遍(无 mock、无 page.route),给合并决策提供依据。

双臂 —— PR head 1359f614 对比 merge base 65c2bb01,各自用 node esbuild.config.js 打出 dist/cli.js
git diff 65c2bb01..1359f614 -- packages/web-shell packages/webui packages/sdk-typescript packages/acp-bridge 为空,所以浏览器臂能精确隔离出 daemon/core 的改动。

实际驱动的层次

环境
浏览器 真实 Chromium → packages/web-shell vite dev server → QWEN_DAEMON_URL → 真实 qwen serve daemon → 真实磁盘 transcript
Daemon 每臂一个 qwen serve,隔离 QWEN_HOME,走真实 HTTP API(POST /session/prompt/branch/side-taskPATCH /metadata
CLI tmux 里的真实交互 TUI(130×40),真实按键:prompt → /rename/branch
Core 真实 SessionService 读真实磁盘 *.jsonl,包含一份真实 265 会话目录的拷贝(126 MB,其中 263 个无自定义标题)
模型 脚本化的 OpenAI 兼容服务,两臂收到完全相同的字节,排除模型侧差异

结论:在 Linux 上该修复行为符合描述。Reviewer Test Plan 四项全过;发现一处已量化的开销,以及四条不阻塞合并的备注。


1. Web Shell —— 问题在 main 上复现,在 PR 上消失

Web Shell before/after

两臂执行完全相同的脚本序列:一条 prompt 创建 MR 描述生成 Skill,然后从源会话分支 3 次、从分支再分支 1 次,另加一个自定义标题会话和一个旧式 (Branch 2) 会话。

场景 main 65c2bb01 PR 1359f614
源会话 picker 名称(来自首条 prompt) 创建 MR 描述生成 Skill 创建 MR 描述生成 Skill
分支 #1 dddddddd (Branch) 创建 MR 描述生成 Skill(1)
分支 #2 dddddddd (Branch 2) 创建 MR 描述生成 Skill(2)
分支 #3 dddddddd (Branch 3) 创建 MR 描述生成 Skill(3)
分支再分支 dddddddd (Branch 4) 创建 MR 描述生成 Skill(4) —— 无嵌套
side task,显式 Side task(2) Side task(2) Side task(2) —— 原样保留
源会话带自定义标题 My custom title (Branch) My custom title(1)
源会话为旧式 Legacy title (Branch 2) Legacy title (Branch) Legacy title(1)

测试计划 ①②③④ 全部通过,且每种情况下源会话名称都保持不变。

2. CLI /branch —— 自定义标题现在被正确继承

TUI before/after

两臂按键完全相同。屏幕上的提示文案一模一样,所以可靠的判据是写入 fork JSONL 的 custom_title 记录:main 上 fork 忽略会话的自定义标题、退回用首条 prompt 重新推导;PR 上则继承 picker 名称。

3. 跨客户端一致性(这是我最想确认的一点)

同一个无自定义标题的源会话,首条 prompt 150 字符,分别从两个客户端各分支一次:

客户端 main 65c2bb01 PR 1359f614
CLI /branch …and add int (Branch)(截断到 100 字符) …timeout path in the gateway(1)(153 字符)
Web Shell / daemon 11119765 (Branch)(UUID 片段) …timeout path in the gateway(1)(153 字符)

main 上同一个会话,从不同客户端分支会得到两个不同的名字;PR 上两个客户端产出逐字节一致的标题。我另外把新增的 getSessionDisplayName 与 picker 自身解析出的名称做了对照,三种形态(自定义标题、prompt 派生、首条 prompt 落在 10 行头部窗口之外)结果完全一致,因此这个 helper 不会成为第二套会漂移的展示名定义。

4. 对抗性探测(全部针对真实 daemon)

探测 结果
首轮对话一进入 idle 就立刻分支,×6 每次都是 … first prompt(1),未观察到 flush 前读盘导致的回退
同一源会话 3 个并发分支 (1) (2) (3),无重名
源标题为 (Branch) / (Branch 2) 回退到 session-id 前缀 → 97640200(1),与文档一致
源标题为 "" / " "(bot 提的 R4-1) ffff9764(1) —— 当前 head 已修复,不会退化成 (1)
源标题为 42(7) 42(1) —— 生成式后缀被正确剥离
首条 prompt 440 字符 标题 188 字符(≤ SESSION_TITLE_MAX_LENGTH),无孤立代理项
密集族 Dense(1..120) Dense(121);改成无上限的 for (let n = 1; ; n++) 由有限的 taken 集合保证终止

5. PR 自己标注的风险,实测数据

findSessionTitlesByPrefix 现在会对每个无自定义标题的会话文件做头部读取,而不再在廉价的标题尾读之后直接跳过。

目录规模 main PR
真实 265 会话目录拷贝(126 MB,263 个无标题)上的 computeUniqueBranchTitle,缓存已热 中位 33 ms 中位 207 ms
合成无标题目录,每会话 200 条记录 —— 50 / 200 / 500 会话 4 / 14 / 37 ms 21 / 86 / 192 ms
真实 daemon 上端到端 POST /session/:id/branch,磁盘 287 份 transcript 中位 64 ms 中位 261 ms

也就是说,在会话很多的工作区里做一次分支大约多花 200 ms,且发生在源会话的 history-mutation 锁内。作为一次性用户操作我认为可以接受,但值得写下来。

如果之后有人想优化,有个数据可以参考:同样 265 个文件,readLines(f, 1) 耗时 121 ms,readLines(f, 10) 耗时 144 ms —— 开销主要在每文件的流打开,而不是 10 行的读取预算,所以缩小头部窗口最多只能省约 15%。真正的杠杆是复用 picker 已有的列表结果,那显然超出本 PR 范围。

6. 不阻塞合并的备注

  1. PR 描述已经过时。 描述里仍然写着"通过 Web Shell 的分支请求传递当前会话名称",但最终 diff 根本没有改 packages/web-shell —— commit 1 里的那处改动已在 8aac1c3 中回退,改为由 daemon 自己推导名称。建议合并前修正描述,避免落库的 commit message 与实现不符。
  2. flush() 不对称。 CLI /branch 在调用 getSessionDisplayName 之前会 await outgoingRecording?.flush(),而 ACP 分支路由没有 flush 就直接读盘。我用 6 次紧凑竞态尝试都没复现出读到陈旧数据,但加一行 await recording.flush() 就能让两条路径对称,成本为零。
  3. Draft (1)Draft(1) normalizeDerivedBranchTitle 的数字分支是 /(\S)\(\d+\)$/,要求 ( 前必须是非空白字符,而旧式分支则允许前导空白。实测:Draft(1)Draft(2)(同一族),Draft (1)Draft (1)(1)(新的嵌套族)。这看起来是有意为之 —— 只剥离生成式后缀,而生成式后缀不会带空格 —— 但设计文档里"remove an existing generated fork suffix"这句并没有说明这一点,而测试计划第 ③ 条又把 Title(1)(1) 描述成要避免的形态。
  4. 归档 transcript 不在扫描范围内archive/SESSION_FILE_PATTERN 过滤掉),所以取消归档后可能出现重名。这一点 main 上也一样,只是说明本 PR 没有顺带解决。

7. 门禁

  • packages/core sessionService.test.ts —— 190/190 通过
  • packages/cli useBranchCommand.test.ts + acpAgent.test.ts —— 513/513 通过
  • 六个改动文件的 eslint --max-warnings 0 —— 干净
  • tsc --noEmit -p packages/core —— 干净
  • PR CI —— 22 通过 / 0 失败

测试平台

OS Status
🍏 macOS ✅(作者)
🪟 Windows ⚠️
🐧 Linux ✅(本次)

从我这边看 LGTM —— 备注 1 的描述修正之后就可以合并。


Verified with Claude Code (Claude Opus 5, 1M context).

@wenshao

wenshao commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@wenshao
wenshao enabled auto-merge August 25, 2026 06:32
@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Sandboxed verification: ✅ passed — merge-ready (agent verdict) - workflow run

Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check.

Scripted assertions: 766 passed · 0 failed · 766 total

Flakiness gate: ✅ 3 changed test file(s) x 5 identical rounds, no divergence

中文 — 判定:✅ 通过 · 可合入(agent 判定)

沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查

脚本断言:766 通过 · 0 失败 · 766 总计

抖动门:✅ 3 changed test file(s) x 5 identical rounds, no divergence

Verification report

PR 9764 Deep Verification — fix(session): preserve source titles when branching

Verdict: merge-ready — 766 scripted assertions executed, 0 unexpected failures.
Verified head: 1359f614116b03e0f4c1031b37dc9f684f9d43d7 (merge commit 06e2dcab80, base tip 9416d6a6ad).

中文摘要
  • 结论merge-ready。766 条脚本化断言全部通过,0 条意外失败。
  • A/B 结论:中心主张成立。在无 mock 的 A/B 中(真实 SessionService + 真实 JSONL 夹具,head dist vs base dist):head 侧 16/16 通过 —— 无自定义标题的会话能通过首条 prompt 解析出展示名(getSessionDisplayName),分支标题按 Title(1)Title(2)… 分配,冲突扫描能看见基于 prompt 的展示名,normalizeDerivedBranchTitle 阻止了 Title(1)(1) 式嵌套;base 侧 6/6 通过 —— 该展示名原语根本不存在(入口只能退回 UUID 片段),旧 (Branch) 方案对数字族和 prompt 名称均不可见。
  • 测试钉扎:变异矩阵 5 个变异体全部被击杀、无幸存者(M1 恢复旧分配方案 → 18 条测试红;M2 去掉 prompt 扫描 → 1 红;M3 normalize 变恒等 → 17 红;M4 去掉 daemon 侧持久展示名回退 → 3 红;M5 去掉 CLI 侧展示名查询 → 1 红),失败消息均为期望值/实际值不符的行为断言。目标门槛:core 200/200、cli 513/513,两工作区 tsc --noEmit 通过。
  • findings:无阻塞项。两条信息级边界记录(见 Findings):手工改名的嵌套标题 T(1)(2) 只剥一层(唯一性不受影响,生成流程永不嵌套);正文所述 “Web Shell 转发当前会话名” 机制在最终代码中不存在(见 Corrections,无需改代码)。
  • 未覆盖:逐 commit 归因(浅克隆仅 3 个提交可达)、真实 daemon + Web Shell 的端到端交互、Playwright smoke、仓库级全量测试与 lint。

Central claim + A/B

Central claim: branching from a session whose picker name comes from its first prompt (no custom title) produces Title(1) derived from that picker name, instead of a UUID fragment; collisions allocate the lowest free numeric suffix.
Secondary claims: (S1) legacy (Branch)/(Branch N) custom titles normalize back into the numeric family (no Title(1)(1) nesting); (S2) explicit side-task names are preserved unchanged, and the new prompt-record scan stays bounded.

Harness: harness-core-ab.mjs — mock-free; drives the real compiled SessionService (head: packages/core/dist; base: tmp/base-tree worktree rebuilt at HEAD^1) against real JSONL fixtures in a throwaway QWEN_HOME. Each arm asserts its own expected behavior, so the base arm failing to do the new thing is encoded as a passing control assertion.

Cell Oracle head base (control)
S1 untitled session display name getSessionDisplayName(src) 'Fix login bug' method absent — UUID fallback was the only path ✅
S2 allocation, empty workspace computeUniqueBranchTitle Fix login bug(1) Fix login bug (Branch)
S3 …(1), …(2) taken allocation Fix login bug(3) Fix login bug (Branch) (family invisible) ✅
S4 untitled session whose prompt is Fix login bug(1) prefix scan + allocation scan returns it; allocates (2) scan returns []; legacy name ✅
S5 custom title Fix login bug(1) normalize + allocate base Fix login bug(2) no normalizer; nests Fix login bug(1) (Branch)
S6 fork loop end-to-end forked JSONL contents (1) then (2), source unchanged; fork-of-fork → (3) n/a (primitives absent)
S7 foreign-project Title(1) allocation does not occupy the family → (1) n/a
S8 scan window prompt at line 10 vs line 13 resolved / undefined n/a
Total 16/16 6/6

Witnesses: 01-ab-head-numeric-allocation.png, 02-ab-base-control.png.

Entry-point wiring (the other half of the central claim — which base name each route feeds to the allocator) is pinned by the PR's own route-level tests, which boot the real QwenAgent.extMethod / the real useBranchCommand hook: mutations M4/M5 below prove they are load-bearing. The composed chain (route resolves base name → real allocator + scan) is exactly what the tests and the harness cover from opposite ends.

The "matches the picker" sub-claim was verified structurally: the new readSessionDisplayNameFromFile returns customTitle || firstPrompt using the same private extractFirstPromptFromRecords, the same MAX_PROMPT_SCAN_LINES = 10 head window, and the same project-membership gate as the picker hydration paths (sessionService.ts lines 1907/3219 vs 3108–3130).

Mutation matrix (vacuity + positive controls)

Each guard the PR introduces was reverted in place (scratch edit, restored afterward; tree verified clean) and the affected suites re-run. All five mutants killed; zero survivors. Every kill was the intended behavioral assertion — representative failure for M1: AssertionError: expected '创建 MR 描述生成 Skill (Branch)' to be '创建 MR 描述生成 Skill(3)' (04-mutation-m1-kill.png).

Mutant Guard removed Suite Result
M1 computeUniqueBranchTitle → legacy (Branch) scheme core sessionService 1 red / 199
M1 same cli useBranchCommand 17 red / 20
M2 prefix scan drops prompt-backed names (custom titles only) core sessionService 1 red / 199
M3 normalizeDerivedBranchTitle → identity core sessionService 5 red / 195
M3 same cli useBranchCommand 5 red / 32
M3 same cli acpAgent routes 7 red / 469
M4 daemon route: no persisted display-name fallback cli acpAgent routes 3 red / 473
M5 CLI /branch: no display-name lookup cli useBranchCommand 1 red / 36

Positive controls: each mutant was killed inside the very suite/file its coverage claim rests on (M2 in the core suite that also passed 199 unchanged, M4 in the acpAgent command, M5 in the useBranchCommand command), so "killed" cannot be an artifact of a dead harness. No survivors, so no survivor classification was needed. The head gates (200 + 513 green) are the unmutated controls.

Corrections

  1. PR body mechanism description is stale relative to the final diff. The description says the change "forwards the active Web Shell name with branch requests". In the aggregate HEAD^1..HEAD diff the Web Shell sends no name: branchCurrentSession(undefined, atRecordId) (packages/web-shell/client/App.tsx:8294), the smoke spec asserts a name-less request body ({ atRecordId }), and the daemon resolves the display name server-side (recording.getCurrentCustomTitle()sessionService.getSessionDisplayName()). Per the commit messages the echo existed mid-series and was deliberately removed in round 2 ("Stop Web Shell name echoing"). The user-visible goal is still met; only the described mechanism is inaccurate. This is a correction to the description, not a request to change code.

Findings

No blocking findings. Two informational boundary notes:

  1. (info) One-level normalization of manually nested custom titles. normalizeDerivedBranchTitle strips only the outermost generated suffix: a user-manually-renamed title T(1)(2) normalizes to T(1), so a branch from it allocates inside the T(1) family (T(1)(1)). Probed in harness-siblings.mjs N2 — uniqueness is preserved and the generated flow never produces nesting (S6c: fork → T(1), fork-of-fork → T(3), never T(1)(1)), so this is reachable only through a manual rename to an already-nested form. Not a defect; recorded so the behavior is known. The spaced sibling Sprint (2) is likewise left intact — pinned intentionally by the PR's own tests.
  2. (info) Dense-family and scan-cost bounds measured, no cliff. The removed MAX_BRANCH_COLLISION_SCAN = 99 cap is safe: the allocation loop terminates by set finiteness — 500 taken slots allocated T(501) in 98 ms (N4). The new prompt-record scan cost the description accepts is real but small: a prefix scan over 300 untitled sessions completed in 44 ms with a 10-line head read per file (N5/N6). 03-sibling-sweep-boundaries.png.

Not covered

  • Per-commit attribution. Depth-2 checkout: git rev-list HEAD^1..HEAD^2 reports 1 commit at the shallow boundary vs 9 in the metadata snapshot, so individual commits (incl. the three fix rounds) were not exercised separately; the aggregate HEAD^1..HEAD diff is what was verified.
  • Live end-to-end through a real daemon + Web Shell UI (branch button → picker rename) and the interactive /branch TUI path. The evidence chain is route-level agent/hook tests (real handler code, mocked allocator or mocked scan at the seams) joined to the mock-free core harness (real allocator + real scan); no TUI/browser was booted.
  • Playwright web-shell smoke spec was not executed (browser infra out of scope); statically checked that its branch assertion ({ atRecordId } request body, no name) matches the final client code.
  • Repo-wide test suite and repo-wide lint/typecheck (only the two affected workspaces were gated); Windows/manual validation (author-marked untested); migration of pre-existing (Branch) titles (the PR explicitly ships none — legacy names normalize only when branched from).
  • The base control build compiled with type-resolution errors (ajv, ignore d.ts) caused by the nested worktree missing per-package node_modules at compile time; the emitted JS was verified behaviorally (the base arm cells) rather than by clean compile.

Methodology

CI verify container (node:22-bookworm), merge-ref checkout at depth 2. Base control: git worktree add tmp/base-tree HEAD^1, core rebuilt there with the repo's TypeScript (per-package node_modules symlinked from the head tree — realpath asserted as /__w/qwen-code/qwen-code/packages/core/node_modules; valid control because the PR changes no package.json/lockfile, and the harness imports the base dist/ by absolute path, so no workspace symlink can smuggle head code into the base cells). Mutation runs reverted key hunks in place via scratch edits, ran the affected vitest suites, and restored the files (git status clean afterward); backups in mutation-backup/. CLI vitest aliases @qwen-code/qwen-code-core to core source, so core mutations propagate to CLI suites without rebuilds. Gates: npx vitest run per changed test file; npm run typecheck per affected workspace (gate liveness witnessed by the same tsc invocation reporting real errors on the broken base compile). Raw logs: per-cell harness output in evidence/*.png, full-text reruns in logs/ (harness-head.txt, harness-base.txt, siblings.txt, gate-core.txt, gate-cli.txt, mutation-matrix.txt); harness sources harness-core-ab.mjs, harness-siblings.mjs in this directory. Assertion accounting: A/B harness 16 (head) + 6 (base control) + siblings 21 + head gates 200 + 513 + mutation kill-runs 8 (each expected-red counted as a pass) + typecheck 2 = 766 pass / 0 fail.

Flakiness gate log

rounds=5 files=3 skipped=0
file packages/cli/src/acp-integration/acpAgent.test.ts: (cd packages/cli) npx --no-install vitest run ./src/acp-integration/acpAgent.test.ts
file packages/cli/src/ui/hooks/useBranchCommand.test.ts: (cd packages/cli) npx --no-install vitest run ./src/ui/hooks/useBranchCommand.test.ts
file packages/core/src/services/sessionService.test.ts: (cd packages/core) npx --no-install vitest run ./src/services/sessionService.test.ts


per-file results (P=pass F=fail I=infra-exit, one letter per run):
  packages/cli/src/acp-integration/acpAgent.test.ts: PPPPP
  packages/cli/src/ui/hooks/useBranchCommand.test.ts: PPPPP
  packages/core/src/services/sessionService.test.ts: PPPPP

verdict: pass
summary: 3 changed test file(s) x 5 identical rounds, no divergence

--- per-invocation detail (full copy in the artifact) ---
round 1 · packages/cli/src/acp-integration/acpAgent.test.ts: P (exit 0)
round 1 · packages/cli/src/ui/hooks/useBranchCommand.test.ts: P (exit 0)
round 1 · packages/core/src/services/sessionService.test.ts: P (exit 0)
round 2 · packages/cli/src/acp-integration/acpAgent.test.ts: P (exit 0)
round 2 · packages/cli/src/ui/hooks/useBranchCommand.test.ts: P (exit 0)
round 2 · packages/core/src/services/sessionService.test.ts: P (exit 0)
round 3 · packages/cli/src/acp-integration/acpAgent.test.ts: P (exit 0)
round 3 · packages/cli/src/ui/hooks/useBranchCommand.test.ts: P (exit 0)
round 3 · packages/core/src/services/sessionService.test.ts: P (exit 0)
round 4 · packages/cli/src/acp-integration/acpAgent.test.ts: P (exit 0)
round 4 · packages/cli/src/ui/hooks/useBranchCommand.test.ts: P (exit 0)
round 4 · packages/core/src/services/sessionService.test.ts: P (exit 0)
round 5 · packages/cli/src/acp-integration/acpAgent.test.ts: P (exit 0)
round 5 · packages/cli/src/ui/hooks/useBranchCommand.test.ts: P (exit 0)
round 5 · packages/core/src/services/sessionService.test.ts: P (exit 0)

Evidence images

01-ab-head-numeric-allocation

02-ab-base-control

03-sibling-sweep-boundaries

04-mutation-m1-kill

Harness scripts and raw logs are in the workflow run artifacts (7-day retention).

Qwen Code · sandboxed verification

@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. ✅

@wenshao
wenshao added this pull request to the merge queue Aug 25, 2026
Merged via the queue into QwenLM:main with commit 26f70c7 Aug 25, 2026
82 checks passed
HiWhaleW pushed a commit to HiWhaleW/qwen-code that referenced this pull request Aug 25, 2026
…quisites (QwenLM#9998)

QwenLM#9764 taught /branch to read the outgoing recorder's custom title and the source session's display name before forking. The QwenLM#9844 regression suite's fake environment predates that and lacks both methods, so on main every /branch scenario throws before the fork and three tests fail. Complete the fakes; no production change.
ZijianZhang989 pushed a commit that referenced this pull request Aug 25, 2026
#9764 added getCurrentCustomTitle/getSessionDisplayName calls to
useBranchCommand after #9844's test fakes were written, so the suite
throws before forkSession on any branch carrying both. Fill the two
missing fake methods; main CI has not run since before either landed.
wenshao added a commit to wenshao/qwen-code that referenced this pull request Aug 25, 2026
Merge of main's persisted-session lifecycle (QwenLM#9626/QwenLM#9764) with the locked sidecar-move refactor: moveSessionPrSidecar gains the optional generation fence (threaded to writeSessionPrs assertCanCommit), conflict-repair paths merge split PR sidecars under the lock, and the fence tests assert the fence rides on the locked move.
aniruddhaadak80 pushed a commit to aniruddhaadak80/qwen-code that referenced this pull request Aug 26, 2026
* fix(core): sync loaded-skill state with history eviction

Refs QwenLM#6762

Split from PR QwenLM#8900 per maintainer direction review: this keeps only
the eviction-state sync half (loaded-skills tracking reconciled with
history evictions across microcompaction, /compress-fast, memory
pressure compaction, client retry, and ACP settle). The /unskill
command is deferred to a separate change pending the QwenLM#6762 design
discussion.

* fix(core): key skill-hook dedup on the whole prepared config

The dedup identity keyed only on type + command/url, but the
frontmatter admits multiple hooks per matcher distinguished solely by
fields that key ignores (timeout/shell for command hooks,
headers/timeout for HTTP hooks): the second of such a pair was
silently skipped even on the skill's first registration. Key on the
whole prepared config instead — frontmatter configs carry no
functions, so the structural key stays stable across reload cycles.

Adds regression tests for both shapes.

* fix(core): address round-2 review — setHistory reconcile, strip-occurrence gate

* fix(core): gate skill-body residency on SkillTool provenance

- Record exact body outputs in SkillTool as residency provenance; all
  residency checks fail closed against a fresh process (empty set)
  instead of trusting the two public markers (spoofable via the shared
  functionResponse{name:'skill'} shape)
- Make setHistory's reconcile the single loaded-skill sync: remove the
  five post-setHistory second writes (3x syncSkillEvictions, hard-rescue
  reconcile, tryCompress blanket clear) and delete the now-dead helpers
- Correct the settle-reconcile comment to name its five pre-try skip
  paths; resume stays intentionally un-enumerated (fail-closed reconcile
  plus one bounded duplicate body beats a marker-based resume door that
  would reopen the injection window)
- Tests: provenance gating (injection/genuine/spoof-stripped), stateful
  Set oracle for clear-before-track order, microcompact F2 witness and
  size-path kept-body twin, mixed-batch unload, isRetry settle arming,
  wire-after-setup hard-rescue, reconcile-only tryCompress, and the
  three second-write sites flipped to single-writer assertions

* fix(core): classify stripped skill responses by call id (R4-1)

The scheduler's persistence gate rewrites large genuine skill bodies
into <persisted-output> stubs before they enter history. The strip
path's provenance/shape check skipped such stubs, leaving the skill
tracked with no resident body — the QwenLM#6762 deadlock. unloadSkillsFromEntries
now classifies every stripped skill response by call-id resolution:
fail-open direction, since over-un-tracking self-heals with one
duplicate body while under-un-tracking deadlocks reload; the
resident-sibling filter keeps precision.

Also pins the R4 findings: provenance recording both halves through
the real SkillTool, microcompact provenance wiring at the client call
site, the ambiguous call-id policy on both strip and reconcile sides,
provenance-mode targeted unload, compressFast's setHistory reconcile,
and the forked-chat compression/restore observable effects. Rewrites
the evictedSkillNames docstrings to the diagnostic-only contract.

* test(cli): complete stale fakes in session-swap telemetry test

QwenLM#9764 added getCurrentCustomTitle/getSessionDisplayName calls to
useBranchCommand after QwenLM#9844's test fakes were written, so the suite
throws before forkSession on any branch carrying both. Fill the two
missing fake methods; main CI has not run since before either landed.

* refactor(core): reduce skill-eviction sync to conservative clear at rewrite boundaries

Per QwenLM#9500 review: drop the process-wide body provenance set and history
scans, the evictedSkillNames/unresolvedEvictedSkills diagnostics, the
exact-reconcile vs targeted-unload algorithms, and the ACP settle
machinery. Instead, conservatively clear loaded-skill tracking at the
three destructive history-rewrite boundaries (setHistory covers every
compaction path, truncateHistory, stripOrphanedUserEntriesFromHistory),
guarded so forked chats never touch the parent's tracker. Over-clearing
self-heals with at most one duplicate body on the next invoke; a stale
entry made the skill permanently unreloadable.

Kept: authoritative-vs-forked ownership guard, idempotent hook and
allowedTools re-registration on reload, and a small behavioral test set.
Exact lifecycle semantics, explicit unload, and richer eviction
diagnostics belong under QwenLM#6762 in follow-up PRs.

---------

Co-authored-by: 俊良 <zzj542558@alibaba-inc.com>
Co-authored-by: yiliang114 <yiliang.yyl@alibaba-inc.com>
Co-authored-by: 易良 <1204183885@qq.com>
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Released in v0.22.2.

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

Labels

autofix/takeover Summon the autofix loop to manage this PR (remove to release; needs triage+) 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.

Preserve source session names when branching

6 participants