Skip to content

Commit 3d2f647

Browse files
committed
feat: introduced deterministic and browser based testing
1 parent 4b3ee40 commit 3d2f647

7 files changed

Lines changed: 112 additions & 6 deletions

File tree

api/src/agent/agent.model.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export const AGENT_PHASES = ['PLAN', 'IMPLEMENT', 'REVIEW', 'REVISE', 'PR_BODY'] as const;
1+
export const AGENT_PHASES = ['PLAN', 'IMPLEMENT', 'TEST', 'REVIEW', 'REVISE', 'PR_BODY'] as const;
22
export type AgentPhase = (typeof AGENT_PHASES)[number];
33

44
export type AgentRunStatus = 'RUNNING' | 'SUCCEEDED' | 'FAILED' | 'TIMED_OUT';
@@ -83,3 +83,11 @@ export interface PrBodyPromptContext {
8383
baseBranch: string;
8484
branchName: string;
8585
}
86+
87+
export interface TestPromptContext {
88+
repoFullName: string;
89+
issueTitle: string;
90+
plan: string;
91+
hasBrowser: boolean;
92+
priorOutput?: string;
93+
}

api/src/agent/agent.prompts.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
type PlanPromptContext,
44
type PrBodyPromptContext,
55
type RevisePromptContext,
6+
type TestPromptContext,
67
} from './agent.model.js';
78

89
const PLAN_OUTPUT_CONTRACT = `Respond with ONLY the implementation plan as GitHub-flavored Markdown.
@@ -75,3 +76,29 @@ export function buildPrBodyPrompt(ctx: PrBodyPromptContext): string {
7576
`Output ONLY the PR body as GitHub-flavored Markdown. No preamble, no explanation — just the content.`,
7677
].join('\n\n');
7778
}
79+
80+
export function buildTestPrompt(ctx: TestPromptContext): string {
81+
const parts: string[] = [
82+
`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}**.`,
83+
`--- APPROVED PLAN ---\n${ctx.plan}\n--- END PLAN ---`,
84+
`Instructions:
85+
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.).
86+
2. Run the tests and capture the output.
87+
3. If tests fail, diagnose the root cause from the output and fix the code.
88+
4. Re-run until all tests pass or you have exhausted your budget.`,
89+
];
90+
if (ctx.hasBrowser) {
91+
parts.push(
92+
`A browser is available. Use browser tools to manually exercise the key user flows described in the plan's acceptance criteria.`,
93+
);
94+
}
95+
if (ctx.priorOutput) {
96+
parts.push(
97+
`The previous test run ended with this output — use it as your starting point:\n\`\`\`\n${ctx.priorOutput.slice(0, 4000)}\n\`\`\``,
98+
);
99+
}
100+
parts.push(
101+
`When done, write a brief summary: which tests ran, which passed, which failed (if any), and what fixes you applied. The orchestrator will commit any file changes — do not run git yourself.`,
102+
);
103+
return parts.join('\n\n');
104+
}

api/src/config/config.model.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export const envSchema = z.object({
3737
MAX_PLAN_REVISIONS: intFromString(10),
3838
MAX_IMPLEMENTATION_ITERATIONS: intFromString(5),
3939
MAX_REVIEW_PASSES: intFromString(5),
40+
MAX_TEST_ITERATIONS: intFromString(3),
4041
COMMAND_PREFIX: z.string().default('/hermes'),
4142

4243
// queue / worker

api/src/job/job.model.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export const JOB_STATES = [
66
'PLANNING',
77
'AWAITING_PLAN_APPROVAL',
88
'IMPLEMENTING',
9+
'TESTING',
910
'SELF_REVIEWING',
1011
'REVISING',
1112
'OPENING_PR',
@@ -28,7 +29,8 @@ export const ALLOWED_TRANSITIONS: Record<JobState, JobState[]> = {
2829
TRIAGED: ['PLANNING'],
2930
PLANNING: ['AWAITING_PLAN_APPROVAL'],
3031
AWAITING_PLAN_APPROVAL: ['PLANNING', 'IMPLEMENTING'],
31-
IMPLEMENTING: ['SELF_REVIEWING'],
32+
IMPLEMENTING: ['TESTING'],
33+
TESTING: ['SELF_REVIEWING'],
3234
SELF_REVIEWING: ['REVISING', 'OPENING_PR'],
3335
REVISING: ['SELF_REVIEWING'],
3436
OPENING_PR: ['AWAITING_PR_APPROVAL'],

api/src/metrics/metrics.model.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
export const METRIC_PREFIX = 'hermes_';
22

3-
export type AgentPhaseLabel = 'PLAN' | 'IMPLEMENT' | 'REVIEW' | 'REVISE' | 'PR_BODY';
3+
export type AgentPhaseLabel = 'PLAN' | 'IMPLEMENT' | 'TEST' | 'REVIEW' | 'REVISE' | 'PR_BODY';
44
export type AgentRunStatusLabel = 'SUCCEEDED' | 'FAILED' | 'TIMED_OUT';

api/src/orchestrator/orchestrator.service.ts

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
buildPlanPrompt,
1414
buildPrBodyPrompt,
1515
buildRevisePrompt,
16+
buildTestPrompt,
1617
} from '../agent/agent.prompts.js';
1718
import { WorkspaceService } from '../workspace/workspace.service.js';
1819
import { ReviewService } from '../review/review.service.js';
@@ -22,7 +23,6 @@ import { formatIssues, parseReview } from '../review/review.utility.js';
2223
import { GithubService } from '../github/github.service.js';
2324
import {
2425
APPROVAL_PERMISSIONS,
25-
type AttachmentRef,
2626
type RepoRef,
2727
type ReviewFeedback,
2828
} from '../github/github.model.js';
@@ -364,6 +364,8 @@ export class OrchestratorService {
364364
return this.handlePlan(task.jobId);
365365
case 'IMPLEMENT':
366366
return this.handleImplement(task.jobId);
367+
case 'TEST':
368+
return this.handleTest(task.jobId);
367369
case 'REVIEW':
368370
return this.handleReview(task.jobId);
369371
case 'OPEN_PR':
@@ -545,11 +547,58 @@ export class OrchestratorService {
545547
if (!committedSomething && !(await this.workspace.hasCommitsAhead(ws.dir, ws.baseBranch))) {
546548
throw new Error('agent produced no changes');
547549
}
548-
await this.jobs.transition(jobId, 'SELF_REVIEWING', {
550+
await this.jobs.transition(jobId, 'TESTING', {
549551
reason: 'implementation complete',
550552
actor: 'AGENT',
551553
});
552554
await this.jobs.update(jobId, { reviewCycle: { increment: 1 } });
555+
await this.queue.enqueue({ jobId, kind: 'TEST' });
556+
}
557+
558+
private async handleTest(jobId: string): Promise<void> {
559+
const { job, ref } = await this.context(jobId);
560+
const ws = await this.workspace.prepare({
561+
jobId,
562+
installationId: this.ghIdFromRef(ref),
563+
owner: job.repoOwner,
564+
repo: job.repoName,
565+
branchName:
566+
job.branchName ?? branchNameFor(this.config.get('BRANCH_PREFIX'), job.issueNumber),
567+
});
568+
const plan = await this.approvedPlan(jobId);
569+
const hasBrowser = !!process.env.CAMOFOX_URL;
570+
const maxIters = this.config.get('MAX_TEST_ITERATIONS');
571+
572+
let priorOutput: string | undefined;
573+
for (let attempt = 1; attempt <= maxIters; attempt++) {
574+
const prompt = buildTestPrompt({
575+
repoFullName: job.repoFullName,
576+
issueTitle: job.issueTitle,
577+
plan,
578+
hasBrowser,
579+
priorOutput,
580+
});
581+
const res = await this.agent.run({
582+
jobId,
583+
phase: 'TEST',
584+
cwd: ws.dir,
585+
prompt,
586+
toolsets: hasBrowser ? 'browser' : undefined,
587+
});
588+
await this.workspace.commitAll(ws.dir, `test: fix failing tests (attempt ${attempt})`);
589+
if (res.status === 'SUCCEEDED') {
590+
break;
591+
}
592+
priorOutput = (res.stderr || res.stdout).slice(0, 4000);
593+
this.logger.warn(
594+
`[job ${jobId}] test attempt ${attempt} ${res.status}; ${attempt < maxIters ? 'retrying' : 'proceeding to review'}`,
595+
);
596+
}
597+
598+
await this.jobs.transition(jobId, 'SELF_REVIEWING', {
599+
reason: 'testing complete',
600+
actor: 'AGENT',
601+
});
553602
await this.queue.enqueue({ jobId, kind: 'REVIEW' });
554603
}
555604

@@ -575,6 +624,25 @@ export class OrchestratorService {
575624

576625
for (let iter = 1; iter <= maxPasses; iter++) {
577626
const pass = priorPasses + iter;
627+
// Run tests before each review pass. The test agent fixes what it can; any
628+
// remaining failures become evidence for the review agent.
629+
const hasBrowser = !!process.env.CAMOFOX_URL;
630+
const testRes = await this.agent.run({
631+
jobId,
632+
phase: 'TEST',
633+
cwd: ws.dir,
634+
prompt: buildTestPrompt({
635+
repoFullName: job.repoFullName,
636+
issueTitle: job.issueTitle,
637+
plan,
638+
hasBrowser,
639+
}),
640+
toolsets: hasBrowser ? 'browser' : undefined,
641+
});
642+
await this.workspace.commitAll(ws.dir, `test: fix failing tests (review pass ${pass})`);
643+
if (testRes.status !== 'SUCCEEDED') {
644+
this.logger.warn(`[job ${jobId}] inline test pass ${pass} ${testRes.status}; proceeding to review`);
645+
}
578646
// Idempotent: a no-op when already SELF_REVIEWING, REVISING -> SELF_REVIEWING otherwise.
579647
await this.jobs.transition(jobId, 'SELF_REVIEWING', {
580648
reason: `review pass ${pass}`,

api/src/queue/queue.model.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export const TASK_KINDS = ['PLAN', 'IMPLEMENT', 'REVIEW', 'REVISE', 'OPEN_PR'] as const;
1+
export const TASK_KINDS = ['PLAN', 'IMPLEMENT', 'TEST', 'REVIEW', 'REVISE', 'OPEN_PR'] as const;
22
export type TaskKind = (typeof TASK_KINDS)[number];
33

44
export const TASK_STATUSES = ['PENDING', 'RUNNING', 'DONE', 'FAILED'] as const;

0 commit comments

Comments
 (0)