Skip to content
Open
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
84 changes: 83 additions & 1 deletion packages/types/src/__tests__/global-settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
DEFAULT_DESTRUCTIVE_COMMAND_GUARD_ENABLED,
GLOBAL_SETTINGS_KEYS,
globalSettingsSchema,
viewStateSchema,
} from "../global-settings.js"

describe("destructive command guard global setting", () => {
Expand Down Expand Up @@ -38,7 +39,6 @@ describe("alwaysDenyUnapprovedCommands global setting", () => {
expect(() => globalSettingsSchema.parse({ alwaysDenyUnapprovedCommands: "true" })).toThrow()
})
})

describe("collapseCommandOutputByDefault global setting", () => {
it.each([true, false, undefined])("round-trips %s through the export/import schema", (value) => {
const settings = globalSettingsSchema.parse({ collapseCommandOutputByDefault: value })
Expand All @@ -51,3 +51,85 @@ describe("collapseCommandOutputByDefault global setting", () => {
expect(globalSettingsSchema.safeParse({ collapseCommandOutputByDefault: "true" }).success).toBe(false)
})
})

describe("viewStates global setting", () => {
it("preserves each webview instance selection through the persisted schema", () => {
const parsed = globalSettingsSchema.parse({
viewStates: {
"session-1": { mode: "code", currentApiConfigName: "default", updatedAt: 1700000000000 },
"session-2": { mode: "architect" },
},
})
// Every field has to survive the round trip: a selection the schema drops is
// written back as absent, so the next window opens in the default mode.
expect(parsed.viewStates).toEqual({
"session-1": { mode: "code", currentApiConfigName: "default", updatedAt: 1700000000000 },
"session-2": { mode: "architect" },
})

// The key list is part of the contract: a setting that never reaches
// GLOBAL_SETTINGS_KEYS is not a persisted setting, whatever the schema does.
expect(GLOBAL_SETTINGS_KEYS).toContain("viewStates")
})

it("accepts an empty entry because every field is optional", () => {
expect(() => globalSettingsSchema.parse({ viewStates: { "view-1": {} } })).not.toThrow()
// The control that makes the line above a test: an absent viewStates is omitted
// rather than defaulted, so an empty entry is a record and not the same shape
// as nothing at all.
expect(globalSettingsSchema.parse({}).viewStates).toBeUndefined()
})

it("parses a single view state on its own", () => {
expect(viewStateSchema.parse({ mode: "ask", currentApiConfigName: "x", updatedAt: 5 })).toEqual({
mode: "ask",
currentApiConfigName: "x",
updatedAt: 5,
})
})

it("rejects a numeric mode and names the offending path", () => {
// The control first: the same record without the bad field must parse, so a
// failure here can only mean the schema rejected that value - not that the record
// shape is unsupported or the assertion never ran.
expect(globalSettingsSchema.safeParse({ viewStates: { a: { mode: "code" } } }).success).toBe(true)

const result = globalSettingsSchema.safeParse({ viewStates: { a: { mode: 42 } } })

expect(result.success).toBe(false)
expect(result.error?.issues[0]?.path).toEqual(["viewStates", "a", "mode"])
})

it("rejects a non-string currentApiConfigName and names the offending path", () => {
expect(globalSettingsSchema.safeParse({ viewStates: { a: { currentApiConfigName: "x" } } }).success).toBe(true)

const result = globalSettingsSchema.safeParse({ viewStates: { a: { currentApiConfigName: 7 } } })

expect(result.success).toBe(false)
expect(result.error?.issues[0]?.path).toEqual(["viewStates", "a", "currentApiConfigName"])
})

it("rejects a non-numeric updatedAt and names the offending path", () => {
expect(globalSettingsSchema.safeParse({ viewStates: { a: { updatedAt: 1 } } }).success).toBe(true)

const result = globalSettingsSchema.safeParse({ viewStates: { a: { updatedAt: "1" } } })

expect(result.success).toBe(false)
expect(result.error?.issues[0]?.path).toEqual(["viewStates", "a", "updatedAt"])
})

it("rejects a view state that is not an object", () => {
// A bare string under a session id is what a half-migrated store looks like;
// accepting it defers the crash to whoever reads the selection back.
expect(globalSettingsSchema.safeParse({ viewStates: { a: { mode: "code" } } }).success).toBe(true)

const result = globalSettingsSchema.safeParse({ viewStates: { a: "code" } })

expect(result.success).toBe(false)
expect(result.error?.issues[0]?.path).toEqual(["viewStates", "a"])

// A null entry fails for a different reason than a string entry: the record
// type tolerates an absent value and rejects a present non-object one.
expect(() => globalSettingsSchema.parse({ viewStates: { "view-1": null } })).toThrow()
})
})
Comment thread
easonLiangWorldedtech marked this conversation as resolved.
Resolved
10 changes: 10 additions & 0 deletions packages/types/src/global-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,15 @@ export const MAX_CHECKPOINT_TIMEOUT_SECONDS = 60
*/
export const DEFAULT_CHECKPOINT_TIMEOUT_SECONDS = 15

/**
* Persisted non-secret selections for a stable webview instance.
*/
export const viewStateSchema = z.object({
mode: z.string().optional(),
currentApiConfigName: z.string().optional(),
updatedAt: z.number().optional(),
})

/**
* GlobalSettings
*/
Expand All @@ -122,6 +131,7 @@ export const globalSettingsSchema = z.object({
currentApiConfigName: z.string().optional(),
listApiConfigMeta: z.array(providerSettingsEntrySchema).optional(),
pinnedApiConfigs: z.record(z.string(), z.boolean()).optional(),
viewStates: z.record(z.string(), viewStateSchema).optional(),

lastShownAnnouncementId: z.string().optional(),
customInstructions: z.string().optional(),
Expand Down
1 change: 1 addition & 0 deletions packages/types/src/vscode-extension-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -712,6 +712,7 @@ export interface WebviewMessage {
| "themeFixtureProbeResponse"
| "diagnosticsResponse"
text?: string
viewStateId?: string
taskId?: string
editedMessageContent?: string
tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud"
Expand Down
3 changes: 3 additions & 0 deletions src/core/config/ContextProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ const globalSettingsExportSchema = globalSettingsSchema.omit({
taskHistory: true,
listApiConfigMeta: true,
currentApiConfigName: true,
// Per-view selection state is machine-local: it keeps flowing through the
// normal runtime and pruning paths but must not transfer between settings.
viewStates: true,
Comment thread
easonLiangWorldedtech marked this conversation as resolved.
Resolved
})

export class ContextProxy {
Expand Down
45 changes: 42 additions & 3 deletions src/core/config/ProviderSettingsManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,19 @@ export const providerProfilesSchema = z.object({

export type ProviderProfiles = z.infer<typeof providerProfilesSchema>

/**
* Signals that a profile's configuration no longer exists. Callers that treat an
* already-deleted profile as an idempotent no-op branch on this type instead of
* matching error message text, which a profile name containing the phrase could
* otherwise spoof.
*/
export class ProviderSettingsNotFoundError extends Error {
constructor(message: string) {
super(message)
this.name = "ProviderSettingsNotFoundError"
}
}

export class ProviderSettingsManager {
private static readonly SCOPE_PREFIX = "roo_cline_config_"
private readonly defaultConfigId = this.generateId()
Expand Down Expand Up @@ -456,7 +469,7 @@ export class ProviderSettingsManager {
name = params.name

if (!providerProfiles.apiConfigs[name]) {
throw new Error(`Config with name '${name}' not found`)
throw new ProviderSettingsNotFoundError(`Config with name '${name}' not found`)
}

providerSettings = providerProfiles.apiConfigs[name]
Expand All @@ -468,7 +481,7 @@ export class ProviderSettingsManager {
)

if (!entry) {
throw new Error(`Config with ID '${id}' not found`)
throw new ProviderSettingsNotFoundError(`Config with ID '${id}' not found`)
}

name = entry[0]
Expand All @@ -478,10 +491,31 @@ export class ProviderSettingsManager {
return { name, ...providerSettings }
})
} catch (error) {
// Keep the typed not-found signal: callers branch on it to treat an already
// deleted profile as an idempotent no-op instead of matching message text.
if (error instanceof ProviderSettingsNotFoundError) {
throw error
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
throw new Error(`Failed to get profile: ${error instanceof Error ? error.message : error}`)
}
}

/**
* Read the manager-owned current profile selection. Exposed for callers that
* snapshot the full activation state before a mutation group so a failure can
* compensate the manager store as well as its ContextProxy copies.
*/
public async getCurrentProfileName(): Promise<string | undefined> {
try {
return await this.lock(async () => {
const providerProfiles = await this.load()
return providerProfiles.currentApiConfigName
})
} catch (error) {
throw new Error(`Failed to read the active profile: ${error instanceof Error ? error.message : error}`)
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

/**
* Activate a profile by name or ID.
*/
Expand Down Expand Up @@ -511,7 +545,7 @@ export class ProviderSettingsManager {
const providerProfiles = await this.load()

if (!providerProfiles.apiConfigs[name]) {
throw new Error(`Config '${name}' not found`)
throw new ProviderSettingsNotFoundError(`Config '${name}' not found`)
}

if (Object.keys(providerProfiles.apiConfigs).length === 1) {
Expand All @@ -522,6 +556,11 @@ export class ProviderSettingsManager {
await this.store(providerProfiles)
})
} catch (error) {
// A missing config is a caller-meaningful signal, not a failure: rethrow it
// unwrapped so callers can branch on the type instead of message text.
if (error instanceof ProviderSettingsNotFoundError) {
throw error
}
throw new Error(`Failed to delete config: ${error}`)
}
}
Expand Down
27 changes: 27 additions & 0 deletions src/core/config/__tests__/ContextProxy.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1170,4 +1170,31 @@ Output only the summary of the conversation so far, without any additional comme
expect(customSupportPromptsUpdateCalls.length).toBe(0)
})
})

describe("export", () => {
it("omits viewStates from the exported global settings", async () => {
// The sibling setting is the control: if the export returned nothing at all -
// a schema failure, an empty value set - the first assertion below fails and the
// absent viewStates cannot be read as a passing test for the wrong reason.
mockGlobalState.get.mockImplementation((key: string) => {
if (key === "viewStates") {
return { "view-1": { mode: "code", currentApiConfigName: "profile-a", updatedAt: 1700000000000 } }
}
if (key === "mode") {
return "code"
}
return undefined
})

const exportProxy = new ContextProxy(mockContext)
await exportProxy.initialize()

const exported = await exportProxy.export()

expect(exported?.mode).toBe("code")
expect(Object.keys(exported ?? {})).toContain("mode")
expect(exported?.viewStates).toBeUndefined()
expect(Object.keys(exported ?? {})).not.toContain("viewStates")
})
})
})
109 changes: 108 additions & 1 deletion src/core/config/__tests__/ProviderSettingsManager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
import { clearAllMocks } from "../../../test-utils/reset"
import { makeExtensionContext } from "../../../test-utils/vscode"

import { ProviderSettingsManager, ProviderProfiles } from "../ProviderSettingsManager"
import { ProviderSettingsManager, ProviderSettingsNotFoundError, ProviderProfiles } from "../ProviderSettingsManager"

// `export()` builds an API handler per profile to read model capabilities. Mock
// buildApiHandler with the real @roo-code/types model definitions so the token-field
Expand Down Expand Up @@ -1829,4 +1829,111 @@ describe("ProviderSettingsManager", () => {
expect(result.activeProfileId).toBe("local-id")
})
})

describe("profile reads and deletes answer with the type callers branch on", () => {
const profilesWith = (apiConfigs: Record<string, unknown>, currentApiConfigName = "default") =>
JSON.stringify({
currentApiConfigName,
apiConfigs,
modeApiConfigs: {},
migrations: { rateLimitSecondsMigrated: false },
})

it("rejects a name lookup that misses with ProviderSettingsNotFoundError", async () => {
mockSecrets.get.mockResolvedValue(profilesWith({ default: { id: "default" } }))

const error = await providerSettingsManager.getProfile({ name: "absent" }).catch((cause: unknown) => cause)

if (!(error instanceof ProviderSettingsNotFoundError)) {
throw new Error(`expected ProviderSettingsNotFoundError, got ${String(error)}`)
}
expect(error.name).toBe("ProviderSettingsNotFoundError")
expect(error.message).toBe("Config with name 'absent' not found")
})

it("rejects an id lookup that misses with ProviderSettingsNotFoundError", async () => {
mockSecrets.get.mockResolvedValue(profilesWith({ default: { id: "default" } }))

const error = await providerSettingsManager
.getProfile({ id: "missing-id" })
.catch((cause: unknown) => cause)

if (!(error instanceof ProviderSettingsNotFoundError)) {
throw new Error(`expected ProviderSettingsNotFoundError, got ${String(error)}`)
}
expect(error.message).toBe("Config with ID 'missing-id' not found")
})

it("keeps a missed lookup out of the wrapped-read branch", async () => {
mockSecrets.get.mockResolvedValue(profilesWith({ default: { id: "default" } }))

await expect(providerSettingsManager.getProfile({ name: "absent" })).rejects.not.toThrow(
"Failed to get profile:",
)
})

it("surfaces a failed profile read as a plain Error naming the read", async () => {
mockSecrets.get.mockRejectedValue(new Error("storage unavailable"))

const error = await providerSettingsManager.getProfile({ name: "default" }).catch((cause: unknown) => cause)

if (!(error instanceof Error)) {
throw new Error(`expected an Error, got ${String(error)}`)
}
expect(error).not.toBeInstanceOf(ProviderSettingsNotFoundError)
expect(error.name).toBe("Error")
expect(error.message.startsWith("Failed to get profile:")).toBe(true)
expect(error.message.includes("storage unavailable")).toBe(true)
})

it("rethrows a missing config from deleteConfig as the typed error", async () => {
mockSecrets.get.mockResolvedValue(profilesWith({ default: { id: "default" }, other: { id: "other-id" } }))

const error = await providerSettingsManager.deleteConfig("absent").catch((cause: unknown) => cause)

if (!(error instanceof ProviderSettingsNotFoundError)) {
throw new Error(`expected ProviderSettingsNotFoundError, got ${String(error)}`)
}
expect(error.message).toBe("Config 'absent' not found")
})

it("keeps the last-remaining-config refusal a plain Error under the delete prefix", async () => {
mockSecrets.get.mockResolvedValue(profilesWith({ default: { id: "default" } }))

const error = await providerSettingsManager.deleteConfig("default").catch((cause: unknown) => cause)

if (!(error instanceof Error)) {
throw new Error(`expected an Error, got ${String(error)}`)
}
expect(error).not.toBeInstanceOf(ProviderSettingsNotFoundError)
expect(error.message).toBe("Failed to delete config: Error: Cannot delete the last remaining configuration")
})
})

describe("getCurrentProfileName", () => {
it("returns the profile the manager store marks current", async () => {
mockSecrets.get.mockResolvedValue(
JSON.stringify({
currentApiConfigName: "work",
apiConfigs: { default: { id: "default" }, work: { id: "work-id" } },
modeApiConfigs: {},
migrations: { rateLimitSecondsMigrated: false },
}),
)

await expect(providerSettingsManager.getCurrentProfileName()).resolves.toBe("work")
})

it("wraps a failed read in an Error naming the active profile", async () => {
mockSecrets.get.mockRejectedValue(new Error("storage unavailable"))

const error = await providerSettingsManager.getCurrentProfileName().catch((cause: unknown) => cause)

if (!(error instanceof Error)) {
throw new Error(`expected an Error, got ${String(error)}`)
}
expect(error).not.toBeInstanceOf(ProviderSettingsNotFoundError)
expect(error.message.startsWith("Failed to read the active profile:")).toBe(true)
})
})
})
Loading
Loading