feat(FN-1053): add AI prompt executor for cron workflow steps
- Define AiPromptExecutor type and update CronRunner constructor to accept injected executor - Implement executeAiPromptStep with real agent session execution in worktrees - Create createAiPromptStepExecutor factory and wire up in dashboard CLI - Add comprehensive tests for AI prompt step execution (mock agent, errors, output capture) - Fix mock typing for dashboard test build compatibility
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { CronRunner } from "./cron-runner.js";
|
||||
import type { AiPromptExecutor } from "./cron-runner.js";
|
||||
import type { TaskStore, AutomationStore, ScheduledTask, AutomationRunResult, AutomationStep, Settings } from "@fusion/core";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
@@ -532,8 +533,11 @@ describe("CronRunner", () => {
|
||||
expect(result.stepResults![0].error).toContain("timed out");
|
||||
}, 10000);
|
||||
|
||||
it("handles AI prompt step execution (mocked)", async () => {
|
||||
it("handles AI prompt step execution with executor", async () => {
|
||||
const store = createMockStore();
|
||||
const mockFn = vi.fn()
|
||||
.mockResolvedValue("AI analysis complete: no issues found");
|
||||
const mockExecutor = mockFn as unknown as AiPromptExecutor;
|
||||
const schedule = createMockSchedule({
|
||||
command: "",
|
||||
steps: [
|
||||
@@ -548,15 +552,20 @@ describe("CronRunner", () => {
|
||||
],
|
||||
});
|
||||
const automationStore = createMockAutomationStore([schedule]);
|
||||
runner = new CronRunner(store, automationStore);
|
||||
runner = new CronRunner(store, automationStore, { aiPromptExecutor: mockExecutor });
|
||||
|
||||
const result = await runner.executeSchedule(schedule);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.stepResults).toHaveLength(1);
|
||||
expect(result.stepResults![0].success).toBe(true);
|
||||
expect(result.stepResults![0].output).toContain("anthropic/claude-sonnet-4-5");
|
||||
expect(result.stepResults![0].output).toContain("Analyze the codebase");
|
||||
expect(result.stepResults![0].output).toContain("AI analysis complete");
|
||||
// Verify executor was called with correct model params from step
|
||||
expect(mockExecutor).toHaveBeenCalledWith(
|
||||
"Analyze the codebase",
|
||||
"anthropic",
|
||||
"claude-sonnet-4-5",
|
||||
);
|
||||
});
|
||||
|
||||
it("fails AI prompt step when no prompt is provided", async () => {
|
||||
@@ -664,6 +673,9 @@ describe("CronRunner", () => {
|
||||
|
||||
it("mixed step types execute correctly", async () => {
|
||||
const store = createMockStore();
|
||||
const mockFn = vi.fn()
|
||||
.mockResolvedValue("Summary: all good");
|
||||
const mockExecutor = mockFn as unknown as AiPromptExecutor;
|
||||
const schedule = createMockSchedule({
|
||||
command: "",
|
||||
steps: [
|
||||
@@ -678,7 +690,7 @@ describe("CronRunner", () => {
|
||||
],
|
||||
});
|
||||
const automationStore = createMockAutomationStore([schedule]);
|
||||
runner = new CronRunner(store, automationStore);
|
||||
runner = new CronRunner(store, automationStore, { aiPromptExecutor: mockExecutor });
|
||||
|
||||
const result = await runner.executeSchedule(schedule);
|
||||
|
||||
@@ -687,6 +699,247 @@ describe("CronRunner", () => {
|
||||
expect(result.stepResults![0].stepName).toBe("Build");
|
||||
expect(result.stepResults![1].stepName).toBe("Summarize");
|
||||
expect(result.stepResults![2].stepName).toBe("Deploy");
|
||||
expect(result.stepResults![1].output).toContain("Summary: all good");
|
||||
});
|
||||
|
||||
// ── AI prompt step execution ────────────────────────────────────────
|
||||
|
||||
function createAiMockExecutor(
|
||||
response: string = "mock AI response",
|
||||
): AiPromptExecutor {
|
||||
const fn = vi.fn().mockResolvedValue(response);
|
||||
return fn as unknown as AiPromptExecutor;
|
||||
}
|
||||
|
||||
it("returns success with AI response when executor is configured", async () => {
|
||||
const store = createMockStore();
|
||||
const mockExecutor = createAiMockExecutor("Analysis complete: 3 findings");
|
||||
const schedule = createMockSchedule({
|
||||
command: "",
|
||||
steps: [
|
||||
makeStep({
|
||||
type: "ai-prompt",
|
||||
name: "Code Review",
|
||||
prompt: "Review the code for issues",
|
||||
command: undefined,
|
||||
}),
|
||||
],
|
||||
});
|
||||
const automationStore = createMockAutomationStore([schedule]);
|
||||
runner = new CronRunner(store, automationStore, { aiPromptExecutor: mockExecutor });
|
||||
|
||||
const result = await runner.executeSchedule(schedule);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.stepResults![0].success).toBe(true);
|
||||
expect(result.stepResults![0].output).toContain("Analysis complete: 3 findings");
|
||||
});
|
||||
|
||||
it("passes step model provider and model ID to executor", async () => {
|
||||
const store = createMockStore();
|
||||
const mockExecutor = createAiMockExecutor("response");
|
||||
const schedule = createMockSchedule({
|
||||
command: "",
|
||||
steps: [
|
||||
makeStep({
|
||||
type: "ai-prompt",
|
||||
name: "Step with model",
|
||||
prompt: "Do something",
|
||||
modelProvider: "openai",
|
||||
modelId: "gpt-4o",
|
||||
command: undefined,
|
||||
}),
|
||||
],
|
||||
});
|
||||
const automationStore = createMockAutomationStore([schedule]);
|
||||
runner = new CronRunner(store, automationStore, { aiPromptExecutor: mockExecutor });
|
||||
|
||||
await runner.executeSchedule(schedule);
|
||||
|
||||
expect(mockExecutor).toHaveBeenCalledWith("Do something", "openai", "gpt-4o");
|
||||
});
|
||||
|
||||
it("falls back to settings defaults when step has no model", async () => {
|
||||
const store = createMockStore({
|
||||
defaultProvider: "anthropic",
|
||||
defaultModelId: "claude-sonnet-4-5",
|
||||
});
|
||||
const mockExecutor = createAiMockExecutor("response");
|
||||
const schedule = createMockSchedule({
|
||||
command: "",
|
||||
steps: [
|
||||
makeStep({
|
||||
type: "ai-prompt",
|
||||
name: "Step without model",
|
||||
prompt: "Use defaults",
|
||||
command: undefined,
|
||||
}),
|
||||
],
|
||||
});
|
||||
const automationStore = createMockAutomationStore([schedule]);
|
||||
runner = new CronRunner(store, automationStore, { aiPromptExecutor: mockExecutor });
|
||||
|
||||
await runner.executeSchedule(schedule);
|
||||
|
||||
expect(mockExecutor).toHaveBeenCalledWith("Use defaults", "anthropic", "claude-sonnet-4-5");
|
||||
});
|
||||
|
||||
it("returns configuration error when no executor is provided", async () => {
|
||||
const store = createMockStore();
|
||||
const schedule = createMockSchedule({
|
||||
command: "",
|
||||
steps: [
|
||||
makeStep({
|
||||
type: "ai-prompt",
|
||||
name: "No executor",
|
||||
prompt: "This should fail",
|
||||
command: undefined,
|
||||
}),
|
||||
],
|
||||
});
|
||||
const automationStore = createMockAutomationStore([schedule]);
|
||||
runner = new CronRunner(store, automationStore);
|
||||
|
||||
const result = await runner.executeSchedule(schedule);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.stepResults![0].success).toBe(false);
|
||||
expect(result.stepResults![0].error).toContain("AI execution is not configured");
|
||||
expect(result.stepResults![0].output).toBe("");
|
||||
});
|
||||
|
||||
it("fails early when prompt is empty even with executor configured", async () => {
|
||||
const store = createMockStore();
|
||||
const mockExecutor = createAiMockExecutor("should not be called");
|
||||
const schedule = createMockSchedule({
|
||||
command: "",
|
||||
steps: [
|
||||
makeStep({
|
||||
type: "ai-prompt",
|
||||
name: "Empty prompt",
|
||||
prompt: " ",
|
||||
command: undefined,
|
||||
}),
|
||||
],
|
||||
});
|
||||
const automationStore = createMockAutomationStore([schedule]);
|
||||
runner = new CronRunner(store, automationStore, { aiPromptExecutor: mockExecutor });
|
||||
|
||||
const result = await runner.executeSchedule(schedule);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.stepResults![0].error).toContain("no prompt specified");
|
||||
expect(mockExecutor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles timeout by returning failure", async () => {
|
||||
const store = createMockStore();
|
||||
const mockFn = vi.fn()
|
||||
.mockImplementation(() => new Promise((resolve) => {
|
||||
setTimeout(() => resolve("too late"), 5000);
|
||||
}));
|
||||
const mockExecutor = mockFn as unknown as AiPromptExecutor;
|
||||
const schedule = createMockSchedule({
|
||||
command: "",
|
||||
timeoutMs: 100,
|
||||
steps: [
|
||||
makeStep({
|
||||
type: "ai-prompt",
|
||||
name: "Slow AI",
|
||||
prompt: "Take your time",
|
||||
command: undefined,
|
||||
}),
|
||||
],
|
||||
});
|
||||
const automationStore = createMockAutomationStore([schedule]);
|
||||
runner = new CronRunner(store, automationStore, { aiPromptExecutor: mockExecutor });
|
||||
|
||||
const result = await runner.executeSchedule(schedule);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.stepResults![0].success).toBe(false);
|
||||
expect(result.stepResults![0].error).toContain("timed out");
|
||||
});
|
||||
|
||||
it("handles executor throwing an error", async () => {
|
||||
const store = createMockStore();
|
||||
const mockFn = vi.fn()
|
||||
.mockRejectedValue(new Error("API rate limit exceeded"));
|
||||
const mockExecutor = mockFn as unknown as AiPromptExecutor;
|
||||
const schedule = createMockSchedule({
|
||||
command: "",
|
||||
steps: [
|
||||
makeStep({
|
||||
type: "ai-prompt",
|
||||
name: "Failing AI",
|
||||
prompt: "Cause an error",
|
||||
command: undefined,
|
||||
}),
|
||||
],
|
||||
});
|
||||
const automationStore = createMockAutomationStore([schedule]);
|
||||
runner = new CronRunner(store, automationStore, { aiPromptExecutor: mockExecutor });
|
||||
|
||||
const result = await runner.executeSchedule(schedule);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.stepResults![0].success).toBe(false);
|
||||
expect(result.stepResults![0].error).toContain("API rate limit exceeded");
|
||||
});
|
||||
|
||||
it("truncates output when AI response exceeds MAX_OUTPUT_LENGTH", async () => {
|
||||
const store = createMockStore();
|
||||
const longResponse = "x".repeat(15_000);
|
||||
const mockExecutor = createAiMockExecutor(longResponse);
|
||||
const schedule = createMockSchedule({
|
||||
command: "",
|
||||
steps: [
|
||||
makeStep({
|
||||
type: "ai-prompt",
|
||||
name: "Verbose AI",
|
||||
prompt: "Give me lots of text",
|
||||
command: undefined,
|
||||
}),
|
||||
],
|
||||
});
|
||||
const automationStore = createMockAutomationStore([schedule]);
|
||||
runner = new CronRunner(store, automationStore, { aiPromptExecutor: mockExecutor });
|
||||
|
||||
const result = await runner.executeSchedule(schedule);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.stepResults![0].success).toBe(true);
|
||||
expect(result.stepResults![0].output).toContain("[output truncated]");
|
||||
expect(result.stepResults![0].output.length).toBeLessThanOrEqual(10 * 1024 + 50);
|
||||
});
|
||||
|
||||
it("uses per-step timeout override over schedule timeout", async () => {
|
||||
const store = createMockStore();
|
||||
const mockFn = vi.fn()
|
||||
.mockImplementation(() => new Promise((resolve) => {
|
||||
setTimeout(() => resolve("too late"), 5000);
|
||||
}));
|
||||
const mockExecutor = mockFn as unknown as AiPromptExecutor;
|
||||
const schedule = createMockSchedule({
|
||||
command: "",
|
||||
timeoutMs: 30000,
|
||||
steps: [
|
||||
makeStep({
|
||||
type: "ai-prompt",
|
||||
name: "Slow AI",
|
||||
prompt: "Take your time",
|
||||
timeoutMs: 100,
|
||||
command: undefined,
|
||||
}),
|
||||
],
|
||||
});
|
||||
const automationStore = createMockAutomationStore([schedule]);
|
||||
runner = new CronRunner(store, automationStore, { aiPromptExecutor: mockExecutor });
|
||||
|
||||
const result = await runner.executeSchedule(schedule);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.stepResults![0].error).toContain("timed out");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,9 +19,21 @@ const DEFAULT_POLL_INTERVAL_MS = 60 * 1000;
|
||||
/** Minimum poll interval: 10 seconds. */
|
||||
const MIN_POLL_INTERVAL_MS = 10 * 1000;
|
||||
|
||||
/**
|
||||
* Function type for executing AI prompts.
|
||||
* Injected into CronRunner to decouple it from agent session creation.
|
||||
*/
|
||||
export type AiPromptExecutor = (
|
||||
prompt: string,
|
||||
modelProvider?: string,
|
||||
modelId?: string,
|
||||
) => Promise<string>;
|
||||
|
||||
export interface CronRunnerOptions {
|
||||
/** Polling interval in milliseconds. Default: 60000 (60s). Minimum: 10000 (10s). */
|
||||
pollIntervalMs?: number;
|
||||
/** Optional AI prompt executor. When not provided, ai-prompt steps return a configuration error. */
|
||||
aiPromptExecutor?: AiPromptExecutor;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -37,6 +49,7 @@ export class CronRunner {
|
||||
private ticking = false;
|
||||
private pollInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private pollIntervalMs: number;
|
||||
private aiPromptExecutor?: AiPromptExecutor;
|
||||
/** Schedule IDs currently being executed — prevents concurrent runs of the same schedule. */
|
||||
private inFlight = new Set<string>();
|
||||
|
||||
@@ -49,6 +62,7 @@ export class CronRunner {
|
||||
MIN_POLL_INTERVAL_MS,
|
||||
options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS,
|
||||
);
|
||||
this.aiPromptExecutor = options.aiPromptExecutor;
|
||||
}
|
||||
|
||||
/** Start the polling loop. */
|
||||
@@ -351,13 +365,13 @@ export class CronRunner {
|
||||
|
||||
/**
|
||||
* Execute an AI prompt step.
|
||||
* In a full implementation, this would create an agent session and run the prompt.
|
||||
* For now, we log the prompt and model selection and return a placeholder result.
|
||||
* Uses the injected aiPromptExecutor to create an agent session and run the prompt.
|
||||
* When no executor is configured, returns a configuration error.
|
||||
*/
|
||||
private async executeAiPromptStep(
|
||||
step: AutomationStep,
|
||||
stepIndex: number,
|
||||
_timeoutMs: number,
|
||||
timeoutMs: number,
|
||||
startedAt: string,
|
||||
): Promise<AutomationStepResult> {
|
||||
if (!step.prompt?.trim()) {
|
||||
@@ -373,25 +387,121 @@ export class CronRunner {
|
||||
};
|
||||
}
|
||||
|
||||
const model = step.modelProvider && step.modelId
|
||||
? `${step.modelProvider}/${step.modelId}`
|
||||
// Check if AI execution is configured
|
||||
if (!this.aiPromptExecutor) {
|
||||
return {
|
||||
stepId: step.id,
|
||||
stepName: step.name,
|
||||
stepIndex,
|
||||
success: false,
|
||||
output: "",
|
||||
error: "AI execution is not configured — no aiPromptExecutor provided to CronRunner",
|
||||
startedAt,
|
||||
completedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// Resolve model: step override → settings default
|
||||
const settings = await this.store.getSettings();
|
||||
const modelProvider = step.modelProvider?.trim() || settings.defaultProvider;
|
||||
const modelId = step.modelId?.trim() || settings.defaultModelId;
|
||||
|
||||
const model = modelProvider && modelId
|
||||
? `${modelProvider}/${modelId}`
|
||||
: "default";
|
||||
log.log(` AI prompt step "${step.name}" using model: ${model}`);
|
||||
log.log(` Prompt: ${step.prompt.slice(0, 100)}${step.prompt.length > 100 ? "…" : ""}`);
|
||||
|
||||
// TODO: Integrate with actual agent session for AI prompt execution
|
||||
return {
|
||||
stepId: step.id,
|
||||
stepName: step.name,
|
||||
stepIndex,
|
||||
success: true,
|
||||
output: `[AI prompt step — model: ${model}]\nPrompt: ${step.prompt}\n\n(AI execution not yet implemented — prompt recorded for future integration)`,
|
||||
startedAt,
|
||||
completedAt: new Date().toISOString(),
|
||||
};
|
||||
try {
|
||||
// Race between executor and timeout
|
||||
const resultPromise = this.aiPromptExecutor(step.prompt, modelProvider, modelId);
|
||||
const timeoutPromise = new Promise<never>((_resolve, reject) => {
|
||||
setTimeout(() => reject(new Error(`AI prompt step timed out after ${timeoutMs / 1000}s`)), timeoutMs);
|
||||
});
|
||||
|
||||
const response = await Promise.race([resultPromise, timeoutPromise]);
|
||||
|
||||
const output = response.length > MAX_OUTPUT_LENGTH
|
||||
? response.slice(0, MAX_OUTPUT_LENGTH) + "\n[output truncated]"
|
||||
: response;
|
||||
|
||||
log.log(` ✓ AI prompt step "${step.name}" completed (${response.length} chars)`);
|
||||
|
||||
return {
|
||||
stepId: step.id,
|
||||
stepName: step.name,
|
||||
stepIndex,
|
||||
success: true,
|
||||
output,
|
||||
startedAt,
|
||||
completedAt: new Date().toISOString(),
|
||||
};
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.message ?? String(err);
|
||||
log.warn(` ✗ AI prompt step "${step.name}" failed: ${errorMessage}`);
|
||||
|
||||
return {
|
||||
stepId: step.id,
|
||||
stepName: step.name,
|
||||
stepIndex,
|
||||
success: false,
|
||||
output: "",
|
||||
error: errorMessage,
|
||||
startedAt,
|
||||
completedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const AI_AUTOMATION_SYSTEM_PROMPT = [
|
||||
"You are an AI automation agent executing a scheduled task.",
|
||||
"You have read-only access to the project files.",
|
||||
"Execute the prompt precisely and return concise, structured results.",
|
||||
"When analyzing code or data, provide actionable summaries.",
|
||||
].join("\n");
|
||||
|
||||
/**
|
||||
* Create an AiPromptExecutor that uses createKbAgent for real AI execution.
|
||||
*
|
||||
* Each call creates a fresh agent session, runs the prompt, collects the
|
||||
* text response, and disposes the session.
|
||||
*
|
||||
* @param cwd — Project root directory (file access scope for the agent).
|
||||
* @returns An AiPromptExecutor function suitable for CronRunnerOptions.
|
||||
*/
|
||||
export async function createAiPromptExecutor(cwd: string): Promise<AiPromptExecutor> {
|
||||
// We import lazily to keep the factory self-contained and to avoid
|
||||
// pulling pi.ts into the module graph when AI execution isn't used.
|
||||
const { createKbAgent, promptWithFallback } = await import("./pi.js");
|
||||
|
||||
return async (prompt: string, modelProvider?: string, modelId?: string): Promise<string> => {
|
||||
let responseText = "";
|
||||
|
||||
const { session } = await createKbAgent({
|
||||
cwd,
|
||||
systemPrompt: AI_AUTOMATION_SYSTEM_PROMPT,
|
||||
tools: "readonly",
|
||||
defaultProvider: modelProvider,
|
||||
defaultModelId: modelId,
|
||||
onText: (delta: string) => {
|
||||
responseText += delta;
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await promptWithFallback(session, prompt);
|
||||
return responseText;
|
||||
} finally {
|
||||
try {
|
||||
session.dispose();
|
||||
} catch {
|
||||
// Best-effort disposal — don't mask the original error
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Combine and truncate stdout/stderr to stay within storage limits. */
|
||||
function truncateOutput(stdout: string, stderr: string): string {
|
||||
let combined = stdout;
|
||||
|
||||
@@ -14,7 +14,7 @@ export { withRateLimitRetry } from "./rate-limit-retry.js";
|
||||
export { PrMonitor, type PrComment, type TrackedPr, type OnNewCommentsCallback } from "./pr-monitor.js";
|
||||
export { PrCommentHandler } from "./pr-comment-handler.js";
|
||||
export { NtfyNotifier, type NtfyNotifierOptions } from "./notifier.js";
|
||||
export { CronRunner, type CronRunnerOptions } from "./cron-runner.js";
|
||||
export { CronRunner, type CronRunnerOptions, type AiPromptExecutor, createAiPromptExecutor } from "./cron-runner.js";
|
||||
export { StuckTaskDetector, type StuckTaskDetectorOptions, type DisposableSession } from "./stuck-task-detector.js";
|
||||
export { TokenCapDetector, type TokenCapCheckResult } from "./token-cap-detector.js";
|
||||
export { SelfHealingManager, type SelfHealingOptions } from "./self-healing.js";
|
||||
|
||||
Reference in New Issue
Block a user