Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/cli-async-condense-handling.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---

Add async condense context handling with completion and error feedback in auto mode
42 changes: 34 additions & 8 deletions cli/src/commands/__tests__/condense.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof vi.fn>).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<typeof vi.fn>).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()
})
Expand All @@ -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<typeof vi.fn>).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<typeof vi.fn>).mock.calls[1][0]
expect(errorMessage.type).toBe("error")
expect(errorMessage.content).toContain("Condensation timed out")
})
})
})
2 changes: 2 additions & 0 deletions cli/src/commands/__tests__/helpers/mockContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ export function createMockContext(overrides: Partial<CommandContext> = {}): Comm
updateModelListFilters: vi.fn(),
changeModelListPage: vi.fn(),
resetModelListState: vi.fn(),
// Condense context
condenseAndWait: vi.fn().mockResolvedValue(undefined),
}

return {
Expand Down
25 changes: 19 additions & 6 deletions cli/src/commands/condense.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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(),
})
}
},
}
2 changes: 2 additions & 0 deletions cli/src/commands/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ export interface CommandContext {
updateModelListFilters: (filters: Partial<ModelListFilters>) => void
changeModelListPage: (pageIndex: number) => void
resetModelListState: () => void
// Condense context
condenseAndWait: (taskId: string) => Promise<void>
}

export type CommandHandler = (context: CommandContext) => Promise<void> | void
Expand Down
193 changes: 193 additions & 0 deletions cli/src/state/atoms/__tests__/condense.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createStore>

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)
})
})
})
Loading