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:
@@ -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"
|
||||
|
||||
237
src/features/hook-message-injector/injector.test.ts
Normal file
237
src/features/hook-message-injector/injector.test.ts
Normal file
@@ -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<string, boolean>
|
||||
}
|
||||
}>): {
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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<string, ToolPermission>
|
||||
}
|
||||
|
||||
type OpencodeClient = PluginInput["client"]
|
||||
|
||||
interface SDKMessage {
|
||||
info?: {
|
||||
agent?: string
|
||||
model?: {
|
||||
providerID?: string
|
||||
modelID?: string
|
||||
variant?: string
|
||||
}
|
||||
providerID?: string
|
||||
modelID?: string
|
||||
tools?: Record<string, ToolPermission>
|
||||
}
|
||||
}
|
||||
|
||||
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<StoredMessage | null> {
|
||||
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<string | null> {
|
||||
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 =
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user