Skip to content

Commit b16adb5

Browse files
LaZzyManclaude
andcommitted
test(worktree): add ACP-mode integration tests for --resume context
Covers: - acpAgent.worktree.test.ts (3 tests): loadSession sets pendingWorktreeNotice only when worktree dir is live, clears stale sidecar otherwise, swallows restoreWorktreeContext errors. - Session.worktree.test.ts (4 tests): #executePrompt prepends the system-reminder block exactly once on first prompt, clears the pending notice, second prompt sees no leakage, no-op when nothing was set. E2E via real ACP protocol is impractical without a Zed client; these tests cover the integration boundaries directly. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent ada0837 commit b16adb5

2 files changed

Lines changed: 644 additions & 0 deletions

File tree

Lines changed: 387 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,387 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Qwen Team
4+
* SPDX-License-Identifier: Apache-2.0
5+
*
6+
* Phase C ACP worktree context restore — agent-level integration tests.
7+
*
8+
* Coverage (this file):
9+
* VP1: loadSession with a stale sidecar — pendingWorktreeNotice stays null.
10+
* VP2: loadSession with a live sidecar — pendingWorktreeNotice is set to
11+
* the contextMessage from restoreWorktreeContext.
12+
* VP2b: restoreWorktreeContext throws — session still loads, notice null.
13+
*
14+
* VP3 / VP4 (Session.prompt consumption) are in Session.worktree.test.ts.
15+
*/
16+
17+
import {
18+
describe,
19+
it,
20+
expect,
21+
vi,
22+
beforeEach,
23+
afterEach,
24+
afterAll,
25+
} from 'vitest';
26+
27+
// ---------------------------------------------------------------------------
28+
// Hoisted mocks
29+
// ---------------------------------------------------------------------------
30+
31+
const { mockRunExitCleanup } = vi.hoisted(() => ({
32+
mockRunExitCleanup: vi.fn().mockResolvedValue(undefined),
33+
}));
34+
vi.mock('../utils/cleanup.js', () => ({
35+
runExitCleanup: mockRunExitCleanup,
36+
}));
37+
38+
const { mockConnectionState } = vi.hoisted(() => {
39+
const state = {
40+
resolve: () => {},
41+
promise: null as unknown as Promise<void>,
42+
reset() {
43+
state.promise = new Promise<void>((r) => {
44+
state.resolve = r;
45+
});
46+
},
47+
};
48+
state.reset();
49+
return { mockConnectionState: state };
50+
});
51+
52+
vi.mock('@agentclientprotocol/sdk', () => ({
53+
AgentSideConnection: vi.fn().mockImplementation(() => ({
54+
get closed() {
55+
return mockConnectionState.promise;
56+
},
57+
})),
58+
ndJsonStream: vi.fn().mockReturnValue({}),
59+
RequestError: class RequestError extends Error {
60+
static authRequired = vi
61+
.fn()
62+
.mockImplementation((data: unknown, msg: string) => {
63+
const err = new Error(msg);
64+
Object.assign(err, data);
65+
return err;
66+
});
67+
static invalidParams = vi
68+
.fn()
69+
.mockImplementation((data: unknown, msg: string) => {
70+
const err = new Error(msg);
71+
Object.assign(err, data);
72+
return err;
73+
});
74+
},
75+
PROTOCOL_VERSION: '1.0.0',
76+
}));
77+
78+
vi.mock('node:stream', async (importOriginal) => {
79+
const actual = await importOriginal<typeof import('node:stream')>();
80+
return {
81+
...actual,
82+
Writable: { ...actual.Writable, toWeb: vi.fn().mockReturnValue({}) },
83+
Readable: { ...actual.Readable, toWeb: vi.fn().mockReturnValue({}) },
84+
};
85+
});
86+
87+
// Core mock — includes restoreWorktreeContext controllable per-test.
88+
const { mockRestoreWorktreeContext } = vi.hoisted(() => ({
89+
mockRestoreWorktreeContext: vi
90+
.fn()
91+
.mockResolvedValue({ contextMessage: null, session: null }),
92+
}));
93+
94+
vi.mock('@qwen-code/qwen-code-core', () => ({
95+
createDebugLogger: () => ({
96+
debug: vi.fn(),
97+
error: vi.fn(),
98+
warn: vi.fn(),
99+
info: vi.fn(),
100+
}),
101+
APPROVAL_MODE_INFO: {},
102+
APPROVAL_MODES: [],
103+
AuthType: {},
104+
clearCachedCredentialFile: vi.fn(),
105+
QwenOAuth2Event: {},
106+
qwenOAuth2Events: { on: vi.fn(), off: vi.fn() },
107+
MCPServerConfig: vi.fn().mockImplementation((...args: unknown[]) => ({
108+
_args: args,
109+
})),
110+
SessionService: vi.fn(),
111+
SESSION_TITLE_MAX_LENGTH: 200,
112+
tokenLimit: vi.fn(),
113+
SessionStartSource: { Startup: 'startup', Resume: 'resume' },
114+
SessionEndReason: { PromptInputExit: 'prompt_input_exit', Other: 'other' },
115+
restoreWorktreeContext: mockRestoreWorktreeContext,
116+
}));
117+
118+
vi.mock('./runtimeOutputDirContext.js', () => ({
119+
runWithAcpRuntimeOutputDir: vi.fn(
120+
async <T>(
121+
_settings: unknown,
122+
_cwd: string,
123+
fn: () => T | Promise<T>,
124+
): Promise<T> => fn(),
125+
),
126+
}));
127+
128+
vi.mock('./authMethods.js', () => ({ buildAuthMethods: vi.fn() }));
129+
vi.mock('./service/filesystem.js', () => ({
130+
AcpFileSystemService: vi.fn(),
131+
}));
132+
vi.mock('../config/settings.js', () => ({
133+
SettingScope: {},
134+
loadSettings: vi.fn(),
135+
}));
136+
vi.mock('../config/config.js', () => ({ loadCliConfig: vi.fn() }));
137+
vi.mock('./session/Session.js', () => ({ Session: vi.fn() }));
138+
vi.mock('../utils/acpModelUtils.js', () => ({
139+
formatAcpModelId: vi.fn(),
140+
}));
141+
142+
// ---------------------------------------------------------------------------
143+
// Imports
144+
// ---------------------------------------------------------------------------
145+
146+
import { runAcpAgent } from './acpAgent.js';
147+
import type { Config } from '@qwen-code/qwen-code-core';
148+
import type { LoadedSettings } from '../config/settings.js';
149+
import type { CliArgs } from '../config/config.js';
150+
import { SessionService } from '@qwen-code/qwen-code-core';
151+
import { AgentSideConnection } from '@agentclientprotocol/sdk';
152+
import { loadSettings } from '../config/settings.js';
153+
import { loadCliConfig } from '../config/config.js';
154+
import { Session } from './session/Session.js';
155+
156+
// ---------------------------------------------------------------------------
157+
// Test suite — VP1, VP2, VP2b
158+
// ---------------------------------------------------------------------------
159+
160+
describe('QwenAgent loadSession — Phase C worktree context restore', () => {
161+
type AgentSideConnectionLike = { closed: Promise<void> };
162+
type AgentLike = {
163+
initialize: (args: Record<string, unknown>) => Promise<unknown>;
164+
loadSession: (args: Record<string, unknown>) => Promise<unknown>;
165+
};
166+
167+
let capturedAgentFactory:
168+
| ((conn: AgentSideConnectionLike) => AgentLike)
169+
| undefined;
170+
let mockConfig: Config;
171+
let lastSessionMock:
172+
| { pendingWorktreeNotice: string | null; getId: ReturnType<typeof vi.fn> }
173+
| undefined;
174+
let processExitSpy: ReturnType<typeof vi.spyOn>;
175+
let stdinDestroySpy: ReturnType<typeof vi.spyOn>;
176+
let stdoutDestroySpy: ReturnType<typeof vi.spyOn>;
177+
178+
const mockArgv = {} as CliArgs;
179+
180+
const SESSION_ID = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee';
181+
const SIDECAR_PATH = `/fake/chats/${SESSION_ID}.worktree.json`;
182+
183+
function makeInnerConfig() {
184+
const mockSessionService = {
185+
sessionExists: vi.fn().mockResolvedValue(true),
186+
getWorktreeSessionPath: vi.fn().mockReturnValue(SIDECAR_PATH),
187+
};
188+
vi.mocked(SessionService).mockImplementation(
189+
() =>
190+
mockSessionService as unknown as InstanceType<typeof SessionService>,
191+
);
192+
193+
return {
194+
initialize: vi.fn().mockResolvedValue(undefined),
195+
waitForMcpReady: vi.fn().mockResolvedValue(undefined),
196+
getModelsConfig: vi.fn().mockReturnValue({
197+
getCurrentAuthType: vi.fn().mockReturnValue('api-key'),
198+
}),
199+
refreshAuth: vi.fn().mockResolvedValue(undefined),
200+
getModel: vi.fn().mockReturnValue('m'),
201+
getContentGeneratorConfig: vi.fn().mockReturnValue({}),
202+
getApprovalMode: vi.fn().mockReturnValue('default'),
203+
getSessionId: vi.fn().mockReturnValue(SESSION_ID),
204+
getAuthType: vi.fn().mockReturnValue('api-key'),
205+
getAllConfiguredModels: vi.fn().mockReturnValue([]),
206+
getGeminiClient: vi.fn().mockReturnValue({
207+
isInitialized: vi.fn().mockReturnValue(true),
208+
initialize: vi.fn().mockResolvedValue(undefined),
209+
waitForMcpReady: vi.fn().mockResolvedValue(undefined),
210+
}),
211+
getFileSystemService: vi.fn().mockReturnValue(undefined),
212+
setFileSystemService: vi.fn(),
213+
getHookSystem: vi.fn().mockReturnValue(undefined),
214+
getDisableAllHooks: vi.fn().mockReturnValue(true),
215+
hasHooksForEvent: vi.fn().mockReturnValue(false),
216+
getResumedSessionData: vi.fn().mockReturnValue(undefined),
217+
getSessionService: vi.fn().mockReturnValue(mockSessionService),
218+
};
219+
}
220+
221+
function makeSessionSettings() {
222+
return {
223+
merged: { mcpServers: {} },
224+
getUserHooks: vi.fn().mockReturnValue({}),
225+
getProjectHooks: vi.fn().mockReturnValue({}),
226+
} as unknown as LoadedSettings;
227+
}
228+
229+
beforeEach(() => {
230+
vi.clearAllMocks();
231+
mockConnectionState.reset();
232+
capturedAgentFactory = undefined;
233+
lastSessionMock = undefined;
234+
235+
vi.mocked(AgentSideConnection).mockImplementation((factory: unknown) => {
236+
capturedAgentFactory = factory as typeof capturedAgentFactory;
237+
return {
238+
get closed() {
239+
return mockConnectionState.promise;
240+
},
241+
} as unknown as InstanceType<typeof AgentSideConnection>;
242+
});
243+
244+
mockConfig = {
245+
initialize: vi.fn().mockResolvedValue(undefined),
246+
waitForMcpReady: vi.fn().mockResolvedValue(undefined),
247+
getHookSystem: vi.fn().mockReturnValue(undefined),
248+
getDisableAllHooks: vi.fn().mockReturnValue(false),
249+
hasHooksForEvent: vi.fn().mockReturnValue(false),
250+
getModel: vi.fn().mockReturnValue('test-model'),
251+
getModelsConfig: vi.fn().mockReturnValue({
252+
getCurrentAuthType: vi.fn().mockReturnValue('api-key'),
253+
}),
254+
refreshAuth: vi.fn().mockResolvedValue(undefined),
255+
} as unknown as Config;
256+
257+
processExitSpy = vi
258+
.spyOn(process, 'exit')
259+
.mockImplementation((() => undefined) as unknown as typeof process.exit);
260+
stdinDestroySpy = vi
261+
.spyOn(process.stdin, 'destroy')
262+
.mockImplementation(() => process.stdin);
263+
stdoutDestroySpy = vi
264+
.spyOn(process.stdout, 'destroy')
265+
.mockImplementation(() => process.stdout);
266+
});
267+
268+
afterEach(() => {
269+
processExitSpy.mockRestore();
270+
stdinDestroySpy.mockRestore();
271+
stdoutDestroySpy.mockRestore();
272+
vi.clearAllMocks();
273+
});
274+
275+
afterAll(() => {
276+
mockConnectionState.resolve();
277+
});
278+
279+
async function bootAgentWithLoadSession(
280+
innerConfig: ReturnType<typeof makeInnerConfig>,
281+
) {
282+
vi.mocked(loadSettings).mockReturnValue(makeSessionSettings());
283+
vi.mocked(loadCliConfig).mockResolvedValue(
284+
innerConfig as unknown as Config,
285+
);
286+
287+
vi.mocked(Session).mockImplementation(() => {
288+
const mock = {
289+
getId: vi.fn().mockReturnValue(SESSION_ID),
290+
getConfig: vi.fn().mockReturnValue(innerConfig),
291+
sendAvailableCommandsUpdate: vi.fn().mockResolvedValue(undefined),
292+
replayHistory: vi.fn().mockResolvedValue(undefined),
293+
installRewriter: vi.fn(),
294+
pendingWorktreeNotice: null as string | null,
295+
};
296+
lastSessionMock = mock;
297+
return mock as unknown as InstanceType<typeof Session>;
298+
});
299+
300+
const agentPromise = runAcpAgent(
301+
mockConfig,
302+
makeSessionSettings(),
303+
mockArgv,
304+
);
305+
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
306+
307+
const agent = capturedAgentFactory!({
308+
get closed() {
309+
return mockConnectionState.promise;
310+
},
311+
}) as AgentLike;
312+
313+
return { agent, agentPromise };
314+
}
315+
316+
it('VP1: stale sidecar — pendingWorktreeNotice stays null', async () => {
317+
// mockRestoreWorktreeContext defaults to { contextMessage: null, session: null }
318+
const innerConfig = makeInnerConfig();
319+
const { agent, agentPromise } = await bootAgentWithLoadSession(innerConfig);
320+
321+
await agent.loadSession({
322+
sessionId: SESSION_ID,
323+
cwd: '/fake/project',
324+
mcpServers: [],
325+
});
326+
327+
expect(mockRestoreWorktreeContext).toHaveBeenCalledWith(SIDECAR_PATH);
328+
expect(lastSessionMock?.pendingWorktreeNotice).toBeNull();
329+
330+
mockConnectionState.resolve();
331+
await agentPromise;
332+
});
333+
334+
it('VP2: live sidecar — pendingWorktreeNotice is set to contextMessage', async () => {
335+
const contextMessage =
336+
'[Resumed] Active worktree: "my-feature" at /repo/.qwen/worktrees/my-feature ' +
337+
'(branch: worktree-my-feature). Continue using this path for all file operations.';
338+
mockRestoreWorktreeContext.mockResolvedValueOnce({
339+
contextMessage,
340+
session: {
341+
slug: 'my-feature',
342+
worktreePath: '/repo/.qwen/worktrees/my-feature',
343+
worktreeBranch: 'worktree-my-feature',
344+
originalCwd: '/repo',
345+
originalBranch: 'main',
346+
originalHeadCommit: 'abc1234',
347+
},
348+
});
349+
350+
const innerConfig = makeInnerConfig();
351+
const { agent, agentPromise } = await bootAgentWithLoadSession(innerConfig);
352+
353+
await agent.loadSession({
354+
sessionId: SESSION_ID,
355+
cwd: '/fake/project',
356+
mcpServers: [],
357+
});
358+
359+
expect(mockRestoreWorktreeContext).toHaveBeenCalledWith(SIDECAR_PATH);
360+
expect(lastSessionMock?.pendingWorktreeNotice).toBe(contextMessage);
361+
362+
mockConnectionState.resolve();
363+
await agentPromise;
364+
});
365+
366+
it('VP2b: restoreWorktreeContext throws — loadSession succeeds and notice stays null', async () => {
367+
mockRestoreWorktreeContext.mockRejectedValueOnce(
368+
new Error('disk I/O error'),
369+
);
370+
371+
const innerConfig = makeInnerConfig();
372+
const { agent, agentPromise } = await bootAgentWithLoadSession(innerConfig);
373+
374+
await expect(
375+
agent.loadSession({
376+
sessionId: SESSION_ID,
377+
cwd: '/fake/project',
378+
mcpServers: [],
379+
}),
380+
).resolves.not.toThrow();
381+
382+
expect(lastSessionMock?.pendingWorktreeNotice).toBeNull();
383+
384+
mockConnectionState.resolve();
385+
await agentPromise;
386+
});
387+
});

0 commit comments

Comments
 (0)