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 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") 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/SessionClient.ts b/src/shared/kilocode/cli-sessions/core/SessionClient.ts index 34fb5a0dcf9..b695c0dd61d 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, + }) } /** @@ -189,7 +188,7 @@ export class SessionClient { ): Promise<{ session_id: string; updated_at: string }> { const { endpoint, getToken } = this.trpcClient - const url = new URL(`${endpoint}/api/upload-cli-session-blob`) + const url = new URL("/api/upload-cli-session-blob", endpoint) url.searchParams.set("session_id", sessionId) url.searchParams.set("blob_type", blobType) diff --git a/src/shared/kilocode/cli-sessions/core/SessionManager.ts b/src/shared/kilocode/cli-sessions/core/SessionManager.ts index f9cc37f3b3c..2e992a89dd3 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 @@ -36,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() @@ -47,16 +42,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.lastActiveSessionId || this.sessionPersistenceManager?.getLastSession()?.sessionId + } + private lastActiveSessionId: 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 @@ -88,24 +84,43 @@ 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) } } - 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 + timestamp: number + }[] - 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, + timestamp: Date.now(), + }) } } @@ -159,8 +174,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 +186,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 +284,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 +295,6 @@ export class SessionManager { sessionId, }) - this.sessionId = null - this.sessionTitle = null - this.sessionGitUrl = null - this.resetBlobHashes() - if (rethrowError) { throw error } @@ -297,18 +303,19 @@ export class SessionManager { } } - async shareSession(sessionId?: string) { + async shareSession(sessionIdInput?: string) { if (!this.sessionClient) { throw new Error("SessionManager used before initialization") } - const sessionIdToShare = sessionId || this.sessionId - if (!sessionIdToShare) { + const sessionId = sessionIdInput || this.sessionId + + if (!sessionId) { throw new Error("No active session") } return await this.sessionClient.share({ - session_id: sessionIdToShare, + session_id: sessionId, shared_state: CliSessionSharedState.Public, }) } @@ -332,7 +339,7 @@ export class SessionManager { title: trimmedTitle, }) - this.sessionTitle = trimmedTitle + this.sessionTitles[sessionId] = trimmedTitle this.logger?.info("Session renamed successfully", "SessionManager", { sessionId, @@ -374,7 +381,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 @@ -401,299 +408,303 @@ 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() { + if (this.isSyncing) { + this.logger?.debug("Sync already in progress, skipping", "SessionManager") + return } - } - - 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.logger?.debug("Sessions disabled via KILO_DISABLE_SESSIONS, clearing queue", "SessionManager") + this.queue = [] return } - this.isSyncing = true - // capture the sessionId at the start of the sync - let capturedSessionId = this.sessionId + 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") - } - - const rawPayload = this.readPaths() - - if (Object.values(rawPayload).every((item) => !item)) { - this.isSyncing = false + this.isSyncing = true - 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" - > = {} + this.logger?.debug("Starting session sync", "SessionManager", { + queueLength: this.queue.length, + taskCount: taskIds.size, + }) let gitInfo: Awaited> | null = null - try { gitInfo = await this.getGitState() - - if (gitInfo?.repoUrl) { - basePayload.git_url = gitInfo.repoUrl - } } 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 - } - } - - if (capturedSessionId) { - const gitUrlChanged = gitInfo?.repoUrl && gitInfo.repoUrl !== this.sessionGitUrl + for (const taskId of taskIds) { + try { + const taskItems = this.queue.filter((item) => item.taskId === taskId) + const reversedTaskItems = [...taskItems].reverse() - if (gitUrlChanged) { - await this.sessionClient.update({ - session_id: capturedSessionId, - ...basePayload, + this.logger?.debug("Processing task", "SessionManager", { + taskId, + itemCount: taskItems.length, }) - this.sessionGitUrl = gitInfo?.repoUrl || null + const basePayload: Omit< + Parameters["create"]>[0], + "created_on_platform" + > = {} - this.logger?.debug("Session updated successfully", "SessionManager", { - sessionId: capturedSessionId, - }) - } - } else { - this.logger?.debug("Creating new session", "SessionManager") + if (gitInfo?.repoUrl) { + basePayload.git_url = gitInfo.repoUrl + } - if (rawPayload.uiMessagesPath) { - const title = this.getFirstMessageText(rawPayload.uiMessagesPath as ClineMessage[], true) + let sessionId = this.sessionPersistenceManager.getSessionForTask(taskId) - if (title) { - basePayload.title = title - } - } + if (sessionId) { + this.logger?.debug("Found existing session for task", "SessionManager", { taskId, sessionId }) - const session = await this.sessionClient.create({ - ...basePayload, - created_on_platform: process.env.KILO_PLATFORM || this.platform, - }) + const gitUrlChanged = !!gitInfo?.repoUrl && gitInfo.repoUrl !== this.taskGitUrls[taskId] - this.sessionId = session.session_id - capturedSessionId = session.session_id + if (gitUrlChanged && gitInfo?.repoUrl) { + this.taskGitUrls[taskId] = gitInfo.repoUrl - this.sessionGitUrl = gitInfo?.repoUrl || null + this.logger?.debug("Git URL changed, updating session", "SessionManager", { + sessionId, + newGitUrl: gitInfo.repoUrl, + }) - this.logger?.info("Session created successfully", "SessionManager", { sessionId: capturedSessionId }) + await this.sessionClient.update({ + session_id: sessionId, + ...basePayload, + }) + } + } else { + this.logger?.debug("Creating new session for task", "SessionManager", { taskId }) - this.sessionPersistenceManager.setLastSession(capturedSessionId) + const createdSession = await this.sessionClient.create({ + ...basePayload, + created_on_platform: this.platform, + }) - this.onSessionCreated?.({ - timestamp: Date.now(), - event: "session_created", - sessionId: capturedSessionId, - }) - } + sessionId = createdSession.session_id - if (this.currentTaskId) { - this.sessionPersistenceManager.setSessionForTask(this.currentTaskId, capturedSessionId) - } + this.logger?.info("Created new session", "SessionManager", { taskId, sessionId }) - const blobUploads: Array> = [] + this.sessionPersistenceManager.setSessionForTask(taskId, createdSession.session_id) - 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") + this.onSessionCreated?.({ + timestamp: Date.now(), + event: "session_created", + sessionId: createdSession.session_id, }) - .catch((error) => { - this.logger?.error("Failed to upload api_conversation_history blob", "SessionManager", { - error: error instanceof Error ? error.message : String(error), - }) - }), - ) - } + } - 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") + if (!sessionId) { + this.logger?.warn("No session ID available after create/get, skipping task", "SessionManager", { + taskId, }) - .catch((error) => { - this.logger?.error("Failed to upload task_metadata blob", "SessionManager", { - error: error instanceof Error ? error.message : String(error), - }) - }), - ) - } + continue + } - 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), - }) - }), - ) - } + const blobNames = new Set(taskItems.map((item) => item.blobName)) + const blobUploads: Promise[] = [] - if (gitInfo) { - const gitStateData = { - head: gitInfo.head, - patch: gitInfo.patch, - branch: gitInfo.branch, - } + 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) - const gitStateHash = this.hashGitState(gitStateData) + if (!lastBlobItem) { + this.logger?.warn("Could not find blob item in reversed list", "SessionManager", { + blobName, + taskId, + }) + continue + } - if (gitStateHash !== this.blobHashes.gitState) { - this.blobHashes.gitState = gitStateHash + const fileContents = JSON.parse(readFileSync(lastBlobItem.blobPath, "utf-8")) - if (this.hasBlobChanged("gitState")) { blobUploads.push( this.sessionClient - .uploadBlob(capturedSessionId, "git_state", gitStateData) + .uploadBlob( + sessionId, + lastBlobItem.blobName as Parameters[1], + fileContents, + ) .then(() => { - this.markBlobSynced("gitState") - this.logger?.debug("Uploaded git_state blob", "SessionManager") + 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 git_state blob", "SessionManager", { + this.logger?.error("Failed to upload blob", "SessionManager", { + sessionId, + blobName, error: error instanceof Error ? error.message : String(error), }) }), ) + + if (blobName === "ui_messages" && !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 + } + + this.sessionTitles[sessionId] = "Pending title" + + 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 + } + + 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 + + this.logger?.debug("Updated session title", "SessionManager", { + sessionId, + generatedTitle, + }) + } catch (error) { + this.logger?.error("Failed to generate session title", "SessionManager", { + sessionId, + error: error instanceof Error ? error.message : String(error), + }) + + this.sessionTitles[sessionId] = "" + } + })() + } } - } - } - await Promise.all(blobUploads) + if (gitInfo) { + const gitStateData = { + head: gitInfo.head, + patch: gitInfo.patch, + branch: gitInfo.branch, + } + + 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 (!this.sessionTitle && rawPayload.uiMessagesPath) { - this.generateTitle(rawPayload.uiMessagesPath as ClineMessage[]) - .then((generatedTitle) => { - if (capturedSessionId && generatedTitle) { - return this.renameSession(capturedSessionId, generatedTitle) + 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) - return null + this.logger?.debug("Completed blob uploads for task", "SessionManager", { + taskId, + sessionId, + uploadCount: blobUploads.length, }) - .catch((error) => { - this.logger?.warn("Failed to generate session title", "SessionManager", { - error: error instanceof Error ? error.message : String(error), - }) + } catch (error) { + this.logger?.error("Failed to sync session", "SessionManager", { + taskId, + error: error instanceof Error ? error.message : String(error), }) + } } - } 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, + + if (lastItem) { + this.lastActiveSessionId = this.sessionPersistenceManager.getSessionForTask(lastItem.taskId) || null + + if (this.lastActiveSessionId) { + this.sessionPersistenceManager.setLastSession(this.lastActiveSessionId) + } + } + + this.logger?.debug("Session sync completed", "SessionManager", { + lastSessionId: this.lastActiveSessionId, + remainingQueueLength: this.queue.length, }) } 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> = {} + /** + * use this when exiting the process + */ + destroy() { + this.logger?.debug("Destroying SessionManager", "SessionManager") - 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 - } + if (!this.pendingSync) { + this.pendingSync = this.syncSession() } - return contents + return this.pendingSync } private async fetchBlobFromSignedUrl(url: string, urlType: string) { @@ -721,60 +732,25 @@ export class SessionManager { } } - private pathKeyToBlobKey(pathKey: keyof typeof defaultPaths) { + 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 } } - 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) @@ -813,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, @@ -927,7 +911,7 @@ export class SessionManager { }) } finally { try { - rmSync(patchFile, { recursive: true, force: true }) + rmSync(tempDir, { recursive: true, force: true }) } catch { // Ignore error } @@ -992,10 +976,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. @@ -1018,13 +998,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 } } } 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..12ed44ecfd0 --- /dev/null +++ b/src/shared/kilocode/cli-sessions/core/__tests__/SessionManager.spec.ts @@ -0,0 +1,888 @@ +import path from "path" +import { SessionManager, SessionManagerDependencies } from "../SessionManager" +import { SessionClient, CliSessionSharedState, SessionWithSignedUrls } from "../SessionClient" +import { SessionPersistenceManager } from "../../utils/SessionPersistenceManager" +import type { ITaskDataProvider } from "../../types/ITaskDataProvider" +import type { ClineMessage } from "@roo-code/types" +import { readFileSync, writeFileSync, mkdirSync } from "fs" + +vi.mock("fs", () => ({ + readFileSync: vi.fn(), + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), + mkdtempSync: vi.fn(), + 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 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(MOCK_TASKS_DIR), + getSessionFilePath: vi.fn().mockImplementation((dir: string) => path.join(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) + }) + + it("should initialize pendingSync as null", () => { + const pendingSync = (manager as unknown as { pendingSync: Promise | null }).pendingSync + expect(pendingSync).toBeNull() + }) + }) + + 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(path.join(MOCK_TASKS_DIR, "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") + }) + }) + + 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") + }) + }) + + 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("") + }) + }) +}) 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..65283721aff --- /dev/null +++ b/src/shared/kilocode/cli-sessions/core/__tests__/SessionManager.syncSession.spec.ts @@ -0,0 +1,1149 @@ +import path from "path" +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(), + 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 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(MOCK_TASKS_DIR), + getSessionFilePath: vi.fn().mockImplementation((dir: string) => path.join(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 + ;(privateInstance as unknown as { pendingSync: Promise | null }).pendingSync = 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 + } + + 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) + 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("") + + 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: "", + 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) + }) + }) + + 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) + }) + }) +}) 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() - }) -} 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", + }) + }) + }) })