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
222 changes: 221 additions & 1 deletion packages/cli/src/acp-integration/acpAgent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ import {
afterAll,
type MockInstance,
} from 'vitest';
import { readFileSync } from 'node:fs';
import type { Stats } from 'node:fs';
import * as ts from 'typescript';
import * as fs from 'node:fs/promises';
import * as os from 'node:os';
import * as path from 'node:path';
Expand Down Expand Up @@ -15070,6 +15072,68 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
await agentPromise;
});

it('resolves sessionTurnStatus settings per request, not from the this.settings cache', async () => {
const sessionId = '11111111-1111-1111-1111-111111111111';
await setupSessionMocks(sessionId);
const readPage = vi.fn().mockResolvedValue({
sessionId,
records: [],
hasMore: false,
gaps: [],
startTime: 'start',
lastUpdated: 'end',
});
vi.mocked(SessionTranscriptReader).mockImplementation(
() =>
({
readPage,
}) as unknown as InstanceType<typeof SessionTranscriptReader>,
);

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: [] });

// Multi-workspace daemon shape: the live session and `this.settings`
// belong to the boot workspace; the status request names another cwd
// whose own settings (and therefore advanced.runtimeOutputDir) must pin
// the transcript read. Routing through the stale cache would scan the
// wrong runtime root.
const perRequestSettings = makeSessionSettings();
vi.mocked(loadSettings).mockClear();
vi.mocked(loadSettings).mockReturnValue(perRequestSettings);
vi.mocked(runWithAcpRuntimeOutputDir).mockClear();

await expect(
agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionTurnStatus, {
cwd: '/tmp/workspace-a',
sessionId,
promptId: 'prompt-1',
}),
).resolves.toEqual({ v: 1, sessionId, turnResult: null });

expect(loadSettings).toHaveBeenCalledWith('/tmp/workspace-a');
expect(runWithAcpRuntimeOutputDir).toHaveBeenCalledTimes(1);
expect(vi.mocked(runWithAcpRuntimeOutputDir).mock.calls[0]![0]).toBe(
perRequestSettings,
);
expect(vi.mocked(runWithAcpRuntimeOutputDir).mock.calls[0]![1]).toBe(
'/tmp/workspace-a',
);

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

it('still scans the transcript when the pre-read flush fails', async () => {
const sessionId = '11111111-1111-1111-1111-111111111111';
const innerConfig = await setupSessionMocks(sessionId);
Expand Down Expand Up @@ -16641,6 +16705,66 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
await agentPromise;
});

it('resolves qwen/status/session/transcript settings per request, not from the this.settings cache', async () => {
const settings = makeCoreSettings();
mockRunExitCleanup.mockResolvedValue(undefined);
const transcriptConfig = {
...makeInnerConfig(),
enableFileCheckpointing: vi.fn(),
};
vi.mocked(loadCliConfig).mockResolvedValue(
transcriptConfig as unknown as Config,
);
const readPage = vi.fn().mockResolvedValue({
sessionId: VALID_SESSION_ID,
records: [],
hasMore: false,
gaps: [],
startTime: 'start',
lastUpdated: 'end',
});
vi.mocked(SessionTranscriptReader).mockImplementation(
() =>
({
readPage,
}) as unknown as InstanceType<typeof SessionTranscriptReader>,
);
mockHistoryReplayPage.mockResolvedValue({ pendingToolCalls: [] });
const { agent, agentPromise } = await bootCoreSettingsAgent(settings);

// Multi-workspace daemon shape: `this.settings` holds the boot
// workspace's settings; the transcript request names another cwd whose
// own settings must pin the read (and seed the replay config).
// A different outputLanguage makes the request's settings distinguishable
// from the boot settings by content, not just by identity.
const perRequestSettings = makeCoreSettings('French');
vi.mocked(loadSettings).mockClear();
vi.mocked(loadSettings).mockReturnValue(perRequestSettings);
vi.mocked(runWithAcpRuntimeOutputDir).mockClear();

await agent.extMethod(SERVE_STATUS_EXT_METHODS.sessionTranscript, {
cwd: '/tmp/workspace-a',
sessionId: VALID_SESSION_ID,
});

expect(loadSettings).toHaveBeenCalledWith('/tmp/workspace-a');
// The outer pin is the handler's; the replay-config build inside it may
// pin again with the same settings. Every pin must carry the request's.
const pins = vi.mocked(runWithAcpRuntimeOutputDir).mock.calls;
expect(pins.length).toBeGreaterThanOrEqual(1);
expect(pins[0]![0]).toBe(perRequestSettings);
expect(pins[0]![1]).toBe('/tmp/workspace-a');
expect(pins.every(([s]) => s === perRequestSettings)).toBe(true);
// The replay config built inside the pinned scope was seeded from the
// request's settings (the operation receives them), not this.settings.
expect(vi.mocked(loadCliConfig).mock.calls.at(-1)?.[0]).toMatchObject({
general: { outputLanguage: 'French' },
});

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

it('flushes the live recording before reading the latest persisted page', async () => {
const innerConfig = await setupSessionMocks(VALID_SESSION_ID);
const recording = innerConfig.getChatRecordingService();
Expand Down Expand Up @@ -20203,7 +20327,7 @@ describe('QwenAgent sessionIdContext binding', () => {
});
});

describe('QwenAgent extMethod renameSession routing', () => {
describe('QwenAgent session-management routing (rename / delete / list / branch / close)', () => {
type AgentSideConnectionLike = { closed: Promise<void> };
type AgentLike = {
initialize: (args: Record<string, unknown>) => Promise<unknown>;
Expand Down Expand Up @@ -20772,6 +20896,46 @@ describe('QwenAgent extMethod renameSession routing', () => {
await agentPromise;
});

it('resolves non-live qwen/session/loadUpdates settings per request, not from the this.settings cache', async () => {
const innerConfig = makeLiveSessionInnerConfig(null);
const { agent, agentPromise } = await bootAgent(innerConfig);

// No newSession: the target is not live in this process, so loadUpdates
// takes the disk-only SessionService branch. `this.settings` holds the
// boot workspace's settings; the request names another cwd whose own
// settings must pin the read.
const perRequestSettings = makeAcpSettings();
vi.mocked(loadSettings).mockReturnValue(perRequestSettings);
const loadSession = vi.fn().mockResolvedValue(null);
vi.mocked(SessionService).mockImplementation(
() => ({ loadSession }) as unknown as InstanceType<typeof SessionService>,
);
vi.mocked(runWithAcpRuntimeOutputDir).mockClear();

await expect(
agent.extMethod('qwen/session/loadUpdates', {
cwd: '/tmp/workspace-a',
sessionId: '6ba7b810-9dad-11d1-80b4-00c04fd430c8',
}),
).resolves.toEqual({ updates: [] });

expect(SessionService).toHaveBeenCalledWith('/tmp/workspace-a');
expect(loadSession).toHaveBeenCalledWith(
'6ba7b810-9dad-11d1-80b4-00c04fd430c8',
);
expect(loadSettings).toHaveBeenCalledWith('/tmp/workspace-a');
expect(runWithAcpRuntimeOutputDir).toHaveBeenCalledTimes(1);
expect(vi.mocked(runWithAcpRuntimeOutputDir).mock.calls[0]![0]).toBe(
perRequestSettings,
);
expect(vi.mocked(runWithAcpRuntimeOutputDir).mock.calls[0]![1]).toBe(
'/tmp/workspace-a',
);

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

it('returns success=false when the live ChatRecordingService rejects the title (I/O error)', async () => {
const recording = makeRecordingService();
recording.recordCustomTitle.mockResolvedValue(false);
Expand Down Expand Up @@ -29916,3 +30080,59 @@ describe('createManagedExternalToolGuard', () => {
expect(extMethod).toHaveBeenCalledTimes(1);
});
});

describe('QwenAgent runtime-root pinning choke point', () => {
it('routes every per-request runtime-root pin through runWithPinnedRuntimeBaseDir', () => {
// #10095 fixed handlers that composed the runtime-root routing by hand
// and picked up the stale `this.settings` cache. Per-request handlers now
// go through `runWithPinnedRuntimeBaseDirForRequest`, which resolves the
// settings from the request cwd itself, and everything else through the
// shared helper. A handler naming `runWithAcpRuntimeOutputDir` directly —
// as a call, via an alias, or via an aliased import — is exactly the
// shape that regressed, and no behavioral test can see the difference
// (both spellings reach the same function), so pin the source. Walk the
// AST rather than lines: comments, string literals and import formatting
// cannot false-positive, the canonical un-aliased import specifier is the
// one exempt mention, and the only other mention must be the shared
// helper's own delegation. The per-request handlers themselves are pinned
// behaviorally (settings/cwd reaching the mock) by the routing tests above.
const sourcePath = 'src/acp-integration/acpAgent.ts';
const source = readFileSync(sourcePath, 'utf8');
const lines = source.split('\n');
const sourceFile = ts.createSourceFile(
sourcePath,
source,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TS,
);
const mentions: string[] = [];
const visit = (node: ts.Node): void => {
if (ts.isIdentifier(node) && node.text === 'runWithAcpRuntimeOutputDir') {
// `import { runWithAcpRuntimeOutputDir } from …` binds the name
// without an alias (`propertyName` is unset). An aliased specifier
// (`runWithAcpRuntimeOutputDir as pin`) keeps this identifier under
// `propertyName` and is reported like any other mention.
const isCanonicalImport =
ts.isImportSpecifier(node.parent) &&
node.parent.propertyName === undefined;
if (!isCanonicalImport) {
const { line } = sourceFile.getLineAndCharacterOfPosition(
node.getStart(sourceFile),
);
mentions.push(`${line + 1}: ${lines[line]!.trim()}`);
}
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
expect(
mentions,
`acpAgent.ts must not name runWithAcpRuntimeOutputDir directly. Handlers serving a caller-supplied cwd route through this.runWithPinnedRuntimeBaseDirForRequest; only callers holding deliberately scoped settings may use this.runWithPinnedRuntimeBaseDir (see #10095). Direct mentions at:\n${mentions.join('\n')}`,
).toEqual([
expect.stringMatching(
/^\d+: return runWithAcpRuntimeOutputDir\(settings, cwd, operation\);$/,
),
]);
});
});
66 changes: 51 additions & 15 deletions packages/cli/src/acp-integration/acpAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4555,6 +4555,21 @@ class QwenAgent implements Agent {
}
}

/**
* Single choke point for pinning the runtime root of an operation to a
* settings object and a cwd. Every caller in this class goes through here
* rather than calling `runWithAcpRuntimeOutputDir` directly, so the routing
* is composed in exactly one place. Which settings to pin with is still the
* caller's decision at this level: callers that already hold deliberately
* scoped settings (workspace MCP discovery, live-session scope checks,
* session creation) pass them in. Per-request session-management handlers
* (list, delete, rename, transcript page, settled turn status, and the
* non-live branch of loadUpdates) must not make that decision themselves —
* they use `runWithPinnedRuntimeBaseDirForRequest` below. Session load and
* resume resolve the request's settings at the call site deliberately,
* under profiler instrumentation, because they adopt those settings for
* the session afterwards.
*/
private runWithPinnedRuntimeBaseDir<T>(
settings: LoadedSettings,
cwd: string,
Expand All @@ -4563,6 +4578,26 @@ class QwenAgent implements Agent {
return runWithAcpRuntimeOutputDir(settings, cwd, operation);
}

/**
* Per-request form of `runWithPinnedRuntimeBaseDir` for handlers that act
* on a caller-supplied cwd. It resolves the settings for THAT cwd itself,
* so the "which settings pin this operation" decision is made here, once,
* and a handler cannot reach the pin with the process-wide `this.settings`
* cache — the bug class fixed in #10095 (three handlers composed the
* routing by hand and pinned another workspace's runtime root). The
* operation receives the resolved settings for handlers that also need
* them inside the pinned scope.
*/
private runWithPinnedRuntimeBaseDirForRequest<T>(
cwd: string,
operation: (settings: LoadedSettings) => T,
): T {
const settings = loadSettingsCached(cwd);
return this.runWithPinnedRuntimeBaseDir(settings, cwd, () =>
operation(settings),
);
}

/**
* Whether an ungated restore replay (qwen/session/loadUpdates) may
* finalize dangling tool calls. A session with an active turn — a client
Expand Down Expand Up @@ -5773,8 +5808,7 @@ class QwenAgent implements Agent {
// a multi-workspace daemon it may hold another workspace's
// advanced.runtimeOutputDir and this listing would scan the wrong runtime
// root (returning an empty/foreign list for this cwd).
const settings = loadSettingsCached(cwd);
const result = await runWithAcpRuntimeOutputDir(settings, cwd, () => {
const result = await this.runWithPinnedRuntimeBaseDirForRequest(cwd, () => {
const sessionService = new SessionService(cwd);
return sessionService.listSessions({
cursor: numericCursor,
Expand Down Expand Up @@ -8860,8 +8894,7 @@ class QwenAgent implements Agent {
}

try {
const settings = loadSettingsCached(cwd);
return await runWithAcpRuntimeOutputDir(settings, cwd, async () => {
const readTranscriptPage = async (settings: LoadedSettings) => {
if (rawDirection === 'backward') {
await this.sessions
.get(sessionId)
Expand Down Expand Up @@ -8913,7 +8946,11 @@ class QwenAgent implements Agent {
? { partial: true, replayError: replay.replayError }
: {}),
} as Record<string, unknown>;
});
};
return await this.runWithPinnedRuntimeBaseDirForRequest(
cwd,
readTranscriptPage,
);
} catch (error) {
if (
error instanceof InvalidSessionTranscriptCursorError ||
Expand Down Expand Up @@ -11785,8 +11822,7 @@ class QwenAgent implements Agent {
);
}
const session = this.sessionOrThrow(sessionId);
const settings = loadSettingsCached(cwd);
return await runWithAcpRuntimeOutputDir(settings, cwd, async () => {
const readSettledTurnResult = async () => {
try {
await session.getConfig().getChatRecordingService()?.flush();
} catch {
Expand Down Expand Up @@ -11859,7 +11895,11 @@ class QwenAgent implements Agent {
}
throw error;
}
});
};
return await this.runWithPinnedRuntimeBaseDirForRequest(
cwd,
readSettledTurnResult,
);
}
case SERVE_CONTROL_EXT_METHODS.sessionContinue: {
const sessionId = params['sessionId'];
Expand Down Expand Up @@ -12077,8 +12117,7 @@ class QwenAgent implements Agent {
// destructive lookup at the wrong runtime root — silently returning
// success:false for a session that exists, or deleting a stale
// same-id copy under the wrong root.
const success = await runWithAcpRuntimeOutputDir(
loadSettingsCached(cwd),
const success = await this.runWithPinnedRuntimeBaseDirForRequest(
cwd,
async () => {
const sessionService = new SessionService(cwd);
Expand Down Expand Up @@ -12128,8 +12167,7 @@ class QwenAgent implements Agent {
return { success: ok };
}
// Per-request settings for the same reason as deleteSession above.
const success = await runWithAcpRuntimeOutputDir(
loadSettingsCached(cwd),
const success = await this.runWithPinnedRuntimeBaseDirForRequest(
cwd,
async () => {
const sessionService = new SessionService(cwd);
Expand Down Expand Up @@ -12360,9 +12398,7 @@ class QwenAgent implements Agent {
: await loadAuthoritative();
replayConfig = config;
} else {
const settings = loadSettingsCached(cwd);
sessionData = await this.runWithPinnedRuntimeBaseDir(
settings,
sessionData = await this.runWithPinnedRuntimeBaseDirForRequest(
cwd,
async () => {
const sessionService = new SessionService(cwd);
Expand Down
Loading