feat: /fork no longer switches to the forked session (#2565)

* feat: /fork no longer switches to the forked session

Forking used to switch to the new session, which closed the source
session and force-stopped its background tasks (and canceled any
in-flight turn). /fork now creates the copy and stays in the current
session; the fork can be opened explicitly via /sessions.

* fix(tui): release fork runtime when staying current
This commit is contained in:
7Sageer 2026-08-04 15:24:12 +08:00 committed by GitHub
parent 4ac7240fff
commit 54c04bf03d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 57 additions and 30 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---
`/fork` no longer switches to the forked session: the current session stays active and its background tasks keep running. Find the fork in `/sessions`.

View file

@ -315,7 +315,7 @@ export const BUILTIN_SLASH_COMMANDS = [
{
name: 'fork',
aliases: [],
description: 'Fork the current session',
description: 'Fork the current session into a copy without switching to it',
priority: 80,
},
{

View file

@ -55,26 +55,28 @@ export async function handleForkCommand(host: SlashCommandHost, args: string): P
}
const sourceTitle = forkSourceTitle(host, session);
let forked: Session;
try {
forked = await host.harness.forkSession({
const forked = await host.harness.forkSession({
id: session.id,
title: `Fork: ${sourceTitle}`,
});
} catch (error) {
const msg = formatErrorMessage(error);
host.showError(`Failed to fork session: ${msg}`);
return;
}
try {
await host.switchToSession(
forked,
`Session forked (${forked.id}). To return to the original session: kimi -r ${session.id}`,
const forkId = forked.id;
try {
await forked.close();
} catch (error) {
const msg = formatErrorMessage(error);
host.showError(`Session forked (${forkId}), but failed to release its runtime: ${msg}`);
return;
}
// Stay in the source session: switching to the fork would close the
// source, killing its in-flight turn and background tasks. The fork is
// an independent copy the user can switch to explicitly via /sessions.
host.showStatus(
`Session forked (${forkId}). Still in the original session; switch to the fork via /sessions.`,
);
} catch (error) {
const msg = formatErrorMessage(error);
host.showError(`Failed to switch to forked session: ${msg}`);
host.showError(`Failed to fork session: ${msg}`);
}
}

View file

@ -5244,7 +5244,7 @@ command = "vim"
}
});
it('forks the active session and switches to the returned session', async () => {
it('forks the active session and stays in the source session', async () => {
const originalTitle = process.title;
const source = makeSession({
id: 'ses-source',
@ -5267,16 +5267,17 @@ command = "vim"
id: 'ses-source',
title: 'Fork: Source title',
});
expect(driver.getCurrentSessionId()).toBe('ses-fork');
expect(driver.state.transcriptContainer.render(120).join('\n')).toContain(
'Session forked (ses-fork). Still in the original session; switch to the fork via /sessions.',
);
});
expect(setTitle).toHaveBeenCalledWith('Fork: Source title');
expect(driver.getCurrentSessionId()).toBe('ses-source');
expect(source.close).not.toHaveBeenCalled();
expect(forked.close).toHaveBeenCalledOnce();
expect(forked.onEvent).not.toHaveBeenCalled();
expect(setTitle).not.toHaveBeenCalled();
expect(process.title).toBe('kimi-test-runner');
expect(source.close).toHaveBeenCalledOnce();
expect(forked.onEvent).toHaveBeenCalledOnce();
expect(harness.resumeSession).not.toHaveBeenCalled();
expect(driver.state.transcriptContainer.render(120).join('\n')).toContain(
'Session forked (ses-fork). To return to the original session: kimi -r ses-source',
);
} finally {
process.title = originalTitle;
}
@ -5302,6 +5303,25 @@ command = "vim"
});
});
it('reports when the forked runtime cannot be released', async () => {
const source = makeSession({ id: 'ses-source' });
const forked = makeSession({ id: 'ses-fork' });
forked.close.mockRejectedValueOnce(new Error('close unavailable'));
const forkSession = vi.fn(async () => forked);
const { driver } = await makeDriver(source, { forkSession });
driver.handleUserInput('/fork');
await vi.waitFor(() => {
expect(forked.close).toHaveBeenCalledOnce();
expect(driver.getCurrentSessionId()).toBe('ses-source');
expect(driver.state.transcriptContainer.render(120).join('\n')).toContain(
'Session forked (ses-fork), but failed to release its runtime: close unavailable',
);
});
expect(source.close).not.toHaveBeenCalled();
});
it('does not create a thinking component for empty thinking deltas', async () => {
const { driver } = await makeDriver();
driver.state.appState.streamingPhase = 'thinking';

View file

@ -145,7 +145,7 @@ For a first-time user, the following is all you need to know:
| `/sessions` | Browse session history and choose one to resume |
| `/model` | Switch the current model |
| `/compact` | Manually compress the context to free up tokens |
| `/fork` | Fork the current session, keeping history but continuing independently |
| `/fork` | Fork the current session into an independent copy with full history (you stay in the current session) |
**Most-used keyboard shortcuts**

View file

@ -85,7 +85,7 @@ To explore a new direction without disrupting the current conversation, use `/fo
/fork
```
The two resulting sessions are completely independent and do not affect each other. You can switch back to the original at any time using `/sessions`. A saved `/goal` is not copied to the fork. Start a new goal there if you want autonomous goal work.
Forking does not switch you away: you stay in the original session and the conversation continues untouched. The fork is an independent copy you can switch to at any time using `/sessions`. A saved `/goal` is not copied to the fork. Start a new goal there if you want autonomous goal work.
## Exporting a session

View file

@ -81,7 +81,7 @@ src/parser/markdown.ts currently has almost no tests. Please add a unit test sui
Extract the repeated "read body → validate → log → respond" pattern in src/handlers into a middleware. Run the tests afterwards to make sure existing behavior is unchanged.
```
For multi-file refactors, use Plan mode first to confirm the approach. You can also use `/fork` to create an experimental branch — if you don't like the result, just switch back to the original session.
For multi-file refactors, use Plan mode first to confirm the approach. You can also `/fork` the session into an experimental branch and switch to it from `/sessions` — forking itself never disrupts the original session, so you can simply switch back if you don't like the result.
## One-off scripts and automation

View file

@ -30,7 +30,7 @@ Some commands are only available in the idle state. Executing these commands whi
| `/new` | `/clear` | Start a fresh session, discarding the current context | No |
| `/sessions` | `/resume` | Browse historical sessions and switch to / restore one | No |
| `/tasks` | `/task` | Browse the background task list | Yes |
| `/fork` | — | Fork a new session from the current one, preserving the full conversation history | No |
| `/fork` | — | Fork a new session from the current one, preserving the full conversation history; you stay in the current session | No |
| `/title [<text>]` | `/rename` | Without arguments, display the current session title; with an argument, set a new title (max 200 characters) | Yes |
| `/compact [<instruction>]` | — | Compact the current conversation context to free up token usage; an optional custom instruction can hint to the model what to preserve | No |
| `/undo [<count>]` | — | Undo recent prompts from the active context. Without a count, opens a selector; with a count, undoes that many prompts. Prompts before the last compaction cannot be undone. Undoing also rolls back the todo list and plan mode state produced by those prompts (code changes are not reverted) | No |

View file

@ -145,7 +145,7 @@ Kimi Code CLI 会规划步骤、修改代码、运行测试,并在每一步告
| `/sessions` | 浏览历史会话,选择恢复 |
| `/model` | 切换当前使用的模型 |
| `/compact` | 手动压缩上下文,释放 token |
| `/fork` | 派生当前会话,保留历史独立继续 |
| `/fork` | 派生当前会话为保留完整历史的独立副本(仍停留在当前会话) |
**最常用快捷键**

View file

@ -85,7 +85,7 @@ kimi --session
/fork
```
派生后的两个会话彼此独立,互不影响,可以随时通过 `/sessions` 切回原来的会话。已保存的 `/goal` 不会复制到派生会话。如果你想在派生会话中进行自主 goal 工作,需要在那里开始一个新 goal。
fork 后你仍停留在原会话,对话不受影响、可以直接继续;派生出的副本与原会话彼此独立,可以随时通过 `/sessions` 切换过去。已保存的 `/goal` 不会复制到派生会话。如果你想在派生会话中进行自主 goal 工作,需要在那里开始一个新 goal。
## 导出会话

View file

@ -81,7 +81,7 @@ src/parser/markdown.ts 目前几乎没有测试。请补一组单元测试,覆
把 src/handlers 下重复的「读 body → 校验 → 写日志 → 返回」逻辑抽成一个中间件。改完跑一遍测试,保证现有行为不变。
```
多文件重构建议先用 Plan 模式确认方案,可用 `/fork` 派生一个试验分支,不满意直接切回原会话
多文件重构建议先用 Plan 模式确认方案,也可以用 `/fork` 派生一个试验分支,再从 `/sessions` 切换过去尝试fork 本身不影响原会话,不满意切回来即可
## 一次性脚本与自动化任务

View file

@ -30,7 +30,7 @@
| `/new` | `/clear` | 开启全新会话,丢弃当前上下文 | 否 |
| `/sessions` | `/resume` | 浏览历史会话并切换/恢复 | 否 |
| `/tasks` | `/task` | 浏览后台任务列表 | 是 |
| `/fork` | — | 基于当前会话 fork 一份新会话,保留完整对话历史 | 否 |
| `/fork` | — | 基于当前会话 fork 一份新会话,保留完整对话历史fork 后仍停留在当前会话 | 否 |
| `/title [<text>]` | `/rename` | 不带参数时显示当前会话标题;带参数时设置为新标题(最长 200 字符) | 是 |
| `/compact [<instruction>]` | — | 压缩当前对话上下文,释放 token 占用;可附带自定义指令,提示模型压缩时保留哪些信息 | 否 |
| `/undo [<count>]` | — | 从当前上下文撤销最近的提示词。不带数量时打开选择器;带数量时撤销对应条数。最后一次上下文压缩之前的提示词不能撤销。撤销会一并回滚这些提示词产生的 todo 列表和计划模式状态(不回滚代码改动) | 否 |