Skip to content
8 changes: 8 additions & 0 deletions docs/design/web-shell/assistant-response-session-branching.md
Original file line number Diff line number Diff line change
Expand Up @@ -700,6 +700,14 @@ target `<newSessionId>.jsonl` must therefore be the last resource published.
Before creating target resources, compute and sanitize the final title. The
Core fork input includes that title, and Core appends its `custom_title` record
inside the staged transcript. There is no post-publication rename transaction.
Use the source session's picker display name (`customTitle || prompt`) as the
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 legacy `(Branch)` or `(Branch N)` token), no picker
name survives: the daemon route falls back to a session-id prefix while CLI
`/branch` falls back to the first prompt. The divergence is deliberate; both
clients allocate the numeric suffix from their chosen base.

### 14.2 Temporary resources

Expand Down
201 changes: 187 additions & 14 deletions packages/cli/src/acp-integration/acpAgent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -638,8 +638,12 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => ({
readonly rpcCode = -32023;
readonly errorKind = 'session_writer_unavailable';
},
computeUniqueBranchTitle: vi.fn(
async (baseName: string) => `${baseName} (Branch)`,
computeUniqueBranchTitle: vi.fn(async (baseName: string) => `${baseName}(1)`),
// The real helper: the route tests below must exercise the shipped
// normalization, not a copy that can silently drift from it.
normalizeDerivedBranchTitle: vi.fn(
(await importOriginal<typeof import('@qwen-code/qwen-code-core')>())
.normalizeDerivedBranchTitle,
),
Comment on lines +644 to 647

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)

Storage: {
getGlobalQwenDir: vi.fn(() => '/tmp/qwen-global-test'),
Expand Down Expand Up @@ -16259,6 +16263,7 @@ describe('QwenAgent extMethod renameSession routing', () => {
{
cwd: '/workspace-other',
sessionId: liveSessionId,
name: '创建 MR 描述生成 Skill',
},
);

Expand All @@ -16271,35 +16276,125 @@ describe('QwenAgent extMethod renameSession routing', () => {
expect(sessionService.forkSession).toHaveBeenCalledWith(
liveSessionId,
expect.any(String),
{ title: 'Source session (Branch)' },
{ title: '创建 MR 描述生成 Skill(1)' },
);
expect(sessionService.renameSession).not.toHaveBeenCalled();
expect(result).toMatchObject({
title: 'Source session (Branch)',
displayName: 'Source session (Branch)',
title: '创建 MR 描述生成 Skill(1)',
displayName: '创建 MR 描述生成 Skill(1)',
});

mockConnectionState.resolve();
await agentPromise;
});

it.each([
{
sourceTitle: 'Source session(2)',
name: undefined,
atRecordId: undefined,
persistedDisplayName: undefined,
expectedTitle: 'Source session(1)',
},
{
sourceTitle: 'Source session(2)',
name: 'Source session(2)',
atRecordId: 'checkpoint-1',
persistedDisplayName: undefined,
expectedTitle: 'Source session(2)(1)',
},
{
sourceTitle: 'Recorder title',
name: 'Picker title(2)',
atRecordId: undefined,
persistedDisplayName: undefined,
expectedTitle: 'Picker title(2)(1)',
},
{
sourceTitle: 'Recorder title',
name: 'Roadmap (2026)',
atRecordId: undefined,
persistedDisplayName: undefined,
expectedTitle: 'Roadmap (2026)(1)',
},
{
sourceTitle: 'Source session (Branch)',
name: undefined,
atRecordId: undefined,
persistedDisplayName: undefined,
expectedTitle: 'Source session(1)',
},
{
sourceTitle: 'Source session (Branch 2)',
expectedTitle: 'Source session (Branch)',
name: undefined,
atRecordId: undefined,
persistedDisplayName: undefined,
expectedTitle: 'Source session(1)',
},
{
sourceTitle: '(Branch)',
name: undefined,
atRecordId: undefined,
persistedDisplayName: undefined,
expectedTitle: '550e8400(1)',
},
{
sourceTitle: '(Branch 2)',
name: undefined,
atRecordId: undefined,
persistedDisplayName: undefined,
expectedTitle: '550e8400(1)',
},
{
sourceTitle: undefined,
name: undefined,
atRecordId: undefined,
persistedDisplayName: 'Prompt session',
expectedTitle: 'Prompt session(1)',
},
{
sourceTitle: undefined,
name: 'Sprint (2)',
atRecordId: 'checkpoint-2',
persistedDisplayName: 'Sprint (2)',
expectedTitle: 'Sprint (2)(1)',
},
{
sourceTitle: undefined,
name: undefined,
atRecordId: undefined,
persistedDisplayName: undefined,
expectedTitle: '550e8400(1)',
},
{
sourceTitle: '',
name: undefined,
atRecordId: undefined,
persistedDisplayName: undefined,
expectedTitle: '550e8400(1)',
},
{
sourceTitle: undefined,
expectedTitle: '550e8400 (Branch)',
name: undefined,
atRecordId: undefined,
persistedDisplayName: ' ',
expectedTitle: '550e8400(1)',
},
])(
'derives the branch title from $sourceTitle',
async ({ sourceTitle, expectedTitle }) => {
async ({
sourceTitle,
name,
atRecordId,
persistedDisplayName,
expectedTitle,
}) => {
const recording = makeRecordingService();
recording.getCurrentCustomTitle.mockReturnValue(sourceTitle);
const sessionService = {
forkSession: vi.fn().mockResolvedValue(undefined),
findSessionTitlesByPrefix: vi.fn().mockResolvedValue([]),
getSessionDisplayName: vi.fn().mockResolvedValue(persistedDisplayName),
renameSession: vi.fn().mockResolvedValue(true),
removeSession: vi.fn().mockResolvedValue(undefined),
};
Expand All @@ -16315,18 +16410,26 @@ describe('QwenAgent extMethod renameSession routing', () => {
{
cwd: '/tmp',
sessionId: liveSessionId,
...(name !== undefined ? { name } : {}),
...(atRecordId !== undefined ? { atRecordId } : {}),
},
);

expect(sessionService.forkSession).toHaveBeenCalledWith(
liveSessionId,
expect.any(String),
{ title: expectedTitle },
{
title: expectedTitle,
...(atRecordId !== undefined ? { atRecordId } : {}),
},
);
expect(result).toMatchObject({
title: expectedTitle,
displayName: expectedTitle,
});
expect(sessionService.getSessionDisplayName).toHaveBeenCalledTimes(
sourceTitle === undefined && name === undefined ? 1 : 0,
);

mockConnectionState.resolve();
await agentPromise;
Expand All @@ -16352,7 +16455,7 @@ describe('QwenAgent extMethod renameSession routing', () => {
{
cwd: '/tmp',
sessionId: liveSessionId,
name: 'Side task',
name: 'Side task(2)',
},
);

Expand All @@ -16364,15 +16467,85 @@ describe('QwenAgent extMethod renameSession routing', () => {
sourceType: 'side_task',
sourceId: liveSessionId,
},
title: 'Side task',
title: 'Side task(2)',
},
);
expect(recording.runWithWriteBarrier).toHaveBeenCalledOnce();
expect(sessionService.renameSession).not.toHaveBeenCalled();
expect(sessionService.removeSession).not.toHaveBeenCalled();
expect(result).toMatchObject({
title: 'Side task',
displayName: 'Side task',
title: 'Side task(2)',
displayName: 'Side task(2)',
});

mockConnectionState.resolve();
await agentPromise;
});

it('falls back to the session id for an empty normalized side-task title', async () => {
const recording = makeRecordingService();
recording.getCurrentCustomTitle.mockReturnValue('(Branch)');
const sessionService = {
forkSession: vi.fn().mockResolvedValue(undefined),
};
const innerConfig = makeLiveSessionInnerConfig(recording);
innerConfig.getSessionService.mockReturnValue(
sessionService as unknown as SessionService,
);
const { agent, agentPromise } = await bootAgent(innerConfig);

await agent.newSession({ cwd: '/tmp', mcpServers: [] });
const result = await agent.extMethod(
SERVE_CONTROL_EXT_METHODS.sessionSideTask,
{
cwd: '/tmp',
sessionId: liveSessionId,
},
);

expect(sessionService.forkSession).toHaveBeenCalledWith(
liveSessionId,
expect.any(String),
expect.objectContaining({ title: '550e8400' }),
);
expect(result).toMatchObject({
title: '550e8400',
displayName: '550e8400',
});

mockConnectionState.resolve();
await agentPromise;
});

it('normalizes the source custom title for a nameless side task', async () => {
const recording = makeRecordingService();
recording.getCurrentCustomTitle.mockReturnValue('My Project(2)');
const sessionService = {
forkSession: vi.fn().mockResolvedValue(undefined),
};
const innerConfig = makeLiveSessionInnerConfig(recording);
innerConfig.getSessionService.mockReturnValue(
sessionService as unknown as SessionService,
);
const { agent, agentPromise } = await bootAgent(innerConfig);

await agent.newSession({ cwd: '/tmp', mcpServers: [] });
const result = await agent.extMethod(
SERVE_CONTROL_EXT_METHODS.sessionSideTask,
{
cwd: '/tmp',
sessionId: liveSessionId,
},
);

expect(sessionService.forkSession).toHaveBeenCalledWith(
liveSessionId,
expect.any(String),
expect.objectContaining({ title: 'My Project' }),
);
expect(result).toMatchObject({
title: 'My Project',
displayName: 'My Project',
});

mockConnectionState.resolve();
Expand Down Expand Up @@ -16437,7 +16610,7 @@ describe('QwenAgent extMethod renameSession routing', () => {
expect(sessionService.forkSession).toHaveBeenCalledWith(
liveSessionId,
expect.any(String),
{ title: 'Source session (Branch)', atRecordId: checkpoint },
{ title: 'Source session(1)', atRecordId: checkpoint },
);
expect(liveBeginHistoryMutation).toHaveBeenCalledOnce();
expect(liveReleaseHistoryMutation).toHaveBeenCalledOnce();
Expand Down
58 changes: 39 additions & 19 deletions packages/cli/src/acp-integration/acpAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ import {
subagentGenerator,
redactUrlCredentials,
computeUniqueBranchTitle,
normalizeDerivedBranchTitle,
BranchPointInvalidError,
parseGoalSnapshotV2,
parseGoalStateCause,
Expand Down Expand Up @@ -1141,19 +1142,10 @@ function getLoadReplayPageSize(params: LoadSessionRequest): number | undefined {
return value as number;
}

function deriveForkBaseName(
name: unknown,
recording: { getCurrentCustomTitle(): string | undefined } | undefined,
sessionId: string,
): string {
if (typeof name === 'string' && name.trim().length > 0) {
return name.trim();
}
const existingTitle = recording?.getCurrentCustomTitle();
const stripped = existingTitle
?.replace(/\s*\(Branch(?:\s+\d+)?\)\s*$/, '')
.trim();
return stripped && stripped.length > 0 ? stripped : sessionId.slice(0, 8);
function normalizeRequestedBranchName(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined;
const normalized = value.trim();
return normalized || undefined;
}
function createHiddenWorkspaceMemoryConfig(config: Config): Config {
return new Proxy(config, {
Expand Down Expand Up @@ -11078,11 +11070,31 @@ class QwenAgent implements Agent {
const recording = sourceConfig.getChatRecordingService();
const sessionService = sourceConfig.getSessionService();

const baseName = deriveForkBaseName(
name,
recording,
sessionId,
);
const requestedName = normalizeRequestedBranchName(name);
const sourceCustomTitle =
requestedName === undefined
? recording?.getCurrentCustomTitle()
: undefined;
const persistedDisplayName =
requestedName === undefined &&
sourceCustomTitle === undefined
? await sessionService.getSessionDisplayName(sessionId)
: undefined;
const sourceDisplayName =
sourceCustomTitle ?? persistedDisplayName;
const derivedBaseName = sourceCustomTitle
? normalizeDerivedBranchTitle(sourceCustomTitle)
: sourceDisplayName;
Comment on lines +11083 to +11087

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] 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; 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 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 (its 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 and CLI /branch still handle '' via truthy checks. The same hole admits a whitespace-only display name from getSessionDisplayName, which trims to empty inside the allocator; useBranchCommand.ts has the identical exposure for a whitespace-only sourceDisplayName.

Witness (scratch-tree probe against the built helpers at this commit):

empty custom title        -> forkSession { title: "(1)" }   (expected "550e8400(1)")
whitespace-only display   -> forkSession { title: " (1)" }
with the fix below        -> both probes pass; renameSession routing suite stays green

Normalize the derived base the way the requested name is already normalized (and apply the same treatment to sourceDisplayName in useBranchCommand.ts):

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

当源会话当前的自定义标题恰好为空字符串时,这条派生链会把它一路泄漏到 computeUniqueBranchTitle,产生退化分支标题 (1)'' 不是 undefined,因此 persistedDisplayName 不会被获取;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 路由与 CLI /branch 至今仍通过真值判断正确处理 ''。同一缺口也接受来自 getSessionDisplayName 的纯空白展示名——它在分配器内被 trim 为空;useBranchCommand.ts 对纯空白的 sourceDisplayName 存在同样的暴露。

见证(在本提交的临时树中对已构建助手执行的探针):空自定义标题 → forkSession { title: "(1)" }(期望 "550e8400(1)");纯空白展示名 → forkSession { title: " (1)" };应用下方修复后两个探针均通过,renameSession routing 套件保持全绿。

建议按请求名称已有的归一化方式同样处理派生基础名(并对 useBranchCommand.ts 中的 sourceDisplayName 做同样处理)。

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

// A base that is empty, whitespace-only, or exactly a
// legacy `(Branch)`/`(Branch N)` token falls back to the
// session-id prefix here, while CLI /branch falls back to
// the first prompt. Deliberate: no picker name survives to
// anchor the family to, and one shared fallback would need
// a prompt-only display-name read on this route.
const baseName =
requestedName ??
(derivedBaseName?.trim() || undefined) ??
sessionId.slice(0, 8);

const title = await computeUniqueBranchTitle(
baseName,
Expand Down Expand Up @@ -11120,7 +11132,15 @@ class QwenAgent implements Agent {
const recording = sourceConfig.getChatRecordingService();
if (recording) await recording.flush();
const sessionService = sourceConfig.getSessionService();
const title = deriveForkBaseName(name, recording, sessionId);
const requestedName = normalizeRequestedBranchName(name);
let title = requestedName;
if (title === undefined) {
const sourceCustomTitle = recording?.getCurrentCustomTitle();
title = sourceCustomTitle
? (normalizeDerivedBranchTitle(sourceCustomTitle) ??
sessionId.slice(0, 8))
Comment on lines +11139 to +11141

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)

: sessionId.slice(0, 8);
Comment on lines +11138 to +11142

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 +11137 to +11143

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.

const newSessionId = randomUUID();
const fork = () =>
sessionService.forkSession(sessionId, newSessionId, {
Expand Down
Loading
Loading