Skip to content

Commit 127182e

Browse files
committed
fix: corrected test stage to structured output
1 parent 530fdaa commit 127182e

4 files changed

Lines changed: 77 additions & 12 deletions

File tree

api/src/agent/agent.model.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,3 +97,14 @@ export interface TestPromptContext {
9797
hasBrowser: boolean;
9898
priorOutput?: string;
9999
}
100+
101+
export interface TestFailure {
102+
name: string;
103+
detail: string;
104+
}
105+
106+
export interface TestResult {
107+
passed: boolean;
108+
summary: string;
109+
failures: TestFailure[];
110+
}

api/src/agent/agent.prompts.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -118,20 +118,32 @@ export function buildPrBodyPrompt(ctx: PrBodyPromptContext): string {
118118
].join('\n\n');
119119
}
120120

121+
const TEST_OUTPUT_CONTRACT = `Output your verdict as the FIRST thing in your response — a \`\`\`json block before any other text:
122+
\`\`\`json
123+
{
124+
"passed": true | false,
125+
"summary": "<one-paragraph summary of what ran and the overall outcome>",
126+
"failures": [
127+
{ "name": "<test name, command, or category that failed>", "detail": "<what went wrong and any relevant output>" }
128+
]
129+
}
130+
\`\`\`
131+
Set "passed" to true only if ALL tests pass AND the application starts and runs correctly. Use an empty array for "failures" when passing. You may include detailed output after the JSON block.`;
132+
121133
export function buildTestPrompt(ctx: TestPromptContext): string {
122134
const parts: string[] = [
123-
`You are Hermes, an autonomous engineer working in a clone of \`${ctx.repoFullName}\`. Your task is to ensure the test suite passes for the changes made to resolve: **${ctx.issueTitle}**.`,
135+
`You are Hermes, an autonomous engineer working in a clone of \`${ctx.repoFullName}\`. Your task is to verify the implementation for: **${ctx.issueTitle}**.`,
124136
`--- APPROVED PLAN ---\n${ctx.plan}\n--- END PLAN ---`,
125137
`Instructions:
126138
1. Discover the test suite by inspecting the project structure (look for \`package.json\` test scripts, \`pytest.ini\`, \`jest.config.*\`, \`vitest.config.*\`, \`go.mod\`, \`Makefile\`, etc.).
127139
2. Run the tests and capture the full output.
128140
3. **Try to run the application itself** — start the dev server, CLI, or process and verify it launches without errors. For web/browser applications, open the running app in the browser and exercise the key user flows from the acceptance criteria. For CLI tools, invoke the main commands and check the output.
129-
4. Report the results. Do NOT modify any source files — if tests fail or the application errors, document what went wrong and stop. Fixes are handled in a separate step.`,
141+
4. Do NOT modify any source files — if tests fail or the application errors, document what went wrong. Fixes are handled in a separate step.`,
130142
];
131143

132144
if (ctx.hasBrowser) {
133145
parts.push(
134-
`A Camofox browser is available for step 5. Use it to open the running application and manually verify the acceptance criteria — click through real user flows, not just check that the page loads.`,
146+
`A Camofox browser is available. Use it to open the running application and manually verify the acceptance criteria — click through real user flows, not just check that the page loads.`,
135147
);
136148
}
137149

@@ -142,7 +154,8 @@ export function buildTestPrompt(ctx: TestPromptContext): string {
142154
}
143155

144156
parts.push(
145-
`When done, write a brief summary of what ran, what passed, and what failed or errored (if anything). Use \`.olympian/\` as a scratch directory for any temporary files (diffs, logs, etc.) — it is excluded from commits automatically. Do not run git yourself.`,
157+
`Use \`.olympian/\` as a scratch directory for any temporary files (diffs, logs, etc.) — it is excluded from commits automatically. Do not run git yourself.`,
158+
TEST_OUTPUT_CONTRACT,
146159
);
147160

148161
return parts.join('\n\n');

api/src/agent/agent.utility.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import { randomUUID } from 'node:crypto';
22
import { writeFileSync } from 'node:fs';
33
import { join } from 'node:path';
44
import { spawn } from 'node:child_process';
5-
import { STDOUT_CAP, type RawSpawnResult, type SpawnSpec } from './agent.model.js';
5+
import { z } from 'zod';
6+
import { STDOUT_CAP, type RawSpawnResult, type SpawnSpec, type TestResult } from './agent.model.js';
67

78
const HERMES_CONTAINER_HOME = '/root/.hermes';
89
const CONTAINER_WORKDIR = '/workspace';
@@ -260,3 +261,29 @@ export function extractJsonBlock(text: string): unknown | null {
260261

261262
return null;
262263
}
264+
265+
const testFailureSchema = z.object({
266+
name: z.string().default(''),
267+
detail: z.string().default(''),
268+
});
269+
270+
const testResultSchema = z.object({
271+
passed: z.boolean(),
272+
summary: z.string().default(''),
273+
failures: z.array(testFailureSchema).default([]),
274+
});
275+
276+
/**
277+
* Parses the test agent's stdout into a structured result. Returns null when the
278+
* agent did not emit a valid JSON verdict (caller treats that as a failing run).
279+
*/
280+
export function parseTestResult(stdout: string): TestResult | null {
281+
const raw = extractJsonBlock(stdout);
282+
const parsed = testResultSchema.safeParse(raw);
283+
284+
if (!raw || !parsed.success) {
285+
return null;
286+
}
287+
288+
return parsed.data;
289+
}

api/src/orchestrator/orchestrator.service.ts

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { formatIssues, formatIssuesMarkdown, parseReview } from '../review/revie
2323
import { GithubService } from '../github/github.service.js';
2424
import { APPROVAL_PERMISSIONS, type RepoRef, type ReviewFeedback } from '../github/github.model.js';
2525
import { extractAttachmentUrls } from '../github/github.utility.js';
26+
import { parseTestResult } from '../agent/agent.utility.js';
2627
import {
2728
type IssueCommentEvent,
2829
type IssueLabeledEvent,
@@ -652,15 +653,20 @@ export class OrchestratorService {
652653
});
653654
await this.workspace.commitAll(ws.dir, 'test: run test suite');
654655

655-
if (res.status === 'SUCCEEDED') {
656+
const testResult = res.status === 'SUCCEEDED' ? parseTestResult(res.stdout) : null;
657+
658+
if (res.status === 'SUCCEEDED' && testResult?.passed === true) {
656659
await this.jobs.transition(jobId, 'SELF_REVIEWING', {
657660
reason: 'tests passed',
658661
actor: 'AGENT',
659662
});
660663
await this.queue.enqueue({ jobId, kind: 'REVIEW' });
661664
} else {
662-
this.logger.warn(`[job ${jobId}] test ${res.status}; routing to revise`);
663-
await this.jobs.transition(jobId, 'REVISING', { reason: 'tests failed', actor: 'AGENT' });
665+
const reason = testResult
666+
? `tests failed: ${testResult.failures.map((f) => f.name).join(', ') || 'see summary'}`
667+
: `test agent ${res.status}`;
668+
this.logger.warn(`[job ${jobId}] ${reason}; routing to revise`);
669+
await this.jobs.transition(jobId, 'REVISING', { reason, actor: 'AGENT' });
664670
await this.queue.enqueue({ jobId, kind: 'REVISE' });
665671
}
666672
}
@@ -695,10 +701,18 @@ export class OrchestratorService {
695701
}),
696702
]);
697703

698-
const testOutput =
699-
lastTestRun && lastTestRun.status !== 'SUCCEEDED'
700-
? (lastTestRun.stderr || lastTestRun.stdout || '').slice(0, 4000)
701-
: undefined;
704+
const testOutput = (() => {
705+
if (!lastTestRun) return undefined;
706+
if (lastTestRun.status !== 'SUCCEEDED') {
707+
return (lastTestRun.stderr || lastTestRun.stdout || '').slice(0, 4000);
708+
}
709+
// Process exited cleanly but the structured verdict said tests failed.
710+
const result = parseTestResult(lastTestRun.stdout ?? '');
711+
if (result && !result.passed) {
712+
return (lastTestRun.stdout ?? '').slice(0, 4000);
713+
}
714+
return undefined;
715+
})();
702716

703717
const issues = lastFailedReview ? (JSON.parse(lastFailedReview.issues) as ReviewIssue[]) : [];
704718
const issuesText = issues.length > 0 ? formatIssues(issues) : undefined;

0 commit comments

Comments
 (0)