Skip to content
Draft
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
49 changes: 29 additions & 20 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1088,32 +1088,41 @@ export class ClineProvider
// This overrides any mode-based config restoration above, because the task's
// specific provider profile takes precedence over mode defaults.
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)
const profile = listApiConfig.find(({ name }) => name === historyItem.apiConfigName)
const currentApiConfigName = this.contextProxy.getValues().currentApiConfigName

if (profile?.name) {
try {
if (profile.apiProvider) {
await this.activateProviderProfile(
{ name: profile.name },
{ persistModeConfig: false, persistTaskHistory: false },
if (currentApiConfigName && currentApiConfigName !== historyItem.apiConfigName) {
this.log(
`Keeping current provider profile '${currentApiConfigName}' instead of restoring stale profile '${historyItem.apiConfigName}' for task ${historyItem.id}.`,
)
historyItem.apiConfigName = currentApiConfigName
} else {
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)
const profile = listApiConfig.find(({ name }) => name === historyItem.apiConfigName)

if (profile?.name) {
try {
if (profile.apiProvider) {
await this.activateProviderProfile(
{ name: profile.name },
{ persistModeConfig: false, persistTaskHistory: false },
)
}
} catch (error) {
// Log the error but continue with task restoration.
this.log(
`Failed to restore API configuration '${historyItem.apiConfigName}' for task: ${
error instanceof Error ? error.message : String(error)
}. Continuing with current configuration.`,
)
}
} catch (error) {
// Log the error but continue with task restoration.
} else {
// Profile no longer exists, log warning but continue
this.log(
`Failed to restore API configuration '${historyItem.apiConfigName}' for task: ${
error instanceof Error ? error.message : String(error)
}. Continuing with current configuration.`,
`Provider profile '${historyItem.apiConfigName}' from history no longer exists. Using current configuration.`,
)
}
} else {
// Profile no longer exists, log warning but continue
this.log(
`Provider profile '${historyItem.apiConfigName}' from history no longer exists. Using current configuration.`,
)
}
} else if (historyItem.apiConfigName && skipProfileRestoreFromHistory) {
this.log(
Expand Down
72 changes: 56 additions & 16 deletions src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,10 @@ describe("ClineProvider - Sticky Provider Profile", () => {

provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext))

// Seed the ContextProxy state cache with the current profile (initialize() is not
// called in these tests, so the cache starts empty).
await provider.contextProxy.setValue("currentApiConfigName", "default-profile")

// Wait for the async TaskHistoryStore initialization to complete
await new Promise((resolve) => setTimeout(resolve, 10))

Expand Down Expand Up @@ -488,10 +492,10 @@ describe("ClineProvider - Sticky Provider Profile", () => {
})

describe("createTaskWithHistoryItem", () => {
it("should restore provider profile from history item when reopening task outside CLI runtime", async () => {
it("should restore provider profile from history item when it matches the current profile", async () => {
await provider.resolveWebviewView(mockWebviewView)

// Create a history item with saved provider profile
// Create a history item with saved provider profile matching the current profile
const historyItem: HistoryItem = {
id: "test-task-id",
number: 1,
Expand All @@ -503,7 +507,7 @@ describe("ClineProvider - Sticky Provider Profile", () => {
cacheReads: 0,
totalCost: 0.001,
mode: "code",
apiConfigName: "saved-profile", // Saved provider profile
apiConfigName: "default-profile", // Matches currentApiConfigName in beforeEach
}

// Mock activateProviderProfile to track calls
Expand All @@ -513,19 +517,55 @@ describe("ClineProvider - Sticky Provider Profile", () => {

// Mock providerSettingsManager.listConfig
vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([
{ name: "saved-profile", id: "saved-profile-id", apiProvider: "anthropic" },
{ name: "default-profile", id: "default-profile-id", apiProvider: "anthropic" },
])

// Initialize task with history item
await provider.createTaskWithHistoryItem(historyItem)

// Verify provider profile was restored via activateProviderProfile (restore-only: don't persist mode config)
expect(activateProviderProfileSpy).toHaveBeenCalledWith(
{ name: "saved-profile" },
{ name: "default-profile" },
{ persistModeConfig: false, persistTaskHistory: false },
)
})

it("should keep the current provider profile when it differs from the history item's stale profile", async () => {
await provider.resolveWebviewView(mockWebviewView)

// The task was last saved with "saved-profile", but the user has since
// switched to "default-profile" (the current profile in beforeEach).
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",
apiConfigName: "saved-profile",
}

const activateProviderProfileSpy = vi
.spyOn(provider, "activateProviderProfile")
.mockResolvedValue(undefined)

vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([
{ name: "saved-profile", id: "saved-profile-id", apiProvider: "anthropic" },
])

await provider.createTaskWithHistoryItem(historyItem)

// The stale profile must NOT be reactivated over the user's current selection.
expect(activateProviderProfileSpy).not.toHaveBeenCalledWith({ name: "saved-profile" }, expect.anything())

// The history item's sticky profile is corrected so the new selection persists.
expect(historyItem.apiConfigName).toBe("default-profile")
})

it("should not restore an empty task apiConfigName profile from history", async () => {
await provider.resolveWebviewView(mockWebviewView)

Expand All @@ -540,15 +580,15 @@ describe("ClineProvider - Sticky Provider Profile", () => {
cacheReads: 0,
totalCost: 0.001,
mode: "ask",
apiConfigName: "default",
apiConfigName: "default-profile", // Matches current profile so the restore path runs
}

const activateProviderProfileSpy = vi
.spyOn(provider, "activateProviderProfile")
.mockResolvedValue(undefined)

vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([
{ name: "default", id: "default-id" },
{ name: "default-profile", id: "default-profile-id" },
])

await provider.createTaskWithHistoryItem(historyItem)
Expand Down Expand Up @@ -657,7 +697,7 @@ describe("ClineProvider - Sticky Provider Profile", () => {
it("should override mode-based config with task's apiConfigName", async () => {
await provider.resolveWebviewView(mockWebviewView)

// Create a history item with both mode and apiConfigName
// Create a history item with both mode and apiConfigName (matching the current profile)
const historyItem: HistoryItem = {
id: "test-task-id",
number: 1,
Expand All @@ -669,7 +709,7 @@ describe("ClineProvider - Sticky Provider Profile", () => {
cacheReads: 0,
totalCost: 0.001,
mode: "architect", // Mode has a different preferred profile
apiConfigName: "task-specific-profile", // Task's actual profile
apiConfigName: "default-profile", // Task's actual profile (matches currentApiConfigName)
}

// Track all activateProviderProfile calls
Expand All @@ -684,14 +724,14 @@ describe("ClineProvider - Sticky Provider Profile", () => {
vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockResolvedValue("mode-config-id")
vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([
{ name: "mode-preferred-profile", id: "mode-config-id", apiProvider: "anthropic" },
{ name: "task-specific-profile", id: "task-profile-id", apiProvider: "openai" },
{ name: "default-profile", id: "default-profile-id", apiProvider: "openai" },
])

// Initialize task with history item
await provider.createTaskWithHistoryItem(historyItem)

// Verify task's apiConfigName was activated LAST (overriding mode-based config)
expect(activateCalls[activateCalls.length - 1]).toBe("task-specific-profile")
expect(activateCalls[activateCalls.length - 1]).toBe("default-profile")
})

it("should handle missing provider profile gracefully", async () => {
Expand All @@ -708,7 +748,7 @@ describe("ClineProvider - Sticky Provider Profile", () => {
cacheWrites: 0,
cacheReads: 0,
totalCost: 0.001,
apiConfigName: "deleted-profile", // Profile that doesn't exist
apiConfigName: "default-profile", // Matches current profile, but profile doesn't exist
}

// Mock providerSettingsManager.listConfig to return empty (profile doesn't exist)
Expand All @@ -722,7 +762,7 @@ describe("ClineProvider - Sticky Provider Profile", () => {

// Verify a warning was logged
expect(logSpy).toHaveBeenCalledWith(
expect.stringContaining("Provider profile 'deleted-profile' from history no longer exists"),
expect.stringContaining("Provider profile 'default-profile' from history no longer exists"),
)
})
})
Expand Down Expand Up @@ -991,12 +1031,12 @@ describe("ClineProvider - Sticky Provider Profile", () => {
cacheWrites: 0,
cacheReads: 0,
totalCost: 0.001,
apiConfigName: "failing-profile",
apiConfigName: "default-profile", // Matches current profile so the restore path runs
}

// Mock providerSettingsManager.listConfig to return the profile
vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([
{ name: "failing-profile", id: "failing-profile-id", apiProvider: "anthropic" },
{ name: "default-profile", id: "default-profile-id", apiProvider: "anthropic" },
])

// Mock activateProviderProfile to throw error
Expand All @@ -1010,7 +1050,7 @@ describe("ClineProvider - Sticky Provider Profile", () => {

// Verify error was logged
expect(logSpy).toHaveBeenCalledWith(
expect.stringContaining("Failed to restore API configuration 'failing-profile' for task"),
expect.stringContaining("Failed to restore API configuration 'default-profile' for task"),
)
})
})
Expand Down
Loading