Skip to content

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

Merged
qqqys merged 10 commits into
QwenLM:mainfrom
qqqys:feat/branch-command
May 8, 2026
Merged

feat(session): add /branch to fork the current conversation#3539
qqqys merged 10 commits into
QwenLM:mainfrom
qqqys:feat/branch-command

Conversation

@qqqys

@qqqys qqqys commented Apr 23, 2026

Copy link
Copy Markdown
Collaborator

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 #2994

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>
@qqqys qqqys requested review from DennisYu07 and tanzhenxin April 23, 2026 01:52
@qqqys

qqqys commented Apr 23, 2026

Copy link
Copy Markdown
Collaborator Author

/branch E2E Test Report

  • Feature: /branch (alias /fork) — fork the current conversation into a new session

  • Commit under test: e43def100 feat(session): add /branch to fork the current conversation

  • Branch: feat/branch-command

  • Date: 2026-04-22

  • Platform: macOS (Darwin 25.1.0, arm64), Node v25.9.0, tmux 3.6a

  • Build: tsc --build --force on packages/core + packages/cli; launched via node packages/cli/dist/index.js

Test Harness

Driven through a detached tmux session so the real TUI runs exactly as a user would see it. Seeded a synthetic parent session on disk (bypasses LLM auth — the feature under test is the fork mechanic, not the chat loop), launched the CLI with -r <seedId>, sent keystrokes via tmux send-keys, captured screen state with tmux capture-pane, and asserted against the on-disk JSONL after each action.

Seed file: ~/.qwen/projects/-Users-qqqys-Desktop-qys-qwen-code/chats/e2e00000-0000-4000-8000-000000000001.jsonl — two records (user prompt + assistant reply) with cwd matching the project root so the projectHash guard passes.

Launch command:

node packages/cli/dist/index.js -r e2e00000-0000-4000-8000-000000000001 --yolo

Scenarios

1. Happy path — /branch my-experiment on a seeded session

Input sequence: wait for UI → type /branch my-experimentEnter.

UI output (captured from tmux pane):

  > help me refactor the login handler

✦ sure — which file is it in?

● Branched conversation "my-experiment". You are now in the branch.

● To resume the original: /resume e2e00000-0000-4000-8000-000000000001

────────────────────────────────────────────────────── my-experiment (Branch) ──
*   Type your message or @path/to/file

Assertions — UI:

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 fresh randomUUID sessionId.

  • Every copied record carries a correct forkedFrom stamp and a rewritten sessionId.

  • parentUuid chain is rebuilt in write order, giving the fork a clean linear lineage.

  • customTitle is 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.

qqqys and others added 2 commits April 23, 2026 09:54
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);

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] 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>
@qqqys

qqqys commented Apr 24, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed the Critical rollback finding in e526c5d.

Root causeuseBranchCommand ran config.startNewSession(newSessionId, resumed)await getGeminiClient().initialize(). The swap is synchronous, initialize() isn't. On a rejected init, the catch only added an error item: UI was still on the parent, but core's sessionId + ChatRecordingService were already pointing at the orphan fork JSONL, so the next user message would silently record into the fork while the user saw the parent conversation. The "safely on the parent" claim in the original commit message only held for the UI layer; core had already moved.

Fix

  1. Snapshot parent ResumedSessionData up front via sessionService.loadSession(oldSessionId) — captures the correct lastCompletedUuid for the restored recorder's parentUuid chain. /branch is gated on isIdleRef so the JSONL isn't being written to concurrently.
  2. Gate rollback on a coreSwapped flag set only after config.startNewSession returns. If the throw comes from startNewSession itself (pre-flag), no rollback fires and behavior matches the existing post-fork-core-throws test.
  3. In catch, if coreSwapped: config.startNewSession(oldSessionId, prevSessionData) + re-initialize() to re-hydrate chat history. Rollback's own init is best-effort — if it throws we log via getDebugLogger().warn and still surface the original failure, since sessionId + recorder are the load-bearing invariant.

Regression tests (both requested by the review) in useBranchCommand.test.ts:

  • initialize rejects after swap → asserts startNewSessionConfig is called twice (fork then (oldSessionId, parentResumed)), initialize called twice, no UI swap (clearItems/loadHistory/startNewSessionUI/setSessionName all untouched), original init boom surfaced.
  • Rollback's own initialize also rejects → sessionId still lands on parent, debug logger gets the warn, user still sees the original failure (not the rollback failure).

VerificationuseBranchCommand.test.ts 13/13, plus branchCommand.test.ts + useResumeCommand.test.ts green (25/25 total), tsc -p packages/cli --noEmit clean.

}
return { type: 'handled' };
case 'branch':
actions.handleBranch(result.name);

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

Suggested change
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

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 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();

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] 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

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 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) {

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] 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

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 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++) {

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] 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

qqqys and others added 3 commits April 29, 2026 17:39
`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>
@wenshao

wenshao commented May 6, 2026

Copy link
Copy Markdown
Collaborator

Code Review

Overview

Adds a /branch (alias /fork) slash command that forks the current conversation into a new session. The fork is implemented as a full in-memory copy of the parent JSONL with sessionId rewritten, parentUuid rebuilt in write order, and a per-record forkedFrom: { sessionId, messageUuid } audit stamp. The CLI swaps into the fork while the parent remains reachable via /resume <oldSessionId>.

Files of substance: branchCommand.ts, useBranchCommand.ts, sessionService.ts (forkSession + findSessionTitlesByPrefix), chatRecordingService.ts (forkedFrom field), slashCommandProcessor.ts, hooks/types.ts (new SessionStartSource.Branch), wiring through AppContainer.tsx, BuiltinCommandLoader.ts, UIActionsContext.tsx, commands/types.ts. Tests cover all three new units (89 tests).

What's done well

  • Swap orchestration is genuinely careful. useBranchCommand.ts uses coreSwapped/uiSwapped flags to gate rollback so a failure between core swap and UI swap puts core back on the parent (avoiding "recorder writes to fork while UI shows parent" split-brain), while a post-UI-swap failure deliberately does not roll core back (avoiding the inverse split-brain). The tests pin both invariants explicitly, including the rollback-of-rollback case where re-init also throws.
  • Atomic exclusive create. fs.openSync(targetPath, 'wx', 0o600) collapses existence check + create into one syscall — no TOCTOU window, can't silently overwrite, mode 600 is correct.
  • Project ownership guard via getProjectHash(records[0].cwd) defends against a manually-moved file appearing in another project's chats dir.
  • Title-collision lookup uses one prefix scan (findSessionTitlesByPrefix) rather than 99 sequential findSessionsByTitle probes — a real perf win on dense title spaces, well-explained in the doc comment.
  • Why-comments throughout the hook explain non-obvious choices (snapshot-after-finalize ordering, why core-first, why parentUuid is rebuilt linearly). Future maintainers will thank you.
  • Skip-recording for /branch is consistent with /new, /resume, /delete, /clear. The dispatcher correctly awaits handleBranch to prevent fast-follow-up prompts from interleaving.

Concerns

1. Diff is polluted with unrelated prettier-style reflows

~13 files with cosmetic-only changes (table column widths in code-review.md, DESIGN.md, SKILL.md; if/import line reflows in cleanup.ts, deterministic.ts, fetch-pr.ts, load-rules.ts, pr-context.ts, presubmit.ts, skill-manager.ts, symlinkScope.test.ts, monitorRegistry.ts). This is presumably from a stale prettier config or a bulk format. Recommend rebasing onto current main and dropping these — they make the real changes harder to review and risk merge conflicts (mergeable: false on the PR is consistent with that).

2. parentUuid linear rewrite collapses sidechains

forkSession rebuilds parentUuid strictly in write order:

let prevUuid: string | null = null;
const forked = records.map((record) => ({
  ...record,
  parentUuid: prevUuid,
  ...
}));

This is fine for the linear main thread, but if the source contains sidechain records (isSidechain: true — subagent output), their original tree-shaped parentUuid chain gets flattened into a single linear chain mixed with the main thread. The records still carry isSidechain: true, so reads can filter, but the parent-pointer is now meaningless for those records.

At minimum, add a one-line comment in forkSession noting that sidechain records' parent links are intentionally flattened — otherwise a future maintainer hunting "why is my subagent's parentUuid wrong in a forked session?" will rediscover this surprise.

3. Streaming-upgrade comment exists in PR description but not in code

PR description says "a streaming upgrade is noted in the code if transcripts grow very large." There is no such note in forkSession. For very large transcripts the full in-memory copy could be a real problem (multi-MB sessions × JSON.stringify × array spread). Either add the TODO to forkSession or drop the claim from the PR description.

4. Announcement doesn't reflect the (Branch N) collision bump

useBranchCommand.ts announces the user's raw name:

const titleInfo = name ? ` "${name}"` : '';
historyManager.addItem({ type: 'info', text: t('Branched conversation{{titleInfo}}. ...') }, ...);

But the actual title saved to the picker is ${baseName} (Branch 2) on collision. So /branch foo when foo (Branch) already exists prints Branched conversation "foo". while the picker shows foo (Branch 2). The PR description argues "(Branch) decoration belongs in the picker, not the announcement," which is reasonable for the no-collision case — but on a bumped collision, the user has no signal that they ended up at slot 2. Consider showing the bumped slot when N>1, e.g. Branched conversation "foo" (Branch 2). You are now in the branch.

5. Partial UI-swap failure leaves history in indeterminate state

The UI swap is three imperative steps: startNewSession(newSessionId)clearItems()loadHistory(uiHistoryItems). uiSwapped = true is set only after all three. If clearItems() throws (unlikely but possible), uiSwapped stays false and the catch block rolls core back to the parent — but the UI's sessionId already changed, and clearItems hasn't run. So core is on parent, UI sessionId is on fork, and the history items haven't been rebuilt. This isn't covered by a test. The realistic mitigation is just acknowledging that the three UI calls aren't expected to throw in normal usage; an audit comment would be enough.

6. Unnecessary type cast in branchCommand.ts

return (
  name
    ? { type: 'dialog', dialog: 'branch', name }
    : { type: 'dialog', dialog: 'branch' }
) as SlashCommandActionReturn;

With name?: string now on OpenDialogActionReturn, this should infer cleanly. Try dropping the as — if TS still complains, narrow with an explicit return type annotation on the action arrow function instead. Casts hide future regressions.

7. 'dialog' type for an action that opens no dialog

branchCommand returns { type: 'dialog', dialog: 'branch' }, which the slash-processor immediately routes to handleBranch without opening any UI. This piggy-backs on the dialog return type to carry the optional name field through. The pattern matches existing commands (e.g. delete), so I'm not blocking on it, but a comment in OpenDialogActionReturn noting that some dialog values are routed to imperative handlers would help. Otherwise a future reader greps for a BranchDialog component that doesn't exist.

8. Test coverage gap: post-UI-swap failures

The tests cover:

  • forkSession throws ✓
  • getGeminiClient().initialize() throws after core swap ✓
  • remount throws after UI swap ✓
  • config.startNewSession throws post-fork ✓

Missing: what happens if recordCustomTitle throws? The catch block surfaces an error item, but setSessionName was never called and no announcement was made — so the user is silently moved into a fork session with no title and no "you are now in the branch" message. Add a test pinning the expected behavior, or move recordCustomTitle/setSessionName into a try/catch that doesn't trigger the outer rollback (the values are nice-to-have, not load-bearing).

9. findSessionTitlesByPrefix reads file headers synchronously in a loop

The new method iterates files, calls readSessionTitleInfoFromFile (sync), and only then does an await jsonl.readLines for the project filter. That's fine for typical projects but blocks the event loop on the sync part for the duration of the scan. Consider whether parallelizing the per-file work via Promise.all would matter — at MAX_FILES_TO_PROCESS size it might be noticeable. Lower priority.

Minor nits

  • deriveFirstPrompt uses for (const part of parts) with an inner if ('text' in part && ...) and only ever uses the first text part it finds. The early return collapsed on first non-empty makes that intentional, but the comment doesn't mention it. A quick // Use first text part would help.
  • i18n template 'Branched conversation{{titleInfo}}.' interpolates the whole quoted name+space prefix as a single var. For non-English locales the punctuation and quote conventions might differ. Consider splitting into two templates (with-name / without-name) or letting the translator handle the interpolation.
  • args.trim().replace(/[\r\n]+/g, ' ') strips newlines but not \t or other control chars. Probably fine since the title is just stored/displayed, but ANSI escape sequences in the name would render in the picker. Low-risk self-targeting only.

Verdict

Solid implementation with thoughtful failure handling and good test coverage on the load-bearing invariants. The unrelated formatting churn should be pruned before merge, and the rebase will resolve mergeable: false. A handful of small comment/UX additions (issues #2, #3, #4, #7, #8) would close the loop. Nothing blocking on correctness — happy to see this land.

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

[Critical] slashCommandProcessor.test.ts:127createMockActions() 缺少 handleBranch 属性(TS2741)。SlashCommandProcessorActions 接口现在要求此属性(本 PR 在 types.ts 中添加),但 mock 工厂未包含。添加 handleBranch: vi.fn() 到返回对象中。

[Critical] slashCommandProcessor.test.ts:562mockClient.stripThoughtsFromHistory 不存在于 GeminiClient 类型上(TS2339)。此属性可能已在主分支上重命名或移除;需要更新测试以使用实际可用的 API。

const result = setupProcessorHook([branchCmd]);
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));

const recorder = mockConfig.getChatRecordingService() as {

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] ChatRecordingService | undefined 强制转换为 { recordSlashCommand: Mock<Procedure>; } — 类型没有充分重叠(TS2352)。需要先通过 unknown 进行中间转换。

Suggested change
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 {

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] 第二个 ChatRecordingService | undefined 强制转换存在相同问题(TS2352),与上方 1207 行模式相同。

Suggested change
const recorder = mockConfig.getChatRecordingService() as {
const recorder = mockConfig.getChatRecordingService() as unknown as {
recordSlashCommand: ReturnType<typeof vi.fn>;
};

— deepseek-v4-pro via Qwen Code /review

@qqqys

qqqys commented May 8, 2026

Copy link
Copy Markdown
Collaborator Author

Re the computeUniqueBranchTitle() suggestion at packages/cli/src/ui/hooks/useBranchCommand.ts:83 — agreed it's a real interactive-path stall risk in projects with heavy session history, but the fix wants a new SessionService batched/prefix API so it's bigger than this PR's scope.

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>
@qqqys qqqys merged commit 35b9cdb into QwenLM:main May 8, 2026
12 checks passed
throw new Error(
`Source session does not belong to current project: ${sourceSessionId}`,
);
}

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] 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

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

Suggested change
// 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(

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] /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(

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] 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

TaimoorSiddiquiOfficial pushed a commit to TaimoorSiddiquiOfficial/HopCode that referenced this pull request May 8, 2026
)

* 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>
B-A-M-N pushed a commit to B-A-M-N/qwen-code that referenced this pull request May 8, 2026
)

* 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[P1] Session Branching / 会话分支

2 participants