feat(web-shell): support daemon session branching (#5613)

* feat(web-shell): support daemon session branching

* fix(webui): keep branch events on new session

* fix(web-shell): harden fork session handling

* fix(cli): neutralize fork history marker

---------

Co-authored-by: ytahdn <ytahdn@gmail.com>
This commit is contained in:
ytahdn 2026-06-22 19:18:01 +08:00 committed by GitHub
parent f9f8f6c2f2
commit 8e652e1ef7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 1286 additions and 27 deletions

View file

@ -34,6 +34,7 @@ import {
RestoreInProgressError,
SessionShellClientRequiredError,
SessionShellDisabledError,
SessionBusyError,
SessionNotFoundError,
WorkspaceMismatchError,
} from './bridgeErrors.js';
@ -2946,6 +2947,70 @@ describe('createAcpSessionBridge', () => {
await bridge.shutdown();
});
it('publishes session_branched only on the new session stream', async () => {
const factory: ChannelFactory = async () =>
makeChannel({
extMethodImpl: async (method) => {
if (method !== 'qwen/control/session/branch') return {};
return { newSessionId: 'branch-1', title: 'Branch 1' };
},
resumeSessionImpl: () => ({}),
}).channel;
const bridge = makeBridge({ channelFactory: factory });
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
const sourceAbort = new AbortController();
const sourceIter = bridge
.subscribeEvents(session.sessionId, { signal: sourceAbort.signal })
[Symbol.asyncIterator]();
const branch = await bridge.branchSession(session.sessionId, {
name: 'Branch 1',
});
const sourceEvent = await Promise.race([
sourceIter.next(),
new Promise<'timeout'>((resolve) => setTimeout(resolve, 25, 'timeout')),
]);
expect(sourceEvent).toBe('timeout');
sourceAbort.abort();
const sourceReplayAbort = new AbortController();
const sourceReplayIter = bridge
.subscribeEvents(session.sessionId, {
lastEventId: 0,
signal: sourceReplayAbort.signal,
})
[Symbol.asyncIterator]();
const sourceReplayEvent = await Promise.race([
sourceReplayIter.next(),
new Promise<'timeout'>((resolve) => setTimeout(resolve, 25, 'timeout')),
]);
expect(sourceReplayEvent).toMatchObject({
value: { type: 'replay_complete' },
});
const sourceReplayNext = await Promise.race([
sourceReplayIter.next(),
new Promise<'timeout'>((resolve) => setTimeout(resolve, 25, 'timeout')),
]);
expect(sourceReplayNext).toBe('timeout');
sourceReplayAbort.abort();
const branchedIter = bridge
.subscribeEvents(branch.sessionId, { lastEventId: 0 })
[Symbol.asyncIterator]();
const replayed = await branchedIter.next();
expect(replayed.value).toMatchObject({
type: 'session_branched',
data: {
sourceSessionId: session.sessionId,
newSessionId: branch.sessionId,
displayName: 'Branch 1',
},
});
await bridge.shutdown();
});
it('a failed prompt does not poison the queue for subsequent prompts', async () => {
let promptCount = 0;
const handles: ChannelHandle[] = [];
@ -2985,6 +3050,37 @@ describe('createAcpSessionBridge', () => {
await bridge.shutdown();
});
it('rejects launchSessionForkAgent while a prompt is active', async () => {
let releasePrompt: (() => void) | undefined;
const handle = makeChannel({
promptImpl: async () =>
new Promise<PromptResponse>((resolve) => {
releasePrompt = () => resolve({ stopReason: 'end_turn' });
}),
});
const bridge = makeBridge({ channelFactory: async () => handle.channel });
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
const active = bridge.sendPrompt(session.sessionId, {
sessionId: session.sessionId,
prompt: [{ type: 'text', text: 'active' }],
});
await vi.waitFor(() => expect(releasePrompt).toBeDefined());
await expect(
bridge.launchSessionForkAgent(session.sessionId, 'review this'),
).rejects.toBeInstanceOf(SessionBusyError);
expect(
handle.agent.extMethodCalls.some(
(call) => call.method === 'qwen/control/session/fork_agent',
),
).toBe(false);
releasePrompt!();
await expect(active).resolves.toEqual({ stopReason: 'end_turn' });
await bridge.shutdown();
});
it('throws SessionNotFoundError for unknown session ids', async () => {
const bridge = makeBridge({
channelFactory: async () => {

View file

@ -3394,14 +3394,14 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
let restored;
try {
restored = await restoreSession('resume', {
restored = await restoreSession('load', {
sessionId: result.newSessionId,
workspaceCwd: boundWorkspace,
clientId: context?.clientId,
});
} catch (restoreErr) {
writeStderrLine(
`qwen serve: branchSession resume failed for ${result.newSessionId}, attempting cleanup...`,
`qwen serve: branchSession load failed for ${result.newSessionId}, attempting cleanup...`,
);
try {
await ci.connection.extMethod(
@ -3429,8 +3429,9 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
data: eventData,
...(originatorClientId ? { originatorClientId } : {}),
};
entry.events.publish(branchEnvelope);
broadcastWorkspaceEvent(branchEnvelope, sessionId);
// The branch announcement belongs to the new session only. Publishing
// it on the source session would persist in that session's replay ring.
newEntry?.events.publish(branchEnvelope);
return {
...restored,
@ -4350,6 +4351,81 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
};
},
async launchSessionForkAgent(sessionId, directive, context) {
const entry = byId.get(sessionId);
if (!entry) throw new SessionNotFoundError(sessionId);
const info = channelInfoForEntry(entry);
if (!info || info.isDying) throw new SessionNotFoundError(sessionId);
resolveTrustedClientId(entry, context?.clientId);
const trimmed = directive.trim();
if (!trimmed) {
throw new Error('Fork directive is required');
}
if (entry.pendingPromptCount > 0 || entry.promptActive) {
throw new SessionBusyError(
sessionId,
'Cannot fork while a response or tool call is in progress',
);
}
return entry.promptQueue.then(async () => {
if (entry.pendingPromptCount > 0 || entry.promptActive) {
throw new SessionBusyError(
sessionId,
'Cannot fork while a response or tool call is in progress',
);
}
opts.onDiagnosticLine?.(
`qwen serve: launchSessionForkAgent requested for session=${sessionId}`,
'info',
);
let response: {
description?: string;
launched?: boolean;
};
try {
response = (await Promise.race([
withTimeout(
entry.connection.extMethod(
SERVE_CONTROL_EXT_METHODS.sessionForkAgent,
{
sessionId,
directive: trimmed,
},
),
initTimeoutMs,
SERVE_CONTROL_EXT_METHODS.sessionForkAgent,
),
getTransportClosedReject(entry),
])) as {
description?: string;
launched?: boolean;
};
} catch (error) {
opts.onDiagnosticLine?.(
`qwen serve: launchSessionForkAgent failed for session=${sessionId}: ${
error instanceof Error ? error.message : String(error)
}`,
'warn',
);
throw error;
}
const result = {
sessionId: entry.sessionId,
description: response.description ?? trimmed.slice(0, 60),
launched: response.launched === true,
};
opts.onDiagnosticLine?.(
`qwen serve: launchSessionForkAgent completed for session=${sessionId} launched=${result.launched}`,
'info',
);
return result;
});
},
async executeShellCommand(
sessionId,
command,

View file

@ -113,6 +113,12 @@ export interface BridgeBranchedSession extends BridgeRestoredSession {
forkedFrom: { sessionId: string; displayName: string };
}
export interface BridgeForkAgentResult {
sessionId: string;
description: string;
launched: boolean;
}
/** Sparse summary used by `GET /workspace/:id/sessions`. */
export interface BridgeSessionSummary {
sessionId: string;
@ -572,6 +578,17 @@ export interface AcpSessionBridge {
context?: BridgeClientRequestContext,
): Promise<{ sessionId: string; answer: string | null }>;
/**
* Launch a background fork agent that inherits the live session's current
* conversation context. This is CLI `/fork`, not ACP `session/fork`
* (which maps to `/branch`).
*/
launchSessionForkAgent(
sessionId: string,
directive: string,
context?: BridgeClientRequestContext,
): Promise<BridgeForkAgentResult>;
/**
* Queue a mid-turn user message for the running turn. The ACP child drains
* it between tool batches via the `craft/drainMidTurnQueue` ext-method so

View file

@ -125,6 +125,7 @@ export const SERVE_CONTROL_EXT_METHODS = {
sessionClose: 'qwen/control/session/close',
sessionApprovalMode: 'qwen/control/session/approval_mode',
sessionBranch: 'qwen/control/session/branch',
sessionForkAgent: 'qwen/control/session/fork_agent',
sessionRecap: 'qwen/control/session/recap',
sessionBtw: 'qwen/control/session/btw',
sessionShellHistory: 'qwen/control/session/shell_history',

View file

@ -125,6 +125,10 @@ vi.mock('@qwen-code/qwen-code-core', () => ({
USE_GEMINI: 'gemini',
USE_VERTEX_AI: 'vertex-ai',
},
ToolNames: {
AGENT: 'agent',
},
FORK_SUBAGENT_TYPE: 'fork',
ALL_PROVIDERS: [
{
id: 'deepseek',
@ -2103,6 +2107,83 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
await agentPromise;
});
it('launches fork agents with neutral history text', async () => {
const sessionId = '11111111-1111-1111-1111-111111111111';
const innerConfig = await setupSessionMocks(sessionId);
const addHistory = vi.fn();
const execute = vi.fn().mockResolvedValue({ llmContent: 'ok' });
const build = vi.fn().mockReturnValue({ execute });
const directive = `review this\nbranch ${'x'.repeat(220)}`;
const collapsed = `review this branch ${'x'.repeat(220)}`;
Object.assign(innerConfig, {
getGeminiClient: vi.fn().mockReturnValue({
isInitialized: vi.fn().mockReturnValue(true),
initialize: vi.fn().mockResolvedValue(undefined),
waitForMcpReady: vi.fn().mockResolvedValue(undefined),
getHistoryShallow: vi
.fn()
.mockReturnValue([{ role: 'user', parts: [{ text: 'before' }] }]),
addHistory,
}),
getToolRegistry: vi.fn().mockReturnValue({
getTool: vi.fn((name: string) =>
name === 'agent' ? { build } : undefined,
),
}),
});
const agentPromise = runAcpAgent(
mockConfig,
makeSessionSettings(),
mockArgv,
);
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
const agent = capturedAgentFactory!({
get closed() {
return mockConnectionState.promise;
},
}) as AgentLike;
await agent.newSession({ cwd: '/tmp', mcpServers: [] });
await expect(
agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionForkAgent, {
sessionId,
directive,
}),
).resolves.toEqual({
sessionId,
description: `${collapsed.slice(0, 57)}`,
launched: true,
});
expect(build).toHaveBeenCalledWith({
description: `${collapsed.slice(0, 57)}`,
prompt: directive.trim(),
subagent_type: 'fork',
run_in_background: true,
});
expect(execute).toHaveBeenCalledTimes(1);
expect(addHistory).toHaveBeenCalledWith({
role: 'user',
parts: [
{
text: `User launched a background fork via /fork. Directive (truncated): ${collapsed.slice(
0,
197,
)}`,
},
],
});
expect(addHistory.mock.calls[0]?.[0]?.parts[0]?.text).not.toContain(
'[system]',
);
mockConnectionState.resolve();
await agentPromise;
});
it('allows cancelling paused agent tasks', async () => {
const sessionId = '11111111-1111-1111-1111-111111111111';
const innerConfig = await setupSessionMocks(sessionId);

View file

@ -58,9 +58,12 @@ import {
redactUrlCredentials,
computeUniqueBranchTitle,
unregisterGoalHook,
ToolNames,
FORK_SUBAGENT_TYPE,
} from '@qwen-code/qwen-code-core';
import { randomUUID } from 'node:crypto';
import type {
AgentParams,
ApprovalMode,
Config,
ConversationRecord,
@ -215,6 +218,32 @@ const debugLogger = createDebugLogger('ACP_AGENT');
// aborts before the bridge's backstop timer fires.
const BTW_CHILD_TIMEOUT_MS = 55_000;
function collapseForkDirective(directive: string, maxLength: number): string {
const oneLine = directive.replace(/\s+/g, ' ').trim();
return oneLine.length > maxLength
? `${oneLine.slice(0, maxLength - 3)}`
: oneLine;
}
function deriveForkDescription(directive: string): string {
return collapseForkDirective(directive, 60);
}
function truncateForkDirectiveForHistory(directive: string): string {
return collapseForkDirective(directive, 200);
}
function hasFailedDisplayStatus(
display: unknown,
): display is { status: 'failed' } {
return (
display !== null &&
typeof display === 'object' &&
'status' in display &&
(display as { status?: unknown }).status === 'failed'
);
}
function sanitizeProviderBaseUrl(baseUrl: string): string {
const scheme = baseUrl.match(/^[A-Za-z][A-Za-z\d+.-]*:\/\//);
if (!scheme) {
@ -5733,6 +5762,90 @@ class QwenAgent implements Agent {
}
return { sessionId, answer: result.text || null };
}
case SERVE_CONTROL_EXT_METHODS.sessionForkAgent: {
const sessionId = params['sessionId'];
if (typeof sessionId !== 'string' || sessionId.length === 0) {
throw RequestError.invalidParams(
undefined,
'Invalid or missing sessionId',
);
}
const directive =
typeof params['directive'] === 'string' ? params['directive'] : '';
const trimmed = directive.trim();
if (!trimmed) {
throw RequestError.invalidParams(
undefined,
'Invalid or missing directive',
);
}
const session = this.sessionOrThrow(sessionId);
const config = session.getConfig();
if (!config.getModel()) {
throw RequestError.invalidParams(undefined, 'No model configured.');
}
let hasHistory = false;
try {
hasHistory =
(config.getGeminiClient().getHistoryShallow() ?? []).length > 0;
} catch (error) {
debugLogger.debug('Failed to read history before /fork:', error);
}
if (!hasHistory) {
throw RequestError.invalidParams(
undefined,
'Cannot fork before the first conversation turn.',
);
}
const agentTool = config.getToolRegistry().getTool(ToolNames.AGENT);
if (!agentTool) {
throw RequestError.invalidParams(
undefined,
'The agent tool is unavailable; cannot fork.',
);
}
const description = deriveForkDescription(trimmed);
const agentParams: AgentParams = {
description,
prompt: trimmed,
subagent_type: FORK_SUBAGENT_TYPE,
run_in_background: true,
};
const result = await agentTool
.build(agentParams)
.execute(new AbortController().signal);
if (hasFailedDisplayStatus(result?.returnDisplay)) {
const reason =
typeof result.llmContent === 'string' && result.llmContent.trim()
? result.llmContent.trim()
: 'the background agent could not be started.';
throw RequestError.invalidParams(
undefined,
`Failed to launch fork: ${reason}`,
);
}
try {
config.getGeminiClient().addHistory({
role: 'user',
parts: [
{
text: `User launched a background fork via /fork. Directive (truncated): ${truncateForkDirectiveForHistory(
trimmed,
)}`,
},
],
});
} catch (error) {
debugLogger.debug('Failed to record fork event in history:', error);
}
return { sessionId, description, launched: true };
}
case SERVE_CONTROL_EXT_METHODS.sessionShellHistory: {
const sessionId = params['sessionId'];
if (typeof sessionId !== 'string' || sessionId.length === 0) {

View file

@ -62,6 +62,7 @@ import {
RestoreInProgressError,
SessionShellClientRequiredError,
SessionShellDisabledError,
SessionBusyError,
SessionLimitExceededError,
SessionNotFoundError,
WorkspaceMismatchError,
@ -382,6 +383,11 @@ interface FakeBridgeOpts {
sessionId: string,
context?: BridgeClientRequestContext,
) => Promise<{ sessionId: string; recap: string | null }>;
launchSessionForkAgentImpl?: (
sessionId: string,
directive: string,
context?: BridgeClientRequestContext,
) => Promise<{ sessionId: string; description: string; launched: boolean }>;
setToolEnabledImpl?: (
toolName: string,
enabled: boolean,
@ -552,6 +558,11 @@ interface FakeBridge extends AcpSessionBridge {
sessionId: string;
context?: BridgeClientRequestContext;
}>;
forkCalls: Array<{
sessionId: string;
directive: string;
context?: BridgeClientRequestContext;
}>;
setToolEnabledCalls: Array<{
toolName: string;
enabled: boolean;
@ -841,6 +852,14 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge {
sessionId,
recap: 'Default fake recap.',
}));
const forkCalls: FakeBridge['forkCalls'] = [];
const launchSessionForkAgentImpl =
opts.launchSessionForkAgentImpl ??
(async (sessionId: string, directive: string) => ({
sessionId,
description: directive.slice(0, 60),
launched: true,
}));
const setToolEnabledCalls: FakeBridge['setToolEnabledCalls'] = [];
const setToolEnabledImpl =
opts.setToolEnabledImpl ??
@ -963,6 +982,7 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge {
setApprovalModeCalls,
shellCalls,
generateSessionRecapCalls,
forkCalls,
setToolEnabledCalls,
initWorkspaceCalls,
restartMcpServerCalls,
@ -1188,6 +1208,14 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge {
async generateSessionBtw(sessionId, _question, _signal, _context) {
return { sessionId, answer: 'mock btw answer' };
},
async launchSessionForkAgent(sessionId, directive, context) {
forkCalls.push({
sessionId,
directive,
...(context ? { context } : {}),
});
return launchSessionForkAgentImpl(sessionId, directive, context);
},
enqueueMidTurnMessage(sessionId, message, context) {
enqueueMidTurnCalls.push({
sessionId,
@ -6093,6 +6121,94 @@ describe('createServeApp', () => {
});
});
describe('POST /session/:id/fork', () => {
it('202 with directive and client identity on success', async () => {
const bridge = fakeBridge();
const app = createServeApp(baseOpts, undefined, { bridge });
const res = await request(app)
.post('/session/session-A/fork')
.set('Host', `127.0.0.1:${baseOpts.port}`)
.set('X-Qwen-Client-Id', 'client-1')
.send({ directive: 'review the current code' });
expect(res.status).toBe(202);
expect(res.body).toEqual({
sessionId: 'session-A',
description: 'review the current code',
launched: true,
});
expect(bridge.forkCalls).toEqual([
{
sessionId: 'session-A',
directive: 'review the current code',
context: { clientId: 'client-1' },
},
]);
});
it('400 when directive is missing or empty', async () => {
const bridge = fakeBridge();
const app = createServeApp(baseOpts, undefined, { bridge });
const missing = await request(app)
.post('/session/session-A/fork')
.set('Host', `127.0.0.1:${baseOpts.port}`)
.send({});
expect(missing.status).toBe(400);
expect(missing.body.code).toBe('missing_directive');
const empty = await request(app)
.post('/session/session-A/fork')
.set('Host', `127.0.0.1:${baseOpts.port}`)
.send({ directive: ' ' });
expect(empty.status).toBe(400);
expect(empty.body.code).toBe('missing_directive');
expect(bridge.forkCalls).toEqual([]);
});
it('404 when bridge throws SessionNotFoundError', async () => {
const bridge = fakeBridge({
launchSessionForkAgentImpl: async (sessionId) => {
throw new SessionNotFoundError(sessionId);
},
});
const app = createServeApp(baseOpts, undefined, { bridge });
const res = await request(app)
.post('/session/missing/fork')
.set('Host', `127.0.0.1:${baseOpts.port}`)
.send({ directive: 'review the current code' });
expect(res.status).toBe(404);
expect(res.body.sessionId).toBe('missing');
});
it('409 when bridge reports the session is busy', async () => {
const bridge = fakeBridge({
launchSessionForkAgentImpl: async (sessionId) => {
throw new SessionBusyError(
sessionId,
'Cannot fork while a response or tool call is in progress',
);
},
});
const app = createServeApp(baseOpts, undefined, { bridge });
const res = await request(app)
.post('/session/session-A/fork')
.set('Host', `127.0.0.1:${baseOpts.port}`)
.send({ directive: 'review the current code' });
expect(res.status).toBe(409);
expect(res.body).toMatchObject({
code: 'session_busy',
sessionId: 'session-A',
});
expect(res.headers['retry-after']).toBe('5');
});
});
describe('POST /session/:id/language', () => {
it('200 with language result on success', async () => {
const bridge = fakeBridge();
@ -10936,15 +11052,16 @@ describe('auth device-flow routes', () => {
// through `sendBridgeError`'s generic 500 path. Build a fake
// provider whose start always throws.
const { UpstreamDeviceFlowError } = await import('./auth/device-flow.js');
const failingProvider: import('./auth/device-flow.js').DeviceFlowProvider = {
providerId: 'qwen-oauth',
async start() {
throw new UpstreamDeviceFlowError('mocked upstream outage');
},
async poll() {
return { kind: 'pending' as const };
},
};
const failingProvider: import('./auth/device-flow.js').DeviceFlowProvider =
{
providerId: 'qwen-oauth',
async start() {
throw new UpstreamDeviceFlowError('mocked upstream outage');
},
async poll() {
return { kind: 'pending' as const };
},
};
const bridge = fakeBridge();
const app = createServeApp({ ...baseOpts, token: 'tkn' }, undefined, {
bridge,

View file

@ -2999,6 +2999,35 @@ export function createServeApp(
}
});
app.post('/session/:id/fork', mutate(), async (req, res) => {
const sessionId = requireSessionId(req, res);
if (sessionId === null) return;
const body = safeBody(req);
const directive = body['directive'];
if (typeof directive !== 'string' || directive.trim().length === 0) {
res.status(400).json({
error: '`directive` is required and must be a non-empty string',
code: 'missing_directive',
});
return;
}
const clientId = parseClientIdHeader(req, res);
if (clientId === null) return;
try {
const result = await bridge.launchSessionForkAgent(
sessionId,
directive,
clientId !== undefined ? { clientId } : undefined,
);
res.status(202).json(result);
} catch (err) {
sendBridgeError(res, err, {
route: 'POST /session/:id/fork',
sessionId,
});
}
});
app.get('/session/:id/context', async (req, res) => {
const sessionId = requireSessionId(req, res);
if (sessionId === null) return;

View file

@ -1941,6 +1941,84 @@ describe('SessionService', () => {
expect(srcLines.every((r) => !r.forkedFrom)).toBe(true);
});
it('forks only the active branch after rewind', async () => {
const oldId = '12121212-1212-1212-1212-121212121212';
const newId = '34343434-3434-3434-3434-343434343434';
const chatsDir = realPath.join(
service['storage'].getProjectDir(),
'chats',
);
fs.mkdirSync(chatsDir, { recursive: true });
fs.writeFileSync(
realPath.join(chatsDir, `${oldId}.jsonl`),
[
{
uuid: 'u1',
parentUuid: null,
sessionId: oldId,
type: 'user',
timestamp: '2026-04-22T00:00:00.000Z',
cwd,
version: 'test',
message: { role: 'user', parts: [{ text: 'first' }] },
},
{
uuid: 'u2',
parentUuid: 'u1',
sessionId: oldId,
type: 'assistant',
timestamp: '2026-04-22T00:00:01.000Z',
cwd,
version: 'test',
message: { role: 'model', parts: [{ text: 'first reply' }] },
},
{
uuid: 'u3',
parentUuid: 'u2',
sessionId: oldId,
type: 'user',
timestamp: '2026-04-22T00:00:02.000Z',
cwd,
version: 'test',
message: { role: 'user', parts: [{ text: 'second' }] },
},
{
uuid: 'u4',
parentUuid: 'u3',
sessionId: oldId,
type: 'assistant',
timestamp: '2026-04-22T00:00:03.000Z',
cwd,
version: 'test',
message: { role: 'model', parts: [{ text: 'second reply' }] },
},
{
uuid: 'rewind-1',
parentUuid: 'u2',
sessionId: oldId,
type: 'system',
subtype: 'rewind',
timestamp: '2026-04-22T00:00:04.000Z',
cwd,
version: 'test',
systemPayload: { targetTurnIndex: 1, truncatedCount: 2 },
},
]
.map((line) => JSON.stringify(line))
.join('\n') + '\n',
);
const result = await service.forkSession(oldId, newId);
const loaded = await service.loadSession(newId);
expect(result.copiedCount).toBe(3);
expect(
loaded?.conversation.messages.flatMap(
(message) => message.message?.parts?.map((part) => part.text) ?? [],
),
).toEqual(['first', 'first reply']);
});
it('throws when the source session does not exist', async () => {
const oldId = '33333333-3333-3333-3333-333333333333';
const newId = '44444444-4444-4444-4444-444444444444';

View file

@ -1003,11 +1003,11 @@ export class SessionService {
/**
* Forks a session to a new sessionId.
*
* Reads the source JSONL into memory, rewrites every record's `sessionId`
* to `newSessionId`, stamps `cwd` to this service's project root, rebuilds
* the `parentUuid` chain in write order so the fork is a linear
* continuation, stamps `forkedFrom: { sessionId, messageUuid }` on every
* copied record for audit, and writes the result to `<newId>.jsonl`.
* Reads the source JSONL into memory, reconstructs the active record chain,
* rewrites every record's `sessionId` to `newSessionId`, stamps `cwd` to this
* service's project root, rebuilds the `parentUuid` chain so the fork is a
* linear continuation, stamps `forkedFrom: { sessionId, messageUuid }` on
* every copied record for audit, and writes the result to `<newId>.jsonl`.
*
* Mirrors Claude Code's `/branch` storage model: full in-memory copy + per-
* message forkedFrom (see claude-code/src/commands/branch/branch.ts).
@ -1049,10 +1049,18 @@ export class SessionService {
);
}
// Rebuild the parentUuid chain in write order so the fork is a clean
// linear descendant. `forkedFrom` captures the origin of each message.
// Copy only the active branch. Rewind leaves old records in the JSONL as
// abandoned parentUuid branches; copying raw records would resurrect them.
const sourceRecords = this.reconstructHistory(records);
if (sourceRecords.length === 0) {
throw new Error(`Source session not found or empty: ${sourceSessionId}`);
}
// Rebuild the parentUuid chain in active-history order so the fork is a
// clean linear descendant. `forkedFrom` captures the origin of each
// message.
let prevUuid: string | null = null;
const forked: ChatRecord[] = records.map((record) => {
const forked: ChatRecord[] = sourceRecords.map((record) => {
const next: ChatRecord = {
...record,
sessionId: newSessionId,

View file

@ -27,7 +27,8 @@ const rootDir = join(__dirname, '..');
// `mid_turn_message_injected` event type/guard/registration, ~150 bytes).
// Bumped from 119KB to 122KB for the workspace extension management surface
// (install/update/enable/disable/uninstall/refresh/check update endpoints).
const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 122 * 1024;
// Bumped from 122KB to 124KB for daemon fork-session APIs/events.
const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 124 * 1024;
rmSync(join(rootDir, 'dist'), { recursive: true, force: true });
mkdirSync(join(rootDir, 'dist'), { recursive: true });

View file

@ -29,6 +29,7 @@ import type {
DaemonSessionContextUsageStatus,
BranchSessionRequest,
DaemonBranchedSession,
DaemonForkSessionResult,
DaemonRestoredSession,
DaemonSession,
DaemonSessionSummary,
@ -79,6 +80,7 @@ import type {
DaemonToolToggleResult,
DaemonRewindSnapshotInfo,
DaemonRewindResult,
ForkSessionRequest,
DaemonSessionHooksStatus,
DaemonWorkspaceExtensionsStatus,
ExtensionMutationResponse,
@ -1265,6 +1267,27 @@ export class DaemonClient {
);
}
async forkSession(
sessionId: string,
req: ForkSessionRequest,
clientId?: string,
): Promise<DaemonForkSessionResult> {
return await this.fetchWithTimeout(
`${this.baseUrl}/session/${encodeURIComponent(sessionId)}/fork`,
{
method: 'POST',
headers: this.headers({ 'Content-Type': 'application/json' }, clientId),
body: JSON.stringify({ directive: req.directive }),
},
async (res) => {
if (!res.ok) {
throw await this.failOnError(res, 'POST /session/:id/fork');
}
return (await res.json()) as DaemonForkSessionResult;
},
);
}
async sessionContext(
sessionId: string,
clientId?: string,

View file

@ -16,6 +16,7 @@ import {
type SubscribeOptions,
} from './DaemonClient.js';
import type {
DaemonForkSessionResult,
DaemonEvent,
DaemonRewindResult,
DaemonRewindSnapshotInfo,
@ -341,6 +342,14 @@ export class DaemonSessionClient {
});
}
async fork(directive: string): Promise<DaemonForkSessionResult> {
return await this.client.forkSession(
this.sessionId,
{ directive },
this.clientId,
);
}
/**
* One-sentence "where did I leave off" recap of this session. See
* `DaemonClient.recapSession` for the full contract: best-effort

View file

@ -332,6 +332,8 @@ export type {
DaemonProtocolVersions,
BranchSessionRequest,
DaemonBranchedSession,
DaemonForkSessionResult,
ForkSessionRequest,
DaemonRestoredSession,
DaemonSession,
DaemonAuthProviderId,

View file

@ -190,6 +190,16 @@ export interface DaemonBranchedSession extends DaemonRestoredSession {
forkedFrom: { sessionId: string; displayName: string };
}
export interface ForkSessionRequest {
directive: string;
}
export interface DaemonForkSessionResult {
sessionId: string;
description: string;
launched: boolean;
}
/** Sparse session record returned by `GET /workspace/:id/sessions`. */
export interface DaemonSessionSummary {
sessionId: string;

View file

@ -176,6 +176,12 @@ export function normalizeDaemonEvent(
case 'state_resync_required':
return normalizeStateResyncRequired(event, base);
case 'session_rewound':
return normalizeSessionRewound(event, base);
case 'session_branched':
return normalizeSessionBranched(event, base);
case 'prompt_cancelled': {
// Forward the optional `reason` (e.g. `'forward_failed'` from the
// bridge's C3 compensating broadcast) so consumers can distinguish a
@ -334,6 +340,48 @@ function normalizeStateResyncRequired(
];
}
function normalizeSessionRewound(
event: DaemonEvent,
base: NormalizedEventBase,
): DaemonUiEvent[] {
const promptId = getString(event.data, 'promptId');
const targetTurnIndex = numberField(event.data, 'targetTurnIndex');
if (!promptId || targetTurnIndex === undefined) {
return fallbackDebug(event, base, 'malformed session_rewound payload');
}
const sessionId = getString(event.data, 'sessionId');
return [
{
...base,
type: 'session.rewound',
promptId,
targetTurnIndex,
...(sessionId ? { sessionId } : {}),
},
];
}
function normalizeSessionBranched(
event: DaemonEvent,
base: NormalizedEventBase,
): DaemonUiEvent[] {
const sourceSessionId = getString(event.data, 'sourceSessionId');
const newSessionId = getString(event.data, 'newSessionId');
const displayName = getString(event.data, 'displayName');
if (!sourceSessionId || !newSessionId || !displayName) {
return fallbackDebug(event, base, 'malformed session_branched payload');
}
return [
{
...base,
type: 'session.branched',
sourceSessionId,
newSessionId,
displayName,
},
];
}
function normalizeFollowupSuggestion(
event: DaemonEvent,
base: NormalizedEventBase,

View file

@ -80,6 +80,18 @@ export function daemonUiEventToTerminalText(event: DaemonUiEvent): string {
`caught up (${event.replayedCount} replayed)`,
'2',
);
case 'session.rewound':
return terminalLine(
'rewound',
`${event.promptId} → turn ${event.targetTurnIndex}`,
'2',
);
case 'session.branched':
return terminalLine(
'branched',
`${event.sourceSessionId}${event.newSessionId} (${event.displayName})`,
'2',
);
case 'prompt.cancelled':
return terminalLine('cancelled', 'prompt cancelled', '33');
case 'followup.suggestion':

View file

@ -327,6 +327,17 @@ function applyDaemonTranscriptEvent(
// stream (or `selectors`) to drop a catch-up indicator. No
// transcript mutation.
break;
case 'session.rewound':
rewindTranscriptToUserTurn(next, event.targetTurnIndex);
break;
case 'session.branched':
appendStatusBlock(
next,
'status',
`Branched conversation "${event.displayName}". You are now in the branch.`,
event,
);
break;
case 'workspace.memory.changed':
case 'workspace.agent.changed':
case 'workspace.tool.toggled':
@ -969,6 +980,16 @@ function appendStatusBlock(
event.data !== undefined
? { data: event.data }
: {}),
...(event?.type === 'session.branched'
? {
source: 'session_branched',
data: {
sourceSessionId: event.sourceSessionId,
newSessionId: event.newSessionId,
displayName: event.displayName,
},
}
: {}),
};
appendBlock(state, block);
if (opts.clearActiveText !== false) clearActiveText(state);
@ -1173,6 +1194,58 @@ function takeBlocksOwnership(state: DaemonTranscriptState): void {
ownedBlocks.set(state, state.blocks);
}
// Applies a daemon rewind event to this in-memory transcript only. The target
// user turn and everything after it are removed so the rendered session view
// matches the already-rewound backend state.
function rewindTranscriptToUserTurn(
state: DaemonTranscriptState,
targetTurnIndex: number,
): void {
let userTurn = 0;
let lastUserIndex = -1;
for (let index = 0; index < state.blocks.length; index += 1) {
if (state.blocks[index]?.kind !== 'user') continue;
lastUserIndex = index;
if (userTurn === targetTurnIndex) {
truncateTranscriptBeforeBlock(state, index);
return;
}
userTurn += 1;
}
if (lastUserIndex >= 0 && targetTurnIndex >= userTurn) {
truncateTranscriptBeforeBlock(state, lastUserIndex);
}
}
function truncateTranscriptBeforeBlock(
state: DaemonTranscriptState,
blockIndex: number,
): void {
takeBlocksOwnership(state);
state.blocks = state.blocks.slice(0, blockIndex);
ownedBlocks.set(state, state.blocks);
state.blockIndexById = rebuildDaemonTranscriptBlockIndex(state.blocks);
state.toolBlockByCallId = {};
state.permissionBlockByRequestId = {};
for (const block of state.blocks) {
if (block.kind === 'tool')
state.toolBlockByCallId[block.toolCallId] = block.id;
if (block.kind === 'permission') {
state.permissionBlockByRequestId[block.requestId] = block.id;
}
}
state.trimmedToolNotificationByCallId = {};
state.activeUserBlockId = undefined;
state.activeAssistantBlockId = undefined;
state.activeThoughtBlockId = undefined;
state.activeAssistantBlockByParent = {};
state.activeThoughtBlockByParent = {};
state.currentToolCallId = undefined;
state.pendingUserShellCommand = undefined;
state.lastFollowupSuggestion = undefined;
state.toolProgress = {};
}
function appendBlock(
state: DaemonTranscriptState,
block: DaemonTranscriptBlock,

View file

@ -38,6 +38,8 @@ export type DaemonUiEventType =
| 'session.available_commands'
| 'session.state_resync_required'
| 'session.replay_complete'
| 'session.rewound'
| 'session.branched'
// Prompt lifecycle (cross-client)
| 'prompt.cancelled'
// Daemon assist push (server-side ghost-text suggestion)
@ -359,6 +361,20 @@ export interface DaemonUiReplayCompleteEvent extends DaemonUiEventBase {
lastReplayedEventId?: number;
}
export interface DaemonUiSessionRewoundEvent extends DaemonUiEventBase {
type: 'session.rewound';
sessionId?: string;
promptId: string;
targetTurnIndex: number;
}
export interface DaemonUiSessionBranchedEvent extends DaemonUiEventBase {
type: 'session.branched';
sourceSessionId: string;
newSessionId: string;
displayName: string;
}
/*
* Workspace events (Wave 3-4)
* */
@ -517,6 +533,8 @@ export type DaemonUiEvent =
| DaemonUiSessionAvailableCommandsEvent
| DaemonUiStateResyncRequiredEvent
| DaemonUiReplayCompleteEvent
| DaemonUiSessionRewoundEvent
| DaemonUiSessionBranchedEvent
// Prompt lifecycle (cross-client)
| DaemonUiPromptCancelledEvent
// Daemon assist push (server-side ghost-text suggestion)

View file

@ -1164,6 +1164,117 @@ describe('daemon UI normalizer and transcript reducer', () => {
expect((malformed[0] as { text: string }).text).not.toContain('secret');
});
it('normalizes session branch events as structured sidechannel events', () => {
const events = normalizeDaemonEvent({
id: 59,
v: 1,
type: 'session_branched',
data: {
sourceSessionId: '9976ed52-1bd3-48cd-b8dc-0f045009ad7d',
newSessionId: '7497af5d-b62f-42f4-82d7-6f2a81daf439',
displayName: 'support-branch-new3 (Branch 2)',
},
});
expect(events).toEqual([
expect.objectContaining({
type: 'session.branched',
sourceSessionId: '9976ed52-1bd3-48cd-b8dc-0f045009ad7d',
newSessionId: '7497af5d-b62f-42f4-82d7-6f2a81daf439',
displayName: 'support-branch-new3 (Branch 2)',
}),
]);
const state = reduceDaemonTranscriptEvents(
createDaemonTranscriptState({ now: 1 }),
events,
{ now: 2 },
);
expect(state.blocks).toEqual([
expect.objectContaining({
kind: 'status',
source: 'session_branched',
data: {
sourceSessionId: '9976ed52-1bd3-48cd-b8dc-0f045009ad7d',
newSessionId: '7497af5d-b62f-42f4-82d7-6f2a81daf439',
displayName: 'support-branch-new3 (Branch 2)',
},
}),
]);
});
it('rewinds to the last user turn when targetTurnIndex is out of range', () => {
let state = createDaemonTranscriptState({ now: 1 });
state = appendLocalUserTranscriptMessage(state, 'first', { now: 2 });
state = reduceDaemonTranscriptEvents(
state,
[{ type: 'assistant.text.delta', text: 'answer one' }],
{ now: 3 },
);
state = appendLocalUserTranscriptMessage(state, 'second', { now: 4 });
state = reduceDaemonTranscriptEvents(
state,
[{ type: 'assistant.text.delta', text: 'answer two' }],
{ now: 5 },
);
state = reduceDaemonTranscriptEvents(
state,
[
{
type: 'session.rewound',
promptId: 'prompt-2',
targetTurnIndex: 99,
},
],
{ now: 6 },
);
expect(state.blocks).toMatchObject([
{ kind: 'user', text: 'first' },
{ kind: 'assistant', text: 'answer one' },
]);
});
it('rewinds to an exact user turn index', () => {
let state = createDaemonTranscriptState({ now: 1 });
state = appendLocalUserTranscriptMessage(state, 'first', { now: 2 });
state = reduceDaemonTranscriptEvents(
state,
[{ type: 'assistant.text.delta', text: 'answer one' }],
{ now: 3 },
);
state = appendLocalUserTranscriptMessage(state, 'second', { now: 4 });
state = reduceDaemonTranscriptEvents(
state,
[{ type: 'assistant.text.delta', text: 'answer two' }],
{ now: 5 },
);
state = appendLocalUserTranscriptMessage(state, 'third', { now: 6 });
state = reduceDaemonTranscriptEvents(
state,
[{ type: 'assistant.text.delta', text: 'answer three' }],
{ now: 7 },
);
state = reduceDaemonTranscriptEvents(
state,
[
{
type: 'session.rewound',
promptId: 'prompt-2',
targetTurnIndex: 1,
},
],
{ now: 8 },
);
expect(state.blocks).toMatchObject([
{ kind: 'user', text: 'first' },
{ kind: 'assistant', text: 'answer one' },
]);
});
it('normalizes plan session updates as visible tool blocks', () => {
const state = reduceDaemonTranscriptEvents(
createDaemonTranscriptState({ now: 1 }),

View file

@ -904,9 +904,12 @@ export function App({
() => getBackgroundTaskActivityKey(messages),
[messages],
);
const [backgroundTasksRefreshTrigger, setBackgroundTasksRefreshTrigger] =
useState(0);
const backgroundTasks = useBackgroundTasks(
backgroundTaskActivityKey,
connection.status === 'connected',
backgroundTasksRefreshTrigger,
);
const footerTasks = useMemo(
() => (renderFooter ? backgroundTasks.map(mapToWebShellTaskInfo) : []),
@ -2083,6 +2086,53 @@ export function App({
setShowReleaseDialog(true);
return true;
}
if (cmd === 'branch') {
if (promptBlocked) return enqueuePrompt(text, images);
const branchName = text.slice(match[0].length).trim();
sessionActions
.branchSession(branchName || undefined)
.then((result) => {
store.dispatch([
{
type: 'status',
text: t('branch.success', {
name: result.displayName,
}),
},
]);
})
.catch((error: unknown) => {
reportError(error, t('branch.failed'));
});
return true;
}
if (cmd === 'fork') {
if (promptBlocked) return enqueuePrompt(text, images);
const directive = text.slice(match[0].length).trim();
if (!directive) {
pushToast('error', t('fork.empty'));
return true;
}
sessionActions
.forkSession(directive)
.then((result) => {
if (!result.launched) {
pushToast('warning', t('fork.notStarted'));
return;
}
setBackgroundTasksRefreshTrigger((value) => value + 1);
pushToast(
'success',
t('fork.started', { name: result.description }),
);
})
.catch((error: unknown) => {
const reason =
error instanceof Error ? error.message : String(error);
reportError(error, t('fork.failed', { reason }));
});
return true;
}
if (cmd === 'auth') {
store.appendLocalUserMessage(text);
setAuthInlineOpen(true);

View file

@ -169,6 +169,41 @@ describe('transcriptBlocksToDaemonMessages', () => {
]);
});
it('localizes structured session branch status blocks', () => {
const messages = transcriptBlocksToDaemonMessages(
[
statusBlock('branch-1', 'Branched conversation "old"', 1, {
source: 'session_branched',
data: {
sourceSessionId: 'source',
newSessionId: 'new',
displayName: 'support-branch-new3 (Branch 2)',
},
}),
],
{
labels: {
branchSuccess: (name) =>
`已复制会话,新会话名称为: "${name}",当前已切换到新的会话。`,
},
},
);
expect(messages).toEqual([
expect.objectContaining({
role: 'system',
content:
'已复制会话,新会话名称为: "support-branch-new3 (Branch 2)",当前已切换到新的会话。',
source: 'session_branched',
data: {
sourceSessionId: 'source',
newSessionId: 'new',
displayName: 'support-branch-new3 (Branch 2)',
},
}),
]);
});
it('ignores daemon plan entries without content', () => {
const plan = {
sessionUpdate: 'plan',

View file

@ -38,14 +38,64 @@ type ExtendedDaemonStatusTranscriptBlock = DaemonStatusTranscriptBlock & {
data?: unknown;
};
type ExtendedDaemonTextTranscriptBlock = DaemonTextTranscriptBlock & {
meta?: Record<string, unknown>;
};
interface TranscriptMessageLabels {
promptCancelled?: string;
branchSuccess?: (name: string) => string;
}
interface TranscriptMessageOptions {
labels?: TranscriptMessageLabels;
}
function isIgnoredWebShellStatus(text: string): boolean {
return (
text.startsWith('language_changed (unrecognized daemon event):') ||
text.startsWith('Model switched: ')
);
}
function getSessionBranchDisplayName(data: unknown): string | null {
if (!data || typeof data !== 'object') return null;
const branchData = data as {
displayName?: unknown;
newSessionId?: unknown;
};
if (typeof branchData.displayName === 'string' && branchData.displayName) {
return branchData.displayName;
}
return typeof branchData.newSessionId === 'string'
? branchData.newSessionId.slice(0, 8)
: null;
}
function isBackgroundNotificationAssistantBlock(
block: DaemonTextTranscriptBlock,
): boolean {
const extended = block as ExtendedDaemonTextTranscriptBlock;
const meta = extended.meta;
return (
meta?.['source'] === 'background_notification' &&
meta['qwenDiscreteMessage'] === true &&
meta['backgroundTask'] !== undefined
);
}
function normalizeAssistantTextBlock(
block: DaemonTextTranscriptBlock,
): DaemonTextTranscriptBlock | null {
if (isBackgroundNotificationAssistantBlock(block)) return null;
if (!block.text && !block.usage) return null;
return block;
}
function isTextBlockEmpty(block: DaemonTextTranscriptBlock): boolean {
return block.text.length === 0;
}
function parseDaemonTodoItemsFromEntries(
entries: readonly unknown[],
): DaemonMessageTodoItem[] | undefined {
@ -145,7 +195,10 @@ export function transcriptBlocksToDaemonMessages(
}
case 'assistant': {
const textBlock = block as DaemonTextTranscriptBlock;
const textBlock = normalizeAssistantTextBlock(
block as DaemonTextTranscriptBlock,
);
if (!textBlock) break;
const parentSubAgent = textBlock.parentToolCallId
? toolsByCallId.get(textBlock.parentToolCallId)
@ -210,7 +263,12 @@ export function transcriptBlocksToDaemonMessages(
currentAssistantIdx !== null
? messages[currentAssistantIdx]
: undefined;
if (target && target.role === 'assistant' && !needsNewContentMessage) {
if (
target &&
target.role === 'assistant' &&
!needsNewContentMessage &&
!isTextBlockEmpty(textBlock)
) {
const usage = mergeAssistantUsage(target.usage, textBlock.usage);
messages[currentAssistantIdx!] = {
...target,
@ -219,7 +277,7 @@ export function transcriptBlocksToDaemonMessages(
...(usage ? { usage } : {}),
};
needsNewContentMessage = false;
} else {
} else if (!isTextBlockEmpty(textBlock)) {
messages.push({
id: block.id,
role: 'assistant',
@ -230,6 +288,12 @@ export function transcriptBlocksToDaemonMessages(
});
currentAssistantIdx = messages.length - 1;
needsNewContentMessage = false;
} else if (textBlock.usage && target && target.role === 'assistant') {
const usage = mergeAssistantUsage(target.usage, textBlock.usage);
messages[currentAssistantIdx!] = {
...target,
...(usage ? { usage } : {}),
};
}
break;
}
@ -444,7 +508,15 @@ export function transcriptBlocksToDaemonMessages(
case 'status':
case 'debug': {
const statusBlock = block as ExtendedDaemonStatusTranscriptBlock;
const text = statusBlock.text;
const branchDisplayName =
statusBlock.source === 'session_branched'
? getSessionBranchDisplayName(statusBlock.data)
: null;
const text =
branchDisplayName && options.labels?.branchSuccess
? options.labels.branchSuccess(branchDisplayName)
: statusBlock.text;
if (isIgnoredWebShellStatus(text)) break;
const todos = parsePlanTodos(text);
if (todos) {
messages.push({

View file

@ -83,6 +83,16 @@ export function getLocalCommands(t: Translate): CommandInfo[] {
},
{ name: 'tasks', description: t('local.tasks') },
{ name: 'recap', description: t('local.recap') },
{
name: 'branch',
description: t('local.branch'),
argumentHint: '[<name>]',
},
{
name: 'fork',
description: t('local.fork'),
argumentHint: '<directive>',
},
{ name: 'clear', description: t('local.clear') },
{ name: 'new', description: t('local.new') },
{ name: 'reset', description: t('local.reset') },

View file

@ -15,6 +15,7 @@ function hasActiveTask(tasks: readonly DaemonSessionTaskStatus[]): boolean {
export function useBackgroundTasks(
taskActivityKey: string,
connected: boolean,
refreshTrigger = 0,
): DaemonSessionTaskStatus[] {
const actions = useActions();
const [tasks, setTasks] = useState<DaemonSessionTaskStatus[]>([]);
@ -38,6 +39,12 @@ export function useBackgroundTasks(
setPollingActive(true);
}, [connected, taskActivityKey]);
useEffect(() => {
if (!connected || refreshTrigger === 0) return;
emptyPollsRef.current = 0;
setPollingActive(true);
}, [connected, refreshTrigger]);
useEffect(() => {
if (tasksPanelActive) return;
if (!connected || !pollingActive) return;

View file

@ -10,7 +10,10 @@ export function useMessages(
return useMemo(
() =>
transcriptBlocksToDaemonMessages(blocks, {
labels: { promptCancelled: t('request.cancelled') },
labels: {
promptCancelled: t('request.cancelled'),
branchSuccess: (name) => t('branch.success', { name }),
},
}),
[blocks, t],
);

View file

@ -348,6 +348,14 @@ const EN: Messages = {
'help.shortcut.compact': 'Toggle compact mode',
'retry.hint': 'Press Ctrl+Y to retry or click to retry',
'retry.none': 'No failed request to retry.',
'branch.failed': 'Failed to branch session.',
'branch.success': (v) =>
`Copied session. New session name: "${v?.name ?? ''}". Switched to the new session.`,
'fork.empty': 'Please provide a directive. Usage: /fork <directive>',
'fork.failed': (v) => `Failed to launch fork: ${v?.reason ?? ''}`,
'fork.notStarted': 'Background agent was not launched.',
'fork.started': (v) =>
`Started background agent: "${v?.name ?? ''}". Track it in background tasks.`,
'command.hidden': 'This command is not available.',
'help.shortcut.approvals': 'Cycle approval modes',
'help.shortcut.cancel': 'Close dialogs or cancel operation',
@ -434,6 +442,8 @@ const EN: Messages = {
'local.copy': 'Copy last output or snippet',
'local.delete': 'Delete a session permanently',
'local.release': 'Release a live session',
'local.branch': 'Copy the current conversation into a new session',
'local.fork': 'Start a background agent from this conversation',
'local.help': 'Show help and commands',
'local.language': 'Change UI language',
'local.mcp': 'Manage MCP servers',
@ -1321,6 +1331,14 @@ const ZH: Messages = {
'help.shortcut.compact': '切换紧凑模式',
'retry.hint': '按 Ctrl+Y 重试或点击重试',
'retry.none': '没有可重试的失败请求。',
'branch.failed': '分支会话失败。',
'branch.success': (v) =>
`已复制会话,新会话名称为: "${v?.name ?? ''}",当前已切换到新的会话。`,
'fork.empty': '请提供任务指令。用法:/fork <指令>',
'fork.failed': (v) => `启动后台智能体失败:${v?.reason ?? ''}`,
'fork.notStarted': '后台智能体未启动。',
'fork.started': (v) =>
`已启动后台智能体:"${v?.name ?? ''}",可在后台任务中查看。`,
'command.hidden': '该命令不可用。',
'help.shortcut.approvals': '切换审批模式',
'help.shortcut.cancel': '关闭弹窗或取消操作',
@ -1403,6 +1421,8 @@ const ZH: Messages = {
'local.copy': '复制最后输出或代码片段',
'local.delete': '永久删除会话',
'local.release': '释放 live session',
'local.branch': '将当前对话复制到新会话',
'local.fork': '基于当前对话启动后台智能体',
'local.help': '查看帮助和可用命令',
'local.language': '切换 UI 语言',
'local.mcp': '管理 MCP servers',

View file

@ -101,6 +101,15 @@ interface MockClient {
getWorkspaceAgent: () => Promise<unknown>;
createWorkspaceAgent: () => Promise<unknown>;
deleteWorkspaceAgent: () => Promise<void>;
branchSession: (
sessionId: string,
req: { name?: string },
clientId?: string,
) => Promise<{
sessionId: string;
displayName: string;
clientId?: string;
}>;
}
const sdkMocks = vi.hoisted(() => {
@ -123,6 +132,7 @@ const sdkMocks = vi.hoisted(() => {
const getWorkspaceAgent = vi.fn();
const createWorkspaceAgent = vi.fn();
const deleteWorkspaceAgent = vi.fn();
const branchSession = vi.fn();
class MockDaemonClient {
constructor(_opts: unknown) {}
@ -145,6 +155,7 @@ const sdkMocks = vi.hoisted(() => {
getWorkspaceAgent = getWorkspaceAgent;
createWorkspaceAgent = createWorkspaceAgent;
deleteWorkspaceAgent = deleteWorkspaceAgent;
branchSession = branchSession;
dispose = vi.fn();
}
@ -176,6 +187,7 @@ const sdkMocks = vi.hoisted(() => {
MockDaemonClient,
MockDaemonSessionClient,
workspaceMcpTools,
branchSession,
reset() {
sessions.length = 0;
capabilities.mockReset();
@ -251,6 +263,12 @@ const sdkMocks = vi.hoisted(() => {
createWorkspaceAgent.mockResolvedValue({ ok: true });
deleteWorkspaceAgent.mockReset();
deleteWorkspaceAgent.mockResolvedValue(undefined);
branchSession.mockReset();
branchSession.mockResolvedValue({
sessionId: 'branch-session',
displayName: 'Branch Session',
clientId: 'branch-client',
});
MockDaemonSessionClient.createOrAttach.mockReset();
MockDaemonSessionClient.createOrAttach.mockImplementation(
async (client: unknown, _req: unknown): Promise<MockSession> =>
@ -2626,6 +2644,54 @@ describe('DaemonSessionProvider', () => {
expect(loadCalls[1]?.[3]).toBe('client-a');
});
it('reuses the branched session client when switching after branch', async () => {
window.sessionStorage.clear();
const sourceSession = createMockSession({
sessionId: 'session-a',
clientId: 'client-a',
});
const branchedSession = createMockSession({
sessionId: 'session-b',
clientId: 'client-b',
});
sdkMocks.branchSession.mockResolvedValue({
sessionId: 'session-b',
displayName: 'Branch 1',
clientId: 'client-b',
});
sdkMocks.sessions.push(sourceSession, branchedSession);
let actions: DaemonSessionActions | undefined;
function Harness() {
actions = useDaemonActions();
return null;
}
await renderWithProvider(<Harness />, { autoConnect: true });
await act(async () => {
await flushPromises();
});
const branch = requireActions(actions).branchSession('Branch 1');
await act(async () => {
await wait(5);
await flushPromises();
});
await expect(branch).resolves.toEqual({
sessionId: 'session-b',
displayName: 'Branch 1',
});
expect(sdkMocks.branchSession).toHaveBeenCalledWith(
'session-a',
{ name: 'Branch 1' },
'client-a',
);
const loadCalls = sdkMocks.MockDaemonSessionClient.load.mock.calls;
expect(loadCalls[0]?.[1]).toBe('session-b');
expect(loadCalls[0]?.[3]).toBe('client-b');
});
it('exposes daemon capabilities on the connection state', async () => {
sdkMocks.capabilities.mockResolvedValue({
v: 1,

View file

@ -9,6 +9,7 @@ import type {
DaemonSessionContextStatus,
DaemonSessionClient,
DaemonSessionBtwResult,
DaemonForkSessionResult,
DaemonMidTurnMessageResult,
DaemonSessionRecapResult,
DaemonSessionTaskStatus,
@ -27,6 +28,7 @@ import {
withActionTimeout,
type TimerRef,
} from '../timing.js';
import { persistStableClientId } from './clientLifecycle.js';
import type {
ActivePrompt,
AddDaemonSessionNotice,
@ -778,6 +780,70 @@ export function createDaemonSessionActions({
);
}
},
async branchSession(name?: string) {
const session = requireSessionForAction(
addNotice,
sessionRef.current,
'Branch session failed',
'branch_session',
);
try {
const result = await withActionTimeout(
session.client.branchSession(
session.sessionId,
{ name },
session.clientId,
),
'Branch session timed out',
);
persistStableClientId(result.clientId, result.sessionId);
void startSessionSwitch(result.sessionId, 'load').catch(
(switchError: unknown) => {
if (isAbortError(switchError)) return;
dispatchActionError(
addNotice,
'Branch session failed',
switchError,
'branch_session',
);
},
);
return {
sessionId: result.sessionId,
displayName: result.displayName,
};
} catch (error) {
throw dispatchActionError(
addNotice,
'Branch session failed',
error,
'branch_session',
);
}
},
async forkSession(directive: string): Promise<DaemonForkSessionResult> {
const session = requireSessionForAction(
addNotice,
sessionRef.current,
'Fork session failed',
'fork_session',
);
try {
return await withActionTimeout(
session.fork(directive),
'Fork session timed out',
);
} catch (error) {
throw dispatchActionError(
addNotice,
'Fork session failed',
error,
'fork_session',
);
}
},
};
}

View file

@ -11,6 +11,7 @@ import type {
DaemonApprovalMode,
DaemonApprovalModeResult,
DaemonAvailableCommand,
DaemonForkSessionResult,
DaemonSessionBtwResult,
DaemonMidTurnMessageResult,
DaemonSessionContextStatus,
@ -161,6 +162,8 @@ export type DaemonNoticeOperation =
| 'refresh_commands'
| 'recap_session'
| 'btw_session'
| 'branch_session'
| 'fork_session'
| 'stream'
| 'normalize_event';
@ -307,6 +310,10 @@ export interface DaemonSessionActions {
): Promise<{ cancelled: boolean }>;
clearGoal(): Promise<{ cleared: boolean; condition?: string }>;
getStats(): Promise<DaemonSessionStatsStatus>;
branchSession(
name?: string,
): Promise<{ sessionId: string; displayName: string }>;
forkSession(directive: string): Promise<DaemonForkSessionResult>;
}
export interface DaemonSessionContextValue {