feat(session): add /branch to fork the current conversation#3539
Conversation
Introduces `/branch` (alias `/fork`), mirroring Claude Code's fork-session
command. Writes a new JSONL under a fresh sessionId with every record
stamped `forkedFrom: { sessionId, messageUuid }`, rebuilds `parentUuid`
in write order so the fork is a clean linear descendant, and swaps the
CLI into the new session with a Claude-style two-line announcement plus
a `/resume <oldSessionId>` hint.
Core:
- `SessionService.forkSession(src, new)` performs the copy. Uses
`fs.openSync(path, 'wx', 0o600)` for exclusive create — atomic
existence + open in one syscall, no TOCTOU window. Rejects invalid
sessionId patterns, missing/empty sources, cross-project sources, and
pre-existing targets.
- `ChatRecord.forkedFrom` optional field records per-message lineage.
- `SessionStartSource.Branch` lets hook consumers distinguish fork from
resume.
CLI:
- `branchCommand` guards on `isIdleRef` so mid-stream forks can't tear
the parent chain, and on `sessionExists` so empty sessions can't be
forked.
- `useBranchCommand` orchestrates finalize → fork → load → core swap →
init → UI swap, in that order: anything that can still fail runs
while the UI is still on the parent, so a throw leaves the user safely
on the parent session instead of stranded with a cleared history.
- Branch title is `<name> (Branch)` with `(Branch N)` collision bump
(cap 99, then timestamp fallback). When no name is given it's derived
from the first real user `ChatRecord` (skipping cron/notification
subtypes), falling back to `Branched conversation`.
- `/branch` is added to `SLASH_COMMANDS_SKIP_RECORDING` so the command
itself doesn't bleed into the fork's tail.
Tests cover: command guards; hook ordering; title collision bump;
synthetic-record skip; empty-transcript fallback; core-throws-after-fork
UI-preservation invariant; forkSession disk I/O including invalid ids,
cross-project rejection, already-exists rejection.
🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
|
| Check | Result |
|---|---|
| Two info lines emitted in Claude-style format | ✅ |
| Quoted name "my-experiment" in first line | ✅ |
| Resume hint contains the old sessionId | ✅ |
| Prompt-bar title decoration shows my-experiment (Branch) | ✅ |
Unit test totals: 90/90 passing (branchCommand 6, useBranchCommand 11, slashCommandProcessor 41, sessionService 32).
Verdict
Pass. Every invariant the implementation claims is observable on disk after a real TUI run:
Source transcript is untouched.
Fork file is created atomically with
0o600, under a freshrandomUUIDsessionId.Every copied record carries a correct
forkedFromstamp and a rewrittensessionId.parentUuidchain is rebuilt in write order, giving the fork a clean linear lineage.customTitleis recorded with the collision-bumped(Branch N)suffix and threaded into the chain.UI state (announcement lines, title bar) matches the Claude Code reference behavior.
Teardown
All four seeded/forked JSONLs removed; tmux session killed. No residual state on disk.
The `commandType: 'local'` field was added referencing the Phase 1 slash-command redesign draft, but the field never made it onto `SlashCommand` — Phase 1 landed with `supportedModes` / `userInvocable` instead. After merging main, strict tsc rejects the unknown property with TS2353 and the CLI package fails to build. 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
| // session, so a throw leaves the user safely on the parent | ||
| // instead of stranded with a cleared history and a half-live | ||
| // client. | ||
| config.startNewSession(newSessionId, resumed); |
There was a problem hiding this comment.
[Critical] config.startNewSession(newSessionId, resumed) switches the active core session and recording service before getGeminiClient().initialize() has completed. If initialization throws, the catch block only appends an error item; the UI/history have not switched yet, but core state is already on the branch. That can make subsequent user messages record into the fork while the UI still appears to be on the original conversation.
Consider either moving the irreversible core session switch after fallible initialization, or adding rollback/restoration for the previous session when initialization fails. A regression test where getGeminiClient().initialize() rejects should assert the active config session/recorder returns to the original session.
— gpt-5.5 via Qwen Code /review
…rows `useBranchCommand` swapped core onto the fork via `config.startNewSession` before `getGeminiClient().initialize()` resolved. If init rejected, the catch only surfaced an error item: UI was still on the parent, but `sessionId` + `ChatRecordingService` were already pointing at the orphan fork JSONL, so the next user message would silently record into the fork while appearing to belong to the parent conversation. Snapshot the parent session's `ResumedSessionData` up front, gate the rollback on a `coreSwapped` flag, and in the catch run `startNewSession(oldSessionId, prevSessionData)` + re-`initialize()` so sessionId, recorder (with the correct parentUuid chain tail), and chat history all return to the parent. Rollback re-init is best-effort — if it throws again we log and still surface the original failure, since sessionId + recorder are the load-bearing invariant. Regression tests: (1) initialize rejects after swap → two `startNewSessionConfig` calls (fork then rollback-with-parent-data), two `initialize` calls, no UI swap, original error surfaced; (2) rollback's own init also rejects → sessionId still lands on parent, debug logger warns, original error still surfaced. Reported by gpt-5.5 via Qwen Code `/review` on QwenLM#3539. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
|
Addressed the Critical rollback finding in e526c5d. Root cause — Fix
Regression tests (both requested by the review) in
Verification — |
| } | ||
| return { type: 'handled' }; | ||
| case 'branch': | ||
| actions.handleBranch(result.name); |
There was a problem hiding this comment.
[Critical] handleBranch is async, but this case starts it without awaiting the returned promise. The processor can return handled and clear its processing guard while /branch is still finalizing, copying/loading the session, and swapping core/UI state. A fast follow-up prompt or command can then interleave with the branch operation and be recorded against the wrong session or disappear from the visible history after the branch UI loads.
| actions.handleBranch(result.name); | |
| await actions.handleBranch(result.name); |
Please also update the action type to return Promise<void> so this cannot silently regress.
— gpt-5.5 via Qwen Code /review
There was a problem hiding this comment.
Addressed in 8ac4af2.
await actions.handleBranch(result.name) — your suggestion verbatim. Also tightened the action type from (name?: string) => void to (name?: string) => Promise<void> in both SlashCommandProcessorActions and UIActionsContext so this cannot silently regress.
| const oldSessionId = config.getSessionId(); | ||
| const newSessionId = randomUUID(); | ||
| const sessionService = config.getSessionService(); | ||
|
|
There was a problem hiding this comment.
[Critical] The rollback snapshot is loaded before the parent recorder is finalized. If finalize() appends or updates the parent tail, a later rollback uses stale prevSessionData, so startNewSession(oldSessionId, prevSessionData) can restore the recorder with an outdated lastCompletedUuid and subsequent parent records may attach to the wrong UUID.
Move the snapshot load after the finalize step, or reload the parent session data after finalize before it can be used for rollback.
— gpt-5.5 via Qwen Code /review
There was a problem hiding this comment.
Addressed in 8ac4af2.
You were right — ChatRecordingService.finalize() (packages/core/src/services/chatRecordingService.ts:567) appends a trailing system/custom_title record via appendRecord, which advances lastRecordUuid. A snapshot taken before that captures a stale tail; on rollback the restored recorder would chain its next record's parentUuid to a record that's no longer the JSONL tail, orphaning the custom_title from the parent chain.
Reordered: finalize() → snapshot loadSession(parent) → fork. The existing order test was updated to match (finalize, load, fork, load, config.start) with a comment explaining why this ordering is load-bearing.
| }, | ||
| Date.now(), | ||
| ); | ||
| } catch (err) { |
There was a problem hiding this comment.
[Critical] coreSwapped remains true after the UI swap begins, so any later failure enters this rollback path and switches core/recorder back to the parent while the UI may already show the branch. Failures in history loading, title/session-name updates, hook firing, remounting, or announcement rendering would recreate the split-brain state in the opposite direction: UI on branch, core recording to parent.
Track a separate uiSwapped flag and only rollback core for coreSwapped && !uiSwapped, or perform a coordinated rollback of both core and UI/history/session name after the UI swap has started.
— gpt-5.5 via Qwen Code /review
There was a problem hiding this comment.
Addressed in 8ac4af2.
Tracking a separate uiSwapped flag — set immediately after historyManager.loadHistory(uiHistoryItems) commits the UI to the branch. The catch-block guard is now if (coreSwapped && !uiSwapped) so post-UI-swap failures (recordCustomTitle, hook fire, remount, announcement addItem) skip the core rollback and just surface as an error item. The disk fork already happened and the UI is on the branch — reverting core at that point would create the inverse split-brain you described.
Pinned the invariant with a new regression test: 'does not roll core back to parent when a post-UI-swap step throws' — makes remount() throw and asserts config.startNewSession was called exactly once (to the branch, not back to the parent).
Three related correctness issues in the /branch core+UI swap, all reported by gpt-5.5 via Qwen Code /review on PR QwenLM#3539: 1. Snapshot-before-finalize. ChatRecordingService.finalize() appends a trailing `system/custom_title` record that advances `lastRecordUuid`. Loading the parent ResumedSessionData snapshot before that ran captured a stale `lastCompletedUuid`; on rollback the restored recorder would chain its next record's parentUuid to a record that's no longer the JSONL tail, orphaning the custom_title from the parent chain. Move the snapshot to AFTER finalize(). 2. Reverse split-brain after UI swap. The catch block was gated solely on `coreSwapped`, so any failure AFTER the UI commits to the branch (recordCustomTitle, hook fire, remount, announcement render) would roll core back to the parent — leaving UI on the branch while the recorder writes new prompts into the parent JSONL. Track `uiSwapped` separately and skip the rollback once UI is committed; surface the failure as an error item without unwinding the swap. Pinned by a new regression test. 3. Slash dispatcher dropped the handleBranch promise. The `branch` case in slashCommandProcessor returned `{type: 'handled'}` while handleBranch was still in flight, so a fast follow-up prompt could interleave with the swap and be recorded against the wrong session. Await it and tighten the action type from `=> void` to `=> Promise<void>` (both in SlashCommandProcessorActions and UIActionsContext) so this cannot silently regress. Tests: vitest packages/cli/src/ui/hooks/useBranchCommand.test.ts 15 ✓ vitest packages/cli/src/ui/hooks/slashCommandProcessor.test.ts 41 ✓ vitest packages/cli/src/ui/commands/branchCommand.test.ts 6 ✓ vitest packages/core/src/services/sessionService.test.ts 32 ✓ tsc --noEmit clean eslint clean Co-Authored-By: Qwen-Coder <noreply@alibabacloud.com>
| if ((await sessionService.findSessionsByTitle(first)).length === 0) { | ||
| return first; | ||
| } | ||
| for (let n = 2; n <= MAX_BRANCH_COLLISION_SCAN; n++) { |
There was a problem hiding this comment.
[Suggestion] computeUniqueBranchTitle() checks each collision candidate by calling SessionService.findSessionsByTitle(), and that helper scans the project session files each time. In projects with many chats and repeated branch names, /branch can end up doing this scan up to 99 times on the interactive command path, which can make the command visibly stall.
Consider adding a batched/prefix title lookup that scans matching titles once, then computes the first free (Branch N) suffix in memory.
— gpt-5.5 via Qwen Code /review
`computeUniqueBranchTitle` was probing each `(Branch N)` candidate via
`SessionService.findSessionsByTitle`, and that helper rescans the
project's chats directory on every call. In dense title spaces /branch
could end up doing the scan up to 99 times in a row before settling on
a free suffix, which was visibly stalling the command.
Add `SessionService.findSessionTitlesByPrefix(prefix)` — one project-
wide scan that uses the cheap tail-read to extract each session's
custom_title, filters to titles starting with the prefix, and applies
the same project-scope filter as `findSessionsByTitle`. Heavy hydration
steps (message count, prompt extraction) are skipped because collision
lookup only needs the title.
`computeUniqueBranchTitle` now does ONE call with prefix
`${trimmed} (Branch`, builds an in-memory Set of taken titles, and
picks the first free `(Branch)` / `(Branch N)` slot. Worst-case disk
work drops from O(N) scans to one.
Tests: new `findSessionTitlesByPrefix` describe in sessionService.test
covers prefix match (case-insensitive), missing chats dir, project
isolation, and files without a custom_title. useBranchCommand.test
gains a perf invariant — even when 4 slots are taken, only ONE
prefix-scan is issued.
Reported by gpt-5.5 via Qwen Code \`/review\` on QwenLM#3539.
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Code ReviewOverviewAdds a Files of substance: What's done well
Concerns1. Diff is polluted with unrelated prettier-style reflows~13 files with cosmetic-only changes (table column widths in 2.
|
wenshao
left a comment
There was a problem hiding this comment.
[Critical] slashCommandProcessor.test.ts:127 — createMockActions() 缺少 handleBranch 属性(TS2741)。SlashCommandProcessorActions 接口现在要求此属性(本 PR 在 types.ts 中添加),但 mock 工厂未包含。添加 handleBranch: vi.fn() 到返回对象中。
[Critical] slashCommandProcessor.test.ts:562 — mockClient.stripThoughtsFromHistory 不存在于 GeminiClient 类型上(TS2339)。此属性可能已在主分支上重命名或移除;需要更新测试以使用实际可用的 API。
| const result = setupProcessorHook([branchCmd]); | ||
| await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); | ||
|
|
||
| const recorder = mockConfig.getChatRecordingService() as { |
There was a problem hiding this comment.
[Critical] ChatRecordingService | undefined 强制转换为 { recordSlashCommand: Mock<Procedure>; } — 类型没有充分重叠(TS2352)。需要先通过 unknown 进行中间转换。
| const recorder = mockConfig.getChatRecordingService() as { | |
| const recorder = mockConfig.getChatRecordingService() as unknown as { | |
| recordSlashCommand: ReturnType<typeof vi.fn>; | |
| }; |
— deepseek-v4-pro via Qwen Code /review
| const result = setupProcessorHook([testCmd]); | ||
| await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); | ||
|
|
||
| const recorder = mockConfig.getChatRecordingService() as { |
There was a problem hiding this comment.
[Critical] 第二个 ChatRecordingService | undefined 强制转换存在相同问题(TS2352),与上方 1207 行模式相同。
| const recorder = mockConfig.getChatRecordingService() as { | |
| const recorder = mockConfig.getChatRecordingService() as unknown as { | |
| recordSlashCommand: ReturnType<typeof vi.fn>; | |
| }; |
— deepseek-v4-pro via Qwen Code /review
|
Re the Tracking as follow-up: #3961 (assigned to me). |
…ssor tests Addresses today's review feedback on QwenLM#3539 plus two tsc gaps the IDE flagged in the same file. 1. ChatRecordingService cast (TS2352) — route through `unknown` at the two `recorder = mockConfig.getChatRecordingService() as { recordSlashCommand }` sites in SLASH_COMMANDS_SKIP_RECORDING. Insufficient overlap between `ChatRecordingService | undefined` and the inline mock shape; the existing single-step cast doesn't compile under strict. 2. SlashCommandProcessorActions mock missing `handleBranch` — this PR added `handleBranch: (name?: string) => Promise<void>` to the actions surface (commit 8ac4af2), but `createMockActions()` was never updated, so the mock failed to satisfy the type. Added `handleBranch: vi.fn().mockResolvedValue(undefined)`. 3. `stripThoughtsFromHistory` cleanup in load_history tests — `GeminiClient` has no `stripThoughtsFromHistory` method (the helper lives inside `sessionService.ts` and is never called from the slash processor), so the mocked field was a zombie and the assertion `expect(mockClient.stripThoughtsFromHistory).not.toHaveBeenCalled()` was vacuously true — it could never fail and provided zero regression guard. Replaced with `expect(mockClient.setHistory).toHaveBeenCalledWith(historyWithThoughts)`, which is what "preserve thoughts" actually means: the `thoughtSignature` inside `clientHistory` reaches `setHistory` untouched. This will fail the day someone reintroduces strip-on-load. Tests: vitest packages/cli/src/ui/hooks/slashCommandProcessor.test.ts 42 ✓ tsc -p packages/cli/tsconfig.json --noEmit clean Co-Authored-By: Qwen-Coder <noreply@alibabacloud.com>
| throw new Error( | ||
| `Source session does not belong to current project: ${sourceSessionId}`, | ||
| ); | ||
| } |
There was a problem hiding this comment.
[Critical] forkSession() copies every JSONL record and then rewrites a fresh linear parentUuid chain in file write order. That does not preserve the active conversation chain semantics used by loadSession(), which reconstructs history by walking from the current leaf. If the parent session was rewound, old tail records remain in the JSONL on a dead branch; this code will resurrect them into the fork.
That means branching a rewound conversation can include turns the user intentionally discarded, and those stale turns can be sent back to the model in the new branch. Please reconstruct/filter the source records to the active chain first, then rewrite only that active transcript into the new session.
— gpt-5.5 via Qwen Code /review
| try { | ||
| config.getChatRecordingService()?.finalize(); | ||
| } catch { | ||
| // best-effort |
There was a problem hiding this comment.
[Critical] finalize() only enqueues the outgoing recorder's final write; it does not wait for the write queue to drain. This code immediately reads the parent JSONL via loadSession() and forkSession(), so the rollback snapshot and fork can be built from a file that is still missing the finalized tail record.
Please capture the recorder once, call finalize(), and then await recorder.flush() before any parent snapshot or fork copy reads the JSONL.
| // best-effort | |
| const recorder = config.getChatRecordingService(); | |
| recorder?.finalize(); | |
| await recorder?.flush(); |
— gpt-5.5 via Qwen Code /review
| const { config, historyManager, startNewSession, setSessionName, remount } = | ||
| options; | ||
|
|
||
| const handleBranch = useCallback( |
There was a problem hiding this comment.
[Critical] /branch switches the core/UI session without the background-work guard and reset used by other session-switching paths such as /resume and /clear. isIdleRef only covers the foreground turn; background agents, monitors, or shells can still be running and later deliver output after the branch swap.
That can record parent-session background output into the fork or let old-session work affect the new branch. Please refuse branching while blocking background work exists and reset the background registries before config.startNewSession(...), mirroring the existing resume/clear session-switch behavior.
— gpt-5.5 via Qwen Code /review
| // mirrors Claude's "use the first parent message" behavior. | ||
| const baseName = | ||
| name ?? deriveFirstPrompt(resumed.conversation.messages); | ||
| const effectiveTitle = await computeUniqueBranchTitle( |
There was a problem hiding this comment.
[Suggestion] After uiSwapped = true, title computation/recording is still inside the outer try. If computeUniqueBranchTitle() or title recording throws at that point, rollback is intentionally skipped, so the user remains in the branch but sees Failed to branch conversation: ... instead of the success and resume hint.
Please treat post-switch decoration as best-effort, or compute/apply the title before committing the UI swap, so a successful branch switch is not reported as a failed branch.
— gpt-5.5 via Qwen Code /review
) * feat(session): add /branch to fork the current conversation Introduces `/branch` (alias `/fork`), mirroring Claude Code's fork-session command. Writes a new JSONL under a fresh sessionId with every record stamped `forkedFrom: { sessionId, messageUuid }`, rebuilds `parentUuid` in write order so the fork is a clean linear descendant, and swaps the CLI into the new session with a Claude-style two-line announcement plus a `/resume <oldSessionId>` hint. Core: - `SessionService.forkSession(src, new)` performs the copy. Uses `fs.openSync(path, 'wx', 0o600)` for exclusive create — atomic existence + open in one syscall, no TOCTOU window. Rejects invalid sessionId patterns, missing/empty sources, cross-project sources, and pre-existing targets. - `ChatRecord.forkedFrom` optional field records per-message lineage. - `SessionStartSource.Branch` lets hook consumers distinguish fork from resume. CLI: - `branchCommand` guards on `isIdleRef` so mid-stream forks can't tear the parent chain, and on `sessionExists` so empty sessions can't be forked. - `useBranchCommand` orchestrates finalize → fork → load → core swap → init → UI swap, in that order: anything that can still fail runs while the UI is still on the parent, so a throw leaves the user safely on the parent session instead of stranded with a cleared history. - Branch title is `<name> (Branch)` with `(Branch N)` collision bump (cap 99, then timestamp fallback). When no name is given it's derived from the first real user `ChatRecord` (skipping cron/notification subtypes), falling back to `Branched conversation`. - `/branch` is added to `SLASH_COMMANDS_SKIP_RECORDING` so the command itself doesn't bleed into the fork's tail. Tests cover: command guards; hook ordering; title collision bump; synthetic-record skip; empty-transcript fallback; core-throws-after-fork UI-preservation invariant; forkSession disk I/O including invalid ids, cross-project rejection, already-exists rejection. 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(session): drop stale `commandType` field from branchCommand The `commandType: 'local'` field was added referencing the Phase 1 slash-command redesign draft, but the field never made it onto `SlashCommand` — Phase 1 landed with `supportedModes` / `userInvocable` instead. After merging main, strict tsc rejects the unknown property with TS2353 and the CLI package fails to build. 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(session): roll core back to parent when /branch post-fork init throws `useBranchCommand` swapped core onto the fork via `config.startNewSession` before `getGeminiClient().initialize()` resolved. If init rejected, the catch only surfaced an error item: UI was still on the parent, but `sessionId` + `ChatRecordingService` were already pointing at the orphan fork JSONL, so the next user message would silently record into the fork while appearing to belong to the parent conversation. Snapshot the parent session's `ResumedSessionData` up front, gate the rollback on a `coreSwapped` flag, and in the catch run `startNewSession(oldSessionId, prevSessionData)` + re-`initialize()` so sessionId, recorder (with the correct parentUuid chain tail), and chat history all return to the parent. Rollback re-init is best-effort — if it throws again we log and still surface the original failure, since sessionId + recorder are the load-bearing invariant. Regression tests: (1) initialize rejects after swap → two `startNewSessionConfig` calls (fork then rollback-with-parent-data), two `initialize` calls, no UI swap, original error surfaced; (2) rollback's own init also rejects → sessionId still lands on parent, debug logger warns, original error still surfaced. Reported by gpt-5.5 via Qwen Code `/review` on QwenLM#3539. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(session): close /branch transactional swap holes flagged in review Three related correctness issues in the /branch core+UI swap, all reported by gpt-5.5 via Qwen Code /review on PR QwenLM#3539: 1. Snapshot-before-finalize. ChatRecordingService.finalize() appends a trailing `system/custom_title` record that advances `lastRecordUuid`. Loading the parent ResumedSessionData snapshot before that ran captured a stale `lastCompletedUuid`; on rollback the restored recorder would chain its next record's parentUuid to a record that's no longer the JSONL tail, orphaning the custom_title from the parent chain. Move the snapshot to AFTER finalize(). 2. Reverse split-brain after UI swap. The catch block was gated solely on `coreSwapped`, so any failure AFTER the UI commits to the branch (recordCustomTitle, hook fire, remount, announcement render) would roll core back to the parent — leaving UI on the branch while the recorder writes new prompts into the parent JSONL. Track `uiSwapped` separately and skip the rollback once UI is committed; surface the failure as an error item without unwinding the swap. Pinned by a new regression test. 3. Slash dispatcher dropped the handleBranch promise. The `branch` case in slashCommandProcessor returned `{type: 'handled'}` while handleBranch was still in flight, so a fast follow-up prompt could interleave with the swap and be recorded against the wrong session. Await it and tighten the action type from `=> void` to `=> Promise<void>` (both in SlashCommandProcessorActions and UIActionsContext) so this cannot silently regress. Tests: vitest packages/cli/src/ui/hooks/useBranchCommand.test.ts 15 ✓ vitest packages/cli/src/ui/hooks/slashCommandProcessor.test.ts 41 ✓ vitest packages/cli/src/ui/commands/branchCommand.test.ts 6 ✓ vitest packages/core/src/services/sessionService.test.ts 32 ✓ tsc --noEmit clean eslint clean Co-Authored-By: Qwen-Coder <noreply@alibabacloud.com> * perf(session): fold /branch (Branch N) collision lookup into one scan `computeUniqueBranchTitle` was probing each `(Branch N)` candidate via `SessionService.findSessionsByTitle`, and that helper rescans the project's chats directory on every call. In dense title spaces /branch could end up doing the scan up to 99 times in a row before settling on a free suffix, which was visibly stalling the command. Add `SessionService.findSessionTitlesByPrefix(prefix)` — one project- wide scan that uses the cheap tail-read to extract each session's custom_title, filters to titles starting with the prefix, and applies the same project-scope filter as `findSessionsByTitle`. Heavy hydration steps (message count, prompt extraction) are skipped because collision lookup only needs the title. `computeUniqueBranchTitle` now does ONE call with prefix `${trimmed} (Branch`, builds an in-memory Set of taken titles, and picks the first free `(Branch)` / `(Branch N)` slot. Worst-case disk work drops from O(N) scans to one. Tests: new `findSessionTitlesByPrefix` describe in sessionService.test covers prefix match (case-insensitive), missing chats dir, project isolation, and files without a custom_title. useBranchCommand.test gains a perf invariant — even when 4 slots are taken, only ONE prefix-scan is issued. Reported by gpt-5.5 via Qwen Code \`/review\` on QwenLM#3539. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * test(cli): tighten mocks and drop dead assertion in slashCommandProcessor tests Addresses today's review feedback on QwenLM#3539 plus two tsc gaps the IDE flagged in the same file. 1. ChatRecordingService cast (TS2352) — route through `unknown` at the two `recorder = mockConfig.getChatRecordingService() as { recordSlashCommand }` sites in SLASH_COMMANDS_SKIP_RECORDING. Insufficient overlap between `ChatRecordingService | undefined` and the inline mock shape; the existing single-step cast doesn't compile under strict. 2. SlashCommandProcessorActions mock missing `handleBranch` — this PR added `handleBranch: (name?: string) => Promise<void>` to the actions surface (commit 8ac4af2), but `createMockActions()` was never updated, so the mock failed to satisfy the type. Added `handleBranch: vi.fn().mockResolvedValue(undefined)`. 3. `stripThoughtsFromHistory` cleanup in load_history tests — `GeminiClient` has no `stripThoughtsFromHistory` method (the helper lives inside `sessionService.ts` and is never called from the slash processor), so the mocked field was a zombie and the assertion `expect(mockClient.stripThoughtsFromHistory).not.toHaveBeenCalled()` was vacuously true — it could never fail and provided zero regression guard. Replaced with `expect(mockClient.setHistory).toHaveBeenCalledWith(historyWithThoughts)`, which is what "preserve thoughts" actually means: the `thoughtSignature` inside `clientHistory` reaches `setHistory` untouched. This will fail the day someone reintroduces strip-on-load. Tests: vitest packages/cli/src/ui/hooks/slashCommandProcessor.test.ts 42 ✓ tsc -p packages/cli/tsconfig.json --noEmit clean Co-Authored-By: Qwen-Coder <noreply@alibabacloud.com> --------- Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> Co-authored-by: Qwen-Coder <noreply@alibabacloud.com>
) * feat(session): add /branch to fork the current conversation Introduces `/branch` (alias `/fork`), mirroring Claude Code's fork-session command. Writes a new JSONL under a fresh sessionId with every record stamped `forkedFrom: { sessionId, messageUuid }`, rebuilds `parentUuid` in write order so the fork is a clean linear descendant, and swaps the CLI into the new session with a Claude-style two-line announcement plus a `/resume <oldSessionId>` hint. Core: - `SessionService.forkSession(src, new)` performs the copy. Uses `fs.openSync(path, 'wx', 0o600)` for exclusive create — atomic existence + open in one syscall, no TOCTOU window. Rejects invalid sessionId patterns, missing/empty sources, cross-project sources, and pre-existing targets. - `ChatRecord.forkedFrom` optional field records per-message lineage. - `SessionStartSource.Branch` lets hook consumers distinguish fork from resume. CLI: - `branchCommand` guards on `isIdleRef` so mid-stream forks can't tear the parent chain, and on `sessionExists` so empty sessions can't be forked. - `useBranchCommand` orchestrates finalize → fork → load → core swap → init → UI swap, in that order: anything that can still fail runs while the UI is still on the parent, so a throw leaves the user safely on the parent session instead of stranded with a cleared history. - Branch title is `<name> (Branch)` with `(Branch N)` collision bump (cap 99, then timestamp fallback). When no name is given it's derived from the first real user `ChatRecord` (skipping cron/notification subtypes), falling back to `Branched conversation`. - `/branch` is added to `SLASH_COMMANDS_SKIP_RECORDING` so the command itself doesn't bleed into the fork's tail. Tests cover: command guards; hook ordering; title collision bump; synthetic-record skip; empty-transcript fallback; core-throws-after-fork UI-preservation invariant; forkSession disk I/O including invalid ids, cross-project rejection, already-exists rejection. 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(session): drop stale `commandType` field from branchCommand The `commandType: 'local'` field was added referencing the Phase 1 slash-command redesign draft, but the field never made it onto `SlashCommand` — Phase 1 landed with `supportedModes` / `userInvocable` instead. After merging main, strict tsc rejects the unknown property with TS2353 and the CLI package fails to build. 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(session): roll core back to parent when /branch post-fork init throws `useBranchCommand` swapped core onto the fork via `config.startNewSession` before `getGeminiClient().initialize()` resolved. If init rejected, the catch only surfaced an error item: UI was still on the parent, but `sessionId` + `ChatRecordingService` were already pointing at the orphan fork JSONL, so the next user message would silently record into the fork while appearing to belong to the parent conversation. Snapshot the parent session's `ResumedSessionData` up front, gate the rollback on a `coreSwapped` flag, and in the catch run `startNewSession(oldSessionId, prevSessionData)` + re-`initialize()` so sessionId, recorder (with the correct parentUuid chain tail), and chat history all return to the parent. Rollback re-init is best-effort — if it throws again we log and still surface the original failure, since sessionId + recorder are the load-bearing invariant. Regression tests: (1) initialize rejects after swap → two `startNewSessionConfig` calls (fork then rollback-with-parent-data), two `initialize` calls, no UI swap, original error surfaced; (2) rollback's own init also rejects → sessionId still lands on parent, debug logger warns, original error still surfaced. Reported by gpt-5.5 via Qwen Code `/review` on QwenLM#3539. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(session): close /branch transactional swap holes flagged in review Three related correctness issues in the /branch core+UI swap, all reported by gpt-5.5 via Qwen Code /review on PR QwenLM#3539: 1. Snapshot-before-finalize. ChatRecordingService.finalize() appends a trailing `system/custom_title` record that advances `lastRecordUuid`. Loading the parent ResumedSessionData snapshot before that ran captured a stale `lastCompletedUuid`; on rollback the restored recorder would chain its next record's parentUuid to a record that's no longer the JSONL tail, orphaning the custom_title from the parent chain. Move the snapshot to AFTER finalize(). 2. Reverse split-brain after UI swap. The catch block was gated solely on `coreSwapped`, so any failure AFTER the UI commits to the branch (recordCustomTitle, hook fire, remount, announcement render) would roll core back to the parent — leaving UI on the branch while the recorder writes new prompts into the parent JSONL. Track `uiSwapped` separately and skip the rollback once UI is committed; surface the failure as an error item without unwinding the swap. Pinned by a new regression test. 3. Slash dispatcher dropped the handleBranch promise. The `branch` case in slashCommandProcessor returned `{type: 'handled'}` while handleBranch was still in flight, so a fast follow-up prompt could interleave with the swap and be recorded against the wrong session. Await it and tighten the action type from `=> void` to `=> Promise<void>` (both in SlashCommandProcessorActions and UIActionsContext) so this cannot silently regress. Tests: vitest packages/cli/src/ui/hooks/useBranchCommand.test.ts 15 ✓ vitest packages/cli/src/ui/hooks/slashCommandProcessor.test.ts 41 ✓ vitest packages/cli/src/ui/commands/branchCommand.test.ts 6 ✓ vitest packages/core/src/services/sessionService.test.ts 32 ✓ tsc --noEmit clean eslint clean Co-Authored-By: Qwen-Coder <noreply@alibabacloud.com> * perf(session): fold /branch (Branch N) collision lookup into one scan `computeUniqueBranchTitle` was probing each `(Branch N)` candidate via `SessionService.findSessionsByTitle`, and that helper rescans the project's chats directory on every call. In dense title spaces /branch could end up doing the scan up to 99 times in a row before settling on a free suffix, which was visibly stalling the command. Add `SessionService.findSessionTitlesByPrefix(prefix)` — one project- wide scan that uses the cheap tail-read to extract each session's custom_title, filters to titles starting with the prefix, and applies the same project-scope filter as `findSessionsByTitle`. Heavy hydration steps (message count, prompt extraction) are skipped because collision lookup only needs the title. `computeUniqueBranchTitle` now does ONE call with prefix `${trimmed} (Branch`, builds an in-memory Set of taken titles, and picks the first free `(Branch)` / `(Branch N)` slot. Worst-case disk work drops from O(N) scans to one. Tests: new `findSessionTitlesByPrefix` describe in sessionService.test covers prefix match (case-insensitive), missing chats dir, project isolation, and files without a custom_title. useBranchCommand.test gains a perf invariant — even when 4 slots are taken, only ONE prefix-scan is issued. Reported by gpt-5.5 via Qwen Code \`/review\` on QwenLM#3539. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * test(cli): tighten mocks and drop dead assertion in slashCommandProcessor tests Addresses today's review feedback on QwenLM#3539 plus two tsc gaps the IDE flagged in the same file. 1. ChatRecordingService cast (TS2352) — route through `unknown` at the two `recorder = mockConfig.getChatRecordingService() as { recordSlashCommand }` sites in SLASH_COMMANDS_SKIP_RECORDING. Insufficient overlap between `ChatRecordingService | undefined` and the inline mock shape; the existing single-step cast doesn't compile under strict. 2. SlashCommandProcessorActions mock missing `handleBranch` — this PR added `handleBranch: (name?: string) => Promise<void>` to the actions surface (commit 8ac4af2), but `createMockActions()` was never updated, so the mock failed to satisfy the type. Added `handleBranch: vi.fn().mockResolvedValue(undefined)`. 3. `stripThoughtsFromHistory` cleanup in load_history tests — `GeminiClient` has no `stripThoughtsFromHistory` method (the helper lives inside `sessionService.ts` and is never called from the slash processor), so the mocked field was a zombie and the assertion `expect(mockClient.stripThoughtsFromHistory).not.toHaveBeenCalled()` was vacuously true — it could never fail and provided zero regression guard. Replaced with `expect(mockClient.setHistory).toHaveBeenCalledWith(historyWithThoughts)`, which is what "preserve thoughts" actually means: the `thoughtSignature` inside `clientHistory` reaches `setHistory` untouched. This will fail the day someone reintroduces strip-on-load. Tests: vitest packages/cli/src/ui/hooks/slashCommandProcessor.test.ts 42 ✓ tsc -p packages/cli/tsconfig.json --noEmit clean Co-Authored-By: Qwen-Coder <noreply@alibabacloud.com> --------- Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> Co-authored-by: Qwen-Coder <noreply@alibabacloud.com>
TLDR
Adds
/branch(alias/fork) — forks the current conversation into a new session so you can explore an alternative direction without losing the current thread. Mirrors Claude Code's/branch: writes a new JSONL under a freshsessionIdwith every record stampedforkedFrom, rebuilds theparentUuidchain in write order, swaps the CLI into the fork, and prints a two-line announcement that tells you how to/resumethe original.Screenshots / Video Demo
Dive Deeper
Why fork at all. Today if you want to explore "what if I asked a different follow-up?" you either
/clear(losing context) or keep going and pollute the transcript./branchwrites a JSONL copy under a new id, stamps each copied record withforkedFrom: { sessionId, messageUuid }for audit, and swaps the CLI into the fork. The parent is untouched and reachable via/resume <oldSessionId>.Storage model — mirrors Claude's
/branch.jsonl.read, each record is rewritten (sessionId→ new,parentUuidrebuilt in write order so the fork is a clean linear descendant,forkedFromstamped), and the result is written to<newId>.jsonl. Acceptable for typical session sizes; a streaming upgrade is noted in the code if transcripts grow very large.fs.openSync(path, 'wx', 0o600)— one syscall that both asserts "doesn't exist yet" and opens for writing. No TOCTOU window, no silent overwrite.getProjectHash(records[0].cwd)), and pre-existing targets.forkedFromis write-only by design. Nothing consumes it at read time — it's per-message audit so a record inspected in isolation is self-describing. Matches Claude's behavior.Swap ordering — "core first, UI last."
useBranchCommandruns: finalize recorder →forkSession(disk) →loadSession→config.startNewSession→getGeminiClient().initialize()→ UI sessionId swap →historyManager.clearItems+loadHistory→ title + hook + announce. Anything that can still fail runs while the UI is still on the parent, so a throw leaves the user safely on the parent instead of stranded with a cleared history and a half-live client. Explicitly tested.Title handling. Branch title is
<name> (Branch)with(Branch 2),(Branch 3), ... collision bump (cap 99, then timestamp fallback). When no name is provided it's derived from the first real userChatRecord— collapsed whitespace, 100-char truncation, fallback"Branched conversation". ReadsChatRecord[](the JSONL transcript), not the APIContent[]history, because the latter is prefixed with environment/context-injection bootstrap messages; those would poison the title. Records withsubtype(cron, notification, slash-command echo) are skipped.Guards at the command layer.
isIdleRef— forking mid-stream or mid-tool-call would tear the new session's parent chain, so we refuse and tell the user to let the in-flight response finish.sessionExists(currentId)— nothing to fork from a brand-new empty session./branchis added toSLASH_COMMANDS_SKIP_RECORDINGso the command itself doesn't bleed into the fork's tail as a trailing user input (same reasoning as/new,/resume,/delete,/clear).Hook surface. New
SessionStartSource.Branchenum variant so hook consumers can distinguish a fork from a resume. A fork is semantically a derivative transcript under a new id — not a resume — so reusingSessionStartSource.Resumewould have been wrong.Announcement format. Two-line info items match Claude:
The quoted title in the first line is the raw user-provided
name(no(Branch)decoration — that belongs in the picker/prompt bar, not the announcement).Reviewer Test Plan
npm run build && npm start(ornpxthe built CLI)./branch my-experiment. Expect:/resume <uuid>hint.my-experiment (Branch)as the session title.ls ~/.qwen/projects/<hash>/chats/shows a new<uuid>.jsonlwith mode600; every line has aforkedFromfield pointing back to the parent.<oldId>.jsonlis unchanged./resume <oldSessionId>(from the hint). You're back on the parent transcript with no traces of the fork./branch my-experimenton the parent again to exercise the collision bump — expect titlemy-experiment (Branch 2)./branchwith no arg on a session whose first user message is long — expect a title derived from the first ~100 chars of that message +(Branch)./forkalias — same behavior as/branch./branchwhile a response is still streaming → error message, no fork, no session swap./branchin a brand-new empty session → "No conversation to branch."Unit tests:
vitest run packages/cli/src/ui/commands/branchCommand.test.ts packages/cli/src/ui/hooks/useBranchCommand.test.ts packages/cli/src/ui/hooks/slashCommandProcessor.test.ts packages/core/src/services/sessionService.test.ts— 90 tests, all green on my machine (6 + 11 + 41 + 32).Testing Matrix
Linked issues / bugs
Resolves #2994