Fix cli task resumption (#11739)

This commit is contained in:
Chris Estreich 2026-02-25 01:24:05 -08:00 committed by GitHub
parent 191053cec5
commit efbba9e8d5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 171 additions and 7 deletions

View file

@ -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"

View file

@ -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<string, unknown>).vscode
delete (global as Record<string, unknown>).__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", () => {

View file

@ -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
}
}
}

View file

@ -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 } =

View file

@ -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)

View file

@ -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]) {