Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions apps/cli/scripts/build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -193,14 +193,18 @@ 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);

// 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'));
Expand All @@ -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"
Expand Down
41 changes: 41 additions & 0 deletions apps/cli/src/agent/__tests__/extension-host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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", () => {
Expand Down
12 changes: 12 additions & 0 deletions apps/cli/src/agent/extension-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
}
}
}
15 changes: 13 additions & 2 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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)
Expand All @@ -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 } =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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([])
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
13 changes: 11 additions & 2 deletions src/i18n/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,24 @@ 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
languages.forEach((language: string) => {
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]) {
Expand Down
Loading