diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index f0798521d5d..2487aa51192 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -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(); + return { ...actual, existsSync: mockExistsSync }; +}); const { mockStartNonInteractiveOpenAILogHousekeeping } = vi.hoisted(() => ({ mockStartNonInteractiveOpenAILogHousekeeping: vi.fn(), })); @@ -1994,6 +2001,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: {} }, @@ -4378,6 +4389,63 @@ 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 tmpDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-cd-worktree-settings-'), + ); + const repoRoot = path.join(tmpDir, 'repo'); + const targetDir = path.join(repoRoot, '.qwen', 'worktrees', 'test'); + await fs.mkdir(targetDir, { recursive: true }); + await fs.mkdir(path.join(repoRoot, '.git')); + await fs.writeFile(path.join(targetDir, '.git'), 'gitdir: /fake'); + 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)?.['worktreeCwd'], + ).toBe(canonicalTargetDir); + + vi.mocked(loadSettings).mockClear(); + await agent.extMethod('qwen/settings/getCore', {}); + expect(vi.mocked(loadSettings)).toHaveBeenCalledWith(canonicalTargetDir); + } finally { + await fs.rm(tmpDir, { 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'); @@ -9899,6 +9967,197 @@ describe('QwenAgent MCP SSE/HTTP support', () => { await agentPromise; }); + it('qwen/settings handlers resolve against worktree cwd set by createAndStoreSession', async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-wt-s1-')); + const repoRoot = path.join(tmpDir, 'repo'); + const WORKTREE_DIR = path.join(repoRoot, '.qwen', 'worktrees', 'test'); + await fs.mkdir(WORKTREE_DIR, { recursive: true }); + await fs.mkdir(path.join(repoRoot, '.git')); + await fs.writeFile(path.join(WORKTREE_DIR, '.git'), 'gitdir: /fake'); + const innerConfig = await setupSessionMocks('wt-settings-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); + + 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; + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it('qwen/settings handlers fall back to process.cwd() after worktree session closes', async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-wt-s2-')); + const repoRoot = path.join(tmpDir, 'repo'); + const WORKTREE_DIR = path.join(repoRoot, '.qwen', 'worktrees', 'test'); + await fs.mkdir(WORKTREE_DIR, { recursive: true }); + await fs.mkdir(path.join(repoRoot, '.git')); + await fs.writeFile(path.join(WORKTREE_DIR, '.git'), 'gitdir: /fake'); + 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; + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it('qwen/settings/getCore resolves per-session worktreeCwd via sessionId param', async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-wt-s3-')); + const repoRoot = path.join(tmpDir, 'repo'); + const WORKTREE_DIR = path.join(repoRoot, '.qwen', 'worktrees', 'test'); + await fs.mkdir(WORKTREE_DIR, { recursive: true }); + await fs.mkdir(path.join(repoRoot, '.git')); + await fs.writeFile(path.join(WORKTREE_DIR, '.git'), 'gitdir: /fake'); + 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; + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it('qwen/settings/getCore falls through to configWt when worktreeCwd dir is deleted', async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-wt-s4-')); + const repoRoot = path.join(tmpDir, 'repo'); + const WORKTREE_DIR = path.join(repoRoot, '.qwen', 'worktrees', 'deleted'); + await fs.mkdir(WORKTREE_DIR, { recursive: true }); + await fs.mkdir(path.join(repoRoot, '.git')); + await fs.writeFile(path.join(WORKTREE_DIR, '.git'), 'gitdir: /fake'); + const innerConfig = await setupSessionMocks('deleted-wt-session'); + innerConfig.getTargetDir = vi.fn().mockReturnValue(WORKTREE_DIR); + (innerConfig as Record)['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; + + await fs.rm(WORKTREE_DIR, { recursive: true, force: true }); + 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; + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it('qwen/settings/getCore uses configWt when worktreeCwd is deleted but activeWorktree exists', async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-wt-s5-')); + const repoRoot = path.join(tmpDir, 'repo'); + const ACTIVE_WT = path.join(repoRoot, '.qwen', 'worktrees', 'active'); + await fs.mkdir(ACTIVE_WT, { recursive: true }); + await fs.mkdir(path.join(repoRoot, '.git')); + await fs.writeFile(path.join(ACTIVE_WT, '.git'), 'gitdir: /fake'); + const innerConfig = await setupSessionMocks('active-wt-session'); + innerConfig.getTargetDir = vi.fn().mockReturnValue('/fake/deleted-wt'); + (innerConfig as Record)['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; + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + it('qwen/permissions/setRules validates scope and ruleType', async () => { const settings = makeCoreSettings(); const { agent, agentPromise } = await bootCoreSettingsAgent(settings); @@ -17948,12 +18207,22 @@ describe('sessionLanguage multi-session propagation', () => { getUserHooks: vi.fn().mockReturnValue({}), getProjectHooks: vi.fn().mockReturnValue({}), } as unknown as LoadedSettings; + const reloadedSettings = { + merged: { modelProviders: providerConfig }, + getUserHooks: vi.fn().mockReturnValue({}), + getProjectHooks: vi.fn().mockReturnValue({}), + } as unknown as LoadedSettings; const cfg = makeConfig({ getSessionId: vi.fn().mockReturnValue('s-reload'), getAuthType: vi.fn().mockReturnValue('openai'), }); - vi.mocked(loadSettings).mockReturnValue(settings); + vi.mocked(loadSettings).mockImplementation((...args) => { + if (args[1] && (args[1] as Record).skipLoadEnvironment) { + return reloadedSettings; + } + return settings; + }); vi.mocked(loadCliConfig).mockResolvedValue(cfg as unknown as Config); vi.mocked(Session).mockImplementation( () => @@ -18012,6 +18281,11 @@ describe('sessionLanguage multi-session propagation', () => { getUserHooks: vi.fn().mockReturnValue({}), getProjectHooks: vi.fn().mockReturnValue({}), } as unknown as LoadedSettings; + const reloadedSettings = { + merged: { tools: { approvalMode: 'plan' } }, + getUserHooks: vi.fn().mockReturnValue({}), + getProjectHooks: vi.fn().mockReturnValue({}), + } as unknown as LoadedSettings; let approvalMode = 'default'; const cfg = makeConfig({ getSessionId: vi.fn().mockReturnValue('s-plan-reload'), @@ -18024,7 +18298,12 @@ describe('sessionLanguage multi-session propagation', () => { const clearActiveTodoPlanRevision = vi.fn(); const clearTodoStopGuardTrust = vi.fn(); - vi.mocked(loadSettings).mockReturnValue(settings); + vi.mocked(loadSettings).mockImplementation((...args) => { + if (args[1] && (args[1] as Record).skipLoadEnvironment) { + return reloadedSettings; + } + return settings; + }); vi.mocked(loadCliConfig).mockResolvedValue(cfg as unknown as Config); vi.mocked(Session).mockImplementation( () => diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 2f10407e7e0..d60f7eb5d47 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -174,6 +174,7 @@ import { normalizeDisabledToolList } from '../config/normalizeDisabledTools.js'; import { pipeline } from 'node:stream/promises'; import type { Stats } from 'node:fs'; import * as fs from 'node:fs/promises'; +import { existsSync, statSync } from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { createGunzip } from 'node:zlib'; @@ -608,6 +609,24 @@ function defaultAcpOnlyLocalReadRoots(): string[] { return process.platform === 'win32' ? [] : [POSIX_TMP_LOCAL_READ_ROOT]; } +/** Returns true when `target` lives under `/.qwen/worktrees/`. */ +export function isWorktreePath(target: string): boolean { + const normalizedTarget = path.normalize(target); + let searchDir = path.resolve(normalizedTarget); + + while (true) { + const gitPath = path.join(searchDir, '.git'); + if (existsSync(gitPath) && statSync(gitPath).isDirectory()) { + const worktreesDir = path.join(searchDir, '.qwen', 'worktrees'); + return normalizedTarget.startsWith(worktreesDir + path.sep); + } + const parentDir = path.dirname(searchDir); + if (parentDir === searchDir) break; + searchDir = parentDir; + } + return false; +} + function buildAcpLocalReadRoots(config: Config): string[] { return [ // SYNC: The first group mirrors ReadFileTool's default allowed local roots, @@ -5265,6 +5284,10 @@ class QwenAgent implements Agent { if (restored.contextMessage) { session.pendingWorktreeNotice = restored.contextMessage; } + if (restored.session?.worktreePath) { + session.worktreeCwd = restored.session.worktreePath; + config.setActiveWorktree?.(restored.session.worktreePath); + } } catch (error) { debugLogger.warn(`ACP worktree restore failed: ${error}`); } @@ -7867,6 +7890,46 @@ class QwenAgent implements Agent { } } + /** + * Resolve the cwd for `qwen/settings/*` handlers with per-session + * worktree awareness. Priority: session.worktreeCwd > session + * config's activeWorktree > explicit `cwd` param > process.cwd(). + * + * TODO(#8138): The desktop Settings panel sends qwen/settings/* through a + * session-less shared ACP process (buildSharedAcpProcessKey never matches + * the session-hosting process). In that process this.sessions is empty, so + * resolution falls through to process.cwd(). A future fix should populate + * worktreeRootPath per the desktop wiring or route the settings panel + * through the session-hosting process. + */ + private resolveSettingsCwd(params: Record): string { + const sessionId = params['sessionId']; + if (typeof sessionId === 'string' && sessionId.length > 0) { + const session = this.sessions.get(sessionId); + if (session) { + const configWt = session.getConfig().getActiveWorktree?.(); + if (session.worktreeCwd && existsSync(session.worktreeCwd)) { + return session.worktreeCwd; + } + if (configWt && existsSync(configWt)) { + return configWt; + } + return process.cwd(); + } + return process.cwd(); + } + for (const session of this.sessions.values()) { + if (session.worktreeCwd && existsSync(session.worktreeCwd)) { + return session.worktreeCwd; + } + } + for (const session of this.sessions.values()) { + const wt = session.getConfig().getActiveWorktree?.(); + if (wt && existsSync(wt)) return wt; + } + return process.cwd(); + } + private async extMethodInternal( method: string, params: Record, @@ -7874,6 +7937,11 @@ class QwenAgent implements Agent { const requestedCwd = typeof params['cwd'] === 'string' ? params['cwd'] : undefined; const cwd = requestedCwd || process.cwd(); + const resolvedWt = this.resolveSettingsCwd(params); + const settingsCwd = + (resolvedWt !== process.cwd() ? resolvedWt : undefined) || + requestedCwd || + process.cwd(); const SESSION_ID_RE = /^[0-9a-fA-F-]{32,36}$/; switch (method) { @@ -8003,7 +8071,7 @@ class QwenAgent implements Agent { ); } case 'qwen/settings/getMemory': { - const settings = loadSettings(cwd); + const settings = loadSettings(settingsCwd); this.settings = settings; return { settings: normalizeQwenMemorySettings(settings.merged.memory), @@ -8014,7 +8082,7 @@ class QwenAgent implements Agent { // Mutate a freshly loaded settings object and adopt it, mirroring the // other settings mutation handlers, instead of writing through the // possibly-stale cached `this.settings` and reading it back. - const settings = loadSettings(cwd); + const settings = loadSettings(settingsCwd); for (const key of QWEN_MEMORY_SETTING_KEYS) { if (updates[key] === undefined) continue; if (typeof updates[key] !== 'boolean') { @@ -8037,9 +8105,12 @@ class QwenAgent implements Agent { const projectRoot = typeof params['projectRoot'] === 'string' ? params['projectRoot'] - : cwd; + : settingsCwd; return { - paths: await resolveQwenMemoryPaths({ cwd, projectRoot }), + paths: await resolveQwenMemoryPaths({ + cwd: settingsCwd, + projectRoot, + }), }; } case SERVE_STATUS_EXT_METHODS.workspaceMcp: @@ -9959,6 +10030,17 @@ class QwenAgent implements Agent { ); } + if ( + canonicalPath !== process.cwd() && + isWorktreePath(canonicalPath) + ) { + session.worktreeCwd = canonicalPath; + session.getConfig().setActiveWorktree?.(canonicalPath); + } else { + session.worktreeCwd = null; + session.getConfig().setActiveWorktree?.(null); + } + try { await config .getGeminiClient() @@ -11142,9 +11224,9 @@ class QwenAgent implements Agent { return { newSessionId, title, displayName: title }; } case 'qwen/settings/getCore': { - const settings = loadSettings(cwd); + const settings = loadSettings(settingsCwd); this.settings = settings; - return this.buildCoreSettings(settings, cwd); + return this.buildCoreSettings(settings, settingsCwd); } case 'qwen/settings/setCoreValue': { const key = params['key']; @@ -11157,7 +11239,7 @@ class QwenAgent implements Agent { 'Unsupported Qwen setting key', ); } - const settings = loadSettings(cwd); + const settings = loadSettings(settingsCwd); const settingKey = key as QwenCoreSettingKey; const normalizedValue = normalizeCoreSettingValue( settingKey, @@ -11187,7 +11269,7 @@ class QwenAgent implements Agent { // `setValue` already persisted to disk and recomputed the in-memory // merged view, so reloading from disk here is redundant I/O. this.settings = settings; - return this.buildCoreSettings(settings, cwd); + return this.buildCoreSettings(settings, settingsCwd); } case 'qwen/settings/setMcpServer': { const name = params['name']; @@ -11197,7 +11279,7 @@ class QwenAgent implements Agent { 'MCP server name is required', ); } - const settings = loadSettings(cwd); + const settings = loadSettings(settingsCwd); const settingScope = toSettingsScope(params['scope']); const scope = settingScope === SettingScope.Workspace ? 'workspace' : 'user'; @@ -11216,7 +11298,7 @@ class QwenAgent implements Agent { // `setValue` already persisted to disk and recomputed the in-memory // merged view, so reloading from disk here is redundant I/O. this.settings = settings; - return this.buildCoreSettings(settings, cwd); + return this.buildCoreSettings(settings, settingsCwd); } case 'qwen/settings/removeMcpServer': { const name = params['name']; @@ -11226,7 +11308,7 @@ class QwenAgent implements Agent { 'MCP server name is required', ); } - const settings = loadSettings(cwd); + const settings = loadSettings(settingsCwd); const settingScope = toSettingsScope(params['scope']); const scope = settingScope === SettingScope.Workspace ? 'workspace' : 'user'; @@ -11237,14 +11319,14 @@ class QwenAgent implements Agent { // `setValue` already persisted to disk and recomputed the in-memory // merged view, so reloading from disk here is redundant I/O. this.settings = settings; - return this.buildCoreSettings(settings, cwd); + return this.buildCoreSettings(settings, settingsCwd); } case 'qwen/settings/setHook': { const event = params['event']; if (!isHookEvent(event)) { throw RequestError.invalidParams(undefined, 'Invalid hook event'); } - const settings = loadSettings(cwd); + const settings = loadSettings(settingsCwd); const settingScope = toSettingsScope(params['scope']); const scope = settingScope === SettingScope.Workspace ? 'workspace' : 'user'; @@ -11284,7 +11366,7 @@ class QwenAgent implements Agent { // `setValue` already persisted to disk and recomputed the in-memory // merged view, so reloading from disk here is redundant I/O. this.settings = settings; - return this.buildCoreSettings(settings, cwd); + return this.buildCoreSettings(settings, settingsCwd); } case 'qwen/settings/removeHook': { const event = params['event']; @@ -11299,7 +11381,7 @@ class QwenAgent implements Agent { ) { throw RequestError.invalidParams(undefined, 'Invalid hook index'); } - const settings = loadSettings(cwd); + const settings = loadSettings(settingsCwd); const settingScope = toSettingsScope(params['scope']); const scope = settingScope === SettingScope.Workspace ? 'workspace' : 'user'; @@ -11320,7 +11402,7 @@ class QwenAgent implements Agent { // `setValue` already persisted to disk and recomputed the in-memory // merged view, so reloading from disk here is redundant I/O. this.settings = settings; - return this.buildCoreSettings(settings, cwd); + return this.buildCoreSettings(settings, settingsCwd); } case 'qwen/settings/setExtensionSetting': { const extensionId = params['extensionId']; @@ -11338,9 +11420,9 @@ class QwenAgent implements Agent { if (typeof value !== 'string') { throw RequestError.invalidParams(undefined, 'value must be a string'); } - const settings = loadSettings(cwd); + const settings = loadSettings(settingsCwd); const extensionManager = new ExtensionManager({ - workspaceDir: cwd, + workspaceDir: settingsCwd, isWorkspaceTrusted: isWorkspaceTrusted(settings.merged).isTrusted ?? true, locale: getCurrentLanguage(), @@ -11368,10 +11450,10 @@ class QwenAgent implements Agent { // so `settings` here is just the snapshot loaded above and is reused to // build the response. this.settings = settings; - return this.buildCoreSettings(settings, cwd); + return this.buildCoreSettings(settings, settingsCwd); } case 'qwen/permissions/getSettings': { - const settings = this.loadPermissionSettings(cwd); + const settings = this.loadPermissionSettings(settingsCwd); return buildPermissionSettings(settings) as unknown as Record< string, unknown @@ -11393,7 +11475,7 @@ class QwenAgent implements Agent { ); } - const settings = this.loadPermissionSettings(cwd); + const settings = this.loadPermissionSettings(settingsCwd); const before = readPermissionRuleSet(settings.merged); const settingScope = scope === 'workspace' ? SettingScope.Workspace : SettingScope.User; @@ -11428,12 +11510,12 @@ class QwenAgent implements Agent { } case SERVE_CONTROL_EXT_METHODS.workspaceReload: { const oldMerged = structuredClone(this.settings.merged); - - this.settings.reloadScopeFromDisk(SettingScope.User); - this.settings.reloadScopeFromDisk(SettingScope.Workspace); + this.settings = loadSettings(settingsCwd, { + skipLoadEnvironment: true, + }); const newMerged = this.settings.merged; - const envResult = reloadEnvironment(newMerged, cwd); + const envResult = reloadEnvironment(newMerged, settingsCwd); const changed = diffSettingsKeys(oldMerged, newMerged); const envChanged = @@ -12136,6 +12218,12 @@ class QwenAgent implements Agent { () => this.activeWorkReporter?.notifyChanged(), ); this.sessions.set(sessionId, session); + + const targetDir = config.getTargetDir(); + if (targetDir !== process.cwd() && isWorktreePath(targetDir)) { + session.worktreeCwd = targetDir; + } + // The Session set itself is part of the snapshot: publish so the daemon // learns about this Session from a report rather than inferring it. this.activeWorkReporter?.notifyChanged(); diff --git a/packages/cli/src/acp-integration/acpAgent.worktree.test.ts b/packages/cli/src/acp-integration/acpAgent.worktree.test.ts index 9ddec5b70a6..9985c101d0a 100644 --- a/packages/cli/src/acp-integration/acpAgent.worktree.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.worktree.test.ts @@ -206,6 +206,7 @@ vi.mock('@qwen-code/qwen-code-core', () => ({ snapshot: vi.fn(() => ({})), })), restoreWorktreeContext: mockRestoreWorktreeContext, + findGitRoot: vi.fn().mockReturnValue(null), HookEventName: { PreToolUse: 'PreToolUse', PostToolUse: 'PostToolUse', @@ -286,6 +287,9 @@ import { AgentSideConnection } from '@agentclientprotocol/sdk'; import { loadSettings } from '../config/settings.js'; import { loadCliConfig } from '../config/config.js'; import { Session } from './session/Session.js'; +import * as fsPromises from 'node:fs/promises'; +import * as nodePath from 'node:path'; +import * as nodeOs from 'node:os'; // --------------------------------------------------------------------------- // Test suite — VP1, VP2, VP2b @@ -365,6 +369,7 @@ describe('QwenAgent loadSession — Phase C worktree context restore', () => { getDebugMode: vi.fn().mockReturnValue(false), getMcpServers: vi.fn().mockReturnValue({}), setMcpBudgetEventCallback: vi.fn(), + getTargetDir: vi.fn().mockReturnValue(process.cwd()), }; } @@ -452,6 +457,7 @@ describe('QwenAgent loadSession — Phase C worktree context restore', () => { startCronScheduler: vi.fn(), dispose: vi.fn(), pendingWorktreeNotice: null as string | null, + worktreeCwd: null as string | null, }; lastSessionMock = mock; return mock as unknown as InstanceType; @@ -544,4 +550,105 @@ describe('QwenAgent loadSession — Phase C worktree context restore', () => { mockConnectionState.resolve(); await agentPromise; }); + + it('VP4: getTargetDir returns a worktree path — session.worktreeCwd is set', async () => { + const tmpDir = await fsPromises.mkdtemp( + nodePath.join(nodeOs.tmpdir(), 'qwen-wt-vp4-'), + ); + const repoDir = nodePath.join(tmpDir, 'repo'); + const worktreesDir = nodePath.join(repoDir, '.qwen', 'worktrees'); + const worktreePath = nodePath.join(worktreesDir, 'my-feature'); + await fsPromises.mkdir(worktreePath, { recursive: true }); + await fsPromises.mkdir(nodePath.join(repoDir, '.git')); + await fsPromises.writeFile( + nodePath.join(worktreePath, '.git'), + 'gitdir: /fake', + ); + + const innerConfig = makeInnerConfig(); + (innerConfig.getTargetDir as ReturnType).mockReturnValue( + worktreePath, + ); + + try { + const { agent, agentPromise } = + await bootAgentWithLoadSession(innerConfig); + + await agent.loadSession({ + sessionId: SESSION_ID, + cwd: '/fake/project', + mcpServers: [], + }); + + expect( + (lastSessionMock as Record)?.['worktreeCwd'], + ).toBe(worktreePath); + + mockConnectionState.resolve(); + await agentPromise; + } finally { + await fsPromises.rm(tmpDir, { recursive: true, force: true }); + } + }); + + it('MUST-1/R2-17: resume restores worktreeCwd from restored sidecar session', async () => { + const worktreePath = '/repo/.qwen/worktrees/my-feature'; + mockRestoreWorktreeContext.mockResolvedValueOnce({ + contextMessage: null, + session: { + slug: 'my-feature', + worktreePath, + worktreeBranch: 'worktree-my-feature', + originalCwd: '/repo', + originalBranch: 'main', + originalHeadCommit: 'abc1234', + }, + }); + + const innerConfig = makeInnerConfig(); + (innerConfig as Record)['setActiveWorktree'] = vi.fn(); + + const { agent, agentPromise } = await bootAgentWithLoadSession(innerConfig); + + await agent.loadSession({ + sessionId: SESSION_ID, + cwd: '/fake/project', + mcpServers: [], + }); + + expect(mockRestoreWorktreeContext).toHaveBeenCalledWith(SIDECAR_PATH); + expect((lastSessionMock as Record)?.['worktreeCwd']).toBe( + worktreePath, + ); + expect( + (innerConfig as Record)['setActiveWorktree'], + ).toHaveBeenCalledWith(worktreePath); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('MUST-1/R2-17: resume without worktree sidecar leaves state untouched', async () => { + // mockRestoreWorktreeContext defaults to { contextMessage: null, session: null } + const innerConfig = makeInnerConfig(); + (innerConfig as Record)['setActiveWorktree'] = vi.fn(); + + const { agent, agentPromise } = await bootAgentWithLoadSession(innerConfig); + + await agent.loadSession({ + sessionId: SESSION_ID, + cwd: '/fake/project', + mcpServers: [], + }); + + expect((lastSessionMock as Record)?.['worktreeCwd']).toBe( + null, + ); + expect( + (innerConfig as Record)['setActiveWorktree'], + ).not.toHaveBeenCalled(); + + mockConnectionState.resolve(); + await agentPromise; + }); }); diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index bc2ab1d8bd9..32da3ec968f 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -1573,6 +1573,15 @@ export class Session implements SessionContext { */ pendingWorktreeNotice: string | null = null; + /** + * Absolute path to the worktree when this session operates inside one. + * Set by acpAgent after session creation; `null` for regular sessions. + * Read by `resolveSettingsCwd` to route `qwen/settings/*` handlers to this + * session's worktree; cleared implicitly when the session is removed from + * the agent's session map. + */ + worktreeCwd: string | null = null; + /** One-shot model notice for background agents restored with the session. */ pendingRecoveredAgentsNotice: string | null = null; diff --git a/packages/cli/src/acp-integration/session/Session.worktree.test.ts b/packages/cli/src/acp-integration/session/Session.worktree.test.ts index fe27cd56706..22a336fd92d 100644 --- a/packages/cli/src/acp-integration/session/Session.worktree.test.ts +++ b/packages/cli/src/acp-integration/session/Session.worktree.test.ts @@ -382,6 +382,20 @@ describe('Session.pendingWorktreeNotice', () => { expect(capturedMessages.length).toBeGreaterThanOrEqual(1); }); + it('worktreeCwd defaults to null and can be set', () => { + const session = new Session( + SESSION_ID, + mockConfig, + mockClient, + mockSettings, + ); + + expect(session.worktreeCwd).toBeNull(); + + session.worktreeCwd = '/repo/.qwen/worktrees/feat'; + expect(session.worktreeCwd).toBe('/repo/.qwen/worktrees/feat'); + }); + // VP5: ordering contract when the worktree notice, system reminders, and a // continuation that leads with functionResponse parts all combine. Locks the // `[...functionResponses, worktreeNotice, ...systemReminders, ...]` order so diff --git a/packages/cli/src/serve/routes/workspace-settings.test.ts b/packages/cli/src/serve/routes/workspace-settings.test.ts index 212afb7db03..7e32b481ed8 100644 --- a/packages/cli/src/serve/routes/workspace-settings.test.ts +++ b/packages/cli/src/serve/routes/workspace-settings.test.ts @@ -30,6 +30,7 @@ function makeApp( overrides: { captureGenerationAssertion?: () => (() => void) | undefined; afterPersist?: () => void; + resolveEffectiveWorkspace?: () => string; } = {}, ) { const app = express(); @@ -49,6 +50,7 @@ function makeApp( broadcastSettingsChanged, parseAndValidateClientId: () => undefined, captureGenerationAssertion: overrides.captureGenerationAssertion, + resolveEffectiveWorkspace: overrides.resolveEffectiveWorkspace, includeLiveVoice: true, }); @@ -477,4 +479,38 @@ describe('POST /workspace/settings', () => { ); }, ); + + it('GET /workspace/settings uses resolveEffectiveWorkspace when provided', async () => { + const { app } = makeApp({ + resolveEffectiveWorkspace: () => '/worktree/effective', + }); + + const res = await request(app).get('/workspace/settings'); + + expect(res.status).toBe(200); + expect(loadSettings).toHaveBeenCalledWith( + '/worktree/effective', + expect.any(Object), + ); + }); + + it('POST /workspace/settings uses resolveEffectiveWorkspace when provided', async () => { + const { app, persistSetting } = makeApp({ + resolveEffectiveWorkspace: () => '/worktree/effective', + }); + + const res = await request(app).post('/workspace/settings').send({ + scope: 'workspace', + key: 'general.cleanupPeriodDays', + value: 7, + }); + + expect(res.status).toBe(200); + expect(persistSetting).toHaveBeenCalledWith( + '/worktree/effective', + expect.any(String), + 'general.cleanupPeriodDays', + 7, + ); + }); }); diff --git a/packages/cli/src/serve/routes/workspace-settings.ts b/packages/cli/src/serve/routes/workspace-settings.ts index 2adbec738e6..3a7bdbf87bd 100644 --- a/packages/cli/src/serve/routes/workspace-settings.ts +++ b/packages/cli/src/serve/routes/workspace-settings.ts @@ -28,6 +28,7 @@ import { resolveWorkspaceRuntimeFromParam, sendGenerationClosedError, } from '../workspace-route-runtime.js'; +import { findEffectiveWorkspace } from '../worktree-workspace.js'; import type { WorkspaceRegistry } from '../workspace-registry.js'; const TUI_ONLY_SETTINGS = new Set([ @@ -290,6 +291,7 @@ export interface WorkspaceSettingsRouteDeps { req: Request, res: Response, ) => string | undefined | null; + resolveEffectiveWorkspace?: () => string; includeLiveVoice?: boolean; } @@ -304,6 +306,7 @@ export function registerWorkspaceSettingsRoutes( persistSetting, broadcastSettingsChanged, parseAndValidateClientId, + resolveEffectiveWorkspace, } = deps; const allowedKeys = getAllowedKeys(deps.includeLiveVoice === true); @@ -313,8 +316,9 @@ export function registerWorkspaceSettingsRoutes( const assertGenerationOpen = deps.captureGenerationAssertion?.() ?? (() => {}); assertGenerationOpen(); + const getWorkspace = resolveEffectiveWorkspace?.() ?? boundWorkspace; const response = buildSettingsResponse( - boundWorkspace, + getWorkspace, allowedKeys, deps.isWorkspaceTrusted?.() ?? true, ); @@ -435,10 +439,12 @@ export function registerWorkspaceSettingsRoutes( return; } let publicValue: unknown = value; + const effectiveWorkspace = + resolveEffectiveWorkspace?.() ?? boundWorkspace; try { const persist = async () => { const prepared = prepareSettingWrite( - boundWorkspace, + effectiveWorkspace, settingScope, key, value, @@ -448,7 +454,7 @@ export function registerWorkspaceSettingsRoutes( publicValue = prepared.publicValue; if (deps.captureGenerationAssertion) { await persistSetting( - boundWorkspace, + effectiveWorkspace, settingScope, key, prepared.persistedValue, @@ -456,7 +462,7 @@ export function registerWorkspaceSettingsRoutes( ); } else { await persistSetting( - boundWorkspace, + effectiveWorkspace, settingScope, key, prepared.persistedValue, @@ -465,7 +471,7 @@ export function registerWorkspaceSettingsRoutes( }; if (mcpServerMutation) { await withMcpServerMutationLock( - boundWorkspace, + effectiveWorkspace, settingScope, persist, ); @@ -475,7 +481,7 @@ export function registerWorkspaceSettingsRoutes( } catch (err) { if (sendGenerationClosedError(res, err)) return; writeStderrLine( - `qwen serve: POST /workspace/settings persist error (key=${key}, scope=${scope}, workspace=${boundWorkspace}): ${ + `qwen serve: POST /workspace/settings persist error (key=${key}, scope=${scope}, workspace=${effectiveWorkspace}): ${ err instanceof Error ? err.message : String(err) }`, ); @@ -535,7 +541,11 @@ export function registerWorkspaceQualifiedSettingsRoutes( // the Phase 3 core-route trust gate. if (!runtime || !requireTrustedWorkspaceRuntime(runtime, res)) return; try { - const response = buildSettingsResponse(runtime.workspaceCwd, allowedKeys); + const effective = findEffectiveWorkspace( + runtime.bridge, + runtime.workspaceCwd, + ); + const response = buildSettingsResponse(effective, allowedKeys); res.status(200).json(response); } catch (err) { writeStderrLine( @@ -647,10 +657,14 @@ export function registerWorkspaceQualifiedSettingsRoutes( return; } let publicValue: unknown = value; + const effective = findEffectiveWorkspace( + runtime.bridge, + runtime.workspaceCwd, + ); try { const persist = async () => { const prepared = prepareSettingWrite( - runtime.workspaceCwd, + effective, settingScope, key, value, @@ -659,7 +673,7 @@ export function registerWorkspaceQualifiedSettingsRoutes( ); publicValue = prepared.publicValue; await deps.persistSetting( - runtime.workspaceCwd, + effective, settingScope, key, prepared.persistedValue, @@ -667,18 +681,14 @@ export function registerWorkspaceQualifiedSettingsRoutes( ); }; if (mcpServerMutation) { - await withMcpServerMutationLock( - runtime.workspaceCwd, - settingScope, - persist, - ); + await withMcpServerMutationLock(effective, settingScope, persist); } else { await persist(); } } catch (err) { if (sendGenerationClosedError(res, err)) return; writeStderrLine( - `qwen serve: POST /workspaces/:workspace/settings persist error (key=${key}, scope=${scope}, workspace=${runtime.workspaceCwd}): ${ + `qwen serve: POST /workspaces/:workspace/settings persist error (key=${key}, scope=${scope}, workspace=${effective}): ${ err instanceof Error ? err.message : String(err) }`, ); diff --git a/packages/cli/src/serve/run-qwen-serve.ts b/packages/cli/src/serve/run-qwen-serve.ts index 92858dd2f34..6108223be4b 100644 --- a/packages/cli/src/serve/run-qwen-serve.ts +++ b/packages/cli/src/serve/run-qwen-serve.ts @@ -78,6 +78,10 @@ import { isDeepHealthQuery } from './health-query.js'; import { isLoopbackBind } from './loopback-binds.js'; import { RUNTIME_STARTUP_CANCELLED_MESSAGE } from './runtime-startup-errors.js'; import { resolveWebShellDir } from './web-shell-resolver.js'; +import { + findEffectiveWorkspace, + type SessionLister, +} from './worktree-workspace.js'; import { allowOriginCors, bearerAuth, @@ -3789,118 +3793,133 @@ async function runQwenServeImpl( (workspaceRegistryForPersistence.current === undefined && workspace === boundWorkspace && trustedWorkspace); - const loadSettingsForPersistence = (workspace: string) => { - const trusted = isWorkspaceTrustedForPersistence(workspace); + const loadSettingsForPersistence = ( + workspace: string, + trustWorkspace?: string, + ) => { + const trusted = isWorkspaceTrustedForPersistence( + trustWorkspace ?? workspace, + ); return settingsRuntime.settings.loadSettings(workspace, { skipLoadEnvironment: true, skipWorkspaceSettings: !trusted, workspaceTrusted: trusted, }); }; - const persistDisabledToolsFn = ( - workspace: string, - toolName: string, - enabled: boolean, - assertGenerationOpen?: () => void, - ): Promise => - withSettingsLock(workspace, async () => { - assertGenerationOpen?.(); - const fresh = loadSettingsForPersistence(workspace); - const wsScope = fresh.forScope(WORKSPACE_SETTING_SCOPE).settings; - const wsDisabled = wsScope.tools?.disabled; - const current = Array.isArray(wsDisabled) - ? wsDisabled.filter((v): v is string => typeof v === 'string') - : []; - const next = new Set(current); - if (enabled) next.delete(toolName); - else next.add(toolName); - assertGenerationOpen?.(); - fresh.setValue( - WORKSPACE_SETTING_SCOPE, - 'tools.disabled', - [...next].sort(), - assertGenerationOpen, - ); - }); - const persistDisabledSkillsFn = ( - workspace: string, - skillName: string, - enabled: boolean, - assertGenerationOpen?: () => void, - ) => - withSettingsLock(workspace, async () => { - assertGenerationOpen?.(); - const { - resolveSkillSettings, - skillSettingStrings, - updateWorkspaceSkillSettingLists, - } = await import('../config/skill-settings.js'); - const fresh = loadSettingsForPersistence(workspace); - const normalizedName = skillName.trim().toLowerCase(); - const resolved = resolveSkillSettings(fresh); - const disablement = resolved.disablements.get(normalizedName); - if (disablement?.reason === 'hard' && disablement.lockedScope) { - throw new runtime.WorkspaceSkillNotToggleableError( - skillName, - 'locked', - disablement.lockedScope, + const createPersistDisabledToolsFn = + (localBridge: SessionLister) => + ( + workspace: string, + toolName: string, + enabled: boolean, + assertGenerationOpen?: () => void, + ): Promise => { + const effective = findEffectiveWorkspace(localBridge, workspace); + return withSettingsLock(effective, async () => { + assertGenerationOpen?.(); + const fresh = loadSettingsForPersistence(effective, workspace); + const wsScope = fresh.forScope(WORKSPACE_SETTING_SCOPE).settings; + const wsDisabled = wsScope.tools?.disabled; + const current = Array.isArray(wsDisabled) + ? wsDisabled.filter((v): v is string => typeof v === 'string') + : []; + const next = new Set(current); + if (enabled) next.delete(toolName); + else next.add(toolName); + assertGenerationOpen?.(); + fresh.setValue( + WORKSPACE_SETTING_SCOPE, + 'tools.disabled', + [...next].sort(), + assertGenerationOpen, ); - } + }); + }; + const createPersistDisabledSkillsFn = + (localBridge: SessionLister) => + ( + workspace: string, + skillName: string, + enabled: boolean, + assertGenerationOpen?: () => void, + ) => { + const effective = findEffectiveWorkspace(localBridge, workspace); + return withSettingsLock(effective, async () => { + assertGenerationOpen?.(); + const { + resolveSkillSettings, + skillSettingStrings, + updateWorkspaceSkillSettingLists, + } = await import('../config/skill-settings.js'); + const fresh = loadSettingsForPersistence(effective, workspace); + const normalizedName = skillName.trim().toLowerCase(); + const resolved = resolveSkillSettings(fresh); + const disablement = resolved.disablements.get(normalizedName); + if (disablement?.reason === 'hard' && disablement.lockedScope) { + throw new runtime.WorkspaceSkillNotToggleableError( + skillName, + 'locked', + disablement.lockedScope, + ); + } - const workspaceDisabled = skillSettingStrings( - fresh, - WORKSPACE_SETTING_SCOPE, - 'disabled', - ); - const workspaceEnabled = skillSettingStrings( - fresh, - WORKSPACE_SETTING_SCOPE, - 'enabled', - ); - const next = updateWorkspaceSkillSettingLists( - { disabled: workspaceDisabled, enabled: workspaceEnabled }, - skillName, - enabled, - resolved.defaultDisabledNames.has(normalizedName) && - !resolved.enabledNames.has(normalizedName), - ); - const settingsChanges: Array<{ - key: 'skills.disabled' | 'skills.enabled'; - value: string[] | undefined; - }> = []; - if ( - JSON.stringify(next.disabled) !== JSON.stringify(workspaceDisabled) - ) { - settingsChanges.push({ - key: 'skills.disabled', - value: next.disabled.length > 0 ? next.disabled : undefined, - }); - } - if (JSON.stringify(next.enabled) !== JSON.stringify(workspaceEnabled)) { - settingsChanges.push({ - key: 'skills.enabled', - value: next.enabled.length > 0 ? next.enabled : undefined, - }); - } - if (settingsChanges.length === 0) { - return { changed: false, disabled: workspaceDisabled }; - } + const workspaceDisabled = skillSettingStrings( + fresh, + WORKSPACE_SETTING_SCOPE, + 'disabled', + ); + const workspaceEnabled = skillSettingStrings( + fresh, + WORKSPACE_SETTING_SCOPE, + 'enabled', + ); + const next = updateWorkspaceSkillSettingLists( + { disabled: workspaceDisabled, enabled: workspaceEnabled }, + skillName, + enabled, + resolved.defaultDisabledNames.has(normalizedName) && + !resolved.enabledNames.has(normalizedName), + ); + const settingsChanges: Array<{ + key: 'skills.disabled' | 'skills.enabled'; + value: string[] | undefined; + }> = []; + if ( + JSON.stringify(next.disabled) !== JSON.stringify(workspaceDisabled) + ) { + settingsChanges.push({ + key: 'skills.disabled', + value: next.disabled.length > 0 ? next.disabled : undefined, + }); + } + if ( + JSON.stringify(next.enabled) !== JSON.stringify(workspaceEnabled) + ) { + settingsChanges.push({ + key: 'skills.enabled', + value: next.enabled.length > 0 ? next.enabled : undefined, + }); + } + if (settingsChanges.length === 0) { + return { changed: false, disabled: workspaceDisabled }; + } - assertGenerationOpen?.(); - fresh.setValues( - settingsChanges.map((change) => ({ - scope: WORKSPACE_SETTING_SCOPE, - ...change, - })), - undefined, - assertGenerationOpen, - ); - return { - changed: true, - disabled: next.disabled, - settingsChanges, - }; - }); + assertGenerationOpen?.(); + fresh.setValues( + settingsChanges.map((change) => ({ + scope: WORKSPACE_SETTING_SCOPE, + ...change, + })), + undefined, + assertGenerationOpen, + ); + return { + changed: true, + disabled: next.disabled, + settingsChanges, + }; + }); + }; const persistDisabledSkillsBatchFn = ( workspace: string, skillNames: readonly string[], @@ -4135,15 +4154,16 @@ async function runQwenServeImpl( fileSystem: createBridgeFileSystemAdapter(fsFactory, { allowSameHostToolWritesOutsideWorkspace: deps.fsFactory === undefined, }), - persistApprovalMode: (workspace, mode) => - withSettingsLock(workspace, async () => { + persistApprovalMode: (workspace, mode) => { + const effective = findEffectiveWorkspace(bridge, workspace); + return withSettingsLock(effective, async () => { primaryGenerationGuard.assertOpen(); if (!trustedWorkspace) { throw new Error( 'Cannot persist approval mode for an untrusted workspace.', ); } - const fresh = settingsRuntime.settings.loadSettings(workspace, { + const fresh = settingsRuntime.settings.loadSettings(effective, { skipLoadEnvironment: true, workspaceTrusted: trustedWorkspace, }); @@ -4154,7 +4174,8 @@ async function runQwenServeImpl( mode, () => primaryGenerationGuard.assertOpen(), ); - }), + }); + }, }); if (!deps.bridge) { bridgeRef = bridge; @@ -4167,14 +4188,21 @@ async function runQwenServeImpl( isWorkspaceTrusted: () => trustedWorkspace, assertGenerationOpen: () => primaryGenerationGuard.assertOpen(), contextFilename: contextFilenameForInit ?? 'QWEN.md', + resolveContextFile: (filename, ws) => { + const effective = findEffectiveWorkspace(bridge, ws); + return { + target: path.resolve(effective, filename), + effectiveWorkspace: effective, + }; + }, statusProvider, workspaceProvidersStatusProvider, workspaceSkillsStatusProvider, skillInstallEnv: runtimeEffectiveEnv, voiceEnv: runtimeEffectiveEnv, isChannelLive: () => bridge.isChannelLive(), - persistDisabledTools: persistDisabledToolsFn, - persistDisabledSkills: persistDisabledSkillsFn, + persistDisabledTools: createPersistDisabledToolsFn(bridge), + persistDisabledSkills: createPersistDisabledSkillsFn(bridge), persistDisabledSkillsBatch: persistDisabledSkillsBatchFn, persistSetting: persistSettingFn, persistSettings: persistSettingsFn, @@ -4541,15 +4569,16 @@ async function runQwenServeImpl( fileSystem: createBridgeFileSystemAdapter(secondaryBridgeFsFactory, { allowSameHostToolWritesOutsideWorkspace: true, }), - persistApprovalMode: (workspace, mode) => - withSettingsLock(workspace, async () => { + persistApprovalMode: (workspace, mode) => { + const effective = findEffectiveWorkspace(secondaryBridge, workspace); + return withSettingsLock(effective, async () => { secondaryGenerationGuard.assertOpen(); if (!secondaryTrusted) { throw new Error( 'Cannot persist approval mode for an untrusted workspace.', ); } - const fresh = settingsRuntime.settings.loadSettings(workspace, { + const fresh = settingsRuntime.settings.loadSettings(effective, { skipLoadEnvironment: true, workspaceTrusted: secondaryTrusted, }); @@ -4560,7 +4589,8 @@ async function runQwenServeImpl( mode, () => secondaryGenerationGuard.assertOpen(), ); - }), + }); + }, }); secondaryBridgeRef = secondaryBridge; runtimeBridges.push(secondaryBridge); @@ -4571,6 +4601,13 @@ async function runQwenServeImpl( isWorkspaceTrusted: () => secondaryTrusted, assertGenerationOpen: () => secondaryGenerationGuard.assertOpen(), contextFilename: secondaryContextFilename, + resolveContextFile: (filename, ws) => { + const effective = findEffectiveWorkspace(secondaryBridge, ws); + return { + target: path.resolve(effective, filename), + effectiveWorkspace: effective, + }; + }, statusProvider: secondaryStatusProvider, workspaceProvidersStatusProvider: runtime.createWorkspaceProvidersStatusProvider({ @@ -4586,8 +4623,8 @@ async function runQwenServeImpl( voiceSettingsScope: WORKSPACE_SETTING_SCOPE, isChannelLive: () => secondaryBridge.isChannelLive(), preheatAcpChild: () => secondaryBridge.preheat(), - persistDisabledTools: persistDisabledToolsFn, - persistDisabledSkills: persistDisabledSkillsFn, + persistDisabledTools: createPersistDisabledToolsFn(secondaryBridge), + persistDisabledSkills: createPersistDisabledSkillsFn(secondaryBridge), persistDisabledSkillsBatch: persistDisabledSkillsBatchFn, persistSetting: persistSettingFn, persistSettings: persistSettingsFn, @@ -5097,15 +5134,16 @@ async function runQwenServeImpl( fileSystem: createBridgeFileSystemAdapter(wsFsFactory, { allowSameHostToolWritesOutsideWorkspace: true, }), - persistApprovalMode: (workspace, mode) => - withSettingsLock(workspace, async () => { + persistApprovalMode: (workspace, mode) => { + const effective = findEffectiveWorkspace(wsBridge, workspace); + return withSettingsLock(effective, async () => { generationGuard.assertOpen(); if (!trusted) { throw new Error( 'Cannot persist approval mode for an untrusted workspace.', ); } - const fresh = settingsRuntime.settings.loadSettings(workspace, { + const fresh = settingsRuntime.settings.loadSettings(effective, { skipLoadEnvironment: true, workspaceTrusted: trusted, }); @@ -5116,7 +5154,8 @@ async function runQwenServeImpl( mode, () => generationGuard.assertOpen(), ); - }), + }); + }, }); } catch (err) { wsSubSessionLauncher.stop(); @@ -5130,6 +5169,13 @@ async function runQwenServeImpl( isWorkspaceTrusted: () => trusted, assertGenerationOpen: () => generationGuard.assertOpen(), contextFilename: wsContextFilename, + resolveContextFile: (filename, ws) => { + const effective = findEffectiveWorkspace(wsBridge, ws); + return { + target: path.resolve(effective, filename), + effectiveWorkspace: effective, + }; + }, statusProvider: runtime.createDaemonStatusProvider({ env: wsEnv.effectiveEnv, }), @@ -5149,8 +5195,8 @@ async function runQwenServeImpl( : { voiceSettingsScope: WORKSPACE_SETTING_SCOPE }), isChannelLive: () => wsBridge.isChannelLive(), preheatAcpChild: () => wsBridge.preheat(), - persistDisabledTools: persistDisabledToolsFn, - persistDisabledSkills: persistDisabledSkillsFn, + persistDisabledTools: createPersistDisabledToolsFn(wsBridge), + persistDisabledSkills: createPersistDisabledSkillsFn(wsBridge), persistDisabledSkillsBatch: persistDisabledSkillsBatchFn, persistSetting: persistSettingFn, persistSettings: persistSettingsFn, @@ -5771,8 +5817,8 @@ async function runQwenServeImpl( // Reverse tool channel (#5626): the SAME registry wired into `bridge` above, // so the WS provider and the child-answering bridge share one sender map. clientMcpSenderRegistry, - persistDisabledTools: persistDisabledToolsFn, - persistDisabledSkills: persistDisabledSkillsFn, + persistDisabledTools: createPersistDisabledToolsFn(bridge), + persistDisabledSkills: createPersistDisabledSkillsFn(bridge), persistDisabledSkillsBatch: persistDisabledSkillsBatchFn, persistSetting: persistSettingFn, persistSettings: persistSettingsFn, diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index f98f6d955d5..bcf4ee5a4f6 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -6,7 +6,9 @@ import express from 'express'; import type { Application } from 'express'; +import * as path from 'node:path'; import type { DaemonStatusProvider } from '@qwen-code/acp-bridge'; +import { findEffectiveWorkspace } from './worktree-workspace.js'; import { hashDaemonWorkspace, Storage, @@ -1087,6 +1089,13 @@ export function createServeApp( boundWorkspace, isWorkspaceTrusted: isPrimaryWorkspaceTrusted, contextFilename: deps.contextFilename ?? 'QWEN.md', + resolveContextFile: (filename, ws) => { + const effective = findEffectiveWorkspace(bridge, ws); + return { + target: path.resolve(effective, filename), + effectiveWorkspace: effective, + }; + }, statusProvider, workspaceProvidersStatusProvider: createWorkspaceProvidersStatusProvider({ ...(primaryEffectiveEnv ? { env: primaryEffectiveEnv } : {}), @@ -2181,6 +2190,8 @@ export function createServeApp( broadcastSettingsChanged, parseAndValidateClientId: (req, res) => parseAndValidateWorkspaceClientId(req, res, primaryBridge), + resolveEffectiveWorkspace: () => + findEffectiveWorkspace(primaryBridge, primaryBoundWorkspace), includeLiveVoice: liveVoiceSurfaceAvailable, }); registerWorkspaceQualifiedSettingsRoutes(app, { diff --git a/packages/cli/src/serve/workspace-service/__tests__/facade.test.ts b/packages/cli/src/serve/workspace-service/__tests__/facade.test.ts index db6637d7936..3d290618fde 100644 --- a/packages/cli/src/serve/workspace-service/__tests__/facade.test.ts +++ b/packages/cli/src/serve/workspace-service/__tests__/facade.test.ts @@ -92,7 +92,10 @@ vi.mock('../../../utils/stdioHelpers.js', () => ({ })); const { createDaemonWorkspaceService } = await import('../index.js'); -import { SessionNotFoundError } from '@qwen-code/acp-bridge/bridgeErrors'; +import { + SessionNotFoundError, + WorkspaceInitPathEscapeError, +} from '@qwen-code/acp-bridge/bridgeErrors'; import { BridgeChannelClosedError, type ServeWorkspaceSkillsStatus, @@ -3703,5 +3706,68 @@ describe('createDaemonWorkspaceService', () => { /parent.*resolves outside|parent.*workspace/i, ); }); + + it('uses resolveContextFile when provided', async () => { + const worktreeDir = path.join(tmpDir, 'worktree'); + await fs.mkdir(worktreeDir, { recursive: true }); + + const svc = createDaemonWorkspaceService( + makeDeps({ + boundWorkspace: tmpDir, + contextFilename: 'QWEN.md', + resolveContextFile: (filename, _ws) => ({ + target: path.resolve(worktreeDir, filename), + effectiveWorkspace: worktreeDir, + }), + }), + ); + + const result = await svc.initWorkspace( + makeCtx({ workspaceCwd: tmpDir }), + {}, + ); + + expect(result.action).toBe('created'); + expect(result.path).toBe(path.join(worktreeDir, 'QWEN.md')); + const stat = await fs.stat(result.path); + expect(stat.isFile()).toBe(true); + }); + + it('falls back to boundWorkspace without resolveContextFile', async () => { + const svc = createDaemonWorkspaceService( + makeDeps({ + boundWorkspace: tmpDir, + contextFilename: 'QWEN.md', + }), + ); + + const result = await svc.initWorkspace( + makeCtx({ workspaceCwd: tmpDir }), + {}, + ); + + expect(result.action).toBe('created'); + expect(result.path).toBe(path.join(tmpDir, 'QWEN.md')); + }); + + it('throws when resolveContextFile returns a target outside its effectiveWorkspace', async () => { + const outsideDir = path.join(tmpDir, 'outside'); + await fs.mkdir(outsideDir, { recursive: true }); + + const svc = createDaemonWorkspaceService( + makeDeps({ + boundWorkspace: tmpDir, + contextFilename: 'QWEN.md', + resolveContextFile: (_filename, _ws) => ({ + target: path.resolve(outsideDir, '..', 'escape.md'), + effectiveWorkspace: outsideDir, + }), + }), + ); + + await expect(svc.initWorkspace(makeCtx(), {})).rejects.toThrow( + WorkspaceInitPathEscapeError, + ); + }); }); }); diff --git a/packages/cli/src/serve/workspace-service/index.ts b/packages/cli/src/serve/workspace-service/index.ts index 599043574d6..8eeaa38ff47 100644 --- a/packages/cli/src/serve/workspace-service/index.ts +++ b/packages/cli/src/serve/workspace-service/index.ts @@ -224,6 +224,7 @@ export function createDaemonWorkspaceService( isWorkspaceTrusted, assertGenerationOpen, contextFilename, + resolveContextFile, statusProvider, workspaceProvidersStatusProvider, workspaceSkillsStatusProvider, @@ -1156,18 +1157,25 @@ export function createDaemonWorkspaceService( assertActiveGeneration(); // Resolve the context filename against the workspace root. const filename = contextFilename; - const target = path.resolve(boundWorkspace, filename); + const resolved = resolveContextFile + ? resolveContextFile(filename, boundWorkspace) + : { + target: path.resolve(boundWorkspace, filename), + effectiveWorkspace: boundWorkspace, + }; + const target = resolved.target; + const effectiveWorkspace = resolved.effectiveWorkspace; // Textual boundary check: reject paths that escape the workspace. const withinWorkspace = - target === boundWorkspace || - target.startsWith(boundWorkspace + path.sep); + target === effectiveWorkspace || + target.startsWith(effectiveWorkspace + path.sep); if (!withinWorkspace) { - throw new WorkspaceInitPathEscapeError(filename, boundWorkspace); + throw new WorkspaceInitPathEscapeError(filename, effectiveWorkspace); } // Symlink check on parent path: canonicalize and verify. - const wsCanonical = await fs.realpath(boundWorkspace); + const wsCanonical = await fs.realpath(effectiveWorkspace); const parentCanonical = await canonicalizeExistingAncestor( path.dirname(target), ); diff --git a/packages/cli/src/serve/workspace-service/types.ts b/packages/cli/src/serve/workspace-service/types.ts index f17c6afcd43..2197285c680 100644 --- a/packages/cli/src/serve/workspace-service/types.ts +++ b/packages/cli/src/serve/workspace-service/types.ts @@ -497,6 +497,17 @@ export interface DaemonWorkspaceServiceDeps { /** Context filename (e.g. 'QWEN.md') from workspace settings. */ contextFilename: string; + /** + * Optional resolver for the context file path. When provided, `initWorkspace` + * uses it to compute the target path and effective workspace root instead of + * resolving against `boundWorkspace` directly. This allows worktree-aware + * resolution where the context file lives in the worktree, not the project root. + */ + resolveContextFile?: ( + filename: string, + boundWorkspace: string, + ) => { target: string; effectiveWorkspace: string }; + /** * Daemon-host status provider for env + preflight cells. * When present, `getWorkspaceEnvStatus` returns daemon-local process state diff --git a/packages/cli/src/serve/worktree-workspace.test.ts b/packages/cli/src/serve/worktree-workspace.test.ts new file mode 100644 index 00000000000..5d3879fa2ed --- /dev/null +++ b/packages/cli/src/serve/worktree-workspace.test.ts @@ -0,0 +1,93 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as path from 'node:path'; + +import { describe, expect, it } from 'vitest'; +import { findEffectiveWorkspace } from './worktree-workspace.js'; + +const always = () => true; +const never = () => false; + +describe('findEffectiveWorkspace', () => { + const ws = '/repo/project'; + + it('returns boundWorkspace when no sessions exist', () => { + const bridge = { listWorkspaceSessions: () => [] }; + expect(findEffectiveWorkspace(bridge, ws, always)).toBe(ws); + }); + + it('returns boundWorkspace when no session has a worktree', () => { + const bridge = { + listWorkspaceSessions: () => [ + { worktree: undefined }, + { worktree: undefined }, + ], + }; + expect(findEffectiveWorkspace(bridge, ws, always)).toBe(ws); + }); + + it('returns the worktree path when a session has one on disk', () => { + const worktree = '/repo/project/.qwen/worktrees/feat'; + const bridge = { + listWorkspaceSessions: () => [ + { worktree: undefined }, + { worktree: { path: worktree } }, + ], + }; + expect(findEffectiveWorkspace(bridge, ws, always)).toBe( + path.normalize(worktree), + ); + }); + + it('skips a worktree whose path no longer exists on disk', () => { + const worktree = '/repo/project/.qwen/worktrees/deleted'; + const bridge = { + listWorkspaceSessions: () => [{ worktree: { path: worktree } }], + }; + expect(findEffectiveWorkspace(bridge, ws, never)).toBe(ws); + }); + + it('returns the first existing worktree when multiple sessions have one', () => { + const wt1 = '/repo/project/.qwen/worktrees/alpha'; + const wt2 = '/repo/project/.qwen/worktrees/beta'; + const bridge = { + listWorkspaceSessions: () => [ + { worktree: { path: wt1 } }, + { worktree: { path: wt2 } }, + ], + }; + expect(findEffectiveWorkspace(bridge, ws, always)).toBe( + path.normalize(wt1), + ); + }); + + it('skips a worktree whose path is outside boundWorkspace', () => { + const outside = '/tmp/evil/worktrees/escape'; + const bridge = { + listWorkspaceSessions: () => [{ worktree: { path: outside } }], + }; + expect(findEffectiveWorkspace(bridge, ws, always)).toBe(ws); + }); + + it('skips a worktree whose path shares a sibling prefix with boundWorkspace', () => { + const sibling = '/repo/project-evil/worktrees/escape'; + const bridge = { + listWorkspaceSessions: () => [{ worktree: { path: sibling } }], + }; + expect(findEffectiveWorkspace(bridge, ws, always)).toBe(ws); + }); + + it('returns a normalized worktree path when the raw path contains .. segments', () => { + const raw = '/repo/project/.qwen/worktrees/../worktrees/feat'; + const bridge = { + listWorkspaceSessions: () => [{ worktree: { path: raw } }], + }; + const result = findEffectiveWorkspace(bridge, ws, always); + expect(result).not.toContain('..'); + expect(result).toBe(path.normalize('/repo/project/.qwen/worktrees/feat')); + }); +}); diff --git a/packages/cli/src/serve/worktree-workspace.ts b/packages/cli/src/serve/worktree-workspace.ts new file mode 100644 index 00000000000..a640d62cef0 --- /dev/null +++ b/packages/cli/src/serve/worktree-workspace.ts @@ -0,0 +1,57 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { existsSync } from 'node:fs'; +import * as path from 'node:path'; + +import { findGitRoot } from '@qwen-code/qwen-code-core'; + +export interface SessionLister { + listWorkspaceSessions( + workspaceCwd: string, + ): ReadonlyArray<{ worktree?: { path: string } }>; +} + +/** + * Resolve the effective workspace for settings/context-file operations. + * Returns the first worktree session's path when one exists on disk, + * otherwise the bound workspace itself. + * + * Containment is checked against both the bound workspace and its git + * repo top-level (mirroring the session-restore candidateRoots logic), + * so monorepo subdirectory workspaces still match repo-root worktrees. + * + * TODO(#8138): This only sees worktrees populated via REST session creation + * or restore (BridgeSessionSummary.worktree). Worktrees entered mid-session + * via the `enter_worktree` tool are not visible here because the tool only + * mutates in-child Config state without calling setSessionWorktree. + */ +export function findEffectiveWorkspace( + bridge: SessionLister, + boundWorkspace: string, + pathExists: (p: string) => boolean = existsSync, +): string { + const sessions = bridge.listWorkspaceSessions(boundWorkspace); + const normalizedBound = path.normalize(boundWorkspace); + + const candidateRoots = [normalizedBound]; + const repoTop = findGitRoot(normalizedBound); + if (repoTop && path.normalize(repoTop) !== normalizedBound) { + candidateRoots.push(path.normalize(repoTop)); + } + + const relocated = sessions.find( + (s) => + s.worktree && + candidateRoots.some((root) => + path.normalize(s.worktree!.path).startsWith(root + path.sep), + ) && + pathExists(s.worktree.path), + ); + return relocated?.worktree + ? path.normalize(relocated.worktree.path) + : boundWorkspace; +} diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index c14265316f2..714fb31b5d8 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -1817,6 +1817,7 @@ export class Config { private readonly providerProtocolConfig?: ProviderProtocolConfig; private readonly sandbox: SandboxConfig | undefined; private targetDir: string; + private activeWorktreePath: string | null = null; private workspaceContext: WorkspaceContext; private readonly debugMode: boolean; private readonly inputFormat: InputFormat; @@ -4715,6 +4716,14 @@ export class Config { return this.targetDir; } + setActiveWorktree(worktreePath: string | null): void { + this.activeWorktreePath = worktreePath; + } + + getActiveWorktree(): string | null { + return this.activeWorktreePath; + } + private getCurrentSessionArtifactMoves( oldStorage: Storage, newStorage: Storage, diff --git a/packages/core/src/tools/enter-worktree.test.ts b/packages/core/src/tools/enter-worktree.test.ts index e08656f26a1..ccee4d43a53 100644 --- a/packages/core/src/tools/enter-worktree.test.ts +++ b/packages/core/src/tools/enter-worktree.test.ts @@ -173,6 +173,51 @@ describe('EnterWorktreeTool.execute', () => { expect(result.error?.message).toMatch(/not a git repository/i); await fs.rm(cwd, { recursive: true, force: true }); }); + + it('calls setActiveWorktree with the created worktree path', async () => { + const fs = await import('node:fs/promises'); + const pathMod = await import('node:path'); + const os = await import('node:os'); + const { execFileSync } = await import('node:child_process'); + const repoRoot = await fs.mkdtemp( + pathMod.join(os.tmpdir(), 'qwen-enter-wt-'), + ); + try { + execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: repoRoot }); + execFileSync('git', ['config', 'user.email', 't@e.com'], { + cwd: repoRoot, + }); + execFileSync('git', ['config', 'user.name', 't'], { cwd: repoRoot }); + execFileSync('git', ['config', 'commit.gpgsign', 'false'], { + cwd: repoRoot, + }); + await fs.writeFile(pathMod.join(repoRoot, 'README.md'), 'hi\n'); + execFileSync('git', ['add', '.'], { cwd: repoRoot }); + execFileSync('git', ['commit', '-q', '-m', 'init', '--no-verify'], { + cwd: repoRoot, + }); + + const setActiveWorktree = vi.fn(); + const cfg = { + getTargetDir: () => repoRoot, + getSessionId: () => 'test-session', + getWorktreeSymlinkDirectories: () => [], + setActiveWorktree, + getActiveWorktree: () => null, + } as unknown as Config; + const tool = new EnterWorktreeTool(cfg); + const result = await tool + .build({ name: 'spy-test' }) + .execute(new AbortController().signal); + expect(result.error).toBeUndefined(); + expect(setActiveWorktree).toHaveBeenCalledTimes(1); + const calledPath = setActiveWorktree.mock.calls[0][0] as string; + expect(calledPath).toContain('.qwen'); + expect(calledPath).toContain('spy-test'); + } finally { + await fs.rm(repoRoot, { recursive: true, force: true }); + } + }); }); describe('session marker round-trip', () => { diff --git a/packages/core/src/tools/enter-worktree.ts b/packages/core/src/tools/enter-worktree.ts index 5962c5dfb13..4c595848f95 100644 --- a/packages/core/src/tools/enter-worktree.ts +++ b/packages/core/src/tools/enter-worktree.ts @@ -211,6 +211,8 @@ class EnterWorktreeInvocation extends BaseToolInvocation< ); } + this.config.setActiveWorktree?.(result.worktree.path); + const output: EnterWorktreeOutput = { worktreePath: result.worktree.path, worktreeBranch: result.worktree.branch, diff --git a/packages/core/src/tools/exit-worktree.test.ts b/packages/core/src/tools/exit-worktree.test.ts index f1ab9b83664..798105cc921 100644 --- a/packages/core/src/tools/exit-worktree.test.ts +++ b/packages/core/src/tools/exit-worktree.test.ts @@ -31,6 +31,8 @@ function makeMockConfig(targetDir = process.cwd()): Config { // setting when creating a worktree. Return empty so the symlink // loop is a no-op in tests. getWorktreeSymlinkDirectories: vi.fn(() => []), + getActiveWorktree: vi.fn(() => null), + setActiveWorktree: vi.fn(), } as unknown as Config; } @@ -186,6 +188,8 @@ describe('ExitWorktreeTool', () => { getTargetDir: () => repoRoot, getSessionId: () => 'session-creator', getWorktreeSymlinkDirectories: () => [], + setActiveWorktree: () => {}, + getActiveWorktree: () => null, } as unknown as Config; const enter = new EnterWorktreeTool(enterCfg); const inv = enter.build({ name: slug }); @@ -308,5 +312,61 @@ describe('ExitWorktreeTool', () => { ); expect(re.trim()).toBe('rewritten-id'); }); + + it('clears activeWorktree on keep when it matches the exited path', async () => { + const wtPath = await provisionWorktree('keep-active'); + const setActiveWorktree = vi.fn(); + const cfg = { + getTargetDir: () => repoRoot, + getSessionId: () => 'session-creator', + getActiveWorktree: () => wtPath, + setActiveWorktree, + } as unknown as Config; + const result = await new ExitWorktreeTool(cfg) + .build({ name: 'keep-active', action: 'keep' }) + .execute(new AbortController().signal); + expect(result.error).toBeUndefined(); + expect(setActiveWorktree).toHaveBeenCalledWith(null); + }); + + it('clears activeWorktree on remove when it matches the exited path', async () => { + const wtPath = await provisionWorktree('remove-active'); + const setActiveWorktree = vi.fn(); + const cfg = { + getTargetDir: () => repoRoot, + getSessionId: () => 'session-creator', + getActiveWorktree: () => wtPath, + setActiveWorktree, + } as unknown as Config; + const result = await new ExitWorktreeTool(cfg) + .build({ + name: 'remove-active', + action: 'remove', + discard_changes: true, + }) + .execute(new AbortController().signal); + expect(result.error).toBeUndefined(); + expect(setActiveWorktree).toHaveBeenCalledWith(null); + }); + + it('does not clear activeWorktree when it points elsewhere', async () => { + await provisionWorktree('other-active'); + const setActiveWorktree = vi.fn(); + const cfg = { + getTargetDir: () => repoRoot, + getSessionId: () => 'session-creator', + getActiveWorktree: () => '/some/other/worktree', + setActiveWorktree, + } as unknown as Config; + const result = await new ExitWorktreeTool(cfg) + .build({ + name: 'other-active', + action: 'remove', + discard_changes: true, + }) + .execute(new AbortController().signal); + expect(result.error).toBeUndefined(); + expect(setActiveWorktree).not.toHaveBeenCalled(); + }); }); }); diff --git a/packages/core/src/tools/exit-worktree.ts b/packages/core/src/tools/exit-worktree.ts index a866e21963c..abf0a806307 100644 --- a/packages/core/src/tools/exit-worktree.ts +++ b/packages/core/src/tools/exit-worktree.ts @@ -219,6 +219,9 @@ class ExitWorktreeInvocation extends BaseToolInvocation< `Kept worktree "${this.params.name}" at ${worktreePath}. ` + `Resume work there by referencing this path in subsequent tool calls.`, }; + if (this.config.getActiveWorktree?.() === worktreePath) { + this.config.setActiveWorktree?.(null); + } return { llmContent: JSON.stringify(output), returnDisplay: `Kept worktree **${this.params.name}** at \`${worktreePath}\``, @@ -339,6 +342,10 @@ class ExitWorktreeInvocation extends BaseToolInvocation< if (!result.success) { return errorResult(result.error ?? 'Failed to remove worktree.'); } + if (this.config.getActiveWorktree?.() === worktreePath) { + this.config.setActiveWorktree?.(null); + } + if (result.branchPreserved) { // Status check passed and unmerged check passed, but the safe // delete still refused — most likely a race where new commits @@ -358,7 +365,6 @@ class ExitWorktreeInvocation extends BaseToolInvocation< returnDisplay: `Removed worktree directory **${this.params.name}**, branch \`${branch}\` preserved`, }; } - debugLogger.debug( `Removed user worktree: ${worktreePath} (branch=${branch})`, ); diff --git a/packages/desktop/packages/shared/src/agent/backend/types.ts b/packages/desktop/packages/shared/src/agent/backend/types.ts index ba224ccb1c1..985d87fe6b5 100644 --- a/packages/desktop/packages/shared/src/agent/backend/types.ts +++ b/packages/desktop/packages/shared/src/agent/backend/types.ts @@ -198,6 +198,8 @@ export interface BridgeUpdateContext { export interface BackendHostRuntimeContext { /** App root path (packaged app path or repository root in development) */ appRootPath: string; + // TODO(#8138): add worktreeRootPath?: string and populate it in + // buildBackendHostRuntimeContext before reading it in qwenSettingsCwd. /** Optional resources path (needed for packaged Windows runtime resolution) */ resourcesPath?: string; /** Whether the host app is running as a packaged build */ diff --git a/packages/desktop/packages/shared/src/agent/qwen-agent.ts b/packages/desktop/packages/shared/src/agent/qwen-agent.ts index e4620967a70..0a6ba6e0665 100644 --- a/packages/desktop/packages/shared/src/agent/qwen-agent.ts +++ b/packages/desktop/packages/shared/src/agent/qwen-agent.ts @@ -592,7 +592,14 @@ function buildQwenAcpSpawnCommand( } function qwenSettingsCwd(hostRuntime: BackendHostRuntimeContext): string { - return hostRuntime.appRootPath || homedir() || process.cwd(); + // TODO(#8138): add worktreeRootPath to BackendHostRuntimeContext and + // populate it in buildBackendHostRuntimeContext so desktop worktree + // sessions resolve settings against the worktree. + return ( + hostRuntime.appRootPath || + homedir() || + process.cwd() + ); } function qwenAcpWithTimeout( diff --git a/tsconfig.json b/tsconfig.json index c4542cc8337..287d83211ac 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,5 +27,23 @@ "target": "es2022", "types": ["node", "vitest/globals"], "jsx": "react-jsx" - } + }, + "files": [], + "references": [ + { "path": "packages/acp-bridge" }, + { "path": "packages/audio-capture" }, + { "path": "packages/channels/base" }, + { "path": "packages/channels/dingtalk" }, + { "path": "packages/channels/feishu" }, + { "path": "packages/channels/github" }, + { "path": "packages/channels/gitlab" }, + { "path": "packages/channels/plugin-example" }, + { "path": "packages/channels/qqbot" }, + { "path": "packages/channels/telegram" }, + { "path": "packages/channels/wecom" }, + { "path": "packages/channels/weixin" }, + { "path": "packages/cli" }, + { "path": "packages/core" }, + { "path": "packages/web-templates" } + ] }