Files
fusion/packages/engine/src/__tests__/executor-task-recommendations.test.ts
Fusion Agent bcc77c9608 FN-035: require automatic task recommendations
Require task recommendations to be explicitly enabled and consistently surfaced across execution and settings.

- Add the automatic task recommendations setting, persistence, validation, and localized UI copy.
- Gate executor recommendation prompts and completion-tool behavior on the setting.
- Update dashboard/API coverage, documentation, and regression tests.

Files changed:
 .changeset/fn-035-required-task-recommendations.md |  7 +++
 docs/dashboard-guide.md                            |  3 +-
 docs/settings-reference.md                         |  2 +
 .../postgres/settings-persistence.pg.test.ts       | 18 +++++++
 .../core/src/__tests__/settings-parity.test.ts     |  3 ++
 packages/core/src/config/settings-schema.ts        |  2 +
 packages/core/src/task-store/settings-ops.ts       | 16 ++++--
 packages/core/src/types/settings/settings-scope.ts |  8 +++
 .../app/__tests__/settings-save-split.test.ts      | 16 ++++++
 .../__tests__/SettingsModal.general.test.tsx       | 36 +++++++++++++
 .../app/components/settings/section-keys.ts        |  1 +
 .../settings/sections/GeneralSection.search.ts     | 10 ++++
 .../settings/sections/GeneralSection.tsx           | 15 +++++-
 .../settings-default-descriptions.test.tsx         |  2 +
 .../register-settings-memory-worktrunk.test.ts     | 28 ++++++++++
 .../src/routes/register-settings-memory-routes.ts  |  5 ++
 ...cutor-prompt-completion-recommendations.test.ts | 31 +++++++++++
 .../executor-task-recommendations.test.ts          | 61 ++++++++++++++++++++--
 .../engine/src/executor/create-task-done-tool.ts   | 18 +++++--
 packages/engine/src/executor/system-prompt.ts      | 20 +++++--
 packages/i18n/locales/en/app.json                  |  3 ++
 packages/i18n/locales/es/app.json                  |  3 ++
 packages/i18n/locales/fr/app.json                  |  3 ++
 packages/i18n/locales/ko/app.json                  |  3 ++
 packages/i18n/locales/pt-BR/app.json               |  5 +-
 packages/i18n/locales/zh-CN/app.json               |  3 ++
 packages/i18n/locales/zh-TW/app.json               |  3 ++
 packages/i18n/src/resources.d.ts                   |  3 ++
 28 files changed, 307 insertions(+), 21 deletions(-)

Fusion-Task-Id: FN-035
Fusion-Task-Lineage: 336d4d99-3105-43df-b82c-ca413b9c5892
Co-authored-by: Fusion <noreply@runfusion.ai>
2026-08-20 03:57:23 +00:00

254 lines
13 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
import "./executor-test-helpers.js";
import { registerTaskRecommendationNoticeMailbox } from "@fusion/core";
import { TaskExecutor, validateCompletionRecommendations } from "../executor.js";
import { __flushPendingRecommendationNotices } from "../executor/completion-recommendation-notice.js";
import * as worktreePool from "../worktree/worktree-pool.js";
import { createMockStore, mockedExecSync, resetExecutorMocks } from "./executor-test-helpers.js";
const recommendation = {
id: "rec-export",
title: "Export completed tasks",
description: "Add CSV export outside the completed task's scope.",
category: "feature" as const,
};
function completionTask() {
return {
id: "FN-8829-test",
title: "Completed recommendation parent",
description: "A completed task with out-of-scope follow-up work.",
column: "in-progress",
worktree: "/repo/.worktrees/recommendations",
branch: "fusion/fn-8829-test",
baseCommitSha: "base-sha",
enabledWorkflowSteps: [],
dependencies: [],
steps: [{ name: "Implement", status: "done" as const }],
currentStep: 0,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
}
function createProductionTaskDoneTool(maximum: number | undefined = 3, recommendationMailboxNoticeEnabled?: boolean, requireTaskRecommendations = false) {
const store = createMockStore();
const task = completionTask();
store._setRow(task.id, task);
store.getSettings.mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15_000,
groupOverlappingFiles: false,
autoMerge: false,
worktreeInitCommand: undefined,
...(maximum === undefined ? {} : { maxRecommendationsPerTask: maximum }),
...(recommendationMailboxNoticeEnabled === undefined ? {} : { recommendationMailboxNoticeEnabled }),
...(requireTaskRecommendations ? { requireTaskRecommendations: true } : {}),
});
const executor = new TaskExecutor(store as any, "/repo");
const onDone = vi.fn();
const tool = (executor as any).createTaskDoneTool(
task.id,
task.worktree,
"# Task\n## Steps\n### Step 0: Implement\n- [x] Complete",
new Map(),
onDone,
);
return { store, task, tool, onDone };
}
describe("fn_task_done recommendation validation", () => {
beforeEach(() => {
resetExecutorMocks();
vi.spyOn(worktreePool, "isUsableTaskWorktree").mockResolvedValue(true);
mockedExecSync.mockImplementation((command: string) => {
if (command.includes("rev-parse --show-toplevel")) return Buffer.from("/repo/.worktrees/recommendations\n");
if (command.includes("rev-parse --abbrev-ref HEAD")) return Buffer.from("fusion/fn-8829-test\n");
if (command.includes("rev-list --count")) return Buffer.from("1\n");
if (command.includes("rev-parse HEAD")) return Buffer.from("head-sha\n");
return Buffer.from("");
});
});
it("accepts a bounded task-ready recommendation list", () => {
expect(validateCompletionRecommendations([recommendation], 1)).toEqual([recommendation]);
expect(validateCompletionRecommendations([], 0)).toEqual([]);
});
it("allows task-ready security recommendations without credential material", () => {
expect(validateCompletionRecommendations([{
...recommendation,
title: "Add password reset support",
description: "Add a password reset flow as a separate security follow-up.",
}], 3)).not.toBeTypeOf("string");
});
it("requires an explicit quality-first evaluation without mutating refused completion", async () => {
const { store, task, tool, onDone } = createProductionTaskDoneTool(3, undefined, true);
const refused = await tool.execute("call-required-omitted", {});
expect(refused.content[0].text).toContain("requires an explicit recommendations array");
expect(refused.content[0].text).toContain("recommendations: []");
expect((await store.getTask(task.id)).recommendations).toBeUndefined();
expect(store.updateTask).not.toHaveBeenCalled();
expect(onDone).not.toHaveBeenCalled();
});
it("accepts shorter, at-cap, and empty explicit arrays when required", async () => {
const short = createProductionTaskDoneTool(3, undefined, true);
await short.tool.execute("call-required-short", { recommendations: [recommendation] });
expect((await short.store.getTask(short.task.id)).recommendations).toEqual([recommendation]);
const empty = createProductionTaskDoneTool(3, undefined, true);
await empty.tool.execute("call-required-empty", { recommendations: [] });
expect((await empty.store.getTask(empty.task.id)).recommendations).toEqual([]);
const atCap = createProductionTaskDoneTool(3, undefined, true);
await atCap.tool.execute("call-required-cap", {
recommendations: [recommendation, { ...recommendation, id: "rec-second" }, { ...recommendation, id: "rec-third" }],
});
expect((await atCap.store.getTask(atCap.task.id)).recommendations).toHaveLength(3);
});
it("keeps blocked exits outside required recommendation enforcement", async () => {
const { store, task, tool } = createProductionTaskDoneTool(3, undefined, true);
const blocked = await tool.execute("call-required-blocked", {
outcome: "blocked",
reason: "Waiting for the upstream API contract.",
});
expect(blocked.content[0].text).toContain("Task parked as blocked");
expect((await store.getTask(task.id)).recommendations).toBeUndefined();
});
it("requires an explicit result for no-op completion too", async () => {
const { store, task, tool, onDone } = createProductionTaskDoneTool(3, undefined, true);
const refused = await tool.execute("call-required-no-op", {
summary: "PREMISE STALE: the requested behavior is already present on HEAD",
});
expect(refused.content[0].text).toContain("requires an explicit recommendations array");
expect((await store.getTask(task.id)).recommendations).toBeUndefined();
expect(store.updateTask).not.toHaveBeenCalled();
expect(onDone).not.toHaveBeenCalled();
});
it("persists and deterministically replaces recommendations through the production completion tool", async () => {
const { store, task, tool } = createProductionTaskDoneTool();
const first = await tool.execute("call-1", { recommendations: [recommendation] });
expect(first.content[0].text).toContain("Task marked complete");
expect((await store.getTask(task.id)).recommendations).toEqual([recommendation]);
const replacement = { ...recommendation, id: "rec-replacement", title: "Improve task exports" };
await tool.execute("call-2", { recommendations: [replacement] });
expect((await store.getTask(task.id)).recommendations).toEqual([replacement]);
});
it("sends one non-blocking operator mailbox notice after accepted completion", async () => {
const { store, task, tool } = createProductionTaskDoneTool();
const messages: Array<{ input: any; key: string }> = [];
registerTaskRecommendationNoticeMailbox(store as any, {
sendMessageOnce: async (input, key) => { messages.push({ input, key }); },
});
await expect(tool.execute("call-notice", { recommendations: [recommendation, { ...recommendation, id: "rec-docs", title: "Document exports" }] })).resolves.toMatchObject({ details: {} });
await __flushPendingRecommendationNotices();
expect(messages).toHaveLength(1);
expect(messages[0].input).toMatchObject({
toId: "dashboard",
type: "system",
metadata: { kind: "task-recommendation-notice", taskId: task.id, recommendationCount: 2 },
});
expect(messages[0].input.content).toContain("Export completed tasks");
expect(messages[0].input.content).toContain("Document exports");
await tool.execute("call-notice-retry", { recommendations: [recommendation, { ...recommendation, id: "rec-docs", title: "Document exports" }] });
await __flushPendingRecommendationNotices();
expect(messages[1].key).toBe(messages[0].key);
});
it("persists recommendations but suppresses notices when the project setting is off", async () => {
const { store, task, tool } = createProductionTaskDoneTool(3, false);
let messages = 0;
registerTaskRecommendationNoticeMailbox(store as any, { sendMessageOnce: async () => { messages += 1; } });
await tool.execute("call-notice-off", { recommendations: [recommendation] });
await __flushPendingRecommendationNotices();
expect((await store.getTask(task.id)).recommendations).toEqual([recommendation]);
expect(messages).toBe(0);
});
it("persists an honest empty list and uses the default cap when the setting is absent", async () => {
const { store, task, tool } = createProductionTaskDoneTool(undefined);
const empty = await tool.execute("call-empty", { recommendations: [] });
expect(empty.content[0].text).toContain("Task marked complete");
expect((await store.getTask(task.id)).recommendations).toEqual([]);
const { store: defaultStore, task: defaultTask, tool: defaultTool } = createProductionTaskDoneTool(undefined);
await defaultTool.execute("call-default", { recommendations: [recommendation, { ...recommendation, id: "rec-second" }, { ...recommendation, id: "rec-third" }] });
expect((await defaultStore.getTask(defaultTask.id)).recommendations).toHaveLength(3);
});
it("accepts an empty list but rejects populated input when capture is disabled", async () => {
const { store, task, tool } = createProductionTaskDoneTool(0);
const empty = await tool.execute("call-disabled-empty", { recommendations: [] });
expect(empty.content[0].text).toContain("Task marked complete");
expect((await store.getTask(task.id)).recommendations).toEqual([]);
const { store: rejectedStore, task: rejectedTask, tool: rejectedTool } = createProductionTaskDoneTool(0);
const rejected = await rejectedTool.execute("call-disabled-populated", { recommendations: [recommendation] });
expect(rejected.content[0].text).toContain("maximum of 0");
expect((await rejectedStore.getTask(rejectedTask.id)).recommendations).toBeUndefined();
});
it("documents the prompted payload and persists its equivalent through the production tool", async () => {
const { store, task, tool } = createProductionTaskDoneTool();
expect(tool.description).toContain("recommendations: []");
expect(tool.description).toContain("a shorter list or [] is valid when relevance does not support more");
expect(tool.parameters.properties.recommendations.description).toContain("unique stable ids");
expect(tool.parameters.properties.recommendations.description).toContain("populated input is rejected");
await tool.execute("call-prompt-shape", { recommendations: [recommendation] });
expect((await store.getTask(task.id)).recommendations).toEqual([recommendation]);
});
it("does not persist recommendations when production completion is refused or blocked", async () => {
const { store, task, tool } = createProductionTaskDoneTool();
const refused = await tool.execute("call-refused", {
recommendations: [{ ...recommendation, description: "Run pnpm export before filing this follow-up." }],
});
expect(refused.content[0].text).toContain("Cannot mark task done yet");
expect((await store.getTask(task.id)).recommendations).toBeUndefined();
const blocked = await tool.execute("call-blocked", {
outcome: "blocked",
reason: "Waiting for the upstream API contract.",
recommendations: [recommendation],
});
expect(blocked.content[0].text).toContain("Task parked as blocked");
expect((await store.getTask(task.id)).recommendations).toBeUndefined();
});
it.each([
["disabled", [recommendation], 0, "maximum of 0"],
["over-cap", [recommendation, { ...recommendation, id: "rec-2" }], 1, "maximum of 1"],
["duplicate id", [recommendation, recommendation], 3, "ids must be unique"],
["invalid category", [{ ...recommendation, category: "unknown" }], 3, "category must be"],
["secret", [{ ...recommendation, description: "Use API_KEY=value for this follow-up." }], 3, "must not contain secrets"],
["command", [{ ...recommendation, description: "Run `pnpm export` after completing this task." }], 3, "must not contain secrets"],
["bare command", [{ ...recommendation, description: "Run pnpm export after completing this task." }], 3, "must not contain secrets"],
["imperative flags", [{ ...recommendation, description: "Run ls -la after completing this task." }], 3, "must not contain secrets"],
["imperative script path", [{ ...recommendation, description: "Execute ./cleanup.sh after completing this task." }], 3, "must not contain secrets"],
["shell prompt", [{ ...recommendation, description: "$ curl https://example.test/export" }], 3, "must not contain secrets"],
["missing title", [{ ...recommendation, title: " " }], 3, "requires id, title, and description"],
["reasoning payload", [{ ...recommendation, reasoning: "I considered several implementation paths." }], 3, "may contain only"],
["pre-linked child", [{ ...recommendation, createdTaskId: "FN-999" }], 3, "may contain only"],
])("rejects %s recommendation input", (_label, input, maximum, expectedError) => {
expect(validateCompletionRecommendations(input, maximum)).toContain(expectedError);
});
});