Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 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
6 changes: 6 additions & 0 deletions .changeset/common-buckets-tickle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@kilocode/cli": patch
"kilo-code": patch
---

refactor session manager to better handle asynchronicity of file save events
2 changes: 0 additions & 2 deletions cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,8 +323,6 @@ export class CLI {
try {
logs.info("Disposing Kilo Code CLI...", "CLI")

await this.sessionService?.destroy()
Copy link
Contributor

@pandemicsyn pandemicsyn Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not blocker, not even sure its really an issue, but do we need to delay exiting on SIGINT to give the background sync stuff a chance to trigger?

Wondering if we'd have stuff in the session manager queue that we're losing.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we do, I'll add back the destroy method


// Signal codes take precedence over CI logic
if (signal === "SIGINT") {
exitCode = 130
Expand Down
48 changes: 6 additions & 42 deletions cli/src/commands/__tests__/new.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,28 +6,16 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
import { newCommand } from "../new.js"
import type { CommandContext } from "../core/types.js"
import { createMockContext } from "./helpers/mockContext.js"
import { SessionManager } from "../../../../src/shared/kilocode/cli-sessions/core/SessionManager.js"

describe("/new command", () => {
let mockContext: CommandContext
let mockSessionManager: Partial<SessionManager> & { destroy: ReturnType<typeof vi.fn> }

beforeEach(() => {
// Mock process.stdout.write to capture terminal clearing
vi.spyOn(process.stdout, "write").mockImplementation(() => true)

mockContext = createMockContext({
input: "/new",
})

// Mock SessionManager
mockSessionManager = {
destroy: vi.fn().mockResolvedValue(undefined),
sessionId: "test-session-id",
}

// Mock SessionManager.init to return our mock
vi.spyOn(SessionManager, "init").mockReturnValue(mockSessionManager as unknown as SessionManager)
})

afterEach(() => {
Expand Down Expand Up @@ -71,27 +59,6 @@ describe("/new command", () => {
expect(mockContext.clearTask).toHaveBeenCalledTimes(1)
})

it("should clear the session", async () => {
await newCommand.handler(mockContext)

expect(SessionManager.init).toHaveBeenCalled()
expect(mockSessionManager.destroy).toHaveBeenCalledTimes(1)
})

it("should continue execution even if session clearing fails", async () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
mockSessionManager.destroy.mockRejectedValue(new Error("Session error"))

await newCommand.handler(mockContext)

// Should still clear task and replace messages despite session error
expect(mockContext.clearTask).toHaveBeenCalled()
expect(mockContext.replaceMessages).toHaveBeenCalled()
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to clear session:", expect.any(Error))

consoleErrorSpy.mockRestore()
})

it("should replace CLI messages with welcome message", async () => {
await newCommand.handler(mockContext)

Expand Down Expand Up @@ -121,18 +88,17 @@ describe("/new command", () => {
callOrder.push("clearTask")
})

mockSessionManager.destroy = vi.fn().mockImplementation(async () => {
callOrder.push("sessionDestroy")
})

mockContext.replaceMessages = vi.fn().mockImplementation(() => {
callOrder.push("replaceMessages")
})

mockContext.refreshTerminal = vi.fn().mockImplementation(async () => {
callOrder.push("refreshTerminal")
})

await newCommand.handler(mockContext)

// Operations should execute in this order
expect(callOrder).toEqual(["clearTask", "sessionDestroy", "replaceMessages"])
expect(callOrder).toEqual(["clearTask", "replaceMessages", "refreshTerminal"])
})

it("should handle clearTask errors gracefully", async () => {
Expand Down Expand Up @@ -162,12 +128,10 @@ describe("/new command", () => {
it("should create a complete fresh start experience", async () => {
await newCommand.handler(mockContext)

// Verify all cleanup operations were performed
expect(mockContext.clearTask).toHaveBeenCalled()
expect(mockSessionManager.destroy).toHaveBeenCalled()
expect(mockContext.replaceMessages).toHaveBeenCalled()
expect(mockContext.refreshTerminal).toHaveBeenCalled()

// Verify welcome message was replaced
const replacedMessages = (mockContext.replaceMessages as ReturnType<typeof vi.fn>).mock.calls[0][0]
expect(replacedMessages).toHaveLength(1)
expect(replacedMessages[0].type).toBe("welcome")
Expand Down
10 changes: 0 additions & 10 deletions cli/src/commands/new.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
* /new command - Start a new task with a clean slate
*/

import { SessionManager } from "../../../src/shared/kilocode/cli-sessions/core/SessionManager.js"
import { createWelcomeMessage } from "../ui/utils/welcomeMessage.js"
import type { Command } from "./core/types.js"

Expand All @@ -20,15 +19,6 @@ export const newCommand: Command = {
// Clear the extension task state (this also clears extension messages)
await clearTask()

// Clear the session to start fresh
try {
const sessionService = SessionManager.init()
await sessionService.destroy()
} catch (error) {
// Log error but don't block the command - session might not exist yet
console.error("Failed to clear session:", error)
}

// Replace CLI message history with fresh welcome message
// This will increment the reset counter, forcing Static component to re-render
replaceMessages([
Expand Down
6 changes: 3 additions & 3 deletions cli/src/state/atoms/effects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -383,7 +383,7 @@ export const messageHandlerEffectAtom = atom(null, (get, set, message: Extension
if (payload && Array.isArray(payload) && payload.length === 2) {
const [taskId, filePath] = payload

SessionManager.init().setPath(taskId, "apiConversationHistoryPath", filePath)
SessionManager.init().handleFileUpdate(taskId, "apiConversationHistoryPath", filePath)
} else {
logs.warn(`[DEBUG] Invalid apiMessagesSaved payload`, "effects", { payload })
}
Expand All @@ -396,7 +396,7 @@ export const messageHandlerEffectAtom = atom(null, (get, set, message: Extension
if (payload && Array.isArray(payload) && payload.length === 2) {
const [taskId, filePath] = payload

SessionManager.init().setPath(taskId, "uiMessagesPath", filePath)
SessionManager.init().handleFileUpdate(taskId, "uiMessagesPath", filePath)
} else {
logs.warn(`[DEBUG] Invalid taskMessagesSaved payload`, "effects", { payload })
}
Expand All @@ -408,7 +408,7 @@ export const messageHandlerEffectAtom = atom(null, (get, set, message: Extension
if (payload && Array.isArray(payload) && payload.length === 2) {
const [taskId, filePath] = payload

SessionManager.init().setPath(taskId, "taskMetadataPath", filePath)
SessionManager.init().handleFileUpdate(taskId, "taskMetadataPath", filePath)
} else {
logs.warn(`[DEBUG] Invalid taskMetadataSaved payload`, "effects", { payload })
}
Expand Down
15 changes: 4 additions & 11 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,10 +109,7 @@ import { getKilocodeDefaultModel } from "../../api/providers/kilocode/getKilocod
import { getKiloCodeWrapperProperties } from "../../core/kilocode/wrapper"
import { getKilocodeConfig, KilocodeConfig } from "../../utils/kilo-config-file"
import { getActiveToolUseStyle } from "../../api/providers/kilocode/nativeToolCallHelpers"
import {
kilo_destroySessionManager,
kilo_execIfExtension,
} from "../../shared/kilocode/cli-sessions/extension/session-manager-utils"
import { kilo_execIfExtension } from "../../shared/kilocode/cli-sessions/extension/session-manager-utils"

export type ClineProviderState = Awaited<ReturnType<ClineProvider["getState"]>>
// kilocode_change end
Expand Down Expand Up @@ -477,8 +474,6 @@ export class ClineProvider
this.clineStack.push(task)
task.emit(RooCodeEventName.TaskFocused)

await kilo_destroySessionManager()

// Perform special setup provider specific tasks.
await this.performPreparationTasks(task)

Expand Down Expand Up @@ -692,8 +687,6 @@ export class ClineProvider
this.autoPurgeScheduler.stop()
this.autoPurgeScheduler = undefined
}

await kilo_destroySessionManager()
// kilocode_change end

this.log("Disposed all disposables")
Expand Down Expand Up @@ -1156,15 +1149,15 @@ ${prompt}
if (message.type === "apiMessagesSaved" && message.payload) {
const [taskId, filePath] = message.payload as [string, string]

SessionManager.init().setPath(taskId, "apiConversationHistoryPath", filePath)
SessionManager.init().handleFileUpdate(taskId, "apiConversationHistoryPath", filePath)
} else if (message.type === "taskMessagesSaved" && message.payload) {
const [taskId, filePath] = message.payload as [string, string]

SessionManager.init().setPath(taskId, "uiMessagesPath", filePath)
SessionManager.init().handleFileUpdate(taskId, "uiMessagesPath", filePath)
} else if (message.type === "taskMetadataSaved" && message.payload) {
const [taskId, filePath] = message.payload as [string, string]

SessionManager.init().setPath(taskId, "taskMetadataPath", filePath)
SessionManager.init().handleFileUpdate(taskId, "taskMetadataPath", filePath)
}
})

Expand Down
11 changes: 5 additions & 6 deletions src/shared/kilocode/cli-sessions/core/SessionClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,11 +114,10 @@ export class SessionClient {
* Create a new session
*/
async create(input: CreateSessionInput): Promise<CreateSessionOutput> {
return await this.trpcClient.request<CreateSessionInput, CreateSessionOutput>(
"cliSessions.create",
"POST",
input,
)
return await this.trpcClient.request<CreateSessionInput, CreateSessionOutput>("cliSessions.create", "POST", {
...input,
created_on_platform: process.env.KILO_PLATFORM || input.created_on_platform,
})
}

/**
Expand Down Expand Up @@ -189,7 +188,7 @@ export class SessionClient {
): Promise<{ session_id: string; updated_at: string }> {
const { endpoint, getToken } = this.trpcClient

const url = new URL(`${endpoint}/api/upload-cli-session-blob`)
const url = new URL("/api/upload-cli-session-blob", endpoint)
url.searchParams.set("session_id", sessionId)
url.searchParams.set("blob_type", blobType)

Expand Down
Loading
Loading