diff --git a/packages/cloud-agents/src/__tests__/opencode-identity-plugin.test.ts b/packages/cloud-agents/src/__tests__/opencode-identity-plugin.test.ts index 4708cebab7..13dfe9f42f 100644 --- a/packages/cloud-agents/src/__tests__/opencode-identity-plugin.test.ts +++ b/packages/cloud-agents/src/__tests__/opencode-identity-plugin.test.ts @@ -10,8 +10,26 @@ type SystemTransformHook = ( output: { system: string[] }, ) => Promise; +type MessageTransformHook = ( + input: unknown, + output: { + messages: Array<{ + info: { role: string; providerID?: string; modelID?: string }; + parts: Array>; + }>; + }, +) => Promise; + describe('OPENCODE_IDENTITY_PLUGIN_SCRIPT', () => { async function loadSystemTransformHook(): Promise { + const hooks = await loadHooks(); + return hooks['experimental.chat.system.transform']; + } + + async function loadHooks(): Promise<{ + 'experimental.chat.system.transform': SystemTransformHook; + 'experimental.chat.messages.transform': MessageTransformHook; + }> { const tempDir = fs.mkdtempSync( path.join(os.tmpdir(), 'roomote-opencode-identity-plugin-'), ); @@ -24,10 +42,10 @@ describe('OPENCODE_IDENTITY_PLUGIN_SCRIPT', () => { )) as { RoomoteOpenCodeIdentity: () => Promise<{ 'experimental.chat.system.transform': SystemTransformHook; + 'experimental.chat.messages.transform': MessageTransformHook; }>; }; - const hooks = await plugin.RoomoteOpenCodeIdentity(); - return hooks['experimental.chat.system.transform']; + return await plugin.RoomoteOpenCodeIdentity(); } finally { fs.rmSync(tempDir, { recursive: true, force: true }); } @@ -93,4 +111,125 @@ describe('OPENCODE_IDENTITY_PLUGIN_SCRIPT', () => { expect(output.system).toEqual(original); }); + + it('preserves and consolidates OpenRouter Gemini reasoning details for tool-call replay', async () => { + const hooks = await loadHooks(); + const encrypted = { + type: 'reasoning.encrypted', + data: 'opaque-provider-data', + id: 'tool_read_1', + format: 'google-gemini-v1', + index: 0, + }; + const text = { + type: 'reasoning.text', + text: 'summary', + format: 'google-gemini-v1', + index: 0, + }; + const output = { + messages: [ + { + info: { + role: 'assistant', + providerID: 'openrouter', + modelID: 'google/gemini-3.8-flash', + }, + parts: [ + { + type: 'reasoning', + text: 'summary', + metadata: { openrouter: { reasoning_details: [text] } }, + }, + { + type: 'tool', + callID: 'tool_read_1', + metadata: { + openrouter: { reasoning_details: [text, encrypted] }, + }, + }, + ], + }, + ], + }; + + await hooks['experimental.chat.messages.transform']({}, output); + + expect(output.messages[0]?.parts[0]?.metadata).toEqual({ + openrouter: { reasoning_details: [text, encrypted] }, + }); + expect(output.messages[0]?.parts[1]?.metadata).toEqual({ + openrouter: { reasoning_details: [text, encrypted] }, + }); + }); + + it('adds the Gemini validator bypass only when encrypted reasoning is unavailable', async () => { + const hooks = await loadHooks(); + const output = { + messages: [ + { + info: { + role: 'assistant', + providerID: 'openrouter', + modelID: 'google/gemini-3.8-flash', + }, + parts: [ + { + type: 'tool', + callID: 'tool_read_1', + metadata: { existing: 'metadata' }, + }, + ], + }, + ], + }; + + const transform = hooks['experimental.chat.messages.transform']; + await transform({}, output); + await transform({}, output); + + expect(output.messages[0]?.parts[0]?.metadata).toEqual({ + existing: 'metadata', + openrouter: { + reasoning_details: [ + { + type: 'reasoning.encrypted', + data: 'skip_thought_signature_validator', + id: 'tool_read_1', + format: 'google-gemini-v1', + index: 0, + }, + ], + }, + }); + }); + + it('leaves non-OpenRouter and pre-Gemini-3 histories unchanged', async () => { + const hooks = await loadHooks(); + const output = { + messages: [ + { + info: { + role: 'assistant', + providerID: 'google', + modelID: 'gemini-3.8-flash', + }, + parts: [{ type: 'tool', callID: 'google-tool' }], + }, + { + info: { + role: 'assistant', + providerID: 'openrouter', + modelID: 'google/gemini-2.5-pro', + }, + parts: [{ type: 'tool', callID: 'old-gemini-tool' }], + }, + ], + }; + const original = structuredClone(output); + + await hooks['experimental.chat.messages.transform']({}, output); + + expect(output).toEqual(original); + }); }); diff --git a/packages/cloud-agents/src/opencode-identity-plugin.ts b/packages/cloud-agents/src/opencode-identity-plugin.ts index a57332203f..fb3526625b 100644 --- a/packages/cloud-agents/src/opencode-identity-plugin.ts +++ b/packages/cloud-agents/src/opencode-identity-plugin.ts @@ -13,5 +13,65 @@ export const OPENCODE_IDENTITY_PLUGIN_SCRIPT = `export const RoomoteOpenCodeIden .replace(/^You are OpenCode,\\s*/iu, ''); } }, + 'experimental.chat.messages.transform': async (_input, output) => { + // OpenRouter replays only the first reasoning_details array it finds. Keep + // split metadata together, and make legacy unsigned Gemini 3 tool calls + // explicitly use Google's documented validator-bypass sentinel. + for (const message of output.messages ?? []) { + if ( + message.info?.role !== 'assistant' || + message.info.providerID !== 'openrouter' || + !/(^|\\/)gemini-3(?:[.-]|$)/iu.test(message.info.modelID ?? '') + ) { + continue; + } + + const tool = message.parts?.find( + (part) => part?.type === 'tool' && typeof part.callID === 'string', + ); + if (!tool) continue; + + const details = []; + const seen = new Set(); + for (const part of message.parts ?? []) { + const partDetails = part?.metadata?.openrouter?.reasoning_details; + if (!Array.isArray(partDetails)) continue; + + for (const detail of partDetails) { + const key = JSON.stringify(detail); + if (seen.has(key)) continue; + seen.add(key); + details.push(detail); + } + } + + if ( + !details.some( + (detail) => + detail?.type === 'reasoning.encrypted' && + typeof detail.data === 'string' && + detail.data.length > 0, + ) + ) { + details.push({ + type: 'reasoning.encrypted', + data: 'skip_thought_signature_validator', + id: tool.callID, + format: 'google-gemini-v1', + index: 0, + }); + } + + const target = + message.parts.find((part) => part?.type === 'reasoning') ?? tool; + target.metadata = { + ...target.metadata, + openrouter: { + ...target.metadata?.openrouter, + reasoning_details: details, + }, + }; + } + }, }); `;