diff --git a/src/sessionSummaryBridge.test.ts b/src/sessionSummaryBridge.test.ts index 6a04219..01997ad 100644 --- a/src/sessionSummaryBridge.test.ts +++ b/src/sessionSummaryBridge.test.ts @@ -82,7 +82,7 @@ describe('OpenCode session summary bridge', () => { error: 'private error', }, '/work/repo', - { ORGX_SESSION_WORK_EPISODE_CAPTURE: 'bounded' } + { ORGX_OPENCODE_WORK_CAPTURE: 'bounded' } ); expect(result).toEqual({ session_id: 'session-1', @@ -119,9 +119,14 @@ describe('OpenCode session summary bridge', () => { expect(sanitizeOpenCodePayload(payload, '/work/repo').prompt).toBeUndefined(); expect( sanitizeOpenCodePayload(payload, '/work/repo', { - ORGX_SESSION_WORK_EPISODE_CAPTURE: '1', + ORGX_OPENCODE_WORK_CAPTURE: 'bounded', }).prompt ).toBe('Only retain this when bounded capture is enabled.'); + expect( + sanitizeOpenCodePayload(payload, '/work/repo', { + ORGX_SESSION_WORK_EPISODE_CAPTURE: 'bounded', + }).prompt + ).toBeUndefined(); }); it('bounds explicitly enabled prompt capture to 600 Unicode characters', () => { @@ -130,7 +135,7 @@ describe('OpenCode session summary bridge', () => { const sanitized = sanitizeOpenCodePayload( { sessionID: 'session-bounded', prompt }, '/work/repo', - { ORGX_SESSION_WORK_EPISODE_CAPTURE: 'bounded' } + { ORGX_OPENCODE_WORK_CAPTURE: 'bounded' } ); expect(Array.from(sanitized.prompt as string)).toHaveLength(600); @@ -165,6 +170,7 @@ describe('OpenCode session summary bridge', () => { argv: [ '--event=RunEnd', '--source_client=opencode', + '--work_episode_capture=metadata-only', ], env: { PATH: process.env.PATH }, stdinText: JSON.stringify({ @@ -184,42 +190,49 @@ describe('OpenCode session summary bridge', () => { } }); - it('leaves Work Episode consent to the explicit Wizard environment setting', async () => { - const dir = mkdtempSync(join(tmpdir(), 'orgx-opencode-bridge-')); - const hookPath = join(dir, 'orgx-session-summary.mjs'); - await writeFile(hookPath, 'export async function main() {}\n', 'utf8'); - const main = vi.fn(async () => ({ ok: true })); - try { - await bridgeOpenCodeSessionSummary({ - nativeEvent: 'chat.message', - payload: { - sessionID: 'session-consent', - prompt: 'Retain this bounded intent.', - }, - directory: '/work/repo', - hookPath, - env: { - PATH: process.env.PATH, - ORGX_SESSION_WORK_EPISODE_CAPTURE: 'bounded', - }, - importHook: async () => ({ main }), - }); + it.each([ + ['an ambient generic variable', { ORGX_SESSION_WORK_EPISODE_CAPTURE: 'bounded' }, 'metadata-only'], + ['the OpenCode-specific opt-in', { ORGX_OPENCODE_WORK_CAPTURE: 'bounded' }, 'bounded'], + ] as const)( + 'pins the capture mode explicitly under %s (plan v3 §5.10)', + async (_label, extraEnv, expected) => { + const dir = mkdtempSync(join(tmpdir(), 'orgx-opencode-bridge-')); + const hookPath = join(dir, 'orgx-session-summary.mjs'); + await writeFile(hookPath, 'export async function main() {}\n', 'utf8'); + const main = vi.fn(async () => ({ ok: true })); + try { + await bridgeOpenCodeSessionSummary({ + nativeEvent: 'chat.message', + payload: { + sessionID: 'session-consent', + prompt: 'Retain this bounded intent.', + }, + directory: '/work/repo', + hookPath, + env: { PATH: process.env.PATH, ...extraEnv }, + importHook: async () => ({ main }), + }); - expect(main).toHaveBeenCalledTimes(1); - expect(main.mock.calls[0][0].argv).toEqual([ - '--event=UserPromptSubmit', - '--source_client=opencode', - ]); - expect(main.mock.calls[0][0].env.ORGX_SESSION_WORK_EPISODE_CAPTURE).toBe( - 'bounded' - ); - expect(JSON.parse(main.mock.calls[0][0].stdinText).prompt).toBe( - 'Retain this bounded intent.' - ); - } finally { - rmSync(dir, { recursive: true, force: true }); + expect(main).toHaveBeenCalledTimes(1); + const call = main.mock.calls[0][0] as unknown as { + argv: string[]; + env: Record; + stdinText: string; + }; + expect(call.argv).toEqual([ + '--event=UserPromptSubmit', + '--source_client=opencode', + `--work_episode_capture=${expected}`, + ]); + expect(call.env.ORGX_SESSION_WORK_EPISODE_CAPTURE).toBeUndefined(); + expect(JSON.parse(call.stdinText).prompt).toBe( + expected === 'bounded' ? 'Retain this bounded intent.' : undefined + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } } - }); + ); it('keeps an offline run queued without starting fallback delivery', async () => { const dir = mkdtempSync(join(tmpdir(), 'orgx-opencode-bridge-')); diff --git a/src/sessionSummaryBridge.ts b/src/sessionSummaryBridge.ts index fc44714..361f5ce 100644 --- a/src/sessionSummaryBridge.ts +++ b/src/sessionSummaryBridge.ts @@ -46,10 +46,26 @@ function duration(...values: unknown[]): number | undefined { return value === undefined ? undefined : Math.max(0, Math.round(value)); } -function workEpisodeCaptureEnabled(value: string | undefined): boolean { - return ['bounded', 'on', 'true', '1'].includes( - String(value ?? '').trim().toLowerCase() - ); +/** + * Capture mode is pinned explicitly on every call (plan v3 §5.10), matching the + * Wizard's installed Claude/Codex hooks: an ambient + * ORGX_SESSION_WORK_EPISODE_CAPTURE — set for another client, or inherited + * from a parent process — must not widen what OpenCode captures. Bounded + * capture (redacted request excerpts) is an OpenCode-specific opt-in. + */ +export function resolveOpenCodeWorkCapture( + env: Env = process.env +): 'bounded' | 'metadata-only' { + return String(env.ORGX_OPENCODE_WORK_CAPTURE ?? '').trim().toLowerCase() === + 'bounded' + ? 'bounded' + : 'metadata-only'; +} + +function withoutAmbientCaptureMode(env: Env): Env { + const next = { ...env }; + delete next.ORGX_SESSION_WORK_EPISODE_CAPTURE; + return next; } function boundedPrompt(value: unknown): string | undefined { @@ -145,7 +161,7 @@ export function sanitizeOpenCodePayload( tool_use_id: string(root.callID, properties.callID), duration_ms: duration(root.duration_ms, root.duration, properties.duration), permission_mode: string(root.permission, properties.permission), - prompt: workEpisodeCaptureEnabled(env.ORGX_SESSION_WORK_EPISODE_CAPTURE) + prompt: resolveOpenCodeWorkCapture(env) === 'bounded' ? boundedPrompt(string(root.prompt, root.message, properties.prompt)) : undefined, root_session_id: string(root.rootSessionID, root.root_session_id), @@ -244,9 +260,10 @@ export async function bridgeOpenCodeSessionSummary({ argv: [ `--event=${canonicalEvent}`, '--source_client=opencode', + `--work_episode_capture=${resolveOpenCodeWorkCapture(env)}`, ...(queueDir ? [`--queue_dir=${queueDir}`] : []), ], - env, + env: withoutAmbientCaptureMode(env), stdinText: JSON.stringify(sanitizeOpenCodePayload(payload, directory, env)), }); const fallbackDeliveryTriggered =