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
- Enter API key
-
+
- Choose a model
-
+
- Adjust model parameters
-
+
### 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 密钥
-
+
- 选择模型
-
+
- 调整模型参数
-
+
### 切换配置文件
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