From 72a225081367413f0dd69f8f37ea152e921b4836 Mon Sep 17 00:00:00 2001 From: Raphael Antonietti Date: Thu, 30 Jul 2026 09:17:41 +0200 Subject: [PATCH] fix(forge): use plain dispatch for no-diff validation --- packages/forge/src/generated/stages.ts | 11 +-- packages/forge/src/kern/stages.kern | 3 +- tests/unit/forge-finalize.test.ts | 104 +++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 6 deletions(-) diff --git a/packages/forge/src/generated/stages.ts b/packages/forge/src/generated/stages.ts index e1c8c46ea..3756dfc06 100644 --- a/packages/forge/src/generated/stages.ts +++ b/packages/forge/src/generated/stages.ts @@ -169,7 +169,8 @@ export async function runForgeEngineAttempt(opts: {engineId:string,prompt:string // Forge is a code-changing workflow. API-only engines must run through // the tool-using agent loop; plain API chat can only return text and will // never modify the worktree. - const useAgent = !!opts.adapter.dispatchAgent && (!!engine.agent || !!engine.api); + const shouldUseAgentLoop = forgeMode === 'implement' || forgeMode === 'improve' || requireDiff; + const useAgent = shouldUseAgentLoop && !!opts.adapter.dispatchAgent && (!!engine.agent || !!engine.api); const onSpawn = (pid: number) => emitForgeEvent(opts.onEvent, 'engine:pid', opts.engineId, { engineId: opts.engineId, pid, phase: opts.metricPhase }); try { if (useAgent) { @@ -298,7 +299,7 @@ export async function runForgeEngineAttempt(opts: {engineId:string,prompt:string return { result, metric, dispatchResult, worktreePath: wtPath }; } -// @kern-source: stages:276 +// @kern-source: stages:277 export async function runBaseline(opts: {cwd:string, baseSha:string, fitnessCmd:string, fitnessTimeout:number, forgeDir:string, onEvent?:ForgeEventCallback}): Promise { opts.onEvent?.({ type: 'baseline:start' }); @@ -322,7 +323,7 @@ export async function runBaseline(opts: {cwd:string, baseSha:string, fitnessCmd: } } -// @kern-source: stages:300 +// @kern-source: stages:301 export async function runStage1(opts: {starter:string, forgePrompt:string, fitnessCmd:string, config:Required, registry:EngineRegistry, adapter:EngineAdapter, cwd:string, baseSha:string, forgeDir:string, worktrees:WorktreeEntry[], onEvent?:ForgeEventCallback, signal?:AbortSignal, taskClass?:string, enginePrompts?:Map, forgeMode?:'implement'|'improve'|'validate', requireDiff?:boolean, baselinePasses?:boolean, acceptReviewOutput?:boolean}): Promise { opts.onEvent?.({ type: 'stage1:start', engineId: opts.starter }); const root = repoRoot(opts.cwd); @@ -352,7 +353,7 @@ export async function runStage1(opts: {starter:string, forgePrompt:string, fitne return { engineResults, accepted, winner: result?.pass ? result.engineId : null, metrics }; } -// @kern-source: stages:330 +// @kern-source: stages:331 export async function runStage2(opts: {challengers:string[], forgePrompt:string, enginePrompts?:Map, fitnessCmd:string, config:Required, registry:EngineRegistry, adapter:EngineAdapter, cwd:string, baseSha:string, forgeDir:string, existingResults:Map, worktrees:WorktreeEntry[], onEvent?:ForgeEventCallback, signal?:AbortSignal, onResult?:(engineId:string,result:EngineResult,metric:DispatchMetric)=>'continue'|'finalize'|void, abortControllers?: Map, forgeMode?: 'implement'|'improve'|'validate', requireDiff?: boolean, baselinePasses?: boolean, acceptReviewOutput?: boolean, earlyFinalizeCount?: number}): Promise { opts.onEvent?.({ type: 'stage2:start' }); @@ -551,7 +552,7 @@ export async function runStage2(opts: {challengers:string[], forgePrompt:string, return { engineResults: allResults, accepted: false, winner: null, metrics }; } -// @kern-source: stages:529 +// @kern-source: stages:530 export async function runStage2WithPeek(opts: {challengers:string[], forgePrompt:string, enginePrompts?:Map, fitnessCmd:string, config:Required, registry:EngineRegistry, adapter:EngineAdapter, cwd:string, baseSha:string, forgeDir:string, existingResults:Map, worktrees:WorktreeEntry[], onEvent?:ForgeEventCallback, signal?:AbortSignal, forgeMode?:'implement'|'improve'|'validate', requireDiff?:boolean, baselinePasses?:boolean, acceptReviewOutput?:boolean}): Promise { if (opts.challengers.length <= 1) { // Only one challenger — no peek possible, use normal stage2 diff --git a/packages/forge/src/kern/stages.kern b/packages/forge/src/kern/stages.kern index 546beaeba..2d67dfc1f 100644 --- a/packages/forge/src/kern/stages.kern +++ b/packages/forge/src/kern/stages.kern @@ -144,7 +144,8 @@ fn name=runForgeEngineAttempt async=true params="opts:{engineId:string,prompt:st // Forge is a code-changing workflow. API-only engines must run through // the tool-using agent loop; plain API chat can only return text and will // never modify the worktree. - const useAgent = !!opts.adapter.dispatchAgent && (!!engine.agent || !!engine.api); + const shouldUseAgentLoop = forgeMode === 'implement' || forgeMode === 'improve' || requireDiff; + const useAgent = shouldUseAgentLoop && !!opts.adapter.dispatchAgent && (!!engine.agent || !!engine.api); const onSpawn = (pid: number) => emitForgeEvent(opts.onEvent, 'engine:pid', opts.engineId, { engineId: opts.engineId, pid, phase: opts.metricPhase }); try { if (useAgent) { diff --git a/tests/unit/forge-finalize.test.ts b/tests/unit/forge-finalize.test.ts index de4068f81..7e750db67 100644 --- a/tests/unit/forge-finalize.test.ts +++ b/tests/unit/forge-finalize.test.ts @@ -62,6 +62,110 @@ function makeRegistry(): EngineRegistry { return registry; } +describe('forge validate dispatch routing', () => { + it('uses plain dispatch, not agent tools, for validate mode without required diff', async () => { + process.env.AGON_FINALIZE_TEST_KEY = 'test'; + const repoDir = makeRepo(); + const forgeDir = join(tmpdir(), `agon-forge-validate-route-${Date.now()}-${Math.random().toString(36).slice(2)}`); + tempDirs.push(forgeDir); + mkdirSync(forgeDir, { recursive: true }); + + const registry = new EngineRegistry(); + registry.register({ + id: 'api-agent', + displayName: 'api-agent', + api: { apiKeyEnv: 'AGON_FINALIZE_TEST_KEY' }, + agent: { args: [] }, + exec: { args: [] }, + review: { args: [] }, + timeout: 120, + tier: 'user', + schemaVersion: 3, + isLocal: false, + } as any); + + const dispatch = vi.fn(async () => ({ + exitCode: 0, + stdout: 'VALIDATE_ROUTE_OK '.repeat(8), + stderr: '', + timedOut: false, + })); + const dispatchAgent = vi.fn(async () => { + throw new Error('dispatchAgent should not run for validate/no-diff'); + }); + + const manifest = await runForge( + { + task: 'validate without editing', + fitnessCmd: 'true', + cwd: repoDir, + forgeDir, + engines: ['api-agent'], + mode: 'validate', + requireDiff: false, + acceptReviewOutput: true, + baselineMayPass: true, + healthCheckEnabled: false, + } as any, + registry, + { isAvailable: async () => true, getVersion: async () => 'test', dispatch, dispatchAgent } as any, + ); + + expect(dispatch).toHaveBeenCalledTimes(1); + expect(dispatchAgent).not.toHaveBeenCalled(); + expect(manifest.results['api-agent']).toMatchObject({ pass: true, status: 'no_diff_but_report' }); + }); + + it('still uses agent tools for validate mode when a diff is required', async () => { + process.env.AGON_FINALIZE_TEST_KEY = 'test'; + const repoDir = makeRepo(); + const forgeDir = join(tmpdir(), `agon-forge-validate-agent-${Date.now()}-${Math.random().toString(36).slice(2)}`); + tempDirs.push(forgeDir); + mkdirSync(forgeDir, { recursive: true }); + + const registry = new EngineRegistry(); + registry.register({ + id: 'api-agent', + displayName: 'api-agent', + api: { apiKeyEnv: 'AGON_FINALIZE_TEST_KEY' }, + agent: { args: [] }, + exec: { args: [] }, + review: { args: [] }, + timeout: 120, + tier: 'user', + schemaVersion: 3, + isLocal: false, + } as any); + + const dispatch = vi.fn(async () => ({ exitCode: 0, stdout: 'plain', stderr: '', timedOut: false })); + const dispatchAgent = vi.fn(async ({ cwd }: any) => { + writeFileSync(join(cwd, 'agent-change.txt'), 'changed by agent\n'); + return { exitCode: 0, stdout: 'agent changed files', stderr: '', timedOut: false }; + }); + + const manifest = await runForge( + { + task: 'validate but require a diff', + fitnessCmd: 'test -f agent-change.txt', + cwd: repoDir, + forgeDir, + engines: ['api-agent'], + mode: 'validate', + requireDiff: true, + acceptReviewOutput: true, + baselineMayPass: true, + healthCheckEnabled: false, + } as any, + registry, + { isAvailable: async () => true, getVersion: async () => 'test', dispatch, dispatchAgent } as any, + ); + + expect(dispatchAgent).toHaveBeenCalledTimes(1); + expect(dispatch).not.toHaveBeenCalled(); + expect(manifest.results['api-agent']).toMatchObject({ pass: true }); + }); +}); + describe('caller-driven forge finalize', () => { it('aborts in-flight stage2 engines when onResult requests finalize', async () => { process.env.AGON_FINALIZE_TEST_KEY = 'test';