mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-20 09:24:03 +00:00
* 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
|
||
|---|---|---|
| .. | ||
| src | ||
| index.ts | ||
| package.json | ||
| test-setup.ts | ||
| tsconfig.json | ||
| vitest.config.ts | ||