feat(session): add /branch to fork the current conversation#107
Open
BingqingLyu wants to merge 4 commits into
Open
feat(session): add /branch to fork the current conversation#107BingqingLyu wants to merge 4 commits into
BingqingLyu wants to merge 4 commits into
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>
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>
…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>
This was referenced Apr 28, 2026
This was referenced May 7, 2026
Owner
Author
Conflict Group 1This PR shares modified functions with 12 other PR(s): #100, #109, #113, #114, #117, #28, #52, #86, #87, #88, #9, #96. These PRs should be reviewed as a batch — merging one may affect the others.
graph LR
PR107["PR #107"]
FcreateTestCommand_6605["createTestCommand<br>slashCommandProcessor.test.ts"]
PR107 -->|modifies| FcreateTestCommand_6605
PR96["PR #96"]
PR96 -->|modifies| FcreateTestCommand_6605
FisToolExecuting_7261["isToolExecuting<br>AppContainer.tsx"]
PR107 -->|modifies| FisToolExecuting_7261
PR100["PR #100"]
PR100 -->|modifies| FisToolExecuting_7261
PR109["PR #109"]
PR109 -->|modifies| FisToolExecuting_7261
PR113["PR #113"]
PR113 -->|modifies| FisToolExecuting_7261
PR114["PR #114"]
PR114 -->|modifies| FisToolExecuting_7261
PR117["PR #117"]
PR117 -->|modifies| FisToolExecuting_7261
PR52["PR #52"]
PR52 -->|modifies| FisToolExecuting_7261
PR88["PR #88"]
PR88 -->|modifies| FisToolExecuting_7261
PR96 -->|modifies| FisToolExecuting_7261
FloadCommands_7884["loadCommands<br>BuiltinCommandLoader.ts"]
PR107 -->|modifies| FloadCommands_7884
PR86["PR #86"]
PR86 -->|modifies| FloadCommands_7884
PR87["PR #87"]
PR87 -->|modifies| FloadCommands_7884
PR9["PR #9"]
PR9 -->|modifies| FloadCommands_7884
PR96 -->|modifies| FloadCommands_7884
FserializeHistoryItemForRecording_1282["serializeHistoryItemForRecording<br>slashCommandProcessor.ts"]
PR107 -->|modifies| FserializeHistoryItemForRecording_1282
PR28["PR #28"]
PR28 -->|modifies| FserializeHistoryItemForRecording_1282
PR96 -->|modifies| FserializeHistoryItemForRecording_1282
Posted by codegraph-ai conflict detection. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 QwenLM#2994