Skip to content

feat(session): add /branch to fork the current conversation#107

Open
BingqingLyu wants to merge 4 commits into
mainfrom
fork-pr-3539-feat-branch-command
Open

feat(session): add /branch to fork the current conversation#107
BingqingLyu wants to merge 4 commits into
mainfrom
fork-pr-3539-feat-branch-command

Conversation

@BingqingLyu

@BingqingLyu BingqingLyu commented Apr 27, 2026

Copy link
Copy Markdown
Owner

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 fresh sessionId with every record stamped forkedFrom, rebuilds the parentUuid chain in write order, swaps the CLI into the fork, and prints a two-line announcement that tells you how to /resume the original.

Screenshots / Video Demo

branch

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. /branch writes a JSONL copy under a new id, stamps each copied record with forkedFrom: { 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.

  • Full in-memory copy + write. The source transcript is read through jsonl.read, each record is rewritten (sessionId → new, parentUuid rebuilt in write order so the fork is a clean linear descendant, forkedFrom stamped), 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.
  • Atomic exclusive create. Target file is opened with fs.openSync(path, 'wx', 0o600) — one syscall that both asserts "doesn't exist yet" and opens for writing. No TOCTOU window, no silent overwrite.
  • Guards. Rejects invalid sessionId patterns, missing/empty sources, cross-project sources (verified via getProjectHash(records[0].cwd)), and pre-existing targets.
  • forkedFrom is 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." useBranchCommand runs: finalize recorder → forkSession (disk) → loadSessionconfig.startNewSessiongetGeminiClient().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 user ChatRecord — collapsed whitespace, 100-char truncation, fallback "Branched conversation". Reads ChatRecord[] (the JSONL transcript), not the API Content[] history, because the latter is prefixed with environment/context-injection bootstrap messages; those would poison the title. Records with subtype (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.
  • /branch is added to SLASH_COMMANDS_SKIP_RECORDING so 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.Branch enum 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 reusing SessionStartSource.Resume would have been wrong.

Announcement format. Two-line info items match Claude:

Branched conversation "my-experiment". You are now in the branch.
To resume the original: /resume <oldSessionId>

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

  1. npm run build && npm start (or npx the built CLI).
  2. Start a session and send a couple of prompts so there's real content to fork.
  3. Run /branch my-experiment. Expect:
    • Two info lines: the branched-conversation line and the /resume <uuid> hint.
    • Prompt bar / picker shows my-experiment (Branch) as the session title.
    • ls ~/.qwen/projects/<hash>/chats/ shows a new <uuid>.jsonl with mode 600; every line has a forkedFrom field pointing back to the parent.
    • The source <oldId>.jsonl is unchanged.
  4. Continue chatting in the fork — new turns append to the new JSONL, not the parent.
  5. Run /resume <oldSessionId> (from the hint). You're back on the parent transcript with no traces of the fork.
  6. Re-run /branch my-experiment on the parent again to exercise the collision bump — expect title my-experiment (Branch 2).
  7. /branch with no arg on a session whose first user message is long — expect a title derived from the first ~100 chars of that message + (Branch).
  8. /fork alias — same behavior as /branch.
  9. Error paths:
    • Run /branch while a response is still streaming → error message, no fork, no session swap.
    • Run /branch in 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

🍏 🪟 🐧
npm run
npx
Docker
Podman - -
Seatbelt - -

Linked issues / bugs

Resolves QwenLM#2994

qqqys and others added 4 commits April 22, 2026 19:13
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>
@BingqingLyu

BingqingLyu commented May 7, 2026

Copy link
Copy Markdown
Owner Author

Conflict Group 1

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

Function File Also modified by
createTestCommand slashCommandProcessor.test.ts #96
isToolExecuting AppContainer.tsx #100, #109, #113, #114, #117, #52, #88, #96
loadCommands BuiltinCommandLoader.ts #86, #87, #9, #96
serializeHistoryItemForRecording slashCommandProcessor.ts #28, #96
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
Loading

Posted by codegraph-ai conflict detection.

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

Labels

conflicting-group-1 Conflicting PR group 1 — review as a batch conflicting-pr Shares at least one cross-PR dependency with other PRs

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[P1] Session Branching / 会话分支

2 participants