From ca5496f7909ad2f27d4dec1b83b34c03f0191b56 Mon Sep 17 00:00:00 2001 From: Neil Date: Sat, 19 Sep 2026 00:13:22 -0700 Subject: [PATCH] fix(opencode2): preserve lifecycle ordering and full session capture --- .../server-opencode-normalization.test.ts | 38 ++-- .../session-search-opencode-index.test.ts | 3 + .../opencode-transcript-capture-limits.ts | 3 + ...scanner-opencode-sources-wsl-stall.test.ts | 8 +- .../session-scanner-opencode-sources.test.ts | 6 +- .../session-scanner-opencode-sources.ts | 42 ++-- ...session-scanner-opencode-sqlite-capture.ts | 35 +--- ...canner-opencode-sqlite-coexistence.test.ts | 2 + .../session-scanner-opencode2-message.test.ts | 35 ++++ .../session-scanner-opencode2-message.ts | 59 ++++++ .../session-scanner-opencode2-sqlite-list.ts | 1 - .../session-scanner-opencode2-sqlite.test.ts | 40 +++- .../session-scanner-opencode2-sqlite.ts | 132 ++++++------ .../session-scanner-source-discovery.ts | 3 +- src/main/ipc/pty/host-env/assembly.ts | 2 +- src/main/ipc/pty/host-env/pi-agent.ts | 7 - .../scanner-windows-data-directory.test.ts | 7 +- .../hook-plugin-module-contract.test.ts | 67 +------ .../hook-plugin-opencode2-setup.test.ts | 189 ++++++++++++++++++ .../opencode/status-plugin-factory-source.ts | 3 +- src/main/opencode2/hook-service.ts | 2 - .../opencode2/status-plugin-setup-source.ts | 3 - src/relay/agent-hook-integration.test.ts | 14 +- src/relay/relay-agent-hook-runtime.ts | 17 +- src/relay/wsl-install-plugins-handler.test.ts | 47 +---- ...t-resume-host-authority-capability.test.ts | 8 + .../agent-resume-host-authority-capability.ts | 3 +- .../opencode-launch-command.test.ts} | 2 +- src/shared/opencode-launch-command.ts | 8 + src/shared/protocol-version.ts | 3 + 30 files changed, 517 insertions(+), 272 deletions(-) create mode 100644 src/main/ai-vault/opencode-transcript-capture-limits.ts create mode 100644 src/main/ai-vault/session-scanner-opencode2-message.test.ts create mode 100644 src/main/ai-vault/session-scanner-opencode2-message.ts create mode 100644 src/main/opencode/hook-plugin-opencode2-setup.test.ts delete mode 100644 src/main/opencode2/hook-service.ts rename src/{main/ipc/pty/host-env/pi-agent.test.ts => shared/opencode-launch-command.test.ts} (86%) create mode 100644 src/shared/opencode-launch-command.ts diff --git a/src/main/agent-hooks/server-opencode-normalization.test.ts b/src/main/agent-hooks/server-opencode-normalization.test.ts index adec0a8048f..56bb0b063b8 100644 --- a/src/main/agent-hooks/server-opencode-normalization.test.ts +++ b/src/main/agent-hooks/server-opencode-normalization.test.ts @@ -26,26 +26,26 @@ afterEach(() => { vi.restoreAllMocks() }) -describe('OpenCode hook normalization', () => { +describe.each(['opencode', 'opencode2'] as const)('%s hook normalization', (source) => { it('SessionBusy maps to working', () => { const result = _internals.normalizeHookPayload( - 'opencode', + source, buildBody({ hook_event_name: 'SessionBusy' }), 'production' ) expect(result?.payload.state).toBe('working') - expect(result?.payload.agentType).toBe('opencode') + expect(result?.payload.agentType).toBe(source) }) it('SessionBusy does NOT clear the cached user prompt', () => { // Why: OpenCode caches the user's MessagePart before SessionBusy fires, so the cached prompt is this turn's; clearing it would clobber the dashboard. _internals.normalizeHookPayload( - 'opencode', + source, buildBody({ hook_event_name: 'MessagePart', role: 'user', text: 'new prompt' }), 'production' ) const result = _internals.normalizeHookPayload( - 'opencode', + source, buildBody({ hook_event_name: 'SessionBusy' }), 'production' ) @@ -55,17 +55,17 @@ describe('OpenCode hook normalization', () => { it('SessionIdle maps to done', () => { const result = _internals.normalizeHookPayload( - 'opencode', + source, buildBody({ hook_event_name: 'SessionIdle' }), 'production' ) expect(result?.payload.state).toBe('done') - expect(result?.payload.agentType).toBe('opencode') + expect(result?.payload.agentType).toBe(source) }) it('PermissionRequest maps to waiting', () => { const result = _internals.normalizeHookPayload( - 'opencode', + source, buildBody({ hook_event_name: 'PermissionRequest' }), 'production' ) @@ -75,17 +75,17 @@ describe('OpenCode hook normalization', () => { it('AskUserQuestion maps to waiting', () => { // Why: AskUserQuestion leaves the agent idle-but-waiting on a human, so it must map to `waiting` (red dot) like permission.asked, not stay `working`. const result = _internals.normalizeHookPayload( - 'opencode', + source, buildBody({ hook_event_name: 'AskUserQuestion' }), 'production' ) expect(result?.payload.state).toBe('waiting') - expect(result?.payload.agentType).toBe('opencode') + expect(result?.payload.agentType).toBe(source) }) it('unknown event name returns null', () => { const result = _internals.normalizeHookPayload( - 'opencode', + source, buildBody({ hook_event_name: 'SomeOtherEvent' }), 'production' ) @@ -94,7 +94,7 @@ describe('OpenCode hook normalization', () => { it('MessagePart with role=user surfaces text as the prompt and stays working', () => { const result = _internals.normalizeHookPayload( - 'opencode', + source, buildBody({ hook_event_name: 'MessagePart', role: 'user', @@ -106,12 +106,12 @@ describe('OpenCode hook normalization', () => { expect(result?.payload.state).toBe('working') expect(result?.payload.prompt).toBe('hi there') expect(result?.hasExplicitPrompt).toBe(true) - expect(result?.promptInteractionKey).toBe('opencode-message-msg-1') + expect(result?.promptInteractionKey).toBe(`${source}-message-msg-1`) }) it('MessagePart with role=assistant populates lastAssistantMessage', () => { const result = _internals.normalizeHookPayload( - 'opencode', + source, buildBody({ hook_event_name: 'MessagePart', role: 'assistant', @@ -126,7 +126,7 @@ describe('OpenCode hook normalization', () => { it('caps oversized MessagePart text from stale (pre-throttle) plugin builds', () => { // Why: stale plugin builds re-post the full reply on every part update, so the listener must cap the text to keep per-event work O(cap). const assistant = _internals.normalizeHookPayload( - 'opencode', + source, buildBody({ hook_event_name: 'MessagePart', role: 'assistant', @@ -138,7 +138,7 @@ describe('OpenCode hook normalization', () => { // Why: prompt is capped at 200 by normalizeAgentStatusObject; assert oversized input still stays within that bound. const user = _internals.normalizeHookPayload( - 'opencode', + source, buildBody({ hook_event_name: 'MessagePart', role: 'user', @@ -152,17 +152,17 @@ describe('OpenCode hook normalization', () => { it('subsequent SessionIdle preserves cached prompt + assistant message', () => { _internals.normalizeHookPayload( - 'opencode', + source, buildBody({ hook_event_name: 'MessagePart', role: 'user', text: 'hi' }), 'production' ) _internals.normalizeHookPayload( - 'opencode', + source, buildBody({ hook_event_name: 'MessagePart', role: 'assistant', text: 'hello back' }), 'production' ) const done = _internals.normalizeHookPayload( - 'opencode', + source, buildBody({ hook_event_name: 'SessionIdle' }), 'production' ) diff --git a/src/main/ai-vault-search/session-search-opencode-index.test.ts b/src/main/ai-vault-search/session-search-opencode-index.test.ts index f825168c16a..52da511765d 100644 --- a/src/main/ai-vault-search/session-search-opencode-index.test.ts +++ b/src/main/ai-vault-search/session-search-opencode-index.test.ts @@ -9,7 +9,10 @@ vi.mock('../ai-vault/session-scanner-opencode-sqlite-worker-spawn', async () => const parse = await import('../ai-vault/session-scanner-opencode-sqlite') const capture = await import('../ai-vault/session-scanner-opencode-sqlite-capture') const own = await import('./session-search-opencode-index.test') + const { listOpenCode2SqliteSessions } = + await import('../ai-vault/session-scanner-opencode2-sqlite-list') return { + listOpenCode2SqliteSessionsViaWorker: listOpenCode2SqliteSessions, resolveOpenCodeSqliteWorkerEntryPath: () => null, listOpenCodeSqliteSessionsViaWorker: ( args: Parameters[0] diff --git a/src/main/ai-vault/opencode-transcript-capture-limits.ts b/src/main/ai-vault/opencode-transcript-capture-limits.ts new file mode 100644 index 00000000000..2781fecb6f8 --- /dev/null +++ b/src/main/ai-vault/opencode-transcript-capture-limits.ts @@ -0,0 +1,3 @@ +// Captures cross a worker boundary; exceeding either bound must fail, never return a complete-looking prefix. +export const OPENCODE_CAPTURE_RECORD_LIMIT = 20_000 +export const OPENCODE_CAPTURE_TEXT_LIMIT = 64 * 1024 * 1024 diff --git a/src/main/ai-vault/session-scanner-opencode-sources-wsl-stall.test.ts b/src/main/ai-vault/session-scanner-opencode-sources-wsl-stall.test.ts index 42ce4f247db..244f8d7afa2 100644 --- a/src/main/ai-vault/session-scanner-opencode-sources-wsl-stall.test.ts +++ b/src/main/ai-vault/session-scanner-opencode-sources-wsl-stall.test.ts @@ -16,7 +16,8 @@ vi.mock('node:fs/promises', async (importOriginal) => ({ // The SQLite leg spawns a real worker thread, which fake timers cannot drive. vi.mock('./session-scanner-opencode-sqlite-worker-spawn', () => ({ - listOpenCodeSqliteSessionsViaWorker: async () => [] + listOpenCodeSqliteSessionsViaWorker: async () => [], + listOpenCode2SqliteSessionsViaWorker: async () => [] })) import { opencodeDiscoveries } from './session-scanner-opencode-sources' @@ -77,7 +78,8 @@ describe('OpenCode source discovery with a stalled WSL data directory', () => { // Zero databases for that home is the degraded answer; without the issue it // would be indistinguishable from "OpenCode was never installed there". const resolved = await discoveries - expect(resolved).toHaveLength(2) + expect(resolved).toHaveLength(4) + expect(mocks.readdir.mock.calls.filter(([path]) => path === WSL_DATA_DIR)).toHaveLength(1) expect(resolved.every((discovery) => discovery.files.length === 0)).toBe(true) expect(issues.some((issue) => issue.path === WSL_DATA_DIR)).toBe(true) expect(issues.every((issue) => issue.agent === 'opencode')).toBe(true) @@ -96,7 +98,7 @@ describe('OpenCode source discovery with a stalled WSL data directory', () => { // The primary source is the one per-root containment cannot reach, so a // silent [] here reads as "no OpenCode sessions" on a clean scan. - await expect(discoveries).resolves.toHaveLength(1) + await expect(discoveries).resolves.toHaveLength(2) expect( issues.some((issue) => issue.agent === 'opencode' && issue.path === `${WSL_HOME}/opencode`) ).toBe(true) diff --git a/src/main/ai-vault/session-scanner-opencode-sources.test.ts b/src/main/ai-vault/session-scanner-opencode-sources.test.ts index 17af8d83ee0..753805405b7 100644 --- a/src/main/ai-vault/session-scanner-opencode-sources.test.ts +++ b/src/main/ai-vault/session-scanner-opencode-sources.test.ts @@ -1,12 +1,12 @@ import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import { opencode2Discoveries, opencodeDiscoveries } from './session-scanner-opencode-sources' +import { opencodeDiscoveries } from './session-scanner-opencode-sources' const { discoverOpenCodeSessionsMock, listOpenCodeDatabasesMock, listOpenCode2SessionsMock } = vi.hoisted(() => ({ discoverOpenCodeSessionsMock: vi.fn(), listOpenCodeDatabasesMock: vi.fn(), - listOpenCode2SessionsMock: vi.fn() + listOpenCode2SessionsMock: vi.fn().mockResolvedValue([]) })) vi.mock('./session-scanner-opencode-sqlite-worker-spawn', () => ({ @@ -30,7 +30,7 @@ describe('opencodeDiscoveries', () => { 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, [])) + await Promise.all(opencodeDiscoveries({ opencodeDbPaths: dbPaths }, [], 25, [])) expect(listOpenCode2SessionsMock).toHaveBeenCalledWith({ dbPaths, limit: 25, issues: [] }) await Promise.all(opencodeDiscoveries({ opencodeDbPaths: dbPaths }, [], 25, [])) expect(discoverOpenCodeSessionsMock).toHaveBeenCalledWith( diff --git a/src/main/ai-vault/session-scanner-opencode-sources.ts b/src/main/ai-vault/session-scanner-opencode-sources.ts index bcb0b8b9c63..ea93787d642 100644 --- a/src/main/ai-vault/session-scanner-opencode-sources.ts +++ b/src/main/ai-vault/session-scanner-opencode-sources.ts @@ -16,26 +16,20 @@ export function opencodeDiscoveries( limit: number, issues: AiVaultScanIssue[] ): Promise[] { - const storageDirs = opencodeStorageDirs(options, wslHomeDirs) - return storageDirs.map(async (storageDir, index) => { - 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 }) - }) -} - -export function opencode2Discoveries( - options: AiVaultScanOptions, - wslHomeDirs: readonly string[], - limit: number, - issues: AiVaultScanIssue[] -): Promise[] { - return opencodeStorageDirs(options, wslHomeDirs).map(async (storageDir, index) => { - // 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) + return opencodeStorageDirs(options, wslHomeDirs).flatMap((storageDir, index) => { + const paths = opencodeDbPathsForSource(options, wslHomeDirs, storageDir, index, issues) + return [ + paths.then((dbPaths) => + discoverOpenCodeSessions({ + storageDir, + dbPaths: dbPaths.filter((path) => !isOpenCodeV2DatabaseName(basename(path))), + limitPerAgent: limit, + issues + }) + ), + // Current releases share opencode.db with v1; the worker checks for v2 tables. + paths.then((dbPaths) => discoverOpenCode2Sessions(storageDir, dbPaths, limit, issues)) + ] }) } @@ -111,11 +105,3 @@ async function discoverOpenCode2Sessions( files: files.map((candidate) => candidate.file) } } - -function emptyOpenCode2Discovery(storageDir: string): SessionFileDiscovery { - return { - agent: 'opencode2' as const, - rootDir: storageDir, - files: [] - } -} diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-capture.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-capture.ts index 9be9b734e2f..f4622118956 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-capture.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-capture.ts @@ -1,3 +1,7 @@ +import { + OPENCODE_CAPTURE_RECORD_LIMIT, + OPENCODE_CAPTURE_TEXT_LIMIT +} from './opencode-transcript-capture-limits' import type { AiVaultSession } from '../../shared/ai-vault-types' import { timestampIso } from './session-scanner-accumulator' import { asRecord } from './session-scanner-record-value' @@ -16,31 +20,6 @@ import type SyncDatabase from '../sqlite/sync-database' /** The part types that carry something a person would search for. */ const OPENCODE_CAPTURE_PART_TYPES = "('text','reasoning','tool')" -/** - * How many parts one session may hold before this read gives up. - * - * A safety valve on memory, not a policy: the rows are materialized and then - * posted across the worker boundary, so an unbounded session would be held - * twice. Exceeding it throws rather than returning a prefix, because a prefix - * committed under a complete-read cursor would leave the tail unsearchable with - * nothing on the row to say so. A failed read is retried and surfaces; a silent - * truncation does neither. Measured against a real 21 GB database: the busiest - * session there holds 1,427 of these parts. - */ -const OPENCODE_CAPTURE_PART_LIMIT = 20_000 - -/** - * How much decoded text one session may carry, for the same reason. - * - * Not a truncation policy and not a second cap on tool rows -- the index writer - * owns that, at 3 KB a row. This is the bound a non-streaming source needs and - * a streaming one does not: a JSONL provider publishes each message as it reads - * it, while this one holds the whole session before posting it. Measured on the - * same database, the largest session's parts total 9.5 MB, so this is ~7x the - * worst real one. - */ -const OPENCODE_CAPTURE_TEXT_LIMIT = 64 * 1024 * 1024 - type CaptureRow = { messageId: string role: string | null @@ -180,10 +159,10 @@ export function readOpenCodeSessionMessages( `OpenCode session ${sessionId} uses an unreadable message-part schema; its transcript was not read.` ) } - const rows = db.prepare(buildCaptureQuery()).all(sessionId, OPENCODE_CAPTURE_PART_LIMIT + 1) - if (rows.length > OPENCODE_CAPTURE_PART_LIMIT) { + const rows = db.prepare(buildCaptureQuery()).all(sessionId, OPENCODE_CAPTURE_RECORD_LIMIT + 1) + if (rows.length > OPENCODE_CAPTURE_RECORD_LIMIT) { throw new Error( - `OpenCode session ${sessionId} holds more than ${OPENCODE_CAPTURE_PART_LIMIT} text parts; its transcript was not read.` + `OpenCode session ${sessionId} holds more than ${OPENCODE_CAPTURE_RECORD_LIMIT} text parts; its transcript was not read.` ) } diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts index a5981a016d3..6a376f7bd85 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts @@ -13,7 +13,9 @@ vi.mock('./session-scanner-opencode-sqlite-worker-spawn', async () => { import('./session-scanner-opencode-sqlite-list'), import('./session-scanner-opencode-sqlite') ]) + const { listOpenCode2SqliteSessions } = await import('./session-scanner-opencode2-sqlite-list') return { + listOpenCode2SqliteSessionsViaWorker: listOpenCode2SqliteSessions, listOpenCodeSqliteSessionsViaWorker: listOpenCodeSqliteSessions, parseOpenCodeSqliteSessionViaWorker: parseOpenCodeSqliteSession } diff --git a/src/main/ai-vault/session-scanner-opencode2-message.test.ts b/src/main/ai-vault/session-scanner-opencode2-message.test.ts new file mode 100644 index 00000000000..beaf6c9ca1b --- /dev/null +++ b/src/main/ai-vault/session-scanner-opencode2-message.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { + decodeOpenCode2Message, + extractOpenCode2MessageText +} from './session-scanner-opencode2-message' + +describe('OpenCode 2 message decoding', () => { + it('captures reasoning, assistant text, tool calls and results without indexing provider state', () => { + const data = JSON.stringify({ + content: [ + { type: 'reasoning', text: 'Checking the result', state: { secret: 'must not index' } }, + { type: 'text', text: 'Finished' }, + { + type: 'tool', + name: 'bash', + state: { + status: 'completed', + input: { command: 'echo proof' }, + content: [{ type: 'text', text: 'proof' }] + } + } + ] + }) + const messages = decodeOpenCode2Message(data, 'assistant', null) + expect(messages).toEqual( + expect.arrayContaining([ + { role: 'assistant', text: 'Checking the result\nFinished', timestamp: null }, + { role: 'tool', text: 'bash: echo proof', timestamp: null }, + { role: 'tool', text: 'proof', timestamp: null } + ]) + ) + expect(JSON.stringify(messages)).not.toContain('must not index') + expect(extractOpenCode2MessageText(data)).toBe('Finished') + }) +}) diff --git a/src/main/ai-vault/session-scanner-opencode2-message.ts b/src/main/ai-vault/session-scanner-opencode2-message.ts new file mode 100644 index 00000000000..17aa0cf632f --- /dev/null +++ b/src/main/ai-vault/session-scanner-opencode2-message.ts @@ -0,0 +1,59 @@ +import type { AiVaultSessionPreviewMessage } from '../../shared/ai-vault-types' +import { asRecord } from './session-scanner-record-value' +import { parseJsonObject } from './session-scanner-values' +import { transcriptMessagesFromContent } from './session-transcript-message-content' + +export function extractOpenCode2MessageText(data: string): string | null { + const record = parseJsonObject(data) + if (typeof record?.text === 'string') { + return record.text + } + if (Array.isArray(record?.text)) { + return record.text.filter((part): part is string => typeof part === 'string').join('\n') || null + } + if (!Array.isArray(record?.content)) { + return null + } + return ( + record.content + .flatMap((value) => { + const item = asRecord(value) + return item?.type === 'text' && typeof item.text === 'string' ? [item.text] : [] + }) + .join('\n') || null + ) +} + +export function decodeOpenCode2Message( + data: string, + role: AiVaultSessionPreviewMessage['role'], + timestamp: string | null +) { + const record = parseJsonObject(data) + const content = Array.isArray(record?.content) + ? record.content.flatMap((value) => { + const item = asRecord(value) + if (item?.type !== 'tool') { + return [value] + } + const state = asRecord(item.state) + return [ + { type: 'tool_use', name: item.name, input: state?.input }, + { type: 'tool_result', content: state?.content } + ] + }) + : record?.text + return transcriptMessagesFromContent(role, content, timestamp) +} + +export function parseOpenCode2MessageRow(value: unknown) { + const row = asRecord(value) + if ( + typeof row?.data !== 'string' || + typeof row.type !== 'string' || + typeof row.time_created !== 'number' + ) { + throw new Error('OpenCode 2 transcript contains an invalid message') + } + return { data: row.data, type: row.type, time_created: row.time_created } +} diff --git a/src/main/ai-vault/session-scanner-opencode2-sqlite-list.ts b/src/main/ai-vault/session-scanner-opencode2-sqlite-list.ts index 6283cbc0bc3..2107d66525a 100644 --- a/src/main/ai-vault/session-scanner-opencode2-sqlite-list.ts +++ b/src/main/ai-vault/session-scanner-opencode2-sqlite-list.ts @@ -91,7 +91,6 @@ function dedupeAndSortCandidates(candidates: SessionFileCandidate[]): SessionFil * List opencode2 sessions from one or more channel-scoped SQLite databases as * synthetic `SessionFileCandidate` entries, mirroring the v1 SQLite list leg. * Databases that lack the `session_v2` table are silently skipped; errors are - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime validation or the local test fixture establishes the asserted shape. * recorded as scan issues. */ export async function listOpenCode2SqliteSessions(args: { diff --git a/src/main/ai-vault/session-scanner-opencode2-sqlite.test.ts b/src/main/ai-vault/session-scanner-opencode2-sqlite.test.ts index feeaa3a7b3d..3365436a238 100644 --- a/src/main/ai-vault/session-scanner-opencode2-sqlite.test.ts +++ b/src/main/ai-vault/session-scanner-opencode2-sqlite.test.ts @@ -5,7 +5,10 @@ import { afterEach, describe, expect, it } from 'vitest' 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 { + parseOpenCode2SqliteSession, + captureOpenCode2SqliteSession +} from './session-scanner-opencode2-sqlite' import { withFullFirstUserPromptCapture } from './session-scanner-first-user-prompt-capture' import type { AiVaultScanIssue } from '../../shared/ai-vault-types' @@ -128,6 +131,41 @@ function insertMessage( const issues: AiVaultScanIssue[] = [] +it('captures the full transcript while keeping list previews bounded', async () => { + const { db, path } = createTempDb('opencode.db') + applyOpenCode2Schema(db) + insertSession(db, { id: 'long', directory: '/repo', timeCreated: 1000, timeUpdated: 9000 }) + for (let index = 0; index < 8; index++) { + insertMessage(db, { + id: `message_${index}`, + sessionId: 'long', + type: 'user', + seq: index, + timeCreated: 1000 + index, + data: JSON.stringify({ text: `Turn ${index}` }) + }) + } + db.close() + const args = { dbPath: path, sessionId: 'long', platform: 'darwin' as const } + const preview = await parseOpenCode2SqliteSession(args) + const capture = await captureOpenCode2SqliteSession(args) + expect(preview?.previewMessages).toHaveLength(5) + expect(capture.messages.map((message) => message.text)).toEqual( + Array.from({ length: 8 }, (_, index) => `Turn ${index}`) + ) +}) + +it('keeps session metadata readable but refuses a complete capture when ordering columns are missing', async () => { + const { db, path } = createTempDb() + applyOpenCode2Schema(db) + insertSession(db, { id: 'partial', directory: '/repo', timeCreated: 1000, timeUpdated: 1000 }) + db.exec('ALTER TABLE session_message DROP COLUMN seq') + db.close() + const args = { dbPath: path, sessionId: 'partial', platform: 'darwin' as const } + expect((await parseOpenCode2SqliteSession(args))?.sessionId).toBe('partial') + await expect(captureOpenCode2SqliteSession(args)).rejects.toThrow('schema is unreadable') +}) + describe('listOpenCode2SqliteSessions', () => { it('lists top-level, non-archived sessions newest-first', async () => { const { db, path } = createTempDb() diff --git a/src/main/ai-vault/session-scanner-opencode2-sqlite.ts b/src/main/ai-vault/session-scanner-opencode2-sqlite.ts index d16203fca6b..86cedcc075b 100644 --- a/src/main/ai-vault/session-scanner-opencode2-sqlite.ts +++ b/src/main/ai-vault/session-scanner-opencode2-sqlite.ts @@ -1,15 +1,25 @@ +import { + OPENCODE_CAPTURE_RECORD_LIMIT, + OPENCODE_CAPTURE_TEXT_LIMIT +} from './opencode-transcript-capture-limits' import type { AiVaultSession, AiVaultSessionPreviewMessage } from '../../shared/ai-vault-types' import { addPreviewMessage, createAccumulator, finalizeSession, - updateTimeline + updateTimeline, + timestampIso } from './session-scanner-accumulator' import { normalizeFullFirstUserPromptText, shouldCaptureFullFirstUserPrompt } from './session-scanner-first-user-prompt' import { normalizeTitleText } from './session-scanner-values' +import { + extractOpenCode2MessageText, + decodeOpenCode2Message, + parseOpenCode2MessageRow +} from './session-scanner-opencode2-message' import SyncDatabase from '../sqlite/sync-database' import { columnExists, tableExists } from '../opencode-usage/schema-helpers' import type { TranscriptMessage, TranscriptMessageSink } from './session-transcript-consumers' @@ -146,56 +156,8 @@ function mapPreviewRole(type: string | null): AiVaultSessionPreviewMessage['role return 'unknown' } -// Why: user messages carry `text` (string or array of strings); assistant -// messages carry `content` as an array of {type:'text'|'reasoning', text}. -function extractMessageText(data: string): string | null { - try { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime validation or the local test fixture establishes the asserted shape. - const parsed = JSON.parse(data) as unknown - const record = - parsed && typeof parsed === 'object' && !Array.isArray(parsed) - ? // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime validation or the local test fixture establishes the asserted shape. - (parsed as Record) - : null - if (!record) { - return null - } - const text = record.text - if (typeof text === 'string') { - return text - } - if (Array.isArray(text)) { - const parts = text.filter((part): part is string => typeof part === 'string') - return parts.length > 0 ? parts.join('\n') : null - } - const content = record.content - if (Array.isArray(content)) { - const texts: string[] = [] - for (const item of content) { - if ( - item && - typeof item === 'object' && - !Array.isArray(item) && - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime validation or the local test fixture establishes the asserted shape. - (item as Record).type === 'text' && - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime validation or the local test fixture establishes the asserted shape. - typeof (item as Record).text === 'string' - ) { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the guards immediately above prove this is a text record with a string text field. - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime validation or the local test fixture establishes the asserted shape. - texts.push((item as Record).text as string) - } - } - return texts.length > 0 ? texts.join('\n') : null - } - return null - } catch { - return null - } -} - function readFirstUserPromptFromDb(db: SyncDatabase, sessionId: string): string | null { - if (!canCountOpenCode2Messages(db) || !columnExists(db, OPENCODE2_MESSAGE_TABLE, 'data')) { + if (!canReadOpenCode2Messages(db)) { return null } try { @@ -211,7 +173,7 @@ function readFirstUserPromptFromDb(db: SyncDatabase, sessionId: string): string // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the SELECT projects one string data column and better-sqlite3 returns rows synchronously. // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime validation or the local test fixture establishes the asserted shape. .all(sessionId) as { data: string }[] - const text = rows[0] ? extractMessageText(rows[0].data) : null + const text = rows[0] ? extractOpenCode2MessageText(rows[0].data) : null return text ? normalizeFullFirstUserPromptText(text) : null } catch { return null @@ -219,7 +181,7 @@ function readFirstUserPromptFromDb(db: SyncDatabase, sessionId: string): string } function buildPreviewQuery(db: SyncDatabase): string | null { - if (!canCountOpenCode2Messages(db) || !columnExists(db, OPENCODE2_MESSAGE_TABLE, 'data')) { + if (!canReadOpenCode2Messages(db)) { return null } return `SELECT type, data, time_created @@ -232,6 +194,35 @@ function buildPreviewQuery(db: SyncDatabase): string | null { LIMIT ?` } +function canReadOpenCode2Messages(db: SyncDatabase): boolean { + return ( + canCountOpenCode2Messages(db) && + ['data', 'time_created', 'seq'].every((column) => + columnExists(db, OPENCODE2_MESSAGE_TABLE, column) + ) + ) +} + +function* readCaptureRows(db: SyncDatabase, sessionId: string): Generator { + if (!canReadOpenCode2Messages(db)) { + throw new Error('OpenCode 2 transcript schema is unreadable') + } + const rows = db + .prepare(`SELECT type, data, time_created FROM session_message + WHERE session_id = ? AND type IN ('user','assistant','tool') ORDER BY time_created, seq`) + .iterate(sessionId) + let count = 0 + let bytes = 0 + for (const value of rows) { + const row = parseOpenCode2MessageRow(value) + bytes += Buffer.byteLength(row.data) + if (++count > OPENCODE_CAPTURE_RECORD_LIMIT || bytes > OPENCODE_CAPTURE_TEXT_LIMIT) { + throw new Error('OpenCode 2 transcript exceeds capture limits; no partial read was published') + } + yield { type: row.type, data: row.data, time_created: row.time_created } + } +} + /** * Parse a single opencode2 session from the channel-scoped SQLite database * into an `AiVaultSession`. Reads session metadata (title, cwd, model, tokens, @@ -288,27 +279,38 @@ export async function parseOpenCode2SqliteSession(args: { updateTimeline(accumulator, row.time_updated) const previewSql = buildPreviewQuery(db) - if (previewSql) { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The guarded preview query selects the declared PreviewRow columns. - const probedRows = db - .prepare(previewSql) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime validation or the local test fixture establishes the asserted shape. - .all(sessionId, OPENCODE2_PREVIEW_LIMIT + 1) as PreviewRow[] + if (args.messages?.active || previewSql) { + const probedRows = + previewSql && !args.messages?.active + ? db + .prepare(previewSql) + .all(sessionId, OPENCODE2_PREVIEW_LIMIT + 1) + .map(parseOpenCode2MessageRow) + : [] if (probedRows.length > OPENCODE2_PREVIEW_LIMIT) { accumulator.previewMessagesTruncated = true } - const previewRows = probedRows.slice(0, OPENCODE2_PREVIEW_LIMIT) - for (let i = previewRows.length - 1; i >= 0; i--) { - const previewRow = previewRows[i] - if (!previewRow) { - continue + const previewRows = args.messages?.active + ? readCaptureRows(db, sessionId) + : probedRows.slice(0, OPENCODE2_PREVIEW_LIMIT).toReversed() + for (const previewRow of previewRows) { + const role = mapPreviewRole(previewRow.type) + if (args.messages?.active) { + for (const message of decodeOpenCode2Message( + previewRow.data, + role, + timestampIso(previewRow.time_created) + )) { + args.messages.push(message) + } } - const text = extractMessageText(previewRow.data) + const text = extractOpenCode2MessageText(previewRow.data) if (!text) { continue } addPreviewMessage(accumulator, { - role: mapPreviewRole(previewRow.type), + role, + publishMessage: false, text, timestamp: previewRow.time_created, seedFirstUserPrompt: false diff --git a/src/main/ai-vault/session-scanner-source-discovery.ts b/src/main/ai-vault/session-scanner-source-discovery.ts index 0008b9b4b3a..d62fdd4d19d 100644 --- a/src/main/ai-vault/session-scanner-source-discovery.ts +++ b/src/main/ai-vault/session-scanner-source-discovery.ts @@ -1,7 +1,7 @@ import { delimiter } from 'node:path' import type { AiVaultAgent, AiVaultScanIssue } from '../../shared/ai-vault-types' import { discoverFiles } from './session-scanner-discovery' -import { opencode2Discoveries, opencodeDiscoveries } from './session-scanner-opencode-sources' +import { opencodeDiscoveries } from './session-scanner-opencode-sources' import { antigravityDiscoveries } from './session-scanner-antigravity-sources' import { AI_VAULT_AGENT_SOURCES, type AiVaultAgentSource } from './session-scanner-agent-sources' import { normalizedWslHomeDirs } from './session-scanner-roots' @@ -24,7 +24,6 @@ export async function discoverAiVaultSessionSources(args: { // SQLite DB. discoverOpenCodeSessions runs both the file scanner (legacy) // and the SQLite scanner (1.17.x); dedup by sessionId happens inside. ...opencodeDiscoveries(options, wslHomeDirs, limitPerAgent, issues), - ...opencode2Discoveries(options, wslHomeDirs, limitPerAgent, issues), ...antigravityDiscoveries(options, wslHomeDirs, limitPerAgent, issues), ...Object.entries(AI_VAULT_AGENT_SOURCES).flatMap(([agent, source]) => source diff --git a/src/main/ipc/pty/host-env/assembly.ts b/src/main/ipc/pty/host-env/assembly.ts index 002cd2c06a3..ef8585a93a2 100644 --- a/src/main/ipc/pty/host-env/assembly.ts +++ b/src/main/ipc/pty/host-env/assembly.ts @@ -1,4 +1,5 @@ import { resolveSetupAgentSequenceLaunchCommand } from '../../../../shared/setup-agent-sequencing' +import { isOpenCode2LaunchCommand } from '../../../../shared/opencode-launch-command' import { detectExplicitPiAgentKindFromCommand, isPiCompatibleAgentType @@ -21,7 +22,6 @@ import { clearPiAgentShadowEnv, exposePiManagedExtensionEnv, isMimoLaunchCommand, - isOpenCode2LaunchCommand, resolveMimocodeSourceHome, resolveOpenCodeSourceConfigDir, resolvePiAgentSourceDir, diff --git a/src/main/ipc/pty/host-env/pi-agent.ts b/src/main/ipc/pty/host-env/pi-agent.ts index ad3ed22d52e..545d04d063d 100644 --- a/src/main/ipc/pty/host-env/pi-agent.ts +++ b/src/main/ipc/pty/host-env/pi-agent.ts @@ -169,13 +169,6 @@ export function isMimoLaunchCommand(launchCommand: string | undefined): boolean return binary === 'mimo' } -export function isOpenCode2LaunchCommand(launchCommand: string | undefined): boolean { - const binary = getCommandTokenPathBasename(getFirstCommandToken(launchCommand ?? '')) - .toLowerCase() - .replace(/\.(?:cmd|exe|sh)$/, '') - return binary === 'opencode2' -} - export function resolveMimocodeSourceHome(baseEnv: Record): string | undefined { const sourceHome = baseEnv.ORCA_MIMOCODE_SOURCE_HOME ?? process.env.ORCA_MIMOCODE_SOURCE_HOME if (sourceHome) { diff --git a/src/main/opencode-usage/scanner-windows-data-directory.test.ts b/src/main/opencode-usage/scanner-windows-data-directory.test.ts index 317e7b57954..59470f77ef3 100644 --- a/src/main/opencode-usage/scanner-windows-data-directory.test.ts +++ b/src/main/opencode-usage/scanner-windows-data-directory.test.ts @@ -9,7 +9,12 @@ import { scanOpenCodeUsageDatabases } from './scanner' vi.mock('../ai-vault/session-scanner-opencode-sqlite-worker-spawn', async () => { const { listOpenCodeSqliteSessions } = await import('../ai-vault/session-scanner-opencode-sqlite-list') - return { listOpenCodeSqliteSessionsViaWorker: listOpenCodeSqliteSessions } + const { listOpenCode2SqliteSessions } = + await import('../ai-vault/session-scanner-opencode2-sqlite-list') + return { + listOpenCodeSqliteSessionsViaWorker: listOpenCodeSqliteSessions, + listOpenCode2SqliteSessionsViaWorker: listOpenCode2SqliteSessions + } }) describe('OpenCode usage discovery on Windows', () => { diff --git a/src/main/opencode/hook-plugin-module-contract.test.ts b/src/main/opencode/hook-plugin-module-contract.test.ts index bc90061b82b..6b3ca68ae5c 100644 --- a/src/main/opencode/hook-plugin-module-contract.test.ts +++ b/src/main/opencode/hook-plugin-module-contract.test.ts @@ -16,14 +16,7 @@ vi.mock('electron', () => ({ import { _internals } from './hook-service' -/** - * OpenCode loads a plugin file either through a named factory export or through the - * module default export. The default-export loader rejects the module outright unless - * the default is an object exposing `server()` — verified against opencode 1.18.18, - * which logs `failed to load plugin … must default export an object with server()` for - * a default of `{ id, setup }` and accepts `{ id, server }`. These tests execute the - * generated module so the shipped file is checked against both loaders, not a substring. - */ +// Execute the generated module against legacy and current plugin contracts. describe('OpenCode status plugin module contract', () => { type PluginHooks = { event: (input: { event: unknown }) => Promise @@ -162,55 +155,7 @@ 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 () => { + it('keeps OpenCode 2 busy across steps until the session becomes idle', async () => { process.env.ORCA_PANE_KEY = 'tab-1:leaf-1' const posts: { body: Record }[] = [] // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The mocked fetch is assigned to the standard Fetch API shape. @@ -237,6 +182,14 @@ describe('OpenCode status plugin module contract', () => { properties: { sessionID: 'ses_root', assistantMessageID: 'msg-1' } } }) + expect(posts).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + payload: expect.objectContaining({ hook_event_name: 'SessionIdle' }) + }) + ]) + ) + await hooks?.event({ event: { type: 'session.idle', properties: { sessionID: 'ses_root' } } }) await new Promise((resolve) => setTimeout(resolve, 50)) const hookEvents = posts.map((post) => { const payload = post.body.payload diff --git a/src/main/opencode/hook-plugin-opencode2-setup.test.ts b/src/main/opencode/hook-plugin-opencode2-setup.test.ts new file mode 100644 index 00000000000..4356ddc85ce --- /dev/null +++ b/src/main/opencode/hook-plugin-opencode2-setup.test.ts @@ -0,0 +1,189 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' + +const { getPathMock } = vi.hoisted(() => ({ + getPathMock: vi.fn<(name: string) => string>() +})) + +vi.mock('electron', () => ({ + app: { + getPath: getPathMock + } +})) + +import { _internals } from './hook-service' + +// Execute the generated module against legacy and current plugin contracts. +describe('OpenCode 2 setup and prompt ordering', () => { + type PluginHooks = { + event: (input: { event: unknown }) => Promise + dispose?: () => Promise + } + type PluginModule = { + default?: { + id?: unknown + server?: (ctx: unknown) => Promise + setup?: (ctx: unknown) => Promise<() => Promise> + } + OrcaOpenCodeStatusPlugin?: (ctx: unknown) => Promise + } + + // Why: the plugin resolves hook coords from the endpoint file first and only then from + // env. Pin every input here so the run does not depend on the developer's Orca session + // (an inherited ORCA_AGENT_HOOK_ENDPOINT would otherwise redirect the post to a live app). + const ENV_KEYS = [ + 'ORCA_PANE_KEY', + 'ORCA_AGENT_HOOK_ENDPOINT', + 'ORCA_AGENT_HOOK_PORT', + 'ORCA_AGENT_HOOK_TOKEN' + ] as const + + let tempDir: string + let savedFetch: typeof globalThis.fetch + let savedEnv: Record + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'orca-opencode-plugin-contract-')) + savedFetch = globalThis.fetch + savedEnv = {} + for (const key of ENV_KEYS) { + savedEnv[key] = process.env[key] + } + delete process.env.ORCA_AGENT_HOOK_ENDPOINT + process.env.ORCA_AGENT_HOOK_PORT = '59999' + process.env.ORCA_AGENT_HOOK_TOKEN = 'test-token' + }) + + afterEach(() => { + globalThis.fetch = savedFetch + for (const key of ENV_KEYS) { + if (savedEnv[key] === undefined) { + delete process.env[key] + } else { + process.env[key] = savedEnv[key] + } + } + rmSync(tempDir, { recursive: true, force: true }) + }) + + async function loadPluginModule( + source = _internals.getOpenCodePluginSource() + ): Promise { + // Why: a unique basename per load defeats the ESM module cache between cases. + const pluginPath = join( + tempDir, + `orca-opencode-status-${Math.random().toString(36).slice(2)}.mjs` + ) + writeFileSync(pluginPath, source) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime validation or the local test fixture establishes the asserted shape. + return (await import(pathToFileURL(pluginPath).href)) as PluginModule + } + + 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.each(['waiting', 'idle', 'disposed'])( + 'drops an admitted prompt overtaken by %s', + async (transition) => { + 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 }) + }) + let releaseLookup: (value: { data: { id: string } }) => void = () => {} + const lookup = new Promise<{ data: { id: string } }>((resolve) => { + releaseLookup = resolve + }) + const module = await loadPluginModule(_internals.getOpenCode2PluginSource()) + const hooks = await module.default?.server?.({ client: { session: { get: () => lookup } } }) + expect(hooks).toBeDefined() + const prompt = hooks?.event({ + event: { + type: 'session.next.prompt.admitted', + properties: { + sessionID: 'ses_root', + messageID: 'msg_user', + prompt: { text: 'stale prompt' } + } + } + }) + if (transition === 'disposed') { + await hooks?.dispose?.() + } else { + // Seed ancestry while the earlier prompt lookup remains suspended. + await hooks?.event({ + event: { type: 'session.created', properties: { info: { id: 'ses_root' } } } + }) + await hooks?.event({ + event: + transition === 'waiting' + ? { + type: 'permission.asked', + properties: { + id: 'perm_1', + sessionID: 'ses_root', + permission: 'bash', + patterns: ['sleep 25'] + } + } + : { type: 'session.idle', properties: { sessionID: 'ses_root' } } + }) + } + releaseLookup({ data: { id: 'ses_root' } }) + await prompt + expect(posts).not.toContainEqual( + expect.objectContaining({ payload: expect.objectContaining({ role: 'user' }) }) + ) + await hooks?.dispose?.() + } + ) +}) diff --git a/src/main/opencode/status-plugin-factory-source.ts b/src/main/opencode/status-plugin-factory-source.ts index 9438be0d74a..48074c5669c 100644 --- a/src/main/opencode/status-plugin-factory-source.ts +++ b/src/main/opencode/status-plugin-factory-source.ts @@ -49,6 +49,7 @@ export function getStatusPluginFactorySource(options: { ' if (event.type === "session.next.prompt.admitted") {', ' if (!sessionID) return;', ' if ((await isChildSession(client, sessionID)) !== false) return;', + ' if (disposed || authorityRevision !== stateArrivalRevision || desiredStatus === "waiting") return;', ' const prompt = event.properties?.prompt?.text;', ' if (typeof prompt !== "string" || !prompt) return;', ' await postMessagePart({', @@ -129,7 +130,7 @@ export function getStatusPluginFactorySource(options: { ' event.type === "question.replied" ||', ` event.type === "question.rejected"${ options.emitNextEvents - ? ' || event.type === "permission.v2.asked" || event.type === "permission.v2.replied" || event.type === "question.v2.asked" || event.type === "question.v2.replied" || event.type === "question.v2.rejected" || event.type === "session.next.step.started" || event.type === "session.next.step.ended" || event.type === "session.next.step.failed" || event.type === "session.next.tool.called" || event.type === "session.next.tool.progress" || event.type === "session.next.retried"' + ? ' || event.type === "permission.v2.asked" || event.type === "permission.v2.replied" || event.type === "question.v2.asked" || event.type === "question.v2.replied" || event.type === "question.v2.rejected" || event.type === "session.next.step.started" || event.type === "session.next.tool.called" || event.type === "session.next.tool.progress" || event.type === "session.next.retried"' : '' }`, ' ) {', diff --git a/src/main/opencode2/hook-service.ts b/src/main/opencode2/hook-service.ts deleted file mode 100644 index ae8cd3401f3..00000000000 --- a/src/main/opencode2/hook-service.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Share overlay management while keeping the v2 plugin and config isolated. -export { openCode2HookService as openCode2ConfigHookService } from '../opencode/hook-service' diff --git a/src/main/opencode2/status-plugin-setup-source.ts b/src/main/opencode2/status-plugin-setup-source.ts index 78991418b29..c33385ef8d6 100644 --- a/src/main/opencode2/status-plugin-setup-source.ts +++ b/src/main/opencode2/status-plugin-setup-source.ts @@ -70,9 +70,6 @@ export function getOpenCode2EventNormalizationSource(): string[] { ' 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;', '}', '' diff --git a/src/relay/agent-hook-integration.test.ts b/src/relay/agent-hook-integration.test.ts index f7781d3cdc7..b5a0f1066e4 100644 --- a/src/relay/agent-hook-integration.test.ts +++ b/src/relay/agent-hook-integration.test.ts @@ -118,7 +118,13 @@ describe('Integration: relay hook server → mux → AgentHookServer.ingestRemot rmSync(tmpDir, { recursive: true, force: true }) }) - it('forwards a Claude UserPromptSubmit POST through to ingestRemote', async () => { + it.each([ + { agent: 'claude', input: { hook_event_name: 'UserPromptSubmit', prompt: 'roundtrip' } }, + { + agent: 'opencode2', + input: { hook_event_name: 'MessagePart', role: 'user', text: 'roundtrip' } + } + ])('forwards a $agent prompt through the relay to ingestRemote', async ({ agent, input }) => { const events: { paneKey: string; payload: unknown; connectionId: string | null }[] = [] orcaServer.setListener((event) => { events.push({ @@ -129,7 +135,7 @@ describe('Integration: relay hook server → mux → AgentHookServer.ingestRemot }) const { port, token } = hookServer.getCoordinates() - const res = await fetch(`http://127.0.0.1:${port}/hook/claude`, { + const res = await fetch(`http://127.0.0.1:${port}/hook/${agent}`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -141,7 +147,7 @@ describe('Integration: relay hook server → mux → AgentHookServer.ingestRemot worktreeId: 'wt-7', env: 'remote', version: '1', - payload: { hook_event_name: 'UserPromptSubmit', prompt: 'roundtrip' } + payload: input }) }) expect(res.status).toBe(204) @@ -159,7 +165,7 @@ describe('Integration: relay hook server → mux → AgentHookServer.ingestRemot const payload = events[0].payload as { state: string; prompt: string; agentType: string } expect(payload.state).toBe('working') expect(payload.prompt).toBe('roundtrip') - expect(payload.agentType).toBe('claude') + expect(payload.agentType).toBe(agent) }) it('sheds an oversized assistant message through the production publication path', async () => { diff --git a/src/relay/relay-agent-hook-runtime.ts b/src/relay/relay-agent-hook-runtime.ts index 1ca629c3263..ac5c5bb8d8f 100644 --- a/src/relay/relay-agent-hook-runtime.ts +++ b/src/relay/relay-agent-hook-runtime.ts @@ -19,6 +19,7 @@ import { isPiCompatibleAgentType } from '../shared/pi-agent-kind' import { resolveSetupAgentSequenceLaunchCommand } from '../shared/setup-agent-sequencing' +import { isOpenCode2LaunchCommand } from '../shared/opencode-launch-command' import { relayLogLine } from './relay-diagnostic-log' import { registerManagedHookInstaller } from './managed-hook-installer' @@ -83,7 +84,11 @@ export class RelayAgentHookRuntime { ): Promise> { const env: Record = {} const overlayId = context.paneKey ?? context.id - const opencodeAgent = context.launchAgent === 'opencode2' ? 'opencode2' : 'opencode' + const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(context.env, context.command) + const opencodeAgent = + context.launchAgent === 'opencode2' || isOpenCode2LaunchCommand(launchCommandHint) + ? 'opencode2' + : 'opencode' if (this.pluginOverlay.hasOpenCodeSource(opencodeAgent)) { const sourceDir = resolveOpenCodeSourceConfigDir(context.env, context.shell) const dir = this.pluginOverlay.materializeOpenCode(overlayId, sourceDir, opencodeAgent) @@ -95,7 +100,9 @@ export class RelayAgentHookRuntime { } } } - const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(context.env, context.command) + if (!this.pluginOverlay.hasPiSource()) { + return env + } const explicitKind = isPiCompatibleAgentType(context.launchAgent) ? context.launchAgent : context.launchAgent === undefined @@ -104,12 +111,6 @@ export class RelayAgentHookRuntime { const kind = explicitKind ?? 'pi' const hasLaunchCommand = typeof launchCommandHint === 'string' && launchCommandHint.trim().length > 0 - if (kind === 'omp' || !hasLaunchCommand) { - env.ORCA_OMP_FRESH_CONFIG = this.pluginOverlay.materializeOmpFreshConfig() - } - if (!this.pluginOverlay.hasPiSource()) { - return env - } if (kind === 'pi') { const sourceDir = resolvePiSourceAgentDir(context.env, context.shell, 'pi') const result = this.pluginOverlay.materializePi(overlayId, sourceDir, 'pi', { diff --git a/src/relay/wsl-install-plugins-handler.test.ts b/src/relay/wsl-install-plugins-handler.test.ts index e6465993587..9a230b768d3 100644 --- a/src/relay/wsl-install-plugins-handler.test.ts +++ b/src/relay/wsl-install-plugins-handler.test.ts @@ -26,14 +26,10 @@ describe.skipIf(process.platform === 'win32')('createInstallPluginsHandler (gues it('writes orca-opencode-status.js into the overlay and returns that dir', () => { withHome((home) => { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The test supplies the complete process environment fields used by the handler. - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The test supplies the complete process environment fields used by the handler. - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The test supplies the complete process environment fields used by the handler. const install = createInstallPluginsHandler(new PluginOverlayManager({ homeDir: home }), { HOME: home, ORCA_WSL_HOOK_INSTANCE: 'inst1' - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime validation or the local test fixture establishes the asserted shape. - } as NodeJS.ProcessEnv) + }) const source = '// orca opencode status plugin\nexport const Plugin = () => ({})\n' const res = install({ opencodePluginSource: source }) @@ -49,14 +45,10 @@ describe.skipIf(process.platform === 'win32')('createInstallPluginsHandler (gues it('writes the OpenCode 2 plugin to its separate overlay', () => { withHome((home) => { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The test supplies the complete process environment fields used by the handler. - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The test supplies the complete process environment fields used by the handler. - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The test supplies the complete process environment fields used by the handler. const install = createInstallPluginsHandler(new PluginOverlayManager({ homeDir: home }), { HOME: home, ORCA_WSL_HOOK_INSTANCE: 'inst-v2' - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime validation or the local test fixture establishes the asserted shape. - } as NodeJS.ProcessEnv) + }) const source = '// opencode2\n' const res = install({ opencode2PluginSource: source }) const dir = res.overlayDirs.opencode2 @@ -72,12 +64,10 @@ describe.skipIf(process.platform === 'win32')('createInstallPluginsHandler (gues it('reuses the overlay on repeat installs instead of rebuilding it', () => { withHome((home) => { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The test supplies the complete process environment fields used by the handler. const install = createInstallPluginsHandler(new PluginOverlayManager({ homeDir: home }), { HOME: home, ORCA_WSL_HOOK_INSTANCE: 'inst1' - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime validation or the local test fixture establishes the asserted shape. - } as NodeJS.ProcessEnv) + }) const source = '// v1\n' // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime validation or the local test fixture establishes the asserted shape. const dir = install({ opencodePluginSource: source }).overlayDirs.opencode as string @@ -100,8 +90,7 @@ describe.skipIf(process.platform === 'win32')('createInstallPluginsHandler (gues // this branch today; it exists so a plugin-only overlay can't outlive a source // dir becoming resolvable. Simulated by mutating the env the factory captured. const userConfig = join(home, 'my-opencode') - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime validation or the local test fixture establishes the asserted shape. - const env = { HOME: home, ORCA_WSL_HOOK_INSTANCE: 'inst1' } as NodeJS.ProcessEnv + const env: NodeJS.ProcessEnv = { HOME: home, ORCA_WSL_HOOK_INSTANCE: 'inst1' } const install = createInstallPluginsHandler(new PluginOverlayManager({ homeDir: home }), env) const source = '// v1\n' install({ opencodePluginSource: source }) @@ -118,12 +107,10 @@ describe.skipIf(process.platform === 'win32')('createInstallPluginsHandler (gues it('rebuilds when the cached overlay lost its plugin file', () => { withHome((home) => { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The test supplies the complete process environment fields used by the handler. const install = createInstallPluginsHandler(new PluginOverlayManager({ homeDir: home }), { HOME: home, ORCA_WSL_HOOK_INSTANCE: 'inst1' - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime validation or the local test fixture establishes the asserted shape. - } as NodeJS.ProcessEnv) + }) const source = '// v1\n' // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime validation or the local test fixture establishes the asserted shape. const dir = install({ opencodePluginSource: source }).overlayDirs.opencode as string @@ -138,12 +125,10 @@ describe.skipIf(process.platform === 'win32')('createInstallPluginsHandler (gues it('re-materializes when the shipped source changes', () => { withHome((home) => { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The test supplies the complete process environment fields used by the handler. const install = createInstallPluginsHandler(new PluginOverlayManager({ homeDir: home }), { HOME: home, ORCA_WSL_HOOK_INSTANCE: 'inst1' - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime validation or the local test fixture establishes the asserted shape. - } as NodeJS.ProcessEnv) + }) install({ opencodePluginSource: '// v1\n' }) // Why: a mid-session Orca upgrade ships new plugin source; future spawns must see it. // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime validation or the local test fixture establishes the asserted shape. @@ -154,12 +139,10 @@ describe.skipIf(process.platform === 'win32')('createInstallPluginsHandler (gues it('rebuilds when the cached overlay disappeared from the guest', () => { withHome((home) => { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The test supplies the complete process environment fields used by the handler. const install = createInstallPluginsHandler(new PluginOverlayManager({ homeDir: home }), { HOME: home, ORCA_WSL_HOOK_INSTANCE: 'inst1' - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime validation or the local test fixture establishes the asserted shape. - } as NodeJS.ProcessEnv) + }) const source = '// v1\n' // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime validation or the local test fixture establishes the asserted shape. const dir = install({ opencodePluginSource: source }).overlayDirs.opencode as string @@ -177,13 +160,11 @@ describe.skipIf(process.platform === 'win32')('createInstallPluginsHandler (gues const userConfig = join(home, 'my-opencode') mkdirSync(userConfig, { recursive: true }) writeFileSync(join(userConfig, 'opencode.json'), '{"model":"user-set"}') - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The test supplies the complete process environment fields used by the handler. const install = createInstallPluginsHandler(new PluginOverlayManager({ homeDir: home }), { HOME: home, ORCA_OPENCODE_SOURCE_CONFIG_DIR: userConfig, ORCA_WSL_HOOK_INSTANCE: 'inst1' - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime validation or the local test fixture establishes the asserted shape. - } as NodeJS.ProcessEnv) + }) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime validation or the local test fixture establishes the asserted shape. const dir = install({ opencodePluginSource: '// v1\n' }).overlayDirs.opencode as string @@ -200,12 +181,10 @@ describe.skipIf(process.platform === 'win32')('createInstallPluginsHandler (gues const defaultConfig = join(home, '.config', 'opencode') mkdirSync(defaultConfig, { recursive: true }) writeFileSync(join(defaultConfig, 'opencode.json'), '{"model":"default"}') - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The test supplies the complete process environment fields used by the handler. const install = createInstallPluginsHandler(new PluginOverlayManager({ homeDir: home }), { HOME: home, ORCA_WSL_HOOK_INSTANCE: 'inst1' - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime validation or the local test fixture establishes the asserted shape. - } as NodeJS.ProcessEnv) + }) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime validation or the local test fixture establishes the asserted shape. const dir = install({ opencodePluginSource: '// v1\n' }).overlayDirs.opencode as string @@ -217,11 +196,9 @@ describe.skipIf(process.platform === 'win32')('createInstallPluginsHandler (gues it('rejects a source that exceeds the byte cap before writing anything', () => { withHome((home) => { const overlay = new PluginOverlayManager({ homeDir: home }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The test supplies the complete process environment fields used by the handler. const install = createInstallPluginsHandler(overlay, { HOME: home - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime validation or the local test fixture establishes the asserted shape. - } as NodeJS.ProcessEnv) + }) const tooBig = 'a'.repeat(PLUGIN_SOURCE_MAX_BYTES + 1) expect(() => install({ opencodePluginSource: tooBig })).toThrow(/byte cap/) expect(overlay.hasOpenCodeSource()).toBe(false) @@ -230,11 +207,9 @@ describe.skipIf(process.platform === 'win32')('createInstallPluginsHandler (gues it('returns no overlay dir when no opencode source is provided', () => { withHome((home) => { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The test supplies the complete process environment fields used by the handler. const install = createInstallPluginsHandler(new PluginOverlayManager({ homeDir: home }), { HOME: home - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime validation or the local test fixture establishes the asserted shape. - } as NodeJS.ProcessEnv) + }) const res = install({}) expect(res.installed.opencode).toBe(false) expect(res.overlayDirs.opencode).toBeUndefined() diff --git a/src/renderer/src/runtime/agent-resume-host-authority-capability.test.ts b/src/renderer/src/runtime/agent-resume-host-authority-capability.test.ts index 83b6a9493d9..1885d059b89 100644 --- a/src/renderer/src/runtime/agent-resume-host-authority-capability.test.ts +++ b/src/renderer/src/runtime/agent-resume-host-authority-capability.test.ts @@ -2,12 +2,19 @@ import { describe, expect, it } from 'vitest' import { RESUMABLE_TUI_AGENTS } from '../../../shared/agent-session-resume' import { AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY, + AGENT_SESSION_OPENCODE2_RESUME_RUNTIME_CAPABILITY, AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY, RUNTIME_CAPABILITIES } from '../../../shared/protocol-version' import { agentResumeHostAuthorityCapability } from './agent-resume-host-authority-capability' describe('agentResumeHostAuthorityCapability', () => { + it('gates OpenCode 2 resume behind its own advertised capability', () => { + expect(agentResumeHostAuthorityCapability('opencode2')).toBe( + AGENT_SESSION_OPENCODE2_RESUME_RUNTIME_CAPABILITY + ) + expect(RUNTIME_CAPABILITIES).toContain(AGENT_SESSION_OPENCODE2_RESUME_RUNTIME_CAPABILITY) + }) it('gates Kimi resume behind its own capability', () => { expect(agentResumeHostAuthorityCapability('kimi')).toBe( AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY @@ -43,6 +50,7 @@ describe('agentResumeHostAuthorityCapability', () => { gemini: undefined, antigravity: undefined, opencode: undefined, + opencode2: AGENT_SESSION_OPENCODE2_RESUME_RUNTIME_CAPABILITY, pi: undefined, 'mimo-code': undefined, droid: undefined, diff --git a/src/renderer/src/runtime/agent-resume-host-authority-capability.ts b/src/renderer/src/runtime/agent-resume-host-authority-capability.ts index 4b6ed0d0206..cd2198e9db2 100644 --- a/src/renderer/src/runtime/agent-resume-host-authority-capability.ts +++ b/src/renderer/src/runtime/agent-resume-host-authority-capability.ts @@ -2,6 +2,7 @@ import type { ResumableTuiAgent } from '../../../shared/agent-session-resume' import type { TuiAgent } from '../../../shared/tui-agent' import { AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY, + AGENT_SESSION_OPENCODE2_RESUME_RUNTIME_CAPABILITY, AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY, type RuntimeCapability } from '../../../shared/protocol-version' @@ -19,7 +20,7 @@ const RESUME_HOST_AUTHORITY_CAPABILITY_BY_AGENT = { gemini: undefined, antigravity: undefined, opencode: undefined, - opencode2: undefined, + opencode2: AGENT_SESSION_OPENCODE2_RESUME_RUNTIME_CAPABILITY, pi: undefined, 'mimo-code': undefined, droid: undefined, diff --git a/src/main/ipc/pty/host-env/pi-agent.test.ts b/src/shared/opencode-launch-command.test.ts similarity index 86% rename from src/main/ipc/pty/host-env/pi-agent.test.ts rename to src/shared/opencode-launch-command.test.ts index 1148b303c78..a422633f4bf 100644 --- a/src/main/ipc/pty/host-env/pi-agent.test.ts +++ b/src/shared/opencode-launch-command.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { isOpenCode2LaunchCommand } from './pi-agent' +import { isOpenCode2LaunchCommand } from './opencode-launch-command' describe('isOpenCode2LaunchCommand', () => { it.each(['opencode2', '/usr/local/bin/opencode2', 'opencode2.exe', 'opencode2.cmd'])( diff --git a/src/shared/opencode-launch-command.ts b/src/shared/opencode-launch-command.ts new file mode 100644 index 00000000000..44dd3038e6b --- /dev/null +++ b/src/shared/opencode-launch-command.ts @@ -0,0 +1,8 @@ +import { getCommandTokenPathBasename, getFirstCommandToken } from './command-token-scanner' + +export function isOpenCode2LaunchCommand(launchCommand: string | undefined): boolean { + const binary = getCommandTokenPathBasename(getFirstCommandToken(launchCommand ?? '')) + .toLowerCase() + .replace(/\.(?:cmd|exe|sh)$/, '') + return binary === 'opencode2' +} diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 302e25f6ed0..5c4fc6fbb12 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -213,6 +213,8 @@ export const AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY = // older host answers the unknown member with invalid_argument — a code the launch fallback does // not retry on — so clients must probe before taking the host-authority path. export const AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY = 'agent-session.kimi-resume.v1' as const +export const AGENT_SESSION_OPENCODE2_RESUME_RUNTIME_CAPABILITY = + 'agent-session.opencode2-resume.v1' as const // Why: older runtimes strip mutation owner fields, so clients must fence writes before RPC. export const FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY = 'files.mutation-ownership.v1' as const export const FILE_MUTATION_OWNERSHIP_UPDATE_REQUIRED_MESSAGE = @@ -354,6 +356,7 @@ export const RUNTIME_CAPABILITIES = [ AGENT_SESSION_TURN_ITEM_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY, + AGENT_SESSION_OPENCODE2_RESUME_RUNTIME_CAPABILITY, FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY, GITHUB_MARK_PR_READY_RUNTIME_CAPABILITY, GITLAB_READY_FOR_REVIEW_RUNTIME_CAPABILITY,