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
7 changes: 7 additions & 0 deletions src/core/task-persistence/TaskHistoryStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,12 @@ export class TaskHistoryStore {
*/
public readonly initialized: Promise<void>
private resolveInitialized!: () => void
private _isInitialized = false

/** True once the initial scan has finished, so reads no longer need to wait on `initialized`. */
get isInitialized(): boolean {
return this._isInitialized
}

/** Periodic reconciliation interval in milliseconds. */
private static readonly RECONCILE_INTERVAL_MS = 5 * 60 * 1000
Expand Down Expand Up @@ -140,6 +146,7 @@ export class TaskHistoryStore {
this.startPeriodicReconciliation()
} finally {
// Mark initialization as complete so callers awaiting `initialized` can proceed
this._isInitialized = true
this.resolveInitialized()
}
}
Expand Down
27 changes: 27 additions & 0 deletions src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,33 @@ describe("TaskHistoryStore", () => {
})

describe("initialize()", () => {
it("reports isInitialized only after the scan completes", async () => {
// Hold the reconciliation scan so the in-flight state can be observed.
let releaseScan!: () => void
const scanGate = new Promise<void>((resolve) => (releaseScan = resolve))
const reconcile = store["reconcile"].bind(store)
store["reconcile"] = async (...args) => {
await scanGate
return reconcile(...args)
}
const settled = vi.fn()
void store.initialized.then(settled)

expect(store.isInitialized).toBe(false)

const initializing = store.initialize()
await Promise.resolve()
expect(store.isInitialized).toBe(false)
expect(settled).not.toHaveBeenCalled()

releaseScan()
await initializing

expect(store.isInitialized).toBe(true)
await expect(store.initialized).resolves.toBeUndefined()
expect(settled).toHaveBeenCalledTimes(1)
})

it("initializes from empty state (no index, no task dirs)", async () => {
await store.initialize()
expect(store.getAll()).toEqual([])
Expand Down
7 changes: 5 additions & 2 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3102,8 +3102,11 @@ export class ClineProvider
}

async getStateToPostToWebview({ includeTaskHistory = true }: GetStateOptions = {}): Promise<ExtensionState> {
// Ensure the store is initialized before reading task history
await this.taskHistoryStore.initialized
// Only wait for the store when task history is requested, so a slow scan of a large
// history cannot keep the webview from receiving its first state.
if (includeTaskHistory) {
await this.taskHistoryStore.initialized
Comment thread
PierrunoYT marked this conversation as resolved.
Resolved
}

const {
apiConfiguration,
Expand Down
10 changes: 10 additions & 0 deletions src/core/webview/__tests__/ClineProvider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4478,6 +4478,16 @@ describe("ClineProvider", () => {
expect(mockContext.globalState.update).toHaveBeenCalledWith("currentApiConfigName", "test-config")
})

it("builds state without task history while the task history store is still loading", async () => {
await provider.resolveWebviewView(mockWebviewView)
// A scan that never finishes must not block a state that omits task history.
Object.defineProperty(provider.taskHistoryStore, "initialized", { value: new Promise<void>(() => {}) })

const state = await provider.getStateToPostToWebview({ includeTaskHistory: false })

expect(state.taskHistory).toEqual([])
})

it("saves current config when switching to mode without config", async () => {
await provider.resolveWebviewView(mockWebviewView)
const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0]
Expand Down
75 changes: 75 additions & 0 deletions src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { ContextProxy } from "../../config/ContextProxy"
import { Task } from "../../task/Task"
import { ProfileValidator } from "../../../shared/ProfileValidator"
import { ClineProvider } from "../ClineProvider"
import { webviewMessageHandler } from "../webviewMessageHandler"

// Mock setup
vi.mock("p-wait-for", () => ({
Expand Down Expand Up @@ -925,6 +926,80 @@ describe("ClineProvider Task History Synchronization", () => {
})
})

describe("state posts while the task history scan is pending", () => {
const postedStates = () =>
findCallsByType(mockPostMessage.mock.calls, "state").map(([message]) => message.state)

// Makes the already-loaded store look like it is still scanning until `release` is called.
function holdTaskHistoryScan() {
const store = provider.taskHistoryStore
let resolveGate!: () => void
const gate = new Promise<void>((resolve) => (resolveGate = resolve))
store["_isInitialized"] = false
Object.defineProperty(store, "initialized", { value: gate })

return () => {
store["_isInitialized"] = true
resolveGate()
}
}

it("launches without history, then posts the full state with history once the scan completes", async () => {
await provider.resolveWebviewView(mockWebviewView)
await provider.updateTaskHistory(createHistoryItem({ id: "launch-task", task: "Launch task" }), {
broadcast: false,
})
// The module-level WorkspaceTracker mock returns undefined; the launch handler chains `.catch` on it.
provider.workspaceTracker!.initializeFilePaths = vi.fn().mockResolvedValue(undefined)
const release = holdTaskHistoryScan()
mockPostMessage.mockClear()

await webviewMessageHandler(provider, { type: "webviewDidLaunch" })

expect(postedStates()).toHaveLength(1)
expect(postedStates()[0]).not.toHaveProperty("taskHistory")

release()
await vi.waitFor(() => expect(postedStates()).toHaveLength(2))

expect(postedStates()[1].taskHistory).toEqual(
expect.arrayContaining([expect.objectContaining({ id: "launch-task" })]),
)
})

it.each([
{ scanComplete: false, expectHistory: false },
{ scanComplete: true, expectHistory: true },
])(
"updateSettings posts state with history: $expectHistory when the scan is complete: $scanComplete",
async ({ scanComplete, expectHistory }) => {
await provider.resolveWebviewView(mockWebviewView)
await provider.updateTaskHistory(createHistoryItem({ id: "settings-task", task: "Settings task" }), {
broadcast: false,
})
if (!scanComplete) {
holdTaskHistoryScan()
}
mockPostMessage.mockClear()

await webviewMessageHandler(provider, {
type: "updateSettings",
updatedSettings: { soundEnabled: true },
})

expect(postedStates()).toHaveLength(1)
const state = postedStates()[0]
if (expectHistory) {
expect(state.taskHistory).toEqual(
expect.arrayContaining([expect.objectContaining({ id: "settings-task" })]),
)
} else {
expect(state).not.toHaveProperty("taskHistory")
}
},
)
})

describe("taskHistory write lock (mutex)", () => {
it("serializes concurrent updateTaskHistory calls so no entries are lost", async () => {
await provider.resolveWebviewView(mockWebviewView)
Expand Down
41 changes: 41 additions & 0 deletions src/core/webview/__tests__/webviewMessageHandler.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,8 @@ const mockClineProvider = {
},
log: vi.fn(),
postStateToWebview: vi.fn(),
postStateToWebviewWithoutTaskHistory: vi.fn(),
taskHistoryStore: { isInitialized: true, initialized: Promise.resolve() },
resolveWebviewThemeFixtureProbe: vi.fn(),
getCurrentTask: vi.fn(),
getTaskWithId: vi.fn(),
Expand Down Expand Up @@ -3053,3 +3055,42 @@ describe("webviewMessageHandler - renameTaskWithId", () => {
expect(vi.mocked(mockClineProvider.renameTaskWithId).mock.calls).toEqual(expectedCalls)
})
})

describe("webviewMessageHandler - webviewDidLaunch task history", () => {
function setupLaunch(taskHistoryStore: { isInitialized: boolean; initialized: Promise<void> }) {
vi.mocked(mockClineProvider.customModesManager.getCustomModes).mockResolvedValue([])
const launchProvider = mockClineProvider as unknown as {
getMcpHub: ReturnType<typeof vi.fn>
providerSettingsManager: { listConfig: ReturnType<typeof vi.fn> }
taskHistoryStore: typeof taskHistoryStore
}
launchProvider.getMcpHub = vi.fn().mockReturnValue(undefined)
launchProvider.providerSettingsManager = { listConfig: vi.fn().mockResolvedValue(undefined) }
launchProvider.taskHistoryStore = taskHistoryStore
vi.mocked(mockClineProvider.postStateToWebview).mockClear()
vi.mocked(mockClineProvider.postStateToWebviewWithoutTaskHistory).mockClear()
}

it("posts the full state right away once task history is loaded", async () => {
setupLaunch({ isInitialized: true, initialized: Promise.resolve() })

await webviewMessageHandler(mockClineProvider, { type: "webviewDidLaunch" })

expect(mockClineProvider.postStateToWebview).toHaveBeenCalledTimes(1)
expect(mockClineProvider.postStateToWebviewWithoutTaskHistory).not.toHaveBeenCalled()
})

it("renders without waiting for a slow task history load, then posts the full state", async () => {
let finishLoading!: () => void
const initialized = new Promise<void>((resolve) => (finishLoading = resolve))
setupLaunch({ isInitialized: false, initialized })

await webviewMessageHandler(mockClineProvider, { type: "webviewDidLaunch" })

expect(mockClineProvider.postStateToWebviewWithoutTaskHistory).toHaveBeenCalledTimes(1)
expect(mockClineProvider.postStateToWebview).not.toHaveBeenCalled()

finishLoading()
await vi.waitFor(() => expect(mockClineProvider.postStateToWebview).toHaveBeenCalledTimes(1))
Comment thread
PierrunoYT marked this conversation as resolved.
Resolved
})
})
19 changes: 17 additions & 2 deletions src/core/webview/webviewMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -597,7 +597,16 @@
const customModes = await provider.customModesManager.getCustomModes()
await updateGlobalState("customModes", customModes)

await provider.postStateToWebview()
if (provider.taskHistoryStore.isInitialized) {
await provider.postStateToWebview()
} else {
// A large task history can take a long time to scan. Render the UI now and
// send the full state, including history, once the scan completes.
await provider.postStateToWebviewWithoutTaskHistory()
void provider.taskHistoryStore.initialized
.then(() => provider.postStateToWebview())
.catch((err) => provider.log(`Task history state update error: ${err}`))

Check warning on line 608 in src/core/webview/webviewMessageHandler.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/webview/webviewMessageHandler.ts:608: 2 mutation test gaps; example: NoCoverage StringLiteral mutant (replacement: ``). See the job summary for the complete list and resolution guidance.
}
void provider.workspaceTracker
?.initializeFilePaths()
.catch((err) => provider.log(`Workspace initialization error: ${err}`)) // Don't await.
Expand Down Expand Up @@ -875,7 +884,13 @@
await provider.contextProxy.setValue(key as keyof RooCodeSettings, newValue)
}

await provider.postStateToWebview()
// While the task history scan runs, a full post would wait on it and stall the
// settings response. The full state follows once the scan completes (see webviewDidLaunch).
if (provider.taskHistoryStore.isInitialized) {
await provider.postStateToWebview()
} else {
await provider.postStateToWebviewWithoutTaskHistory()
}
}

break
Expand Down
Loading