Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
fa5604c
feat(serve): support caller-supplied sessionId in POST /session (#7831)
qwen-code-dev-bot Jul 27, 2026
2d050d2
fix(serve): enforce UUID format for caller-supplied sessionId (#7836)
qwen-code-dev-bot Jul 27, 2026
109da65
Merge branch 'main' into feat/session-id-passthrough
qwen-code-dev-bot Jul 27, 2026
8a49c28
fix(serve): reject duplicate caller-supplied sessionId with 409 (#7836)
qwen-code-dev-bot Jul 27, 2026
87a434a
Merge branch 'main' into feat/session-id-passthrough
qwen-code-dev-bot Jul 27, 2026
952ff1c
fix(serve): extract HTTP_SESSION_ID_REGEX constant to document diverg…
Jul 27, 2026
7d6f2c8
Merge branch 'main' into feat/session-id-passthrough
qwen-code-dev-bot Jul 27, 2026
c572512
fix(serve): guard concurrent duplicate sessionId with in-flight set (…
Jul 27, 2026
1db646e
Merge branch 'main' into feat/session-id-passthrough
qwen-code-dev-bot Jul 27, 2026
ac7864f
fix(cli): close TOCTOU race in sessionId in-flight guard (#7836)
qwen-code Jul 28, 2026
38bffa2
Merge branch 'main' into feat/session-id-passthrough
wenshao Jul 28, 2026
5bd043d
test(cli): assert sessionScope in sessionId forwarding test (#7836)
qwen-code-ci-bot Jul 28, 2026
6043fa0
fix(cli): normalize caller-supplied sessionId to lowercase (#7836)
qwen-code-ci-bot Jul 28, 2026
3a14e6e
Merge branch 'main' into feat/session-id-passthrough
qwen-code-dev-bot Jul 28, 2026
8d632bd
test(cli): cover sessionId extraction from ACP _meta in newSession (#…
qwen-code-dev-bot Jul 28, 2026
50e8577
test(cli): cover sessionId cleanup on spawn failure and bridge meta i…
Jul 28, 2026
bfb131d
Merge branch 'main' into feat/session-id-passthrough
qwen-code-dev-bot Jul 28, 2026
ed22833
Merge branch 'main' into feat/session-id-passthrough
wenshao Jul 28, 2026
d27b511
Merge branch 'main' into feat/session-id-passthrough
wenshao Jul 28, 2026
eda89d5
Merge branch 'main' into feat/session-id-passthrough
qwen-code-dev-bot Jul 29, 2026
7cd5763
chore(serve): merge main, combine sessionId and session-source newSes…
qwen-code-dev-bot Jul 30, 2026
d9cae4c
fix(serve): wrap sessionId existence check in runWithRuntimeBaseDir (…
Jul 30, 2026
4aecfb9
fix(serve): capture narrowed sessionId in const for closure type chec…
Jul 30, 2026
e194728
Merge branch 'main' into feat/session-id-passthrough
qwen-code-dev-bot Jul 30, 2026
087d03f
Merge remote-tracking branch 'origin/main' into feat/session-id-passt…
Jul 30, 2026
2dc439d
fix(serve): degrade one session, not the ACP child, on duplicate sess…
Jul 30, 2026
6ddeff1
fix(serve): clean up branch and worktree when session id is not honor…
Jul 30, 2026
e58fdec
Merge branch 'main' into feat/session-id-passthrough
wenshao Jul 31, 2026
c016302
fix(serve): 409 on a live-but-unflushed duplicate sessionId (#7836)
wenshao Jul 31, 2026
c600a9d
Merge branch 'main' into feat/session-id-passthrough
qwen-code-dev-bot Jul 31, 2026
0b89e9c
fix(cli): correct misleading daemon-wide comment on session liveness …
qwen-code-ci-bot Jul 31, 2026
744ca37
test(cli): pin throwOnSessionIdConflict arg in ACP sessionId test (#7…
qwen-code-ci-bot Jul 31, 2026
3ffb369
Merge branch 'main' into feat/session-id-passthrough
qwen-code-dev-bot Jul 31, 2026
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
22 changes: 22 additions & 0 deletions packages/acp-bridge/src/bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ import {
} from './internal/testUtils.js';
import { SessionArtifactAuthorizationError } from './sessionArtifacts.js';
import {
REQUESTED_SESSION_ID_META_KEY,
MID_TURN_QUEUE_DRAIN_METHOD,
PROMPT_CANCEL_METHOD,
TODO_STOP_GUARD_CONTINUATION_CLAIM_METHOD,
Expand Down Expand Up @@ -1282,6 +1283,27 @@ describe('createAcpSessionBridge', () => {
expect(handles[0]?.killed).toBe(true);
});

it('injects REQUESTED_SESSION_ID_META_KEY into newSession _meta when sessionId is set', async () => {
const handles: ChannelHandle[] = [];
const factory: ChannelFactory = async () => {
const h = makeChannel();
handles.push(h);
return h.channel;
};
const bridge = makeBridge({ channelFactory: factory });

await bridge.spawnOrAttach({
workspaceCwd: WS_A,
sessionId: '550e8400-e29b-41d4-a716-446655440000',
});

expect(handles[0]?.agent.newSessionCalls[0]!._meta).toMatchObject({
[REQUESTED_SESSION_ID_META_KEY]: '550e8400-e29b-41d4-a716-446655440000',
});

await bridge.shutdown();
});

it('reuses the existing session under sessionScope:single', async () => {
const handles: ChannelHandle[] = [];
const factory: ChannelFactory = async () => {
Expand Down
16 changes: 14 additions & 2 deletions packages/acp-bridge/src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ import {
LOAD_REPLAY_PAGE_SIZE_META_KEY,
LOAD_REPLAY_VERSION,
PROMPT_CANCEL_METHOD,
REQUESTED_SESSION_ID_META_KEY,
TODO_STOP_GUARD_QUEUE_RELEASE_METHOD,
WORKTREE_MCP_DEFER_META_KEY,
} from './bridgeTypes.js';
Expand Down Expand Up @@ -2602,6 +2603,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
sourceId?: string,
worktree?: { slug: string; path: string; branch: string },
branch?: { name: string; baseBranch: string },
requestedSessionId?: string,
): Promise<BridgeSession> {
// Get-or-create the daemon's single channel, then call
// `connection.newSession()` on it. Sessions share the child's
Expand Down Expand Up @@ -2658,8 +2660,17 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
const request = telemetry.injectPromptContext({
cwd: boundWorkspace,
mcpServers: [],
...(sourceType
? { _meta: sessionSourceRequestMeta(sourceType, sourceId) }
...(requestedSessionId || sourceType
? {
_meta: {
...sessionSourceRequestMeta(sourceType, sourceId),
...(requestedSessionId
? {
[REQUESTED_SESSION_ID_META_KEY]: requestedSessionId,
}
: {}),
},
}
: {}),
});
const response = await withTimeout(
Expand Down Expand Up @@ -5355,6 +5366,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
source.sourceId,
req.worktree,
req.branch,
req.sessionId,
);
// Track in-flight spawns regardless of scope. Under `single`
// this also serves the coalescing path above (a parallel
Expand Down
10 changes: 10 additions & 0 deletions packages/acp-bridge/src/bridgeTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,14 @@ export interface BridgeSpawnRequest {
worktree?: { slug: string; path: string; branch: string };
/** Branch metadata, set by the daemon route before spawn. */
branch?: { name: string; baseBranch: string };
/**
* Optional caller-supplied session id. When provided, the agent uses this
* id instead of generating a random UUID. Must be validated at the route
* boundary since the core Config constructor uses it verbatim. Passed
* through ACP `_meta` since the protocol's NewSessionRequest has no native
* sessionId field.
*/
sessionId?: string;
}

export interface BridgeSession {
Expand Down Expand Up @@ -172,6 +180,8 @@ export const LOAD_REPLAY_HIDE_INHERITED_META_KEY =
export const LOAD_REPLAY_BULK_MODE = 'bulk';
export const LOAD_REPLAY_VERSION = 1 as const;

export const REQUESTED_SESSION_ID_META_KEY = 'qwen-code/sessionId';

export const CHANNEL_STARTUP_PROFILE_META_KEY =
'qwen.daemon.channelStartupProfile';
export const CHANNEL_STARTUP_PROFILE_VERSION = 1 as const;
Expand Down
62 changes: 60 additions & 2 deletions packages/cli/src/acp-integration/acpAgent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -723,6 +723,14 @@ vi.mock('../config/loadedSettingsAdapter.js', () => ({
vi.mock('../config/config.js', () => ({
loadCliConfig: vi.fn(),
buildDisabledSkillNamesProvider: vi.fn(() => () => new Set<string>()),
SessionIdConflictError: class SessionIdConflictError extends Error {
sessionId: string;
constructor(sessionId: string, message: string) {
super(message);
this.name = 'SessionIdConflictError';
this.sessionId = sessionId;
}
},
}));
vi.mock('../ui/commands/contextCommand.js', () => ({
collectContextData: vi.fn().mockResolvedValue({
Expand Down Expand Up @@ -843,13 +851,13 @@ import type {
McpServer,
ResumeSessionResponse,
} from '@agentclientprotocol/sdk';
import { AgentSideConnection } from '@agentclientprotocol/sdk';
import { AgentSideConnection, RequestError } from '@agentclientprotocol/sdk';
import { loadSettings, SettingScope } from '../config/settings.js';
import {
MAX_PERMISSION_RULE_LENGTH,
MAX_PERMISSION_RULES_COUNT,
} from '../config/permission-settings.js';
import { loadCliConfig } from '../config/config.js';
import { loadCliConfig, SessionIdConflictError } from '../config/config.js';
import { createLoadedSettingsAdapter } from '../config/loadedSettingsAdapter.js';
import { AcpFileSystemService } from './service/filesystem.js';
import { Session, buildAvailableCommandsSnapshot } from './session/Session.js';
Expand Down Expand Up @@ -2804,6 +2812,56 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
await agentPromise;
});

it('forwards a caller-supplied sessionId from _meta to loadCliConfig', async () => {
await setupSessionMocks('meta-session');
const { agent, agentPromise } = await bootAcpAgent();

await agent.newSession({
cwd: '/tmp',
mcpServers: [],
_meta: { 'qwen-code/sessionId': '550e8400-e29b-41d4-a716-446655440000' },
});

const argv = vi.mocked(loadCliConfig).mock.calls[0]![1];
expect(argv).toMatchObject({
sessionId: '550e8400-e29b-41d4-a716-446655440000',
});
Comment thread
qwen-code-dev-bot marked this conversation as resolved.
// Index 8 is `throwOnSessionIdConflict`: it must be true so a duplicate
// caller-supplied id throws (mapped to a RequestError) instead of
// process.exit(1)-ing the shared ACP child.
expect(vi.mocked(loadCliConfig).mock.calls[0]![8]).toBe(true);

mockConnectionState.resolve();
await agentPromise;
});

it('maps a duplicate sessionId conflict to a RequestError instead of crashing the child', async () => {
const conflict = new SessionIdConflictError(
'550e8400-e29b-41d4-a716-446655440000',
'Error: Session Id 550e8400-e29b-41d4-a716-446655440000 already exists (active or archived). Delete or unarchive it first.',
);
vi.mocked(loadCliConfig).mockRejectedValue(conflict);
const agentPromise = runAcpAgent(
mockConfig,
makeSessionSettings(),
mockArgv,
);
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
const agent = capturedAgentFactory!({
get closed() {
return mockConnectionState.promise;
},
}) as AgentLike;

await expect(
agent.newSession({ cwd: '/tmp', mcpServers: [] }),
).rejects.toThrow('already exists');
expect(RequestError.invalidParams).toHaveBeenCalled();

mockConnectionState.resolve();
await agentPromise;
});

it('creates a session when OpenTelemetry is disabled', async () => {
mockWithDaemonSpan.mockImplementationOnce(
async (_name, _attributes, fn) => await fn(undefined),
Expand Down
17 changes: 16 additions & 1 deletion packages/cli/src/acp-integration/acpAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ import type { CliArgs } from '../config/config.js';
import {
buildDisabledSkillNamesProvider,
loadCliConfig,
SessionIdConflictError,
} from '../config/config.js';
import { resolveSkillSettings } from '../config/skill-settings.js';
import {
Expand Down Expand Up @@ -306,6 +307,7 @@ import {
LOAD_REPLAY_PAGE_SIZE_META_KEY,
LOAD_REPLAY_VERSION,
PROMPT_CANCEL_METHOD,
REQUESTED_SESSION_ID_META_KEY,
TODO_STOP_GUARD_QUEUE_RELEASE_METHOD,
WORKTREE_MCP_DEFER_META_KEY,
type ClientMcpOverWsRuntimeConfig,
Expand Down Expand Up @@ -4422,6 +4424,10 @@ class QwenAgent implements Agent {

async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
const { cwd, mcpServers } = params;
const requestedSessionId =
typeof params._meta?.[REQUESTED_SESSION_ID_META_KEY] === 'string'
? (params._meta[REQUESTED_SESSION_ID_META_KEY] as string)
: undefined;
Comment thread
qwen-code-dev-bot marked this conversation as resolved.
Comment thread
qwen-code-dev-bot marked this conversation as resolved.
const sessionSource = getSessionSource(params);
const parentContext = extractDaemonTraceContext(params);
return await withDaemonSpan(
Expand All @@ -4445,7 +4451,7 @@ class QwenAgent implements Agent {
mcpServers,
settings,
sessionSource,
undefined,
requestedSessionId,
undefined,
shouldDeferMcpDiscovery(params)
? { skipMcpDiscovery: true }
Expand Down Expand Up @@ -10900,6 +10906,9 @@ class QwenAgent implements Agent {
);
});
} catch (error) {
if (error instanceof SessionIdConflictError) {
throw RequestError.invalidParams(undefined, error.message);
}
const writerError = getSessionWriterError(error);
if (writerError) {
throw new RequestError(writerError.rpcCode, writerError.message, {
Expand Down Expand Up @@ -11025,6 +11034,12 @@ class QwenAgent implements Agent {
// into the first <available_skills> at cold start.
buildDisabledSkillNamesProvider(settings),
sessionMcpServers,
// The daemon owns the settings watcher lifecycle.
undefined,
// A duplicate caller-supplied session id must fail this one request,
// not process.exit(1) the shared ACP child and every session on its
// channel. newSessionConfig maps the throw to a RequestError.
true,
);
if (sessionSource) {
config.setSessionSource(sessionSource.sourceType, sessionSource.sourceId);
Expand Down
66 changes: 65 additions & 1 deletion packages/cli/src/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,12 @@ import {
NativeLspService,
Storage,
} from '@qwen-code/qwen-code-core';
import { loadCliConfig, parseArguments, type CliArgs } from './config.js';
import {
loadCliConfig,
parseArguments,
SessionIdConflictError,
type CliArgs,
} from './config.js';
import type { Settings } from './settings.js';
import * as ServerConfig from '@qwen-code/qwen-code-core';
import { isWorkspaceTrusted } from './trustedFolders.js';
Expand All @@ -28,6 +33,7 @@ const mockSessionServiceInstance = vi.hoisted(() => ({
loadSession: vi.fn(),
forkSession: vi.fn(),
sessionExists: vi.fn(),
sessionExistsInAnyState: vi.fn(),
}));
const mockSessionServiceCtor = vi.hoisted(() =>
vi.fn(() => mockSessionServiceInstance),
Expand Down Expand Up @@ -1011,6 +1017,7 @@ describe('loadCliConfig', () => {
copiedCount: 1,
});
mockSessionServiceInstance.sessionExists.mockResolvedValue(false);
mockSessionServiceInstance.sessionExistsInAnyState.mockResolvedValue(false);
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
vi.stubEnv('GEMINI_API_KEY', 'test-api-key');
resetMcpApprovalsForTesting();
Expand Down Expand Up @@ -1675,6 +1682,63 @@ describe('loadCliConfig', () => {
expect(mockExit).toHaveBeenCalledWith(1);
});

it('should exit when a caller-supplied sessionId already exists (default CLI behavior)', async () => {
const sessionId = '123e4567-e89b-12d3-a456-426614174000';
mockSessionServiceInstance.sessionExistsInAnyState.mockResolvedValue(true);
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit called');
});

await expect(loadCliConfig({}, { sessionId } as CliArgs)).rejects.toThrow(
'process.exit called',
);

expect(mockExit).toHaveBeenCalledWith(1);
});

it('should throw SessionIdConflictError instead of exiting when throwOnSessionIdConflict is set', async () => {
const sessionId = '123e4567-e89b-12d3-a456-426614174000';
mockSessionServiceInstance.sessionExistsInAnyState.mockResolvedValue(true);
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit called');
});

const promise = loadCliConfig(
{},
{ sessionId } as CliArgs,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
true,
);

await expect(promise).rejects.toBeInstanceOf(SessionIdConflictError);
await expect(promise).rejects.toMatchObject({ sessionId });
expect(mockExit).not.toHaveBeenCalled();
});

it('should not throw for a fresh caller-supplied sessionId when throwOnSessionIdConflict is set', async () => {
const sessionId = '123e4567-e89b-12d3-a456-426614174000';
mockSessionServiceInstance.sessionExistsInAnyState.mockResolvedValue(false);

const config = await loadCliConfig(
{},
{ sessionId } as CliArgs,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
true,
);

expect(config.getSessionId()).toBe(sessionId);
});

it('should use internal sandbox session ID without treating it as a new session', async () => {
const sessionId = '123e4567-e89b-12d3-a456-426614174000';
vi.stubEnv('SANDBOX', 'sandbox-exec');
Expand Down
26 changes: 26 additions & 0 deletions packages/cli/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1475,6 +1475,22 @@ export function buildDisabledSkillNamesProvider(
return () => resolveSkillSettings(loadedSettings).disabledNames;
}

/**
* Thrown (instead of `process.exit(1)`) when a caller-supplied session id
* already exists and `throwOnSessionIdConflict` is set. The interactive CLI
* exits the process on a duplicate id, but that would kill a shared ACP child
* and every session on its channel — embedded callers catch this and fail the
* single request instead.
*/
export class SessionIdConflictError extends Error {
readonly sessionId: string;
constructor(sessionId: string, message: string) {
super(message);
this.name = 'SessionIdConflictError';
this.sessionId = sessionId;
}
}

export async function loadCliConfig(
settings: Settings,
argv: CliArgs,
Expand Down Expand Up @@ -1519,6 +1535,13 @@ export async function loadCliConfig(
* core decoupled from the CLI-owned `SettingsWatcher` implementation.
*/
settingsWatcher?: { stopWatching(): void },
/**
* When true, a duplicate caller-supplied session id throws
* `SessionIdConflictError` instead of calling `process.exit(1)`. Embedded
* callers (ACP/daemon) set this so one conflicting `newSession` degrades a
* single request rather than terminating the shared child process.
*/
throwOnSessionIdConflict = false,
): Promise<Config> {
const debugMode = isDebugMode(argv);
if (debugMode && process.env['QWEN_DEBUG_LOG_FILE'] === undefined) {
Expand Down Expand Up @@ -1997,6 +2020,9 @@ export async function loadCliConfig(
);
if (exists) {
const message = `Error: Session Id ${argv['sessionId']} already exists (active or archived). Delete or unarchive it first.`;
if (throwOnSessionIdConflict) {
throw new SessionIdConflictError(argv['sessionId'], message);
}
writeStderrLine(message);
process.exit(1);
}
Expand Down
Loading
Loading