diff --git a/web/app/flows/onboarding/AgentStepEditor.tsx b/web/app/flows/onboarding/AgentStepEditor.tsx
index ef213ef..793cc67 100644
--- a/web/app/flows/onboarding/AgentStepEditor.tsx
+++ b/web/app/flows/onboarding/AgentStepEditor.tsx
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import { X } from 'lucide-react';
import { agentLabel, isCodingAgent, type FactoryDraft } from '../../../lib/flow-onboarding';
-import { defaultAgentPrompt, resolveAgentSettings, type AgentRole, type FlowAgentSettings } from '../../../lib/flow-agent-settings';
+import { DEFAULT_AGENT_MODELS, defaultAgentPrompt, resolveAgentSettings, type AgentRole, type FlowAgentSettings } from '../../../lib/flow-agent-settings';
import type { FlowTrack } from '../../../lib/flow-analytics';
import s from './onboarding.module.css';
@@ -40,9 +40,9 @@ export function AgentStepEditor({ draft, roles, onChange, onClose, onTrack }: {
-
+
-
Leave the model blank to use {agentLabel(current.agent)}’s configured model, or enter a model ID available to your account.
+ Leave blank to pin {DEFAULT_AGENT_MODELS[current.agent]} in the generated flow, or enter a different model ID available to your account.
ReasoningAgent default
Uses your agent’s reasoning settings. The flow runner doesn’t support overriding reasoning per step yet.
diff --git a/web/app/flows/onboarding/WorkflowPicker.tsx b/web/app/flows/onboarding/WorkflowPicker.tsx
index 11a8041..960dc48 100644
--- a/web/app/flows/onboarding/WorkflowPicker.tsx
+++ b/web/app/flows/onboarding/WorkflowPicker.tsx
@@ -1,5 +1,5 @@
import { AgentStepEditor } from './AgentStepEditor';
-import { resolveAgentSettings, rolesForStep, type AgentRole } from '../../../lib/flow-agent-settings';
+import { resolveGeneratedAgentSettings, rolesForStep, type AgentRole } from '../../../lib/flow-agent-settings';
import { GitBranch, Layers3, Zap, UserRound, LockKeyhole, Terminal, Settings } from 'lucide-react';
import { useState } from 'react';
import { WORKFLOWS, WORKFLOW_STEP_DETAILS, workflowAgents } from '../../../lib/flow-workflows';
@@ -92,7 +92,7 @@ export function WorkflowPlan({ draft, onChange, onTrack }: { draft: FactoryDraft
}
{selectedWorkflow?.steps.map(step => {
const roles = rolesForStep(step);
- const configs = roles.map(role => resolveAgentSettings(selectedWorkflow.id, role, draft.agents, draft.agentSettings));
+ const configs = roles.map(role => resolveGeneratedAgentSettings(selectedWorkflow.id, role, draft.agents, draft.agentSettings));
const nodeConfigs = step === '2× adversarial review' ? configs.slice(0, 1) : configs;
const agents = nodeConfigs.map(value => value.agent);
const Node = agents.length ? 'button' : 'div';
@@ -109,7 +109,7 @@ export function WorkflowPlan({ draft, onChange, onTrack }: { draft: FactoryDraft
{human ? 'Your approval' : review ? 'Adversarial review' : step}{human ? 'You' : script ? 'Script' : step === '3 implementations' ? 'Parallel' : 'Agent'}
{WORKFLOW_STEP_DETAILS[step]}
- {agents.length > 0 && {nodeConfigs.length > 1 ? 'Configure 3 agents' : nodeConfigs[0].model || 'Default model'}}
+ {agents.length > 0 && {nodeConfigs.length > 1 ? 'Configure 3 agents' : nodeConfigs[0].model}}
{review && 2 rounds}
{human && }
diff --git a/web/lib/flow-agent-settings.ts b/web/lib/flow-agent-settings.ts
index 94b27bf..6835f7c 100644
--- a/web/lib/flow-agent-settings.ts
+++ b/web/lib/flow-agent-settings.ts
@@ -6,6 +6,21 @@ export type AgentRole = typeof AGENT_ROLES[number];
export type AgentSettings = { agent?: AgentId; model?: string; prompt?: string };
export type FlowAgentSettings = Partial>;
+/**
+ * Stable defaults for first-party generated flows. These identifiers are
+ * verified against the current CLI model catalogs/readiness probes; the
+ * runtime still proves the exact credential/model pair before agent work.
+ *
+ * Keep this exhaustive so enabling another generator agent cannot silently
+ * reintroduce an omitted model and inherit an adapter default.
+ */
+export const DEFAULT_AGENT_MODELS: Readonly> = {
+ claude: 'claude-sonnet-5',
+ codex: 'gpt-5.6-sol',
+ cursor: 'gpt-5.6-sol-high',
+ grok: 'grok-4.7',
+};
+
export function rolesForStep(step: WorkflowStep): AgentRole[] {
if (step === '3 implementations') return ['prototype-1', 'prototype-2', 'prototype-3'];
if (step === '2× adversarial review') return ['adversary', 'fixer'];
@@ -38,7 +53,14 @@ export function resolveAgentSettings(workflow: WorkflowId, role: AgentRole, sele
// Changing the selected agents must never leave an unavailable CLI assigned.
const agent = saved?.agent && isCodingAgent(saved.agent) && available.includes(saved.agent) ? saved.agent : defaultAgent;
const compatible = !saved?.agent || saved.agent === agent;
- return { agent, model: compatible ? saved?.model?.trim() || '' : '', prompt: saved?.prompt ?? defaultAgentPrompt(workflow, role) };
+ const model = compatible ? saved?.model?.trim() || '' : '';
+ return { agent, model, prompt: saved?.prompt ?? defaultAgentPrompt(workflow, role) };
+}
+
+/** Resolve the explicit pair emitted by a first-party generated flow. */
+export function resolveGeneratedAgentSettings(workflow: WorkflowId, role: AgentRole, selected: readonly string[], settings: FlowAgentSettings = {}) {
+ const value = resolveAgentSettings(workflow, role, selected, settings);
+ return { ...value, model: value.model || DEFAULT_AGENT_MODELS[value.agent] };
}
export function validFlowAgentSettings(value: unknown): value is FlowAgentSettings {
diff --git a/web/lib/flow-preview.ts b/web/lib/flow-preview.ts
index 3e4efa3..2ae3c78 100644
--- a/web/lib/flow-preview.ts
+++ b/web/lib/flow-preview.ts
@@ -1,5 +1,5 @@
import { CODING_AGENTS } from './flow-agents';
-import { resolveAgentSettings, rolesForStep } from './flow-agent-settings';
+import { resolveGeneratedAgentSettings, rolesForStep } from './flow-agent-settings';
import type { FactoryDraft } from './flow-onboarding';
import { repositoryHost, sourceLabel, sourceSummary } from './flow-sources';
import { WORKFLOWS, WORKFLOW_STEP_DETAILS } from './flow-workflows';
@@ -21,12 +21,12 @@ export function flowPreview(draft: FactoryDraft) {
owner: 'Trigger', detail: draft.sources.length > 1 ? 'Any one source can start the flow.' : '', icons: draft.sources,
},
...workflow.steps.map(step => {
- const configs = rolesForStep(step).map(role => resolveAgentSettings(workflow.id, role, draft.agents, draft.agentSettings));
+ const configs = rolesForStep(step).map(role => resolveGeneratedAgentSettings(workflow.id, role, draft.agents, draft.agentSettings));
const human = step === 'Human gate';
const review = step === '2× adversarial review';
const agents = (review ? configs.slice(0, 1) : configs).map(config => ({
id: config.agent, label: CODING_AGENTS.find(agent => agent.id === config.agent)!.label,
- model: config.model || 'Default model',
+ model: config.model,
}));
return {
kind: human ? 'approval' : configs.length ? 'agent' : 'script',
diff --git a/web/lib/flow-workflows.ts b/web/lib/flow-workflows.ts
index 0fcf678..aa6562f 100644
--- a/web/lib/flow-workflows.ts
+++ b/web/lib/flow-workflows.ts
@@ -1,4 +1,4 @@
-import { resolveAgentSettings, type AgentRole, type FlowAgentSettings } from './flow-agent-settings';
+import { resolveGeneratedAgentSettings, type AgentRole, type FlowAgentSettings } from './flow-agent-settings';
import { isCodingAgent } from './flow-agents';
export const WORKFLOWS = [
@@ -586,7 +586,7 @@ export function workflowAgents(selected: readonly string[]) {
}
export function workflowCode(workflow: WorkflowId, agents: ReturnType, instructions: string, _target: 'cloud' | 'local' = 'cloud', settings: FlowAgentSettings = {}, selected: readonly string[] = [agents.builder, agents.reviewer]) {
- const config = (role: AgentRole) => resolveAgentSettings(workflow, role, selected, settings);
+ const config = (role: AgentRole) => resolveGeneratedAgentSettings(workflow, role, selected, settings);
const options = (role: AgentRole, fallback: string, context = '') => {
const value = config(role);
const cli = value.agent === agents.builder && fallback === 'builder' ? 'builder' : JSON.stringify(value.agent);
@@ -640,7 +640,7 @@ export function workflowCode(workflow: WorkflowId, agents: ReturnType "'" + value.replace(/'/g, "'\\\\''") + "'";
const prototypeAgents = ${JSON.stringify(prototypeConfigs.map(value => value.agent))};
- const prototypeSettings: { model?: string; prompt: string }[] = ${JSON.stringify(prototypeConfigs.map(({ model, prompt }) => ({ ...(model ? { model } : {}), prompt })))};
+ const prototypeSettings: { model: string; prompt: string }[] = ${JSON.stringify(prototypeConfigs.map(({ model, prompt }) => ({ model, prompt })))};
const approaches = ["the smallest change", "a maintainable design", "a different approach"];
const paths = approaches.map((_, index) => prototypeRoot + "/" + (index + 1));
const base = (await f.run("git rev-parse HEAD")).trim();
@@ -650,7 +650,7 @@ export function workflowCode(workflow: WorkflowId, agents: ReturnType f.agent("prototype-" + (index + 1), {
cli: prototypeAgents[index],
cwd,
- ...(prototypeSettings[index].model ? { model: prototypeSettings[index].model } : {}),
+ model: prototypeSettings[index].model,
task: task + "\\n" + prototypeSettings[index].prompt + " Assigned approach: " + approaches[index],
})));
// All three implementations are finished before comparison begins.
diff --git a/web/lib/test/flow-agent-settings.test.ts b/web/lib/test/flow-agent-settings.test.ts
index 739b6ab..992bad2 100644
--- a/web/lib/test/flow-agent-settings.test.ts
+++ b/web/lib/test/flow-agent-settings.test.ts
@@ -1,38 +1,143 @@
import { describe, expect, it } from 'vitest';
import ts from 'typescript';
import { DEFAULT_FACTORY, factorySource, readFactoryDraft, cloudConnectionsHref, type FactoryDraft } from '../flow-onboarding';
-import { resolveAgentSettings } from '../flow-agent-settings';
+import { DEFAULT_AGENT_MODELS, resolveAgentSettings, resolveGeneratedAgentSettings } from '../flow-agent-settings';
import { localKitFiles } from '../flow-local';
-import { FLOW_VALIDATE_CHANGE_METADATA_COMMAND } from '../flow-workflows';
+import { FLOW_CHECK_RUN_COMMAND, FLOW_VALIDATE_CHANGE_METADATA_COMMAND } from '../flow-workflows';
+import type { CodingAgent } from '../flow-agents';
const draft: FactoryDraft = { ...DEFAULT_FACTORY, sources: ['github'], agents: ['claude', 'codex', 'grok', 'cursor'], workflow: 'prototype', step: 3 };
-async function execute(value: FactoryDraft) {
- const source = factorySource(value).replace('import { flow } from "@relayflows/surface";', '');
+type AgentOptions = { cli: CodingAgent; model?: string; task: string; cwd?: string };
+
+async function execute(value: FactoryDraft, target: 'cloud' | 'local' = 'cloud') {
+ const source = factorySource(value, target).replace('import { flow } from "@relayflows/surface";', '');
const compiled = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } });
const exports: { default?: (ctx: unknown, input: unknown) => Promise } = {};
new Function('exports', 'flow', compiled.outputText)(exports, (_name: string, _options: unknown, body: unknown) => body);
- const calls: Record = {};
- await exports.default!({ agent: async (name: string, options: typeof calls[string]) => { calls[name] = options; },
+ const calls: Record = {};
+ let checkRuns = 0;
+ let reviewChecks = 0;
+ await exports.default!({ agent: async (name: string, options: AgentOptions) => { calls[name] = options; },
// "base=..." is the publish check; without a verdict it understands, the
// flow correctly stops before the reviews rather than opening a pull
// request for work that was never committed.
- run: async (command: string) => command.endsWith(FLOW_VALIDATE_CHANGE_METADATA_COMMAND) ? 'valid' : command.startsWith('base=') ? 'publish' : command.startsWith('mktemp') ? '/tmp/prototypes' : command.includes('review.clean &&') ? 'yes' : 'base', done: () => {} },
- { issue: { source: 'github', title: 'Ticket title', body: 'Ticket body', labels: [], identifier: '#507' } });
+ run: async (command: string) => {
+ if (command.endsWith(FLOW_VALIDATE_CHANGE_METADATA_COMMAND)) return 'valid';
+ if (command === FLOW_CHECK_RUN_COMMAND) return ++checkRuns <= 2 ? 'fail' : 'pass';
+ if (command.startsWith('base=')) return 'publish';
+ if (command.startsWith('mktemp')) return '/tmp/prototypes';
+ if (command.includes('review.clean &&')) return ++reviewChecks === 1 ? 'no' : 'yes';
+ return 'base';
+ }, done: () => {} },
+ { issue: { source: value.sources[0] ?? 'github', title: 'Ticket title', body: 'Ticket body', labels: [], identifier: value.sources[0] === 'github' ? '#507' : undefined } });
+ return calls;
+}
+
+function agentObjectProperties(source: string): string[][] {
+ const file = ts.createSourceFile('software-factory.flow.ts', source, ts.ScriptTarget.ES2022, true, ts.ScriptKind.TS);
+ const calls: string[][] = [];
+ const visit = (node: ts.Node) => {
+ if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) &&
+ node.expression.name.text === 'agent' && ts.isObjectLiteralExpression(node.arguments[1])) {
+ calls.push(node.arguments[1].properties.flatMap(property => ts.isPropertyAssignment(property) ? [property.name.getText(file)] : []));
+ }
+ ts.forEachChild(node, visit);
+ };
+ visit(file);
return calls;
}
+function expectSupportedCalls(calls: Record) {
+ expect(Object.keys(calls).length).toBeGreaterThan(0);
+ for (const [name, options] of Object.entries(calls)) {
+ expect(options.cli, `${name} CLI`).toBeTruthy();
+ expect(options.model, `${name} model`).toBe(DEFAULT_AGENT_MODELS[options.cli]);
+ }
+}
+
describe('per-step agent settings', () => {
- it('inherits CLI models and adapts assignments to the selected agents', () => {
+ it('keeps editable overrides blank while generation resolves current model defaults', () => {
+ expect(DEFAULT_AGENT_MODELS).toEqual({ claude: 'claude-sonnet-5', codex: 'gpt-5.6-sol', cursor: 'gpt-5.6-sol-high', grok: 'grok-4.7' });
expect(resolveAgentSettings('traditional', 'planner', ['grok', 'cursor'])).toMatchObject({ agent: 'grok', model: '' });
expect(resolveAgentSettings('traditional', 'adversary', ['grok', 'cursor'])).toMatchObject({ agent: 'cursor', model: '' });
- expect(resolveAgentSettings('prototype', 'prototype-2', ['codex']).agent).toBe('codex');
- expect(resolveAgentSettings('simple', 'implementer', ['claude'], { 'simple:implementer': { agent: 'grok', model: 'grok-model', prompt: 'Custom work' } })).toMatchObject({ agent: 'claude', model: '', prompt: 'Custom work' });
+ expect(resolveGeneratedAgentSettings('traditional', 'planner', ['grok', 'cursor'])).toMatchObject({ agent: 'grok', model: 'grok-4.7' });
+ expect(resolveGeneratedAgentSettings('traditional', 'adversary', ['grok', 'cursor'])).toMatchObject({ agent: 'cursor', model: 'gpt-5.6-sol-high' });
+ expect(resolveGeneratedAgentSettings('prototype', 'prototype-2', ['codex'])).toMatchObject({ agent: 'codex', model: 'gpt-5.6-sol' });
+ expect(resolveGeneratedAgentSettings('simple', 'implementer', ['claude'], { 'simple:implementer': { agent: 'grok', model: 'grok-model', prompt: 'Custom work' } })).toMatchObject({ agent: 'claude', model: 'claude-sonnet-5', prompt: 'Custom work' });
});
it('keeps old OpenCode settings readable while falling back to a supported agent', () => {
const saved = { 'simple:implementer': { agent: 'opencode' as const, model: 'opencode-model', prompt: 'Custom work' } };
expect(readFactoryDraft(JSON.stringify({ ...draft, agentSettings: saved })))?.toMatchObject({ agentSettings: saved });
expect(resolveAgentSettings('simple', 'implementer', ['opencode'], saved)).toMatchObject({ agent: 'claude', model: '', prompt: 'Custom work' });
+ expect(resolveGeneratedAgentSettings('simple', 'implementer', ['opencode'], saved)).toMatchObject({ agent: 'claude', model: 'claude-sonnet-5', prompt: 'Custom work' });
+ });
+
+ it('does not turn a prompt-only editor change into a model override', () => {
+ const current = resolveAgentSettings('simple', 'implementer', ['claude']);
+ expect(current.model).toBe('');
+ const settings = { 'simple:implementer': { ...current, prompt: 'Custom prompt' } };
+ expect(resolveAgentSettings('simple', 'implementer', ['claude'], settings)).toMatchObject({ model: '', prompt: 'Custom prompt' });
+ expect(resolveGeneratedAgentSettings('simple', 'implementer', ['claude'], settings)).toMatchObject({ model: 'claude-sonnet-5', prompt: 'Custom prompt' });
+ });
+
+ it('pins every Claude step in the simple prebuilt flow to a probeable model', async () => {
+ const calls = await execute({ ...draft, agents: ['claude'], workflow: 'simple' });
+ expect(Object.keys(calls)).toEqual(['check-discovery', 'implementer', 'check-repair-1', 'check-repair-2']);
+ for (const options of Object.values(calls)) {
+ expect(options).toMatchObject({ cli: 'claude', model: 'claude-sonnet-5' });
+ }
+ });
+
+ it('gives every generated preset agent an explicit supported CLI/model pair', async () => {
+ const workflows = ['simple', 'traditional', 'prototype'] as const;
+ const targets = ['cloud', 'local'] as const;
+ const sources = ['github', 'slack'] as const;
+ const selections: CodingAgent[][] = [['claude'], ['codex'], ['claude', 'codex'], ['cursor'], ['grok']];
+
+ for (const workflow of workflows) {
+ for (const target of targets) {
+ for (const source of sources) {
+ for (const agents of selections) {
+ const value: FactoryDraft = { ...draft, workflow, sources: [source], sourceSettings: {}, agents };
+ const generated = factorySource(value, target);
+ const objects = agentObjectProperties(generated);
+ expect(objects.length, `${workflow}/${target}/${source}/${agents.join('+')}`).toBeGreaterThan(0);
+ for (const properties of objects) {
+ expect(properties).toContain('cli');
+ expect(properties).toContain('model');
+ }
+ expectSupportedCalls(await execute(value, target));
+ }
+ }
+ }
+ }
+ });
+
+ it('keeps Cloud handoff and local-kit source on the same explicit model contract', () => {
+ for (const workflow of ['simple', 'traditional', 'prototype'] as const) {
+ for (const source of ['github', 'slack'] as const) {
+ for (const agents of [['claude'], ['codex'], ['claude', 'codex'], ['cursor'], ['grok']] as CodingAgent[][]) {
+ const value: FactoryDraft = { ...draft, workflow, sources: [source], sourceSettings: {}, agents };
+ const handoff = JSON.parse(decodeURIComponent(new URL(cloudConnectionsHref(value, 'model-contract')).hash.slice(1))) as { source: string };
+ expect(handoff.source).toBe(factorySource(value, 'cloud'));
+ expect(localKitFiles(value)['software-factory.flow.mts']).toBe(factorySource(value, 'local'));
+ for (const generated of [handoff.source, localKitFiles(value)['software-factory.flow.mts']]) {
+ for (const properties of agentObjectProperties(generated)) {
+ expect(properties).toContain('cli');
+ expect(properties).toContain('model');
+ }
+ }
+ }
+ }
+ }
+ });
+
+ it('materializes the model on the third Simple agent in the failed agent-3 topology', () => {
+ const source = factorySource({ ...draft, workflow: 'simple', agents: ['claude'], sources: ['slack'], sourceSettings: {} });
+ const objects = agentObjectProperties(source);
+ expect(objects[2]).toEqual(expect.arrayContaining(['cli', 'model']));
+ expect(source).not.toContain('claude-opus-5');
});
it('runs distinct prototype overrides while preserving ticket and worktree context', async () => {
@@ -48,7 +153,7 @@ describe('per-step agent settings', () => {
expect(calls['prototype-1'].task).toContain('Ticket title\nTicket body\nKeep changes focused.');
expect(calls['prototype-1'].task).toContain('Assigned approach: the smallest change');
expect(calls['prototype-2'].model).toBe('model-two');
- expect(calls['prototype-3'].model).toBeUndefined();
+ expect(calls['prototype-3'].model).toBe('claude-sonnet-5');
expect(calls.comparator.task).toContain('/tmp/prototypes/1, /tmp/prototypes/2, /tmp/prototypes/3');
expect(calls.implementer).toMatchObject({ cli: 'cursor', model: 'build-model' });
});
@@ -56,7 +161,7 @@ describe('per-step agent settings', () => {
it('applies the shared reviewer settings to both traditional rounds', async () => {
const calls = await execute({ ...draft, workflow: 'traditional', agentSettings: { 'traditional:adversary': { agent: 'grok', model: 'review-model', prompt: 'Check the diff. Write review.clean only if clean.' } } });
for (const role of ['adversary-1', 'adversary-2']) expect(calls[role]).toMatchObject({ cli: 'grok', model: 'review-model' });
- expect(calls.planner.model).toBeUndefined();
+ expect(calls.planner.model).toBe('claude-sonnet-5');
});
it('persists valid overrides and includes them in both handoff sources', () => {
diff --git a/web/lib/test/flow-preview.test.ts b/web/lib/test/flow-preview.test.ts
index a42e136..2a5bd7e 100644
--- a/web/lib/test/flow-preview.test.ts
+++ b/web/lib/test/flow-preview.test.ts
@@ -27,4 +27,10 @@ describe('Cloud visual preview handoff', () => {
expect(flowPreview(draft)!.nodes.find(node => node.title === 'Adversarial review')).toMatchObject({ badge: '2 rounds' });
expect(flowPreview({ ...draft, workflow: 'simple' })!.nodes.map(node => node.title)).toEqual(['Work matches your sources', 'Implement', 'Run checks', 'Open PR', 'Your approval']);
});
+
+ it('shows the current generated model without persisting it as an override', () => {
+ const value = { ...draft, workflow: 'simple' as const, agents: ['claude' as const], agentSettings: undefined };
+ expect(flowPreview(value)!.nodes.find(node => node.title === 'Implement')).toMatchObject({ owner: 'Claude Code', detail: 'claude-sonnet-5' });
+ expect(JSON.parse(decodeURIComponent(cloudConnectionsHref(value, 'default-preview').split('#')[1])).source).toContain('model: "claude-sonnet-5"');
+ });
});