diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 386d6ee11..9534ce0eb 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -22,6 +22,7 @@ import { SessionStartSource, SessionEndReason, type PermissionMode, + restoreWorktreeContext, } from '@qwen-code/qwen-code-core'; import { AgentSideConnection, @@ -325,7 +326,26 @@ class QwenAgent implements Agent { this.setupFileSystem(config); const sessionData = config.getResumedSessionData(); - await this.createAndStoreSession(config, sessionData?.conversation); + const session = await this.createAndStoreSession( + config, + sessionData?.conversation, + ); + + // Phase C: restore worktree context on --resume. Cleans up stale + // sidecars (worktree dir deleted out-of-band) and queues a notice for + // the next prompt when the worktree is alive. Best-effort: failures + // don't block session load. + try { + const sessionPath = config + .getSessionService() + .getWorktreeSessionPath(config.getSessionId()); + const restored = await restoreWorktreeContext(sessionPath); + if (restored.contextMessage) { + session.pendingWorktreeNotice = restored.contextMessage; + } + } catch (error) { + debugLogger.warn(`ACP worktree restore failed: ${error}`); + } const modesData = this.buildModesData(config); const availableModels = this.buildAvailableModels(config); diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 02345a7a9..9b3cdeda3 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -158,6 +158,16 @@ export class Session implements SessionContext { // Message rewrite middleware (optional, installed after history replay) messageRewriter?: MessageRewriteMiddleware; + /** + * Phase C worktree restore notice. Set by acpAgent.loadSession when a + * resumed session has a live worktree sidecar; prepended to the next + * #executePrompt call as a , then cleared. TUI uses + * historyManager.addItem(INFO) for the equivalent UX hint and headless + * prepends to the single shot prompt — all three modes share the + * `restoreWorktreeContext` helper that produces this string. + */ + pendingWorktreeNotice: string | null = null; + // Implement SessionContext interface readonly sessionId: string; @@ -530,6 +540,20 @@ export class Session implements SessionContext { parts = [...systemReminders, ...parts]; } + // Phase C: one-shot worktree restore notice, set by acpAgent on + // --resume / loadSession when the session's worktree is still alive. + // Prepended exactly once, then cleared so it doesn't repeat on + // subsequent turns. + if (this.pendingWorktreeNotice) { + parts = [ + { + text: `\n${this.pendingWorktreeNotice}\n\n\n`, + }, + ...parts, + ]; + this.pendingWorktreeNotice = null; + } + let nextMessage: Content | null = { role: 'user', parts }; while (nextMessage !== null) { diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index b1389452d..5988bc4a0 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -26,6 +26,7 @@ import { parseAndFormatApiError, createDebugLogger, SendMessageType, + restoreWorktreeContext, } from '@qwen-code/qwen-code-core'; import type { Content, Part, PartListUnion } from '@google/genai'; import type { CLIUserMessage, PermissionMode } from './nonInteractive/types.js'; @@ -375,6 +376,39 @@ export async function runNonInteractive( initialPartList = [{ text: input }]; } + // Phase C: when --resume restored a session with an active worktree, + // prepend a system-reminder block to the user prompt so the model + // knows to keep using the worktree path. Stale sidecars (worktree + // dir deleted between sessions) are cleaned up inside the helper. + // TUI does this via historyManager.addItem(INFO); headless does it + // here because there is no UI history to write into. + if (config.getResumedSessionData()) { + try { + const sessionPath = config + .getSessionService() + .getWorktreeSessionPath(sessionId); + const restored = await restoreWorktreeContext(sessionPath); + if (restored.contextMessage) { + const reminderPart: Part = { + text: `\n${restored.contextMessage}\n\n\n`, + }; + const partsArr = Array.isArray(initialPartList) + ? initialPartList + : [initialPartList]; + initialPartList = [reminderPart, ...partsArr]; + // Also surface the notice in the JSON stream so SDK consumers + // can react to it (logging, UI hints, etc.). + adapter.emitSystemMessage('worktree_restored', { + slug: restored.session?.slug, + path: restored.session?.worktreePath, + branch: restored.session?.worktreeBranch, + }); + } + } catch (error) { + debugLogger.warn(`worktree restore failed (non-fatal):`, error); + } + } + const initialParts = normalizePartList(initialPartList); let currentMessages: Content[] = [{ role: 'user', parts: initialParts }]; diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 3319b1c81..0766c985a 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -60,11 +60,10 @@ import { ToolConfirmationOutcome, type WaitingToolCall, ToolNames, - readWorktreeSession, clearWorktreeSession, + restoreWorktreeContext, GitWorktreeService, } from '@qwen-code/qwen-code-core'; -import * as fsPromises from 'node:fs/promises'; import { buildResumedHistoryItems } from './utils/resumeHistoryUtils.js'; import { getStickyTodos, @@ -509,37 +508,22 @@ export const AppContainer = (props: AppContainerProps) => { setSessionName(title); } - // If the resumed session had an active worktree, inject a context - // message so the model immediately knows to keep using the - // worktree path for file operations (qwen-code can't `chdir` the - // way claude-code does — Config.targetDir is immutable). - // - // Stale sidecars (worktree dir deleted between sessions) get - // cleaned up so Footer / useWorktreeSession don't show a phantom - // worktree indicator. + // Restore worktree context (shared logic — headless and ACP use + // the same helper). Stale sidecars get cleaned up; live ones + // produce an INFO message the model sees on the next turn. try { const sessionPath = config .getSessionService() .getWorktreeSessionPath(config.getSessionId()); - const ws = await readWorktreeSession(sessionPath); - if (ws) { - const worktreeAlive = await fsPromises - .stat(ws.worktreePath) - .then((s) => s.isDirectory()) - .catch(() => false); - if (worktreeAlive) { - historyManager.addItem( - { - type: MessageType.INFO, - text: - `[Resumed] Active worktree: "${ws.slug}" at ${ws.worktreePath} ` + - `(branch: ${ws.worktreeBranch}). Continue using this path for all file operations.`, - }, - Date.now(), - ); - } else { - await clearWorktreeSession(sessionPath); - } + const restored = await restoreWorktreeContext(sessionPath, (err) => { + // eslint-disable-next-line no-console + console.debug('worktree session restore warning:', err); + }); + if (restored.contextMessage) { + historyManager.addItem( + { type: MessageType.INFO, text: restored.contextMessage }, + Date.now(), + ); } } catch (error) { // Best-effort: failures here only affect UI hint visibility, diff --git a/packages/core/src/services/worktreeSessionService.test.ts b/packages/core/src/services/worktreeSessionService.test.ts index 416096a66..e66220b67 100644 --- a/packages/core/src/services/worktreeSessionService.test.ts +++ b/packages/core/src/services/worktreeSessionService.test.ts @@ -12,6 +12,7 @@ import { readWorktreeSession, writeWorktreeSession, clearWorktreeSession, + restoreWorktreeContext, type WorktreeSession, } from './worktreeSessionService.js'; @@ -84,3 +85,58 @@ describe('clearWorktreeSession', () => { await expect(clearWorktreeSession(filePath)).resolves.not.toThrow(); }); }); + +describe('restoreWorktreeContext', () => { + it('returns nulls when no sidecar exists', async () => { + const result = await restoreWorktreeContext(filePath); + expect(result.session).toBeNull(); + expect(result.contextMessage).toBeNull(); + }); + + it('returns context message + session when worktree dir is alive', async () => { + // Point sample at a real existing directory (tmpDir itself). + const live: WorktreeSession = { ...sample, worktreePath: tmpDir }; + await writeWorktreeSession(filePath, live); + const result = await restoreWorktreeContext(filePath); + + expect(result.session).toEqual(live); + expect(result.contextMessage).toContain(`"${live.slug}"`); + expect(result.contextMessage).toContain(live.worktreePath); + expect(result.contextMessage).toContain(live.worktreeBranch); + // Sidecar should remain on disk so subsequent reads still see it. + expect(await readWorktreeSession(filePath)).toEqual(live); + }); + + it('cleans up stale sidecar when worktree dir is gone', async () => { + // sample.worktreePath points at /repo/.qwen/... which does not exist. + await writeWorktreeSession(filePath, sample); + expect(await readWorktreeSession(filePath)).toEqual(sample); + + const result = await restoreWorktreeContext(filePath); + expect(result.session).toBeNull(); + expect(result.contextMessage).toBeNull(); + // Sidecar should be deleted. + expect(await readWorktreeSession(filePath)).toBeNull(); + }); + + it('treats a regular file at worktreePath as not-a-worktree', async () => { + const filePathTarget = path.join(tmpDir, 'pretend-worktree'); + await fs.writeFile(filePathTarget, 'not a dir', 'utf-8'); + const bogus: WorktreeSession = { ...sample, worktreePath: filePathTarget }; + await writeWorktreeSession(filePath, bogus); + + const result = await restoreWorktreeContext(filePath); + expect(result.session).toBeNull(); + expect(await readWorktreeSession(filePath)).toBeNull(); + }); + + it('forwards warning when sidecar JSON is malformed', async () => { + await fs.writeFile(filePath, 'not valid json {', 'utf-8'); + const warnings: unknown[] = []; + const result = await restoreWorktreeContext(filePath, (e) => + warnings.push(e), + ); + expect(result.session).toBeNull(); + expect(warnings.length).toBe(1); + }); +}); diff --git a/packages/core/src/services/worktreeSessionService.ts b/packages/core/src/services/worktreeSessionService.ts index 4030c39f2..055b5813e 100644 --- a/packages/core/src/services/worktreeSessionService.ts +++ b/packages/core/src/services/worktreeSessionService.ts @@ -59,3 +59,69 @@ export async function clearWorktreeSession(filePath: string): Promise { throw error; } } + +export interface WorktreeRestoreResult { + /** + * When non-null, the worktree directory is still alive — callers should + * surface this one-line context message so the model continues using + * the worktree path for file operations after a `--resume`. + * + * Each entry point chooses its own injection mechanism: + * - TUI: `historyManager.addItem({ type: INFO, text })` + * - Headless: prepend as a `` block to the user prompt + * - ACP: emit as a `system` message and prepend to the next prompt + */ + contextMessage: string | null; + /** Active worktree session, or null when no sidecar / sidecar was stale. */ + session: WorktreeSession | null; +} + +/** + * Reads the WorktreeSession sidecar for the current session, validates + * that the worktree directory still exists on disk, and either: + * + * - returns a context message + the live session, or + * - deletes the stale sidecar and returns nulls. + * + * Shared by TUI / headless / ACP entry points so all three behave + * consistently on `--resume`. Failures are logged via the supplied + * `onWarn` callback but never thrown — worktree restore is best-effort, + * the session itself must still load. + */ +export async function restoreWorktreeContext( + sidecarPath: string, + onWarn?: (error: unknown) => void, +): Promise { + let session: WorktreeSession | null = null; + try { + session = await readWorktreeSession(sidecarPath); + } catch (error) { + onWarn?.(error); + return { contextMessage: null, session: null }; + } + if (!session) return { contextMessage: null, session: null }; + + let worktreeAlive = false; + try { + const stat = await fs.stat(session.worktreePath); + worktreeAlive = stat.isDirectory(); + } catch { + worktreeAlive = false; + } + + if (!worktreeAlive) { + try { + await clearWorktreeSession(sidecarPath); + } catch (error) { + onWarn?.(error); + } + return { contextMessage: null, session: null }; + } + + return { + session, + contextMessage: + `[Resumed] Active worktree: "${session.slug}" at ${session.worktreePath} ` + + `(branch: ${session.worktreeBranch}). Continue using this path for all file operations.`, + }; +}