diff --git a/apps/worker/src/run-task/agent-home.test.ts b/apps/worker/src/run-task/agent-home.test.ts index 83e5abff8..abdd23924 100644 --- a/apps/worker/src/run-task/agent-home.test.ts +++ b/apps/worker/src/run-task/agent-home.test.ts @@ -14,6 +14,7 @@ import { generateOpenCodeConfig, seedRuntimeHomeMiseGlobalConfig, } from './agent-home'; +import { OPENCODE_IDENTITY_PLUGIN_SCRIPT } from '@roomote/cloud-agents'; describe('createIntegrationMcpInstructions', () => { it.each(['gbrain', 'supermemory'])( @@ -113,6 +114,23 @@ describe('generateOpenCodeConfig provider support', () => { return homeDir; } + it('installs the Roomote identity plugin for standard task sessions', () => { + const result = generateOpenCodeConfig({ + homeDir: createHomeDir(), + runtimeEnv: { + R_MODEL: 'openrouter/openai/gpt-5.6-terra', + OPENROUTER_API_KEY: 'openrouter-key', + }, + }); + + expect( + readFileSync( + join(result.openCodeConfigDir, 'plugins', 'roomote-identity.js'), + 'utf8', + ), + ).toBe(OPENCODE_IDENTITY_PLUGIN_SCRIPT); + }); + it('applies the per-task reasoning effort to a launch-time model override', () => { const result = generateOpenCodeConfig({ homeDir: createHomeDir(), diff --git a/apps/worker/src/run-task/agent-home.ts b/apps/worker/src/run-task/agent-home.ts index dbffe4a8d..e33ca716d 100644 --- a/apps/worker/src/run-task/agent-home.ts +++ b/apps/worker/src/run-task/agent-home.ts @@ -5,6 +5,7 @@ import * as path from 'node:path'; import { createRoomoteAdvisorAgentPrompt, createRoomoteJudgeAgentPrompt, + OPENCODE_IDENTITY_PLUGIN_SCRIPT, ROOMOTE_OPENCODE_ADVISOR_AGENT_DESCRIPTION, ROOMOTE_OPENCODE_ADVISOR_AGENT_NAME, ROOMOTE_OPENCODE_JUDGE_AGENT_DESCRIPTION, @@ -180,6 +181,8 @@ const ROOMOTE_OPENCODE_CHATGPT_GATEWAY_PLUGIN_FILE_NAME = const ROOMOTE_OPENCODE_TOOL_SAFETY_PLUGIN_FILE_NAME = 'roomote-tool-safety.js'; +const ROOMOTE_OPENCODE_IDENTITY_PLUGIN_FILE_NAME = 'roomote-identity.js'; + const OPENCODE_ALLOW_ALL_PERMISSION = { read: 'allow', edit: 'allow', @@ -653,6 +656,10 @@ function writeOpenCodeManagedFiles(openCodeConfigDir: string): void { pluginsDir, ROOMOTE_OPENCODE_TOOL_SAFETY_PLUGIN_FILE_NAME, ); + const identityPluginPath = path.join( + pluginsDir, + ROOMOTE_OPENCODE_IDENTITY_PLUGIN_FILE_NAME, + ); const silenceHookPath = path.join( openCodeConfigDir, ROOMOTE_OPENCODE_SLACK_SILENCE_HOOK_FILE_NAME, @@ -674,6 +681,7 @@ function writeOpenCodeManagedFiles(openCodeConfigDir: string): void { OPENCODE_TOOL_SAFETY_PLUGIN_SCRIPT, 'utf8', ); + fs.writeFileSync(identityPluginPath, OPENCODE_IDENTITY_PLUGIN_SCRIPT, 'utf8'); fs.writeFileSync(silenceHookPath, SLACK_SILENCE_HOOK_SCRIPT, 'utf8'); fs.writeFileSync(stopHookPath, SLACK_STOP_HOOK_SCRIPT, 'utf8'); fs.chmodSync(silenceHookPath, 0o755); diff --git a/packages/cloud-agents/src/__tests__/opencode-identity-plugin.test.ts b/packages/cloud-agents/src/__tests__/opencode-identity-plugin.test.ts new file mode 100644 index 000000000..f0c674b32 --- /dev/null +++ b/packages/cloud-agents/src/__tests__/opencode-identity-plugin.test.ts @@ -0,0 +1,89 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { OPENCODE_IDENTITY_PLUGIN_SCRIPT } from '../opencode-identity-plugin'; + +type SystemTransformHook = ( + input: unknown, + output: { system: string[] }, +) => Promise; + +describe('OPENCODE_IDENTITY_PLUGIN_SCRIPT', () => { + async function loadSystemTransformHook(): Promise { + const tempDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'roomote-opencode-identity-plugin-'), + ); + const pluginPath = path.join(tempDir, 'roomote-identity.mjs'); + + try { + fs.writeFileSync(pluginPath, OPENCODE_IDENTITY_PLUGIN_SCRIPT, 'utf8'); + const plugin = (await import( + /* @vite-ignore */ pathToFileURL(pluginPath).href + )) as { + RoomoteOpenCodeIdentity: () => Promise<{ + 'experimental.chat.system.transform': SystemTransformHook; + }>; + }; + const hooks = await plugin.RoomoteOpenCodeIdentity(); + return hooks['experimental.chat.system.transform']; + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + } + + it.each([ + { + name: 'default prompt', + prompt: + 'You are opencode, an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.', + expected: + 'an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.', + }, + { + name: 'Anthropic prompt', + prompt: + 'You are OpenCode, the best coding agent on the planet.\n\nYou are an interactive CLI tool that helps users with software engineering tasks.', + expected: + 'the best coding agent on the planet.\n\nYou are an interactive CLI tool that helps users with software engineering tasks.', + }, + { + name: 'GPT prompt', + prompt: + "You are OpenCode, You and the user share the same workspace and collaborate to achieve the user's goals.", + expected: + "You and the user share the same workspace and collaborate to achieve the user's goals.", + }, + ])( + 'removes only the leading identity declaration from the $name', + async ({ prompt, expected }) => { + const transform = await loadSystemTransformHook(); + const output = { + system: [prompt, 'You are Roomote in fast mode.'], + }; + + await transform({}, output); + + expect(output.system).toEqual([ + expected, + 'You are Roomote in fast mode.', + ]); + }, + ); + + it('does not rewrite non-leading or Roomote identity text', async () => { + const transform = await loadSystemTransformHook(); + const output = { + system: [ + 'Preserve this preface. You are OpenCode, as referenced in documentation.', + 'You are Roomote, a software engineering teammate.', + ], + }; + const original = structuredClone(output.system); + + await transform({}, output); + + expect(output.system).toEqual(original); + }); +}); diff --git a/packages/cloud-agents/src/index.ts b/packages/cloud-agents/src/index.ts index a016e3e2a..5e3f5cdc1 100644 --- a/packages/cloud-agents/src/index.ts +++ b/packages/cloud-agents/src/index.ts @@ -16,6 +16,7 @@ export { export { resolveRoomoteReleaseVersion } from './release-version'; export * from './style-guidance'; export * from './opencode-prompt-subagents'; +export { OPENCODE_IDENTITY_PLUGIN_SCRIPT } from './opencode-identity-plugin'; export { DEFAULT_STANDARD_TASK_MODEL, DEFAULT_STANDARD_TASK_MODEL_PROVIDER, diff --git a/packages/cloud-agents/src/opencode-identity-plugin.ts b/packages/cloud-agents/src/opencode-identity-plugin.ts new file mode 100644 index 000000000..23e5d62db --- /dev/null +++ b/packages/cloud-agents/src/opencode-identity-plugin.ts @@ -0,0 +1,13 @@ +/** + * OpenCode 1.18.10 prefixes its model-specific system prompt with its own + * product identity. Roomote supplies the product identity in later prompt + * layers, so remove only that prefix and preserve the remaining instructions. + */ +export const OPENCODE_IDENTITY_PLUGIN_SCRIPT = `export const RoomoteOpenCodeIdentity = async () => ({ + 'experimental.chat.system.transform': async (_input, output) => { + if (typeof output.system?.[0] === 'string') { + output.system[0] = output.system[0].replace(/^You are OpenCode,\\s*/iu, ''); + } + }, +}); +`; diff --git a/packages/cloud-agents/src/server/__tests__/opencode-runtime.test.ts b/packages/cloud-agents/src/server/__tests__/opencode-runtime.test.ts index 470020002..41aa19914 100644 --- a/packages/cloud-agents/src/server/__tests__/opencode-runtime.test.ts +++ b/packages/cloud-agents/src/server/__tests__/opencode-runtime.test.ts @@ -208,6 +208,9 @@ describe('buildOpenCodeCliEnv', () => { ...NON_TASK_TOOL_PERMISSION_DENIALS, task: 'allow', }); + expect(config.plugin).toEqual([ + expect.stringMatching(/^file:\/\/.*roomote-identity\.mjs$/u), + ]); expect(Object.keys(config.agent)).toEqual(['advisor', 'judge']); for (const agentName of ['advisor', 'judge']) { diff --git a/packages/cloud-agents/src/server/opencode-runtime.ts b/packages/cloud-agents/src/server/opencode-runtime.ts index 3e329f8b4..714f43e9a 100644 --- a/packages/cloud-agents/src/server/opencode-runtime.ts +++ b/packages/cloud-agents/src/server/opencode-runtime.ts @@ -1,6 +1,10 @@ import { execFileSync, spawn, type ChildProcess } from 'node:child_process'; import { createHash } from 'node:crypto'; +import { mkdtempSync, writeFileSync } from 'node:fs'; import { createServer } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; import { collectOpenRouterVariantModelAlias, @@ -28,6 +32,7 @@ import { ROOMOTE_OPENCODE_JUDGE_AGENT_DESCRIPTION, ROOMOTE_OPENCODE_JUDGE_AGENT_NAME, } from '../opencode-prompt-subagents'; +import { OPENCODE_IDENTITY_PLUGIN_SCRIPT } from '../opencode-identity-plugin'; import { FAST_AGENT_SUBAGENT_TOOL_FILTER } from './fast-agent/fast-agent-tool-policy'; const ESCAPE_CHARACTER = String.fromCharCode(27); @@ -221,6 +226,23 @@ type NonTaskOpenCodeRuntimeOptions = { promptOnlySubagents?: boolean; }; +let openCodeIdentityPluginUrl: string | undefined; + +function getOpenCodeIdentityPluginUrl(): string { + if (openCodeIdentityPluginUrl) { + return openCodeIdentityPluginUrl; + } + + const directory = mkdtempSync(join(tmpdir(), 'roomote-opencode-identity-')); + const pluginPath = join(directory, 'roomote-identity.mjs'); + writeFileSync(pluginPath, OPENCODE_IDENTITY_PLUGIN_SCRIPT, { + encoding: 'utf8', + mode: 0o600, + }); + openCodeIdentityPluginUrl = pathToFileURL(pluginPath).href; + return openCodeIdentityPluginUrl; +} + function buildRestrictedNonTaskConfig( options: NonTaskOpenCodeRuntimeOptions, ): Record { @@ -230,6 +252,7 @@ function buildRestrictedNonTaskConfig( return { agent: PROMPT_ONLY_SUBAGENTS, + plugin: [getOpenCodeIdentityPluginUrl()], permission: { ...NON_TASK_TOOL_PERMISSION_DENIALS, task: 'allow' }, }; }