diff --git a/.changeset/deep-peaches-melt.md b/.changeset/deep-peaches-melt.md new file mode 100644 index 00000000000..36638412a24 --- /dev/null +++ b/.changeset/deep-peaches-melt.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": minor +"kilo-code": minor +--- + +add sessions support diff --git a/apps/kilocode-docs/docs/features/api-configuration-profiles.md b/apps/kilocode-docs/docs/features/api-configuration-profiles.md index 96370ee7c87..b329f174e2e 100644 --- a/apps/kilocode-docs/docs/features/api-configuration-profiles.md +++ b/apps/kilocode-docs/docs/features/api-configuration-profiles.md @@ -38,15 +38,15 @@ Note that available settings vary by provider and model. Each provider offers di Provider selection dropdown - Enter API key - API key entry field + API key entry field - Choose a model - Model selection interface + Model selection interface - Adjust model parameters - Model parameter adjustment controls + Model parameter adjustment controls ### Switching Profiles diff --git a/apps/kilocode-docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/api-configuration-profiles.md b/apps/kilocode-docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/api-configuration-profiles.md index dd42e4929a0..8d323e86307 100644 --- a/apps/kilocode-docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/api-configuration-profiles.md +++ b/apps/kilocode-docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/api-configuration-profiles.md @@ -36,19 +36,19 @@ API 配置配置文件允许您创建和切换不同的 AI 设置集。每个配 - 选择您的 API 提供商 - 提供商选择下拉菜单 + 提供商选择下拉菜单 - 输入 API 密钥 - API 密钥输入字段 + API 密钥输入字段 - 选择模型 - 模型选择界面 + 模型选择界面 - 调整模型参数 - 模型参数调整控件 + 模型参数调整控件 ### 切换配置文件 diff --git a/apps/kilocode-docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/checkpoints.md b/apps/kilocode-docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/checkpoints.md index bf8234ab053..6050d8d23c6 100644 --- a/apps/kilocode-docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/checkpoints.md +++ b/apps/kilocode-docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/checkpoints.md @@ -86,7 +86,7 @@ Kilo Code 使用一个影子 Git 仓库(独立于您的主版本控制系统 - **恢复文件和任务** - 恢复工作区文件并删除所有后续对话消息。当您希望将代码和对话完全重置回检查点的时间点时使用。此选项需要在对话框中进行确认,因为它无法撤消。 - 恢复文件和任务检查点的确认对话框 + 恢复文件和任务检查点的确认对话框 ### 限制和注意事项 diff --git a/cli/src/cli.ts b/cli/src/cli.ts index 3f54eeab430..940d0e5ccc9 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -13,7 +13,7 @@ import { requestRouterModelsAtom } from "./state/atoms/actions.js" import { loadHistoryAtom } from "./state/atoms/history.js" import { taskHistoryDataAtom, updateTaskHistoryFiltersAtom } from "./state/atoms/taskHistory.js" import { sendWebviewMessageAtom } from "./state/atoms/actions.js" -import { taskResumedViaContinueAtom } from "./state/atoms/extension.js" +import { taskResumedViaContinueOrSessionAtom } from "./state/atoms/extension.js" import { getTelemetryService, getIdentityManager } from "./services/telemetry/index.js" import { notificationsAtom, notificationsErrorAtom, notificationsLoadingAtom } from "./state/atoms/notifications.js" import { fetchKilocodeNotifications } from "./utils/notifications.js" @@ -24,6 +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 { getKiloToken } from "./config/persistence.js" /** * Main application class that orchestrates the CLI lifecycle @@ -34,6 +37,7 @@ export class CLI { private ui: Instance | null = null private options: CLIOptions private isInitialized = false + private sessionService: SessionService | null = null constructor(options: CLIOptions = {}) { this.options = options @@ -126,6 +130,31 @@ export class CLI { // Track successful extension initialization telemetryService.trackExtensionInitialized(true) + // Initialize services and restore session if kiloToken is available + // This must happen AFTER ExtensionService initialization to allow webview messages + const kiloToken = getKiloToken(config) + + if (kiloToken) { + TrpcClient.init(kiloToken) + logs.debug("TrpcClient initialized with kiloToken", "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 }) + + if (this.options.session) { + await this.sessionService.restoreSession(this.options.session) + } else if (this.options.fork) { + logs.info("Forking session from share ID", "CLI", { shareId: this.options.fork }) + + await this.sessionService.forkSession(this.options.fork) + } + } + // Load command history await this.store.set(loadHistoryAtom) logs.debug("Command history loaded", "CLI") @@ -273,6 +302,8 @@ export class CLI { try { logs.info("Disposing Kilo Code CLI...", "CLI") + await this.sessionService?.destroy() + // Signal codes take precedence over CI logic if (signal === "SIGINT") { exitCode = 130 @@ -432,8 +463,21 @@ export class CLI { try { logs.info("Attempting to resume last conversation", "CLI", { workspace }) + // First, try to restore from persisted session ID if kiloToken is available + if (this.sessionService) { + const restored = await this.sessionService.restoreLastSession() + if (restored) { + return + } + + logs.debug("Falling back to task history", "CLI") + } + + // Fallback: Use task history approach + logs.debug("Using task history fallback to resume conversation", "CLI") + // Update filters to current workspace and newest sort - await this.store.set(updateTaskHistoryFiltersAtom, { + this.store.set(updateTaskHistoryFiltersAtom, { workspace: "current", sort: "newest", favoritesOnly: false, @@ -461,7 +505,6 @@ export class CLI { logs.warn("No previous tasks found for workspace", "CLI", { workspace }) console.error("\nNo previous tasks found for this workspace. Please start a new conversation.\n") process.exit(1) - return // TypeScript doesn't know process.exit stops execution } // Find the most recent task (first in the list since we sorted by newest) @@ -471,7 +514,6 @@ export class CLI { logs.warn("No valid task found in history", "CLI", { workspace }) console.error("\nNo valid task found to resume. Please start a new conversation.\n") process.exit(1) - return } logs.debug("Found last task", "CLI", { taskId: lastTask.id, task: lastTask.task }) @@ -483,7 +525,7 @@ export class CLI { }) // Mark that the task was resumed via --continue to prevent showing "Task ready to resume" message - this.store.set(taskResumedViaContinueAtom, true) + this.store.set(taskResumedViaContinueOrSessionAtom, true) logs.info("Task resume initiated", "CLI", { taskId: lastTask.id, task: lastTask.task }) } catch (error) { diff --git a/cli/src/commands/__tests__/new.test.ts b/cli/src/commands/__tests__/new.test.ts index c44e839c10f..c0965b28890 100644 --- a/cli/src/commands/__tests__/new.test.ts +++ b/cli/src/commands/__tests__/new.test.ts @@ -2,13 +2,16 @@ * Tests for /new command */ -import { describe, it, expect, vi, beforeEach } from "vitest" +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" describe("/new command", () => { let mockContext: CommandContext + let mockSessionService: Partial & { destroy: ReturnType } + beforeEach(() => { // Mock process.stdout.write to capture terminal clearing vi.spyOn(process.stdout, "write").mockImplementation(() => true) @@ -16,6 +19,19 @@ describe("/new command", () => { mockContext = createMockContext({ input: "/new", }) + + // Mock SessionService + mockSessionService = { + 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) + }) + + afterEach(() => { + vi.restoreAllMocks() }) describe("Command metadata", () => { @@ -55,6 +71,27 @@ describe("/new command", () => { expect(mockContext.clearTask).toHaveBeenCalledTimes(1) }) + it("should clear the session", async () => { + await newCommand.handler(mockContext) + + expect(SessionService.init).toHaveBeenCalled() + expect(mockSessionService.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")) + + await newCommand.handler(mockContext) + + // Should still clear task and replace messages despite session error + expect(mockContext.clearTask).toHaveBeenCalled() + expect(mockContext.replaceMessages).toHaveBeenCalled() + expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to clear session:", expect.any(Error)) + + consoleErrorSpy.mockRestore() + }) + it("should replace CLI messages with welcome message", async () => { await newCommand.handler(mockContext) @@ -84,6 +121,10 @@ describe("/new command", () => { callOrder.push("clearTask") }) + mockSessionService.destroy = vi.fn().mockImplementation(async () => { + callOrder.push("sessionDestroy") + }) + mockContext.replaceMessages = vi.fn().mockImplementation(() => { callOrder.push("replaceMessages") }) @@ -91,7 +132,7 @@ describe("/new command", () => { await newCommand.handler(mockContext) // Operations should execute in this order - expect(callOrder).toEqual(["clearTask", "replaceMessages"]) + expect(callOrder).toEqual(["clearTask", "sessionDestroy", "replaceMessages"]) }) it("should handle clearTask errors gracefully", async () => { @@ -123,6 +164,7 @@ describe("/new command", () => { // Verify all cleanup operations were performed expect(mockContext.clearTask).toHaveBeenCalled() + expect(mockSessionService.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 new file mode 100644 index 00000000000..c64b9ea63c4 --- /dev/null +++ b/cli/src/commands/__tests__/session.test.ts @@ -0,0 +1,1263 @@ +/** + * Tests for the /session command + */ + +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" + +// Mock the SessionService +vi.mock("../../services/session.js", () => ({ + SessionService: { + init: vi.fn(), + }, +})) + +// Mock the SessionClient +vi.mock("../../services/sessionClient.js", () => ({ + SessionClient: { + getInstance: vi.fn(), + }, + CliSessionSharedState: { + Private: "private", + Public: "public", + }, +})) + +// Mock simple-git +vi.mock("simple-git", () => ({ + default: vi.fn(), +})) + +describe("sessionCommand", () => { + let mockContext: CommandContext + let mockSessionService: Partial + let mockSessionClient: Partial + + beforeEach(() => { + mockContext = createMockContext({ + 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({ + cliSessions: [], + nextCursor: null, + }), + search: vi.fn().mockResolvedValue({ + results: [], + total: 0, + limit: 20, + offset: 0, + }), + } + + // Mock SessionService.init to return our mock instance + vi.mocked(SessionService.init).mockReturnValue(mockSessionService as SessionService) + + // Mock SessionClient.getInstance to return our mock instance + vi.mocked(SessionClient.getInstance).mockReturnValue(mockSessionClient as SessionClient) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + describe("command metadata", () => { + it("should have correct name", () => { + expect(sessionCommand.name).toBe("session") + }) + + it("should have empty aliases array", () => { + expect(sessionCommand.aliases).toEqual([]) + }) + + it("should have correct category", () => { + expect(sessionCommand.category).toBe("system") + }) + + it("should have correct priority", () => { + expect(sessionCommand.priority).toBe(5) + }) + + it("should have description", () => { + expect(sessionCommand.description).toBeTruthy() + expect(sessionCommand.description).toContain("session") + }) + + it("should have usage examples", () => { + expect(sessionCommand.examples).toHaveLength(8) + expect(sessionCommand.examples).toContain("/session show") + expect(sessionCommand.examples).toContain("/session list") + expect(sessionCommand.examples).toContain("/session search ") + expect(sessionCommand.examples).toContain("/session select ") + expect(sessionCommand.examples).toContain("/session share") + expect(sessionCommand.examples).toContain("/session fork ") + expect(sessionCommand.examples).toContain("/session delete ") + expect(sessionCommand.examples).toContain("/session rename ") + }) + + it("should have subcommand argument defined", () => { + expect(sessionCommand.arguments).toBeDefined() + expect(sessionCommand.arguments).toHaveLength(2) + expect(sessionCommand.arguments![0].name).toBe("subcommand") + expect(sessionCommand.arguments![0].required).toBe(false) + }) + + it("should have all subcommand values defined", () => { + const subcommandArg = sessionCommand.arguments![0] + expect(subcommandArg.values).toBeDefined() + expect(subcommandArg.values).toHaveLength(8) + expect(subcommandArg.values!.map((v) => v.value)).toEqual([ + "show", + "list", + "search", + "select", + "share", + "fork", + "delete", + "rename", + ]) + }) + + it("should have argument with conditional providers", () => { + const argumentArg = sessionCommand.arguments![1] + expect(argumentArg.name).toBe("argument") + expect(argumentArg.required).toBe(false) + expect(argumentArg.conditionalProviders).toBeDefined() + expect(argumentArg.conditionalProviders).toHaveLength(1) + + // Check conditional provider exists + expect(argumentArg.conditionalProviders![0].condition).toBeDefined() + expect(argumentArg.conditionalProviders![0].provider).toBeDefined() + }) + }) + + describe("handler - no arguments", () => { + it("should show usage message when called without arguments", async () => { + mockContext.args = [] + + await sessionCommand.handler(mockContext) + + expect(mockContext.addMessage).toHaveBeenCalledTimes(1) + const message = (mockContext.addMessage as ReturnType).mock.calls[0][0] + expect(message.type).toBe("system") + expect(message.content).toContain("Usage: /session") + expect(message.content).toContain("show") + expect(message.content).toContain("list") + expect(message.content).toContain("search") + expect(message.content).toContain("select") + expect(message.content).toContain("share") + expect(message.content).toContain("fork") + expect(message.content).toContain("delete") + expect(message.content).toContain("rename") + }) + + it("should not call SessionService when showing usage", async () => { + mockContext.args = [] + + await sessionCommand.handler(mockContext) + + expect(SessionService.init).not.toHaveBeenCalled() + expect(SessionClient.getInstance).not.toHaveBeenCalled() + }) + }) + + describe("handler - show subcommand", () => { + it("should display session ID when session exists", async () => { + const testSessionId = "test-session-123" + mockSessionService.sessionId = testSessionId + mockContext.args = ["show"] + + await sessionCommand.handler(mockContext) + + expect(SessionService.init).toHaveBeenCalledTimes(1) + expect(mockContext.addMessage).toHaveBeenCalledTimes(1) + const message = (mockContext.addMessage as ReturnType).mock.calls[0][0] + expect(message.type).toBe("system") + expect(message.content).toContain("Current Session ID") + expect(message.content).toContain(testSessionId) + }) + + it("should display message when no session exists", async () => { + mockSessionService.sessionId = null + mockContext.args = ["show"] + + await sessionCommand.handler(mockContext) + + expect(SessionService.init).toHaveBeenCalledTimes(1) + expect(mockContext.addMessage).toHaveBeenCalledTimes(1) + const message = (mockContext.addMessage as ReturnType).mock.calls[0][0] + expect(message.type).toBe("system") + expect(message.content).toContain("No active session") + }) + + it("should handle 'show' subcommand case-insensitively", async () => { + mockSessionService.sessionId = "test-id" + mockContext.args = ["SHOW"] + + await sessionCommand.handler(mockContext) + + expect(SessionService.init).toHaveBeenCalledTimes(1) + expect(mockContext.addMessage).toHaveBeenCalledTimes(1) + }) + }) + + describe("handler - list subcommand", () => { + it("should display empty sessions list", async () => { + mockSessionClient.list = vi.fn().mockResolvedValue({ + cliSessions: [], + nextCursor: null, + }) + mockContext.args = ["list"] + + await sessionCommand.handler(mockContext) + + expect(SessionClient.getInstance).toHaveBeenCalled() + expect(mockSessionClient.list).toHaveBeenCalledWith({ limit: 50 }) + expect(mockContext.addMessage).toHaveBeenCalledTimes(1) + const message = (mockContext.addMessage as ReturnType).mock.calls[0][0] + expect(message.type).toBe("system") + expect(message.content).toContain("No sessions found") + }) + + it("should display sessions list with results", async () => { + const mockSessions = [ + { + session_id: "session-1", + title: "Test Session 1", + created_at: new Date(Date.now() - 3600000).toISOString(), // 1 hour ago + updated_at: new Date().toISOString(), + }, + { + session_id: "session-2", + title: "Test Session 2", + created_at: new Date(Date.now() - 86400000).toISOString(), // 1 day ago + updated_at: new Date().toISOString(), + }, + ] + + mockSessionClient.list = vi.fn().mockResolvedValue({ + cliSessions: mockSessions, + nextCursor: null, + }) + mockContext.args = ["list"] + + await sessionCommand.handler(mockContext) + + expect(mockSessionClient.list).toHaveBeenCalledWith({ limit: 50 }) + expect(mockContext.addMessage).toHaveBeenCalledTimes(1) + const message = (mockContext.addMessage as ReturnType).mock.calls[0][0] + expect(message.type).toBe("system") + expect(message.content).toContain("Available Sessions") + expect(message.content).toContain("Test Session 1") + expect(message.content).toContain("Test Session 2") + expect(message.content).toContain("session-1") + expect(message.content).toContain("session-2") + }) + + it("should indicate active session in list", async () => { + mockSessionService.sessionId = "session-active" + const mockSessions = [ + { + session_id: "session-active", + title: "Active Session", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, + { + session_id: "session-inactive", + title: "Inactive Session", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, + ] + + mockSessionClient.list = vi.fn().mockResolvedValue({ + cliSessions: mockSessions, + nextCursor: null, + }) + mockContext.args = ["list"] + + await sessionCommand.handler(mockContext) + + const message = (mockContext.addMessage as ReturnType).mock.calls[0][0] + expect(message.content).toContain("* [Active]") + }) + + it("should display pagination cursor when available", async () => { + const mockSessions = Array.from({ length: 50 }, (_, i) => ({ + session_id: `session-${i}`, + title: `Session ${i}`, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + })) + + mockSessionClient.list = vi.fn().mockResolvedValue({ + cliSessions: mockSessions, + nextCursor: "cursor-next", + }) + mockContext.args = ["list"] + + await sessionCommand.handler(mockContext) + + const message = (mockContext.addMessage as ReturnType).mock.calls[0][0] + expect(message.content).toContain("Showing first 50 sessions. More available.") + }) + + it("should handle list error gracefully", async () => { + mockSessionClient.list = vi.fn().mockRejectedValue(new Error("Network error")) + mockContext.args = ["list"] + + await sessionCommand.handler(mockContext) + + expect(mockContext.addMessage).toHaveBeenCalledTimes(1) + const message = (mockContext.addMessage as ReturnType).mock.calls[0][0] + expect(message.type).toBe("error") + expect(message.content).toContain("Failed to list sessions") + expect(message.content).toContain("Network error") + }) + + it("should format relative time correctly", async () => { + const mockSessions = [ + { + session_id: "session-1", + title: "Just created", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, + ] + + mockSessionClient.list = vi.fn().mockResolvedValue({ + cliSessions: mockSessions, + nextCursor: null, + }) + mockContext.args = ["list"] + + await sessionCommand.handler(mockContext) + + const message = (mockContext.addMessage as ReturnType).mock.calls[0][0] + expect(message.content).toContain("just now") + }) + }) + + describe("handler - select subcommand", () => { + it("should restore session successfully", async () => { + mockContext.args = ["select", "session-123"] + + await sessionCommand.handler(mockContext) + + expect(SessionService.init).toHaveBeenCalled() + expect(mockContext.replaceMessages).toHaveBeenCalledTimes(1) + expect(mockContext.refreshTerminal).toHaveBeenCalled() + expect(mockSessionService.restoreSession).toHaveBeenCalledWith("session-123", true) + + const replacedMessages = (mockContext.replaceMessages as ReturnType).mock.calls[0][0] + expect(replacedMessages).toHaveLength(2) + expect(replacedMessages[1].content).toContain("Restoring session") + expect(replacedMessages[1].content).toContain("session-123") + }) + + it("should show error when sessionId is missing", async () => { + mockContext.args = ["select"] + + await sessionCommand.handler(mockContext) + + expect(mockContext.addMessage).toHaveBeenCalledTimes(1) + 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() + }) + + it("should show error when sessionId is empty string", async () => { + mockContext.args = ["select", ""] + + await sessionCommand.handler(mockContext) + + expect(mockContext.addMessage).toHaveBeenCalledTimes(1) + const message = (mockContext.addMessage as ReturnType).mock.calls[0][0] + expect(message.type).toBe("error") + expect(message.content).toContain("Usage: /session select ") + }) + + it("should handle restore error gracefully", async () => { + mockSessionService.restoreSession = vi.fn().mockRejectedValue(new Error("Session not found")) + mockContext.args = ["select", "invalid-session"] + + await sessionCommand.handler(mockContext) + + expect(mockContext.addMessage).toHaveBeenCalled() + const errorMessage = (mockContext.addMessage as ReturnType).mock.calls.find( + (call) => call[0].type === "error", + ) + expect(errorMessage).toBeDefined() + if (errorMessage) { + expect(errorMessage[0].content).toContain("Failed to restore session") + expect(errorMessage[0].content).toContain("Session not found") + } + }) + + it("should handle 'select' subcommand case-insensitively", async () => { + mockContext.args = ["SELECT", "session-123"] + + await sessionCommand.handler(mockContext) + + expect(mockSessionService.restoreSession).toHaveBeenCalledWith("session-123", true) + }) + }) + + describe("handler - share subcommand", () => { + beforeEach(() => { + // Setup shareSession mock on service + mockSessionService.shareSession = vi.fn().mockResolvedValue({ + share_id: "share-123", + session_id: "test-session-123", + }) + }) + + it("should share current session", async () => { + mockSessionService.sessionId = "test-session-123" + mockContext.args = ["share"] + + await sessionCommand.handler(mockContext) + + expect(SessionService.init).toHaveBeenCalled() + expect(mockSessionService.shareSession).toHaveBeenCalled() + + expect(mockContext.addMessage).toHaveBeenCalledTimes(1) + const message = (mockContext.addMessage as ReturnType).mock.calls[0][0] + expect(message.type).toBe("system") + expect(message.content).toContain("Session shared successfully") + expect(message.content).toContain("share-123") + }) + + it("should handle share error gracefully", async () => { + mockSessionService.sessionId = "test-session-123" + mockSessionService.shareSession = vi.fn().mockRejectedValue(new Error("Not in a git repository")) + mockContext.args = ["share"] + + await sessionCommand.handler(mockContext) + + expect(mockContext.addMessage).toHaveBeenCalledTimes(1) + const message = (mockContext.addMessage as ReturnType).mock.calls[0][0] + expect(message.type).toBe("error") + expect(message.content).toContain("Failed to share session") + expect(message.content).toContain("Not in a git repository") + }) + }) + + describe("handler - search subcommand", () => { + it("should search sessions and display results", async () => { + const mockSearchResults = [ + { + session_id: "session-search-1", + title: "Search Result 1", + created_at: new Date(Date.now() - 7200000).toISOString(), // 2 hours ago + updated_at: new Date().toISOString(), + }, + { + session_id: "session-search-2", + title: "Search Result 2", + created_at: new Date(Date.now() - 86400000).toISOString(), // 1 day ago + updated_at: new Date().toISOString(), + }, + ] + + mockSessionClient.search = vi.fn().mockResolvedValue({ + results: mockSearchResults, + total: 2, + limit: 20, + offset: 0, + }) + mockContext.args = ["search", "test-query"] + + await sessionCommand.handler(mockContext) + + expect(SessionClient.getInstance).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] + expect(message.type).toBe("system") + expect(message.content).toContain("Search Results") + expect(message.content).toContain("2 of 2") + expect(message.content).toContain("Search Result 1") + expect(message.content).toContain("Search Result 2") + expect(message.content).toContain("session-search-1") + expect(message.content).toContain("session-search-2") + }) + + it("should show error when query is missing", async () => { + mockContext.args = ["search"] + + await sessionCommand.handler(mockContext) + + expect(mockContext.addMessage).toHaveBeenCalledTimes(1) + const message = (mockContext.addMessage as ReturnType).mock.calls[0][0] + expect(message.type).toBe("error") + expect(message.content).toContain("Usage: /session search ") + expect(mockSessionClient.search).not.toHaveBeenCalled() + }) + + it("should show error when query is empty string", async () => { + mockContext.args = ["search", ""] + + await sessionCommand.handler(mockContext) + + expect(mockContext.addMessage).toHaveBeenCalledTimes(1) + const message = (mockContext.addMessage as ReturnType).mock.calls[0][0] + expect(message.type).toBe("error") + expect(message.content).toContain("Usage: /session search ") + }) + + it("should handle empty search results", async () => { + mockSessionClient.search = vi.fn().mockResolvedValue({ + results: [], + total: 0, + limit: 20, + offset: 0, + }) + mockContext.args = ["search", "nonexistent"] + + await sessionCommand.handler(mockContext) + + expect(mockContext.addMessage).toHaveBeenCalledTimes(1) + const message = (mockContext.addMessage as ReturnType).mock.calls[0][0] + expect(message.type).toBe("system") + expect(message.content).toContain('No sessions found matching "nonexistent"') + }) + + it("should indicate active session in search results", async () => { + mockSessionService.sessionId = "session-active" + const mockSearchResults = [ + { + session_id: "session-active", + title: "Active Session", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, + { + session_id: "session-inactive", + title: "Inactive Session", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, + ] + + mockSessionClient.search = vi.fn().mockResolvedValue({ + results: mockSearchResults, + total: 2, + limit: 20, + offset: 0, + }) + mockContext.args = ["search", "query"] + + await sessionCommand.handler(mockContext) + + const message = (mockContext.addMessage as ReturnType).mock.calls[0][0] + expect(message.content).toContain("* [Active]") + }) + + it("should handle search error gracefully", async () => { + mockSessionClient.search = vi.fn().mockRejectedValue(new Error("Database error")) + mockContext.args = ["search", "test"] + + await sessionCommand.handler(mockContext) + + expect(mockContext.addMessage).toHaveBeenCalledTimes(1) + const message = (mockContext.addMessage as ReturnType).mock.calls[0][0] + expect(message.type).toBe("error") + expect(message.content).toContain("Failed to search sessions") + expect(message.content).toContain("Database error") + }) + + it("should handle 'search' subcommand case-insensitively", async () => { + mockSessionClient.search = vi.fn().mockResolvedValue({ + results: [], + total: 0, + limit: 20, + offset: 0, + }) + mockContext.args = ["SEARCH", "test"] + + await sessionCommand.handler(mockContext) + + expect(mockSessionClient.search).toHaveBeenCalledWith({ search_string: "test", limit: 20 }) + }) + }) + + describe("handler - fork subcommand", () => { + beforeEach(() => { + // Setup forkSession mock on service + mockSessionService.forkSession = vi.fn().mockResolvedValue({ + session_id: "forked-session-123", + title: "Forked Session", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + api_conversation_history_blob_url: null, + task_metadata_blob_url: null, + ui_messages_blob_url: null, + git_state_blob_url: null, + }) + }) + + it("should fork a session successfully", async () => { + mockContext.args = ["fork", "share-123"] + + await sessionCommand.handler(mockContext) + + expect(SessionService.init).toHaveBeenCalled() + expect(mockContext.replaceMessages).toHaveBeenCalledTimes(1) + expect(mockContext.refreshTerminal).toHaveBeenCalled() + expect(mockSessionService.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] + expect(replacedMessages).toHaveLength(2) + expect(replacedMessages[1].content).toContain("Forking session from share ID") + expect(replacedMessages[1].content).toContain("share-123") + }) + + it("should show error when shareId is missing", async () => { + mockContext.args = ["fork"] + + await sessionCommand.handler(mockContext) + + expect(mockContext.addMessage).toHaveBeenCalledTimes(1) + 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() + }) + + it("should show error when shareId is empty string", async () => { + mockContext.args = ["fork", ""] + + await sessionCommand.handler(mockContext) + + expect(mockContext.addMessage).toHaveBeenCalledTimes(1) + const message = (mockContext.addMessage as ReturnType).mock.calls[0][0] + expect(message.type).toBe("error") + expect(message.content).toContain("Usage: /session fork ") + }) + + it("should handle fork error gracefully", async () => { + mockSessionService.forkSession = vi.fn().mockRejectedValue(new Error("Share ID not found")) + mockContext.args = ["fork", "invalid-share-id"] + + await sessionCommand.handler(mockContext) + + expect(mockContext.addMessage).toHaveBeenCalled() + const errorMessage = (mockContext.addMessage as ReturnType).mock.calls.find( + (call) => call[0].type === "error", + ) + expect(errorMessage).toBeDefined() + if (errorMessage) { + expect(errorMessage[0].content).toContain("Failed to fork session") + expect(errorMessage[0].content).toContain("Share ID not found") + } + }) + + it("should handle 'fork' subcommand case-insensitively", async () => { + mockContext.args = ["FORK", "share-123"] + + await sessionCommand.handler(mockContext) + + expect(mockSessionService.forkSession).toHaveBeenCalledWith("share-123", true) + }) + }) + + describe("handler - rename subcommand", () => { + beforeEach(() => { + // Setup renameSession mock on service + mockSessionService.renameSession = vi.fn().mockResolvedValue(undefined) + }) + + it("should rename session successfully", async () => { + mockSessionService.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(mockContext.addMessage).toHaveBeenCalledTimes(1) + const message = (mockContext.addMessage as ReturnType).mock.calls[0][0] + expect(message.type).toBe("system") + expect(message.content).toContain("Session renamed to") + expect(message.content).toContain("My New Session Name") + }) + + it("should rename session with single word name", async () => { + mockSessionService.sessionId = "test-session-123" + mockContext.args = ["rename", "SingleWord"] + + await sessionCommand.handler(mockContext) + + expect(mockSessionService.renameSession).toHaveBeenCalledWith("SingleWord") + const message = (mockContext.addMessage as ReturnType).mock.calls[0][0] + expect(message.type).toBe("system") + expect(message.content).toContain("SingleWord") + }) + + it("should show error when name is missing", async () => { + mockContext.args = ["rename"] + + await sessionCommand.handler(mockContext) + + expect(mockContext.addMessage).toHaveBeenCalledTimes(1) + 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() + }) + + it("should handle rename error when no active session", async () => { + mockSessionService.renameSession = vi.fn().mockRejectedValue(new Error("No active session")) + mockContext.args = ["rename", "New", "Name"] + + await sessionCommand.handler(mockContext) + + expect(mockContext.addMessage).toHaveBeenCalledTimes(1) + const message = (mockContext.addMessage as ReturnType).mock.calls[0][0] + expect(message.type).toBe("error") + expect(message.content).toContain("Failed to rename session") + expect(message.content).toContain("No active session") + }) + + it("should handle rename error when title is empty", async () => { + mockSessionService.renameSession = vi.fn().mockRejectedValue(new Error("Session title cannot be empty")) + mockContext.args = ["rename", " "] + + await sessionCommand.handler(mockContext) + + expect(mockContext.addMessage).toHaveBeenCalledTimes(1) + const message = (mockContext.addMessage as ReturnType).mock.calls[0][0] + expect(message.type).toBe("error") + expect(message.content).toContain("Failed to rename session") + }) + + it("should handle 'rename' subcommand case-insensitively", async () => { + mockSessionService.sessionId = "test-session-123" + mockContext.args = ["RENAME", "New", "Name"] + + await sessionCommand.handler(mockContext) + + expect(mockSessionService.renameSession).toHaveBeenCalledWith("New Name") + }) + + it("should handle backend error gracefully", async () => { + mockSessionService.renameSession = vi.fn().mockRejectedValue(new Error("Network error")) + mockContext.args = ["rename", "New", "Name"] + + await sessionCommand.handler(mockContext) + + expect(mockContext.addMessage).toHaveBeenCalledTimes(1) + const message = (mockContext.addMessage as ReturnType).mock.calls[0][0] + expect(message.type).toBe("error") + expect(message.content).toContain("Failed to rename session") + expect(message.content).toContain("Network error") + }) + }) + + describe("handler - delete subcommand", () => { + beforeEach(() => { + // Setup delete mock on client + mockSessionClient.delete = vi.fn().mockResolvedValue({ success: true }) + }) + + it("should delete a session successfully", async () => { + mockContext.args = ["delete", "session-123"] + + await sessionCommand.handler(mockContext) + + expect(SessionClient.getInstance).toHaveBeenCalled() + expect(mockSessionClient.delete).toHaveBeenCalledWith({ session_id: "session-123" }) + expect(mockContext.addMessage).toHaveBeenCalledTimes(1) + const message = (mockContext.addMessage as ReturnType).mock.calls[0][0] + expect(message.type).toBe("system") + expect(message.content).toContain("Session `session-123` deleted successfully") + }) + + it("should show error when sessionId is missing", async () => { + mockContext.args = ["delete"] + + await sessionCommand.handler(mockContext) + + expect(mockContext.addMessage).toHaveBeenCalledTimes(1) + const message = (mockContext.addMessage as ReturnType).mock.calls[0][0] + expect(message.type).toBe("error") + expect(message.content).toContain("Usage: /session delete ") + expect(mockSessionClient.delete).not.toHaveBeenCalled() + }) + + it("should show error when sessionId is empty string", async () => { + mockContext.args = ["delete", ""] + + await sessionCommand.handler(mockContext) + + expect(mockContext.addMessage).toHaveBeenCalledTimes(1) + const message = (mockContext.addMessage as ReturnType).mock.calls[0][0] + expect(message.type).toBe("error") + expect(message.content).toContain("Usage: /session delete ") + }) + + it("should handle delete error gracefully", async () => { + mockSessionClient.delete = vi.fn().mockRejectedValue(new Error("Session not found")) + mockContext.args = ["delete", "nonexistent-session"] + + await sessionCommand.handler(mockContext) + + expect(mockContext.addMessage).toHaveBeenCalledTimes(1) + const message = (mockContext.addMessage as ReturnType).mock.calls[0][0] + expect(message.type).toBe("error") + expect(message.content).toContain("Failed to delete session") + expect(message.content).toContain("Session not found") + }) + + it("should handle 'delete' subcommand case-insensitively", async () => { + mockContext.args = ["DELETE", "session-123"] + + await sessionCommand.handler(mockContext) + + expect(mockSessionClient.delete).toHaveBeenCalledWith({ session_id: "session-123" }) + }) + }) + + describe("handler - invalid subcommand", () => { + it("should show error for unknown subcommand", async () => { + mockContext.args = ["invalid"] + + await sessionCommand.handler(mockContext) + + expect(mockContext.addMessage).toHaveBeenCalledTimes(1) + const message = (mockContext.addMessage as ReturnType).mock.calls[0][0] + expect(message.type).toBe("error") + expect(message.content).toContain("Unknown subcommand") + expect(message.content).toContain("invalid") + expect(message.content).toContain("show") + expect(message.content).toContain("list") + expect(message.content).toContain("search") + expect(message.content).toContain("select") + expect(message.content).toContain("share") + expect(message.content).toContain("fork") + expect(message.content).toContain("delete") + expect(message.content).toContain("rename") + }) + + it("should not call SessionService for invalid subcommand", async () => { + mockContext.args = ["invalid"] + + await sessionCommand.handler(mockContext) + + expect(SessionService.init).not.toHaveBeenCalled() + expect(SessionClient.getInstance).not.toHaveBeenCalled() + }) + }) + + describe("handler - execution", () => { + it("should execute without errors when session exists", async () => { + mockSessionService.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 + mockContext.args = ["show"] + + await expect(sessionCommand.handler(mockContext)).resolves.not.toThrow() + }) + + it("should execute without errors for invalid subcommand", async () => { + mockContext.args = ["invalid"] + + await expect(sessionCommand.handler(mockContext)).resolves.not.toThrow() + }) + }) + + describe("message generation", () => { + it("should generate messages with proper structure", async () => { + mockSessionService.sessionId = "test-id" + mockContext.args = ["show"] + + await sessionCommand.handler(mockContext) + + const message = (mockContext.addMessage as ReturnType).mock.calls[0][0] + expect(message).toHaveProperty("id") + expect(message).toHaveProperty("type") + expect(message).toHaveProperty("content") + expect(message).toHaveProperty("ts") + }) + }) + + describe("sessionIdAutocompleteProvider", () => { + // Helper to create minimal ArgumentProviderContext for testing + const createProviderContext = (partialInput: string, subcommand?: string): ArgumentProviderContext => ({ + commandName: "session", + argumentIndex: 1, + argumentName: "argument", + currentArgs: [], + currentOptions: {}, + partialInput, + getArgument: (name: string) => (name === "subcommand" ? subcommand : undefined), + parsedValues: { args: {}, options: {} }, + command: sessionCommand, + }) + + it("should NOT run provider when subcommand is 'share'", async () => { + const conditionalProvider = sessionCommand.arguments![1].conditionalProviders![0] + const condition = conditionalProvider.condition + const context = createProviderContext("test", "share") + + const shouldRun = condition(context) + + expect(shouldRun).toBe(false) + }) + + it("should run provider when subcommand is 'select'", async () => { + const conditionalProvider = sessionCommand.arguments![1].conditionalProviders![0] + const condition = conditionalProvider.condition + const context = createProviderContext("test", "select") + + const shouldRun = condition(context) + + expect(shouldRun).toBe(true) + }) + + it("should run provider when subcommand is 'delete'", async () => { + const conditionalProvider = sessionCommand.arguments![1].conditionalProviders![0] + const condition = conditionalProvider.condition + const context = createProviderContext("test", "delete") + + const shouldRun = condition(context) + + expect(shouldRun).toBe(true) + }) + + it("should NOT run provider when subcommand is 'fork'", async () => { + const conditionalProvider = sessionCommand.arguments![1].conditionalProviders![0] + const condition = conditionalProvider.condition + const context = createProviderContext("test", "fork") + + const shouldRun = condition(context) + + expect(shouldRun).toBe(false) + }) + + it("should return empty array for empty prefix", async () => { + const provider = sessionCommand.arguments![1].conditionalProviders![0].provider + const context = createProviderContext("", "select") + + const result = await provider(context) + + expect(result).toEqual([]) + expect(mockSessionClient.search).not.toHaveBeenCalled() + }) + + it("should return empty array for whitespace-only prefix", async () => { + const provider = sessionCommand.arguments![1].conditionalProviders![0].provider + const context = createProviderContext(" ", "select") + + const result = await provider(context) + + expect(result).toEqual([]) + expect(mockSessionClient.search).not.toHaveBeenCalled() + }) + + it("should call sessionClient.search with searchString", async () => { + const mockSessions = [ + { + session_id: "session-abc123", + title: "ABC Session", + created_at: "2025-01-01T00:00:00Z", + updated_at: "2025-01-01T00:00:00Z", + }, + { + session_id: "session-abc456", + title: "Another ABC", + created_at: "2025-01-02T00:00:00Z", + updated_at: "2025-01-02T00:00:00Z", + }, + ] + + mockSessionClient.search = vi.fn().mockResolvedValue({ + results: mockSessions, + total: 2, + limit: 20, + offset: 0, + }) + + const provider = sessionCommand.arguments![1].conditionalProviders![0].provider + const context = createProviderContext("abc", "select") + + const result = await provider(context) + + expect(mockSessionClient.search).toHaveBeenCalledWith({ search_string: "abc", limit: 20 }) + expect(result).toHaveLength(2) + }) + + it("should map results correctly to suggestion format", async () => { + const mockSessions = [ + { + session_id: "session-test123", + title: "Test Session", + created_at: "2025-01-15T10:30:00Z", + updated_at: "2025-01-15T10:30:00Z", + }, + ] + + mockSessionClient.search = vi.fn().mockResolvedValue({ + results: mockSessions, + total: 1, + limit: 20, + offset: 0, + }) + + const provider = sessionCommand.arguments![1].conditionalProviders![0].provider + const context = createProviderContext("test", "select") + + const result = (await provider(context)) as ArgumentSuggestion[] + + expect(result).toHaveLength(1) + expect(result[0]).toMatchObject({ + value: "session-test123", + highlightedValue: "session-test123", + }) + // Description should include title and created date + expect(result[0].description).toContain("Test Session") + expect(result[0].description).toContain("Created:") + expect(result[0].matchScore).toBe(100) // First item gets score of 100 + }) + + it("should handle Untitled sessions", async () => { + const mockSessions = [ + { + session_id: "session-untitled", + title: "", + created_at: "2025-01-15T10:30:00Z", + updated_at: "2025-01-15T10:30:00Z", + }, + ] + + mockSessionClient.search = vi.fn().mockResolvedValue({ + results: mockSessions, + total: 1, + limit: 20, + offset: 0, + }) + + const provider = sessionCommand.arguments![1].conditionalProviders![0].provider + const context = createProviderContext("session-untitled", "select") + + const result = (await provider(context)) as ArgumentSuggestion[] + + // When no title, description should only show created date + expect(result[0].value).toBe("session-untitled") + expect(result[0].highlightedValue).toBe("session-untitled") + expect(result[0].description).toContain("Created:") + expect(result[0].description).not.toContain("|") // No title separator + }) + + it("should preserve backend ordering with matchScore", async () => { + const mockSessions = [ + { + session_id: "session-1", + title: "Most Recent", + created_at: "2025-01-15T10:30:00Z", + updated_at: "2025-01-15T10:30:00Z", + }, + { + session_id: "session-2", + title: "Second", + created_at: "2025-01-14T10:30:00Z", + updated_at: "2025-01-14T10:30:00Z", + }, + { + session_id: "session-3", + title: "Third", + created_at: "2025-01-13T10:30:00Z", + updated_at: "2025-01-13T10:30:00Z", + }, + ] + + mockSessionClient.search = vi.fn().mockResolvedValue({ + results: mockSessions, + total: 3, + limit: 20, + offset: 0, + }) + + const provider = sessionCommand.arguments![1].conditionalProviders![0].provider + const context = createProviderContext("session", "select") + + const result = (await provider(context)) as ArgumentSuggestion[] + + // Verify descending matchScore to preserve backend ordering + expect(result[0].matchScore).toBe(100) + expect(result[1].matchScore).toBe(99) + expect(result[2].matchScore).toBe(98) + + // Verify description format includes title + expect(result[0].description).toContain("Most Recent") + expect(result[1].description).toContain("Second") + expect(result[2].description).toContain("Third") + }) + + it("should return all backend results without client-side filtering", async () => { + const mockSessions = [ + { + session_id: "session-abc123", + title: "My Project", + created_at: "2025-01-15T10:30:00Z", + updated_at: "2025-01-15T10:30:00Z", + }, + { + session_id: "session-xyz789", + title: "ABC Task", + created_at: "2025-01-14T10:30:00Z", + updated_at: "2025-01-14T10:30:00Z", + }, + { + session_id: "session-def456", + title: "Other Task", + created_at: "2025-01-13T10:30:00Z", + updated_at: "2025-01-13T10:30:00Z", + }, + ] + + mockSessionClient.search = vi.fn().mockResolvedValue({ + results: mockSessions, + total: 3, + limit: 20, + offset: 0, + }) + + const provider = sessionCommand.arguments![1].conditionalProviders![0].provider + const context = createProviderContext("abc", "select") + + const result = (await provider(context)) as ArgumentSuggestion[] + + // Backend handles filtering, so all results should be returned + expect(result).toHaveLength(3) + expect(result[0].value).toBe("session-abc123") + expect(result[1].value).toBe("session-xyz789") + expect(result[2].value).toBe("session-def456") + }) + + it("should handle errors gracefully", async () => { + mockSessionClient.search = vi.fn().mockRejectedValue(new Error("Network error")) + + const provider = sessionCommand.arguments![1].conditionalProviders![0].provider + const context = createProviderContext("test", "select") + + const result = await provider(context) + + expect(result).toEqual([]) + }) + + it("should handle empty results from backend", async () => { + mockSessionClient.search = vi.fn().mockResolvedValue({ + results: [], + total: 0, + limit: 20, + offset: 0, + }) + + const provider = sessionCommand.arguments![1].conditionalProviders![0].provider + const context = createProviderContext("nonexistent", "select") + + const result = await provider(context) + + expect(result).toEqual([]) + }) + + it("should pass limit parameter to search", async () => { + mockSessionClient.search = vi.fn().mockResolvedValue({ + results: [], + total: 0, + limit: 20, + offset: 0, + }) + + const provider = sessionCommand.arguments![1].conditionalProviders![0].provider + const context = createProviderContext("test", "select") + + await provider(context) + + expect(mockSessionClient.search).toHaveBeenCalledWith({ search_string: "test", limit: 20 }) + }) + + it("should truncate long titles in description", async () => { + const longTitle = "This is a very long session title that exceeds fifty characters and should be truncated" + const mockSessions = [ + { + session_id: "session-long-title", + title: longTitle, + created_at: "2025-01-15T10:30:00Z", + updated_at: "2025-01-15T10:30:00Z", + }, + ] + + mockSessionClient.search = vi.fn().mockResolvedValue({ + results: mockSessions, + total: 1, + limit: 20, + offset: 0, + }) + + const provider = sessionCommand.arguments![1].conditionalProviders![0].provider + const context = createProviderContext("test", "select") + + const result = (await provider(context)) as ArgumentSuggestion[] + + expect(result).toHaveLength(1) + // Title should be truncated to 50 chars with "..." + expect(result[0].description).not.toContain(longTitle) + expect(result[0].description).toContain("...") + // The truncated title should be 50 chars (47 chars + "...") + const titlePart = result[0].description!.split(" | ")[0] + expect(titlePart.length).toBe(50) + }) + + it("should not truncate short titles", async () => { + const shortTitle = "Short Title" + const mockSessions = [ + { + session_id: "session-short-title", + title: shortTitle, + created_at: "2025-01-15T10:30:00Z", + updated_at: "2025-01-15T10:30:00Z", + }, + ] + + mockSessionClient.search = vi.fn().mockResolvedValue({ + results: mockSessions, + total: 1, + limit: 20, + offset: 0, + }) + + const provider = sessionCommand.arguments![1].conditionalProviders![0].provider + const context = createProviderContext("test", "select") + + const result = (await provider(context)) as ArgumentSuggestion[] + + expect(result).toHaveLength(1) + // Short title should not be truncated + expect(result[0].description).toContain(shortTitle) + expect(result[0].description).not.toContain("...") + }) + }) +}) diff --git a/cli/src/commands/index.ts b/cli/src/commands/index.ts index bb083ca7b1e..53c44cdbd0b 100644 --- a/cli/src/commands/index.ts +++ b/cli/src/commands/index.ts @@ -20,6 +20,7 @@ import { configCommand } from "./config.js" import { tasksCommand } from "./tasks.js" import { themeCommand } from "./theme.js" import { checkpointCommand } from "./checkpoint.js" +import { sessionCommand } from "./session.js" /** * Initialize all commands @@ -39,4 +40,5 @@ export function initializeCommands(): void { commandRegistry.register(tasksCommand) commandRegistry.register(themeCommand) commandRegistry.register(checkpointCommand) + commandRegistry.register(sessionCommand) } diff --git a/cli/src/commands/mode.ts b/cli/src/commands/mode.ts index 54cb30a5c8d..473ce1f93cf 100644 --- a/cli/src/commands/mode.ts +++ b/cli/src/commands/mode.ts @@ -34,7 +34,11 @@ export const modeCommand: Command = { const modesList = allModes.map((mode) => { // Treat undefined source as "global" (for built-in modes from @roo-code/types) const source = - mode.source === "project" ? " (project)" : mode.source === "global" || !mode.source ? " (global)" : "" + mode.source === "project" + ? " (project)" + : mode.source === "global" || !mode.source + ? " (global)" + : "" return ` - **${mode.name}** (${mode.slug})${source}: ${mode.description || "No description"}` }) diff --git a/cli/src/commands/new.ts b/cli/src/commands/new.ts index 7d305ffab12..e25393adc3f 100644 --- a/cli/src/commands/new.ts +++ b/cli/src/commands/new.ts @@ -4,6 +4,7 @@ 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", @@ -19,6 +20,15 @@ export const newCommand: Command = { // Clear the extension task state (this also clears extension messages) await clearTask() + // Clear the session to start fresh + try { + const sessionService = SessionService.init() + await sessionService.destroy() + } catch (error) { + // Log error but don't block the command - session might not exist yet + console.error("Failed to clear session:", error) + } + // Replace CLI message history with fresh welcome message // This will increment the reset counter, forcing Static component to re-render replaceMessages([ diff --git a/cli/src/commands/session.ts b/cli/src/commands/session.ts new file mode 100644 index 00000000000..9655b2a42e9 --- /dev/null +++ b/cli/src/commands/session.ts @@ -0,0 +1,459 @@ +/** + * /session command - Manage session information + */ + +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" + +/** + * Show current session ID + */ +async function showSessionId(context: CommandContext): Promise { + const { addMessage } = context + + const sessionService = SessionService.init() + const sessionId = sessionService.sessionId + + if (!sessionId) { + addMessage({ + ...generateMessage(), + type: "system", + content: "No active session. Start a new task to create a session.", + }) + return + } + + addMessage({ + ...generateMessage(), + type: "system", + content: `**Current Session ID:** ${sessionId}`, + }) +} + +/** + * List all sessions + */ +async function listSessions(context: CommandContext): Promise { + const { addMessage } = context + const sessionService = SessionService.init() + const sessionClient = SessionClient.getInstance() + + try { + const result = await sessionClient.list({ limit: 50 }) + const { cliSessions } = result + + if (cliSessions.length === 0) { + addMessage({ + ...generateMessage(), + type: "system", + content: "No sessions found.", + }) + return + } + + // Format and display sessions + let content = `**Available Sessions:**\n\n` + cliSessions.forEach((session, index) => { + const isActive = session.session_id === sessionService.sessionId ? " * [Active]" : "" + const title = session.title || "Untitled" + const createdTime = formatRelativeTime(new Date(session.created_at).getTime()) + + content += `${index + 1}. **${title}**${isActive}\n` + content += ` ID: \`${session.session_id}\`\n` + content += ` Created: ${createdTime}\n\n` + }) + + if (result.nextCursor) { + content += `\n_Showing first ${cliSessions.length} sessions. More available._` + } + + addMessage({ + ...generateMessage(), + type: "system", + content, + }) + } catch (error) { + addMessage({ + ...generateMessage(), + type: "error", + content: `Failed to list sessions: ${error instanceof Error ? error.message : String(error)}`, + }) + } +} + +/** + * Select a specific session + */ +async function selectSession(context: CommandContext, sessionId: string): Promise { + const { addMessage, replaceMessages, refreshTerminal } = context + const sessionService = SessionService.init() + + if (!sessionId) { + addMessage({ + ...generateMessage(), + type: "error", + content: "Usage: /session select ", + }) + return + } + + try { + // Clear messages and show loading state + const now = Date.now() + replaceMessages([ + { + id: `empty-${now}`, + type: "empty", + content: "", + ts: 1, + }, + { + id: `system-${now + 1}`, + type: "system", + content: `Restoring session \`${sessionId}\`...`, + ts: 2, + }, + ]) + + await refreshTerminal() + await sessionService.restoreSession(sessionId, true) + + // Success message is handled by restoreSession via extension messages + } catch (error) { + addMessage({ + ...generateMessage(), + type: "error", + content: `Failed to restore session: ${error instanceof Error ? error.message : String(error)}`, + }) + } +} + +/** + * Search sessions by title or ID + */ +async function searchSessions(context: CommandContext, query: string): Promise { + const { addMessage } = context + const sessionService = SessionService.init() + const sessionClient = SessionClient.getInstance() + + if (!query) { + addMessage({ + ...generateMessage(), + type: "error", + content: "Usage: /session search ", + }) + return + } + + try { + const result = await sessionClient.search({ search_string: query, limit: 20 }) + const { results, total } = result + + if (results.length === 0) { + addMessage({ + ...generateMessage(), + type: "system", + content: `No sessions found matching "${query}".`, + }) + return + } + + let content = `**Search Results** (${results.length} of ${total}):\n\n` + results.forEach((session, index) => { + const isActive = session.session_id === sessionService.sessionId ? " * [Active]" : "" + const title = session.title || "Untitled" + const createdTime = formatRelativeTime(new Date(session.created_at).getTime()) + + content += `${index + 1}. **${title}**${isActive}\n` + content += ` ID: \`${session.session_id}\`\n` + content += ` Created: ${createdTime}\n\n` + }) + + addMessage({ + ...generateMessage(), + type: "system", + content, + }) + } catch (error) { + addMessage({ + ...generateMessage(), + type: "error", + content: `Failed to search sessions: ${error instanceof Error ? error.message : String(error)}`, + }) + } +} + +/** + * Share the current session publicly with git state + */ +async function shareSession(context: CommandContext): Promise { + const { addMessage } = context + const sessionService = SessionService.init() + + try { + const result = await sessionService.shareSession() + + addMessage({ + ...generateMessage(), + type: "system", + content: `✅ Session shared successfully!\n\n\`https://kilo.ai/share/${result.share_id}\``, + }) + } catch (error) { + addMessage({ + ...generateMessage(), + type: "error", + content: `Failed to share session: ${error instanceof Error ? error.message : String(error)}`, + }) + } +} + +/** + * Fork a shared session by share ID + */ +async function forkSession(context: CommandContext, shareId: string): Promise { + const { addMessage, replaceMessages, refreshTerminal } = context + const sessionService = SessionService.init() + + if (!shareId) { + addMessage({ + ...generateMessage(), + type: "error", + content: "Usage: /session fork ", + }) + return + } + + try { + // Clear messages and show loading state + const now = Date.now() + replaceMessages([ + { + id: `empty-${now}`, + type: "empty", + content: "", + ts: 1, + }, + { + id: `system-${now + 1}`, + type: "system", + content: `Forking session from share ID \`${shareId}\`...`, + ts: 2, + }, + ]) + + await refreshTerminal() + + await sessionService.forkSession(shareId, true) + + // Success message handled by restoreSession via extension messages + } catch (error) { + addMessage({ + ...generateMessage(), + type: "error", + content: `Failed to fork session: ${error instanceof Error ? error.message : String(error)}`, + }) + } +} + +/** + * Delete a session by ID + */ +async function deleteSession(context: CommandContext, sessionId: string): Promise { + const { addMessage } = context + const sessionClient = SessionClient.getInstance() + + if (!sessionId) { + addMessage({ + ...generateMessage(), + type: "error", + content: "Usage: /session delete ", + }) + return + } + + try { + await sessionClient.delete({ session_id: sessionId }) + + addMessage({ + ...generateMessage(), + type: "system", + content: `✅ Session \`${sessionId}\` deleted successfully.`, + }) + } catch (error) { + addMessage({ + ...generateMessage(), + type: "error", + content: `Failed to delete session: ${error instanceof Error ? error.message : String(error)}`, + }) + } +} + +/** + * Rename the current session + */ +async function renameSession(context: CommandContext, newName: string): Promise { + const { addMessage } = context + const sessionService = SessionService.init() + + if (!newName) { + addMessage({ + ...generateMessage(), + type: "error", + content: "Usage: /session rename ", + }) + return + } + + try { + await sessionService.renameSession(newName) + + addMessage({ + ...generateMessage(), + type: "system", + content: `✅ Session renamed to "${newName}".`, + }) + } catch (error) { + addMessage({ + ...generateMessage(), + type: "error", + content: `Failed to rename session: ${error instanceof Error ? error.message : String(error)}`, + }) + } +} + +/** + * Autocomplete provider for session IDs + */ +async function sessionIdAutocompleteProvider(context: ArgumentProviderContext): Promise { + const sessionClient = SessionClient.getInstance() + + // Extract prefix from user input + const prefix = context.partialInput.trim() + + // Return empty array if no input + if (!prefix) { + return [] + } + + try { + const response = await sessionClient.search({ search_string: prefix, limit: 20 }) + + return response.results.map((session, index) => { + const title = session.title || "Untitled" + const truncatedTitle = title.length > 50 ? `${title.slice(0, 47)}...` : title + + const description = session.title + ? `${truncatedTitle} | Created: ${new Date(session.created_at).toLocaleDateString()}` + : `Created: ${new Date(session.created_at).toLocaleDateString()}` + + return { + value: session.session_id, + description, + matchScore: 100 - index, // Backend orders by updated_at DESC, preserve order + highlightedValue: session.session_id, + } + }) + } catch (_error) { + return [] + } +} + +export const sessionCommand: Command = { + name: "session", + aliases: [], + description: "Manage sessions", + usage: "/session [subcommand] [args]", + examples: [ + "/session show", + "/session list", + "/session search ", + "/session select ", + "/session share", + "/session fork ", + "/session delete ", + "/session rename ", + ], + category: "system", + priority: 5, + arguments: [ + { + name: "subcommand", + description: "Subcommand: show, list, search, select, share, fork, delete, rename", + required: false, + values: [ + { value: "show", description: "Display current session ID" }, + { value: "list", description: "List all sessions" }, + { value: "search", description: "Search sessions by title or ID" }, + { value: "select", description: "Restore a session" }, + { value: "share", description: "Share current session publicly" }, + { value: "fork", description: "Fork a shared session" }, + { value: "delete", description: "Delete a session" }, + { value: "rename", description: "Rename the current session" }, + ], + }, + { + name: "argument", + description: "Argument for the subcommand", + required: false, + conditionalProviders: [ + { + condition: (context) => { + const subcommand = context.getArgument("subcommand") + return subcommand === "select" || subcommand === "delete" + }, + provider: sessionIdAutocompleteProvider, + }, + ], + }, + ], + handler: async (context) => { + const { args, addMessage } = context + + if (args.length === 0) { + addMessage({ + ...generateMessage(), + type: "system", + content: "Usage: /session [show|list|search|select|share|fork|delete|rename] [args]", + }) + return + } + + const subcommand = args[0]?.toLowerCase() + + switch (subcommand) { + case "show": + await showSessionId(context) + break + case "list": + await listSessions(context) + break + case "search": + await searchSessions(context, args[1] || "") + break + case "select": + await selectSession(context, args[1] || "") + break + case "share": + await shareSession(context) + break + case "fork": + await forkSession(context, args[1] || "") + break + case "delete": + await deleteSession(context, args[1] || "") + break + case "rename": + await renameSession(context, args.slice(1).join(" ")) + break + default: + addMessage({ + ...generateMessage(), + type: "error", + content: `Unknown subcommand "${subcommand}". Available: show, list, search, select, share, fork, delete, rename`, + }) + } + }, +} diff --git a/cli/src/commands/tasks.ts b/cli/src/commands/tasks.ts index 2854b20cd91..a88dfc12937 100644 --- a/cli/src/commands/tasks.ts +++ b/cli/src/commands/tasks.ts @@ -6,10 +6,8 @@ import { generateMessage } from "../ui/utils/messages.js" import type { Command, ArgumentProviderContext, CommandContext } from "./core/types.js" import type { HistoryItem } from "@roo-code/types" import type { TaskHistoryData, TaskHistoryFilters } from "../state/atoms/taskHistory.js" +import { formatRelativeTime } from "../utils/time.js" -/** - * Map kebab-case sort options to camelCase - */ const SORT_OPTION_MAP: Record = { newest: "newest", oldest: "oldest", @@ -18,23 +16,6 @@ const SORT_OPTION_MAP: Record = { "most-relevant": "mostRelevant", } -/** - * Format a timestamp as a relative time string - */ -function formatRelativeTime(ts: number): string { - const now = Date.now() - const diff = now - ts - const seconds = Math.floor(diff / 1000) - const minutes = Math.floor(seconds / 60) - const hours = Math.floor(minutes / 60) - const days = Math.floor(hours / 24) - - if (days > 0) return `${days}d ago` - if (hours > 0) return `${hours}h ago` - if (minutes > 0) return `${minutes}m ago` - return "just now" -} - /** * Format cost as a currency string */ diff --git a/cli/src/config/__tests__/persistence.test.ts b/cli/src/config/__tests__/persistence.test.ts index 9823b119f4f..806d90e57cc 100644 --- a/cli/src/config/__tests__/persistence.test.ts +++ b/cli/src/config/__tests__/persistence.test.ts @@ -10,6 +10,7 @@ import { configExists, setConfigPaths, resetConfigPaths, + getKiloToken, } from "../persistence.js" import { DEFAULT_CONFIG } from "../defaults.js" @@ -247,4 +248,114 @@ describe("Config Persistence", () => { expect(exists).toBe(true) }) }) + + describe("getKiloToken", () => { + it("should extract kilocodeToken from kilocode provider", async () => { + const config = { + version: "1.0.0", + mode: "code", + telemetry: true, + provider: "default", + providers: [ + { + id: "default", + provider: "kilocode", + kilocodeToken: "provider-token-1234567890", + kilocodeModel: "anthropic/claude-sonnet-4.5", + }, + ], + autoApproval: DEFAULT_CONFIG.autoApproval, + theme: "dark", + } as CLIConfig + + const token = getKiloToken(config) + expect(token).toBe("provider-token-1234567890") + }) + + it("should return null when provider is not kilocode", async () => { + const config = { + version: "1.0.0", + mode: "code", + telemetry: true, + provider: "anthropic-provider", + providers: [ + { + id: "anthropic-provider", + provider: "anthropic", + apiKey: "anthropic-key-1234567890", + apiModelId: "claude-3-5-sonnet-20241022", + }, + ], + autoApproval: DEFAULT_CONFIG.autoApproval, + theme: "dark", + } as CLIConfig + + const token = getKiloToken(config) + expect(token).toBeNull() + }) + + it("should return null when provider is kilocode but kilocodeToken doesn't exist", async () => { + const config = { + version: "1.0.0", + mode: "code", + telemetry: true, + provider: "default", + providers: [ + { + id: "default", + provider: "kilocode", + kilocodeModel: "anthropic/claude-sonnet-4.5", + }, + ], + autoApproval: DEFAULT_CONFIG.autoApproval, + theme: "dark", + } as CLIConfig + + const token = getKiloToken(config) + expect(token).toBeNull() + }) + + it("should return empty string when provider has empty kilocodeToken", async () => { + const config = { + version: "1.0.0", + mode: "code", + telemetry: true, + provider: "default", + providers: [ + { + id: "default", + provider: "kilocode", + kilocodeToken: "", + kilocodeModel: "anthropic/claude-sonnet-4.5", + }, + ], + autoApproval: DEFAULT_CONFIG.autoApproval, + theme: "dark", + } as CLIConfig + + const token = getKiloToken(config) + expect(token).toBe("") + }) + + it("should return null when no kilocode provider exists", async () => { + const config = { + version: "1.0.0", + mode: "code", + telemetry: true, + provider: "openai-provider", + providers: [ + { + id: "openai-provider", + provider: "openai", + apiKey: "openai-key-1234567890", + }, + ], + autoApproval: DEFAULT_CONFIG.autoApproval, + theme: "dark", + } as CLIConfig + + const token = getKiloToken(config) + expect(token).toBeNull() + }) + }) }) diff --git a/cli/src/config/customModes.ts b/cli/src/config/customModes.ts index 2e0101ea559..7fae43e2d66 100644 --- a/cli/src/config/customModes.ts +++ b/cli/src/config/customModes.ts @@ -106,8 +106,7 @@ function parseCustomModes(content: string, source: "global" | "project"): ModeCo roleDefinition: (m.roleDefinition as string) || (m.systemPrompt as string) || "", groups: (m.groups as ModeConfig["groups"]) || ["read", "edit", "browser", "command", "mcp"], customInstructions: - (m.customInstructions as string) || - (m.rules ? (m.rules as string[]).join("\n") : undefined), + (m.customInstructions as string) || (m.rules ? (m.rules as string[]).join("\n") : undefined), source: (m.source as ModeConfig["source"]) || source, } }) diff --git a/cli/src/config/persistence.ts b/cli/src/config/persistence.ts index e6d3e6893f0..b105ce2362d 100644 --- a/cli/src/config/persistence.ts +++ b/cli/src/config/persistence.ts @@ -80,6 +80,16 @@ function deepMerge(target: any, source: any): any { return result } +export function getKiloToken(config: CLIConfig) { + const kiloProvider = config.providers.find((p) => p.provider === "kilocode") + + if (kiloProvider && "kilocodeToken" in kiloProvider) { + return kiloProvider.kilocodeToken + } + + return null +} + /** * Merge loaded config with defaults to fill in missing keys */ diff --git a/cli/src/index.ts b/cli/src/index.ts index aaf6506ea8c..dab6507b327 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -43,6 +43,8 @@ program .option("-eb, --existing-branch ", "(Parallel mode only) Instructs the agent to work on an existing branch") .option("-pv, --provider ", "Select provider by ID (e.g., 'kilocode-1')") .option("-mo, --model ", "Override model for the selected provider") + .option("-s, --session ", "Restore a session by ID") + .option("-f, --fork ", "Fork a shared session by share ID") .option("--nosplash", "Disable the welcome message and update notifications", false) .argument("[prompt]", "The prompt or command to execute") .action(async (prompt, options) => { @@ -124,6 +126,12 @@ program process.exit(1) } + // Validate that --fork and --session are not used together + if (options.fork && options.session) { + console.error("Error: --fork and --session options cannot be used together") + process.exit(1) + } + // Validate provider if specified if (options.provider) { // Load config to check if provider exists @@ -210,6 +218,8 @@ program continue: options.continue, provider: options.provider, model: options.model, + session: options.session, + fork: options.fork, noSplash: options.nosplash, }) await cli.start() diff --git a/cli/src/services/__tests__/extension.singleCompletion.test.ts b/cli/src/services/__tests__/extension.singleCompletion.test.ts new file mode 100644 index 00000000000..449a40d856a --- /dev/null +++ b/cli/src/services/__tests__/extension.singleCompletion.test.ts @@ -0,0 +1,469 @@ +import path from "path" +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest" +import { ExtensionService } from "../extension.js" +import type { ExtensionMessage, WebviewMessage } from "../../types/messages.js" + +// Mock the extension-paths module +vi.mock("../../utils/extension-paths.js", () => ({ + resolveExtensionPaths: () => ({ + extensionBundlePath: "/mock/extension/dist/extension.js", + extensionRootPath: "/mock/extension", + }), +})) + +// Type definitions for mocks +interface MockExtensionModule { + activate: ReturnType + deactivate: ReturnType +} + +interface MockVSCodeAPI { + context: { + subscriptions: unknown[] + secrets: { + get: ReturnType + store: ReturnType + } + globalState: { + get: ReturnType + update: ReturnType + } + workspaceState: { + get: ReturnType + update: ReturnType + } + extensionPath: string + extensionUri: { fsPath: string } + } + window: { + registerWebviewViewProvider: ReturnType + showInformationMessage: ReturnType + showErrorMessage: ReturnType + showWarningMessage: ReturnType + createOutputChannel: ReturnType + } + workspace: { + workspaceFolders: Array<{ uri: { fsPath: string } }> + onDidChangeConfiguration: ReturnType + getConfiguration: ReturnType + } + commands: { + registerCommand: ReturnType + } + Uri: { + file: ReturnType + joinPath: ReturnType + } + EventEmitter: ReturnType +} + +// Extend global type for test mocks +declare global { + // eslint-disable-next-line no-var + var __extensionHost: + | { + registerWebviewProvider: ( + name: string, + provider: { handleCLIMessage: ReturnType }, + ) => void + } + | undefined + // eslint-disable-next-line no-var + var vscode: MockVSCodeAPI | undefined +} + +describe("ExtensionService - requestSingleCompletion", () => { + let service: ExtensionService + let mockExtensionModule: MockExtensionModule + let originalRequire: NodeJS.Require + let mockVSCodeAPI: MockVSCodeAPI + + beforeEach(() => { + // Create a mock VSCode API + mockVSCodeAPI = { + context: { + subscriptions: [], + secrets: { + get: vi.fn(async () => + JSON.stringify({ + currentApiConfigName: "default", + apiConfigs: { + default: { + id: "test-id", + apiProvider: "anthropic", + apiKey: "test-api-key", + apiModelId: "claude-3-5-sonnet-20241022", + }, + }, + modeApiConfigs: {}, + migrations: { + rateLimitSecondsMigrated: true, + diffSettingsMigrated: true, + openAiHeadersMigrated: true, + consecutiveMistakeLimitMigrated: true, + todoListEnabledMigrated: true, + morphApiKeyMigrated: true, + }, + }), + ), + store: vi.fn(async () => {}), + }, + globalState: { + get: vi.fn(), + update: vi.fn(), + }, + workspaceState: { + get: vi.fn(), + update: vi.fn(), + }, + extensionPath: "/mock/extension", + extensionUri: { fsPath: "/mock/extension" }, + }, + window: { + registerWebviewViewProvider: vi.fn(), + showInformationMessage: vi.fn(), + showErrorMessage: vi.fn(), + showWarningMessage: vi.fn(), + createOutputChannel: vi.fn(() => ({ + appendLine: vi.fn(), + append: vi.fn(), + clear: vi.fn(), + dispose: vi.fn(), + })), + }, + workspace: { + workspaceFolders: [{ uri: { fsPath: "/mock/workspace" } }], + onDidChangeConfiguration: vi.fn(), + getConfiguration: vi.fn(() => ({ + get: vi.fn(), + update: vi.fn(), + })), + }, + commands: { + registerCommand: vi.fn(), + }, + Uri: { + file: vi.fn((filePath: string) => ({ fsPath: filePath })), + joinPath: vi.fn((uri: { fsPath: string }, ...paths: string[]) => ({ + fsPath: path.join(uri.fsPath, ...paths), + })), + }, + EventEmitter: vi.fn(() => ({ + event: vi.fn(), + fire: vi.fn(), + dispose: vi.fn(), + })), + } + + // Create a mock extension module + mockExtensionModule = { + activate: vi.fn(async (_context) => { + // Register a mock webview provider + if (global.__extensionHost) { + const mockProvider = { + handleCLIMessage: vi.fn(async () => {}), + } + global.__extensionHost.registerWebviewProvider("kilo-code.SidebarProvider", mockProvider) + } + + return { + getState: vi.fn(() => ({ + version: "1.0.0", + apiConfiguration: { + apiProvider: "anthropic", + apiKey: "test-api-key", + apiModelId: "claude-3-5-sonnet-20241022", + }, + clineMessages: [], + mode: "code", + customModes: [], + taskHistoryFullLength: 0, + taskHistoryVersion: 0, + renderContext: "cli", + telemetrySetting: "disabled", + cwd: "/mock/workspace", + mcpServers: [], + listApiConfigMeta: [], + currentApiConfigName: "default", + })), + sendMessage: vi.fn(), + updateState: vi.fn(), + startNewTask: vi.fn(), + cancelTask: vi.fn(), + condense: vi.fn(), + condenseTaskContext: vi.fn(), + handleTerminalOperation: vi.fn(), + } + }), + deactivate: vi.fn(async () => {}), + } + + // Mock the module loading system + // eslint-disable-next-line @typescript-eslint/no-require-imports + const Module = require("module") as { prototype: { require: NodeJS.Require } } + originalRequire = Module.prototype.require + + Module.prototype.require = function (this: NodeJS.Module, id: string) { + if (id === "/mock/extension/dist/extension.js") { + return mockExtensionModule + } + if (id === "vscode" || id === "vscode-mock") { + return mockVSCodeAPI + } + return originalRequire.call(this, id) + } as NodeJS.Require + + // Set global vscode + global.vscode = mockVSCodeAPI + }) + + afterEach(async () => { + if (service) { + await service.dispose() + } + + // Restore original require + if (originalRequire) { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const Module = require("module") as { prototype: { require: typeof require } } + Module.prototype.require = originalRequire + } + + // Clean up global vscode + delete global.vscode + }) + + describe("Error Handling", () => { + it("should throw error when service is not ready", async () => { + service = new ExtensionService({ + extensionBundlePath: "/mock/extension/dist/extension.js", + extensionRootPath: "/mock/extension", + }) + + // Don't initialize - service not ready + await expect(service.requestSingleCompletion("test")).rejects.toThrow("ExtensionService not ready") + }) + + it("should handle message send failures", async () => { + service = new ExtensionService({ + extensionBundlePath: "/mock/extension/dist/extension.js", + extensionRootPath: "/mock/extension", + }) + + await service.initialize() + service.getExtensionHost().markWebviewReady() + + // Mock sendWebviewMessage to fail + vi.spyOn(service, "sendWebviewMessage").mockRejectedValue(new Error("Send failed")) + + await expect(service.requestSingleCompletion("test")).rejects.toThrow("Send failed") + }) + }) + + describe("Timeout Handling", () => { + it("should timeout if no response received within default timeout", async () => { + service = new ExtensionService({ + extensionBundlePath: "/mock/extension/dist/extension.js", + extensionRootPath: "/mock/extension", + }) + + await service.initialize() + service.getExtensionHost().markWebviewReady() + + // Use a very short timeout for testing + const completionPromise = service.requestSingleCompletion("test", 100) + + // Don't send any response - let it timeout + await expect(completionPromise).rejects.toThrow("Single completion request timed out") + }, 10000) + + it("should use custom timeout value", async () => { + service = new ExtensionService({ + extensionBundlePath: "/mock/extension/dist/extension.js", + extensionRootPath: "/mock/extension", + }) + + await service.initialize() + service.getExtensionHost().markWebviewReady() + + const customTimeout = 200 + const completionPromise = service.requestSingleCompletion("test", customTimeout) + + await expect(completionPromise).rejects.toThrow("Single completion request timed out") + }, 10000) + + it("should cleanup event listeners on timeout", async () => { + service = new ExtensionService({ + extensionBundlePath: "/mock/extension/dist/extension.js", + extensionRootPath: "/mock/extension", + }) + + await service.initialize() + service.getExtensionHost().markWebviewReady() + + const initialListenerCount = service.listenerCount("message") + + try { + await service.requestSingleCompletion("test", 100) + } catch (_error) { + // Expected to timeout + } + + // Wait a bit for cleanup + await new Promise((resolve) => setTimeout(resolve, 50)) + + // Listener count should be back to initial + expect(service.listenerCount("message")).toBe(initialListenerCount) + }, 10000) + }) + + describe("Request Correlation", () => { + it("should not mix up responses for different request IDs", async () => { + service = new ExtensionService({ + extensionBundlePath: "/mock/extension/dist/extension.js", + extensionRootPath: "/mock/extension", + }) + + await service.initialize() + service.getExtensionHost().markWebviewReady() + + // Capture request IDs + const requestIds: string[] = [] + const originalSend = service.sendWebviewMessage.bind(service) + vi.spyOn(service, "sendWebviewMessage").mockImplementation(async (msg: WebviewMessage) => { + if (msg.type === "singleCompletion" && "completionRequestId" in msg) { + requestIds.push(msg.completionRequestId as string) + } + return originalSend(msg) + }) + + const promise1 = service.requestSingleCompletion("prompt 1") + const promise2 = service.requestSingleCompletion("prompt 2") + + await new Promise((resolve) => setTimeout(resolve, 100)) + + const requestId1 = requestIds[0] + const requestId2 = requestIds[1] + + // Send response for request 2 first + service.emit("message", { + type: "singleCompletionResult", + completionRequestId: requestId2, + completionText: "result 2", + success: true, + } as ExtensionMessage) + + // Send response for request 1 + service.emit("message", { + type: "singleCompletionResult", + completionRequestId: requestId1, + completionText: "result 1", + success: true, + } as ExtensionMessage) + + const result1 = await promise1 + const result2 = await promise2 + + expect(result1).toBe("result 1") + expect(result2).toBe("result 2") + }) + }) + + describe("Edge Cases", () => { + it("should handle very long prompts", async () => { + service = new ExtensionService({ + extensionBundlePath: "/mock/extension/dist/extension.js", + extensionRootPath: "/mock/extension", + }) + + await service.initialize() + service.getExtensionHost().markWebviewReady() + + let capturedRequestId: string | undefined + const originalSend = service.sendWebviewMessage.bind(service) + vi.spyOn(service, "sendWebviewMessage").mockImplementation(async (msg: WebviewMessage) => { + if (msg.type === "singleCompletion" && "completionRequestId" in msg) { + capturedRequestId = msg.completionRequestId as string + setTimeout(() => { + service.emit("message", { + type: "singleCompletionResult", + completionRequestId: capturedRequestId, + completionText: "result", + success: true, + } as ExtensionMessage) + }, 10) + } + return originalSend(msg) + }) + + const longPrompt = "a".repeat(10000) + const result = await service.requestSingleCompletion(longPrompt) + expect(result).toBe("result") + }) + + it("should handle special characters in prompt", async () => { + service = new ExtensionService({ + extensionBundlePath: "/mock/extension/dist/extension.js", + extensionRootPath: "/mock/extension", + }) + + await service.initialize() + service.getExtensionHost().markWebviewReady() + + let capturedRequestId: string | undefined + const originalSend = service.sendWebviewMessage.bind(service) + vi.spyOn(service, "sendWebviewMessage").mockImplementation(async (msg: WebviewMessage) => { + if (msg.type === "singleCompletion" && "completionRequestId" in msg) { + capturedRequestId = msg.completionRequestId as string + setTimeout(() => { + service.emit("message", { + type: "singleCompletionResult", + completionRequestId: capturedRequestId, + completionText: "result", + success: true, + } as ExtensionMessage) + }, 10) + } + return originalSend(msg) + }) + + const specialPrompt = "Test with\nnewlines\tand\ttabs and 'quotes'" + const result = await service.requestSingleCompletion(specialPrompt) + expect(result).toBe("result") + }) + + it("should cleanup listeners on successful completion", async () => { + service = new ExtensionService({ + extensionBundlePath: "/mock/extension/dist/extension.js", + extensionRootPath: "/mock/extension", + }) + + await service.initialize() + service.getExtensionHost().markWebviewReady() + + const initialListenerCount = service.listenerCount("message") + + let capturedRequestId: string | undefined + const originalSend = service.sendWebviewMessage.bind(service) + vi.spyOn(service, "sendWebviewMessage").mockImplementation(async (msg: WebviewMessage) => { + if (msg.type === "singleCompletion" && "completionRequestId" in msg) { + capturedRequestId = msg.completionRequestId as string + setTimeout(() => { + service.emit("message", { + type: "singleCompletionResult", + completionRequestId: capturedRequestId, + completionText: "result", + success: true, + } as ExtensionMessage) + }, 10) + } + return originalSend(msg) + }) + + await service.requestSingleCompletion("test") + + // Listener count should be back to initial + expect(service.listenerCount("message")).toBe(initialListenerCount) + }) + }) +}) diff --git a/cli/src/services/__tests__/session.test.ts b/cli/src/services/__tests__/session.test.ts new file mode 100644 index 00000000000..e38f27ad9a5 --- /dev/null +++ b/cli/src/services/__tests__/session.test.ts @@ -0,0 +1,2623 @@ +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 new file mode 100644 index 00000000000..ebf6fdbe730 --- /dev/null +++ b/cli/src/services/__tests__/sessionClient.test.ts @@ -0,0 +1,733 @@ +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 new file mode 100644 index 00000000000..2d9c08cf8a2 --- /dev/null +++ b/cli/src/services/__tests__/trpcClient.test.ts @@ -0,0 +1,422 @@ +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/extension.ts b/cli/src/services/extension.ts index 7f86603db41..ca2dcb96c59 100644 --- a/cli/src/services/extension.ts +++ b/cli/src/services/extension.ts @@ -261,6 +261,58 @@ export class ExtensionService extends EventEmitter { } } + /** + * Request a single completion from the extension + * + * @param prompt - The prompt text to complete + * @param timeoutMs - Request timeout in milliseconds (default: 60000) + * @returns Promise resolving to the completed text + */ + async requestSingleCompletion(prompt: string, timeoutMs: number = 60000): Promise { + if (!this.isReady()) { + throw new Error("ExtensionService not ready") + } + + const completionRequestId = crypto.randomUUID() + + return new Promise((resolve, reject) => { + // Setup timeout + const timeoutId = setTimeout(() => { + this.off("message", messageHandler) + reject(new Error("Single completion request timed out")) + }, timeoutMs) + + // Setup message handler + const messageHandler = (message: ExtensionMessage) => { + if (message.type === "singleCompletionResult" && message.completionRequestId === completionRequestId) { + clearTimeout(timeoutId) + this.off("message", messageHandler) + + if (message.success && typeof message.completionText === "string") { + resolve(message.completionText) + } else { + const errorMessage = + typeof message.completionError === "string" ? message.completionError : "Unknown error" + reject(new Error(errorMessage)) + } + } + } + + this.on("message", messageHandler) + + // Send request + this.sendWebviewMessage({ + type: "singleCompletion", + text: prompt, + completionRequestId, + }).catch((error) => { + clearTimeout(timeoutId) + this.off("message", messageHandler) + reject(error) + }) + }) + } + /** * Get the current extension state * diff --git a/cli/src/services/session.ts b/cli/src/services/session.ts new file mode 100644 index 00000000000..0b3e0fb455f --- /dev/null +++ b/cli/src/services/session.ts @@ -0,0 +1,937 @@ +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 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" + +const defaultPaths = { + apiConversationHistoryPath: null as null | string, + uiMessagesPath: null as null | string, + taskMetadataPath: null as null | string, +} + +export class SessionService { + 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) + + logs.debug("Initialized SessionService", "SessionService") + } + + const instance = SessionService.instance + + if (!instance) { + throw new Error("SessionService not initialized") + } + + instance.startTimer() + + return SessionService.instance! + } + + private paths = { ...defaultPaths } + public sessionId: string | null = null + private workspaceDir: string | null = null + private sessionTitle: string | null = null + private sessionGitUrl: string | null = null + + private timer: NodeJS.Timeout | null = null + private blobHashes = this.createDefaultBlobHashes() + private lastSyncedBlobHashes = this.createDefaultBlobHashes() + private isSyncing: boolean = false + + private constructor( + private extensionService: ExtensionService, + private store: ReturnType, + private jsonMode: boolean, + ) {} + + setPath(key: keyof typeof defaultPaths, value: string) { + this.paths[key] = value + + const blobKey = this.pathKeyToBlobKey(key) + + if (blobKey) { + this.updateBlobHash(blobKey) + } + } + + setWorkspaceDirectory(dir: string): void { + this.workspaceDir = dir + } + + private saveLastSessionId(sessionId: string): void { + if (!this.workspaceDir) { + logs.warn("Cannot save last session ID: workspace directory not set", "SessionService") + return + } + + 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), + }) + } + } + + 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 + } + + 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), + }) + 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 }) + + try { + await this.restoreSession(lastSessionId, true) + + logs.info("Successfully restored persisted session", "SessionService", { sessionId: lastSessionId }) + return true + } catch (error) { + logs.warn("Failed to restore persisted session", "SessionService", { + 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 }) + + // 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({ + session_id: sessionId, + include_blob_urls: true, + })) as SessionWithSignedUrls + + if (!session) { + logs.error("Failed to obtain session", "SessionService", { sessionId }) + + throw new Error("Failed to obtain session") + } + + this.sessionTitle = session.title + + const sessionDirectoryPath = path.join(KiloCodePaths.getTasksDir(), sessionId) + + ensureDirSync(sessionDirectoryPath) + + // Fetch and write each blob type from signed URLs + const blobUrlFields = [ + "api_conversation_history_blob_url", + "ui_messages_blob_url", + "task_metadata_blob_url", + "git_state_blob_url", + ] as const + + const fetchPromises = blobUrlFields + .filter((blobUrlField) => { + const signedUrl = session[blobUrlField] + if (!signedUrl) { + logs.debug(`No signed URL for ${blobUrlField}`, "SessionService") + return false + } + return true + }) + .map(async (blobUrlField) => { + const signedUrl = session[blobUrlField]! + + return { + filename: blobUrlField.replace("_blob_url", ""), + result: await this.fetchBlobFromSignedUrl(signedUrl, blobUrlField) + .then((content) => ({ success: true as const, content })) + .catch((error) => ({ + success: false as const, + error: error instanceof Error ? error.message : String(error), + })), + } + }) + + const results = await Promise.allSettled(fetchPromises) + + for (const result of results) { + if (result.status === "fulfilled") { + const { filename, result: fetchResult } = result.value + + if (fetchResult.success) { + let fileContent = fetchResult.content + + if (filename === "git_state") { + const gitState = fileContent as Parameters[0] + + await this.executeGitRestore(gitState) + + continue + } + + if (filename === "ui_messages") { + // eliminate checkpoints for now + fileContent = (fileContent as ClineMessage[]).filter( + (message) => message.say !== "checkpoint_saved", + ) + } + + const fullPath = path.join(sessionDirectoryPath, `${filename}.json`) + + writeFileSync(fullPath, JSON.stringify(fileContent, null, 2)) + + logs.debug(`Wrote blob to file`, "SessionService", { fullPath }) + } else { + logs.error(`Failed to process blob`, "SessionService", { + filename, + error: fetchResult.error, + }) + } + } + } + + const historyItem: HistoryItem = { + id: sessionId, + number: 1, + task: session.title, + ts: new Date(session.created_at).getTime(), + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + } + + // Send message to register the task in extension history + await this.extensionService.sendWebviewMessage({ + type: "addTaskToHistory", + historyItem, + }) + + logs.info("Task registered with extension", "SessionService", { + sessionId, + taskId: historyItem.id, + }) + + // Automatically switch to the restored task + await this.extensionService.sendWebviewMessage({ + type: "showTaskWithId", + text: sessionId, + }) + + logs.info("Switched to restored task", "SessionService", { sessionId }) + + this.saveLastSessionId(sessionId) + + this.store.set(taskResumedViaContinueOrSessionAtom, true) + + logs.debug("Marked task as resumed after session restoration", "SessionService", { sessionId }) + } catch (error) { + logs.error("Failed to restore session", "SessionService", { + error: error instanceof Error ? error.message : String(error), + sessionId, + }) + + this.sessionId = null + this.sessionTitle = null + this.sessionGitUrl = null + this.resetBlobHashes() + + if (rethrowError) { + throw error + } + } finally { + this.isSyncing = false + } + } + + async shareSession(): Promise { + const sessionId = this.sessionId + if (!sessionId) { + throw new Error("No active session") + } + + const sessionClient = SessionClient.getInstance() + + return await sessionClient.share({ + session_id: sessionId, + shared_state: CliSessionSharedState.Public, + }) + } + + async renameSession(newTitle: string): Promise { + const sessionId = this.sessionId + if (!sessionId) { + throw new Error("No active session") + } + + const trimmedTitle = newTitle.trim() + if (!trimmedTitle) { + throw new Error("Session title cannot be empty") + } + + const sessionClient = SessionClient.getInstance() + + await sessionClient.update({ + session_id: sessionId, + title: trimmedTitle, + }) + + this.sessionTitle = trimmedTitle + + logs.info("Session renamed successfully", "SessionService", { + sessionId, + newTitle: trimmedTitle, + }) + } + + async forkSession(shareId: string, rethrowError = false) { + const sessionClient = SessionClient.getInstance() + const { session_id } = await sessionClient.fork({ share_id: shareId }) + + await this.restoreSession(session_id, rethrowError) + } + + async destroy() { + logs.debug("Destroying SessionService", "SessionService", { + sessionId: this.sessionId, + isSyncing: this.isSyncing, + }) + + if (this.timer) { + clearInterval(this.timer) + this.timer = null + } + + if (this.sessionId) { + if (this.isSyncing) { + await new Promise((r) => setTimeout(r, 2000)) + } else { + await this.syncSession(true) + } + } + + this.paths = { ...defaultPaths } + this.sessionId = null + this.sessionTitle = null + this.isSyncing = false + + logs.debug("SessionService flushed", "SessionService") + } + + private startTimer() { + if (!this.timer) { + this.timer = setInterval(() => { + this.syncSession() + }, SessionService.SYNC_INTERVAL) + } + } + + private async syncSession(force = false) { + if (!force) { + if (this.isSyncing) { + return + } + + if (Object.values(this.paths).every((item) => !item)) { + return + } + + if (!this.hasAnyBlobChanged()) { + return + } + } + + this.isSyncing = true + + try { + const rawPayload = this.readPaths() + + if (Object.values(rawPayload).every((item) => !item)) { + this.isSyncing = false + + return + } + + const sessionClient = SessionClient.getInstance() + + const basePayload: Omit[0], "created_on_platform"> = {} + + let gitInfo: Awaited> | null = null + + try { + gitInfo = await this.getGitState() + + if (gitInfo?.repoUrl) { + basePayload.git_url = gitInfo.repoUrl + } + } catch (error) { + logs.debug("Could not get git state", "SessionService", { + error: error instanceof Error ? error.message : String(error), + }) + } + + if (this.sessionId) { + const gitUrlChanged = gitInfo?.repoUrl && gitInfo.repoUrl !== this.sessionGitUrl + + if (gitUrlChanged) { + logs.debug("Updating existing session", "SessionService", { sessionId: this.sessionId }) + + await sessionClient.update({ + session_id: this.sessionId, + ...basePayload, + }) + + this.sessionGitUrl = gitInfo?.repoUrl || null + + logs.debug("Session updated successfully", "SessionService", { sessionId: this.sessionId }) + } + } else { + logs.debug("Creating new session", "SessionService") + + if (rawPayload.uiMessagesPath) { + const title = this.getFirstMessageText(rawPayload.uiMessagesPath as ClineMessage[], true) + + if (title) { + basePayload.title = title + } + } + + const session = await sessionClient.create({ + ...basePayload, + created_on_platform: process.env.KILO_PLATFORM || "cli", + }) + + this.sessionId = session.session_id + this.sessionGitUrl = gitInfo?.repoUrl || null + + logs.info("Session created successfully", "SessionService", { sessionId: this.sessionId }) + + this.saveLastSessionId(this.sessionId) + + if (this.jsonMode) { + console.log( + JSON.stringify({ + timestamp: Date.now(), + event: "session_created", + sessionId: this.sessionId, + }), + ) + } + } + + const blobUploads: Array> = [] + + if (rawPayload.apiConversationHistoryPath && this.hasBlobChanged("apiConversationHistory")) { + blobUploads.push( + sessionClient + .uploadBlob(this.sessionId, "api_conversation_history", rawPayload.apiConversationHistoryPath) + .then(() => { + this.markBlobSynced("apiConversationHistory") + logs.debug("Uploaded api_conversation_history blob", "SessionService") + }) + .catch((error) => { + logs.error("Failed to upload api_conversation_history blob", "SessionService", { + error: error instanceof Error ? error.message : String(error), + }) + }), + ) + } + + if (rawPayload.taskMetadataPath && this.hasBlobChanged("taskMetadata")) { + blobUploads.push( + sessionClient + .uploadBlob(this.sessionId, "task_metadata", rawPayload.taskMetadataPath) + .then(() => { + this.markBlobSynced("taskMetadata") + logs.debug("Uploaded task_metadata blob", "SessionService") + }) + .catch((error) => { + logs.error("Failed to upload task_metadata blob", "SessionService", { + error: error instanceof Error ? error.message : String(error), + }) + }), + ) + } + + if (rawPayload.uiMessagesPath && this.hasBlobChanged("uiMessages")) { + blobUploads.push( + sessionClient + .uploadBlob(this.sessionId, "ui_messages", rawPayload.uiMessagesPath) + .then(() => { + this.markBlobSynced("uiMessages") + logs.debug("Uploaded ui_messages blob", "SessionService") + }) + .catch((error) => { + logs.error("Failed to upload ui_messages blob", "SessionService", { + error: error instanceof Error ? error.message : String(error), + }) + }), + ) + } + + if (gitInfo) { + const gitStateData = { + head: gitInfo.head, + patch: gitInfo.patch, + branch: gitInfo.branch, + } + + const gitStateHash = this.hashGitState(gitStateData) + + if (gitStateHash !== this.blobHashes.gitState) { + this.blobHashes.gitState = gitStateHash + + if (this.hasBlobChanged("gitState")) { + blobUploads.push( + sessionClient + .uploadBlob(this.sessionId, "git_state", gitStateData) + .then(() => { + this.markBlobSynced("gitState") + logs.debug("Uploaded git_state blob", "SessionService") + }) + .catch((error) => { + logs.error("Failed to upload git_state blob", "SessionService", { + error: error instanceof Error ? error.message : String(error), + }) + }), + ) + } + } + } + + 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) { + return this.renameSession(generatedTitle) + } + + return null + }) + .catch((error) => { + logs.warn("Failed to generate session title", "SessionService", { + error: error instanceof Error ? error.message : String(error), + }) + }) + } + } catch (error) { + logs.error("Failed to sync session", "SessionService", { + error: error instanceof Error ? error.message : String(error), + sessionId: this.sessionId, + hasApiHistory: !!this.paths.apiConversationHistoryPath, + hasUiMessages: !!this.paths.uiMessagesPath, + hasTaskMetadata: !!this.paths.taskMetadataPath, + }) + } finally { + this.isSyncing = false + } + } + + private readPath(path: string) { + try { + const content = readFileSync(path, "utf-8") + try { + return JSON.parse(content) + } catch { + return undefined + } + } catch { + return undefined + } + } + + private readPaths() { + const contents: Partial> = {} + + for (const [key, value] of Object.entries(this.paths)) { + if (!value) { + continue + } + + const content = this.readPath(value) + if (content !== undefined) { + contents[key as keyof typeof this.paths] = content + } + } + + return contents + } + + private async fetchBlobFromSignedUrl(url: string, urlType: string): Promise { + try { + logs.debug(`Fetching blob from signed URL`, "SessionService", { url, urlType }) + + const response = await fetch(url) + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + + const data = await response.json() + + logs.debug(`Successfully fetched blob`, "SessionService", { url, urlType }) + + return data + } catch (error) { + logs.error(`Failed to fetch blob from signed URL`, "SessionService", { + url, + urlType, + error: error instanceof Error ? error.message : String(error), + }) + throw error + } + } + + private pathKeyToBlobKey(pathKey: keyof typeof defaultPaths): keyof typeof this.blobHashes | null { + switch (pathKey) { + case "apiConversationHistoryPath": + return "apiConversationHistory" + case "uiMessagesPath": + return "uiMessages" + case "taskMetadataPath": + return "taskMetadata" + default: + return null + } + } + + private updateBlobHash(blobKey: keyof typeof this.blobHashes) { + this.blobHashes[blobKey] = crypto.randomUUID() + } + + private hasBlobChanged(blobKey: keyof typeof this.blobHashes): boolean { + return this.blobHashes[blobKey] !== this.lastSyncedBlobHashes[blobKey] + } + + private hasAnyBlobChanged(): boolean { + return ( + this.hasBlobChanged("apiConversationHistory") || + this.hasBlobChanged("uiMessages") || + this.hasBlobChanged("taskMetadata") || + this.hasBlobChanged("gitState") + ) + } + + private markBlobSynced(blobKey: keyof typeof this.blobHashes) { + this.lastSyncedBlobHashes[blobKey] = this.blobHashes[blobKey] + } + + private hashGitState( + gitState: Pick>>, "head" | "patch" | "branch">, + ): string { + return createHash("sha256").update(JSON.stringify(gitState)).digest("hex") + } + + private createDefaultBlobHashes() { + return { + apiConversationHistory: "", + uiMessages: "", + taskMetadata: "", + gitState: "", + } + } + + private resetBlobHashes() { + this.blobHashes = this.createDefaultBlobHashes() + this.lastSyncedBlobHashes = this.createDefaultBlobHashes() + } + + private async getGitState() { + const cwd = this.workspaceDir || process.cwd() + const git = simpleGit(cwd) + + const remotes = await git.getRemotes(true) + const repoUrl = remotes[0]?.refs?.fetch || remotes[0]?.refs?.push + + const head = await git.revparse(["HEAD"]) + + let branch: string | undefined + try { + const symbolicRef = await git.raw(["symbolic-ref", "-q", "HEAD"]) + branch = symbolicRef.trim().replace(/^refs\/heads\//, "") + } catch { + branch = undefined + } + + const untrackedOutput = await git.raw(["ls-files", "--others", "--exclude-standard"]) + const untrackedFiles = untrackedOutput.trim().split("\n").filter(Boolean) + + if (untrackedFiles.length > 0) { + await git.raw(["add", "--intent-to-add", "--", ...untrackedFiles]) + } + + try { + let patch = await git.diff(["HEAD"]) + + if (!patch || patch.trim().length === 0) { + const parents = await git.raw(["rev-list", "--parents", "-n", "1", "HEAD"]) + const isFirstCommit = parents.trim().split(" ").length === 1 + + if (isFirstCommit) { + const nullDevice = process.platform === "win32" ? "NUL" : "/dev/null" + const emptyTreeHash = (await git.raw(["hash-object", "-t", "tree", nullDevice])).trim() + patch = await git.diff([emptyTreeHash, "HEAD"]) + } + } + + return { + repoUrl, + head, + branch, + patch, + } + } finally { + if (untrackedFiles.length > 0) { + await git.raw(["reset", "HEAD", "--", ...untrackedFiles]) + } + } + } + + private async executeGitRestore(gitState: { head: string; patch: string; branch: string }): Promise { + try { + const cwd = this.workspaceDir || process.cwd() + const git = simpleGit(cwd) + + let shouldPop = false + + try { + const stashListBefore = await git.stashList() + const stashCountBefore = stashListBefore.total + + await git.stash() + + const stashListAfter = await git.stashList() + const stashCountAfter = stashListAfter.total + + if (stashCountAfter > stashCountBefore) { + shouldPop = true + logs.debug(`Stashed current work`, "SessionService") + } else { + logs.debug(`No changes to stash`, "SessionService") + } + } catch (error) { + logs.warn(`Failed to stash current work`, "SessionService", { + error: error instanceof Error ? error.message : String(error), + }) + } + + try { + const currentHead = await git.revparse(["HEAD"]) + + if (currentHead.trim() === gitState.head.trim()) { + logs.debug(`Already at target commit, skipping checkout`, "SessionService", { + head: gitState.head.substring(0, 8), + }) + } else { + if (gitState.branch) { + try { + const branchCommit = await git.revparse([gitState.branch]) + + if (branchCommit.trim() === gitState.head.trim()) { + await git.checkout(gitState.branch) + + logs.debug(`Checked out to branch`, "SessionService", { + 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), + }) + } + } 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), + }) + } + } else { + await git.checkout(gitState.head) + + logs.debug(`No branch info, checked out to commit (detached HEAD)`, "SessionService", { + head: gitState.head.substring(0, 8), + }) + } + } + } catch (error) { + logs.warn(`Failed to checkout`, "SessionService", { + branch: gitState.branch, + head: gitState.head.substring(0, 8), + error: error instanceof Error ? error.message : String(error), + }) + } + + try { + const tempDir = mkdtempSync(path.join(tmpdir(), "kilocode-git-patches")) + const patchFile = path.join(tempDir, `${Date.now()}.patch`) + + try { + writeFileSync(patchFile, gitState.patch) + + await git.applyPatch(patchFile) + + logs.debug(`Applied patch`, "SessionService", { + patchSize: gitState.patch.length, + }) + } finally { + try { + rmSync(patchFile, { recursive: true, force: true }) + } catch { + // Ignore error + } + } + } catch (error) { + logs.warn(`Failed to apply patch`, "SessionService", { + error: error instanceof Error ? error.message : String(error), + }) + } + + try { + if (shouldPop) { + await git.stash(["pop"]) + + logs.debug(`Popped stash`, "SessionService") + } + } catch (error) { + logs.warn(`Failed to pop stash`, "SessionService", { + error: error instanceof Error ? error.message : String(error), + }) + } + + logs.info(`Git state restored successfully`, "SessionService", { + head: gitState.head.substring(0, 8), + }) + } catch (error) { + logs.error(`Failed to restore git state`, "SessionService", { + error: error instanceof Error ? error.message : String(error), + }) + } + } + + getFirstMessageText(uiMessages: ClineMessage[], truncate = false): string | null { + if (uiMessages.length === 0) { + return null + } + + const firstMessageWithText = uiMessages.find((msg) => msg.text) + + if (!firstMessageWithText?.text) { + return null + } + + let rawText = firstMessageWithText.text.trim() + rawText = rawText.replace(/\s+/g, " ") + + if (!rawText) { + return null + } + + if (truncate && rawText.length > 140) { + return rawText.substring(0, 137) + "..." + } + + return rawText + } + + async generateTitle(uiMessages: ClineMessage[]): Promise { + const rawText = this.getFirstMessageText(uiMessages) + + if (!rawText) { + return null + } + + if (rawText.length <= 140) { + return rawText + } + + try { + const prompt = `Summarize the following user request in 140 characters or less. Be concise and capture the main intent. Do not use quotes or add any prefix like "Summary:" - just provide the summary text directly. Your result will be used as the conversation title. + +User request: +${rawText} + +Summary:` + + const summary = await this.extensionService.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", { + error: error instanceof Error ? error.message : String(error), + }) + + // Fallback to simple truncation + return rawText.substring(0, 137) + "..." + } + } +} diff --git a/cli/src/services/sessionClient.ts b/cli/src/services/sessionClient.ts new file mode 100644 index 00000000000..29cea69030e --- /dev/null +++ b/cli/src/services/sessionClient.ts @@ -0,0 +1,252 @@ +import { TrpcClient, TrpcResponse } from "./trpcClient.js" + +// Type definitions matching backend schema +export interface Session { + session_id: string + title: string + created_at: string + updated_at: string +} + +export interface SessionWithSignedUrls extends Session { + api_conversation_history_blob_url: string | null + task_metadata_blob_url: string | null + ui_messages_blob_url: string | null + git_state_blob_url: string | null +} + +export interface GetSessionInput { + session_id: string + include_blob_urls?: boolean +} + +export type GetSessionOutput = Session | SessionWithSignedUrls + +export interface CreateSessionInput { + title?: string + git_url?: string + created_on_platform: string +} + +export type CreateSessionOutput = Session + +export interface UpdateSessionInput { + session_id: string + title?: string + git_url?: string +} + +export interface UpdateSessionOutput { + session_id: string + title: string + updated_at: string +} + +export interface ListSessionsInput { + cursor?: string + limit?: number +} + +export interface ListSessionsOutput { + cliSessions: Session[] + nextCursor: string | null +} + +export interface SearchSessionInput { + search_string: string + limit?: number + offset?: number +} + +export interface SearchSessionOutput { + results: Session[] + total: number + limit: number + offset: number +} + +// Shared state enum +export enum CliSessionSharedState { + Public = "public", +} + +export type ShareSessionInput = { + session_id: string + shared_state: CliSessionSharedState +} + +export interface ShareSessionOutput { + share_id: string + session_id: string +} + +export interface ForkSessionInput { + share_id: string +} + +export interface ForkSessionOutput { + session_id: string +} + +export interface DeleteSessionInput { + session_id: string +} + +export interface DeleteSessionOutput { + success: boolean + session_id: string +} + +export class SessionClient { + private static instance: SessionClient | null = null + + static getInstance() { + if (!SessionClient.instance) { + SessionClient.instance = new SessionClient() + } + + return SessionClient.instance! + } + + private constructor() {} + + /** + * 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 + } + + /** + * Create a new session + */ + async create(input: CreateSessionInput): Promise { + const client = TrpcClient.init() + const response = await client.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>( + "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>( + "cliSessions.list", + "GET", + input || {}, + ) + return response.result.data + } + + /** + * Search sessions + */ + async search(input: SearchSessionInput): Promise { + const client = TrpcClient.init() + const response = await client.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 + } + + /** + * 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 + } + + /** + * Delete a session + */ + async delete(input: DeleteSessionInput): Promise { + const client = TrpcClient.init() + const response = await client.request>( + "cliSessions.delete", + "POST", + input, + ) + return response.result.data + } + + /** + * Upload a blob for a session + */ + async uploadBlob( + sessionId: string, + 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 url = new URL(`${endpoint}/api/upload-cli-session-blob`) + url.searchParams.set("session_id", sessionId) + url.searchParams.set("blob_type", blobType) + + const response = await fetch(url.toString(), { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + 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}` : "" + }`, + ) + } + + return response.json() + } +} diff --git a/cli/src/services/trpcClient.ts b/cli/src/services/trpcClient.ts new file mode 100644 index 00000000000..4c597390dd1 --- /dev/null +++ b/cli/src/services/trpcClient.ts @@ -0,0 +1,62 @@ +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 8cde76eef4f..644205dc815 100644 --- a/cli/src/state/atoms/effects.ts +++ b/cli/src/state/atoms/effects.ts @@ -32,6 +32,7 @@ import { resolveTaskHistoryRequestAtom, } from "./taskHistory.js" import { logs } from "../../services/logs.js" +import { SessionService } from "../../services/session.js" /** * Message buffer to handle race conditions during initialization @@ -370,6 +371,43 @@ export const messageHandlerEffectAtom = atom(null, (get, set, message: Extension break } + case "apiMessagesSaved": { + const payload = message.payload as [string, string] | undefined + + if (payload && Array.isArray(payload) && payload.length === 2) { + const [, filePath] = payload + + SessionService.init().setPath("apiConversationHistoryPath", filePath) + } else { + logs.warn(`[DEBUG] Invalid apiMessagesSaved payload`, "effects", { payload }) + } + break + } + + case "taskMessagesSaved": { + const payload = message.payload as [string, string] | undefined + + if (payload && Array.isArray(payload) && payload.length === 2) { + const [, filePath] = payload + + SessionService.init().setPath("uiMessagesPath", filePath) + } else { + logs.warn(`[DEBUG] Invalid taskMessagesSaved payload`, "effects", { payload }) + } + break + } + + case "taskMetadataSaved": { + const payload = message.payload as [string, string] | undefined + if (payload && Array.isArray(payload) && payload.length === 2) { + const [, filePath] = payload + + SessionService.init().setPath("taskMetadataPath", filePath) + } else { + logs.warn(`[DEBUG] Invalid taskMetadataSaved payload`, "effects", { payload }) + } + break + } case "commandExecutionStatus": { // Handle command execution status messages // Store output updates and apply them when the ask appears diff --git a/cli/src/state/atoms/extension.ts b/cli/src/state/atoms/extension.ts index 24e84b2a36b..3869c04bfec 100644 --- a/cli/src/state/atoms/extension.ts +++ b/cli/src/state/atoms/extension.ts @@ -152,7 +152,7 @@ export const hasActiveTaskAtom = atom((get) => { * Atom to track if the task was resumed via --continue flag * Prevents showing "Task ready to resume" message when already resumed */ -export const taskResumedViaContinueAtom = atom(false) +export const taskResumedViaContinueOrSessionAtom = atom(false) /** * Derived atom to check if there's a resume_task ask pending @@ -160,7 +160,7 @@ export const taskResumedViaContinueAtom = atom(false) * But doesn't show the message if the task was already resumed via --continue */ export const hasResumeTaskAtom = atom((get) => { - const taskResumedViaContinue = get(taskResumedViaContinueAtom) + const taskResumedViaContinue = get(taskResumedViaContinueOrSessionAtom) if (taskResumedViaContinue) { return false } diff --git a/cli/src/types/cli.ts b/cli/src/types/cli.ts index 8118791c5b4..c8232d2ca57 100644 --- a/cli/src/types/cli.ts +++ b/cli/src/types/cli.ts @@ -38,5 +38,7 @@ export interface CLIOptions { continue?: boolean provider?: string model?: string + session?: string + fork?: string noSplash?: boolean } diff --git a/cli/src/utils/paths.ts b/cli/src/utils/paths.ts index 38e0e929d9e..57c4d4a68bc 100644 --- a/cli/src/utils/paths.ts +++ b/cli/src/utils/paths.ts @@ -39,6 +39,21 @@ export class KiloCodePaths { return path.join(this.getKiloCodeDir(), "global") } + /** + * Get tasks base directory + */ + static getTasksDir(): string { + return path.join(this.getGlobalStorageDir(), "tasks") + } + + /** + * Get the path to the last session file for a workspace + */ + static getLastSessionPath(workspacePath: string): string { + const workspaceDir = this.getWorkspaceStorageDir(workspacePath) + return path.join(workspaceDir, "last-session.json") + } + /** * Get workspaces base directory */ diff --git a/cli/src/utils/time.ts b/cli/src/utils/time.ts new file mode 100644 index 00000000000..61334116c0f --- /dev/null +++ b/cli/src/utils/time.ts @@ -0,0 +1,13 @@ +export function formatRelativeTime(ts: number): string { + const now = Date.now() + const diff = now - ts + const seconds = Math.floor(diff / 1000) + const minutes = Math.floor(seconds / 60) + const hours = Math.floor(minutes / 60) + const days = Math.floor(hours / 24) + + if (days > 0) return `${days}d ago` + if (hours > 0) return `${hours}h ago` + if (minutes > 0) return `${minutes}m ago` + return "just now" +} diff --git a/src/core/context-tracking/FileContextTracker.ts b/src/core/context-tracking/FileContextTracker.ts index 5741b62cfc2..73be89fd266 100644 --- a/src/core/context-tracking/FileContextTracker.ts +++ b/src/core/context-tracking/FileContextTracker.ts @@ -132,6 +132,17 @@ export class FileContextTracker { const taskDir = await getTaskDirectoryPath(globalStoragePath, taskId) const filePath = path.join(taskDir, GlobalFileNames.taskMetadata) await safeWriteJson(filePath, metadata) + + // kilocode_change start + // Post directly to webview for CLI to react to file save + const provider = this.providerRef.deref() + if (provider) { + await provider.postMessageToWebview({ + type: "taskMetadataSaved", + payload: [taskId, filePath], + }) + } + // kilocode_change end } catch (error) { console.error("Failed to save task metadata:", error) } diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index ca1314c9048..162314b5f84 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -110,6 +110,7 @@ import { saveTaskMessages, taskMetadata, } from "../task-persistence" +import { getTaskDirectoryPath } from "../../utils/storage" import { getEnvironmentDetails } from "../environment/getEnvironmentDetails" import { checkContextWindowExceededError } from "../context/context-management/context-error-handling" import { @@ -706,6 +707,19 @@ export class Task extends EventEmitter implements TaskLike { taskId: this.taskId, globalStoragePath: this.globalStoragePath, }) + + // kilocode_change start + // Post directly to webview for CLI to react to file save + const taskDir = await getTaskDirectoryPath(this.globalStoragePath, this.taskId) + const filePath = path.join(taskDir, GlobalFileNames.apiConversationHistory) + const provider = this.providerRef.deref() + if (provider) { + await provider.postMessageToWebview({ + type: "apiMessagesSaved", + payload: [this.taskId, filePath], + }) + } + // kilocode_change end } catch (error) { // In the off chance this fails, we don't want to stop the task. console.error("Failed to save API conversation history:", error) @@ -768,6 +782,19 @@ export class Task extends EventEmitter implements TaskLike { globalStoragePath: this.globalStoragePath, }) + // kilocode_change start + // Post directly to webview for CLI to react to file save + const taskDir = await getTaskDirectoryPath(this.globalStoragePath, this.taskId) + const filePath = path.join(taskDir, GlobalFileNames.uiMessages) + const provider = this.providerRef.deref() + if (provider) { + await provider.postMessageToWebview({ + type: "taskMessagesSaved", + payload: [this.taskId, filePath], + }) + } + // kilocode_change end + const { historyItem, tokenUsage } = await taskMetadata({ taskId: this.taskId, rootTaskId: this.rootTaskId, diff --git a/src/core/webview/__tests__/webviewMessageHandler.singleCompletion.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.singleCompletion.spec.ts new file mode 100644 index 00000000000..10854c21093 --- /dev/null +++ b/src/core/webview/__tests__/webviewMessageHandler.singleCompletion.spec.ts @@ -0,0 +1,340 @@ +// kilocode_change - new file +// npx vitest src/core/webview/__tests__/webviewMessageHandler.singleCompletion.spec.ts + +import type { Mock } from "vitest" +import { webviewMessageHandler } from "../webviewMessageHandler" +import type { ClineProvider } from "../ClineProvider" +import * as singleCompletionHandler from "../../../utils/single-completion-handler" + +// Mock the single completion handler +vi.mock("../../../utils/single-completion-handler", () => ({ + singleCompletionHandler: vi.fn(), +})) + +// Mock vscode +vi.mock("vscode", () => ({ + window: { + showInformationMessage: vi.fn(), + showErrorMessage: vi.fn(), + createTextEditorDecorationType: vi.fn(() => ({ dispose: vi.fn() })), + }, + workspace: { + workspaceFolders: [{ uri: { fsPath: "/mock/workspace" } }], + }, +})) + +const mockSingleCompletionHandler = singleCompletionHandler.singleCompletionHandler as Mock + +// Mock ClineProvider +const mockClineProvider = { + getState: vi.fn(), + postMessageToWebview: vi.fn(), + log: vi.fn(), + contextProxy: { + getValue: vi.fn(), + setValue: vi.fn(), + }, +} as unknown as ClineProvider + +describe("webviewMessageHandler - singleCompletion", () => { + beforeEach(() => { + vi.clearAllMocks() + mockClineProvider.getState = vi.fn().mockResolvedValue({ + apiConfiguration: { + apiProvider: "anthropic", + apiKey: "test-key", + apiModelId: "claude-3-5-sonnet-20241022", + }, + }) + }) + + describe("Successful Completion Flow", () => { + it("should handle successful completion with all required parameters", async () => { + const completionRequestId = "test-request-123" + const promptText = "Write a hello world function" + const expectedResult = "function helloWorld() {\n console.log('Hello, World!');\n}" + + mockSingleCompletionHandler.mockResolvedValue(expectedResult) + + await webviewMessageHandler(mockClineProvider, { + type: "singleCompletion", + text: promptText, + completionRequestId, + }) + + // Verify handler was called with correct config and prompt + expect(mockSingleCompletionHandler).toHaveBeenCalledWith( + expect.objectContaining({ + apiProvider: "anthropic", + apiKey: "test-key", + apiModelId: "claude-3-5-sonnet-20241022", + }), + promptText, + ) + + // Verify success response was sent + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "singleCompletionResult", + completionRequestId, + completionText: expectedResult, + success: true, + }) + }) + + it("should handle empty completion result", async () => { + const completionRequestId = "test-request-789" + const promptText = "Generate nothing" + + mockSingleCompletionHandler.mockResolvedValue("") + + await webviewMessageHandler(mockClineProvider, { + type: "singleCompletion", + text: promptText, + completionRequestId, + }) + + // Verify success response with empty string + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "singleCompletionResult", + completionRequestId, + completionText: "", + success: true, + }) + }) + }) + + describe("Error Handling", () => { + it("should handle missing completionRequestId", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "singleCompletion", + text: "test prompt", + // Missing completionRequestId + }) + + // Verify error response was sent + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "singleCompletionResult", + completionRequestId: undefined, + completionError: "Missing completionRequestId", + success: false, + }) + + // Handler should not be called + expect(mockSingleCompletionHandler).not.toHaveBeenCalled() + }) + + it("should handle missing prompt text", async () => { + const completionRequestId = "test-request-error-1" + + await webviewMessageHandler(mockClineProvider, { + type: "singleCompletion", + completionRequestId, + // Missing text + }) + + // Verify error response was sent + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "singleCompletionResult", + completionRequestId, + completionError: "Missing prompt text", + success: false, + }) + + // Handler should not be called + expect(mockSingleCompletionHandler).not.toHaveBeenCalled() + }) + + it("should handle API configuration errors", async () => { + const completionRequestId = "test-request-error-2" + const promptText = "test prompt" + + // Mock getState to return invalid config + mockClineProvider.getState = vi.fn().mockResolvedValue({ + apiConfiguration: null, + }) + + mockSingleCompletionHandler.mockRejectedValue(new Error("No valid API configuration provided")) + + await webviewMessageHandler(mockClineProvider, { + type: "singleCompletion", + text: promptText, + completionRequestId, + }) + + // Verify error response was sent + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "singleCompletionResult", + completionRequestId, + completionError: "No valid API configuration provided", + success: false, + }) + }) + + it("should handle completion handler failures", async () => { + const completionRequestId = "test-request-error-3" + const promptText = "test prompt" + const errorMessage = "API rate limit exceeded" + + mockSingleCompletionHandler.mockRejectedValue(new Error(errorMessage)) + + await webviewMessageHandler(mockClineProvider, { + type: "singleCompletion", + text: promptText, + completionRequestId, + }) + + // Verify error response was sent + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "singleCompletionResult", + completionRequestId, + completionError: errorMessage, + success: false, + }) + }) + + it("should handle non-Error exceptions", async () => { + const completionRequestId = "test-request-error-4" + const promptText = "test prompt" + + mockSingleCompletionHandler.mockRejectedValue("String error") + + await webviewMessageHandler(mockClineProvider, { + type: "singleCompletion", + text: promptText, + completionRequestId, + }) + + // Verify error response was sent with string conversion + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "singleCompletionResult", + completionRequestId, + completionError: "String error", + success: false, + }) + }) + + it("should handle network errors", async () => { + const completionRequestId = "test-request-error-5" + const promptText = "test prompt" + + mockSingleCompletionHandler.mockRejectedValue(new Error("Network request failed")) + + await webviewMessageHandler(mockClineProvider, { + type: "singleCompletion", + text: promptText, + completionRequestId, + }) + + // Verify error response was sent + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "singleCompletionResult", + completionRequestId, + completionError: "Network request failed", + success: false, + }) + }) + }) + + describe("Edge Cases", () => { + it("should handle very long prompts", async () => { + const completionRequestId = "test-request-long" + const longPrompt = "a".repeat(10000) + const expectedResult = "result" + + mockSingleCompletionHandler.mockResolvedValue(expectedResult) + + await webviewMessageHandler(mockClineProvider, { + type: "singleCompletion", + text: longPrompt, + completionRequestId, + }) + + expect(mockSingleCompletionHandler).toHaveBeenCalledWith(expect.any(Object), longPrompt) + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "singleCompletionResult", + completionRequestId, + completionText: expectedResult, + success: true, + }) + }) + + it("should handle special characters in prompt", async () => { + const completionRequestId = "test-request-special" + const specialPrompt = "Test with\nnewlines\tand\ttabs and 'quotes' and \"double quotes\"" + const expectedResult = "result" + + mockSingleCompletionHandler.mockResolvedValue(expectedResult) + + await webviewMessageHandler(mockClineProvider, { + type: "singleCompletion", + text: specialPrompt, + completionRequestId, + }) + + expect(mockSingleCompletionHandler).toHaveBeenCalledWith(expect.any(Object), specialPrompt) + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "singleCompletionResult", + completionRequestId, + completionText: expectedResult, + success: true, + }) + }) + + it("should handle Unicode characters in prompt", async () => { + const completionRequestId = "test-request-unicode" + const unicodePrompt = "Test with émojis 🚀 and 中文字符" + const expectedResult = "Unicode result 🎉" + + mockSingleCompletionHandler.mockResolvedValue(expectedResult) + + await webviewMessageHandler(mockClineProvider, { + type: "singleCompletion", + text: unicodePrompt, + completionRequestId, + }) + + expect(mockSingleCompletionHandler).toHaveBeenCalledWith(expect.any(Object), unicodePrompt) + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "singleCompletionResult", + completionRequestId, + completionText: expectedResult, + success: true, + }) + }) + + it("should handle multiple concurrent requests with different IDs", async () => { + const requests = [ + { id: "req-1", prompt: "prompt 1", result: "result 1" }, + { id: "req-2", prompt: "prompt 2", result: "result 2" }, + { id: "req-3", prompt: "prompt 3", result: "result 3" }, + ] + + // Mock handler to return different results based on prompt + mockSingleCompletionHandler.mockImplementation(async (_config, prompt) => { + const req = requests.find((r) => r.prompt === prompt) + return req?.result || "default" + }) + + // Send all requests concurrently + await Promise.all( + requests.map((req) => + webviewMessageHandler(mockClineProvider, { + type: "singleCompletion", + text: req.prompt, + completionRequestId: req.id, + }), + ), + ) + + // Verify each request got its own response + requests.forEach((req) => { + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "singleCompletionResult", + completionRequestId: req.id, + completionText: req.result, + success: true, + }) + }) + }) + }) +}) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 7d69112ff3c..1f457701a0c 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -3832,6 +3832,51 @@ export const webviewMessageHandler = async ( }) break } + // kilocode_change start + case "addTaskToHistory": { + if (message.historyItem) { + await provider.updateTaskHistory(message.historyItem) + await provider.postStateToWebview() + } + break + } + case "singleCompletion": { + try { + const { text, completionRequestId } = message + + if (!completionRequestId) { + throw new Error("Missing completionRequestId") + } + + if (!text) { + throw new Error("Missing prompt text") + } + + // Always use current configuration + const config = (await provider.getState()).apiConfiguration + + // Call the single completion handler + const result = await singleCompletionHandler(config, text) + + // Send success response + await provider.postMessageToWebview({ + type: "singleCompletionResult", + completionRequestId, + completionText: result, + success: true, + }) + } catch (error) { + // Send error response + await provider.postMessageToWebview({ + type: "singleCompletionResult", + completionRequestId: message.completionRequestId, + completionError: error instanceof Error ? error.message : String(error), + success: false, + }) + } + break + } + // kilocode_change end // kilocode_change start - ManagedIndexer state case "requestManagedIndexerState": { try { diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 6d3bf2fbc59..adfb7c206df 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -171,19 +171,27 @@ export interface ExtensionMessage { | "commands" | "insertTextIntoTextarea" | "dismissedUpsells" - | "showTimestamps" // kilocode_change - | "organizationSwitchResult" | "interactionRequired" + | "organizationSwitchResult" + | "showTimestamps" // kilocode_change + | "apiMessagesSaved" // kilocode_change: File save event for API messages + | "taskMessagesSaved" // kilocode_change: File save event for task messages + | "taskMetadataSaved" // kilocode_change: File save event for task metadata + | "managedIndexerState" // kilocode_change + | "singleCompletionResult" // kilocode_change | "managedIndexerState" // kilocode_change | "managedIndexerEnabled" // kilocode_change - | "organizationSwitchResult" text?: string // kilocode_change start + completionRequestId?: string // Correlation ID from request + completionText?: string // The completed text + completionError?: string // Error message if failed payload?: | ProfileDataResponsePayload | BalanceDataResponsePayload | TasksByIdResponsePayload | TaskHistoryResponsePayload + | [string, string] // For file save events [taskId, filePath] // kilocode_change end // Checkpoint warning message checkpointWarning?: { diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index f4de908a641..39ff2bd48f4 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -256,7 +256,10 @@ export interface WebviewMessage { | "getDismissedUpsells" | "updateSettings" | "requestManagedIndexerState" // kilocode_change + | "addTaskToHistory" // kilocode_change + | "singleCompletion" // kilocode_change text?: string + completionRequestId?: string // kilocode_change editedMessageContent?: string tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud" disabled?: boolean @@ -322,6 +325,7 @@ export interface WebviewMessage { upsellId?: string // For dismissUpsell list?: string[] // For dismissedUpsells response organizationId?: string | null // For organization switching + historyItem?: HistoryItem // kilocode_change For addTaskToHistory codeIndexSettings?: { // Global state settings codebaseIndexEnabled: boolean