diff --git a/.changeset/lucky-chicken-doubt.md b/.changeset/lucky-chicken-doubt.md new file mode 100644 index 00000000000..f52940c68ff --- /dev/null +++ b/.changeset/lucky-chicken-doubt.md @@ -0,0 +1,6 @@ +--- +"kilo-code": minor +"@kilocode/cli": patch +--- + +use shared session manager from extension folder diff --git a/cli/src/cli.ts b/cli/src/cli.ts index 940d0e5ccc9..53d99ec3e02 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -24,9 +24,9 @@ import type { CLIOptions } from "./types/cli.js" import type { CLIConfig, ProviderConfig } from "./config/types.js" import { getModelIdKey } from "./constants/providers/models.js" import type { ProviderName } from "./types/messages.js" -import { TrpcClient } from "./services/trpcClient.js" -import { SessionService } from "./services/session.js" +import { KiloCodePathProvider, ExtensionMessengerAdapter } from "./services/session-adapters.js" import { getKiloToken } from "./config/persistence.js" +import { SessionManager } from "../../src/shared/kilocode/cli-sessions/core/SessionManager.js" /** * Main application class that orchestrates the CLI lifecycle @@ -37,7 +37,7 @@ export class CLI { private ui: Instance | null = null private options: CLIOptions private isInitialized = false - private sessionService: SessionService | null = null + private sessionService: SessionManager | null = null constructor(options: CLIOptions = {}) { this.options = options @@ -135,16 +135,31 @@ export class CLI { const kiloToken = getKiloToken(config) if (kiloToken) { - TrpcClient.init(kiloToken) - logs.debug("TrpcClient initialized with kiloToken", "CLI") + const pathProvider = new KiloCodePathProvider() + const extensionMessenger = new ExtensionMessengerAdapter(this.service) + + this.sessionService = SessionManager.init({ + pathProvider, + logger: logs, + extensionMessenger, + getToken: () => Promise.resolve(kiloToken), + onSessionCreated: (message) => { + if (this.options.json) { + console.log(JSON.stringify(message)) + } + }, + onSessionRestored: () => { + if (this.store) { + this.store.set(taskResumedViaContinueOrSessionAtom, true) + } + }, + platform: "cli", + }) + logs.debug("SessionManager initialized with dependencies", "CLI") - this.sessionService = SessionService.init(this.service, this.store, this.options.json) - logs.debug("SessionService initialized with ExtensionService", "CLI") - - // Set workspace directory for git operations (important for parallel mode/worktrees) const workspace = this.options.workspace || process.cwd() this.sessionService.setWorkspaceDirectory(workspace) - logs.debug("SessionService workspace directory set", "CLI", { workspace }) + logs.debug("SessionManager workspace directory set", "CLI", { workspace }) if (this.options.session) { await this.sessionService.restoreSession(this.options.session) diff --git a/cli/src/commands/__tests__/new.test.ts b/cli/src/commands/__tests__/new.test.ts index c0965b28890..9ec0a898827 100644 --- a/cli/src/commands/__tests__/new.test.ts +++ b/cli/src/commands/__tests__/new.test.ts @@ -6,11 +6,11 @@ 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 { SessionService } from "../../services/session.js" +import { SessionManager } from "../../../../src/shared/kilocode/cli-sessions/core/SessionManager.js" describe("/new command", () => { let mockContext: CommandContext - let mockSessionService: Partial & { destroy: ReturnType } + let mockSessionManager: Partial & { destroy: ReturnType } beforeEach(() => { // Mock process.stdout.write to capture terminal clearing @@ -20,14 +20,14 @@ describe("/new command", () => { input: "/new", }) - // Mock SessionService - mockSessionService = { + // Mock SessionManager + mockSessionManager = { destroy: vi.fn().mockResolvedValue(undefined), sessionId: "test-session-id", } - // Mock SessionService.init to return our mock - vi.spyOn(SessionService, "init").mockReturnValue(mockSessionService as unknown as SessionService) + // Mock SessionManager.init to return our mock + vi.spyOn(SessionManager, "init").mockReturnValue(mockSessionManager as unknown as SessionManager) }) afterEach(() => { @@ -74,13 +74,13 @@ describe("/new command", () => { it("should clear the session", async () => { await newCommand.handler(mockContext) - expect(SessionService.init).toHaveBeenCalled() - expect(mockSessionService.destroy).toHaveBeenCalledTimes(1) + 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(() => {}) - mockSessionService.destroy.mockRejectedValue(new Error("Session error")) + mockSessionManager.destroy.mockRejectedValue(new Error("Session error")) await newCommand.handler(mockContext) @@ -121,7 +121,7 @@ describe("/new command", () => { callOrder.push("clearTask") }) - mockSessionService.destroy = vi.fn().mockImplementation(async () => { + mockSessionManager.destroy = vi.fn().mockImplementation(async () => { callOrder.push("sessionDestroy") }) @@ -164,7 +164,7 @@ describe("/new command", () => { // Verify all cleanup operations were performed expect(mockContext.clearTask).toHaveBeenCalled() - expect(mockSessionService.destroy).toHaveBeenCalled() + expect(mockSessionManager.destroy).toHaveBeenCalled() expect(mockContext.replaceMessages).toHaveBeenCalled() // Verify welcome message was replaced diff --git a/cli/src/commands/__tests__/session.test.ts b/cli/src/commands/__tests__/session.test.ts index c64b9ea63c4..26154d97478 100644 --- a/cli/src/commands/__tests__/session.test.ts +++ b/cli/src/commands/__tests__/session.test.ts @@ -6,12 +6,12 @@ import { describe, it, expect, beforeEach, vi, afterEach } from "vitest" import { sessionCommand } from "../session.js" import type { CommandContext, ArgumentProviderContext, ArgumentSuggestion } from "../core/types.js" import { createMockContext } from "./helpers/mockContext.js" -import { SessionService } from "../../services/session.js" -import { SessionClient } from "../../services/sessionClient.js" +import { SessionManager } from "../../../../src/shared/kilocode/cli-sessions/core/SessionManager.js" +import { SessionClient } from "../../../../src/shared/kilocode/cli-sessions/core/SessionClient.js" -// Mock the SessionService -vi.mock("../../services/session.js", () => ({ - SessionService: { +// Mock the SessionManager +vi.mock("../../../../src/shared/kilocode/cli-sessions/core/SessionManager.js", () => ({ + SessionManager: { init: vi.fn(), }, })) @@ -34,7 +34,7 @@ vi.mock("simple-git", () => ({ describe("sessionCommand", () => { let mockContext: CommandContext - let mockSessionService: Partial + let mockSessionManager: Partial let mockSessionClient: Partial beforeEach(() => { @@ -42,12 +42,6 @@ describe("sessionCommand", () => { input: "/session", }) - // Create a mock session service instance - mockSessionService = { - sessionId: null, - restoreSession: vi.fn().mockResolvedValue(undefined), - } - // Create a mock session client instance mockSessionClient = { list: vi.fn().mockResolvedValue({ @@ -62,11 +56,15 @@ describe("sessionCommand", () => { }), } - // Mock SessionService.init to return our mock instance - vi.mocked(SessionService.init).mockReturnValue(mockSessionService as SessionService) + // Create a mock session manager instance with sessionClient property + mockSessionManager = { + sessionId: null, + restoreSession: vi.fn().mockResolvedValue(undefined), + sessionClient: mockSessionClient as SessionClient, + } - // Mock SessionClient.getInstance to return our mock instance - vi.mocked(SessionClient.getInstance).mockReturnValue(mockSessionClient as SessionClient) + // Mock SessionManager.init to return our mock instance + vi.mocked(SessionManager.init).mockReturnValue(mockSessionManager as SessionManager) }) afterEach(() => { @@ -163,25 +161,24 @@ describe("sessionCommand", () => { expect(message.content).toContain("rename") }) - it("should not call SessionService when showing usage", async () => { + it("should not call SessionManager when showing usage", async () => { mockContext.args = [] await sessionCommand.handler(mockContext) - expect(SessionService.init).not.toHaveBeenCalled() - expect(SessionClient.getInstance).not.toHaveBeenCalled() + expect(SessionManager.init).not.toHaveBeenCalled() }) }) describe("handler - show subcommand", () => { it("should display session ID when session exists", async () => { const testSessionId = "test-session-123" - mockSessionService.sessionId = testSessionId + mockSessionManager.sessionId = testSessionId mockContext.args = ["show"] await sessionCommand.handler(mockContext) - expect(SessionService.init).toHaveBeenCalledTimes(1) + expect(SessionManager.init).toHaveBeenCalledTimes(1) expect(mockContext.addMessage).toHaveBeenCalledTimes(1) const message = (mockContext.addMessage as ReturnType).mock.calls[0][0] expect(message.type).toBe("system") @@ -190,12 +187,12 @@ describe("sessionCommand", () => { }) it("should display message when no session exists", async () => { - mockSessionService.sessionId = null + mockSessionManager.sessionId = null mockContext.args = ["show"] await sessionCommand.handler(mockContext) - expect(SessionService.init).toHaveBeenCalledTimes(1) + expect(SessionManager.init).toHaveBeenCalledTimes(1) expect(mockContext.addMessage).toHaveBeenCalledTimes(1) const message = (mockContext.addMessage as ReturnType).mock.calls[0][0] expect(message.type).toBe("system") @@ -203,12 +200,12 @@ describe("sessionCommand", () => { }) it("should handle 'show' subcommand case-insensitively", async () => { - mockSessionService.sessionId = "test-id" + mockSessionManager.sessionId = "test-id" mockContext.args = ["SHOW"] await sessionCommand.handler(mockContext) - expect(SessionService.init).toHaveBeenCalledTimes(1) + expect(SessionManager.init).toHaveBeenCalledTimes(1) expect(mockContext.addMessage).toHaveBeenCalledTimes(1) }) }) @@ -223,7 +220,7 @@ describe("sessionCommand", () => { await sessionCommand.handler(mockContext) - expect(SessionClient.getInstance).toHaveBeenCalled() + expect(SessionManager.init).toHaveBeenCalled() expect(mockSessionClient.list).toHaveBeenCalledWith({ limit: 50 }) expect(mockContext.addMessage).toHaveBeenCalledTimes(1) const message = (mockContext.addMessage as ReturnType).mock.calls[0][0] @@ -267,7 +264,7 @@ describe("sessionCommand", () => { }) it("should indicate active session in list", async () => { - mockSessionService.sessionId = "session-active" + mockSessionManager.sessionId = "session-active" const mockSessions = [ { session_id: "session-active", @@ -357,10 +354,10 @@ describe("sessionCommand", () => { await sessionCommand.handler(mockContext) - expect(SessionService.init).toHaveBeenCalled() + expect(SessionManager.init).toHaveBeenCalled() expect(mockContext.replaceMessages).toHaveBeenCalledTimes(1) expect(mockContext.refreshTerminal).toHaveBeenCalled() - expect(mockSessionService.restoreSession).toHaveBeenCalledWith("session-123", true) + expect(mockSessionManager.restoreSession).toHaveBeenCalledWith("session-123", true) const replacedMessages = (mockContext.replaceMessages as ReturnType).mock.calls[0][0] expect(replacedMessages).toHaveLength(2) @@ -377,7 +374,7 @@ describe("sessionCommand", () => { const message = (mockContext.addMessage as ReturnType).mock.calls[0][0] expect(message.type).toBe("error") expect(message.content).toContain("Usage: /session select ") - expect(mockSessionService.restoreSession).not.toHaveBeenCalled() + expect(mockSessionManager.restoreSession).not.toHaveBeenCalled() }) it("should show error when sessionId is empty string", async () => { @@ -392,7 +389,7 @@ describe("sessionCommand", () => { }) it("should handle restore error gracefully", async () => { - mockSessionService.restoreSession = vi.fn().mockRejectedValue(new Error("Session not found")) + mockSessionManager.restoreSession = vi.fn().mockRejectedValue(new Error("Session not found")) mockContext.args = ["select", "invalid-session"] await sessionCommand.handler(mockContext) @@ -413,27 +410,27 @@ describe("sessionCommand", () => { await sessionCommand.handler(mockContext) - expect(mockSessionService.restoreSession).toHaveBeenCalledWith("session-123", true) + expect(mockSessionManager.restoreSession).toHaveBeenCalledWith("session-123", true) }) }) describe("handler - share subcommand", () => { beforeEach(() => { - // Setup shareSession mock on service - mockSessionService.shareSession = vi.fn().mockResolvedValue({ + // Setup shareSession mock on manager + mockSessionManager.shareSession = vi.fn().mockResolvedValue({ share_id: "share-123", session_id: "test-session-123", }) }) it("should share current session", async () => { - mockSessionService.sessionId = "test-session-123" + mockSessionManager.sessionId = "test-session-123" mockContext.args = ["share"] await sessionCommand.handler(mockContext) - expect(SessionService.init).toHaveBeenCalled() - expect(mockSessionService.shareSession).toHaveBeenCalled() + expect(SessionManager.init).toHaveBeenCalled() + expect(mockSessionManager.shareSession).toHaveBeenCalled() expect(mockContext.addMessage).toHaveBeenCalledTimes(1) const message = (mockContext.addMessage as ReturnType).mock.calls[0][0] @@ -443,8 +440,8 @@ describe("sessionCommand", () => { }) it("should handle share error gracefully", async () => { - mockSessionService.sessionId = "test-session-123" - mockSessionService.shareSession = vi.fn().mockRejectedValue(new Error("Not in a git repository")) + mockSessionManager.sessionId = "test-session-123" + mockSessionManager.shareSession = vi.fn().mockRejectedValue(new Error("Not in a git repository")) mockContext.args = ["share"] await sessionCommand.handler(mockContext) @@ -484,7 +481,7 @@ describe("sessionCommand", () => { await sessionCommand.handler(mockContext) - expect(SessionClient.getInstance).toHaveBeenCalled() + expect(SessionManager.init).toHaveBeenCalled() expect(mockSessionClient.search).toHaveBeenCalledWith({ search_string: "test-query", limit: 20 }) expect(mockContext.addMessage).toHaveBeenCalledTimes(1) const message = (mockContext.addMessage as ReturnType).mock.calls[0][0] @@ -538,7 +535,7 @@ describe("sessionCommand", () => { }) it("should indicate active session in search results", async () => { - mockSessionService.sessionId = "session-active" + mockSessionManager.sessionId = "session-active" const mockSearchResults = [ { session_id: "session-active", @@ -598,8 +595,8 @@ describe("sessionCommand", () => { describe("handler - fork subcommand", () => { beforeEach(() => { - // Setup forkSession mock on service - mockSessionService.forkSession = vi.fn().mockResolvedValue({ + // Setup forkSession mock on manager + mockSessionManager.forkSession = vi.fn().mockResolvedValue({ session_id: "forked-session-123", title: "Forked Session", created_at: new Date().toISOString(), @@ -616,10 +613,10 @@ describe("sessionCommand", () => { await sessionCommand.handler(mockContext) - expect(SessionService.init).toHaveBeenCalled() + expect(SessionManager.init).toHaveBeenCalled() expect(mockContext.replaceMessages).toHaveBeenCalledTimes(1) expect(mockContext.refreshTerminal).toHaveBeenCalled() - expect(mockSessionService.forkSession).toHaveBeenCalledWith("share-123", true) + expect(mockSessionManager.forkSession).toHaveBeenCalledWith("share-123", true) // restoreSession is now called internally by forkSession, not by the command handler const replacedMessages = (mockContext.replaceMessages as ReturnType).mock.calls[0][0] @@ -637,7 +634,7 @@ describe("sessionCommand", () => { const message = (mockContext.addMessage as ReturnType).mock.calls[0][0] expect(message.type).toBe("error") expect(message.content).toContain("Usage: /session fork ") - expect(mockSessionService.forkSession).not.toHaveBeenCalled() + expect(mockSessionManager.forkSession).not.toHaveBeenCalled() }) it("should show error when shareId is empty string", async () => { @@ -652,7 +649,7 @@ describe("sessionCommand", () => { }) it("should handle fork error gracefully", async () => { - mockSessionService.forkSession = vi.fn().mockRejectedValue(new Error("Share ID not found")) + mockSessionManager.forkSession = vi.fn().mockRejectedValue(new Error("Share ID not found")) mockContext.args = ["fork", "invalid-share-id"] await sessionCommand.handler(mockContext) @@ -673,24 +670,24 @@ describe("sessionCommand", () => { await sessionCommand.handler(mockContext) - expect(mockSessionService.forkSession).toHaveBeenCalledWith("share-123", true) + expect(mockSessionManager.forkSession).toHaveBeenCalledWith("share-123", true) }) }) describe("handler - rename subcommand", () => { beforeEach(() => { - // Setup renameSession mock on service - mockSessionService.renameSession = vi.fn().mockResolvedValue(undefined) + // Setup renameSession mock on manager + mockSessionManager.renameSession = vi.fn().mockResolvedValue(undefined) }) it("should rename session successfully", async () => { - mockSessionService.sessionId = "test-session-123" + mockSessionManager.sessionId = "test-session-123" mockContext.args = ["rename", "My", "New", "Session", "Name"] await sessionCommand.handler(mockContext) - expect(SessionService.init).toHaveBeenCalled() - expect(mockSessionService.renameSession).toHaveBeenCalledWith("My New Session Name") + expect(SessionManager.init).toHaveBeenCalled() + expect(mockSessionManager.renameSession).toHaveBeenCalledWith("My New Session Name") expect(mockContext.addMessage).toHaveBeenCalledTimes(1) const message = (mockContext.addMessage as ReturnType).mock.calls[0][0] expect(message.type).toBe("system") @@ -699,12 +696,12 @@ describe("sessionCommand", () => { }) it("should rename session with single word name", async () => { - mockSessionService.sessionId = "test-session-123" + mockSessionManager.sessionId = "test-session-123" mockContext.args = ["rename", "SingleWord"] await sessionCommand.handler(mockContext) - expect(mockSessionService.renameSession).toHaveBeenCalledWith("SingleWord") + expect(mockSessionManager.renameSession).toHaveBeenCalledWith("SingleWord") const message = (mockContext.addMessage as ReturnType).mock.calls[0][0] expect(message.type).toBe("system") expect(message.content).toContain("SingleWord") @@ -719,11 +716,11 @@ describe("sessionCommand", () => { const message = (mockContext.addMessage as ReturnType).mock.calls[0][0] expect(message.type).toBe("error") expect(message.content).toContain("Usage: /session rename ") - expect(mockSessionService.renameSession).not.toHaveBeenCalled() + expect(mockSessionManager.renameSession).not.toHaveBeenCalled() }) it("should handle rename error when no active session", async () => { - mockSessionService.renameSession = vi.fn().mockRejectedValue(new Error("No active session")) + mockSessionManager.renameSession = vi.fn().mockRejectedValue(new Error("No active session")) mockContext.args = ["rename", "New", "Name"] await sessionCommand.handler(mockContext) @@ -736,7 +733,7 @@ describe("sessionCommand", () => { }) it("should handle rename error when title is empty", async () => { - mockSessionService.renameSession = vi.fn().mockRejectedValue(new Error("Session title cannot be empty")) + mockSessionManager.renameSession = vi.fn().mockRejectedValue(new Error("Session title cannot be empty")) mockContext.args = ["rename", " "] await sessionCommand.handler(mockContext) @@ -748,16 +745,16 @@ describe("sessionCommand", () => { }) it("should handle 'rename' subcommand case-insensitively", async () => { - mockSessionService.sessionId = "test-session-123" + mockSessionManager.sessionId = "test-session-123" mockContext.args = ["RENAME", "New", "Name"] await sessionCommand.handler(mockContext) - expect(mockSessionService.renameSession).toHaveBeenCalledWith("New Name") + expect(mockSessionManager.renameSession).toHaveBeenCalledWith("New Name") }) it("should handle backend error gracefully", async () => { - mockSessionService.renameSession = vi.fn().mockRejectedValue(new Error("Network error")) + mockSessionManager.renameSession = vi.fn().mockRejectedValue(new Error("Network error")) mockContext.args = ["rename", "New", "Name"] await sessionCommand.handler(mockContext) @@ -781,7 +778,7 @@ describe("sessionCommand", () => { await sessionCommand.handler(mockContext) - expect(SessionClient.getInstance).toHaveBeenCalled() + expect(SessionManager.init).toHaveBeenCalled() expect(mockSessionClient.delete).toHaveBeenCalledWith({ session_id: "session-123" }) expect(mockContext.addMessage).toHaveBeenCalledTimes(1) const message = (mockContext.addMessage as ReturnType).mock.calls[0][0] @@ -855,26 +852,25 @@ describe("sessionCommand", () => { expect(message.content).toContain("rename") }) - it("should not call SessionService for invalid subcommand", async () => { + it("should not call SessionManager for invalid subcommand", async () => { mockContext.args = ["invalid"] await sessionCommand.handler(mockContext) - expect(SessionService.init).not.toHaveBeenCalled() - expect(SessionClient.getInstance).not.toHaveBeenCalled() + expect(SessionManager.init).not.toHaveBeenCalled() }) }) describe("handler - execution", () => { it("should execute without errors when session exists", async () => { - mockSessionService.sessionId = "test-id" + mockSessionManager.sessionId = "test-id" mockContext.args = ["show"] await expect(sessionCommand.handler(mockContext)).resolves.not.toThrow() }) it("should execute without errors when no session exists", async () => { - mockSessionService.sessionId = null + mockSessionManager.sessionId = null mockContext.args = ["show"] await expect(sessionCommand.handler(mockContext)).resolves.not.toThrow() @@ -889,7 +885,7 @@ describe("sessionCommand", () => { describe("message generation", () => { it("should generate messages with proper structure", async () => { - mockSessionService.sessionId = "test-id" + mockSessionManager.sessionId = "test-id" mockContext.args = ["show"] await sessionCommand.handler(mockContext) diff --git a/cli/src/commands/new.ts b/cli/src/commands/new.ts index e25393adc3f..e62a94b195c 100644 --- a/cli/src/commands/new.ts +++ b/cli/src/commands/new.ts @@ -2,9 +2,9 @@ * /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" -import { SessionService } from "../services/session.js" export const newCommand: Command = { name: "new", @@ -22,7 +22,7 @@ export const newCommand: Command = { // Clear the session to start fresh try { - const sessionService = SessionService.init() + const sessionService = SessionManager.init() await sessionService.destroy() } catch (error) { // Log error but don't block the command - session might not exist yet diff --git a/cli/src/commands/session.ts b/cli/src/commands/session.ts index 9655b2a42e9..caa3ce84c5d 100644 --- a/cli/src/commands/session.ts +++ b/cli/src/commands/session.ts @@ -4,9 +4,8 @@ import { generateMessage } from "../ui/utils/messages.js" import type { Command, CommandContext, ArgumentProviderContext, ArgumentSuggestion } from "./core/types.js" -import { SessionService } from "../services/session.js" -import { SessionClient } from "../services/sessionClient.js" import { formatRelativeTime } from "../utils/time.js" +import { SessionManager } from "../../../src/shared/kilocode/cli-sessions/core/SessionManager.js" /** * Show current session ID @@ -14,7 +13,7 @@ import { formatRelativeTime } from "../utils/time.js" async function showSessionId(context: CommandContext): Promise { const { addMessage } = context - const sessionService = SessionService.init() + const sessionService = SessionManager.init() const sessionId = sessionService.sessionId if (!sessionId) { @@ -38,8 +37,8 @@ async function showSessionId(context: CommandContext): Promise { */ async function listSessions(context: CommandContext): Promise { const { addMessage } = context - const sessionService = SessionService.init() - const sessionClient = SessionClient.getInstance() + const sessionService = SessionManager.init() + const sessionClient = sessionService.sessionClient try { const result = await sessionClient.list({ limit: 50 }) @@ -89,7 +88,7 @@ async function listSessions(context: CommandContext): Promise { */ async function selectSession(context: CommandContext, sessionId: string): Promise { const { addMessage, replaceMessages, refreshTerminal } = context - const sessionService = SessionService.init() + const sessionService = SessionManager.init() if (!sessionId) { addMessage({ @@ -136,8 +135,8 @@ async function selectSession(context: CommandContext, sessionId: string): Promis */ async function searchSessions(context: CommandContext, query: string): Promise { const { addMessage } = context - const sessionService = SessionService.init() - const sessionClient = SessionClient.getInstance() + const sessionService = SessionManager.init() + const sessionClient = sessionService.sessionClient if (!query) { addMessage({ @@ -191,7 +190,7 @@ async function searchSessions(context: CommandContext, query: string): Promise { const { addMessage } = context - const sessionService = SessionService.init() + const sessionService = SessionManager.init() try { const result = await sessionService.shareSession() @@ -215,7 +214,7 @@ async function shareSession(context: CommandContext): Promise { */ async function forkSession(context: CommandContext, shareId: string): Promise { const { addMessage, replaceMessages, refreshTerminal } = context - const sessionService = SessionService.init() + const sessionService = SessionManager.init() if (!shareId) { addMessage({ @@ -263,7 +262,8 @@ async function forkSession(context: CommandContext, shareId: string): Promise { const { addMessage } = context - const sessionClient = SessionClient.getInstance() + const sessionService = SessionManager.init() + const sessionClient = sessionService.sessionClient if (!sessionId) { addMessage({ @@ -296,7 +296,7 @@ async function deleteSession(context: CommandContext, sessionId: string): Promis */ async function renameSession(context: CommandContext, newName: string): Promise { const { addMessage } = context - const sessionService = SessionService.init() + const sessionService = SessionManager.init() if (!newName) { addMessage({ @@ -328,7 +328,8 @@ async function renameSession(context: CommandContext, newName: string): Promise< * Autocomplete provider for session IDs */ async function sessionIdAutocompleteProvider(context: ArgumentProviderContext): Promise { - const sessionClient = SessionClient.getInstance() + const sessionService = SessionManager.init() + const sessionClient = sessionService.sessionClient // Extract prefix from user input const prefix = context.partialInput.trim() diff --git a/cli/src/services/__tests__/session.test.ts b/cli/src/services/__tests__/session.test.ts deleted file mode 100644 index e38f27ad9a5..00000000000 --- a/cli/src/services/__tests__/session.test.ts +++ /dev/null @@ -1,2623 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from "vitest" -import { SessionService } from "../session.js" -import { SessionClient } from "../sessionClient.js" -import type { ExtensionService } from "../extension.js" -import type { ClineMessage } from "@roo-code/types" -import type { SimpleGit, RemoteWithRefs } from "simple-git" - -// Mock fs module -vi.mock("fs", () => ({ - readFileSync: vi.fn(), - writeFileSync: vi.fn(), - existsSync: vi.fn(), -})) - -// Mock fs-extra module -vi.mock("fs-extra", () => ({ - ensureDirSync: vi.fn(), -})) - -vi.mock("../sessionClient.js") -vi.mock("../logs.js", () => ({ - logs: { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }, -})) - -// Mock KiloCodePaths -vi.mock("../../utils/paths.js", () => ({ - KiloCodePaths: { - getTasksDir: vi.fn(() => "/mock/tasks/dir"), - getLastSessionPath: vi.fn((workspace: string) => `/mock/workspace/${workspace}/last-session.json`), - }, -})) - -// Mock simple-git -vi.mock("simple-git") - -// Import after mocking -import { readFileSync, writeFileSync, existsSync } from "fs" -import { ensureDirSync } from "fs-extra" -import { logs } from "../logs.js" -import simpleGit from "simple-git" -import { createStore } from "jotai" - -describe("SessionService", () => { - let service: SessionService - let mockSessionClient: SessionClient - let mockCreate: ReturnType - let mockUpdate: ReturnType - let mockGet: ReturnType - let mockExtensionService: ExtensionService - let mockSendWebviewMessage: ReturnType - let mockRequestSingleCompletion: ReturnType - let mockGit: Partial - let mockStore: ReturnType - - beforeEach(() => { - vi.useFakeTimers() - vi.clearAllMocks() - - // Reset the singleton instance before each test - // @ts-expect-error - Accessing private static property for testing - SessionService.instance = null - - // Mock ExtensionService - mockSendWebviewMessage = vi.fn().mockResolvedValue(undefined) - mockRequestSingleCompletion = vi.fn() - mockExtensionService = { - sendWebviewMessage: mockSendWebviewMessage, - requestSingleCompletion: mockRequestSingleCompletion, - } as unknown as ExtensionService - - // Mock Jotai store - mockStore = { - get: vi.fn(), - set: vi.fn(), - sub: vi.fn(), - } as unknown as ReturnType - - // Mock SessionClient methods - mockCreate = vi.fn() - mockUpdate = vi.fn() - mockGet = vi.fn() - mockSessionClient = { - create: mockCreate, - update: mockUpdate, - get: mockGet, - } as unknown as SessionClient - - // Mock SessionClient.getInstance to return our mock - vi.spyOn(SessionClient, "getInstance").mockReturnValue(mockSessionClient) - - // Set up default git mocks for all tests - make git fail by default - // (as if not in a git repository). Tests that need git will set up their own mocks. - mockGit = { - getRemotes: vi.fn().mockRejectedValue(new Error("not a git repository")), - revparse: vi.fn().mockRejectedValue(new Error("not a git repository")), - raw: vi.fn().mockRejectedValue(new Error("not a git repository")), - diff: vi.fn().mockRejectedValue(new Error("not a git repository")), - } - vi.mocked(simpleGit).mockReturnValue(mockGit as SimpleGit) - - service = SessionService.init(mockExtensionService, mockStore, false) - }) - - afterEach(async () => { - if (service) { - await service.destroy() - } - vi.restoreAllMocks() - vi.useRealTimers() - }) - - describe("init", () => { - it("should throw error when called without extensionService on first init", () => { - // @ts-expect-error - Accessing private static property for testing - SessionService.instance = null - - expect(() => SessionService.init()).toThrow("SessionService not initialized") - }) - - it("should return same instance on multiple calls", () => { - const instance1 = SessionService.init(mockExtensionService, mockStore, false) - const instance2 = SessionService.init() - expect(instance1).toBe(instance2) - }) - - it("should be a singleton", () => { - // @ts-expect-error - Accessing private static property for testing - expect(SessionService.instance).not.toBeNull() - }) - - it("should accept extensionService parameter", () => { - // @ts-expect-error - Accessing private static property for testing - SessionService.instance = null - - const instance = SessionService.init(mockExtensionService, mockStore, false) - expect(instance).toBeInstanceOf(SessionService) - expect(vi.mocked(logs.debug)).toHaveBeenCalledWith("Initialized SessionService", "SessionService") - }) - }) - - describe("readPath", () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it("should read and parse JSON file", () => { - const jsonContent = JSON.stringify({ key: "value" }) - vi.mocked(readFileSync).mockReturnValueOnce(jsonContent) - - // @ts-expect-error - Testing private method - const result = service.readPath("/path/to/file.json") - - expect(readFileSync).toHaveBeenCalledWith("/path/to/file.json", "utf-8") - expect(result).toEqual({ key: "value" }) - }) - - it("should return undefined for non-JSON files", () => { - const textContent = "plain text content" - vi.mocked(readFileSync).mockReturnValueOnce(textContent) - - // @ts-expect-error - Testing private method - const result = service.readPath("/path/to/file.txt") - - expect(readFileSync).toHaveBeenCalledWith("/path/to/file.txt", "utf-8") - expect(result).toBeUndefined() - }) - - it("should return undefined when file read fails", () => { - vi.mocked(readFileSync).mockImplementationOnce(() => { - throw new Error("File not found") - }) - - // @ts-expect-error - Testing private method - const result = service.readPath("/path/to/nonexistent.json") - - expect(result).toBeUndefined() - }) - - it("should return undefined when JSON parse fails", () => { - const invalidJson = "{invalid json" - vi.mocked(readFileSync).mockReturnValueOnce(invalidJson) - - // @ts-expect-error - Testing private method - const result = service.readPath("/path/to/invalid.json") - - expect(result).toBeUndefined() - }) - }) - - describe("readPaths", () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it("should read all configured paths", () => { - const mockData1 = { data: "api" } - const mockData2 = { data: "ui" } - const mockData3 = { data: "metadata" } - - vi.mocked(readFileSync) - .mockReturnValueOnce(JSON.stringify(mockData1)) - .mockReturnValueOnce(JSON.stringify(mockData2)) - .mockReturnValueOnce(JSON.stringify(mockData3)) - - service.setPath("apiConversationHistoryPath", "/path/to/api.json") - service.setPath("uiMessagesPath", "/path/to/ui.json") - service.setPath("taskMetadataPath", "/path/to/metadata.json") - - // @ts-expect-error - Testing private method - const result = service.readPaths() - - expect(result).toEqual({ - apiConversationHistoryPath: mockData1, - uiMessagesPath: mockData2, - taskMetadataPath: mockData3, - }) - }) - - it("should skip null paths", () => { - const mockData = { data: "api" } - vi.mocked(readFileSync).mockReturnValueOnce(JSON.stringify(mockData)) - - service.setPath("apiConversationHistoryPath", "/path/to/api.json") - - // @ts-expect-error - Testing private method - const result = service.readPaths() - - expect(result).toEqual({ - apiConversationHistoryPath: mockData, - }) - expect(readFileSync).toHaveBeenCalledTimes(1) - }) - - it("should skip paths with undefined content", () => { - vi.mocked(readFileSync).mockImplementationOnce(() => { - throw new Error("File not found") - }) - - service.setPath("apiConversationHistoryPath", "/path/to/nonexistent.json") - - // @ts-expect-error - Testing private method - const result = service.readPaths() - - expect(result).toEqual({}) - }) - - it("should return empty object when no paths configured", () => { - // @ts-expect-error - Testing private method - const result = service.readPaths() - - expect(result).toEqual({}) - expect(readFileSync).not.toHaveBeenCalled() - }) - }) - - describe("syncSession", () => { - it("should not sync when no paths configured", async () => { - vi.advanceTimersByTime(1000) - - expect(mockCreate).not.toHaveBeenCalled() - expect(mockUpdate).not.toHaveBeenCalled() - }) - - it("should create new session on first sync", async () => { - const mockData = { messages: [] } - vi.mocked(readFileSync).mockReturnValueOnce(JSON.stringify(mockData)) - - mockCreate.mockResolvedValueOnce({ - session_id: "new-session-id", - title: "", - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-01T00:00:00Z", - }) - - const mockUploadBlob = vi.fn().mockResolvedValue({ - session_id: "new-session-id", - updated_at: "2025-01-01T00:00:00Z", - }) - mockSessionClient.uploadBlob = mockUploadBlob - - service.setPath("apiConversationHistoryPath", "/path/to/api.json") - - // Trigger sync via timer - await vi.advanceTimersByTimeAsync(SessionService.SYNC_INTERVAL) - - expect(mockCreate).toHaveBeenCalledWith({ - created_on_platform: "cli", - }) - expect(mockUploadBlob).toHaveBeenCalledWith("new-session-id", "api_conversation_history", mockData) - expect(mockUpdate).not.toHaveBeenCalled() - }) - - it("should update existing session when git URL changes", async () => { - vi.mocked(readFileSync) - .mockReturnValueOnce(JSON.stringify({ messages: ["first"] })) - .mockReturnValueOnce(JSON.stringify({ messages: ["first", "second"] })) - - mockCreate.mockResolvedValueOnce({ - session_id: "session-id", - title: "", - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-01T00:00:00Z", - }) - - mockUpdate.mockResolvedValueOnce({ - session_id: "session-id", - title: "", - updated_at: "2025-01-01T00:01:00Z", - }) - - const mockUploadBlob = vi.fn().mockResolvedValue({ - session_id: "session-id", - updated_at: "2025-01-01T00:00:00Z", - }) - mockSessionClient.uploadBlob = mockUploadBlob - - // Set up git mocks for first sync - mockGit.getRemotes = vi.fn().mockResolvedValue([ - { - name: "origin", - refs: { - fetch: "https://github.com/user/repo.git", - push: "https://github.com/user/repo.git", - }, - }, - ]) - mockGit.revparse = vi.fn().mockResolvedValue("abc123") - mockGit.raw = vi.fn().mockImplementation((...args: unknown[]) => { - const cmd = Array.isArray(args[0]) ? args[0] : args - if (cmd[0] === "ls-files") { - return Promise.resolve("") - } - if (cmd[0] === "symbolic-ref") { - return Promise.resolve("refs/heads/main") - } - return Promise.resolve("") - }) - mockGit.diff = vi.fn().mockResolvedValue("some diff") - - service.setPath("apiConversationHistoryPath", "/path/to/api.json") - - // First sync - creates session with git URL - await vi.advanceTimersByTimeAsync(SessionService.SYNC_INTERVAL) - - expect(mockCreate).toHaveBeenCalledWith({ - created_on_platform: "cli", - git_url: "https://github.com/user/repo.git", - }) - - // Change git URL to trigger update - mockGit.getRemotes = vi.fn().mockResolvedValue([ - { - name: "origin", - refs: { - fetch: "https://github.com/user/new-repo.git", - push: "https://github.com/user/new-repo.git", - }, - }, - ]) - - // Modify path to trigger new sync - service.setPath("apiConversationHistoryPath", "/path/to/api.json") - - // Second sync - updates session because git URL changed - await vi.advanceTimersByTimeAsync(SessionService.SYNC_INTERVAL) - - expect(mockUpdate).toHaveBeenCalledWith({ - session_id: "session-id", - git_url: "https://github.com/user/new-repo.git", - }) - expect(mockUploadBlob).toHaveBeenCalledWith("session-id", "api_conversation_history", { - messages: ["first", "second"], - }) - }) - - it("should not sync when no blob has changed", async () => { - const mockData = { messages: [] } - vi.mocked(readFileSync).mockReturnValue(JSON.stringify(mockData)) - - mockCreate.mockResolvedValueOnce({ - session_id: "session-id", - title: "", - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-01T00:00:00Z", - }) - - const mockUploadBlob = vi.fn().mockResolvedValue({ - session_id: "session-id", - updated_at: "2025-01-01T00:00:00Z", - }) - mockSessionClient.uploadBlob = mockUploadBlob - - service.setPath("apiConversationHistoryPath", "/path/to/api.json") - - // First sync - await vi.advanceTimersByTimeAsync(SessionService.SYNC_INTERVAL) - - expect(mockCreate).toHaveBeenCalledTimes(1) - expect(mockUploadBlob).toHaveBeenCalledTimes(1) - - // Clear mocks to check second sync - vi.clearAllMocks() - - // Second timer tick without setPath - should not sync - await vi.advanceTimersByTimeAsync(SessionService.SYNC_INTERVAL) - - expect(mockCreate).not.toHaveBeenCalled() - expect(mockUpdate).not.toHaveBeenCalled() - expect(mockUploadBlob).not.toHaveBeenCalled() - }) - - it("should not sync when all file reads return undefined", async () => { - vi.mocked(readFileSync).mockImplementation(() => { - throw new Error("File not found") - }) - - service.setPath("apiConversationHistoryPath", "/path/to/nonexistent.json") - - await vi.advanceTimersByTimeAsync(1000) - - expect(mockCreate).not.toHaveBeenCalled() - expect(mockUpdate).not.toHaveBeenCalled() - }) - - it("should sync with multiple paths", async () => { - const mockData1 = { api: "data" } - const mockData2 = [{ ts: 1000, type: "say", say: "text", text: "test message" }] - const mockData3 = { task: "data" } - - vi.mocked(readFileSync) - .mockReturnValueOnce(JSON.stringify(mockData1)) - .mockReturnValueOnce(JSON.stringify(mockData2)) - .mockReturnValueOnce(JSON.stringify(mockData3)) - - mockCreate.mockResolvedValueOnce({ - session_id: "session-id", - title: "", - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-01T00:00:00Z", - }) - - const mockUploadBlob = vi.fn().mockResolvedValue({ - session_id: "session-id", - updated_at: "2025-01-01T00:00:00Z", - }) - mockSessionClient.uploadBlob = mockUploadBlob - - service.setPath("apiConversationHistoryPath", "/path/to/api.json") - service.setPath("uiMessagesPath", "/path/to/ui.json") - service.setPath("taskMetadataPath", "/path/to/metadata.json") - - await vi.advanceTimersByTimeAsync(SessionService.SYNC_INTERVAL) - - expect(mockCreate).toHaveBeenCalledWith({ - created_on_platform: "cli", - title: "test message", - }) - expect(mockUploadBlob).toHaveBeenCalledWith("session-id", "api_conversation_history", mockData1) - expect(mockUploadBlob).toHaveBeenCalledWith("session-id", "ui_messages", mockData2) - expect(mockUploadBlob).toHaveBeenCalledWith("session-id", "task_metadata", mockData3) - }) - }) - - describe("setPath", () => { - it("should set path and trigger blob hash update", () => { - service.setPath("apiConversationHistoryPath", "/path/to/api.json") - - // @ts-expect-error - Accessing private property for testing - expect(service.paths.apiConversationHistoryPath).toBe("/path/to/api.json") - // @ts-expect-error - Accessing private property for testing - expect(service.blobHashes.apiConversationHistory).toBeTruthy() - }) - - it("should update blob hash with unique values on each call", () => { - service.setPath("apiConversationHistoryPath", "/path/to/api.json") - // @ts-expect-error - Accessing private property for testing - const firstHash = service.blobHashes.apiConversationHistory - - service.setPath("apiConversationHistoryPath", "/path/to/api.json") - // @ts-expect-error - Accessing private property for testing - const secondHash = service.blobHashes.apiConversationHistory - - expect(firstHash).not.toBe(secondHash) - }) - }) - - describe("destroy", () => { - it("should clear timer", async () => { - const clearIntervalSpy = vi.spyOn(global, "clearInterval") - - await service.destroy() - - expect(clearIntervalSpy).toHaveBeenCalled() - }) - - it("should flush session before destroying", async () => { - const mockData = { messages: [] } - vi.mocked(readFileSync).mockReturnValue(JSON.stringify(mockData)) - - mockCreate.mockResolvedValueOnce({ - session_id: "session-id", - title: "", - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-01T00:00:00Z", - }) - - const mockUploadBlob = vi.fn().mockResolvedValue({ - session_id: "session-id", - updated_at: "2025-01-01T00:00:00Z", - }) - mockSessionClient.uploadBlob = mockUploadBlob - - // Set up git mocks - mockGit.getRemotes = vi.fn().mockResolvedValue([ - { - name: "origin", - refs: { - fetch: "https://github.com/user/repo.git", - push: "https://github.com/user/repo.git", - }, - }, - ]) - mockGit.revparse = vi.fn().mockResolvedValue("abc123") - mockGit.raw = vi.fn().mockImplementation((...args: unknown[]) => { - const cmd = Array.isArray(args[0]) ? args[0] : args - if (cmd[0] === "ls-files") { - return Promise.resolve("") - } - if (cmd[0] === "symbolic-ref") { - return Promise.resolve("refs/heads/main") - } - return Promise.resolve("") - }) - mockGit.diff = vi.fn().mockResolvedValue("some diff") - - service.setPath("apiConversationHistoryPath", "/path/to/api.json") - - // Wait for initial sync to create the session - await vi.advanceTimersByTimeAsync(SessionService.SYNC_INTERVAL) - - expect(mockCreate).toHaveBeenCalledTimes(1) - - // Clear mocks to isolate destroy behavior - vi.clearAllMocks() - - // Change git URL to trigger update during destroy - mockGit.getRemotes = vi.fn().mockResolvedValue([ - { - name: "origin", - refs: { - fetch: "https://github.com/user/new-repo.git", - push: "https://github.com/user/new-repo.git", - }, - }, - ]) - - // Set a new path to trigger sync during destroy - service.setPath("apiConversationHistoryPath", "/path/to/api.json") - - mockUpdate.mockResolvedValueOnce({ - session_id: "session-id", - title: "", - updated_at: "2025-01-01T00:01:00Z", - }) - - await service.destroy() - - // Should have called update because git URL changed - expect(mockUpdate).toHaveBeenCalledWith({ - session_id: "session-id", - git_url: "https://github.com/user/new-repo.git", - }) - expect(mockUploadBlob).toHaveBeenCalledWith("session-id", "api_conversation_history", mockData) - }) - - it("should reset paths to default", async () => { - service.setPath("apiConversationHistoryPath", "/path/to/api.json") - - // @ts-expect-error - Accessing private property for testing - expect(service.paths.apiConversationHistoryPath).toBe("/path/to/api.json") - - await service.destroy() - - // @ts-expect-error - Accessing private property for testing - expect(service.paths.apiConversationHistoryPath).toBeNull() - }) - - it("should reset sessionId", async () => { - // Set up a session - const mockData = { messages: [] } - vi.mocked(readFileSync).mockReturnValue(JSON.stringify(mockData)) - - mockCreate.mockResolvedValueOnce({ - session_id: "session-id", - title: "", - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-01T00:00:00Z", - }) - - service.setPath("apiConversationHistoryPath", "/path/to/api.json") - - // Wait for session creation - await vi.advanceTimersByTimeAsync(SessionService.SYNC_INTERVAL) - - await service.destroy() - - expect(service.sessionId).toBeNull() - }) - - it("should allow timer to be cleared multiple times safely", async () => { - await service.destroy() - await expect(service.destroy()).resolves.not.toThrow() - }) - }) - - describe("timer behavior", () => { - it("should start timer on construction", () => { - const setIntervalSpy = vi.spyOn(global, "setInterval") - - // Create new instance to test construction - // @ts-expect-error - Reset for testing - SessionService.instance = null - SessionService.init(mockExtensionService, mockStore, false) - - expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), SessionService.SYNC_INTERVAL) - }) - - it("should call syncSession at SYNC_INTERVAL", async () => { - const mockData = { messages: [] } - vi.mocked(readFileSync).mockReturnValue(JSON.stringify(mockData)) - - mockCreate.mockResolvedValue({ - session_id: "session-id", - title: "", - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-01T00:00:00Z", - }) - - // Set up git mocks for first sync - mockGit.getRemotes = vi.fn().mockResolvedValue([ - { - name: "origin", - refs: { - fetch: "https://github.com/user/repo.git", - push: "https://github.com/user/repo.git", - }, - }, - ]) - mockGit.revparse = vi.fn().mockResolvedValue("abc123") - mockGit.raw = vi.fn().mockImplementation((...args: unknown[]) => { - const cmd = Array.isArray(args[0]) ? args[0] : args - if (cmd[0] === "ls-files") { - return Promise.resolve("") - } - if (cmd[0] === "symbolic-ref") { - return Promise.resolve("refs/heads/main") - } - return Promise.resolve("") - }) - mockGit.diff = vi.fn().mockResolvedValue("some diff") - - service.setPath("apiConversationHistoryPath", "/path/to/api.json") - - // First tick - await vi.advanceTimersByTimeAsync(SessionService.SYNC_INTERVAL) - expect(mockCreate).toHaveBeenCalledTimes(1) - - // Change git URL to trigger update - mockGit.getRemotes = vi.fn().mockResolvedValue([ - { - name: "origin", - refs: { - fetch: "https://github.com/user/new-repo.git", - push: "https://github.com/user/new-repo.git", - }, - }, - ]) - - // Trigger new save event - service.setPath("apiConversationHistoryPath", "/path/to/api.json") - - // Second tick - should call update because git URL changed - mockUpdate.mockResolvedValue({ - session_id: "session-id", - title: "", - updated_at: "2025-01-01T00:01:00Z", - }) - - await vi.advanceTimersByTimeAsync(SessionService.SYNC_INTERVAL) - expect(mockUpdate).toHaveBeenCalledTimes(1) - }) - }) - - describe("error handling", () => { - it("should handle API errors gracefully and continue syncing", async () => { - const mockData = { messages: [] } - vi.mocked(readFileSync).mockReturnValue(JSON.stringify(mockData)) - - // First call fails - mockCreate.mockRejectedValueOnce(new Error("Network error")) - - service.setPath("apiConversationHistoryPath", "/path/to/api.json") - - // First sync attempt - should fail but not throw - await vi.advanceTimersByTimeAsync(SessionService.SYNC_INTERVAL) - - expect(mockCreate).toHaveBeenCalledTimes(1) - expect(vi.mocked(logs.error)).toHaveBeenCalledWith( - "Failed to sync session", - "SessionService", - expect.objectContaining({ - error: "Network error", - }), - ) - - // Second call succeeds - mockCreate.mockResolvedValueOnce({ - session_id: "session-id", - title: "", - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-01T00:00:00Z", - }) - - // Trigger new save event to force new sync - service.setPath("apiConversationHistoryPath", "/path/to/api.json") - - // Second sync attempt - should succeed - await vi.advanceTimersByTimeAsync(SessionService.SYNC_INTERVAL) - - expect(mockCreate).toHaveBeenCalledTimes(2) - expect(vi.mocked(logs.info)).toHaveBeenCalledWith( - "Session created successfully", - "SessionService", - expect.objectContaining({ - sessionId: "session-id", - }), - ) - }) - - it("should handle update failures gracefully", async () => { - const mockData = { messages: [] } - vi.mocked(readFileSync).mockReturnValue(JSON.stringify(mockData)) - - mockCreate.mockResolvedValueOnce({ - session_id: "session-id", - title: "", - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-01T00:00:00Z", - }) - - // Set up git mocks for first sync - mockGit.getRemotes = vi.fn().mockResolvedValue([ - { - name: "origin", - refs: { - fetch: "https://github.com/user/repo.git", - push: "https://github.com/user/repo.git", - }, - }, - ]) - mockGit.revparse = vi.fn().mockResolvedValue("abc123") - mockGit.raw = vi.fn().mockImplementation((...args: unknown[]) => { - const cmd = Array.isArray(args[0]) ? args[0] : args - if (cmd[0] === "ls-files") { - return Promise.resolve("") - } - if (cmd[0] === "symbolic-ref") { - return Promise.resolve("refs/heads/main") - } - return Promise.resolve("") - }) - mockGit.diff = vi.fn().mockResolvedValue("some diff") - - service.setPath("apiConversationHistoryPath", "/path/to/api.json") - - // First sync - creates session - await vi.advanceTimersByTimeAsync(SessionService.SYNC_INTERVAL) - - expect(mockCreate).toHaveBeenCalledTimes(1) - - // Change git URL to trigger update - mockGit.getRemotes = vi.fn().mockResolvedValue([ - { - name: "origin", - refs: { - fetch: "https://github.com/user/new-repo.git", - push: "https://github.com/user/new-repo.git", - }, - }, - ]) - - // Update fails - mockUpdate.mockRejectedValueOnce(new Error("Update failed")) - - service.setPath("apiConversationHistoryPath", "/path/to/api.json") - - // Second sync - update fails but doesn't throw - await vi.advanceTimersByTimeAsync(SessionService.SYNC_INTERVAL) - - expect(mockUpdate).toHaveBeenCalledTimes(1) - expect(vi.mocked(logs.error)).toHaveBeenCalledWith( - "Failed to sync session", - "SessionService", - expect.objectContaining({ - error: "Update failed", - sessionId: "session-id", - }), - ) - }) - - it("should complete destroy even when final sync fails", async () => { - const mockData = { messages: [] } - vi.mocked(readFileSync).mockReturnValue(JSON.stringify(mockData)) - - // Set up git mocks for first sync - mockGit.getRemotes = vi.fn().mockResolvedValue([ - { - name: "origin", - refs: { - fetch: "https://github.com/user/repo.git", - push: "https://github.com/user/repo.git", - }, - }, - ]) - mockGit.revparse = vi.fn().mockResolvedValue("abc123") - mockGit.raw = vi.fn().mockImplementation((...args: unknown[]) => { - const cmd = Array.isArray(args[0]) ? args[0] : args - if (cmd[0] === "ls-files") { - return Promise.resolve("") - } - if (cmd[0] === "symbolic-ref") { - return Promise.resolve("refs/heads/main") - } - return Promise.resolve("") - }) - mockGit.diff = vi.fn().mockResolvedValue("some diff") - - // Create a session first - mockCreate.mockResolvedValueOnce({ - session_id: "session-id", - title: "", - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-01T00:00:00Z", - }) - - const mockUploadBlob = vi.fn().mockResolvedValue({ - session_id: "session-id", - updated_at: "2025-01-01T00:00:00Z", - }) - mockSessionClient.uploadBlob = mockUploadBlob - - service.setPath("apiConversationHistoryPath", "/path/to/api.json") - - // Wait for initial sync to complete - await vi.advanceTimersByTimeAsync(SessionService.SYNC_INTERVAL) - - // Clear mocks to isolate destroy behavior - vi.clearAllMocks() - - // Change git URL to trigger update during destroy - mockGit.getRemotes = vi.fn().mockResolvedValue([ - { - name: "origin", - refs: { - fetch: "https://github.com/user/new-repo.git", - push: "https://github.com/user/new-repo.git", - }, - }, - ]) - - // Set a new path to trigger sync during destroy - service.setPath("apiConversationHistoryPath", "/path/to/api2.json") - - // Make the update during destroy fail - mockUpdate.mockRejectedValueOnce(new Error("Sync failed during destroy")) - - // Destroy should complete without throwing, even though sync fails - await expect(service.destroy()).resolves.not.toThrow() - - // syncSession logs the error (not destroy, since syncSession catches internally) - expect(vi.mocked(logs.error)).toHaveBeenCalledWith( - "Failed to sync session", - "SessionService", - expect.objectContaining({ - error: "Sync failed during destroy", - sessionId: "session-id", - }), - ) - - // Verify destroy completed successfully - check for flushed message instead - expect(vi.mocked(logs.debug)).toHaveBeenCalledWith("SessionService flushed", "SessionService") - }) - }) - - describe("concurrency protection", () => { - it("should prevent concurrent sync operations", async () => { - const mockData = { messages: [] } - vi.mocked(readFileSync).mockReturnValue(JSON.stringify(mockData)) - - // Make the first sync take a long time - let resolveFirst: () => void - const firstSyncPromise = new Promise<{ - session_id: string - title: string - created_at: string - updated_at: string - }>((resolve) => { - resolveFirst = () => - resolve({ - session_id: "session-id", - title: "", - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-01T00:00:00Z", - }) - }) - - mockCreate.mockReturnValueOnce(firstSyncPromise) - - service.setPath("apiConversationHistoryPath", "/path/to/api.json") - - // Start first sync (but don't await - it's already running via timer) - const firstTick = vi.advanceTimersByTimeAsync(SessionService.SYNC_INTERVAL) - - // Try to trigger another sync while first is in progress - service.setPath("apiConversationHistoryPath", "/path/to/api2.json") - const secondTick = vi.advanceTimersByTimeAsync(SessionService.SYNC_INTERVAL) - - // Both ticks run but second should skip due to lock - await Promise.all([firstTick, secondTick]) - - // Now resolve the first sync - resolveFirst!() - await firstSyncPromise - - // Only one create call should have been made (second was blocked by lock) - expect(mockCreate).toHaveBeenCalledTimes(1) - }) - - it("should release lock even if sync fails", async () => { - const mockData = { messages: [] } - vi.mocked(readFileSync).mockReturnValue(JSON.stringify(mockData)) - - // First sync fails - mockCreate.mockRejectedValueOnce(new Error("Sync failed")) - - service.setPath("apiConversationHistoryPath", "/path/to/api.json") - - await vi.advanceTimersByTimeAsync(SessionService.SYNC_INTERVAL) - - expect(mockCreate).toHaveBeenCalledTimes(1) - - // Second sync should work (lock was released) - mockCreate.mockResolvedValueOnce({ - session_id: "session-id", - title: "", - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-01T00:00:00Z", - }) - - service.setPath("apiConversationHistoryPath", "/path/to/api.json") - - await vi.advanceTimersByTimeAsync(SessionService.SYNC_INTERVAL) - - expect(mockCreate).toHaveBeenCalledTimes(2) - }) - }) - - describe("restoreSession", () => { - beforeEach(() => { - // Mock global fetch - global.fetch = vi.fn() - }) - - afterEach(() => { - // Restore global fetch - vi.restoreAllMocks() - }) - - it("should restore session from signed URLs and write files to disk", async () => { - const mockSessionData = { - session_id: "restored-session-id", - title: "Restored Session", - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-01T00:00:00Z", - api_conversation_history_blob_url: "https://signed-url.com/api_conversation_history", - ui_messages_blob_url: "https://signed-url.com/ui_messages", - task_metadata_blob_url: "https://signed-url.com/task_metadata", - } - - const apiConversationData = { messages: [{ role: "user", content: "test" }] } - const uiMessagesData = [ - { say: "text", text: "message 1", ts: 1000 }, - { say: "checkpoint_saved", text: "", ts: 2000 }, // Should be filtered out - { say: "text", text: "message 2", ts: 3000 }, - ] as ClineMessage[] - const taskMetadataData = { task: "test task" } - - mockGet.mockResolvedValueOnce(mockSessionData) - - // Mock fetch responses for each signed URL - vi.mocked(global.fetch) - .mockResolvedValueOnce({ - ok: true, - headers: new Headers({ "content-type": "application/json" }), - json: async () => apiConversationData, - } as unknown as Response) - .mockResolvedValueOnce({ - ok: true, - headers: new Headers({ "content-type": "application/json" }), - json: async () => uiMessagesData, - } as unknown as Response) - .mockResolvedValueOnce({ - ok: true, - headers: new Headers({ "content-type": "application/json" }), - json: async () => taskMetadataData, - } as unknown as Response) - - await service.restoreSession("restored-session-id") - - // Verify SessionClient.get was called with include_blob_urls - expect(mockGet).toHaveBeenCalledWith({ - session_id: "restored-session-id", - include_blob_urls: true, - }) - - // Verify fetch was called for each signed URL - expect(global.fetch).toHaveBeenCalledWith("https://signed-url.com/api_conversation_history") - expect(global.fetch).toHaveBeenCalledWith("https://signed-url.com/ui_messages") - expect(global.fetch).toHaveBeenCalledWith("https://signed-url.com/task_metadata") - - // Verify directory was created - expect(vi.mocked(ensureDirSync)).toHaveBeenCalledWith("/mock/tasks/dir/restored-session-id") - - // Verify files were written - expect(vi.mocked(writeFileSync)).toHaveBeenCalledTimes(3) - expect(vi.mocked(writeFileSync)).toHaveBeenCalledWith( - "/mock/tasks/dir/restored-session-id/api_conversation_history.json", - JSON.stringify(apiConversationData, null, 2), - ) - - // Verify checkpoint messages were filtered out - const uiMessagesCall = vi - .mocked(writeFileSync) - .mock.calls.find((call) => call[0] === "/mock/tasks/dir/restored-session-id/ui_messages.json") - expect(uiMessagesCall?.[1]).toContain("message 1") - expect(uiMessagesCall?.[1]).toContain("message 2") - expect(uiMessagesCall?.[1]).not.toContain("checkpoint_saved") - - expect(vi.mocked(writeFileSync)).toHaveBeenCalledWith( - "/mock/tasks/dir/restored-session-id/task_metadata.json", - JSON.stringify(taskMetadataData, null, 2), - ) - }) - - it("should send messages to extension to register task", async () => { - const mockSessionData = { - session_id: "restored-session-id", - title: "Restored Session", - created_at: "2025-01-01T12:00:00Z", - updated_at: "2025-01-01T12:00:00Z", - api_conversation_history: null, - ui_messages: null, - task_metadata: null, - } - - mockGet.mockResolvedValueOnce(mockSessionData) - - await service.restoreSession("restored-session-id") - - // Verify addTaskToHistory message was sent - expect(mockSendWebviewMessage).toHaveBeenCalledWith({ - type: "addTaskToHistory", - historyItem: { - id: "restored-session-id", - number: 1, - task: "Restored Session", - ts: new Date("2025-01-01T12:00:00Z").getTime(), - tokensIn: 0, - tokensOut: 0, - totalCost: 0, - }, - }) - - // Verify showTaskWithId message was sent - expect(mockSendWebviewMessage).toHaveBeenCalledWith({ - type: "showTaskWithId", - text: "restored-session-id", - }) - }) - - it("should handle missing session gracefully", async () => { - mockGet.mockResolvedValueOnce(null) - - await service.restoreSession("non-existent-id") - - expect(vi.mocked(logs.error)).toHaveBeenCalledWith( - "Failed to obtain session", - "SessionService", - expect.objectContaining({ - sessionId: "non-existent-id", - }), - ) - - // Should not write any files - expect(vi.mocked(writeFileSync)).not.toHaveBeenCalled() - }) - - it("should handle restore errors gracefully", async () => { - mockGet.mockRejectedValueOnce(new Error("Network error")) - - await service.restoreSession("error-session-id") - - expect(vi.mocked(logs.error)).toHaveBeenCalledWith( - "Failed to restore session", - "SessionService", - expect.objectContaining({ - error: "Network error", - sessionId: "error-session-id", - }), - ) - - // SessionId should be reset to null on error - expect(service.sessionId).toBeNull() - }) - - it("should skip fetching blobs when signed URLs are null", async () => { - const mockSessionData = { - session_id: "partial-session-id", - title: "Partial Session", - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-01T00:00:00Z", - api_conversation_history_blob_url: "https://signed-url.com/api_conversation_history", - ui_messages_blob_url: null, - task_metadata_blob_url: null, - } - - const apiConversationData = { messages: [] } - - mockGet.mockResolvedValueOnce(mockSessionData) - - vi.mocked(global.fetch).mockResolvedValueOnce({ - ok: true, - headers: new Headers({ "content-type": "application/json" }), - json: async () => apiConversationData, - } as unknown as Response) - - await service.restoreSession("partial-session-id") - - // Only one fetch call should be made - expect(global.fetch).toHaveBeenCalledTimes(1) - expect(global.fetch).toHaveBeenCalledWith("https://signed-url.com/api_conversation_history") - - // Only one file should be written - expect(vi.mocked(writeFileSync)).toHaveBeenCalledTimes(1) - expect(vi.mocked(writeFileSync)).toHaveBeenCalledWith( - "/mock/tasks/dir/partial-session-id/api_conversation_history.json", - JSON.stringify(apiConversationData, null, 2), - ) - }) - - it("should handle fetch errors gracefully and continue processing other blobs", async () => { - const mockSessionData = { - session_id: "error-session-id", - title: "Error Session", - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-01T00:00:00Z", - api_conversation_history_blob_url: "https://signed-url.com/api_conversation_history", - ui_messages_blob_url: "https://signed-url.com/ui_messages", - task_metadata_blob_url: "https://signed-url.com/task_metadata", - } - - const uiMessagesData = [{ say: "text", text: "message", ts: 1000 }] as ClineMessage[] - const taskMetadataData = { task: "test" } - - mockGet.mockResolvedValueOnce(mockSessionData) - - // First fetch fails, others succeed - vi.mocked(global.fetch) - .mockResolvedValueOnce({ - ok: false, - status: 403, - statusText: "Forbidden", - headers: new Headers({ "content-type": "application/json" }), - } as unknown as Response) - .mockResolvedValueOnce({ - ok: true, - headers: new Headers({ "content-type": "application/json" }), - json: async () => uiMessagesData, - } as unknown as Response) - .mockResolvedValueOnce({ - ok: true, - headers: new Headers({ "content-type": "application/json" }), - json: async () => taskMetadataData, - } as unknown as Response) - - await service.restoreSession("error-session-id") - - // Should log error for failed blob fetch - expect(vi.mocked(logs.error)).toHaveBeenCalledWith( - "Failed to process blob", - "SessionService", - expect.objectContaining({ - filename: "api_conversation_history", - }), - ) - - // Should still write the successful blobs - expect(vi.mocked(writeFileSync)).toHaveBeenCalledTimes(2) - }) - - it("should handle non-JSON responses", async () => { - const mockSessionData = { - session_id: "invalid-json-session", - title: "Invalid JSON Session", - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-01T00:00:00Z", - api_conversation_history_blob_url: "https://signed-url.com/api_conversation_history", - ui_messages_blob_url: null, - task_metadata_blob_url: null, - } - - mockGet.mockResolvedValueOnce(mockSessionData) - - vi.mocked(global.fetch).mockResolvedValueOnce({ - ok: true, - headers: new Headers({ "content-type": "text/html" }), - } as unknown as Response) - - await service.restoreSession("invalid-json-session") - - // Should log error for invalid content type - expect(vi.mocked(logs.error)).toHaveBeenCalledWith( - "Failed to process blob", - "SessionService", - expect.objectContaining({ - filename: "api_conversation_history", - }), - ) - - // Should not write any files - expect(vi.mocked(writeFileSync)).not.toHaveBeenCalled() - }) - - it("should log info messages during restoration", async () => { - const mockSessionData = { - session_id: "session-with-logs", - title: "Test Session", - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-01T00:00:00Z", - api_conversation_history: null, - ui_messages: null, - task_metadata: null, - } - - mockGet.mockResolvedValueOnce(mockSessionData) - - await service.restoreSession("session-with-logs") - - expect(vi.mocked(logs.info)).toHaveBeenCalledWith( - "Restoring session", - "SessionService", - expect.objectContaining({ - sessionId: "session-with-logs", - }), - ) - - expect(vi.mocked(logs.info)).toHaveBeenCalledWith( - "Task registered with extension", - "SessionService", - expect.objectContaining({ - sessionId: "session-with-logs", - taskId: "session-with-logs", - }), - ) - - expect(vi.mocked(logs.info)).toHaveBeenCalledWith( - "Switched to restored task", - "SessionService", - expect.objectContaining({ - sessionId: "session-with-logs", - }), - ) - }) - }) - - describe("getGitState", () => { - beforeEach(() => { - // Override default git mocks with working ones for getGitState tests - mockGit = { - getRemotes: vi.fn(), - revparse: vi.fn(), - raw: vi.fn().mockImplementation((...args: unknown[]) => { - // Return appropriate mock based on the git command - const cmd = Array.isArray(args[0]) ? args[0] : args - if (cmd[0] === "hash-object") { - return Promise.resolve("4b825dc642cb6eb9a060e54bf8d69288fbee4904\n") - } - return Promise.resolve("") - }), - diff: vi.fn(), - } - - vi.mocked(simpleGit).mockReturnValue(mockGit as SimpleGit) - }) - - it("should handle first commit (no parent) by diffing against empty tree", async () => { - // Mock git responses for first commit scenario - vi.mocked(mockGit.getRemotes!).mockResolvedValue([ - { - name: "origin", - refs: { - fetch: "https://github.com/user/repo.git", - push: "https://github.com/user/repo.git", - }, - }, - ]) - vi.mocked(mockGit.revparse!).mockResolvedValue("abc123def456") - // First commit scenario: diff HEAD returns empty, so check if it's first commit - vi.mocked(mockGit.diff!).mockResolvedValueOnce("") // diff HEAD returns empty for first commit - ;(mockGit.raw as ReturnType).mockImplementation((...args: unknown[]) => { - const cmd = Array.isArray(args[0]) ? args[0] : args - if (cmd[0] === "symbolic-ref") { - return Promise.resolve("refs/heads/main") - } - if (cmd[0] === "ls-files") { - return Promise.resolve("") // No untracked files - } - if (cmd[0] === "rev-list") { - return Promise.resolve("abc123def456\n") // Single SHA (no parent) - } - if (cmd[0] === "hash-object") { - return Promise.resolve("4b825dc642cb6eb9a060e54bf8d69288fbee4904\n") - } - return Promise.resolve("") - }) - vi.mocked(mockGit.diff!).mockResolvedValueOnce("diff --git a/file.txt b/file.txt\nnew file mode 100644") // diff against empty tree - - service.setWorkspaceDirectory("/test/repo") - - // @ts-expect-error - Testing private method - const result = await service.getGitState() - - expect(result).toEqual({ - repoUrl: "https://github.com/user/repo.git", - head: "abc123def456", - branch: "main", - patch: "diff --git a/file.txt b/file.txt\nnew file mode 100644", - }) - - // Verify correct git commands were called - expect(mockGit.getRemotes).toHaveBeenCalledWith(true) - expect(mockGit.revparse).toHaveBeenCalledWith(["HEAD"]) - // First tries diff HEAD - expect(mockGit.diff).toHaveBeenCalledWith(["HEAD"]) - // Then checks if it's first commit - expect(mockGit.raw).toHaveBeenCalledWith(["rev-list", "--parents", "-n", "1", "HEAD"]) - // Then falls back to empty tree - expect(mockGit.raw).toHaveBeenCalledWith(["hash-object", "-t", "tree", "/dev/null"]) - expect(mockGit.diff).toHaveBeenCalledWith(["4b825dc642cb6eb9a060e54bf8d69288fbee4904", "HEAD"]) - }) - - it("should handle regular commit (has parent) by diffing HEAD", async () => { - // Mock git responses for regular commit scenario - vi.mocked(mockGit.getRemotes!).mockResolvedValue([ - { - name: "origin", - refs: { - fetch: "https://github.com/user/repo.git", - push: "https://github.com/user/repo.git", - }, - }, - ]) - vi.mocked(mockGit.revparse!).mockResolvedValue("def456abc789") - ;(mockGit.raw as ReturnType).mockImplementation((...args: unknown[]) => { - const cmd = Array.isArray(args[0]) ? args[0] : args - if (cmd[0] === "symbolic-ref") { - return Promise.resolve("refs/heads/feature-branch") - } - if (cmd[0] === "ls-files") { - return Promise.resolve("") // No untracked files - } - return Promise.resolve("") - }) - // Regular commit: diff HEAD returns the patch directly - vi.mocked(mockGit.diff!).mockResolvedValue("diff --git a/file.txt b/file.txt\nindex 123..456") - - service.setWorkspaceDirectory("/test/repo") - - // @ts-expect-error - Testing private method - const result = await service.getGitState() - - expect(result).toEqual({ - repoUrl: "https://github.com/user/repo.git", - head: "def456abc789", - branch: "feature-branch", - patch: "diff --git a/file.txt b/file.txt\nindex 123..456", - }) - - // Verify correct git commands were called - expect(mockGit.getRemotes).toHaveBeenCalledWith(true) - expect(mockGit.revparse).toHaveBeenCalledWith(["HEAD"]) - // Regular commit should diff HEAD for uncommitted changes - expect(mockGit.diff).toHaveBeenCalledWith(["HEAD"]) - }) - - it("should handle commit with no uncommitted changes", async () => { - // Mock git responses for commit with no changes scenario - vi.mocked(mockGit.getRemotes!).mockResolvedValue([ - { - name: "origin", - refs: { - fetch: "https://github.com/user/repo.git", - push: "", - }, - } as RemoteWithRefs, - ]) - vi.mocked(mockGit.revparse!).mockResolvedValue("merge123abc456") - ;(mockGit.raw as ReturnType).mockImplementation((...args: unknown[]) => { - const cmd = Array.isArray(args[0]) ? args[0] : args - if (cmd[0] === "symbolic-ref") { - return Promise.resolve("refs/heads/main") - } - if (cmd[0] === "ls-files") { - return Promise.resolve("") // No untracked files - } - if (cmd[0] === "rev-list") { - return Promise.resolve("merge123abc456 parent123abc\n") // Has parent (not first commit) - } - return Promise.resolve("") - }) - // No uncommitted changes: diff HEAD returns empty, but we're not on first commit - vi.mocked(mockGit.diff!).mockResolvedValueOnce("") // diff HEAD returns empty - - service.setWorkspaceDirectory("/test/repo") - - // @ts-expect-error - Testing private method - const result = await service.getGitState() - - expect(result).toEqual({ - repoUrl: "https://github.com/user/repo.git", - head: "merge123abc456", - branch: "main", - patch: "", - }) - - // Should try diff HEAD first - expect(mockGit.diff).toHaveBeenCalledWith(["HEAD"]) - // Should check if it's first commit - expect(mockGit.raw).toHaveBeenCalledWith(["rev-list", "--parents", "-n", "1", "HEAD"]) - // Should NOT fall back to empty tree since it's not first commit - expect(mockGit.raw).not.toHaveBeenCalledWith(["hash-object", "-t", "tree", "/dev/null"]) - }) - - it("should use push URL when fetch URL is not available", async () => { - vi.mocked(mockGit.getRemotes!).mockResolvedValue([ - { - name: "origin", - refs: { - fetch: "", - push: "https://github.com/user/repo.git", - }, - } as RemoteWithRefs, - ]) - vi.mocked(mockGit.revparse!).mockResolvedValue("abc123") - ;(mockGit.raw as ReturnType).mockImplementation((...args: unknown[]) => { - const cmd = Array.isArray(args[0]) ? args[0] : args - if (cmd[0] === "symbolic-ref") { - return Promise.resolve("refs/heads/main") - } - if (cmd[0] === "ls-files") { - return Promise.resolve("") - } - return Promise.resolve("") - }) - vi.mocked(mockGit.diff!).mockResolvedValue("some diff") - - service.setWorkspaceDirectory("/test/repo") - - // @ts-expect-error - Testing private method - const result = await service.getGitState() - - expect(result.repoUrl).toBe("https://github.com/user/repo.git") - }) - - it("should return undefined repoUrl when no remotes configured", async () => { - vi.mocked(mockGit.getRemotes!).mockResolvedValue([]) - vi.mocked(mockGit.revparse!).mockResolvedValue("abc123") - ;(mockGit.raw as ReturnType).mockImplementation((...args: unknown[]) => { - const cmd = Array.isArray(args[0]) ? args[0] : args - if (cmd[0] === "symbolic-ref") { - return Promise.resolve("refs/heads/main") - } - if (cmd[0] === "ls-files") { - return Promise.resolve("") - } - return Promise.resolve("") - }) - vi.mocked(mockGit.diff!).mockResolvedValue("some diff") - - service.setWorkspaceDirectory("/test/repo") - - // @ts-expect-error - Testing private method - const result = await service.getGitState() - - expect(result.repoUrl).toBeUndefined() - expect(result.head).toBe("abc123") - }) - - it("should handle first commit with changes by using empty tree fallback", async () => { - vi.mocked(mockGit.getRemotes!).mockResolvedValue([ - { - name: "origin", - refs: { - fetch: "https://github.com/user/repo.git", - push: "", - }, - } as RemoteWithRefs, - ]) - vi.mocked(mockGit.revparse!).mockResolvedValue("firstcommit123") - ;(mockGit.raw as ReturnType).mockImplementation((...args: unknown[]) => { - const cmd = Array.isArray(args[0]) ? args[0] : args - if (cmd[0] === "symbolic-ref") { - return Promise.resolve("refs/heads/main") - } - if (cmd[0] === "ls-files") { - return Promise.resolve("") // No untracked files - } - if (cmd[0] === "rev-list") { - return Promise.resolve("firstcommit123\n") // Single SHA (no parent) - } - if (cmd[0] === "hash-object") { - return Promise.resolve("4b825dc642cb6eb9a060e54bf8d69288fbee4904\n") - } - return Promise.resolve("") - }) - // First commit: diff HEAD returns empty (no parent), check first commit, then fallback generates patch - vi.mocked(mockGit.diff!).mockResolvedValueOnce("") // diff HEAD returns empty - vi.mocked(mockGit.diff!).mockResolvedValueOnce("diff --git a/initial.txt b/initial.txt\nnew file") // diff against empty tree - - service.setWorkspaceDirectory("/test/repo") - - // @ts-expect-error - Testing private method - const result = await service.getGitState() - - expect(result.patch).toBe("diff --git a/initial.txt b/initial.txt\nnew file") - // Should try HEAD first - expect(mockGit.diff).toHaveBeenCalledWith(["HEAD"]) - // Should check if it's first commit - expect(mockGit.raw).toHaveBeenCalledWith(["rev-list", "--parents", "-n", "1", "HEAD"]) - // Then use empty tree - expect(mockGit.raw).toHaveBeenCalledWith(["hash-object", "-t", "tree", "/dev/null"]) - expect(mockGit.diff).toHaveBeenCalledWith(["4b825dc642cb6eb9a060e54bf8d69288fbee4904", "HEAD"]) - }) - - it("should use process.cwd() when workspace directory not set", async () => { - vi.mocked(mockGit.getRemotes!).mockResolvedValue([ - { - name: "origin", - refs: { - fetch: "https://github.com/user/repo.git", - push: "", - }, - } as RemoteWithRefs, - ]) - vi.mocked(mockGit.revparse!).mockResolvedValue("abc123") - vi.mocked(mockGit.diff!).mockResolvedValue("some diff") - ;(mockGit.raw as ReturnType).mockImplementation((...args: unknown[]) => { - const cmd = Array.isArray(args[0]) ? args[0] : args - if (cmd[0] === "ls-files") { - return Promise.resolve("") - } - if (cmd[0] === "symbolic-ref") { - return Promise.resolve("refs/heads/main") - } - return Promise.resolve("") - }) - - // @ts-expect-error - Testing private method - const result = await service.getGitState() - - expect(result).toBeDefined() - // Verify simple-git was called (it would use process.cwd()) - expect(simpleGit).toHaveBeenCalled() - }) - - describe("untracked files handling", () => { - it("should include untracked files in the patch", async () => { - vi.mocked(mockGit.getRemotes!).mockResolvedValue([ - { - name: "origin", - refs: { - fetch: "https://github.com/user/repo.git", - push: "", - }, - } as RemoteWithRefs, - ]) - vi.mocked(mockGit.revparse!).mockResolvedValue("abc123def456") - - // Track the order of git.raw calls - const rawCalls: unknown[][] = [] - ;(mockGit.raw as ReturnType).mockImplementation((...args: unknown[]) => { - const cmd = Array.isArray(args[0]) ? args[0] : args - rawCalls.push(cmd as unknown[]) - if (cmd[0] === "ls-files" && cmd[1] === "--others") { - // Return untracked files - return Promise.resolve("new-file.txt\nanother-new-file.js\n") - } - if (cmd[0] === "add" && cmd[1] === "--intent-to-add") { - return Promise.resolve("") - } - if (cmd[0] === "reset") { - return Promise.resolve("") - } - if (cmd[0] === "symbolic-ref") { - return Promise.resolve("refs/heads/main") - } - return Promise.resolve("") - }) - - // Diff includes the untracked files after intent-to-add - vi.mocked(mockGit.diff!).mockResolvedValue( - "diff --git a/new-file.txt b/new-file.txt\nnew file mode 100644\n+content", - ) - - service.setWorkspaceDirectory("/test/repo") - - // @ts-expect-error - Testing private method - const result = await service.getGitState() - - // Verify untracked files were fetched - expect(mockGit.raw).toHaveBeenCalledWith(["ls-files", "--others", "--exclude-standard"]) - - // Verify intent-to-add was called with the untracked files - expect(mockGit.raw).toHaveBeenCalledWith([ - "add", - "--intent-to-add", - "--", - "new-file.txt", - "another-new-file.js", - ]) - - // Verify the patch includes the untracked file content - expect(result.patch).toContain("new-file.txt") - }) - - it("should restore repository state after getGitState completes", async () => { - vi.mocked(mockGit.getRemotes!).mockResolvedValue([ - { - name: "origin", - refs: { - fetch: "https://github.com/user/repo.git", - push: "", - }, - } as RemoteWithRefs, - ]) - vi.mocked(mockGit.revparse!).mockResolvedValue("abc123def456") - - const rawCalls: unknown[][] = [] - ;(mockGit.raw as ReturnType).mockImplementation((...args: unknown[]) => { - const cmd = Array.isArray(args[0]) ? args[0] : args - rawCalls.push(cmd as unknown[]) - if (cmd[0] === "ls-files" && cmd[1] === "--others") { - return Promise.resolve("untracked.txt\n") - } - if (cmd[0] === "add" && cmd[1] === "--intent-to-add") { - return Promise.resolve("") - } - if (cmd[0] === "reset") { - return Promise.resolve("") - } - if (cmd[0] === "symbolic-ref") { - return Promise.resolve("refs/heads/main") - } - return Promise.resolve("") - }) - - vi.mocked(mockGit.diff!).mockResolvedValue("diff content") - - service.setWorkspaceDirectory("/test/repo") - - // @ts-expect-error - Testing private method - await service.getGitState() - - // Verify reset was called to restore the untracked state - expect(mockGit.raw).toHaveBeenCalledWith(["reset", "HEAD", "--", "untracked.txt"]) - - // Verify the order: ls-files -> add --intent-to-add -> ... -> reset - const lsFilesIndex = rawCalls.findIndex((args) => args[0] === "ls-files") - const addIndex = rawCalls.findIndex((args) => args[0] === "add" && args[1] === "--intent-to-add") - const resetIndex = rawCalls.findIndex((args) => args[0] === "reset") - - expect(lsFilesIndex).toBeLessThan(addIndex) - expect(addIndex).toBeLessThan(resetIndex) - }) - - it("should restore repository state even when diff throws an error", async () => { - vi.mocked(mockGit.getRemotes!).mockResolvedValue([ - { - name: "origin", - refs: { - fetch: "https://github.com/user/repo.git", - push: "", - }, - } as RemoteWithRefs, - ]) - vi.mocked(mockGit.revparse!).mockResolvedValue("abc123def456") - ;(mockGit.raw as ReturnType).mockImplementation((...args: unknown[]) => { - const cmd = Array.isArray(args[0]) ? args[0] : args - if (cmd[0] === "ls-files" && cmd[1] === "--others") { - return Promise.resolve("untracked-file.txt\n") - } - if (cmd[0] === "add" && cmd[1] === "--intent-to-add") { - return Promise.resolve("") - } - if (cmd[0] === "reset") { - return Promise.resolve("") - } - if (cmd[0] === "symbolic-ref") { - return Promise.resolve("refs/heads/main") - } - return Promise.resolve("") - }) - - // Make diff throw an error - vi.mocked(mockGit.diff!).mockRejectedValue(new Error("Diff failed")) - - service.setWorkspaceDirectory("/test/repo") - - // @ts-expect-error - Testing private method - await expect(service.getGitState()).rejects.toThrow("Diff failed") - - // Verify reset was still called in the finally block - expect(mockGit.raw).toHaveBeenCalledWith(["reset", "HEAD", "--", "untracked-file.txt"]) - }) - - it("should handle empty untracked files list without errors", async () => { - vi.mocked(mockGit.getRemotes!).mockResolvedValue([ - { - name: "origin", - refs: { - fetch: "https://github.com/user/repo.git", - push: "", - }, - } as RemoteWithRefs, - ]) - vi.mocked(mockGit.revparse!).mockResolvedValue("abc123def456") - ;(mockGit.raw as ReturnType).mockImplementation((...args: unknown[]) => { - const cmd = Array.isArray(args[0]) ? args[0] : args - if (cmd[0] === "ls-files" && cmd[1] === "--others") { - // No untracked files - return Promise.resolve("") - } - if (cmd[0] === "symbolic-ref") { - return Promise.resolve("refs/heads/main") - } - return Promise.resolve("") - }) - - vi.mocked(mockGit.diff!).mockResolvedValue("diff --git a/tracked.txt b/tracked.txt\nmodified content") - - service.setWorkspaceDirectory("/test/repo") - - // @ts-expect-error - Testing private method - const result = await service.getGitState() - - // Should not call add --intent-to-add when there are no untracked files - expect(mockGit.raw).not.toHaveBeenCalledWith(expect.arrayContaining(["add", "--intent-to-add"])) - - // Should not call reset when there are no untracked files - expect(mockGit.raw).not.toHaveBeenCalledWith(expect.arrayContaining(["reset", "HEAD"])) - - // Should still return the diff for tracked files - expect(result.patch).toContain("tracked.txt") - }) - - it("should exclude ignored files from untracked files list", async () => { - vi.mocked(mockGit.getRemotes!).mockResolvedValue([ - { - name: "origin", - refs: { - fetch: "https://github.com/user/repo.git", - push: "", - }, - } as RemoteWithRefs, - ]) - vi.mocked(mockGit.revparse!).mockResolvedValue("abc123def456") - ;(mockGit.raw as ReturnType).mockImplementation((...args: unknown[]) => { - const cmd = Array.isArray(args[0]) ? args[0] : args - if (cmd[0] === "ls-files" && cmd[1] === "--others" && cmd[2] === "--exclude-standard") { - // --exclude-standard flag ensures .gitignore patterns are respected - // Only return files that are NOT in .gitignore - return Promise.resolve("valid-untracked.txt\n") - } - if (cmd[0] === "add" && cmd[1] === "--intent-to-add") { - return Promise.resolve("") - } - if (cmd[0] === "reset") { - return Promise.resolve("") - } - if (cmd[0] === "symbolic-ref") { - return Promise.resolve("refs/heads/main") - } - return Promise.resolve("") - }) - - vi.mocked(mockGit.diff!).mockResolvedValue("diff content with valid-untracked.txt") - - service.setWorkspaceDirectory("/test/repo") - - // @ts-expect-error - Testing private method - await service.getGitState() - - // Verify ls-files was called with --exclude-standard to respect .gitignore - expect(mockGit.raw).toHaveBeenCalledWith(["ls-files", "--others", "--exclude-standard"]) - - // Verify only the non-ignored file was added with intent-to-add - expect(mockGit.raw).toHaveBeenCalledWith(["add", "--intent-to-add", "--", "valid-untracked.txt"]) - - // Verify ignored files like node_modules/* are NOT included - expect(mockGit.raw).not.toHaveBeenCalledWith( - expect.arrayContaining(["add", "--intent-to-add", "--", expect.stringContaining("node_modules")]), - ) - }) - - it("should handle multiple untracked files correctly", async () => { - vi.mocked(mockGit.getRemotes!).mockResolvedValue([ - { - name: "origin", - refs: { - fetch: "https://github.com/user/repo.git", - push: "", - }, - } as RemoteWithRefs, - ]) - vi.mocked(mockGit.revparse!).mockResolvedValue("abc123def456") - - const untrackedFiles = ["file1.txt", "src/file2.js", "docs/readme.md"] - - ;(mockGit.raw as ReturnType).mockImplementation((...args: unknown[]) => { - const cmd = Array.isArray(args[0]) ? args[0] : args - if (cmd[0] === "ls-files" && cmd[1] === "--others") { - return Promise.resolve(untrackedFiles.join("\n") + "\n") - } - if (cmd[0] === "add" && cmd[1] === "--intent-to-add") { - return Promise.resolve("") - } - if (cmd[0] === "reset") { - return Promise.resolve("") - } - if (cmd[0] === "symbolic-ref") { - return Promise.resolve("refs/heads/main") - } - return Promise.resolve("") - }) - - vi.mocked(mockGit.diff!).mockResolvedValue("diff with multiple files") - - service.setWorkspaceDirectory("/test/repo") - - // @ts-expect-error - Testing private method - await service.getGitState() - - // Verify all untracked files were added with intent-to-add - expect(mockGit.raw).toHaveBeenCalledWith([ - "add", - "--intent-to-add", - "--", - "file1.txt", - "src/file2.js", - "docs/readme.md", - ]) - - // Verify all untracked files were reset - expect(mockGit.raw).toHaveBeenCalledWith([ - "reset", - "HEAD", - "--", - "file1.txt", - "src/file2.js", - "docs/readme.md", - ]) - }) - }) - }) - - describe("renameSession", () => { - it("should rename session successfully", async () => { - const mockData = { messages: [] } - vi.mocked(readFileSync).mockReturnValue(JSON.stringify(mockData)) - - // Create a session first - mockCreate.mockResolvedValueOnce({ - session_id: "session-to-rename", - title: "Original Title", - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-01T00:00:00Z", - }) - - service.setPath("apiConversationHistoryPath", "/path/to/api.json") - - // Wait for session creation - await vi.advanceTimersByTimeAsync(SessionService.SYNC_INTERVAL) - - expect(service.sessionId).toBe("session-to-rename") - - // Now rename the session - mockUpdate.mockResolvedValueOnce({ - session_id: "session-to-rename", - title: "New Title", - updated_at: "2025-01-01T00:01:00Z", - }) - - await service.renameSession("New Title") - - expect(mockUpdate).toHaveBeenCalledWith({ - session_id: "session-to-rename", - title: "New Title", - }) - - expect(vi.mocked(logs.info)).toHaveBeenCalledWith( - "Session renamed successfully", - "SessionService", - expect.objectContaining({ - sessionId: "session-to-rename", - newTitle: "New Title", - }), - ) - }) - - it("should throw error when no active session", async () => { - // No session created, sessionId is null - expect(service.sessionId).toBeNull() - - await expect(service.renameSession("New Title")).rejects.toThrow("No active session") - - expect(mockUpdate).not.toHaveBeenCalled() - }) - - it("should throw error when title is empty", async () => { - const mockData = { messages: [] } - vi.mocked(readFileSync).mockReturnValue(JSON.stringify(mockData)) - - // Create a session first - mockCreate.mockResolvedValueOnce({ - session_id: "session-id", - title: "Original Title", - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-01T00:00:00Z", - }) - - service.setPath("apiConversationHistoryPath", "/path/to/api.json") - - await vi.advanceTimersByTimeAsync(SessionService.SYNC_INTERVAL) - - await expect(service.renameSession("")).rejects.toThrow("Session title cannot be empty") - - expect(mockUpdate).not.toHaveBeenCalled() - }) - - it("should throw error when title is only whitespace", async () => { - const mockData = { messages: [] } - vi.mocked(readFileSync).mockReturnValue(JSON.stringify(mockData)) - - // Create a session first - mockCreate.mockResolvedValueOnce({ - session_id: "session-id", - title: "Original Title", - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-01T00:00:00Z", - }) - - service.setPath("apiConversationHistoryPath", "/path/to/api.json") - - await vi.advanceTimersByTimeAsync(SessionService.SYNC_INTERVAL) - - await expect(service.renameSession(" ")).rejects.toThrow("Session title cannot be empty") - - expect(mockUpdate).not.toHaveBeenCalled() - }) - - it("should trim whitespace from title", async () => { - const mockData = { messages: [] } - vi.mocked(readFileSync).mockReturnValue(JSON.stringify(mockData)) - - // Create a session first - mockCreate.mockResolvedValueOnce({ - session_id: "session-id", - title: "Original Title", - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-01T00:00:00Z", - }) - - service.setPath("apiConversationHistoryPath", "/path/to/api.json") - - await vi.advanceTimersByTimeAsync(SessionService.SYNC_INTERVAL) - - mockUpdate.mockResolvedValueOnce({ - session_id: "session-id", - title: "Trimmed Title", - updated_at: "2025-01-01T00:01:00Z", - }) - - await service.renameSession(" Trimmed Title ") - - expect(mockUpdate).toHaveBeenCalledWith({ - session_id: "session-id", - title: "Trimmed Title", - }) - }) - - it("should propagate backend errors", async () => { - const mockData = { messages: [] } - vi.mocked(readFileSync).mockReturnValue(JSON.stringify(mockData)) - - // Create a session first - mockCreate.mockResolvedValueOnce({ - session_id: "session-id", - title: "Original Title", - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-01T00:00:00Z", - }) - - service.setPath("apiConversationHistoryPath", "/path/to/api.json") - - await vi.advanceTimersByTimeAsync(SessionService.SYNC_INTERVAL) - - mockUpdate.mockRejectedValueOnce(new Error("Network error")) - - await expect(service.renameSession("New Title")).rejects.toThrow("Network error") - }) - - it("should update local sessionTitle after successful rename", async () => { - const mockData = { messages: [] } - vi.mocked(readFileSync).mockReturnValue(JSON.stringify(mockData)) - - // Create a session first - mockCreate.mockResolvedValueOnce({ - session_id: "session-id", - title: "Original Title", - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-01T00:00:00Z", - }) - - service.setPath("apiConversationHistoryPath", "/path/to/api.json") - - await vi.advanceTimersByTimeAsync(SessionService.SYNC_INTERVAL) - - mockUpdate.mockResolvedValueOnce({ - session_id: "session-id", - title: "New Title", - updated_at: "2025-01-01T00:01:00Z", - }) - - await service.renameSession("New Title") - - // @ts-expect-error - Accessing private property for testing - expect(service.sessionTitle).toBe("New Title") - }) - }) - - describe("generateTitle", () => { - describe("short messages (≤140 chars)", () => { - it("should return short message directly without LLM call", async () => { - const messages: ClineMessage[] = [ - { - ts: Date.now(), - type: "ask", - ask: "followup", - text: "Help me write a function to calculate fibonacci numbers", - }, - ] - - const title = await service.generateTitle(messages) - - expect(title).toBe("Help me write a function to calculate fibonacci numbers") - expect(mockRequestSingleCompletion).not.toHaveBeenCalled() - }) - - it("should trim and collapse whitespace for short messages", async () => { - const messages: ClineMessage[] = [ - { - ts: Date.now(), - type: "ask", - ask: "followup", - text: " Hello world ", - }, - ] - - const title = await service.generateTitle(messages) - - expect(title).toBe("Hello world") - expect(mockRequestSingleCompletion).not.toHaveBeenCalled() - }) - - it("should replace newlines with spaces for short messages", async () => { - const messages: ClineMessage[] = [ - { - ts: Date.now(), - type: "ask", - ask: "followup", - text: "Hello\nworld\ntest", - }, - ] - - const title = await service.generateTitle(messages) - - expect(title).toBe("Hello world test") - }) - }) - - describe("long messages (>140 chars)", () => { - it("should use LLM to summarize long messages", async () => { - mockRequestSingleCompletion.mockResolvedValue("Summarized title from LLM") - - const longText = "a".repeat(200) - const messages: ClineMessage[] = [ - { - ts: Date.now(), - type: "ask", - ask: "followup", - text: longText, - }, - ] - - const title = await service.generateTitle(messages) - - expect(title).toBe("Summarized title from LLM") - expect(mockRequestSingleCompletion).toHaveBeenCalledTimes(1) - expect(mockRequestSingleCompletion).toHaveBeenCalledWith( - expect.stringContaining("Summarize the following user request"), - 30000, - ) - }) - - it("should truncate LLM response if still too long", async () => { - mockRequestSingleCompletion.mockResolvedValue("b".repeat(200)) - - const longText = "a".repeat(200) - const messages: ClineMessage[] = [ - { - ts: Date.now(), - type: "ask", - ask: "followup", - text: longText, - }, - ] - - const title = await service.generateTitle(messages) - - expect(title!.length).toBeLessThanOrEqual(140) - expect(title).toMatch(/\.\.\.$/i) - }) - - it("should remove quotes from LLM response", async () => { - mockRequestSingleCompletion.mockResolvedValue('"Quoted summary"') - - const longText = "a".repeat(200) - const messages: ClineMessage[] = [ - { - ts: Date.now(), - type: "ask", - ask: "followup", - text: longText, - }, - ] - - const title = await service.generateTitle(messages) - - expect(title).toBe("Quoted summary") - }) - - it("should fallback to truncation when LLM fails", async () => { - mockRequestSingleCompletion.mockRejectedValue(new Error("LLM error")) - - const longText = "a".repeat(200) - const messages: ClineMessage[] = [ - { - ts: Date.now(), - type: "ask", - ask: "followup", - text: longText, - }, - ] - - const title = await service.generateTitle(messages) - - expect(title!.length).toBeLessThanOrEqual(140) - expect(title).toMatch(/\.\.\.$/i) - expect(title).toContain("a".repeat(137)) - }) - }) - - describe("edge cases", () => { - it("should return null for empty array", async () => { - const title = await service.generateTitle([]) - - expect(title).toBeNull() - }) - - it("should return null when no messages have text", async () => { - const messages: ClineMessage[] = [ - { - ts: Date.now(), - type: "say", - say: "api_req_started", - }, - { - ts: Date.now(), - type: "ask", - ask: "command", - }, - ] - - const title = await service.generateTitle(messages) - - expect(title).toBeNull() - }) - - it("should return null when user message has empty text", async () => { - const messages: ClineMessage[] = [ - { - ts: Date.now(), - type: "ask", - ask: "followup", - text: "", - }, - ] - - const title = await service.generateTitle(messages) - - expect(title).toBeNull() - }) - - it("should return null when user message has only whitespace", async () => { - const messages: ClineMessage[] = [ - { - ts: Date.now(), - type: "ask", - ask: "followup", - text: " \n\t ", - }, - ] - - const title = await service.generateTitle(messages) - - expect(title).toBeNull() - }) - - it("should skip messages without text and find next valid message", async () => { - const messages: ClineMessage[] = [ - { - ts: Date.now(), - type: "ask", - ask: "followup", - }, - { - ts: Date.now(), - type: "say", - say: "user_feedback", - text: "Valid user message", - }, - ] - - const title = await service.generateTitle(messages) - - expect(title).toBe("Valid user message") - }) - }) - - describe("message type behavior", () => { - it("should extract from any message type with text (first message wins)", async () => { - const messages: ClineMessage[] = [ - { - ts: Date.now(), - type: "say", - say: "text", - text: "First message with text", - }, - { - ts: Date.now(), - type: "ask", - ask: "followup", - text: "Second message", - }, - ] - - const title = await service.generateTitle(messages) - - expect(title).toBe("First message with text") - }) - - it("should extract from command ask if it has text", async () => { - const messages: ClineMessage[] = [ - { - ts: Date.now(), - type: "ask", - ask: "command", - text: "npm install", - }, - ] - - const title = await service.generateTitle(messages) - - expect(title).toBe("npm install") - }) - }) - }) - - describe("logging", () => { - it("should log debug messages for successful operations", async () => { - const mockData = { messages: [] } - vi.mocked(readFileSync).mockReturnValue(JSON.stringify(mockData)) - - mockCreate.mockResolvedValueOnce({ - session_id: "new-session-id", - title: "", - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-01T00:00:00Z", - }) - - service.setPath("apiConversationHistoryPath", "/path/to/api.json") - - await vi.advanceTimersByTimeAsync(SessionService.SYNC_INTERVAL) - - expect(vi.mocked(logs.debug)).toHaveBeenCalledWith("Creating new session", "SessionService") - expect(vi.mocked(logs.info)).toHaveBeenCalledWith( - "Session created successfully", - "SessionService", - expect.objectContaining({ - sessionId: "new-session-id", - }), - ) - }) - - it("should log debug messages for updates", async () => { - const mockData = { messages: [] } - vi.mocked(readFileSync).mockReturnValue(JSON.stringify(mockData)) - - // Set up git mocks for first sync - mockGit.getRemotes = vi.fn().mockResolvedValue([ - { - name: "origin", - refs: { - fetch: "https://github.com/user/repo.git", - push: "https://github.com/user/repo.git", - }, - }, - ]) - mockGit.revparse = vi.fn().mockResolvedValue("abc123") - mockGit.raw = vi.fn().mockImplementation((...args: unknown[]) => { - const cmd = Array.isArray(args[0]) ? args[0] : args - if (cmd[0] === "ls-files") { - return Promise.resolve("") - } - if (cmd[0] === "symbolic-ref") { - return Promise.resolve("refs/heads/main") - } - return Promise.resolve("") - }) - mockGit.diff = vi.fn().mockResolvedValue("some diff") - - mockCreate.mockResolvedValueOnce({ - session_id: "session-id", - title: "", - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-01T00:00:00Z", - }) - - service.setPath("apiConversationHistoryPath", "/path/to/api.json") - - await vi.advanceTimersByTimeAsync(SessionService.SYNC_INTERVAL) - - vi.clearAllMocks() - - // Change git URL to trigger update - mockGit.getRemotes = vi.fn().mockResolvedValue([ - { - name: "origin", - refs: { - fetch: "https://github.com/user/new-repo.git", - push: "https://github.com/user/new-repo.git", - }, - }, - ]) - - mockUpdate.mockResolvedValueOnce({ - session_id: "session-id", - title: "", - updated_at: "2025-01-01T00:01:00Z", - }) - - service.setPath("apiConversationHistoryPath", "/path/to/api.json") - - await vi.advanceTimersByTimeAsync(SessionService.SYNC_INTERVAL) - - expect(vi.mocked(logs.debug)).toHaveBeenCalledWith( - "Updating existing session", - "SessionService", - expect.objectContaining({ - sessionId: "session-id", - }), - ) - expect(vi.mocked(logs.debug)).toHaveBeenCalledWith( - "Session updated successfully", - "SessionService", - expect.objectContaining({ - sessionId: "session-id", - }), - ) - }) - - it("should log during destroy", async () => { - await service.destroy() - - expect(vi.mocked(logs.debug)).toHaveBeenCalledWith( - "Destroying SessionService", - "SessionService", - expect.objectContaining({ - sessionId: null, - }), - ) - expect(vi.mocked(logs.debug)).toHaveBeenCalledWith("SessionService flushed", "SessionService") - }) - }) - - describe("restoreLastSession", () => { - beforeEach(() => { - vi.clearAllMocks() - global.fetch = vi.fn() - }) - - it("should return false when no last session ID exists", async () => { - service.setWorkspaceDirectory("/test/workspace") - vi.mocked(existsSync).mockReturnValueOnce(false) - - const result = await service.restoreLastSession() - - expect(result).toBe(false) - expect(vi.mocked(logs.debug)).toHaveBeenCalledWith("No persisted session ID found", "SessionService") - }) - - it("should return true when session is restored successfully", async () => { - service.setWorkspaceDirectory("/test/workspace") - - const sessionData = { - sessionId: "saved-session-id", - timestamp: Date.now(), - } - - vi.mocked(existsSync).mockReturnValueOnce(true) - vi.mocked(readFileSync).mockReturnValueOnce(JSON.stringify(sessionData)) - - const mockSessionData = { - session_id: "saved-session-id", - title: "Saved Session", - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-01T00:00:00Z", - } - - mockGet.mockResolvedValueOnce(mockSessionData) - - const result = await service.restoreLastSession() - - expect(result).toBe(true) - expect(vi.mocked(logs.info)).toHaveBeenCalledWith( - "Found persisted session ID, attempting to restore", - "SessionService", - expect.objectContaining({ - sessionId: "saved-session-id", - }), - ) - expect(vi.mocked(logs.info)).toHaveBeenCalledWith( - "Successfully restored persisted session", - "SessionService", - expect.objectContaining({ - sessionId: "saved-session-id", - }), - ) - }) - - it("should return false when restoration fails", async () => { - service.setWorkspaceDirectory("/test/workspace") - - const sessionData = { - sessionId: "invalid-session-id", - timestamp: Date.now(), - } - - vi.mocked(existsSync).mockReturnValueOnce(true) - vi.mocked(readFileSync).mockReturnValueOnce(JSON.stringify(sessionData)) - - mockGet.mockRejectedValueOnce(new Error("Session not found")) - - const result = await service.restoreLastSession() - - expect(result).toBe(false) - expect(vi.mocked(logs.warn)).toHaveBeenCalledWith( - "Failed to restore persisted session", - "SessionService", - expect.objectContaining({ - error: "Session not found", - sessionId: "invalid-session-id", - }), - ) - }) - }) - - describe("session persistence", () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - describe("saveLastSessionId", () => { - it("should save session ID to workspace-specific file", async () => { - service.setWorkspaceDirectory("/test/workspace") - - const mockData = { messages: [] } - vi.mocked(readFileSync).mockReturnValue(JSON.stringify(mockData)) - - mockCreate.mockResolvedValueOnce({ - session_id: "test-session-id", - title: "", - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-01T00:00:00Z", - }) - - service.setPath("apiConversationHistoryPath", "/path/to/api.json") - - await vi.advanceTimersByTimeAsync(SessionService.SYNC_INTERVAL) - - const writeCall = vi - .mocked(writeFileSync) - .mock.calls.find((call) => call[0] === "/mock/workspace//test/workspace/last-session.json") - expect(writeCall).toBeDefined() - const writtenData = JSON.parse(writeCall![1] as string) - expect(writtenData.sessionId).toBe("test-session-id") - expect(writtenData.timestamp).toBeTypeOf("number") - }) - - it("should not save when workspace directory is not set", async () => { - const mockData = { messages: [] } - vi.mocked(readFileSync).mockReturnValue(JSON.stringify(mockData)) - - mockCreate.mockResolvedValueOnce({ - session_id: "test-session-id", - title: "", - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-01T00:00:00Z", - }) - - service.setPath("apiConversationHistoryPath", "/path/to/api.json") - - await vi.advanceTimersByTimeAsync(SessionService.SYNC_INTERVAL) - - expect(vi.mocked(logs.warn)).toHaveBeenCalledWith( - "Cannot save last session ID: workspace directory not set", - "SessionService", - ) - }) - - it("should save session ID when restoring a session", async () => { - service.setWorkspaceDirectory("/test/workspace") - - const mockSessionData = { - session_id: "restored-session-id", - title: "Restored Session", - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-01T00:00:00Z", - api_conversation_history_blob_url: null, - ui_messages_blob_url: null, - task_metadata_blob_url: null, - } - - mockGet.mockResolvedValueOnce(mockSessionData) - - await service.restoreSession("restored-session-id") - - const writeCall = vi - .mocked(writeFileSync) - .mock.calls.find((call) => call[0] === "/mock/workspace//test/workspace/last-session.json") - expect(writeCall).toBeDefined() - const writtenData = JSON.parse(writeCall![1] as string) - expect(writtenData.sessionId).toBe("restored-session-id") - expect(writtenData.timestamp).toBeTypeOf("number") - }) - - it("should handle write errors gracefully", async () => { - service.setWorkspaceDirectory("/test/workspace") - - const mockData = { messages: [] } - vi.mocked(readFileSync).mockReturnValue(JSON.stringify(mockData)) - - vi.mocked(writeFileSync).mockImplementationOnce(() => { - throw new Error("Write failed") - }) - - mockCreate.mockResolvedValueOnce({ - session_id: "test-session-id", - title: "", - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-01T00:00:00Z", - }) - - service.setPath("apiConversationHistoryPath", "/path/to/api.json") - - await vi.advanceTimersByTimeAsync(SessionService.SYNC_INTERVAL) - - expect(vi.mocked(logs.warn)).toHaveBeenCalledWith( - "Failed to save last session ID", - "SessionService", - expect.objectContaining({ - error: "Write failed", - }), - ) - }) - }) - }) -}) diff --git a/cli/src/services/__tests__/sessionClient.test.ts b/cli/src/services/__tests__/sessionClient.test.ts deleted file mode 100644 index ebf6fdbe730..00000000000 --- a/cli/src/services/__tests__/sessionClient.test.ts +++ /dev/null @@ -1,733 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from "vitest" -import { SessionClient } from "../sessionClient.js" -import { TrpcClient } from "../trpcClient.js" - -describe("SessionClient", () => { - let service: SessionClient - let mockTrpcClient: TrpcClient - let requestMock: ReturnType - - beforeEach(() => { - // Reset the singleton instance before each test - // @ts-expect-error - Accessing private static property for testing - SessionClient.instance = null - - // Mock TrpcClient - requestMock = vi.fn() - mockTrpcClient = { - request: requestMock, - } as unknown as TrpcClient - - // Mock TrpcClient.init to return our mock - vi.spyOn(TrpcClient, "init").mockReturnValue(mockTrpcClient) - - service = SessionClient.getInstance() - }) - - afterEach(() => { - vi.restoreAllMocks() - }) - - describe("getInstance", () => { - it("should return same instance on multiple calls", () => { - const instance1 = SessionClient.getInstance() - const instance2 = SessionClient.getInstance() - expect(instance1).toBe(instance2) - }) - - it("should be a singleton", () => { - // @ts-expect-error - Accessing private static property for testing - expect(SessionClient.instance).not.toBeNull() - }) - }) - - describe("get", () => { - it("should get session without blobs", async () => { - const mockSession = { - id: "session-1", - title: "Test Session", - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-01T00:00:00Z", - } - - requestMock.mockResolvedValueOnce({ - result: { data: mockSession }, - }) - - const result = await service.get({ - session_id: "session-1", - }) - - expect(requestMock).toHaveBeenCalledWith("cliSessions.get", "GET", { - session_id: "session-1", - }) - expect(result).toEqual(mockSession) - }) - - it("should get session with blobs", async () => { - const mockSession = { - id: "session-1", - title: "Test Session", - api_conversation_history: { messages: [] }, - task_metadata: { task: "test" }, - ui_messages: [{ type: "user", content: "hello" }], - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-01T00:00:00Z", - } - - requestMock.mockResolvedValueOnce({ - result: { data: mockSession }, - }) - - const result = await service.get({ - session_id: "session-1", - include_blob_urls: true, - }) - - expect(requestMock).toHaveBeenCalledWith("cliSessions.get", "GET", { - session_id: "session-1", - include_blob_urls: true, - }) - expect(result).toEqual(mockSession) - }) - - it("should get session with signed blob URLs", async () => { - const mockSession = { - id: "session-1", - title: "Test Session", - api_conversation_history_blob_url: "https://storage.example.com/api-history", - task_metadata_blob_url: "https://storage.example.com/task-metadata", - ui_messages_blob_url: "https://storage.example.com/ui-messages", - git_state_blob_url: "https://storage.example.com/git-state", - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-01T00:00:00Z", - } - - requestMock.mockResolvedValueOnce({ - result: { data: mockSession }, - }) - - const result = await service.get({ - session_id: "session-1", - include_blob_urls: true, - }) - - expect(requestMock).toHaveBeenCalledWith("cliSessions.get", "GET", { - session_id: "session-1", - include_blob_urls: true, - }) - expect(result).toEqual(mockSession) - // Verify git_state_blob_url is present - if ("git_state_blob_url" in result) { - expect(result.git_state_blob_url).toBe("https://storage.example.com/git-state") - } - }) - - it("should handle NOT_FOUND error", async () => { - requestMock.mockRejectedValueOnce(new Error("tRPC request failed: 404 Not Found - Session not found")) - - await expect( - service.get({ - session_id: "non-existent", - }), - ).rejects.toThrow("Session not found") - }) - }) - - describe("create", () => { - it("should create session with default title", async () => { - const mockSession = { - id: "new-session-1", - title: "", - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-01T00:00:00Z", - } - - requestMock.mockResolvedValueOnce({ - result: { data: mockSession }, - }) - - const result = await service.create({}) - - expect(requestMock).toHaveBeenCalledWith("cliSessions.create", "POST", {}) - expect(result).toEqual(mockSession) - }) - - it("should create session with title", async () => { - const mockSession = { - id: "new-session-2", - title: "My Session", - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-01T00:00:00Z", - } - - requestMock.mockResolvedValueOnce({ - result: { data: mockSession }, - }) - - const result = await service.create({ - title: "My Session", - }) - - expect(requestMock).toHaveBeenCalledWith("cliSessions.create", "POST", { - title: "My Session", - }) - expect(result).toEqual(mockSession) - }) - - it("should create session with git_url", async () => { - const mockSession = { - id: "new-session-3", - title: "Full Session", - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-01T00:00:00Z", - } - - const input = { - title: "Full Session", - git_url: "https://github.com/user/repo", - } - - requestMock.mockResolvedValueOnce({ - result: { data: mockSession }, - }) - - const result = await service.create(input) - - expect(requestMock).toHaveBeenCalledWith("cliSessions.create", "POST", input) - expect(result).toEqual(mockSession) - }) - }) - - describe("update", () => { - it("should update session title", async () => { - const mockSession = { - id: "session-1", - title: "Updated Title", - updated_at: "2025-01-02T00:00:00Z", - } - - requestMock.mockResolvedValueOnce({ - result: { data: mockSession }, - }) - - const result = await service.update({ - session_id: "session-1", - title: "Updated Title", - }) - - expect(requestMock).toHaveBeenCalledWith("cliSessions.update", "POST", { - session_id: "session-1", - title: "Updated Title", - }) - expect(result).toEqual(mockSession) - }) - - it("should update session with git_url", async () => { - const mockSession = { - id: "session-1", - title: "Updated Session", - updated_at: "2025-01-02T00:00:00Z", - } - - const input = { - session_id: "session-1", - title: "Updated Session", - git_url: "https://github.com/user/repo", - } - - requestMock.mockResolvedValueOnce({ - result: { data: mockSession }, - }) - - const result = await service.update(input) - - expect(requestMock).toHaveBeenCalledWith("cliSessions.update", "POST", input) - expect(result).toEqual(mockSession) - }) - - it("should handle NOT_FOUND error on update", async () => { - requestMock.mockRejectedValueOnce(new Error("tRPC request failed: 404 Not Found - Session not found")) - - await expect( - service.update({ - session_id: "non-existent", - title: "New Title", - }), - ).rejects.toThrow("Session not found") - }) - - it("should handle BAD_REQUEST error when no fields to update", async () => { - requestMock.mockRejectedValueOnce(new Error("tRPC request failed: 400 Bad Request - No fields to update")) - - await expect( - service.update({ - session_id: "session-1", - }), - ).rejects.toThrow("No fields to update") - }) - }) - - describe("error handling", () => { - it("should propagate network errors", async () => { - requestMock.mockRejectedValueOnce(new Error("Network error")) - - await expect(service.get({ session_id: "session-1" })).rejects.toThrow("Network error") - }) - - it("should propagate authorization errors", async () => { - requestMock.mockRejectedValueOnce(new Error("tRPC request failed: 401 Unauthorized - Invalid token")) - - await expect(service.get({ session_id: "session-1" })).rejects.toThrow("Invalid token") - }) - - it("should propagate validation errors", async () => { - requestMock.mockRejectedValueOnce(new Error("tRPC request failed: 400 Bad Request - Invalid input")) - - await expect( - service.create({ - title: "a".repeat(200), // Too long - }), - ).rejects.toThrow("Invalid input") - }) - }) - - describe("list", () => { - it("should list sessions without parameters", async () => { - const mockSessions = [ - { - id: "session-1", - title: "First Session", - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-01T00:00:00Z", - }, - { - id: "session-2", - title: "Second Session", - created_at: "2025-01-02T00:00:00Z", - updated_at: "2025-01-02T00:00:00Z", - }, - ] - - requestMock.mockResolvedValueOnce({ - result: { - data: { - cliSessions: mockSessions, - nextCursor: null, - }, - }, - }) - - const result = await service.list() - - expect(requestMock).toHaveBeenCalledWith("cliSessions.list", "GET", {}) - expect(result.cliSessions).toEqual(mockSessions) - expect(result.nextCursor).toBeNull() - }) - - it("should list sessions with limit parameter", async () => { - const mockSessions = [ - { - id: "session-1", - title: "Session 1", - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-01T00:00:00Z", - }, - ] - - requestMock.mockResolvedValueOnce({ - result: { - data: { - cliSessions: mockSessions, - nextCursor: "cursor-abc", - }, - }, - }) - - const result = await service.list({ limit: 1 }) - - expect(requestMock).toHaveBeenCalledWith("cliSessions.list", "GET", { limit: 1 }) - expect(result.cliSessions).toHaveLength(1) - expect(result.nextCursor).toBe("cursor-abc") - }) - - it("should list sessions with cursor parameter", async () => { - const mockSessions = [ - { - id: "session-3", - title: "Third Session", - created_at: "2025-01-03T00:00:00Z", - updated_at: "2025-01-03T00:00:00Z", - }, - ] - - requestMock.mockResolvedValueOnce({ - result: { - data: { - cliSessions: mockSessions, - nextCursor: null, - }, - }, - }) - - const result = await service.list({ cursor: "cursor-xyz" }) - - expect(requestMock).toHaveBeenCalledWith("cliSessions.list", "GET", { cursor: "cursor-xyz" }) - expect(result.cliSessions).toEqual(mockSessions) - expect(result.nextCursor).toBeNull() - }) - - it("should list sessions with both limit and cursor", async () => { - const mockSessions = [ - { - id: "session-4", - title: "Fourth Session", - created_at: "2025-01-04T00:00:00Z", - updated_at: "2025-01-04T00:00:00Z", - }, - { - id: "session-5", - title: "Fifth Session", - created_at: "2025-01-05T00:00:00Z", - updated_at: "2025-01-05T00:00:00Z", - }, - ] - - requestMock.mockResolvedValueOnce({ - result: { - data: { - cliSessions: mockSessions, - nextCursor: "cursor-next", - }, - }, - }) - - const result = await service.list({ limit: 2, cursor: "cursor-prev" }) - - expect(requestMock).toHaveBeenCalledWith("cliSessions.list", "GET", { limit: 2, cursor: "cursor-prev" }) - expect(result.cliSessions).toHaveLength(2) - expect(result.nextCursor).toBe("cursor-next") - }) - - it("should handle empty sessions list", async () => { - requestMock.mockResolvedValueOnce({ - result: { - data: { - cliSessions: [], - nextCursor: null, - }, - }, - }) - - const result = await service.list() - - expect(result.cliSessions).toEqual([]) - expect(result.nextCursor).toBeNull() - }) - - it("should handle error when listing sessions", async () => { - requestMock.mockRejectedValueOnce(new Error("tRPC request failed: 500 Internal Server Error")) - - await expect(service.list()).rejects.toThrow("Internal Server Error") - }) - - it("should handle authorization error", async () => { - requestMock.mockRejectedValueOnce(new Error("tRPC request failed: 401 Unauthorized - Invalid token")) - - await expect(service.list()).rejects.toThrow("Invalid token") - }) - }) - - describe("search", () => { - it("should search sessions with searchString", async () => { - const mockSessions = [ - { - id: "session-abc123", - title: "ABC Session", - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-01T00:00:00Z", - }, - { - id: "session-abc456", - title: "Another ABC", - created_at: "2025-01-02T00:00:00Z", - updated_at: "2025-01-02T00:00:00Z", - }, - ] - - requestMock.mockResolvedValueOnce({ - result: { - data: { - results: mockSessions, - total: 2, - limit: 10, - offset: 0, - }, - }, - }) - - const result = await service.search({ search_string: "abc" }) - - expect(requestMock).toHaveBeenCalledWith("cliSessions.search", "GET", { search_string: "abc" }) - expect(result.results).toEqual(mockSessions) - expect(result.results).toHaveLength(2) - expect(result.total).toBe(2) - expect(result.limit).toBe(10) - expect(result.offset).toBe(0) - }) - - it("should search sessions with searchString and limit", async () => { - const mockSessions = [ - { - id: "session-test1", - title: "Test 1", - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-01T00:00:00Z", - }, - ] - - requestMock.mockResolvedValueOnce({ - result: { - data: { - results: mockSessions, - total: 1, - limit: 5, - offset: 0, - }, - }, - }) - - const result = await service.search({ search_string: "test", limit: 5 }) - - expect(requestMock).toHaveBeenCalledWith("cliSessions.search", "GET", { search_string: "test", limit: 5 }) - expect(result.results).toEqual(mockSessions) - expect(result.total).toBe(1) - expect(result.limit).toBe(5) - }) - - it("should return empty results when no sessions match", async () => { - requestMock.mockResolvedValueOnce({ - result: { - data: { - results: [], - total: 0, - limit: 10, - offset: 0, - }, - }, - }) - - const result = await service.search({ search_string: "nonexistent" }) - - expect(result.results).toEqual([]) - expect(result.total).toBe(0) - }) - - it("should handle search error", async () => { - requestMock.mockRejectedValueOnce(new Error("tRPC request failed: 500 Internal Server Error")) - - await expect(service.search({ search_string: "test" })).rejects.toThrow("Internal Server Error") - }) - - it("should handle authorization error", async () => { - requestMock.mockRejectedValueOnce(new Error("tRPC request failed: 401 Unauthorized - Invalid token")) - - await expect(service.search({ search_string: "test" })).rejects.toThrow("Invalid token") - }) - - it("should pass through the limit parameter correctly", async () => { - requestMock.mockResolvedValueOnce({ - result: { - data: { - results: [], - total: 0, - limit: 20, - offset: 0, - }, - }, - }) - - await service.search({ search_string: "test", limit: 20 }) - - expect(requestMock).toHaveBeenCalledWith("cliSessions.search", "GET", { - search_string: "test", - limit: 20, - }) - }) - - it("should support offset parameter for pagination", async () => { - const mockSessions = [ - { - id: "session-page2", - title: "Page 2 Session", - created_at: "2025-01-03T00:00:00Z", - updated_at: "2025-01-03T00:00:00Z", - }, - ] - - requestMock.mockResolvedValueOnce({ - result: { - data: { - results: mockSessions, - total: 15, - limit: 10, - offset: 10, - }, - }, - }) - - const result = await service.search({ search_string: "test", limit: 10, offset: 10 }) - - expect(requestMock).toHaveBeenCalledWith("cliSessions.search", "GET", { - search_string: "test", - limit: 10, - offset: 10, - }) - expect(result.results).toHaveLength(1) - expect(result.total).toBe(15) - expect(result.offset).toBe(10) - }) - }) - - describe("type safety", () => { - it("should handle typed responses correctly", async () => { - const mockSession = { - session_id: "uuid-string", - title: "Typed Session", - created_at: "2025-01-01T00:00:00.000Z", - updated_at: "2025-01-01T00:00:00.000Z", - } - - requestMock.mockResolvedValueOnce({ - result: { data: mockSession }, - }) - - const result = await service.get({ - session_id: "uuid-string", - }) - - expect(typeof result.session_id).toBe("string") - expect(typeof result.title).toBe("string") - expect(typeof result.created_at).toBe("string") - expect(typeof result.updated_at).toBe("string") - }) - - it("should handle sessions with blobs correctly", async () => { - const mockSession = { - session_id: "uuid-string", - title: "Session with Blobs", - api_conversation_history: { messages: [] }, - task_metadata: { key: "value" }, - ui_messages: [], - created_at: "2025-01-01T00:00:00.000Z", - updated_at: "2025-01-01T00:00:00.000Z", - } - - requestMock.mockResolvedValueOnce({ - result: { data: mockSession }, - }) - - const result = await service.get({ - session_id: "uuid-string", - include_blob_urls: true, - }) - - // Result should have blob fields when includeBlobUrls is true - expect("api_conversation_history" in result).toBe(true) - expect("task_metadata" in result).toBe(true) - expect("ui_messages" in result).toBe(true) - }) - }) - - describe("fork", () => { - it("should fork a session by share_id", async () => { - const mockForkedSession = { - session_id: "forked-session-1", - } - - requestMock.mockResolvedValueOnce({ - result: { data: mockForkedSession }, - }) - - const result = await service.fork({ - share_id: "share-123", - }) - - expect(requestMock).toHaveBeenCalledWith("cliSessions.fork", "POST", { - share_id: "share-123", - }) - expect(result).toEqual(mockForkedSession) - }) - - it("should return forked session_id", async () => { - const mockForkedSession = { - session_id: "forked-session-2", - } - - requestMock.mockResolvedValueOnce({ - result: { data: mockForkedSession }, - }) - - const result = await service.fork({ - share_id: "share-456", - }) - - expect(result.session_id).toBe("forked-session-2") - }) - - it("should handle NOT_FOUND error when forking", async () => { - requestMock.mockRejectedValueOnce(new Error("tRPC request failed: 404 Not Found - Share not found")) - - await expect( - service.fork({ - share_id: "non-existent", - }), - ).rejects.toThrow("Share not found") - }) - - it("should handle authorization error when forking", async () => { - requestMock.mockRejectedValueOnce(new Error("tRPC request failed: 401 Unauthorized - Invalid token")) - - await expect( - service.fork({ - share_id: "share-123", - }), - ).rejects.toThrow("Invalid token") - }) - - it("should properly send request parameters", async () => { - const mockForkedSession = { - session_id: "forked-session-3", - } - - requestMock.mockResolvedValueOnce({ - result: { data: mockForkedSession }, - }) - - await service.fork({ - share_id: "share-original", - }) - - // Verify the exact parameters sent - expect(requestMock).toHaveBeenCalledWith("cliSessions.fork", "POST", { - share_id: "share-original", - }) - }) - - it("should handle successful fork response", async () => { - const mockForkedSession = { - session_id: "forked-session-4", - } - - requestMock.mockResolvedValueOnce({ - result: { data: mockForkedSession }, - }) - - const result = await service.fork({ - share_id: "share-empty", - }) - - expect(result.session_id).toBe("forked-session-4") - }) - }) -}) diff --git a/cli/src/services/__tests__/trpcClient.test.ts b/cli/src/services/__tests__/trpcClient.test.ts deleted file mode 100644 index 2d9c08cf8a2..00000000000 --- a/cli/src/services/__tests__/trpcClient.test.ts +++ /dev/null @@ -1,422 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from "vitest" -import { TrpcClient } from "../trpcClient.js" - -vi.mock("@roo-code/types", () => ({ - getApiUrl: vi.fn(() => "https://api.kilocode.ai"), -})) - -describe("TrpcClient", () => { - let fetchMock: ReturnType - - beforeEach(() => { - // Reset the singleton instance before each test - // @ts-expect-error - Accessing private static property for testing - TrpcClient.instance = null - - // Always set environment variable to a valid default for tests - // This ensures consistency across all tests - process.env.KILOCODE_BACKEND_BASE_URL = "https://api.kilocode.ai" - - // Mock global fetch - fetchMock = vi.fn() - global.fetch = fetchMock - }) - - afterEach(() => { - vi.restoreAllMocks() - }) - - describe("init", () => { - it("should throw error when no token provided and no instance exists", () => { - expect(() => TrpcClient.init()).toThrow("token required to init TrpcClient service") - }) - - it("should create new instance with token", () => { - const instance = TrpcClient.init("test-token") - expect(instance).toBeInstanceOf(TrpcClient) - }) - - it("should return same instance on subsequent calls", () => { - const instance1 = TrpcClient.init("test-token") - const instance2 = TrpcClient.init() - expect(instance1).toBe(instance2) - }) - - it("should not create new instance if one already exists, even with new token", () => { - const instance1 = TrpcClient.init("token1") - const instance2 = TrpcClient.init("token2") - expect(instance1).toBe(instance2) - }) - }) - - describe("request", () => { - let client: TrpcClient - - beforeEach(() => { - client = TrpcClient.init("test-token") - }) - - describe("GET requests", () => { - it("should make GET request without input", async () => { - const mockResponse = { result: { data: "test" } } - fetchMock.mockResolvedValueOnce({ - ok: true, - json: async () => mockResponse, - }) - - const result = await client.request("test.procedure", "GET") - - expect(fetchMock).toHaveBeenCalledTimes(1) - expect(fetchMock).toHaveBeenCalledWith( - expect.any(URL), - expect.objectContaining({ - method: "GET", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer test-token", - }, - }), - ) - expect(result).toEqual(mockResponse) - }) - - it("should add input as URL search param for GET requests", async () => { - const mockResponse = { result: "success" } - fetchMock.mockResolvedValueOnce({ - ok: true, - json: async () => mockResponse, - }) - - const input = { key: "value", number: 42 } - await client.request("test.procedure", "GET", input) - - const callUrl = fetchMock.mock.calls[0]?.[0] as URL - expect(callUrl.searchParams.get("input")).toBe(JSON.stringify(input)) - }) - - it("should construct correct URL with procedure name", async () => { - fetchMock.mockResolvedValueOnce({ - ok: true, - json: async () => ({}), - }) - - await client.request("user.getProfile", "GET") - - const callUrl = fetchMock.mock.calls[0]?.[0] as URL - expect(callUrl.pathname).toBe("/api/trpc/user.getProfile") - }) - - it("should use endpoint from getApiUrl", async () => { - const { getApiUrl } = await import("@roo-code/types") - vi.mocked(getApiUrl).mockReturnValueOnce("https://custom.api.com") - - // Need to create new client instance to pick up mocked getApiUrl - // @ts-expect-error - Accessing private static property for testing - TrpcClient.instance = null - const customClient = TrpcClient.init("test-token") - - fetchMock.mockResolvedValueOnce({ - ok: true, - json: async () => ({}), - }) - - await customClient.request("test", "GET") - - const callUrl = fetchMock.mock.calls[0]?.[0] as URL - expect(callUrl.origin).toBe("https://custom.api.com") - }) - - it("should use default endpoint from getApiUrl when no environment variable", async () => { - const { getApiUrl } = await import("@roo-code/types") - vi.mocked(getApiUrl).mockReturnValueOnce("https://api.kilocode.ai") - - // @ts-expect-error - Accessing private static property for testing - TrpcClient.instance = null - const defaultClient = TrpcClient.init("test-token")! - - fetchMock.mockResolvedValueOnce({ - ok: true, - json: async () => ({}), - }) - - await defaultClient.request("test", "GET") - - const callUrl = fetchMock.mock.calls[0]?.[0] as URL - expect(callUrl.origin).toBe("https://api.kilocode.ai") - }) - }) - - describe("POST requests", () => { - it("should make POST request without input", async () => { - const mockResponse = { result: "created" } - fetchMock.mockResolvedValueOnce({ - ok: true, - json: async () => mockResponse, - }) - - const result = await client.request("test.create", "POST") - - expect(fetchMock).toHaveBeenCalledWith( - expect.any(URL), - expect.objectContaining({ - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer test-token", - }, - }), - ) - expect(result).toEqual(mockResponse) - }) - - it("should add input as body for POST requests", async () => { - const mockResponse = { result: "created" } - fetchMock.mockResolvedValueOnce({ - ok: true, - json: async () => mockResponse, - }) - - const input = { name: "test", data: { nested: true } } - await client.request("test.create", "POST", input) - - expect(fetchMock).toHaveBeenCalledWith( - expect.any(URL), - expect.objectContaining({ - method: "POST", - body: JSON.stringify(input), - }), - ) - }) - - it("should not add input to URL params for POST requests", async () => { - fetchMock.mockResolvedValueOnce({ - ok: true, - json: async () => ({}), - }) - - const input = { key: "value" } - await client.request("test", "POST", input) - - const callUrl = fetchMock.mock.calls[0]?.[0] as URL - expect(callUrl.searchParams.has("input")).toBe(false) - }) - }) - - describe("error handling", () => { - it("should throw error when response is not ok", async () => { - fetchMock.mockResolvedValueOnce({ - ok: false, - status: 404, - statusText: "Not Found", - json: async () => ({}), - }) - - await expect(client.request("test", "GET")).rejects.toThrow("tRPC request failed: 404 Not Found") - }) - - it("should include error message from response if available", async () => { - fetchMock.mockResolvedValueOnce({ - ok: false, - status: 400, - statusText: "Bad Request", - json: async () => ({ message: "Invalid input data" }), - }) - - await expect(client.request("test", "POST")).rejects.toThrow( - "tRPC request failed: 400 Bad Request - Invalid input data", - ) - }) - - it("should handle response without message field", async () => { - fetchMock.mockResolvedValueOnce({ - ok: false, - status: 500, - statusText: "Internal Server Error", - json: async () => ({ error: "Something went wrong" }), - }) - - await expect(client.request("test", "GET")).rejects.toThrow( - "tRPC request failed: 500 Internal Server Error", - ) - }) - - it("should handle JSON parse error in error response", async () => { - fetchMock.mockResolvedValueOnce({ - ok: false, - status: 500, - statusText: "Internal Server Error", - json: async () => { - throw new Error("Invalid JSON") - }, - }) - - await expect(client.request("test", "GET")).rejects.toThrow( - "tRPC request failed: 500 Internal Server Error", - ) - }) - - it("should handle network errors", async () => { - fetchMock.mockRejectedValueOnce(new Error("Network error")) - - await expect(client.request("test", "GET")).rejects.toThrow("Network error") - }) - - it("should handle 401 Unauthorized", async () => { - fetchMock.mockResolvedValueOnce({ - ok: false, - status: 401, - statusText: "Unauthorized", - json: async () => ({ message: "Invalid token" }), - }) - - await expect(client.request("test", "GET")).rejects.toThrow( - "tRPC request failed: 401 Unauthorized - Invalid token", - ) - }) - - it("should handle 403 Forbidden", async () => { - fetchMock.mockResolvedValueOnce({ - ok: false, - status: 403, - statusText: "Forbidden", - json: async () => ({ message: "Access denied" }), - }) - - await expect(client.request("test", "GET")).rejects.toThrow( - "tRPC request failed: 403 Forbidden - Access denied", - ) - }) - }) - - describe("authorization", () => { - it("should include Bearer token in Authorization header", async () => { - fetchMock.mockResolvedValueOnce({ - ok: true, - json: async () => ({}), - }) - - await client.request("test", "GET") - - const headers = fetchMock.mock.calls[0]?.[1]?.headers - expect(headers?.Authorization).toBe("Bearer test-token") - }) - - it("should use token from init call", async () => { - // @ts-expect-error - Accessing private static property for testing - TrpcClient.instance = null - const clientWithToken = TrpcClient.init("custom-token-123") - - fetchMock.mockResolvedValueOnce({ - ok: true, - json: async () => ({}), - }) - - await clientWithToken.request("test", "POST") - - const headers = fetchMock.mock.calls[0]?.[1]?.headers - expect(headers?.Authorization).toBe("Bearer custom-token-123") - }) - }) - - describe("content types", () => { - it("should set Content-Type to application/json", async () => { - fetchMock.mockResolvedValueOnce({ - ok: true, - json: async () => ({}), - }) - - await client.request("test", "POST", { data: "test" }) - - const headers = fetchMock.mock.calls[0]?.[1]?.headers - expect(headers?.["Content-Type"]).toBe("application/json") - }) - }) - - describe("type safety", () => { - it("should handle typed input and output", async () => { - interface TestInput { - name: string - age: number - } - - interface TestOutput { - id: string - created: boolean - } - - const mockResponse: TestOutput = { id: "123", created: true } - fetchMock.mockResolvedValueOnce({ - ok: true, - json: async () => mockResponse, - }) - - const input: TestInput = { name: "Test", age: 25 } - const result = await client.request("user.create", "POST", input) - - expect(result).toEqual(mockResponse) - expect(result.id).toBe("123") - expect(result.created).toBe(true) - }) - - it("should handle void input type", async () => { - interface TestOutput { - status: string - } - - const mockResponse: TestOutput = { status: "ok" } - fetchMock.mockResolvedValueOnce({ - ok: true, - json: async () => mockResponse, - }) - - const result = await client.request("health.check", "GET") - - expect(result).toEqual(mockResponse) - }) - }) - - describe("complex input types", () => { - it("should handle nested objects in input", async () => { - fetchMock.mockResolvedValueOnce({ - ok: true, - json: async () => ({}), - }) - - const input = { - user: { - name: "Test", - settings: { - theme: "dark", - notifications: true, - }, - }, - metadata: { - timestamp: Date.now(), - }, - } - - await client.request("test", "POST", input) - - const body = fetchMock.mock.calls[0]?.[1]?.body - expect(body).toBe(JSON.stringify(input)) - }) - - it("should handle arrays in input", async () => { - fetchMock.mockResolvedValueOnce({ - ok: true, - json: async () => ({}), - }) - - const input = { - items: [1, 2, 3], - tags: ["test", "example"], - } - - await client.request("test", "POST", input) - - const body = fetchMock.mock.calls[0]?.[1]?.body - expect(JSON.parse(body as string)).toEqual(input) - }) - }) - }) -}) diff --git a/cli/src/services/session-adapters.ts b/cli/src/services/session-adapters.ts new file mode 100644 index 00000000000..09afcd3affc --- /dev/null +++ b/cli/src/services/session-adapters.ts @@ -0,0 +1,27 @@ +import { KiloCodePaths } from "../utils/paths.js" +import type { ExtensionService } from "./extension.js" +import type { IPathProvider } from "../../../src/shared/kilocode/cli-sessions/types/IPathProvider" +import type { IExtensionMessenger } from "../../../src/shared/kilocode/cli-sessions/types/IExtensionMessenger" +import type { WebviewMessage } from "../../../src/shared/WebviewMessage" + +export class KiloCodePathProvider implements IPathProvider { + getTasksDir(): string { + return KiloCodePaths.getTasksDir() + } + + getSessionFilePath(workspaceDir: string): string { + return KiloCodePaths.getSessionFilePath(workspaceDir) + } +} + +export class ExtensionMessengerAdapter implements IExtensionMessenger { + constructor(private extensionService: ExtensionService) {} + + async sendWebviewMessage(message: WebviewMessage): Promise { + return this.extensionService.sendWebviewMessage(message) + } + + async requestSingleCompletion(prompt: string, timeoutMs: number): Promise { + return this.extensionService.requestSingleCompletion(prompt, timeoutMs) + } +} diff --git a/cli/src/services/trpcClient.ts b/cli/src/services/trpcClient.ts deleted file mode 100644 index 4c597390dd1..00000000000 --- a/cli/src/services/trpcClient.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { logs } from "./logs" -import { getApiUrl } from "@roo-code/types" - -type HttpMethod = "GET" | "POST" - -// Generic tRPC response wrapper -export type TrpcResponse = { result: { data: T } } - -export class TrpcClient { - private static instance: TrpcClient | null = null - - static init(token?: string) { - if (!token && !TrpcClient.instance) { - throw new Error("token required to init TrpcClient service") - } - - if (token && !TrpcClient.instance) { - TrpcClient.instance = new TrpcClient(token) - - logs.debug("Initiated TrpcClient", "TrpcClient") - } - - return TrpcClient.instance! - } - - public readonly endpoint = getApiUrl() - - private constructor(public readonly token: string) {} - - async request( - procedure: string, - method: HttpMethod, - input?: TInput, - ): Promise { - const url = new URL(`${this.endpoint}/api/trpc/${procedure}`) - - if (method === "GET" && input) { - url.searchParams.set("input", JSON.stringify(input)) - } - - const response = await fetch(url, { - method, - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${this.token}`, - }, - ...(method === "POST" && input && { body: JSON.stringify(input) }), - }) - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - - throw new Error( - `tRPC request failed: ${response.status} ${response.statusText}${ - errorData.message ? ` - ${errorData.message}` : "" - }`, - ) - } - - return response.json() - } -} diff --git a/cli/src/state/atoms/effects.ts b/cli/src/state/atoms/effects.ts index 644205dc815..7028af211f2 100644 --- a/cli/src/state/atoms/effects.ts +++ b/cli/src/state/atoms/effects.ts @@ -32,7 +32,7 @@ import { resolveTaskHistoryRequestAtom, } from "./taskHistory.js" import { logs } from "../../services/logs.js" -import { SessionService } from "../../services/session.js" +import { SessionManager } from "../../../../src/shared/kilocode/cli-sessions/core/SessionManager.js" /** * Message buffer to handle race conditions during initialization @@ -375,9 +375,9 @@ export const messageHandlerEffectAtom = atom(null, (get, set, message: Extension const payload = message.payload as [string, string] | undefined if (payload && Array.isArray(payload) && payload.length === 2) { - const [, filePath] = payload + const [taskId, filePath] = payload - SessionService.init().setPath("apiConversationHistoryPath", filePath) + SessionManager.init().setPath(taskId, "apiConversationHistoryPath", filePath) } else { logs.warn(`[DEBUG] Invalid apiMessagesSaved payload`, "effects", { payload }) } @@ -388,9 +388,9 @@ export const messageHandlerEffectAtom = atom(null, (get, set, message: Extension const payload = message.payload as [string, string] | undefined if (payload && Array.isArray(payload) && payload.length === 2) { - const [, filePath] = payload + const [taskId, filePath] = payload - SessionService.init().setPath("uiMessagesPath", filePath) + SessionManager.init().setPath(taskId, "uiMessagesPath", filePath) } else { logs.warn(`[DEBUG] Invalid taskMessagesSaved payload`, "effects", { payload }) } @@ -400,9 +400,9 @@ export const messageHandlerEffectAtom = atom(null, (get, set, message: Extension case "taskMetadataSaved": { const payload = message.payload as [string, string] | undefined if (payload && Array.isArray(payload) && payload.length === 2) { - const [, filePath] = payload + const [taskId, filePath] = payload - SessionService.init().setPath("taskMetadataPath", filePath) + SessionManager.init().setPath(taskId, "taskMetadataPath", filePath) } else { logs.warn(`[DEBUG] Invalid taskMetadataSaved payload`, "effects", { payload }) } diff --git a/cli/src/utils/paths.ts b/cli/src/utils/paths.ts index 57c4d4a68bc..5c6e3f4fde5 100644 --- a/cli/src/utils/paths.ts +++ b/cli/src/utils/paths.ts @@ -49,9 +49,10 @@ export class KiloCodePaths { /** * Get the path to the last session file for a workspace */ - static getLastSessionPath(workspacePath: string): string { + static getSessionFilePath(workspacePath: string): string { const workspaceDir = this.getWorkspaceStorageDir(workspacePath) - return path.join(workspaceDir, "last-session.json") + + return path.join(workspaceDir, "session.json") } /** diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index faf00d1d4b0..36989b47eff 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -71,6 +71,7 @@ import { ShadowCheckpointService } from "../../services/checkpoints/ShadowCheckp import { CodeIndexManager } from "../../services/code-index/manager" import type { IndexProgressUpdate } from "../../services/code-index/interfaces/manager" import { MdmService } from "../../services/mdm/MdmService" +import { SessionManager } from "../../shared/kilocode/cli-sessions/core/SessionManager" import { fileExistsAtPath } from "../../utils/fs" import { setTtsEnabled, setTtsSpeed } from "../../utils/tts" @@ -107,6 +108,10 @@ 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" export type ClineProviderState = Awaited> // kilocode_change end @@ -471,6 +476,8 @@ export class ClineProvider this.clineStack.push(task) task.emit(RooCodeEventName.TaskFocused) + await kilo_destroySessionManager() + // Perform special setup provider specific tasks. await this.performPreparationTasks(task) @@ -684,6 +691,8 @@ export class ClineProvider this.autoPurgeScheduler.stop() this.autoPurgeScheduler = undefined } + + await kilo_destroySessionManager() // kilocode_change end this.log("Disposed all disposables") @@ -1139,6 +1148,22 @@ ${prompt} } public async postMessageToWebview(message: ExtensionMessage) { + await kilo_execIfExtension(() => { + if (message.type === "apiMessagesSaved" && message.payload) { + const [taskId, filePath] = message.payload as [string, string] + + SessionManager.init().setPath(taskId, "apiConversationHistoryPath", filePath) + } else if (message.type === "taskMessagesSaved" && message.payload) { + const [taskId, filePath] = message.payload as [string, string] + + SessionManager.init().setPath(taskId, "uiMessagesPath", filePath) + } else if (message.type === "taskMetadataSaved" && message.payload) { + const [taskId, filePath] = message.payload as [string, string] + + SessionManager.init().setPath(taskId, "taskMetadataPath", filePath) + } + }) + await this.view?.webview.postMessage(message) } @@ -1879,6 +1904,13 @@ ${prompt} async refreshWorkspace() { this.currentWorkspacePath = getWorkspacePath() + + await kilo_execIfExtension(() => { + if (this.currentWorkspacePath) { + SessionManager.init().setWorkspaceDirectory(this.currentWorkspacePath) + } + }) + await this.postStateToWebview() } diff --git a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts index d01a830eb0f..36d1642dfd5 100644 --- a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts @@ -141,6 +141,17 @@ vi.mock("@roo-code/cloud", () => ({ getRooCodeApiUrl: vi.fn().mockReturnValue("https://app.roocode.com"), })) +vi.mock("../../../shared/kilocode/cli-sessions/core/SessionManager", () => ({ + SessionManager: { + init: vi.fn().mockReturnValue({ + startTimer: vi.fn(), + setPath: vi.fn(), + setWorkspaceDirectory: vi.fn(), + destroy: vi.fn().mockResolvedValue(undefined), + }), + }, +})) + describe("ClineProvider - API Handler Rebuild Guard", () => { let provider: ClineProvider let mockContext: vscode.ExtensionContext diff --git a/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts b/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts index 36c23512e77..d56659cc7ad 100644 --- a/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts @@ -88,6 +88,17 @@ vi.mock("../../../shared/embeddingModels", () => ({ EMBEDDING_MODEL_PROFILES: [], })) +vi.mock("../../../shared/kilocode/cli-sessions/core/SessionManager", () => ({ + SessionManager: { + init: vi.fn().mockReturnValue({ + startTimer: vi.fn(), + setPath: vi.fn(), + setWorkspaceDirectory: vi.fn(), + destroy: vi.fn().mockResolvedValue(undefined), + }), + }, +})) + describe("ClineProvider flicker-free cancel", () => { let provider: ClineProvider let mockContext: any diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 0079ae008ed..377de8d5a25 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -347,6 +347,17 @@ vi.mock("@roo-code/cloud", () => ({ getRooCodeApiUrl: vi.fn().mockReturnValue("https://app.roocode.com"), })) +vi.mock("../../../shared/kilocode/cli-sessions/core/SessionManager", () => ({ + SessionManager: { + init: vi.fn().mockReturnValue({ + startTimer: vi.fn(), + setPath: vi.fn(), + setWorkspaceDirectory: vi.fn(), + destroy: vi.fn().mockResolvedValue(undefined), + }), + }, +})) + afterAll(() => { vi.restoreAllMocks() }) diff --git a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts index bb7723d4b9a..9531509a0e7 100644 --- a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts @@ -126,6 +126,17 @@ vi.mock("@roo-code/cloud", () => ({ getRooCodeApiUrl: vi.fn().mockReturnValue("https://app.roocode.com"), })) +vi.mock("../../../shared/kilocode/cli-sessions/core/SessionManager", () => ({ + SessionManager: { + init: vi.fn().mockReturnValue({ + startTimer: vi.fn(), + setPath: vi.fn(), + setWorkspaceDirectory: vi.fn(), + destroy: vi.fn().mockResolvedValue(undefined), + }), + }, +})) + vi.mock("../../../shared/modes", () => ({ modes: [ { diff --git a/src/extension.ts b/src/extension.ts index 2a735ccd5c5..8005525509b 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -49,6 +49,7 @@ import { checkAnthropicApiKeyConflict } from "./utils/anthropicApiKeyWarning" // import { SettingsSyncService } from "./services/settings-sync/SettingsSyncService" // kilocode_change import { flushModels, getModels } from "./api/providers/fetchers/modelCache" import { ManagedIndexer } from "./services/code-index/managed/ManagedIndexer" // kilocode_change +import { kilo_initializeSessionManager } from "./shared/kilocode/cli-sessions/extension/session-manager-utils" /** * Built using https://github.com/microsoft/vscode-webview-ui-toolkit @@ -267,6 +268,24 @@ export async function activate(context: vscode.ExtensionContext) { ) } + // kilocode_change start + try { + const { apiConfiguration } = await provider.getState() + + await kilo_initializeSessionManager({ + context: context, + kiloToken: apiConfiguration.kilocodeToken, + log: provider.log.bind(provider), + outputChannel, + provider, + }) + } catch (error) { + outputChannel.appendLine( + `[SessionManager] Failed to initialize SessionManager: ${error instanceof Error ? error.message : String(error)}`, + ) + } + // kilocode_change end + // Finish initializing the provider. TelemetryService.instance.setProvider(provider) diff --git a/src/package.json b/src/package.json index 1764d0c25bb..9be652a64c9 100644 --- a/src/package.json +++ b/src/package.json @@ -723,8 +723,8 @@ "sanitize-filename": "^1.6.3", "say": "^0.16.0", "serialize-error": "^12.0.0", - "shiki": "^3.6.0", "shell-quote": "^1.8.2", + "shiki": "^3.6.0", "simple-git": "^3.27.0", "socket.io-client": "^4.8.1", "sound-play": "^1.1.0", diff --git a/src/services/kilo-session/ExtensionLoggerAdapter.ts b/src/services/kilo-session/ExtensionLoggerAdapter.ts new file mode 100644 index 00000000000..10a30de12fc --- /dev/null +++ b/src/services/kilo-session/ExtensionLoggerAdapter.ts @@ -0,0 +1,41 @@ +import * as vscode from "vscode" +import type { ILogger } from "../../shared/kilocode/cli-sessions/types/ILogger" + +export class ExtensionLoggerAdapter implements ILogger { + constructor(private readonly outputChannel: vscode.OutputChannel) {} + + private formatLog(message: string, source: string, metadata?: Record): string { + const timestamp = new Date().toISOString() + let logMessage = `[${timestamp}] [${source}] ${message}` + + if (metadata && Object.keys(metadata).length > 0) { + try { + logMessage += ` ${JSON.stringify(metadata)}` + } catch (error) { + logMessage += ` [metadata serialization error]` + } + } + + return logMessage + } + + debug(message: string, source: string, metadata?: Record): void { + const logMessage = this.formatLog(message, source, metadata) + this.outputChannel.appendLine(`[DEBUG] ${logMessage}`) + } + + info(message: string, source: string, metadata?: Record): void { + const logMessage = this.formatLog(message, source, metadata) + this.outputChannel.appendLine(`[INFO] ${logMessage}`) + } + + warn(message: string, source: string, metadata?: Record): void { + const logMessage = this.formatLog(message, source, metadata) + this.outputChannel.appendLine(`[WARN] ${logMessage}`) + } + + error(message: string, source: string, metadata?: Record): void { + const logMessage = this.formatLog(message, source, metadata) + this.outputChannel.appendLine(`[ERROR] ${logMessage}`) + } +} diff --git a/src/services/kilo-session/ExtensionMessengerImpl.ts b/src/services/kilo-session/ExtensionMessengerImpl.ts new file mode 100644 index 00000000000..b740bcfb2d3 --- /dev/null +++ b/src/services/kilo-session/ExtensionMessengerImpl.ts @@ -0,0 +1,34 @@ +import type { IExtensionMessenger } from "../../shared/kilocode/cli-sessions/types/IExtensionMessenger" +import type { WebviewMessage } from "../../shared/kilocode/cli-sessions/types/IExtensionMessenger" +import type { ExtensionMessage } from "../../shared/ExtensionMessage" +import type { ClineProvider } from "../../core/webview/ClineProvider" +import { singleCompletionHandler } from "../../utils/single-completion-handler" + +export class ExtensionMessengerImpl implements IExtensionMessenger { + constructor(private readonly provider: ClineProvider) {} + + async sendWebviewMessage(message: WebviewMessage): Promise { + await this.provider.postMessageToWebview(message as unknown as ExtensionMessage) + } + + async requestSingleCompletion(prompt: string, timeoutMs: number): Promise { + const state = await this.provider.getState() + if (!state?.apiConfiguration) { + throw new Error("No API configuration available") + } + + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error("Single completion request timed out")), timeoutMs) + }) + + try { + const completionPromise = singleCompletionHandler(state.apiConfiguration, prompt) + return await Promise.race([completionPromise, timeoutPromise]) + } catch (error) { + if (error instanceof Error && error.message.includes("timed out")) { + throw new Error("Single completion request timed out") + } + throw error + } + } +} diff --git a/src/services/kilo-session/ExtensionPathProvider.ts b/src/services/kilo-session/ExtensionPathProvider.ts new file mode 100644 index 00000000000..a8a8990ad86 --- /dev/null +++ b/src/services/kilo-session/ExtensionPathProvider.ts @@ -0,0 +1,42 @@ +import * as vscode from "vscode" +import * as path from "path" +import { createHash } from "crypto" +import { existsSync, mkdirSync } from "fs" +import type { IPathProvider } from "../../shared/kilocode/cli-sessions/types/IPathProvider" + +export class ExtensionPathProvider implements IPathProvider { + private readonly globalStoragePath: string + + constructor(context: vscode.ExtensionContext) { + this.globalStoragePath = context.globalStorageUri.fsPath + this.ensureDirectories() + } + + private ensureDirectories(): void { + const sessionsDir = path.join(this.globalStoragePath, "sessions") + const tasksDir = this.getTasksDir() + const workspacesDir = path.join(sessionsDir, "workspaces") + + for (const dir of [sessionsDir, tasksDir, workspacesDir]) { + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }) + } + } + } + + getTasksDir(): string { + return path.join(this.globalStoragePath, "sessions", "tasks") + } + + getSessionFilePath(workspaceDir: string): string { + const hash = createHash("sha256").update(workspaceDir).digest("hex").substring(0, 16) + const workspacesDir = path.join(this.globalStoragePath, "sessions", "workspaces") + const workspaceSessionDir = path.join(workspacesDir, hash) + + if (!existsSync(workspaceSessionDir)) { + mkdirSync(workspaceSessionDir, { recursive: true }) + } + + return path.join(workspaceSessionDir, "session.json") + } +} diff --git a/cli/src/services/sessionClient.ts b/src/shared/kilocode/cli-sessions/core/SessionClient.ts similarity index 62% rename from cli/src/services/sessionClient.ts rename to src/shared/kilocode/cli-sessions/core/SessionClient.ts index 29cea69030e..34fb5a0dcf9 100644 --- a/cli/src/services/sessionClient.ts +++ b/src/shared/kilocode/cli-sessions/core/SessionClient.ts @@ -1,6 +1,5 @@ -import { TrpcClient, TrpcResponse } from "./trpcClient.js" +import type { TrpcClient } from "./TrpcClient.js" -// Type definitions matching backend schema export interface Session { session_id: string title: string @@ -65,7 +64,6 @@ export interface SearchSessionOutput { offset: number } -// Shared state enum export enum CliSessionSharedState { Public = "public", } @@ -81,7 +79,8 @@ export interface ShareSessionOutput { } export interface ForkSessionInput { - share_id: string + share_or_session_id: string + created_on_platform: string } export interface ForkSessionOutput { @@ -97,121 +96,87 @@ export interface DeleteSessionOutput { session_id: string } +/** + * Client for interacting with session-related API endpoints. + * Provides methods for CRUD operations on sessions. + */ export class SessionClient { - private static instance: SessionClient | null = null - - static getInstance() { - if (!SessionClient.instance) { - SessionClient.instance = new SessionClient() - } - - return SessionClient.instance! - } - - private constructor() {} + constructor(private readonly trpcClient: TrpcClient) {} /** * Get a specific session by ID */ async get(input: GetSessionInput): Promise { - const client = TrpcClient.init() - const response = await client.request>( - "cliSessions.get", - "GET", - input, - ) - return response.result.data + return await this.trpcClient.request("cliSessions.get", "GET", input) } /** * Create a new session */ async create(input: CreateSessionInput): Promise { - const client = TrpcClient.init() - const response = await client.request>( + return await this.trpcClient.request( "cliSessions.create", "POST", input, ) - return response.result.data } /** * Update an existing session */ async update(input: UpdateSessionInput): Promise { - const client = TrpcClient.init() - const response = await client.request>( + return await this.trpcClient.request( "cliSessions.update", "POST", input, ) - return response.result.data } /** * List sessions with pagination support */ async list(input?: ListSessionsInput): Promise { - const client = TrpcClient.init() - const response = await client.request>( + return await this.trpcClient.request( "cliSessions.list", "GET", input || {}, ) - return response.result.data } /** * Search sessions */ async search(input: SearchSessionInput): Promise { - const client = TrpcClient.init() - const response = await client.request>( + return await this.trpcClient.request( "cliSessions.search", "GET", input, ) - return response.result.data } /** * Share a session */ async share(input: ShareSessionInput): Promise { - const client = TrpcClient.init() - const response = await client.request>( - "cliSessions.share", - "POST", - input, - ) - return response.result.data + return await this.trpcClient.request("cliSessions.share", "POST", input) } /** * Fork a shared session by share ID */ async fork(input: ForkSessionInput): Promise { - const client = TrpcClient.init() - const response = await client.request>( - "cliSessions.fork", - "POST", - input, - ) - return response.result.data + return await this.trpcClient.request("cliSessions.fork", "POST", input) } /** * Delete a session */ async delete(input: DeleteSessionInput): Promise { - const client = TrpcClient.init() - const response = await client.request>( + return await this.trpcClient.request( "cliSessions.delete", "POST", input, ) - return response.result.data } /** @@ -222,8 +187,7 @@ export class SessionClient { blobType: "api_conversation_history" | "task_metadata" | "ui_messages" | "git_state", blobData: unknown, ): Promise<{ session_id: string; updated_at: string }> { - const client = TrpcClient.init() - const { endpoint, token } = client + const { endpoint, getToken } = this.trpcClient const url = new URL(`${endpoint}/api/upload-cli-session-blob`) url.searchParams.set("session_id", sessionId) @@ -233,18 +197,13 @@ export class SessionClient { method: "POST", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${token}`, + Authorization: `Bearer ${await getToken()}`, }, body: JSON.stringify(blobData), }) if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - throw new Error( - `Blob upload failed: ${response.status} ${response.statusText}${ - errorData.error ? ` - ${errorData.error}` : "" - }`, - ) + throw new Error(`uploadBlob failed: ${url.toString()} ${response.status}`) } return response.json() diff --git a/cli/src/services/session.ts b/src/shared/kilocode/cli-sessions/core/SessionManager.ts similarity index 63% rename from cli/src/services/session.ts rename to src/shared/kilocode/cli-sessions/core/SessionManager.ts index 0b3e0fb455f..7471a9aa93a 100644 --- a/cli/src/services/session.ts +++ b/src/shared/kilocode/cli-sessions/core/SessionManager.ts @@ -1,16 +1,16 @@ -import { readFileSync, writeFileSync, mkdtempSync, rmSync, existsSync } from "fs" -import { KiloCodePaths } from "../utils/paths" -import { SessionClient, SessionWithSignedUrls, ShareSessionOutput, CliSessionSharedState } from "./sessionClient" -import { logs } from "./logs.js" +import { readFileSync, writeFileSync, mkdirSync, mkdtempSync, rmSync } from "fs" import path from "path" -import { ensureDirSync } from "fs-extra" -import type { ExtensionService } from "./extension.js" -import type { ClineMessage, HistoryItem } from "@roo-code/types" import simpleGit from "simple-git" import { tmpdir } from "os" import { createHash } from "crypto" -import type { createStore } from "jotai" -import { taskResumedViaContinueOrSessionAtom } from "../state/atoms/extension.js" +import type { IPathProvider } from "../types/IPathProvider.js" +import type { ILogger } from "../types/ILogger.js" +import type { IExtensionMessenger } from "../types/IExtensionMessenger.js" +import { SessionClient } from "./SessionClient.js" +import { SessionWithSignedUrls, CliSessionSharedState } from "./SessionClient.js" +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, @@ -18,31 +18,46 @@ const defaultPaths = { taskMetadataPath: null as null | string, } -export class SessionService { +interface SessionCreatedMessage { + sessionId: string + timestamp: number + event: "session_created" +} + +export interface SessionManagerDependencies extends TrpcClientDependencies { + platform: string + pathProvider: IPathProvider + logger: ILogger + extensionMessenger: IExtensionMessenger + onSessionCreated?: (message: SessionCreatedMessage) => void + onSessionRestored?: () => void +} + +export class SessionManager { static readonly SYNC_INTERVAL = 1000 - private static instance: SessionService | null = null - static init(extensionService?: ExtensionService, store?: ReturnType, json?: boolean) { - if (extensionService && store && json !== undefined && !SessionService.instance) { - SessionService.instance = new SessionService(extensionService, store, json) + private static instance: SessionManager | null = null - logs.debug("Initialized SessionService", "SessionService") + static init(dependencies?: SessionManagerDependencies) { + if (!dependencies && !SessionManager.instance) { + throw new Error("SessionManager not initialized") } - const instance = SessionService.instance - - if (!instance) { - throw new Error("SessionService not initialized") + if (dependencies && !SessionManager.instance) { + SessionManager.instance = new SessionManager(dependencies) } + const instance = SessionManager.instance! + instance.startTimer() - return SessionService.instance! + return 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 @@ -51,13 +66,43 @@ export class SessionService { private lastSyncedBlobHashes = this.createDefaultBlobHashes() private isSyncing: boolean = false - private constructor( - private extensionService: ExtensionService, - private store: ReturnType, - private jsonMode: boolean, - ) {} + private readonly pathProvider: IPathProvider + private readonly logger: ILogger + private readonly extensionMessenger: IExtensionMessenger + private readonly sessionPersistenceManager: SessionPersistenceManager + public readonly sessionClient: SessionClient + private readonly onSessionCreated: (message: SessionCreatedMessage) => void + private readonly onSessionRestored: () => void + private readonly platform: string + + private constructor(dependencies: SessionManagerDependencies) { + this.pathProvider = dependencies.pathProvider + this.logger = dependencies.logger + this.extensionMessenger = dependencies.extensionMessenger + this.onSessionCreated = dependencies.onSessionCreated ?? (() => {}) + this.onSessionRestored = dependencies.onSessionRestored ?? (() => {}) + this.platform = dependencies.platform + + const trpcClient = new TrpcClient({ + getToken: dependencies.getToken, + }) + + this.sessionClient = new SessionClient(trpcClient) + this.sessionPersistenceManager = new SessionPersistenceManager(this.pathProvider) - setPath(key: keyof typeof defaultPaths, value: string) { + this.logger.debug("Initialized SessionManager", "SessionManager") + } + + private startTimer() { + if (!this.timer) { + this.timer = setInterval(() => { + this.syncSession() + }, SessionManager.SYNC_INTERVAL) + } + } + + setPath(taskId: string, key: keyof typeof defaultPaths, value: string) { + this.currentTaskId = taskId this.paths[key] = value const blobKey = this.pathKeyToBlobKey(key) @@ -67,113 +112,63 @@ export class SessionService { } } - setWorkspaceDirectory(dir: string): void { + setWorkspaceDirectory(dir: string) { this.workspaceDir = dir + this.sessionPersistenceManager.setWorkspaceDir(dir) } - private saveLastSessionId(sessionId: string): void { - if (!this.workspaceDir) { - logs.warn("Cannot save last session ID: workspace directory not set", "SessionService") - return - } - + async restoreLastSession() { try { - const lastSessionPath = KiloCodePaths.getLastSessionPath(this.workspaceDir) - const data = { - sessionId, - timestamp: Date.now(), - } - writeFileSync(lastSessionPath, JSON.stringify(data, null, 2)) - logs.debug("Saved last session ID", "SessionService", { sessionId, path: lastSessionPath }) - } catch (error) { - logs.warn("Failed to save last session ID", "SessionService", { - error: error instanceof Error ? error.message : String(error), - }) - } - } + const lastSession = this.sessionPersistenceManager.getLastSession() - private getLastSessionId(): string | null { - if (!this.workspaceDir) { - logs.warn("Cannot get last session ID: workspace directory not set", "SessionService") - return null - } - - try { - const lastSessionPath = KiloCodePaths.getLastSessionPath(this.workspaceDir) - if (!existsSync(lastSessionPath)) { - return null + if (!lastSession?.sessionId) { + this.logger.debug("No persisted session ID found", "SessionManager") + return false } - const content = readFileSync(lastSessionPath, "utf-8") - const data = JSON.parse(content) - - if (data.sessionId && typeof data.sessionId === "string") { - logs.debug("Retrieved last session ID", "SessionService", { sessionId: data.sessionId }) - return data.sessionId - } - - return null - } catch (error) { - logs.warn("Failed to read last session ID", "SessionService", { - error: error instanceof Error ? error.message : String(error), + this.logger.info("Found persisted session ID, attempting to restore", "SessionManager", { + sessionId: lastSession.sessionId, }) - return null - } - } - - async restoreLastSession(): Promise { - const lastSessionId = this.getLastSessionId() - - if (!lastSessionId) { - logs.debug("No persisted session ID found", "SessionService") - return false - } - logs.info("Found persisted session ID, attempting to restore", "SessionService", { sessionId: lastSessionId }) + await this.restoreSession(lastSession.sessionId, true) - try { - await this.restoreSession(lastSessionId, true) - - logs.info("Successfully restored persisted session", "SessionService", { sessionId: lastSessionId }) + this.logger.info("Successfully restored persisted session", "SessionManager", { + sessionId: lastSession.sessionId, + }) return true } catch (error) { - logs.warn("Failed to restore persisted session", "SessionService", { + this.logger.warn("Failed to restore persisted session", "SessionManager", { error: error instanceof Error ? error.message : String(error), - sessionId: lastSessionId, }) + return false } } async restoreSession(sessionId: string, rethrowError = false) { try { - logs.info("Restoring session", "SessionService", { sessionId }) + this.logger.info("Restoring session", "SessionManager", { sessionId }) - // Set sessionId immediately to prevent race condition with syncSession timer - // If restoration fails, we'll reset it in the catch block this.sessionId = sessionId this.resetBlobHashes() this.isSyncing = true - const sessionClient = SessionClient.getInstance() - const session = (await sessionClient.get({ + const session = (await this.sessionClient.get({ session_id: sessionId, include_blob_urls: true, })) as SessionWithSignedUrls if (!session) { - logs.error("Failed to obtain session", "SessionService", { sessionId }) - + this.logger.error("Failed to obtain session", "SessionManager", { sessionId }) throw new Error("Failed to obtain session") } this.sessionTitle = session.title - const sessionDirectoryPath = path.join(KiloCodePaths.getTasksDir(), sessionId) + const sessionDirectoryPath = path.join(this.pathProvider.getTasksDir(), sessionId) - ensureDirSync(sessionDirectoryPath) + mkdirSync(sessionDirectoryPath, { recursive: true }) - // Fetch and write each blob type from signed URLs const blobUrlFields = [ "api_conversation_history_blob_url", "ui_messages_blob_url", @@ -185,7 +180,7 @@ export class SessionService { .filter((blobUrlField) => { const signedUrl = session[blobUrlField] if (!signedUrl) { - logs.debug(`No signed URL for ${blobUrlField}`, "SessionService") + this.logger.debug(`No signed URL for ${blobUrlField}`, "SessionManager") return false } return true @@ -222,7 +217,6 @@ export class SessionService { } if (filename === "ui_messages") { - // eliminate checkpoints for now fileContent = (fileContent as ClineMessage[]).filter( (message) => message.say !== "checkpoint_saved", ) @@ -232,9 +226,9 @@ export class SessionService { writeFileSync(fullPath, JSON.stringify(fileContent, null, 2)) - logs.debug(`Wrote blob to file`, "SessionService", { fullPath }) + this.logger.debug(`Wrote blob to file`, "SessionManager", { fullPath }) } else { - logs.error(`Failed to process blob`, "SessionService", { + this.logger.error(`Failed to process blob`, "SessionManager", { filename, error: fetchResult.error, }) @@ -252,32 +246,30 @@ export class SessionService { totalCost: 0, } - // Send message to register the task in extension history - await this.extensionService.sendWebviewMessage({ + await this.extensionMessenger.sendWebviewMessage({ type: "addTaskToHistory", historyItem, }) - logs.info("Task registered with extension", "SessionService", { + this.logger.info("Task registered with extension", "SessionManager", { sessionId, taskId: historyItem.id, }) - // Automatically switch to the restored task - await this.extensionService.sendWebviewMessage({ + await this.extensionMessenger.sendWebviewMessage({ type: "showTaskWithId", text: sessionId, }) - logs.info("Switched to restored task", "SessionService", { sessionId }) + this.logger.info("Switched to restored task", "SessionManager", { sessionId }) - this.saveLastSessionId(sessionId) + this.sessionPersistenceManager.setLastSession(this.sessionId) - this.store.set(taskResumedViaContinueOrSessionAtom, true) + this.onSessionRestored() - logs.debug("Marked task as resumed after session restoration", "SessionService", { sessionId }) + this.logger.debug("Marked task as resumed after session restoration", "SessionManager", { sessionId }) } catch (error) { - logs.error("Failed to restore session", "SessionService", { + this.logger.error("Failed to restore session", "SessionManager", { error: error instanceof Error ? error.message : String(error), sessionId, }) @@ -295,21 +287,19 @@ export class SessionService { } } - async shareSession(): Promise { + async shareSession() { const sessionId = this.sessionId if (!sessionId) { throw new Error("No active session") } - const sessionClient = SessionClient.getInstance() - - return await sessionClient.share({ + return await this.sessionClient.share({ session_id: sessionId, shared_state: CliSessionSharedState.Public, }) } - async renameSession(newTitle: string): Promise { + async renameSession(newTitle: string) { const sessionId = this.sessionId if (!sessionId) { throw new Error("No active session") @@ -320,30 +310,30 @@ export class SessionService { throw new Error("Session title cannot be empty") } - const sessionClient = SessionClient.getInstance() - - await sessionClient.update({ + await this.sessionClient.update({ session_id: sessionId, title: trimmedTitle, }) this.sessionTitle = trimmedTitle - logs.info("Session renamed successfully", "SessionService", { + this.logger.info("Session renamed successfully", "SessionManager", { sessionId, newTitle: trimmedTitle, }) } - async forkSession(shareId: string, rethrowError = false) { - const sessionClient = SessionClient.getInstance() - const { session_id } = await sessionClient.fork({ share_id: shareId }) + async forkSession(shareOrSessionId: string, rethrowError = false) { + const { session_id } = await this.sessionClient.fork({ + share_or_session_id: shareOrSessionId, + created_on_platform: this.platform, + }) await this.restoreSession(session_id, rethrowError) } async destroy() { - logs.debug("Destroying SessionService", "SessionService", { + this.logger.debug("Destroying SessionManager", "SessionManager", { sessionId: this.sessionId, isSyncing: this.isSyncing, }) @@ -366,15 +356,7 @@ export class SessionService { this.sessionTitle = null this.isSyncing = false - logs.debug("SessionService flushed", "SessionService") - } - - private startTimer() { - if (!this.timer) { - this.timer = setInterval(() => { - this.syncSession() - }, SessionService.SYNC_INTERVAL) - } + this.logger.debug("SessionManager flushed", "SessionManager") } private async syncSession(force = false) { @@ -403,9 +385,7 @@ export class SessionService { return } - const sessionClient = SessionClient.getInstance() - - const basePayload: Omit[0], "created_on_platform"> = {} + const basePayload: Omit[0], "created_on_platform"> = {} let gitInfo: Awaited> | null = null @@ -416,28 +396,36 @@ export class SessionService { basePayload.git_url = gitInfo.repoUrl } } catch (error) { - logs.debug("Could not get git state", "SessionService", { + this.logger.debug("Could not get git state", "SessionManager", { error: error instanceof Error ? error.message : String(error), }) } + if (!this.sessionId && this.currentTaskId) { + const existingSessionId = this.sessionPersistenceManager.getSessionForTask(this.currentTaskId) + + if (existingSessionId) { + this.sessionId = existingSessionId + } + } + if (this.sessionId) { const gitUrlChanged = gitInfo?.repoUrl && gitInfo.repoUrl !== this.sessionGitUrl if (gitUrlChanged) { - logs.debug("Updating existing session", "SessionService", { sessionId: this.sessionId }) + this.logger.debug("Updating existing session", "SessionManager", { sessionId: this.sessionId }) - await sessionClient.update({ + await this.sessionClient.update({ session_id: this.sessionId, ...basePayload, }) this.sessionGitUrl = gitInfo?.repoUrl || null - logs.debug("Session updated successfully", "SessionService", { sessionId: this.sessionId }) + this.logger.debug("Session updated successfully", "SessionManager", { sessionId: this.sessionId }) } } else { - logs.debug("Creating new session", "SessionService") + this.logger.debug("Creating new session", "SessionManager") if (rawPayload.uiMessagesPath) { const title = this.getFirstMessageText(rawPayload.uiMessagesPath as ClineMessage[], true) @@ -447,41 +435,41 @@ export class SessionService { } } - const session = await sessionClient.create({ + const session = await this.sessionClient.create({ ...basePayload, - created_on_platform: process.env.KILO_PLATFORM || "cli", + created_on_platform: process.env.KILO_PLATFORM || this.platform, }) this.sessionId = session.session_id this.sessionGitUrl = gitInfo?.repoUrl || null - logs.info("Session created successfully", "SessionService", { sessionId: this.sessionId }) + this.logger.info("Session created successfully", "SessionManager", { sessionId: this.sessionId }) - this.saveLastSessionId(this.sessionId) + this.sessionPersistenceManager.setLastSession(this.sessionId) - if (this.jsonMode) { - console.log( - JSON.stringify({ - timestamp: Date.now(), - event: "session_created", - sessionId: this.sessionId, - }), - ) - } + this.onSessionCreated({ + timestamp: Date.now(), + event: "session_created", + sessionId: this.sessionId, + }) + } + + if (this.currentTaskId) { + this.sessionPersistenceManager.setSessionForTask(this.currentTaskId, this.sessionId) } const blobUploads: Array> = [] if (rawPayload.apiConversationHistoryPath && this.hasBlobChanged("apiConversationHistory")) { blobUploads.push( - sessionClient + this.sessionClient .uploadBlob(this.sessionId, "api_conversation_history", rawPayload.apiConversationHistoryPath) .then(() => { this.markBlobSynced("apiConversationHistory") - logs.debug("Uploaded api_conversation_history blob", "SessionService") + this.logger.debug("Uploaded api_conversation_history blob", "SessionManager") }) .catch((error) => { - logs.error("Failed to upload api_conversation_history blob", "SessionService", { + this.logger.error("Failed to upload api_conversation_history blob", "SessionManager", { error: error instanceof Error ? error.message : String(error), }) }), @@ -490,14 +478,14 @@ export class SessionService { if (rawPayload.taskMetadataPath && this.hasBlobChanged("taskMetadata")) { blobUploads.push( - sessionClient + this.sessionClient .uploadBlob(this.sessionId, "task_metadata", rawPayload.taskMetadataPath) .then(() => { this.markBlobSynced("taskMetadata") - logs.debug("Uploaded task_metadata blob", "SessionService") + this.logger.debug("Uploaded task_metadata blob", "SessionManager") }) .catch((error) => { - logs.error("Failed to upload task_metadata blob", "SessionService", { + this.logger.error("Failed to upload task_metadata blob", "SessionManager", { error: error instanceof Error ? error.message : String(error), }) }), @@ -506,14 +494,14 @@ export class SessionService { if (rawPayload.uiMessagesPath && this.hasBlobChanged("uiMessages")) { blobUploads.push( - sessionClient + this.sessionClient .uploadBlob(this.sessionId, "ui_messages", rawPayload.uiMessagesPath) .then(() => { this.markBlobSynced("uiMessages") - logs.debug("Uploaded ui_messages blob", "SessionService") + this.logger.debug("Uploaded ui_messages blob", "SessionManager") }) .catch((error) => { - logs.error("Failed to upload ui_messages blob", "SessionService", { + this.logger.error("Failed to upload ui_messages blob", "SessionManager", { error: error instanceof Error ? error.message : String(error), }) }), @@ -534,14 +522,14 @@ export class SessionService { if (this.hasBlobChanged("gitState")) { blobUploads.push( - sessionClient + this.sessionClient .uploadBlob(this.sessionId, "git_state", gitStateData) .then(() => { this.markBlobSynced("gitState") - logs.debug("Uploaded git_state blob", "SessionService") + this.logger.debug("Uploaded git_state blob", "SessionManager") }) .catch((error) => { - logs.error("Failed to upload git_state blob", "SessionService", { + this.logger.error("Failed to upload git_state blob", "SessionManager", { error: error instanceof Error ? error.message : String(error), }) }), @@ -553,7 +541,6 @@ export class SessionService { await Promise.all(blobUploads) if (!this.sessionTitle && rawPayload.uiMessagesPath) { - // Intentionally not awaiting as we don't want this to block this.generateTitle(rawPayload.uiMessagesPath as ClineMessage[]) .then((generatedTitle) => { if (generatedTitle) { @@ -563,13 +550,13 @@ export class SessionService { return null }) .catch((error) => { - logs.warn("Failed to generate session title", "SessionService", { + this.logger.warn("Failed to generate session title", "SessionManager", { error: error instanceof Error ? error.message : String(error), }) }) } } catch (error) { - logs.error("Failed to sync session", "SessionService", { + this.logger.error("Failed to sync session", "SessionManager", { error: error instanceof Error ? error.message : String(error), sessionId: this.sessionId, hasApiHistory: !!this.paths.apiConversationHistoryPath, @@ -611,9 +598,9 @@ export class SessionService { return contents } - private async fetchBlobFromSignedUrl(url: string, urlType: string): Promise { + private async fetchBlobFromSignedUrl(url: string, urlType: string) { try { - logs.debug(`Fetching blob from signed URL`, "SessionService", { url, urlType }) + this.logger.debug(`Fetching blob from signed URL`, "SessionManager", { url, urlType }) const response = await fetch(url) @@ -623,11 +610,11 @@ export class SessionService { const data = await response.json() - logs.debug(`Successfully fetched blob`, "SessionService", { url, urlType }) + this.logger.debug(`Successfully fetched blob`, "SessionManager", { url, urlType }) return data } catch (error) { - logs.error(`Failed to fetch blob from signed URL`, "SessionService", { + this.logger.error(`Failed to fetch blob from signed URL`, "SessionManager", { url, urlType, error: error instanceof Error ? error.message : String(error), @@ -636,7 +623,7 @@ export class SessionService { } } - private pathKeyToBlobKey(pathKey: keyof typeof defaultPaths): keyof typeof this.blobHashes | null { + private pathKeyToBlobKey(pathKey: keyof typeof defaultPaths) { switch (pathKey) { case "apiConversationHistoryPath": return "apiConversationHistory" @@ -653,11 +640,11 @@ export class SessionService { this.blobHashes[blobKey] = crypto.randomUUID() } - private hasBlobChanged(blobKey: keyof typeof this.blobHashes): boolean { + private hasBlobChanged(blobKey: keyof typeof this.blobHashes) { return this.blobHashes[blobKey] !== this.lastSyncedBlobHashes[blobKey] } - private hasAnyBlobChanged(): boolean { + private hasAnyBlobChanged() { return ( this.hasBlobChanged("apiConversationHistory") || this.hasBlobChanged("uiMessages") || @@ -672,7 +659,7 @@ export class SessionService { private hashGitState( gitState: Pick>>, "head" | "patch" | "branch">, - ): string { + ) { return createHash("sha256").update(JSON.stringify(gitState)).digest("hex") } @@ -741,7 +728,7 @@ export class SessionService { } } - private async executeGitRestore(gitState: { head: string; patch: string; branch: string }): Promise { + private async executeGitRestore(gitState: { head: string; patch: string; branch: string }) { try { const cwd = this.workspaceDir || process.cwd() const git = simpleGit(cwd) @@ -759,12 +746,12 @@ export class SessionService { if (stashCountAfter > stashCountBefore) { shouldPop = true - logs.debug(`Stashed current work`, "SessionService") + this.logger.debug(`Stashed current work`, "SessionManager") } else { - logs.debug(`No changes to stash`, "SessionService") + this.logger.debug(`No changes to stash`, "SessionManager") } } catch (error) { - logs.warn(`Failed to stash current work`, "SessionService", { + this.logger.warn(`Failed to stash current work`, "SessionManager", { error: error instanceof Error ? error.message : String(error), }) } @@ -773,7 +760,7 @@ export class SessionService { const currentHead = await git.revparse(["HEAD"]) if (currentHead.trim() === gitState.head.trim()) { - logs.debug(`Already at target commit, skipping checkout`, "SessionService", { + this.logger.debug(`Already at target commit, skipping checkout`, "SessionManager", { head: gitState.head.substring(0, 8), }) } else { @@ -784,36 +771,44 @@ export class SessionService { if (branchCommit.trim() === gitState.head.trim()) { await git.checkout(gitState.branch) - logs.debug(`Checked out to branch`, "SessionService", { + this.logger.debug(`Checked out to branch`, "SessionManager", { branch: gitState.branch, head: gitState.head.substring(0, 8), }) } else { await git.checkout(gitState.head) - logs.debug(`Branch moved, checked out to commit (detached HEAD)`, "SessionService", { - branch: gitState.branch, - head: gitState.head.substring(0, 8), - }) + this.logger.debug( + `Branch moved, checked out to commit (detached HEAD)`, + "SessionManager", + { + branch: gitState.branch, + head: gitState.head.substring(0, 8), + }, + ) } } catch { await git.checkout(gitState.head) - logs.debug(`Branch not found, checked out to commit (detached HEAD)`, "SessionService", { - branch: gitState.branch, - head: gitState.head.substring(0, 8), - }) + this.logger.debug( + `Branch not found, checked out to commit (detached HEAD)`, + "SessionManager", + { + branch: gitState.branch, + head: gitState.head.substring(0, 8), + }, + ) } } else { await git.checkout(gitState.head) - logs.debug(`No branch info, checked out to commit (detached HEAD)`, "SessionService", { + this.logger.debug(`No branch info, checked out to commit (detached HEAD)`, "SessionManager", { head: gitState.head.substring(0, 8), }) } } } catch (error) { - logs.warn(`Failed to checkout`, "SessionService", { + this.logger.warn(`Failed to checkout`, "SessionManager", { branch: gitState.branch, head: gitState.head.substring(0, 8), error: error instanceof Error ? error.message : String(error), @@ -829,7 +824,7 @@ export class SessionService { await git.applyPatch(patchFile) - logs.debug(`Applied patch`, "SessionService", { + this.logger.debug(`Applied patch`, "SessionManager", { patchSize: gitState.patch.length, }) } finally { @@ -840,7 +835,7 @@ export class SessionService { } } } catch (error) { - logs.warn(`Failed to apply patch`, "SessionService", { + this.logger.warn(`Failed to apply patch`, "SessionManager", { error: error instanceof Error ? error.message : String(error), }) } @@ -849,25 +844,25 @@ export class SessionService { if (shouldPop) { await git.stash(["pop"]) - logs.debug(`Popped stash`, "SessionService") + this.logger.debug(`Popped stash`, "SessionManager") } } catch (error) { - logs.warn(`Failed to pop stash`, "SessionService", { + this.logger.warn(`Failed to pop stash`, "SessionManager", { error: error instanceof Error ? error.message : String(error), }) } - logs.info(`Git state restored successfully`, "SessionService", { + this.logger.info(`Git state restoration finished`, "SessionManager", { head: gitState.head.substring(0, 8), }) } catch (error) { - logs.error(`Failed to restore git state`, "SessionService", { + this.logger.error(`Failed to restore git state`, "SessionManager", { error: error instanceof Error ? error.message : String(error), }) } } - getFirstMessageText(uiMessages: ClineMessage[], truncate = false): string | null { + getFirstMessageText(uiMessages: ClineMessage[], truncate = false) { if (uiMessages.length === 0) { return null } @@ -892,7 +887,7 @@ export class SessionService { return rawText } - async generateTitle(uiMessages: ClineMessage[]): Promise { + async generateTitle(uiMessages: ClineMessage[]) { const rawText = this.getFirstMessageText(uiMessages) if (!rawText) { @@ -911,26 +906,22 @@ ${rawText} Summary:` - const summary = await this.extensionService.requestSingleCompletion(prompt, 30000) + const summary = await this.extensionMessenger.requestSingleCompletion(prompt, 30000) - // Clean up the response and ensure it's within 140 characters let cleanedSummary = summary.trim() - // Remove any quotes that might have been added cleanedSummary = cleanedSummary.replace(/^["']|["']$/g, "") - // Truncate if still too long if (cleanedSummary.length > 140) { cleanedSummary = cleanedSummary.substring(0, 137) + "..." } return cleanedSummary || rawText.substring(0, 137) + "..." } catch (error) { - logs.warn("Failed to generate title using LLM, falling back to truncation", "SessionService", { + this.logger.warn("Failed to generate title using LLM, falling back to truncation", "SessionManager", { error: error instanceof Error ? error.message : String(error), }) - // Fallback to simple truncation return rawText.substring(0, 137) + "..." } } diff --git a/src/shared/kilocode/cli-sessions/core/TrpcClient.ts b/src/shared/kilocode/cli-sessions/core/TrpcClient.ts new file mode 100644 index 00000000000..e0084ae0f4f --- /dev/null +++ b/src/shared/kilocode/cli-sessions/core/TrpcClient.ts @@ -0,0 +1,62 @@ +import { getApiUrl } from "@roo-code/types" + +type HttpMethod = "GET" | "POST" + +/** + * Generic tRPC response wrapper + */ +export type TrpcResponse = { result: { data: T } } + +export interface TrpcClientDependencies { + getToken: () => Promise +} + +/** + * Client for making tRPC requests to the KiloCode API. + * Handles authentication and request formatting. + */ +export class TrpcClient { + public readonly endpoint: string + + public readonly getToken: () => Promise + + constructor(dependencies: TrpcClientDependencies) { + this.endpoint = getApiUrl() + this.getToken = dependencies.getToken + } + + /** + * Make a tRPC request to the API. + * @param procedure The tRPC procedure name (e.g., "cliSessions.get") + * @param method The HTTP method to use + * @param input Optional input data for the request + * @returns The unwrapped response data + */ + async request( + procedure: string, + method: HttpMethod, + input?: TInput, + ): Promise { + const url = new URL(`${this.endpoint}/api/trpc/${procedure}`) + + if (method === "GET" && input) { + url.searchParams.set("input", JSON.stringify(input)) + } + + const response = await fetch(url, { + method, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${await this.getToken()}`, + }, + ...(method === "POST" && input && { body: JSON.stringify(input) }), + }) + + if (!response.ok) { + throw new Error(`tRPC request failed: ${response.status}`) + } + + const trpcResponse = (await response.json()) as TrpcResponse + return trpcResponse.result.data + } +} diff --git a/src/shared/kilocode/cli-sessions/core/__tests__/SessionManager.test.ts b/src/shared/kilocode/cli-sessions/core/__tests__/SessionManager.test.ts new file mode 100644 index 00000000000..ab705bd80a9 --- /dev/null +++ b/src/shared/kilocode/cli-sessions/core/__tests__/SessionManager.test.ts @@ -0,0 +1,1016 @@ +import { SessionManager, SessionManagerDependencies } from "../SessionManager" +import { SessionClient, CliSessionSharedState, SessionWithSignedUrls } from "../SessionClient" +import { SessionPersistenceManager } from "../../utils/SessionPersistenceManager" +import type { IPathProvider } from "../../types/IPathProvider" +import type { ILogger } from "../../types/ILogger" +import type { IExtensionMessenger } from "../../types/IExtensionMessenger" +import type { ClineMessage } from "@roo-code/types" +import { readFileSync, writeFileSync } from "fs" + +vi.mock("fs", () => ({ + readFileSync: vi.fn(), + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), + mkdtempSync: vi.fn(), + rmSync: vi.fn(), + existsSync: vi.fn(), +})) + +vi.mock("simple-git", () => ({ + default: 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("../TrpcClient", () => ({ + TrpcClient: vi.fn().mockImplementation(() => ({ + request: vi.fn(), + endpoint: "https://api.example.com", + getToken: vi.fn().mockResolvedValue("test-token"), + })), +})) + +vi.mock("../../utils/SessionPersistenceManager", () => ({ + SessionPersistenceManager: vi.fn().mockImplementation(() => ({ + setWorkspaceDir: vi.fn(), + getLastSession: vi.fn(), + setLastSession: vi.fn(), + getSessionForTask: vi.fn(), + setSessionForTask: vi.fn(), + })), +})) + +const mockFetch = vi.fn() +global.fetch = mockFetch + +function resetSessionManagerInstance(): void { + ;(SessionManager as unknown as { instance: SessionManager | null }).instance = null +} + +describe("SessionManager", () => { + let mockPathProvider: IPathProvider + let mockLogger: ILogger + let mockExtensionMessenger: IExtensionMessenger + let mockDependencies: SessionManagerDependencies + let mockSessionClient: { + get: ReturnType + create: ReturnType + update: ReturnType + share: ReturnType + fork: ReturnType + uploadBlob: ReturnType + } + let mockSessionPersistenceManager: { + setWorkspaceDir: ReturnType + getLastSession: ReturnType + setLastSession: ReturnType + getSessionForTask: ReturnType + setSessionForTask: ReturnType + } + + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + + resetSessionManagerInstance() + + mockPathProvider = { + getTasksDir: vi.fn().mockReturnValue("/home/user/.kilocode/tasks"), + getSessionFilePath: vi + .fn() + .mockImplementation((workspaceDir: string) => `${workspaceDir}/.kilocode/session.json`), + } + + mockLogger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } + + mockExtensionMessenger = { + sendWebviewMessage: vi.fn().mockResolvedValue(undefined), + requestSingleCompletion: vi.fn().mockResolvedValue("Generated Title"), + } + + mockDependencies = { + platform: "vscode", + pathProvider: mockPathProvider, + logger: mockLogger, + extensionMessenger: mockExtensionMessenger, + getToken: vi.fn().mockResolvedValue("test-token"), + onSessionCreated: vi.fn(), + onSessionRestored: vi.fn(), + } + + mockSessionClient = { + get: vi.fn(), + create: vi.fn(), + update: vi.fn(), + share: vi.fn(), + fork: vi.fn(), + uploadBlob: vi.fn(), + } + + mockSessionPersistenceManager = { + setWorkspaceDir: vi.fn(), + getLastSession: vi.fn(), + setLastSession: vi.fn(), + getSessionForTask: vi.fn(), + setSessionForTask: vi.fn(), + } + + vi.mocked(SessionClient).mockImplementation(() => mockSessionClient as unknown as SessionClient) + vi.mocked(SessionPersistenceManager).mockImplementation( + () => mockSessionPersistenceManager as unknown as SessionPersistenceManager, + ) + }) + + afterEach(() => { + vi.useRealTimers() + resetSessionManagerInstance() + }) + + describe("init", () => { + it("should throw error when initialized without dependencies and no instance exists", () => { + expect(() => SessionManager.init()).toThrow("SessionManager not initialized") + }) + + it("should create instance with dependencies", () => { + const manager = SessionManager.init(mockDependencies) + + expect(manager).toBeInstanceOf(SessionManager) + expect(mockLogger.debug).toHaveBeenCalledWith("Initialized SessionManager", "SessionManager") + }) + + it("should return existing instance when already initialized", () => { + const manager1 = SessionManager.init(mockDependencies) + const manager2 = SessionManager.init() + + expect(manager1).toBe(manager2) + }) + + it("should start timer on init", () => { + SessionManager.init(mockDependencies) + + expect(vi.getTimerCount()).toBe(1) + }) + }) + + describe("setPath", () => { + it("should set path for task", () => { + const manager = SessionManager.init(mockDependencies) + + manager.setPath("task-123", "apiConversationHistoryPath", "/path/to/history.json") + + expect(manager["currentTaskId"]).toBe("task-123") + expect(manager["paths"].apiConversationHistoryPath).toBe("/path/to/history.json") + }) + + it("should update blob hash when setting path", () => { + const manager = SessionManager.init(mockDependencies) + const initialHash = manager["blobHashes"].apiConversationHistory + + manager.setPath("task-123", "apiConversationHistoryPath", "/path/to/history.json") + + expect(manager["blobHashes"].apiConversationHistory).not.toBe(initialHash) + }) + }) + + describe("setWorkspaceDirectory", () => { + it("should set workspace directory", () => { + const manager = SessionManager.init(mockDependencies) + + manager.setWorkspaceDirectory("/workspace") + + expect(manager["workspaceDir"]).toBe("/workspace") + expect(mockSessionPersistenceManager.setWorkspaceDir).toHaveBeenCalledWith("/workspace") + }) + }) + + describe("restoreLastSession", () => { + it("should return false when no persisted session exists", async () => { + mockSessionPersistenceManager.getLastSession.mockReturnValue(undefined) + const manager = SessionManager.init(mockDependencies) + + const result = await manager.restoreLastSession() + + expect(result).toBe(false) + expect(mockLogger.debug).toHaveBeenCalledWith("No persisted session ID found", "SessionManager") + }) + + it("should return false when getLastSession returns null sessionId", async () => { + mockSessionPersistenceManager.getLastSession.mockReturnValue({ sessionId: null, timestamp: 123 }) + const manager = SessionManager.init(mockDependencies) + + const result = await manager.restoreLastSession() + + expect(result).toBe(false) + }) + + it("should restore session when persisted session exists", async () => { + mockSessionPersistenceManager.getLastSession.mockReturnValue({ + sessionId: "session-123", + timestamp: 123456, + }) + mockSessionClient.get.mockResolvedValue({ + session_id: "session-123", + title: "Test Session", + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + api_conversation_history_blob_url: null, + task_metadata_blob_url: null, + ui_messages_blob_url: null, + git_state_blob_url: null, + }) + const manager = SessionManager.init(mockDependencies) + + const result = await manager.restoreLastSession() + + expect(result).toBe(true) + expect(mockLogger.info).toHaveBeenCalledWith( + "Found persisted session ID, attempting to restore", + "SessionManager", + { sessionId: "session-123" }, + ) + }) + + it("should return false and log warning when restore fails", async () => { + mockSessionPersistenceManager.getLastSession.mockReturnValue({ + sessionId: "session-123", + timestamp: 123456, + }) + mockSessionClient.get.mockRejectedValue(new Error("Network error")) + const manager = SessionManager.init(mockDependencies) + + const result = await manager.restoreLastSession() + + expect(result).toBe(false) + expect(mockLogger.warn).toHaveBeenCalledWith( + "Failed to restore persisted session", + "SessionManager", + expect.objectContaining({ error: "Network error" }), + ) + }) + }) + + describe("restoreSession", () => { + it("should restore session successfully", async () => { + const sessionData: SessionWithSignedUrls = { + session_id: "session-123", + title: "Test Session", + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + api_conversation_history_blob_url: null, + task_metadata_blob_url: null, + ui_messages_blob_url: null, + git_state_blob_url: null, + } + mockSessionClient.get.mockResolvedValue(sessionData) + const manager = SessionManager.init(mockDependencies) + + await manager.restoreSession("session-123") + + expect(manager.sessionId).toBe("session-123") + expect(mockLogger.info).toHaveBeenCalledWith("Restoring session", "SessionManager", { + sessionId: "session-123", + }) + }) + + it("should throw error when session is not found", async () => { + mockSessionClient.get.mockResolvedValue(null) + const manager = SessionManager.init(mockDependencies) + + await expect(manager.restoreSession("session-123", true)).rejects.toThrow("Failed to obtain session") + }) + + it("should fetch blobs from signed URLs", async () => { + const sessionData: SessionWithSignedUrls = { + session_id: "session-123", + title: "Test Session", + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + api_conversation_history_blob_url: "https://storage.example.com/api-history", + task_metadata_blob_url: null, + ui_messages_blob_url: null, + git_state_blob_url: null, + } + mockSessionClient.get.mockResolvedValue(sessionData) + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve([{ role: "user", text: "Hello" }]), + }) + const manager = SessionManager.init(mockDependencies) + + await manager.restoreSession("session-123") + + expect(mockFetch).toHaveBeenCalledWith("https://storage.example.com/api-history") + }) + + it("should filter checkpoint_saved messages from ui_messages", async () => { + const sessionData: SessionWithSignedUrls = { + session_id: "session-123", + title: "Test Session", + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + api_conversation_history_blob_url: null, + task_metadata_blob_url: null, + ui_messages_blob_url: "https://storage.example.com/ui-messages", + git_state_blob_url: null, + } + mockSessionClient.get.mockResolvedValue(sessionData) + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve([ + { say: "text", text: "Hello" }, + { say: "checkpoint_saved", text: "Checkpoint" }, + { say: "text", text: "World" }, + ]), + }) + const manager = SessionManager.init(mockDependencies) + + await manager.restoreSession("session-123") + + expect(writeFileSync).toHaveBeenCalledWith( + expect.stringContaining("ui_messages.json"), + expect.not.stringContaining("checkpoint_saved"), + ) + }) + + it("should call onSessionRestored callback", async () => { + const sessionData: SessionWithSignedUrls = { + session_id: "session-123", + title: "Test Session", + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + api_conversation_history_blob_url: null, + task_metadata_blob_url: null, + ui_messages_blob_url: null, + git_state_blob_url: null, + } + mockSessionClient.get.mockResolvedValue(sessionData) + const manager = SessionManager.init(mockDependencies) + + await manager.restoreSession("session-123") + + expect(mockDependencies.onSessionRestored).toHaveBeenCalled() + }) + + it("should reset state on failure when rethrowError is false", async () => { + mockSessionClient.get.mockRejectedValue(new Error("Network error")) + const manager = SessionManager.init(mockDependencies) + manager.sessionId = "old-session" + + await manager.restoreSession("session-123", false) + + expect(manager.sessionId).toBeNull() + expect(mockLogger.error).toHaveBeenCalled() + }) + }) + + describe("shareSession", () => { + it("should throw error when no active session", async () => { + const manager = SessionManager.init(mockDependencies) + + await expect(manager.shareSession()).rejects.toThrow("No active session") + }) + + it("should share session successfully", async () => { + mockSessionClient.share.mockResolvedValue({ share_id: "share-123", session_id: "session-123" }) + const manager = SessionManager.init(mockDependencies) + manager.sessionId = "session-123" + + const result = await manager.shareSession() + + expect(result).toEqual({ share_id: "share-123", session_id: "session-123" }) + expect(mockSessionClient.share).toHaveBeenCalledWith({ + session_id: "session-123", + shared_state: CliSessionSharedState.Public, + }) + }) + }) + + describe("renameSession", () => { + it("should throw error when no active session", async () => { + const manager = SessionManager.init(mockDependencies) + + await expect(manager.renameSession("New Title")).rejects.toThrow("No active session") + }) + + it("should throw error when title is empty", async () => { + const manager = SessionManager.init(mockDependencies) + manager.sessionId = "session-123" + + await expect(manager.renameSession(" ")).rejects.toThrow("Session title cannot be empty") + }) + + it("should rename session successfully", async () => { + mockSessionClient.update.mockResolvedValue({ + session_id: "session-123", + title: "New Title", + updated_at: "2024-01-01T00:00:00Z", + }) + const manager = SessionManager.init(mockDependencies) + manager.sessionId = "session-123" + + await manager.renameSession(" New Title ") + + expect(mockSessionClient.update).toHaveBeenCalledWith({ + session_id: "session-123", + title: "New Title", + }) + expect(manager["sessionTitle"]).toBe("New Title") + }) + }) + + describe("forkSession", () => { + it("should fork and restore session", async () => { + mockSessionClient.fork.mockResolvedValue({ session_id: "forked-session-123" }) + mockSessionClient.get.mockResolvedValue({ + session_id: "forked-session-123", + title: "Forked Session", + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + api_conversation_history_blob_url: null, + task_metadata_blob_url: null, + ui_messages_blob_url: null, + git_state_blob_url: null, + }) + const manager = SessionManager.init(mockDependencies) + + await manager.forkSession("share-123") + + expect(mockSessionClient.fork).toHaveBeenCalledWith({ + share_or_session_id: "share-123", + created_on_platform: "vscode", + }) + expect(manager.sessionId).toBe("forked-session-123") + }) + }) + + describe("destroy", () => { + it("should clear timer and reset state", async () => { + const manager = SessionManager.init(mockDependencies) + manager.sessionId = "session-123" + manager["sessionTitle"] = "Test Title" + + await manager.destroy() + + expect(vi.getTimerCount()).toBe(0) + expect(manager.sessionId).toBeNull() + expect(manager["sessionTitle"]).toBeNull() + }) + + it("should wait for syncing to complete", async () => { + const manager = SessionManager.init(mockDependencies) + manager.sessionId = "session-123" + manager["isSyncing"] = true + + const destroyPromise = manager.destroy() + vi.advanceTimersByTime(2000) + await destroyPromise + + expect(mockLogger.debug).toHaveBeenCalledWith("SessionManager flushed", "SessionManager") + }) + }) + + describe("getFirstMessageText", () => { + it("should return null for empty messages array", () => { + const manager = SessionManager.init(mockDependencies) + + const result = manager.getFirstMessageText([]) + + expect(result).toBeNull() + }) + + it("should return null when no message has text", () => { + const manager = SessionManager.init(mockDependencies) + const messages: ClineMessage[] = [ + { ts: 123, type: "say", say: "text" }, + { ts: 124, type: "say", say: "text" }, + ] + + const result = manager.getFirstMessageText(messages) + + expect(result).toBeNull() + }) + + it("should return first message with text", () => { + const manager = SessionManager.init(mockDependencies) + const messages: ClineMessage[] = [ + { ts: 123, type: "say", say: "text" }, + { ts: 124, type: "say", say: "text", text: "Hello World" }, + { ts: 125, type: "say", say: "text", text: "Goodbye" }, + ] + + const result = manager.getFirstMessageText(messages) + + expect(result).toBe("Hello World") + }) + + it("should normalize whitespace", () => { + const manager = SessionManager.init(mockDependencies) + const messages: ClineMessage[] = [{ ts: 123, type: "say", say: "text", text: " Hello \n World " }] + + const result = manager.getFirstMessageText(messages) + + expect(result).toBe("Hello World") + }) + + it("should truncate long text when truncate is true", () => { + const manager = SessionManager.init(mockDependencies) + const longText = "A".repeat(200) + const messages: ClineMessage[] = [{ ts: 123, type: "say", say: "text", text: longText }] + + const result = manager.getFirstMessageText(messages, true) + + expect(result).toHaveLength(140) + expect(result?.endsWith("...")).toBe(true) + }) + + it("should not truncate text at 140 characters or less", () => { + const manager = SessionManager.init(mockDependencies) + const messages: ClineMessage[] = [{ ts: 123, type: "say", say: "text", text: "A".repeat(140) }] + + const result = manager.getFirstMessageText(messages, true) + + expect(result).toHaveLength(140) + expect(result?.endsWith("...")).toBe(false) + }) + }) + + describe("generateTitle", () => { + it("should return null for empty messages", async () => { + const manager = SessionManager.init(mockDependencies) + + const result = await manager.generateTitle([]) + + expect(result).toBeNull() + }) + + it("should return raw text when it is 140 characters or less", async () => { + const manager = SessionManager.init(mockDependencies) + const messages: ClineMessage[] = [{ ts: 123, type: "say", say: "text", text: "Short title" }] + + const result = await manager.generateTitle(messages) + + expect(result).toBe("Short title") + expect(mockExtensionMessenger.requestSingleCompletion).not.toHaveBeenCalled() + }) + + it("should generate summary for long text", async () => { + mockExtensionMessenger.requestSingleCompletion = vi.fn().mockResolvedValue("Summarized title") + const manager = SessionManager.init(mockDependencies) + const longText = "A".repeat(200) + const messages: ClineMessage[] = [{ ts: 123, type: "say", say: "text", text: longText }] + + const result = await manager.generateTitle(messages) + + expect(result).toBe("Summarized title") + expect(mockExtensionMessenger.requestSingleCompletion).toHaveBeenCalled() + }) + + it("should strip quotes from generated summary", async () => { + mockExtensionMessenger.requestSingleCompletion = vi.fn().mockResolvedValue('"Quoted title"') + const manager = SessionManager.init(mockDependencies) + const longText = "A".repeat(200) + const messages: ClineMessage[] = [{ ts: 123, type: "say", say: "text", text: longText }] + + const result = await manager.generateTitle(messages) + + expect(result).toBe("Quoted title") + }) + + it("should truncate long generated summary to 140 characters", async () => { + mockExtensionMessenger.requestSingleCompletion = vi.fn().mockResolvedValue("B".repeat(200)) + const manager = SessionManager.init(mockDependencies) + const longText = "A".repeat(200) + const messages: ClineMessage[] = [{ ts: 123, type: "say", say: "text", text: longText }] + + const result = await manager.generateTitle(messages) + + expect(result).toHaveLength(140) + expect(result?.endsWith("...")).toBe(true) + }) + + it("should fallback to truncation when LLM fails", async () => { + mockExtensionMessenger.requestSingleCompletion = vi.fn().mockRejectedValue(new Error("LLM error")) + const manager = SessionManager.init(mockDependencies) + const longText = "A".repeat(200) + const messages: ClineMessage[] = [{ ts: 123, type: "say", say: "text", text: longText }] + + const result = await manager.generateTitle(messages) + + expect(result).toHaveLength(140) + expect(result?.endsWith("...")).toBe(true) + expect(mockLogger.warn).toHaveBeenCalledWith( + "Failed to generate title using LLM, falling back to truncation", + "SessionManager", + expect.any(Object), + ) + }) + }) + + describe("SYNC_INTERVAL constant", () => { + it("should be 1000ms", () => { + expect(SessionManager.SYNC_INTERVAL).toBe(1000) + }) + }) + + describe("syncSession", () => { + async function triggerSyncAndWait(manager: SessionManager): Promise { + if (manager["timer"]) { + clearInterval(manager["timer"]) + manager["timer"] = null + } + await (manager as unknown as { syncSession: (force?: boolean) => Promise })["syncSession"]() + } + + it("should not sync when no paths are set", async () => { + const manager = SessionManager.init(mockDependencies) + + await triggerSyncAndWait(manager) + + expect(mockSessionClient.create).not.toHaveBeenCalled() + expect(mockSessionClient.update).not.toHaveBeenCalled() + }) + + it("should not sync when already syncing", async () => { + const manager = SessionManager.init(mockDependencies) + manager["isSyncing"] = true + manager.setPath("task-123", "apiConversationHistoryPath", "/path/to/history.json") + vi.mocked(readFileSync).mockReturnValue(JSON.stringify([{ role: "user", content: "test" }])) + + await triggerSyncAndWait(manager) + + expect(mockSessionClient.create).not.toHaveBeenCalled() + }) + + it("should not sync when no blob has changed", async () => { + const manager = SessionManager.init(mockDependencies) + manager.setPath("task-123", "apiConversationHistoryPath", "/path/to/history.json") + vi.mocked(readFileSync).mockReturnValue(JSON.stringify([{ role: "user", content: "test" }])) + + manager["blobHashes"] = { ...manager["lastSyncedBlobHashes"] } + + await triggerSyncAndWait(manager) + + expect(mockSessionClient.create).not.toHaveBeenCalled() + }) + + it("should create new session when no session exists", async () => { + mockSessionClient.create.mockResolvedValue({ + session_id: "new-session-123", + title: "Test", + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + }) + mockSessionClient.uploadBlob.mockResolvedValue({ + session_id: "new-session-123", + updated_at: "2024-01-01T00:00:00Z", + }) + vi.mocked(readFileSync).mockReturnValue(JSON.stringify([{ role: "user", content: "test" }])) + + const manager = SessionManager.init(mockDependencies) + manager.setPath("task-123", "apiConversationHistoryPath", "/path/to/history.json") + + await triggerSyncAndWait(manager) + + expect(mockSessionClient.create).toHaveBeenCalledWith( + expect.objectContaining({ + created_on_platform: "vscode", + }), + ) + }) + + it("should call onSessionCreated callback when new session is created", async () => { + mockSessionClient.create.mockResolvedValue({ + session_id: "new-session-123", + title: "Test", + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + }) + mockSessionClient.uploadBlob.mockResolvedValue({ + session_id: "new-session-123", + updated_at: "2024-01-01T00:00:00Z", + }) + vi.mocked(readFileSync).mockReturnValue(JSON.stringify([{ role: "user", content: "test" }])) + + const manager = SessionManager.init(mockDependencies) + manager.setPath("task-123", "apiConversationHistoryPath", "/path/to/history.json") + + await triggerSyncAndWait(manager) + + expect(mockDependencies.onSessionCreated).toHaveBeenCalledWith( + expect.objectContaining({ + event: "session_created", + sessionId: "new-session-123", + }), + ) + }) + + it("should use existing session ID from task mapping", async () => { + mockSessionPersistenceManager.getSessionForTask.mockReturnValue("existing-session-456") + mockSessionClient.uploadBlob.mockResolvedValue({ + session_id: "existing-session-456", + updated_at: "2024-01-01T00:00:00Z", + }) + vi.mocked(readFileSync).mockReturnValue(JSON.stringify([{ role: "user", content: "test" }])) + + const manager = SessionManager.init(mockDependencies) + manager.setPath("task-123", "apiConversationHistoryPath", "/path/to/history.json") + + await triggerSyncAndWait(manager) + + expect(mockSessionClient.create).not.toHaveBeenCalled() + expect(manager.sessionId).toBe("existing-session-456") + }) + + it("should upload api_conversation_history blob", async () => { + mockSessionClient.create.mockResolvedValue({ + session_id: "new-session-123", + title: "Test", + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + }) + mockSessionClient.uploadBlob.mockResolvedValue({ + session_id: "new-session-123", + updated_at: "2024-01-01T00:00:00Z", + }) + const testData = [{ role: "user", content: "test message" }] + vi.mocked(readFileSync).mockReturnValue(JSON.stringify(testData)) + + const manager = SessionManager.init(mockDependencies) + manager.setPath("task-123", "apiConversationHistoryPath", "/path/to/history.json") + + await triggerSyncAndWait(manager) + + expect(mockSessionClient.uploadBlob).toHaveBeenCalledWith( + "new-session-123", + "api_conversation_history", + testData, + ) + }) + + it("should upload ui_messages blob", async () => { + mockSessionClient.create.mockResolvedValue({ + session_id: "new-session-123", + title: "Test", + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + }) + mockSessionClient.uploadBlob.mockResolvedValue({ + session_id: "new-session-123", + updated_at: "2024-01-01T00:00:00Z", + }) + const testData = [{ ts: 123, type: "say", say: "text", text: "Hello" }] + vi.mocked(readFileSync).mockReturnValue(JSON.stringify(testData)) + + const manager = SessionManager.init(mockDependencies) + manager.setPath("task-123", "uiMessagesPath", "/path/to/messages.json") + + await triggerSyncAndWait(manager) + + expect(mockSessionClient.uploadBlob).toHaveBeenCalledWith("new-session-123", "ui_messages", testData) + }) + + it("should upload task_metadata blob", async () => { + mockSessionClient.create.mockResolvedValue({ + session_id: "new-session-123", + title: "Test", + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + }) + mockSessionClient.uploadBlob.mockResolvedValue({ + session_id: "new-session-123", + updated_at: "2024-01-01T00:00:00Z", + }) + const testData = { taskId: "task-123", metadata: "test" } + vi.mocked(readFileSync).mockReturnValue(JSON.stringify(testData)) + + const manager = SessionManager.init(mockDependencies) + manager.setPath("task-123", "taskMetadataPath", "/path/to/metadata.json") + + await triggerSyncAndWait(manager) + + expect(mockSessionClient.uploadBlob).toHaveBeenCalledWith("new-session-123", "task_metadata", testData) + }) + + it("should extract title from first UI message", async () => { + mockSessionClient.create.mockResolvedValue({ + session_id: "new-session-123", + title: "Test", + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + }) + mockSessionClient.uploadBlob.mockResolvedValue({ + session_id: "new-session-123", + updated_at: "2024-01-01T00:00:00Z", + }) + const testData = [{ ts: 123, type: "say", say: "text", text: "Create a hello world app" }] + vi.mocked(readFileSync).mockReturnValue(JSON.stringify(testData)) + + const manager = SessionManager.init(mockDependencies) + manager.setPath("task-123", "uiMessagesPath", "/path/to/messages.json") + + await triggerSyncAndWait(manager) + + expect(mockSessionClient.create).toHaveBeenCalledWith( + expect.objectContaining({ + title: "Create a hello world app", + }), + ) + }) + + it("should handle blob upload failures gracefully", async () => { + mockSessionClient.create.mockResolvedValue({ + session_id: "new-session-123", + title: "Test", + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + }) + mockSessionClient.uploadBlob.mockRejectedValue(new Error("Upload failed")) + vi.mocked(readFileSync).mockReturnValue(JSON.stringify([{ role: "user", content: "test" }])) + + const manager = SessionManager.init(mockDependencies) + manager.setPath("task-123", "apiConversationHistoryPath", "/path/to/history.json") + + await triggerSyncAndWait(manager) + + expect(mockLogger.error).toHaveBeenCalledWith( + "Failed to upload api_conversation_history blob", + "SessionManager", + expect.objectContaining({ error: "Upload failed" }), + ) + }) + + it("should handle session creation failure gracefully", async () => { + mockSessionClient.create.mockRejectedValue(new Error("API Error")) + vi.mocked(readFileSync).mockReturnValue(JSON.stringify([{ role: "user", content: "test" }])) + + const manager = SessionManager.init(mockDependencies) + manager.setPath("task-123", "apiConversationHistoryPath", "/path/to/history.json") + + await triggerSyncAndWait(manager) + + expect(mockLogger.error).toHaveBeenCalledWith( + "Failed to sync session", + "SessionManager", + expect.objectContaining({ error: "API Error" }), + ) + }) + + it("should not create session if paths are empty after reading", async () => { + vi.mocked(readFileSync).mockImplementation(() => { + throw new Error("File not found") + }) + + const manager = SessionManager.init(mockDependencies) + manager.setPath("task-123", "apiConversationHistoryPath", "/path/to/missing.json") + + await triggerSyncAndWait(manager) + + expect(mockSessionClient.create).not.toHaveBeenCalled() + }) + + it("should sync on interval", async () => { + mockSessionClient.create.mockResolvedValue({ + session_id: "new-session-123", + title: "Test", + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + }) + mockSessionClient.uploadBlob.mockResolvedValue({ + session_id: "new-session-123", + updated_at: "2024-01-01T00:00:00Z", + }) + vi.mocked(readFileSync).mockReturnValue(JSON.stringify([{ role: "user", content: "test" }])) + + const manager = SessionManager.init(mockDependencies) + manager.setPath("task-123", "apiConversationHistoryPath", "/path/to/history.json") + + vi.advanceTimersByTime(500) + await Promise.resolve() + expect(mockSessionClient.create).not.toHaveBeenCalled() + + await triggerSyncAndWait(manager) + expect(mockSessionClient.create).toHaveBeenCalled() + }) + + it("should force sync even when already syncing", async () => { + mockSessionClient.create.mockResolvedValue({ + session_id: "new-session-123", + title: "Test", + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + }) + mockSessionClient.uploadBlob.mockResolvedValue({ + session_id: "new-session-123", + updated_at: "2024-01-01T00:00:00Z", + }) + vi.mocked(readFileSync).mockReturnValue(JSON.stringify([{ role: "user", content: "test" }])) + + const manager = SessionManager.init(mockDependencies) + manager.setPath("task-123", "apiConversationHistoryPath", "/path/to/history.json") + manager["isSyncing"] = true + + if (manager["timer"]) { + clearInterval(manager["timer"]) + manager["timer"] = null + } + await (manager as unknown as { syncSession: (force?: boolean) => Promise })["syncSession"](true) + + expect(mockSessionClient.create).toHaveBeenCalled() + }) + + it("should mark blob as synced after successful upload", async () => { + mockSessionClient.create.mockResolvedValue({ + session_id: "new-session-123", + title: "Test", + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + }) + mockSessionClient.uploadBlob.mockResolvedValue({ + session_id: "new-session-123", + updated_at: "2024-01-01T00:00:00Z", + }) + vi.mocked(readFileSync).mockReturnValue(JSON.stringify([{ role: "user", content: "test" }])) + + const manager = SessionManager.init(mockDependencies) + manager.setPath("task-123", "apiConversationHistoryPath", "/path/to/history.json") + + await triggerSyncAndWait(manager) + + expect(manager["blobHashes"].apiConversationHistory).toBe( + manager["lastSyncedBlobHashes"].apiConversationHistory, + ) + }) + + it("should save last session ID after creating new session", async () => { + mockSessionClient.create.mockResolvedValue({ + session_id: "new-session-123", + title: "Test", + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + }) + mockSessionClient.uploadBlob.mockResolvedValue({ + session_id: "new-session-123", + updated_at: "2024-01-01T00:00:00Z", + }) + vi.mocked(readFileSync).mockReturnValue(JSON.stringify([{ role: "user", content: "test" }])) + + const manager = SessionManager.init(mockDependencies) + manager.setPath("task-123", "apiConversationHistoryPath", "/path/to/history.json") + + await triggerSyncAndWait(manager) + + expect(mockSessionPersistenceManager.setLastSession).toHaveBeenCalledWith("new-session-123") + }) + + it("should set isSyncing to false after sync completes", async () => { + mockSessionClient.create.mockResolvedValue({ + session_id: "new-session-123", + title: "Test", + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + }) + mockSessionClient.uploadBlob.mockResolvedValue({ + session_id: "new-session-123", + updated_at: "2024-01-01T00:00:00Z", + }) + vi.mocked(readFileSync).mockReturnValue(JSON.stringify([{ role: "user", content: "test" }])) + + const manager = SessionManager.init(mockDependencies) + manager.setPath("task-123", "apiConversationHistoryPath", "/path/to/history.json") + + await triggerSyncAndWait(manager) + + expect(manager["isSyncing"]).toBe(false) + }) + + it("should set isSyncing to false even after sync fails", async () => { + mockSessionClient.create.mockRejectedValue(new Error("API Error")) + vi.mocked(readFileSync).mockReturnValue(JSON.stringify([{ role: "user", content: "test" }])) + + const manager = SessionManager.init(mockDependencies) + manager.setPath("task-123", "apiConversationHistoryPath", "/path/to/history.json") + + await triggerSyncAndWait(manager) + + expect(manager["isSyncing"]).toBe(false) + }) + }) +}) diff --git a/src/shared/kilocode/cli-sessions/extension/session-manager-utils.ts b/src/shared/kilocode/cli-sessions/extension/session-manager-utils.ts new file mode 100644 index 00000000000..93a650dafdf --- /dev/null +++ b/src/shared/kilocode/cli-sessions/extension/session-manager-utils.ts @@ -0,0 +1,76 @@ +import type { ClineProvider } from "../../../../core/webview/ClineProvider" +import { ExtensionLoggerAdapter } from "../../../../services/kilo-session/ExtensionLoggerAdapter" +import { ExtensionMessengerImpl } from "../../../../services/kilo-session/ExtensionMessengerImpl" +import { ExtensionPathProvider } from "../../../../services/kilo-session/ExtensionPathProvider" +import { SessionManager } from "../core/SessionManager" +import * as vscode from "vscode" + +const kilo_isCli = () => { + return process.env.KILO_CLI_MODE === "true" +} + +export async function kilo_execIfExtension any>(cb: T): Promise | void> { + if (kilo_isCli()) { + return Promise.resolve() + } + + return await cb() +} + +interface InitializeSessionManagerInput { + kiloToken: string | undefined + log: (message: string) => void + context: vscode.ExtensionContext + outputChannel: vscode.OutputChannel + provider: ClineProvider +} + +export function kilo_initializeSessionManager({ + kiloToken, + context, + log, + outputChannel, + provider, +}: InitializeSessionManagerInput) { + return kilo_execIfExtension(() => { + try { + if (!kiloToken) { + log("SessionManager not initialized: No authentication token available") + return + } + + const pathProvider = new ExtensionPathProvider(context) + const logger = new ExtensionLoggerAdapter(outputChannel) + const extensionMessenger = new ExtensionMessengerImpl(provider) + + const sessionManager = SessionManager.init({ + pathProvider, + logger, + extensionMessenger, + getToken: () => Promise.resolve(kiloToken), + onSessionCreated: (message) => { + log(`Session created: ${message.sessionId}`) + }, + onSessionRestored: () => { + log("Session restored") + }, + platform: vscode.env.appName, + }) + + const workspaceFolder = vscode.workspace.workspaceFolders?.[0] + if (workspaceFolder) { + sessionManager.setWorkspaceDirectory(workspaceFolder.uri.fsPath) + } + + log("SessionManager initialized successfully") + } catch (error) { + log(`Failed to initialize SessionManager: ${error instanceof Error ? error.message : String(error)}`) + } + }) +} + +export async function kilo_destroySessionManager() { + return kilo_execIfExtension(() => { + return SessionManager.init().destroy() + }) +} diff --git a/src/shared/kilocode/cli-sessions/types/IExtensionMessenger.ts b/src/shared/kilocode/cli-sessions/types/IExtensionMessenger.ts new file mode 100644 index 00000000000..60117db1ef5 --- /dev/null +++ b/src/shared/kilocode/cli-sessions/types/IExtensionMessenger.ts @@ -0,0 +1,24 @@ +import type { HistoryItem } from "@roo-code/types" +import type { WebviewMessage } from "../../../WebviewMessage" +export type { HistoryItem, WebviewMessage } + +/** + * Interface for communicating with the extension/UI layer. + * Implementations should handle messaging between the session manager and the extension. + */ +export interface IExtensionMessenger { + /** + * Send a message to the webview/UI. + * @param message The message to send + */ + sendWebviewMessage(message: WebviewMessage): Promise + + /** + * Request a single completion/summary from the LLM. + * Used for generating session titles. + * @param prompt The prompt to send to the LLM + * @param timeoutMs Timeout in milliseconds + * @returns The generated text completion + */ + requestSingleCompletion(prompt: string, timeoutMs: number): Promise +} diff --git a/src/shared/kilocode/cli-sessions/types/ILogger.ts b/src/shared/kilocode/cli-sessions/types/ILogger.ts new file mode 100644 index 00000000000..060a64f3a6a --- /dev/null +++ b/src/shared/kilocode/cli-sessions/types/ILogger.ts @@ -0,0 +1,37 @@ +/** + * Interface for logging functionality. + * Implementations should provide methods for different log levels. + */ +export interface ILogger { + /** + * Log a debug message. + * @param message The message to log + * @param source The source/component generating the log + * @param metadata Optional metadata object to include with the log + */ + debug(message: string, source: string, metadata?: Record): void + + /** + * Log an informational message. + * @param message The message to log + * @param source The source/component generating the log + * @param metadata Optional metadata object to include with the log + */ + info(message: string, source: string, metadata?: Record): void + + /** + * Log a warning message. + * @param message The message to log + * @param source The source/component generating the log + * @param metadata Optional metadata object to include with the log + */ + warn(message: string, source: string, metadata?: Record): void + + /** + * Log an error message. + * @param message The message to log + * @param source The source/component generating the log + * @param metadata Optional metadata object to include with the log + */ + error(message: string, source: string, metadata?: Record): void +} diff --git a/src/shared/kilocode/cli-sessions/types/IPathProvider.ts b/src/shared/kilocode/cli-sessions/types/IPathProvider.ts new file mode 100644 index 00000000000..a0ecc684e93 --- /dev/null +++ b/src/shared/kilocode/cli-sessions/types/IPathProvider.ts @@ -0,0 +1,18 @@ +/** + * Interface for providing file system paths needed by session management. + * Implementations should provide methods to resolve paths for various session-related files and directories. + */ +export interface IPathProvider { + /** + * Get the directory where task data is stored. + * @returns The absolute path to the tasks directory + */ + getTasksDir(): string + + /** + * Get the path to the file that stores the local session data. + * @param workspaceDir The workspace directory path + * @returns The absolute path to the session file + */ + getSessionFilePath(workspaceDir: string): string +} diff --git a/src/shared/kilocode/cli-sessions/utils/SessionPersistenceManager.ts b/src/shared/kilocode/cli-sessions/utils/SessionPersistenceManager.ts new file mode 100644 index 00000000000..a8a00c68671 --- /dev/null +++ b/src/shared/kilocode/cli-sessions/utils/SessionPersistenceManager.ts @@ -0,0 +1,102 @@ +import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs" +import path from "path" +import type { IPathProvider } from "../types/IPathProvider.js" + +interface WorkspaceSessionState { + lastSession?: { + sessionId: string + timestamp: number + } + taskSessionMap: Record +} + +export class SessionPersistenceManager { + private pathProvider: IPathProvider + private workspaceDir: string | null = null + + constructor(pathProvider: IPathProvider) { + this.pathProvider = pathProvider + } + + setWorkspaceDir(dir: string): void { + this.workspaceDir = dir + } + + private getSessionStatePath(): string | null { + if (!this.workspaceDir) { + return null + } + return this.pathProvider.getSessionFilePath(this.workspaceDir) + } + + private readWorkspaceState(): WorkspaceSessionState { + const statePath = this.getSessionStatePath() + if (!statePath || !existsSync(statePath)) { + return { taskSessionMap: {} } + } + + const content = readFileSync(statePath, "utf-8") + const data = JSON.parse(content) + + return { + lastSession: data.lastSession, + taskSessionMap: data.taskSessionMap || {}, + } + } + + private writeWorkspaceState(state: WorkspaceSessionState): void { + const statePath = this.getSessionStatePath() + + if (!statePath) { + return + } + + const stateDir = path.dirname(statePath) + + mkdirSync(stateDir, { recursive: true }) + + writeFileSync(statePath, JSON.stringify(state, null, 2)) + } + + getLastSession(): { sessionId: string; timestamp: number } | undefined { + const state = this.readWorkspaceState() + + return state.lastSession + } + + setLastSession(sessionId: string): void { + const state = this.readWorkspaceState() + + state.lastSession = { sessionId, timestamp: Date.now() } + + this.writeWorkspaceState(state) + } + + getTaskSessionMap(): Record { + const state = this.readWorkspaceState() + + return state.taskSessionMap + } + + setTaskSessionMap(taskSessionMap: Record): void { + const state = this.readWorkspaceState() + + state.taskSessionMap = taskSessionMap + + this.writeWorkspaceState(state) + } + + getSessionForTask(taskId: string): string | undefined { + const taskSessionMap = this.getTaskSessionMap() + + return taskSessionMap[taskId] + } + + setSessionForTask(taskId: string, sessionId: string): void { + const state = this.readWorkspaceState() + + state.taskSessionMap[taskId] = sessionId + + this.writeWorkspaceState(state) + } +} diff --git a/src/shared/kilocode/cli-sessions/utils/__tests__/SessionPersistenceManager.test.ts b/src/shared/kilocode/cli-sessions/utils/__tests__/SessionPersistenceManager.test.ts new file mode 100644 index 00000000000..2d61a8b54d0 --- /dev/null +++ b/src/shared/kilocode/cli-sessions/utils/__tests__/SessionPersistenceManager.test.ts @@ -0,0 +1,301 @@ +import { SessionPersistenceManager } from "../SessionPersistenceManager" +import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs" +import type { IPathProvider } from "../../types/IPathProvider" + +vi.mock("fs", () => ({ + readFileSync: vi.fn(), + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), + existsSync: vi.fn(), +})) + +vi.mock("path", async () => { + const actual = await vi.importActual("path") + return { + ...actual, + default: { + ...actual, + dirname: vi.fn((p: string) => p.split("/").slice(0, -1).join("/")), + }, + } +}) + +describe("SessionPersistenceManager", () => { + let manager: SessionPersistenceManager + let mockPathProvider: IPathProvider + + beforeEach(() => { + vi.clearAllMocks() + + mockPathProvider = { + getTasksDir: vi.fn().mockReturnValue("/home/user/.kilocode/tasks"), + getSessionFilePath: vi + .fn() + .mockImplementation((workspaceDir: string) => `${workspaceDir}/.kilocode/session.json`), + } + + manager = new SessionPersistenceManager(mockPathProvider) + }) + + describe("setWorkspaceDir", () => { + it("should set the workspace directory", () => { + manager.setWorkspaceDir("/workspace") + + expect(mockPathProvider.getSessionFilePath).not.toHaveBeenCalled() + }) + }) + + describe("getLastSession", () => { + it("should return undefined when workspace directory is not set", () => { + const result = manager.getLastSession() + + expect(result).toBeUndefined() + }) + + it("should return undefined when session file does not exist", () => { + manager.setWorkspaceDir("/workspace") + vi.mocked(existsSync).mockReturnValue(false) + + const result = manager.getLastSession() + + expect(result).toBeUndefined() + }) + + it("should return last session when it exists", () => { + manager.setWorkspaceDir("/workspace") + vi.mocked(existsSync).mockReturnValue(true) + vi.mocked(readFileSync).mockReturnValue( + JSON.stringify({ + lastSession: { sessionId: "session-123", timestamp: 1234567890 }, + taskSessionMap: {}, + }), + ) + + const result = manager.getLastSession() + + expect(result).toEqual({ sessionId: "session-123", timestamp: 1234567890 }) + }) + + it("should return undefined when lastSession is not set in state", () => { + manager.setWorkspaceDir("/workspace") + vi.mocked(existsSync).mockReturnValue(true) + vi.mocked(readFileSync).mockReturnValue( + JSON.stringify({ + taskSessionMap: {}, + }), + ) + + const result = manager.getLastSession() + + expect(result).toBeUndefined() + }) + }) + + describe("setLastSession", () => { + it("should not write when workspace directory is not set", () => { + manager.setLastSession("session-123") + + expect(writeFileSync).not.toHaveBeenCalled() + }) + + it("should write last session to file", () => { + manager.setWorkspaceDir("/workspace") + vi.mocked(existsSync).mockReturnValue(true) + vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ taskSessionMap: {} })) + + manager.setLastSession("session-456") + + expect(mkdirSync).toHaveBeenCalledWith("/workspace/.kilocode", { recursive: true }) + expect(writeFileSync).toHaveBeenCalledWith( + "/workspace/.kilocode/session.json", + expect.stringContaining('"sessionId": "session-456"'), + ) + }) + + it("should preserve existing taskSessionMap when setting last session", () => { + manager.setWorkspaceDir("/workspace") + vi.mocked(existsSync).mockReturnValue(true) + vi.mocked(readFileSync).mockReturnValue( + JSON.stringify({ + taskSessionMap: { "task-1": "session-1" }, + }), + ) + + manager.setLastSession("session-456") + + const writtenData = JSON.parse(vi.mocked(writeFileSync).mock.calls[0][1] as string) + expect(writtenData.taskSessionMap).toEqual({ "task-1": "session-1" }) + expect(writtenData.lastSession).toEqual({ sessionId: "session-456", timestamp: expect.any(Number) }) + }) + }) + + describe("getTaskSessionMap", () => { + it("should return empty object when workspace directory is not set", () => { + const result = manager.getTaskSessionMap() + + expect(result).toEqual({}) + }) + + it("should return empty object when session file does not exist", () => { + manager.setWorkspaceDir("/workspace") + vi.mocked(existsSync).mockReturnValue(false) + + const result = manager.getTaskSessionMap() + + expect(result).toEqual({}) + }) + + it("should return task session map when it exists", () => { + manager.setWorkspaceDir("/workspace") + vi.mocked(existsSync).mockReturnValue(true) + vi.mocked(readFileSync).mockReturnValue( + JSON.stringify({ + taskSessionMap: { + "task-1": "session-1", + "task-2": "session-2", + }, + }), + ) + + const result = manager.getTaskSessionMap() + + expect(result).toEqual({ + "task-1": "session-1", + "task-2": "session-2", + }) + }) + }) + + describe("setTaskSessionMap", () => { + it("should not write when workspace directory is not set", () => { + manager.setTaskSessionMap({ "task-1": "session-1" }) + + expect(writeFileSync).not.toHaveBeenCalled() + }) + + it("should write task session map to file", () => { + manager.setWorkspaceDir("/workspace") + vi.mocked(existsSync).mockReturnValue(true) + vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ taskSessionMap: {} })) + + manager.setTaskSessionMap({ "task-1": "session-1", "task-2": "session-2" }) + + const writtenData = JSON.parse(vi.mocked(writeFileSync).mock.calls[0][1] as string) + expect(writtenData.taskSessionMap).toEqual({ + "task-1": "session-1", + "task-2": "session-2", + }) + }) + + it("should preserve existing lastSession when setting task session map", () => { + manager.setWorkspaceDir("/workspace") + vi.mocked(existsSync).mockReturnValue(true) + vi.mocked(readFileSync).mockReturnValue( + JSON.stringify({ + lastSession: { sessionId: "session-old", timestamp: 111 }, + taskSessionMap: {}, + }), + ) + + manager.setTaskSessionMap({ "task-1": "session-1" }) + + const writtenData = JSON.parse(vi.mocked(writeFileSync).mock.calls[0][1] as string) + expect(writtenData.lastSession).toEqual({ sessionId: "session-old", timestamp: 111 }) + }) + }) + + describe("getSessionForTask", () => { + it("should return undefined when task is not mapped", () => { + manager.setWorkspaceDir("/workspace") + vi.mocked(existsSync).mockReturnValue(true) + vi.mocked(readFileSync).mockReturnValue( + JSON.stringify({ + taskSessionMap: { "task-1": "session-1" }, + }), + ) + + const result = manager.getSessionForTask("task-unknown") + + expect(result).toBeUndefined() + }) + + it("should return session ID for mapped task", () => { + manager.setWorkspaceDir("/workspace") + vi.mocked(existsSync).mockReturnValue(true) + vi.mocked(readFileSync).mockReturnValue( + JSON.stringify({ + taskSessionMap: { "task-1": "session-1" }, + }), + ) + + const result = manager.getSessionForTask("task-1") + + expect(result).toBe("session-1") + }) + }) + + describe("setSessionForTask", () => { + it("should add task-session mapping to existing map", () => { + manager.setWorkspaceDir("/workspace") + vi.mocked(existsSync).mockReturnValue(true) + vi.mocked(readFileSync).mockReturnValue( + JSON.stringify({ + taskSessionMap: { "task-1": "session-1" }, + }), + ) + + manager.setSessionForTask("task-2", "session-2") + + const writtenData = JSON.parse(vi.mocked(writeFileSync).mock.calls[0][1] as string) + expect(writtenData.taskSessionMap).toEqual({ + "task-1": "session-1", + "task-2": "session-2", + }) + }) + + it("should update existing task-session mapping", () => { + manager.setWorkspaceDir("/workspace") + vi.mocked(existsSync).mockReturnValue(true) + vi.mocked(readFileSync).mockReturnValue( + JSON.stringify({ + taskSessionMap: { "task-1": "session-old" }, + }), + ) + + manager.setSessionForTask("task-1", "session-new") + + const writtenData = JSON.parse(vi.mocked(writeFileSync).mock.calls[0][1] as string) + expect(writtenData.taskSessionMap["task-1"]).toBe("session-new") + }) + + it("should create taskSessionMap when it does not exist", () => { + manager.setWorkspaceDir("/workspace") + vi.mocked(existsSync).mockReturnValue(false) + + manager.setSessionForTask("task-1", "session-1") + + const writtenData = JSON.parse(vi.mocked(writeFileSync).mock.calls[0][1] as string) + expect(writtenData.taskSessionMap).toEqual({ "task-1": "session-1" }) + }) + }) + + describe("edge cases", () => { + it("should handle malformed JSON gracefully by allowing the error to propagate", () => { + manager.setWorkspaceDir("/workspace") + vi.mocked(existsSync).mockReturnValue(true) + vi.mocked(readFileSync).mockReturnValue("invalid json") + + expect(() => manager.getLastSession()).toThrow() + }) + + it("should handle empty taskSessionMap in JSON", () => { + manager.setWorkspaceDir("/workspace") + vi.mocked(existsSync).mockReturnValue(true) + vi.mocked(readFileSync).mockReturnValue(JSON.stringify({})) + + const result = manager.getTaskSessionMap() + + expect(result).toEqual({}) + }) + }) +}) diff --git a/webview-ui/tsconfig.json b/webview-ui/tsconfig.json index c075b996125..33e108c9a5e 100644 --- a/webview-ui/tsconfig.json +++ b/webview-ui/tsconfig.json @@ -8,6 +8,7 @@ "esModuleInterop": true, "allowSyntheticDefaultImports": true, "strict": true, + "useUnknownInCatchVariables": false, "forceConsistentCasingInFileNames": true, "noFallthroughCasesInSwitch": true, "module": "esnext",