diff --git a/web/app/flows/onboarding/WorkflowPicker.tsx b/web/app/flows/onboarding/WorkflowPicker.tsx
index b956199..11a8041 100644
--- a/web/app/flows/onboarding/WorkflowPicker.tsx
+++ b/web/app/flows/onboarding/WorkflowPicker.tsx
@@ -6,8 +6,8 @@ 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';
@@ -15,7 +15,7 @@ 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 ;
}
diff --git a/web/lib/flow-agent-settings.ts b/web/lib/flow-agent-settings.ts
index 1a038b1..94b27bf 100644
--- a/web/lib/flow-agent-settings.ts
+++ b/web/lib/flow-agent-settings.ts
@@ -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>;
export function rolesForStep(step: WorkflowStep): AgentRole[] {
@@ -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) };
}
@@ -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);
});
diff --git a/web/lib/flow-agents.ts b/web/lib/flow-agents.ts
index de84c5a..e38580b 100644
--- a/web/lib/flow-agents.ts
+++ b/web/lib/flow-agents.ts
@@ -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'];
diff --git a/web/lib/test/flow-agent-settings.test.ts b/web/lib/test/flow-agent-settings.test.ts
index 2cc7f56..faf9130 100644
--- a/web/lib/test/flow-agent-settings.test.ts
+++ b/web/lib/test/flow-agent-settings.test.ts
@@ -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 } });
@@ -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.');
diff --git a/web/lib/test/flow-analytics.test.ts b/web/lib/test/flow-analytics.test.ts
index 5d81d22..138a3a8 100644
--- a/web/lib/test/flow-analytics.test.ts
+++ b/web/lib/test/flow-analytics.test.ts
@@ -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,
});
});
diff --git a/web/lib/test/flow-onboarding.test.ts b/web/lib/test/flow-onboarding.test.ts
index 6a8be58..6b7bab4 100644
--- a/web/lib/test/flow-onboarding.test.ts
+++ b/web/lib/test/flow-onboarding.test.ts
@@ -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}"`);
@@ -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', () => {