qwen-code/packages/cli
qqqys 35b9cdb22d
feat(session): add /branch to fork the current conversation (#3539)
* 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 #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 #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 #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 #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 8ac4af285), 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>
2026-05-08 19:34:11 +08:00
..
src feat(session): add /branch to fork the current conversation (#3539) 2026-05-08 19:34:11 +08:00
index.ts fix(cli): stop double-wrapping and double-printing API errors in non-interactive mode (#3749) 2026-05-03 08:39:31 +08:00
package.json chore(release): v0.15.8 (#3928) [skip ci] 2026-05-08 00:46:00 +08:00
test-setup.ts fix: prevent bogus shell permission rules in tests 2026-03-20 17:55:33 +08:00
tsconfig.json Add background agent resume and continuation (#3739) 2026-05-01 12:14:33 +08:00
vitest.config.ts refactor(core): Unify package exports and improve dev experience 2026-02-01 11:59:05 +08:00