Skip to content

Commit 746ceee

Browse files
committed
feat: implement hybrid conversation history system with workspace hashes
- Add workspace hash functionality using VS Code stable identifiers - Update HistoryItem schema to include optional workspaceHash field - Implement automatic migration for existing path-based history - Add hybrid filtering with hash-based primary matching and path fallback - Include path search functionality with "path:" prefix - Add comprehensive test coverage for all new functionality - Maintain backward compatibility with existing workspace paths Fixes #6398
1 parent e654ced commit 746ceee

File tree

11 files changed

+546
-16
lines changed

11 files changed

+546
-16
lines changed

packages/types/src/history.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export const historyItemSchema = z.object({
1616
totalCost: z.number(),
1717
size: z.number().optional(),
1818
workspace: z.string().optional(),
19+
workspaceHash: z.string().optional(),
1920
mode: z.string().optional(),
2021
})
2122

src/core/task-persistence/taskMetadata.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { combineCommandSequences } from "../../shared/combineCommandSequences"
88
import { getApiMetrics } from "../../shared/getApiMetrics"
99
import { findLastIndex } from "../../shared/array"
1010
import { getTaskDirectoryPath } from "../../utils/storage"
11+
import { getWorkspaceHash } from "../../utils/workspaceHash"
1112
import { t } from "../../i18n"
1213

1314
const taskSizeCache = new NodeCache({ stdTTL: 30, checkperiod: 5 * 60 })
@@ -18,6 +19,7 @@ export type TaskMetadataOptions = {
1819
taskNumber: number
1920
globalStoragePath: string
2021
workspace: string
22+
workspaceHash?: string
2123
mode?: string
2224
}
2325

@@ -27,6 +29,7 @@ export async function taskMetadata({
2729
taskNumber,
2830
globalStoragePath,
2931
workspace,
32+
workspaceHash,
3033
mode,
3134
}: TaskMetadataOptions) {
3235
const taskDir = await getTaskDirectoryPath(globalStoragePath, taskId)
@@ -79,6 +82,9 @@ export async function taskMetadata({
7982
}
8083
}
8184

85+
// Generate workspace hash if not provided, convert null to undefined
86+
const finalWorkspaceHash = workspaceHash || getWorkspaceHash() || undefined
87+
8288
// Create historyItem once with pre-calculated values
8389
const historyItem: HistoryItem = {
8490
id: taskId,
@@ -94,6 +100,7 @@ export async function taskMetadata({
94100
totalCost: tokenUsage.totalCost,
95101
size: taskDirSize,
96102
workspace,
103+
workspaceHash: finalWorkspaceHash,
97104
mode,
98105
}
99106

src/core/task/Task.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ import { TerminalRegistry } from "../../integrations/terminal/TerminalRegistry"
6262
// utils
6363
import { calculateApiCostAnthropic } from "../../shared/cost"
6464
import { getWorkspacePath } from "../../utils/path"
65+
import { getWorkspaceHash } from "../../utils/workspaceHash"
6566

6667
// prompts
6768
import { formatResponse } from "../prompts/responses"

src/core/webview/ClineProvider.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ import { getNonce } from "./getNonce"
6666
import { getUri } from "./getUri"
6767
import { getSystemPromptFilePath } from "../prompts/sections/custom-system-prompt"
6868
import { getWorkspacePath } from "../../utils/path"
69+
import { migrateHistoryToWorkspaceHash, isMigrationNeeded } from "../../utils/historyMigration"
6970
import { webviewMessageHandler } from "./webviewMessageHandler"
7071
import { WebviewMessage } from "../../shared/WebviewMessage"
7172
import { EMBEDDING_MODEL_PROFILES } from "../../shared/embeddingModels"
@@ -1826,6 +1827,24 @@ export class ClineProvider
18261827
return history
18271828
}
18281829

1830+
/**
1831+
* Migrates existing task history to include workspace hashes if needed
1832+
*/
1833+
async migrateHistoryIfNeeded(): Promise<void> {
1834+
try {
1835+
const taskHistory = this.getGlobalState("taskHistory") ?? []
1836+
1837+
if (isMigrationNeeded(taskHistory)) {
1838+
this.log("Migrating task history to include workspace hashes...")
1839+
const migratedHistory = migrateHistoryToWorkspaceHash(taskHistory)
1840+
await this.updateGlobalState("taskHistory", migratedHistory)
1841+
this.log(`Successfully migrated ${migratedHistory.length} history items`)
1842+
}
1843+
} catch (error) {
1844+
this.log(`Error during history migration: ${error instanceof Error ? error.message : String(error)}`)
1845+
}
1846+
}
1847+
18291848
// ContextProxy
18301849

18311850
// @deprecated - Use `ContextProxy#setValue` instead.
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest"
2+
import * as vscode from "vscode"
3+
import type { HistoryItem } from "@roo-code/types"
4+
import {
5+
migrateHistoryToWorkspaceHash,
6+
isMigrationNeeded,
7+
findOrphanedHistory,
8+
relinkHistoryItem,
9+
} from "../historyMigration"
10+
import * as workspaceHashModule from "../workspaceHash"
11+
import * as pathModule from "../path"
12+
13+
// Mock vscode
14+
vi.mock("vscode", () => ({
15+
workspace: {
16+
workspaceFolders: undefined,
17+
},
18+
}))
19+
20+
// Mock workspaceHash module
21+
vi.mock("../workspaceHash", () => ({
22+
getWorkspaceHash: vi.fn(),
23+
areWorkspaceHashesEqual: vi.fn(),
24+
}))
25+
26+
// Mock path module
27+
vi.mock("../path", () => ({
28+
arePathsEqual: vi.fn(),
29+
}))
30+
31+
describe("historyMigration", () => {
32+
beforeEach(() => {
33+
vi.clearAllMocks()
34+
})
35+
36+
describe("migrateHistoryToWorkspaceHash", () => {
37+
it("should skip items that already have workspace hash", () => {
38+
vi.mocked(workspaceHashModule.getWorkspaceHash).mockReturnValue("current-hash")
39+
40+
const historyItems: HistoryItem[] = [
41+
{
42+
id: "1",
43+
number: 1,
44+
ts: Date.now(),
45+
task: "Test task",
46+
tokensIn: 100,
47+
tokensOut: 50,
48+
totalCost: 0.01,
49+
workspace: "/test/workspace",
50+
workspaceHash: "existing-hash",
51+
},
52+
]
53+
54+
const result = migrateHistoryToWorkspaceHash(historyItems)
55+
expect(result[0].workspaceHash).toBe("existing-hash")
56+
})
57+
58+
it("should add workspace hash for items matching current workspace", () => {
59+
vi.mocked(workspaceHashModule.getWorkspaceHash).mockReturnValue("current-hash")
60+
vi.mocked(pathModule.arePathsEqual).mockReturnValue(true)
61+
;(vscode.workspace as any).workspaceFolders = [{ uri: { fsPath: "/test/workspace" } }]
62+
63+
const historyItems: HistoryItem[] = [
64+
{
65+
id: "1",
66+
number: 1,
67+
ts: Date.now(),
68+
task: "Test task",
69+
tokensIn: 100,
70+
tokensOut: 50,
71+
totalCost: 0.01,
72+
workspace: "/test/workspace",
73+
},
74+
]
75+
76+
const result = migrateHistoryToWorkspaceHash(historyItems)
77+
expect(result[0].workspaceHash).toBe("current-hash")
78+
})
79+
80+
it("should not add workspace hash for items from different workspace", () => {
81+
vi.mocked(workspaceHashModule.getWorkspaceHash).mockReturnValue("current-hash")
82+
vi.mocked(pathModule.arePathsEqual).mockReturnValue(false)
83+
;(vscode.workspace as any).workspaceFolders = [{ uri: { fsPath: "/test/workspace" } }]
84+
85+
const historyItems: HistoryItem[] = [
86+
{
87+
id: "1",
88+
number: 1,
89+
ts: Date.now(),
90+
task: "Test task",
91+
tokensIn: 100,
92+
tokensOut: 50,
93+
totalCost: 0.01,
94+
workspace: "/different/workspace",
95+
},
96+
]
97+
98+
const result = migrateHistoryToWorkspaceHash(historyItems)
99+
expect(result[0].workspaceHash).toBeUndefined()
100+
})
101+
})
102+
103+
describe("isMigrationNeeded", () => {
104+
it("should return true when items without workspace hash exist", () => {
105+
const historyItems: HistoryItem[] = [
106+
{
107+
id: "1",
108+
number: 1,
109+
ts: Date.now(),
110+
task: "Test task",
111+
tokensIn: 100,
112+
tokensOut: 50,
113+
totalCost: 0.01,
114+
workspace: "/test/workspace",
115+
},
116+
]
117+
118+
expect(isMigrationNeeded(historyItems)).toBe(true)
119+
})
120+
121+
it("should return false when all items have workspace hash", () => {
122+
const historyItems: HistoryItem[] = [
123+
{
124+
id: "1",
125+
number: 1,
126+
ts: Date.now(),
127+
task: "Test task",
128+
tokensIn: 100,
129+
tokensOut: 50,
130+
totalCost: 0.01,
131+
workspace: "/test/workspace",
132+
workspaceHash: "hash",
133+
},
134+
]
135+
136+
expect(isMigrationNeeded(historyItems)).toBe(false)
137+
})
138+
139+
it("should return false for empty history", () => {
140+
expect(isMigrationNeeded([])).toBe(false)
141+
})
142+
})
143+
144+
describe("findOrphanedHistory", () => {
145+
it("should find items with different workspace hash", () => {
146+
vi.mocked(workspaceHashModule.getWorkspaceHash).mockReturnValue("current-hash")
147+
vi.mocked(workspaceHashModule.areWorkspaceHashesEqual).mockReturnValue(false)
148+
149+
const historyItems: HistoryItem[] = [
150+
{
151+
id: "1",
152+
number: 1,
153+
ts: Date.now(),
154+
task: "Test task",
155+
tokensIn: 100,
156+
tokensOut: 50,
157+
totalCost: 0.01,
158+
workspace: "/test/workspace",
159+
workspaceHash: "different-hash",
160+
},
161+
]
162+
163+
const result = findOrphanedHistory(historyItems)
164+
expect(result).toHaveLength(1)
165+
expect(result[0].id).toBe("1")
166+
})
167+
168+
it("should find items with different workspace path", () => {
169+
vi.mocked(workspaceHashModule.getWorkspaceHash).mockReturnValue("current-hash")
170+
vi.mocked(pathModule.arePathsEqual).mockReturnValue(false)
171+
;(vscode.workspace as any).workspaceFolders = [{ uri: { fsPath: "/test/workspace" } }]
172+
173+
const historyItems: HistoryItem[] = [
174+
{
175+
id: "1",
176+
number: 1,
177+
ts: Date.now(),
178+
task: "Test task",
179+
tokensIn: 100,
180+
tokensOut: 50,
181+
totalCost: 0.01,
182+
workspace: "/different/workspace",
183+
},
184+
]
185+
186+
const result = findOrphanedHistory(historyItems)
187+
expect(result).toHaveLength(1)
188+
expect(result[0].id).toBe("1")
189+
})
190+
})
191+
192+
describe("relinkHistoryItem", () => {
193+
it("should update workspace information", () => {
194+
const item: HistoryItem = {
195+
id: "1",
196+
number: 1,
197+
ts: Date.now(),
198+
task: "Test task",
199+
tokensIn: 100,
200+
tokensOut: 50,
201+
totalCost: 0.01,
202+
workspace: "/old/workspace",
203+
workspaceHash: "old-hash",
204+
}
205+
206+
const result = relinkHistoryItem(item, "/new/workspace", "new-hash")
207+
208+
expect(result.workspace).toBe("/new/workspace")
209+
expect(result.workspaceHash).toBe("new-hash")
210+
expect(result.id).toBe("1") // Other properties preserved
211+
})
212+
213+
it("should handle null workspace hash", () => {
214+
const item: HistoryItem = {
215+
id: "1",
216+
number: 1,
217+
ts: Date.now(),
218+
task: "Test task",
219+
tokensIn: 100,
220+
tokensOut: 50,
221+
totalCost: 0.01,
222+
workspace: "/old/workspace",
223+
}
224+
225+
const result = relinkHistoryItem(item, "/new/workspace", null)
226+
227+
expect(result.workspace).toBe("/new/workspace")
228+
expect(result.workspaceHash).toBeUndefined()
229+
})
230+
})
231+
})

0 commit comments

Comments
 (0)