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
This commit is contained in:
YeonGyu-Kim
2026-02-14 18:16:18 +09:00
parent 5eebef953b
commit b0944b7fd1
8 changed files with 720 additions and 10 deletions
+171
View File
@@ -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<typeof setStorageClient>[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<typeof setStorageClient>[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<typeof setStorageClient>[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<typeof setStorageClient>[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<typeof setStorageClient>[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([])
})
})
+121
View File
@@ -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<SessionMetadata[]> {
// 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<string[]> {
// 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<SessionMessage[]> {
// 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<string, unknown>
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<Array<{ id: string; type: s
}
export async function readSessionTodos(sessionID: string): Promise<TodoItem[]> {
// 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 {
+4 -1
View File
@@ -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<T>(promise: Promise<T>, ms: number, operation: string): Pro
}
export function createSessionManagerTools(ctx: PluginInput): Record<string, ToolDefinition> {
// Initialize storage client for SDK-based operations (beta mode)
setStorageClient(ctx.client)
const session_list: ToolDefinition = tool({
description: SESSION_LIST_DESCRIPTION,
args: {