Skip to content
Closed
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
3046b94
fix(acp): isolate workspace settings and context file resolution for …
Jul 30, 2026
79f2170
fix(acp): address review feedback — fix worktree test mock, add coverage
Jul 30, 2026
f8635ac
docs(acp): document single-worktree-session assumption on defaultSett…
Jul 30, 2026
f1ac5e2
test(acp): cover defaultSettingsCwd clear-on-close path
Jul 30, 2026
89df65e
fix(acp): address runtime verification feedback — per-session setting…
Aug 1, 2026
731126b
fix(acp): cover enter_worktree path + fix findEffectiveWorkspace dead…
Aug 1, 2026
76868ae
fix(acp): read activeWorktree from session config, fix GET/POST split…
Aug 1, 2026
7af1462
Merge branch 'main' into worktree-settings-isolation
wenshao Aug 1, 2026
c0f9b91
fix(acp): address PR review — stale worktree guards, containment, err…
Aug 1, 2026
0099742
Merge remote-tracking branch 'origin/worktree-settings-isolation' int…
Aug 1, 2026
55696ea
fix(acp): guard all resolveSettingsCwd returns with existsSync
Aug 1, 2026
ec57679
fix(acp): prefer worktree settings cwd over explicit client cwd, clea…
Aug 1, 2026
52a068c
fix(acp): address PR #8152 review — JSDoc priority, stale sessionId, …
Aug 1, 2026
959c362
fix(acp): address PR #8152 review — dead switch removal, test coverag…
Aug 2, 2026
a12c58b
fix(acp): address PR #8152 review — dead switch removal, test coverag…
Aug 8, 2026
18530dc
merge: sync with upstream/main
Aug 8, 2026
0c0512b
fix(acp): address PR #8152 R2 review — settings reload ordering, work…
Aug 9, 2026
7d9c586
Merge remote-tracking branch 'upstream/main' into worktree-settings-i…
Aug 10, 2026
b5a8b2a
fix(acp): restore worktree cwd on ACP session resume
Aug 10, 2026
078e788
Merge remote-tracking branch 'upstream/main' into worktree-settings-i…
Aug 11, 2026
2b2bec8
chore: merge origin/main into worktree-settings-isolation
qwen-code-dev-bot Aug 12, 2026
18e2a98
fix(acp): resolve worktree settings isolation review comments
Aug 12, 2026
10dad43
Merge remote-tracking branch 'origin/worktree-settings-isolation' int…
Aug 12, 2026
e424719
Merge remote-tracking branch 'origin/main' into worktree-settings-iso…
wenshao Aug 30, 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
Empty file added .tsserver.log
Empty file.
224 changes: 224 additions & 0 deletions packages/cli/src/acp-integration/acpAgent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ import { ACP_EVENT_LOOP_STALL_RESTART_MS } from '@qwen-code/channel-base';
const { mockRunExitCleanup } = vi.hoisted(() => ({
mockRunExitCleanup: vi.fn().mockResolvedValue(undefined),
}));
const { mockExistsSync } = vi.hoisted(() => ({
mockExistsSync: vi.fn().mockReturnValue(true),
}));
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
return { ...actual, existsSync: mockExistsSync };
});
const { mockMcpPoolDrainAll } = vi.hoisted(() => ({
mockMcpPoolDrainAll: vi
.fn()
Expand Down Expand Up @@ -1975,6 +1982,10 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
stdoutDestroySpy.mockRestore();
});

afterEach(() => {
mockExistsSync.mockReturnValue(true);
});

it('initialize response includes mcpCapabilities with sse and http', async () => {
const mockSettings = {
merged: { mcpServers: {} },
Expand Down Expand Up @@ -4353,6 +4364,58 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
await agentPromise;
});

it('session/cd sets worktreeCwd and getCore resolves against the worktree path', async () => {
const sessionId = '11111111-1111-1111-1111-111111111111';
const targetDir = await fs.mkdtemp(
path.join(os.tmpdir(), 'qwen-cd-worktree-settings-'),
);
const canonicalTargetDir = await fs.realpath(targetDir);
const innerConfig = await setupSessionMocks(sessionId);
const relocateWorkingDirectory = vi.fn().mockResolvedValue({});
Object.assign(innerConfig, {
getTargetDir: vi.fn().mockReturnValue('/tmp'),
isRestrictiveSandbox: vi.fn().mockReturnValue(false),
relocateWorkingDirectory,
});
Object.assign(innerConfig.getGeminiClient(), {
addWorkingDirectoryChangedContext: vi.fn().mockResolvedValue(undefined),
});

const settings = makeCoreSettings();
vi.mocked(loadSettings).mockReturnValue(settings);
vi.mocked(loadCliConfig).mockResolvedValue(
innerConfig as unknown as Config,
);

const { agent, agentPromise } = await bootAcpAgent();
await agent.newSession({ cwd: '/tmp', mcpServers: [] });

try {
await expect(
agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionCd, {
sessionId,
path: targetDir,
}),
).resolves.toMatchObject({
previousCwd: '/tmp',
newCwd: canonicalTargetDir,
});

expect(
(lastSessionMock as Record<string, unknown>)?.['worktreeCwd'],
).toBe(canonicalTargetDir);

vi.mocked(loadSettings).mockClear();
await agent.extMethod('qwen/settings/getCore', {});
expect(vi.mocked(loadSettings)).toHaveBeenCalledWith(canonicalTargetDir);
} finally {
await fs.rm(targetDir, { recursive: true, force: true });
}

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

it('allows a private Live managed relocation without a global folder trust rule', async () => {
await withEmptyTrustedFolders(async (directory) => {
const root = path.join(directory, 'Conversations');
Expand Down Expand Up @@ -9773,6 +9836,167 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
await agentPromise;
});

it('qwen/settings handlers resolve against worktree cwd set by createAndStoreSession', async () => {
const WORKTREE_DIR = os.tmpdir();
const innerConfig = await setupSessionMocks('wt-settings-session');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] No test replays the issue's actual end-to-end shape: enter_worktree tool called mid-session → workspace-scoped setting change → setting lands in worktree. The two halves are tested independently (enter-worktree.test.ts asserts setActiveWorktree is called; acpAgent.test.ts mocks getActiveWorktree to return a preset path) but the Config identity wiring is never exercised. — Failure scenario: a refactor that breaks Config identity (tools receiving a cloned Config, or Session.getConfig() returning a wrapper) would pass both test halves independently while silently re-breaking issue #8138.

中文说明

[Suggestion] 没有测试复现 issue 的实际端到端形态:会话中调用 enter_worktree 工具 → 更改工作区范围设置 → 设置写入 worktree。两半被独立测试(enter-worktree.test.ts 断言 setActiveWorktree 被调用;acpAgent.test.ts mock getActiveWorktree 返回预设路径),但 Config 身份接线从未被验证。— 失败场景:一个破坏 Config 身份的重构(工具收到克隆的 Config,或 Session.getConfig() 返回包装器)会让两半测试独立通过,同时静默地重新破坏 issue #8138。

— qwen3.8-max-preview via Qwen Code /review (v0.21.3)

innerConfig.getTargetDir = vi.fn().mockReturnValue(WORKTREE_DIR);

const settings = makeCoreSettings();
vi.mocked(loadSettings).mockReturnValue(settings);
vi.mocked(loadCliConfig).mockResolvedValue(
innerConfig as unknown as Config,
);
const { agent, agentPromise } = await bootAcpAgent();

await agent.newSession({ cwd: '/fake/project', mcpServers: [] });

vi.mocked(loadSettings).mockClear();
await agent.extMethod('qwen/settings/getCore', {});
expect(vi.mocked(loadSettings)).toHaveBeenCalledWith(WORKTREE_DIR);

innerConfig.getTargetDir = vi.fn().mockReturnValue(process.cwd());
innerConfig.getSessionId = vi.fn().mockReturnValue('regular-session');
vi.mocked(loadCliConfig).mockResolvedValue(
innerConfig as unknown as Config,
);
await agent.newSession({ cwd: '/fake/project', mcpServers: [] });

// The fallback loop scans all sessions for worktreeCwd; the first
// session's worktreeCwd is still set, so it wins over process.cwd().
vi.mocked(loadSettings).mockClear();
await agent.extMethod('qwen/settings/getCore', {});
expect(vi.mocked(loadSettings)).toHaveBeenCalledWith(WORKTREE_DIR);

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

it('qwen/settings handlers fall back to process.cwd() after worktree session closes', async () => {
const WORKTREE_DIR = os.tmpdir();
const innerConfig = await setupSessionMocks('wt-close-session');
innerConfig.getTargetDir = vi.fn().mockReturnValue(WORKTREE_DIR);

const settings = makeCoreSettings();
vi.mocked(loadSettings).mockReturnValue(settings);
vi.mocked(loadCliConfig).mockResolvedValue(
innerConfig as unknown as Config,
);
const { agent, agentPromise } = await bootAcpAgent();

await agent.newSession({ cwd: '/fake/project', mcpServers: [] });

vi.mocked(loadSettings).mockClear();
await agent.extMethod('qwen/settings/getCore', {});
expect(vi.mocked(loadSettings)).toHaveBeenCalledWith(WORKTREE_DIR);

await agent.extMethod('qwen/control/session/close', {
sessionId: 'wt-close-session',
});

vi.mocked(loadSettings).mockClear();
await agent.extMethod('qwen/settings/getCore', {});
expect(vi.mocked(loadSettings)).toHaveBeenCalledWith(process.cwd());

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

it('qwen/settings/getCore resolves per-session worktreeCwd via sessionId param', async () => {
const WORKTREE_DIR = os.tmpdir();
const innerConfig = await setupSessionMocks('per-session-wt-id');
innerConfig.getTargetDir = vi.fn().mockReturnValue(WORKTREE_DIR);

const settings = makeCoreSettings();
vi.mocked(loadSettings).mockReturnValue(settings);
vi.mocked(loadCliConfig).mockResolvedValue(
innerConfig as unknown as Config,
);
const { agent, agentPromise } = await bootAcpAgent();

const newResult = (await agent.newSession({
cwd: '/fake/project',
mcpServers: [],
})) as { sessionId: string };
const sessionId = newResult.sessionId;

vi.mocked(loadSettings).mockClear();
await agent.extMethod('qwen/settings/getCore', { sessionId });
expect(vi.mocked(loadSettings)).toHaveBeenCalledWith(WORKTREE_DIR);

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

it('qwen/settings/getCore falls through to configWt when worktreeCwd dir is deleted', async () => {
const WORKTREE_DIR = '/fake/deleted-wt';
const innerConfig = await setupSessionMocks('deleted-wt-session');
innerConfig.getTargetDir = vi.fn().mockReturnValue(WORKTREE_DIR);
(innerConfig as Record<string, unknown>)['getActiveWorktree'] = vi
.fn()
.mockReturnValue(null);

const settings = makeCoreSettings();
vi.mocked(loadSettings).mockReturnValue(settings);
vi.mocked(loadCliConfig).mockResolvedValue(
innerConfig as unknown as Config,
);
const { agent, agentPromise } = await bootAcpAgent();

const newResult = (await agent.newSession({
cwd: '/fake/project',
mcpServers: [],
})) as { sessionId: string };
const sessionId = newResult.sessionId;

mockExistsSync.mockReturnValue(false);
try {
vi.mocked(loadSettings).mockClear();
await agent.extMethod('qwen/settings/getCore', { sessionId });
expect(vi.mocked(loadSettings)).toHaveBeenCalledWith(process.cwd());
} finally {
mockExistsSync.mockReturnValue(true);
}

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

it('qwen/settings/getCore uses configWt when worktreeCwd is deleted but activeWorktree exists', async () => {
const ACTIVE_WT = os.tmpdir();
const innerConfig = await setupSessionMocks('active-wt-session');
innerConfig.getTargetDir = vi.fn().mockReturnValue('/fake/deleted-wt');
(innerConfig as Record<string, unknown>)['getActiveWorktree'] = vi
.fn()
.mockReturnValue(ACTIVE_WT);

const settings = makeCoreSettings();
vi.mocked(loadSettings).mockReturnValue(settings);
vi.mocked(loadCliConfig).mockResolvedValue(
innerConfig as unknown as Config,
);
const { agent, agentPromise } = await bootAcpAgent();

const newResult = (await agent.newSession({
cwd: '/fake/project',
mcpServers: [],
})) as { sessionId: string };
const sessionId = newResult.sessionId;

mockExistsSync.mockImplementation(
(p: string | Buffer | URL) => p === ACTIVE_WT,
);
try {
vi.mocked(loadSettings).mockClear();
await agent.extMethod('qwen/settings/getCore', { sessionId });
expect(vi.mocked(loadSettings)).toHaveBeenCalledWith(ACTIVE_WT);
} finally {
mockExistsSync.mockReturnValue(true);
}

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

it('qwen/permissions/setRules validates scope and ruleType', async () => {
const settings = makeCoreSettings();
const { agent, agentPromise } = await bootCoreSettingsAgent(settings);
Expand Down
Loading