diff --git a/.changeset/cli-async-condense-handling.md b/.changeset/cli-async-condense-handling.md new file mode 100644 index 00000000000..cda7a19d0c6 --- /dev/null +++ b/.changeset/cli-async-condense-handling.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Add async condense context handling with completion and error feedback in auto mode diff --git a/cli/src/commands/__tests__/condense.test.ts b/cli/src/commands/__tests__/condense.test.ts index 58e8df1caf5..bb23b0960f1 100644 --- a/cli/src/commands/__tests__/condense.test.ts +++ b/cli/src/commands/__tests__/condense.test.ts @@ -59,25 +59,32 @@ describe("condenseCommand", () => { }) describe("handler", () => { - it("should send condenseTaskContextRequest webview message", async () => { + it("should call condenseAndWait with task ID", async () => { await condenseCommand.handler(mockContext) - expect(mockContext.sendWebviewMessage).toHaveBeenCalledTimes(1) - expect(mockContext.sendWebviewMessage).toHaveBeenCalledWith({ - type: "condenseTaskContextRequest", - text: "test-task-123", - }) + expect(mockContext.condenseAndWait).toHaveBeenCalledTimes(1) + expect(mockContext.condenseAndWait).toHaveBeenCalledWith("test-task-123") }) it("should add system message before condensing", async () => { await condenseCommand.handler(mockContext) - expect(mockContext.addMessage).toHaveBeenCalledTimes(1) + // First call is the "Condensing..." message const addedMessage = (mockContext.addMessage as ReturnType).mock.calls[0][0] expect(addedMessage.type).toBe("system") expect(addedMessage.content).toContain("Condensing") }) + it("should add completion message after successful condensation", async () => { + await condenseCommand.handler(mockContext) + + // Should have two messages: start and complete + expect(mockContext.addMessage).toHaveBeenCalledTimes(2) + const completionMessage = (mockContext.addMessage as ReturnType).mock.calls[1][0] + expect(completionMessage.type).toBe("system") + expect(completionMessage.content).toContain("complete") + }) + it("should execute without errors", async () => { await expect(condenseCommand.handler(mockContext)).resolves.not.toThrow() }) @@ -103,11 +110,30 @@ describe("condenseCommand", () => { await condenseCommand.handler(emptyContext) - expect(emptyContext.sendWebviewMessage).not.toHaveBeenCalled() + expect(emptyContext.condenseAndWait).not.toHaveBeenCalled() expect(emptyContext.addMessage).toHaveBeenCalledTimes(1) const addedMessage = (emptyContext.addMessage as ReturnType).mock.calls[0][0] expect(addedMessage.type).toBe("error") expect(addedMessage.content).toContain("No active task") }) + + it("should show error message when condensation fails", async () => { + const errorContext = createMockContext({ + input: "/condense", + currentTask: { + id: "test-task-123", + ts: Date.now(), + task: "Test task", + }, + condenseAndWait: vi.fn().mockRejectedValue(new Error("Condensation timed out")), + }) + + await condenseCommand.handler(errorContext) + + expect(errorContext.addMessage).toHaveBeenCalledTimes(2) + const errorMessage = (errorContext.addMessage as ReturnType).mock.calls[1][0] + expect(errorMessage.type).toBe("error") + expect(errorMessage.content).toContain("Condensation timed out") + }) }) }) diff --git a/cli/src/commands/__tests__/helpers/mockContext.ts b/cli/src/commands/__tests__/helpers/mockContext.ts index ac2ca939500..be9d25a76f1 100644 --- a/cli/src/commands/__tests__/helpers/mockContext.ts +++ b/cli/src/commands/__tests__/helpers/mockContext.ts @@ -81,6 +81,8 @@ export function createMockContext(overrides: Partial = {}): Comm updateModelListFilters: vi.fn(), changeModelListPage: vi.fn(), resetModelListState: vi.fn(), + // Condense context + condenseAndWait: vi.fn().mockResolvedValue(undefined), } return { diff --git a/cli/src/commands/condense.ts b/cli/src/commands/condense.ts index 86dd15c0690..59832edb349 100644 --- a/cli/src/commands/condense.ts +++ b/cli/src/commands/condense.ts @@ -13,7 +13,7 @@ export const condenseCommand: Command = { category: "chat", priority: 6, handler: async (context) => { - const { sendWebviewMessage, addMessage, currentTask } = context + const { condenseAndWait, addMessage, currentTask } = context const now = Date.now() @@ -34,10 +34,23 @@ export const condenseCommand: Command = { ts: now, }) - // Send request to extension with the task ID - await sendWebviewMessage({ - type: "condenseTaskContextRequest", - text: currentTask.id, - }) + try { + // Send request to extension and wait for completion + await condenseAndWait(currentTask.id) + + addMessage({ + id: `condense-complete-${Date.now()}`, + type: "system", + content: "Context condensation complete.", + ts: Date.now(), + }) + } catch (error) { + addMessage({ + id: `condense-error-${Date.now()}`, + type: "error", + content: `Context condensation failed: ${error instanceof Error ? error.message : String(error)}`, + ts: Date.now(), + }) + } }, } diff --git a/cli/src/commands/core/types.ts b/cli/src/commands/core/types.ts index 6da920bb58f..bc379726748 100644 --- a/cli/src/commands/core/types.ts +++ b/cli/src/commands/core/types.ts @@ -86,6 +86,8 @@ export interface CommandContext { updateModelListFilters: (filters: Partial) => void changeModelListPage: (pageIndex: number) => void resetModelListState: () => void + // Condense context + condenseAndWait: (taskId: string) => Promise } export type CommandHandler = (context: CommandContext) => Promise | void diff --git a/cli/src/state/atoms/__tests__/condense.test.ts b/cli/src/state/atoms/__tests__/condense.test.ts new file mode 100644 index 00000000000..41cf84452b3 --- /dev/null +++ b/cli/src/state/atoms/__tests__/condense.test.ts @@ -0,0 +1,193 @@ +/** + * Tests for condense context state management atoms + */ + +import { describe, it, expect, beforeEach, vi } from "vitest" +import { createStore } from "jotai" +import { + condensePendingRequestsAtom, + addPendingCondenseRequestAtom, + removePendingCondenseRequestAtom, + resolveCondenseRequestAtom, + CONDENSE_REQUEST_TIMEOUT_MS, +} from "../condense.js" + +describe("condense atoms", () => { + let store: ReturnType + + beforeEach(() => { + store = createStore() + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + describe("condensePendingRequestsAtom", () => { + it("should initialize with empty map", () => { + const pendingRequests = store.get(condensePendingRequestsAtom) + expect(pendingRequests.size).toBe(0) + }) + }) + + describe("addPendingCondenseRequestAtom", () => { + it("should add a pending request", () => { + const resolve = vi.fn() + const reject = vi.fn() + const timeout = setTimeout(() => {}, CONDENSE_REQUEST_TIMEOUT_MS) + + store.set(addPendingCondenseRequestAtom, { + taskId: "task-123", + resolve, + reject, + timeout, + }) + + const pendingRequests = store.get(condensePendingRequestsAtom) + expect(pendingRequests.size).toBe(1) + expect(pendingRequests.has("task-123")).toBe(true) + + clearTimeout(timeout) + }) + + it("should store resolve and reject functions", () => { + const resolve = vi.fn() + const reject = vi.fn() + const timeout = setTimeout(() => {}, CONDENSE_REQUEST_TIMEOUT_MS) + + store.set(addPendingCondenseRequestAtom, { + taskId: "task-456", + resolve, + reject, + timeout, + }) + + const pendingRequests = store.get(condensePendingRequestsAtom) + const request = pendingRequests.get("task-456") + expect(request?.resolve).toBe(resolve) + expect(request?.reject).toBe(reject) + + clearTimeout(timeout) + }) + }) + + describe("removePendingCondenseRequestAtom", () => { + it("should remove a pending request", () => { + const resolve = vi.fn() + const reject = vi.fn() + const timeout = setTimeout(() => {}, CONDENSE_REQUEST_TIMEOUT_MS) + + store.set(addPendingCondenseRequestAtom, { + taskId: "task-789", + resolve, + reject, + timeout, + }) + + expect(store.get(condensePendingRequestsAtom).size).toBe(1) + + store.set(removePendingCondenseRequestAtom, "task-789") + + expect(store.get(condensePendingRequestsAtom).size).toBe(0) + }) + + it("should clear timeout when removing request", () => { + const resolve = vi.fn() + const reject = vi.fn() + const clearTimeoutSpy = vi.spyOn(global, "clearTimeout") + const timeout = setTimeout(() => {}, CONDENSE_REQUEST_TIMEOUT_MS) + + store.set(addPendingCondenseRequestAtom, { + taskId: "task-timeout", + resolve, + reject, + timeout, + }) + + store.set(removePendingCondenseRequestAtom, "task-timeout") + + expect(clearTimeoutSpy).toHaveBeenCalled() + }) + + it("should do nothing for non-existent request", () => { + // Should not throw + store.set(removePendingCondenseRequestAtom, "non-existent") + expect(store.get(condensePendingRequestsAtom).size).toBe(0) + }) + }) + + describe("resolveCondenseRequestAtom", () => { + it("should resolve pending request successfully", () => { + const resolve = vi.fn() + const reject = vi.fn() + const timeout = setTimeout(() => {}, CONDENSE_REQUEST_TIMEOUT_MS) + + store.set(addPendingCondenseRequestAtom, { + taskId: "task-success", + resolve, + reject, + timeout, + }) + + store.set(resolveCondenseRequestAtom, { taskId: "task-success" }) + + expect(resolve).toHaveBeenCalledTimes(1) + expect(reject).not.toHaveBeenCalled() + expect(store.get(condensePendingRequestsAtom).size).toBe(0) + }) + + it("should reject pending request with error", () => { + const resolve = vi.fn() + const reject = vi.fn() + const timeout = setTimeout(() => {}, CONDENSE_REQUEST_TIMEOUT_MS) + + store.set(addPendingCondenseRequestAtom, { + taskId: "task-error", + resolve, + reject, + timeout, + }) + + store.set(resolveCondenseRequestAtom, { + taskId: "task-error", + error: "Condensation failed", + }) + + expect(reject).toHaveBeenCalledTimes(1) + expect(reject).toHaveBeenCalledWith(new Error("Condensation failed")) + expect(resolve).not.toHaveBeenCalled() + expect(store.get(condensePendingRequestsAtom).size).toBe(0) + }) + + it("should clear timeout when resolving", () => { + const resolve = vi.fn() + const reject = vi.fn() + const clearTimeoutSpy = vi.spyOn(global, "clearTimeout") + const timeout = setTimeout(() => {}, CONDENSE_REQUEST_TIMEOUT_MS) + + store.set(addPendingCondenseRequestAtom, { + taskId: "task-clear-timeout", + resolve, + reject, + timeout, + }) + + store.set(resolveCondenseRequestAtom, { taskId: "task-clear-timeout" }) + + expect(clearTimeoutSpy).toHaveBeenCalled() + }) + + it("should do nothing for non-existent request", () => { + // Should not throw + store.set(resolveCondenseRequestAtom, { taskId: "non-existent" }) + expect(store.get(condensePendingRequestsAtom).size).toBe(0) + }) + }) + + describe("CONDENSE_REQUEST_TIMEOUT_MS", () => { + it("should be 60 seconds", () => { + expect(CONDENSE_REQUEST_TIMEOUT_MS).toBe(60000) + }) + }) +}) diff --git a/cli/src/state/atoms/condense.ts b/cli/src/state/atoms/condense.ts new file mode 100644 index 00000000000..58994c683ec --- /dev/null +++ b/cli/src/state/atoms/condense.ts @@ -0,0 +1,95 @@ +/** + * Condense context state management atoms + * Handles pending condense requests and their resolution + */ + +import { atom } from "jotai" +import { logs } from "../../services/logs.js" + +/** + * Pending condense request resolver + */ +interface PendingCondenseRequest { + taskId: string + resolve: () => void + reject: (error: Error) => void + timeout: NodeJS.Timeout +} + +/** + * Map of pending condense requests waiting for responses + * Key is the taskId + */ +export const condensePendingRequestsAtom = atom>(new Map()) + +/** + * Action atom to add a pending condense request + */ +export const addPendingCondenseRequestAtom = atom( + null, + ( + get, + set, + request: { + taskId: string + resolve: () => void + reject: (error: Error) => void + timeout: NodeJS.Timeout + }, + ) => { + const pendingRequests = get(condensePendingRequestsAtom) + const newPendingRequests = new Map(pendingRequests) + newPendingRequests.set(request.taskId, request) + set(condensePendingRequestsAtom, newPendingRequests) + logs.debug(`Added pending condense request for task: ${request.taskId}`, "condense") + }, +) + +/** + * Action atom to remove a pending condense request + */ +export const removePendingCondenseRequestAtom = atom(null, (get, set, taskId: string) => { + const pendingRequests = get(condensePendingRequestsAtom) + const request = pendingRequests.get(taskId) + if (request) { + clearTimeout(request.timeout) + const newPendingRequests = new Map(pendingRequests) + newPendingRequests.delete(taskId) + set(condensePendingRequestsAtom, newPendingRequests) + logs.debug(`Removed pending condense request for task: ${taskId}`, "condense") + } +}) + +/** + * Action atom to resolve a pending condense request + */ +export const resolveCondenseRequestAtom = atom( + null, + (get, set, { taskId, error }: { taskId: string; error?: string }) => { + const pendingRequests = get(condensePendingRequestsAtom) + const request = pendingRequests.get(taskId) + + if (request) { + clearTimeout(request.timeout) + if (error) { + logs.error(`Condense request failed for task ${taskId}: ${error}`, "condense") + request.reject(new Error(error)) + } else { + logs.info(`Condense request completed for task: ${taskId}`, "condense") + request.resolve() + } + // Remove from pending requests + const newPendingRequests = new Map(pendingRequests) + newPendingRequests.delete(taskId) + set(condensePendingRequestsAtom, newPendingRequests) + } else { + logs.debug(`No pending condense request found for task: ${taskId}`, "condense") + } + }, +) + +/** + * Default timeout for condense requests (60 seconds) + * Condensation can take a while depending on conversation size + */ +export const CONDENSE_REQUEST_TIMEOUT_MS = 60000 diff --git a/cli/src/state/atoms/effects.ts b/cli/src/state/atoms/effects.ts index 879dc3e23ce..32320652dbf 100644 --- a/cli/src/state/atoms/effects.ts +++ b/cli/src/state/atoms/effects.ts @@ -32,6 +32,7 @@ import { taskHistoryErrorAtom, resolveTaskHistoryRequestAtom, } from "./taskHistory.js" +import { resolveCondenseRequestAtom } from "./condense.js" import { validateModelOnRouterModelsUpdateAtom } from "./modelValidation.js" import { validateModeOnCustomModesUpdateAtom } from "./modeValidation.js" import { logs } from "../../services/logs.js" @@ -398,6 +399,10 @@ export const messageHandlerEffectAtom = atom(null, (get, set, message: Extension case "condenseTaskContextResponse": { const taskId = message.text logs.info(`Context condensation completed for task: ${taskId || "current task"}`, "effects") + // Resolve any pending condense request for this task + if (taskId) { + set(resolveCondenseRequestAtom, { taskId }) + } break } diff --git a/cli/src/state/hooks/__tests__/useCondense.test.ts b/cli/src/state/hooks/__tests__/useCondense.test.ts new file mode 100644 index 00000000000..545975d957d --- /dev/null +++ b/cli/src/state/hooks/__tests__/useCondense.test.ts @@ -0,0 +1,301 @@ +/** + * Tests for useCondense hook + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest" +import { createStore } from "jotai" +import { + condensePendingRequestsAtom, + addPendingCondenseRequestAtom, + removePendingCondenseRequestAtom, + resolveCondenseRequestAtom, + CONDENSE_REQUEST_TIMEOUT_MS, +} from "../../atoms/condense.js" + +vi.mock("../../../services/logs.js", () => ({ + logs: { + info: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})) + +describe("useCondense", () => { + let store: ReturnType + + beforeEach(() => { + store = createStore() + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + vi.clearAllMocks() + }) + + describe("condenseAndWait behavior", () => { + describe("timeout handling", () => { + it("should reject with timeout error after CONDENSE_REQUEST_TIMEOUT_MS", async () => { + const taskId = "test-task-timeout" + const resolve = vi.fn() + const reject = vi.fn() + + // Create a timeout as the hook would + const timeout = setTimeout(() => { + store.set(removePendingCondenseRequestAtom, taskId) + reject(new Error(`Condense request timed out after ${CONDENSE_REQUEST_TIMEOUT_MS / 1000} seconds`)) + }, CONDENSE_REQUEST_TIMEOUT_MS) + + // Add the pending request + store.set(addPendingCondenseRequestAtom, { + taskId, + resolve, + reject, + timeout, + }) + + // Verify request is pending + expect(store.get(condensePendingRequestsAtom).size).toBe(1) + expect(store.get(condensePendingRequestsAtom).has(taskId)).toBe(true) + + // Fast-forward time to trigger timeout + vi.advanceTimersByTime(CONDENSE_REQUEST_TIMEOUT_MS) + + // Verify timeout was triggered + expect(reject).toHaveBeenCalledTimes(1) + expect(reject).toHaveBeenCalledWith( + new Error(`Condense request timed out after ${CONDENSE_REQUEST_TIMEOUT_MS / 1000} seconds`), + ) + expect(resolve).not.toHaveBeenCalled() + + // Verify request was removed + expect(store.get(condensePendingRequestsAtom).size).toBe(0) + }) + + it("should not timeout if resolved before timeout", async () => { + const taskId = "test-task-no-timeout" + const resolve = vi.fn() + const reject = vi.fn() + + // Create a timeout as the hook would + const timeout = setTimeout(() => { + store.set(removePendingCondenseRequestAtom, taskId) + reject(new Error(`Condense request timed out after ${CONDENSE_REQUEST_TIMEOUT_MS / 1000} seconds`)) + }, CONDENSE_REQUEST_TIMEOUT_MS) + + // Add the pending request + store.set(addPendingCondenseRequestAtom, { + taskId, + resolve, + reject, + timeout, + }) + + // Advance time but not enough to trigger timeout + vi.advanceTimersByTime(CONDENSE_REQUEST_TIMEOUT_MS / 2) + + // Resolve the request before timeout + store.set(resolveCondenseRequestAtom, { taskId }) + + // Verify resolve was called + expect(resolve).toHaveBeenCalledTimes(1) + expect(reject).not.toHaveBeenCalled() + + // Advance past the original timeout time + vi.advanceTimersByTime(CONDENSE_REQUEST_TIMEOUT_MS) + + // Verify reject was NOT called (timeout was cleared) + expect(reject).not.toHaveBeenCalled() + }) + }) + + describe("success resolution", () => { + it("should resolve successfully when response is received", () => { + const taskId = "test-task-success" + const resolve = vi.fn() + const reject = vi.fn() + const timeout = setTimeout(() => {}, CONDENSE_REQUEST_TIMEOUT_MS) + + // Add pending request + store.set(addPendingCondenseRequestAtom, { + taskId, + resolve, + reject, + timeout, + }) + + // Simulate receiving the response + store.set(resolveCondenseRequestAtom, { taskId }) + + // Verify correct callback was invoked + expect(resolve).toHaveBeenCalledTimes(1) + expect(reject).not.toHaveBeenCalled() + + // Verify request was cleaned up + expect(store.get(condensePendingRequestsAtom).size).toBe(0) + }) + + it("should handle multiple concurrent requests independently", () => { + const taskId1 = "task-1" + const taskId2 = "task-2" + const resolve1 = vi.fn() + const resolve2 = vi.fn() + const reject1 = vi.fn() + const reject2 = vi.fn() + const timeout1 = setTimeout(() => {}, CONDENSE_REQUEST_TIMEOUT_MS) + const timeout2 = setTimeout(() => {}, CONDENSE_REQUEST_TIMEOUT_MS) + + // Add two pending requests + store.set(addPendingCondenseRequestAtom, { + taskId: taskId1, + resolve: resolve1, + reject: reject1, + timeout: timeout1, + }) + store.set(addPendingCondenseRequestAtom, { + taskId: taskId2, + resolve: resolve2, + reject: reject2, + timeout: timeout2, + }) + + expect(store.get(condensePendingRequestsAtom).size).toBe(2) + + // Resolve only the first one + store.set(resolveCondenseRequestAtom, { taskId: taskId1 }) + + expect(resolve1).toHaveBeenCalledTimes(1) + expect(resolve2).not.toHaveBeenCalled() + expect(store.get(condensePendingRequestsAtom).size).toBe(1) + expect(store.get(condensePendingRequestsAtom).has(taskId2)).toBe(true) + + // Now resolve the second + store.set(resolveCondenseRequestAtom, { taskId: taskId2 }) + + expect(resolve2).toHaveBeenCalledTimes(1) + expect(store.get(condensePendingRequestsAtom).size).toBe(0) + + clearTimeout(timeout1) + clearTimeout(timeout2) + }) + }) + + describe("error handling", () => { + it("should reject when error is returned in response", () => { + const taskId = "test-task-error" + const resolve = vi.fn() + const reject = vi.fn() + const timeout = setTimeout(() => {}, CONDENSE_REQUEST_TIMEOUT_MS) + + // Add pending request + store.set(addPendingCondenseRequestAtom, { + taskId, + resolve, + reject, + timeout, + }) + + // Simulate receiving an error response + store.set(resolveCondenseRequestAtom, { + taskId, + error: "Condensation failed due to insufficient context", + }) + + // Verify reject was called with correct error + expect(reject).toHaveBeenCalledTimes(1) + expect(reject).toHaveBeenCalledWith(new Error("Condensation failed due to insufficient context")) + expect(resolve).not.toHaveBeenCalled() + + // Verify request was cleaned up + expect(store.get(condensePendingRequestsAtom).size).toBe(0) + }) + + it("should handle sendMessage failure by removing pending request", () => { + const taskId = "test-task-send-fail" + const resolve = vi.fn() + const reject = vi.fn() + + // Simulate the flow: add request, then sendMessage fails + const timeout = setTimeout(() => {}, CONDENSE_REQUEST_TIMEOUT_MS) + + store.set(addPendingCondenseRequestAtom, { + taskId, + resolve, + reject, + timeout, + }) + + expect(store.get(condensePendingRequestsAtom).size).toBe(1) + + // Simulate sendMessage catch block behavior + store.set(removePendingCondenseRequestAtom, taskId) + + // Request should be removed + expect(store.get(condensePendingRequestsAtom).size).toBe(0) + }) + }) + + describe("cleanup behavior", () => { + it("should clear timeout when request is removed", () => { + const clearTimeoutSpy = vi.spyOn(global, "clearTimeout") + const taskId = "test-task-cleanup" + const resolve = vi.fn() + const reject = vi.fn() + const timeout = setTimeout(() => {}, CONDENSE_REQUEST_TIMEOUT_MS) + + store.set(addPendingCondenseRequestAtom, { + taskId, + resolve, + reject, + timeout, + }) + + store.set(removePendingCondenseRequestAtom, taskId) + + expect(clearTimeoutSpy).toHaveBeenCalled() + }) + + it("should clear timeout when request is resolved", () => { + const clearTimeoutSpy = vi.spyOn(global, "clearTimeout") + const taskId = "test-task-cleanup-resolve" + const resolve = vi.fn() + const reject = vi.fn() + const timeout = setTimeout(() => {}, CONDENSE_REQUEST_TIMEOUT_MS) + + store.set(addPendingCondenseRequestAtom, { + taskId, + resolve, + reject, + timeout, + }) + + store.set(resolveCondenseRequestAtom, { taskId }) + + expect(clearTimeoutSpy).toHaveBeenCalled() + }) + + it("should not throw when removing non-existent request", () => { + // Should not throw + expect(() => { + store.set(removePendingCondenseRequestAtom, "non-existent-task") + }).not.toThrow() + + expect(store.get(condensePendingRequestsAtom).size).toBe(0) + }) + + it("should not call resolve/reject when resolving non-existent request", () => { + // Should not throw and not call any callbacks + expect(() => { + store.set(resolveCondenseRequestAtom, { taskId: "non-existent-task" }) + }).not.toThrow() + }) + }) + }) + + describe("constants", () => { + it("CONDENSE_REQUEST_TIMEOUT_MS should be 60 seconds", () => { + expect(CONDENSE_REQUEST_TIMEOUT_MS).toBe(60000) + }) + }) +}) diff --git a/cli/src/state/hooks/useCommandContext.ts b/cli/src/state/hooks/useCommandContext.ts index 403ae99eff0..8ecb053fc09 100644 --- a/cli/src/state/hooks/useCommandContext.ts +++ b/cli/src/state/hooks/useCommandContext.ts @@ -49,6 +49,7 @@ import { } from "../atoms/modelList.js" import { useWebviewMessage } from "./useWebviewMessage.js" import { useTaskHistory } from "./useTaskHistory.js" +import { useCondense } from "./useCondense.js" import { getModelIdKey } from "../../constants/providers/models.js" const TERMINAL_CLEAR_DELAY_MS = 500 @@ -141,6 +142,9 @@ export function useCommandContext(): UseCommandContextReturn { const changeModelListPage = useSetAtom(changeModelListPageAtom) const resetModelListState = useSetAtom(resetModelListStateAtom) + // Get condense function + const { condenseAndWait } = useCondense() + // Create the factory function const createContext = useCallback( ( @@ -245,6 +249,8 @@ export function useCommandContext(): UseCommandContextReturn { updateModelListFilters, changeModelListPage, resetModelListState, + // Condense context + condenseAndWait, } }, [ @@ -287,6 +293,7 @@ export function useCommandContext(): UseCommandContextReturn { updateModelListFilters, changeModelListPage, resetModelListState, + condenseAndWait, ], ) diff --git a/cli/src/state/hooks/useCondense.ts b/cli/src/state/hooks/useCondense.ts new file mode 100644 index 00000000000..c81a20139b5 --- /dev/null +++ b/cli/src/state/hooks/useCondense.ts @@ -0,0 +1,90 @@ +/** + * Hook for condense operations with async response handling + */ + +import { useSetAtom } from "jotai" +import { useCallback } from "react" +import { + addPendingCondenseRequestAtom, + removePendingCondenseRequestAtom, + CONDENSE_REQUEST_TIMEOUT_MS, +} from "../atoms/condense.js" +import { useWebviewMessage } from "./useWebviewMessage.js" +import { logs } from "../../services/logs.js" + +/** + * Return type for useCondense hook + */ +export interface UseCondenseReturn { + /** + * Request context condensation and wait for completion + * @param taskId The task ID to condense + * @returns Promise that resolves when condensation is complete + * @throws Error if condensation fails or times out + */ + condenseAndWait: (taskId: string) => Promise +} + +/** + * Hook that provides condense functionality with async response handling + * + * This hook encapsulates the logic for sending a condense request and + * waiting for the response from the extension. + * + * @example + * ```tsx + * function MyComponent() { + * const { condenseAndWait } = useCondense() + * + * const handleCondense = async (taskId: string) => { + * try { + * await condenseAndWait(taskId) + * console.log('Condensation complete') + * } catch (error) { + * console.error('Condensation failed:', error) + * } + * } + * } + * ``` + */ +export function useCondense(): UseCondenseReturn { + const addPendingRequest = useSetAtom(addPendingCondenseRequestAtom) + const removePendingRequest = useSetAtom(removePendingCondenseRequestAtom) + const { sendMessage } = useWebviewMessage() + + const condenseAndWait = useCallback( + async (taskId: string): Promise => { + return new Promise((resolve, reject) => { + // Set up timeout + const timeout = setTimeout(() => { + logs.error(`Condense request timed out for task: ${taskId}`, "useCondense") + removePendingRequest(taskId) + reject(new Error(`Condense request timed out after ${CONDENSE_REQUEST_TIMEOUT_MS / 1000} seconds`)) + }, CONDENSE_REQUEST_TIMEOUT_MS) + + // Add pending request + addPendingRequest({ + taskId, + resolve, + reject, + timeout, + }) + + // Send the condense request + sendMessage({ + type: "condenseTaskContextRequest", + text: taskId, + }).catch((error) => { + logs.error(`Failed to send condense request: ${error}`, "useCondense") + removePendingRequest(taskId) + reject(error) + }) + + logs.info(`Condense request sent for task: ${taskId}, waiting for response...`, "useCondense") + }) + }, + [addPendingRequest, removePendingRequest, sendMessage], + ) + + return { condenseAndWait } +}