fix(opencode2): support current plugin lifecycle and session storage

This commit is contained in:
Neil
2026-09-19 17:16:21 -07:00
parent 20981ad54d
commit e753af79bb
13 changed files with 198 additions and 79 deletions
@@ -1,10 +1,16 @@
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { opencodeDiscoveries } from './session-scanner-opencode-sources'
import { opencode2Discoveries, opencodeDiscoveries } from './session-scanner-opencode-sources'
const { discoverOpenCodeSessionsMock, listOpenCodeDatabasesMock } = vi.hoisted(() => ({
discoverOpenCodeSessionsMock: vi.fn(),
listOpenCodeDatabasesMock: vi.fn()
const { discoverOpenCodeSessionsMock, listOpenCodeDatabasesMock, listOpenCode2SessionsMock } =
vi.hoisted(() => ({
discoverOpenCodeSessionsMock: vi.fn(),
listOpenCodeDatabasesMock: vi.fn(),
listOpenCode2SessionsMock: vi.fn()
}))
vi.mock('./session-scanner-opencode-sqlite-worker-spawn', () => ({
listOpenCode2SqliteSessionsViaWorker: listOpenCode2SessionsMock
}))
vi.mock('./session-scanner-opencode-sqlite-discovery', () => ({
@@ -21,6 +27,19 @@ describe('opencodeDiscoveries', () => {
vi.clearAllMocks()
})
it('checks the shared database for v2 sessions as well as the beta databases', async () => {
const dbPaths = [join('/data', 'opencode.db'), join('/data', 'opencode-next.db')]
listOpenCode2SessionsMock.mockResolvedValue([])
await Promise.all(opencode2Discoveries({ opencodeDbPaths: dbPaths }, [], 25, []))
expect(listOpenCode2SessionsMock).toHaveBeenCalledWith({ dbPaths, limit: 25, issues: [] })
await Promise.all(opencodeDiscoveries({ opencodeDbPaths: dbPaths }, [], 25, []))
expect(discoverOpenCodeSessionsMock).toHaveBeenCalledWith(
expect.objectContaining({
dbPaths: [dbPaths[0]]
})
)
})
it('discovers local storage from the OpenCode XDG data directory', async () => {
vi.stubEnv('XDG_DATA_HOME', '/xdg/data')
vi.stubEnv('OPENCODE_CONFIG_DIR', '/opencode/config')
@@ -10,27 +10,6 @@ import { listOpenCode2SqliteSessionsViaWorker } from './session-scanner-opencode
import { isOpenCodeV2DatabaseName } from '../../shared/opencode-database-name'
import type { AiVaultScanOptions, SessionFileDiscovery } from './session-scanner-types'
// Why: opencode2 (beta) stores sessions in channel-scoped DBs
// (opencode-next.db / opencode-local.db) alongside the v1 opencode.db. Both
// match the `opencode*.db` glob, so paths are split by basename here: the v1
// discovery never sees v2 DBs (different, beta-unstable schema) and vice versa.
function splitDatabasePaths(dbPaths: readonly string[]): {
v1Paths: string[]
v2Paths: string[]
} {
const v1Paths: string[] = []
const v2Paths: string[] = []
for (const dbPath of dbPaths) {
if (isOpenCodeV2DatabaseName(basename(dbPath))) {
v2Paths.push(dbPath)
} else {
v1Paths.push(dbPath)
}
}
return { v1Paths, v2Paths }
}
export function opencodeDiscoveries(
options: AiVaultScanOptions,
wslHomeDirs: readonly string[],
@@ -39,9 +18,8 @@ export function opencodeDiscoveries(
): Promise<SessionFileDiscovery>[] {
const storageDirs = opencodeStorageDirs(options, wslHomeDirs)
return storageDirs.map(async (storageDir, index) => {
const { v1Paths } = splitDatabasePaths(
await opencodeDbPathsForSource(options, wslHomeDirs, storageDir, index, issues)
)
const dbPaths = await opencodeDbPathsForSource(options, wslHomeDirs, storageDir, index, issues)
const v1Paths = dbPaths.filter((path) => !isOpenCodeV2DatabaseName(basename(path)))
return discoverOpenCodeSessions({ storageDir, dbPaths: v1Paths, limitPerAgent: limit, issues })
})
}
@@ -53,9 +31,8 @@ export function opencode2Discoveries(
issues: AiVaultScanIssue[]
): Promise<SessionFileDiscovery>[] {
return opencodeStorageDirs(options, wslHomeDirs).map(async (storageDir, index) => {
const { v2Paths } = splitDatabasePaths(
await opencodeDbPathsForSource(options, wslHomeDirs, storageDir, index, issues)
)
// Current releases share opencode.db with v1; the worker checks for v2 tables.
const v2Paths = await opencodeDbPathsForSource(options, wslHomeDirs, storageDir, index, issues)
return v2Paths.length > 0
? discoverOpenCode2Sessions(storageDir, v2Paths, limit, issues)
: emptyOpenCode2Discovery(storageDir)
@@ -80,8 +57,7 @@ async function opencodeDbPathsForSource(
issues: AiVaultScanIssue[]
): Promise<readonly string[]> {
if (options.opencodeDbPaths) {
const split = splitDatabasePaths(sourceIndex === 0 ? options.opencodeDbPaths : [])
return [...split.v1Paths, ...split.v2Paths]
return sourceIndex === 0 ? options.opencodeDbPaths : []
}
// Why: custom OpenCode storage roots still keep SQLite DBs in the parent data dir.
if (sourceIndex === 0 && options.opencodeStorageDir) {
@@ -6,6 +6,7 @@ import Database from '../sqlite/sync-database'
import { buildOpenCodeSqliteCandidatePath } from './session-scanner-opencode-sqlite-paths'
import { listOpenCode2SqliteSessions } from './session-scanner-opencode2-sqlite-list'
import { parseOpenCode2SqliteSession } from './session-scanner-opencode2-sqlite'
import { withFullFirstUserPromptCapture } from './session-scanner-first-user-prompt-capture'
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
// Why: the opencode2 (beta) channel-scoped DB schema differs from v1 —
@@ -209,7 +210,6 @@ describe('parseOpenCode2SqliteSession', () => {
timeCreated: 1_777_634_000_500,
data: JSON.stringify({
id: 'msg_1',
type: 'user',
text: 'Add login flow',
time: { created: 1_777_634_000_500 }
})
@@ -229,17 +229,20 @@ describe('parseOpenCode2SqliteSession', () => {
})
db.close()
const session = await parseOpenCode2SqliteSession({
dbPath: path,
sessionId: 'session_1',
platform: 'darwin'
})
const session = await withFullFirstUserPromptCapture(() =>
parseOpenCode2SqliteSession({
dbPath: path,
sessionId: 'session_1',
platform: 'darwin'
})
)
expect(session).not.toBeNull()
expect(session!.agent).toBe('opencode2')
expect(session!.sessionId).toBe('session_1')
expect(session!.filePath).toBe(path)
expect(session!.title).toBe('Fix login')
expect(session?.firstUserPrompt).toBe('Add login flow')
expect(session!.cwd).toBe('/repo')
expect(session!.model).toBe('glm-5.2')
expect(session!.totalTokens).toBe(35)
@@ -256,7 +259,9 @@ describe('parseOpenCode2SqliteSession', () => {
timestamp: new Date(1_777_634_000_900).toISOString()
}
])
expect(session!.resumeCommand).toBe("cd '/repo' && opencode2 --session 'session_1'")
expect(session!.resumeCommand).toBe(
"cd '/repo' && opencode2 --standalone --session 'session_1'"
)
})
it('extracts assistant text from content arrays and falls back to raw model ids', async () => {
@@ -205,7 +205,6 @@ function readFirstUserPromptFromDb(db: SyncDatabase, sessionId: string): string
`SELECT data FROM ${OPENCODE2_MESSAGE_TABLE}
WHERE session_id = ?
AND type = 'user'
AND data LIKE '%"type":"user"%'
ORDER BY time_created ASC, seq ASC
LIMIT 1`
)
+1 -1
View File
@@ -421,7 +421,7 @@ describe('scanAiVaultSessions', () => {
"cd '/tmp/opencode' && opencode --session 'opencode-session'"
)
expect(commandByAgent.get('opencode2')).toBe(
"cd '/tmp/opencode2' && opencode2 --session 'opencode2-session'"
"cd '/tmp/opencode2' && opencode2 --standalone --session 'opencode2-session'"
)
expect(commandByAgent.get('grok')).toBe("cd '/tmp/grok' && grok --resume 'grok-session'")
expect(commandByAgent.get('hermes')).toBe(
@@ -30,7 +30,11 @@ describe('OpenCode status plugin module contract', () => {
dispose?: () => Promise<void>
}
type PluginModule = {
default?: { id?: unknown; server?: (ctx: unknown) => Promise<PluginHooks> }
default?: {
id?: unknown
server?: (ctx: unknown) => Promise<PluginHooks>
setup?: (ctx: unknown) => Promise<() => Promise<void>>
}
OrcaOpenCodeStatusPlugin?: (ctx: unknown) => Promise<PluginHooks>
}
@@ -158,6 +162,54 @@ describe('OpenCode status plugin module contract', () => {
})
})
it('subscribes through the OpenCode 2 setup API and disposes its registrations', async () => {
process.env.ORCA_PANE_KEY = 'tab-1:leaf-1'
const posts: unknown[] = []
globalThis.fetch = vi.fn(async (_input, init) => {
posts.push(JSON.parse(String(init?.body)))
return new Response('{}', { status: 200 })
})
const dispose = vi.fn()
let subscriptionSignal: AbortSignal | undefined
const module = await loadPluginModule(_internals.getOpenCode2PluginSource())
expect(module.default?.setup).toBeTypeOf('function')
const cleanup = await module.default?.setup?.({
session: {
get: async ({ sessionID }: { sessionID: string }) => ({ data: { id: sessionID } }),
hook: async () => ({ dispose })
},
event: {
subscribe: async function* ({ signal }: { signal: AbortSignal }) {
subscriptionSignal = signal
yield { type: 'session.created', data: { sessionID: 'ses_root' } }
yield {
type: 'session.execution.started',
data: { sessionID: 'ses_root' }
}
yield {
type: 'session.execution.succeeded',
data: { sessionID: 'ses_root' }
}
}
}
})
await vi.waitFor(() => {
expect(posts).toEqual(
expect.arrayContaining([
expect.objectContaining({
payload: expect.objectContaining({ hook_event_name: 'SessionBusy' })
}),
expect.objectContaining({
payload: expect.objectContaining({ hook_event_name: 'SessionIdle' })
})
])
)
})
await cleanup?.()
expect(dispose).toHaveBeenCalledOnce()
expect(subscriptionSignal?.aborted).toBe(true)
})
it('turns OpenCode 2 step lifecycle events into working and done hooks', async () => {
process.env.ORCA_PANE_KEY = 'tab-1:leaf-1'
const posts: { body: Record<string, unknown> }[] = []
@@ -1,30 +1,14 @@
import {
getOpenCode2SetupSource,
getOpenCode2EventNormalizationSource
} from '../opencode2/status-plugin-setup-source'
export function getStatusPluginFactorySource(options: {
emitSessionStart: boolean
emitNextEvents?: boolean
}): string[] {
return [
...(options.emitNextEvents
? [
'',
'function normalizeNextLifecycleEvent(event) {',
' if (!event || typeof event.type !== "string") return event;',
' const properties = event.properties || {};',
' if (event.type === "permission.v2.asked") return { ...event, type: "permission.asked", properties: { ...properties, id: properties.id, permission: properties.action, patterns: properties.resources } };',
' if (event.type === "permission.v2.replied") return { ...event, type: "permission.replied", properties: { ...properties } };',
' if (event.type === "question.v2.asked") return { ...event, type: "question.asked", properties: { ...properties } };',
' if (event.type === "question.v2.replied") return { ...event, type: "question.replied", properties: { ...properties } };',
' if (event.type === "question.v2.rejected") return { ...event, type: "question.rejected", properties: { ...properties } };',
' if (event.type === "session.next.step.started" || event.type === "session.next.tool.called" || event.type === "session.next.tool.progress" || event.type === "session.next.retried") {',
' return { ...event, type: "session.status", properties: { ...properties, status: { type: "busy" } } };',
' }',
' if (event.type === "session.next.step.ended" || event.type === "session.next.step.failed") {',
' return { ...event, type: "session.status", properties: { ...properties, status: { type: "idle" } } };',
' }',
' return event;',
'}',
''
]
: []),
...(options.emitNextEvents ? getOpenCode2EventNormalizationSource() : []),
'// Why: accept the factory argument as an optional opaque parameter instead',
'// of destructuring (`async ({ client }) => …`). OpenCode can invoke the',
'// plugin factory with undefined during startup, which makes the',
@@ -290,10 +274,17 @@ export function getStatusPluginFactorySource(options: {
' },',
' };',
'};',
'// Why: OpenCode also resolves plugins through the module default export, which must expose server(); keep the named factory too.',
...(options.emitNextEvents ? getOpenCode2SetupSource() : []),
'',
'// Why: OpenCode also resolves plugins through the module default export, and that',
'// loader rejects the module unless the default exposes `server()` ("must default',
'// export an object with server()"). `setup()` does not satisfy it. Keep the named',
'// export so the factory-based loader still finds the same instance.',
'export default {',
' id: "orca-opencode-status",',
' server: OrcaOpenCodeStatusPlugin,',
'};'
...(options.emitNextEvents ? [' setup: setupOpenCode2Status,'] : []),
'};',
''
]
}
+1 -3
View File
@@ -1,4 +1,2 @@
// OpenCode 2 uses the same supported server-plugin loader as OpenCode. The
// variant is kept under the opencode module so config overlays and plugin
// filenames stay isolated from the legacy agent.
// Share overlay management while keeping the v2 plugin and config isolated.
export { openCode2HookService as openCode2ConfigHookService } from '../opencode/hook-service'
@@ -0,0 +1,80 @@
export function getOpenCode2SetupSource(): string[] {
return String.raw`
async function setupOpenCode2Status(ctx) {
const controller = new AbortController();
const client = { session: { get: (input, options) => ctx.session.get(input, options) } };
const hooks = await OrcaOpenCodeStatusPlugin({ client });
const promptRegistration = await ctx.session.hook("prompt", async (properties) => {
await hooks.event({ event: { type: "session.next.prompt.admitted", properties } });
});
const consume = async () => {
for await (const input of ctx.event.subscribe({ signal: controller.signal })) {
if (controller.signal.aborted) break;
let type = input.type;
let properties = input.data;
if (type === "session.created") {
properties = { info: { ...properties, id: properties.sessionID } };
} else if (type === "session.execution.started") {
type = "session.status";
properties = { ...properties, status: { type: "busy" } };
} else if (type === "session.execution.succeeded" || type === "session.execution.failed" || type === "session.execution.interrupted") {
type = "session.status";
properties = { ...properties, status: { type: "idle" } };
} else if (type === "permission.asked") {
properties = { ...properties, permission: properties.action, patterns: properties.resources };
} else if (type === "form.created") {
type = "question.asked";
const form = properties.form;
properties = {
...form,
questions: form.fields.map((field) => ({
header: field.title || form.title,
question: field.description || field.title || form.title,
options: (field.options || []).map((option) => ({ label: option.label || option.value, description: option.description || "" })),
multiple: field.type === "multiselect",
})),
};
} else if (type === "form.replied" || type === "form.cancelled") {
type = type === "form.replied" ? "question.replied" : "question.rejected";
properties = { ...properties, requestID: properties.id };
} else if (type === "session.text.started" || type === "session.text.delta" || type === "session.text.ended") {
type = type.replace("session.", "session.next.");
}
await hooks.event({ event: { type, properties } });
}
};
const consuming = consume().catch((error) => {
if (!controller.signal.aborted) console.warn("[orca-hook] event subscription failed:", error.message);
});
return async () => {
controller.abort();
await promptRegistration.dispose();
await consuming;
await hooks.dispose();
};
}
`.split('\n')
}
export function getOpenCode2EventNormalizationSource(): string[] {
return [
'',
'function normalizeNextLifecycleEvent(event) {',
' if (!event || typeof event.type !== "string") return event;',
' const properties = event.properties || {};',
' if (event.type === "permission.v2.asked") return { ...event, type: "permission.asked", properties: { ...properties, id: properties.id, permission: properties.action, patterns: properties.resources } };',
' if (event.type === "permission.v2.replied") return { ...event, type: "permission.replied", properties: { ...properties } };',
' if (event.type === "question.v2.asked") return { ...event, type: "question.asked", properties: { ...properties } };',
' if (event.type === "question.v2.replied") return { ...event, type: "question.replied", properties: { ...properties } };',
' if (event.type === "question.v2.rejected") return { ...event, type: "question.rejected", properties: { ...properties } };',
' if (event.type === "session.next.step.started" || event.type === "session.next.tool.called" || event.type === "session.next.tool.progress" || event.type === "session.next.retried") {',
' return { ...event, type: "session.status", properties: { ...properties, status: { type: "busy" } } };',
' }',
' if (event.type === "session.next.step.ended" || event.type === "session.next.step.failed") {',
' return { ...event, type: "session.status", properties: { ...properties, status: { type: "idle" } } };',
' }',
' return event;',
'}',
''
]
}
+3 -1
View File
@@ -263,7 +263,9 @@ export function getAgentResumeArgv(
case 'opencode':
return providerSession.key === 'session_id' ? ['opencode', '--session', id] : null
case 'opencode2':
return providerSession.key === 'session_id' ? ['opencode2', '--session', id] : null
return providerSession.key === 'session_id'
? ['opencode2', '--standalone', '--session', id]
: null
case 'pi':
return providerSession.key === 'session_id' && providerSession.transcriptPath
? ['pi', '--session', providerSession.transcriptPath]
+2 -1
View File
@@ -200,8 +200,9 @@ function buildAgentResumeInvocation(
return `${baseCommand} resume ${sessionArg}`
case 'rovo':
return `${baseCommand} rovodev run --restore ${sessionArg}`
case 'opencode':
case 'opencode2':
return `${baseCommand} --standalone --session ${sessionArg}`
case 'opencode':
case 'pi':
// Why: Kimi Code resumes with `kimi --session <id>` (alias `-S`). Sessions
// are work-dir-scoped, so the cwd prefix from buildAiVaultResumeCommand is
+1 -6
View File
@@ -1,9 +1,4 @@
// Why: the opencode2 beta stores sessions in a channel-scoped SQLite DB
// (opencode-next.db on the default channel, opencode-local.db on the dev
// channel) while opencode v1 uses opencode.db plus stale sibling copies. Both
// match the `opencode*.db` glob used by Orca's v1 scanners, so callers must
// classify before parsing: the v2 schema (session_v2/session_message) is
// explicitly unstable in beta and must never reach the v1 parsers.
// Beta-only database names; current v2 releases share opencode.db with v1.
export const OPENCODE_V2_DATABASE_NAME_RE = /^opencode-(?:next|local)\.db$/i
export function isOpenCodeV2DatabaseName(name: string): boolean {
+2 -1
View File
@@ -132,7 +132,8 @@ const TUI_AGENT_CONFIG_SOURCE: Record<TuiAgent, TuiAgentConfigSource> = {
// Its @opentui composer keeps the same cursor-gated paste signal.
opencode2: {
detectCmd: 'opencode2',
launchCmd: 'opencode2',
// The private server inherits this pane's hook endpoint and identity.
launchCmd: 'opencode2 --standalone',
expectedProcess: 'opencode2',
promptInjectionMode: 'flag-prompt',
draftPasteReadySignal: 'render-cursor-after-bracketed-paste'