feat(HAI-075): add project commands to buildExecutionPrompt
- Inject test and build commands from settings into the execution prompt - Add Project Commands section with testCommand and buildCommand when configured - Hoist getSettings call above the isResume check so commands are always available - Add unit tests for project command propagation in buildExecutionPrompt - Update integration test assertions for the hoisted getSettings call
This commit is contained in:
@@ -344,8 +344,8 @@ describe("TaskExecutor worktreeInitCommand", () => {
|
||||
const executor = new TaskExecutor(store, "/tmp/test");
|
||||
await executor.execute(makeTask());
|
||||
|
||||
// getSettings should NOT have been called (skipped entire !isResume block)
|
||||
expect(store.getSettings).not.toHaveBeenCalled();
|
||||
// getSettings is called (for project commands in execution prompt) but init command should not run
|
||||
expect(store.getSettings).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -704,4 +704,93 @@ describe("buildExecutionPrompt", () => {
|
||||
|
||||
expect(result).not.toContain("## Attachments");
|
||||
});
|
||||
|
||||
it("includes Project Commands section with test command when settings.testCommand is set", () => {
|
||||
const task = createMockTaskDetail();
|
||||
const result = buildExecutionPrompt(task, "/home/user/project", {
|
||||
testCommand: "pnpm test",
|
||||
} as any);
|
||||
|
||||
expect(result).toContain("## Project Commands");
|
||||
expect(result).toContain("- **Test:** `pnpm test`");
|
||||
expect(result).not.toContain("- **Build:**");
|
||||
});
|
||||
|
||||
it("includes Project Commands section with build command when settings.buildCommand is set", () => {
|
||||
const task = createMockTaskDetail();
|
||||
const result = buildExecutionPrompt(task, "/home/user/project", {
|
||||
buildCommand: "pnpm build",
|
||||
} as any);
|
||||
|
||||
expect(result).toContain("## Project Commands");
|
||||
expect(result).toContain("- **Build:** `pnpm build`");
|
||||
expect(result).not.toContain("- **Test:**");
|
||||
});
|
||||
|
||||
it("includes both commands when both are set", () => {
|
||||
const task = createMockTaskDetail();
|
||||
const result = buildExecutionPrompt(task, "/home/user/project", {
|
||||
testCommand: "pnpm test",
|
||||
buildCommand: "pnpm build",
|
||||
} as any);
|
||||
|
||||
expect(result).toContain("## Project Commands");
|
||||
expect(result).toContain("- **Test:** `pnpm test`");
|
||||
expect(result).toContain("- **Build:** `pnpm build`");
|
||||
});
|
||||
|
||||
it("omits Project Commands section when neither command is set", () => {
|
||||
const task = createMockTaskDetail();
|
||||
const result = buildExecutionPrompt(task, "/home/user/project", {} as any);
|
||||
|
||||
expect(result).not.toContain("## Project Commands");
|
||||
});
|
||||
|
||||
it("omits Project Commands section when settings is undefined", () => {
|
||||
const task = createMockTaskDetail();
|
||||
const result = buildExecutionPrompt(task);
|
||||
|
||||
expect(result).not.toContain("## Project Commands");
|
||||
});
|
||||
|
||||
it("passes settings to buildExecutionPrompt in TaskExecutor.execute()", async () => {
|
||||
const store = createMockStore();
|
||||
store.getSettings.mockResolvedValue({
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
pollIntervalMs: 15000,
|
||||
groupOverlappingFiles: false,
|
||||
autoMerge: false,
|
||||
testCommand: "npm test",
|
||||
buildCommand: "npm run build",
|
||||
});
|
||||
|
||||
const mockPrompt = vi.fn().mockResolvedValue(undefined);
|
||||
mockedCreateHaiAgent.mockResolvedValue({
|
||||
session: {
|
||||
prompt: mockPrompt,
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
} as any);
|
||||
|
||||
const executor = new TaskExecutor(store, "/tmp/test");
|
||||
await executor.execute({
|
||||
id: "HAI-001",
|
||||
title: "Test",
|
||||
description: "Test",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
expect(mockPrompt).toHaveBeenCalledOnce();
|
||||
const agentPrompt = mockPrompt.mock.calls[0][0];
|
||||
expect(agentPrompt).toContain("## Project Commands");
|
||||
expect(agentPrompt).toContain("- **Test:** `npm test`");
|
||||
expect(agentPrompt).toContain("- **Build:** `npm run build`");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { join } from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import type { TaskStore, Task, TaskDetail, StepStatus } from "@hai/core";
|
||||
import type { TaskStore, Task, TaskDetail, StepStatus, Settings } from "@hai/core";
|
||||
import { findWorktreeUser } from "./merger.js";
|
||||
import { generateWorktreeName } from "./worktree-names.js";
|
||||
import { Type, type Static } from "@mariozechner/pi-ai";
|
||||
@@ -246,9 +246,9 @@ export class TaskExecutor {
|
||||
let worktreePath = task.worktree || join(this.rootDir, ".worktrees", task.id);
|
||||
let isResume = existsSync(worktreePath);
|
||||
let acquiredFromPool = false;
|
||||
const settings = await this.store.getSettings();
|
||||
|
||||
if (!isResume) {
|
||||
const settings = await this.store.getSettings();
|
||||
|
||||
// Try acquiring a warm worktree from the pool
|
||||
if (this.options.pool && settings.recycleWorktrees) {
|
||||
@@ -365,7 +365,7 @@ export class TaskExecutor {
|
||||
});
|
||||
|
||||
try {
|
||||
const agentPrompt = buildExecutionPrompt(detail, this.rootDir);
|
||||
const agentPrompt = buildExecutionPrompt(detail, this.rootDir, settings);
|
||||
await session.prompt(agentPrompt);
|
||||
|
||||
if (taskDone) {
|
||||
@@ -612,7 +612,10 @@ export class TaskExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
export function buildExecutionPrompt(task: TaskDetail, rootDir?: string): string {
|
||||
// Project commands are injected here (for reliability) and also in the PROMPT.md (by triage).
|
||||
// This ensures the executor agent always sees the authoritative commands from settings,
|
||||
// even if the PROMPT.md was written manually or before commands were configured.
|
||||
export function buildExecutionPrompt(task: TaskDetail, rootDir?: string, settings?: Settings): string {
|
||||
const reviewMatch = task.prompt.match(/##\s*Review Level[:\s]*(\d)/);
|
||||
const reviewLevel = reviewMatch ? parseInt(reviewMatch[1], 10) : 0;
|
||||
|
||||
@@ -660,6 +663,15 @@ git log --oneline
|
||||
attachmentsSection = "\n" + lines.join("\n") + "\n";
|
||||
}
|
||||
|
||||
// Build project commands section from settings
|
||||
let commandsSection = "";
|
||||
if (settings?.testCommand || settings?.buildCommand) {
|
||||
const lines = ["## Project Commands"];
|
||||
if (settings.testCommand) lines.push(`- **Test:** \`${settings.testCommand}\``);
|
||||
if (settings.buildCommand) lines.push(`- **Build:** \`${settings.buildCommand}\``);
|
||||
commandsSection = "\n" + lines.join("\n") + "\n";
|
||||
}
|
||||
|
||||
return `Execute this task.
|
||||
|
||||
## Task: ${task.id}
|
||||
@@ -669,7 +681,7 @@ ${task.dependencies.length > 0 ? `Dependencies: ${task.dependencies.join(", ")}`
|
||||
## PROMPT.md
|
||||
|
||||
${task.prompt}
|
||||
${attachmentsSection}${progressSection}
|
||||
${attachmentsSection}${commandsSection}${progressSection}
|
||||
## Review level: ${reviewLevel}
|
||||
|
||||
${reviewLevel === 0 ? "No reviews required. Implement directly." : ""}
|
||||
|
||||
@@ -228,8 +228,8 @@ describe("In-progress task resume after restart", () => {
|
||||
await executor.resumeOrphaned();
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
// getSettings should NOT have been called (skipped entire !isResume block)
|
||||
expect(store.getSettings).not.toHaveBeenCalled();
|
||||
// getSettings is called (for project commands in execution prompt) but init command should not run
|
||||
expect(store.getSettings).toHaveBeenCalled();
|
||||
|
||||
// No init command calls
|
||||
const initCalls = mockedExecSync.mock.calls.filter(
|
||||
|
||||
Reference in New Issue
Block a user