Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 132 additions & 21 deletions packages/acp-bridge/src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
type ServeSessionTasksStatus,
} from './status.js';
import {
BranchWhilePromptActiveError,
SessionNotFoundError,
RestoreInProgressError,
InvalidSessionScopeError,
Expand Down Expand Up @@ -270,6 +271,7 @@ interface SessionEntry {
* inline session updates / permission requests can safely inherit this id.
*/
activePromptOriginatorClientId?: string;
promptActive: boolean;
/**
* Per-prompt "already broadcast `prompt_cancelled`" latch. The explicit
* `cancelSession` route and the `sendPrompt` abort path (originator SSE
Expand Down Expand Up @@ -1698,6 +1700,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
clientLastSeenAt: new Map(),
attachCount: 0,
spawnOwnerWantedKill: false,
promptActive: false,
};
ci.sessionIds.add(entry.sessionId);
byId.set(entry.sessionId, entry);
Expand Down Expand Up @@ -2283,30 +2286,38 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
} else {
entry.activePromptOriginatorClientId = originatorClientId;
}
// Echo the user prompt to the session bus so other SSE-subscribed
// clients see the input alongside the agent response.
//
// The interactive prompt path was the only one not emitting
// `user_message_chunk` — `Session#executePrompt` (the agent
// side) forwards the prompt directly to the LLM; the cron path
// (Session.ts:1402) and `HistoryReplayer` (line 65) emit it
// explicitly. Without this echo, multi-client UIs only saw
// assistant text from peer prompts — no record of who said what.
//
// Originator dedup: SDK consumers' `normalizeDaemonEvent` with
// `suppressOwnUserEcho: true` filters the echo when
// `event.originatorClientId === opts.clientId`. So the
// originator's local UI doesn't double-render its own input.
//
// Multi-modal: one envelope per content block. Non-text blocks
// pass through verbatim (the agent's Core multimodal echo is a
// for now the common text path is the immediate fix.
entry.cancelBroadcast = false;
echoPromptToSessionBus(entry, normalized, originatorClientId);
entry.promptActive = true;
Comment thread
doudouOUC marked this conversation as resolved.
try {
// Echo the user prompt to the session bus so other SSE-subscribed
// clients see the input alongside the agent response.
//
// The interactive prompt path was the only one not emitting
// `user_message_chunk` — `Session#executePrompt` (the agent
// side) forwards the prompt directly to the LLM; the cron path
// (Session.ts:1402) and `HistoryReplayer` (line 65) emit it
// explicitly. Without this echo, multi-client UIs only saw
// assistant text from peer prompts — no record of who said what.
//
// Originator dedup: SDK consumers' `normalizeDaemonEvent` with
// `suppressOwnUserEcho: true` filters the echo when
// `event.originatorClientId === opts.clientId`. So the
// originator's local UI doesn't double-render its own input.
//
// Multi-modal: one envelope per content block. Non-text blocks
// pass through verbatim (the agent's Core multimodal echo is a
// for now the common text path is the immediate fix.
entry.cancelBroadcast = false;
echoPromptToSessionBus(entry, normalized, originatorClientId);
} catch (echoErr) {
entry.promptActive = false;
delete entry.activePromptOriginatorClientId;
throw echoErr;
}
const promptPromise = entry.connection
.prompt(normalized)
.finally(() => {
delete entry.activePromptOriginatorClientId;
entry.promptActive = false;
});

// Race against channel termination: if the underlying transport
Expand Down Expand Up @@ -2632,6 +2643,106 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
}
},

async branchSession(sessionId, req, context) {
Comment thread
doudouOUC marked this conversation as resolved.
if (shuttingDown) throw new Error('AcpSessionBridge is shutting down');

const entry = byId.get(sessionId);
if (!entry) throw new SessionNotFoundError(sessionId);

let originatorClientId: string | undefined;
if (context?.clientId !== undefined) {
originatorClientId = resolveTrustedClientId(entry, context.clientId);
}

const branchResult = entry.promptQueue.then(async () => {
if (entry.promptActive) {
Comment thread
doudouOUC marked this conversation as resolved.
throw new BranchWhilePromptActiveError(sessionId);
}

if (
byId.size + inFlightSpawns.size + inFlightRestores.size >=
maxSessions
) {
throw new SessionLimitExceededError(maxSessions);
}

const ci = await ensureChannel();
const result = (await withTimeout(
ci.connection.extMethod(SERVE_CONTROL_EXT_METHODS.sessionBranch, {
sessionId,
cwd: boundWorkspace,
name: req.name,
}),
initTimeoutMs,
'branchSession',
)) as { newSessionId: string; title: string };

if (
!result ||
typeof result.newSessionId !== 'string' ||
typeof result.title !== 'string'
) {
throw new Error(
`branchSession: agent returned invalid response: ${JSON.stringify(result)}`,
);
}

let restored;
try {
restored = await restoreSession('resume', {
sessionId: result.newSessionId,
workspaceCwd: boundWorkspace,
clientId: context?.clientId,
});
} catch (restoreErr) {
writeStderrLine(
`qwen serve: branchSession resume failed for ${result.newSessionId}, attempting cleanup...`,
);
try {
await ci.connection.extMethod(
SERVE_CONTROL_EXT_METHODS.sessionClose,
{ sessionId: result.newSessionId, cwd: boundWorkspace },
);
} catch (cleanupErr) {
writeStderrLine(
`qwen serve: branchSession cleanup of ${result.newSessionId} failed: ${cleanupErr instanceof Error ? cleanupErr.message : cleanupErr}`,
);
}
throw restoreErr;
}

const newEntry = byId.get(result.newSessionId);
if (newEntry) newEntry.displayName = result.title;

const eventData = {
sourceSessionId: sessionId,
newSessionId: result.newSessionId,
displayName: result.title,
};
const branchEnvelope = {
type: 'session_branched' as const,
data: eventData,
...(originatorClientId ? { originatorClientId } : {}),
};
entry.events.publish(branchEnvelope);
broadcastWorkspaceEvent(branchEnvelope, sessionId);

return {
...restored,
title: result.title,
forkedFrom: {
sessionId,
title: entry.displayName ?? sessionId.slice(0, 8),
},
};
});
entry.promptQueue = branchResult.then(
() => undefined,
() => undefined,
);
return branchResult;
},

async closeSession(sessionId, context) {
const entry = byId.get(sessionId);
if (!entry) throw new SessionNotFoundError(sessionId);
Expand Down Expand Up @@ -2795,7 +2906,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
createdAt: entry.createdAt,
displayName: entry.displayName,
clientCount: entry.clientIds.size,
hasActivePrompt: entry.activePromptOriginatorClientId !== undefined,
hasActivePrompt: entry.promptActive,
Comment thread
doudouOUC marked this conversation as resolved.
Comment thread
doudouOUC marked this conversation as resolved.
});
}
}
Expand Down
11 changes: 11 additions & 0 deletions packages/acp-bridge/src/bridgeErrors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -445,3 +445,14 @@ export class InvalidRewindTargetError extends Error {
this.sessionId = sessionId;
}
}

export class BranchWhilePromptActiveError extends Error {
readonly sessionId: string;
constructor(sessionId: string) {
super(
`Cannot branch session ${sessionId}: a prompt is currently active`,
);
this.name = 'BranchWhilePromptActiveError';
this.sessionId = sessionId;
}
}
19 changes: 19 additions & 0 deletions packages/acp-bridge/src/bridgeTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,15 @@ export interface BridgeRestoredSession extends BridgeSession {
lastEventId?: number;
}

export interface BridgeBranchSessionRequest {
name?: string;
}

export interface BridgeBranchedSession extends BridgeRestoredSession {
title: string;
forkedFrom: { sessionId: string; title: string };
}

/** Sparse summary used by `GET /workspace/:id/sessions`. */
export interface BridgeSessionSummary {
sessionId: string;
Expand Down Expand Up @@ -192,6 +201,16 @@ export interface AcpSessionBridge {
req: BridgeRestoreSessionRequest,
): Promise<BridgeRestoredSession>;

/**
* Fork a live session's JSONL transcript and load the fork via resume
* semantics (no history replay). Source must be idle (no active prompt).
*/
branchSession(
sessionId: string,
req: BridgeBranchSessionRequest,
context?: BridgeClientRequestContext,
): Promise<BridgeBranchedSession>;

/**
* Forward a prompt to the agent. Concurrent prompts against the same
* session FIFO-serialize through a per-session queue. Throws
Expand Down
1 change: 1 addition & 0 deletions packages/acp-bridge/src/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ export const SERVE_STATUS_EXT_METHODS = {
export const SERVE_CONTROL_EXT_METHODS = {
sessionClose: 'qwen/control/session/close',
sessionApprovalMode: 'qwen/control/session/approval_mode',
sessionBranch: 'qwen/control/session/branch',
sessionRecap: 'qwen/control/session/recap',
sessionBtw: 'qwen/control/session/btw',
sessionShellHistory: 'qwen/control/session/shell_history',
Expand Down
76 changes: 76 additions & 0 deletions packages/cli/src/acp-integration/acpAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@ import {
MCPOAuthTokenStorage,
subagentGenerator,
redactUrlCredentials,
computeUniqueBranchTitle,
} from '@qwen-code/qwen-code-core';
import { randomUUID } from 'node:crypto';
import type {
ApprovalMode,
Config,
Expand Down Expand Up @@ -3314,6 +3316,80 @@ class QwenAgent implements Agent {
apiKeyEnvKey: cfg?.apiKeyEnvKey ?? null,
};
}
case SERVE_CONTROL_EXT_METHODS.sessionBranch: {
Comment thread
doudouOUC marked this conversation as resolved.
const sessionId = params['sessionId'];
if (typeof sessionId !== 'string' || !SESSION_ID_RE.test(sessionId)) {
throw RequestError.invalidParams(
undefined,
'Invalid or missing sessionId',
);
}
const name = params['name'];

const sourceSession = this.sessions.get(sessionId);
if (!sourceSession) {
throw new RequestError(-32004, `Session not found: ${sessionId}`, {
errorKind: 'session_not_found',
sessionId,
});
}

const recording = sourceSession.getConfig().getChatRecordingService();
if (recording) {
await recording.flush();
}

const newSessionId = randomUUID();
return await runWithAcpRuntimeOutputDir(
this.settings,
cwd,
async () => {
const sessionService = new SessionService(cwd);
await sessionService.forkSession(sessionId, newSessionId);

let title: string;
try {
let baseName: string;
if (typeof name === 'string' && name.trim().length > 0) {
baseName = name.trim();
} else {
const existingTitle = recording?.getCurrentCustomTitle();
const stripped = existingTitle
?.replace(/\s*\(Branch(?:\s+\d+)?\)\s*$/, '')
.trim();
if (stripped && stripped.length > 0) {
baseName = stripped;
} else {
baseName = sessionId.slice(0, 8);
}
}

title = await computeUniqueBranchTitle(baseName, sessionService);
Comment thread
doudouOUC marked this conversation as resolved.
const renamed = await sessionService.renameSession(
newSessionId,
title,
'manual',
);
if (!renamed) {
throw new RequestError(
-32603,
`Failed to set title on forked session ${newSessionId}`,
{ errorKind: 'internal', sessionId: newSessionId },
);
}
} catch (err) {
sessionService.removeSession(newSessionId).catch((rmErr) => {
process.stderr.write(
`qwen serve: failed to clean up orphan session ${newSessionId}: ${rmErr instanceof Error ? rmErr.message : rmErr}\n`,
);
});
throw err;
}

return { newSessionId, title };
},
);
}
default:
throw RequestError.methodNotFound(method);
}
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/serve/acpSessionBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ export type {
} from '@qwen-code/acp-bridge/bridgeTypes';

export {
BranchWhilePromptActiveError,
SessionNotFoundError,
RestoreInProgressError,
InvalidSessionScopeError,
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/serve/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ export const SERVE_CAPABILITY_REGISTRY = {
workspace_hooks: { since: 'v1' },
session_hooks: { since: 'v1' },
workspace_extensions: { since: 'v1' },
session_branch: { since: 'v1' },
Comment thread
doudouOUC marked this conversation as resolved.
} as const satisfies Record<string, ServeCapabilityDescriptor>;

export type ServeFeature = keyof typeof SERVE_CAPABILITY_REGISTRY;
Expand Down
5 changes: 4 additions & 1 deletion packages/cli/src/serve/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ const EXPECTED_STAGE1_FEATURES = [
'workspace_hooks',
'session_hooks',
'workspace_extensions',
'session_branch',
] as const;

// Issue #4175 PR 15. `require_auth` is registered but conditionally
Expand Down Expand Up @@ -209,7 +210,8 @@ const EXPECTED_REGISTERED_FEATURES = [
f !== 'session_rewind' &&
f !== 'workspace_hooks' &&
f !== 'session_hooks' &&
f !== 'workspace_extensions',
f !== 'workspace_extensions' &&
f !== 'session_branch',
),
'workspace_settings',
'workspace_init',
Expand All @@ -229,6 +231,7 @@ const EXPECTED_REGISTERED_FEATURES = [
'workspace_hooks',
Comment thread
doudouOUC marked this conversation as resolved.
'session_hooks',
'workspace_extensions',
'session_branch',
] as const;

interface FakeBridgeOpts {
Expand Down
Loading