Skip to content
Closed
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
1 change: 1 addition & 0 deletions packages/types/src/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export const historyItemSchema = z.object({
totalCost: z.number(),
size: z.number().optional(),
workspace: z.string().optional(),
workspaceHash: z.string().optional(),
mode: z.string().optional(),
})

Expand Down
7 changes: 7 additions & 0 deletions src/core/task-persistence/taskMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { combineCommandSequences } from "../../shared/combineCommandSequences"
import { getApiMetrics } from "../../shared/getApiMetrics"
import { findLastIndex } from "../../shared/array"
import { getTaskDirectoryPath } from "../../utils/storage"
import { getWorkspaceHash } from "../../utils/workspaceHash"
import { t } from "../../i18n"

const taskSizeCache = new NodeCache({ stdTTL: 30, checkperiod: 5 * 60 })
Expand All @@ -18,6 +19,7 @@ export type TaskMetadataOptions = {
taskNumber: number
globalStoragePath: string
workspace: string
workspaceHash?: string
mode?: string
}

Expand All @@ -27,6 +29,7 @@ export async function taskMetadata({
taskNumber,
globalStoragePath,
workspace,
workspaceHash,
mode,
}: TaskMetadataOptions) {
const taskDir = await getTaskDirectoryPath(globalStoragePath, taskId)
Expand Down Expand Up @@ -79,6 +82,9 @@ export async function taskMetadata({
}
}

// Generate workspace hash if not provided, convert null to undefined
const finalWorkspaceHash = workspaceHash || getWorkspaceHash() || undefined

// Create historyItem once with pre-calculated values
const historyItem: HistoryItem = {
id: taskId,
Expand All @@ -94,6 +100,7 @@ export async function taskMetadata({
totalCost: tokenUsage.totalCost,
size: taskDirSize,
workspace,
workspaceHash: finalWorkspaceHash,
mode,
}

Expand Down
1 change: 1 addition & 0 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import { TerminalRegistry } from "../../integrations/terminal/TerminalRegistry"
// utils
import { calculateApiCostAnthropic } from "../../shared/cost"
import { getWorkspacePath } from "../../utils/path"
import { getWorkspaceHash } from "../../utils/workspaceHash"

// prompts
import { formatResponse } from "../prompts/responses"
Expand Down
19 changes: 19 additions & 0 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ import { getNonce } from "./getNonce"
import { getUri } from "./getUri"
import { getSystemPromptFilePath } from "../prompts/sections/custom-system-prompt"
import { getWorkspacePath } from "../../utils/path"
import { migrateHistoryToWorkspaceHash, isMigrationNeeded } from "../../utils/historyMigration"
import { webviewMessageHandler } from "./webviewMessageHandler"
import { WebviewMessage } from "../../shared/WebviewMessage"
import { EMBEDDING_MODEL_PROFILES } from "../../shared/embeddingModels"
Expand Down Expand Up @@ -1826,6 +1827,24 @@ export class ClineProvider
return history
}

/**
* Migrates existing task history to include workspace hashes if needed
*/
async migrateHistoryIfNeeded(): Promise<void> {
try {
const taskHistory = this.getGlobalState("taskHistory") ?? []

if (isMigrationNeeded(taskHistory)) {
this.log("Migrating task history to include workspace hashes...")
const migratedHistory = migrateHistoryToWorkspaceHash(taskHistory)
await this.updateGlobalState("taskHistory", migratedHistory)
this.log(`Successfully migrated ${migratedHistory.length} history items`)
}
} catch (error) {
this.log(`Error during history migration: ${error instanceof Error ? error.message : String(error)}`)
}
}

// ContextProxy

// @deprecated - Use `ContextProxy#setValue` instead.
Expand Down
231 changes: 231 additions & 0 deletions src/utils/__tests__/historyMigration.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import * as vscode from "vscode"
import type { HistoryItem } from "@roo-code/types"
import {
migrateHistoryToWorkspaceHash,
isMigrationNeeded,
findOrphanedHistory,
relinkHistoryItem,
} from "../historyMigration"
import * as workspaceHashModule from "../workspaceHash"
import * as pathModule from "../path"

// Mock vscode
vi.mock("vscode", () => ({
workspace: {
workspaceFolders: undefined,
},
}))

// Mock workspaceHash module
vi.mock("../workspaceHash", () => ({
getWorkspaceHash: vi.fn(),
areWorkspaceHashesEqual: vi.fn(),
}))

// Mock path module
vi.mock("../path", () => ({
arePathsEqual: vi.fn(),
}))

describe("historyMigration", () => {
beforeEach(() => {
vi.clearAllMocks()
})

describe("migrateHistoryToWorkspaceHash", () => {
it("should skip items that already have workspace hash", () => {
vi.mocked(workspaceHashModule.getWorkspaceHash).mockReturnValue("current-hash")

const historyItems: HistoryItem[] = [
{
id: "1",
number: 1,
ts: Date.now(),
task: "Test task",
tokensIn: 100,
tokensOut: 50,
totalCost: 0.01,
workspace: "/test/workspace",
workspaceHash: "existing-hash",
},
]

const result = migrateHistoryToWorkspaceHash(historyItems)
expect(result[0].workspaceHash).toBe("existing-hash")
})

it("should add workspace hash for items matching current workspace", () => {
vi.mocked(workspaceHashModule.getWorkspaceHash).mockReturnValue("current-hash")
vi.mocked(pathModule.arePathsEqual).mockReturnValue(true)
;(vscode.workspace as any).workspaceFolders = [{ uri: { fsPath: "/test/workspace" } }]

const historyItems: HistoryItem[] = [
{
id: "1",
number: 1,
ts: Date.now(),
task: "Test task",
tokensIn: 100,
tokensOut: 50,
totalCost: 0.01,
workspace: "/test/workspace",
},
]

const result = migrateHistoryToWorkspaceHash(historyItems)
expect(result[0].workspaceHash).toBe("current-hash")
})

it("should not add workspace hash for items from different workspace", () => {
vi.mocked(workspaceHashModule.getWorkspaceHash).mockReturnValue("current-hash")
vi.mocked(pathModule.arePathsEqual).mockReturnValue(false)
;(vscode.workspace as any).workspaceFolders = [{ uri: { fsPath: "/test/workspace" } }]

const historyItems: HistoryItem[] = [
{
id: "1",
number: 1,
ts: Date.now(),
task: "Test task",
tokensIn: 100,
tokensOut: 50,
totalCost: 0.01,
workspace: "/different/workspace",
},
]

const result = migrateHistoryToWorkspaceHash(historyItems)
expect(result[0].workspaceHash).toBeUndefined()
})
})

describe("isMigrationNeeded", () => {
it("should return true when items without workspace hash exist", () => {
const historyItems: HistoryItem[] = [
{
id: "1",
number: 1,
ts: Date.now(),
task: "Test task",
tokensIn: 100,
tokensOut: 50,
totalCost: 0.01,
workspace: "/test/workspace",
},
]

expect(isMigrationNeeded(historyItems)).toBe(true)
})

it("should return false when all items have workspace hash", () => {
const historyItems: HistoryItem[] = [
{
id: "1",
number: 1,
ts: Date.now(),
task: "Test task",
tokensIn: 100,
tokensOut: 50,
totalCost: 0.01,
workspace: "/test/workspace",
workspaceHash: "hash",
},
]

expect(isMigrationNeeded(historyItems)).toBe(false)
})

it("should return false for empty history", () => {
expect(isMigrationNeeded([])).toBe(false)
})
})

describe("findOrphanedHistory", () => {
it("should find items with different workspace hash", () => {
vi.mocked(workspaceHashModule.getWorkspaceHash).mockReturnValue("current-hash")
vi.mocked(workspaceHashModule.areWorkspaceHashesEqual).mockReturnValue(false)

const historyItems: HistoryItem[] = [
{
id: "1",
number: 1,
ts: Date.now(),
task: "Test task",
tokensIn: 100,
tokensOut: 50,
totalCost: 0.01,
workspace: "/test/workspace",
workspaceHash: "different-hash",
},
]

const result = findOrphanedHistory(historyItems)
expect(result).toHaveLength(1)
expect(result[0].id).toBe("1")
})

it("should find items with different workspace path", () => {
vi.mocked(workspaceHashModule.getWorkspaceHash).mockReturnValue("current-hash")
vi.mocked(pathModule.arePathsEqual).mockReturnValue(false)
;(vscode.workspace as any).workspaceFolders = [{ uri: { fsPath: "/test/workspace" } }]

const historyItems: HistoryItem[] = [
{
id: "1",
number: 1,
ts: Date.now(),
task: "Test task",
tokensIn: 100,
tokensOut: 50,
totalCost: 0.01,
workspace: "/different/workspace",
},
]

const result = findOrphanedHistory(historyItems)
expect(result).toHaveLength(1)
expect(result[0].id).toBe("1")
})
})

describe("relinkHistoryItem", () => {
it("should update workspace information", () => {
const item: HistoryItem = {
id: "1",
number: 1,
ts: Date.now(),
task: "Test task",
tokensIn: 100,
tokensOut: 50,
totalCost: 0.01,
workspace: "/old/workspace",
workspaceHash: "old-hash",
}

const result = relinkHistoryItem(item, "/new/workspace", "new-hash")

expect(result.workspace).toBe("/new/workspace")
expect(result.workspaceHash).toBe("new-hash")
expect(result.id).toBe("1") // Other properties preserved
})

it("should handle null workspace hash", () => {
const item: HistoryItem = {
id: "1",
number: 1,
ts: Date.now(),
task: "Test task",
tokensIn: 100,
tokensOut: 50,
totalCost: 0.01,
workspace: "/old/workspace",
}

const result = relinkHistoryItem(item, "/new/workspace", null)

expect(result.workspace).toBe("/new/workspace")
expect(result.workspaceHash).toBeUndefined()
})
})
})
Loading