From 3b82513b30b4938adb9328dd8b33b6be5a4370e1 Mon Sep 17 00:00:00 2001 From: cte Date: Wed, 25 Feb 2026 01:16:19 -0800 Subject: [PATCH] Fix cli task resumption --- apps/cli/scripts/build.sh | 19 ++++- .../agent/__tests__/extension-host.test.ts | 41 ++++++++++ apps/cli/src/agent/extension-host.ts | 12 +++ src/core/webview/ClineProvider.ts | 15 +++- .../ClineProvider.sticky-profile.spec.ts | 78 ++++++++++++++++++- src/i18n/setup.ts | 13 +++- 6 files changed, 171 insertions(+), 7 deletions(-) diff --git a/apps/cli/scripts/build.sh b/apps/cli/scripts/build.sh index 97a33c384c8..fae70473df7 100755 --- a/apps/cli/scripts/build.sh +++ b/apps/cli/scripts/build.sh @@ -193,6 +193,7 @@ create_tarball() { import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; +import { existsSync } from 'fs'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -200,7 +201,10 @@ const __dirname = dirname(__filename); // Set environment variables for the CLI process.env.ROO_CLI_ROOT = join(__dirname, '..'); process.env.ROO_EXTENSION_PATH = join(__dirname, '..', 'extension'); -process.env.ROO_RIPGREP_PATH = join(__dirname, 'rg'); +const ripgrepPath = join(__dirname, 'rg'); +if (existsSync(ripgrepPath)) { + process.env.ROO_RIPGREP_PATH = ripgrepPath; +} // Import and run the actual CLI await import(join(__dirname, '..', 'lib', 'index.js')); @@ -211,10 +215,21 @@ WRAPPER_EOF # Create empty .env file touch "$RELEASE_DIR/.env" + # Strip macOS metadata artifacts before packaging. + find "$RELEASE_DIR" -type f -name "._*" -delete + find "$RELEASE_DIR" -type f -name ".DS_Store" -delete + find "$RELEASE_DIR" -type d -name "__MACOSX" -prune -exec rm -rf {} + + # Create tarball info "Creating tarball..." cd "$REPO_ROOT" - tar -czvf "$TARBALL" "$(basename "$RELEASE_DIR")" + COPYFILE_DISABLE=1 tar \ + --exclude="._*" \ + --exclude=".DS_Store" \ + --exclude="__MACOSX" \ + --exclude="*/._*" \ + --exclude="*/.DS_Store" \ + -czvf "$TARBALL" "$(basename "$RELEASE_DIR")" # Clean up release directory rm -rf "$RELEASE_DIR" diff --git a/apps/cli/src/agent/__tests__/extension-host.test.ts b/apps/cli/src/agent/__tests__/extension-host.test.ts index 2354e3ab75d..acc92c27057 100644 --- a/apps/cli/src/agent/__tests__/extension-host.test.ts +++ b/apps/cli/src/agent/__tests__/extension-host.test.ts @@ -80,13 +80,28 @@ function spyOnPrivate(host: ExtensionHost, method: string) { } describe("ExtensionHost", () => { + const initialRooCliRuntimeEnv = process.env.ROO_CLI_RUNTIME + beforeEach(() => { vi.resetAllMocks() + if (initialRooCliRuntimeEnv === undefined) { + delete process.env.ROO_CLI_RUNTIME + } else { + process.env.ROO_CLI_RUNTIME = initialRooCliRuntimeEnv + } // Clean up globals delete (global as Record).vscode delete (global as Record).__extensionHost }) + afterAll(() => { + if (initialRooCliRuntimeEnv === undefined) { + delete process.env.ROO_CLI_RUNTIME + } else { + process.env.ROO_CLI_RUNTIME = initialRooCliRuntimeEnv + } + }) + describe("constructor", () => { it("should store options correctly", () => { const options: ExtensionHostOptions = { @@ -135,6 +150,12 @@ describe("ExtensionHost", () => { expect(getPrivate(host, "promptManager")).toBeDefined() expect(getPrivate(host, "askDispatcher")).toBeDefined() }) + + it("should mark process as CLI runtime", () => { + delete process.env.ROO_CLI_RUNTIME + createTestHost() + expect(process.env.ROO_CLI_RUNTIME).toBe("1") + }) }) describe("webview provider registration", () => { @@ -429,6 +450,26 @@ describe("ExtensionHost", () => { expect(restoreConsoleSpy).toHaveBeenCalled() }) + + it("should clear ROO_CLI_RUNTIME on dispose when it was previously unset", async () => { + delete process.env.ROO_CLI_RUNTIME + host = createTestHost() + expect(process.env.ROO_CLI_RUNTIME).toBe("1") + + await host.dispose() + + expect(process.env.ROO_CLI_RUNTIME).toBeUndefined() + }) + + it("should restore prior ROO_CLI_RUNTIME value on dispose", async () => { + process.env.ROO_CLI_RUNTIME = "preexisting-value" + host = createTestHost() + expect(process.env.ROO_CLI_RUNTIME).toBe("1") + + await host.dispose() + + expect(process.env.ROO_CLI_RUNTIME).toBe("preexisting-value") + }) }) describe("runTask", () => { diff --git a/apps/cli/src/agent/extension-host.ts b/apps/cli/src/agent/extension-host.ts index ce9103df6f5..78cbdf443d1 100644 --- a/apps/cli/src/agent/extension-host.ts +++ b/apps/cli/src/agent/extension-host.ts @@ -135,6 +135,7 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac // Ephemeral storage. private ephemeralStorageDir: string | null = null + private previousCliRuntimeEnv: string | undefined // ========================================================================== // Managers - These do all the heavy lifting @@ -172,6 +173,10 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac super() this.options = options + // Mark this process as CLI runtime so extension code can apply + // CLI-specific behavior without affecting VS Code desktop usage. + this.previousCliRuntimeEnv = process.env.ROO_CLI_RUNTIME + process.env.ROO_CLI_RUNTIME = "1" // Enable file-based debug logging only when --debug is passed. if (options.debug) { @@ -570,5 +575,12 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac // NO-OP } } + + // Restore previous CLI runtime marker for process hygiene in tests. + if (this.previousCliRuntimeEnv === undefined) { + delete process.env.ROO_CLI_RUNTIME + } else { + process.env.ROO_CLI_RUNTIME = this.previousCliRuntimeEnv + } } } diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index e4a51d8645b..231e2794c52 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -963,6 +963,12 @@ export class ClineProvider historyItem: HistoryItem & { rootTask?: Task; parentTask?: Task }, options?: { startTask?: boolean }, ) { + const isCliRuntime = process.env.ROO_CLI_RUNTIME === "1" + // CLI injects runtime provider settings from command flags/env at startup. + // Restoring provider profiles from task history can overwrite those + // runtime settings with stale/incomplete persisted profiles. + const skipProfileRestoreFromHistory = isCliRuntime + // Check if we're rehydrating the current task to avoid flicker const currentTask = this.getCurrentTask() const isRehydratingCurrentTask = currentTask && currentTask.taskId === historyItem.id @@ -991,7 +997,8 @@ export class ClineProvider // Skip mode-based profile activation if historyItem.apiConfigName exists, // since the task's specific provider profile will override it anyway. const lockApiConfigAcrossModes = this.context.workspaceState.get("lockApiConfigAcrossModes", false) - if (!historyItem.apiConfigName && !lockApiConfigAcrossModes) { + + if (!historyItem.apiConfigName && !lockApiConfigAcrossModes && !skipProfileRestoreFromHistory) { const savedConfigId = await this.providerSettingsManager.getModeConfigId(historyItem.mode) const listApiConfig = await this.providerSettingsManager.listConfig() @@ -1033,7 +1040,7 @@ export class ClineProvider // If the history item has a saved API config name (provider profile), restore it. // This overrides any mode-based config restoration above, because the task's // specific provider profile takes precedence over mode defaults. - if (historyItem.apiConfigName) { + if (historyItem.apiConfigName && !skipProfileRestoreFromHistory) { const listApiConfig = await this.providerSettingsManager.listConfig() // Keep global state/UI in sync with latest profiles for parity with mode restoration above. await this.updateGlobalState("listApiConfigMeta", listApiConfig) @@ -1059,6 +1066,10 @@ export class ClineProvider `Provider profile '${historyItem.apiConfigName}' from history no longer exists. Using current configuration.`, ) } + } else if (historyItem.apiConfigName && skipProfileRestoreFromHistory) { + this.log( + `Skipping restore of provider profile '${historyItem.apiConfigName}' for task ${historyItem.id} in CLI runtime.`, + ) } const { apiConfiguration, enableCheckpoints, checkpointTimeout, experiments, cloudUserInfo, taskSyncEnabled } = diff --git a/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts b/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts index da2734de876..605f5c1a6ff 100644 --- a/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts @@ -201,10 +201,13 @@ describe("ClineProvider - Sticky Provider Profile", () => { let mockOutputChannel: vscode.OutputChannel let mockWebviewView: vscode.WebviewView let mockPostMessage: any + let originalRooCliRuntimeEnv: string | undefined beforeEach(async () => { vi.clearAllMocks() taskIdCounter = 0 + originalRooCliRuntimeEnv = process.env.ROO_CLI_RUNTIME + delete process.env.ROO_CLI_RUNTIME if (!TelemetryService.hasInstance()) { TelemetryService.createInstance([]) @@ -293,6 +296,14 @@ describe("ClineProvider - Sticky Provider Profile", () => { }) }) + afterEach(() => { + if (originalRooCliRuntimeEnv === undefined) { + delete process.env.ROO_CLI_RUNTIME + } else { + process.env.ROO_CLI_RUNTIME = originalRooCliRuntimeEnv + } + }) + describe("activateProviderProfile", () => { beforeEach(async () => { await provider.resolveWebviewView(mockWebviewView) @@ -457,7 +468,7 @@ describe("ClineProvider - Sticky Provider Profile", () => { }) describe("createTaskWithHistoryItem", () => { - it("should restore provider profile from history item when reopening task", async () => { + it("should restore provider profile from history item when reopening task outside CLI runtime", async () => { await provider.resolveWebviewView(mockWebviewView) // Create a history item with saved provider profile @@ -495,6 +506,71 @@ describe("ClineProvider - Sticky Provider Profile", () => { ) }) + it("should skip restoring task apiConfigName from history in CLI runtime", async () => { + await provider.resolveWebviewView(mockWebviewView) + process.env.ROO_CLI_RUNTIME = "1" + + const historyItem: HistoryItem = { + id: "test-task-id", + number: 1, + ts: Date.now(), + task: "Test task", + tokensIn: 100, + tokensOut: 200, + cacheWrites: 0, + cacheReads: 0, + totalCost: 0.001, + apiConfigName: "saved-profile", + } + + const activateProviderProfileSpy = vi + .spyOn(provider, "activateProviderProfile") + .mockResolvedValue(undefined) + const logSpy = vi.spyOn(provider, "log") + + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([ + { name: "saved-profile", id: "saved-profile-id", apiProvider: "anthropic" }, + ]) + + await provider.createTaskWithHistoryItem(historyItem) + + expect(activateProviderProfileSpy).not.toHaveBeenCalledWith({ name: "saved-profile" }, expect.anything()) + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining("Skipping restore of provider profile 'saved-profile'"), + ) + }) + + it("should skip restoring mode-based provider config from history in CLI runtime", async () => { + await provider.resolveWebviewView(mockWebviewView) + process.env.ROO_CLI_RUNTIME = "1" + + const historyItem: HistoryItem = { + id: "test-task-id", + number: 1, + ts: Date.now(), + task: "Test task", + tokensIn: 100, + tokensOut: 200, + cacheWrites: 0, + cacheReads: 0, + totalCost: 0.001, + mode: "code", + } + + const activateProviderProfileSpy = vi + .spyOn(provider, "activateProviderProfile") + .mockResolvedValue(undefined) + + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockResolvedValue("mode-config-id") + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([ + { name: "mode-profile", id: "mode-config-id", apiProvider: "anthropic" }, + ]) + + await provider.createTaskWithHistoryItem(historyItem) + + expect(activateProviderProfileSpy).not.toHaveBeenCalled() + }) + it("should use current profile if history item has no saved apiConfigName", async () => { await provider.resolveWebviewView(mockWebviewView) diff --git a/src/i18n/setup.ts b/src/i18n/setup.ts index 5e6793b089e..cf4c24446f1 100644 --- a/src/i18n/setup.ts +++ b/src/i18n/setup.ts @@ -20,7 +20,10 @@ if (!isTestEnv) { const languageDirs = fs.readdirSync(localesDir, { withFileTypes: true }) const languages = languageDirs - .filter((dirent: { isDirectory: () => boolean }) => dirent.isDirectory()) + .filter( + (dirent: { isDirectory: () => boolean; name: string }) => + dirent.isDirectory() && !dirent.name.startsWith("."), + ) .map((dirent: { name: string }) => dirent.name) // Process each language @@ -28,7 +31,13 @@ if (!isTestEnv) { const langPath = path.join(localesDir, language) // Find all JSON files in the language directory - const files = fs.readdirSync(langPath).filter((file: string) => file.endsWith(".json")) + const files = fs + .readdirSync(langPath, { withFileTypes: true }) + .filter( + (dirent: { isFile: () => boolean; name: string }) => + dirent.isFile() && dirent.name.endsWith(".json") && !dirent.name.startsWith("."), + ) + .map((dirent: { name: string }) => dirent.name) // Initialize language in translations object if (!translations[language]) {