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
+7 -1
View File
@@ -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"
@@ -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)
})
})
+155 -8
View File
@@ -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 =