diff --git a/README.md b/README.md index 1a69e1a..656ed0d 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,8 @@ allowlisted lifecycle shape into the shared durable session-summary queue. `session.idle` becomes `RunEnd`, so one completed agent response can issue one run receipt without pretending the multi-turn OpenCode conversation ended. `session.deleted` remains the whole-session terminal boundary. +An auth or provider failure before a completed assistant response or tool +request does not create a run receipt. The adapter observes session, user-message, permission, and tool lifecycle events. Capture is metadata-only by default. Set @@ -83,11 +85,15 @@ missing, mismatched, or unverified v2 activation fails closed; the task is not prompted with ambient organizational authority. For an interactive session without a Gateway activation, `session.created` -(or the first session-bound message/model request if that event was missed) -requests one context pack for the most specific configured anchor: task, -workstream, initiative, then workspace. Hydration is exactly once per native -OpenCode session. Internal model requests without a native session ID receive -no OrgX context or authority. +(or the first session-bound user message if that event was missed) first asks +the Wizard to claim any staged cwd-only context for the exact native session. +A successful local claim is delivered on that user turn without a network +refresh. Otherwise, the plugin requests one context pack for the most specific +configured anchor: task, workstream, initiative, then workspace. Hydration is +exactly once per native OpenCode session. The bounded context is retried after +a pre-inference failure, then stops being added after a completed assistant +response or tool request. Auxiliary title and small-model system transforms do +not receive it. The full pack and any pending activation are stored outside the repository in owner-only Wizard state. Each directory is keyed by the SHA-256 of the resolved @@ -110,10 +116,15 @@ orgx-wizard sessions context set --file - --cwd \ The digest is calculated from recursively key-sorted JSON (array order is preserved and undefined values are omitted). The plugin accepts activation only when the Wizard returns the v1 acknowledgement and v2 activation versions plus -the exact resolved cwd, source client, native session ID, and digest. It clears -that exact lease on `session.deleted`, and it clears stale authority when a -refresh fails or returns an invalid API envelope. If the Wizard is offline, the -plugin keeps the exact context in that session's private pending file. +the exact resolved cwd, source client, native session ID, and digest. After +model work, `session.deleted` clears that exact lease. If the session ends +before a completed assistant response or tool request, the Wizard atomically +returns the exact v2 lease to one cwd-only v1 activation before the plugin +clears private state. A newer cwd-only activation is never overwritten. If +release cannot be proven, the exact lease and private context remain intact. +The plugin also clears stale authority when a refresh fails or returns an +invalid API envelope. If the Wizard is offline, it keeps the exact context in +that session's private pending file. Interactive sessions may continue with briefing explicitly marked non-authoritative; Gateway dispatches fail before prompting. Context fetch and activation each time out after 3 seconds by default. diff --git a/package-lock.json b/package-lock.json index c66214e..788f87a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@useorgx/orgx-opencode-plugin", - "version": "0.1.0-alpha.18", + "version": "0.1.0-alpha.19", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@useorgx/orgx-opencode-plugin", - "version": "0.1.0-alpha.18", + "version": "0.1.0-alpha.19", "license": "MIT", "dependencies": { "@opencode-ai/sdk": "^1.18.2", diff --git a/package.json b/package.json index e4792d8..424c862 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@useorgx/orgx-opencode-plugin", - "version": "0.1.0-alpha.18", + "version": "0.1.0-alpha.19", "publishConfig": { "access": "public" }, diff --git a/plugin.manifest.json b/plugin.manifest.json index 95c4f85..3395f3a 100644 --- a/plugin.manifest.json +++ b/plugin.manifest.json @@ -1,6 +1,6 @@ { "plugin_name": "@useorgx/orgx-opencode-plugin", - "version": "0.1.0-alpha.18", + "version": "0.1.0-alpha.19", "manifest_fingerprint": "", "signature": "", "capabilities": [ diff --git a/src/contextPackHydration.test.ts b/src/contextPackHydration.test.ts index 2822af8..a123f18 100644 --- a/src/contextPackHydration.test.ts +++ b/src/contextPackHydration.test.ts @@ -30,6 +30,7 @@ import { sessionWorkContextSha256, type SpawnLike, } from './contextPackHydration.js'; +import { normalizeAbsoluteHostPath } from './hostPath.js'; type HydrationInput = NonNullable< Parameters[0] @@ -346,7 +347,7 @@ describe('opencode context-pack hydration', () => { '--file', '-', '--cwd', - projectDir, + normalizeAbsoluteHostPath(projectDir), '--source-client', 'opencode', '--session-id', @@ -663,7 +664,7 @@ describe('opencode context-pack hydration', () => { 'context', 'clear', '--cwd', - projectDir, + normalizeAbsoluteHostPath(projectDir), '--source-client', 'opencode', '--session-id', @@ -780,7 +781,7 @@ describe('opencode context-pack hydration', () => { 'context', 'clear', '--cwd', - projectDir, + normalizeAbsoluteHostPath(projectDir), '--source-client', 'opencode', '--session-id', @@ -963,7 +964,7 @@ describe('opencode context-pack hydration', () => { 'context', 'clear', '--cwd', - projectDir, + normalizeAbsoluteHostPath(projectDir), '--source-client', 'opencode', '--session-id', diff --git a/src/hostPath.test.ts b/src/hostPath.test.ts index 5b376f8..ce8c5b7 100644 --- a/src/hostPath.test.ts +++ b/src/hostPath.test.ts @@ -1,3 +1,12 @@ +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + symlinkSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; import { normalizeAbsoluteHostPath } from './hostPath'; @@ -44,4 +53,19 @@ describe('normalizeAbsoluteHostPath', () => { ); expect(normalizeAbsoluteHostPath('work/orgx', 'linux')).toBeNull(); }); + + it('canonicalizes a POSIX symlink alias through its longest existing ancestor', () => { + const root = mkdtempSync(join(tmpdir(), 'orgx-opencode-path-')); + const physical = join(root, 'physical'); + const alias = join(root, 'alias'); + mkdirSync(physical); + symlinkSync(physical, alias, 'dir'); + try { + expect(normalizeAbsoluteHostPath(join(alias, 'future', 'repo'))).toBe( + join(realpathSync.native(physical), 'future', 'repo') + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); }); diff --git a/src/hostPath.ts b/src/hostPath.ts index 03c14e0..e17c267 100644 --- a/src/hostPath.ts +++ b/src/hostPath.ts @@ -1,3 +1,4 @@ +import { realpathSync } from 'node:fs'; import { platform as runtimePlatform } from 'node:os'; import { posix, win32 } from 'node:path'; @@ -5,6 +6,23 @@ const WINDOWS_DRIVE_ABSOLUTE = /^[A-Za-z]:[\\/]/; const WINDOWS_UNC_ABSOLUTE = /^[\\/]{2}(?![?.][\\/])[^\\/]+[\\/][^\\/]+(?:[\\/]|$)/; const WINDOWS_DEVICE_NAMESPACE = /^[\\/]{1,2}(?:[?.][\\/]|\?\?[\\/])/; +function canonicalPosixPath(value: string): string { + const absolute = posix.resolve(value); + let ancestor = absolute; + const missing: string[] = []; + + while (true) { + try { + return posix.resolve(realpathSync.native(ancestor), ...missing); + } catch { + const parent = posix.dirname(ancestor); + if (parent === ancestor) return absolute; + missing.unshift(posix.basename(ancestor)); + ancestor = parent; + } + } +} + /** * Canonicalize an absolute path using the selected host's path contract. * @@ -19,7 +37,7 @@ export function normalizeAbsoluteHostPath( if (typeof value !== 'string' || value.trim().length === 0) return null; if (hostPlatform !== 'win32') { - return posix.isAbsolute(value) ? posix.resolve(value) : null; + return posix.isAbsolute(value) ? canonicalPosixPath(value) : null; } if ( diff --git a/src/peer.test.ts b/src/peer.test.ts index f3d0a6c..19fb74b 100644 --- a/src/peer.test.ts +++ b/src/peer.test.ts @@ -110,7 +110,7 @@ describe('startPeer', () => { ); expect(presence?.body).toMatchObject({ - gateway_version: '0.1.0-alpha.18', + gateway_version: '0.1.0-alpha.19', metadata: { execution_provider: null, execution_provider_id: null, diff --git a/src/plugin.test.ts b/src/plugin.test.ts index 2511f67..6bdb92e 100644 --- a/src/plugin.test.ts +++ b/src/plugin.test.ts @@ -22,13 +22,12 @@ import { } from './runtimeSessionContext'; type PluginHooks = { - 'experimental.chat.system.transform': ( - input: { sessionID?: string }, - output: { system: string[] } - ) => Promise; 'chat.message': ( input: { sessionID: string; messageID?: string }, - output: { parts: Array> } + output: { + message: { system?: string }; + parts: Array>; + } ) => Promise; event: (input: { event: Record & { type: string }; @@ -203,7 +202,7 @@ describe('OrgXOpenCodePlugin', () => { expect(completed).toBe(true); }); - it('injects compiled context into every model request without duplicating one output', async () => { + it('retries context before inference without resending it after model work', async () => { const hydrateContextPack = vi.fn(async () => ({ ok: true, additionalContext: 'accepted OrgX decision context', @@ -217,30 +216,33 @@ describe('OrgXOpenCodePlugin', () => { ORGX_WORKSPACE_ID: 'workspace-123', }, }); - const first = { system: ['native system'] }; - const second = { system: ['native system'] }; + const first = { message: { system: 'native system' }, parts: [] }; + const retry = { message: {}, parts: [] }; + const afterCompletion = { message: {}, parts: [] }; - await hooks['experimental.chat.system.transform']( - { sessionID: 'session-1' }, - first - ); - await hooks['experimental.chat.system.transform']( - { sessionID: 'session-1' }, - second - ); - await hooks['experimental.chat.system.transform']( - { sessionID: 'session-1' }, - second - ); + await hooks['chat.message']({ sessionID: 'session-1' }, first); + await hooks['chat.message']({ sessionID: 'session-1' }, first); + await hooks['chat.message']({ sessionID: 'session-1' }, retry); + await hooks.event({ + event: { + type: 'message.updated', + properties: { + sessionID: 'session-1', + info: { + sessionID: 'session-1', + role: 'assistant', + time: { created: 1, completed: 2 }, + }, + }, + }, + }); + await hooks['chat.message']({ sessionID: 'session-1' }, afterCompletion); - expect(first.system).toEqual([ - 'native system', - 'accepted OrgX decision context', - ]); - expect(second.system).toEqual([ - 'native system', - 'accepted OrgX decision context', - ]); + expect(first.message.system).toBe( + 'native system\n\naccepted OrgX decision context' + ); + expect(retry.message.system).toBe('accepted OrgX decision context'); + expect(afterCompletion.message.system).toBeUndefined(); expect(hydrateContextPack).toHaveBeenCalledTimes(1); }); @@ -273,42 +275,26 @@ describe('OrgXOpenCodePlugin', () => { reason: 'wizard_activated', }, }); - const output = { system: ['native system'] }; + const output = { message: { system: 'native system' }, parts: [] }; - await hooks['experimental.chat.system.transform']( - { sessionID: sessionId }, - output - ); + await hooks['chat.message']({ sessionID: sessionId }, output); - expect(output.system).toEqual([ - 'native system', - 'exact dispatched task context', - ]); - expect(output.system).not.toContain('ambient workspace context'); + expect(output.message.system).toBe( + 'native system\n\nexact dispatched task context' + ); + expect(output.message.system).not.toContain('ambient workspace context'); } finally { clearRuntimeSessionHydration('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/work/repo', sessionId); } }); - it('does not hydrate or inject into sessionless internal agent generation', async () => { - const hydrateContextPack = vi.fn(async () => ({ - ok: true, - additionalContext: 'must remain session-bound', - } as const)); + it('does not expose context through the auxiliary system-transform hook', async () => { const hooks = await loadHooks({ - hydrateContextPack, logger: createLogger(), - env: { - ORGX_API_KEY: 'oxk_test', - ORGX_WORKSPACE_ID: 'workspace-123', - }, + env: {}, }); - const output = { system: ['native system'] }; - - await hooks['experimental.chat.system.transform']({}, output); - expect(hydrateContextPack).not.toHaveBeenCalled(); - expect(output.system).toEqual(['native system']); + expect(hooks).not.toHaveProperty('experimental.chat.system.transform'); }); it('isolates hydration caches by native session and drops a deleted session', async () => { @@ -339,6 +325,19 @@ describe('OrgXOpenCodePlugin', () => { await hooks.event({ event: sessionEvent('session.created', 'session-a') }); await hooks.event({ event: sessionEvent('session.created', 'session-b') }); + await hooks.event({ + event: { + type: 'message.updated', + properties: { + sessionID: 'session-a', + info: { + sessionID: 'session-a', + role: 'assistant', + time: { created: 1, completed: 2 }, + }, + }, + }, + }); await hooks.event({ event: sessionEvent('session.deleted', 'session-a') }); await hooks.event({ event: sessionEvent('session.created', 'session-a') }); @@ -396,6 +395,20 @@ describe('OrgXOpenCodePlugin', () => { } ); + await hooks.event({ + event: { + type: 'message.updated', + properties: { + sessionID: 'session-a', + info: { + sessionID: 'session-a', + role: 'assistant', + time: { created: 1, completed: 2 }, + }, + }, + }, + }); + await hooks.event({ event: { type: 'session.deleted', @@ -421,7 +434,7 @@ describe('OrgXOpenCodePlugin', () => { } }); - it('waits for in-flight hydration before clearing the terminal session lease', async () => { + it('waits for in-flight hydration before releasing an unused terminal session lease', async () => { let finishHydration: (() => void) | undefined; const hydrateContextPack = vi.fn( () => @@ -433,9 +446,20 @@ describe('OrgXOpenCodePlugin', () => { cleared: true, reason: 'wizard_cleared', } as const)); + const bridgeSessionSummary = vi.fn(async ({ nativeEvent }) => + nativeEvent === 'session.abandoned' + ? { + ok: true, + state_persisted: true, + activation_released: true, + activation_release_state: 'released', + } + : { ok: true } + ); const hooks = await loadHooks({ hydrateContextPack, clearSessionWorkContext, + bridgeSessionSummary, logger: createLogger(), env: {}, }); @@ -449,15 +473,16 @@ describe('OrgXOpenCodePlugin', () => { event: { type: 'session.deleted', properties }, }); await Promise.resolve(); - expect(clearSessionWorkContext).not.toHaveBeenCalled(); + expect( + bridgeSessionSummary.mock.calls.map(([call]) => call.nativeEvent) + ).not.toContain('session.abandoned'); finishHydration?.(); await Promise.all([creating, deleting]); - expect(clearSessionWorkContext).toHaveBeenCalledWith({ - env: {}, - projectDir: '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/work/repo', - sessionId: 'session-race', - }); + expect(clearSessionWorkContext).not.toHaveBeenCalled(); + expect( + bridgeSessionSummary.mock.calls.map(([call]) => call.nativeEvent) + ).toContain('session.abandoned'); }); it('does not send a credential-bearing base override to any network path', async () => { @@ -576,6 +601,7 @@ describe('OrgXOpenCodePlugin', () => { await hooks['chat.message']( { sessionID: 'session-1', messageID: 'message-1' }, { + message: {}, parts: [ { type: 'text', text: 'Implement the work episode.' }, { type: 'text', text: 'hidden', synthetic: true }, @@ -596,11 +622,12 @@ describe('OrgXOpenCodePlugin', () => { expect(bridgeSessionSummary.mock.calls.map(([call]) => call.nativeEvent)).toEqual([ 'session.idle', + 'session.created', 'chat.message', 'tool.execute.before', 'tool.execute.after', ]); - expect(bridgeSessionSummary.mock.calls[1][0].payload).toEqual({ + expect(bridgeSessionSummary.mock.calls[2][0].payload).toEqual({ sessionID: 'session-1', messageID: 'message-1', prompt: 'Implement the work episode.', diff --git a/src/plugin.ts b/src/plugin.ts index 9ed9b84..482ab4d 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -3,7 +3,6 @@ import { createOpencodeClient } from '@opencode-ai/sdk/v2'; import type { StartedPeer, StartPeerOptions } from './peer.js'; import { - MAX_ADDITIONAL_CONTEXT_BYTES, clearPrivateSessionContext, clearSessionWorkContext, hydrateContextPack, @@ -24,6 +23,15 @@ import { import { captureGatewayCredential } from './childProcessEnv.js'; import { bridgeOpenCodeSessionSummary } from './sessionSummaryBridge.js'; import { normalizeAbsoluteHostPath } from './hostPath.js'; +import { + appendAdditionalContext, + contextHydrationKey, + finalizeSessionContext, + isRunEndConsumptionPersisted, + isSuccessfulAssistantCompletion, + nativeSessionId, + sessionStartAdditionalContext, +} from './pluginSessionLifecycle.js'; type StartPeer = (opts: StartPeerOptions) => Promise; type Env = Record; @@ -46,42 +54,6 @@ type ClearPrivateSessionContext = (input: { sessionId?: string; }) => Promise; -function record(value: unknown): Record { - return value && typeof value === 'object' && !Array.isArray(value) - ? (value as Record) - : {}; -} - -function nativeSessionId(value: unknown): string | undefined { - const root = record(value); - const properties = record(root.properties); - const info = record(properties.info); - for (const candidate of [ - root.sessionID, - root.session_id, - properties.sessionID, - properties.session_id, - info.sessionID, - info.session_id, - info.id, - ]) { - if (typeof candidate !== 'string') continue; - const normalized = candidate.trim(); - if (normalized) return normalized; - } - return undefined; -} - -function contextHydrationKey( - projectDir: string, - sessionId: string -): string | null { - const normalizedProjectDir = normalizeAbsoluteHostPath(projectDir); - return normalizedProjectDir - ? `${normalizedProjectDir}\0${sessionId}` - : null; -} - export type CreateOrgXOpenCodePluginOptions = { startPeer?: StartPeer; env?: Env; @@ -115,6 +87,13 @@ export function createOrgXOpenCodePlugin( string, Promise >(); + const sessionStarts = new Map< + string, + Promise + >(); + const sessionsWithModelWork = new Set(); + const sessionsWithRunEnd = new Set(); + const gatewayScopedSessions = new Set(); let warnedMissingConfig = false; let warnedMissingAttentionConfig = false; const activeAttention = new Set(); @@ -169,8 +148,11 @@ export function createOrgXOpenCodePlugin( normalizedProjectDir, sessionId ); - if (runtimeHydration) return runtimeHydration; const key = contextHydrationKey(normalizedProjectDir, sessionId)!; + if (runtimeHydration) { + gatewayScopedSessions.add(key); + return runtimeHydration; + } const existing = contextHydrations.get(key); if (existing) return existing; const hydration = hydrate({ @@ -198,7 +180,7 @@ export function createOrgXOpenCodePlugin( const projectDir = exactProjectDir ?? input.directory; const capture = async (nativeEvent: string, payload: unknown) => { try { - await bridgeSessionSummary({ + return await bridgeSessionSummary({ nativeEvent, payload, directory: projectDir, @@ -209,31 +191,61 @@ export function createOrgXOpenCodePlugin( logger.warn( `[orgx-opencode-plugin] session summary capture unavailable: ${formatError(error)}` ); + return undefined; } }; - return { - 'experimental.chat.system.transform': async (chatInput, output) => { - const sessionId = nativeSessionId(chatInput); - if (!sessionId) return; - const result = await hydrateProjectContext(projectDir, sessionId); - const additionalContext = result.additionalContext; - if ( - typeof additionalContext !== 'string' || - !additionalContext || - Buffer.byteLength(additionalContext, 'utf8') > - MAX_ADDITIONAL_CONTEXT_BYTES - ) { - return; + const ensureSessionStart = async ( + sessionId: string, + payload: unknown + ): Promise => { + const normalizedProjectDir = normalizeAbsoluteHostPath(projectDir); + const key = normalizedProjectDir + ? contextHydrationKey(normalizedProjectDir, sessionId) + : null; + if (!normalizedProjectDir || !key) { + return { ok: true, skipped: 'project_directory_unavailable' }; + } + + const runtimeHydration = readRuntimeSessionHydration( + normalizedProjectDir, + sessionId + ); + if (runtimeHydration) { + gatewayScopedSessions.add(key); + if (!sessionStarts.has(key)) { + sessionStarts.set(key, Promise.resolve(runtimeHydration)); } - if (!output.system.includes(additionalContext)) { - output.system.push(additionalContext); + return runtimeHydration; + } + + const existing = sessionStarts.get(key); + if (existing) return existing; + const start = (async () => { + const sessionStart = await capture('session.created', payload); + const additionalContext = sessionStartAdditionalContext(sessionStart); + if (additionalContext) { + const claimed = { ok: true, additionalContext } as const; + contextHydrations.set(key, Promise.resolve(claimed)); + return claimed; } - }, + return hydrateProjectContext(normalizedProjectDir, sessionId); + })(); + sessionStarts.set(key, start); + return start; + }; + + return { 'chat.message': async (messageInput, output) => { const sessionId = nativeSessionId(messageInput); if (sessionId) { - await hydrateProjectContext(projectDir, sessionId); + const result = await ensureSessionStart(sessionId, { + sessionID: sessionId, + }); + const key = contextHydrationKey(projectDir, sessionId); + if (!key || !sessionsWithModelWork.has(key)) { + appendAdditionalContext(output.message, result.additionalContext); + } } const prompt = output.parts .filter( @@ -254,10 +266,68 @@ export function createOrgXOpenCodePlugin( }, event: async ({ event }) => { const sessionId = nativeSessionId(event); + const key = sessionId + ? contextHydrationKey(projectDir, sessionId) + : null; + if (key && isSuccessfulAssistantCompletion(event)) { + sessionsWithModelWork.add(key); + } + let eventAlreadyCaptured = false; if (event.type === 'session.created' && sessionId) { - await hydrateProjectContext(projectDir, sessionId); + await ensureSessionStart(sessionId, event); + eventAlreadyCaptured = true; + } + + const runEndedBeforeModelWork = + key !== null && + !sessionsWithModelWork.has(key) && + (event.type === 'session.idle' || event.type === 'session.error'); + const sessionAbandoned = + key !== null && + !sessionsWithModelWork.has(key) && + !gatewayScopedSessions.has(key) && + event.type === 'session.deleted'; + const gatewaySessionFailedBeforeModelWork = + key !== null && + !sessionsWithModelWork.has(key) && + gatewayScopedSessions.has(key) && + event.type === 'session.deleted'; + + let usedSessionEndUnverified = false; + if ( + key && + event.type === 'session.deleted' && + sessionsWithModelWork.has(key) && + !sessionsWithRunEnd.has(key) + ) { + const runEnd = await capture('session.idle', event); + if (isRunEndConsumptionPersisted(runEnd)) { + sessionsWithRunEnd.add(key); + } else { + usedSessionEndUnverified = true; + logger.warn( + '[orgx-opencode-plugin] model work consumption marker unverified; SessionEnd recovery was suppressed' + ); + } + } + + if ( + !eventAlreadyCaptured && + !runEndedBeforeModelWork && + !sessionAbandoned && + !gatewaySessionFailedBeforeModelWork && + !usedSessionEndUnverified + ) { + const result = await capture(event.type, event); + if ( + key && + sessionsWithModelWork.has(key) && + (event.type === 'session.idle' || event.type === 'session.error') && + isRunEndConsumptionPersisted(result) + ) { + sessionsWithRunEnd.add(key); + } } - await capture(event.type, event); if (event.type === 'server.connected') { if (!exactProjectDir) { @@ -272,46 +342,34 @@ export function createOrgXOpenCodePlugin( } } if (event.type === 'session.deleted' && sessionId) { - const key = contextHydrationKey(projectDir, sessionId); if (key) await contextHydrations.get(key); - const [clearance, privateClearance] = await Promise.all([ - clearContext({ - env, - projectDir, - sessionId, - }).catch((error) => { - capturePluginException(error, { - stage: 'session_context_clear', - }); - return { cleared: false, reason: 'wizard_unavailable' } as const; - }), - clearPrivateContext({ - env, - projectDir, - sessionId, - }).catch((error) => { - capturePluginException(error, { - stage: 'private_session_context_clear', - }); - return { - cleared: false, - reason: 'private_state_clear_failed', - removedFiles: 0, - } as const; - }), - ]); - if (!clearance.cleared) { - logger.warn( - `[orgx-opencode-plugin] session context clear unverified: ${clearance.reason}` - ); - } - if (!privateClearance.cleared) { - logger.warn( - `[orgx-opencode-plugin] private session context clear unverified: ${privateClearance.reason}` - ); + await finalizeSessionContext({ + capture, + clearContext, + clearPrivate: clearPrivateContext, + env, + event, + logger, + mode: gatewaySessionFailedBeforeModelWork + ? 'gateway_abandoned' + : sessionAbandoned + ? 'interactive_abandoned' + : 'used', + onException: (error, stage) => { + capturePluginException(error, { stage }); + }, + projectDir, + sessionId, + }); + if (key) { + contextHydrations.delete(key); + sessionStarts.delete(key); + sessionsWithModelWork.delete(key); + sessionsWithRunEnd.delete(key); + gatewayScopedSessions.delete(key); } - if (key) contextHydrations.delete(key); clearRuntimeSessionHydration(projectDir, sessionId); + return; } if (env.ORGX_REMOTE_ATTENTION !== '1') return; @@ -365,7 +423,9 @@ export function createOrgXOpenCodePlugin( 'tool.execute.before': async (toolInput, output) => { const sessionId = nativeSessionId(toolInput); if (sessionId) { - await hydrateProjectContext(projectDir, sessionId); + await ensureSessionStart(sessionId, { sessionID: sessionId }); + const key = contextHydrationKey(projectDir, sessionId); + if (key) sessionsWithModelWork.add(key); } await capture('tool.execute.before', { ...toolInput, @@ -375,7 +435,7 @@ export function createOrgXOpenCodePlugin( 'tool.execute.after': async (toolInput) => { const sessionId = nativeSessionId(toolInput); if (sessionId) { - await hydrateProjectContext(projectDir, sessionId); + await ensureSessionStart(sessionId, { sessionID: sessionId }); } await capture('tool.execute.after', toolInput); }, diff --git a/src/pluginContextRecovery.test.ts b/src/pluginContextRecovery.test.ts new file mode 100644 index 0000000..04b2332 --- /dev/null +++ b/src/pluginContextRecovery.test.ts @@ -0,0 +1,556 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createOrgXOpenCodePlugin } from './plugin'; +import { + clearRuntimeSessionHydration, + publishRuntimeSessionHydration, +} from './runtimeSessionContext'; + +type Hooks = { + event(input: { event: Record & { type: string } }): Promise; + 'chat.message'( + input: { sessionID: string; messageID?: string }, + output: { + message: { system?: string }; + parts: Array>; + } + ): Promise; + 'tool.execute.before'(input: Record): Promise; +}; + +function logger() { + return { log: vi.fn(), warn: vi.fn(), error: vi.fn() }; +} + +async function hooks( + opts: Parameters[0] +): Promise { + const plugin = createOrgXOpenCodePlugin({ + hydrateContextPack: vi.fn(async () => ({ ok: true })), + clearSessionWorkContext: vi.fn(async () => ({ + cleared: true, + reason: 'wizard_cleared', + })), + clearPrivateSessionContext: vi.fn(async () => ({ + cleared: true, + reason: 'private_state_cleared', + removedFiles: 1, + })), + ...opts, + }); + return (await plugin({ + directory: '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/work/repo', + serverUrl: new URL('http://localhost:4096'), + } as never)) as Hooks; +} + +function sessionEvent(type: string, sessionID: string) { + return { type, properties: { sessionID, info: { id: sessionID } } }; +} + +function assistantEvent( + sessionID: string, + error?: Record +) { + return { + type: 'message.updated', + properties: { + sessionID, + info: { + id: 'assistant-1', + sessionID, + role: 'assistant', + time: { created: 1, completed: 2 }, + ...(error ? { error } : {}), + }, + }, + }; +} + +describe('OpenCode failed-start context recovery', () => { + it('injects Wizard SessionStart context into the first model request', async () => { + const hydrateContextPack = vi.fn(async () => ({ + ok: true, + skipped: 'context_pack_unconfigured', + } as const)); + const bridgeSessionSummary = vi.fn(async ({ nativeEvent }) => + nativeEvent === 'session.created' + ? { + ok: true, + hook_output: { + continue: true, + suppressOutput: true, + hookSpecificOutput: { + hookEventName: 'SessionStart', + additionalContext: + 'Objective: recover the exact activation. Decision: preserve owner atomicity. Source: OrgX accepted context.', + }, + }, + } + : { ok: true } + ); + const active = await hooks({ + hydrateContextPack, + bridgeSessionSummary, + logger: logger(), + env: {}, + }); + const sessionID = 'first-turn-context'; + const output = { + message: { system: 'native system' }, + parts: [{ type: 'text', text: 'Continue the accepted task.' }], + }; + + await active.event({ event: sessionEvent('session.created', sessionID) }); + await active['chat.message']({ sessionID }, output); + + expect(output.message.system).toBe( + 'native system\n\nObjective: recover the exact activation. Decision: preserve owner atomicity. Source: OrgX accepted context.' + ); + expect(hydrateContextPack).not.toHaveBeenCalled(); + expect(active).not.toHaveProperty('experimental.chat.system.transform'); + }); + + it('claims local context before hydration when chat.message arrives first', async () => { + const hydrateContextPack = vi.fn(async () => ({ + ok: true, + skipped: 'context_pack_unconfigured', + } as const)); + const bridgeSessionSummary = vi.fn(async ({ nativeEvent }) => + nativeEvent === 'session.created' + ? { + ok: true, + hook_output: { + continue: true, + suppressOutput: true, + hookSpecificOutput: { + hookEventName: 'SessionStart', + additionalContext: 'Locally claimed Wizard context', + }, + }, + } + : { ok: true } + ); + const active = await hooks({ + hydrateContextPack, + bridgeSessionSummary, + logger: logger(), + env: {}, + }); + const sessionID = 'chat-first'; + const output = { + message: {}, + parts: [{ type: 'text', text: 'Continue.' }], + }; + + await active['chat.message']({ sessionID }, output); + await active.event({ event: sessionEvent('session.created', sessionID) }); + + expect(output.message.system).toBe('Locally claimed Wizard context'); + expect(hydrateContextPack).not.toHaveBeenCalled(); + expect( + bridgeSessionSummary.mock.calls.map(([call]) => call.nativeEvent) + ).toEqual(['session.created', 'chat.message']); + }); + + it('rejects an oversized SessionStart payload and uses bounded hydration', async () => { + const hydrateContextPack = vi.fn(async () => ({ + ok: true, + additionalContext: 'bounded fallback context', + } as const)); + const bridgeSessionSummary = vi.fn(async () => ({ + ok: true, + hook_output: { + continue: true, + suppressOutput: true, + hookSpecificOutput: { + hookEventName: 'SessionStart', + additionalContext: 'x'.repeat(9 * 1024), + }, + }, + })); + const active = await hooks({ + hydrateContextPack, + bridgeSessionSummary, + logger: logger(), + env: {}, + }); + const output = { + message: { system: 'native system' }, + parts: [{ type: 'text', text: 'Continue.' }], + }; + + await active.event({ + event: sessionEvent('session.created', 'oversized-start-context'), + }); + await active['chat.message']( + { sessionID: 'oversized-start-context' }, + output + ); + + expect(output.message.system).toBe( + 'native system\n\nbounded fallback context' + ); + expect(hydrateContextPack).toHaveBeenCalledTimes(1); + }); + + it('does not append an oversized hydrated context to a user turn', async () => { + const hydrateContextPack = vi.fn(async () => ({ + ok: true, + additionalContext: 'x'.repeat(9 * 1024), + } as const)); + const active = await hooks({ + hydrateContextPack, + bridgeSessionSummary: vi.fn(async () => ({ ok: true })), + logger: logger(), + env: {}, + }); + const output = { + message: { system: 'native system' }, + parts: [{ type: 'text', text: 'Continue.' }], + }; + + await active['chat.message']({ sessionID: 'oversized-hydration' }, output); + + expect(output.message.system).toBe('native system'); + expect(hydrateContextPack).toHaveBeenCalledTimes(1); + }); + + it('gives Gateway hydration precedence without invoking claim-capable SessionStart', async () => { + const sessionID = 'gateway-precedence'; + const hydrateContextPack = vi.fn(async () => ({ + ok: true, + additionalContext: 'ambient remote context', + } as const)); + const bridgeSessionSummary = vi.fn(async ({ nativeEvent }) => + nativeEvent === 'session.created' + ? { + ok: true, + hook_output: { + continue: true, + suppressOutput: true, + hookSpecificOutput: { + hookEventName: 'SessionStart', + additionalContext: 'local staged context', + }, + }, + } + : { ok: true } + ); + publishRuntimeSessionHydration('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/work/repo', sessionID, { + ok: true, + additionalContext: 'exact Gateway context', + sessionContext: { activated: true, reason: 'wizard_activated' }, + }); + try { + const active = await hooks({ + hydrateContextPack, + bridgeSessionSummary, + logger: logger(), + env: {}, + }); + const output = { + message: {}, + parts: [{ type: 'text', text: 'Execute the dispatch.' }], + }; + + await active['chat.message']({ sessionID }, output); + + expect(output.message.system).toBe('exact Gateway context'); + expect(hydrateContextPack).not.toHaveBeenCalled(); + expect( + bridgeSessionSummary.mock.calls.map(([call]) => call.nativeEvent) + ).toEqual(['chat.message']); + } finally { + clearRuntimeSessionHydration('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/work/repo', sessionID); + } + }); + + it('releases an unused context after provider auth fails before model work', async () => { + const clearSessionWorkContext = vi.fn(); + const clearPrivateSessionContext = vi.fn(async () => ({ + cleared: true, + reason: 'private_state_cleared', + removedFiles: 1, + } as const)); + const bridgeSessionSummary = vi.fn(async ({ nativeEvent }) => + nativeEvent === 'session.abandoned' + ? { + ok: true, + session_state_discarded: true, + activation_released: true, + activation_release_state: 'released', + } + : { ok: true } + ); + const active = await hooks({ + clearSessionWorkContext, + clearPrivateSessionContext, + bridgeSessionSummary, + logger: logger(), + env: {}, + }); + const sessionID = 'provider-auth-failed'; + + await active.event({ event: sessionEvent('session.created', sessionID) }); + await active['chat.message']( + { sessionID, messageID: 'user-1' }, + { + message: {}, + parts: [{ type: 'text', text: 'Continue the accepted task.' }], + } + ); + await active.event({ + event: assistantEvent(sessionID, { + name: 'ProviderAuthError', + data: { providerID: 'anthropic', message: 'reauth required' }, + }), + }); + await active.event({ event: sessionEvent('session.error', sessionID) }); + await active.event({ event: sessionEvent('session.deleted', sessionID) }); + + expect( + bridgeSessionSummary.mock.calls.map(([call]) => call.nativeEvent) + ).toEqual([ + 'session.created', + 'chat.message', + 'message.updated', + 'session.abandoned', + ]); + expect(clearSessionWorkContext).not.toHaveBeenCalled(); + expect(clearPrivateSessionContext).toHaveBeenCalledWith({ + env: {}, + projectDir: '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/work/repo', + sessionId: sessionID, + }); + }); + + it('preserves exact owner state when unused-context release is unverified', async () => { + const clearSessionWorkContext = vi.fn(); + const clearPrivateSessionContext = vi.fn(); + const bridgeSessionSummary = vi.fn(async ({ nativeEvent }) => + nativeEvent === 'session.abandoned' + ? { + ok: true, + session_state_discarded: false, + activation_released: false, + activation_release_state: 'preserved', + } + : { ok: true } + ); + const captureLogger = logger(); + const active = await hooks({ + clearSessionWorkContext, + clearPrivateSessionContext, + bridgeSessionSummary, + logger: captureLogger, + env: {}, + }); + const sessionID = 'release-unverified'; + + await active.event({ event: sessionEvent('session.created', sessionID) }); + await active.event({ event: sessionEvent('session.deleted', sessionID) }); + + expect(clearSessionWorkContext).not.toHaveBeenCalled(); + expect(clearPrivateSessionContext).not.toHaveBeenCalled(); + expect(captureLogger.warn).toHaveBeenCalledWith( + '[orgx-opencode-plugin] unused session context release unverified; exact owner state was preserved' + ); + }); + + it('clears terminal context after a successful assistant completion', async () => { + const clearSessionWorkContext = vi.fn(async () => ({ + cleared: true, + reason: 'wizard_cleared', + } as const)); + const clearPrivateSessionContext = vi.fn(async () => ({ + cleared: true, + reason: 'private_state_cleared', + removedFiles: 2, + } as const)); + const bridgeSessionSummary = vi.fn(async ({ nativeEvent }) => + nativeEvent === 'session.idle' + ? { ok: true, queued: true, state_persisted: true } + : { ok: true } + ); + const active = await hooks({ + clearSessionWorkContext, + clearPrivateSessionContext, + bridgeSessionSummary, + logger: logger(), + env: {}, + }); + const sessionID = 'assistant-completed'; + + await active.event({ event: sessionEvent('session.created', sessionID) }); + await active.event({ event: assistantEvent(sessionID) }); + await active.event({ event: sessionEvent('session.idle', sessionID) }); + await active.event({ event: sessionEvent('session.deleted', sessionID) }); + + expect( + bridgeSessionSummary.mock.calls.map(([call]) => call.nativeEvent) + ).toEqual([ + 'session.created', + 'message.updated', + 'session.idle', + 'session.deleted', + ]); + expect(clearSessionWorkContext).toHaveBeenCalledTimes(1); + expect(clearPrivateSessionContext).toHaveBeenCalledTimes(1); + }); + + it('records a bounded RunEnd before SessionEnd when native idle is missing', async () => { + const bridgeSessionSummary = vi.fn(async ({ nativeEvent }) => + nativeEvent === 'session.idle' + ? { ok: true, queued: true, state_persisted: true } + : { ok: true } + ); + const active = await hooks({ + bridgeSessionSummary, + logger: logger(), + env: {}, + }); + const sessionID = 'assistant-completed-without-idle'; + + await active.event({ event: sessionEvent('session.created', sessionID) }); + await active.event({ event: assistantEvent(sessionID) }); + await active.event({ event: sessionEvent('session.deleted', sessionID) }); + + expect( + bridgeSessionSummary.mock.calls.map(([call]) => call.nativeEvent) + ).toEqual([ + 'session.created', + 'message.updated', + 'session.idle', + 'session.deleted', + ]); + }); + + it('suppresses recovery when model-work consumption cannot be persisted', async () => { + const clearSessionWorkContext = vi.fn(async () => ({ + cleared: true, + reason: 'wizard_cleared', + } as const)); + const clearPrivateSessionContext = vi.fn(async () => ({ + cleared: true, + reason: 'private_state_cleared', + removedFiles: 1, + } as const)); + const bridgeSessionSummary = vi.fn(async ({ nativeEvent }) => + nativeEvent === 'session.idle' + ? { + ok: true, + skipped: 'queue_write_failed', + state_persisted: false, + work_context_consumed: false, + } + : { ok: true } + ); + const captureLogger = logger(); + const active = await hooks({ + bridgeSessionSummary, + clearSessionWorkContext, + clearPrivateSessionContext, + logger: captureLogger, + env: {}, + }); + const sessionID = 'consumption-unverified'; + + await active.event({ event: sessionEvent('session.created', sessionID) }); + await active.event({ event: assistantEvent(sessionID) }); + await active.event({ event: sessionEvent('session.deleted', sessionID) }); + + expect( + bridgeSessionSummary.mock.calls.map(([call]) => call.nativeEvent) + ).toEqual(['session.created', 'message.updated', 'session.idle']); + expect(captureLogger.warn).toHaveBeenCalledWith( + '[orgx-opencode-plugin] model work consumption marker unverified; SessionEnd recovery was suppressed' + ); + expect(clearSessionWorkContext).toHaveBeenCalledTimes(1); + expect(clearPrivateSessionContext).toHaveBeenCalledTimes(1); + }); + + it('treats a requested tool execution as real model work after a later error', async () => { + const clearSessionWorkContext = vi.fn(async () => ({ + cleared: true, + reason: 'wizard_cleared', + } as const)); + const bridgeSessionSummary = vi.fn(async () => ({ ok: true })); + const active = await hooks({ + clearSessionWorkContext, + bridgeSessionSummary, + logger: logger(), + env: {}, + }); + const sessionID = 'tool-started'; + + await active.event({ event: sessionEvent('session.created', sessionID) }); + await active['tool.execute.before']({ + sessionID, + callID: 'tool-1', + tool: 'bash', + }); + await active.event({ event: sessionEvent('session.deleted', sessionID) }); + + expect( + bridgeSessionSummary.mock.calls.map(([call]) => call.nativeEvent) + ).not.toContain('session.abandoned'); + expect(clearSessionWorkContext).toHaveBeenCalledTimes(1); + }); + + it('clears rather than requeues an unused Gateway-dispatched context', async () => { + const sessionID = 'gateway-provider-auth-failed'; + const clearSessionWorkContext = vi.fn(async () => ({ + cleared: true, + reason: 'wizard_cleared', + } as const)); + const clearPrivateSessionContext = vi.fn(async () => ({ + cleared: true, + reason: 'private_state_cleared', + removedFiles: 1, + } as const)); + const bridgeSessionSummary = vi.fn(async ({ nativeEvent }) => + nativeEvent === 'session.abandoned' + ? { + ok: true, + session_state_discarded: true, + activation_release_state: 'absent', + } + : { ok: true } + ); + publishRuntimeSessionHydration('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/work/repo', sessionID, { + ok: true, + additionalContext: 'Gateway-dispatched exact task context', + sessionContext: { activated: true, reason: 'wizard_activated' }, + }); + try { + const active = await hooks({ + clearSessionWorkContext, + clearPrivateSessionContext, + bridgeSessionSummary, + logger: logger(), + env: {}, + }); + + await active.event({ event: sessionEvent('session.created', sessionID) }); + await active.event({ + event: assistantEvent(sessionID, { + name: 'ProviderAuthError', + data: { providerID: 'anthropic', message: 'reauth required' }, + }), + }); + await active.event({ event: sessionEvent('session.deleted', sessionID) }); + + expect(clearSessionWorkContext).toHaveBeenCalledTimes(1); + expect(clearPrivateSessionContext).toHaveBeenCalledTimes(1); + expect( + bridgeSessionSummary.mock.calls.map(([call]) => call.nativeEvent) + ).toContain('session.abandoned'); + expect( + bridgeSessionSummary.mock.calls.map(([call]) => call.nativeEvent) + ).not.toContain('session.created'); + } finally { + clearRuntimeSessionHydration('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/work/repo', sessionID); + } + }); +}); diff --git a/src/pluginSessionLifecycle.ts b/src/pluginSessionLifecycle.ts new file mode 100644 index 0000000..ccb603f --- /dev/null +++ b/src/pluginSessionLifecycle.ts @@ -0,0 +1,235 @@ +import { + MAX_ADDITIONAL_CONTEXT_BYTES, + type PrivateSessionContextClearance, + type SessionContextClearance, +} from './contextPackHydration.js'; +import { normalizeAbsoluteHostPath } from './hostPath.js'; + +type Env = Record; +type Logger = Pick; +type Capture = ( + nativeEvent: string, + payload: unknown +) => Promise | undefined>; +type ClearSessionWorkContext = (input: { + env?: Env; + projectDir?: string; + sessionId?: string; +}) => Promise; +type ClearPrivateSessionContext = (input: { + env?: Env; + projectDir?: string; + sessionId?: string; +}) => Promise; +type ExceptionStage = + | 'session_context_clear' + | 'private_session_context_clear'; + +export function nativeSessionId(value: unknown): string | undefined { + const root = record(value); + const properties = record(root.properties); + const info = record(properties.info); + for (const candidate of [ + root.sessionID, + root.session_id, + properties.sessionID, + properties.session_id, + info.sessionID, + info.session_id, + info.id, + ]) { + if (typeof candidate !== 'string') continue; + const normalized = candidate.trim(); + if (normalized) return normalized; + } + return undefined; +} + +export function isSuccessfulAssistantCompletion(value: unknown): boolean { + const root = record(value); + if (root.type !== 'message.updated') return false; + const info = record(record(root.properties).info); + const completed = record(info.time).completed; + return ( + info.role === 'assistant' && + typeof completed === 'number' && + Number.isFinite(completed) && + info.error == null + ); +} + +export function isRunEndConsumptionPersisted(value: unknown): boolean { + const root = record(value); + return ( + root.work_context_consumed === true || + (root.queued === true && root.state_persisted === true) + ); +} + +export function sessionStartAdditionalContext( + value: unknown +): string | undefined { + const root = record(value); + const hookOutput = record(root.hook_output); + const hookSpecificOutput = record(hookOutput.hookSpecificOutput); + const additionalContext = hookSpecificOutput.additionalContext; + return hookOutput.continue === true && + hookOutput.suppressOutput === true && + hookSpecificOutput.hookEventName === 'SessionStart' && + typeof additionalContext === 'string' && + additionalContext.length > 0 && + Buffer.byteLength(additionalContext, 'utf8') <= MAX_ADDITIONAL_CONTEXT_BYTES + ? additionalContext + : undefined; +} + +export function appendAdditionalContext( + message: { system?: string }, + additionalContext: unknown +): void { + if ( + typeof additionalContext !== 'string' || + additionalContext.length === 0 || + Buffer.byteLength(additionalContext, 'utf8') > MAX_ADDITIONAL_CONTEXT_BYTES + ) { + return; + } + const existing = typeof message.system === 'string' ? message.system : ''; + if (existing.includes(additionalContext)) return; + message.system = existing + ? `${existing}\n\n${additionalContext}` + : additionalContext; +} + +export function contextHydrationKey( + projectDir: string, + sessionId: string +): string | null { + const normalizedProjectDir = normalizeAbsoluteHostPath(projectDir); + return normalizedProjectDir + ? `${normalizedProjectDir}\0${sessionId}` + : null; +} + +async function clearExactContext( + clearContext: ClearSessionWorkContext, + input: { env: Env; projectDir: string; sessionId: string }, + onException: (error: unknown, stage: ExceptionStage) => void +): Promise { + return clearContext(input).catch((error) => { + onException(error, 'session_context_clear'); + return { cleared: false, reason: 'wizard_unavailable' }; + }); +} + +async function clearPrivateContext( + clearPrivate: ClearPrivateSessionContext, + input: { env: Env; projectDir: string; sessionId: string }, + onException: (error: unknown, stage: ExceptionStage) => void +): Promise { + return clearPrivate(input).catch((error) => { + onException(error, 'private_session_context_clear'); + return { + cleared: false, + reason: 'private_state_clear_failed', + removedFiles: 0, + }; + }); +} + +export async function finalizeSessionContext({ + capture, + clearContext, + clearPrivate, + env, + event, + logger, + mode, + onException, + projectDir, + sessionId, +}: { + capture: Capture; + clearContext: ClearSessionWorkContext; + clearPrivate: ClearPrivateSessionContext; + env: Env; + event: unknown; + logger: Logger; + mode: 'gateway_abandoned' | 'interactive_abandoned' | 'used'; + onException: (error: unknown, stage: ExceptionStage) => void; + projectDir: string; + sessionId: string; +}): Promise { + const input = { env, projectDir, sessionId }; + + if (mode === 'gateway_abandoned') { + const clearance = await clearExactContext( + clearContext, + input, + onException + ); + if (!clearance.cleared) { + logger.warn( + `[orgx-opencode-plugin] Gateway session context clear unverified: ${clearance.reason}` + ); + return; + } + const privateClearance = await clearPrivateContext( + clearPrivate, + input, + onException + ); + if (!privateClearance.cleared) { + logger.warn( + `[orgx-opencode-plugin] private session context clear unverified: ${privateClearance.reason}` + ); + } + await capture('session.abandoned', event); + return; + } + + if (mode === 'interactive_abandoned') { + const release = await capture('session.abandoned', event); + if ( + release?.activation_release_state !== 'released' && + release?.activation_release_state !== 'absent' + ) { + logger.warn( + '[orgx-opencode-plugin] unused session context release unverified; exact owner state was preserved' + ); + return; + } + const privateClearance = await clearPrivateContext( + clearPrivate, + input, + onException + ); + if (!privateClearance.cleared) { + logger.warn( + `[orgx-opencode-plugin] private session context clear unverified: ${privateClearance.reason}` + ); + } + return; + } + + const [clearance, privateClearance] = await Promise.all([ + clearExactContext(clearContext, input, onException), + clearPrivateContext(clearPrivate, input, onException), + ]); + if (!clearance.cleared) { + logger.warn( + `[orgx-opencode-plugin] session context clear unverified: ${clearance.reason}` + ); + } + if (!privateClearance.cleared) { + logger.warn( + `[orgx-opencode-plugin] private session context clear unverified: ${privateClearance.reason}` + ); + } +} + +function record(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : {}; +} diff --git a/src/sessionSummaryBridge.test.ts b/src/sessionSummaryBridge.test.ts index 6a04219..9d361da 100644 --- a/src/sessionSummaryBridge.test.ts +++ b/src/sessionSummaryBridge.test.ts @@ -15,6 +15,9 @@ describe('OpenCode session summary bridge', () => { expect(canonicalOpenCodeEvent('session.created')).toBe('SessionStart'); expect(canonicalOpenCodeEvent('session.idle')).toBe('RunEnd'); expect(canonicalOpenCodeEvent('session.deleted')).toBe('SessionEnd'); + expect(canonicalOpenCodeEvent('session.abandoned')).toBe( + 'SessionAbandoned' + ); expect(canonicalOpenCodeEvent('chat.message')).toBe('UserPromptSubmit'); expect( canonicalOpenCodeEvent('message.updated', { diff --git a/src/sessionSummaryBridge.ts b/src/sessionSummaryBridge.ts index fc44714..01af4e5 100644 --- a/src/sessionSummaryBridge.ts +++ b/src/sessionSummaryBridge.ts @@ -16,6 +16,7 @@ const EVENT_MAP: Record = { 'session.idle': 'RunEnd', 'session.error': 'RunEnd', 'session.deleted': 'SessionEnd', + 'session.abandoned': 'SessionAbandoned', 'permission.asked': 'PermissionRequest', 'tool.execute.before': 'PreToolUse', 'tool.execute.after': 'PostToolUse',