Verify task chat timestamps and immediate steering delivery across UI, route, and executor surfaces. - Assert inline and expanded TaskChatTab timestamp parity for agent output and user steering comments. - Cover steering route wake payloads so assigned agents receive the newest steering comment id immediately. - Harden executor real-time steering tests for seen-before-inject ordering, queued prompt delivery, duplicate suppression, and empty-comment no-ops. Files changed: .../app/components/__tests__/TaskChatTab.test.tsx | 79 +++++++++++- .../src/__tests__/routes-tasks-ops.test.ts | 136 +++++++++++++++++++++ .../src/__tests__/executor-step-session.test.ts | 116 ++++++++++++++++-- 3 files changed, 316 insertions(+), 15 deletions(-) Fusion-Task-Id: FN-6750 Fusion-Task-Lineage: 48e1d57d-fd37-4d45-9b98-766c4a2f704d
3732 lines
129 KiB
TypeScript
3732 lines
129 KiB
TypeScript
// -nocheck
|
||
/* eslint-disable -eslint/no-unused-vars */
|
||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||
import "./executor-test-helpers.js";
|
||
import { AgentSemaphore } from "../concurrency.js";
|
||
import { detectReviewHandoffIntent, determineRevisionResetStart } from "../executor.js";
|
||
import { TaskExecutor, buildExecutionPrompt } from "../executor.js";
|
||
import { createFnAgent } from "../pi.js";
|
||
import { reviewStep as mockedReviewStepFn } from "../reviewer.js";
|
||
import { execSync } from "node:child_process";
|
||
import { findWorktreeUser, aiMergeTask } from "../merger.js";
|
||
import { WorktreePool } from "../worktree-pool.js";
|
||
import { generateWorktreeName, slugify } from "../worktree-names.js";
|
||
import type { Task, TaskDetail } from "@fusion/core";
|
||
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
||
import { StepSessionExecutor } from "../step-session-executor.js";
|
||
import { executorLog } from "../logger.js";
|
||
import { withRateLimitRetry } from "../rate-limit-retry.js";
|
||
import { runVerificationCommand as mockedRunVerificationCommand } from "../verification-utils.js";
|
||
import {
|
||
createMockStore,
|
||
mockedCreateFnAgent,
|
||
mockedSessionManager,
|
||
mockedGenerateWorktreeName,
|
||
mockedFindWorktreeUser,
|
||
mockedStepSessionExecutor,
|
||
mockedWithRateLimitRetry,
|
||
mockedExecSync,
|
||
mockedExistsSync,
|
||
mockExecuteAll,
|
||
mockTerminateAllSessions,
|
||
mockCleanup,
|
||
mockSteerActiveSessions,
|
||
resetExecutorMocks,
|
||
} from "./executor-test-helpers.js";
|
||
|
||
const mockedReviewStep = vi.mocked(mockedReviewStepFn);
|
||
|
||
describe("Workflow Steps Execution", () => {
|
||
beforeEach(() => {
|
||
resetExecutorMocks();
|
||
});
|
||
|
||
/**
|
||
* Create a mock agent that auto-triggers the fn_task_done tool when prompt is called.
|
||
* This simulates a successful task execution where the agent calls fn_task_done().
|
||
*/
|
||
function createAgentWithTaskDone() {
|
||
let capturedCustomTools: any[] = [];
|
||
|
||
mockedCreateFnAgent.mockImplementation((async (opts: any) => {
|
||
capturedCustomTools = opts.customTools || [];
|
||
const session = {
|
||
prompt: vi.fn().mockImplementation(async () => {
|
||
// Find and execute fn_task_done tool to set taskDone = true
|
||
const taskDoneTool = capturedCustomTools.find((t: any) => t.name === "fn_task_done");
|
||
if (taskDoneTool) {
|
||
await taskDoneTool.execute("tool-1", {});
|
||
}
|
||
}),
|
||
dispose: vi.fn(),
|
||
subscribe: vi.fn(),
|
||
on: vi.fn(),
|
||
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
|
||
state: {},
|
||
};
|
||
return { session };
|
||
}) as any);
|
||
}
|
||
|
||
it("requeues to todo after 3 retries when the agent exits without calling fn_task_done", async () => {
|
||
const store = createMockStore();
|
||
store.getTask.mockResolvedValue({
|
||
id: "FN-001",
|
||
title: "Test",
|
||
description: "Test task",
|
||
column: "in-progress",
|
||
dependencies: [],
|
||
steps: [{ name: "Preflight", status: "in-progress" }],
|
||
currentStep: 0,
|
||
log: [],
|
||
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
mockedCreateFnAgent.mockResolvedValue({
|
||
session: {
|
||
prompt: vi.fn().mockResolvedValue(undefined),
|
||
dispose: vi.fn(),
|
||
subscribe: vi.fn(),
|
||
on: vi.fn(),
|
||
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
|
||
state: {},
|
||
},
|
||
} as any);
|
||
|
||
const onComplete = vi.fn();
|
||
const onError = vi.fn();
|
||
const executor = new TaskExecutor(store, "/tmp/test", { onComplete, onError });
|
||
|
||
await executor.execute({
|
||
id: "FN-001",
|
||
title: "Test",
|
||
description: "Test task",
|
||
column: "in-progress",
|
||
dependencies: [],
|
||
steps: [{ name: "Preflight", status: "in-progress" }],
|
||
currentStep: 0,
|
||
log: [],
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
// Should have been called four times: initial + 3 retries
|
||
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(4);
|
||
|
||
/*
|
||
FNXC:EngineTests 2026-06-18-07:22:
|
||
FN-6610 confirmed the intended executor.ts no-fn_task_done exhaustion behavior: after three in-session retries, tasks with remaining requeue budget return to todo with progress preserved; only exhausted requeue budget parks them in review.
|
||
Keep these expectations aligned with Executor.execute()'s MAX_TASK_DONE_REQUEUE_RETRIES branch rather than treating the first in-session exhaustion as terminal.
|
||
*/
|
||
expect(store.updateTask).toHaveBeenCalledWith("FN-001", {
|
||
status: "queued",
|
||
error: null,
|
||
taskDoneRetryCount: 1,
|
||
});
|
||
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo", { preserveProgress: true });
|
||
expect(store.logEntry).toHaveBeenCalledWith(
|
||
"FN-001",
|
||
"Agent finished without calling fn_task_done (after 3 retries) — requeued to todo immediately (1/3)",
|
||
undefined,
|
||
expect.objectContaining({ agentId: "executor" }),
|
||
);
|
||
expect(onError).toHaveBeenCalledWith(
|
||
expect.objectContaining({ id: "FN-001" }),
|
||
expect.objectContaining({ message: "Agent finished without calling fn_task_done (after 3 retries)" }),
|
||
);
|
||
expect(onComplete).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("moves task to in-review once fn_task_done requeue budget is exhausted", async () => {
|
||
const store = createMockStore();
|
||
store.getTask.mockResolvedValue({
|
||
id: "FN-001",
|
||
title: "Test",
|
||
description: "Test task",
|
||
column: "in-progress",
|
||
dependencies: [],
|
||
steps: [{ name: "Preflight", status: "in-progress" }],
|
||
currentStep: 0,
|
||
taskDoneRetryCount: 3,
|
||
log: [],
|
||
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
mockedCreateFnAgent.mockResolvedValue({
|
||
session: {
|
||
prompt: vi.fn().mockResolvedValue(undefined),
|
||
dispose: vi.fn(),
|
||
subscribe: vi.fn(),
|
||
on: vi.fn(),
|
||
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
|
||
state: {},
|
||
},
|
||
} as any);
|
||
|
||
const onError = vi.fn();
|
||
const executor = new TaskExecutor(store, "/tmp/test", { onError });
|
||
|
||
await executor.execute({
|
||
id: "FN-001",
|
||
title: "Test",
|
||
description: "Test task",
|
||
column: "in-progress",
|
||
dependencies: [],
|
||
steps: [{ name: "Preflight", status: "in-progress" }],
|
||
currentStep: 0,
|
||
taskDoneRetryCount: 3,
|
||
log: [],
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
expect(mockedCreateFnAgent.mock.calls.length).toBeGreaterThanOrEqual(1);
|
||
expect(store.updateTask).toHaveBeenCalledWith("FN-001", {
|
||
status: "failed",
|
||
error: "Agent finished without calling fn_task_done (after 3 retries)",
|
||
});
|
||
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review");
|
||
expect(store.moveTask).not.toHaveBeenCalledWith("FN-001", "todo");
|
||
expect(onError).toHaveBeenCalledWith(
|
||
expect.objectContaining({ id: "FN-001" }),
|
||
expect.objectContaining({ message: "Agent finished without calling fn_task_done (after 3 retries)" }),
|
||
);
|
||
});
|
||
|
||
describe("FN-5436: pending-review skip on no-fn_task_done exit", () => {
|
||
it("does not park in-review when code review REVISE requires more executor work", async () => {
|
||
const store = createMockStore();
|
||
const baseTask = {
|
||
id: "FN-5436-A",
|
||
title: "Test",
|
||
description: "Test task",
|
||
column: "in-progress",
|
||
dependencies: [],
|
||
steps: [{ name: "Implement", status: "in-progress" }],
|
||
currentStep: 0,
|
||
log: [],
|
||
prompt: "# test\n## Steps\n### Step 1: Implement\n- [ ] implement",
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
};
|
||
store.getTask.mockResolvedValue(baseTask as any);
|
||
|
||
mockedReviewStep.mockResolvedValue({
|
||
verdict: "REVISE",
|
||
review: "needs changes",
|
||
summary: "needs changes",
|
||
});
|
||
|
||
mockedCreateFnAgent.mockImplementation((async (opts: any) => {
|
||
const tools = opts.customTools || [];
|
||
return {
|
||
session: {
|
||
prompt: vi.fn().mockImplementation(async () => {
|
||
const reviewTool = tools.find((t: any) => t.name === "fn_review_step");
|
||
if (reviewTool) {
|
||
await reviewTool.execute("tool-review", { step: 0, type: "code", step_name: "Implement" });
|
||
}
|
||
}),
|
||
dispose: vi.fn(),
|
||
subscribe: vi.fn(),
|
||
on: vi.fn(),
|
||
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
|
||
state: {},
|
||
},
|
||
};
|
||
}) as any);
|
||
|
||
const onError = vi.fn();
|
||
const executor = new TaskExecutor(store, "/tmp/test", { onError });
|
||
|
||
await executor.execute(baseTask as any);
|
||
|
||
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(4);
|
||
expect(store.updateTask).toHaveBeenCalledWith("FN-5436-A", expect.objectContaining({
|
||
status: "queued",
|
||
error: null,
|
||
taskDoneRetryCount: 1,
|
||
}));
|
||
expect(store.moveTask).toHaveBeenCalledWith("FN-5436-A", "todo", { preserveProgress: true });
|
||
expect(store.moveTask).not.toHaveBeenCalledWith("FN-5436-A", "in-review");
|
||
expect(store.updateTask).not.toHaveBeenCalledWith("FN-5436-A", {
|
||
status: "failed",
|
||
error: "executor-exit-while-review-pending",
|
||
});
|
||
expect(onError).toHaveBeenCalledWith(
|
||
expect.objectContaining({ id: "FN-5436-A" }),
|
||
expect.objectContaining({ message: "Agent finished without calling fn_task_done (after 3 retries)" }),
|
||
);
|
||
});
|
||
|
||
it("parks in-review when review request has no subsequent verdict", async () => {
|
||
const store = createMockStore();
|
||
const baseTask = {
|
||
id: "FN-5436-B",
|
||
title: "Test",
|
||
description: "Test task",
|
||
column: "in-progress",
|
||
dependencies: [],
|
||
steps: [{ name: "Implement", status: "in-progress" }],
|
||
currentStep: 0,
|
||
log: [{ action: "code review requested for Step 0 (Implement)", timestamp: new Date().toISOString() }],
|
||
prompt: "# test\n## Steps\n### Step 1: Implement\n- [ ] implement",
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
};
|
||
store.getTask.mockResolvedValue(baseTask as any);
|
||
|
||
mockedCreateFnAgent.mockResolvedValue({
|
||
session: {
|
||
prompt: vi.fn().mockResolvedValue(undefined),
|
||
dispose: vi.fn(),
|
||
subscribe: vi.fn(),
|
||
on: vi.fn(),
|
||
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
|
||
state: {},
|
||
},
|
||
} as any);
|
||
|
||
const executor = new TaskExecutor(store, "/tmp/test", {});
|
||
await executor.execute(baseTask as any);
|
||
|
||
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(1);
|
||
expect(store.updateTask).not.toHaveBeenCalledWith("FN-5436-B", {
|
||
status: "failed",
|
||
error: "executor-exit-while-review-pending",
|
||
});
|
||
expect(store.logEntry).toHaveBeenCalledWith(
|
||
"FN-5436-B",
|
||
expect.stringContaining("blocked on pending review (review-request-without-verdict)"),
|
||
undefined,
|
||
expect.objectContaining({ agentId: "executor" }),
|
||
);
|
||
expect(store.moveTask).toHaveBeenCalledWith("FN-5436-B", "in-review");
|
||
});
|
||
|
||
it("keeps existing retry loop when no pending review block is present", async () => {
|
||
const store = createMockStore();
|
||
const baseTask = {
|
||
id: "FN-5436-C",
|
||
title: "Test",
|
||
description: "Test task",
|
||
column: "in-progress",
|
||
dependencies: [],
|
||
steps: [{ name: "Implement", status: "in-progress" }],
|
||
currentStep: 0,
|
||
log: [{ action: "code review Step 0: APPROVE", timestamp: new Date().toISOString() }],
|
||
prompt: "# test\n## Steps\n### Step 1: Implement\n- [ ] implement",
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
};
|
||
store.getTask.mockResolvedValue(baseTask as any);
|
||
|
||
mockedCreateFnAgent.mockResolvedValue({
|
||
session: {
|
||
prompt: vi.fn().mockResolvedValue(undefined),
|
||
dispose: vi.fn(),
|
||
subscribe: vi.fn(),
|
||
on: vi.fn(),
|
||
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
|
||
state: {},
|
||
},
|
||
} as any);
|
||
|
||
const executor = new TaskExecutor(store, "/tmp/test", {});
|
||
await executor.execute(baseTask as any);
|
||
|
||
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(4);
|
||
expect(store.updateTask).toHaveBeenCalledWith("FN-5436-C", {
|
||
status: "queued",
|
||
error: null,
|
||
taskDoneRetryCount: 1,
|
||
});
|
||
});
|
||
|
||
it("allows implicit done to complete when no in-progress step exists", async () => {
|
||
const store = createMockStore();
|
||
const baseTask = {
|
||
id: "FN-5436-D",
|
||
title: "Test",
|
||
description: "Test task",
|
||
column: "in-progress",
|
||
dependencies: [],
|
||
steps: [{ name: "Implement", status: "done" }],
|
||
currentStep: 0,
|
||
log: [],
|
||
prompt: "# test\n## Steps\n### Step 1: Implement\n- [x] implement",
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
};
|
||
store.getTask.mockResolvedValue(baseTask as any);
|
||
|
||
mockedCreateFnAgent.mockResolvedValue({
|
||
session: {
|
||
prompt: vi.fn().mockResolvedValue(undefined),
|
||
dispose: vi.fn(),
|
||
subscribe: vi.fn(),
|
||
on: vi.fn(),
|
||
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
|
||
state: {},
|
||
},
|
||
} as any);
|
||
|
||
const onComplete = vi.fn();
|
||
const onError = vi.fn();
|
||
const executor = new TaskExecutor(store, "/tmp/test", { onComplete, onError });
|
||
await executor.execute(baseTask as any);
|
||
|
||
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(1);
|
||
expect(store.updateTask).toHaveBeenCalledWith("FN-5436-D", { workflowStepRetries: undefined, taskDoneRetryCount: null });
|
||
expect(store.updateTask).not.toHaveBeenCalledWith("FN-5436-D", {
|
||
status: "failed",
|
||
error: "executor-exit-while-review-pending",
|
||
});
|
||
expect(store.moveTask).toHaveBeenCalledWith("FN-5436-D", "in-review");
|
||
expect(onComplete).toHaveBeenCalled();
|
||
expect(onError).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
it("runs workflow steps after main task execution", async () => {
|
||
const store = createMockStore();
|
||
|
||
// Task has workflow steps enabled
|
||
store.getTask.mockResolvedValue({
|
||
id: "FN-001",
|
||
title: "Test",
|
||
description: "Test task",
|
||
column: "in-progress",
|
||
dependencies: [],
|
||
steps: [{ name: "Preflight", status: "pending" }],
|
||
currentStep: 0,
|
||
log: [],
|
||
enabledWorkflowSteps: ["WS-001"],
|
||
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
store.getWorkflowStep.mockResolvedValue({
|
||
id: "WS-001",
|
||
name: "Docs Review",
|
||
description: "Check documentation",
|
||
prompt: "Review all docs and verify they are complete.",
|
||
enabled: true,
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
// First call: main agent with fn_task_done, subsequent calls: simple mocks for workflow step agents
|
||
let callIdx = 0;
|
||
mockedCreateFnAgent.mockImplementation((async (opts: any) => {
|
||
callIdx++;
|
||
if (callIdx === 1) {
|
||
// Main execution — find and trigger fn_task_done
|
||
const customTools = opts.customTools || [];
|
||
const session = {
|
||
prompt: vi.fn().mockImplementation(async () => {
|
||
const taskDoneTool = customTools.find((t: any) => t.name === "fn_task_done");
|
||
if (taskDoneTool) await taskDoneTool.execute("tool-1", {});
|
||
}),
|
||
dispose: vi.fn(),
|
||
subscribe: vi.fn(),
|
||
on: vi.fn(),
|
||
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
|
||
state: {},
|
||
};
|
||
return { session };
|
||
} else {
|
||
// Workflow step agent (no custom tools, uses readonly tools)
|
||
return {
|
||
session: {
|
||
prompt: vi.fn().mockResolvedValue(undefined),
|
||
dispose: vi.fn(),
|
||
subscribe: vi.fn(),
|
||
on: vi.fn(),
|
||
state: {},
|
||
},
|
||
};
|
||
}
|
||
}) as any);
|
||
|
||
const onComplete = vi.fn();
|
||
const executor = new TaskExecutor(store, "/tmp/test", { onComplete });
|
||
|
||
await executor.execute({
|
||
id: "FN-001",
|
||
title: "Test",
|
||
description: "Test task",
|
||
column: "in-progress",
|
||
dependencies: [],
|
||
steps: [{ name: "Preflight", status: "pending" }],
|
||
currentStep: 0,
|
||
log: [],
|
||
enabledWorkflowSteps: ["WS-001"],
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
// createFnAgent called twice: main agent + workflow step agent
|
||
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2);
|
||
|
||
// Second call should be the workflow step with readonly tools
|
||
const secondCall = mockedCreateFnAgent.mock.calls[1];
|
||
expect(secondCall[0].tools).toBe("readonly");
|
||
expect(secondCall[0].systemPrompt).toContain("Docs Review");
|
||
expect(secondCall[0].systemPrompt).toContain("Review all docs and verify they are complete.");
|
||
const withoutWorkflowStep = (env: Record<string, string | undefined>) => {
|
||
const { FUSION_WORKFLOW_STEP: _workflowStep, ...stableEnv } = env;
|
||
return stableEnv;
|
||
};
|
||
expect(secondCall[0].taskEnv.FUSION_WORKFLOW_STEP).toBe("1");
|
||
expect(withoutWorkflowStep(secondCall[0].taskEnv)).toEqual(withoutWorkflowStep(mockedCreateFnAgent.mock.calls[0][0].taskEnv));
|
||
|
||
// Task should move to in-review
|
||
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review");
|
||
});
|
||
|
||
it("executes plugin-prefixed workflow steps", async () => {
|
||
const store = createMockStore();
|
||
|
||
store.getTask.mockResolvedValue({
|
||
id: "FN-001",
|
||
title: "Test",
|
||
description: "Test task",
|
||
column: "in-progress",
|
||
dependencies: [],
|
||
steps: [{ name: "Preflight", status: "pending" }],
|
||
currentStep: 0,
|
||
log: [],
|
||
enabledWorkflowSteps: ["plugin:agent-browser:workflow-check"],
|
||
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
store.getWorkflowStep.mockResolvedValue({
|
||
id: "plugin:agent-browser:workflow-check",
|
||
name: "Plugin Workflow Check",
|
||
description: "Plugin contributed step",
|
||
prompt: "Run plugin check",
|
||
enabled: true,
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
let callIdx = 0;
|
||
mockedCreateFnAgent.mockImplementation((async (opts: any) => {
|
||
callIdx++;
|
||
if (callIdx === 1) {
|
||
const customTools = opts.customTools || [];
|
||
return {
|
||
session: {
|
||
prompt: vi.fn().mockImplementation(async () => {
|
||
const taskDoneTool = customTools.find((t: any) => t.name === "fn_task_done");
|
||
if (taskDoneTool) await taskDoneTool.execute("tool-1", {});
|
||
}),
|
||
dispose: vi.fn(),
|
||
subscribe: vi.fn(),
|
||
on: vi.fn(),
|
||
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
|
||
state: {},
|
||
},
|
||
};
|
||
}
|
||
|
||
return {
|
||
session: {
|
||
prompt: vi.fn().mockResolvedValue(undefined),
|
||
dispose: vi.fn(),
|
||
subscribe: vi.fn(),
|
||
on: vi.fn(),
|
||
state: {},
|
||
},
|
||
};
|
||
}) as any);
|
||
|
||
const executor = new TaskExecutor(store, "/tmp/test", {});
|
||
|
||
await executor.execute({
|
||
id: "FN-001",
|
||
title: "Test",
|
||
description: "Test task",
|
||
column: "in-progress",
|
||
dependencies: [],
|
||
steps: [{ name: "Preflight", status: "pending" }],
|
||
currentStep: 0,
|
||
log: [],
|
||
enabledWorkflowSteps: ["plugin:agent-browser:workflow-check"],
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
expect(store.setPluginWorkflowStepTemplates).toHaveBeenCalledWith([]);
|
||
expect(store.logEntry).toHaveBeenCalledWith(
|
||
"FN-001",
|
||
"[pre-merge] Starting plugin workflow step: Plugin Workflow Check (plugin:agent-browser:workflow-check)",
|
||
);
|
||
});
|
||
|
||
it("runs browser verification workflow steps with coding tools", async () => {
|
||
const store = createMockStore();
|
||
|
||
store.getTask.mockResolvedValue({
|
||
id: "FN-001",
|
||
title: "Browser task",
|
||
description: "Verify browser behavior",
|
||
column: "in-progress",
|
||
dependencies: [],
|
||
steps: [{ name: "Preflight", status: "pending" }],
|
||
currentStep: 0,
|
||
log: [],
|
||
enabledWorkflowSteps: ["WS-001"],
|
||
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
store.getWorkflowStep.mockResolvedValue({
|
||
id: "WS-001",
|
||
templateId: "browser-verification",
|
||
name: "Browser Verification",
|
||
description: "Verify with browser automation",
|
||
mode: "prompt",
|
||
toolMode: "coding",
|
||
prompt: "Use browser automation to verify the app.",
|
||
enabled: true,
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
let callIdx = 0;
|
||
mockedCreateFnAgent.mockImplementation((async (opts: any) => {
|
||
callIdx++;
|
||
if (callIdx === 1) {
|
||
const customTools = opts.customTools || [];
|
||
const session = {
|
||
prompt: vi.fn().mockImplementation(async () => {
|
||
const taskDoneTool = customTools.find((t: any) => t.name === "fn_task_done");
|
||
if (taskDoneTool) await taskDoneTool.execute("tool-1", {});
|
||
}),
|
||
dispose: vi.fn(),
|
||
subscribe: vi.fn(),
|
||
on: vi.fn(),
|
||
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
|
||
state: {},
|
||
};
|
||
return { session };
|
||
}
|
||
|
||
return {
|
||
session: {
|
||
prompt: vi.fn().mockResolvedValue(undefined),
|
||
dispose: vi.fn(),
|
||
subscribe: vi.fn(),
|
||
on: vi.fn(),
|
||
state: {},
|
||
},
|
||
};
|
||
}) as any);
|
||
|
||
const executor = new TaskExecutor(store, "/tmp/test");
|
||
await executor.execute({
|
||
id: "FN-001",
|
||
title: "Browser task",
|
||
description: "Verify browser behavior",
|
||
column: "in-progress",
|
||
dependencies: [],
|
||
steps: [{ name: "Preflight", status: "pending" }],
|
||
currentStep: 0,
|
||
log: [],
|
||
enabledWorkflowSteps: ["WS-001"],
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2);
|
||
const secondCall = mockedCreateFnAgent.mock.calls[1];
|
||
expect(secondCall[0].tools).toBe("coding");
|
||
});
|
||
|
||
it("runs QA workflow steps with coding tools", async () => {
|
||
const store = createMockStore();
|
||
|
||
store.getTask.mockResolvedValue({
|
||
id: "FN-001",
|
||
title: "QA task",
|
||
description: "Verify tests pass",
|
||
column: "in-progress",
|
||
dependencies: [],
|
||
steps: [{ name: "Preflight", status: "pending" }],
|
||
currentStep: 0,
|
||
log: [],
|
||
enabledWorkflowSteps: ["WS-001"],
|
||
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
store.getWorkflowStep.mockResolvedValue({
|
||
id: "WS-001",
|
||
templateId: "qa-check",
|
||
name: "QA Check",
|
||
description: "Run tests and verify they pass",
|
||
mode: "prompt",
|
||
toolMode: "coding",
|
||
prompt: "Run the test suite and report results.",
|
||
enabled: true,
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
let callIdx = 0;
|
||
mockedCreateFnAgent.mockImplementation((async (opts: any) => {
|
||
callIdx++;
|
||
if (callIdx === 1) {
|
||
const customTools = opts.customTools || [];
|
||
const session = {
|
||
prompt: vi.fn().mockImplementation(async () => {
|
||
const taskDoneTool = customTools.find((t: any) => t.name === "fn_task_done");
|
||
if (taskDoneTool) await taskDoneTool.execute("tool-1", {});
|
||
}),
|
||
dispose: vi.fn(),
|
||
subscribe: vi.fn(),
|
||
on: vi.fn(),
|
||
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
|
||
state: {},
|
||
};
|
||
return { session };
|
||
}
|
||
|
||
return {
|
||
session: {
|
||
prompt: vi.fn().mockResolvedValue(undefined),
|
||
dispose: vi.fn(),
|
||
subscribe: vi.fn(),
|
||
on: vi.fn(),
|
||
state: {},
|
||
},
|
||
};
|
||
}) as any);
|
||
|
||
const executor = new TaskExecutor(store, "/tmp/test");
|
||
await executor.execute({
|
||
id: "FN-001",
|
||
title: "QA task",
|
||
description: "Verify tests pass",
|
||
column: "in-progress",
|
||
dependencies: [],
|
||
steps: [{ name: "Preflight", status: "pending" }],
|
||
currentStep: 0,
|
||
log: [],
|
||
enabledWorkflowSteps: ["WS-001"],
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2);
|
||
const secondCall = mockedCreateFnAgent.mock.calls[1];
|
||
expect(secondCall[0].tools).toBe("coding");
|
||
});
|
||
|
||
it("skips workflow steps with no prompt", async () => {
|
||
const store = createMockStore();
|
||
|
||
store.getTask.mockResolvedValue({
|
||
id: "FN-001",
|
||
title: "Test",
|
||
description: "Test task",
|
||
column: "in-progress",
|
||
dependencies: [],
|
||
steps: [{ name: "Preflight", status: "pending" }],
|
||
currentStep: 0,
|
||
log: [],
|
||
enabledWorkflowSteps: ["WS-001"],
|
||
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
store.getWorkflowStep.mockResolvedValue({
|
||
id: "WS-001",
|
||
name: "Empty Step",
|
||
description: "No prompt",
|
||
prompt: "",
|
||
enabled: true,
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
createAgentWithTaskDone();
|
||
|
||
const onComplete = vi.fn();
|
||
const executor = new TaskExecutor(store, "/tmp/test", { onComplete });
|
||
|
||
await executor.execute({
|
||
id: "FN-001",
|
||
title: "Test",
|
||
description: "Test task",
|
||
column: "in-progress",
|
||
dependencies: [],
|
||
steps: [{ name: "Preflight", status: "pending" }],
|
||
currentStep: 0,
|
||
log: [],
|
||
enabledWorkflowSteps: ["WS-001"],
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
// Should only call createFnAgent once (main execution), skip workflow step
|
||
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(1);
|
||
|
||
// Should log that it was skipped
|
||
expect(store.logEntry).toHaveBeenCalledWith(
|
||
"FN-001",
|
||
expect.stringContaining("has no prompt"),
|
||
);
|
||
|
||
// Task should still move to in-review
|
||
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review");
|
||
});
|
||
|
||
it("handles tasks with no workflow steps", async () => {
|
||
const store = createMockStore();
|
||
|
||
store.getTask.mockResolvedValue({
|
||
id: "FN-001",
|
||
title: "Test",
|
||
description: "Test task",
|
||
column: "in-progress",
|
||
dependencies: [],
|
||
steps: [{ name: "Preflight", status: "pending" }],
|
||
currentStep: 0,
|
||
log: [],
|
||
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
createAgentWithTaskDone();
|
||
|
||
const onComplete = vi.fn();
|
||
const executor = new TaskExecutor(store, "/tmp/test", { onComplete });
|
||
|
||
await executor.execute({
|
||
id: "FN-001",
|
||
title: "Test",
|
||
description: "Test task",
|
||
column: "in-progress",
|
||
dependencies: [],
|
||
steps: [{ name: "Preflight", status: "pending" }],
|
||
currentStep: 0,
|
||
log: [],
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
// Only main agent call
|
||
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(1);
|
||
// Task should still move to in-review
|
||
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review");
|
||
});
|
||
|
||
it("uses workflow step model override when both provider and modelId are set", async () => {
|
||
const store = createMockStore();
|
||
|
||
store.getTask.mockResolvedValue({
|
||
id: "FN-001",
|
||
title: "Test",
|
||
description: "Test task",
|
||
column: "in-progress",
|
||
dependencies: [],
|
||
steps: [{ name: "Preflight", status: "pending" }],
|
||
currentStep: 0,
|
||
log: [],
|
||
enabledWorkflowSteps: ["WS-001"],
|
||
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
store.getWorkflowStep.mockResolvedValue({
|
||
id: "WS-001",
|
||
name: "Security Audit",
|
||
description: "Check security",
|
||
prompt: "Scan for vulnerabilities.",
|
||
enabled: true,
|
||
modelProvider: "anthropic",
|
||
modelId: "claude-sonnet-4-5",
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
let callIdx = 0;
|
||
mockedCreateFnAgent.mockImplementation((async (opts: any) => {
|
||
callIdx++;
|
||
if (callIdx === 1) {
|
||
// Main execution agent
|
||
const customTools = opts.customTools || [];
|
||
const session = {
|
||
prompt: vi.fn().mockImplementation(async () => {
|
||
const taskDoneTool = customTools.find((t: any) => t.name === "fn_task_done");
|
||
if (taskDoneTool) await taskDoneTool.execute("tool-1", {});
|
||
}),
|
||
dispose: vi.fn(),
|
||
subscribe: vi.fn(),
|
||
on: vi.fn(),
|
||
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
|
||
state: {},
|
||
};
|
||
return { session };
|
||
} else {
|
||
// Workflow step agent
|
||
return {
|
||
session: {
|
||
prompt: vi.fn().mockResolvedValue(undefined),
|
||
dispose: vi.fn(),
|
||
subscribe: vi.fn(),
|
||
on: vi.fn(),
|
||
state: {},
|
||
},
|
||
};
|
||
}
|
||
}) as any);
|
||
|
||
const onComplete = vi.fn();
|
||
const executor = new TaskExecutor(store, "/tmp/test", { onComplete });
|
||
|
||
await executor.execute({
|
||
id: "FN-001",
|
||
title: "Test",
|
||
description: "Test task",
|
||
column: "in-progress",
|
||
dependencies: [],
|
||
steps: [{ name: "Preflight", status: "pending" }],
|
||
currentStep: 0,
|
||
log: [],
|
||
enabledWorkflowSteps: ["WS-001"],
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
// createFnAgent called twice: main agent + workflow step agent
|
||
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2);
|
||
|
||
// Second call should use the workflow step's model override
|
||
const secondCall = mockedCreateFnAgent.mock.calls[1];
|
||
expect(secondCall[0].defaultProvider).toBe("anthropic");
|
||
expect(secondCall[0].defaultModelId).toBe("claude-sonnet-4-5");
|
||
|
||
// Log should indicate the override
|
||
expect(store.logEntry).toHaveBeenCalledWith(
|
||
"FN-001",
|
||
expect.stringContaining("workflow step override"),
|
||
);
|
||
});
|
||
|
||
it("uses global defaults when workflow step has no model override", async () => {
|
||
const store = createMockStore();
|
||
|
||
store.getTask.mockResolvedValue({
|
||
id: "FN-001",
|
||
title: "Test",
|
||
description: "Test task",
|
||
column: "in-progress",
|
||
dependencies: [],
|
||
steps: [{ name: "Preflight", status: "pending" }],
|
||
currentStep: 0,
|
||
log: [],
|
||
enabledWorkflowSteps: ["WS-001"],
|
||
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
// Workflow step without model override
|
||
store.getWorkflowStep.mockResolvedValue({
|
||
id: "WS-001",
|
||
name: "Docs Review",
|
||
description: "Check documentation",
|
||
prompt: "Review all docs.",
|
||
enabled: true,
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
let callIdx = 0;
|
||
mockedCreateFnAgent.mockImplementation((async (opts: any) => {
|
||
callIdx++;
|
||
if (callIdx === 1) {
|
||
const customTools = opts.customTools || [];
|
||
const session = {
|
||
prompt: vi.fn().mockImplementation(async () => {
|
||
const taskDoneTool = customTools.find((t: any) => t.name === "fn_task_done");
|
||
if (taskDoneTool) await taskDoneTool.execute("tool-1", {});
|
||
}),
|
||
dispose: vi.fn(),
|
||
subscribe: vi.fn(),
|
||
on: vi.fn(),
|
||
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
|
||
state: {},
|
||
};
|
||
return { session };
|
||
} else {
|
||
return {
|
||
session: {
|
||
prompt: vi.fn().mockResolvedValue(undefined),
|
||
dispose: vi.fn(),
|
||
subscribe: vi.fn(),
|
||
on: vi.fn(),
|
||
state: {},
|
||
},
|
||
};
|
||
}
|
||
}) as any);
|
||
|
||
const onComplete = vi.fn();
|
||
const executor = new TaskExecutor(store, "/tmp/test", { onComplete });
|
||
|
||
await executor.execute({
|
||
id: "FN-001",
|
||
title: "Test",
|
||
description: "Test task",
|
||
column: "in-progress",
|
||
dependencies: [],
|
||
steps: [{ name: "Preflight", status: "pending" }],
|
||
currentStep: 0,
|
||
log: [],
|
||
enabledWorkflowSteps: ["WS-001"],
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2);
|
||
|
||
// Second call should use settings defaults (no override indicator)
|
||
const secondCall = mockedCreateFnAgent.mock.calls[1];
|
||
// defaults come from the mock store's getSettings
|
||
expect(secondCall[0].defaultProvider).toBeUndefined();
|
||
expect(secondCall[0].defaultModelId).toBeUndefined();
|
||
|
||
// Log should NOT indicate override
|
||
expect(store.logEntry).not.toHaveBeenCalledWith(
|
||
"FN-001",
|
||
expect.stringContaining("workflow step override"),
|
||
);
|
||
});
|
||
|
||
it("auto-skips built-in Frontend UX Design when diff scope has no frontend files", async () => {
|
||
const store = createMockStore();
|
||
const task = {
|
||
id: "FN-001",
|
||
title: "Test",
|
||
description: "Test task",
|
||
column: "in-progress" as const,
|
||
dependencies: [],
|
||
steps: [{ name: "Preflight", status: "done" as const }],
|
||
currentStep: 0,
|
||
log: [],
|
||
enabledWorkflowSteps: ["frontend-ux-design"],
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
};
|
||
|
||
store.getTask.mockResolvedValue(task as any);
|
||
store.getWorkflowStep.mockResolvedValue({
|
||
id: "frontend-ux-design",
|
||
name: "Frontend UX Design",
|
||
description: "UI review",
|
||
prompt: "Review UI",
|
||
enabled: true,
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
||
if (typeof cmd === "string" && cmd.includes("git merge-base HEAD origin/main")) {
|
||
return Buffer.from("abc123\n");
|
||
}
|
||
if (typeof cmd === "string" && cmd.includes("git diff --name-only abc123..HEAD")) {
|
||
return Buffer.from("packages/engine/src/foo.ts\n");
|
||
}
|
||
return Buffer.from("");
|
||
});
|
||
|
||
const executor = new TaskExecutor(store, "/tmp/test", {});
|
||
vi.spyOn(executor as any, "captureModifiedFiles").mockResolvedValue(["packages/engine/src/foo.ts"]);
|
||
const executeStepSpy = vi.spyOn(executor as any, "executeWorkflowStep").mockResolvedValue({ success: true, output: "ok" });
|
||
|
||
const result = await (executor as any).runWorkflowSteps(task, "/tmp/test", {});
|
||
|
||
expect(result).toEqual({ allPassed: true });
|
||
expect(executeStepSpy).not.toHaveBeenCalled();
|
||
expect(store.updateTask).toHaveBeenCalledWith(
|
||
"FN-001",
|
||
expect.objectContaining({
|
||
workflowStepResults: expect.arrayContaining([
|
||
expect.objectContaining({
|
||
workflowStepId: "frontend-ux-design",
|
||
status: "skipped",
|
||
output: expect.stringContaining("No frontend/UI files in diff scope"),
|
||
}),
|
||
]),
|
||
}),
|
||
);
|
||
expect(store.logEntry).toHaveBeenCalledWith(
|
||
"FN-001",
|
||
"[pre-merge] Auto-skipped Frontend UX Design — no frontend/UI files in diff scope",
|
||
);
|
||
const logged = store.logEntry.mock.calls.map((call: any[]) => String(call[1] ?? ""));
|
||
expect(logged.some((line: string) => line.includes("Completion handoff deferred"))).toBe(false);
|
||
});
|
||
|
||
it("runs built-in Frontend UX Design normally when UI files are in scope", async () => {
|
||
const store = createMockStore();
|
||
const task = {
|
||
id: "FN-001",
|
||
title: "Test",
|
||
description: "Test task",
|
||
column: "in-progress" as const,
|
||
dependencies: [],
|
||
steps: [{ name: "Preflight", status: "done" as const }],
|
||
currentStep: 0,
|
||
log: [],
|
||
enabledWorkflowSteps: ["frontend-ux-design"],
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
};
|
||
|
||
store.getTask.mockResolvedValue(task as any);
|
||
store.getWorkflowStep.mockResolvedValue({
|
||
id: "frontend-ux-design",
|
||
name: "Frontend UX Design",
|
||
description: "UI review",
|
||
prompt: "Review UI",
|
||
enabled: true,
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
||
if (typeof cmd === "string" && cmd.includes("git merge-base HEAD origin/main")) {
|
||
return Buffer.from("abc123\n");
|
||
}
|
||
if (typeof cmd === "string" && cmd.includes("git diff --name-only abc123..HEAD")) {
|
||
return Buffer.from("packages/dashboard/app/components/Foo.tsx\n");
|
||
}
|
||
return Buffer.from("");
|
||
});
|
||
|
||
const executor = new TaskExecutor(store, "/tmp/test", {});
|
||
const executeStepSpy = vi.spyOn(executor as any, "executeWorkflowStep").mockResolvedValue({ success: true, output: "approved" });
|
||
|
||
const result = await (executor as any).runWorkflowSteps(task, "/tmp/test", {});
|
||
|
||
expect(result).toEqual({ allPassed: true });
|
||
expect(executeStepSpy).toHaveBeenCalledTimes(1);
|
||
expect(store.updateTask).toHaveBeenCalledWith(
|
||
"FN-001",
|
||
expect.objectContaining({
|
||
workflowStepResults: expect.arrayContaining([
|
||
expect.objectContaining({ workflowStepId: "frontend-ux-design", status: "passed" }),
|
||
]),
|
||
}),
|
||
);
|
||
const logged = store.logEntry.mock.calls.map((call: any[]) => String(call[1] ?? ""));
|
||
expect(logged.some((line: string) => line.includes("Auto-skipped Frontend UX Design"))).toBe(false);
|
||
});
|
||
|
||
it("avoids paused defer for built-in Frontend UX Design when no UI files are in scope", async () => {
|
||
const store = createMockStore();
|
||
const pausedTask = {
|
||
id: "FN-001",
|
||
title: "Test",
|
||
description: "Test task",
|
||
column: "in-progress" as const,
|
||
dependencies: [],
|
||
steps: [{ name: "Preflight", status: "done" as const }],
|
||
currentStep: 0,
|
||
paused: true,
|
||
log: [],
|
||
enabledWorkflowSteps: ["frontend-ux-design"],
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
};
|
||
|
||
store.getTask.mockResolvedValue(pausedTask as any);
|
||
store.getWorkflowStep.mockResolvedValue({
|
||
id: "frontend-ux-design",
|
||
name: "Frontend UX Design",
|
||
description: "UI review",
|
||
prompt: "Review UI",
|
||
enabled: true,
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
||
if (typeof cmd === "string" && cmd.includes("git merge-base HEAD origin/main")) {
|
||
return Buffer.from("abc123\n");
|
||
}
|
||
if (typeof cmd === "string" && cmd.includes("git diff --name-only abc123..HEAD")) {
|
||
return Buffer.from("packages/engine/src/foo.ts\n");
|
||
}
|
||
return Buffer.from("");
|
||
});
|
||
|
||
const executor = new TaskExecutor(store, "/tmp/test", {});
|
||
vi.spyOn(executor as any, "captureModifiedFiles").mockResolvedValue(["packages/engine/src/foo.ts"]);
|
||
const executeStepSpy = vi.spyOn(executor as any, "executeWorkflowStep").mockResolvedValue({ success: true, output: "ok" });
|
||
|
||
const result = await (executor as any).runWorkflowSteps(pausedTask, "/tmp/test", {});
|
||
|
||
expect(result).toEqual({ allPassed: true });
|
||
expect(executeStepSpy).not.toHaveBeenCalled();
|
||
const logged = store.logEntry.mock.calls.map((call: any[]) => String(call[1] ?? ""));
|
||
expect(logged.some((line: string) => line.includes("Auto-skipped Frontend UX Design — no frontend/UI files in diff scope"))).toBe(true);
|
||
expect(logged.some((line: string) => line.includes("Completion handoff deferred — task paused (before workflow step 'Frontend UX Design')"))).toBe(false);
|
||
});
|
||
|
||
it("does not auto-skip custom step id even when named Frontend UX Design", async () => {
|
||
const store = createMockStore();
|
||
const task = {
|
||
id: "FN-001",
|
||
title: "Test",
|
||
description: "Test task",
|
||
column: "in-progress" as const,
|
||
dependencies: [],
|
||
steps: [{ name: "Preflight", status: "done" as const }],
|
||
currentStep: 0,
|
||
log: [],
|
||
enabledWorkflowSteps: ["ws-custom-1"],
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
};
|
||
|
||
store.getTask.mockResolvedValue(task as any);
|
||
store.getWorkflowStep.mockResolvedValue({
|
||
id: "ws-custom-1",
|
||
name: "Frontend UX Design",
|
||
description: "Custom UI review",
|
||
prompt: "Review UI",
|
||
enabled: true,
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
const executor = new TaskExecutor(store, "/tmp/test", {});
|
||
const executeStepSpy = vi.spyOn(executor as any, "executeWorkflowStep").mockResolvedValue({ success: true, output: "ok" });
|
||
|
||
const result = await (executor as any).runWorkflowSteps(task, "/tmp/test", {});
|
||
|
||
expect(result).toEqual({ allPassed: true });
|
||
expect(executeStepSpy).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it("executes script-mode workflow step successfully", async () => {
|
||
const store = createMockStore();
|
||
process.env.FN3968_SCRIPT_ENV = "workflow-script-env";
|
||
|
||
store.getSettings.mockResolvedValue({
|
||
maxConcurrent: 2,
|
||
maxWorktrees: 4,
|
||
scripts: { test: `node -e "if (process.env.FN3968_SCRIPT_ENV !== 'workflow-script-env') process.exit(42)"` },
|
||
});
|
||
|
||
store.getTask.mockResolvedValue({
|
||
id: "FN-001",
|
||
title: "Test",
|
||
description: "Test task",
|
||
column: "in-progress",
|
||
dependencies: [],
|
||
steps: [{ name: "Preflight", status: "pending" }],
|
||
currentStep: 0,
|
||
log: [],
|
||
enabledWorkflowSteps: ["WS-001"],
|
||
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
store.getWorkflowStep.mockResolvedValue({
|
||
id: "WS-001",
|
||
name: "Run Tests",
|
||
description: "Execute test suite",
|
||
mode: "script",
|
||
prompt: "",
|
||
scriptName: "test",
|
||
enabled: true,
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
// Main agent with fn_task_done
|
||
createAgentWithTaskDone();
|
||
|
||
const onComplete = vi.fn();
|
||
const executor = new TaskExecutor(store, "/tmp/test", { onComplete });
|
||
|
||
await executor.execute({
|
||
id: "FN-001",
|
||
title: "Test",
|
||
description: "Test task",
|
||
column: "in-progress",
|
||
dependencies: [],
|
||
steps: [{ name: "Preflight", status: "pending" }],
|
||
currentStep: 0,
|
||
log: [],
|
||
enabledWorkflowSteps: ["WS-001"],
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
// Should only call createFnAgent once (main execution — no agent for script mode)
|
||
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(1);
|
||
|
||
// Should log script execution
|
||
expect(store.logEntry).toHaveBeenCalledWith(
|
||
"FN-001",
|
||
expect.stringContaining("executing script 'test'"),
|
||
);
|
||
|
||
// Task should move to in-review
|
||
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review");
|
||
|
||
// Should record a passed result
|
||
expect(store.updateTask).toHaveBeenCalledWith(
|
||
"FN-001",
|
||
expect.objectContaining({
|
||
workflowStepResults: expect.arrayContaining([
|
||
expect.objectContaining({
|
||
workflowStepId: "WS-001",
|
||
workflowStepName: "Run Tests",
|
||
status: "passed",
|
||
output: "Script 'test' completed successfully",
|
||
}),
|
||
]),
|
||
}),
|
||
);
|
||
delete process.env.FN3968_SCRIPT_ENV;
|
||
});
|
||
|
||
it("executes plugin script-mode workflow step successfully", async () => {
|
||
const store = createMockStore();
|
||
|
||
store.getSettings.mockResolvedValue({
|
||
maxConcurrent: 2,
|
||
maxWorktrees: 4,
|
||
scripts: { test: "echo 'all tests passed'" },
|
||
});
|
||
|
||
store.getTask.mockResolvedValue({
|
||
id: "FN-001",
|
||
title: "Test",
|
||
description: "Test task",
|
||
column: "in-progress",
|
||
dependencies: [],
|
||
steps: [{ name: "Preflight", status: "pending" }],
|
||
currentStep: 0,
|
||
log: [],
|
||
enabledWorkflowSteps: ["plugin:agent-browser:script-check"],
|
||
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
store.getWorkflowStep.mockResolvedValue({
|
||
id: "plugin:agent-browser:script-check",
|
||
name: "Plugin Script Check",
|
||
description: "Execute plugin script",
|
||
mode: "script",
|
||
prompt: "",
|
||
scriptName: "test",
|
||
enabled: true,
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
||
if (typeof cmd === "string" && cmd.includes("echo")) return Buffer.from("all tests passed\n");
|
||
return Buffer.from("");
|
||
});
|
||
|
||
createAgentWithTaskDone();
|
||
const executor = new TaskExecutor(store, "/tmp/test", {});
|
||
|
||
await executor.execute({
|
||
id: "FN-001",
|
||
title: "Test",
|
||
description: "Test task",
|
||
column: "in-progress",
|
||
dependencies: [],
|
||
steps: [{ name: "Preflight", status: "pending" }],
|
||
currentStep: 0,
|
||
log: [],
|
||
enabledWorkflowSteps: ["plugin:agent-browser:script-check"],
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(1);
|
||
expect(store.updateTask).toHaveBeenCalledWith(
|
||
"FN-001",
|
||
expect.objectContaining({
|
||
workflowStepResults: expect.arrayContaining([
|
||
expect.objectContaining({
|
||
workflowStepId: "plugin:agent-browser:script-check",
|
||
status: "passed",
|
||
}),
|
||
]),
|
||
}),
|
||
);
|
||
});
|
||
|
||
it("executes mixed db and plugin workflow steps in sequence", async () => {
|
||
const store = createMockStore();
|
||
|
||
store.getTask.mockResolvedValue({
|
||
id: "FN-001",
|
||
title: "Test",
|
||
description: "Test task",
|
||
column: "in-progress",
|
||
dependencies: [],
|
||
steps: [{ name: "Preflight", status: "pending" }],
|
||
currentStep: 0,
|
||
log: [],
|
||
enabledWorkflowSteps: ["WS-001", "plugin:agent-browser:workflow-check"],
|
||
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
store.getWorkflowStep.mockImplementation(async (id: string) => id === "WS-001"
|
||
? {
|
||
id: "WS-001", name: "DB Step", description: "DB", prompt: "Run DB step", enabled: true,
|
||
createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
|
||
}
|
||
: {
|
||
id: "plugin:agent-browser:workflow-check", name: "Plugin Step", description: "Plugin", prompt: "Run plugin step", enabled: true,
|
||
createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
let callIdx = 0;
|
||
mockedCreateFnAgent.mockImplementation((async (opts: any) => {
|
||
callIdx++;
|
||
if (callIdx === 1) {
|
||
const customTools = opts.customTools || [];
|
||
return { session: { prompt: vi.fn().mockImplementation(async () => {
|
||
const taskDoneTool = customTools.find((t: any) => t.name === "fn_task_done");
|
||
if (taskDoneTool) await taskDoneTool.execute("tool-1", {});
|
||
}), dispose: vi.fn(), subscribe: vi.fn(), on: vi.fn(), sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") }, state: {} } };
|
||
}
|
||
return { session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), subscribe: vi.fn(), on: vi.fn(), state: {} } };
|
||
}) as any);
|
||
|
||
const executor = new TaskExecutor(store, "/tmp/test", {});
|
||
await executor.execute({
|
||
id: "FN-001", title: "Test", description: "Test task", column: "in-progress", dependencies: [],
|
||
steps: [{ name: "Preflight", status: "pending" }], currentStep: 0, log: [],
|
||
enabledWorkflowSteps: ["WS-001", "plugin:agent-browser:workflow-check"], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
expect(store.getWorkflowStep).toHaveBeenNthCalledWith(1, "WS-001");
|
||
expect(store.getWorkflowStep).toHaveBeenNthCalledWith(2, "plugin:agent-browser:workflow-check");
|
||
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(3);
|
||
});
|
||
|
||
it("skips missing plugin workflow step IDs with warning log", async () => {
|
||
const store = createMockStore();
|
||
|
||
store.getTask.mockResolvedValue({
|
||
id: "FN-001", title: "Test", description: "Test task", column: "in-progress", dependencies: [],
|
||
steps: [{ name: "Preflight", status: "pending" }], currentStep: 0, log: [],
|
||
enabledWorkflowSteps: ["plugin:missing:step"], prompt: "# test", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
|
||
});
|
||
store.getWorkflowStep.mockResolvedValue(undefined);
|
||
createAgentWithTaskDone();
|
||
|
||
const executor = new TaskExecutor(store, "/tmp/test", {});
|
||
await executor.execute({
|
||
id: "FN-001", title: "Test", description: "Test task", column: "in-progress", dependencies: [],
|
||
steps: [{ name: "Preflight", status: "pending" }], currentStep: 0, log: [], enabledWorkflowSteps: ["plugin:missing:step"],
|
||
createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
expect(store.logEntry).toHaveBeenCalledWith("FN-001", "[pre-merge] Workflow step plugin:missing:step not found — skipping");
|
||
});
|
||
|
||
it("sends task back to in-progress when script-mode workflow step fails with exhausted retries", async () => {
|
||
const store = createMockStore();
|
||
|
||
store.getSettings.mockResolvedValue({
|
||
maxConcurrent: 2,
|
||
maxWorktrees: 4,
|
||
scripts: { lint: "pnpm lint" },
|
||
});
|
||
|
||
// Mutable task object to track step changes
|
||
const mutableTask = {
|
||
id: "FN-001",
|
||
title: "Test",
|
||
description: "Test task",
|
||
column: "in-progress" as const,
|
||
dependencies: [] as string[],
|
||
steps: [{ name: "Preflight", status: "pending" as const }],
|
||
currentStep: 0,
|
||
log: [] as any[],
|
||
enabledWorkflowSteps: ["WS-001"],
|
||
workflowStepRetries: 3, // Exhaust retries so task fails immediately
|
||
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
};
|
||
store.getTask.mockResolvedValue(mutableTask);
|
||
|
||
// Make updateStep track changes in the mutable task
|
||
store.updateStep.mockImplementation(async (taskId: string, stepIndex: number, status: string) => {
|
||
if (mutableTask.steps[stepIndex]) {
|
||
mutableTask.steps[stepIndex].status = status as any;
|
||
}
|
||
return {};
|
||
});
|
||
|
||
store.getWorkflowStep.mockResolvedValue({
|
||
id: "WS-001",
|
||
name: "Lint Check",
|
||
description: "Run linter",
|
||
mode: "script",
|
||
prompt: "",
|
||
scriptName: "lint",
|
||
enabled: true,
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
// Mock execSync to throw for the lint command
|
||
const scriptErr = new Error("Command failed: pnpm lint");
|
||
(scriptErr as any).status = 1;
|
||
(scriptErr as any).stderr = Buffer.from("syntax error on line 42\n");
|
||
(scriptErr as any).stdout = Buffer.from("");
|
||
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
||
if (typeof cmd === "string" && cmd.includes("lint")) {
|
||
throw scriptErr;
|
||
}
|
||
return Buffer.from("");
|
||
});
|
||
|
||
// Use createAgentWithTaskDone to properly set up the agent mock
|
||
createAgentWithTaskDone();
|
||
|
||
const onComplete = vi.fn();
|
||
const onError = vi.fn();
|
||
const executor = new TaskExecutor(store, "/tmp/test", { onComplete, onError });
|
||
|
||
// Use fake timers to control the setTimeout in sendTaskBackForFix
|
||
vi.useFakeTimers();
|
||
|
||
await executor.execute({
|
||
id: "FN-001",
|
||
title: "Test",
|
||
description: "Test task",
|
||
column: "in-progress",
|
||
dependencies: [],
|
||
steps: [{ name: "Preflight", status: "pending" }],
|
||
currentStep: 0,
|
||
log: [],
|
||
enabledWorkflowSteps: ["WS-001"],
|
||
workflowStepRetries: 3, // Exhaust retries so task fails immediately
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
// Should record a failed result with exit code and stderr
|
||
// (This may not be the first call, so check if any call has workflowStepResults)
|
||
const updateTaskCalls = store.updateTask.mock.calls;
|
||
const hasWorkflowStepFailure = updateTaskCalls.some(
|
||
(call: any[]) =>
|
||
call[0] === "FN-001" &&
|
||
call[1]?.workflowStepResults?.some(
|
||
(r: any) =>
|
||
r.workflowStepId === "WS-001" &&
|
||
r.workflowStepName === "Lint Check" &&
|
||
r.status === "failed" &&
|
||
r.output?.includes("Exit code: 1")
|
||
)
|
||
);
|
||
expect(hasWorkflowStepFailure).toBe(true);
|
||
|
||
// Task should be cleared and reset for retry (not failed + in-review)
|
||
expect(store.updateTask).toHaveBeenCalledWith(
|
||
"FN-001",
|
||
expect.objectContaining({ status: null, error: null, sessionFile: null, workflowStepRetries: 0 }),
|
||
);
|
||
|
||
// Should add a comment with failure feedback
|
||
// This will fail if sendTaskBackForFix is not called
|
||
expect(store.addTaskComment).toHaveBeenCalledWith(
|
||
"FN-001",
|
||
expect.stringContaining("Workflow step failed"),
|
||
"agent",
|
||
);
|
||
|
||
// Should reset all steps to pending
|
||
// Check that updateStep was called with "pending" for step 0
|
||
// (There may be multiple calls - first from fn_task_done marking it done, second from sendTaskBackForFix resetting it)
|
||
const updateStepCalls = store.updateStep.mock.calls;
|
||
const hasResetToPending = updateStepCalls.some(
|
||
(call: any[]) => call[0] === "FN-001" && call[1] === 0 && call[2] === "pending"
|
||
);
|
||
expect(hasResetToPending).toBe(true);
|
||
|
||
// Advance timers to trigger the setTimeout that moves task to todo then in-progress
|
||
vi.advanceTimersByTime(0);
|
||
// Run any pending microtasks (the async code in setTimeout)
|
||
await vi.runAllTimersAsync();
|
||
|
||
// Task should move to todo then in-progress (not in-review). The hop to
|
||
// todo must flag preserveResumeState so the workflow-rerun bounce keeps
|
||
// the worktree and accumulated step progress through the transient
|
||
// todo state on its way back to in-progress.
|
||
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo", { preserveResumeState: true, preserveWorktree: true });
|
||
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-progress");
|
||
|
||
// onComplete should NOT be called (task is being retried, not completed)
|
||
expect(onComplete).not.toHaveBeenCalled();
|
||
|
||
// onError should NOT be called (task is being retried, not permanently failed)
|
||
expect(onError).not.toHaveBeenCalled();
|
||
|
||
vi.useRealTimers();
|
||
});
|
||
|
||
it("sends task back to in-progress when script is missing from settings.scripts", async () => {
|
||
const store = createMockStore();
|
||
|
||
store.getSettings.mockResolvedValue({
|
||
maxConcurrent: 2,
|
||
maxWorktrees: 4,
|
||
scripts: { other: "echo other" },
|
||
});
|
||
|
||
// Mutable task object to track step changes
|
||
const mutableTask = {
|
||
id: "FN-001",
|
||
title: "Test",
|
||
description: "Test task",
|
||
column: "in-progress" as const,
|
||
dependencies: [] as string[],
|
||
steps: [{ name: "Preflight", status: "pending" as const }],
|
||
currentStep: 0,
|
||
log: [] as any[],
|
||
enabledWorkflowSteps: ["WS-001"],
|
||
workflowStepRetries: 3, // Exhaust retries so task fails immediately
|
||
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
};
|
||
store.getTask.mockResolvedValue(mutableTask);
|
||
|
||
// Make updateStep track changes in the mutable task
|
||
store.updateStep.mockImplementation(async (taskId: string, stepIndex: number, status: string) => {
|
||
if (mutableTask.steps[stepIndex]) {
|
||
mutableTask.steps[stepIndex].status = status as any;
|
||
}
|
||
return {};
|
||
});
|
||
|
||
store.getWorkflowStep.mockResolvedValue({
|
||
id: "WS-001",
|
||
name: "Missing Script",
|
||
description: "Uses nonexistent script",
|
||
mode: "script",
|
||
prompt: "",
|
||
scriptName: "nonexistent",
|
||
enabled: true,
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
// Use createAgentWithTaskDone to properly set up the agent mock
|
||
createAgentWithTaskDone();
|
||
|
||
const onComplete = vi.fn();
|
||
const onError = vi.fn();
|
||
const executor = new TaskExecutor(store, "/tmp/test", { onComplete, onError });
|
||
|
||
// Use fake timers to control the setTimeout in sendTaskBackForFix
|
||
vi.useFakeTimers();
|
||
|
||
await executor.execute({
|
||
id: "FN-001",
|
||
title: "Test",
|
||
description: "Test task",
|
||
column: "in-progress",
|
||
dependencies: [],
|
||
steps: [{ name: "Preflight", status: "pending" }],
|
||
currentStep: 0,
|
||
log: [],
|
||
enabledWorkflowSteps: ["WS-001"],
|
||
workflowStepRetries: 3, // Exhaust retries so task fails immediately
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
// Should log that the script was not found
|
||
expect(store.logEntry).toHaveBeenCalledWith(
|
||
"FN-001",
|
||
expect.stringContaining("not found in project settings"),
|
||
);
|
||
|
||
// Should record a failed result
|
||
expect(store.updateTask).toHaveBeenCalledWith(
|
||
"FN-001",
|
||
expect.objectContaining({
|
||
workflowStepResults: expect.arrayContaining([
|
||
expect.objectContaining({
|
||
workflowStepId: "WS-001",
|
||
status: "failed",
|
||
output: expect.stringContaining("not found in project settings"),
|
||
}),
|
||
]),
|
||
}),
|
||
);
|
||
|
||
// Task should be cleared and reset for retry (not failed + in-review)
|
||
expect(store.updateTask).toHaveBeenCalledWith(
|
||
"FN-001",
|
||
expect.objectContaining({ status: null, error: null, sessionFile: null, workflowStepRetries: 0 }),
|
||
);
|
||
|
||
// Should add a comment with failure feedback
|
||
expect(store.addTaskComment).toHaveBeenCalledWith(
|
||
"FN-001",
|
||
expect.stringContaining("Workflow step failed"),
|
||
"agent",
|
||
);
|
||
|
||
// Should reset all steps to pending
|
||
// Check that updateStep was called with "pending" for step 0
|
||
// (There may be multiple calls - first from fn_task_done marking it done, second from sendTaskBackForFix resetting it)
|
||
const updateStepCalls = store.updateStep.mock.calls;
|
||
const hasResetToPending = updateStepCalls.some(
|
||
(call: any[]) => call[0] === "FN-001" && call[1] === 0 && call[2] === "pending"
|
||
);
|
||
expect(hasResetToPending).toBe(true);
|
||
|
||
// Advance timers to trigger the setTimeout that moves task to todo then in-progress
|
||
vi.advanceTimersByTime(0);
|
||
// Run any pending microtasks (the async code in setTimeout)
|
||
await vi.runAllTimersAsync();
|
||
|
||
// Task should move to todo then in-progress (not in-review). The hop to
|
||
// todo must flag preserveResumeState so the workflow-rerun bounce keeps
|
||
// the worktree and accumulated step progress through the transient
|
||
// todo state on its way back to in-progress.
|
||
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo", { preserveResumeState: true, preserveWorktree: true });
|
||
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-progress");
|
||
|
||
// onComplete should NOT be called (task is being retried, not completed)
|
||
expect(onComplete).not.toHaveBeenCalled();
|
||
|
||
// onError should NOT be called (task is being retried, not permanently failed)
|
||
expect(onError).not.toHaveBeenCalled();
|
||
|
||
vi.useRealTimers();
|
||
});
|
||
|
||
it("routes exhausted prompt-mode workflow hard failures back to remediation and only reopens the last step", async () => {
|
||
// This test was previously written as an end-to-end run through
|
||
// executor.execute(...) with vi.useFakeTimers(), but that path hung
|
||
// deterministically under the 15 s budget: createResolvedAgentSession's
|
||
// workflow-step Promise.race used a frozen 360 s setTimeout, and the
|
||
// rejection from the mock prompt never reached the catch block in time.
|
||
// The behavior we actually need to lock down is:
|
||
// 1. sendTaskBackForFix re-opens only the last completed step
|
||
// (reopenLastStepForRevision) — earlier done steps stay done.
|
||
// 2. The rerun bounce uses preserveResumeState so step progress and
|
||
// the worktree survive the in-progress → todo hop.
|
||
// 3. PROMPT.md gains the Workflow Step Failure section with the
|
||
// step name and feedback so the next session sees the regression.
|
||
// We exercise (1)–(3) by calling sendTaskBackForFix directly, which is
|
||
// what the executor's full failure path invokes once retries are
|
||
// exhausted (executor.ts:2113/2626/2787).
|
||
// Ensure we're on real timers — earlier tests in this describe block
|
||
// call vi.useFakeTimers() and rely on per-test cleanup; defending
|
||
// against any leak guarantees scheduleWorkflowRerun's setTimeout(0)
|
||
// bounce actually fires here.
|
||
vi.useRealTimers();
|
||
|
||
const store = createMockStore();
|
||
// The full file-backed path was unavailable here: this test file mocks
|
||
// node:fs at the module level, which breaks node:fs/promises.mkdtemp
|
||
// under the vitest module resolver. Stub out the PROMPT.md mutation
|
||
// (already covered by other tests' addTaskComment + injection unit
|
||
// checks) and assert the behavior we actually care about — only the
|
||
// last step is reopened, and the rerun bounce flags preserveResumeState.
|
||
store.getFusionDir.mockReturnValue("/tmp/fn-2301-workflow/.fusion");
|
||
|
||
const mutableTask = {
|
||
id: "FN-001",
|
||
title: "Test",
|
||
description: "Test task",
|
||
column: "in-progress" as const,
|
||
dependencies: [] as string[],
|
||
steps: [
|
||
{ name: "Step 0", status: "done" as const },
|
||
{ name: "Step 1", status: "done" as const },
|
||
],
|
||
currentStep: 1,
|
||
log: [] as any[],
|
||
enabledWorkflowSteps: ["WS-001"],
|
||
workflowStepRetries: 3,
|
||
prompt: "# test\n## Steps\n### Step 0\n- [x] done\n### Step 1\n- [x] done",
|
||
worktree: "/tmp/test/worktree",
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
};
|
||
store.getTask.mockImplementation(async () => mutableTask);
|
||
|
||
store.updateStep.mockImplementation(async (_taskId: string, stepIndex: number, status: string) => {
|
||
if (mutableTask.steps[stepIndex]) {
|
||
mutableTask.steps[stepIndex].status = status as any;
|
||
}
|
||
return {};
|
||
});
|
||
|
||
const onError = vi.fn();
|
||
const executor = new TaskExecutor(store, "/tmp/test", { onError });
|
||
|
||
// Stub injectWorkflowStepFailureInstructions: PROMPT.md write is verified
|
||
// by separate tests; here we just need sendTaskBackForFix to proceed past
|
||
// it without doing real fs I/O (which is unavailable under this file's
|
||
// node:fs mock).
|
||
const injectSpy = vi
|
||
.spyOn(executor as unknown as { injectWorkflowStepFailureInstructions: (...a: unknown[]) => Promise<void> }, "injectWorkflowStepFailureInstructions")
|
||
.mockResolvedValue(undefined);
|
||
|
||
// Run the rerun bounce inline rather than via setTimeout(0). When this
|
||
// suite runs with sibling tests, fake-timer leaks from earlier
|
||
// describe blocks have made the original setTimeout-driven path
|
||
// non-deterministic; calling performWorkflowRerunBounce directly is
|
||
// exactly what the timer would have done after the next event-loop
|
||
// tick and removes the timing dependency entirely.
|
||
const scheduleSpy = vi
|
||
.spyOn(executor as unknown as {
|
||
scheduleWorkflowRerun: (
|
||
taskId: string,
|
||
worktreePath: string,
|
||
successMessage: string,
|
||
) => void;
|
||
}, "scheduleWorkflowRerun")
|
||
.mockImplementation((taskId, worktreePath) => {
|
||
void (executor as unknown as {
|
||
performWorkflowRerunBounce: (taskId: string, worktreePath: string) => Promise<unknown>;
|
||
}).performWorkflowRerunBounce(taskId, worktreePath);
|
||
});
|
||
|
||
const stepName = "Frontend UX Design";
|
||
const feedback = "Quality gate hard failure: spacing regression in dashboard cards";
|
||
|
||
await (executor as unknown as {
|
||
sendTaskBackForFix: (
|
||
task: typeof mutableTask,
|
||
worktreePath: string,
|
||
failureFeedback: string,
|
||
stepName: string,
|
||
reason: string,
|
||
) => Promise<void>;
|
||
}).sendTaskBackForFix(
|
||
mutableTask,
|
||
mutableTask.worktree,
|
||
feedback,
|
||
stepName,
|
||
"Workflow step failed",
|
||
);
|
||
|
||
// (1) failure comment + only the last step re-opened
|
||
expect(store.addTaskComment).toHaveBeenCalledWith(
|
||
"FN-001",
|
||
expect.stringContaining("Workflow step failed"),
|
||
"agent",
|
||
);
|
||
const reopenedStepIndexes = store.updateStep.mock.calls
|
||
.filter((call: any[]) => call[0] === "FN-001" && call[2] === "pending")
|
||
.map((call: any[]) => call[1]);
|
||
expect(reopenedStepIndexes).toContain(1);
|
||
expect(reopenedStepIndexes).not.toContain(0);
|
||
|
||
// performWorkflowRerunBounce was invoked synchronously by the spy
|
||
// above; flush microtasks so its awaited store calls settle before
|
||
// we assert.
|
||
await new Promise<void>((resolve) => queueMicrotask(resolve));
|
||
await new Promise<void>((resolve) => queueMicrotask(resolve));
|
||
await new Promise<void>((resolve) => queueMicrotask(resolve));
|
||
|
||
// (2) bounce uses preserveResumeState so step progress + worktree survive
|
||
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo", { preserveResumeState: true, preserveWorktree: true });
|
||
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-progress");
|
||
expect(store.moveTask).not.toHaveBeenCalledWith("FN-001", "in-review");
|
||
expect(onError).not.toHaveBeenCalled();
|
||
|
||
// (3) PROMPT.md injection was invoked with the failure context. The
|
||
// actual file write is covered by other tests; here we just need to
|
||
// confirm sendTaskBackForFix forwards the right step name and feedback.
|
||
// Last arg is MAX_WORKFLOW_STEP_RETRIES (private const, currently 3) so
|
||
// the injected PROMPT.md note shows "3/3 (0 remaining)".
|
||
expect(injectSpy).toHaveBeenCalledWith(
|
||
mutableTask,
|
||
feedback,
|
||
stepName,
|
||
expect.any(Number),
|
||
);
|
||
|
||
// The scheduleWorkflowRerun stub above never registers the 15 s
|
||
// watchdog timer, so there's nothing to clear here.
|
||
scheduleSpy.mockRestore();
|
||
injectSpy.mockRestore();
|
||
});
|
||
|
||
it("skips script-mode step when scriptName is missing", async () => {
|
||
const store = createMockStore();
|
||
|
||
store.getSettings.mockResolvedValue({
|
||
maxConcurrent: 2,
|
||
maxWorktrees: 4,
|
||
scripts: {},
|
||
});
|
||
|
||
store.getTask.mockResolvedValue({
|
||
id: "FN-001",
|
||
title: "Test",
|
||
description: "Test task",
|
||
column: "in-progress",
|
||
dependencies: [],
|
||
steps: [{ name: "Preflight", status: "pending" }],
|
||
currentStep: 0,
|
||
log: [],
|
||
enabledWorkflowSteps: ["WS-001"],
|
||
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
store.getWorkflowStep.mockResolvedValue({
|
||
id: "WS-001",
|
||
name: "No Script",
|
||
description: "Script step without scriptName",
|
||
mode: "script",
|
||
prompt: "",
|
||
scriptName: undefined,
|
||
enabled: true,
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
createAgentWithTaskDone();
|
||
|
||
const onComplete = vi.fn();
|
||
const executor = new TaskExecutor(store, "/tmp/test", { onComplete });
|
||
|
||
await executor.execute({
|
||
id: "FN-001",
|
||
title: "Test",
|
||
description: "Test task",
|
||
column: "in-progress",
|
||
dependencies: [],
|
||
steps: [{ name: "Preflight", status: "pending" }],
|
||
currentStep: 0,
|
||
log: [],
|
||
enabledWorkflowSteps: ["WS-001"],
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
// Should only call createFnAgent once (main execution)
|
||
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(1);
|
||
|
||
// Should log that it was skipped
|
||
expect(store.logEntry).toHaveBeenCalledWith(
|
||
"FN-001",
|
||
expect.stringContaining("no scriptName"),
|
||
);
|
||
|
||
// Task should move to in-review (skipped step doesn't block)
|
||
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review");
|
||
});
|
||
|
||
it("treats legacy steps without mode as prompt-mode", async () => {
|
||
const store = createMockStore();
|
||
|
||
store.getTask.mockResolvedValue({
|
||
id: "FN-001",
|
||
title: "Test",
|
||
description: "Test task",
|
||
column: "in-progress",
|
||
dependencies: [],
|
||
steps: [{ name: "Preflight", status: "pending" }],
|
||
currentStep: 0,
|
||
log: [],
|
||
enabledWorkflowSteps: ["WS-001"],
|
||
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
// Legacy step without mode field
|
||
store.getWorkflowStep.mockResolvedValue({
|
||
id: "WS-001",
|
||
name: "Legacy Review",
|
||
description: "Old step without mode",
|
||
prompt: "Review the code changes.",
|
||
enabled: true,
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
} as any); // mode field intentionally omitted
|
||
|
||
let callIdx = 0;
|
||
mockedCreateFnAgent.mockImplementation((async (opts: any) => {
|
||
callIdx++;
|
||
if (callIdx === 1) {
|
||
const customTools = opts.customTools || [];
|
||
const session = {
|
||
prompt: vi.fn().mockImplementation(async () => {
|
||
const taskDoneTool = customTools.find((t: any) => t.name === "fn_task_done");
|
||
if (taskDoneTool) await taskDoneTool.execute("tool-1", {});
|
||
}),
|
||
dispose: vi.fn(),
|
||
subscribe: vi.fn(),
|
||
on: vi.fn(),
|
||
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
|
||
state: {},
|
||
};
|
||
return { session };
|
||
} else {
|
||
return {
|
||
session: {
|
||
prompt: vi.fn().mockResolvedValue(undefined),
|
||
dispose: vi.fn(),
|
||
subscribe: vi.fn(),
|
||
on: vi.fn(),
|
||
state: {},
|
||
},
|
||
};
|
||
}
|
||
}) as any);
|
||
|
||
const onComplete = vi.fn();
|
||
const executor = new TaskExecutor(store, "/tmp/test", { onComplete });
|
||
|
||
await executor.execute({
|
||
id: "FN-001",
|
||
title: "Test",
|
||
description: "Test task",
|
||
column: "in-progress",
|
||
dependencies: [],
|
||
steps: [{ name: "Preflight", status: "pending" }],
|
||
currentStep: 0,
|
||
log: [],
|
||
enabledWorkflowSteps: ["WS-001"],
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
// createFnAgent called twice: main agent + workflow step agent (prompt mode)
|
||
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2);
|
||
|
||
// Second call should use prompt mode (readonly tools, agent-based)
|
||
const secondCall = mockedCreateFnAgent.mock.calls[1];
|
||
expect(secondCall[0].tools).toBe("readonly");
|
||
expect(secondCall[0].systemPrompt).toContain("Legacy Review");
|
||
|
||
// Task should move to in-review
|
||
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review");
|
||
});
|
||
|
||
// ── Workflow Step Phase Filtering ────────────────────────────────────
|
||
|
||
it("skips post-merge workflow steps during executor pre-merge execution", async () => {
|
||
const store = createMockStore();
|
||
|
||
store.getTask.mockResolvedValue({
|
||
id: "FN-001",
|
||
title: "Test",
|
||
description: "Test task",
|
||
column: "in-progress",
|
||
dependencies: [],
|
||
steps: [{ name: "Preflight", status: "pending" }],
|
||
currentStep: 0,
|
||
log: [],
|
||
enabledWorkflowSteps: ["WS-001", "WS-002"],
|
||
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
store.getWorkflowStep.mockImplementation(async (id: string) => {
|
||
if (id === "WS-001") {
|
||
return {
|
||
id: "WS-001",
|
||
name: "Pre-merge Check",
|
||
description: "Before merge",
|
||
prompt: "Run pre-merge checks",
|
||
phase: "pre-merge",
|
||
enabled: true,
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
};
|
||
}
|
||
if (id === "WS-002") {
|
||
return {
|
||
id: "WS-002",
|
||
name: "Post-merge Notify",
|
||
description: "After merge",
|
||
prompt: "Send notifications",
|
||
phase: "post-merge",
|
||
enabled: true,
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
};
|
||
}
|
||
return undefined;
|
||
});
|
||
|
||
// Main agent calls fn_task_done, then a workflow step agent for pre-merge only
|
||
let callIdx = 0;
|
||
mockedCreateFnAgent.mockImplementation((async (opts: any) => {
|
||
callIdx++;
|
||
if (callIdx === 1) {
|
||
const customTools = opts.customTools || [];
|
||
const session = {
|
||
prompt: vi.fn().mockImplementation(async () => {
|
||
const taskDoneTool = customTools.find((t: any) => t.name === "fn_task_done");
|
||
if (taskDoneTool) await taskDoneTool.execute("tool-1", {});
|
||
}),
|
||
dispose: vi.fn(),
|
||
subscribe: vi.fn(),
|
||
on: vi.fn(),
|
||
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
|
||
state: {},
|
||
};
|
||
return { session };
|
||
} else {
|
||
return {
|
||
session: {
|
||
prompt: vi.fn().mockResolvedValue(undefined),
|
||
dispose: vi.fn(),
|
||
subscribe: vi.fn(),
|
||
on: vi.fn(),
|
||
state: {},
|
||
},
|
||
};
|
||
}
|
||
}) as any);
|
||
|
||
const executor = new TaskExecutor(store, "/tmp/test", {});
|
||
|
||
await executor.execute({
|
||
id: "FN-001",
|
||
title: "Test",
|
||
description: "Test task",
|
||
column: "in-progress",
|
||
dependencies: [],
|
||
steps: [{ name: "Preflight", status: "pending" }],
|
||
currentStep: 0,
|
||
log: [],
|
||
enabledWorkflowSteps: ["WS-001", "WS-002"],
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
// createFnAgent called twice: main agent + 1 pre-merge step (post-merge skipped)
|
||
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2);
|
||
|
||
// Verify the workflow step results only contain pre-merge
|
||
const updateCalls = store.updateTask.mock.calls;
|
||
const resultsCall = updateCalls.find((c: any) =>
|
||
c[1]?.workflowStepResults?.length > 0
|
||
);
|
||
expect(resultsCall).toBeDefined();
|
||
const results = resultsCall![1].workflowStepResults;
|
||
expect(results).toHaveLength(1);
|
||
expect(results[0].workflowStepId).toBe("WS-001");
|
||
expect(results[0].phase).toBe("pre-merge");
|
||
});
|
||
|
||
it("normalizes legacy workflow steps without phase as pre-merge", async () => {
|
||
const store = createMockStore();
|
||
|
||
store.getTask.mockResolvedValue({
|
||
id: "FN-001",
|
||
title: "Test",
|
||
description: "Test task",
|
||
column: "in-progress",
|
||
dependencies: [],
|
||
steps: [{ name: "Preflight", status: "pending" }],
|
||
currentStep: 0,
|
||
log: [],
|
||
enabledWorkflowSteps: ["WS-001"],
|
||
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
// Legacy step without phase field
|
||
store.getWorkflowStep.mockResolvedValue({
|
||
id: "WS-001",
|
||
name: "Legacy Check",
|
||
description: "No phase field",
|
||
prompt: "Run checks",
|
||
// phase is undefined — should be treated as pre-merge
|
||
enabled: true,
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
let callIdx = 0;
|
||
mockedCreateFnAgent.mockImplementation((async (opts: any) => {
|
||
callIdx++;
|
||
if (callIdx === 1) {
|
||
const customTools = opts.customTools || [];
|
||
const session = {
|
||
prompt: vi.fn().mockImplementation(async () => {
|
||
const taskDoneTool = customTools.find((t: any) => t.name === "fn_task_done");
|
||
if (taskDoneTool) await taskDoneTool.execute("tool-1", {});
|
||
}),
|
||
dispose: vi.fn(),
|
||
subscribe: vi.fn(),
|
||
on: vi.fn(),
|
||
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
|
||
state: {},
|
||
};
|
||
return { session };
|
||
} else {
|
||
return {
|
||
session: {
|
||
prompt: vi.fn().mockResolvedValue(undefined),
|
||
dispose: vi.fn(),
|
||
subscribe: vi.fn(),
|
||
on: vi.fn(),
|
||
state: {},
|
||
},
|
||
};
|
||
}
|
||
}) as any);
|
||
|
||
const executor = new TaskExecutor(store, "/tmp/test", {});
|
||
|
||
await executor.execute({
|
||
id: "FN-001",
|
||
title: "Test",
|
||
description: "Test task",
|
||
column: "in-progress",
|
||
dependencies: [],
|
||
steps: [{ name: "Preflight", status: "pending" }],
|
||
currentStep: 0,
|
||
log: [],
|
||
enabledWorkflowSteps: ["WS-001"],
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
// Legacy step should have been executed (treated as pre-merge)
|
||
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2);
|
||
|
||
// Verify result has phase: "pre-merge"
|
||
const updateCalls = store.updateTask.mock.calls;
|
||
const resultsCall = updateCalls.find((c: any) =>
|
||
c[1]?.workflowStepResults?.some((r: any) => r.workflowStepId === "WS-001")
|
||
);
|
||
expect(resultsCall).toBeDefined();
|
||
const results = resultsCall![1].workflowStepResults;
|
||
expect(results[0].phase).toBe("pre-merge");
|
||
});
|
||
|
||
it("only runs post-merge steps when all are post-merge (skips all in executor)", async () => {
|
||
const store = createMockStore();
|
||
|
||
store.getTask.mockResolvedValue({
|
||
id: "FN-001",
|
||
title: "Test",
|
||
description: "Test task",
|
||
column: "in-progress",
|
||
dependencies: [],
|
||
steps: [{ name: "Preflight", status: "pending" }],
|
||
currentStep: 0,
|
||
log: [],
|
||
enabledWorkflowSteps: ["WS-001"],
|
||
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
store.getWorkflowStep.mockResolvedValue({
|
||
id: "WS-001",
|
||
name: "Post-merge Notify",
|
||
description: "After merge",
|
||
prompt: "Send notifications",
|
||
phase: "post-merge",
|
||
enabled: true,
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
createAgentWithTaskDone();
|
||
|
||
const executor = new TaskExecutor(store, "/tmp/test", {});
|
||
|
||
await executor.execute({
|
||
id: "FN-001",
|
||
title: "Test",
|
||
description: "Test task",
|
||
column: "in-progress",
|
||
dependencies: [],
|
||
steps: [{ name: "Preflight", status: "pending" }],
|
||
currentStep: 0,
|
||
log: [],
|
||
enabledWorkflowSteps: ["WS-001"],
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
// Only main agent called (no workflow step agent since all are post-merge)
|
||
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(1);
|
||
|
||
// Task should still move to in-review
|
||
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review");
|
||
});
|
||
|
||
// ── Workflow Step Revision Request ──────────────────────────────────
|
||
|
||
it("workflow step agent returns revisionRequested when output starts with REQUEST REVISION", async () => {
|
||
const store = createMockStore();
|
||
|
||
store.getTask.mockResolvedValue({
|
||
id: "FN-001",
|
||
title: "Test",
|
||
description: "Test task",
|
||
column: "in-progress",
|
||
dependencies: [],
|
||
steps: [{ name: "Preflight", status: "pending" }],
|
||
currentStep: 0,
|
||
log: [],
|
||
enabledWorkflowSteps: ["WS-001"],
|
||
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
store.getWorkflowStep.mockResolvedValue({
|
||
id: "WS-001",
|
||
name: "Security Audit",
|
||
description: "Check for vulnerabilities",
|
||
gateMode: "gate",
|
||
prompt: "Scan for security issues.",
|
||
enabled: true,
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
store.parseFileScopeFromPrompt.mockResolvedValue(["src/auth.ts"]);
|
||
|
||
// First call: main agent with fn_task_done
|
||
// Second call: workflow step agent that returns REQUEST REVISION
|
||
let callIdx = 0;
|
||
let subscribeHandler: any;
|
||
mockedCreateFnAgent.mockImplementation((async (opts: any) => {
|
||
callIdx++;
|
||
if (callIdx === 1) {
|
||
// Main execution
|
||
const customTools = opts.customTools || [];
|
||
const session = {
|
||
prompt: vi.fn().mockImplementation(async () => {
|
||
const taskDoneTool = customTools.find((t: any) => t.name === "fn_task_done");
|
||
if (taskDoneTool) await taskDoneTool.execute("tool-1", {});
|
||
}),
|
||
dispose: vi.fn(),
|
||
subscribe: vi.fn(),
|
||
on: vi.fn(),
|
||
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
|
||
state: {},
|
||
};
|
||
return { session };
|
||
} else {
|
||
// Workflow step agent that requests revision
|
||
const session = {
|
||
prompt: vi.fn().mockImplementation(async () => {
|
||
// Call the subscribe handler to simulate agent outputting REQUEST REVISION
|
||
if (subscribeHandler) {
|
||
subscribeHandler({
|
||
type: "message_update",
|
||
assistantMessageEvent: { type: "text_delta", delta: "REQUEST REVISION\n\nFix the SQL injection vulnerability in src/auth.ts" },
|
||
});
|
||
}
|
||
}),
|
||
dispose: vi.fn(),
|
||
subscribe: vi.fn((handler: any) => {
|
||
subscribeHandler = handler;
|
||
}),
|
||
state: {},
|
||
};
|
||
return { session };
|
||
}
|
||
}) as any);
|
||
|
||
const onComplete = vi.fn();
|
||
const executor = new TaskExecutor(store, "/tmp/test", { onComplete });
|
||
|
||
await executor.execute({
|
||
id: "FN-001",
|
||
title: "Test",
|
||
description: "Test task",
|
||
column: "in-progress",
|
||
dependencies: [],
|
||
steps: [{ name: "Preflight", status: "pending" }],
|
||
currentStep: 0,
|
||
log: [],
|
||
enabledWorkflowSteps: ["WS-001"],
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
// Both agents should be called
|
||
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2);
|
||
|
||
// Log should show revision was requested
|
||
expect(store.logEntry).toHaveBeenCalledWith(
|
||
"FN-001",
|
||
expect.stringContaining("requested revision"),
|
||
expect.stringContaining("SQL injection"),
|
||
expect.objectContaining({ agentId: "executor" }),
|
||
);
|
||
expect(store.createTask).not.toHaveBeenCalled();
|
||
|
||
// Workflow step result should be marked as failed
|
||
expect(store.updateTask).toHaveBeenCalledWith(
|
||
"FN-001",
|
||
expect.objectContaining({
|
||
workflowStepResults: expect.arrayContaining([
|
||
expect.objectContaining({
|
||
workflowStepId: "WS-001",
|
||
status: "failed",
|
||
output: expect.stringContaining("SQL injection"),
|
||
}),
|
||
]),
|
||
}),
|
||
);
|
||
|
||
// Task should NOT be marked as failed (revision requested, not hard failure)
|
||
expect(store.updateTask).not.toHaveBeenCalledWith(
|
||
"FN-001",
|
||
expect.objectContaining({ status: "failed", error: "Workflow step failed" }),
|
||
);
|
||
});
|
||
|
||
it("treats prompt advisory workflow revision requests as non-blocking findings", async () => {
|
||
const store = createMockStore();
|
||
|
||
store.getTask.mockResolvedValue({
|
||
id: "FN-001",
|
||
title: "Test",
|
||
description: "Test task",
|
||
column: "in-progress",
|
||
dependencies: [],
|
||
steps: [{ name: "Preflight", status: "pending" }],
|
||
currentStep: 0,
|
||
log: [],
|
||
enabledWorkflowSteps: ["WS-001"],
|
||
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
store.getWorkflowStep.mockResolvedValue({
|
||
id: "WS-001",
|
||
name: "Frontend UX Design",
|
||
description: "Polish pass",
|
||
gateMode: "advisory",
|
||
mode: "prompt",
|
||
prompt: "Review polish quality.",
|
||
enabled: true,
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
store.parseFileScopeFromPrompt.mockResolvedValue(["src/auth.ts"]);
|
||
|
||
let callIdx = 0;
|
||
let subscribeHandler: any;
|
||
mockedCreateFnAgent.mockImplementation((async (opts: any) => {
|
||
callIdx++;
|
||
if (callIdx === 1) {
|
||
const customTools = opts.customTools || [];
|
||
const session = {
|
||
prompt: vi.fn().mockImplementation(async () => {
|
||
const taskDoneTool = customTools.find((t: any) => t.name === "fn_task_done");
|
||
if (taskDoneTool) await taskDoneTool.execute("tool-1", {});
|
||
}),
|
||
dispose: vi.fn(),
|
||
subscribe: vi.fn(),
|
||
on: vi.fn(),
|
||
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
|
||
state: {},
|
||
};
|
||
return { session };
|
||
}
|
||
|
||
const session = {
|
||
prompt: vi.fn().mockImplementation(async () => {
|
||
subscribeHandler?.({
|
||
type: "message_update",
|
||
assistantMessageEvent: { type: "text_delta", delta: "REQUEST REVISION\n\nPolish note: tighten auth error copy." },
|
||
});
|
||
}),
|
||
dispose: vi.fn(),
|
||
subscribe: vi.fn((handler: any) => {
|
||
subscribeHandler = handler;
|
||
}),
|
||
state: {},
|
||
};
|
||
return { session };
|
||
}) as any);
|
||
|
||
const onComplete = vi.fn();
|
||
const executor = new TaskExecutor(store, "/tmp/test", { onComplete });
|
||
|
||
await executor.execute({
|
||
id: "FN-001",
|
||
title: "Test",
|
||
description: "Test task",
|
||
column: "in-progress",
|
||
dependencies: [],
|
||
steps: [{ name: "Preflight", status: "pending" }],
|
||
currentStep: 0,
|
||
log: [],
|
||
enabledWorkflowSteps: ["WS-001"],
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
expect(store.updateTask).toHaveBeenCalledWith(
|
||
"FN-001",
|
||
expect.objectContaining({
|
||
workflowStepResults: expect.arrayContaining([
|
||
expect.objectContaining({
|
||
workflowStepId: "WS-001",
|
||
status: "advisory_failure",
|
||
notes: expect.stringContaining("Polish note"),
|
||
}),
|
||
]),
|
||
}),
|
||
);
|
||
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review");
|
||
expect(store.logEntry).toHaveBeenCalledWith(
|
||
"FN-001",
|
||
expect.stringContaining("Advisory workflow step failed"),
|
||
);
|
||
expect(store.updateTask).not.toHaveBeenCalledWith(
|
||
"FN-001",
|
||
expect.objectContaining({ status: "failed", error: "Workflow step failed" }),
|
||
);
|
||
expect(onComplete).toHaveBeenCalled();
|
||
});
|
||
|
||
it("forks out-of-scope workflow revision feedback into a follow-up task and leaves the original task untouched", async () => {
|
||
const store = createMockStore();
|
||
|
||
store.getTask.mockResolvedValue({
|
||
id: "FN-001",
|
||
title: "Test",
|
||
description: "Test task",
|
||
column: "in-progress",
|
||
dependencies: [],
|
||
steps: [{ name: "Implement", status: "done" }],
|
||
currentStep: 0,
|
||
log: [],
|
||
enabledWorkflowSteps: ["WS-001"],
|
||
prompt: "# test\n## File Scope\n- `packages/engine/src/executor.ts`\n## Steps\n### Step 0: Implement\n- [x] check",
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
store.parseFileScopeFromPrompt.mockResolvedValue(["packages/engine/src/executor.ts"]);
|
||
|
||
store.getWorkflowStep.mockResolvedValue({
|
||
id: "WS-001",
|
||
name: "Security Audit",
|
||
description: "Check for vulnerabilities",
|
||
gateMode: "gate",
|
||
prompt: "Scan for security issues.",
|
||
enabled: true,
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
let callIdx = 0;
|
||
let subscribeHandler: any;
|
||
mockedCreateFnAgent.mockImplementation((async (opts: any) => {
|
||
callIdx++;
|
||
if (callIdx === 1) {
|
||
const customTools = opts.customTools || [];
|
||
return {
|
||
session: {
|
||
prompt: vi.fn().mockImplementation(async () => {
|
||
const taskDoneTool = customTools.find((t: any) => t.name === "fn_task_done");
|
||
if (taskDoneTool) await taskDoneTool.execute("tool-1", {});
|
||
}),
|
||
dispose: vi.fn(),
|
||
subscribe: vi.fn(),
|
||
on: vi.fn(),
|
||
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
|
||
state: {},
|
||
},
|
||
};
|
||
}
|
||
|
||
return {
|
||
session: {
|
||
prompt: vi.fn().mockImplementation(async () => {
|
||
subscribeHandler?.({
|
||
type: "message_update",
|
||
assistantMessageEvent: { type: "text_delta", delta: "REQUEST REVISION\n\nFix `packages/core/src/types.ts` before merge." },
|
||
});
|
||
}),
|
||
dispose: vi.fn(),
|
||
subscribe: vi.fn((handler: any) => {
|
||
subscribeHandler = handler;
|
||
}),
|
||
state: {},
|
||
},
|
||
};
|
||
}) as any);
|
||
|
||
const onComplete = vi.fn();
|
||
const executor = new TaskExecutor(store, "/tmp/test", { onComplete });
|
||
|
||
await executor.execute({
|
||
id: "FN-001",
|
||
title: "Test",
|
||
description: "Test task",
|
||
column: "in-progress",
|
||
dependencies: [],
|
||
steps: [{ name: "Implement", status: "done" }],
|
||
currentStep: 0,
|
||
log: [],
|
||
enabledWorkflowSteps: ["WS-001"],
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
expect(store.createTask).toHaveBeenCalledWith(expect.objectContaining({
|
||
title: expect.stringContaining("workflow follow-up"),
|
||
dependencies: ["FN-001"],
|
||
source: expect.objectContaining({
|
||
sourceType: "workflow_step",
|
||
sourceParentTaskId: "FN-001",
|
||
}),
|
||
}));
|
||
expect(store.updateTask).not.toHaveBeenCalledWith("FN-001", expect.objectContaining({ status: null, sessionFile: null }));
|
||
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review");
|
||
expect(onComplete).toHaveBeenCalled();
|
||
});
|
||
|
||
it("splits mixed workflow revision feedback between the original task and a follow-up task", async () => {
|
||
const store = createMockStore();
|
||
|
||
store.getTask.mockResolvedValue({
|
||
id: "FN-001",
|
||
title: "Test",
|
||
description: "Test task",
|
||
column: "in-progress",
|
||
dependencies: [],
|
||
steps: [{ name: "Implement", status: "done" }],
|
||
currentStep: 0,
|
||
log: [],
|
||
enabledWorkflowSteps: ["WS-001"],
|
||
prompt: "# test\n## File Scope\n- `packages/engine/src/executor.ts`\n## Steps\n### Step 0: Implement\n- [x] check",
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
store.parseFileScopeFromPrompt.mockResolvedValue(["packages/engine/src/executor.ts"]);
|
||
|
||
store.getWorkflowStep.mockResolvedValue({
|
||
id: "WS-001",
|
||
name: "Security Audit",
|
||
description: "Check for vulnerabilities",
|
||
gateMode: "gate",
|
||
prompt: "Scan for security issues.",
|
||
enabled: true,
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
let callIdx = 0;
|
||
let subscribeHandler: any;
|
||
mockedCreateFnAgent.mockImplementation((async (opts: any) => {
|
||
callIdx++;
|
||
if (callIdx === 1) {
|
||
const customTools = opts.customTools || [];
|
||
return {
|
||
session: {
|
||
prompt: vi.fn().mockImplementation(async () => {
|
||
const taskDoneTool = customTools.find((t: any) => t.name === "fn_task_done");
|
||
if (taskDoneTool) await taskDoneTool.execute("tool-1", {});
|
||
}),
|
||
dispose: vi.fn(),
|
||
subscribe: vi.fn(),
|
||
on: vi.fn(),
|
||
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
|
||
state: {},
|
||
},
|
||
};
|
||
}
|
||
|
||
return {
|
||
session: {
|
||
prompt: vi.fn().mockImplementation(async () => {
|
||
subscribeHandler?.({
|
||
type: "message_update",
|
||
assistantMessageEvent: {
|
||
type: "text_delta",
|
||
delta: "REQUEST REVISION\n\nTighten `packages/engine/src/executor.ts`.\n\nAdd the setting in `packages/core/src/settings-schema.ts`.",
|
||
},
|
||
});
|
||
}),
|
||
dispose: vi.fn(),
|
||
subscribe: vi.fn((handler: any) => {
|
||
subscribeHandler = handler;
|
||
}),
|
||
state: {},
|
||
},
|
||
};
|
||
}) as any);
|
||
|
||
const executor = new TaskExecutor(store, "/tmp/test");
|
||
|
||
await executor.execute({
|
||
id: "FN-001",
|
||
title: "Test",
|
||
description: "Test task",
|
||
column: "in-progress",
|
||
dependencies: [],
|
||
steps: [{ name: "Implement", status: "done" }],
|
||
currentStep: 0,
|
||
log: [],
|
||
enabledWorkflowSteps: ["WS-001"],
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
expect(store.createTask).toHaveBeenCalledTimes(1);
|
||
expect(store.logEntry).toHaveBeenCalledWith(
|
||
"FN-001",
|
||
expect.stringContaining("split feedback"),
|
||
expect.stringContaining("packages/engine/src/executor.ts"),
|
||
expect.objectContaining({ agentId: "executor" }),
|
||
);
|
||
expect(store.updateTask).toHaveBeenCalledWith("FN-001", expect.objectContaining({ status: null, sessionFile: null }));
|
||
});
|
||
|
||
it("preserves legacy append-and-rerun behavior when workflow revision forking is disabled", async () => {
|
||
const store = createMockStore();
|
||
store.getSettings.mockResolvedValue({
|
||
maxConcurrent: 2,
|
||
maxWorktrees: 4,
|
||
pollIntervalMs: 15000,
|
||
groupOverlappingFiles: false,
|
||
autoMerge: false,
|
||
workflowRevisionForkOnScopeMismatch: false,
|
||
worktreeInitCommand: undefined,
|
||
});
|
||
|
||
store.getTask.mockResolvedValue({
|
||
id: "FN-001",
|
||
title: "Test",
|
||
description: "Test task",
|
||
column: "in-progress",
|
||
dependencies: [],
|
||
steps: [{ name: "Implement", status: "done" }],
|
||
currentStep: 0,
|
||
log: [],
|
||
enabledWorkflowSteps: ["WS-001"],
|
||
prompt: "# test\n## File Scope\n- `packages/engine/src/executor.ts`\n## Steps\n### Step 0: Implement\n- [x] check",
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
store.parseFileScopeFromPrompt.mockResolvedValue(["packages/engine/src/executor.ts"]);
|
||
store.getWorkflowStep.mockResolvedValue({
|
||
id: "WS-001",
|
||
name: "Security Audit",
|
||
description: "Check for vulnerabilities",
|
||
gateMode: "gate",
|
||
prompt: "Scan for security issues.",
|
||
enabled: true,
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
let callIdx = 0;
|
||
let subscribeHandler: any;
|
||
mockedCreateFnAgent.mockImplementation((async (opts: any) => {
|
||
callIdx++;
|
||
if (callIdx === 1) {
|
||
const customTools = opts.customTools || [];
|
||
return {
|
||
session: {
|
||
prompt: vi.fn().mockImplementation(async () => {
|
||
const taskDoneTool = customTools.find((t: any) => t.name === "fn_task_done");
|
||
if (taskDoneTool) await taskDoneTool.execute("tool-1", {});
|
||
}),
|
||
dispose: vi.fn(),
|
||
subscribe: vi.fn(),
|
||
on: vi.fn(),
|
||
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
|
||
state: {},
|
||
},
|
||
};
|
||
}
|
||
|
||
return {
|
||
session: {
|
||
prompt: vi.fn().mockImplementation(async () => {
|
||
subscribeHandler?.({
|
||
type: "message_update",
|
||
assistantMessageEvent: { type: "text_delta", delta: "REQUEST REVISION\n\nFix `packages/core/src/types.ts` before merge." },
|
||
});
|
||
}),
|
||
dispose: vi.fn(),
|
||
subscribe: vi.fn((handler: any) => {
|
||
subscribeHandler = handler;
|
||
}),
|
||
state: {},
|
||
},
|
||
};
|
||
}) as any);
|
||
|
||
const executor = new TaskExecutor(store, "/tmp/test");
|
||
|
||
await executor.execute({
|
||
id: "FN-001",
|
||
title: "Test",
|
||
description: "Test task",
|
||
column: "in-progress",
|
||
dependencies: [],
|
||
steps: [{ name: "Implement", status: "done" }],
|
||
currentStep: 0,
|
||
log: [],
|
||
enabledWorkflowSteps: ["WS-001"],
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
expect(store.createTask).not.toHaveBeenCalled();
|
||
expect(store.updateTask).toHaveBeenCalledWith("FN-001", expect.objectContaining({ status: null, sessionFile: null }));
|
||
});
|
||
|
||
it("passing workflow step moves task to in-review normally", async () => {
|
||
const store = createMockStore();
|
||
|
||
store.getTask.mockResolvedValue({
|
||
id: "FN-001",
|
||
title: "Test",
|
||
description: "Test task",
|
||
column: "in-progress",
|
||
dependencies: [],
|
||
steps: [{ name: "Preflight", status: "pending" }],
|
||
currentStep: 0,
|
||
log: [],
|
||
enabledWorkflowSteps: ["WS-001"],
|
||
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
store.getWorkflowStep.mockResolvedValue({
|
||
id: "WS-001",
|
||
name: "QA Check",
|
||
description: "Run tests",
|
||
prompt: "Run the test suite.",
|
||
enabled: true,
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
let callIdx = 0;
|
||
mockedCreateFnAgent.mockImplementation((async (opts: any) => {
|
||
callIdx++;
|
||
if (callIdx === 1) {
|
||
const customTools = opts.customTools || [];
|
||
const session = {
|
||
prompt: vi.fn().mockImplementation(async () => {
|
||
const taskDoneTool = customTools.find((t: any) => t.name === "fn_task_done");
|
||
if (taskDoneTool) await taskDoneTool.execute("tool-1", {});
|
||
}),
|
||
dispose: vi.fn(),
|
||
subscribe: vi.fn(),
|
||
on: vi.fn(),
|
||
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
|
||
state: {},
|
||
};
|
||
return { session };
|
||
} else {
|
||
let stepSubscribeHandler: any;
|
||
// Workflow step agent that passes with the structured verdict required by the workflow-step parser.
|
||
return {
|
||
session: {
|
||
prompt: vi.fn().mockImplementation(async () => {
|
||
stepSubscribeHandler?.({
|
||
type: "message_update",
|
||
assistantMessageEvent: { type: "text_delta", delta: '{"verdict":"APPROVE","notes":""}' },
|
||
});
|
||
}),
|
||
dispose: vi.fn(),
|
||
subscribe: vi.fn((handler: any) => { stepSubscribeHandler = handler; }),
|
||
state: {},
|
||
},
|
||
};
|
||
}
|
||
}) as any);
|
||
|
||
const onComplete = vi.fn();
|
||
const onError = vi.fn();
|
||
const executor = new TaskExecutor(store, "/tmp/test", { onComplete, onError });
|
||
|
||
await executor.execute({
|
||
id: "FN-001",
|
||
title: "Test",
|
||
description: "Test task",
|
||
column: "in-progress",
|
||
dependencies: [],
|
||
steps: [{ name: "Preflight", status: "pending" }],
|
||
currentStep: 0,
|
||
log: [],
|
||
enabledWorkflowSteps: ["WS-001"],
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
// Both agents called
|
||
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2);
|
||
|
||
// Log should show workflow step passed
|
||
expect(store.logEntry).toHaveBeenCalledWith(
|
||
"FN-001",
|
||
expect.stringContaining("Workflow step completed"),
|
||
);
|
||
|
||
// Task should move to in-review (not revision loop)
|
||
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review");
|
||
|
||
// onComplete should be called
|
||
expect(onComplete).toHaveBeenCalled();
|
||
expect(onError).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("parks a task pause during a prompt-mode workflow step instead of routing through failure recovery", async () => {
|
||
const store = createMockStore();
|
||
const mutableTask = {
|
||
id: "FN-001",
|
||
title: "Test",
|
||
description: "Test task",
|
||
column: "in-progress" as const,
|
||
paused: false,
|
||
dependencies: [] as string[],
|
||
steps: [{ name: "Preflight", status: "pending" as const }],
|
||
currentStep: 0,
|
||
log: [] as any[],
|
||
enabledWorkflowSteps: ["WS-001"],
|
||
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
};
|
||
|
||
store.getTask.mockImplementation(async () => mutableTask as any);
|
||
store.updateTask.mockImplementation(async (_taskId: string, patch: Record<string, unknown>) => {
|
||
Object.assign(mutableTask, patch);
|
||
return { ...mutableTask };
|
||
});
|
||
store.moveTask.mockImplementation(async (_taskId: string, column: string) => {
|
||
mutableTask.column = column as typeof mutableTask.column;
|
||
return { ...mutableTask };
|
||
});
|
||
|
||
store.getWorkflowStep.mockResolvedValue({
|
||
id: "WS-001",
|
||
name: "QA Check",
|
||
description: "Run tests",
|
||
mode: "prompt",
|
||
prompt: "Run the test suite.",
|
||
enabled: true,
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
const workflowAbort = vi.fn().mockResolvedValue(undefined);
|
||
const workflowDispose = vi.fn();
|
||
let callIdx = 0;
|
||
mockedCreateFnAgent.mockImplementation((async (opts: any) => {
|
||
callIdx++;
|
||
if (callIdx === 1) {
|
||
const customTools = opts.customTools || [];
|
||
return {
|
||
session: {
|
||
prompt: vi.fn().mockImplementation(async () => {
|
||
const taskDoneTool = customTools.find((tool: any) => tool.name === "fn_task_done");
|
||
if (taskDoneTool) {
|
||
await taskDoneTool.execute("tool-1", {});
|
||
}
|
||
}),
|
||
dispose: vi.fn(),
|
||
subscribe: vi.fn(),
|
||
on: vi.fn(),
|
||
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
|
||
state: {},
|
||
},
|
||
};
|
||
}
|
||
|
||
return {
|
||
session: {
|
||
prompt: vi.fn().mockImplementation(async () => {
|
||
mutableTask.paused = true;
|
||
store._trigger("task:updated", { ...mutableTask });
|
||
throw new Error("workflow step aborted by pause");
|
||
}),
|
||
abort: workflowAbort,
|
||
dispose: workflowDispose,
|
||
subscribe: vi.fn(),
|
||
on: vi.fn(),
|
||
state: {},
|
||
},
|
||
};
|
||
}) as any);
|
||
|
||
const executor = new TaskExecutor(store, "/tmp/test");
|
||
await executor.execute({ ...mutableTask });
|
||
|
||
expect(workflowDispose).toHaveBeenCalled();
|
||
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo", { preserveResumeState: true });
|
||
expect(store.moveTask).not.toHaveBeenCalledWith("FN-001", "in-review");
|
||
expect(store.addTaskComment).not.toHaveBeenCalled();
|
||
expect(store.logEntry).toHaveBeenCalledWith(
|
||
"FN-001",
|
||
"Execution paused during pre-merge workflow step — moved to todo",
|
||
undefined,
|
||
expect.objectContaining({ agentId: "executor" }),
|
||
);
|
||
});
|
||
});
|
||
|
||
describe("Real-time steering injection", () => {
|
||
beforeEach(() => {
|
||
resetExecutorMocks();
|
||
});
|
||
|
||
function makeSteeringTask(steeringComments: Array<{ id: string; text: string; createdAt: string; author: "user" | "agent" }> = []) {
|
||
return {
|
||
id: "FN-001",
|
||
title: "Test",
|
||
description: "Test",
|
||
column: "in-progress",
|
||
dependencies: [],
|
||
steps: [],
|
||
currentStep: 0,
|
||
log: [],
|
||
steeringComments,
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
};
|
||
}
|
||
|
||
function setLegacyActiveSession(executor: TaskExecutor, steerFn: ReturnType<typeof vi.fn>, seenSteeringIds = new Set<string>()) {
|
||
const session = { steer: steerFn, dispose: vi.fn() };
|
||
const state = { session, seenSteeringIds };
|
||
(executor as any).activeSessions.set("FN-001", state);
|
||
return { session, state };
|
||
}
|
||
|
||
it("initializes seenSteeringIds with existing comments at session start", async () => {
|
||
const store = createMockStore();
|
||
const steerFn = vi.fn().mockResolvedValue(undefined);
|
||
const executor = new TaskExecutor(store, "/tmp/test");
|
||
const existingComment = {
|
||
id: "1234567890-abc123",
|
||
text: "Existing comment",
|
||
createdAt: new Date().toISOString(),
|
||
author: "user" as const,
|
||
};
|
||
setLegacyActiveSession(executor, steerFn, new Set([existingComment.id]));
|
||
|
||
await (store as any)._triggerAsync("task:updated", makeSteeringTask([existingComment]));
|
||
|
||
expect(steerFn).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("injects new steering comments via session.steer() on task:updated", async () => {
|
||
const store = createMockStore();
|
||
const steerFn = vi.fn().mockResolvedValue(undefined);
|
||
const executor = new TaskExecutor(store, "/tmp/test");
|
||
setLegacyActiveSession(executor, steerFn);
|
||
const newComment = {
|
||
id: "9876543210-def456",
|
||
text: "Please use a different approach",
|
||
createdAt: new Date().toISOString(),
|
||
author: "user" as const,
|
||
};
|
||
|
||
await (store as any)._triggerAsync("task:updated", makeSteeringTask([newComment]));
|
||
|
||
expect(steerFn).toHaveBeenCalledOnce();
|
||
expect(steerFn.mock.calls[0][0]).toContain("📣 **New feedback**");
|
||
expect(steerFn.mock.calls[0][0]).toContain("Please use a different approach");
|
||
expect(store.logEntry).toHaveBeenCalledWith(
|
||
"FN-001",
|
||
expect.stringContaining("Comment received mid-execution"),
|
||
"by user"
|
||
);
|
||
});
|
||
|
||
it("injects new steering comments via active StepSessionExecutor on task:updated", async () => {
|
||
const store = createMockStore();
|
||
const executor = new TaskExecutor(store, "/tmp/test");
|
||
const seenIds = new Set<string>();
|
||
const updateSteeringComments = vi.fn();
|
||
const steerActiveSessions = vi.fn().mockImplementation(async () => {
|
||
expect(updateSteeringComments).toHaveBeenCalledWith([newComment]);
|
||
expect(seenIds.has(newComment.id)).toBe(true);
|
||
return 1;
|
||
});
|
||
const markSteeringCommentsDelivered = vi.fn();
|
||
const newComment = {
|
||
id: "step-session-comment",
|
||
text: "Please adjust the active step",
|
||
createdAt: new Date().toISOString(),
|
||
author: "user" as const,
|
||
};
|
||
|
||
(executor as any).activeStepExecutors.set("FN-001", {
|
||
steerActiveSessions,
|
||
markSteeringCommentsDelivered,
|
||
updateSteeringComments,
|
||
});
|
||
(executor as any).activeStepExecutorSeenSteeringIds.set("FN-001", seenIds);
|
||
|
||
await (store as any)._triggerAsync("task:updated", {
|
||
id: "FN-001",
|
||
title: "Test",
|
||
description: "Test",
|
||
column: "in-progress",
|
||
dependencies: [],
|
||
steps: [],
|
||
currentStep: 0,
|
||
log: [],
|
||
steeringComments: [newComment],
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
expect(updateSteeringComments).toHaveBeenCalledOnce();
|
||
expect(steerActiveSessions).toHaveBeenCalledOnce();
|
||
expect(steerActiveSessions.mock.calls[0][0]).toContain("📣 **New feedback**");
|
||
expect(steerActiveSessions.mock.calls[0][0]).toContain("Please adjust the active step");
|
||
expect(markSteeringCommentsDelivered).toHaveBeenCalledWith([newComment.id]);
|
||
expect(store.logEntry).toHaveBeenCalledWith(
|
||
"FN-001",
|
||
expect.stringContaining("Comment received mid-execution"),
|
||
"by user",
|
||
);
|
||
});
|
||
|
||
it("queues step-session steering comments for the next prompt when no step session is active", async () => {
|
||
const store = createMockStore();
|
||
const executor = new TaskExecutor(store, "/tmp/test");
|
||
const newComment = {
|
||
id: "step-session-queued-comment",
|
||
text: "Please apply this in the next step prompt",
|
||
createdAt: new Date().toISOString(),
|
||
author: "user" as const,
|
||
};
|
||
const { StepSessionExecutor: ActualStepSessionExecutor } = await vi.importActual<typeof import("../step-session-executor.js")>("../step-session-executor.js");
|
||
const stepExecutor = new ActualStepSessionExecutor({
|
||
taskDetail: makeSteeringTask() as any,
|
||
worktreePath: "/tmp/test",
|
||
rootDir: "/tmp",
|
||
settings: {} as any,
|
||
});
|
||
const markSteeringCommentsDelivered = vi.spyOn(stepExecutor, "markSteeringCommentsDelivered");
|
||
|
||
(executor as any).activeStepExecutors.set("FN-001", stepExecutor);
|
||
(executor as any).activeStepExecutorSeenSteeringIds.set("FN-001", new Set());
|
||
|
||
await (store as any)._triggerAsync("task:updated", makeSteeringTask([newComment]));
|
||
|
||
expect(markSteeringCommentsDelivered).not.toHaveBeenCalled();
|
||
expect(store.logEntry).toHaveBeenCalledWith(
|
||
"FN-001",
|
||
expect.stringContaining("Comment received mid-execution"),
|
||
"by user",
|
||
);
|
||
|
||
const nextPromptTask = (stepExecutor as any).consumeTaskDetailForStepPrompt();
|
||
expect(nextPromptTask.steeringComments).toEqual([newComment]);
|
||
const laterPromptTask = (stepExecutor as any).consumeTaskDetailForStepPrompt();
|
||
expect(laterPromptTask.steeringComments).toBeUndefined();
|
||
});
|
||
|
||
it("injects new steering comments via active workflow step session on task:updated", async () => {
|
||
const store = createMockStore();
|
||
const executor = new TaskExecutor(store, "/tmp/test");
|
||
const steer = vi.fn().mockResolvedValue(undefined);
|
||
const newComment = {
|
||
id: "workflow-step-comment",
|
||
text: "Please adjust the workflow step",
|
||
createdAt: new Date().toISOString(),
|
||
author: "user" as const,
|
||
};
|
||
|
||
(executor as any).activeWorkflowStepSessions.set("FN-001", { steer });
|
||
(executor as any).activeWorkflowStepSessionSeenSteeringIds.set("FN-001", new Set());
|
||
|
||
await (store as any)._triggerAsync("task:updated", {
|
||
id: "FN-001",
|
||
title: "Test",
|
||
description: "Test",
|
||
column: "in-progress",
|
||
dependencies: [],
|
||
steps: [],
|
||
currentStep: 0,
|
||
log: [],
|
||
steeringComments: [newComment],
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
expect(steer).toHaveBeenCalledOnce();
|
||
expect(steer.mock.calls[0][0]).toContain("📣 **New feedback**");
|
||
expect(steer.mock.calls[0][0]).toContain("Please adjust the workflow step");
|
||
expect(store.logEntry).toHaveBeenCalledWith(
|
||
"FN-001",
|
||
expect.stringContaining("Comment received mid-execution"),
|
||
"by user",
|
||
);
|
||
});
|
||
|
||
it("marks new comments seen before injecting and logs once across simultaneous surfaces", async () => {
|
||
const store = createMockStore();
|
||
const executor = new TaskExecutor(store, "/tmp/test");
|
||
const newComment = {
|
||
id: "shared-surface-comment",
|
||
text: "Please reach every live surface once",
|
||
createdAt: new Date().toISOString(),
|
||
author: "user" as const,
|
||
};
|
||
const legacySeen = new Set<string>();
|
||
const stepSeen = new Set<string>();
|
||
const workflowSeen = new Set<string>();
|
||
const legacySteer = vi.fn().mockImplementation(async () => {
|
||
expect(legacySeen.has(newComment.id)).toBe(true);
|
||
});
|
||
const stepSteerActiveSessions = vi.fn().mockImplementation(async () => {
|
||
expect(stepSeen.has(newComment.id)).toBe(true);
|
||
return 1;
|
||
});
|
||
const workflowSteer = vi.fn().mockImplementation(async () => {
|
||
expect(workflowSeen.has(newComment.id)).toBe(true);
|
||
});
|
||
const markSteeringCommentsDelivered = vi.fn();
|
||
|
||
setLegacyActiveSession(executor, legacySteer, legacySeen);
|
||
(executor as any).activeStepExecutors.set("FN-001", { steerActiveSessions: stepSteerActiveSessions, markSteeringCommentsDelivered });
|
||
(executor as any).activeStepExecutorSeenSteeringIds.set("FN-001", stepSeen);
|
||
(executor as any).activeWorkflowStepSessions.set("FN-001", { steer: workflowSteer });
|
||
(executor as any).activeWorkflowStepSessionSeenSteeringIds.set("FN-001", workflowSeen);
|
||
|
||
await (store as any)._triggerAsync("task:updated", makeSteeringTask([newComment]));
|
||
await (store as any)._triggerAsync("task:updated", makeSteeringTask([newComment]));
|
||
|
||
expect(legacySteer).toHaveBeenCalledOnce();
|
||
expect(stepSteerActiveSessions).toHaveBeenCalledOnce();
|
||
expect(workflowSteer).toHaveBeenCalledOnce();
|
||
expect(markSteeringCommentsDelivered).toHaveBeenCalledOnce();
|
||
expect(markSteeringCommentsDelivered).toHaveBeenCalledWith([newComment.id]);
|
||
expect(store.logEntry).toHaveBeenCalledTimes(1);
|
||
expect(store.logEntry).toHaveBeenCalledWith(
|
||
"FN-001",
|
||
expect.stringContaining("Comment received mid-execution"),
|
||
"by user",
|
||
);
|
||
});
|
||
|
||
it("does not re-inject an already seen active StepSessionExecutor steering comment", async () => {
|
||
const store = createMockStore();
|
||
const executor = new TaskExecutor(store, "/tmp/test");
|
||
const steerActiveSessions = vi.fn().mockResolvedValue(undefined);
|
||
const comment = {
|
||
id: "step-session-seen-comment",
|
||
text: "Already delivered",
|
||
createdAt: new Date().toISOString(),
|
||
author: "user" as const,
|
||
};
|
||
|
||
(executor as any).activeStepExecutors.set("FN-001", { steerActiveSessions });
|
||
(executor as any).activeStepExecutorSeenSteeringIds.set("FN-001", new Set([comment.id]));
|
||
|
||
await (store as any)._triggerAsync("task:updated", {
|
||
id: "FN-001",
|
||
title: "Test",
|
||
description: "Test",
|
||
column: "in-progress",
|
||
dependencies: [],
|
||
steps: [],
|
||
currentStep: 0,
|
||
log: [],
|
||
steeringComments: [comment],
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
expect(steerActiveSessions).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("does not re-inject already seen steering comments", async () => {
|
||
const store = createMockStore();
|
||
const steerFn = vi.fn().mockResolvedValue(undefined);
|
||
const executor = new TaskExecutor(store, "/tmp/test");
|
||
const comment = {
|
||
id: "1111111111-aaa111",
|
||
text: "Original comment",
|
||
createdAt: new Date().toISOString(),
|
||
author: "user" as const,
|
||
};
|
||
setLegacyActiveSession(executor, steerFn, new Set([comment.id]));
|
||
|
||
await (store as any)._triggerAsync("task:updated", makeSteeringTask([comment]));
|
||
|
||
expect(steerFn).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("marks comment as seen even if steer() throws", async () => {
|
||
const store = createMockStore();
|
||
const steerFn = vi.fn().mockRejectedValue(new Error("Session disconnected"));
|
||
const executor = new TaskExecutor(store, "/tmp/test");
|
||
const comment = {
|
||
id: "2222222222-bbb222",
|
||
text: "Comment that fails",
|
||
createdAt: new Date().toISOString(),
|
||
author: "user" as const,
|
||
};
|
||
setLegacyActiveSession(executor, steerFn);
|
||
|
||
await (store as any)._triggerAsync("task:updated", makeSteeringTask([comment]));
|
||
await (store as any)._triggerAsync("task:updated", makeSteeringTask([comment]));
|
||
|
||
expect(steerFn).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it("does not inject or log when active surfaces receive empty or undefined steering comments", async () => {
|
||
const store = createMockStore();
|
||
const executor = new TaskExecutor(store, "/tmp/test");
|
||
const legacySteer = vi.fn().mockResolvedValue(undefined);
|
||
const stepSteerActiveSessions = vi.fn().mockResolvedValue(1);
|
||
const workflowSteer = vi.fn().mockResolvedValue(undefined);
|
||
|
||
setLegacyActiveSession(executor, legacySteer);
|
||
(executor as any).activeStepExecutors.set("FN-001", {
|
||
steerActiveSessions: stepSteerActiveSessions,
|
||
updateSteeringComments: vi.fn(),
|
||
});
|
||
(executor as any).activeStepExecutorSeenSteeringIds.set("FN-001", new Set());
|
||
(executor as any).activeWorkflowStepSessions.set("FN-001", { steer: workflowSteer });
|
||
(executor as any).activeWorkflowStepSessionSeenSteeringIds.set("FN-001", new Set());
|
||
|
||
await (store as any)._triggerAsync("task:updated", { ...makeSteeringTask(), steeringComments: undefined });
|
||
await (store as any)._triggerAsync("task:updated", makeSteeringTask([]));
|
||
|
||
expect(legacySteer).not.toHaveBeenCalled();
|
||
expect(stepSteerActiveSessions).not.toHaveBeenCalled();
|
||
expect(workflowSteer).not.toHaveBeenCalled();
|
||
expect(store.logEntry).not.toHaveBeenCalledWith(
|
||
"FN-001",
|
||
expect.stringContaining("Comment received mid-execution"),
|
||
expect.anything(),
|
||
);
|
||
});
|
||
|
||
it("does not inject steering comments for tasks without an active injection target", async () => {
|
||
const store = createMockStore();
|
||
new TaskExecutor(store, "/tmp/test");
|
||
|
||
await (store as any)._triggerAsync("task:updated", {
|
||
...makeSteeringTask([{
|
||
id: "3333333333-ccc333",
|
||
text: "Should not be injected",
|
||
createdAt: new Date().toISOString(),
|
||
author: "user" as const,
|
||
}]),
|
||
id: "FN-NOT-EXECUTING",
|
||
});
|
||
|
||
expect(store.logEntry).not.toHaveBeenCalledWith(
|
||
"FN-NOT-EXECUTING",
|
||
expect.stringContaining("Comment received mid-execution"),
|
||
expect.anything(),
|
||
);
|
||
});
|
||
|
||
it("handles multiple new steering comments in a single task:updated", async () => {
|
||
const store = createMockStore();
|
||
const steerFn = vi.fn().mockResolvedValue(undefined);
|
||
const executor = new TaskExecutor(store, "/tmp/test");
|
||
setLegacyActiveSession(executor, steerFn, new Set(["existing-comment"]));
|
||
|
||
await (store as any)._triggerAsync("task:updated", makeSteeringTask([
|
||
{
|
||
id: "existing-comment",
|
||
text: "Original",
|
||
createdAt: new Date().toISOString(),
|
||
author: "user" as const,
|
||
},
|
||
{
|
||
id: "new-comment-1",
|
||
text: "First new comment",
|
||
createdAt: new Date().toISOString(),
|
||
author: "user" as const,
|
||
},
|
||
{
|
||
id: "new-comment-2",
|
||
text: "Second new comment",
|
||
createdAt: new Date().toISOString(),
|
||
author: "user" as const,
|
||
},
|
||
]));
|
||
|
||
expect(steerFn).toHaveBeenCalledTimes(2);
|
||
});
|
||
|
||
it("keeps agent-authored steering on the legacy review-handoff path", async () => {
|
||
const store = createMockStore();
|
||
store.getSettings.mockResolvedValue({ reviewHandoffPolicy: "comment-triggered" } as any);
|
||
const steerFn = vi.fn().mockResolvedValue(undefined);
|
||
const executor = new TaskExecutor(store, "/tmp/test");
|
||
const { session, state } = setLegacyActiveSession(executor, steerFn);
|
||
const executeReviewHandoff = vi.fn().mockResolvedValue(undefined);
|
||
(executor as any).executeReviewHandoff = executeReviewHandoff;
|
||
const agentComment = {
|
||
id: "agent-handoff-comment",
|
||
text: "Implementation is ready; requesting user review now.",
|
||
createdAt: new Date().toISOString(),
|
||
author: "agent" as const,
|
||
};
|
||
|
||
await (store as any)._triggerAsync("task:updated", makeSteeringTask([agentComment]));
|
||
|
||
expect(steerFn).toHaveBeenCalledOnce();
|
||
expect(executeReviewHandoff).toHaveBeenCalledWith(
|
||
expect.objectContaining({ id: "FN-001" }),
|
||
session,
|
||
state,
|
||
);
|
||
});
|
||
});
|
||
|
||
// ── Loop recovery (compact-and-resume) integration tests ────────────
|
||
|
||
describe("TaskExecutor loop recovery", () => {
|
||
beforeEach(() => {
|
||
resetExecutorMocks();
|
||
});
|
||
|
||
function createMockSessionForLoopRecovery(overrides?: { compactResult?: any }) {
|
||
const defaultResult = {
|
||
summary: "Compacted conversation",
|
||
tokensBefore: 150000,
|
||
};
|
||
const compactRetVal = overrides && "compactResult" in overrides ? overrides.compactResult : defaultResult;
|
||
const compact = vi.fn(async () => compactRetVal);
|
||
const steer = vi.fn(async () => {});
|
||
const abort = vi.fn(async () => {});
|
||
|
||
return {
|
||
prompt: vi.fn(async () => {}),
|
||
dispose: vi.fn(),
|
||
abort,
|
||
subscribe: vi.fn(),
|
||
setThinkingLevel: vi.fn(),
|
||
steer,
|
||
compact,
|
||
sessionFile: "/tmp/test-session.json",
|
||
model: { provider: "mock", id: "mock-model", name: "Mock" },
|
||
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
|
||
state: {},
|
||
};
|
||
}
|
||
|
||
function setupExecutorWithActiveSession(mockSession: ReturnType<typeof createMockSessionForLoopRecovery>) {
|
||
const store = createMockStore();
|
||
(store.getSettings as any).mockResolvedValue({
|
||
maxConcurrent: 2,
|
||
maxWorktrees: 4,
|
||
pollIntervalMs: 15000,
|
||
groupOverlappingFiles: false,
|
||
autoMerge: false,
|
||
});
|
||
|
||
const executor = new TaskExecutor(store, "/tmp/test-root");
|
||
|
||
// Directly inject an active session (avoids full execute() chain)
|
||
(executor as any).activeSessions.set("FN-001", {
|
||
session: mockSession,
|
||
seenSteeringIds: new Set(),
|
||
});
|
||
|
||
return { store, executor, mockSession };
|
||
}
|
||
|
||
it("handleLoopDetected returns true and compacts session when active session exists", async () => {
|
||
const mockSession = createMockSessionForLoopRecovery();
|
||
const { store, executor } = setupExecutorWithActiveSession(mockSession);
|
||
|
||
const result = await executor.handleLoopDetected({
|
||
taskId: "FN-001",
|
||
reason: "loop",
|
||
noProgressMs: 600000,
|
||
inactivityMs: 0,
|
||
activitySinceProgress: 100,
|
||
ignoredStepUpdateCount: 0,
|
||
shouldRequeue: true,
|
||
});
|
||
|
||
expect(result).toBe(true);
|
||
expect(mockSession.compact).toHaveBeenCalled();
|
||
expect(mockSession.steer).toHaveBeenCalled();
|
||
expect(store.logEntry).toHaveBeenCalledWith(
|
||
"FN-001",
|
||
expect.stringContaining("compact-and-resume"),
|
||
);
|
||
});
|
||
|
||
it("handleLoopDetected returns false when no active session", async () => {
|
||
const store = createMockStore();
|
||
const executor = new TaskExecutor(store, "/tmp/test-root");
|
||
|
||
// No session active (activeSessions is empty)
|
||
const result = await executor.handleLoopDetected({
|
||
taskId: "FN-001",
|
||
reason: "loop",
|
||
noProgressMs: 600000,
|
||
inactivityMs: 0,
|
||
activitySinceProgress: 100,
|
||
ignoredStepUpdateCount: 0,
|
||
shouldRequeue: true,
|
||
});
|
||
|
||
expect(result).toBe(false);
|
||
});
|
||
|
||
it("handleLoopDetected returns false when attempt ceiling reached", async () => {
|
||
const mockSession = createMockSessionForLoopRecovery();
|
||
const { executor } = setupExecutorWithActiveSession(mockSession);
|
||
|
||
// First call succeeds
|
||
const result1 = await executor.handleLoopDetected({
|
||
taskId: "FN-001",
|
||
reason: "loop",
|
||
noProgressMs: 600000,
|
||
inactivityMs: 0,
|
||
activitySinceProgress: 100,
|
||
ignoredStepUpdateCount: 0,
|
||
shouldRequeue: true,
|
||
});
|
||
expect(result1).toBe(true);
|
||
|
||
// Second call hits ceiling (max 1 attempt per execute() lifecycle)
|
||
const result2 = await executor.handleLoopDetected({
|
||
taskId: "FN-001",
|
||
reason: "loop",
|
||
noProgressMs: 600000,
|
||
inactivityMs: 0,
|
||
activitySinceProgress: 200,
|
||
ignoredStepUpdateCount: 0,
|
||
shouldRequeue: true,
|
||
});
|
||
expect(result2).toBe(false);
|
||
});
|
||
|
||
it("handleLoopDetected returns false when compaction fails", async () => {
|
||
const mockSession = createMockSessionForLoopRecovery({ compactResult: null });
|
||
const { executor } = setupExecutorWithActiveSession(mockSession);
|
||
|
||
const result = await executor.handleLoopDetected({
|
||
taskId: "FN-001",
|
||
reason: "loop",
|
||
noProgressMs: 600000,
|
||
inactivityMs: 0,
|
||
activitySinceProgress: 100,
|
||
ignoredStepUpdateCount: 0,
|
||
shouldRequeue: true,
|
||
});
|
||
|
||
expect(result).toBe(false);
|
||
});
|
||
|
||
it("handleLoopDetected returns false when compaction hangs", async () => {
|
||
vi.useFakeTimers();
|
||
const mockSession = createMockSessionForLoopRecovery({ compactResult: new Promise(() => {}) });
|
||
const { store, executor } = setupExecutorWithActiveSession(mockSession);
|
||
|
||
const resultPromise = executor.handleLoopDetected({
|
||
taskId: "FN-001",
|
||
reason: "loop",
|
||
noProgressMs: 600000,
|
||
inactivityMs: 0,
|
||
activitySinceProgress: 100,
|
||
ignoredStepUpdateCount: 0,
|
||
shouldRequeue: true,
|
||
});
|
||
|
||
await vi.advanceTimersByTimeAsync(60000);
|
||
|
||
await expect(resultPromise).resolves.toBe(false);
|
||
expect(mockSession.abort).toHaveBeenCalled();
|
||
expect(store.logEntry).toHaveBeenCalledWith(
|
||
"FN-001",
|
||
expect.stringContaining("Context compaction timed out"),
|
||
);
|
||
|
||
vi.useRealTimers();
|
||
});
|
||
});
|
||
|
||
// ── Context limit error recovery tests ────────────────────────────────
|
||
|
||
// ── U2 RETHINK delegation characterization (plan 2026-06-04-001, KTD-2) ──
|
||
//
|
||
// The legacy in-session fn_review_step RETHINK case now DELEGATES to
|
||
// step-runner.ts's resetStepToBaseline. These tests pin that the observable
|
||
// side effects are byte-identical to the pre-extraction block: git reset to
|
||
// the agent-supplied baseline, session rewind via navigateTree, step→pending,
|
||
// and the RETHINK log entry — all reached through the real executor session.
|
||
describe("U2: fn_review_step RETHINK delegates to resetStepToBaseline (characterization)", () => {
|
||
beforeEach(() => {
|
||
resetExecutorMocks();
|
||
});
|
||
|
||
function runRethinkScenario(reviewType: "code" | "plan", navigateTree: any) {
|
||
const store = createMockStore();
|
||
const baseTask = {
|
||
id: "FN-RT-1",
|
||
title: "Test",
|
||
description: "Test task",
|
||
column: "in-progress",
|
||
dependencies: [],
|
||
steps: [{ name: "Implement", status: "in-progress" }],
|
||
currentStep: 0,
|
||
log: [],
|
||
prompt: "# test\n## Steps\n### Step 1: Implement\n- [ ] implement",
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
};
|
||
store.getTask.mockResolvedValue(baseTask as any);
|
||
// updateStep returns the task with the step persisted in-progress so the
|
||
// executor's checkpoint-capture path (executor.ts ~6517) populates the
|
||
// stepCheckpoints map that RETHINK rewinds to.
|
||
store.updateStep.mockResolvedValue({
|
||
...baseTask,
|
||
steps: [{ name: "Implement", status: "in-progress" }],
|
||
} as any);
|
||
|
||
mockedReviewStep.mockResolvedValue({
|
||
verdict: "RETHINK",
|
||
review: "wrong approach",
|
||
summary: "rejected approach",
|
||
} as any);
|
||
|
||
let reviewToolError: unknown;
|
||
mockedCreateFnAgent.mockImplementation((async (opts: any) => {
|
||
const tools = opts.customTools || [];
|
||
return {
|
||
session: {
|
||
prompt: vi.fn().mockImplementation(async () => {
|
||
// First, flip the step to in-progress via fn_task_update so the
|
||
// checkpoint map is populated (mirrors the real session lifecycle).
|
||
const updateTool = tools.find((t: any) => t.name === "fn_task_update");
|
||
if (updateTool) {
|
||
try {
|
||
await updateTool.execute("tool-update", { step: 0, status: "in-progress" });
|
||
} catch { /* tool param shape varies; ignore */ }
|
||
}
|
||
const reviewTool = tools.find((t: any) => t.name === "fn_review_step");
|
||
if (reviewTool) {
|
||
try {
|
||
await reviewTool.execute("tool-review", {
|
||
step: 0,
|
||
type: reviewType,
|
||
step_name: "Implement",
|
||
baseline: reviewType === "code" ? "agentBaselineSHA" : undefined,
|
||
});
|
||
} catch (e) {
|
||
reviewToolError = e;
|
||
}
|
||
}
|
||
}),
|
||
dispose: vi.fn(),
|
||
subscribe: vi.fn(),
|
||
on: vi.fn(),
|
||
navigateTree,
|
||
sessionManager: {
|
||
getLeafId: vi.fn().mockReturnValue("leaf-pre-step"),
|
||
branchWithSummary: vi.fn(),
|
||
},
|
||
state: {},
|
||
},
|
||
};
|
||
}) as any);
|
||
|
||
const executor = new TaskExecutor(store, "/tmp/test", {});
|
||
return { store, baseTask, executor, getReviewToolError: () => reviewToolError };
|
||
}
|
||
|
||
it("code RETHINK: git reset to baseline, navigateTree rewind, step→pending, RETHINK log", async () => {
|
||
const navigateTree = vi.fn().mockResolvedValue(undefined);
|
||
const { store, baseTask, executor } = runRethinkScenario("code", navigateTree);
|
||
|
||
await executor.execute(baseTask as any);
|
||
|
||
// git reset --hard <baseline> issued in the worktree (via the mocked exec).
|
||
const resetIssued = mockedExecSync.mock.calls.some(
|
||
(c) => typeof c[0] === "string" && (c[0] as string).includes("git reset --hard agentBaselineSHA"),
|
||
);
|
||
expect(resetIssued).toBe(true);
|
||
// Session rewound to the captured pre-step checkpoint.
|
||
expect(navigateTree).toHaveBeenCalledWith("leaf-pre-step", { summarize: false });
|
||
// Step reset to pending through the projection sink.
|
||
expect(store.updateStep).toHaveBeenCalledWith("FN-RT-1", 0, "pending");
|
||
// RETHINK log entry (code-review variant references the git reset).
|
||
expect(store.logEntry).toHaveBeenCalledWith(
|
||
"FN-RT-1",
|
||
expect.stringContaining("git reset to agentBaselineSHA"),
|
||
"rejected approach",
|
||
);
|
||
});
|
||
|
||
it("plan RETHINK: no git reset, navigateTree rewind, step→pending, plan-rewound log", async () => {
|
||
const navigateTree = vi.fn().mockResolvedValue(undefined);
|
||
const { store, baseTask, executor } = runRethinkScenario("plan", navigateTree);
|
||
|
||
await executor.execute(baseTask as any);
|
||
|
||
const resetIssued = mockedExecSync.mock.calls.some(
|
||
(c) => typeof c[0] === "string" && (c[0] as string).includes("git reset --hard"),
|
||
);
|
||
expect(resetIssued).toBe(false);
|
||
expect(navigateTree).toHaveBeenCalledWith("leaf-pre-step", { summarize: false });
|
||
expect(store.updateStep).toHaveBeenCalledWith("FN-RT-1", 0, "pending");
|
||
expect(store.logEntry).toHaveBeenCalledWith(
|
||
"FN-RT-1",
|
||
expect.stringContaining("Step 0 plan rewound"),
|
||
"rejected approach",
|
||
);
|
||
});
|
||
});
|