Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions web/app/flows/onboarding/WorkflowPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,16 @@ import { WORKFLOWS, WORKFLOW_STEP_DETAILS, workflowAgents } from '../../../lib/f
import { SiGithub, SiGitlab } from 'react-icons/si';
import Claude from '@lobehub/icons/es/Claude';
import Codex from '@lobehub/icons/es/Codex';
import Grok from '@lobehub/icons/es/Grok';
import Cursor from '@lobehub/icons/es/Cursor';
import OpenCode from '@lobehub/icons/es/OpenCode';
import { agentLabel, canContinue, isCodingAgent, type CodingAgent, type FactoryDraft } from '../../../lib/flow-onboarding';
import { repositoryHost, sourceLabel, sourceSummary } from '../../../lib/flow-sources';
import { SourceIcon } from './SourcePicker';
import type { FlowTrack } from '../../../lib/flow-analytics';
import s from './onboarding.module.css';

function ProcessAgent({ id }: { id: CodingAgent }) {
const Icon = { claude: Claude.Color, codex: Codex.Color, cursor: Cursor, opencode: OpenCode }[id];
const Icon = { claude: Claude.Color, codex: Codex.Color, cursor: Cursor, grok: Grok }[id];
return <span className={s.processAgent} role="img" aria-label={agentLabel(id)} title={agentLabel(id)}><Icon size={21} aria-hidden="true" /></span>;
}

Expand Down
8 changes: 4 additions & 4 deletions web/lib/flow-agent-settings.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { isCodingAgent, type CodingAgent } from './flow-agents';
import { CODING_AGENTS, isCodingAgent, type AgentId, type CodingAgent } from './flow-agents';
import type { WorkflowId, WorkflowStep } from './flow-workflows';

export const AGENT_ROLES = ['planner', 'plan-reviewer', 'prototype-1', 'prototype-2', 'prototype-3', 'comparator', 'implementer', 'adversary', 'fixer', 'check-discovery', 'check-repair'] as const;
export type AgentRole = typeof AGENT_ROLES[number];
export type AgentSettings = { agent?: CodingAgent; model?: string; prompt?: string };
export type AgentSettings = { agent?: AgentId; model?: string; prompt?: string };
export type FlowAgentSettings = Partial<Record<`${WorkflowId}:${AgentRole}`, AgentSettings>>;

export function rolesForStep(step: WorkflowStep): AgentRole[] {
Expand Down Expand Up @@ -36,7 +36,7 @@ export function resolveAgentSettings(workflow: WorkflowId, role: AgentRole, sele
const defaultAgent = ['plan-reviewer', 'comparator', 'adversary', 'prototype-2'].includes(role) ? reviewer : builder;
const saved = settings[`${workflow}:${role}`];
// Changing the selected agents must never leave an unavailable CLI assigned.
const agent = saved?.agent && available.includes(saved.agent) ? saved.agent : defaultAgent;
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) };
}
Expand All @@ -48,7 +48,7 @@ export function validFlowAgentSettings(value: unknown): value is FlowAgentSettin
const [workflow, role, extra] = key.split(':');
if (extra || !['traditional', 'prototype', 'simple'].includes(workflow) || !(AGENT_ROLES as readonly string[]).includes(role)) return false;
if (!settings || typeof settings !== 'object' || Array.isArray(settings)) return false;
return Object.entries(settings).every(([field, v]) => field === 'agent' ? typeof v === 'string' && isCodingAgent(v)
return Object.entries(settings).every(([field, v]) => field === 'agent' ? typeof v === 'string' && CODING_AGENTS.some(agent => agent.id === v)
: field === 'model' ? typeof v === 'string' && v.length <= 120 && !/[\r\n\0]/.test(v)
: field === 'prompt' ? typeof v === 'string' && v.trim().length > 0 && v.length <= 6000 : false);
});
Expand Down
4 changes: 2 additions & 2 deletions web/lib/flow-agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@ export const CODING_AGENTS = [
{ id: 'claude', label: 'Claude Code', available: true },
{ id: 'codex', label: 'Codex', available: true },
{ id: 'gemini', label: 'Gemini CLI', available: false },
{ id: 'opencode', label: 'OpenCode', available: true },
{ id: 'opencode', label: 'OpenCode', available: false },
{ id: 'cursor', label: 'Cursor', available: true },
{ id: 'copilot', label: 'GitHub Copilot', available: false },
{ id: 'windsurf', label: 'Windsurf', available: false },
{ id: 'aider', label: 'Aider', available: false },
{ id: 'goose', label: 'Goose', available: false },
{ id: 'grok', label: 'Grok', available: false },
{ id: 'grok', label: 'Grok', available: true },
{ id: 'pi', label: 'Pi', available: false },
] as const;
export type AgentId = (typeof CODING_AGENTS)[number]['id'];
Expand Down
32 changes: 19 additions & 13 deletions web/lib/test/flow-agent-settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { DEFAULT_FACTORY, factorySource, readFactoryDraft, cloudConnectionsHref,
import { resolveAgentSettings } from '../flow-agent-settings';
import { localKitFiles } from '../flow-local';

const draft: FactoryDraft = { ...DEFAULT_FACTORY, sources: ['github'], agents: ['claude', 'codex', 'cursor', 'opencode'], workflow: 'prototype', step: 3 };
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";', '');
const compiled = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } });
Expand All @@ -22,41 +22,47 @@ async function execute(value: FactoryDraft) {

describe('per-step agent settings', () => {
it('inherits CLI models and adapts assignments to the selected agents', () => {
expect(resolveAgentSettings('traditional', 'planner', ['cursor', 'opencode'])).toMatchObject({ agent: 'cursor', model: '' });
expect(resolveAgentSettings('traditional', 'adversary', ['cursor', 'opencode'])).toMatchObject({ agent: 'opencode', model: '' });
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: 'cursor', model: 'cursor-model', prompt: 'Custom work' } })).toMatchObject({ agent: 'claude', model: '', prompt: 'Custom work' });
expect(resolveAgentSettings('simple', 'implementer', ['claude'], { 'simple:implementer': { agent: 'grok', model: 'grok-model', prompt: 'Custom work' } })).toMatchObject({ agent: 'claude', model: '', 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' });
});

it('runs distinct prototype overrides while preserving ticket and worktree context', async () => {
const prompt = 'Use "quotes", `ticks`, ${literal}, and a newline.\nWrite prototype-notes.md.';
const calls = await execute({ ...draft, task: 'Keep changes focused.', agentSettings: {
'prototype:prototype-1': { agent: 'cursor', model: 'model-one', prompt },
'prototype:prototype-2': { agent: 'opencode', model: 'model-two' },
'prototype:prototype-1': { agent: 'grok', model: 'model-one', prompt },
'prototype:prototype-2': { agent: 'cursor', model: 'model-two' },
'prototype:comparator': { agent: 'codex', prompt: 'Write comparison.md.' },
'prototype:implementer': { agent: 'opencode', model: 'build-model' },
'prototype:implementer': { agent: 'cursor', model: 'build-model' },
} });
expect(calls['prototype-1']).toMatchObject({ cli: 'cursor', model: 'model-one', cwd: '/tmp/prototypes/1' });
expect(calls['prototype-1']).toMatchObject({ cli: 'grok', model: 'model-one', cwd: '/tmp/prototypes/1' });
expect(calls['prototype-1'].task).toContain(prompt);
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.comparator.task).toContain('/tmp/prototypes/1, /tmp/prototypes/2, /tmp/prototypes/3');
expect(calls.implementer).toMatchObject({ cli: 'opencode', model: 'build-model' });
expect(calls.implementer).toMatchObject({ cli: 'cursor', model: 'build-model' });
});

it('applies the shared reviewer settings to both traditional rounds', async () => {
const calls = await execute({ ...draft, workflow: 'traditional', agentSettings: { 'traditional:adversary': { agent: 'cursor', 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: 'cursor', model: 'review-model' });
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();
});

it('persists valid overrides and includes them in both handoff sources', () => {
const value: FactoryDraft = { ...draft, agentSettings: { 'prototype:implementer': { agent: 'cursor', model: 'custom-model', prompt: 'Write summary.md.' } } };
const value: FactoryDraft = { ...draft, agentSettings: { 'prototype:implementer': { agent: 'grok', model: 'custom-model', prompt: 'Write summary.md.' } } };
expect(readFactoryDraft(JSON.stringify(value))?.agentSettings).toEqual(value.agentSettings);
expect(localKitFiles(value)['software-factory.flow.mts']).toContain('model: "custom-model"');
expect(localKitFiles(value)['START-HERE.txt']).toContain('Cursor');
expect(localKitFiles(value)['START-HERE.txt']).toContain('Grok');
const cloud = JSON.parse(decodeURIComponent(new URL(cloudConnectionsHref(value, 'test')).hash.slice(1)));
expect(cloud.source).toContain('model: "custom-model"');
expect(cloud.source).toContain('Write summary.md.');
Expand Down
4 changes: 2 additions & 2 deletions web/lib/test/flow-analytics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ function fixture() {
}

describe('onboarding funnel accounting', () => {
it('counts Cursor and OpenCode as supported agents', () => {
expect(flowMetrics({ ...DEFAULT_FACTORY, agents: ['cursor', 'opencode', 'pi'] })).toMatchObject({
it('counts Grok and Cursor as supported agents', () => {
expect(flowMetrics({ ...DEFAULT_FACTORY, agents: ['grok', 'cursor', 'opencode'] })).toMatchObject({
supported_agent_count: 2, requested_agent_count: 1,
});
});
Expand Down
16 changes: 8 additions & 8 deletions web/lib/test/flow-onboarding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ describe('software factory onboarding', () => {
expect(readFactoryDraft(JSON.stringify(draft))?.agents).toEqual(['windsurf', 'gemini']);
});

it.each(['cursor', 'opencode'] as const)('uses %s throughout a workflow without falling back to Claude', async agent => {
it.each(['grok', 'cursor'] as const)('uses %s throughout a workflow without falling back to Claude', async agent => {
const draft = { ...completed, agents: [agent] };
expect(primaryAgent(draft)).toBe(agent);
expect(factorySource(draft)).toContain(`const builder = "${agent}"`);
Expand All @@ -125,14 +125,14 @@ describe('software factory onboarding', () => {
expect(calls.some(call => call.endsWith(':claude'))).toBe(false);
});

it('assigns Cursor and OpenCode distinct prototype and review roles', async () => {
const draft: FactoryDraft = { ...completed, agents: ['cursor', 'opencode'], workflow: 'prototype' };
it('assigns Grok and Cursor distinct prototype and review roles', async () => {
const draft: FactoryDraft = { ...completed, agents: ['grok', 'cursor'], workflow: 'prototype' };
const { calls } = await runFactory([true], true, matchingIssue, draft);
expect(calls).toContain('prototype-1:cursor');
expect(calls).toContain('prototype-2:opencode');
expect(calls).toContain('prototype-3:cursor');
expect(calls).toContain('comparator:opencode');
expect(calls).toContain('implementer:cursor');
expect(calls).toContain('prototype-1:grok');
expect(calls).toContain('prototype-2:cursor');
expect(calls).toContain('prototype-3:grok');
expect(calls).toContain('comparator:cursor');
expect(calls).toContain('implementer:grok');
});

it('restores incomplete drafts to the first unanswered question', () => {
Expand Down
Loading