From 9609be53bd1ce95f1d49f19428b8501178227b8d Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Wed, 20 May 2026 12:09:37 -0400 Subject: [PATCH 1/4] refactor: unify plan title and loop name extraction into single metadata function --- src/agents/architect.ts | 4 +-- src/hooks/plan-approval.ts | 7 ++-- src/index.ts | 1 + src/services/execution.ts | 8 ++--- src/tui.tsx | 4 +-- src/tui/execute-plan-panel.tsx | 5 +-- src/utils/plan-execution.ts | 64 +++++++++++++++++++--------------- src/utils/tui-client.ts | 13 ++----- src/version.ts | 2 +- test/plan-execution.test.ts | 26 ++++++++++++++ 10 files changed, 80 insertions(+), 54 deletions(-) diff --git a/src/agents/architect.ts b/src/agents/architect.ts index 55e537cfb1..9d3eaa9468 100644 --- a/src/agents/architect.ts +++ b/src/agents/architect.ts @@ -63,7 +63,7 @@ The plugin auto-captures marked plans from your assistant responses into SQL sto - Start with a short unmarked summary containing **Intention**, **Goal**, and **Approach**. Keep it brief: 1-3 sentences for intention/goal and 2-4 bullets for approach. - After the summary, wrap exactly one final plan with \`\` and \`\` markers (each on its own line) - Do NOT wrap only summaries, design options, or partial drafts - - The marked plan body must follow the existing detailed plan format: Objective, Loop Name, Phases with file targets/edits/acceptance criteria/verification, Decisions, Conventions, Key Context + - The marked plan body must follow the existing detailed plan format: Objective, a machine-readable \`Loop Name: short-slug\` line, Phases with file targets/edits/acceptance criteria/verification, Decisions, Conventions, Key Context - The marked plan must be extremely detailed and execution-ready: name exact files, exact symbols/functions/types to change, concrete data shapes, command wiring, expected control flow, error handling, and validation steps - Every phase must include explicit implementation instructions, precise edits per file, acceptance criteria, and targeted verification commands or assertions the code agent can run 4. **Approve** — After the marked plan is output and auto-captured, call the question tool to get explicit approval with these options: @@ -76,7 +76,7 @@ The plugin auto-captures marked plans from your assistant responses into SQL sto Present plans with: - **Objective**: What we're building and why -- **Loop Name**: A short, machine-friendly name (1-3 words) that captures the plan's main intent. This will be used for worktree/session naming. Example: "Loop Name: auth-refactor" or "Loop Name: api-validation" +- **Loop Name**: A short, machine-friendly name (1-3 words) that captures the plan's main intent. This will be used for worktree/session naming. Emit it as a plain machine-readable line, not a markdown heading or bullet: \`Loop Name: auth-refactor\` or \`Loop Name: api-validation\`. Place it near the top of the marked plan immediately after the objective. - **Phases**: Ordered implementation steps. Use exactly one \`\` marker per executable phase. Place it immediately before that phase's \`## Phase ...\` heading. Never place it before \`### Files\`, \`### Edits\`, \`### Acceptance Criteria\`, or \`### Verification\` — those are subsections inside the current phase. For every phase, specify the exact files affected, the precise code-level edits to make, sample change examples (such as function signature updates, new branches, or new exports), the existing symbols/modules being integrated with, concrete acceptance criteria, and phase-specific verification. Use \`### Files\`, \`### Edits\`, \`### Acceptance Criteria\`, and \`### Verification\` as subsections inside each phase. Shared blocks (\`## Decisions\`, \`## Conventions\`, \`## Key Context\`) go after all sections without a preceding marker. **Valid shape:** diff --git a/src/hooks/plan-approval.ts b/src/hooks/plan-approval.ts index 7f422385e8..1f800e7e20 100644 --- a/src/hooks/plan-approval.ts +++ b/src/hooks/plan-approval.ts @@ -1,7 +1,7 @@ import type { ToolContext } from '../tools/types' import type { Hooks } from '@opencode-ai/plugin' import { parseModelString, retryWithModelFallback } from '../utils/model-fallback' -import { extractPlanTitle, extractLoopNames, PLAN_EXECUTION_LABELS, type PlanExecutionLabel } from '../utils/plan-execution' +import { extractPlanExecutionMetadata, PLAN_EXECUTION_LABELS, type PlanExecutionLabel } from '../utils/plan-execution' import { buildStartLoopCommand, createForgeExecutionService, type ForgeExecutionRequestContext } from '../services/execution' import { captureLatestPlanForSession } from '../services/plan-capture' @@ -96,7 +96,7 @@ const processedApprovalCalls = new WeakMap>() const claimedApprovalPlans = new Set() export { LOOP_BLOCKED_TOOLS } -export { extractPlanTitle } +export { extractPlanTitle } from '../utils/plan-execution' function isActiveLoopToolSession(state: { active?: boolean; sessionId?: string }, sessionID: string): boolean { return state.active === true && state.sessionId === sessionID @@ -262,7 +262,7 @@ export function createToolExecuteAfterHook(ctx: ToolContext): Hooks['tool.execut return } const planText = plan.content - const title = extractPlanTitle(planText) + const { title, executionName } = extractPlanExecutionMetadata(planText) if (matchedLabel && !claimApprovalCall(ctx, input, matchedLabel, plan.key)) { markApprovalHandled(output, true) @@ -330,7 +330,6 @@ export function createToolExecuteAfterHook(ctx: ToolContext): Hooks['tool.execut } if (matchedLabel === 'Loop') { - const { executionName } = extractLoopNames(planText) const uniqueLoopName = loop.generateUniqueLoopName(executionName) logger.log(`Plan approval: "${matchedLabel}" — scheduling dispatch IIFE for loop "${uniqueLoopName}"`) diff --git a/src/index.ts b/src/index.ts index 71a8992cad..dea505621a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -491,6 +491,7 @@ READ-ONLY mode: no file edits, no destructive commands. Search and analyze only. When emitting the final plan: - Wrap the plan in \`\` and \`\` (each on its own line) +- Include one plain machine-readable \`Loop Name: short-slug\` line near the top of the marked plan, immediately after the objective. Do not emit loop name as a markdown heading or bullet. - Use exactly one \`\` marker per executable phase; place it immediately before that phase's \`## Phase\` heading - Do not insert \`\` before \`### Files\`, \`### Edits\`, \`### Acceptance Criteria\`, or \`### Verification\` - Shared \`## Decisions\` / \`## Conventions\` / \`## Key Context\` blocks go after all sections (no preceding marker) diff --git a/src/services/execution.ts b/src/services/execution.ts index a398d4355d..3f45d58173 100644 --- a/src/services/execution.ts +++ b/src/services/execution.ts @@ -11,7 +11,7 @@ import type { PlansRepo } from '../storage/repos/plans-repo' import type { LoopsRepo } from '../storage/repos/loops-repo' import type { createLoopEventHandler } from '../hooks' import type { SandboxManager } from '../sandbox/manager' -import { extractPlanTitle, extractLoopNames } from '../utils/plan-execution' +import { extractPlanExecutionMetadata } from '../utils/plan-execution' import { parseModelString, retryWithModelFallback } from '../utils/model-fallback' import { formatLoopSessionTitle, formatPlanSessionTitle } from '../utils/session-titles' @@ -1055,7 +1055,7 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo if (!planResult.ok) return { ok: false, error: planResult.error } const planText = planResult.planText - const title = command.title ?? extractPlanTitle(planText) + const title = command.title ?? extractPlanExecutionMetadata(planText).title const sessionTitle = formatPlanSessionTitle(title) const executionModel = command.executionModel ?? deps.config.executionModel const parsedModel = parseModelString(executionModel) @@ -1153,7 +1153,7 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo if (!planResult.ok) return { ok: false, error: planResult.error } const planText = planResult.planText - const title = command.title ?? extractPlanTitle(planText) + const title = command.title ?? extractPlanExecutionMetadata(planText).title const executionModel = command.executionModel ?? deps.config.executionModel const parsedModel = parseModelString(executionModel) @@ -1206,7 +1206,7 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo const planText = planResult.planText // Extract loop names first so the session title can prefer the explicit Loop Name - const { displayName, executionName } = extractLoopNames(planText) + const { displayName, executionName } = extractPlanExecutionMetadata(planText) const title = command.title ?? displayName const sessionTitle = formatLoopSessionTitle(title, { iteration: 1, currentSectionIndex: 0, totalSections: 0 }) diff --git a/src/tui.tsx b/src/tui.tsx index 323271f0c9..32c8a912a9 100644 --- a/src/tui.tsx +++ b/src/tui.tsx @@ -7,7 +7,7 @@ import { join } from 'path' import { VERSION } from './version' import { loadPluginConfig, setTuiAutoSavePlans } from './setup' import { slugify } from './utils/logger' -import { extractLoopName } from './utils/plan-execution' +import { extractPlanExecutionMetadata } from './utils/plan-execution' import type { ExecutionContextCache } from './utils/tui-execution-context-cache' import { createExecutionContextCache } from './utils/tui-execution-context-cache' import type { PluginConfig } from './types' @@ -111,7 +111,7 @@ function PlanViewerDialog(props: { const handleExport = () => { const planText = content() - const name = extractLoopName(planText) + const name = extractPlanExecutionMetadata(planText).executionName const slugifiedName = slugify(name) const directory = props.api.state.path.directory const filename = `${slugifiedName}.md` diff --git a/src/tui/execute-plan-panel.tsx b/src/tui/execute-plan-panel.tsx index 24ebb03481..c41167b986 100644 --- a/src/tui/execute-plan-panel.tsx +++ b/src/tui/execute-plan-panel.tsx @@ -2,7 +2,7 @@ import type { TuiPluginApi } from '@opencode-ai/plugin/tui' import { createEffect, createSignal, onCleanup, untrack } from 'solid-js' import { PLAN_EXECUTION_LABELS, type PlanExecutionLabel } from '../utils/plan-execution' -import { extractPlanTitle } from '../utils/plan-execution' +import { extractPlanExecutionMetadata } from '../utils/plan-execution' import { buildDialogSelectOptions, flattenProviders, getModelDisplayLabel, sortModelsByPriority, type ModelInfo } from '../utils/tui-models' import { resolveExecutionDialogDefaults } from '../utils/tui-execution-preferences' import { selectTuiSession, type ForgeProjectClient } from '../utils/tui-client' @@ -160,7 +160,7 @@ export function ExecutePlanPanel(props: { async function runExecuteMode(mode: string, execModel?: string, auditModel?: string): Promise { const planText = props.planContent - const title = extractPlanTitle(planText) + const { title, executionName } = extractPlanExecutionMetadata(planText) const normalizedMode = mode.toLowerCase() const matchedLabel = PLAN_EXECUTION_LABELS.find( @@ -178,6 +178,7 @@ export function ExecutePlanPanel(props: { const result = await props.client.plan.execute(props.sessionId, { mode: apiMode, title, + loopName: executionName, plan: planText, executionModel: execModel, auditorModel: auditModel, diff --git a/src/utils/plan-execution.ts b/src/utils/plan-execution.ts index 51fd83941c..87cafecd2a 100644 --- a/src/utils/plan-execution.ts +++ b/src/utils/plan-execution.ts @@ -28,6 +28,9 @@ const STRUCTURAL_PLAN_HEADINGS = new Set([ 'phase', 'plan', 'verification', + 'files', + 'edits', + 'acceptance criteria', 'decisions', 'conventions', 'key context', @@ -40,12 +43,10 @@ const STRUCTURAL_PLAN_HEADINGS = new Set([ * Truncates to 60 characters with ellipsis if needed. */ export function extractPlanTitle(planContent: string): string { - // Priority 1: Extract loop name if present (most meaningful title) - const loopNameFromHeading = extractLoopNameFromHeading(planContent) - if (loopNameFromHeading) { - return loopNameFromHeading.length > 60 ? `${loopNameFromHeading.substring(0, 57)}...` : loopNameFromHeading - } + return extractPlanExecutionMetadata(planContent).title +} +function extractFallbackPlanTitle(planContent: string): string { const headings: Array<{ text: string; line: number }> = [] const lines = planContent.split('\n') @@ -62,7 +63,7 @@ export function extractPlanTitle(planContent: string): string { if (STRUCTURAL_PLAN_HEADINGS.has(normalized)) continue if (isStructuralHeadingPrefix(normalized)) continue const title = heading.text - return title.length > 60 ? `${title.substring(0, 57)}...` : title + return truncateName(title, true) } // Try first sentence/line under Objective @@ -70,7 +71,7 @@ export function extractPlanTitle(planContent: string): string { if (objectiveMatch?.[1]) { const firstLine = objectiveMatch[1].trim().split('\n')[0] if (firstLine) { - return firstLine.length > 60 ? `${firstLine.substring(0, 57)}...` : firstLine + return truncateName(firstLine, true) } } @@ -78,7 +79,7 @@ export function extractPlanTitle(planContent: string): string { const firstLine = planContent.split('\n').find(line => line.trim() && !line.trim().startsWith('---')) if (firstLine) { const trimmed = firstLine.trim() - return trimmed.length > 60 ? `${trimmed.substring(0, 57)}...` : trimmed + return truncateName(trimmed, true) } return 'Implementation Plan' @@ -102,6 +103,10 @@ export interface LoopNameResult { executionName: string } +export interface PlanExecutionMetadata extends LoopNameResult { + title: string +} + /** * Extracts loop name from heading-style field. * Handles: @@ -113,19 +118,32 @@ function extractLoopNameFromHeading(planContent: string): string | null { const headingInlineMatch = planContent.match(/^#+\s*Loop Name:\s*(.+)$/im) if (headingInlineMatch?.[1]) { const name = headingInlineMatch[1].trim() - return name.length > 60 ? name.substring(0, 60) : name + return truncateName(name) } // Try heading followed by value on next line: ## Loop Name\n\nvalue const headingBlockMatch = planContent.match(/^#+\s*Loop Name\s*\n+\s*([^\n#]+)/im) if (headingBlockMatch?.[1]) { const name = headingBlockMatch[1].trim() - return name.length > 60 ? name.substring(0, 60) : name + return truncateName(name) } return null } +function extractExplicitLoopName(planContent: string): string | null { + const loopNameMatch = planContent.match(/^(?:\s*(?:-\s*)?)?(?:\*\*)?Loop Name(?:\*\*)?:\s*(.+)$/m) + if (loopNameMatch?.[1]) { + return truncateName(loopNameMatch[1].trim()) + } + return extractLoopNameFromHeading(planContent) +} + +function truncateName(name: string, ellipsis = false): string { + if (name.length <= 60) return name + return ellipsis ? `${name.substring(0, 57)}...` : name.substring(0, 60) +} + /** * Extracts a short loop name from plan content for worktree/session naming. * @@ -147,24 +165,7 @@ function extractLoopNameFromHeading(planContent: string): string | null { * The result is truncated to 60 characters. */ export function extractLoopName(planContent: string): string { - // Try inline loop name field first - // Accepts: "Loop Name: foo", "**Loop Name**: foo", "- **Loop Name**: foo" - // with optional leading whitespace and markdown list prefixes - const loopNameMatch = planContent.match(/^(?:\s*(?:-\s*)?)?(?:\*\*)?Loop Name(?:\*\*)?:\s*(.+)$/m) - if (loopNameMatch?.[1]) { - const name = loopNameMatch[1].trim() - return name.length > 60 ? name.substring(0, 60) : name - } - - // Try heading-style loop name - const headingLoopName = extractLoopNameFromHeading(planContent) - if (headingLoopName) { - return headingLoopName - } - - // Fallback to title extraction for older plans - const title = extractPlanTitle(planContent) - return title + return extractExplicitLoopName(planContent) ?? extractFallbackPlanTitle(planContent) } /** @@ -177,9 +178,14 @@ export function extractLoopName(planContent: string): string { * This is the preferred way to get loop naming information. */ export function extractLoopNames(planContent: string): LoopNameResult { + const { displayName, executionName } = extractPlanExecutionMetadata(planContent) + return { displayName, executionName } +} + +export function extractPlanExecutionMetadata(planContent: string): PlanExecutionMetadata { const displayName = extractLoopName(planContent) const executionName = sanitizeLoopName(displayName) - return { displayName, executionName } + return { title: truncateName(displayName, true), displayName, executionName } } /** diff --git a/src/utils/tui-client.ts b/src/utils/tui-client.ts index 6264a7f75a..38ffcd4807 100644 --- a/src/utils/tui-client.ts +++ b/src/utils/tui-client.ts @@ -14,6 +14,7 @@ import { getForgeWorkspaceLoopName, removeExistingForgeLoopWorkspaces } from '.. import { fetchLoopsList } from './tui-loop-store' import { decomposeDeterministically } from '../services/deterministic-decomposer' import { buildSectionInitialPromptText } from '../loop/prompts' +import { extractPlanExecutionMetadata } from './plan-execution' export type ApiExecutionMode = 'new-session' | 'execute-here' | 'loop' @@ -30,6 +31,7 @@ export interface ExecutionContext { export interface ExecutePlanRequest { mode: ApiExecutionMode title: string + loopName?: string plan: string executionModel?: string auditorModel?: string @@ -173,15 +175,6 @@ async function waitForWorkspacePluginSettle(workspaceId: string): Promise await new Promise((resolve) => setTimeout(resolve, settleMs)) } -function deriveLoopNameFromTitle(title: string): string { - return title - .toLowerCase() - .replace(/[^a-z0-9-]/g, '-') - .replace(/-+/g, '-') - .replace(/^-|-$/g, '') - .substring(0, 60) -} - function buildTuiLoopInitialPrompt(planText: string): string { const sections = decomposeDeterministically(planText, { maxSections: 12 }) const firstSection = sections[0] @@ -329,7 +322,7 @@ export async function connectForgeProject( } if (req.mode === 'loop') { - const loopName = await reserveTuiLoopName(api, projectId, deriveLoopNameFromTitle(req.title)) + const loopName = await reserveTuiLoopName(api, projectId, req.loopName ?? extractPlanExecutionMetadata(req.plan).executionName) tuiDebug(`plan.execute(loop): inline plan (planText.length=${req.plan.length}) hostSession=${sessionId ?? 'none'} loop=${loopName}`) const createdAt = Date.now() const forgeLoop: ForgeLoopExtra = { diff --git a/src/version.ts b/src/version.ts index 12408f750a..a7be5e3012 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = '0.4.4' +export const VERSION = '0.4.5' diff --git a/test/plan-execution.test.ts b/test/plan-execution.test.ts index 8561c91dcf..a4dd7ec87d 100644 --- a/test/plan-execution.test.ts +++ b/test/plan-execution.test.ts @@ -1,6 +1,7 @@ import { describe, test, expect } from 'vitest' import { extractPlanTitle, + extractPlanExecutionMetadata, extractLoopName, extractLoopNames, sanitizeLoopName, @@ -71,6 +72,31 @@ describe('Plan Execution Utilities', () => { const plan = '# Plan\n\n## Loop Name: auth-refactor\n\n## Phase 1: Setup\n\nContent' expect(extractPlanTitle(plan)).toBe('auth-refactor') }) + + test('Prioritizes inline Loop Name over phase subsections like Files', () => { + const plan = '# Objective\n\nAdd model variant selection.\n\nLoop Name: plan-variant-selection\n\n\n## Phase 1: Capture variants\n\n### Files\n\n- src/utils/tui-models.ts\n\n### Edits\n\n1. Extend metadata' + expect(extractPlanTitle(plan)).toBe('plan-variant-selection') + }) + }) + + describe('extractPlanExecutionMetadata', () => { + test('Returns one canonical metadata shape for execution methods', () => { + const plan = '# Objective\n\nAdd model variant selection.\n\nLoop Name: plan-variant-selection\n\n\n## Phase 1: Capture variants\n\n### Files\n\n- src/utils/tui-models.ts' + expect(extractPlanExecutionMetadata(plan)).toEqual({ + title: 'plan-variant-selection', + displayName: 'plan-variant-selection', + executionName: 'plan-variant-selection', + }) + }) + + test('Falls back to non-structural heading when no explicit loop name exists', () => { + const plan = '# Objective\n\nBuild the thing.\n\n## Real Feature Title\n\n### Files\n\n- src/a.ts' + expect(extractPlanExecutionMetadata(plan)).toEqual({ + title: 'Real Feature Title', + displayName: 'Real Feature Title', + executionName: 'real-feature-title', + }) + }) }) describe('extractLoopName', () => { From 6af7ea6ab073d71deb9fd36d036f60c60392371a Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Wed, 20 May 2026 17:48:38 -0400 Subject: [PATCH 2/4] loop: plan-variant-selection-2 removed after 0 iterations --- README.md | 2 +- docs/api/README.md | 14 + docs/architecture.md | 2 +- docs/modules.md | 2 +- src/hooks/forge-session-attach.ts | 6 +- src/loop/runtime.ts | 19 +- src/loop/service.ts | 4 + src/loop/state.ts | 6 + src/services/execution.ts | 30 +- .../131_add_loop_model_variants.sql | 2 + src/storage/migrations/index.ts | 9 + src/storage/repos/loops-repo.ts | 22 +- src/tui.tsx | 18 +- src/tui/execute-plan-panel.tsx | 125 +++++- src/utils/audit-session.ts | 2 + src/utils/tui-client.ts | 64 +-- src/utils/tui-execution-context-cache.ts | 2 + src/utils/tui-execution-preferences.ts | 11 +- src/utils/tui-models.ts | 73 ++++ test/loop/runtime.test.ts | 164 +++++++- test/services/attach-loop.test.ts | 106 +++-- test/storage-migrations.test.ts | 31 +- test/tui-execution-preferences.test.ts | 99 +++++ test/tui-models.test.ts | 382 ++++++++++++++++++ test/tui/execute-plan-panel-busy.test.ts | 4 +- test/utils/tui-client-variants.test.ts | 31 ++ 26 files changed, 1140 insertions(+), 90 deletions(-) create mode 100644 src/storage/migrations/131_add_loop_model_variants.sql create mode 100644 test/utils/tui-client-variants.test.ts diff --git a/README.md b/README.md index 032da50092..a6df235e64 100644 --- a/README.md +++ b/README.md @@ -432,7 +432,7 @@ After the architect presents a summary, the user chooses an execution mode from | `Execute here` | When preserving current context matters | | `Loop` | Safer autonomous iteration | -The dialog also lets you pick the execution model and auditor model at launch time. Those selections are remembered per project and pre-filled on later launches. +The dialog also lets you pick the execution model and auditor model at launch time. Those selections are remembered per project and pre-filled on later launches. Optional **variant selectors** accompany each model selector, letting you choose provider-specific reasoning or thinking-effort levels (e.g., `low`, `high`, `max`) when the model exposes them. Variant selections are also persisted per project. Execution is immediate — there are no additional LLM calls between approval and execution. The system intercepts the user's approval answer, reads the cached plan, and dispatches it programmatically to the code agent. The architect never processes the approval response. diff --git a/docs/api/README.md b/docs/api/README.md index 140d45ecff..fc5e4a142b 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -342,6 +342,16 @@ Two model selectors are available: - Same model selection interface - Defaults to last-used selection, falling back to `config.auditorModel` → `config.executionModel` +#### Variant Selection + +Each model selector has a companion **variant selector** that lets you choose a provider-specific reasoning or thinking-effort level. Variants are optional and provider/model-dependent — they appear only when the selected model exposes them via OpenCode model metadata. + +- Variants are provided by OpenCode model metadata. Examples include reasoning/thinking effort levels such as `low`, `high`, or `max` when exposed by the provider. +- **Use default** leaves OpenCode, the agent, and the model defaults in control. +- **Execution variants** affect code prompts for all launch modes (`New session`, `Execute here`, and `Loop`). +- **Auditor variants** affect loop audit and final-audit prompts. +- Variants are persisted per-project alongside model selections (same 30-day TTL). + #### Persistence Your selections are automatically saved in `tui_preferences` after launch: @@ -478,6 +488,10 @@ Model selection follows this priority order: 3. `config.executionModel` 4. Platform default +**For variants:** +1. Dialog selection persisted per project +2. OpenCode/agent/model default + ### Troubleshooting - **No plan found** — Ensure the architect output included the forge plan markers, or open the Plan Viewer for the current session. diff --git a/docs/architecture.md b/docs/architecture.md index 3825b2faa1..642910be4e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -48,7 +48,7 @@ The TUI plugin provides a sidebar widget that displays: - Active and recent loops - Plan viewer with inline editing (view/edit/execute/export tabs) -- Execution dialog with mode and model selection +- Execution dialog with mode, model, and variant selection - Loop details dialog with session statistics - Command palette integration (`Forge: Show loops`, `Forge: View plan`, `Forge: Execute plan`) - Model selection dialog with recent model tracking diff --git a/docs/modules.md b/docs/modules.md index ca651636ca..d8e175ac0a 100644 --- a/docs/modules.md +++ b/docs/modules.md @@ -54,7 +54,7 @@ The TUI plugin providing sidebar widget and dialog system. Communicates with the - Exports `{ id: 'oc-forge', tui }` - Registers commands: `forge.plan.view`, `forge.plan.load` -- Provides plan viewer, execution dialog, loop details, model selection +- Provides plan viewer, execution dialog, loop details, model and variant selection Source: [src/tui.tsx](../src/tui.tsx) diff --git a/src/hooks/forge-session-attach.ts b/src/hooks/forge-session-attach.ts index 30ab220f74..8307583d20 100644 --- a/src/hooks/forge-session-attach.ts +++ b/src/hooks/forge-session-attach.ts @@ -116,6 +116,8 @@ async function attachForgeSession( title?: string executionModel?: string auditorModel?: string + executionVariant?: string + auditorVariant?: string planSource?: 'stored' | 'inline' planText?: string initialPromptOwner?: 'server' | 'tui' @@ -270,6 +272,8 @@ async function attachForgeSession( hostSessionId: resolvedHostSessionId, executionModel: cfg.executionModel, auditorModel: cfg.auditorModel, + executionVariant: cfg.executionVariant, + auditorVariant: cfg.auditorVariant, maxIterations: cfg.maxIterations ?? 50, sandboxEnabled: cfg.sandboxEnabled ?? false, planText, @@ -278,7 +282,7 @@ async function attachForgeSession( startWatchdog: true, sendInitialPrompt, }, - ) + ) if (!result.ok && result.code === 'conflict') { const row = deps.execDeps.loopsRepo.get(sessionProjectId, loopName) const removalAction = row?.status === 'cancelled' || row?.status === 'errored' || row?.status === 'stalled' diff --git a/src/loop/runtime.ts b/src/loop/runtime.ts index 1a52c35c66..f2c46c5413 100644 --- a/src/loop/runtime.ts +++ b/src/loop/runtime.ts @@ -183,6 +183,7 @@ export function createLoop(deps: LoopRuntimeDeps): Loop { promptText: string agent: 'code' | 'auditor-loop' model?: { providerID: string; modelID: string } | null + variant?: string }): Promise<{ error?: unknown; usedModel?: { providerID: string; modelID: string } | undefined }> { const { loopName, sessionId, promptText, agent } = input @@ -200,7 +201,7 @@ export function createLoop(deps: LoopRuntimeDeps): Loop { worktreeDir: freshState.worktreeDir, workspaceId: freshState.workspaceId, prompt: promptText, - ...(model ? { auditorModel: model } : {}), + ...(model ? { auditorModel: model, ...(input.variant ? { auditorVariant: input.variant } : {}) } : {}), }) return result.ok ? { data: true } : { error: result.error } }) @@ -234,7 +235,7 @@ export function createLoop(deps: LoopRuntimeDeps): Loop { ...(freshState.workspaceId ? { workspace: freshState.workspaceId } : {}), agent: 'code', parts: [{ type: 'text' as const, text: promptText }], - ...(model ? { model } : {}), + ...(model ? { model, ...(input.variant ? { variant: input.variant } : {}) } : {}), }) }) } catch (err) { @@ -700,6 +701,7 @@ export function createLoop(deps: LoopRuntimeDeps): Loop { promptText: continuationPrompt, agent: 'code', model: loopModel, + variant: currentState.executionVariant, }) if (promptResultError) { @@ -762,6 +764,7 @@ export function createLoop(deps: LoopRuntimeDeps): Loop { promptText: continuationPrompt, agent: 'code', model: loopModel, + variant: state.executionVariant, }) if (error) { logger.error(`rotateToCodingAfterAuditFailure: failed to send continuation prompt`, error) @@ -804,6 +807,7 @@ export function createLoop(deps: LoopRuntimeDeps): Loop { promptText: recoveryPrompt, agent: 'code', model: resolveLoopModel(currentConfig, loopService, loopName), + variant: freshState.executionVariant, }) if (promptResultError) { clearPromptPending(loopName, logger) @@ -1057,6 +1061,7 @@ export function createLoop(deps: LoopRuntimeDeps): Loop { workspaceId?: string prompt: string auditorModel?: { providerID: string; modelID: string } + auditorVariant?: string }): Promise<{ ok: true } | { ok: false; error: unknown }> { const result = await promptAuditSession(v2Client, input) if (result.ok) return result @@ -1069,7 +1074,7 @@ export function createLoop(deps: LoopRuntimeDeps): Loop { body: { agent: 'auditor-loop', parts: [{ type: 'text' as const, text: input.prompt }], - ...(input.auditorModel ? { model: input.auditorModel } : {}), + ...(input.auditorModel ? { model: input.auditorModel, ...(input.auditorVariant ? { variant: input.auditorVariant } : {}) } : {}), }, }) if (legacyResult.error) return { ok: false, error: legacyResult.error } @@ -1149,6 +1154,7 @@ export function createLoop(deps: LoopRuntimeDeps): Loop { promptText: finalAuditPrompt, agent: 'auditor-loop', model: auditorModel, + variant: currentState.auditorVariant, }) if (finalAuditPromptErr) { @@ -1281,6 +1287,7 @@ export function createLoop(deps: LoopRuntimeDeps): Loop { sessionId: rotatedSessionId, promptText: continuationPrompt, agent: 'code', + variant: currentState.executionVariant, }) if (promptErr) { logger.error(`Loop: failed to send continuation prompt after audit creation failure`, promptErr) @@ -1315,6 +1322,7 @@ export function createLoop(deps: LoopRuntimeDeps): Loop { promptText: loopService.buildAuditPrompt(currentState), agent: 'auditor-loop', model: auditorModel, + variant: currentState.auditorVariant, }) if (auditPromptErr) { @@ -1327,6 +1335,8 @@ export function createLoop(deps: LoopRuntimeDeps): Loop { worktreeDir: currentState.worktreeDir, workspaceId: currentState.workspaceId, prompt: loopService.buildAuditPrompt(currentState), + auditorModel, + auditorVariant: currentState.auditorVariant, }) if (retryResult.ok) { logger.log(`Loop: recovered audit prompt after workspace re-bind for ${loopName}`) @@ -1345,6 +1355,8 @@ export function createLoop(deps: LoopRuntimeDeps): Loop { worktreeDir: fresh.worktreeDir, workspaceId: fresh.workspaceId, prompt: loopService.buildAuditPrompt(fresh), + auditorModel, + auditorVariant: fresh.auditorVariant, }) if (!retry.ok) throw retry.error }) @@ -1699,6 +1711,7 @@ export function createLoop(deps: LoopRuntimeDeps): Loop { sessionId: newCodeSessionId, promptText: continuationPrompt, agent: 'code', + variant: currentState.executionVariant, }) if (promptErr) { logger.error(`Loop: failed to send rewind continuation prompt for ${loopName}`, promptErr) diff --git a/src/loop/service.ts b/src/loop/service.ts index c38e8a7341..0bd04b06b9 100644 --- a/src/loop/service.ts +++ b/src/loop/service.ts @@ -111,6 +111,8 @@ export function rowToLoopState(row: LoopRow, large: LoopLargeFields | null): Loo currentSectionIndex: row.currentSectionIndex, totalSections: row.totalSections, finalAuditDone: row.finalAuditDone === 1, + executionVariant: row.executionVariant ?? undefined, + auditorVariant: row.auditorVariant ?? undefined, } } @@ -156,6 +158,8 @@ export function createLoopService( currentSectionIndex: state.currentSectionIndex, totalSections: state.totalSections, finalAuditDone: state.finalAuditDone ? 1 : 0, + executionVariant: state.executionVariant ?? null, + auditorVariant: state.auditorVariant ?? null, } } diff --git a/src/loop/state.ts b/src/loop/state.ts index fbb226b8c4..52d28c64f3 100644 --- a/src/loop/state.ts +++ b/src/loop/state.ts @@ -29,6 +29,8 @@ interface LoopStateBase { currentSectionIndex: number totalSections: number finalAuditDone: boolean + executionVariant?: string + auditorVariant?: string } export interface CodingState extends LoopStateBase { @@ -76,6 +78,8 @@ export function loopRowToState(row: LoopRow, large?: LoopLargeFields | null): Lo currentSectionIndex: row.currentSectionIndex, totalSections: row.totalSections, finalAuditDone: row.finalAuditDone === 1, + executionVariant: row.executionVariant ?? undefined, + auditorVariant: row.auditorVariant ?? undefined, } switch (row.phase) { @@ -117,5 +121,7 @@ export function loopStateToRow(state: LoopState, projectId: string): Omit { try { return await withInFlightGuard( @@ -1860,7 +1888,7 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo directory: stoppedState.worktreeDir, parts: [{ type: 'text' as const, text: promptText }], agent: promptAgent, - ...(model ? { model } : {}), + ...(model ? { model, ...(restartVariant ? { variant: restartVariant } : {}) } : {}), ...workspaceParam, }) }, diff --git a/src/storage/migrations/131_add_loop_model_variants.sql b/src/storage/migrations/131_add_loop_model_variants.sql new file mode 100644 index 0000000000..8d404221f7 --- /dev/null +++ b/src/storage/migrations/131_add_loop_model_variants.sql @@ -0,0 +1,2 @@ +ALTER TABLE loops ADD COLUMN execution_variant TEXT; +ALTER TABLE loops ADD COLUMN auditor_variant TEXT; diff --git a/src/storage/migrations/index.ts b/src/storage/migrations/index.ts index 83672a22bf..a4f788e623 100644 --- a/src/storage/migrations/index.ts +++ b/src/storage/migrations/index.ts @@ -289,5 +289,14 @@ export const migrations: Migration[] = [ db.run(loadSql('130_create_loop_session_usage.sql')) }, }, + { + id: '131', + description: 'Add execution and auditor model variant columns to loops table', + apply: (db: Database) => { + const cols = db.prepare('PRAGMA table_info(loops)').all() as Array<{ name: string }> + if (cols.some((c) => c.name === 'execution_variant')) return + db.run(loadSql('131_add_loop_model_variants.sql')) + }, + }, ] diff --git a/src/storage/repos/loops-repo.ts b/src/storage/repos/loops-repo.ts index 3b7d40f62d..f89e07b6ee 100644 --- a/src/storage/repos/loops-repo.ts +++ b/src/storage/repos/loops-repo.ts @@ -29,6 +29,8 @@ export interface LoopRow { currentSectionIndex: number totalSections: number finalAuditDone: number + executionVariant: string | null + auditorVariant: string | null } export interface LoopLargeFields { @@ -123,6 +125,8 @@ function mapRow(row: LoopRowRaw): LoopRow { currentSectionIndex: row.current_section_index, totalSections: row.total_sections, finalAuditDone: row.final_audit_done, + executionVariant: row.execution_variant, + auditorVariant: row.auditor_variant, } } @@ -154,6 +158,8 @@ interface LoopRowRaw { current_section_index: number total_sections: number final_audit_done: number + execution_variant: string | null + auditor_variant: string | null } export function createLoopsRepo(db: Database): LoopsRepo { @@ -164,8 +170,9 @@ export function createLoopsRepo(db: Database): LoopsRepo { error_count, phase, execution_model, auditor_model, model_failed, sandbox, sandbox_container, started_at, completed_at, termination_reason, completion_summary, workspace_id, host_session_id, - current_section_index, total_sections, final_audit_done - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + current_section_index, total_sections, final_audit_done, + execution_variant, auditor_variant + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `) const upsertLargeStmt = db.prepare(` @@ -181,7 +188,8 @@ export function createLoopsRepo(db: Database): LoopsRepo { error_count, phase, execution_model, auditor_model, model_failed, sandbox, sandbox_container, started_at, completed_at, termination_reason, completion_summary, workspace_id, host_session_id, - current_section_index, total_sections, final_audit_done + current_section_index, total_sections, final_audit_done, + execution_variant, auditor_variant FROM loops WHERE project_id = ? AND loop_name = ? `) @@ -198,7 +206,8 @@ export function createLoopsRepo(db: Database): LoopsRepo { error_count, phase, execution_model, auditor_model, model_failed, sandbox, sandbox_container, started_at, completed_at, termination_reason, completion_summary, workspace_id, host_session_id, - current_section_index, total_sections, final_audit_done + current_section_index, total_sections, final_audit_done, + execution_variant, auditor_variant FROM loops WHERE project_id = ? AND current_session_id = ? `) @@ -209,7 +218,8 @@ export function createLoopsRepo(db: Database): LoopsRepo { error_count, phase, execution_model, auditor_model, model_failed, sandbox, sandbox_container, started_at, completed_at, termination_reason, completion_summary, workspace_id, host_session_id, - current_section_index, total_sections, final_audit_done + current_section_index, total_sections, final_audit_done, + execution_variant, auditor_variant FROM loops WHERE project_id = ? AND status IN ` @@ -358,6 +368,8 @@ export function createLoopsRepo(db: Database): LoopsRepo { row.currentSectionIndex ?? 0, row.totalSections ?? 0, row.finalAuditDone ?? 0, + row.executionVariant ?? null, + row.auditorVariant ?? null, ) as unknown as { changes: number } if (result.changes === 0) { return false diff --git a/src/tui.tsx b/src/tui.tsx index 32c8a912a9..0c9676e041 100644 --- a/src/tui.tsx +++ b/src/tui.tsx @@ -84,13 +84,17 @@ function PlanViewerDialog(props: { startInExecuteMode?: boolean initialExecutionModel?: string initialAuditorModel?: string + initialExecutionVariant?: string + initialAuditorVariant?: string }) { const theme = () => props.api.theme.current const [editing, setEditing] = createSignal(false) const startInExecuteModeValue = () => !!props.startInExecuteMode const planContentValue = () => props.planContent - const initialExecutionModelValue = () => props.initialExecutionModel ?? '' - const initialAuditorModelValue = () => props.initialAuditorModel ?? '' + const initialExecutionModelValue = () => props.initialExecutionModel + const initialAuditorModelValue = () => props.initialAuditorModel + const initialExecutionVariantValue = () => props.initialExecutionVariant + const initialAuditorVariantValue = () => props.initialAuditorVariant const [executing, setExecuting] = createSignal(startInExecuteModeValue()) const [content, setContent] = createSignal(planContentValue()) let textareaRef: TextareaRenderable | undefined @@ -206,9 +210,11 @@ function PlanViewerDialog(props: { sessionId={props.sessionId} initialExecutionModel={initialExecutionModelValue()} initialAuditorModel={initialAuditorModelValue()} + initialExecutionVariant={initialExecutionVariantValue()} + initialAuditorVariant={initialAuditorVariantValue()} onBack={() => setExecuting(false)} onExecuted={props.onRefresh} - onModelSelected={({ target, selectedModel, executionModel, auditorModel }) => { + onSelectionChanged={({ executionModel, auditorModel, executionVariant, auditorVariant }) => { props.api.ui.dialog.setSize('xlarge') props.api.ui.dialog.replace(() => ( )) }} diff --git a/src/tui/execute-plan-panel.tsx b/src/tui/execute-plan-panel.tsx index c41167b986..ada6404729 100644 --- a/src/tui/execute-plan-panel.tsx +++ b/src/tui/execute-plan-panel.tsx @@ -3,7 +3,7 @@ import type { TuiPluginApi } from '@opencode-ai/plugin/tui' import { createEffect, createSignal, onCleanup, untrack } from 'solid-js' import { PLAN_EXECUTION_LABELS, type PlanExecutionLabel } from '../utils/plan-execution' import { extractPlanExecutionMetadata } from '../utils/plan-execution' -import { buildDialogSelectOptions, flattenProviders, getModelDisplayLabel, sortModelsByPriority, type ModelInfo } from '../utils/tui-models' +import { buildDialogSelectOptions, flattenProviders, getModelDisplayLabel, sortModelsByPriority, getAvailableModelVariants, getVariantDisplayLabel, normalizeVariantForModel, type ModelInfo } from '../utils/tui-models' import { resolveExecutionDialogDefaults } from '../utils/tui-execution-preferences' import { selectTuiSession, type ForgeProjectClient } from '../utils/tui-client' import type { ExecutionContextCache, ExecutionContextSnapshot } from '../utils/tui-execution-context-cache' @@ -19,13 +19,15 @@ export function ExecutePlanPanel(props: { sessionId: string initialExecutionModel?: string initialAuditorModel?: string + initialExecutionVariant?: string + initialAuditorVariant?: string onBack: () => void onExecuted?: () => void | Promise - onModelSelected: (args: { - target: 'execution' | 'auditor' - selectedModel: string + onSelectionChanged: (args: { executionModel: string auditorModel: string + executionVariant: string + auditorVariant: string }) => void }) { const cache = untrack(() => props.cache) @@ -46,19 +48,38 @@ export function ExecutePlanPanel(props: { const [auditorModel, setAuditorModel] = createSignal( props.initialAuditorModel ?? initialDefaults.auditorModel, ) + const [executionVariant, setExecutionVariant] = createSignal( + props.initialExecutionVariant ?? initialDefaults.executionVariant, + ) + const [auditorVariant, setAuditorVariant] = createSignal( + props.initialAuditorVariant ?? initialDefaults.auditorVariant, + ) const [models, setModels] = createSignal(initialSnapshot?.models ?? []) const [recents, setRecents] = createSignal(initialSnapshot?.recents ?? []) const [modelsError, setModelsError] = createSignal(initialSnapshot?.modelsError) const [modelsLoaded, setModelsLoaded] = createSignal(!!initialSnapshot) const [busy, setBusy] = createSignal(false) - const applyDefaults = (defaults: { executionModel: string; auditorModel: string }) => { + const selectedModelInfo = (target: 'execution' | 'auditor') => { + const selected = target === 'execution' ? executionModel() : auditorModel() + const fallback = openCodeDefaultModel() + const fullName = selected || fallback + return models().find(m => m.fullName === fullName) ?? null + } + + const applyDefaults = (defaults: { executionModel: string; auditorModel: string; executionVariant?: string; auditorVariant?: string }) => { if (!hasInitialOverrides() && !props.initialExecutionModel && !executionModel()) { setExecutionModel(defaults.executionModel) } if (!hasInitialOverrides() && !props.initialAuditorModel && !auditorModel()) { setAuditorModel(defaults.auditorModel) } + if (props.initialExecutionVariant === undefined && !executionVariant()) { + setExecutionVariant(defaults.executionVariant ?? '') + } + if (props.initialAuditorVariant === undefined && !auditorVariant()) { + setAuditorVariant(defaults.auditorVariant ?? '') + } } const applySnapshot = (snap: ExecutionContextSnapshot) => { @@ -67,6 +88,9 @@ export function ExecutePlanPanel(props: { setRecents(snap.recents) setModelsError(snap.modelsError) setModelsLoaded(true) + // Normalize variants against loaded models + setExecutionVariant(normalizeVariantForModel(executionVariant(), selectedModelInfo('execution'))) + setAuditorVariant(normalizeVariantForModel(auditorVariant(), selectedModelInfo('auditor'))) } const loadInline = async () => { @@ -90,6 +114,9 @@ export function ExecutePlanPanel(props: { setModels(sorted) applyDefaults(inlineDefaults) setModelsLoaded(true) + // Normalize variants against loaded models + setExecutionVariant(normalizeVariantForModel(executionVariant(), selectedModelInfo('execution'))) + setAuditorVariant(normalizeVariantForModel(auditorVariant(), selectedModelInfo('auditor'))) } catch (err) { setModelsError(err instanceof Error ? err.message : 'Failed to load models') setModelsLoaded(true) @@ -133,12 +160,68 @@ export function ExecutePlanPanel(props: { current={currentValue || ''} onSelect={(opt) => { const selectedModel = typeof opt.value === 'string' ? opt.value : '' + // Resolve the effective model for variant normalization: + // if selected model is empty (use default), resolve against OpenCode default + const effectiveModelName = selectedModel || openCodeDefaultModel() + const effectiveModelInfo = models().find(m => m.fullName === effectiveModelName) ?? null + // Normalize variant against newly selected model + const normalizedVariant = normalizeVariantForModel( + target === 'execution' ? executionVariant() : auditorVariant(), + effectiveModelInfo, + ) + props.api.ui.dialog.setSize('xlarge') + props.onSelectionChanged({ + executionModel: target === 'execution' ? selectedModel : executionModel(), + auditorModel: target === 'auditor' ? selectedModel : auditorModel(), + executionVariant: target === 'execution' ? normalizedVariant : executionVariant(), + auditorVariant: target === 'auditor' ? normalizedVariant : auditorVariant(), + }) + }} + /> + )) + } + + const openVariantDialog = (target: 'execution' | 'auditor') => { + if (!modelsLoaded()) return + + const model = selectedModelInfo(target) + if (!model) { + props.api.ui.toast({ message: 'No variants available for this model', variant: 'info', duration: 3000 }) + return + } + + const availableVariants = getAvailableModelVariants(model) + if (availableVariants.length === 0) { + props.api.ui.toast({ message: 'No variants available for this model', variant: 'info', duration: 3000 }) + return + } + + const currentValue = target === 'execution' ? executionVariant() : auditorVariant() + const options = [ + { title: 'Use default', value: '', description: 'Use OpenCode/model default variant' }, + ...availableVariants.map(v => ({ + title: v.label, + value: v.id, + description: v.description, + })), + ] + + const title = target === 'execution' ? 'Execution Variant' : 'Auditor Variant' + + props.api.ui.dialog.setSize('large') + props.api.ui.dialog.replace(() => ( + { + const selectedVariant = typeof opt.value === 'string' ? opt.value : '' props.api.ui.dialog.setSize('xlarge') - props.onModelSelected({ - target, - selectedModel, + props.onSelectionChanged({ executionModel: executionModel(), auditorModel: auditorModel(), + executionVariant: target === 'execution' ? selectedVariant : executionVariant(), + auditorVariant: target === 'auditor' ? selectedVariant : auditorVariant(), }) }} /> @@ -158,7 +241,7 @@ export function ExecutePlanPanel(props: { } } - async function runExecuteMode(mode: string, execModel?: string, auditModel?: string): Promise { + async function runExecuteMode(mode: string, execModel?: string, auditModel?: string, execVariant?: string, auditVariant?: string): Promise { const planText = props.planContent const { title, executionName } = extractPlanExecutionMetadata(planText) @@ -182,11 +265,15 @@ export function ExecutePlanPanel(props: { plan: planText, executionModel: execModel, auditorModel: auditModel, + executionVariant: execVariant, + auditorVariant: auditVariant, targetSessionId: props.sessionId, }, { mode: matchedLabel as PlanExecutionLabel, executionModel: execModel, auditorModel: auditModel, + executionVariant: execVariant, + auditorVariant: auditVariant, }) if (!result) { @@ -226,11 +313,21 @@ export function ExecutePlanPanel(props: { description: 'Press enter to change', value: 'model:execution', }, + { + name: `Execution variant: ${getVariantDisplayLabel(executionVariant(), selectedModelInfo('execution'))}`, + description: 'Press enter to change', + value: 'variant:execution', + }, { name: `Auditor model: ${getModelDisplayLabel(auditorModel(), models(), openCodeDefaultModel())}`, description: 'Press enter to change', value: 'model:auditor', }, + { + name: `Auditor variant: ${getVariantDisplayLabel(auditorVariant(), selectedModelInfo('auditor'))}`, + description: 'Press enter to change', + value: 'variant:auditor', + }, ...PLAN_EXECUTION_LABELS.map(label => ({ name: label, description: getModeDescription(label), @@ -247,8 +344,16 @@ export function ExecutePlanPanel(props: { openModelDialog('auditor') return } + if (option.value === 'variant:execution') { + openVariantDialog('execution') + return + } + if (option.value === 'variant:auditor') { + openVariantDialog('auditor') + return + } if (typeof option.value === 'string' && option.value.startsWith('mode:')) { - handleExecuteMode(option.value.slice(5), executionModel(), auditorModel()) + handleExecuteMode(option.value.slice(5), executionModel(), auditorModel(), executionVariant(), auditorVariant()) } } }} diff --git a/src/utils/audit-session.ts b/src/utils/audit-session.ts index fea2114c04..db335353fa 100644 --- a/src/utils/audit-session.ts +++ b/src/utils/audit-session.ts @@ -60,6 +60,7 @@ export async function promptAuditSession( workspaceId?: string prompt: string auditorModel?: { providerID: string; modelID: string } + auditorVariant?: string }, ): Promise<{ ok: true } | { ok: false; error: unknown }> { const parts = [{ type: 'text' as const, text: input.prompt }] @@ -70,6 +71,7 @@ export async function promptAuditSession( agent: 'auditor-loop', parts, ...(input.auditorModel ? { model: input.auditorModel } : {}), + ...(input.auditorVariant ? { variant: input.auditorVariant } : {}), }) if (result.error) return { ok: false, error: result.error } return { ok: true } diff --git a/src/utils/tui-client.ts b/src/utils/tui-client.ts index 38ffcd4807..44311f7431 100644 --- a/src/utils/tui-client.ts +++ b/src/utils/tui-client.ts @@ -18,6 +18,20 @@ import { extractPlanExecutionMetadata } from './plan-execution' export type ApiExecutionMode = 'new-session' | 'execute-here' | 'loop' +/** + * Builds a consistent model+variant payload for promptAsync calls. + * Centralizes the spreading logic so each call site doesn't reinvent it. + */ +export function buildPromptModelSelection( + model: { providerID: string; modelID: string } | undefined, + variant?: string, +): { model?: { providerID: string; modelID: string }; variant?: string } { + return { + ...(model ? { model } : {}), + ...(variant ? { variant } : {}), + } +} + export interface ExecutionContext { preferences: ExecutionPreferences | null models: { @@ -35,6 +49,8 @@ export interface ExecutePlanRequest { plan: string executionModel?: string auditorModel?: string + executionVariant?: string + auditorVariant?: string targetSessionId?: string } @@ -272,20 +288,14 @@ export async function connectForgeProject( if (req.mode === 'execute-here') { const prompt = `The architect agent has created an implementation plan in this conversation above. You are now the code agent taking over this session. Your job is to execute the plan — edit files, run commands, create tests, and implement every phase. Do NOT just describe or summarize the changes. Actually make them.\n\nPlan reference: ${req.plan}` - const result = parsedModel - ? await api.client.session.promptAsync({ - sessionID: req.targetSessionId ?? sessionId, - directory, - agent: 'code', - model: parsedModel, - parts: [{ type: 'text' as const, text: prompt }], - }) - : await api.client.session.promptAsync({ - sessionID: req.targetSessionId ?? sessionId, - directory, - agent: 'code', - parts: [{ type: 'text' as const, text: prompt }], - }) + const modelVariant = buildPromptModelSelection(parsedModel, req.executionVariant) + const result = await api.client.session.promptAsync({ + sessionID: req.targetSessionId ?? sessionId, + directory, + agent: 'code', + ...modelVariant, + parts: [{ type: 'text' as const, text: prompt }], + }) if (result.error) return null if (projectId) writeExecutionPreferences(projectId, prefs) @@ -301,20 +311,14 @@ export async function connectForgeProject( if (createResult.error || !createResult.data) return null const newSessionId = createResult.data.id - const result = parsedModel - ? await api.client.session.promptAsync({ - sessionID: newSessionId, - directory, - agent: 'code', - model: parsedModel, - parts: [{ type: 'text' as const, text: req.plan }], - }) - : await api.client.session.promptAsync({ - sessionID: newSessionId, - directory, - agent: 'code', - parts: [{ type: 'text' as const, text: req.plan }], - }) + const modelVariant = buildPromptModelSelection(parsedModel, req.executionVariant) + const result = await api.client.session.promptAsync({ + sessionID: newSessionId, + directory, + agent: 'code', + ...modelVariant, + parts: [{ type: 'text' as const, text: req.plan }], + }) if (result.error) return null if (projectId) writeExecutionPreferences(projectId, prefs) @@ -330,6 +334,8 @@ export async function connectForgeProject( title: req.title, executionModel: req.executionModel, auditorModel: req.auditorModel, + executionVariant: req.executionVariant, + auditorVariant: req.auditorVariant, planSource: 'inline', planText: req.plan, initialPromptOwner: 'tui', @@ -373,7 +379,7 @@ export async function connectForgeProject( workspace: workspace.id, agent: 'code', parts: [{ type: 'text' as const, text: promptText }], - ...(parsedModel ? { model: parsedModel } : {}), + ...buildPromptModelSelection(parsedModel, req.executionVariant), } const promptResult = await api.client.session.promptAsync(promptInput) if (promptResult.error) { diff --git a/src/utils/tui-execution-context-cache.ts b/src/utils/tui-execution-context-cache.ts index e976525d32..78014cb509 100644 --- a/src/utils/tui-execution-context-cache.ts +++ b/src/utils/tui-execution-context-cache.ts @@ -24,6 +24,8 @@ export interface ExecutionContextSnapshot { executionModel: string auditorModel: string mode: string + executionVariant: string + auditorVariant: string } } diff --git a/src/utils/tui-execution-preferences.ts b/src/utils/tui-execution-preferences.ts index e22230e33f..0a3161bb4d 100644 --- a/src/utils/tui-execution-preferences.ts +++ b/src/utils/tui-execution-preferences.ts @@ -15,6 +15,8 @@ export interface ExecutionPreferences { mode: 'New session' | 'Execute here' | 'Loop' executionModel?: string auditorModel?: string + executionVariant?: string + auditorVariant?: string } const PREFERENCES_KEY = 'tui:plan-execution-preferences' @@ -59,6 +61,8 @@ export function readExecutionPreferences(projectId: string, dbPathOverride?: str mode: normalizeMode(stored.mode ?? 'Loop'), executionModel: stored.executionModel, auditorModel: stored.auditorModel, + executionVariant: stored.executionVariant, + auditorVariant: stored.auditorVariant, } } catch { return null @@ -119,7 +123,7 @@ export function writeExecutionPreferences( export function resolveExecutionDialogDefaults( config: PluginConfig, storedPrefs: ExecutionPreferences | null -): { mode: string; executionModel: string; auditorModel: string } { +): { mode: string; executionModel: string; auditorModel: string; executionVariant: string; auditorVariant: string } { const mode = normalizeMode(storedPrefs?.mode ?? 'Loop') const executionModel = storedPrefs?.executionModel ?? config.executionModel @@ -130,6 +134,9 @@ export function resolveExecutionDialogDefaults( ?? storedPrefs?.executionModel ?? config.executionModel ?? '' + + const executionVariant = storedPrefs?.executionVariant ?? '' + const auditorVariant = storedPrefs?.auditorVariant ?? '' - return { mode, executionModel, auditorModel } + return { mode, executionModel, auditorModel, executionVariant, auditorVariant } } diff --git a/src/utils/tui-models.ts b/src/utils/tui-models.ts index 2c950bbe4c..7856dbac3f 100644 --- a/src/utils/tui-models.ts +++ b/src/utils/tui-models.ts @@ -30,6 +30,7 @@ export interface ModelInfo { input?: number output?: number } + variants?: Record } export interface ProviderInfo { @@ -187,6 +188,7 @@ export async function fetchAvailableModels(api: TuiPluginApi): Promise | undefined)?.attachment as boolean | undefined, }, cost: md.cost as { input?: number; output?: number } | undefined, + variants: md.variants as ModelInfo['variants'], }) } } @@ -380,3 +382,74 @@ export function sortModelsByPriority( return a.name.localeCompare(b.name) }) } + +export interface ModelVariantInfo { + id: string + label: string + description?: string +} + +/** + * Converts a raw variant key into a human-readable label. + * E.g., "thinking-max" → "Thinking Max", "reasoning_high" → "Reasoning High" + */ +function variantKeyToLabel(key: string): string { + return key + .replace(/[-_]/g, ' ') + .replace(/\b\w/g, c => c.toUpperCase()) +} + +/** + * Generates a description from variant config when applicable. + * Returns undefined when no relevant field is present. + */ +function variantDescription(config: Record): string | undefined { + if (typeof config.description === 'string') return config.description + + const reasoning = config.reasoningEffort ?? config.reasoning_effort + if (reasoning != null) return `Reasoning: ${reasoning}` + + const thinking = config.thinking + if (thinking != null) return `Thinking: ${thinking}` + + const budget = config.thinkingBudget ?? config.thinking_budget + if (budget != null) return `Thinking budget: ${budget}` + + return undefined +} + +export function getAvailableModelVariants(model?: ModelInfo | null): ModelVariantInfo[] { + if (!model?.variants) return [] + + return Object.entries(model.variants) + .filter(([, cfg]) => cfg && !cfg.disabled) + .map(([key, cfg]) => ({ + id: key, + label: typeof cfg.name === 'string' ? cfg.name : variantKeyToLabel(key), + description: variantDescription(cfg as Record), + })) +} + +export function getVariantDisplayLabel( + variant: string | undefined, + model?: ModelInfo | null, +): string { + if (!variant) return 'default' + + const available = getAvailableModelVariants(model) + const found = available.find(v => v.id === variant) + if (found) return found.label + + return variant +} + +export function normalizeVariantForModel( + variant: string | undefined, + model?: ModelInfo | null, +): string { + if (!variant) return '' + if (!model?.variants) return '' + + const available = getAvailableModelVariants(model) + return available.some(v => v.id === variant) ? variant : '' +} diff --git a/test/loop/runtime.test.ts b/test/loop/runtime.test.ts index 3f7081d400..d1fbcc5a24 100644 --- a/test/loop/runtime.test.ts +++ b/test/loop/runtime.test.ts @@ -40,7 +40,8 @@ interface MockClientState { selectCalls: Array<{ sessionID: string; workspace?: string }> deleteThrows: boolean abortCalls: string[] - promptCalls: Array<{ sessionID: string; agent?: string }> + promptCalls: Array<{ sessionID: string; agent?: string; variant?: string }> + promptAsyncFailCount?: number messagesResult: Array<{ info: { role: string; finish?: string }; parts: Array<{ type: string; text?: string }> }> | null messagesBySession?: Map }>> } @@ -53,7 +54,11 @@ function createMockV2Client(state: MockClientState): OpencodeClient { return { error: null, data: { id: 'sess' } } }, promptAsync: async (params) => { - state.promptCalls.push({ sessionID: (params as any).sessionID ?? '', agent: (params as any).agent }) + state.promptCalls.push({ sessionID: (params as any).sessionID ?? '', agent: (params as any).agent, variant: (params as any).variant }) + if (state.promptAsyncFailCount && state.promptAsyncFailCount > 0) { + state.promptAsyncFailCount-- + return { error: { name: 'TestError', data: { message: 'simulated model failure' } }, data: null } + } return { error: null, data: null } }, status: async () => ({ error: null, data: {} }), @@ -145,6 +150,8 @@ CREATE TABLE loops ( total_sections INTEGER NOT NULL DEFAULT 0, final_audit_done INTEGER NOT NULL DEFAULT 0, final_audit_attempts INTEGER NOT NULL DEFAULT 0, + execution_variant TEXT, + auditor_variant TEXT, PRIMARY KEY (project_id, loop_name) ) ` @@ -300,6 +307,8 @@ describe('Loop Runtime', () => { sandbox: false, executionModel: 'test/model', auditorModel: 'test/auditor', + executionVariant: undefined, + auditorVariant: undefined, currentSectionIndex: 0, totalSections: 0, finalAuditDone: false, @@ -1163,4 +1172,155 @@ describe('stall handling terminates with stall timeout when configured cap is re expect(usage!.byModel['state/exec-model'].inputTokens).toBe(150) }) }) + + describe('variant dispatch', () => { + test('coding prompt sends executionVariant from loop state', async () => { + const { loop, clientState } = createRuntime() + clientState.messagesResult = [ + { + info: { role: 'assistant', finish: 'stop' }, + parts: [{ type: 'text', text: 'Audit passed.' }], + }, + ] + + const state = makeState({ + phase: 'auditing', + totalSections: 0, + auditCount: 1, + executionVariant: 'thinking-max', + auditorVariant: 'audit-high', + }) + loopService.setState(state.loopName, state) + + // Add a bug finding so the audit is dirty and transitions back to coding + reviewFindingsRepo.write({ + projectId: PROJECT_ID, + loopName: state.loopName, + file: 'src/test.ts', + line: 1, + severity: 'bug', + description: 'Test bug', + }) + + await loop.tick({ + type: 'session.status', + properties: { + status: { type: 'idle' }, + sessionID: state.sessionId, + }, + }) + + // After auditing phase processes dirty audit, it transitions to coding and sends code prompts + const codePrompts = clientState.promptCalls.filter(c => c.agent === 'code') + expect(codePrompts.length).toBeGreaterThan(0) + for (const call of codePrompts) { + expect(call.variant).toBe('thinking-max') + } + }) + + test('auditor prompt sends auditorVariant from loop state', async () => { + const { loop, clientState } = createRuntime() + clientState.messagesResult = [ + { + info: { role: 'assistant', finish: 'stop' }, + parts: [{ type: 'text', text: 'Audit passed.' }], + }, + ] + + const state = makeState({ + phase: 'coding', + totalSections: 0, + auditCount: 0, + executionVariant: 'thinking-max', + auditorVariant: 'audit-high', + }) + loopService.setState(state.loopName, state) + + await loop.tick({ + type: 'session.status', + properties: { + status: { type: 'idle' }, + sessionID: state.sessionId, + }, + }) + + // The auditor prompt should have the auditorVariant + const auditorPrompts = clientState.promptCalls.filter(c => c.agent === 'auditor-loop') + expect(auditorPrompts.length).toBeGreaterThan(0) + for (const call of auditorPrompts) { + expect(call.variant).toBe('audit-high') + } + }) + + test('model fallback omits variant when model is undefined', async () => { + const clientState: MockClientState = { + deleteCalls: [], + createCalls: [], + publishCalls: [], + selectCalls: [], + deleteThrows: false, + abortCalls: [], + promptCalls: [], + promptAsyncFailCount: 2, + messagesResult: [ + { + info: { role: 'assistant', finish: 'stop' }, + parts: [{ type: 'text', text: 'Audit passed.' }], + }, + ], + } + + const v2Client = createMockV2Client(clientState) + const { logger } = createCapturingLogger() + const config: PluginConfig = { ...mockConfig, executionModel: 'test/model' } + + const loop = createLoop({ + loopsRepo, + plansRepo, + reviewFindingsRepo, + sectionPlansRepo, + projectId: PROJECT_ID, + client: { client: {} as any } as any, + v2Client, + logger, + getConfig: () => config, + sandboxManager: undefined, + dataDir: tempDir, + }) + + const state = makeState({ + phase: 'auditing', + totalSections: 0, + auditCount: 1, + executionModel: 'test/model', + executionVariant: 'thinking-max', + }) + loopService.setState(state.loopName, state) + + // Add a bug finding so the audit is dirty and transitions back to coding + reviewFindingsRepo.write({ + projectId: PROJECT_ID, + loopName: state.loopName, + file: 'src/test.ts', + line: 1, + severity: 'bug', + description: 'Test bug', + }) + + await loop.tick({ + type: 'session.status', + properties: { + status: { type: 'idle' }, + sessionID: state.sessionId, + }, + }) + + // Model-based attempts should have been made (and failed) + const codePrompts = clientState.promptCalls.filter(c => c.agent === 'code') + expect(codePrompts.length).toBeGreaterThan(0) + // After model fails, fallback without model should NOT send variant + const fallbackPrompts = codePrompts.filter(c => !c.variant) + expect(fallbackPrompts.length).toBeGreaterThan(0) + }) + }) }) diff --git a/test/services/attach-loop.test.ts b/test/services/attach-loop.test.ts index a85ba0e361..5da82ff13d 100644 --- a/test/services/attach-loop.test.ts +++ b/test/services/attach-loop.test.ts @@ -1,5 +1,5 @@ -import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest' -import Database from 'better-sqlite3' +import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test' +import { Database } from 'bun:sqlite' import { mkdtempSync } from 'fs' import { join } from 'path' import { tmpdir } from 'os' @@ -44,6 +44,8 @@ CREATE TABLE loops ( total_sections INTEGER NOT NULL DEFAULT 0, final_audit_done INTEGER NOT NULL DEFAULT 0, final_audit_attempts INTEGER NOT NULL DEFAULT 0, + execution_variant TEXT, + auditor_variant TEXT, PRIMARY KEY (project_id, loop_name) ) ` @@ -145,8 +147,8 @@ describe('attachLoopToSession', () => { sectionPlansRepo, ) - const promptAsyncMock = vi.fn().mockResolvedValue({ error: null }) - const tuiSelectSessionMock = vi.fn().mockResolvedValue(undefined) + const promptAsyncMock = mock(async () => ({ error: null })) + const tuiSelectSessionMock = mock(async () => undefined) const deps = { projectId: PROJECT_ID, @@ -160,17 +162,17 @@ describe('attachLoopToSession', () => { dataDir: '/tmp', v2: { session: { - create: vi.fn().mockResolvedValue({ data: { id: 'new-session' } }), - get: vi.fn().mockResolvedValue({ data: {} }), - update: vi.fn().mockResolvedValue({ data: {} }), + create: mock(async () => ({ data: { id: 'new-session' } })), + get: mock(async () => ({ data: {} })), + update: mock(async () => ({ data: {} })), promptAsync: promptAsyncMock, - abort: vi.fn().mockResolvedValue({}), - delete: vi.fn().mockResolvedValue({}), - messages: vi.fn().mockResolvedValue({ data: [] }), - status: vi.fn().mockResolvedValue({ data: {} }), + abort: mock(async () => ({})), + delete: mock(async () => ({})), + messages: mock(async () => ({ data: [] })), + status: mock(async () => ({ data: {} })), }, tui: { - publish: vi.fn(), + publish: mock(() => {}), selectSession: tuiSelectSessionMock, }, }, @@ -181,15 +183,15 @@ describe('attachLoopToSession', () => { loop: loopService as any, loopHandler: { runExclusive: async (name: string, fn: () => Promise) => fn(), - startWatchdog: vi.fn(), + startWatchdog: mock(() => {}), clearLoopTimers: noopFn, }, sandboxManager: null, workspaceStatusRegistry: { - recordEvent: vi.fn(), - getStatus: vi.fn().mockReturnValue('connected' as const), - awaitConnected: vi.fn().mockResolvedValue({ connected: true, elapsedMs: 0, source: 'cached' as const }), - primeFromSnapshot: vi.fn(), + recordEvent: mock(() => {}), + getStatus: mock(() => 'connected' as const), + awaitConnected: mock(async () => ({ connected: true, elapsedMs: 0, source: 'cached' as const })), + primeFromSnapshot: mock(() => {}), }, } @@ -247,7 +249,7 @@ describe('attachLoopToSession', () => { test('onStarted callback is invoked after state persistence', async () => { const { deps } = buildDeps() - const onStartedSpy = vi.fn() + const onStartedSpy = mock(() => {}) const { attachLoopToSession } = await import('../../src/services/execution') @@ -286,7 +288,7 @@ describe('attachLoopToSession', () => { const { deps, loopsRepo, promptAsyncMock } = buildDeps() // Make promptAsync return an error - promptAsyncMock.mockResolvedValueOnce({ error: new Error('network timeout') }) + promptAsyncMock.mockImplementationOnce(async () => ({ error: new Error('network timeout') })) const { attachLoopToSession } = await import('../../src/services/execution') @@ -322,8 +324,11 @@ describe('attachLoopToSession', () => { test('attachLoopToSession does NOT call loop.deleteState when setState throws because loop already exists', async () => { const { deps } = buildDeps() - const deleteStateSpy = vi.spyOn(deps.loop, 'deleteState') - ;(deps.loop as any).setState = vi.fn().mockImplementation(() => { + let deleteStateCalled = false + const originalDeleteState = deps.loop.deleteState.bind(deps.loop) + deps.loop.deleteState = (...args: any[]) => { deleteStateCalled = true; return originalDeleteState(...args) } + + ;(deps.loop as any).setState = mock((...args: any[]) => { throw new Error('setState: loop "my-feature" already exists') }) @@ -353,7 +358,7 @@ describe('attachLoopToSession', () => { expect(result.code).toBe('already_attached') } - expect(deleteStateSpy).not.toHaveBeenCalled() + expect(deleteStateCalled).toBe(false) }) test('attachLoopToSession refuses terminal loop row without deleting state', async () => { @@ -385,7 +390,9 @@ describe('attachLoopToSession', () => { const existingBefore = loopsRepo.get(PROJECT_ID, 'reusable-loop') expect(existingBefore?.status).toBe('cancelled') - const deleteStateSpy = vi.spyOn(deps.loop, 'deleteState') + let deleteStateCalled = false + const originalDeleteState = deps.loop.deleteState.bind(deps.loop) + deps.loop.deleteState = (...args: any[]) => { deleteStateCalled = true; return originalDeleteState(...args) } const { attachLoopToSession } = await import('../../src/services/execution') @@ -408,7 +415,7 @@ describe('attachLoopToSession', () => { }, ) - expect(deleteStateSpy).not.toHaveBeenCalled() + expect(deleteStateCalled).toBe(false) expect(result.ok).toBe(false) if (!result.ok) { expect(result.code).toBe('conflict') @@ -442,7 +449,9 @@ describe('attachLoopToSession', () => { sandbox: false, } as any) - const deleteStateSpy = vi.spyOn(deps.loop, 'deleteState') + let deleteStateCalled = false + const originalDeleteState = deps.loop.deleteState.bind(deps.loop) + deps.loop.deleteState = (...args: any[]) => { deleteStateCalled = true; return originalDeleteState(...args) } const { attachLoopToSession } = await import('../../src/services/execution') @@ -469,7 +478,7 @@ describe('attachLoopToSession', () => { if (!result.ok) { expect(result.code).toBe('already_attached') } - expect(deleteStateSpy).not.toHaveBeenCalled() + expect(deleteStateCalled).toBe(false) }) test('attach extracts sections via forge-section markers', async () => { @@ -669,4 +678,49 @@ describe('attachLoopToSession', () => { expect(sessionUpdateMock).not.toHaveBeenCalled() } }) + + test('attachLoopToSession persists execution and auditor variants', async () => { + const { deps, loopsRepo } = buildDeps() + + const { attachLoopToSession } = await import('../../src/services/execution') + + const result = await attachLoopToSession( + deps as any, + { surface: 'tui', projectId: PROJECT_ID, directory: '/tmp/test' }, + { + sessionId: 'sess_variant', + workspaceId: 'ws_variant', + worktreeDir: '/tmp/wt/variant', + loopName: 'variant-loop', + displayName: 'Variant Loop', + executionName: 'variant-loop', + hostSessionId: 'host-variant', + executionModel: 'prov/exec', + auditorModel: 'prov/aud', + executionVariant: 'thinking-max', + auditorVariant: 'audit-high', + maxIterations: 50, + sandboxEnabled: false, + planText: '# Variant Plan\n\nDo things.', + selectSession: false, + selectSessionTiming: 'after-prompt', + startWatchdog: false, + }, + ) + + expect(result.ok).toBe(true) + + // Verify loop state was persisted with variants + const state = (deps.loop as any).getActiveState('variant-loop') + expect(state).not.toBeNull() + expect(state!.sessionId).toBe('sess_variant') + expect(state!.executionVariant).toBe('thinking-max') + expect(state!.auditorVariant).toBe('audit-high') + + // Verify DB row contains variants + const row = loopsRepo.get(PROJECT_ID, 'variant-loop') + expect(row).not.toBeNull() + expect(row!.executionVariant).toBe('thinking-max') + expect(row!.auditorVariant).toBe('audit-high') + }) }) diff --git a/test/storage-migrations.test.ts b/test/storage-migrations.test.ts index 8b4954115a..81b503ba5e 100644 --- a/test/storage-migrations.test.ts +++ b/test/storage-migrations.test.ts @@ -563,7 +563,7 @@ test('migration 129 narrows phase CHECK and drops decomposition columns', () => // decomposing row should be deleted const decompRow = migrated.prepare("SELECT * FROM loops WHERE phase = 'decomposing'").get() - expect(decompRow).toBeUndefined() + expect(decompRow).toBeFalsy() // coding row should remain const codingRow = migrated.prepare("SELECT * FROM loops WHERE project_id = 'project-a' AND loop_name = 'loop-coding'").get() @@ -637,3 +637,32 @@ test('migration 130 is idempotent on re-opened databases', () => { db2.close() }) + +test('migration 131 adds execution_variant and auditor_variant columns to loops', () => { + const dbPath = createTempDb() + const db = openForgeDatabase(dbPath) + + const cols = db.prepare('PRAGMA table_info(loops)').all() as Array<{ name: string }> + expect(cols.some(c => c.name === 'execution_variant')).toBe(true) + expect(cols.some(c => c.name === 'auditor_variant')).toBe(true) + + db.close() +}) + +test('migration 131 is idempotent on re-opened databases', () => { + const dbPath = createTempDb() + + const db1 = openForgeDatabase(dbPath) + db1.close() + + const db2 = openForgeDatabase(dbPath) + + const cols = db2.prepare('PRAGMA table_info(loops)').all() as Array<{ name: string }> + expect(cols.some(c => c.name === 'execution_variant')).toBe(true) + expect(cols.some(c => c.name === 'auditor_variant')).toBe(true) + + const count = db2.prepare('SELECT COUNT(*) as count FROM migrations').get() as { count: number } + expect(count.count).toBeGreaterThan(0) + + db2.close() +}) diff --git a/test/tui-execution-preferences.test.ts b/test/tui-execution-preferences.test.ts index 0438031128..012e3f044d 100644 --- a/test/tui-execution-preferences.test.ts +++ b/test/tui-execution-preferences.test.ts @@ -210,6 +210,76 @@ describe('Execution Preferences', () => { expect(result.auditorModel).toBe('anthropic/claude-3-opus') }) + test('write then read preserves executionVariant and auditorVariant', () => { + const projectId = 'test-project' + const prefs: ExecutionPreferences = { + mode: 'Loop', + executionModel: 'anthropic/claude-3-5-sonnet', + auditorModel: 'anthropic/claude-3-opus', + executionVariant: 'extended-thinking', + auditorVariant: 'extended-reasoning', + } + + writeExecutionPreferences(projectId, prefs, TEST_DB_PATH) + const result = readExecutionPreferences(projectId, TEST_DB_PATH) + + expect(result?.executionVariant).toBe('extended-thinking') + expect(result?.auditorVariant).toBe('extended-reasoning') + }) + + test('resolveExecutionDialogDefaults returns stored variants', () => { + const config: PluginConfig = { + executionModel: 'anthropic/claude-3-haiku', + loop: { model: 'anthropic/claude-3-sonnet' }, + auditorModel: 'anthropic/claude-3-opus', + } + const storedPrefs: ExecutionPreferences = { + mode: 'Loop', + executionModel: 'anthropic/claude-3-5-sonnet', + auditorModel: 'anthropic/claude-3-opus', + executionVariant: 'extended-thinking', + auditorVariant: 'extended-reasoning', + } + + const result = resolveExecutionDialogDefaults(config, storedPrefs) + expect(result.executionVariant).toBe('extended-thinking') + expect(result.auditorVariant).toBe('extended-reasoning') + }) + + test('resolveExecutionDialogDefaults defaults missing variants to empty string', () => { + const config: PluginConfig = { + executionModel: 'anthropic/claude-3-haiku', + loop: { model: 'anthropic/claude-3-sonnet' }, + auditorModel: 'anthropic/claude-3-opus', + } + + const result = resolveExecutionDialogDefaults(config, null) + expect(result.executionVariant).toBe('') + expect(result.auditorVariant).toBe('') + }) + + test('legacy preferences without variant fields still read successfully', () => { + const projectId = 'legacy-prefs-test' + + const db = new Database(TEST_DB_PATH) + db.run('PRAGMA busy_timeout=5000') + const repo = createTuiPrefsRepo(db) + repo.set(projectId, 'tui:plan-execution-preferences', { + mode: 'Loop', + executionModel: 'some/model', + auditorModel: 'other/model', + }, 7 * 24 * 60 * 60 * 1000) + db.close() + + const result = readExecutionPreferences(projectId, TEST_DB_PATH) + expect(result?.mode).toBe('Loop') + expect(result?.executionModel).toBe('some/model') + expect(result?.auditorModel).toBe('other/model') + // Variant fields are undefined for legacy prefs (not stored) + expect(result?.executionVariant).toBeUndefined() + expect(result?.auditorVariant).toBeUndefined() + }) + test('writeExecutionPreferences does not mutate other preference keys', () => { const projectId = 'test-project' @@ -249,4 +319,33 @@ describe('Execution Preferences', () => { } loopDb.close() }) + + test('resolveExecutionDialogDefaults preserves stored variant values (regression: explicit empty override)', () => { + // Regression for Bug 1: When the user explicitly selects "Use default" for a variant, + // the initial variant prop is "". The applyDefaults function must treat "" as an explicit + // override and not reapply stored defaults. + const config: PluginConfig = { + executionModel: 'anthropic/claude-3-haiku', + loop: { model: 'anthropic/claude-3-sonnet' }, + auditorModel: 'anthropic/claude-3-opus', + } + + const storedPrefs: ExecutionPreferences = { + mode: 'Loop', + executionModel: 'anthropic/claude-3-5-sonnet', + auditorModel: 'anthropic/claude-3-opus', + executionVariant: 'thinking-max', + auditorVariant: 'reasoning-high', + } + + // resolveExecutionDialogDefaults returns stored variants when present + const result = resolveExecutionDialogDefaults(config, storedPrefs) + expect(result.executionVariant).toBe('thinking-max') + expect(result.auditorVariant).toBe('reasoning-high') + + // When stored prefs have no variant fields, defaults to empty string + const resultWithoutVariants = resolveExecutionDialogDefaults(config, { ...storedPrefs, executionVariant: undefined, auditorVariant: undefined }) + expect(resultWithoutVariants.executionVariant).toBe('') + expect(resultWithoutVariants.auditorVariant).toBe('') + }) }) diff --git a/test/tui-models.test.ts b/test/tui-models.test.ts index e465124c1d..7a47c3efa2 100644 --- a/test/tui-models.test.ts +++ b/test/tui-models.test.ts @@ -6,6 +6,9 @@ import { buildDialogSelectOptions, getModelDisplayLabel, sortModelsByPriority, + getAvailableModelVariants, + getVariantDisplayLabel, + normalizeVariantForModel, type ProviderInfo, type ModelInfo, } from '../src/utils/tui-models' @@ -567,3 +570,382 @@ describe('getModelDisplayLabel', () => { expect(getModelDisplayLabel('anthropic/claude', models, 'unknown/model')).toBe('Claude Sonnet') }) }) + +describe('fetchAvailableModels with variants', () => { + test('preserves variants from provider model data', async () => { + const mockProviders: any = [ + { + id: 'anthropic', + name: 'Anthropic', + models: { + 'claude-sonnet': { + id: 'claude-sonnet', + name: 'Claude Sonnet', + variants: { + default: { name: 'Default' }, + 'thinking-max': { name: 'Thinking Max' }, + }, + }, + }, + }, + ] + + const providerListMock = mock(() => Promise.resolve({ data: { all: mockProviders, connected: ['anthropic'] } })) + const mockApi = createMockApi(['anthropic'], providerListMock) + + const result = await fetchAvailableModels(mockApi) + + expect(result.providers[0].models[0].variants).toEqual({ + default: { name: 'Default' }, + 'thinking-max': { name: 'Thinking Max' }, + }) + }) +}) + +describe('getAvailableModelVariants', () => { + test('returns empty array when model is null', () => { + expect(getAvailableModelVariants(null)).toEqual([]) + }) + + test('returns empty array when model has no variants', () => { + const model: ModelInfo = { + id: 'test', + name: 'Test', + providerID: 'provider', + providerName: 'Provider', + fullName: 'provider/test', + } + expect(getAvailableModelVariants(model)).toEqual([]) + }) + + test('excludes disabled variants', () => { + const model: ModelInfo = { + id: 'test', + name: 'Test', + providerID: 'provider', + providerName: 'Provider', + fullName: 'provider/test', + variants: { + default: { disabled: false }, + legacy: { disabled: true }, + active: {}, + }, + } + const result = getAvailableModelVariants(model) + expect(result).toHaveLength(2) + expect(result.map(v => v.id)).toEqual(['default', 'active']) + }) + + test('uses configured names/descriptions when present', () => { + const model: ModelInfo = { + id: 'test', + name: 'Test', + providerID: 'provider', + providerName: 'Provider', + fullName: 'provider/test', + variants: { + default: { name: 'Default Variant', description: 'The default configuration' }, + 'thinking-max': { thinkingBudget: 32000 }, + }, + } + const result = getAvailableModelVariants(model) + expect(result).toHaveLength(2) + expect(result[0].label).toBe('Default Variant') + expect(result[0].description).toBe('The default configuration') + expect(result[1].label).toBe('Thinking Max') + expect(result[1].description).toBe('Thinking budget: 32000') + }) + + test('generates labels from kebab-case and snake_case keys', () => { + const model: ModelInfo = { + id: 'test', + name: 'Test', + providerID: 'provider', + providerName: 'Provider', + fullName: 'provider/test', + variants: { + 'thinking-max': {}, + reasoning_high: {}, + }, + } + const result = getAvailableModelVariants(model) + expect(result.map(v => v.label)).toEqual(['Thinking Max', 'Reasoning High']) + }) + + test('generates descriptions from reasoningEffort field', () => { + const model: ModelInfo = { + id: 'test', + name: 'Test', + providerID: 'provider', + providerName: 'Provider', + fullName: 'provider/test', + variants: { + high: { reasoningEffort: 'high' }, + }, + } + const result = getAvailableModelVariants(model) + expect(result[0].description).toBe('Reasoning: high') + }) + + test('generates descriptions from thinking field', () => { + const model: ModelInfo = { + id: 'test', + name: 'Test', + providerID: 'provider', + providerName: 'Provider', + fullName: 'provider/test', + variants: { + default: { thinking: 'low' }, + }, + } + const result = getAvailableModelVariants(model) + expect(result[0].description).toBe('Thinking: low') + }) + + test('generates descriptions from reasoning_effort field', () => { + const model: ModelInfo = { + id: 'test', + name: 'Test', + providerID: 'provider', + providerName: 'Provider', + fullName: 'provider/test', + variants: { + mid: { reasoning_effort: 'medium' }, + }, + } + const result = getAvailableModelVariants(model) + expect(result[0].description).toBe('Reasoning: medium') + }) + + test('generates descriptions from thinking_budget field', () => { + const model: ModelInfo = { + id: 'test', + name: 'Test', + providerID: 'provider', + providerName: 'Provider', + fullName: 'provider/test', + variants: { + default: { thinking_budget: 16000 }, + }, + } + const result = getAvailableModelVariants(model) + expect(result[0].description).toBe('Thinking budget: 16000') + }) + + test('returns undefined description when no relevant fields present', () => { + const model: ModelInfo = { + id: 'test', + name: 'Test', + providerID: 'provider', + providerName: 'Provider', + fullName: 'provider/test', + variants: { + default: { unrelated_field: 'value' }, + }, + } + const result = getAvailableModelVariants(model) + expect(result[0].description).toBeUndefined() + }) + + test('preserves variant key as id', () => { + const model: ModelInfo = { + id: 'test', + name: 'Test', + providerID: 'provider', + providerName: 'Provider', + fullName: 'provider/test', + variants: { + 'custom-id': {}, + }, + } + const result = getAvailableModelVariants(model) + expect(result[0].id).toBe('custom-id') + }) +}) + +describe('getVariantDisplayLabel', () => { + test('returns "default" for undefined variant', () => { + expect(getVariantDisplayLabel(undefined)).toBe('default') + }) + + test('returns "default" for empty string', () => { + expect(getVariantDisplayLabel('')).toBe('default') + }) + + test('returns friendly label for known variant', () => { + const model: ModelInfo = { + id: 'test', + name: 'Test', + providerID: 'provider', + providerName: 'Provider', + fullName: 'provider/test', + variants: { + 'thinking-max': { name: 'Thinking Max' }, + }, + } + expect(getVariantDisplayLabel('thinking-max', model)).toBe('Thinking Max') + }) + + test('returns raw string for unknown variant', () => { + const model: ModelInfo = { + id: 'test', + name: 'Test', + providerID: 'provider', + providerName: 'Provider', + fullName: 'provider/test', + variants: { + default: {}, + }, + } + expect(getVariantDisplayLabel('unknown-variant', model)).toBe('unknown-variant') + }) + + test('returns raw string when model is null', () => { + expect(getVariantDisplayLabel('some-variant', null)).toBe('some-variant') + }) + + test('returns raw string when model has no variants', () => { + const model: ModelInfo = { + id: 'test', + name: 'Test', + providerID: 'provider', + providerName: 'Provider', + fullName: 'provider/test', + } + expect(getVariantDisplayLabel('some-variant', model)).toBe('some-variant') + }) +}) + +describe('normalizeVariantForModel', () => { + test('returns empty string for undefined variant', () => { + expect(normalizeVariantForModel(undefined)).toBe('') + }) + + test('returns empty string for empty string', () => { + expect(normalizeVariantForModel('')).toBe('') + }) + + test('returns empty string when model has no variants', () => { + const model: ModelInfo = { + id: 'test', + name: 'Test', + providerID: 'provider', + providerName: 'Provider', + fullName: 'provider/test', + } + expect(normalizeVariantForModel('some-variant', model)).toBe('') + }) + + test('returns variant when it exists in available variants', () => { + const model: ModelInfo = { + id: 'test', + name: 'Test', + providerID: 'provider', + providerName: 'Provider', + fullName: 'provider/test', + variants: { + 'thinking-max': {}, + default: {}, + }, + } + expect(normalizeVariantForModel('thinking-max', model)).toBe('thinking-max') + expect(normalizeVariantForModel('default', model)).toBe('default') + }) + + test('returns empty string when variant is not in available variants', () => { + const model: ModelInfo = { + id: 'test', + name: 'Test', + providerID: 'provider', + providerName: 'Provider', + fullName: 'provider/test', + variants: { + default: {}, + }, + } + expect(normalizeVariantForModel('thinking-max', model)).toBe('') + }) + + test('returns empty string when variant is disabled', () => { + const model: ModelInfo = { + id: 'test', + name: 'Test', + providerID: 'provider', + providerName: 'Provider', + fullName: 'provider/test', + variants: { + default: { disabled: false }, + legacy: { disabled: true }, + }, + } + expect(normalizeVariantForModel('legacy', model)).toBe('') + expect(normalizeVariantForModel('default', model)).toBe('default') + }) + + test('clears invalid variants when switching models', () => { + const modelA: ModelInfo = { + id: 'test-a', + name: 'Test A', + providerID: 'provider', + providerName: 'Provider', + fullName: 'provider/test-a', + variants: { + 'thinking-max': {}, + }, + } + + const modelB: ModelInfo = { + id: 'test-b', + name: 'Test B', + providerID: 'provider', + providerName: 'Provider', + fullName: 'provider/test-b', + variants: { + default: {}, + }, + } + + // User had thinking-max on modelA, now switching to modelB which doesn't have it + expect(normalizeVariantForModel('thinking-max', modelA)).toBe('thinking-max') + expect(normalizeVariantForModel('thinking-max', modelB)).toBe('') + }) + + test('returns empty string when model is null (regression: use-default model)', () => { + // Bug 2 fix: When a user selects "Use default" for a model (empty string), + // the component must resolve the OpenCode default model first before normalizing. + // This test verifies that passing null directly returns empty string. + expect(normalizeVariantForModel('thinking-max', null)).toBe('') + }) + + test('preserves variant when effective model supports it (regression: use-default variant)', () => { + // Bug 2 fix: When the effective model supports a variant, it should be preserved. + const defaultModel: ModelInfo = { + id: 'default', + name: 'Default', + providerID: 'provider', + providerName: 'Provider', + fullName: 'provider/default', + variants: { + 'thinking-max': {}, + default: {}, + }, + } + // Even if we started from null, normalizing against the resolved default model preserves the variant + expect(normalizeVariantForModel('thinking-max', defaultModel)).toBe('thinking-max') + }) + + test('returns empty string when variant is not available in effective model', () => { + // Variant 'thinking-max' is not in the model's available variants + const modelWithoutThinking: ModelInfo = { + id: 'no-thinking', + name: 'No Thinking', + providerID: 'provider', + providerName: 'Provider', + fullName: 'provider/no-thinking', + variants: { + default: {}, + }, + } + expect(normalizeVariantForModel('thinking-max', modelWithoutThinking)).toBe('') + }) +}) diff --git a/test/tui/execute-plan-panel-busy.test.ts b/test/tui/execute-plan-panel-busy.test.ts index 1da89dba0f..8e4e62fe12 100644 --- a/test/tui/execute-plan-panel-busy.test.ts +++ b/test/tui/execute-plan-panel-busy.test.ts @@ -72,8 +72,8 @@ describe('withBusyGuard (used by ExecutePlanPanel handleExecuteMode)', () => { setBusy: signal.set, }) - await guarded('Execute here', 'prov/exec', 'prov/aud') + await guarded('Execute here', 'prov/exec', 'prov/aud', 'thinking-max', 'reasoning-high') - expect(work).toHaveBeenCalledWith('Execute here', 'prov/exec', 'prov/aud') + expect(work).toHaveBeenCalledWith('Execute here', 'prov/exec', 'prov/aud', 'thinking-max', 'reasoning-high') }) }) diff --git a/test/utils/tui-client-variants.test.ts b/test/utils/tui-client-variants.test.ts new file mode 100644 index 0000000000..00efcdda18 --- /dev/null +++ b/test/utils/tui-client-variants.test.ts @@ -0,0 +1,31 @@ +import { describe, test, expect } from 'vitest' +import { buildPromptModelSelection } from '../../src/utils/tui-client' + +describe('buildPromptModelSelection', () => { + const MODEL = { providerID: 'openai', modelID: 'gpt-4o' } + + test('model + variant returns both fields', () => { + const result = buildPromptModelSelection(MODEL, 'reasoning') + expect(result).toEqual({ model: MODEL, variant: 'reasoning' }) + }) + + test('model without variant returns only model', () => { + const result = buildPromptModelSelection(MODEL) + expect(result).toEqual({ model: MODEL }) + }) + + test('variant without model returns only variant', () => { + const result = buildPromptModelSelection(undefined, 'fast') + expect(result).toEqual({ variant: 'fast' }) + }) + + test('empty variant is omitted', () => { + const result = buildPromptModelSelection(MODEL, '') + expect(result).toEqual({ model: MODEL }) + }) + + test('no model/no variant returns empty object', () => { + const result = buildPromptModelSelection(undefined, undefined) + expect(result).toEqual({}) + }) +}) From b1679ea2f1465aa9b96a5d1a836e6b8193f53081 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Wed, 20 May 2026 18:13:08 -0400 Subject: [PATCH 3/4] loop: code-reach-gate-1 completed after 2 iterations --- src/loop/runtime.ts | 13 ++++++++----- test/loop/runtime.test.ts | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/src/loop/runtime.ts b/src/loop/runtime.ts index f2c46c5413..d1d5e398db 100644 --- a/src/loop/runtime.ts +++ b/src/loop/runtime.ts @@ -283,14 +283,17 @@ export function createLoop(deps: LoopRuntimeDeps): Loop { }> const lastMessage = messages.length > 0 ? messages[messages.length - 1] : null - const lastAssistant = [...messages].reverse().find((m) => m.info.role === 'assistant') - if (!lastAssistant) { - const role = lastMessage?.info.role ?? 'none' - logger.log(`Loop: no assistant message found in session ${sessionId}, last message role: ${role}`) - return { text: null, error: null, lastMessageRole: role } + if (!lastMessage) { + return { text: null, error: null, lastMessageRole: 'none' } } + if (lastMessage.info.role !== 'assistant') { + logger.log(`Loop: no assistant message found in session ${sessionId}, last message role: ${lastMessage.info.role ?? 'unknown'}`) + return { text: null, error: null, lastMessageRole: lastMessage.info.role ?? 'unknown' } + } + + const lastAssistant = lastMessage if (lastAssistant.info.finish && lastAssistant.info.finish !== 'stop') { logger.log(`Loop: assistant message in session ${sessionId} is not final yet (finish=${lastAssistant.info.finish})`) return { text: null, error: null, lastMessageRole: `assistant:${lastAssistant.info.finish}` } diff --git a/test/loop/runtime.test.ts b/test/loop/runtime.test.ts index d1fbcc5a24..48a2206a98 100644 --- a/test/loop/runtime.test.ts +++ b/test/loop/runtime.test.ts @@ -385,6 +385,44 @@ describe('Loop Runtime', () => { expect(updatedState).not.toBeNull() expect(updatedState!.phase).toBe('auditing') }) + + test('does not transition to auditing when latest coding message is still user prompt', async () => { + const { loop, clientState } = createRuntime() + clientState.messagesResult = [ + { + info: { role: 'assistant', finish: 'stop' }, + parts: [{ type: 'text', text: 'Older code response.' }], + }, + { + info: { role: 'user' }, + parts: [{ type: 'text', text: 'Latest code prompt that was not answered.' }], + }, + ] + + const state = makeState({ + phase: 'coding', + totalSections: 0, + auditCount: 0, + }) + loopService.setState(state.loopName, state) + + await loop.tick({ + type: 'session.status', + properties: { + status: { type: 'idle' }, + sessionID: state.sessionId, + }, + }) + + const updatedState = loopService.getActiveState(state.loopName) + expect(updatedState).not.toBeNull() + expect(updatedState!.phase).toBe('coding') + + expect(clientState.promptCalls.some((call) => call.agent === 'auditor-loop')).toBe(false) + + const hasCodePrompt = clientState.promptCalls.some((call) => call.agent === 'code') + expect(hasCodePrompt).toBe(false) + }) }) describe('clean non-sectioned audit terminates completed', () => { From d353a5a3ce63d99a47367505421879aa69853f4a Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Wed, 20 May 2026 18:43:19 -0400 Subject: [PATCH 4/4] chore: bump version to 0.4.6 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4be24d6c4d..0975ea9d22 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-forge", - "version": "0.4.5", + "version": "0.4.6", "type": "module", "oc-plugin": [ "server",