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>
254 lines
13 KiB
TypeScript
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);
|
|
});
|
|
});
|