From 16e56b29057de1c753c4e44a12dbb40d79ca6cd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 4 Dec 2025 17:31:18 +0100 Subject: [PATCH 01/20] refactor sync session to use a queue for extension file save events --- cli/src/cli.ts | 2 - cli/src/commands/new.ts | 10 - cli/src/state/atoms/effects.ts | 6 +- src/core/webview/ClineProvider.ts | 15 +- .../cli-sessions/core/SessionManager.ts | 433 +++------- .../core/__tests__/SessionManager.test.ts | 797 ------------------ .../extension/session-manager-utils.ts | 6 - 7 files changed, 132 insertions(+), 1137 deletions(-) delete mode 100644 src/shared/kilocode/cli-sessions/core/__tests__/SessionManager.test.ts diff --git a/cli/src/cli.ts b/cli/src/cli.ts index aa5dac3ce0e..756deeee7c4 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -323,8 +323,6 @@ export class CLI { try { logs.info("Disposing Kilo Code CLI...", "CLI") - await this.sessionService?.destroy() - // Signal codes take precedence over CI logic if (signal === "SIGINT") { exitCode = 130 diff --git a/cli/src/commands/new.ts b/cli/src/commands/new.ts index e62a94b195c..7d305ffab12 100644 --- a/cli/src/commands/new.ts +++ b/cli/src/commands/new.ts @@ -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" @@ -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([ diff --git a/cli/src/state/atoms/effects.ts b/cli/src/state/atoms/effects.ts index 68e943515fc..76c72156c2d 100644 --- a/cli/src/state/atoms/effects.ts +++ b/cli/src/state/atoms/effects.ts @@ -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 }) } @@ -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 }) } @@ -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 }) } diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 80cdbba24c5..001088bda99 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -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> // kilocode_change end @@ -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) @@ -692,8 +687,6 @@ export class ClineProvider this.autoPurgeScheduler.stop() this.autoPurgeScheduler = undefined } - - await kilo_destroySessionManager() // kilocode_change end this.log("Disposed all disposables") @@ -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) } }) diff --git a/src/shared/kilocode/cli-sessions/core/SessionManager.ts b/src/shared/kilocode/cli-sessions/core/SessionManager.ts index f9cc37f3b3c..0721e50d742 100644 --- a/src/shared/kilocode/cli-sessions/core/SessionManager.ts +++ b/src/shared/kilocode/cli-sessions/core/SessionManager.ts @@ -13,12 +13,6 @@ import type { ClineMessage, HistoryItem } from "@roo-code/types" import { TrpcClient, TrpcClientDependencies } from "./TrpcClient.js" import { SessionPersistenceManager } from "../utils/SessionPersistenceManager.js" -const defaultPaths = { - apiConversationHistoryPath: null as null | string, - uiMessagesPath: null as null | string, - taskMetadataPath: null as null | string, -} - interface SessionCreatedMessage { sessionId: string timestamp: number @@ -47,16 +41,17 @@ export class SessionManager { return SessionManager.instance } - private paths = { ...defaultPaths } - public sessionId: string | null = null private workspaceDir: string | null = null - private currentTaskId: string | null = null - private sessionTitle: string | null = null - private sessionGitUrl: string | null = null + private taskGitUrls: Record = {} + private taskGitHashes: Record = {} + private sessionTitles: Record = {} + + public get sessionId() { + return this.lastSessionId + } + private lastSessionId: string | null = null private timer: NodeJS.Timeout | null = null - private blobHashes = this.createDefaultBlobHashes() - private lastSyncedBlobHashes = this.createDefaultBlobHashes() private isSyncing: boolean = false private pathProvider: IPathProvider | undefined @@ -98,14 +93,21 @@ export class SessionManager { } } - setPath(taskId: string, key: keyof typeof defaultPaths, value: string) { - this.currentTaskId = taskId - this.paths[key] = value + private queue = [] as { + taskId: string + blobName: string + blobPath: string + }[] - const blobKey = this.pathKeyToBlobKey(key) + handleFileUpdate(taskId: string, key: string, value: string) { + const blobName = this.pathKeyToBlobKey(key) - if (blobKey) { - this.updateBlobHash(blobKey) + if (blobName) { + this.queue.push({ + taskId, + blobName, + blobPath: value, + }) } } @@ -159,8 +161,6 @@ export class SessionManager { throw new Error("SessionManager used before initialization") } - this.sessionId = sessionId - this.resetBlobHashes() this.isSyncing = true const session = (await this.sessionClient.get({ @@ -173,8 +173,6 @@ export class SessionManager { throw new Error("Failed to obtain session") } - this.sessionTitle = session.title - const sessionDirectoryPath = path.join(this.pathProvider.getTasksDir(), sessionId) mkdirSync(sessionDirectoryPath, { recursive: true }) @@ -273,7 +271,7 @@ export class SessionManager { this.logger?.info("Switched to restored task", "SessionManager", { sessionId }) - this.sessionPersistenceManager.setLastSession(this.sessionId) + this.sessionPersistenceManager.setLastSession(sessionId) this.onSessionRestored?.() @@ -284,11 +282,6 @@ export class SessionManager { sessionId, }) - this.sessionId = null - this.sessionTitle = null - this.sessionGitUrl = null - this.resetBlobHashes() - if (rethrowError) { throw error } @@ -302,13 +295,12 @@ export class SessionManager { throw new Error("SessionManager used before initialization") } - const sessionIdToShare = sessionId || this.sessionId - if (!sessionIdToShare) { + if (!sessionId) { throw new Error("No active session") } return await this.sessionClient.share({ - session_id: sessionIdToShare, + session_id: sessionId, shared_state: CliSessionSharedState.Public, }) } @@ -332,8 +324,6 @@ export class SessionManager { title: trimmedTitle, }) - this.sessionTitle = trimmedTitle - this.logger?.info("Session renamed successfully", "SessionManager", { sessionId, newTitle: trimmedTitle, @@ -401,301 +391,167 @@ export class SessionManager { } } - async destroy() { - this.logger?.debug("Destroying SessionManager", "SessionManager", { - sessionId: this.sessionId, - isSyncing: this.isSyncing, - currentTaskId: this.currentTaskId, - }) - - if (this.timer) { - clearInterval(this.timer) - this.timer = null - } - - if (this.sessionId) { - if (this.isSyncing) { - await new Promise((r) => setTimeout(r, 2000)) - } else { - await this.syncSession(true) - } - } - - this.paths = { ...defaultPaths } - this.sessionId = null - this.currentTaskId = null - this.sessionTitle = null - this.sessionGitUrl = null - this.isSyncing = false - - this.logger?.debug("SessionManager flushed", "SessionManager") - - if (!this.timer) { - this.timer = setInterval(() => { - this.syncSession() - }, SessionManager.SYNC_INTERVAL) - } - } - private async syncSession(force = false) { if (!force) { if (this.isSyncing) { return } + } - if (Object.values(this.paths).every((item) => !item)) { - return - } - - if (!this.hasAnyBlobChanged()) { - return - } + if (this.queue.length === 0) { + return } if (process.env.KILO_DISABLE_SESSIONS) { + this.queue = [] return } this.isSyncing = true - // capture the sessionId at the start of the sync - let capturedSessionId = this.sessionId + + // TODO: logging + // TODO: (some) promises should have catches try { if (!this.platform || !this.sessionClient || !this.sessionPersistenceManager) { throw new Error("SessionManager used before initialization") } - const rawPayload = this.readPaths() - - if (Object.values(rawPayload).every((item) => !item)) { - this.isSyncing = false - - return - } + const taskIds = new Set(this.queue.map((item) => item.taskId)) + const lastItem = this.queue[this.queue.length - 1] - const basePayload: Omit< - Parameters["create"]>[0], - "created_on_platform" - > = {} + for (const taskId of taskIds) { + const taskItems = this.queue.filter((item) => item.taskId === taskId) + const reversedTaskItems = [...taskItems].reverse() - let gitInfo: Awaited> | null = null + const basePayload: Omit< + Parameters["create"]>[0], + "created_on_platform" + > = {} - try { - gitInfo = await this.getGitState() + let gitInfo: Awaited> | null = null + try { + gitInfo = await this.getGitState() + if (gitInfo?.repoUrl) { + basePayload.git_url = gitInfo.repoUrl - if (gitInfo?.repoUrl) { - basePayload.git_url = gitInfo.repoUrl + this.taskGitUrls[taskId] = gitInfo.repoUrl + } + } catch (error) { + this.logger?.debug("Could not get git state", "SessionManager", { + error: error instanceof Error ? error.message : String(error), + }) } - } catch (error) { - this.logger?.debug("Could not get git state", "SessionManager", { - error: error instanceof Error ? error.message : String(error), - }) - } - if (!capturedSessionId && this.currentTaskId) { - const existingSessionId = this.sessionPersistenceManager.getSessionForTask(this.currentTaskId) - - if (existingSessionId) { - this.sessionId = existingSessionId - capturedSessionId = existingSessionId - } - } + let sessionId = this.sessionPersistenceManager.getSessionForTask(taskId) - if (capturedSessionId) { - const gitUrlChanged = gitInfo?.repoUrl && gitInfo.repoUrl !== this.sessionGitUrl + if (sessionId) { + const gitUrlChanged = !!gitInfo?.repoUrl && gitInfo.repoUrl !== this.taskGitUrls[taskId] - if (gitUrlChanged) { - await this.sessionClient.update({ - session_id: capturedSessionId, + if (gitUrlChanged) { + await this.sessionClient.update({ + session_id: sessionId, + ...basePayload, + }) + } + } else { + const createdSession = await this.sessionClient.create({ ...basePayload, + created_on_platform: process.env.KILO_PLATFORM || this.platform, }) - this.sessionGitUrl = gitInfo?.repoUrl || null + sessionId = createdSession.session_id - this.logger?.debug("Session updated successfully", "SessionManager", { - sessionId: capturedSessionId, + this.sessionPersistenceManager.setSessionForTask(taskId, createdSession.session_id) + + this.onSessionCreated?.({ + timestamp: Date.now(), + event: "session_created", + sessionId: createdSession.session_id, }) } - } else { - this.logger?.debug("Creating new session", "SessionManager") - if (rawPayload.uiMessagesPath) { - const title = this.getFirstMessageText(rawPayload.uiMessagesPath as ClineMessage[], true) - - if (title) { - basePayload.title = title - } + if (!sessionId) { + // this should never happen + continue } - const session = await this.sessionClient.create({ - ...basePayload, - created_on_platform: process.env.KILO_PLATFORM || this.platform, - }) - - this.sessionId = session.session_id - capturedSessionId = session.session_id - - this.sessionGitUrl = gitInfo?.repoUrl || null + const blobNames = new Set(taskItems.map((item) => item.blobName)) + const blobUploads: Promise[] = [] - this.logger?.info("Session created successfully", "SessionManager", { sessionId: capturedSessionId }) + for (const blobName of blobNames) { + const lastBlobItem = reversedTaskItems.find((item) => item.blobName === blobName) - this.sessionPersistenceManager.setLastSession(capturedSessionId) - - this.onSessionCreated?.({ - timestamp: Date.now(), - event: "session_created", - sessionId: capturedSessionId, - }) - } + if (!lastBlobItem) { + // this should also never happen + // famous last words + continue + } - if (this.currentTaskId) { - this.sessionPersistenceManager.setSessionForTask(this.currentTaskId, capturedSessionId) - } + blobUploads.push( + this.sessionClient + .uploadBlob( + sessionId, + lastBlobItem.blobName as Parameters[1], + JSON.parse(readFileSync(lastBlobItem.blobPath, "utf-8")), + ) + .then(() => { + this.queue = this.queue.filter( + (item) => item.blobName !== blobName && item.taskId !== taskId, + ) + }), + ) - const blobUploads: Array> = [] - - if (rawPayload.apiConversationHistoryPath && this.hasBlobChanged("apiConversationHistory")) { - blobUploads.push( - this.sessionClient - .uploadBlob( - capturedSessionId, - "api_conversation_history", - rawPayload.apiConversationHistoryPath, - ) - .then(() => { - this.markBlobSynced("apiConversationHistory") - this.logger?.debug("Uploaded api_conversation_history blob", "SessionManager") - }) - .catch((error) => { - this.logger?.error("Failed to upload api_conversation_history blob", "SessionManager", { - error: error instanceof Error ? error.message : String(error), + if (blobName === "uiMessages" && !this.sessionTitles[sessionId]) { + this.sessionClient + .get({ + session_id: sessionId, }) - }), - ) - } - - if (rawPayload.taskMetadataPath && this.hasBlobChanged("taskMetadata")) { - blobUploads.push( - this.sessionClient - .uploadBlob(capturedSessionId, "task_metadata", rawPayload.taskMetadataPath) - .then(() => { - this.markBlobSynced("taskMetadata") - this.logger?.debug("Uploaded task_metadata blob", "SessionManager") - }) - .catch((error) => { - this.logger?.error("Failed to upload task_metadata blob", "SessionManager", { - error: error instanceof Error ? error.message : String(error), + .then((session) => { + if (session.title) { + this.sessionTitles[sessionId] = session.title + + return + } else { + return this.generateTitle(JSON.parse(readFileSync(lastBlobItem.blobPath, "utf-8"))) + } }) - }), - ) - } - - if (rawPayload.uiMessagesPath && this.hasBlobChanged("uiMessages")) { - blobUploads.push( - this.sessionClient - .uploadBlob(capturedSessionId, "ui_messages", rawPayload.uiMessagesPath) - .then(() => { - this.markBlobSynced("uiMessages") - this.logger?.debug("Uploaded ui_messages blob", "SessionManager") - }) - .catch((error) => { - this.logger?.error("Failed to upload ui_messages blob", "SessionManager", { - error: error instanceof Error ? error.message : String(error), - }) - }), - ) - } - - if (gitInfo) { - const gitStateData = { - head: gitInfo.head, - patch: gitInfo.patch, - branch: gitInfo.branch, + } } - const gitStateHash = this.hashGitState(gitStateData) + if (gitInfo) { + const gitStateData = { + head: gitInfo.head, + patch: gitInfo.patch, + branch: gitInfo.branch, + } - if (gitStateHash !== this.blobHashes.gitState) { - this.blobHashes.gitState = gitStateHash + const gitStateHash = this.hashGitState(gitStateData) - if (this.hasBlobChanged("gitState")) { - blobUploads.push( - this.sessionClient - .uploadBlob(capturedSessionId, "git_state", gitStateData) - .then(() => { - this.markBlobSynced("gitState") - this.logger?.debug("Uploaded git_state blob", "SessionManager") - }) - .catch((error) => { - this.logger?.error("Failed to upload git_state blob", "SessionManager", { - error: error instanceof Error ? error.message : String(error), - }) - }), - ) + if (gitStateHash !== this.taskGitHashes[taskId]) { + this.taskGitHashes[taskId] = gitStateHash + + blobUploads.push(this.sessionClient.uploadBlob(sessionId, "git_state", gitStateData)) } } + + await Promise.all(blobUploads) } - await Promise.all(blobUploads) + this.lastSessionId = this.sessionPersistenceManager.getSessionForTask(lastItem.taskId) || null - if (!this.sessionTitle && rawPayload.uiMessagesPath) { - this.generateTitle(rawPayload.uiMessagesPath as ClineMessage[]) - .then((generatedTitle) => { - if (capturedSessionId && generatedTitle) { - return this.renameSession(capturedSessionId, generatedTitle) - } - - return null - }) - .catch((error) => { - this.logger?.warn("Failed to generate session title", "SessionManager", { - error: error instanceof Error ? error.message : String(error), - }) - }) + if (this.lastSessionId) { + this.sessionPersistenceManager.setLastSession(this.lastSessionId) } } catch (error) { this.logger?.error("Failed to sync session", "SessionManager", { error: error instanceof Error ? error.message : String(error), - sessionId: capturedSessionId, - hasApiHistory: !!this.paths.apiConversationHistoryPath, - hasUiMessages: !!this.paths.uiMessagesPath, - hasTaskMetadata: !!this.paths.taskMetadataPath, }) } finally { this.isSyncing = false } } - private readPath(path: string) { - try { - const content = readFileSync(path, "utf-8") - try { - return JSON.parse(content) - } catch { - return undefined - } - } catch { - return undefined - } - } - - private readPaths() { - const contents: Partial> = {} - - for (const [key, value] of Object.entries(this.paths)) { - if (!value) { - continue - } - - const content = this.readPath(value) - if (content !== undefined) { - contents[key as keyof typeof this.paths] = content - } - } - - return contents - } - private async fetchBlobFromSignedUrl(url: string, urlType: string) { try { this.logger?.debug(`Fetching blob from signed URL`, "SessionManager", { url, urlType }) @@ -721,7 +577,7 @@ export class SessionManager { } } - private pathKeyToBlobKey(pathKey: keyof typeof defaultPaths) { + private pathKeyToBlobKey(pathKey: string) { switch (pathKey) { case "apiConversationHistoryPath": return "apiConversationHistory" @@ -734,47 +590,12 @@ export class SessionManager { } } - private updateBlobHash(blobKey: keyof typeof this.blobHashes) { - this.blobHashes[blobKey] = crypto.randomUUID() - } - - private hasBlobChanged(blobKey: keyof typeof this.blobHashes) { - return this.blobHashes[blobKey] !== this.lastSyncedBlobHashes[blobKey] - } - - private hasAnyBlobChanged() { - return ( - this.hasBlobChanged("apiConversationHistory") || - this.hasBlobChanged("uiMessages") || - this.hasBlobChanged("taskMetadata") || - this.hasBlobChanged("gitState") - ) - } - - private markBlobSynced(blobKey: keyof typeof this.blobHashes) { - this.lastSyncedBlobHashes[blobKey] = this.blobHashes[blobKey] - } - private hashGitState( gitState: Pick>>, "head" | "patch" | "branch">, ) { return createHash("sha256").update(JSON.stringify(gitState)).digest("hex") } - private createDefaultBlobHashes() { - return { - apiConversationHistory: "", - uiMessages: "", - taskMetadata: "", - gitState: "", - } - } - - private resetBlobHashes() { - this.blobHashes = this.createDefaultBlobHashes() - this.lastSyncedBlobHashes = this.createDefaultBlobHashes() - } - private async getGitState() { const cwd = this.workspaceDir || process.cwd() const git = simpleGit(cwd) @@ -992,10 +813,6 @@ export class SessionManager { return null } - if (rawText.length <= 140) { - return rawText - } - try { const prompt = `Summarize the following user request in 140 characters or less. Be concise and capture the main intent. Do not use quotes or add any prefix like "Summary:" - just provide the summary text directly. Strip out any sensitive information. Your result will be used as the conversation title. diff --git a/src/shared/kilocode/cli-sessions/core/__tests__/SessionManager.test.ts b/src/shared/kilocode/cli-sessions/core/__tests__/SessionManager.test.ts deleted file mode 100644 index e556e47048b..00000000000 --- a/src/shared/kilocode/cli-sessions/core/__tests__/SessionManager.test.ts +++ /dev/null @@ -1,797 +0,0 @@ -import { readFileSync, mkdirSync } from "fs" -import type { IPathProvider } from "../../types/IPathProvider" -import type { ILogger } from "../../types/ILogger" -import type { IExtensionMessenger } from "../../types/IExtensionMessenger" -import type { ITaskDataProvider } from "../../types/ITaskDataProvider" -import { SessionManager, SessionManagerDependencies } from "../SessionManager" -import { CliSessionSharedState } from "../SessionClient" -import type { ClineMessage } from "@roo-code/types" - -vi.mock("fs", () => ({ - readFileSync: vi.fn(), - writeFileSync: vi.fn(), - mkdirSync: vi.fn(), - mkdtempSync: vi.fn(), - rmSync: vi.fn(), -})) - -vi.mock("path", async () => { - const actual = await vi.importActual("path") - return { - ...actual, - default: { - ...actual, - join: vi.fn((...args: string[]) => args.join("/")), - }, - } -}) - -vi.mock("simple-git", () => ({ - default: vi.fn(() => ({ - getRemotes: vi.fn(), - revparse: vi.fn(), - raw: vi.fn(), - diff: vi.fn(), - stash: vi.fn(), - stashList: vi.fn(), - checkout: vi.fn(), - applyPatch: vi.fn(), - })), -})) - -vi.mock("os", () => ({ - tmpdir: vi.fn(() => "/tmp"), -})) - -vi.mock("crypto", () => ({ - createHash: vi.fn(() => ({ - update: vi.fn().mockReturnThis(), - digest: vi.fn().mockReturnValue("mock-hash"), - })), -})) - -vi.mock("../TrpcClient", () => ({ - TrpcClient: vi.fn().mockImplementation(() => ({ - endpoint: "https://api.example.com", - getToken: vi.fn().mockResolvedValue("mock-token"), - request: vi.fn(), - })), -})) - -vi.mock("../SessionClient", () => ({ - SessionClient: vi.fn().mockImplementation(() => ({ - get: vi.fn(), - create: vi.fn(), - update: vi.fn(), - share: vi.fn(), - fork: vi.fn(), - uploadBlob: vi.fn(), - })), - CliSessionSharedState: { - Public: "public", - }, -})) - -vi.mock("../../utils/SessionPersistenceManager", () => ({ - SessionPersistenceManager: vi.fn().mockImplementation(() => ({ - setWorkspaceDir: vi.fn(), - getLastSession: vi.fn(), - setLastSession: vi.fn(), - getSessionForTask: vi.fn(), - setSessionForTask: vi.fn(), - })), -})) - -function createMockPathProvider(): IPathProvider { - return { - getTasksDir: vi.fn().mockReturnValue("/home/user/.kilocode/tasks"), - getSessionFilePath: vi - .fn() - .mockImplementation((workspaceDir: string) => `${workspaceDir}/.kilocode/session.json`), - } -} - -function createMockLogger(): ILogger { - return { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } -} - -function createMockExtensionMessenger(): IExtensionMessenger { - return { - sendWebviewMessage: vi.fn().mockResolvedValue(undefined), - requestSingleCompletion: vi.fn().mockResolvedValue("Generated Title"), - } -} - -function createMockDependencies(): SessionManagerDependencies { - return { - platform: "vscode", - pathProvider: createMockPathProvider(), - logger: createMockLogger(), - extensionMessenger: createMockExtensionMessenger(), - getToken: vi.fn().mockResolvedValue("mock-token"), - onSessionCreated: vi.fn(), - onSessionRestored: vi.fn(), - } -} - -describe("SessionManager", () => { - let sessionManager: SessionManager - let mockDependencies: SessionManagerDependencies - - beforeEach(() => { - vi.clearAllMocks() - vi.useFakeTimers() - - mockDependencies = createMockDependencies() - sessionManager = SessionManager.init(mockDependencies) - }) - - afterEach(() => { - vi.useRealTimers() - }) - - describe("init", () => { - it("should return the singleton instance", () => { - const instance1 = SessionManager.init() - const instance2 = SessionManager.init() - - expect(instance1).toBe(instance2) - }) - - it("should initialize with dependencies when provided", () => { - const deps = createMockDependencies() - const instance = SessionManager.init(deps) - - expect(instance).toBeDefined() - expect(instance.sessionClient).toBeDefined() - expect(instance.sessionPersistenceManager).toBeDefined() - }) - - it("should initialize timer property", () => { - const deps = createMockDependencies() - const instance = SessionManager.init(deps) - - expect(instance["timer"]).not.toBeNull() - }) - }) - - describe("setPath", () => { - it("should set the apiConversationHistoryPath", () => { - sessionManager.setPath("task-123", "apiConversationHistoryPath", "/path/to/api_conversation_history.json") - - expect(sessionManager["currentTaskId"]).toBe("task-123") - expect(sessionManager["paths"].apiConversationHistoryPath).toBe("/path/to/api_conversation_history.json") - }) - - it("should set the uiMessagesPath", () => { - sessionManager.setPath("task-123", "uiMessagesPath", "/path/to/ui_messages.json") - - expect(sessionManager["paths"].uiMessagesPath).toBe("/path/to/ui_messages.json") - }) - - it("should set the taskMetadataPath", () => { - sessionManager.setPath("task-123", "taskMetadataPath", "/path/to/task_metadata.json") - - expect(sessionManager["paths"].taskMetadataPath).toBe("/path/to/task_metadata.json") - }) - - it("should update blob hash when path is set", () => { - const initialHash = sessionManager["blobHashes"].apiConversationHistory - - sessionManager.setPath("task-123", "apiConversationHistoryPath", "/path/to/file.json") - - expect(sessionManager["blobHashes"].apiConversationHistory).not.toBe(initialHash) - }) - }) - - describe("setWorkspaceDirectory", () => { - it("should set the workspace directory", () => { - sessionManager.setWorkspaceDirectory("/workspace") - - expect(sessionManager["workspaceDir"]).toBe("/workspace") - }) - - it("should propagate workspace directory to persistence manager", () => { - sessionManager.setWorkspaceDirectory("/workspace") - - expect(sessionManager.sessionPersistenceManager?.setWorkspaceDir).toHaveBeenCalledWith("/workspace") - }) - }) - - describe("restoreLastSession", () => { - it("should return false when no persisted session exists", async () => { - vi.mocked(sessionManager.sessionPersistenceManager!.getLastSession).mockReturnValue(undefined) - - const result = await sessionManager.restoreLastSession() - - expect(result).toBe(false) - }) - - it("should return false when persisted session has no sessionId", async () => { - vi.mocked(sessionManager.sessionPersistenceManager!.getLastSession).mockReturnValue({ - sessionId: "", - timestamp: Date.now(), - }) - - const result = await sessionManager.restoreLastSession() - - expect(result).toBe(false) - }) - - it("should attempt to restore session when persisted session exists", async () => { - vi.mocked(sessionManager.sessionPersistenceManager!.getLastSession).mockReturnValue({ - sessionId: "session-123", - timestamp: Date.now(), - }) - vi.mocked(sessionManager.sessionClient!.get).mockResolvedValue({ - session_id: "session-123", - title: "Test Session", - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - api_conversation_history_blob_url: null, - task_metadata_blob_url: null, - ui_messages_blob_url: null, - git_state_blob_url: null, - }) - - const result = await sessionManager.restoreLastSession() - - expect(result).toBe(true) - expect(sessionManager.sessionClient!.get).toHaveBeenCalledWith({ - session_id: "session-123", - include_blob_urls: true, - }) - }) - - it("should return false when restore fails", async () => { - vi.mocked(sessionManager.sessionPersistenceManager!.getLastSession).mockReturnValue({ - sessionId: "session-123", - timestamp: Date.now(), - }) - vi.mocked(sessionManager.sessionClient!.get).mockRejectedValue(new Error("Network error")) - - const result = await sessionManager.restoreLastSession() - - expect(result).toBe(false) - }) - }) - - describe("restoreSession", () => { - it("should throw error if SessionManager is not initialized", async () => { - const uninitializedManager = SessionManager.init() - uninitializedManager["pathProvider"] = undefined - - await expect(uninitializedManager.restoreSession("session-123", true)).rejects.toThrow( - "SessionManager used before initialization", - ) - }) - - it("should set sessionId and reset blob hashes", async () => { - vi.mocked(sessionManager.sessionClient!.get).mockResolvedValue({ - session_id: "session-123", - title: "Test Session", - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - api_conversation_history_blob_url: null, - task_metadata_blob_url: null, - ui_messages_blob_url: null, - git_state_blob_url: null, - }) - - await sessionManager.restoreSession("session-123") - - expect(sessionManager.sessionId).toBe("session-123") - }) - - it("should throw error when session is not found", async () => { - vi.mocked(sessionManager.sessionClient!.get).mockResolvedValue(undefined as never) - - await expect(sessionManager.restoreSession("session-123", true)).rejects.toThrow("Failed to obtain session") - }) - - it("should create session directory", async () => { - vi.mocked(sessionManager.sessionClient!.get).mockResolvedValue({ - session_id: "session-123", - title: "Test Session", - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - api_conversation_history_blob_url: null, - task_metadata_blob_url: null, - ui_messages_blob_url: null, - git_state_blob_url: null, - }) - - await sessionManager.restoreSession("session-123") - - expect(mkdirSync).toHaveBeenCalledWith("/home/user/.kilocode/tasks/session-123", { recursive: true }) - }) - - it("should send webview messages to add task to history", async () => { - vi.mocked(sessionManager.sessionClient!.get).mockResolvedValue({ - session_id: "session-123", - title: "Test Session", - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - api_conversation_history_blob_url: null, - task_metadata_blob_url: null, - ui_messages_blob_url: null, - git_state_blob_url: null, - }) - - await sessionManager.restoreSession("session-123") - - expect(mockDependencies.extensionMessenger.sendWebviewMessage).toHaveBeenCalledWith( - expect.objectContaining({ - type: "addTaskToHistory", - historyItem: expect.objectContaining({ - id: "session-123", - task: "Test Session", - }), - }), - ) - }) - - it("should send showTaskWithId message", async () => { - vi.mocked(sessionManager.sessionClient!.get).mockResolvedValue({ - session_id: "session-123", - title: "Test Session", - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - api_conversation_history_blob_url: null, - task_metadata_blob_url: null, - ui_messages_blob_url: null, - git_state_blob_url: null, - }) - - await sessionManager.restoreSession("session-123") - - expect(mockDependencies.extensionMessenger.sendWebviewMessage).toHaveBeenCalledWith({ - type: "showTaskWithId", - text: "session-123", - }) - }) - - it("should call onSessionRestored callback", async () => { - vi.mocked(sessionManager.sessionClient!.get).mockResolvedValue({ - session_id: "session-123", - title: "Test Session", - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - api_conversation_history_blob_url: null, - task_metadata_blob_url: null, - ui_messages_blob_url: null, - git_state_blob_url: null, - }) - - await sessionManager.restoreSession("session-123") - - expect(mockDependencies.onSessionRestored).toHaveBeenCalled() - }) - - it("should reset session state on error when rethrowError is false", async () => { - vi.mocked(sessionManager.sessionClient!.get).mockRejectedValue(new Error("Network error")) - - await sessionManager.restoreSession("session-123", false) - - expect(sessionManager.sessionId).toBeNull() - expect(sessionManager["sessionTitle"]).toBeNull() - expect(sessionManager["sessionGitUrl"]).toBeNull() - }) - - it("should rethrow error when rethrowError is true", async () => { - vi.mocked(sessionManager.sessionClient!.get).mockRejectedValue(new Error("Network error")) - - await expect(sessionManager.restoreSession("session-123", true)).rejects.toThrow("Network error") - }) - }) - - describe("shareSession", () => { - it("should throw error when no active session", async () => { - sessionManager.sessionId = null - - await expect(sessionManager.shareSession()).rejects.toThrow("No active session") - }) - - it("should share the active session", async () => { - sessionManager.sessionId = "session-123" - vi.mocked(sessionManager.sessionClient!.share).mockResolvedValue({ - share_id: "share-456", - session_id: "session-123", - }) - - const result = await sessionManager.shareSession() - - expect(sessionManager.sessionClient!.share).toHaveBeenCalledWith({ - session_id: "session-123", - shared_state: CliSessionSharedState.Public, - }) - expect(result).toEqual({ - share_id: "share-456", - session_id: "session-123", - }) - }) - - it("should share a specific session when sessionId is provided", async () => { - sessionManager.sessionId = "active-session" - vi.mocked(sessionManager.sessionClient!.share).mockResolvedValue({ - share_id: "share-789", - session_id: "specific-session", - }) - - await sessionManager.shareSession("specific-session") - - expect(sessionManager.sessionClient!.share).toHaveBeenCalledWith({ - session_id: "specific-session", - shared_state: CliSessionSharedState.Public, - }) - }) - }) - - describe("renameSession", () => { - it("should throw error when no active session", async () => { - await expect(sessionManager.renameSession("", "New Title")).rejects.toThrow("No active session") - }) - - it("should throw error when title is empty", async () => { - await expect(sessionManager.renameSession("session-123", " ")).rejects.toThrow( - "Session title cannot be empty", - ) - }) - - it("should rename the session", async () => { - vi.mocked(sessionManager.sessionClient!.update).mockResolvedValue({ - session_id: "session-123", - title: "New Title", - updated_at: new Date().toISOString(), - }) - - await sessionManager.renameSession("session-123", "New Title") - - expect(sessionManager.sessionClient!.update).toHaveBeenCalledWith({ - session_id: "session-123", - title: "New Title", - }) - expect(sessionManager["sessionTitle"]).toBe("New Title") - }) - - it("should trim the title", async () => { - vi.mocked(sessionManager.sessionClient!.update).mockResolvedValue({ - session_id: "session-123", - title: "Trimmed Title", - updated_at: new Date().toISOString(), - }) - - await sessionManager.renameSession("session-123", " Trimmed Title ") - - expect(sessionManager.sessionClient!.update).toHaveBeenCalledWith({ - session_id: "session-123", - title: "Trimmed Title", - }) - }) - }) - - describe("forkSession", () => { - it("should throw error if SessionManager is not initialized", async () => { - sessionManager["platform"] = undefined - - await expect(sessionManager.forkSession("share-123")).rejects.toThrow( - "SessionManager used before initialization", - ) - }) - - it("should fork the session and restore it", async () => { - vi.mocked(sessionManager.sessionClient!.fork).mockResolvedValue({ - session_id: "forked-session-456", - }) - vi.mocked(sessionManager.sessionClient!.get).mockResolvedValue({ - session_id: "forked-session-456", - title: "Forked Session", - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - api_conversation_history_blob_url: null, - task_metadata_blob_url: null, - ui_messages_blob_url: null, - git_state_blob_url: null, - }) - - await sessionManager.forkSession("share-123") - - expect(sessionManager.sessionClient!.fork).toHaveBeenCalledWith({ - share_or_session_id: "share-123", - created_on_platform: "vscode", - }) - expect(sessionManager.sessionId).toBe("forked-session-456") - }) - }) - - describe("getSessionFromTask", () => { - let mockTaskDataProvider: ITaskDataProvider - - beforeEach(() => { - mockTaskDataProvider = { - getTaskWithId: vi.fn().mockResolvedValue({ - historyItem: { task: "Test Task" }, - apiConversationHistoryFilePath: "/path/to/api.json", - uiMessagesFilePath: "/path/to/ui.json", - }), - } - }) - - it("should throw error if SessionManager is not initialized", async () => { - sessionManager["platform"] = undefined - - await expect(sessionManager.getSessionFromTask("task-123", mockTaskDataProvider)).rejects.toThrow( - "SessionManager used before initialization", - ) - }) - - it("should return existing session if task is already mapped", async () => { - vi.mocked(sessionManager.sessionPersistenceManager!.getSessionForTask).mockReturnValue("existing-session") - - const result = await sessionManager.getSessionFromTask("task-123", mockTaskDataProvider) - - expect(result).toBe("existing-session") - expect(sessionManager.sessionClient!.create).not.toHaveBeenCalled() - }) - - it("should create new session if task is not mapped", async () => { - vi.mocked(sessionManager.sessionPersistenceManager!.getSessionForTask).mockReturnValue(undefined) - vi.mocked(readFileSync).mockReturnValue(JSON.stringify([{ text: "Hello" }])) - vi.mocked(sessionManager.sessionClient!.create).mockResolvedValue({ - session_id: "new-session-123", - title: "Test Task", - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - }) - - const result = await sessionManager.getSessionFromTask("task-123", mockTaskDataProvider) - - expect(result).toBe("new-session-123") - expect(sessionManager.sessionClient!.create).toHaveBeenCalledWith( - expect.objectContaining({ - title: "Test Task", - }), - ) - }) - - it("should upload blobs after creating session", async () => { - vi.mocked(sessionManager.sessionPersistenceManager!.getSessionForTask).mockReturnValue(undefined) - vi.mocked(readFileSync).mockReturnValue(JSON.stringify([{ text: "Hello" }])) - vi.mocked(sessionManager.sessionClient!.create).mockResolvedValue({ - session_id: "new-session-123", - title: "Test Task", - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - }) - - await sessionManager.getSessionFromTask("task-123", mockTaskDataProvider) - - expect(sessionManager.sessionClient!.uploadBlob).toHaveBeenCalledWith( - "new-session-123", - "api_conversation_history", - expect.anything(), - ) - expect(sessionManager.sessionClient!.uploadBlob).toHaveBeenCalledWith( - "new-session-123", - "ui_messages", - expect.anything(), - ) - }) - - it("should persist task-session mapping", async () => { - vi.mocked(sessionManager.sessionPersistenceManager!.getSessionForTask).mockReturnValue(undefined) - vi.mocked(readFileSync).mockReturnValue(JSON.stringify([{ text: "Hello" }])) - vi.mocked(sessionManager.sessionClient!.create).mockResolvedValue({ - session_id: "new-session-123", - title: "Test Task", - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - }) - - await sessionManager.getSessionFromTask("task-123", mockTaskDataProvider) - - expect(sessionManager.sessionPersistenceManager!.setSessionForTask).toHaveBeenCalledWith( - "task-123", - "new-session-123", - ) - }) - }) - - describe("destroy", () => { - it("should clear the timer", async () => { - const initialTimerCount = vi.getTimerCount() - sessionManager["timer"] = setInterval(() => {}, 1000) - - await sessionManager.destroy() - - expect(sessionManager["timer"]).not.toBeNull() - }) - - it("should reset paths and session state", async () => { - sessionManager.sessionId = "session-123" - sessionManager["sessionTitle"] = "Test Session" - - await sessionManager.destroy() - - expect(sessionManager.sessionId).toBeNull() - expect(sessionManager["sessionTitle"]).toBeNull() - }) - - it("should wait for sync to complete if syncing", async () => { - sessionManager.sessionId = "session-123" - sessionManager["isSyncing"] = true - - const destroyPromise = sessionManager.destroy() - vi.advanceTimersByTime(2000) - await destroyPromise - - expect(sessionManager["isSyncing"]).toBe(false) - }) - - it("should clear currentTaskId to prevent session ID clobbering across tasks", async () => { - sessionManager.setPath("task-A", "apiConversationHistoryPath", "/path/to/taskA/api.json") - sessionManager.sessionId = "session-A" - vi.mocked(sessionManager.sessionPersistenceManager!.getSessionForTask).mockReturnValue("session-A") - vi.mocked(sessionManager.sessionClient!.create).mockResolvedValue({ - session_id: "session-B", - title: "Task B", - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - }) - - await sessionManager.destroy() - - expect(sessionManager["currentTaskId"]).toBeNull() - expect(sessionManager.sessionId).toBeNull() - - sessionManager.setPath("task-B", "apiConversationHistoryPath", "/path/to/taskB/api.json") - - vi.mocked(sessionManager.sessionPersistenceManager!.getSessionForTask).mockReturnValue(undefined) - - await sessionManager["syncSession"]() - - expect(sessionManager.sessionId).toBe("session-B") - expect(sessionManager.sessionId).not.toBe("session-A") - }) - }) - - describe("getFirstMessageText", () => { - it("should return null for empty messages array", () => { - const result = sessionManager.getFirstMessageText([]) - - expect(result).toBeNull() - }) - - it("should return null when no message has text", () => { - const messages = [{ type: "say" }, { type: "ask" }] as ClineMessage[] - - const result = sessionManager.getFirstMessageText(messages) - - expect(result).toBeNull() - }) - - it("should return the first message with text", () => { - const messages = [ - { type: "say", text: "" }, - { type: "say", text: "Hello World" }, - { type: "say", text: "Second message" }, - ] as ClineMessage[] - - const result = sessionManager.getFirstMessageText(messages) - - expect(result).toBe("Hello World") - }) - - it("should normalize whitespace in the message", () => { - const messages = [{ type: "say", text: "Hello World\n\nTest" }] as ClineMessage[] - - const result = sessionManager.getFirstMessageText(messages) - - expect(result).toBe("Hello World Test") - }) - - it("should truncate message when truncate is true and message exceeds 140 chars", () => { - const longText = "A".repeat(200) - const messages = [{ type: "say", text: longText }] as ClineMessage[] - - const result = sessionManager.getFirstMessageText(messages, true) - - expect(result).toHaveLength(140) - expect(result?.endsWith("...")).toBe(true) - }) - - it("should not truncate message when truncate is true but message is under 140 chars", () => { - const messages = [{ type: "say", text: "Short message" }] as ClineMessage[] - - const result = sessionManager.getFirstMessageText(messages, true) - - expect(result).toBe("Short message") - }) - - it("should return null for whitespace-only message", () => { - const messages = [{ type: "say", text: " \n\t " }] as ClineMessage[] - - const result = sessionManager.getFirstMessageText(messages) - - expect(result).toBeNull() - }) - }) - - describe("generateTitle", () => { - it("should return null for empty messages", async () => { - const result = await sessionManager.generateTitle([]) - - expect(result).toBeNull() - }) - - it("should return raw text if under 140 characters", async () => { - const messages = [{ type: "say", text: "Short task description" }] as ClineMessage[] - - const result = await sessionManager.generateTitle(messages) - - expect(result).toBe("Short task description") - }) - - it("should use LLM to generate title for long messages", async () => { - const longText = "A".repeat(200) - const messages = [{ type: "say", text: longText }] as ClineMessage[] - vi.mocked(mockDependencies.extensionMessenger.requestSingleCompletion).mockResolvedValue("Short summary") - - const result = await sessionManager.generateTitle(messages) - - expect(mockDependencies.extensionMessenger.requestSingleCompletion).toHaveBeenCalled() - expect(result).toBe("Short summary") - }) - - it("should remove quotes from generated title", async () => { - const longText = "A".repeat(200) - const messages = [{ type: "say", text: longText }] as ClineMessage[] - vi.mocked(mockDependencies.extensionMessenger.requestSingleCompletion).mockResolvedValue('"Quoted summary"') - - const result = await sessionManager.generateTitle(messages) - - expect(result).toBe("Quoted summary") - }) - - it("should truncate generated title if over 140 characters", async () => { - const longText = "A".repeat(200) - const messages = [{ type: "say", text: longText }] as ClineMessage[] - vi.mocked(mockDependencies.extensionMessenger.requestSingleCompletion).mockResolvedValue("B".repeat(200)) - - const result = await sessionManager.generateTitle(messages) - - expect(result).toHaveLength(140) - expect(result?.endsWith("...")).toBe(true) - }) - - it("should fallback to truncation on LLM error", async () => { - const longText = "A".repeat(200) - const messages = [{ type: "say", text: longText }] as ClineMessage[] - vi.mocked(mockDependencies.extensionMessenger.requestSingleCompletion).mockRejectedValue( - new Error("LLM error"), - ) - - const result = await sessionManager.generateTitle(messages) - - expect(result).toHaveLength(140) - expect(result?.startsWith("AAA")).toBe(true) - expect(result?.endsWith("...")).toBe(true) - }) - - it("should fallback to truncation if extension messenger is not initialized", async () => { - const longText = "A".repeat(200) - const messages = [{ type: "say", text: longText }] as ClineMessage[] - sessionManager["extensionMessenger"] = undefined - - const result = await sessionManager.generateTitle(messages) - - expect(result).toHaveLength(140) - expect(result?.endsWith("...")).toBe(true) - }) - }) -}) diff --git a/src/shared/kilocode/cli-sessions/extension/session-manager-utils.ts b/src/shared/kilocode/cli-sessions/extension/session-manager-utils.ts index 93a650dafdf..15f555afddf 100644 --- a/src/shared/kilocode/cli-sessions/extension/session-manager-utils.ts +++ b/src/shared/kilocode/cli-sessions/extension/session-manager-utils.ts @@ -68,9 +68,3 @@ export function kilo_initializeSessionManager({ } }) } - -export async function kilo_destroySessionManager() { - return kilo_execIfExtension(() => { - return SessionManager.init().destroy() - }) -} From 5a601a9fa231b01f52e3a9d714371958d0664454 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 4 Dec 2025 17:35:44 +0100 Subject: [PATCH 02/20] some stuff failing should stop the flow, other shouldn't --- .../cli-sessions/core/SessionManager.ts | 51 +++++++++++-------- 1 file changed, 31 insertions(+), 20 deletions(-) diff --git a/src/shared/kilocode/cli-sessions/core/SessionManager.ts b/src/shared/kilocode/cli-sessions/core/SessionManager.ts index 0721e50d742..788d9fb359d 100644 --- a/src/shared/kilocode/cli-sessions/core/SessionManager.ts +++ b/src/shared/kilocode/cli-sessions/core/SessionManager.ts @@ -409,18 +409,18 @@ export class SessionManager { this.isSyncing = true - // TODO: logging - // TODO: (some) promises should have catches + if (!this.platform || !this.sessionClient || !this.sessionPersistenceManager) { + this.logger?.error("SessionManager used before initialization", "SessionManager") + return + } - try { - if (!this.platform || !this.sessionClient || !this.sessionPersistenceManager) { - throw new Error("SessionManager used before initialization") - } + // TODO: logging - const taskIds = new Set(this.queue.map((item) => item.taskId)) - const lastItem = this.queue[this.queue.length - 1] + const taskIds = new Set(this.queue.map((item) => item.taskId)) + const lastItem = this.queue[this.queue.length - 1] - for (const taskId of taskIds) { + for (const taskId of taskIds) { + try { const taskItems = this.queue.filter((item) => item.taskId === taskId) const reversedTaskItems = [...taskItems].reverse() @@ -499,6 +499,11 @@ export class SessionManager { this.queue = this.queue.filter( (item) => item.blobName !== blobName && item.taskId !== taskId, ) + }) + .catch((error) => { + this.logger?.error("Failed to upload blob", "SessionManager", { + error: error instanceof Error ? error.message : String(error), + }) }), ) @@ -531,24 +536,30 @@ export class SessionManager { if (gitStateHash !== this.taskGitHashes[taskId]) { this.taskGitHashes[taskId] = gitStateHash - blobUploads.push(this.sessionClient.uploadBlob(sessionId, "git_state", gitStateData)) + blobUploads.push( + this.sessionClient.uploadBlob(sessionId, "git_state", gitStateData).catch((error) => { + this.logger?.error("Failed to upload git state", "SessionManager", { + error: error instanceof Error ? error.message : String(error), + }) + }), + ) } } await Promise.all(blobUploads) + } catch (error) { + this.logger?.error("Failed to sync session", "SessionManager", { + error: error instanceof Error ? error.message : String(error), + }) + } finally { + this.isSyncing = false } + } - this.lastSessionId = this.sessionPersistenceManager.getSessionForTask(lastItem.taskId) || null + this.lastSessionId = this.sessionPersistenceManager.getSessionForTask(lastItem.taskId) || null - if (this.lastSessionId) { - this.sessionPersistenceManager.setLastSession(this.lastSessionId) - } - } catch (error) { - this.logger?.error("Failed to sync session", "SessionManager", { - error: error instanceof Error ? error.message : String(error), - }) - } finally { - this.isSyncing = false + if (this.lastSessionId) { + this.sessionPersistenceManager.setLastSession(this.lastSessionId) } } From fc337d4bd71f1a062f4522f67e070b8bf7e18e91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 4 Dec 2025 18:02:10 +0100 Subject: [PATCH 03/20] add logging and fix some bugs --- .../cli-sessions/core/SessionManager.ts | 165 ++++++++++++++---- 1 file changed, 132 insertions(+), 33 deletions(-) diff --git a/src/shared/kilocode/cli-sessions/core/SessionManager.ts b/src/shared/kilocode/cli-sessions/core/SessionManager.ts index 788d9fb359d..80c2b8d91df 100644 --- a/src/shared/kilocode/cli-sessions/core/SessionManager.ts +++ b/src/shared/kilocode/cli-sessions/core/SessionManager.ts @@ -97,6 +97,7 @@ export class SessionManager { taskId: string blobName: string blobPath: string + timestamp: number }[] handleFileUpdate(taskId: string, key: string, value: string) { @@ -107,6 +108,7 @@ export class SessionManager { taskId, blobName, blobPath: value, + timestamp: Date.now(), }) } } @@ -394,6 +396,7 @@ export class SessionManager { private async syncSession(force = false) { if (!force) { if (this.isSyncing) { + this.logger?.debug("Sync already in progress, skipping", "SessionManager") return } } @@ -403,58 +406,77 @@ export class SessionManager { } if (process.env.KILO_DISABLE_SESSIONS) { + this.logger?.debug("Sessions disabled via KILO_DISABLE_SESSIONS, clearing queue", "SessionManager") this.queue = [] return } - this.isSyncing = true - if (!this.platform || !this.sessionClient || !this.sessionPersistenceManager) { this.logger?.error("SessionManager used before initialization", "SessionManager") return } - // TODO: logging + this.isSyncing = true const taskIds = new Set(this.queue.map((item) => item.taskId)) const lastItem = this.queue[this.queue.length - 1] + this.logger?.debug("Starting session sync", "SessionManager", { + queueLength: this.queue.length, + taskCount: taskIds.size, + }) + + let gitInfo: Awaited> | null = null + try { + gitInfo = await this.getGitState() + } catch (error) { + this.logger?.debug("Could not get git state", "SessionManager", { + error: error instanceof Error ? error.message : String(error), + }) + } + for (const taskId of taskIds) { try { const taskItems = this.queue.filter((item) => item.taskId === taskId) const reversedTaskItems = [...taskItems].reverse() + this.logger?.debug("Processing task", "SessionManager", { + taskId, + itemCount: taskItems.length, + }) + const basePayload: Omit< Parameters["create"]>[0], "created_on_platform" > = {} - let gitInfo: Awaited> | null = null - try { - gitInfo = await this.getGitState() - if (gitInfo?.repoUrl) { - basePayload.git_url = gitInfo.repoUrl - - this.taskGitUrls[taskId] = gitInfo.repoUrl - } - } catch (error) { - this.logger?.debug("Could not get git state", "SessionManager", { - error: error instanceof Error ? error.message : String(error), - }) + if (gitInfo?.repoUrl) { + basePayload.git_url = gitInfo.repoUrl } let sessionId = this.sessionPersistenceManager.getSessionForTask(taskId) if (sessionId) { + this.logger?.debug("Found existing session for task", "SessionManager", { taskId, sessionId }) + const gitUrlChanged = !!gitInfo?.repoUrl && gitInfo.repoUrl !== this.taskGitUrls[taskId] - if (gitUrlChanged) { + if (gitUrlChanged && gitInfo?.repoUrl) { + this.taskGitUrls[taskId] = gitInfo.repoUrl + + this.logger?.debug("Git URL changed, updating session", "SessionManager", { + sessionId, + newGitUrl: gitInfo.repoUrl, + }) + await this.sessionClient.update({ session_id: sessionId, ...basePayload, }) } } else { + this.logger?.debug("Creating new session for task", "SessionManager", { taskId }) + const createdSession = await this.sessionClient.create({ ...basePayload, created_on_platform: process.env.KILO_PLATFORM || this.platform, @@ -462,6 +484,8 @@ export class SessionManager { sessionId = createdSession.session_id + this.logger?.info("Created new session", "SessionManager", { taskId, sessionId }) + this.sessionPersistenceManager.setSessionForTask(taskId, createdSession.session_id) this.onSessionCreated?.({ @@ -472,55 +496,110 @@ export class SessionManager { } if (!sessionId) { - // this should never happen + this.logger?.warn("No session ID available after create/get, skipping task", "SessionManager", { + taskId, + }) continue } const blobNames = new Set(taskItems.map((item) => item.blobName)) const blobUploads: Promise[] = [] + this.logger?.debug("Uploading blobs for session", "SessionManager", { + sessionId, + blobNames: Array.from(blobNames), + }) + for (const blobName of blobNames) { const lastBlobItem = reversedTaskItems.find((item) => item.blobName === blobName) if (!lastBlobItem) { - // this should also never happen - // famous last words + this.logger?.warn("Could not find blob item in reversed list", "SessionManager", { + blobName, + taskId, + }) continue } + const fileContents = JSON.parse(readFileSync(lastBlobItem.blobPath, "utf-8")) + blobUploads.push( this.sessionClient .uploadBlob( sessionId, lastBlobItem.blobName as Parameters[1], - JSON.parse(readFileSync(lastBlobItem.blobPath, "utf-8")), + fileContents, ) .then(() => { - this.queue = this.queue.filter( - (item) => item.blobName !== blobName && item.taskId !== taskId, - ) + this.logger?.debug("Blob uploaded successfully", "SessionManager", { + sessionId, + blobName, + }) + + for (let i = 0; i < this.queue.length; i++) { + const item = this.queue[i] + + if ( + item.blobName === blobName && + item.taskId === taskId && + item.timestamp <= lastBlobItem.timestamp + ) { + this.queue.splice(i, 1) + i-- + } + } }) .catch((error) => { this.logger?.error("Failed to upload blob", "SessionManager", { + sessionId, + blobName, error: error instanceof Error ? error.message : String(error), }) }), ) if (blobName === "uiMessages" && !this.sessionTitles[sessionId]) { - this.sessionClient - .get({ - session_id: sessionId, - }) - .then((session) => { + this.logger?.debug("Checking for session title generation", "SessionManager", { sessionId }) + + void (async () => { + try { + if (!this.sessionClient) { + this.logger?.warn("Session client not initialized", "SessionManager", { sessionId }) + return + } + + const session = await this.sessionClient.get({ session_id: sessionId }) + if (session.title) { this.sessionTitles[sessionId] = session.title + this.logger?.debug("Found existing session title", "SessionManager", { + sessionId, + title: session.title, + }) + return - } else { - return this.generateTitle(JSON.parse(readFileSync(lastBlobItem.blobPath, "utf-8"))) } - }) + + const generatedTitle = await this.generateTitle(fileContents) + + if (!generatedTitle) { + throw new Error("Failed to generate session title") + } + + await this.sessionClient.update({ + session_id: sessionId, + title: generatedTitle, + }) + + this.sessionTitles[sessionId] = generatedTitle + } catch (error) { + this.logger?.error("Failed to get session", "SessionManager", { + sessionId, + error: error instanceof Error ? error.message : String(error), + }) + } + })() } } @@ -534,25 +613,38 @@ export class SessionManager { const gitStateHash = this.hashGitState(gitStateData) if (gitStateHash !== this.taskGitHashes[taskId]) { + this.logger?.debug("Git state changed, uploading", "SessionManager", { + sessionId, + head: gitInfo.head?.substring(0, 8), + }) + this.taskGitHashes[taskId] = gitStateHash blobUploads.push( this.sessionClient.uploadBlob(sessionId, "git_state", gitStateData).catch((error) => { this.logger?.error("Failed to upload git state", "SessionManager", { + sessionId, error: error instanceof Error ? error.message : String(error), }) }), ) + } else { + this.logger?.debug("Git state unchanged, skipping upload", "SessionManager", { sessionId }) } } await Promise.all(blobUploads) + + this.logger?.debug("Completed blob uploads for task", "SessionManager", { + taskId, + sessionId, + uploadCount: blobUploads.length, + }) } catch (error) { this.logger?.error("Failed to sync session", "SessionManager", { + taskId, error: error instanceof Error ? error.message : String(error), }) - } finally { - this.isSyncing = false } } @@ -561,6 +653,13 @@ export class SessionManager { if (this.lastSessionId) { this.sessionPersistenceManager.setLastSession(this.lastSessionId) } + + this.logger?.debug("Session sync completed", "SessionManager", { + lastSessionId: this.lastSessionId, + remainingQueueLength: this.queue.length, + }) + + this.isSyncing = false } private async fetchBlobFromSignedUrl(url: string, urlType: string) { From 489b3669c34f437dfd7c4b9a692cf7d84fff73a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 4 Dec 2025 18:03:22 +0100 Subject: [PATCH 04/20] add changeset --- .changeset/common-buckets-tickle.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/common-buckets-tickle.md diff --git a/.changeset/common-buckets-tickle.md b/.changeset/common-buckets-tickle.md new file mode 100644 index 00000000000..bafdcdf1211 --- /dev/null +++ b/.changeset/common-buckets-tickle.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"kilo-code": patch +--- + +refactor session manager to better handle asynchronicity of file save events From 316549f6eb846b391578cad025bb330a3250fdb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 4 Dec 2025 18:07:56 +0100 Subject: [PATCH 05/20] fix cli compilation errors --- .../kilocode/cli-sessions/core/SessionManager.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/shared/kilocode/cli-sessions/core/SessionManager.ts b/src/shared/kilocode/cli-sessions/core/SessionManager.ts index 80c2b8d91df..d6f5c39c974 100644 --- a/src/shared/kilocode/cli-sessions/core/SessionManager.ts +++ b/src/shared/kilocode/cli-sessions/core/SessionManager.ts @@ -539,6 +539,10 @@ export class SessionManager { for (let i = 0; i < this.queue.length; i++) { const item = this.queue[i] + if (!item) { + continue + } + if ( item.blobName === blobName && item.taskId === taskId && @@ -648,10 +652,12 @@ export class SessionManager { } } - this.lastSessionId = this.sessionPersistenceManager.getSessionForTask(lastItem.taskId) || null + if (lastItem) { + this.lastSessionId = this.sessionPersistenceManager.getSessionForTask(lastItem.taskId) || null - if (this.lastSessionId) { - this.sessionPersistenceManager.setLastSession(this.lastSessionId) + if (this.lastSessionId) { + this.sessionPersistenceManager.setLastSession(this.lastSessionId) + } } this.logger?.debug("Session sync completed", "SessionManager", { From 694a8c948868e60cfc99e89f24465d826cc35a46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 4 Dec 2025 18:16:41 +0100 Subject: [PATCH 06/20] rename lastSessionId to lastActiveSessionId --- .../kilocode/cli-sessions/core/SessionManager.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/shared/kilocode/cli-sessions/core/SessionManager.ts b/src/shared/kilocode/cli-sessions/core/SessionManager.ts index d6f5c39c974..8b821e740e7 100644 --- a/src/shared/kilocode/cli-sessions/core/SessionManager.ts +++ b/src/shared/kilocode/cli-sessions/core/SessionManager.ts @@ -47,9 +47,9 @@ export class SessionManager { private sessionTitles: Record = {} public get sessionId() { - return this.lastSessionId + return this.lastActiveSessionId || this.sessionPersistenceManager?.getLastSession()?.sessionId } - private lastSessionId: string | null = null + private lastActiveSessionId: string | null = null private timer: NodeJS.Timeout | null = null private isSyncing: boolean = false @@ -292,11 +292,13 @@ export class SessionManager { } } - async shareSession(sessionId?: string) { + async shareSession(sessionIdInput: string) { if (!this.sessionClient) { throw new Error("SessionManager used before initialization") } + const sessionId = sessionIdInput || this.sessionId + if (!sessionId) { throw new Error("No active session") } @@ -653,15 +655,15 @@ export class SessionManager { } if (lastItem) { - this.lastSessionId = this.sessionPersistenceManager.getSessionForTask(lastItem.taskId) || null + this.lastActiveSessionId = this.sessionPersistenceManager.getSessionForTask(lastItem.taskId) || null - if (this.lastSessionId) { - this.sessionPersistenceManager.setLastSession(this.lastSessionId) + if (this.lastActiveSessionId) { + this.sessionPersistenceManager.setLastSession(this.lastActiveSessionId) } } this.logger?.debug("Session sync completed", "SessionManager", { - lastSessionId: this.lastSessionId, + lastSessionId: this.lastActiveSessionId, remainingQueueLength: this.queue.length, }) From 8a9169804f953da695f23b475de2b3a769c4ee38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 4 Dec 2025 18:17:56 +0100 Subject: [PATCH 07/20] hijack platform at the client level --- src/shared/kilocode/cli-sessions/core/SessionClient.ts | 9 ++++----- src/shared/kilocode/cli-sessions/core/SessionManager.ts | 4 ++-- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/shared/kilocode/cli-sessions/core/SessionClient.ts b/src/shared/kilocode/cli-sessions/core/SessionClient.ts index 34fb5a0dcf9..e104ebd349d 100644 --- a/src/shared/kilocode/cli-sessions/core/SessionClient.ts +++ b/src/shared/kilocode/cli-sessions/core/SessionClient.ts @@ -114,11 +114,10 @@ export class SessionClient { * Create a new session */ async create(input: CreateSessionInput): Promise { - return await this.trpcClient.request( - "cliSessions.create", - "POST", - input, - ) + return await this.trpcClient.request("cliSessions.create", "POST", { + ...input, + created_on_platform: process.env.KILO_PLATFORM || input.created_on_platform, + }) } /** diff --git a/src/shared/kilocode/cli-sessions/core/SessionManager.ts b/src/shared/kilocode/cli-sessions/core/SessionManager.ts index 8b821e740e7..ad91b4bc23a 100644 --- a/src/shared/kilocode/cli-sessions/core/SessionManager.ts +++ b/src/shared/kilocode/cli-sessions/core/SessionManager.ts @@ -368,7 +368,7 @@ export class SessionManager { const session = await this.sessionClient.create({ title, - created_on_platform: process.env.KILO_PLATFORM || this.platform, + created_on_platform: this.platform, }) sessionId = session.session_id @@ -481,7 +481,7 @@ export class SessionManager { const createdSession = await this.sessionClient.create({ ...basePayload, - created_on_platform: process.env.KILO_PLATFORM || this.platform, + created_on_platform: this.platform, }) sessionId = createdSession.session_id From 39538803df17bf87a03681e1e7554e398a8a2e6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 4 Dec 2025 18:18:53 +0100 Subject: [PATCH 08/20] let shareSession have an optional session id param --- src/shared/kilocode/cli-sessions/core/SessionManager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/shared/kilocode/cli-sessions/core/SessionManager.ts b/src/shared/kilocode/cli-sessions/core/SessionManager.ts index ad91b4bc23a..2987f26d3ed 100644 --- a/src/shared/kilocode/cli-sessions/core/SessionManager.ts +++ b/src/shared/kilocode/cli-sessions/core/SessionManager.ts @@ -292,7 +292,7 @@ export class SessionManager { } } - async shareSession(sessionIdInput: string) { + async shareSession(sessionIdInput?: string) { if (!this.sessionClient) { throw new Error("SessionManager used before initialization") } From e155cfe2a5a842f31f6015b1f7e8d833ec4b2b4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 4 Dec 2025 18:26:26 +0100 Subject: [PATCH 09/20] make sure isSyncing is always reset by wrapping everything in a try..finally block --- .../cli-sessions/core/SessionManager.ts | 390 +++++++++--------- 1 file changed, 197 insertions(+), 193 deletions(-) diff --git a/src/shared/kilocode/cli-sessions/core/SessionManager.ts b/src/shared/kilocode/cli-sessions/core/SessionManager.ts index 2987f26d3ed..8da2d97525a 100644 --- a/src/shared/kilocode/cli-sessions/core/SessionManager.ts +++ b/src/shared/kilocode/cli-sessions/core/SessionManager.ts @@ -418,256 +418,260 @@ export class SessionManager { return } - this.isSyncing = true - - const taskIds = new Set(this.queue.map((item) => item.taskId)) - const lastItem = this.queue[this.queue.length - 1] + try { + this.isSyncing = true - this.logger?.debug("Starting session sync", "SessionManager", { - queueLength: this.queue.length, - taskCount: taskIds.size, - }) + const taskIds = new Set(this.queue.map((item) => item.taskId)) + const lastItem = this.queue[this.queue.length - 1] - let gitInfo: Awaited> | null = null - try { - gitInfo = await this.getGitState() - } catch (error) { - this.logger?.debug("Could not get git state", "SessionManager", { - error: error instanceof Error ? error.message : String(error), + this.logger?.debug("Starting session sync", "SessionManager", { + queueLength: this.queue.length, + taskCount: taskIds.size, }) - } - for (const taskId of taskIds) { + let gitInfo: Awaited> | null = null try { - const taskItems = this.queue.filter((item) => item.taskId === taskId) - const reversedTaskItems = [...taskItems].reverse() - - this.logger?.debug("Processing task", "SessionManager", { - taskId, - itemCount: taskItems.length, + gitInfo = await this.getGitState() + } catch (error) { + this.logger?.debug("Could not get git state", "SessionManager", { + error: error instanceof Error ? error.message : String(error), }) + } - const basePayload: Omit< - Parameters["create"]>[0], - "created_on_platform" - > = {} - - if (gitInfo?.repoUrl) { - basePayload.git_url = gitInfo.repoUrl - } - - let sessionId = this.sessionPersistenceManager.getSessionForTask(taskId) + for (const taskId of taskIds) { + try { + const taskItems = this.queue.filter((item) => item.taskId === taskId) + const reversedTaskItems = [...taskItems].reverse() - if (sessionId) { - this.logger?.debug("Found existing session for task", "SessionManager", { taskId, sessionId }) + this.logger?.debug("Processing task", "SessionManager", { + taskId, + itemCount: taskItems.length, + }) - const gitUrlChanged = !!gitInfo?.repoUrl && gitInfo.repoUrl !== this.taskGitUrls[taskId] + const basePayload: Omit< + Parameters["create"]>[0], + "created_on_platform" + > = {} - if (gitUrlChanged && gitInfo?.repoUrl) { - this.taskGitUrls[taskId] = gitInfo.repoUrl + if (gitInfo?.repoUrl) { + basePayload.git_url = gitInfo.repoUrl + } - this.logger?.debug("Git URL changed, updating session", "SessionManager", { - sessionId, - newGitUrl: gitInfo.repoUrl, - }) + let sessionId = this.sessionPersistenceManager.getSessionForTask(taskId) - await this.sessionClient.update({ - session_id: sessionId, - ...basePayload, - }) - } - } else { - this.logger?.debug("Creating new session for task", "SessionManager", { taskId }) + if (sessionId) { + this.logger?.debug("Found existing session for task", "SessionManager", { taskId, sessionId }) - const createdSession = await this.sessionClient.create({ - ...basePayload, - created_on_platform: this.platform, - }) + const gitUrlChanged = !!gitInfo?.repoUrl && gitInfo.repoUrl !== this.taskGitUrls[taskId] - sessionId = createdSession.session_id + if (gitUrlChanged && gitInfo?.repoUrl) { + this.taskGitUrls[taskId] = gitInfo.repoUrl - this.logger?.info("Created new session", "SessionManager", { taskId, sessionId }) + this.logger?.debug("Git URL changed, updating session", "SessionManager", { + sessionId, + newGitUrl: gitInfo.repoUrl, + }) - this.sessionPersistenceManager.setSessionForTask(taskId, createdSession.session_id) + await this.sessionClient.update({ + session_id: sessionId, + ...basePayload, + }) + } + } else { + this.logger?.debug("Creating new session for task", "SessionManager", { taskId }) - this.onSessionCreated?.({ - timestamp: Date.now(), - event: "session_created", - sessionId: createdSession.session_id, - }) - } + const createdSession = await this.sessionClient.create({ + ...basePayload, + created_on_platform: this.platform, + }) - if (!sessionId) { - this.logger?.warn("No session ID available after create/get, skipping task", "SessionManager", { - taskId, - }) - continue - } + sessionId = createdSession.session_id - const blobNames = new Set(taskItems.map((item) => item.blobName)) - const blobUploads: Promise[] = [] + this.logger?.info("Created new session", "SessionManager", { taskId, sessionId }) - this.logger?.debug("Uploading blobs for session", "SessionManager", { - sessionId, - blobNames: Array.from(blobNames), - }) + this.sessionPersistenceManager.setSessionForTask(taskId, createdSession.session_id) - for (const blobName of blobNames) { - const lastBlobItem = reversedTaskItems.find((item) => item.blobName === blobName) + this.onSessionCreated?.({ + timestamp: Date.now(), + event: "session_created", + sessionId: createdSession.session_id, + }) + } - if (!lastBlobItem) { - this.logger?.warn("Could not find blob item in reversed list", "SessionManager", { - blobName, + if (!sessionId) { + this.logger?.warn("No session ID available after create/get, skipping task", "SessionManager", { taskId, }) continue } - const fileContents = JSON.parse(readFileSync(lastBlobItem.blobPath, "utf-8")) - - blobUploads.push( - this.sessionClient - .uploadBlob( - sessionId, - lastBlobItem.blobName as Parameters[1], - fileContents, - ) - .then(() => { - this.logger?.debug("Blob uploaded successfully", "SessionManager", { - sessionId, - blobName, - }) + const blobNames = new Set(taskItems.map((item) => item.blobName)) + const blobUploads: Promise[] = [] - for (let i = 0; i < this.queue.length; i++) { - const item = this.queue[i] + this.logger?.debug("Uploading blobs for session", "SessionManager", { + sessionId, + blobNames: Array.from(blobNames), + }) - if (!item) { - continue - } + for (const blobName of blobNames) { + const lastBlobItem = reversedTaskItems.find((item) => item.blobName === blobName) - if ( - item.blobName === blobName && - item.taskId === taskId && - item.timestamp <= lastBlobItem.timestamp - ) { - this.queue.splice(i, 1) - i-- - } - } + if (!lastBlobItem) { + this.logger?.warn("Could not find blob item in reversed list", "SessionManager", { + blobName, + taskId, }) - .catch((error) => { - this.logger?.error("Failed to upload blob", "SessionManager", { + continue + } + + const fileContents = JSON.parse(readFileSync(lastBlobItem.blobPath, "utf-8")) + + blobUploads.push( + this.sessionClient + .uploadBlob( sessionId, - blobName, - error: error instanceof Error ? error.message : String(error), + lastBlobItem.blobName as Parameters[1], + fileContents, + ) + .then(() => { + this.logger?.debug("Blob uploaded successfully", "SessionManager", { + sessionId, + blobName, + }) + + for (let i = 0; i < this.queue.length; i++) { + const item = this.queue[i] + + if (!item) { + continue + } + + if ( + item.blobName === blobName && + item.taskId === taskId && + item.timestamp <= lastBlobItem.timestamp + ) { + this.queue.splice(i, 1) + i-- + } + } }) - }), - ) + .catch((error) => { + this.logger?.error("Failed to upload blob", "SessionManager", { + sessionId, + blobName, + error: error instanceof Error ? error.message : String(error), + }) + }), + ) - if (blobName === "uiMessages" && !this.sessionTitles[sessionId]) { - this.logger?.debug("Checking for session title generation", "SessionManager", { sessionId }) + if (blobName === "uiMessages" && !this.sessionTitles[sessionId]) { + this.logger?.debug("Checking for session title generation", "SessionManager", { sessionId }) - void (async () => { - try { - if (!this.sessionClient) { - this.logger?.warn("Session client not initialized", "SessionManager", { sessionId }) - return - } + void (async () => { + try { + if (!this.sessionClient) { + this.logger?.warn("Session client not initialized", "SessionManager", { + sessionId, + }) + return + } - const session = await this.sessionClient.get({ session_id: sessionId }) + const session = await this.sessionClient.get({ session_id: sessionId }) - if (session.title) { - this.sessionTitles[sessionId] = session.title + if (session.title) { + this.sessionTitles[sessionId] = session.title - this.logger?.debug("Found existing session title", "SessionManager", { - sessionId, - title: session.title, - }) + this.logger?.debug("Found existing session title", "SessionManager", { + sessionId, + title: session.title, + }) - return - } + return + } - const generatedTitle = await this.generateTitle(fileContents) + const generatedTitle = await this.generateTitle(fileContents) - if (!generatedTitle) { - throw new Error("Failed to generate session title") - } + if (!generatedTitle) { + throw new Error("Failed to generate session title") + } - await this.sessionClient.update({ - session_id: sessionId, - title: generatedTitle, - }) + await this.sessionClient.update({ + session_id: sessionId, + title: generatedTitle, + }) - this.sessionTitles[sessionId] = generatedTitle - } catch (error) { - this.logger?.error("Failed to get session", "SessionManager", { - sessionId, - error: error instanceof Error ? error.message : String(error), - }) - } - })() + this.sessionTitles[sessionId] = generatedTitle + } catch (error) { + this.logger?.error("Failed to get session", "SessionManager", { + sessionId, + error: error instanceof Error ? error.message : String(error), + }) + } + })() + } } - } - if (gitInfo) { - const gitStateData = { - head: gitInfo.head, - patch: gitInfo.patch, - branch: gitInfo.branch, - } + if (gitInfo) { + const gitStateData = { + head: gitInfo.head, + patch: gitInfo.patch, + branch: gitInfo.branch, + } - const gitStateHash = this.hashGitState(gitStateData) + const gitStateHash = this.hashGitState(gitStateData) - if (gitStateHash !== this.taskGitHashes[taskId]) { - this.logger?.debug("Git state changed, uploading", "SessionManager", { - sessionId, - head: gitInfo.head?.substring(0, 8), - }) + if (gitStateHash !== this.taskGitHashes[taskId]) { + this.logger?.debug("Git state changed, uploading", "SessionManager", { + sessionId, + head: gitInfo.head?.substring(0, 8), + }) - this.taskGitHashes[taskId] = gitStateHash + this.taskGitHashes[taskId] = gitStateHash - blobUploads.push( - this.sessionClient.uploadBlob(sessionId, "git_state", gitStateData).catch((error) => { - this.logger?.error("Failed to upload git state", "SessionManager", { - sessionId, - error: error instanceof Error ? error.message : String(error), - }) - }), - ) - } else { - this.logger?.debug("Git state unchanged, skipping upload", "SessionManager", { sessionId }) + blobUploads.push( + this.sessionClient.uploadBlob(sessionId, "git_state", gitStateData).catch((error) => { + this.logger?.error("Failed to upload git state", "SessionManager", { + sessionId, + error: error instanceof Error ? error.message : String(error), + }) + }), + ) + } else { + this.logger?.debug("Git state unchanged, skipping upload", "SessionManager", { sessionId }) + } } - } - await Promise.all(blobUploads) + await Promise.all(blobUploads) - this.logger?.debug("Completed blob uploads for task", "SessionManager", { - taskId, - sessionId, - uploadCount: blobUploads.length, - }) - } catch (error) { - this.logger?.error("Failed to sync session", "SessionManager", { - taskId, - error: error instanceof Error ? error.message : String(error), - }) + this.logger?.debug("Completed blob uploads for task", "SessionManager", { + taskId, + sessionId, + uploadCount: blobUploads.length, + }) + } catch (error) { + this.logger?.error("Failed to sync session", "SessionManager", { + taskId, + error: error instanceof Error ? error.message : String(error), + }) + } } - } - if (lastItem) { - this.lastActiveSessionId = this.sessionPersistenceManager.getSessionForTask(lastItem.taskId) || null + if (lastItem) { + this.lastActiveSessionId = this.sessionPersistenceManager.getSessionForTask(lastItem.taskId) || null - if (this.lastActiveSessionId) { - this.sessionPersistenceManager.setLastSession(this.lastActiveSessionId) + if (this.lastActiveSessionId) { + this.sessionPersistenceManager.setLastSession(this.lastActiveSessionId) + } } - } - - this.logger?.debug("Session sync completed", "SessionManager", { - lastSessionId: this.lastActiveSessionId, - remainingQueueLength: this.queue.length, - }) - this.isSyncing = false + this.logger?.debug("Session sync completed", "SessionManager", { + lastSessionId: this.lastActiveSessionId, + remainingQueueLength: this.queue.length, + }) + } finally { + this.isSyncing = false + } } private async fetchBlobFromSignedUrl(url: string, urlType: string) { From cbdbb16e8aa42c5fc6bc2da9d6a3f1e1c1a5acb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 4 Dec 2025 18:29:06 +0100 Subject: [PATCH 10/20] small improvements --- .../kilocode/cli-sessions/core/SessionManager.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/shared/kilocode/cli-sessions/core/SessionManager.ts b/src/shared/kilocode/cli-sessions/core/SessionManager.ts index 8da2d97525a..36fdf392484 100644 --- a/src/shared/kilocode/cli-sessions/core/SessionManager.ts +++ b/src/shared/kilocode/cli-sessions/core/SessionManager.ts @@ -870,7 +870,7 @@ export class SessionManager { }) } finally { try { - rmSync(patchFile, { recursive: true, force: true }) + rmSync(tempDir, { recursive: true, force: true }) } catch { // Ignore error } @@ -957,13 +957,21 @@ Summary:` cleanedSummary = cleanedSummary.substring(0, 137) + "..." } - return cleanedSummary || rawText.substring(0, 137) + "..." + if (cleanedSummary) { + return cleanedSummary + } + + throw new Error("Empty summary generated") } catch (error) { this.logger?.warn("Failed to generate title using LLM, falling back to truncation", "SessionManager", { error: error instanceof Error ? error.message : String(error), }) - return rawText.substring(0, 137) + "..." + if (rawText.length > 140) { + return rawText.substring(0, 137) + "..." + } + + return rawText } } } From 0236b49c57c68333cede211751cd81dc48dfd12c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 4 Dec 2025 18:47:34 +0100 Subject: [PATCH 11/20] fix api compatibility --- src/shared/kilocode/cli-sessions/core/SessionClient.ts | 2 +- src/shared/kilocode/cli-sessions/core/SessionManager.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/shared/kilocode/cli-sessions/core/SessionClient.ts b/src/shared/kilocode/cli-sessions/core/SessionClient.ts index e104ebd349d..b695c0dd61d 100644 --- a/src/shared/kilocode/cli-sessions/core/SessionClient.ts +++ b/src/shared/kilocode/cli-sessions/core/SessionClient.ts @@ -188,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) diff --git a/src/shared/kilocode/cli-sessions/core/SessionManager.ts b/src/shared/kilocode/cli-sessions/core/SessionManager.ts index 36fdf392484..660875e0362 100644 --- a/src/shared/kilocode/cli-sessions/core/SessionManager.ts +++ b/src/shared/kilocode/cli-sessions/core/SessionManager.ts @@ -565,7 +565,7 @@ export class SessionManager { }), ) - if (blobName === "uiMessages" && !this.sessionTitles[sessionId]) { + if (blobName === "ui_messages" && !this.sessionTitles[sessionId]) { this.logger?.debug("Checking for session title generation", "SessionManager", { sessionId }) void (async () => { @@ -702,11 +702,11 @@ export class SessionManager { private pathKeyToBlobKey(pathKey: string) { switch (pathKey) { case "apiConversationHistoryPath": - return "apiConversationHistory" + return "api_conversation_history" case "uiMessagesPath": - return "uiMessages" + return "ui_messages" case "taskMetadataPath": - return "taskMetadata" + return "task_metadata" default: return null } From 56c2023c271e5d55f553484aa3cf364767449b4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 4 Dec 2025 19:05:13 +0100 Subject: [PATCH 12/20] dedupe title generation --- .../kilocode/cli-sessions/core/SessionManager.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/shared/kilocode/cli-sessions/core/SessionManager.ts b/src/shared/kilocode/cli-sessions/core/SessionManager.ts index 660875e0362..cbcaaa54399 100644 --- a/src/shared/kilocode/cli-sessions/core/SessionManager.ts +++ b/src/shared/kilocode/cli-sessions/core/SessionManager.ts @@ -328,6 +328,8 @@ export class SessionManager { title: trimmedTitle, }) + this.sessionTitles[sessionId] = trimmedTitle + this.logger?.info("Session renamed successfully", "SessionManager", { sessionId, newTitle: trimmedTitle, @@ -577,6 +579,8 @@ export class SessionManager { return } + this.sessionTitles[sessionId] = "Pending title" + const session = await this.sessionClient.get({ session_id: sessionId }) if (session.title) { @@ -602,11 +606,18 @@ export class SessionManager { }) this.sessionTitles[sessionId] = generatedTitle + + this.logger?.debug("Updated session title", "SessionManager", { + sessionId, + generatedTitle, + }) } catch (error) { this.logger?.error("Failed to get session", "SessionManager", { sessionId, error: error instanceof Error ? error.message : String(error), }) + + this.sessionTitles[sessionId] = "" } })() } From 1c6a94a546939b041d88f30c8d6901af0a285252 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 4 Dec 2025 19:09:23 +0100 Subject: [PATCH 13/20] fix failing test --- cli/src/commands/__tests__/new.test.ts | 48 ++++---------------------- 1 file changed, 6 insertions(+), 42 deletions(-) diff --git a/cli/src/commands/__tests__/new.test.ts b/cli/src/commands/__tests__/new.test.ts index 9ec0a898827..0cc55ab0c3b 100644 --- a/cli/src/commands/__tests__/new.test.ts +++ b/cli/src/commands/__tests__/new.test.ts @@ -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 & { destroy: ReturnType } 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(() => { @@ -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) @@ -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 () => { @@ -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).mock.calls[0][0] expect(replacedMessages).toHaveLength(1) expect(replacedMessages[0].type).toBe("welcome") From 20e4e06aedb1c63c89602d950ae290d065ba6425 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 4 Dec 2025 19:13:33 +0100 Subject: [PATCH 14/20] regenerate tests --- .../core/__tests__/SessionManager.spec.ts | 760 ++++++++++++++++++ 1 file changed, 760 insertions(+) create mode 100644 src/shared/kilocode/cli-sessions/core/__tests__/SessionManager.spec.ts diff --git a/src/shared/kilocode/cli-sessions/core/__tests__/SessionManager.spec.ts b/src/shared/kilocode/cli-sessions/core/__tests__/SessionManager.spec.ts new file mode 100644 index 00000000000..df696739a16 --- /dev/null +++ b/src/shared/kilocode/cli-sessions/core/__tests__/SessionManager.spec.ts @@ -0,0 +1,760 @@ +import { SessionManager, SessionManagerDependencies } from "../SessionManager" +import { SessionClient, CliSessionSharedState, SessionWithSignedUrls } from "../SessionClient" +import { SessionPersistenceManager } from "../../utils/SessionPersistenceManager" +import { TrpcClient } from "../TrpcClient" +import type { IPathProvider } from "../../types/IPathProvider" +import type { ILogger } from "../../types/ILogger" +import type { IExtensionMessenger } from "../../types/IExtensionMessenger" +import type { ITaskDataProvider } from "../../types/ITaskDataProvider" +import type { ClineMessage } from "@roo-code/types" +import { readFileSync, writeFileSync, mkdirSync, mkdtempSync, rmSync } from "fs" + +vi.mock("fs", () => ({ + readFileSync: vi.fn(), + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), + mkdtempSync: vi.fn().mockReturnValue("/tmp/kilocode-git-patches-123"), + rmSync: vi.fn(), +})) + +vi.mock("simple-git", () => ({ + default: vi.fn(() => ({ + getRemotes: vi.fn().mockResolvedValue([{ refs: { fetch: "https://github.com/test/repo.git" } }]), + revparse: vi.fn().mockResolvedValue("abc123def456"), + raw: vi.fn().mockResolvedValue(""), + diff: vi.fn().mockResolvedValue("diff content"), + stash: vi.fn().mockResolvedValue(undefined), + stashList: vi.fn().mockResolvedValue({ total: 0 }), + checkout: vi.fn().mockResolvedValue(undefined), + applyPatch: vi.fn().mockResolvedValue(undefined), + })), +})) + +vi.mock("../TrpcClient", () => ({ + TrpcClient: vi.fn().mockImplementation(() => ({ + endpoint: "https://api.kilocode.ai", + getToken: vi.fn().mockResolvedValue("test-token"), + request: vi.fn(), + })), +})) + +vi.mock("../SessionClient", () => ({ + SessionClient: vi.fn().mockImplementation(() => ({ + get: vi.fn(), + create: vi.fn(), + update: vi.fn(), + share: vi.fn(), + fork: vi.fn(), + uploadBlob: vi.fn(), + })), + CliSessionSharedState: { + Public: "public", + }, +})) + +vi.mock("../../utils/SessionPersistenceManager", () => ({ + SessionPersistenceManager: vi.fn().mockImplementation(() => ({ + setWorkspaceDir: vi.fn(), + getLastSession: vi.fn(), + setLastSession: vi.fn(), + getSessionForTask: vi.fn(), + setSessionForTask: vi.fn(), + })), +})) + +const createMockDependencies = (): SessionManagerDependencies => ({ + platform: "vscode", + getToken: vi.fn().mockResolvedValue("test-token"), + pathProvider: { + getTasksDir: vi.fn().mockReturnValue("/home/user/.kilocode/tasks"), + getSessionFilePath: vi.fn().mockImplementation((dir: string) => `${dir}/.kilocode/session.json`), + }, + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + extensionMessenger: { + sendWebviewMessage: vi.fn().mockResolvedValue(undefined), + requestSingleCompletion: vi.fn().mockResolvedValue("Generated title"), + }, + onSessionCreated: vi.fn(), + onSessionRestored: vi.fn(), +}) + +describe("SessionManager", () => { + let manager: SessionManager + let mockDependencies: SessionManagerDependencies + + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + + mockDependencies = createMockDependencies() + + const privateInstance = (SessionManager as unknown as { instance: SessionManager }).instance + if (privateInstance) { + ;(privateInstance as unknown as { timer: NodeJS.Timeout | null }).timer = null + ;(privateInstance as unknown as { sessionClient: SessionClient | undefined }).sessionClient = undefined + ;( + privateInstance as unknown as { sessionPersistenceManager: SessionPersistenceManager | undefined } + ).sessionPersistenceManager = undefined + ;(privateInstance as unknown as { queue: unknown[] }).queue = [] + } + + manager = SessionManager.init(mockDependencies) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + describe("init", () => { + it("should return the singleton instance", () => { + const instance1 = SessionManager.init() + const instance2 = SessionManager.init() + + expect(instance1).toBe(instance2) + }) + + it("should initialize dependencies when provided", () => { + expect(manager.sessionClient).toBeDefined() + expect(manager.sessionPersistenceManager).toBeDefined() + }) + + it("should set up sync interval timer", () => { + expect(vi.getTimerCount()).toBe(1) + }) + }) + + describe("sessionId", () => { + it("should return null when no active session", () => { + vi.mocked(manager.sessionPersistenceManager!.getLastSession).mockReturnValue(undefined) + + expect(manager.sessionId).toBeUndefined() + }) + + it("should return persisted session ID when available", () => { + vi.mocked(manager.sessionPersistenceManager!.getLastSession).mockReturnValue({ + sessionId: "persisted-session-123", + timestamp: Date.now(), + }) + + expect(manager.sessionId).toBe("persisted-session-123") + }) + }) + + describe("setWorkspaceDirectory", () => { + it("should set workspace directory on persistence manager", () => { + manager.setWorkspaceDirectory("/workspace/project") + + expect(manager.sessionPersistenceManager!.setWorkspaceDir).toHaveBeenCalledWith("/workspace/project") + }) + }) + + describe("handleFileUpdate", () => { + it("should add api conversation history to queue", () => { + manager.handleFileUpdate("task-123", "apiConversationHistoryPath", "/path/to/file.json") + + const queue = (manager as unknown as { queue: unknown[] }).queue + expect(queue).toHaveLength(1) + expect(queue[0]).toMatchObject({ + taskId: "task-123", + blobName: "api_conversation_history", + blobPath: "/path/to/file.json", + }) + }) + + it("should add ui messages to queue", () => { + manager.handleFileUpdate("task-123", "uiMessagesPath", "/path/to/ui.json") + + const queue = (manager as unknown as { queue: unknown[] }).queue + expect(queue).toHaveLength(1) + expect(queue[0]).toMatchObject({ + taskId: "task-123", + blobName: "ui_messages", + }) + }) + + it("should add task metadata to queue", () => { + manager.handleFileUpdate("task-123", "taskMetadataPath", "/path/to/metadata.json") + + const queue = (manager as unknown as { queue: unknown[] }).queue + expect(queue).toHaveLength(1) + expect(queue[0]).toMatchObject({ + taskId: "task-123", + blobName: "task_metadata", + }) + }) + + it("should not add unknown path keys to queue", () => { + manager.handleFileUpdate("task-123", "unknownPath", "/path/to/file.json") + + const queue = (manager as unknown as { queue: unknown[] }).queue + expect(queue).toHaveLength(0) + }) + }) + + describe("restoreLastSession", () => { + it("should return false when no persisted session exists", async () => { + vi.mocked(manager.sessionPersistenceManager!.getLastSession).mockReturnValue(undefined) + + const result = await manager.restoreLastSession() + + expect(result).toBe(false) + }) + + it("should return false when manager not initialized", async () => { + ;(manager as unknown as { sessionPersistenceManager: undefined }).sessionPersistenceManager = undefined + + const result = await manager.restoreLastSession() + + expect(result).toBe(false) + }) + + it("should attempt to restore persisted session", async () => { + vi.mocked(manager.sessionPersistenceManager!.getLastSession).mockReturnValue({ + sessionId: "session-to-restore", + timestamp: Date.now(), + }) + + const mockSession: SessionWithSignedUrls = { + session_id: "session-to-restore", + title: "Test Session", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + api_conversation_history_blob_url: null, + task_metadata_blob_url: null, + ui_messages_blob_url: null, + git_state_blob_url: null, + } + + vi.mocked(manager.sessionClient!.get).mockResolvedValue(mockSession) + + const result = await manager.restoreLastSession() + + expect(result).toBe(true) + expect(manager.sessionClient!.get).toHaveBeenCalledWith({ + session_id: "session-to-restore", + include_blob_urls: true, + }) + }) + + it("should return false when restore fails", async () => { + vi.mocked(manager.sessionPersistenceManager!.getLastSession).mockReturnValue({ + sessionId: "session-to-restore", + timestamp: Date.now(), + }) + + vi.mocked(manager.sessionClient!.get).mockRejectedValue(new Error("Network error")) + + const result = await manager.restoreLastSession() + + expect(result).toBe(false) + expect(mockDependencies.logger.warn).toHaveBeenCalled() + }) + }) + + describe("restoreSession", () => { + it("should throw error when manager not initialized and rethrowError is true", async () => { + ;(manager as unknown as { pathProvider: undefined }).pathProvider = undefined + + await expect(manager.restoreSession("session-123", true)).rejects.toThrow( + "SessionManager used before initialization", + ) + }) + + it("should not throw error when manager not initialized and rethrowError is false", async () => { + ;(manager as unknown as { pathProvider: undefined }).pathProvider = undefined + + await expect(manager.restoreSession("session-123")).resolves.toBeUndefined() + expect(mockDependencies.logger.error).toHaveBeenCalled() + }) + + it("should throw error when session not found", async () => { + vi.mocked(manager.sessionClient!.get).mockResolvedValue(undefined as unknown as SessionWithSignedUrls) + + await expect(manager.restoreSession("session-123", true)).rejects.toThrow("Failed to obtain session") + }) + + it("should create session directory", async () => { + const mockSession: SessionWithSignedUrls = { + session_id: "session-123", + title: "Test Session", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + api_conversation_history_blob_url: null, + task_metadata_blob_url: null, + ui_messages_blob_url: null, + git_state_blob_url: null, + } + + vi.mocked(manager.sessionClient!.get).mockResolvedValue(mockSession) + + await manager.restoreSession("session-123") + + expect(mkdirSync).toHaveBeenCalledWith("/home/user/.kilocode/tasks/session-123", { recursive: true }) + }) + + it("should send webview messages to register and show task", async () => { + const mockSession: SessionWithSignedUrls = { + session_id: "session-123", + title: "Test Session", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + api_conversation_history_blob_url: null, + task_metadata_blob_url: null, + ui_messages_blob_url: null, + git_state_blob_url: null, + } + + vi.mocked(manager.sessionClient!.get).mockResolvedValue(mockSession) + + await manager.restoreSession("session-123") + + expect(mockDependencies.extensionMessenger.sendWebviewMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "addTaskToHistory", + }), + ) + expect(mockDependencies.extensionMessenger.sendWebviewMessage).toHaveBeenCalledWith({ + type: "showTaskWithId", + text: "session-123", + }) + }) + + it("should call onSessionRestored callback", async () => { + const mockSession: SessionWithSignedUrls = { + session_id: "session-123", + title: "Test Session", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + api_conversation_history_blob_url: null, + task_metadata_blob_url: null, + ui_messages_blob_url: null, + git_state_blob_url: null, + } + + vi.mocked(manager.sessionClient!.get).mockResolvedValue(mockSession) + + await manager.restoreSession("session-123") + + expect(mockDependencies.onSessionRestored).toHaveBeenCalled() + }) + + it("should fetch and write blobs when URLs are provided", async () => { + const mockSession: SessionWithSignedUrls = { + session_id: "session-123", + title: "Test Session", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + api_conversation_history_blob_url: "https://storage.example.com/api_history.json", + task_metadata_blob_url: null, + ui_messages_blob_url: null, + git_state_blob_url: null, + } + + vi.mocked(manager.sessionClient!.get).mockResolvedValue(mockSession) + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue([{ role: "user", content: "test" }]), + }) + + await manager.restoreSession("session-123") + + expect(global.fetch).toHaveBeenCalledWith("https://storage.example.com/api_history.json") + expect(writeFileSync).toHaveBeenCalled() + }) + + it("should filter checkpoint_saved messages from ui_messages", async () => { + const mockSession: SessionWithSignedUrls = { + session_id: "session-123", + title: "Test Session", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + api_conversation_history_blob_url: null, + task_metadata_blob_url: null, + ui_messages_blob_url: "https://storage.example.com/ui_messages.json", + git_state_blob_url: null, + } + + vi.mocked(manager.sessionClient!.get).mockResolvedValue(mockSession) + + const uiMessages = [ + { say: "text", text: "Hello" }, + { say: "checkpoint_saved", text: "Checkpoint" }, + { say: "text", text: "World" }, + ] + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue(uiMessages), + }) + + await manager.restoreSession("session-123") + + const writeCall = vi + .mocked(writeFileSync) + .mock.calls.find((call) => (call[0] as string).includes("ui_messages")) + expect(writeCall).toBeDefined() + const writtenContent = JSON.parse(writeCall![1] as string) + expect(writtenContent).toHaveLength(2) + expect(writtenContent.every((msg: ClineMessage) => msg.say !== "checkpoint_saved")).toBe(true) + }) + }) + + describe("shareSession", () => { + it("should throw error when manager not initialized", async () => { + ;(manager as unknown as { sessionClient: undefined }).sessionClient = undefined + + await expect(manager.shareSession()).rejects.toThrow("SessionManager used before initialization") + }) + + it("should throw error when no active session", async () => { + vi.mocked(manager.sessionPersistenceManager!.getLastSession).mockReturnValue(undefined) + + await expect(manager.shareSession()).rejects.toThrow("No active session") + }) + + it("should share session with provided session ID", async () => { + vi.mocked(manager.sessionClient!.share).mockResolvedValue({ + share_id: "share-123", + session_id: "session-456", + }) + + const result = await manager.shareSession("session-456") + + expect(manager.sessionClient!.share).toHaveBeenCalledWith({ + session_id: "session-456", + shared_state: CliSessionSharedState.Public, + }) + expect(result).toEqual({ share_id: "share-123", session_id: "session-456" }) + }) + + it("should use current session ID when not provided", async () => { + vi.mocked(manager.sessionPersistenceManager!.getLastSession).mockReturnValue({ + sessionId: "current-session", + timestamp: Date.now(), + }) + vi.mocked(manager.sessionClient!.share).mockResolvedValue({ + share_id: "share-123", + session_id: "current-session", + }) + + await manager.shareSession() + + expect(manager.sessionClient!.share).toHaveBeenCalledWith({ + session_id: "current-session", + shared_state: CliSessionSharedState.Public, + }) + }) + }) + + describe("renameSession", () => { + it("should throw error when manager not initialized", async () => { + ;(manager as unknown as { sessionClient: undefined }).sessionClient = undefined + + await expect(manager.renameSession("session-123", "New Title")).rejects.toThrow( + "SessionManager used before initialization", + ) + }) + + it("should throw error when session ID is empty", async () => { + await expect(manager.renameSession("", "New Title")).rejects.toThrow("No active session") + }) + + it("should throw error when title is empty or whitespace", async () => { + await expect(manager.renameSession("session-123", " ")).rejects.toThrow("Session title cannot be empty") + }) + + it("should update session with trimmed title", async () => { + vi.mocked(manager.sessionClient!.update).mockResolvedValue({ + session_id: "session-123", + title: "New Title", + updated_at: new Date().toISOString(), + }) + + await manager.renameSession("session-123", " New Title ") + + expect(manager.sessionClient!.update).toHaveBeenCalledWith({ + session_id: "session-123", + title: "New Title", + }) + }) + }) + + describe("forkSession", () => { + it("should throw error when manager not initialized", async () => { + ;(manager as unknown as { platform: undefined }).platform = undefined + + await expect(manager.forkSession("share-123")).rejects.toThrow("SessionManager used before initialization") + }) + + it("should fork session and restore it", async () => { + vi.mocked(manager.sessionClient!.fork).mockResolvedValue({ + session_id: "forked-session-456", + }) + + const mockSession: SessionWithSignedUrls = { + session_id: "forked-session-456", + title: "Forked Session", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + api_conversation_history_blob_url: null, + task_metadata_blob_url: null, + ui_messages_blob_url: null, + git_state_blob_url: null, + } + + vi.mocked(manager.sessionClient!.get).mockResolvedValue(mockSession) + + await manager.forkSession("share-123") + + expect(manager.sessionClient!.fork).toHaveBeenCalledWith({ + share_or_session_id: "share-123", + created_on_platform: "vscode", + }) + expect(manager.sessionClient!.get).toHaveBeenCalledWith({ + session_id: "forked-session-456", + include_blob_urls: true, + }) + }) + }) + + describe("getSessionFromTask", () => { + let mockTaskDataProvider: ITaskDataProvider + + beforeEach(() => { + mockTaskDataProvider = { + getTaskWithId: vi.fn().mockResolvedValue({ + historyItem: { task: "Test task" }, + apiConversationHistoryFilePath: "/path/to/api_history.json", + uiMessagesFilePath: "/path/to/ui_messages.json", + }), + } + }) + + it("should throw error when manager not initialized", async () => { + ;(manager as unknown as { platform: undefined }).platform = undefined + + await expect(manager.getSessionFromTask("task-123", mockTaskDataProvider)).rejects.toThrow( + "SessionManager used before initialization", + ) + }) + + it("should return existing session ID when task is already mapped", async () => { + vi.mocked(manager.sessionPersistenceManager!.getSessionForTask).mockReturnValue("existing-session-123") + + const result = await manager.getSessionFromTask("task-123", mockTaskDataProvider) + + expect(result).toBe("existing-session-123") + expect(manager.sessionClient!.create).not.toHaveBeenCalled() + }) + + it("should create new session when task is not mapped", async () => { + vi.mocked(manager.sessionPersistenceManager!.getSessionForTask).mockReturnValue(undefined) + vi.mocked(readFileSync).mockReturnValue(JSON.stringify([])) + vi.mocked(manager.sessionClient!.create).mockResolvedValue({ + session_id: "new-session-456", + title: "Test task", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }) + vi.mocked(manager.sessionClient!.uploadBlob).mockResolvedValue({ + session_id: "new-session-456", + updated_at: new Date().toISOString(), + }) + + const result = await manager.getSessionFromTask("task-123", mockTaskDataProvider) + + expect(result).toBe("new-session-456") + expect(manager.sessionClient!.create).toHaveBeenCalledWith({ + title: "Test task", + created_on_platform: "vscode", + }) + expect(manager.sessionPersistenceManager!.setSessionForTask).toHaveBeenCalledWith( + "task-123", + "new-session-456", + ) + }) + + it("should upload conversation blobs for new session", async () => { + vi.mocked(manager.sessionPersistenceManager!.getSessionForTask).mockReturnValue(undefined) + vi.mocked(readFileSync).mockReturnValue(JSON.stringify([{ role: "user", content: "test" }])) + vi.mocked(manager.sessionClient!.create).mockResolvedValue({ + session_id: "new-session-456", + title: "Test task", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }) + vi.mocked(manager.sessionClient!.uploadBlob).mockResolvedValue({ + session_id: "new-session-456", + updated_at: new Date().toISOString(), + }) + + await manager.getSessionFromTask("task-123", mockTaskDataProvider) + + expect(manager.sessionClient!.uploadBlob).toHaveBeenCalledWith( + "new-session-456", + "api_conversation_history", + expect.any(Array), + ) + expect(manager.sessionClient!.uploadBlob).toHaveBeenCalledWith( + "new-session-456", + "ui_messages", + expect.any(Array), + ) + }) + }) + + describe("getFirstMessageText", () => { + it("should return null for empty messages array", () => { + const result = manager.getFirstMessageText([]) + + expect(result).toBeNull() + }) + + it("should return null when no message has text", () => { + const messages: ClineMessage[] = [{ type: "say", say: "text" } as ClineMessage] + + const result = manager.getFirstMessageText(messages) + + expect(result).toBeNull() + }) + + it("should return first message text", () => { + const messages: ClineMessage[] = [ + { type: "say", say: "text", text: "Hello world" } as ClineMessage, + { type: "say", say: "text", text: "Second message" } as ClineMessage, + ] + + const result = manager.getFirstMessageText(messages) + + expect(result).toBe("Hello world") + }) + + it("should normalize whitespace in message text", () => { + const messages: ClineMessage[] = [{ type: "say", say: "text", text: "Hello \n\t world" } as ClineMessage] + + const result = manager.getFirstMessageText(messages) + + expect(result).toBe("Hello world") + }) + + it("should truncate long messages when truncate is true", () => { + const longText = "A".repeat(200) + const messages: ClineMessage[] = [{ type: "say", say: "text", text: longText } as ClineMessage] + + const result = manager.getFirstMessageText(messages, true) + + expect(result).toHaveLength(140) + expect(result?.endsWith("...")).toBe(true) + }) + + it("should not truncate short messages when truncate is true", () => { + const messages: ClineMessage[] = [{ type: "say", say: "text", text: "Short message" } as ClineMessage] + + const result = manager.getFirstMessageText(messages, true) + + expect(result).toBe("Short message") + }) + + it("should skip messages without text and return first with text", () => { + const messages: ClineMessage[] = [ + { type: "say", say: "text" } as ClineMessage, + { type: "say", say: "text", text: "" } as ClineMessage, + { type: "say", say: "text", text: "Found it" } as ClineMessage, + ] + + const result = manager.getFirstMessageText(messages) + + expect(result).toBe("Found it") + }) + }) + + describe("generateTitle", () => { + it("should return null for empty messages", async () => { + const result = await manager.generateTitle([]) + + expect(result).toBeNull() + }) + + it("should generate title using LLM", async () => { + const messages: ClineMessage[] = [ + { type: "say", say: "text", text: "Create a React component for user authentication" } as ClineMessage, + ] + + vi.mocked(mockDependencies.extensionMessenger.requestSingleCompletion).mockResolvedValue( + "React auth component", + ) + + const result = await manager.generateTitle(messages) + + expect(result).toBe("React auth component") + expect(mockDependencies.extensionMessenger.requestSingleCompletion).toHaveBeenCalledWith( + expect.stringContaining("Create a React component"), + 30000, + ) + }) + + it("should clean quotes from generated title", async () => { + const messages: ClineMessage[] = [{ type: "say", say: "text", text: "Test message" } as ClineMessage] + + vi.mocked(mockDependencies.extensionMessenger.requestSingleCompletion).mockResolvedValue('"Quoted title"') + + const result = await manager.generateTitle(messages) + + expect(result).toBe("Quoted title") + }) + + it("should truncate long generated titles", async () => { + const messages: ClineMessage[] = [{ type: "say", say: "text", text: "Test message" } as ClineMessage] + + vi.mocked(mockDependencies.extensionMessenger.requestSingleCompletion).mockResolvedValue("A".repeat(200)) + + const result = await manager.generateTitle(messages) + + expect(result).toHaveLength(140) + expect(result?.endsWith("...")).toBe(true) + }) + + it("should fall back to truncated message on LLM error", async () => { + const longMessage = "B".repeat(200) + const messages: ClineMessage[] = [{ type: "say", say: "text", text: longMessage } as ClineMessage] + + vi.mocked(mockDependencies.extensionMessenger.requestSingleCompletion).mockRejectedValue( + new Error("LLM error"), + ) + + const result = await manager.generateTitle(messages) + + expect(result).toHaveLength(140) + expect(result?.endsWith("...")).toBe(true) + expect(mockDependencies.logger.warn).toHaveBeenCalled() + }) + + it("should return raw text when short and LLM fails", async () => { + const messages: ClineMessage[] = [{ type: "say", say: "text", text: "Short message" } as ClineMessage] + + vi.mocked(mockDependencies.extensionMessenger.requestSingleCompletion).mockRejectedValue( + new Error("LLM error"), + ) + + const result = await manager.generateTitle(messages) + + expect(result).toBe("Short message") + }) + + it("should fall back to raw text when extension messenger not initialized", async () => { + ;(manager as unknown as { extensionMessenger: undefined }).extensionMessenger = undefined + + const messages: ClineMessage[] = [{ type: "say", say: "text", text: "Test message" } as ClineMessage] + + const result = await manager.generateTitle(messages) + + expect(result).toBe("Test message") + }) + }) +}) From afca3d8296e416afc65797e3a9d2079a4c863174 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 4 Dec 2025 19:25:05 +0100 Subject: [PATCH 15/20] add tests targeting syncSession --- .../cli-sessions/core/SessionManager.ts | 10 +- .../SessionManager.syncSession.spec.ts | 1022 +++++++++++++++++ 2 files changed, 1026 insertions(+), 6 deletions(-) create mode 100644 src/shared/kilocode/cli-sessions/core/__tests__/SessionManager.syncSession.spec.ts diff --git a/src/shared/kilocode/cli-sessions/core/SessionManager.ts b/src/shared/kilocode/cli-sessions/core/SessionManager.ts index cbcaaa54399..73f78ce7c79 100644 --- a/src/shared/kilocode/cli-sessions/core/SessionManager.ts +++ b/src/shared/kilocode/cli-sessions/core/SessionManager.ts @@ -397,12 +397,10 @@ export class SessionManager { } } - private async syncSession(force = false) { - if (!force) { - if (this.isSyncing) { - this.logger?.debug("Sync already in progress, skipping", "SessionManager") - return - } + private async syncSession() { + if (this.isSyncing) { + this.logger?.debug("Sync already in progress, skipping", "SessionManager") + return } if (this.queue.length === 0) { diff --git a/src/shared/kilocode/cli-sessions/core/__tests__/SessionManager.syncSession.spec.ts b/src/shared/kilocode/cli-sessions/core/__tests__/SessionManager.syncSession.spec.ts new file mode 100644 index 00000000000..bcb812dc982 --- /dev/null +++ b/src/shared/kilocode/cli-sessions/core/__tests__/SessionManager.syncSession.spec.ts @@ -0,0 +1,1022 @@ +import { SessionManager, SessionManagerDependencies } from "../SessionManager" +import { SessionClient } from "../SessionClient" +import { SessionPersistenceManager } from "../../utils/SessionPersistenceManager" +import { readFileSync } from "fs" + +const mockGit = { + getRemotes: vi.fn().mockResolvedValue([{ refs: { fetch: "https://github.com/test/repo.git" } }]), + revparse: vi.fn().mockResolvedValue("abc123def456"), + raw: vi.fn().mockResolvedValue(""), + diff: vi.fn().mockResolvedValue("diff content"), + stash: vi.fn().mockResolvedValue(undefined), + stashList: vi.fn().mockResolvedValue({ total: 0 }), + checkout: vi.fn().mockResolvedValue(undefined), + applyPatch: vi.fn().mockResolvedValue(undefined), +} + +vi.mock("fs", () => ({ + readFileSync: vi.fn(), + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), + mkdtempSync: vi.fn().mockReturnValue("/tmp/kilocode-git-patches-123"), + rmSync: vi.fn(), +})) + +vi.mock("simple-git", () => ({ + default: vi.fn(() => mockGit), +})) + +vi.mock("../TrpcClient", () => ({ + TrpcClient: vi.fn().mockImplementation(() => ({ + endpoint: "https://api.kilocode.ai", + getToken: vi.fn().mockResolvedValue("test-token"), + request: vi.fn(), + })), +})) + +vi.mock("../SessionClient", () => ({ + SessionClient: vi.fn().mockImplementation(() => ({ + get: vi.fn(), + create: vi.fn(), + update: vi.fn(), + share: vi.fn(), + fork: vi.fn(), + uploadBlob: vi.fn(), + })), + CliSessionSharedState: { + Public: "public", + }, +})) + +vi.mock("../../utils/SessionPersistenceManager", () => ({ + SessionPersistenceManager: vi.fn().mockImplementation(() => ({ + setWorkspaceDir: vi.fn(), + getLastSession: vi.fn(), + setLastSession: vi.fn(), + getSessionForTask: vi.fn(), + setSessionForTask: vi.fn(), + })), +})) + +const createMockDependencies = (): SessionManagerDependencies => ({ + platform: "vscode", + getToken: vi.fn().mockResolvedValue("test-token"), + pathProvider: { + getTasksDir: vi.fn().mockReturnValue("/home/user/.kilocode/tasks"), + getSessionFilePath: vi.fn().mockImplementation((dir: string) => `${dir}/.kilocode/session.json`), + }, + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + extensionMessenger: { + sendWebviewMessage: vi.fn().mockResolvedValue(undefined), + requestSingleCompletion: vi.fn().mockResolvedValue("Generated title"), + }, + onSessionCreated: vi.fn(), + onSessionRestored: vi.fn(), +}) + +describe("SessionManager.syncSession", () => { + let manager: SessionManager + let mockDependencies: SessionManagerDependencies + let originalEnv: string | undefined + + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + + originalEnv = process.env.KILO_DISABLE_SESSIONS + delete process.env.KILO_DISABLE_SESSIONS + + mockDependencies = createMockDependencies() + + const privateInstance = (SessionManager as unknown as { instance: SessionManager }).instance + if (privateInstance) { + const timer = (privateInstance as unknown as { timer: NodeJS.Timeout | null }).timer + if (timer) { + clearInterval(timer) + } + ;(privateInstance as unknown as { timer: NodeJS.Timeout | null }).timer = null + ;(privateInstance as unknown as { sessionClient: SessionClient | undefined }).sessionClient = undefined + ;( + privateInstance as unknown as { sessionPersistenceManager: SessionPersistenceManager | undefined } + ).sessionPersistenceManager = undefined + ;(privateInstance as unknown as { queue: unknown[] }).queue = [] + ;(privateInstance as unknown as { isSyncing: boolean }).isSyncing = false + ;(privateInstance as unknown as { taskGitUrls: Record }).taskGitUrls = {} + ;(privateInstance as unknown as { taskGitHashes: Record }).taskGitHashes = {} + ;(privateInstance as unknown as { sessionTitles: Record }).sessionTitles = {} + ;(privateInstance as unknown as { lastActiveSessionId: string | null }).lastActiveSessionId = null + } + + manager = SessionManager.init(mockDependencies) + + mockGit.getRemotes.mockResolvedValue([{ refs: { fetch: "https://github.com/test/repo.git" } }]) + mockGit.revparse.mockResolvedValue("abc123def456") + mockGit.raw.mockResolvedValue("") + mockGit.diff.mockResolvedValue("diff content") + }) + + afterEach(() => { + if (originalEnv !== undefined) { + process.env.KILO_DISABLE_SESSIONS = originalEnv + } else { + delete process.env.KILO_DISABLE_SESSIONS + } + vi.useRealTimers() + }) + + const triggerSync = async () => { + const syncSession = (manager as unknown as { syncSession: () => Promise }).syncSession.bind(manager) + await syncSession() + } + + const getQueue = () => (manager as unknown as { queue: unknown[] }).queue + + const getIsSyncing = () => (manager as unknown as { isSyncing: boolean }).isSyncing + + const setIsSyncing = (value: boolean) => { + ;(manager as unknown as { isSyncing: boolean }).isSyncing = value + } + + describe("sync skipping conditions", () => { + it("should skip sync when already syncing", async () => { + setIsSyncing(true) + manager.handleFileUpdate("task-123", "uiMessagesPath", "/path/to/file.json") + + await triggerSync() + + expect(mockDependencies.logger.debug).toHaveBeenCalledWith( + "Sync already in progress, skipping", + "SessionManager", + ) + }) + + it("should return early when queue is empty", async () => { + await triggerSync() + + expect(manager.sessionClient!.create).not.toHaveBeenCalled() + expect(manager.sessionClient!.uploadBlob).not.toHaveBeenCalled() + }) + + it("should clear queue and return when KILO_DISABLE_SESSIONS is set", async () => { + process.env.KILO_DISABLE_SESSIONS = "true" + + manager.handleFileUpdate("task-123", "uiMessagesPath", "/path/to/file.json") + expect(getQueue()).toHaveLength(1) + + await triggerSync() + + expect(getQueue()).toHaveLength(0) + expect(mockDependencies.logger.debug).toHaveBeenCalledWith( + "Sessions disabled via KILO_DISABLE_SESSIONS, clearing queue", + "SessionManager", + ) + + delete process.env.KILO_DISABLE_SESSIONS + }) + + it("should log error and return when manager not initialized", async () => { + ;(manager as unknown as { platform: undefined }).platform = undefined + manager.handleFileUpdate("task-123", "uiMessagesPath", "/path/to/file.json") + + await triggerSync() + + expect(mockDependencies.logger.error).toHaveBeenCalledWith( + "SessionManager used before initialization", + "SessionManager", + ) + }) + }) + + describe("session creation", () => { + it("should create new session when task has no existing session", async () => { + vi.mocked(manager.sessionPersistenceManager!.getSessionForTask).mockReturnValue(undefined) + vi.mocked(manager.sessionClient!.create).mockResolvedValue({ + session_id: "new-session-123", + title: "", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }) + vi.mocked(readFileSync).mockReturnValue(JSON.stringify([])) + vi.mocked(manager.sessionClient!.uploadBlob).mockResolvedValue({ + session_id: "new-session-123", + updated_at: new Date().toISOString(), + }) + + manager.handleFileUpdate("task-123", "uiMessagesPath", "/path/to/file.json") + + await triggerSync() + + expect(manager.sessionClient!.create).toHaveBeenCalledWith({ + created_on_platform: "vscode", + git_url: "https://github.com/test/repo.git", + }) + expect(manager.sessionPersistenceManager!.setSessionForTask).toHaveBeenCalledWith( + "task-123", + "new-session-123", + ) + }) + + it("should call onSessionCreated callback when new session is created", async () => { + vi.mocked(manager.sessionPersistenceManager!.getSessionForTask).mockReturnValue(undefined) + vi.mocked(manager.sessionClient!.create).mockResolvedValue({ + session_id: "new-session-123", + title: "", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }) + vi.mocked(readFileSync).mockReturnValue(JSON.stringify([])) + vi.mocked(manager.sessionClient!.uploadBlob).mockResolvedValue({ + session_id: "new-session-123", + updated_at: new Date().toISOString(), + }) + + manager.handleFileUpdate("task-123", "uiMessagesPath", "/path/to/file.json") + + await triggerSync() + + expect(mockDependencies.onSessionCreated).toHaveBeenCalledWith({ + timestamp: expect.any(Number), + event: "session_created", + sessionId: "new-session-123", + }) + }) + + it("should use existing session when task already has one", async () => { + vi.mocked(manager.sessionPersistenceManager!.getSessionForTask).mockReturnValue("existing-session-456") + vi.mocked(readFileSync).mockReturnValue(JSON.stringify([])) + vi.mocked(manager.sessionClient!.uploadBlob).mockResolvedValue({ + session_id: "existing-session-456", + updated_at: new Date().toISOString(), + }) + + manager.handleFileUpdate("task-123", "uiMessagesPath", "/path/to/file.json") + + await triggerSync() + + expect(manager.sessionClient!.create).not.toHaveBeenCalled() + expect(manager.sessionClient!.uploadBlob).toHaveBeenCalledWith( + "existing-session-456", + "ui_messages", + expect.any(Array), + ) + }) + }) + + describe("blob uploads", () => { + beforeEach(() => { + vi.mocked(manager.sessionPersistenceManager!.getSessionForTask).mockReturnValue("session-123") + vi.mocked(manager.sessionClient!.uploadBlob).mockResolvedValue({ + session_id: "session-123", + updated_at: new Date().toISOString(), + }) + }) + + it("should upload ui_messages blob", async () => { + const uiMessages = [{ type: "say", say: "text", text: "Hello" }] + vi.mocked(readFileSync).mockReturnValue(JSON.stringify(uiMessages)) + + manager.handleFileUpdate("task-123", "uiMessagesPath", "/path/to/ui_messages.json") + + await triggerSync() + + expect(manager.sessionClient!.uploadBlob).toHaveBeenCalledWith("session-123", "ui_messages", uiMessages) + }) + + it("should upload api_conversation_history blob", async () => { + const apiHistory = [{ role: "user", content: "test" }] + vi.mocked(readFileSync).mockReturnValue(JSON.stringify(apiHistory)) + + manager.handleFileUpdate("task-123", "apiConversationHistoryPath", "/path/to/api_history.json") + + await triggerSync() + + expect(manager.sessionClient!.uploadBlob).toHaveBeenCalledWith( + "session-123", + "api_conversation_history", + apiHistory, + ) + }) + + it("should upload task_metadata blob", async () => { + const metadata = { tokensIn: 100, tokensOut: 200 } + vi.mocked(readFileSync).mockReturnValue(JSON.stringify(metadata)) + + manager.handleFileUpdate("task-123", "taskMetadataPath", "/path/to/metadata.json") + + await triggerSync() + + expect(manager.sessionClient!.uploadBlob).toHaveBeenCalledWith("session-123", "task_metadata", metadata) + }) + + it("should upload only the latest blob when multiple updates for same blob type", async () => { + vi.mocked(readFileSync).mockReturnValue(JSON.stringify([{ text: "latest" }])) + + manager.handleFileUpdate("task-123", "uiMessagesPath", "/path/to/file1.json") + manager.handleFileUpdate("task-123", "uiMessagesPath", "/path/to/file2.json") + manager.handleFileUpdate("task-123", "uiMessagesPath", "/path/to/file3.json") + + await triggerSync() + + const uiMessagesUploadCalls = vi + .mocked(manager.sessionClient!.uploadBlob) + .mock.calls.filter((call) => call[1] === "ui_messages") + expect(uiMessagesUploadCalls).toHaveLength(1) + expect(readFileSync).toHaveBeenCalledWith("/path/to/file3.json", "utf-8") + }) + + it("should upload multiple different blob types in single sync", async () => { + vi.mocked(readFileSync).mockReturnValue(JSON.stringify([])) + + manager.handleFileUpdate("task-123", "uiMessagesPath", "/path/to/ui.json") + manager.handleFileUpdate("task-123", "apiConversationHistoryPath", "/path/to/api.json") + manager.handleFileUpdate("task-123", "taskMetadataPath", "/path/to/meta.json") + + await triggerSync() + + expect(manager.sessionClient!.uploadBlob).toHaveBeenCalledWith( + "session-123", + "ui_messages", + expect.any(Array), + ) + expect(manager.sessionClient!.uploadBlob).toHaveBeenCalledWith( + "session-123", + "api_conversation_history", + expect.any(Array), + ) + expect(manager.sessionClient!.uploadBlob).toHaveBeenCalledWith("session-123", "task_metadata", []) + }) + + it("should remove uploaded items from queue after successful upload", async () => { + vi.mocked(readFileSync).mockReturnValue(JSON.stringify([])) + + manager.handleFileUpdate("task-123", "uiMessagesPath", "/path/to/file.json") + expect(getQueue()).toHaveLength(1) + + await triggerSync() + + expect(getQueue()).toHaveLength(0) + }) + + it("should handle blob upload failure gracefully", async () => { + vi.mocked(readFileSync).mockReturnValue(JSON.stringify([])) + vi.mocked(manager.sessionClient!.uploadBlob).mockRejectedValue(new Error("Upload failed")) + + manager.handleFileUpdate("task-123", "uiMessagesPath", "/path/to/file.json") + + await triggerSync() + + expect(mockDependencies.logger.error).toHaveBeenCalledWith("Failed to upload blob", "SessionManager", { + sessionId: "session-123", + blobName: "ui_messages", + error: "Upload failed", + }) + }) + }) + + describe("git state handling", () => { + beforeEach(() => { + vi.mocked(manager.sessionPersistenceManager!.getSessionForTask).mockReturnValue("session-123") + vi.mocked(readFileSync).mockReturnValue(JSON.stringify([])) + vi.mocked(manager.sessionClient!.uploadBlob).mockResolvedValue({ + session_id: "session-123", + updated_at: new Date().toISOString(), + }) + }) + + it("should upload git state when it changes", async () => { + manager.handleFileUpdate("task-123", "uiMessagesPath", "/path/to/file.json") + + await triggerSync() + + expect(manager.sessionClient!.uploadBlob).toHaveBeenCalledWith("session-123", "git_state", { + head: "abc123def456", + patch: "diff content", + branch: "", + }) + }) + + it("should not upload git state when unchanged", async () => { + manager.handleFileUpdate("task-123", "uiMessagesPath", "/path/to/file1.json") + await triggerSync() + + vi.mocked(manager.sessionClient!.uploadBlob).mockClear() + + manager.handleFileUpdate("task-123", "uiMessagesPath", "/path/to/file2.json") + await triggerSync() + + const gitStateUploadCalls = vi + .mocked(manager.sessionClient!.uploadBlob) + .mock.calls.filter((call) => call[1] === "git_state") + expect(gitStateUploadCalls).toHaveLength(0) + }) + + it("should handle git state fetch failure gracefully", async () => { + mockGit.getRemotes.mockRejectedValueOnce(new Error("Git error")) + + manager.handleFileUpdate("task-123", "uiMessagesPath", "/path/to/file.json") + + await triggerSync() + + expect(mockDependencies.logger.debug).toHaveBeenCalledWith( + "Could not get git state", + "SessionManager", + expect.any(Object), + ) + }) + }) + + describe("git URL updates", () => { + it("should update session when git URL changes", async () => { + vi.mocked(manager.sessionPersistenceManager!.getSessionForTask).mockReturnValue("session-123") + vi.mocked(readFileSync).mockReturnValue(JSON.stringify([])) + vi.mocked(manager.sessionClient!.uploadBlob).mockResolvedValue({ + session_id: "session-123", + updated_at: new Date().toISOString(), + }) + vi.mocked(manager.sessionClient!.update).mockResolvedValue({ + session_id: "session-123", + title: "", + updated_at: new Date().toISOString(), + }) + + const taskGitUrls = (manager as unknown as { taskGitUrls: Record }).taskGitUrls + taskGitUrls["task-123"] = "https://github.com/old/repo.git" + + manager.handleFileUpdate("task-123", "uiMessagesPath", "/path/to/file.json") + + await triggerSync() + + expect(manager.sessionClient!.update).toHaveBeenCalledWith({ + session_id: "session-123", + git_url: "https://github.com/test/repo.git", + }) + }) + }) + + describe("title generation", () => { + beforeEach(() => { + vi.mocked(manager.sessionPersistenceManager!.getSessionForTask).mockReturnValue("session-123") + vi.mocked(manager.sessionClient!.uploadBlob).mockResolvedValue({ + session_id: "session-123", + updated_at: new Date().toISOString(), + }) + }) + + it("should check for title generation when uploading ui_messages blob", async () => { + const uiMessages = [{ type: "say", say: "text", text: "Create a login form" }] + vi.mocked(readFileSync).mockReturnValue(JSON.stringify(uiMessages)) + vi.mocked(manager.sessionClient!.get).mockResolvedValue({ + session_id: "session-123", + title: "", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }) + vi.mocked(manager.sessionClient!.update).mockResolvedValue({ + session_id: "session-123", + title: "Login form creation", + updated_at: new Date().toISOString(), + }) + + manager.handleFileUpdate("task-123", "uiMessagesPath", "/path/to/file.json") + + vi.useRealTimers() + await triggerSync() + + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(manager.sessionClient!.get).toHaveBeenCalledWith({ session_id: "session-123" }) + }) + + it("should use existing title when session already has one", async () => { + const uiMessages = [{ type: "say", say: "text", text: "Create a login form" }] + vi.mocked(readFileSync).mockReturnValue(JSON.stringify(uiMessages)) + vi.mocked(manager.sessionClient!.get).mockResolvedValue({ + session_id: "session-123", + title: "Existing title", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }) + + manager.handleFileUpdate("task-123", "uiMessagesPath", "/path/to/file.json") + + vi.useRealTimers() + await triggerSync() + + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(mockDependencies.extensionMessenger.requestSingleCompletion).not.toHaveBeenCalled() + }) + }) + + describe("multiple tasks handling", () => { + it("should process multiple tasks in single sync", async () => { + vi.mocked(manager.sessionPersistenceManager!.getSessionForTask) + .mockReturnValueOnce("session-1") + .mockReturnValueOnce("session-2") + vi.mocked(readFileSync).mockReturnValue(JSON.stringify([])) + vi.mocked(manager.sessionClient!.uploadBlob).mockResolvedValue({ + session_id: "session-1", + updated_at: new Date().toISOString(), + }) + + manager.handleFileUpdate("task-1", "uiMessagesPath", "/path/to/file1.json") + manager.handleFileUpdate("task-2", "uiMessagesPath", "/path/to/file2.json") + + await triggerSync() + + expect(manager.sessionClient!.uploadBlob).toHaveBeenCalledWith( + "session-1", + "ui_messages", + expect.any(Array), + ) + expect(manager.sessionClient!.uploadBlob).toHaveBeenCalledWith( + "session-2", + "ui_messages", + expect.any(Array), + ) + }) + + it("should update lastActiveSessionId to the last task's session", async () => { + vi.mocked(manager.sessionPersistenceManager!.getSessionForTask) + .mockReturnValueOnce("session-1") + .mockReturnValueOnce("session-2") + .mockReturnValueOnce("session-2") + vi.mocked(readFileSync).mockReturnValue(JSON.stringify([])) + vi.mocked(manager.sessionClient!.uploadBlob).mockResolvedValue({ + session_id: "session-2", + updated_at: new Date().toISOString(), + }) + + manager.handleFileUpdate("task-1", "uiMessagesPath", "/path/to/file1.json") + manager.handleFileUpdate("task-2", "uiMessagesPath", "/path/to/file2.json") + + await triggerSync() + + expect(manager.sessionPersistenceManager!.setLastSession).toHaveBeenCalledWith("session-2") + }) + }) + + describe("isSyncing flag", () => { + it("should reset isSyncing to false after sync completes", async () => { + vi.mocked(manager.sessionPersistenceManager!.getSessionForTask).mockReturnValue("session-123") + vi.mocked(readFileSync).mockReturnValue(JSON.stringify([])) + vi.mocked(manager.sessionClient!.uploadBlob).mockResolvedValue({ + session_id: "session-123", + updated_at: new Date().toISOString(), + }) + + manager.handleFileUpdate("task-123", "uiMessagesPath", "/path/to/file.json") + + await triggerSync() + + expect(getIsSyncing()).toBe(false) + }) + + it("should reset isSyncing to false even on error", async () => { + vi.mocked(manager.sessionPersistenceManager!.getSessionForTask).mockReturnValue("session-123") + vi.mocked(readFileSync).mockImplementation(() => { + throw new Error("Read error") + }) + + manager.handleFileUpdate("task-123", "uiMessagesPath", "/path/to/file.json") + + await triggerSync() + + expect(getIsSyncing()).toBe(false) + }) + }) + + describe("error handling", () => { + it("should continue processing other tasks when one fails", async () => { + vi.mocked(manager.sessionPersistenceManager!.getSessionForTask) + .mockReturnValueOnce("session-1") + .mockReturnValueOnce("session-2") + + vi.mocked(readFileSync) + .mockImplementationOnce(() => { + throw new Error("Read error for task 1") + }) + .mockReturnValueOnce(JSON.stringify([])) + + vi.mocked(manager.sessionClient!.uploadBlob).mockResolvedValue({ + session_id: "session-2", + updated_at: new Date().toISOString(), + }) + + manager.handleFileUpdate("task-1", "uiMessagesPath", "/path/to/file1.json") + manager.handleFileUpdate("task-2", "uiMessagesPath", "/path/to/file2.json") + + await triggerSync() + + expect(mockDependencies.logger.error).toHaveBeenCalledWith( + "Failed to sync session", + "SessionManager", + expect.objectContaining({ taskId: "task-1" }), + ) + expect(manager.sessionClient!.uploadBlob).toHaveBeenCalledWith( + "session-2", + "ui_messages", + expect.any(Array), + ) + }) + + it("should warn when no session ID available after create/get", async () => { + vi.mocked(manager.sessionPersistenceManager!.getSessionForTask).mockReturnValue(undefined) + vi.mocked(manager.sessionClient!.create).mockResolvedValue({ + session_id: "", + title: "", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }) + + manager.handleFileUpdate("task-123", "uiMessagesPath", "/path/to/file.json") + + await triggerSync() + + expect(mockDependencies.logger.warn).toHaveBeenCalledWith( + "No session ID available after create/get, skipping task", + "SessionManager", + { taskId: "task-123" }, + ) + }) + }) + + describe("race conditions", () => { + describe("title generation race conditions", () => { + beforeEach(() => { + vi.mocked(manager.sessionPersistenceManager!.getSessionForTask).mockReturnValue("session-123") + vi.mocked(manager.sessionClient!.uploadBlob).mockResolvedValue({ + session_id: "session-123", + updated_at: new Date().toISOString(), + }) + }) + + it("should not trigger multiple title generations for the same session", async () => { + const uiMessages = [{ type: "say", say: "text", text: "Create a login form" }] + vi.mocked(readFileSync).mockReturnValue(JSON.stringify(uiMessages)) + + let getCallCount = 0 + vi.mocked(manager.sessionClient!.get).mockImplementation(async () => { + getCallCount++ + await new Promise((resolve) => setTimeout(resolve, 50)) + return { + session_id: "session-123", + title: "", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + } + }) + vi.mocked(manager.sessionClient!.update).mockResolvedValue({ + session_id: "session-123", + title: "Generated title", + updated_at: new Date().toISOString(), + }) + + manager.handleFileUpdate("task-123", "uiMessagesPath", "/path/to/file1.json") + + vi.useRealTimers() + await triggerSync() + + manager.handleFileUpdate("task-123", "uiMessagesPath", "/path/to/file2.json") + await triggerSync() + + await new Promise((resolve) => setTimeout(resolve, 200)) + + expect(getCallCount).toBe(1) + }) + + it("should handle title generation failure without affecting subsequent syncs", async () => { + const uiMessages = [{ type: "say", say: "text", text: "Create a login form" }] + vi.mocked(readFileSync).mockReturnValue(JSON.stringify(uiMessages)) + vi.mocked(manager.sessionClient!.get).mockRejectedValueOnce(new Error("Network error")) + + manager.handleFileUpdate("task-123", "uiMessagesPath", "/path/to/file.json") + + vi.useRealTimers() + await triggerSync() + + await new Promise((resolve) => setTimeout(resolve, 100)) + + const sessionTitles = (manager as unknown as { sessionTitles: Record }).sessionTitles + expect(sessionTitles["session-123"]).toBe("") + + vi.mocked(manager.sessionClient!.get).mockResolvedValue({ + session_id: "session-123", + title: "", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }) + vi.mocked(manager.sessionClient!.update).mockResolvedValue({ + session_id: "session-123", + title: "Generated title", + updated_at: new Date().toISOString(), + }) + + sessionTitles["session-123"] = "" + + manager.handleFileUpdate("task-123", "uiMessagesPath", "/path/to/file2.json") + await triggerSync() + + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(manager.sessionClient!.get).toHaveBeenCalledTimes(2) + }) + + it("should set pending title marker to prevent concurrent title generation", async () => { + const uiMessages = [{ type: "say", say: "text", text: "Create a login form" }] + vi.mocked(readFileSync).mockReturnValue(JSON.stringify(uiMessages)) + + let titleDuringGet: string | undefined + vi.mocked(manager.sessionClient!.get).mockImplementation(async () => { + const sessionTitles = (manager as unknown as { sessionTitles: Record }) + .sessionTitles + titleDuringGet = sessionTitles["session-123"] + return { + session_id: "session-123", + title: "", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + } + }) + vi.mocked(manager.sessionClient!.update).mockResolvedValue({ + session_id: "session-123", + title: "Generated title", + updated_at: new Date().toISOString(), + }) + + manager.handleFileUpdate("task-123", "uiMessagesPath", "/path/to/file.json") + + vi.useRealTimers() + await triggerSync() + + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(titleDuringGet).toBe("Pending title") + }) + }) + + describe("concurrent sync attempts", () => { + it("should prevent concurrent syncs via isSyncing flag", async () => { + vi.mocked(manager.sessionPersistenceManager!.getSessionForTask).mockReturnValue("session-123") + vi.mocked(readFileSync).mockReturnValue(JSON.stringify([])) + + let uploadStarted = false + let uploadCompleted = false + vi.mocked(manager.sessionClient!.uploadBlob).mockImplementation(async () => { + uploadStarted = true + await new Promise((resolve) => setTimeout(resolve, 100)) + uploadCompleted = true + return { + session_id: "session-123", + updated_at: new Date().toISOString(), + } + }) + + manager.handleFileUpdate("task-123", "uiMessagesPath", "/path/to/file.json") + + vi.useRealTimers() + const sync1 = triggerSync() + + await new Promise((resolve) => setTimeout(resolve, 10)) + expect(uploadStarted).toBe(true) + expect(uploadCompleted).toBe(false) + + manager.handleFileUpdate("task-123", "uiMessagesPath", "/path/to/file2.json") + const sync2 = triggerSync() + + await Promise.all([sync1, sync2]) + + expect(mockDependencies.logger.debug).toHaveBeenCalledWith( + "Sync already in progress, skipping", + "SessionManager", + ) + }) + + it("should process queued items after blocked sync completes", async () => { + vi.mocked(manager.sessionPersistenceManager!.getSessionForTask).mockReturnValue("session-123") + vi.mocked(readFileSync).mockReturnValue(JSON.stringify([])) + vi.mocked(manager.sessionClient!.uploadBlob).mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)) + return { + session_id: "session-123", + updated_at: new Date().toISOString(), + } + }) + + manager.handleFileUpdate("task-123", "uiMessagesPath", "/path/to/file1.json") + + vi.useRealTimers() + const sync1 = triggerSync() + + await new Promise((resolve) => setTimeout(resolve, 10)) + + manager.handleFileUpdate("task-123", "uiMessagesPath", "/path/to/file2.json") + await triggerSync() + + await sync1 + + expect(getQueue()).toHaveLength(1) + + await triggerSync() + + expect(getQueue()).toHaveLength(0) + }) + }) + + describe("queue modification during sync", () => { + it("should handle items added to queue during sync", async () => { + vi.mocked(manager.sessionPersistenceManager!.getSessionForTask).mockReturnValue("session-123") + vi.mocked(readFileSync).mockReturnValue(JSON.stringify([])) + + let syncInProgress = false + vi.mocked(manager.sessionClient!.uploadBlob).mockImplementation(async () => { + syncInProgress = true + await new Promise((resolve) => setTimeout(resolve, 50)) + syncInProgress = false + return { + session_id: "session-123", + updated_at: new Date().toISOString(), + } + }) + + manager.handleFileUpdate("task-123", "uiMessagesPath", "/path/to/file1.json") + + vi.useRealTimers() + const syncPromise = triggerSync() + + await new Promise((resolve) => setTimeout(resolve, 10)) + expect(syncInProgress).toBe(true) + + manager.handleFileUpdate("task-123", "uiMessagesPath", "/path/to/file2.json") + + await syncPromise + + expect(getQueue()).toHaveLength(1) + }) + + it("should only remove items with timestamp <= uploaded item timestamp", async () => { + vi.mocked(manager.sessionPersistenceManager!.getSessionForTask).mockReturnValue("session-123") + vi.mocked(readFileSync).mockReturnValue(JSON.stringify([])) + + const uploadTimestamps: number[] = [] + vi.mocked(manager.sessionClient!.uploadBlob).mockImplementation(async () => { + uploadTimestamps.push(Date.now()) + await new Promise((resolve) => setTimeout(resolve, 30)) + return { + session_id: "session-123", + updated_at: new Date().toISOString(), + } + }) + + manager.handleFileUpdate("task-123", "uiMessagesPath", "/path/to/file1.json") + + vi.useRealTimers() + const syncPromise = triggerSync() + + await new Promise((resolve) => setTimeout(resolve, 10)) + + manager.handleFileUpdate("task-123", "uiMessagesPath", "/path/to/file2.json") + + await syncPromise + + const queue = getQueue() as { timestamp: number }[] + expect(queue.length).toBe(1) + expect(queue[0].timestamp).toBeGreaterThan(uploadTimestamps[0]) + }) + }) + + describe("session creation race conditions", () => { + it("should handle rapid session creation requests for same task", async () => { + vi.mocked(manager.sessionPersistenceManager!.getSessionForTask).mockReturnValue(undefined) + vi.mocked(readFileSync).mockReturnValue(JSON.stringify([])) + + let createCallCount = 0 + vi.mocked(manager.sessionClient!.create).mockImplementation(async () => { + createCallCount++ + await new Promise((resolve) => setTimeout(resolve, 50)) + return { + session_id: `session-${createCallCount}`, + title: "", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + } + }) + vi.mocked(manager.sessionClient!.uploadBlob).mockResolvedValue({ + session_id: "session-1", + updated_at: new Date().toISOString(), + }) + + manager.handleFileUpdate("task-123", "uiMessagesPath", "/path/to/file1.json") + + vi.useRealTimers() + await triggerSync() + + vi.mocked(manager.sessionPersistenceManager!.getSessionForTask).mockReturnValue("session-1") + + manager.handleFileUpdate("task-123", "uiMessagesPath", "/path/to/file2.json") + await triggerSync() + + expect(createCallCount).toBe(1) + }) + + it("should handle multiple tasks creating sessions simultaneously", async () => { + vi.mocked(manager.sessionPersistenceManager!.getSessionForTask).mockReturnValue(undefined) + vi.mocked(readFileSync).mockReturnValue(JSON.stringify([])) + + const createdSessions: string[] = [] + vi.mocked(manager.sessionClient!.create).mockImplementation(async () => { + const sessionId = `session-${createdSessions.length + 1}` + createdSessions.push(sessionId) + await new Promise((resolve) => setTimeout(resolve, 20)) + return { + session_id: sessionId, + title: "", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + } + }) + vi.mocked(manager.sessionClient!.uploadBlob).mockResolvedValue({ + session_id: "session-1", + updated_at: new Date().toISOString(), + }) + + manager.handleFileUpdate("task-1", "uiMessagesPath", "/path/to/file1.json") + manager.handleFileUpdate("task-2", "uiMessagesPath", "/path/to/file2.json") + + vi.useRealTimers() + await triggerSync() + + expect(createdSessions).toHaveLength(2) + expect(manager.sessionPersistenceManager!.setSessionForTask).toHaveBeenCalledWith("task-1", "session-1") + expect(manager.sessionPersistenceManager!.setSessionForTask).toHaveBeenCalledWith("task-2", "session-2") + }) + }) + + describe("git state race conditions", () => { + it("should handle git state changes during sync", async () => { + vi.mocked(manager.sessionPersistenceManager!.getSessionForTask).mockReturnValue("session-123") + vi.mocked(readFileSync).mockReturnValue(JSON.stringify([])) + + let gitCallCount = 0 + mockGit.revparse.mockImplementation(async () => { + gitCallCount++ + return `commit-${gitCallCount}` + }) + mockGit.diff.mockImplementation(async () => { + return `diff-${gitCallCount}` + }) + + vi.mocked(manager.sessionClient!.uploadBlob).mockResolvedValue({ + session_id: "session-123", + updated_at: new Date().toISOString(), + }) + + manager.handleFileUpdate("task-123", "uiMessagesPath", "/path/to/file.json") + + vi.useRealTimers() + await triggerSync() + + expect(manager.sessionClient!.uploadBlob).toHaveBeenCalledWith("session-123", "git_state", { + head: "commit-1", + patch: "diff-1", + branch: "", + }) + }) + + it("should use consistent git state hash for deduplication", async () => { + vi.mocked(manager.sessionPersistenceManager!.getSessionForTask).mockReturnValue("session-123") + vi.mocked(readFileSync).mockReturnValue(JSON.stringify([])) + vi.mocked(manager.sessionClient!.uploadBlob).mockResolvedValue({ + session_id: "session-123", + updated_at: new Date().toISOString(), + }) + + mockGit.revparse.mockResolvedValue("same-commit") + mockGit.diff.mockResolvedValue("same-diff") + + manager.handleFileUpdate("task-123", "uiMessagesPath", "/path/to/file1.json") + await triggerSync() + + const gitStateUploads1 = vi + .mocked(manager.sessionClient!.uploadBlob) + .mock.calls.filter((call) => call[1] === "git_state") + expect(gitStateUploads1).toHaveLength(1) + + vi.mocked(manager.sessionClient!.uploadBlob).mockClear() + + manager.handleFileUpdate("task-123", "uiMessagesPath", "/path/to/file2.json") + await triggerSync() + + const gitStateUploads2 = vi + .mocked(manager.sessionClient!.uploadBlob) + .mock.calls.filter((call) => call[1] === "git_state") + expect(gitStateUploads2).toHaveLength(0) + }) + }) + }) +}) From 0e35beff12cd2cc53d44b0329f064b166f7d5821 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 4 Dec 2025 19:35:14 +0100 Subject: [PATCH 16/20] fix copilot remarks --- src/shared/kilocode/cli-sessions/core/SessionManager.ts | 2 +- .../cli-sessions/core/__tests__/SessionManager.spec.ts | 6 +----- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/shared/kilocode/cli-sessions/core/SessionManager.ts b/src/shared/kilocode/cli-sessions/core/SessionManager.ts index 73f78ce7c79..c869c212480 100644 --- a/src/shared/kilocode/cli-sessions/core/SessionManager.ts +++ b/src/shared/kilocode/cli-sessions/core/SessionManager.ts @@ -610,7 +610,7 @@ export class SessionManager { generatedTitle, }) } catch (error) { - this.logger?.error("Failed to get session", "SessionManager", { + this.logger?.error("Failed to generate session title", "SessionManager", { sessionId, error: error instanceof Error ? error.message : String(error), }) diff --git a/src/shared/kilocode/cli-sessions/core/__tests__/SessionManager.spec.ts b/src/shared/kilocode/cli-sessions/core/__tests__/SessionManager.spec.ts index df696739a16..31f09ea3b70 100644 --- a/src/shared/kilocode/cli-sessions/core/__tests__/SessionManager.spec.ts +++ b/src/shared/kilocode/cli-sessions/core/__tests__/SessionManager.spec.ts @@ -1,13 +1,9 @@ import { SessionManager, SessionManagerDependencies } from "../SessionManager" import { SessionClient, CliSessionSharedState, SessionWithSignedUrls } from "../SessionClient" import { SessionPersistenceManager } from "../../utils/SessionPersistenceManager" -import { TrpcClient } from "../TrpcClient" -import type { IPathProvider } from "../../types/IPathProvider" -import type { ILogger } from "../../types/ILogger" -import type { IExtensionMessenger } from "../../types/IExtensionMessenger" import type { ITaskDataProvider } from "../../types/ITaskDataProvider" import type { ClineMessage } from "@roo-code/types" -import { readFileSync, writeFileSync, mkdirSync, mkdtempSync, rmSync } from "fs" +import { readFileSync, writeFileSync, mkdirSync } from "fs" vi.mock("fs", () => ({ readFileSync: vi.fn(), From 699dcafcb3ac785a064b2844ce00c77b36309579 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 5 Dec 2025 12:41:12 +0100 Subject: [PATCH 17/20] remove duplicates from task session map this was caused by a previous race condition --- .../utils/SessionPersistenceManager.ts | 19 +++++++++++++- .../SessionPersistenceManager.test.ts | 26 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/shared/kilocode/cli-sessions/utils/SessionPersistenceManager.ts b/src/shared/kilocode/cli-sessions/utils/SessionPersistenceManager.ts index a8a00c68671..85f285eeba5 100644 --- a/src/shared/kilocode/cli-sessions/utils/SessionPersistenceManager.ts +++ b/src/shared/kilocode/cli-sessions/utils/SessionPersistenceManager.ts @@ -75,7 +75,24 @@ export class SessionPersistenceManager { getTaskSessionMap(): Record { const state = this.readWorkspaceState() - return state.taskSessionMap + return this.deduplicateTaskSessionMap(state.taskSessionMap) + } + + private deduplicateTaskSessionMap(taskSessionMap: Record): Record { + const entries = Object.entries(taskSessionMap) + const sessionToLastTaskId = new Map() + + for (const [taskId, sessionId] of entries) { + sessionToLastTaskId.set(sessionId, taskId) + } + + const result: Record = {} + + for (const [sessionId, taskId] of sessionToLastTaskId) { + result[taskId] = sessionId + } + + return result } setTaskSessionMap(taskSessionMap: Record): void { diff --git a/src/shared/kilocode/cli-sessions/utils/__tests__/SessionPersistenceManager.test.ts b/src/shared/kilocode/cli-sessions/utils/__tests__/SessionPersistenceManager.test.ts index 2d61a8b54d0..af43f276a0d 100644 --- a/src/shared/kilocode/cli-sessions/utils/__tests__/SessionPersistenceManager.test.ts +++ b/src/shared/kilocode/cli-sessions/utils/__tests__/SessionPersistenceManager.test.ts @@ -298,4 +298,30 @@ describe("SessionPersistenceManager", () => { expect(result).toEqual({}) }) }) + + describe("taskSessionMap duplicate values", () => { + it("should deduplicate session IDs keeping only the last entry for each duplicate value", () => { + manager.setWorkspaceDir("/workspace") + vi.mocked(existsSync).mockReturnValue(true) + vi.mocked(readFileSync).mockReturnValue( + JSON.stringify({ + taskSessionMap: { + "task-1": "session-1", + "task-2": "session-2", + "task-3": "session-1", + }, + }), + ) + + const taskSessionMap = manager.getTaskSessionMap() + const sessionIds = Object.values(taskSessionMap) + const uniqueSessionIds = new Set(sessionIds) + + expect(sessionIds.length).toBe(uniqueSessionIds.size) + expect(taskSessionMap).toEqual({ + "task-2": "session-2", + "task-3": "session-1", + }) + }) + }) }) From 6bb4cf7d52ab36993bc7990d393ea2dfc3d53845 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 5 Dec 2025 12:57:31 +0100 Subject: [PATCH 18/20] add back destroy method --- cli/src/cli.ts | 2 + .../cli-sessions/core/SessionManager.ts | 27 +++- .../core/__tests__/SessionManager.spec.ts | 34 +++++ .../SessionManager.syncSession.spec.ts | 124 ++++++++++++++++++ 4 files changed, 185 insertions(+), 2 deletions(-) diff --git a/cli/src/cli.ts b/cli/src/cli.ts index 756deeee7c4..aa5dac3ce0e 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -323,6 +323,8 @@ export class CLI { try { logs.info("Disposing Kilo Code CLI...", "CLI") + await this.sessionService?.destroy() + // Signal codes take precedence over CI logic if (signal === "SIGINT") { exitCode = 130 diff --git a/src/shared/kilocode/cli-sessions/core/SessionManager.ts b/src/shared/kilocode/cli-sessions/core/SessionManager.ts index c869c212480..97b30699a06 100644 --- a/src/shared/kilocode/cli-sessions/core/SessionManager.ts +++ b/src/shared/kilocode/cli-sessions/core/SessionManager.ts @@ -83,12 +83,22 @@ export class SessionManager { this.logger.debug("Initialized SessionManager", "SessionManager") } + private pendingSync: Promise | null = null + private initSingleton(dependencies: SessionManagerDependencies) { this.initDeps(dependencies) if (!this.timer) { - this.timer = setInterval(() => { - this.syncSession() + this.timer = setInterval(async () => { + if (this.pendingSync) { + return + } + + this.pendingSync = this.syncSession() + + await this.pendingSync + + this.pendingSync = null }, SessionManager.SYNC_INTERVAL) } } @@ -683,6 +693,19 @@ export class SessionManager { } } + /** + * use this when exiting the process + */ + destroy() { + this.logger?.debug("Destroying SessionManager", "SessionManager") + + if (!this.pendingSync) { + this.pendingSync = this.syncSession() + } + + return this.pendingSync + } + private async fetchBlobFromSignedUrl(url: string, urlType: string) { try { this.logger?.debug(`Fetching blob from signed URL`, "SessionManager", { url, urlType }) diff --git a/src/shared/kilocode/cli-sessions/core/__tests__/SessionManager.spec.ts b/src/shared/kilocode/cli-sessions/core/__tests__/SessionManager.spec.ts index 31f09ea3b70..d4ac778b890 100644 --- a/src/shared/kilocode/cli-sessions/core/__tests__/SessionManager.spec.ts +++ b/src/shared/kilocode/cli-sessions/core/__tests__/SessionManager.spec.ts @@ -122,6 +122,11 @@ describe("SessionManager", () => { it("should set up sync interval timer", () => { expect(vi.getTimerCount()).toBe(1) }) + + it("should initialize pendingSync as null", () => { + const pendingSync = (manager as unknown as { pendingSync: Promise | null }).pendingSync + expect(pendingSync).toBeNull() + }) }) describe("sessionId", () => { @@ -753,4 +758,33 @@ describe("SessionManager", () => { expect(result).toBe("Test message") }) }) + + describe("destroy", () => { + it("should return a promise", async () => { + const syncSessionSpy = vi.spyOn(manager as unknown as { syncSession: () => Promise }, "syncSession") + syncSessionSpy.mockResolvedValue(undefined) + + const result = manager.destroy() + + expect(result).toBeInstanceOf(Promise) + }) + + it("should return existing pendingSync when one exists", async () => { + const existingPromise = Promise.resolve() + ;(manager as unknown as { pendingSync: Promise | null }).pendingSync = existingPromise + + const result = manager.destroy() + + expect(result).toBe(existingPromise) + }) + + it("should log debug message when destroying", async () => { + const syncSessionSpy = vi.spyOn(manager as unknown as { syncSession: () => Promise }, "syncSession") + syncSessionSpy.mockResolvedValue(undefined) + + manager.destroy() + + expect(mockDependencies.logger.debug).toHaveBeenCalledWith("Destroying SessionManager", "SessionManager") + }) + }) }) diff --git a/src/shared/kilocode/cli-sessions/core/__tests__/SessionManager.syncSession.spec.ts b/src/shared/kilocode/cli-sessions/core/__tests__/SessionManager.syncSession.spec.ts index bcb812dc982..118647c0491 100644 --- a/src/shared/kilocode/cli-sessions/core/__tests__/SessionManager.syncSession.spec.ts +++ b/src/shared/kilocode/cli-sessions/core/__tests__/SessionManager.syncSession.spec.ts @@ -110,6 +110,7 @@ describe("SessionManager.syncSession", () => { ;(privateInstance as unknown as { taskGitHashes: Record }).taskGitHashes = {} ;(privateInstance as unknown as { sessionTitles: Record }).sessionTitles = {} ;(privateInstance as unknown as { lastActiveSessionId: string | null }).lastActiveSessionId = null + ;(privateInstance as unknown as { pendingSync: Promise | null }).pendingSync = null } manager = SessionManager.init(mockDependencies) @@ -142,6 +143,12 @@ describe("SessionManager.syncSession", () => { ;(manager as unknown as { isSyncing: boolean }).isSyncing = value } + const getPendingSync = () => (manager as unknown as { pendingSync: Promise | null }).pendingSync + + const setPendingSync = (value: Promise | null) => { + ;(manager as unknown as { pendingSync: Promise | null }).pendingSync = value + } + describe("sync skipping conditions", () => { it("should skip sync when already syncing", async () => { setIsSyncing(true) @@ -705,6 +712,15 @@ describe("SessionManager.syncSession", () => { const sessionTitles = (manager as unknown as { sessionTitles: Record }).sessionTitles expect(sessionTitles["session-123"]).toBe("") + expect(mockDependencies.logger.error).toHaveBeenCalledWith( + "Failed to generate session title", + "SessionManager", + expect.objectContaining({ + sessionId: "session-123", + error: "Network error", + }), + ) + vi.mocked(manager.sessionClient!.get).mockResolvedValue({ session_id: "session-123", title: "", @@ -1018,5 +1034,113 @@ describe("SessionManager.syncSession", () => { expect(gitStateUploads2).toHaveLength(0) }) }) + + describe("pendingSync tracking in interval", () => { + it("should skip interval sync when pendingSync exists", async () => { + vi.mocked(manager.sessionPersistenceManager!.getSessionForTask).mockReturnValue("session-123") + vi.mocked(readFileSync).mockReturnValue(JSON.stringify([])) + vi.mocked(manager.sessionClient!.uploadBlob).mockResolvedValue({ + session_id: "session-123", + updated_at: new Date().toISOString(), + }) + + const existingPromise = new Promise((resolve) => setTimeout(resolve, 200)) + setPendingSync(existingPromise) + + manager.handleFileUpdate("task-123", "uiMessagesPath", "/path/to/file.json") + + vi.advanceTimersByTime(SessionManager.SYNC_INTERVAL) + + expect(manager.sessionClient!.uploadBlob).not.toHaveBeenCalled() + }) + + it("should clear pendingSync after sync completes via direct call", async () => { + vi.mocked(manager.sessionPersistenceManager!.getSessionForTask).mockReturnValue("session-123") + vi.mocked(readFileSync).mockReturnValue(JSON.stringify([])) + vi.mocked(manager.sessionClient!.uploadBlob).mockResolvedValue({ + session_id: "session-123", + updated_at: new Date().toISOString(), + }) + + manager.handleFileUpdate("task-123", "uiMessagesPath", "/path/to/file.json") + + setPendingSync(null) + expect(getPendingSync()).toBeNull() + + await triggerSync() + + expect(getPendingSync()).toBeNull() + }) + + it("should set pendingSync during sync execution via direct call", async () => { + vi.mocked(manager.sessionPersistenceManager!.getSessionForTask).mockReturnValue("session-123") + vi.mocked(readFileSync).mockReturnValue(JSON.stringify([])) + + let pendingSyncDuringUpload: Promise | null = null + vi.mocked(manager.sessionClient!.uploadBlob).mockImplementation(async () => { + pendingSyncDuringUpload = getIsSyncing() ? Promise.resolve() : null + return { + session_id: "session-123", + updated_at: new Date().toISOString(), + } + }) + + manager.handleFileUpdate("task-123", "uiMessagesPath", "/path/to/file.json") + + await triggerSync() + + expect(pendingSyncDuringUpload).not.toBeNull() + }) + }) + }) + + describe("destroy method", () => { + it("should trigger final sync on destroy", async () => { + vi.mocked(manager.sessionPersistenceManager!.getSessionForTask).mockReturnValue("session-123") + vi.mocked(readFileSync).mockReturnValue(JSON.stringify([])) + vi.mocked(manager.sessionClient!.uploadBlob).mockResolvedValue({ + session_id: "session-123", + updated_at: new Date().toISOString(), + }) + + manager.handleFileUpdate("task-123", "uiMessagesPath", "/path/to/file.json") + + const destroyPromise = manager.destroy() + + await destroyPromise + + expect(manager.sessionClient!.uploadBlob).toHaveBeenCalledWith( + "session-123", + "ui_messages", + expect.any(Array), + ) + }) + + it("should return existing pendingSync if one is in progress", async () => { + const existingPromise = Promise.resolve() + setPendingSync(existingPromise) + + const result = manager.destroy() + + expect(result).toBe(existingPromise) + }) + + it("should flush queue items during destroy", async () => { + vi.mocked(manager.sessionPersistenceManager!.getSessionForTask).mockReturnValue("session-123") + vi.mocked(readFileSync).mockReturnValue(JSON.stringify([])) + vi.mocked(manager.sessionClient!.uploadBlob).mockResolvedValue({ + session_id: "session-123", + updated_at: new Date().toISOString(), + }) + + manager.handleFileUpdate("task-123", "uiMessagesPath", "/path/to/file1.json") + manager.handleFileUpdate("task-123", "apiConversationHistoryPath", "/path/to/file2.json") + + expect(getQueue()).toHaveLength(2) + + await manager.destroy() + + expect(getQueue()).toHaveLength(0) + }) }) }) From 726603105a1db73a652a38d6d636b960d0e8a4a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 5 Dec 2025 13:11:29 +0100 Subject: [PATCH 19/20] prevent large patch upload useful if someone has their node_modules in the diff --- .../cli-sessions/core/SessionManager.ts | 9 ++ .../core/__tests__/SessionManager.spec.ts | 95 +++++++++++++++++++ 2 files changed, 104 insertions(+) diff --git a/src/shared/kilocode/cli-sessions/core/SessionManager.ts b/src/shared/kilocode/cli-sessions/core/SessionManager.ts index 97b30699a06..2e992a89dd3 100644 --- a/src/shared/kilocode/cli-sessions/core/SessionManager.ts +++ b/src/shared/kilocode/cli-sessions/core/SessionManager.ts @@ -30,6 +30,7 @@ export interface SessionManagerDependencies extends TrpcClientDependencies { export class SessionManager { static readonly SYNC_INTERVAL = 3000 + static readonly MAX_PATCH_SIZE_BYTES = 1024 * 1024 private static instance = new SessionManager() @@ -788,6 +789,14 @@ export class SessionManager { } } + if (patch && patch.length > SessionManager.MAX_PATCH_SIZE_BYTES) { + this.logger?.warn("Git patch too large", "SessionManager", { + patchSize: patch.length, + maxSize: SessionManager.MAX_PATCH_SIZE_BYTES, + }) + patch = "" + } + return { repoUrl, head, diff --git a/src/shared/kilocode/cli-sessions/core/__tests__/SessionManager.spec.ts b/src/shared/kilocode/cli-sessions/core/__tests__/SessionManager.spec.ts index d4ac778b890..8f138dc0f0a 100644 --- a/src/shared/kilocode/cli-sessions/core/__tests__/SessionManager.spec.ts +++ b/src/shared/kilocode/cli-sessions/core/__tests__/SessionManager.spec.ts @@ -787,4 +787,99 @@ describe("SessionManager", () => { expect(mockDependencies.logger.debug).toHaveBeenCalledWith("Destroying SessionManager", "SessionManager") }) }) + + describe("getGitState patch size limit", () => { + it("should return patch when size is under the limit", async () => { + const simpleGit = await import("simple-git") + const smallPatch = "a".repeat(1000) + + const mockRaw = vi.fn().mockResolvedValue("") + vi.mocked(simpleGit.default).mockReturnValue({ + getRemotes: vi.fn().mockResolvedValue([{ refs: { fetch: "https://github.com/test/repo.git" } }]), + revparse: vi.fn().mockResolvedValue("abc123def456"), + raw: mockRaw, + diff: vi.fn().mockResolvedValue(smallPatch), + } as unknown as ReturnType) + + const getGitState = (manager as unknown as { getGitState: () => Promise<{ patch?: string }> }).getGitState + const result = await getGitState.call(manager) + + expect(result.patch).toBe(smallPatch) + }) + + it("should return empty string patch when size exceeds the limit", async () => { + const simpleGit = await import("simple-git") + const largePatch = "a".repeat(2 * 1024 * 1024) + + const mockRaw = vi.fn().mockResolvedValue("") + vi.mocked(simpleGit.default).mockReturnValue({ + getRemotes: vi.fn().mockResolvedValue([{ refs: { fetch: "https://github.com/test/repo.git" } }]), + revparse: vi.fn().mockResolvedValue("abc123def456"), + raw: mockRaw, + diff: vi.fn().mockResolvedValue(largePatch), + } as unknown as ReturnType) + + const getGitState = (manager as unknown as { getGitState: () => Promise<{ patch?: string }> }).getGitState + const result = await getGitState.call(manager) + + expect(result.patch).toBe("") + }) + + it("should log warning when patch exceeds size limit", async () => { + const simpleGit = await import("simple-git") + const largePatch = "a".repeat(2 * 1024 * 1024) + + const mockRaw = vi.fn().mockResolvedValue("") + vi.mocked(simpleGit.default).mockReturnValue({ + getRemotes: vi.fn().mockResolvedValue([{ refs: { fetch: "https://github.com/test/repo.git" } }]), + revparse: vi.fn().mockResolvedValue("abc123def456"), + raw: mockRaw, + diff: vi.fn().mockResolvedValue(largePatch), + } as unknown as ReturnType) + + const getGitState = (manager as unknown as { getGitState: () => Promise<{ patch?: string }> }).getGitState + await getGitState.call(manager) + + expect(mockDependencies.logger.warn).toHaveBeenCalledWith("Git patch too large", "SessionManager", { + patchSize: largePatch.length, + maxSize: SessionManager.MAX_PATCH_SIZE_BYTES, + }) + }) + + it("should return patch when size is exactly at the limit", async () => { + const simpleGit = await import("simple-git") + const exactLimitPatch = "a".repeat(1024 * 1024) + + const mockRaw = vi.fn().mockResolvedValue("") + vi.mocked(simpleGit.default).mockReturnValue({ + getRemotes: vi.fn().mockResolvedValue([{ refs: { fetch: "https://github.com/test/repo.git" } }]), + revparse: vi.fn().mockResolvedValue("abc123def456"), + raw: mockRaw, + diff: vi.fn().mockResolvedValue(exactLimitPatch), + } as unknown as ReturnType) + + const getGitState = (manager as unknown as { getGitState: () => Promise<{ patch?: string }> }).getGitState + const result = await getGitState.call(manager) + + expect(result.patch).toBe(exactLimitPatch) + }) + + it("should return empty string patch when size is one byte over the limit", async () => { + const simpleGit = await import("simple-git") + const overLimitPatch = "a".repeat(1024 * 1024 + 1) + + const mockRaw = vi.fn().mockResolvedValue("") + vi.mocked(simpleGit.default).mockReturnValue({ + getRemotes: vi.fn().mockResolvedValue([{ refs: { fetch: "https://github.com/test/repo.git" } }]), + revparse: vi.fn().mockResolvedValue("abc123def456"), + raw: mockRaw, + diff: vi.fn().mockResolvedValue(overLimitPatch), + } as unknown as ReturnType) + + const getGitState = (manager as unknown as { getGitState: () => Promise<{ patch?: string }> }).getGitState + const result = await getGitState.call(manager) + + expect(result.patch).toBe("") + }) + }) }) From dba3f3286e76d8f97203a9b7266b6a3f46252181 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 5 Dec 2025 13:38:12 +0100 Subject: [PATCH 20/20] fix failing tests due to hardcoded paths --- .../core/__tests__/SessionManager.spec.ts | 11 +++++++---- .../core/__tests__/SessionManager.syncSession.spec.ts | 9 ++++++--- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/shared/kilocode/cli-sessions/core/__tests__/SessionManager.spec.ts b/src/shared/kilocode/cli-sessions/core/__tests__/SessionManager.spec.ts index 8f138dc0f0a..12ed44ecfd0 100644 --- a/src/shared/kilocode/cli-sessions/core/__tests__/SessionManager.spec.ts +++ b/src/shared/kilocode/cli-sessions/core/__tests__/SessionManager.spec.ts @@ -1,3 +1,4 @@ +import path from "path" import { SessionManager, SessionManagerDependencies } from "../SessionManager" import { SessionClient, CliSessionSharedState, SessionWithSignedUrls } from "../SessionClient" import { SessionPersistenceManager } from "../../utils/SessionPersistenceManager" @@ -9,7 +10,7 @@ vi.mock("fs", () => ({ readFileSync: vi.fn(), writeFileSync: vi.fn(), mkdirSync: vi.fn(), - mkdtempSync: vi.fn().mockReturnValue("/tmp/kilocode-git-patches-123"), + mkdtempSync: vi.fn(), rmSync: vi.fn(), })) @@ -58,12 +59,14 @@ vi.mock("../../utils/SessionPersistenceManager", () => ({ })), })) +const MOCK_TASKS_DIR = path.join("mock", "user", ".kilocode", "tasks") + const createMockDependencies = (): SessionManagerDependencies => ({ platform: "vscode", getToken: vi.fn().mockResolvedValue("test-token"), pathProvider: { - getTasksDir: vi.fn().mockReturnValue("/home/user/.kilocode/tasks"), - getSessionFilePath: vi.fn().mockImplementation((dir: string) => `${dir}/.kilocode/session.json`), + getTasksDir: vi.fn().mockReturnValue(MOCK_TASKS_DIR), + getSessionFilePath: vi.fn().mockImplementation((dir: string) => path.join(dir, ".kilocode", "session.json")), }, logger: { debug: vi.fn(), @@ -295,7 +298,7 @@ describe("SessionManager", () => { await manager.restoreSession("session-123") - expect(mkdirSync).toHaveBeenCalledWith("/home/user/.kilocode/tasks/session-123", { recursive: true }) + expect(mkdirSync).toHaveBeenCalledWith(path.join(MOCK_TASKS_DIR, "session-123"), { recursive: true }) }) it("should send webview messages to register and show task", async () => { diff --git a/src/shared/kilocode/cli-sessions/core/__tests__/SessionManager.syncSession.spec.ts b/src/shared/kilocode/cli-sessions/core/__tests__/SessionManager.syncSession.spec.ts index 118647c0491..65283721aff 100644 --- a/src/shared/kilocode/cli-sessions/core/__tests__/SessionManager.syncSession.spec.ts +++ b/src/shared/kilocode/cli-sessions/core/__tests__/SessionManager.syncSession.spec.ts @@ -1,3 +1,4 @@ +import path from "path" import { SessionManager, SessionManagerDependencies } from "../SessionManager" import { SessionClient } from "../SessionClient" import { SessionPersistenceManager } from "../../utils/SessionPersistenceManager" @@ -18,7 +19,7 @@ vi.mock("fs", () => ({ readFileSync: vi.fn(), writeFileSync: vi.fn(), mkdirSync: vi.fn(), - mkdtempSync: vi.fn().mockReturnValue("/tmp/kilocode-git-patches-123"), + mkdtempSync: vi.fn(), rmSync: vi.fn(), })) @@ -58,12 +59,14 @@ vi.mock("../../utils/SessionPersistenceManager", () => ({ })), })) +const MOCK_TASKS_DIR = path.join("mock", "user", ".kilocode", "tasks") + const createMockDependencies = (): SessionManagerDependencies => ({ platform: "vscode", getToken: vi.fn().mockResolvedValue("test-token"), pathProvider: { - getTasksDir: vi.fn().mockReturnValue("/home/user/.kilocode/tasks"), - getSessionFilePath: vi.fn().mockImplementation((dir: string) => `${dir}/.kilocode/session.json`), + getTasksDir: vi.fn().mockReturnValue(MOCK_TASKS_DIR), + getSessionFilePath: vi.fn().mockImplementation((dir: string) => path.join(dir, ".kilocode", "session.json")), }, logger: { debug: vi.fn(),