From b0944b7fd18fcace636b2ce480c125ff08d4cf33 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 14 Feb 2026 18:16:18 +0900 Subject: [PATCH] feat(session-manager): add version-gated SDK read path for OpenCode beta - Add SDK client injection via setStorageClient() - Version-gate getMainSessions(), getAllSessions(), readSessionMessages(), readSessionTodos() - Add comprehensive tests for SDK path (beta mode) - Maintain backward compatibility with JSON fallback --- src/features/hook-message-injector/index.ts | 8 +- .../hook-message-injector/injector.test.ts | 237 ++++++++++++++++++ .../hook-message-injector/injector.ts | 163 +++++++++++- src/shared/opencode-message-dir.test.ts | 23 ++ src/shared/opencode-message-dir.ts | 2 + src/tools/session-manager/storage.test.ts | 171 +++++++++++++ src/tools/session-manager/storage.ts | 121 +++++++++ src/tools/session-manager/tools.ts | 5 +- 8 files changed, 720 insertions(+), 10 deletions(-) create mode 100644 src/features/hook-message-injector/injector.test.ts diff --git a/src/features/hook-message-injector/index.ts b/src/features/hook-message-injector/index.ts index 9a46758f..2c8a91e6 100644 --- a/src/features/hook-message-injector/index.ts +++ b/src/features/hook-message-injector/index.ts @@ -1,4 +1,10 @@ -export { injectHookMessage, findNearestMessageWithFields, findFirstMessageWithAgent } from "./injector" +export { + injectHookMessage, + findNearestMessageWithFields, + findFirstMessageWithAgent, + findNearestMessageWithFieldsFromSDK, + findFirstMessageWithAgentFromSDK, +} from "./injector" export type { StoredMessage } from "./injector" export type { MessageMeta, OriginalMessageContext, TextPart, ToolPermission } from "./types" export { MESSAGE_STORAGE } from "./constants" diff --git a/src/features/hook-message-injector/injector.test.ts b/src/features/hook-message-injector/injector.test.ts new file mode 100644 index 00000000..fffdf5a7 --- /dev/null +++ b/src/features/hook-message-injector/injector.test.ts @@ -0,0 +1,237 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "bun:test" +import { + findNearestMessageWithFields, + findFirstMessageWithAgent, + findNearestMessageWithFieldsFromSDK, + findFirstMessageWithAgentFromSDK, + injectHookMessage, +} from "./injector" +import { isSqliteBackend, resetSqliteBackendCache } from "../../shared/opencode-storage-detection" + +//#region Mocks + +const mockIsSqliteBackend = vi.fn() + +vi.mock("../../shared/opencode-storage-detection", () => ({ + isSqliteBackend: mockIsSqliteBackend, + resetSqliteBackendCache: () => {}, +})) + +//#endregion + +//#region Test Helpers + +function createMockClient(messages: Array<{ + info?: { + agent?: string + model?: { providerID?: string; modelID?: string; variant?: string } + providerID?: string + modelID?: string + tools?: Record + } +}>): { + session: { + messages: (opts: { path: { id: string } }) => Promise<{ data: typeof messages }> + } +} { + return { + session: { + messages: async () => ({ data: messages }), + }, + } +} + +//#endregion + +describe("findNearestMessageWithFieldsFromSDK", () => { + it("returns message with all fields when available", async () => { + const mockClient = createMockClient([ + { info: { agent: "sisyphus", model: { providerID: "anthropic", modelID: "claude-opus-4" } } }, + ]) + + const result = await findNearestMessageWithFieldsFromSDK(mockClient as any, "ses_123") + + expect(result).toEqual({ + agent: "sisyphus", + model: { providerID: "anthropic", modelID: "claude-opus-4" }, + tools: undefined, + }) + }) + + it("returns message with assistant shape (providerID/modelID directly on info)", async () => { + const mockClient = createMockClient([ + { info: { agent: "sisyphus", providerID: "openai", modelID: "gpt-5" } }, + ]) + + const result = await findNearestMessageWithFieldsFromSDK(mockClient as any, "ses_123") + + expect(result).toEqual({ + agent: "sisyphus", + model: { providerID: "openai", modelID: "gpt-5" }, + tools: undefined, + }) + }) + + it("returns nearest (most recent) message with all fields", async () => { + const mockClient = createMockClient([ + { info: { agent: "old-agent", model: { providerID: "old", modelID: "model" } } }, + { info: { agent: "new-agent", model: { providerID: "new", modelID: "model" } } }, + ]) + + const result = await findNearestMessageWithFieldsFromSDK(mockClient as any, "ses_123") + + expect(result?.agent).toBe("new-agent") + }) + + it("falls back to message with partial fields", async () => { + const mockClient = createMockClient([ + { info: { agent: "partial-agent" } }, + ]) + + const result = await findNearestMessageWithFieldsFromSDK(mockClient as any, "ses_123") + + expect(result?.agent).toBe("partial-agent") + }) + + it("returns null when no messages have useful fields", async () => { + const mockClient = createMockClient([ + { info: {} }, + { info: {} }, + ]) + + const result = await findNearestMessageWithFieldsFromSDK(mockClient as any, "ses_123") + + expect(result).toBeNull() + }) + + it("returns null when messages array is empty", async () => { + const mockClient = createMockClient([]) + + const result = await findNearestMessageWithFieldsFromSDK(mockClient as any, "ses_123") + + expect(result).toBeNull() + }) + + it("returns null on SDK error", async () => { + const mockClient = { + session: { + messages: async () => { + throw new Error("SDK error") + }, + }, + } + + const result = await findNearestMessageWithFieldsFromSDK(mockClient as any, "ses_123") + + expect(result).toBeNull() + }) + + it("includes tools when available", async () => { + const mockClient = createMockClient([ + { + info: { + agent: "sisyphus", + model: { providerID: "anthropic", modelID: "claude-opus-4" }, + tools: { edit: true, write: false }, + }, + }, + ]) + + const result = await findNearestMessageWithFieldsFromSDK(mockClient as any, "ses_123") + + expect(result?.tools).toEqual({ edit: true, write: false }) + }) +}) + +describe("findFirstMessageWithAgentFromSDK", () => { + it("returns agent from first message", async () => { + const mockClient = createMockClient([ + { info: { agent: "first-agent" } }, + { info: { agent: "second-agent" } }, + ]) + + const result = await findFirstMessageWithAgentFromSDK(mockClient as any, "ses_123") + + expect(result).toBe("first-agent") + }) + + it("skips messages without agent field", async () => { + const mockClient = createMockClient([ + { info: {} }, + { info: { agent: "first-real-agent" } }, + ]) + + const result = await findFirstMessageWithAgentFromSDK(mockClient as any, "ses_123") + + expect(result).toBe("first-real-agent") + }) + + it("returns null when no messages have agent", async () => { + const mockClient = createMockClient([ + { info: {} }, + { info: {} }, + ]) + + const result = await findFirstMessageWithAgentFromSDK(mockClient as any, "ses_123") + + expect(result).toBeNull() + }) + + it("returns null on SDK error", async () => { + const mockClient = { + session: { + messages: async () => { + throw new Error("SDK error") + }, + }, + } + + const result = await findFirstMessageWithAgentFromSDK(mockClient as any, "ses_123") + + expect(result).toBeNull() + }) +}) + +describe("injectHookMessage", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + it("returns false and logs warning on beta/SQLite backend", () => { + mockIsSqliteBackend.mockReturnValue(true) + + const result = injectHookMessage("ses_123", "test content", { + agent: "sisyphus", + model: { providerID: "anthropic", modelID: "claude-opus-4" }, + }) + + expect(result).toBe(false) + expect(mockIsSqliteBackend).toHaveBeenCalled() + }) + + it("returns false for empty hook content", () => { + mockIsSqliteBackend.mockReturnValue(false) + + const result = injectHookMessage("ses_123", "", { + agent: "sisyphus", + model: { providerID: "anthropic", modelID: "claude-opus-4" }, + }) + + expect(result).toBe(false) + }) + + it("returns false for whitespace-only hook content", () => { + mockIsSqliteBackend.mockReturnValue(false) + + const result = injectHookMessage("ses_123", " \n\t ", { + agent: "sisyphus", + model: { providerID: "anthropic", modelID: "claude-opus-4" }, + }) + + expect(result).toBe(false) + }) +}) diff --git a/src/features/hook-message-injector/injector.ts b/src/features/hook-message-injector/injector.ts index bd3c5537..e8fac0d4 100644 --- a/src/features/hook-message-injector/injector.ts +++ b/src/features/hook-message-injector/injector.ts @@ -1,8 +1,10 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs" import { join } from "node:path" +import type { PluginInput } from "@opencode-ai/plugin" import { MESSAGE_STORAGE, PART_STORAGE } from "./constants" import type { MessageMeta, OriginalMessageContext, TextPart, ToolPermission } from "./types" import { log } from "../../shared/logger" +import { isSqliteBackend } from "../../shared/opencode-storage-detection" export interface StoredMessage { agent?: string @@ -10,14 +12,125 @@ export interface StoredMessage { tools?: Record } +type OpencodeClient = PluginInput["client"] + +interface SDKMessage { + info?: { + agent?: string + model?: { + providerID?: string + modelID?: string + variant?: string + } + providerID?: string + modelID?: string + tools?: Record + } +} + +function convertSDKMessageToStoredMessage(msg: SDKMessage): StoredMessage | null { + const info = msg.info + if (!info) return null + + const providerID = info.model?.providerID ?? info.providerID + const modelID = info.model?.modelID ?? info.modelID + const variant = info.model?.variant + + if (!info.agent && !providerID && !modelID) { + return null + } + + return { + agent: info.agent, + model: providerID && modelID + ? { providerID, modelID, ...(variant ? { variant } : {}) } + : undefined, + tools: info.tools, + } +} + +/** + * Finds the nearest message with required fields using SDK (for beta/SQLite backend). + * Uses client.session.messages() to fetch message data from SQLite. + */ +export async function findNearestMessageWithFieldsFromSDK( + client: OpencodeClient, + sessionID: string +): Promise { + try { + const response = await client.session.messages({ path: { id: sessionID } }) + const messages = (response.data ?? []) as SDKMessage[] + + for (let i = messages.length - 1; i >= 0; i--) { + const stored = convertSDKMessageToStoredMessage(messages[i]) + if (stored?.agent && stored.model?.providerID && stored.model?.modelID) { + return stored + } + } + + for (let i = messages.length - 1; i >= 0; i--) { + const stored = convertSDKMessageToStoredMessage(messages[i]) + if (stored?.agent || (stored?.model?.providerID && stored?.model?.modelID)) { + return stored + } + } + } catch (error) { + log("[hook-message-injector] SDK message fetch failed", { + sessionID, + error: String(error), + }) + } + return null +} + +/** + * Finds the FIRST (oldest) message with agent field using SDK (for beta/SQLite backend). + */ +export async function findFirstMessageWithAgentFromSDK( + client: OpencodeClient, + sessionID: string +): Promise { + try { + const response = await client.session.messages({ path: { id: sessionID } }) + const messages = (response.data ?? []) as SDKMessage[] + + for (const msg of messages) { + const stored = convertSDKMessageToStoredMessage(msg) + if (stored?.agent) { + return stored.agent + } + } + } catch (error) { + log("[hook-message-injector] SDK agent fetch failed", { + sessionID, + error: String(error), + }) + } + return null +} + +/** + * Finds the nearest message with required fields (agent, model.providerID, model.modelID). + * Reads from JSON files - for stable (JSON) backend. + * + * **Version-gated behavior:** + * - On beta (SQLite backend): Returns null immediately (no JSON storage) + * - On stable (JSON backend): Reads from JSON files in messageDir + * + * @deprecated Use findNearestMessageWithFieldsFromSDK for beta/SQLite backend + */ export function findNearestMessageWithFields(messageDir: string): StoredMessage | null { + // On beta SQLite backend, skip JSON file reads entirely + if (isSqliteBackend()) { + return null + } + try { const files = readdirSync(messageDir) .filter((f) => f.endsWith(".json")) .sort() .reverse() - // First pass: find message with ALL fields (ideal) for (const file of files) { try { const content = readFileSync(join(messageDir, file), "utf-8") @@ -30,8 +143,6 @@ export function findNearestMessageWithFields(messageDir: string): StoredMessage } } - // Second pass: find message with ANY useful field (fallback) - // This ensures agent info isn't lost when model info is missing for (const file of files) { try { const content = readFileSync(join(messageDir, file), "utf-8") @@ -51,15 +162,24 @@ export function findNearestMessageWithFields(messageDir: string): StoredMessage /** * Finds the FIRST (oldest) message in the session with agent field. - * This is used to get the original agent that started the session, - * avoiding issues where newer messages may have a different agent - * due to OpenCode's internal agent switching. + * Reads from JSON files - for stable (JSON) backend. + * + * **Version-gated behavior:** + * - On beta (SQLite backend): Returns null immediately (no JSON storage) + * - On stable (JSON backend): Reads from JSON files in messageDir + * + * @deprecated Use findFirstMessageWithAgentFromSDK for beta/SQLite backend */ export function findFirstMessageWithAgent(messageDir: string): string | null { + // On beta SQLite backend, skip JSON file reads entirely + if (isSqliteBackend()) { + return null + } + try { const files = readdirSync(messageDir) .filter((f) => f.endsWith(".json")) - .sort() // Oldest first (no reverse) + .sort() for (const file of files) { try { @@ -111,12 +231,29 @@ function getOrCreateMessageDir(sessionID: string): string { return directPath } +/** + * Injects a hook message into the session storage. + * + * **Version-gated behavior:** + * - On beta (SQLite backend): Logs warning and skips injection (writes are invisible to SQLite) + * - On stable (JSON backend): Writes message and part JSON files + * + * Features degraded on beta: + * - Hook message injection (e.g., continuation prompts, context injection) won't persist + * - Atlas hook's injected messages won't be visible in SQLite backend + * - Todo continuation enforcer's injected prompts won't persist + * - Ralph loop's continuation prompts won't persist + * + * @param sessionID - Target session ID + * @param hookContent - Content to inject + * @param originalMessage - Context from the original message + * @returns true if injection succeeded, false otherwise + */ export function injectHookMessage( sessionID: string, hookContent: string, originalMessage: OriginalMessageContext ): boolean { - // Validate hook content to prevent empty message injection if (!hookContent || hookContent.trim().length === 0) { log("[hook-message-injector] Attempted to inject empty hook content, skipping injection", { sessionID, @@ -126,6 +263,16 @@ export function injectHookMessage( return false } + if (isSqliteBackend()) { + log("[hook-message-injector] WARNING: Skipping message injection on beta/SQLite backend. " + + "Injected messages are not visible to SQLite storage. " + + "Features affected: continuation prompts, context injection.", { + sessionID, + agent: originalMessage.agent, + }) + return false + } + const messageDir = getOrCreateMessageDir(sessionID) const needsFallback = diff --git a/src/shared/opencode-message-dir.test.ts b/src/shared/opencode-message-dir.test.ts index a47d61db..c13f4079 100644 --- a/src/shared/opencode-message-dir.test.ts +++ b/src/shared/opencode-message-dir.test.ts @@ -19,6 +19,12 @@ beforeAll(async () => { join: mock((...args: string[]) => args.join("/")), })) + // Mock storage detection to return false (stable mode) + mock.module("./opencode-storage-detection", () => ({ + isSqliteBackend: () => false, + resetSqliteBackendCache: () => {}, + })) + ;({ getMessageDir } = await import("./opencode-message-dir")) }) @@ -110,4 +116,21 @@ describe("getMessageDir", () => { // then expect(result).toBe(null) }) + + it("returns null when isSqliteBackend returns true (beta mode)", async () => { + // given - mock beta mode (SQLite backend) + mock.module("./opencode-storage-detection", () => ({ + isSqliteBackend: () => true, + resetSqliteBackendCache: () => {}, + })) + + // Re-import to get fresh module with mocked isSqliteBackend + const { getMessageDir: getMessageDirBeta } = await import("./opencode-message-dir") + + // when + const result = getMessageDirBeta("ses_123") + + // then + expect(result).toBe(null) + }) }) \ No newline at end of file diff --git a/src/shared/opencode-message-dir.ts b/src/shared/opencode-message-dir.ts index 080eadc8..86bdb220 100644 --- a/src/shared/opencode-message-dir.ts +++ b/src/shared/opencode-message-dir.ts @@ -1,11 +1,13 @@ import { existsSync, readdirSync } from "node:fs" import { join } from "node:path" import { getOpenCodeStorageDir } from "./data-path" +import { isSqliteBackend } from "./opencode-storage-detection" const MESSAGE_STORAGE = join(getOpenCodeStorageDir(), "message") export function getMessageDir(sessionID: string): string | null { if (!sessionID.startsWith("ses_")) return null + if (isSqliteBackend()) return null if (!existsSync(MESSAGE_STORAGE)) return null const directPath = join(MESSAGE_STORAGE, sessionID) diff --git a/src/tools/session-manager/storage.test.ts b/src/tools/session-manager/storage.test.ts index 76507867..771457c4 100644 --- a/src/tools/session-manager/storage.test.ts +++ b/src/tools/session-manager/storage.test.ts @@ -26,6 +26,11 @@ mock.module("./constants", () => ({ TOOL_NAME_PREFIX: "session_", })) +mock.module("../../shared/opencode-storage-detection", () => ({ + isSqliteBackend: () => false, + resetSqliteBackendCache: () => {}, +})) + const { getAllSessions, getMessageDir, sessionExists, readSessionMessages, readSessionTodos, getSessionInfo } = await import("./storage") @@ -314,3 +319,169 @@ describe("session-manager storage - getMainSessions", () => { expect(sessions.length).toBe(2) }) }) + +describe("session-manager storage - SDK path (beta mode)", () => { + const mockClient = { + session: { + list: mock(() => Promise.resolve({ data: [] })), + messages: mock(() => Promise.resolve({ data: [] })), + todo: mock(() => Promise.resolve({ data: [] })), + }, + } + + beforeEach(() => { + // Reset mocks + mockClient.session.list.mockClear() + mockClient.session.messages.mockClear() + mockClient.session.todo.mockClear() + }) + + test("getMainSessions uses SDK when beta mode is enabled", async () => { + // given + const mockSessions = [ + { id: "ses_1", directory: "/test", parentID: null, time: { created: 1000, updated: 2000 } }, + { id: "ses_2", directory: "/test", parentID: "ses_1", time: { created: 1000, updated: 1500 } }, + ] + mockClient.session.list.mockImplementation(() => Promise.resolve({ data: mockSessions })) + + // Mock isSqliteBackend to return true + mock.module("../../shared/opencode-storage-detection", () => ({ + isSqliteBackend: () => true, + resetSqliteBackendCache: () => {}, + })) + + // Re-import to get fresh module with mocked isSqliteBackend + const { setStorageClient, getMainSessions } = await import("./storage") + setStorageClient(mockClient as unknown as Parameters[0]) + + // when + const sessions = await getMainSessions({ directory: "/test" }) + + // then + expect(mockClient.session.list).toHaveBeenCalled() + expect(sessions.length).toBe(1) + expect(sessions[0].id).toBe("ses_1") + }) + + test("getAllSessions uses SDK when beta mode is enabled", async () => { + // given + const mockSessions = [ + { id: "ses_1", directory: "/test", time: { created: 1000, updated: 2000 } }, + { id: "ses_2", directory: "/test", time: { created: 1000, updated: 1500 } }, + ] + mockClient.session.list.mockImplementation(() => Promise.resolve({ data: mockSessions })) + + mock.module("../../shared/opencode-storage-detection", () => ({ + isSqliteBackend: () => true, + resetSqliteBackendCache: () => {}, + })) + + const { setStorageClient, getAllSessions } = await import("./storage") + setStorageClient(mockClient as unknown as Parameters[0]) + + // when + const sessionIDs = await getAllSessions() + + // then + expect(mockClient.session.list).toHaveBeenCalled() + expect(sessionIDs).toEqual(["ses_1", "ses_2"]) + }) + + test("readSessionMessages uses SDK when beta mode is enabled", async () => { + // given + const mockMessages = [ + { + info: { id: "msg_1", role: "user", agent: "test", time: { created: 1000 } }, + parts: [{ id: "part_1", type: "text", text: "Hello" }], + }, + { + info: { id: "msg_2", role: "assistant", agent: "oracle", time: { created: 2000 } }, + parts: [{ id: "part_2", type: "text", text: "Hi there" }], + }, + ] + mockClient.session.messages.mockImplementation(() => Promise.resolve({ data: mockMessages })) + + mock.module("../../shared/opencode-storage-detection", () => ({ + isSqliteBackend: () => true, + resetSqliteBackendCache: () => {}, + })) + + const { setStorageClient, readSessionMessages } = await import("./storage") + setStorageClient(mockClient as unknown as Parameters[0]) + + // when + const messages = await readSessionMessages("ses_test") + + // then + expect(mockClient.session.messages).toHaveBeenCalledWith({ path: { id: "ses_test" } }) + expect(messages.length).toBe(2) + expect(messages[0].id).toBe("msg_1") + expect(messages[1].id).toBe("msg_2") + expect(messages[0].role).toBe("user") + expect(messages[1].role).toBe("assistant") + }) + + test("readSessionTodos uses SDK when beta mode is enabled", async () => { + // given + const mockTodos = [ + { id: "todo_1", content: "Task 1", status: "pending", priority: "high" }, + { id: "todo_2", content: "Task 2", status: "completed", priority: "medium" }, + ] + mockClient.session.todo.mockImplementation(() => Promise.resolve({ data: mockTodos })) + + mock.module("../../shared/opencode-storage-detection", () => ({ + isSqliteBackend: () => true, + resetSqliteBackendCache: () => {}, + })) + + const { setStorageClient, readSessionTodos } = await import("./storage") + setStorageClient(mockClient as unknown as Parameters[0]) + + // when + const todos = await readSessionTodos("ses_test") + + // then + expect(mockClient.session.todo).toHaveBeenCalledWith({ path: { id: "ses_test" } }) + expect(todos.length).toBe(2) + expect(todos[0].content).toBe("Task 1") + expect(todos[1].content).toBe("Task 2") + expect(todos[0].status).toBe("pending") + expect(todos[1].status).toBe("completed") + }) + + test("SDK path returns empty array on error", async () => { + // given + mockClient.session.messages.mockImplementation(() => Promise.reject(new Error("API error"))) + + mock.module("../../shared/opencode-storage-detection", () => ({ + isSqliteBackend: () => true, + resetSqliteBackendCache: () => {}, + })) + + const { setStorageClient, readSessionMessages } = await import("./storage") + setStorageClient(mockClient as unknown as Parameters[0]) + + // when + const messages = await readSessionMessages("ses_test") + + // then + expect(messages).toEqual([]) + }) + + test("SDK path returns empty array when client is not set", async () => { + // given - beta mode enabled but no client set + mock.module("../../shared/opencode-storage-detection", () => ({ + isSqliteBackend: () => true, + resetSqliteBackendCache: () => {}, + })) + + // Re-import without setting client + const { readSessionMessages } = await import("./storage") + + // when - calling readSessionMessages without client set + const messages = await readSessionMessages("ses_test") + + // then - should return empty array since no client and no JSON fallback + expect(messages).toEqual([]) + }) +}) diff --git a/src/tools/session-manager/storage.ts b/src/tools/session-manager/storage.ts index 38ea0a0b..d10a18d6 100644 --- a/src/tools/session-manager/storage.ts +++ b/src/tools/session-manager/storage.ts @@ -1,14 +1,41 @@ import { existsSync, readdirSync } from "node:fs" import { readdir, readFile } from "node:fs/promises" import { join } from "node:path" +import type { PluginInput } from "@opencode-ai/plugin" import { MESSAGE_STORAGE, PART_STORAGE, SESSION_STORAGE, TODO_DIR, TRANSCRIPT_DIR } from "./constants" +import { isSqliteBackend } from "../../shared/opencode-storage-detection" import type { SessionMessage, SessionInfo, TodoItem, SessionMetadata } from "./types" export interface GetMainSessionsOptions { directory?: string } +// SDK client reference for beta mode +let sdkClient: PluginInput["client"] | null = null + +export function setStorageClient(client: PluginInput["client"]): void { + sdkClient = client +} + export async function getMainSessions(options: GetMainSessionsOptions): Promise { + // Beta mode: use SDK + if (isSqliteBackend() && sdkClient) { + try { + const response = await sdkClient.session.list() + const sessions = (response.data || []) as SessionMetadata[] + const mainSessions = sessions.filter((s) => !s.parentID) + if (options.directory) { + return mainSessions + .filter((s) => s.directory === options.directory) + .sort((a, b) => b.time.updated - a.time.updated) + } + return mainSessions.sort((a, b) => b.time.updated - a.time.updated) + } catch { + return [] + } + } + + // Stable mode: use JSON files if (!existsSync(SESSION_STORAGE)) return [] const sessions: SessionMetadata[] = [] @@ -46,6 +73,18 @@ export async function getMainSessions(options: GetMainSessionsOptions): Promise< } export async function getAllSessions(): Promise { + // Beta mode: use SDK + if (isSqliteBackend() && sdkClient) { + try { + const response = await sdkClient.session.list() + const sessions = (response.data || []) as SessionMetadata[] + return sessions.map((s) => s.id) + } catch { + return [] + } + } + + // Stable mode: use JSON files if (!existsSync(MESSAGE_STORAGE)) return [] const sessions: string[] = [] @@ -100,6 +139,66 @@ export function sessionExists(sessionID: string): boolean { } export async function readSessionMessages(sessionID: string): Promise { + // Beta mode: use SDK + if (isSqliteBackend() && sdkClient) { + try { + const response = await sdkClient.session.messages({ path: { id: sessionID } }) + const rawMessages = (response.data || []) as Array<{ + info?: { + id?: string + role?: string + agent?: string + time?: { created?: number; updated?: number } + } + parts?: Array<{ + id?: string + type?: string + text?: string + thinking?: string + tool?: string + callID?: string + input?: Record + output?: string + error?: string + }> + }> + const messages: SessionMessage[] = rawMessages + .filter((m) => m.info?.id) + .map((m) => ({ + id: m.info!.id!, + role: (m.info!.role as "user" | "assistant") || "user", + agent: m.info!.agent, + time: m.info!.time?.created + ? { + created: m.info!.time.created, + updated: m.info!.time.updated, + } + : undefined, + parts: + m.parts?.map((p) => ({ + id: p.id || "", + type: p.type || "text", + text: p.text, + thinking: p.thinking, + tool: p.tool, + callID: p.callID, + input: p.input, + output: p.output, + error: p.error, + })) || [], + })) + return messages.sort((a, b) => { + const aTime = a.time?.created ?? 0 + const bTime = b.time?.created ?? 0 + if (aTime !== bTime) return aTime - bTime + return a.id.localeCompare(b.id) + }) + } catch { + return [] + } + } + + // Stable mode: use JSON files const messageDir = getMessageDir(sessionID) if (!messageDir || !existsSync(messageDir)) return [] @@ -161,6 +260,28 @@ async function readParts(messageID: string): Promise { + // Beta mode: use SDK + if (isSqliteBackend() && sdkClient) { + try { + const response = await sdkClient.session.todo({ path: { id: sessionID } }) + const data = (response.data || []) as Array<{ + id?: string + content?: string + status?: string + priority?: string + }> + return data.map((item) => ({ + id: item.id || "", + content: item.content || "", + status: (item.status as TodoItem["status"]) || "pending", + priority: item.priority, + })) + } catch { + return [] + } + } + + // Stable mode: use JSON files if (!existsSync(TODO_DIR)) return [] try { diff --git a/src/tools/session-manager/tools.ts b/src/tools/session-manager/tools.ts index 7650013c..0fd26b6b 100644 --- a/src/tools/session-manager/tools.ts +++ b/src/tools/session-manager/tools.ts @@ -6,7 +6,7 @@ import { SESSION_SEARCH_DESCRIPTION, SESSION_INFO_DESCRIPTION, } from "./constants" -import { getAllSessions, getMainSessions, getSessionInfo, readSessionMessages, readSessionTodos, sessionExists } from "./storage" +import { getAllSessions, getMainSessions, getSessionInfo, readSessionMessages, readSessionTodos, sessionExists, setStorageClient } from "./storage" import { filterSessionsByDate, formatSessionInfo, @@ -28,6 +28,9 @@ function withTimeout(promise: Promise, ms: number, operation: string): Pro } export function createSessionManagerTools(ctx: PluginInput): Record { + // Initialize storage client for SDK-based operations (beta mode) + setStorageClient(ctx.client) + const session_list: ToolDefinition = tool({ description: SESSION_LIST_DESCRIPTION, args: {