Skip to content

Commit 94ad2df

Browse files
committed
refactor: use external file for storing task history
1 parent 4f72714 commit 94ad2df

File tree

3 files changed

+173
-50
lines changed

3 files changed

+173
-50
lines changed

src/__mocks__/fs/promises.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import * as vscode from "vscode"
2+
13
// Mock file system data
24
const mockFiles = new Map()
35
const mockDirectories = new Set()
@@ -44,7 +46,18 @@ const ensureDirectoryExists = (path: string) => {
4446
}
4547
}
4648

47-
const mockFs = {
49+
// Mock types for vscode workspace fs
50+
type MockFileSystem = {
51+
readFile: jest.Mock<Promise<Uint8Array>, [vscode.Uri]>
52+
writeFile: jest.Mock<Promise<void>, [vscode.Uri, Uint8Array]>
53+
mkdir: jest.Mock<Promise<void>, [vscode.Uri, { recursive?: boolean }]>
54+
access: jest.Mock<Promise<void>, [vscode.Uri]>
55+
rename: jest.Mock<Promise<void>, [vscode.Uri, vscode.Uri]>
56+
delete: jest.Mock<Promise<void>, [vscode.Uri]>
57+
[key: string]: any // Allow additional properties to match vscode API
58+
}
59+
60+
const mockFs: MockFileSystem = {
4861
readFile: jest.fn().mockImplementation(async (filePath: string, _encoding?: string) => {
4962
// Return stored content if it exists
5063
if (mockFiles.has(filePath)) {
@@ -148,6 +161,12 @@ const mockFs = {
148161
throw error
149162
}),
150163

164+
delete: jest.fn().mockImplementation(async (path: string) => {
165+
// Delete file
166+
mockFiles.delete(path)
167+
return Promise.resolve()
168+
}),
169+
151170
constants: jest.requireActual("fs").constants,
152171

153172
// Expose mock data for test assertions
@@ -181,6 +200,22 @@ const mockFs = {
181200
mockDirectories.add(currentPath)
182201
}
183202
})
203+
204+
// Set up taskHistory file
205+
const tasks = [
206+
{
207+
id: "1",
208+
number: 1,
209+
ts: Date.now(),
210+
task: "test",
211+
tokensIn: 100,
212+
tokensOut: 50,
213+
totalCost: 0.001,
214+
cacheWrites: 0,
215+
cacheReads: 0,
216+
},
217+
]
218+
mockFiles.set("/mock/storage/path/taskHistory.jsonl", tasks.map((t) => JSON.stringify(t)).join("\n") + "\n")
184219
},
185220
}
186221

src/core/config/ContextProxy.ts

Lines changed: 70 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ type GlobalStateKey = keyof GlobalState
2222
type SecretStateKey = keyof SecretState
2323
type RooCodeSettingsKey = keyof RooCodeSettings
2424

25-
const PASS_THROUGH_STATE_KEYS = ["taskHistory"]
25+
const PASS_THROUGH_STATE_KEYS: string[] = []
2626

2727
export const isPassThroughStateKey = (key: string) => PASS_THROUGH_STATE_KEYS.includes(key)
2828

@@ -50,6 +50,31 @@ export class ContextProxy {
5050
return this._isInitialized
5151
}
5252

53+
private async readTaskHistoryFile(): Promise<any[]> {
54+
try {
55+
const taskHistoryUri = vscode.Uri.joinPath(this.globalStorageUri, "taskHistory.jsonl")
56+
const fileContent = await vscode.workspace.fs.readFile(taskHistoryUri)
57+
const lines = fileContent.toString().split("\n").filter(Boolean)
58+
return lines.map((line) => JSON.parse(line))
59+
} catch (error) {
60+
if (error instanceof vscode.FileSystemError && error.code === "FileNotFound") {
61+
return []
62+
}
63+
logger.error(`Error reading task history file: ${error instanceof Error ? error.message : String(error)}`)
64+
return []
65+
}
66+
}
67+
68+
private async writeTaskHistoryFile(tasks: any[]): Promise<void> {
69+
try {
70+
const taskHistoryUri = vscode.Uri.joinPath(this.globalStorageUri, "taskHistory.jsonl")
71+
const content = tasks.map((task) => JSON.stringify(task)).join("\n") + "\n"
72+
await vscode.workspace.fs.writeFile(taskHistoryUri, Buffer.from(content))
73+
} catch (error) {
74+
logger.error(`Error writing task history file: ${error instanceof Error ? error.message : String(error)}`)
75+
}
76+
}
77+
5378
public async initialize() {
5479
for (const key of GLOBAL_STATE_KEYS) {
5580
try {
@@ -60,6 +85,31 @@ export class ContextProxy {
6085
}
6186
}
6287

88+
// Load task history from file
89+
if (GLOBAL_STATE_KEYS.includes("taskHistory")) {
90+
const tasks = await this.readTaskHistoryFile()
91+
this.stateCache.taskHistory = tasks
92+
93+
// Migrate task history from global state if global state has data
94+
const globalStateTasks = this.originalContext.globalState.get("taskHistory")
95+
if (Array.isArray(globalStateTasks) && globalStateTasks.length > 0) {
96+
try {
97+
// Append global state tasks to existing file content
98+
const combinedTasks = [...tasks, ...globalStateTasks]
99+
await this.writeTaskHistoryFile(combinedTasks)
100+
this.stateCache.taskHistory = combinedTasks
101+
await this.originalContext.globalState.update("taskHistory", undefined)
102+
vscode.window.showInformationMessage(
103+
"Task history has been migrated using an append strategy to preserve existing entries.",
104+
)
105+
} catch (error) {
106+
logger.error(
107+
`Error migrating task history: ${error instanceof Error ? error.message : String(error)}`,
108+
)
109+
}
110+
}
111+
}
112+
63113
const promises = SECRET_STATE_KEYS.map(async (key) => {
64114
try {
65115
this.secretCache[key] = await this.originalContext.secrets.get(key)
@@ -105,18 +155,17 @@ export class ContextProxy {
105155
getGlobalState<K extends GlobalStateKey>(key: K): GlobalState[K]
106156
getGlobalState<K extends GlobalStateKey>(key: K, defaultValue: GlobalState[K]): GlobalState[K]
107157
getGlobalState<K extends GlobalStateKey>(key: K, defaultValue?: GlobalState[K]): GlobalState[K] {
108-
if (isPassThroughStateKey(key)) {
109-
const value = this.originalContext.globalState.get<GlobalState[K]>(key)
110-
return value === undefined || value === null ? defaultValue : value
111-
}
112-
113158
const value = this.stateCache[key]
114159
return value !== undefined ? value : defaultValue
115160
}
116161

117-
updateGlobalState<K extends GlobalStateKey>(key: K, value: GlobalState[K]) {
118-
if (isPassThroughStateKey(key)) {
119-
return this.originalContext.globalState.update(key, value)
162+
async updateGlobalState<K extends GlobalStateKey>(key: K, value: GlobalState[K]) {
163+
if (key === "taskHistory") {
164+
this.stateCache[key] = value
165+
if (Array.isArray(value)) {
166+
await this.writeTaskHistoryFile(value)
167+
}
168+
return
120169
}
121170

122171
this.stateCache[key] = value
@@ -264,6 +313,18 @@ export class ContextProxy {
264313
this.stateCache = {}
265314
this.secretCache = {}
266315

316+
// Delete task history file
317+
try {
318+
const taskHistoryUri = vscode.Uri.joinPath(this.globalStorageUri, "taskHistory.jsonl")
319+
await vscode.workspace.fs.delete(taskHistoryUri)
320+
} catch (error) {
321+
if (!(error instanceof vscode.FileSystemError && error.code === "FileNotFound")) {
322+
logger.error(
323+
`Error deleting task history file: ${error instanceof Error ? error.message : String(error)}`,
324+
)
325+
}
326+
}
327+
267328
await Promise.all([
268329
...GLOBAL_STATE_KEYS.map((key) => this.originalContext.globalState.update(key, undefined)),
269330
...SECRET_STATE_KEYS.map((key) => this.originalContext.secrets.delete(key)),

src/core/config/__tests__/ContextProxy.test.ts

Lines changed: 67 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,25 @@
22

33
import * as vscode from "vscode"
44
import { ContextProxy } from "../ContextProxy"
5-
5+
import type { HistoryItem } from "../../../schemas"
66
import { GLOBAL_STATE_KEYS, SECRET_STATE_KEYS } from "../../../schemas"
77

88
jest.mock("vscode", () => ({
99
Uri: {
1010
file: jest.fn((path) => ({ path })),
11+
joinPath: jest.fn((base, ...paths) => ({ path: `${base.path}/${paths.join("/")}` })),
1112
},
1213
ExtensionMode: {
1314
Development: 1,
1415
Production: 2,
1516
Test: 3,
1617
},
18+
FileSystemError: class extends Error {
19+
code = "FileNotFound"
20+
},
21+
window: {
22+
showInformationMessage: jest.fn(),
23+
},
1724
}))
1825

1926
describe("ContextProxy", () => {
@@ -54,6 +61,18 @@ describe("ContextProxy", () => {
5461
// Create proxy instance
5562
proxy = new ContextProxy(mockContext)
5663
await proxy.initialize()
64+
65+
// Ensure fs mock is properly initialized
66+
const mockFs = jest.requireMock("fs/promises")
67+
mockFs._setInitialMockData()
68+
69+
// Use it for vscode.workspace.fs operations
70+
jest.mock("vscode", () => ({
71+
...jest.requireMock("vscode"),
72+
workspace: {
73+
fs: mockFs,
74+
},
75+
}))
5776
})
5877

5978
describe("read-only pass-through properties", () => {
@@ -102,39 +121,34 @@ describe("ContextProxy", () => {
102121
expect(result).toBe("deepseek")
103122
})
104123

105-
it("should bypass cache for pass-through state keys", async () => {
106-
// Setup mock return value
107-
mockGlobalState.get.mockReturnValue("pass-through-value")
108-
109-
// Use a pass-through key (taskHistory)
110-
const result = proxy.getGlobalState("taskHistory")
111-
112-
// Should get value directly from original context
113-
expect(result).toBe("pass-through-value")
114-
expect(mockGlobalState.get).toHaveBeenCalledWith("taskHistory")
115-
})
116-
117-
it("should respect default values for pass-through state keys", async () => {
118-
// Setup mock to return undefined
119-
mockGlobalState.get.mockReturnValue(undefined)
120-
121-
// Use a pass-through key with default value
122-
const historyItems = [
124+
it("should read task history from file", async () => {
125+
const mockTasks: HistoryItem[] = [
123126
{
124127
id: "1",
125128
number: 1,
126-
ts: 1,
129+
ts: Date.now(),
127130
task: "test",
128-
tokensIn: 1,
129-
tokensOut: 1,
130-
totalCost: 1,
131+
tokensIn: 100,
132+
tokensOut: 50,
133+
totalCost: 0.001,
134+
cacheWrites: 0,
135+
cacheReads: 0,
131136
},
132137
]
133138

134-
const result = proxy.getGlobalState("taskHistory", historyItems)
139+
const result = proxy.getGlobalState("taskHistory")
140+
expect(result).toEqual(mockTasks)
141+
expect(vscode.workspace.fs.readFile).toHaveBeenCalled()
142+
})
143+
144+
it("should return empty array when task history file doesn't exist", async () => {
145+
const vscode = jest.requireMock("vscode")
146+
147+
const error = new vscode.FileSystemError("File not found")
148+
vscode.workspace.fs.readFile.mockRejectedValue(error)
135149

136-
// Should return default value when original context returns undefined
137-
expect(result).toBe(historyItems)
150+
const result = proxy.getGlobalState("taskHistory")
151+
expect(result).toEqual([])
138152
})
139153
})
140154

@@ -150,31 +164,37 @@ describe("ContextProxy", () => {
150164
expect(storedValue).toBe("deepseek")
151165
})
152166

153-
it("should bypass cache for pass-through state keys", async () => {
154-
const historyItems = [
167+
it("should write task history to file", async () => {
168+
const historyItems: HistoryItem[] = [
155169
{
156170
id: "1",
157171
number: 1,
158-
ts: 1,
172+
ts: Date.now(),
159173
task: "test",
160-
tokensIn: 1,
161-
tokensOut: 1,
162-
totalCost: 1,
174+
tokensIn: 100,
175+
tokensOut: 50,
176+
totalCost: 0.001,
177+
cacheWrites: 0,
178+
cacheReads: 0,
163179
},
164180
]
165181

166182
await proxy.updateGlobalState("taskHistory", historyItems)
167183

168-
// Should update original context
169-
expect(mockGlobalState.update).toHaveBeenCalledWith("taskHistory", historyItems)
184+
// Should write to file
185+
const expectedContent = JSON.stringify(historyItems[0]) + "\n"
186+
expect(vscode.workspace.fs.writeFile).toHaveBeenCalledWith(
187+
expect.objectContaining({ path: expect.stringContaining("taskHistory.jsonl") }),
188+
Buffer.from(expectedContent),
189+
)
170190

171-
// Setup mock for subsequent get
172-
mockGlobalState.get.mockReturnValue(historyItems)
191+
// Should update cache
192+
expect(proxy.getGlobalState("taskHistory")).toEqual(historyItems)
193+
})
173194

174-
// Should get fresh value from original context
175-
const storedValue = proxy.getGlobalState("taskHistory")
176-
expect(storedValue).toBe(historyItems)
177-
expect(mockGlobalState.get).toHaveBeenCalledWith("taskHistory")
195+
it("should handle undefined task history", async () => {
196+
await proxy.updateGlobalState("taskHistory", undefined)
197+
expect(vscode.workspace.fs.writeFile).not.toHaveBeenCalled()
178198
})
179199
})
180200

@@ -390,6 +410,13 @@ describe("ContextProxy", () => {
390410
expect(mockGlobalState.update).toHaveBeenCalledTimes(expectedUpdateCalls)
391411
})
392412

413+
it("should delete task history file when resetting state", async () => {
414+
await proxy.resetAllState()
415+
expect(vscode.workspace.fs.delete).toHaveBeenCalledWith(
416+
expect.objectContaining({ path: expect.stringContaining("taskHistory.jsonl") }),
417+
)
418+
})
419+
393420
it("should delete all secrets", async () => {
394421
// Setup initial secrets
395422
await proxy.storeSecret("apiKey", "test-api-key")

0 commit comments

Comments
 (0)