diff --git a/.changeset/advisor-model-plan-mode-default.md b/.changeset/advisor-model-plan-mode-default.md new file mode 100644 index 000000000..092396e5b --- /dev/null +++ b/.changeset/advisor-model-plan-mode-default.md @@ -0,0 +1,7 @@ +--- +'@roomote/feature-flags': minor +'@roomote/web': minor +'@roomote/worker': minor +--- + +Plan mode is now always on: the `PlanMode` feature flag has been removed entirely, so planning turns run read-only for every deployment with no opt-out. The model role that powers it is now called "Advisor" in the settings UI and docs — it keeps backing the planning workflow, and it also backs a new `advisor` subagent that the coding agent consults when it is stuck or needs a second opinion. The advisor uses the configured Advisor model when one is set and otherwise falls back to the active coding model at the advisor reasoning level, which defaults to high. diff --git a/apps/docs/environment-variables.mdx b/apps/docs/environment-variables.mdx index 9d0e58930..63c2b41d9 100644 --- a/apps/docs/environment-variables.mdx +++ b/apps/docs/environment-variables.mdx @@ -161,13 +161,13 @@ as per-task auth tokens or workspace paths. | `R_VISION_MODEL` | Optional | Vision-capable model for visual inspection and screenshot/image work. | | `R_CODE_REVIEW_MODEL` | Optional | Model for initial PR/MR review and review-sync tasks. | | `R_EXPLORE_MODEL` | Optional | Exploration/helper model for investigation-oriented subtasks. | -| `R_PLANNING_MODEL` | Optional | Planning model used for longer coding-task planning. | +| `R_PLANNING_MODEL` | Optional | Advisor model used for planning turns and advisor consultations inside coding tasks. | | `R_MODEL_REASONING_EFFORT` | Optional | Reasoning level for the coding model: `low`, `medium`, `high`, or `xhigh`. | | `R_SMALL_MODEL_REASONING_EFFORT` | Optional | Reasoning level for the helper model. | | `R_VISION_MODEL_REASONING_EFFORT` | Optional | Reasoning level for the vision model. | | `R_CODE_REVIEW_MODEL_REASONING_EFFORT` | Optional | Reasoning level for the code review model. | | `R_EXPLORE_MODEL_REASONING_EFFORT` | Optional | Reasoning level for the exploration model. | -| `R_PLANNING_MODEL_REASONING_EFFORT` | Optional | Reasoning level for the planning model. | +| `R_PLANNING_MODEL_REASONING_EFFORT` | Optional | Reasoning level for the advisor model. | | `R_MODEL_ENV_KEYS` | Optional | Comma- or space-separated list of extra provider key env vars to forward to task workers. | | `OPENROUTER_API_KEY` | Provider key | OpenRouter API key. Can also be saved from **Settings > Models**. | | `AI_GATEWAY_API_KEY` | Provider key | Vercel AI Gateway API key. | diff --git a/apps/docs/models.mdx b/apps/docs/models.mdx index afe749f8b..6bd8a554e 100644 --- a/apps/docs/models.mdx +++ b/apps/docs/models.mdx @@ -159,7 +159,7 @@ where you want more speed, quality, or cost control. | Helper model | Lightweight routing, titles, summaries, and quick internal decisions | Low latency, low cost, and enough accuracy for short judgments | | Vision model | Visual inspection, screenshots, UI review, and image-heavy work | Image understanding, layout reasoning, and concise visual feedback | | Code review model | Initial PR or MR review tasks and review-sync work | Careful reasoning, bug finding, security awareness, and willingness to cite evidence | -| Planning model | Planning steps inside longer coding tasks | Deliberate reasoning, decomposition, and ability to keep constraints in mind | +| Advisor model | Planning turns and advisor consultations inside longer coding tasks | Deliberate reasoning, decomposition, and ability to keep constraints in mind | You do not need a separate model for every role. Many teams start with one strong default model, then add a cheaper helper model or a stronger review @@ -178,7 +178,8 @@ A practical starting point: - use **Medium** for the default coding model - use **Low** for helper and vision work unless you see quality issues -- use **High** for code review and planning when you want more careful analysis +- use **High** for code review and advisor work when you want more careful + analysis - reserve **Extra high** for models and workflows where the added cost is justified @@ -215,7 +216,7 @@ For code review, prioritize: - good false-positive control - attention to tests, regressions, security, and edge cases -For planning, prioritize: +For advisor work, prioritize: - structured reasoning - ability to break work into practical steps diff --git a/apps/web/src/components/settings/ModelSettingsSection.test.tsx b/apps/web/src/components/settings/ModelSettingsSection.test.tsx index 164309a3f..ddc75b205 100644 --- a/apps/web/src/components/settings/ModelSettingsSection.test.tsx +++ b/apps/web/src/components/settings/ModelSettingsSection.test.tsx @@ -403,7 +403,7 @@ describe('ModelSettingsSection', () => { screen.getByLabelText('Default coding model is managed by R_MODEL'), ).toBeInTheDocument(); expect( - screen.getByLabelText('Planning model is managed by R_PLANNING_MODEL'), + screen.getByLabelText('Advisor model is managed by R_PLANNING_MODEL'), ).toBeInTheDocument(); }); @@ -434,7 +434,7 @@ describe('ModelSettingsSection', () => { ).toBeInTheDocument(); expect( screen.getByLabelText( - 'Planning model reasoning is managed by R_PLANNING_MODEL_REASONING_EFFORT', + 'Advisor model reasoning is managed by R_PLANNING_MODEL_REASONING_EFFORT', ), ).toBeInTheDocument(); }); @@ -815,14 +815,14 @@ describe('ModelSettingsSection', () => { expect(toast.success).not.toHaveBeenCalled(); }); - it('shows the planning model role', () => { + it('shows the advisor model role', () => { settingsData.current = buildSettingsData(); renderModelSettingsSection(); - expect(screen.getByText('Planning model')).toBeInTheDocument(); + expect(screen.getByText('Advisor model')).toBeInTheDocument(); expect( - screen.getByRole('combobox', { name: 'Planning model reasoning level' }), + screen.getByRole('combobox', { name: 'Advisor model reasoning level' }), ).toBeInTheDocument(); }); diff --git a/apps/web/src/components/settings/ModelSettingsSection.tsx b/apps/web/src/components/settings/ModelSettingsSection.tsx index 693de3c10..6437481b3 100644 --- a/apps/web/src/components/settings/ModelSettingsSection.tsx +++ b/apps/web/src/components/settings/ModelSettingsSection.tsx @@ -237,14 +237,14 @@ const TASK_MODEL_ROLE_CONFIGS: readonly TaskModelRoleConfig[] = [ }, { role: 'planning', - label: 'Planning model', + label: 'Advisor model', description: - 'Used for plan-mode turns while the planning workflow is active.', + 'Used for plan-mode turns in the planning workflow and for the advisor subagent that coding tasks consult when they need help.', icon: ScrollText, modelEnvVarName: 'R_PLANNING_MODEL', reasoningEnvVarName: 'R_PLANNING_MODEL_REASONING_EFFORT', - placeholder: 'Select a planning model', - reasoningAriaLabel: 'Planning model reasoning level', + placeholder: 'Select an advisor model', + reasoningAriaLabel: 'Advisor model reasoning level', allowSameAsCoding: true, }, ]; diff --git a/apps/web/src/lib/mock-utils.ts b/apps/web/src/lib/mock-utils.ts index 6912632b9..a2f9ed913 100644 --- a/apps/web/src/lib/mock-utils.ts +++ b/apps/web/src/lib/mock-utils.ts @@ -3,7 +3,6 @@ import { FeatureFlag } from '@roomote/feature-flags'; import type { UserResource } from '@/types'; export const mockFeatureFlags: Record = { - [FeatureFlag.PlanMode]: false, [FeatureFlag.SlackEvalLauncher]: false, [FeatureFlag.ShowDebugUISetting]: false, [FeatureFlag.SlackProofAutoPost]: false, diff --git a/apps/web/src/trpc/commands/task-models/index.ts b/apps/web/src/trpc/commands/task-models/index.ts index 49e363909..71e8f5eb1 100644 --- a/apps/web/src/trpc/commands/task-models/index.ts +++ b/apps/web/src/trpc/commands/task-models/index.ts @@ -864,7 +864,7 @@ export async function updateTaskModelSettingsCommand( normalizedPlanningModelId !== null && !knownModelIds.has(normalizedPlanningModelId) ) { - fieldErrors.planningModelId = 'Choose a valid planning model.'; + fieldErrors.planningModelId = 'Choose a valid advisor model.'; } const codingManagedByEnv = isConfiguredEnvValue(process.env.R_MODEL); diff --git a/apps/worker/src/run-task/__tests__/run-task.test.ts b/apps/worker/src/run-task/__tests__/run-task.test.ts index c4d47ba5a..82538babf 100644 --- a/apps/worker/src/run-task/__tests__/run-task.test.ts +++ b/apps/worker/src/run-task/__tests__/run-task.test.ts @@ -578,103 +578,6 @@ describe('runTask', () => { ); }); - it('passes the plan mode flag into the runtime env when enabled for the org', async () => { - mockEvaluateFeatureFlag.mockImplementation(async (flag: string) => { - return flag === 'PlanMode'; - }); - - await runTask({ - taskRun: { - id: 106, - taskId: 'task-106', - payloadKind: TaskPayloadKind.StandardTask, - harness: 'opencode-server', - payload: {}, - result: null, - } as never, - envVars: {}, - workspacePath: '/tmp/workspace', - prompt: '', - harnessInstructions: undefined, - agentInstructions: undefined, - environmentConfig: undefined, - callbacks: {}, - context: {}, - logger: { - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - log: vi.fn(), - } as never, - harnessSessionId: undefined, - workerEnv: { - authToken: 'cloud-token', - roomoteAppUrl: 'https://api.example.test', - trpcUrl: 'https://web.example.test', - buildUserFacingEnv: vi.fn(() => ({ - HOME: '/tmp/home', - PATH: '/usr/bin', - })), - } as never, - }); - - expect(mockEvaluateFeatureFlag).toHaveBeenCalledWith('PlanMode'); - expect(createHarnessMock).toHaveBeenCalledWith( - expect.objectContaining({ - runtimeEnv: expect.objectContaining({ - ROOMOTE_PLAN_MODE: 'true', - }), - }), - ); - }); - - it('does not set the plan mode env when the flag is disabled', async () => { - mockEvaluateFeatureFlag.mockResolvedValue(false); - - await runTask({ - taskRun: { - id: 107, - taskId: 'task-107', - payloadKind: TaskPayloadKind.StandardTask, - harness: 'opencode-server', - payload: {}, - result: null, - } as never, - envVars: {}, - workspacePath: '/tmp/workspace', - prompt: '', - harnessInstructions: undefined, - agentInstructions: undefined, - environmentConfig: undefined, - callbacks: {}, - context: {}, - logger: { - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - log: vi.fn(), - } as never, - harnessSessionId: undefined, - workerEnv: { - authToken: 'cloud-token', - roomoteAppUrl: 'https://api.example.test', - trpcUrl: 'https://web.example.test', - buildUserFacingEnv: vi.fn(() => ({ - HOME: '/tmp/home', - PATH: '/usr/bin', - })), - } as never, - }); - - expect(createHarnessMock).toHaveBeenCalledWith( - expect.objectContaining({ - runtimeEnv: expect.not.objectContaining({ - ROOMOTE_PLAN_MODE: expect.anything(), - }), - }), - ); - }); - it('does not set the background subagents env when the flag is disabled', async () => { mockEvaluateFeatureFlag.mockResolvedValue(false); diff --git a/apps/worker/src/run-task/agent-home.ts b/apps/worker/src/run-task/agent-home.ts index e59bd4848..df0200447 100644 --- a/apps/worker/src/run-task/agent-home.ts +++ b/apps/worker/src/run-task/agent-home.ts @@ -63,6 +63,8 @@ const ROOMOTE_OPENCODE_VISUAL_AGENT_NAME = 'visual'; const ROOMOTE_OPENCODE_JUDGE_AGENT_NAME = 'judge'; +const ROOMOTE_OPENCODE_ADVISOR_AGENT_NAME = 'advisor'; + const ROOMOTE_OPENCODE_EXPLORE_AGENT_NAME = 'explore'; const OPENCODE_GENERAL_AGENT_NAME = 'general'; @@ -72,6 +74,9 @@ const ROOMOTE_OPENCODE_VISUAL_MODEL_INSTRUCTIONS_FILE_NAME = const ROOMOTE_OPENCODE_JUDGE_MODEL_INSTRUCTIONS_FILE_NAME = 'roomote-opencode-judge-model-instructions.md'; +const ROOMOTE_OPENCODE_ADVISOR_MODEL_INSTRUCTIONS_FILE_NAME = + 'roomote-opencode-advisor-model-instructions.md'; + const ROOMOTE_OPENCODE_PROOF_RUNNER_INSTRUCTIONS_FILE_NAME = 'roomote-opencode-proof-runner-instructions.md'; @@ -104,6 +109,20 @@ const ROOMOTE_OPENCODE_JUDGE_AGENT_PROMPT = [ 'Do not edit files, run shell commands, launch other agents, or make final product decisions. Keep your response focused on review findings and verdicts for the parent agent.', ].join('\n'); +const ROOMOTE_OPENCODE_ADVISOR_AGENT_PROMPT = [ + 'You are Roomote coding advisor support.', + '', + 'The parent coding agent consults you when it is stuck or needs help: repeated failed attempts, a confusing bug, an uncertain approach or design decision, or conflicting constraints.', + '', + 'Start from the context the parent provides: the goal, what was tried, exact errors or failing output, and the relevant files. Read additional repository files when needed to ground your advice, but keep exploration targeted to the question.', + '', + 'Return concrete, actionable guidance: 1) your diagnosis or best hypotheses, 2) the recommended approach and why, 3) specific next steps the parent can execute, 4) any risks or alternatives worth considering. Prefer a single clear recommendation over a menu of options.', + '', + 'If the provided context is insufficient to advise confidently, say exactly what evidence would disambiguate the situation.', + '', + 'Do not edit files, run shell commands, launch other agents, or make final product decisions. Keep your response focused on advice the parent agent can act on.', +].join('\n'); + const ROOMOTE_OPENCODE_ARCHITECT_AGENT_PROMPT = [ 'You are Roomote planning support: a planning specialist for read-mostly plan-mode turns.', '', @@ -623,6 +642,36 @@ function createJudgeAgentConfig( }; } +function createAdvisorAgentConfig( + model: string, + reasoningOptions?: Record | null, +): Record { + return { + description: + 'Consulting advisor the coding agent can ask for help when it is stuck, has repeated failed attempts, or needs a second opinion on approach or debugging.', + mode: 'subagent', + model, + ...(reasoningOptions ? { options: reasoningOptions } : {}), + prompt: ROOMOTE_OPENCODE_ADVISOR_AGENT_PROMPT, + permission: { + read: 'allow', + list: 'allow', + glob: 'allow', + grep: 'allow', + external_directory: 'allow', + webfetch: 'allow', + edit: 'deny', + bash: 'deny', + task: 'deny', + todowrite: 'deny', + lsp: 'deny', + skill: 'deny', + question: 'deny', + }, + tools: { ...SLACK_POSTING_TOOL_EXCLUSIONS }, + }; +} + // Note: the architect agent deliberately keeps the Slack-posting tools. It is // a primary (parent-session) agent that owns plan-mode turns end to end, so // it must be able to reply to the originating Slack thread itself. @@ -735,6 +784,22 @@ function createJudgeModelInstructions(): string { ].join('\n'); } +function createAdvisorModelInstructions(): string { + return [ + `An OpenCode \`${ROOMOTE_OPENCODE_ADVISOR_AGENT_NAME}\` subagent is always configured as consulting support for the active coding agent.`, + '', + 'When `R_PLANNING_MODEL` is configured, the advisor uses that advisor model. Otherwise it falls back to the active coding model, running at the advisor reasoning level (defaults to high) for deeper analysis.', + '', + `When you are stuck or need help — repeated failed fix attempts, a confusing bug, an uncertain approach or design decision, or conflicting constraints — delegate one focused consultation to the \`${ROOMOTE_OPENCODE_ADVISOR_AGENT_NAME}\` subagent with the Task tool before grinding through more blind attempts.`, + '', + 'Give the advisor a complete brief: the goal, what you already tried, the exact errors or failing output, and the relevant file paths. The advisor can read the repository but cannot run commands, so include runtime evidence it cannot gather itself.', + '', + 'Treat the advisor response as internal guidance for the parent workflow. Verify its recommendations against the repository before acting on them, and keep orchestration, code changes, and final user-facing decisions in the parent agent.', + '', + "Do not paste the advisor's full output into chat or any user-facing reply. Surface at most a brief, parent-authored summary of the direction taken.", + ].join('\n'); +} + function createExploreAgentConfig(options: { model: string; reasoningOptions?: Record | null; @@ -872,6 +937,22 @@ function resolveModelBackedOpenCodeConfig( : null, ), }; + // The advisor subagent shares the planning/advisor model role. It defaults + // to the coding model when no advisor model is configured, and the advisor + // reasoning level (default high) applies in either case so consultations + // run with deeper reasoning than ordinary coding turns. + const advisorModel = planningModel ?? effectiveCodingModel; + const advisorAgent = { + [ROOMOTE_OPENCODE_ADVISOR_AGENT_NAME]: createAdvisorAgentConfig( + advisorModel, + planningModelReasoningEffort + ? buildOpenCodeModelReasoningOptions( + advisorModel, + planningModelReasoningEffort, + ) + : null, + ), + }; const exploreEffectiveModel = exploreModel ?? effectiveCodingModel; const exploreAgent = exploreModel || exploreModelReasoningEffort @@ -917,6 +998,7 @@ function resolveModelBackedOpenCodeConfig( const agent = { ...(visualAgent ?? {}), ...judgeAgent, + ...advisorAgent, ...(exploreAgent ?? {}), ...architectAgent, ...generalAgent, @@ -1124,6 +1206,19 @@ export function generateOpenCodeConfig({ instructions.push(judgeModelInstructionsPath); } + if (operatorAgent[ROOMOTE_OPENCODE_ADVISOR_AGENT_NAME]) { + const advisorModelInstructionsPath = path.join( + openCodeConfigDir, + ROOMOTE_OPENCODE_ADVISOR_MODEL_INSTRUCTIONS_FILE_NAME, + ); + fs.writeFileSync( + advisorModelInstructionsPath, + createAdvisorModelInstructions(), + 'utf8', + ); + instructions.push(advisorModelInstructionsPath); + } + const proofBrowserTarget = runtimeEnv.ROOMOTE_PROOF_BROWSER_TARGET?.trim(); delete runtimeEnv.ROOMOTE_PROOF_BROWSER_TARGET; diff --git a/apps/worker/src/run-task/run-task.ts b/apps/worker/src/run-task/run-task.ts index fb204bad5..b1ab68513 100644 --- a/apps/worker/src/run-task/run-task.ts +++ b/apps/worker/src/run-task/run-task.ts @@ -486,7 +486,6 @@ function getQueuedSnapshotResumeLinearMessages( : []; } -const PLAN_MODE_FLAG = FeatureFlag.PlanMode; const SLACK_PROOF_AUTO_POST_FLAG = FeatureFlag.SlackProofAutoPost; const BACKGROUND_SUBAGENTS_FLAG = FeatureFlag.BackgroundSubagents; @@ -678,18 +677,6 @@ export const runTask = async ({ ); } - try { - const planModeEnabled = await sdk.featureFlags.evaluate(PLAN_MODE_FLAG); - - if (planModeEnabled) { - runtimeEnv.ROOMOTE_PLAN_MODE = 'true'; - } - } catch (error) { - logger.warn( - `[runTask] Failed to evaluate PlanMode feature flag: ${error instanceof Error ? error.message : String(error)}`, - ); - } - try { const slackProofAutoPostEnabled = await sdk.featureFlags.evaluate( SLACK_PROOF_AUTO_POST_FLAG, diff --git a/apps/worker/src/sandbox-server/lib/harnesses/__tests__/opencode-server-bootstrap.test.ts b/apps/worker/src/sandbox-server/lib/harnesses/__tests__/opencode-server-bootstrap.test.ts index a5994da98..a68fca28d 100644 --- a/apps/worker/src/sandbox-server/lib/harnesses/__tests__/opencode-server-bootstrap.test.ts +++ b/apps/worker/src/sandbox-server/lib/harnesses/__tests__/opencode-server-bootstrap.test.ts @@ -279,6 +279,10 @@ describe('opencode-server bootstrap', () => { openCodeConfigDir, 'roomote-opencode-judge-model-instructions.md', ); + const advisorModelInstructionsPath = path.join( + openCodeConfigDir, + 'roomote-opencode-advisor-model-instructions.md', + ); const systemPromptPath = path.join( openCodeConfigDir, 'roomote-opencode-system-prompt.md', @@ -289,6 +293,7 @@ describe('opencode-server bootstrap', () => { expect(config.instructions).toEqual([ developerInstructionsPath, judgeModelInstructionsPath, + advisorModelInstructionsPath, ]); expect(fs.readFileSync(developerInstructionsPath, 'utf8')).toBe( 'Use the task tools.', @@ -327,6 +332,10 @@ describe('opencode-server bootstrap', () => { openCodeConfigDir, 'roomote-opencode-judge-model-instructions.md', ); + const advisorModelInstructionsPath = path.join( + openCodeConfigDir, + 'roomote-opencode-advisor-model-instructions.md', + ); expect(baseConfig.agent?.visual).toMatchObject({ description: expect.stringContaining('images'), @@ -345,6 +354,7 @@ describe('opencode-server bootstrap', () => { expect(config.instructions).toEqual([ visualModelInstructionsPath, judgeModelInstructionsPath, + advisorModelInstructionsPath, ]); expect(fs.readFileSync(visualModelInstructionsPath, 'utf8')).toContain( 'visual', @@ -380,6 +390,10 @@ describe('opencode-server bootstrap', () => { openCodeConfigDir, 'roomote-opencode-judge-model-instructions.md', ); + const advisorModelInstructionsPath = path.join( + openCodeConfigDir, + 'roomote-opencode-advisor-model-instructions.md', + ); expect(baseConfig.agent?.judge).toEqual({ description: @@ -412,7 +426,10 @@ describe('opencode-server bootstrap', () => { ), }); expect(config.agent).toEqual(baseConfig.agent); - expect(config.instructions).toEqual([judgeModelInstructionsPath]); + expect(config.instructions).toEqual([ + judgeModelInstructionsPath, + advisorModelInstructionsPath, + ]); expect(fs.readFileSync(judgeModelInstructionsPath, 'utf8')).toContain( 'judge', ); @@ -450,6 +467,12 @@ describe('opencode-server bootstrap', () => { 'opencode', 'roomote-opencode-judge-model-instructions.md', ); + const advisorModelInstructionsPath = path.join( + homeDir, + '.config', + 'opencode', + 'roomote-opencode-advisor-model-instructions.md', + ); expect(baseConfig.agent?.judge).toEqual({ description: @@ -481,7 +504,10 @@ describe('opencode-server bootstrap', () => { ), }); expect(config.agent).toEqual(baseConfig.agent); - expect(config.instructions).toEqual([judgeModelInstructionsPath]); + expect(config.instructions).toEqual([ + judgeModelInstructionsPath, + advisorModelInstructionsPath, + ]); expect(fs.readFileSync(judgeModelInstructionsPath, 'utf8')).toContain( 'falls back to the active coding model', ); @@ -618,6 +644,104 @@ describe('opencode-server bootstrap', () => { expect(baseConfig.agent?.architect).not.toHaveProperty('model'); }); + it('configures a hidden advisor subagent with the advisor model when a planning model is configured', async () => { + const { prepareOpenCodeCommandEnv } = + await import('../opencode-server/bootstrap'); + + const homeDir = createTempHome(); + + const { commandEnv: runtimeEnv } = await prepareOpenCodeCommandEnv({ + runtimeEnv: { + ...createDirectHarnessRuntimeEnv(homeDir), + R_PLANNING_MODEL: 'openrouter/anthropic/claude-opus-4.7', + R_PLANNING_MODEL_REASONING_EFFORT: 'high', + }, + workspacePath: '/tmp/workspace', + logger: createLogger(), + }); + + const baseConfig = JSON.parse(readOpenCodeConfig(homeDir)) as { + agent?: Record; + }; + const config = readRoomoteOpenCodeOverlay(runtimeEnv) as { + agent?: Record; + instructions?: string[]; + }; + const advisorModelInstructionsPath = path.join( + homeDir, + '.config', + 'opencode', + 'roomote-opencode-advisor-model-instructions.md', + ); + + expect(baseConfig.agent?.advisor).toEqual({ + description: expect.stringContaining('stuck'), + mode: 'subagent', + model: 'openrouter/anthropic/claude-opus-4.7', + options: { reasoning: { effort: 'high' } }, + prompt: expect.stringContaining('coding advisor support'), + permission: { + read: 'allow', + list: 'allow', + glob: 'allow', + grep: 'allow', + external_directory: 'allow', + webfetch: 'allow', + edit: 'deny', + bash: 'deny', + task: 'deny', + todowrite: 'deny', + lsp: 'deny', + skill: 'deny', + question: 'deny', + }, + tools: slackPostingToolExclusions, + }); + expect(config.agent).toEqual(baseConfig.agent); + expect(config.instructions).toContain(advisorModelInstructionsPath); + expect(fs.readFileSync(advisorModelInstructionsPath, 'utf8')).toContain( + 'advisor', + ); + expect(fs.readFileSync(advisorModelInstructionsPath, 'utf8')).toContain( + 'delegate one focused consultation', + ); + }); + + it('configures a hidden advisor subagent with the coding model at the advisor reasoning level when no planning model is configured', async () => { + const { prepareOpenCodeCommandEnv } = + await import('../opencode-server/bootstrap'); + + const homeDir = createTempHome(); + + await prepareOpenCodeCommandEnv({ + runtimeEnv: { + ...createDirectHarnessRuntimeEnv(homeDir), + R_PLANNING_MODEL_REASONING_EFFORT: 'high', + }, + workspacePath: '/tmp/workspace', + logger: createLogger(), + }); + + const baseConfig = JSON.parse(readOpenCodeConfig(homeDir)) as { + agent?: Record; + }; + const advisorModelInstructionsPath = path.join( + homeDir, + '.config', + 'opencode', + 'roomote-opencode-advisor-model-instructions.md', + ); + + expect(baseConfig.agent?.advisor).toMatchObject({ + mode: 'subagent', + model: 'test-provider/main-model', + options: { reasoningEffort: 'high' }, + }); + expect(fs.readFileSync(advisorModelInstructionsPath, 'utf8')).toContain( + 'falls back to the active coding model', + ); + }); + it('applies configured reasoning levels as per-model provider options and scrubs the env vars', async () => { const { prepareOpenCodeCommandEnv } = await import('../opencode-server/bootstrap'); @@ -839,6 +963,7 @@ describe('opencode-server bootstrap', () => { expect(baseConfig.agent).toEqual({ judge: expect.objectContaining({ model: 'test-provider/main-model' }), + advisor: expect.objectContaining({ model: 'test-provider/main-model' }), architect: expect.objectContaining({ mode: 'primary' }), general: { tools: slackPostingToolExclusions }, }); @@ -850,6 +975,12 @@ describe('opencode-server bootstrap', () => { 'opencode', 'roomote-opencode-judge-model-instructions.md', ), + path.join( + homeDir, + '.config', + 'opencode', + 'roomote-opencode-advisor-model-instructions.md', + ), ]); expect(fs.existsSync(visualModelInstructionsPath)).toBe(false); expect(runtimeEnv).not.toHaveProperty('R_VISION_MODEL'); @@ -881,6 +1012,9 @@ describe('opencode-server bootstrap', () => { expect(baseConfig.agent).toEqual({ judge: expect.objectContaining({ model: 'test-provider/override-model' }), + advisor: expect.objectContaining({ + model: 'test-provider/override-model', + }), architect: expect.objectContaining({ mode: 'primary' }), general: { tools: slackPostingToolExclusions }, }); @@ -981,6 +1115,12 @@ describe('opencode-server bootstrap', () => { 'opencode', 'roomote-opencode-judge-model-instructions.md', ); + const advisorModelInstructionsPath = path.join( + homeDir, + '.config', + 'opencode', + 'roomote-opencode-advisor-model-instructions.md', + ); const proofRunnerAgent = config.agent?.['proof-runner'] as { prompt?: string; }; @@ -1024,6 +1164,7 @@ describe('opencode-server bootstrap', () => { ); expect(config.instructions).toEqual([ judgeModelInstructionsPath, + advisorModelInstructionsPath, proofRunnerInstructionsPath, ]); expect(fs.readFileSync(proofRunnerInstructionsPath, 'utf8')).toContain( @@ -1060,6 +1201,7 @@ describe('opencode-server bootstrap', () => { expect(config.agent).toEqual({ judge: expect.objectContaining({ model: 'test-provider/main-model' }), + advisor: expect.objectContaining({ model: 'test-provider/main-model' }), architect: expect.objectContaining({ mode: 'primary' }), general: { tools: slackPostingToolExclusions }, }); @@ -1070,6 +1212,12 @@ describe('opencode-server bootstrap', () => { 'opencode', 'roomote-opencode-judge-model-instructions.md', ), + path.join( + homeDir, + '.config', + 'opencode', + 'roomote-opencode-advisor-model-instructions.md', + ), ]); expect(fs.existsSync(proofRunnerInstructionsPath)).toBe(false); expect(runtimeEnv).not.toHaveProperty('ROOMOTE_PROOF_BROWSER_TARGET'); @@ -1098,10 +1246,11 @@ describe('opencode-server bootstrap', () => { expect(config.agent?.visual).toMatchObject({ mode: 'subagent' }); expect(config.agent?.judge).toMatchObject({ mode: 'subagent' }); + expect(config.agent?.advisor).toMatchObject({ mode: 'subagent' }); expect(config.agent?.['proof-runner']).toMatchObject({ mode: 'subagent', }); - expect(config.instructions).toHaveLength(3); + expect(config.instructions).toHaveLength(4); }); it('excludes the Slack-posting tools from every generated subagent and the built-in general agent', async () => { @@ -1128,6 +1277,7 @@ describe('opencode-server bootstrap', () => { for (const agentName of [ 'visual', 'judge', + 'advisor', 'explore', 'proof-runner', 'general', diff --git a/apps/worker/src/sandbox-server/lib/harnesses/__tests__/opencode-server.test.ts b/apps/worker/src/sandbox-server/lib/harnesses/__tests__/opencode-server.test.ts index 6538aea07..b0be55bbb 100644 --- a/apps/worker/src/sandbox-server/lib/harnesses/__tests__/opencode-server.test.ts +++ b/apps/worker/src/sandbox-server/lib/harnesses/__tests__/opencode-server.test.ts @@ -982,139 +982,9 @@ describe('OpenCodeServerHarness', () => { } }); - it('keeps using the build agent after the primary session loads the plan skill when plan mode is disabled', async () => { + it('switches to the architect agent after the primary session loads the plan skill', async () => { const { client, harness } = createHarness(new FakeOpenCodeServerClient()); - try { - await connectHarness(harness, client); - - expect( - harness.sendCommand({ - commandName: TaskCommandName.StartNewTask, - data: { - text: 'Plan the rollout.', - visibleInTranscript: true, - source: 'web', - }, - }), - ).toBe(true); - - await vi.waitFor(() => { - expect(client.promptAsync).toHaveBeenCalledTimes(1); - }); - - // The routing turn itself starts on the default build agent. - expect(client.promptAsync.mock.calls[0]?.[0]).toMatchObject({ - request: { agent: 'build' }, - }); - - // A skill load from a child (subagent) session never changes the - // primary session's agent. - await client.emit({ - type: 'message.part.updated', - properties: { - part: { - id: 'skill_part_child', - sessionID: 'ses_child', - messageID: 'msg_child', - type: 'tool', - callID: 'skill_call_child', - tool: 'skill', - state: { - status: 'completed', - input: { name: 'plan-repo-implementation' }, - title: 'Load skill', - }, - }, - }, - }); - - await client.emit({ - type: 'message.part.updated', - properties: { - part: { - id: 'skill_part_1', - sessionID: 'ses_1', - messageID: 'msg_1', - type: 'tool', - callID: 'skill_call_1', - tool: 'skill', - state: { - status: 'completed', - input: { name: 'plan-repo-implementation' }, - title: 'Load skill', - }, - }, - }, - }); - - expect( - harness.sendCommand({ - commandName: TaskCommandName.SendMessage, - data: { - text: 'Sounds good, keep planning.', - autoSteerWhenQueued: true, - visibleInTranscript: true, - }, - }), - ).toBe(true); - - await vi.waitFor(() => { - expect(client.promptAsync).toHaveBeenCalledTimes(2); - }); - - expect(client.promptAsync.mock.calls[1]?.[0]).toMatchObject({ - request: { agent: 'build' }, - }); - - // Loading another packaged workflow skill switches back to build. - await client.emit({ - type: 'message.part.updated', - properties: { - part: { - id: 'skill_part_2', - sessionID: 'ses_1', - messageID: 'msg_2', - type: 'tool', - callID: 'skill_call_2', - tool: 'skill', - state: { - status: 'completed', - input: { name: 'implement-changes' }, - title: 'Load skill', - }, - }, - }, - }); - - expect( - harness.sendCommand({ - commandName: TaskCommandName.SendMessage, - data: { - text: 'Now build it.', - autoSteerWhenQueued: true, - visibleInTranscript: true, - }, - }), - ).toBe(true); - - await vi.waitFor(() => { - expect(client.promptAsync).toHaveBeenCalledTimes(3); - }); - - expect(client.promptAsync.mock.calls[2]?.[0]).toMatchObject({ - request: { agent: 'build' }, - }); - } finally { - harness.dispose(); - } - }); - - it('switches to the architect agent after the primary session loads the plan skill when plan mode is enabled', async () => { - const { client, harness } = createHarness(new FakeOpenCodeServerClient(), { - commandEnv: { ROOMOTE_PLAN_MODE: 'true' }, - }); - try { await connectHarness(harness, client); @@ -1245,9 +1115,7 @@ describe('OpenCodeServerHarness', () => { }); it('auto-submits one hidden continuation on the build agent after an in-flight plan turn loads implement-changes', async () => { - const { client, harness } = createHarness(new FakeOpenCodeServerClient(), { - commandEnv: { ROOMOTE_PLAN_MODE: 'true' }, - }); + const { client, harness } = createHarness(new FakeOpenCodeServerClient()); const runtimeOutputEvents: AcpMessage[] = []; harness.subscribeRuntimeOutput((event) => runtimeOutputEvents.push(event)); @@ -1388,76 +1256,6 @@ describe('OpenCodeServerHarness', () => { } }); - it('does not queue a plan-exit continuation when plan mode is disabled', async () => { - const { client, harness } = createHarness(new FakeOpenCodeServerClient()); - - try { - await connectHarness(harness, client); - - expect( - harness.sendCommand({ - commandName: TaskCommandName.StartNewTask, - data: { - text: 'Plan the rollout.', - visibleInTranscript: true, - source: 'web', - }, - }), - ).toBe(true); - - await vi.waitFor(() => { - expect(client.promptAsync).toHaveBeenCalledTimes(1); - }); - - for (const skillName of [ - 'plan-repo-implementation', - 'implement-changes', - ]) { - await client.emit({ - type: 'message.part.updated', - properties: { - part: { - id: `skill_part_${skillName}`, - sessionID: 'ses_1', - messageID: 'msg_skill', - type: 'tool', - callID: `call_${skillName}`, - tool: 'skill', - state: { - status: 'completed', - input: { name: skillName }, - title: 'Load skill', - }, - }, - }, - }); - } - - client.message.mockResolvedValueOnce(createFinalAssistantMessage()); - await client.emit({ - type: 'message.updated', - properties: { - info: { - id: 'msg_1', - sessionID: 'ses_1', - role: 'assistant', - time: { completed: 1 }, - }, - }, - }); - await client.emit({ - type: 'session.idle', - properties: { sessionID: 'ses_1' }, - }); - - // The turn ends without a queued continuation: only the original - // prompt was submitted. - expect(client.promptAsync).toHaveBeenCalledTimes(1); - } finally { - harness.dispose(); - } - }); - it('does not emit submitted user prompt parts as assistant chunks', async () => { const { client, harness } = createHarness(); const runtimeOutputEvents: AcpMessage[] = []; diff --git a/apps/worker/src/sandbox-server/lib/harnesses/opencode-server/harness.ts b/apps/worker/src/sandbox-server/lib/harnesses/opencode-server/harness.ts index 4b85aded5..a0dddecc4 100644 --- a/apps/worker/src/sandbox-server/lib/harnesses/opencode-server/harness.ts +++ b/apps/worker/src/sandbox-server/lib/harnesses/opencode-server/harness.ts @@ -3012,7 +3012,7 @@ export class OpenCodeServerHarness this.activeWorkflowSkill = transition.nextSkill; - if (transition.queueContinuation && this.isPlanModeEnabled()) { + if (transition.queueContinuation) { this.enqueuePlanExitContinuation(); } } @@ -3042,22 +3042,13 @@ export class OpenCodeServerHarness }); } - private isPlanModeEnabled(): boolean { - return ( - this.commandEnv?.ROOMOTE_PLAN_MODE === '1' || - this.commandEnv?.ROOMOTE_PLAN_MODE === 'true' - ); - } - /** * Prompts only switch onto Roomote's generated read-mostly `architect` - * agent when both the planning workflow skill is active and plan mode is - * enabled for the task runtime. Everything else uses the default `build` - * agent. + * agent while the planning workflow skill is active. Everything else uses + * the default `build` agent. */ private resolvePromptAgent(): string { - return this.isPlanModeEnabled() && - this.activeWorkflowSkill === PLAN_WORKFLOW_SKILL + return this.activeWorkflowSkill === PLAN_WORKFLOW_SKILL ? OPENCODE_ARCHITECT_AGENT : OPENCODE_BUILD_AGENT; } diff --git a/packages/feature-flags/src/__tests__/config.test.ts b/packages/feature-flags/src/__tests__/config.test.ts index b826259ed..b338a3b48 100644 --- a/packages/feature-flags/src/__tests__/config.test.ts +++ b/packages/feature-flags/src/__tests__/config.test.ts @@ -15,12 +15,6 @@ describe('FEATURE_FLAG_CONFIG', () => { expect(config.metadataKey).toBe('show_debug_ui_setting'); }); - it('PlanMode defaults to false', () => { - const config = FEATURE_FLAG_CONFIG[FeatureFlag.PlanMode]; - expect(config.defaultValue).toBe(false); - expect(config.metadataKey).toBe('plan_mode'); - }); - it('VisualProofAutoScreencast defaults to false', () => { const config = FEATURE_FLAG_CONFIG[FeatureFlag.VisualProofAutoScreencast]; expect(config.defaultValue).toBe(false); diff --git a/packages/feature-flags/src/config.ts b/packages/feature-flags/src/config.ts index 0e37db1e0..f47065c50 100644 --- a/packages/feature-flags/src/config.ts +++ b/packages/feature-flags/src/config.ts @@ -9,11 +9,6 @@ import { * Defines default values and optional overrides for all feature flags */ export const FEATURE_FLAG_CONFIG: FeatureFlagConfigMap = { - [FeatureFlag.PlanMode]: { - defaultValue: false, - metadataKey: 'plan_mode', - description: 'Enable plan mode, which keeps planning turns read-only', - }, [FeatureFlag.SlackEvalLauncher]: { defaultValue: false, metadataKey: 'slack_eval_launcher', diff --git a/packages/feature-flags/src/types.ts b/packages/feature-flags/src/types.ts index 4cbc35de0..390a206a1 100644 --- a/packages/feature-flags/src/types.ts +++ b/packages/feature-flags/src/types.ts @@ -3,7 +3,6 @@ */ export enum FeatureFlag { - PlanMode = 'PlanMode', SlackEvalLauncher = 'SlackEvalLauncher', ShowDebugUISetting = 'ShowDebugUISetting', SlackProofAutoPost = 'SlackProofAutoPost', @@ -87,7 +86,6 @@ export type FeatureFlagContext = }; export interface MetadataRecord { - plan_mode?: boolean; slack_eval_launcher?: boolean; show_debug_ui_setting?: boolean; show_debug_ui?: boolean;