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
110 changes: 110 additions & 0 deletions packages/cli/src/acp-integration/acpAgent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1077,6 +1077,7 @@ import {
reloadEnvironment,
SettingScope,
} from '../config/settings.js';
import { runWithAcpRuntimeOutputDir } from './runtimeOutputDirContext.js';
import { resetTrustedFoldersForTesting } from '../config/trustedFolders.js';
import {
MAX_PERMISSION_RULE_LENGTH,
Expand Down Expand Up @@ -20343,6 +20344,115 @@ describe('QwenAgent extMethod renameSession routing', () => {
await agentPromise;
});

it('resolves deleteSession settings per request, not from the this.settings cache', async () => {
const innerConfig = makeLiveSessionInnerConfig(null);
const { agent, agentPromise } = await bootAgent(innerConfig);

// Multi-workspace daemon shape: this.settings holds the boot workspace's
// settings; the delete targets a cwd whose own settings (and therefore
// advanced.runtimeOutputDir) resolve differently. Routing through the
// stale cache would scan the wrong runtime root.
const perRequestSettings = makeAcpSettings();
vi.mocked(loadSettings).mockReturnValue(perRequestSettings);
const removeSession = vi.fn().mockResolvedValue(true);
vi.mocked(SessionService).mockImplementation(
() =>
({ removeSession }) as unknown as InstanceType<typeof SessionService>,
);
vi.mocked(runWithAcpRuntimeOutputDir).mockClear();

await agent.extMethod('deleteSession', {
cwd: '/tmp/workspace-a',
sessionId: '6ba7b810-9dad-11d1-80b4-00c04fd430c8',
});

expect(runWithAcpRuntimeOutputDir).toHaveBeenCalledTimes(1);
expect(vi.mocked(runWithAcpRuntimeOutputDir).mock.calls[0]![0]).toBe(
perRequestSettings,
);
// Pin the per-request cwd too: settings must be resolved FROM the
// request's directory and routing must target it — not the boot
// workspace's dir.
expect(loadSettings).toHaveBeenCalledWith('/tmp/workspace-a');
expect(vi.mocked(runWithAcpRuntimeOutputDir).mock.calls[0]![1]).toBe(
'/tmp/workspace-a',
);

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

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

const perRequestSettings = makeAcpSettings();
vi.mocked(loadSettings).mockReturnValue(perRequestSettings);
const renameSession = vi.fn().mockResolvedValue(true);
vi.mocked(SessionService).mockImplementation(
() =>
({ renameSession }) as unknown as InstanceType<typeof SessionService>,
);
vi.mocked(runWithAcpRuntimeOutputDir).mockClear();

// No newSession: the target is not live in this process, so the rename
// takes the disk-only SessionService path.
await agent.extMethod('renameSession', {
cwd: '/tmp/workspace-a',
sessionId: '6ba7b810-9dad-11d1-80b4-00c04fd430c8',
title: 'renamed elsewhere',
});

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

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

it('resolves unstable_listSessions settings per request, not from the this.settings cache', async () => {
const innerConfig = makeLiveSessionInnerConfig(null);
const { agent, agentPromise } = await bootAgent(innerConfig);

const perRequestSettings = makeAcpSettings();
vi.mocked(loadSettings).mockReturnValue(perRequestSettings);
const listSessions = vi
.fn()
.mockResolvedValue({ items: [], nextCursor: undefined });
vi.mocked(SessionService).mockImplementation(
() =>
({ listSessions }) as unknown as InstanceType<typeof SessionService>,
);
vi.mocked(runWithAcpRuntimeOutputDir).mockClear();

await (
agent as unknown as {
unstable_listSessions: (p: Record<string, unknown>) => Promise<unknown>;
}
).unstable_listSessions({ cwd: '/tmp/workspace-a' });

expect(listSessions).toHaveBeenCalled();
expect(vi.mocked(runWithAcpRuntimeOutputDir).mock.calls[0]![0]).toBe(
perRequestSettings,
);
expect(loadSettings).toHaveBeenCalledWith('/tmp/workspace-a');
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
17 changes: 14 additions & 3 deletions packages/cli/src/acp-integration/acpAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5768,7 +5768,12 @@ class QwenAgent implements Agent {
// (same pattern filesystem.ts uses for `_meta.bom` / `_meta.encoding`).
const size = normalizeAcpSessionListSize(params._meta?.['size']);

const result = await runWithAcpRuntimeOutputDir(this.settings, cwd, () => {
// Per-request settings: `this.settings` is a "latest loaded" cache, so in
// 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 sessionService = new SessionService(cwd);
return sessionService.listSessions({
cursor: numericCursor,
Expand Down Expand Up @@ -12009,8 +12014,13 @@ class QwenAgent implements Agent {
'Invalid or missing sessionId',
);
}
// Per-request settings, not the "latest loaded" this.settings cache:
// another workspace's advanced.runtimeOutputDir would point this
// 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(
this.settings,
loadSettingsCached(cwd),
cwd,
async () => {
const sessionService = new SessionService(cwd);
Expand Down Expand Up @@ -12059,8 +12069,9 @@ class QwenAgent implements Agent {
const ok = await liveRecording.recordCustomTitle(title, 'manual');
return { success: ok };
}
// Per-request settings for the same reason as deleteSession above.
const success = await runWithAcpRuntimeOutputDir(
this.settings,
loadSettingsCached(cwd),
cwd,
async () => {
const sessionService = new SessionService(cwd);
Expand Down
Loading