Skip to content

Commit 6ca5161

Browse files
committed
refactor: moved testing stage to module
1 parent 9d426e0 commit 6ca5161

10 files changed

Lines changed: 151 additions & 102 deletions

api/src/agent/agent.model.ts

Lines changed: 0 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -89,22 +89,3 @@ export interface PrBodyPromptContext {
8989
baseBranch: string;
9090
branchName: string;
9191
}
92-
93-
export interface TestPromptContext {
94-
repoFullName: string;
95-
issueTitle: string;
96-
plan: string;
97-
hasBrowser: boolean;
98-
priorOutput?: string;
99-
}
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: 0 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import {
33
type PlanPromptContext,
44
type PrBodyPromptContext,
55
type RevisePromptContext,
6-
type TestPromptContext,
76
} from './agent.model.js';
87

98
const PLAN_OUTPUT_CONTRACT = `Your response MUST be the complete, detailed implementation plan — not a statement of intent and not a preamble. Use your tools to fully explore the codebase first, then output the full plan in a single response.
@@ -117,46 +116,3 @@ export function buildPrBodyPrompt(ctx: PrBodyPromptContext): string {
117116
`Output ONLY the PR body as GitHub-flavored Markdown. No preamble, no explanation — just the content.`,
118117
].join('\n\n');
119118
}
120-
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-
133-
export function buildTestPrompt(ctx: TestPromptContext): string {
134-
const parts: string[] = [
135-
`You are Hermes, an autonomous engineer working in a clone of \`${ctx.repoFullName}\`. Your task is to verify the implementation for: **${ctx.issueTitle}**.`,
136-
`--- APPROVED PLAN ---\n${ctx.plan}\n--- END PLAN ---`,
137-
`Instructions:
138-
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.).
139-
2. Run the tests and capture the full output.
140-
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.
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.`,
142-
];
143-
144-
if (ctx.hasBrowser) {
145-
parts.push(
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.`,
147-
);
148-
}
149-
150-
if (ctx.priorOutput) {
151-
parts.push(
152-
`The previous test run ended with this output — use it as your starting point:\n\`\`\`\n${ctx.priorOutput.slice(0, 4000)}\n\`\`\``,
153-
);
154-
}
155-
156-
parts.push(
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,
159-
);
160-
161-
return parts.join('\n\n');
162-
}

api/src/agent/agent.utility.ts

Lines changed: 1 addition & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@ 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 { z } from 'zod';
6-
import { STDOUT_CAP, type RawSpawnResult, type SpawnSpec, type TestResult } from './agent.model.js';
5+
import { STDOUT_CAP, type RawSpawnResult, type SpawnSpec } from './agent.model.js';
76

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

262261
return null;
263262
}
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.module.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,20 @@ import { QueueModule } from '../queue/queue.module.js';
44
import { AgentModule } from '../agent/agent.module.js';
55
import { WorkspaceModule } from '../workspace/workspace.module.js';
66
import { ReviewModule } from '../review/review.module.js';
7+
import { TestingModule } from '../testing/testing.module.js';
78
import { GithubModule } from '../github/github.module.js';
89
import { OrchestratorService } from './orchestrator.service.js';
910

1011
@Module({
11-
imports: [JobModule, QueueModule, AgentModule, WorkspaceModule, ReviewModule, GithubModule],
12+
imports: [
13+
JobModule,
14+
QueueModule,
15+
AgentModule,
16+
WorkspaceModule,
17+
ReviewModule,
18+
TestingModule,
19+
GithubModule,
20+
],
1221
providers: [OrchestratorService],
1322
exports: [OrchestratorService],
1423
})

api/src/orchestrator/orchestrator.service.ts

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ import {
1313
buildPlanPrompt,
1414
buildPrBodyPrompt,
1515
buildRevisePrompt,
16-
buildTestPrompt,
1716
} from '../agent/agent.prompts.js';
17+
import { buildTestPrompt } from '../testing/testing.prompts.js';
1818
import { WorkspaceService } from '../workspace/workspace.service.js';
1919
import { ReviewService } from '../review/review.service.js';
2020
import { buildReviewPrompt } from '../review/review.prompts.js';
@@ -23,7 +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';
26+
import { TestingService } from '../testing/testing.service.js';
2727
import {
2828
type IssueCommentEvent,
2929
type IssueLabeledEvent,
@@ -59,6 +59,7 @@ export class OrchestratorService {
5959
private readonly agent: HermesAgentService,
6060
private readonly workspace: WorkspaceService,
6161
private readonly review: ReviewService,
62+
private readonly testing: TestingService,
6263
private readonly github: GithubService,
6364
) {}
6465

@@ -653,7 +654,7 @@ export class OrchestratorService {
653654
});
654655
await this.workspace.commitAll(ws.dir, 'test: run test suite');
655656

656-
const testResult = res.status === 'SUCCEEDED' ? parseTestResult(res.stdout) : null;
657+
const testResult = res.status === 'SUCCEEDED' ? this.testing.parse(res.stdout) : null;
657658

658659
if (res.status === 'SUCCEEDED' && testResult?.passed === true) {
659660
await this.jobs.transition(jobId, 'SELF_REVIEWING', {
@@ -707,14 +708,9 @@ export class OrchestratorService {
707708
return (lastTestRun.stderr || lastTestRun.stdout || '').slice(0, 4000);
708709
}
709710
// Process exited cleanly but the structured verdict said tests failed.
710-
const result = parseTestResult(lastTestRun.stdout ?? '');
711+
const result = this.testing.parse(lastTestRun.stdout ?? '');
711712
if (!result || result.passed) return undefined;
712-
const lines = [`Summary: ${result.summary}`];
713-
if (result.failures.length > 0) {
714-
lines.push('Failures:');
715-
result.failures.forEach((f, i) => lines.push(`${i + 1}. ${f.name}\n ${f.detail}`));
716-
}
717-
return lines.join('\n');
713+
return this.testing.formatFailures(result);
718714
})();
719715

720716
const issues = lastFailedReview ? (JSON.parse(lastFailedReview.issues) as ReviewIssue[]) : [];

api/src/testing/testing.model.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
export interface TestFailure {
2+
name: string;
3+
detail: string;
4+
}
5+
6+
export interface TestResult {
7+
passed: boolean;
8+
summary: string;
9+
failures: TestFailure[];
10+
}
11+
12+
export interface TestPromptContext {
13+
repoFullName: string;
14+
issueTitle: string;
15+
plan: string;
16+
hasBrowser: boolean;
17+
priorOutput?: string;
18+
}

api/src/testing/testing.module.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { Module } from '@nestjs/common';
2+
import { TestingService } from './testing.service.js';
3+
4+
@Module({
5+
providers: [TestingService],
6+
exports: [TestingService],
7+
})
8+
export class TestingModule {}

api/src/testing/testing.prompts.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { type TestPromptContext } from './testing.model.js';
2+
3+
const TEST_OUTPUT_CONTRACT = `Output your verdict as the FIRST thing in your response — a \`\`\`json block before any other text:
4+
\`\`\`json
5+
{
6+
"passed": true | false,
7+
"summary": "<one-paragraph summary of what ran and the overall outcome>",
8+
"failures": [
9+
{ "name": "<test name, command, or category that failed>", "detail": "<what went wrong and any relevant output>" }
10+
]
11+
}
12+
\`\`\`
13+
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.`;
14+
15+
export function buildTestPrompt(ctx: TestPromptContext): string {
16+
const parts: string[] = [
17+
`You are Hermes, an autonomous engineer working in a clone of \`${ctx.repoFullName}\`. Your task is to verify the implementation for: **${ctx.issueTitle}**.`,
18+
`--- APPROVED PLAN ---\n${ctx.plan}\n--- END PLAN ---`,
19+
`Instructions:
20+
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.).
21+
2. Run the tests and capture the full output.
22+
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.
23+
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.`,
24+
];
25+
26+
if (ctx.hasBrowser) {
27+
parts.push(
28+
`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.`,
29+
);
30+
}
31+
32+
if (ctx.priorOutput) {
33+
parts.push(
34+
`The previous test run ended with this output — use it as your starting point:\n\`\`\`\n${ctx.priorOutput.slice(0, 4000)}\n\`\`\``,
35+
);
36+
}
37+
38+
parts.push(
39+
`Use \`.olympian/\` as a scratch directory for any temporary files (diffs, logs, etc.) — it is excluded from commits automatically. Do not run git yourself.`,
40+
TEST_OUTPUT_CONTRACT,
41+
);
42+
43+
return parts.join('\n\n');
44+
}

api/src/testing/testing.service.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { Injectable } from '@nestjs/common';
2+
import { AppConfigService } from '../config/config.service.js';
3+
import { type TestResult } from './testing.model.js';
4+
import { formatTestFailures, parseTestResult } from './testing.utility.js';
5+
6+
/**
7+
* Owns the TEST phase policy: structured verdict parsing, failure formatting, and
8+
* the iteration cap that prevents the TEST→REVISE loop from running indefinitely.
9+
*/
10+
@Injectable()
11+
export class TestingService {
12+
constructor(private readonly config: AppConfigService) {}
13+
14+
get maxIterations(): number {
15+
return this.config.get('MAX_TEST_ITERATIONS');
16+
}
17+
18+
parse(stdout: string): TestResult | null {
19+
return parseTestResult(stdout);
20+
}
21+
22+
formatFailures(result: TestResult): string {
23+
return formatTestFailures(result);
24+
}
25+
}

api/src/testing/testing.utility.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { z } from 'zod';
2+
import { extractJsonBlock } from '../agent/agent.utility.js';
3+
import { type TestFailure, type TestResult } from './testing.model.js';
4+
5+
const testFailureSchema = z.object({
6+
name: z.string().default(''),
7+
detail: z.string().default(''),
8+
});
9+
10+
const testResultSchema = z.object({
11+
passed: z.boolean(),
12+
summary: z.string().default(''),
13+
failures: z.array(testFailureSchema).default([]),
14+
});
15+
16+
/**
17+
* Parses the test agent's stdout into a structured result. Returns null when the
18+
* agent did not emit a valid JSON verdict (caller treats that as a failing run).
19+
*/
20+
export function parseTestResult(stdout: string): TestResult | null {
21+
const raw = extractJsonBlock(stdout);
22+
const parsed = testResultSchema.safeParse(raw);
23+
if (!raw || !parsed.success) {
24+
return null;
25+
}
26+
return parsed.data;
27+
}
28+
29+
/** Formats a parsed TestResult's failures as a human-readable string for the REVISE prompt. */
30+
export function formatTestFailures(result: TestResult): string {
31+
const lines = [`Summary: ${result.summary}`];
32+
if (result.failures.length > 0) {
33+
lines.push('Failures:');
34+
result.failures.forEach((f: TestFailure, i: number) =>
35+
lines.push(`${i + 1}. ${f.name}\n ${f.detail}`),
36+
);
37+
}
38+
return lines.join('\n');
39+
}

0 commit comments

Comments
 (0)