feat(FN-4074): add priority option to task creation in engine and extension
Adds task priority support to the task creation flow, spanning the engine runtime (`agent-tools.ts`, `triage.ts`), the CLI extension interface (`extension.ts`), and corresponding documentation and tests. The changeset bumps `@runfusion/fusion` as a minor version. Fusion-Task-Id: FN-4074 Fusion-Task-Lineage: 726c0ae6-90fb-4c13-ace1-215a2becb2a9
This commit is contained in:
@@ -61,7 +61,7 @@ Based on the user's request, route to the appropriate workflow:
|
||||
<quick_reference>
|
||||
|
||||
**Create a task:**
|
||||
Use `fn_task_create` with a descriptive message. Include the problem AND desired outcome.
|
||||
Use `fn_task_create` with a descriptive message. Include the problem AND desired outcome, and set `priority` (`low`/`normal`/`high`/`urgent`) when urgency matters.
|
||||
|
||||
**List tasks:**
|
||||
Use `fn_task_list` to see all tasks grouped by column. Use `column` param to filter.
|
||||
|
||||
@@ -11,7 +11,7 @@ These tools are **not** part of the user-invokable extension surface. They are i
|
||||
|
||||
| Tool | Agent Types | Purpose | Parameters |
|
||||
|---|---|---|---|
|
||||
| `fn_task_create` | triage, executor, heartbeat | Create a follow-up task from within an agent run | `description` (string), `dependencies?` (string[]) |
|
||||
| `fn_task_create` | triage, executor, heartbeat | Create a follow-up task from within an agent run | `description` (string), `dependencies?` (string[]), `priority?` (`low` \| `normal` \| `high` \| `urgent`) |
|
||||
| `fn_task_log` | executor, heartbeat | Write significant task log entries | `message` (string), `outcome?` (string) |
|
||||
| `fn_task_document_write` | triage, executor, heartbeat | Save/update a named task document revision | `key` (string), `content` (string), `author?` (string) |
|
||||
| `fn_task_document_read` | triage, executor, heartbeat | Read one task document or list all | `key?` (string) |
|
||||
|
||||
@@ -17,6 +17,7 @@ Create a new task on the Fusion task board. The task enters the planning column
|
||||
| `description` | string | ✓ | What needs to be done — be descriptive |
|
||||
| `depends` | array | — | Task IDs this depends on (e.g. ['FN-001', 'FN-002']) |
|
||||
| `agentId` | string | — | Agent ID to assign this task to (e.g. 'agent-abc123') |
|
||||
| `priority` | string(enum) | — | Task priority (low, normal, high, urgent) |
|
||||
|
||||
### fn_task_update
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ Use only the public `fn_*` extension tools in this workflow. Do not substitute i
|
||||
- Include the problem AND the desired outcome
|
||||
- Be specific — the AI triage agent uses your description to write the specification
|
||||
- Optionally add dependencies with the `depends` parameter
|
||||
- Optionally set urgency with `priority` (`low`, `normal`, `high`, `urgent`)
|
||||
|
||||
2. The task enters **triage** where the AI auto-generates a PROMPT.md with:
|
||||
- Steps, file scope, acceptance criteria
|
||||
@@ -29,7 +30,8 @@ Example:
|
||||
```
|
||||
fn_task_create({
|
||||
description: "The login form doesn't validate email format before submission. Add client-side email validation that shows an inline error message when the email is invalid. Use the existing form validation pattern from the signup form.",
|
||||
depends: ["FN-042"]
|
||||
depends: ["FN-042"],
|
||||
priority: "high"
|
||||
})
|
||||
```
|
||||
|
||||
|
||||
@@ -176,6 +176,7 @@ describe.skipIf(!SHOULD_RUN_EXTENSION_INTEGRATION)("built fn pi extension integr
|
||||
|
||||
expect(created.details.taskId).toMatch(/^[A-Z]+-\d+$/);
|
||||
expect(created.details.column).toBe("triage");
|
||||
expect(created.details.priority).toBe("normal");
|
||||
|
||||
const listTool = api.tools.get("fn_task_list")!;
|
||||
const listed = await listTool.execute("list-1", {}, undefined, undefined, makeCtx(tmpDir));
|
||||
@@ -186,6 +187,17 @@ describe.skipIf(!SHOULD_RUN_EXTENSION_INTEGRATION)("built fn pi extension integr
|
||||
await store.init();
|
||||
const persisted = await store.getTask(created.details.taskId);
|
||||
expect(persisted?.description).toBe("Ship the packed CLI contract");
|
||||
|
||||
const urgent = await createTool.execute(
|
||||
"create-2",
|
||||
{ description: "Needs urgency", priority: "high" },
|
||||
undefined,
|
||||
undefined,
|
||||
makeCtx(tmpDir),
|
||||
);
|
||||
expect(urgent.details.priority).toBe("high");
|
||||
const urgentPersisted = await store.getTask(urgent.details.taskId);
|
||||
expect(urgentPersisted?.priority).toBe("high");
|
||||
});
|
||||
|
||||
it("runs provisioning tools through the built extension", async () => {
|
||||
|
||||
@@ -269,6 +269,25 @@ describe.skipIf(!SHOULD_RUN_LEGACY_EXTENSION_INTEGRATION)("fn pi extension (lega
|
||||
expect(result.content[0].text).toContain("Fix the login button");
|
||||
expect(result.content[0].text).toContain("triage");
|
||||
expect(result.details.column).toBe("triage");
|
||||
expect(result.details.priority).toBe("normal");
|
||||
});
|
||||
|
||||
it("creates a task with explicit priority", async () => {
|
||||
const tool = api.tools.get("fn_task_create")!;
|
||||
const result = await tool.execute(
|
||||
"call-priority",
|
||||
{ description: "Urgent task", priority: "urgent" },
|
||||
undefined,
|
||||
undefined,
|
||||
makeCtx(tmpDir),
|
||||
);
|
||||
|
||||
expect(result.details.priority).toBe("urgent");
|
||||
expect(result.content[0].text).toContain("Priority: urgent");
|
||||
|
||||
const showTool = api.tools.get("fn_task_show")!;
|
||||
const show = await showTool.execute("s-priority", { id: result.details.taskId }, undefined, undefined, makeCtx(tmpDir));
|
||||
expect(show.details.task.priority).toBe("urgent");
|
||||
});
|
||||
|
||||
it("creates a task with dependencies", async () => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
validateNodeOverrideChange,
|
||||
type Task,
|
||||
type InsightCategory,
|
||||
type TaskPriority,
|
||||
type InsightStatus,
|
||||
type InsightRunStatus,
|
||||
type InsightRunTrigger,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
canAgentTakeImplementationTaskForExplicitRouting,
|
||||
formatRoleMismatchReason,
|
||||
resolveAgentProvisioningPolicy,
|
||||
TASK_PRIORITIES,
|
||||
} from "@fusion/core";
|
||||
import {
|
||||
getGhErrorMessage,
|
||||
@@ -406,6 +408,9 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
description: "Agent ID to assign this task to (e.g. 'agent-abc123')",
|
||||
}),
|
||||
),
|
||||
priority: Type.Optional(
|
||||
StringEnum([...TASK_PRIORITIES], { description: "Task priority (low, normal, high, urgent)" }) as unknown as TSchema,
|
||||
),
|
||||
}),
|
||||
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
@@ -430,6 +435,7 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
description: params.description.trim(),
|
||||
dependencies: params.depends,
|
||||
assignedAgentId: normalizedAgentId === null ? undefined : normalizedAgentId,
|
||||
priority: params.priority as TaskPriority | undefined,
|
||||
source: { sourceType: "api" },
|
||||
});
|
||||
|
||||
@@ -451,6 +457,7 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
(task.assignedAgentId
|
||||
? `Assigned to: ${task.assignedAgentId}\n`
|
||||
: "") +
|
||||
`Priority: ${task.priority}\n` +
|
||||
`Path: .fusion/tasks/${task.id}/`,
|
||||
},
|
||||
],
|
||||
@@ -459,6 +466,7 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
column: task.column,
|
||||
dependencies: task.dependencies,
|
||||
assignedAgentId: task.assignedAgentId,
|
||||
priority: task.priority,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
@@ -114,6 +114,7 @@ describe("createTaskCreateTool", () => {
|
||||
description: "Follow-up task",
|
||||
dependencies: ["PROJ-001"],
|
||||
column: "triage",
|
||||
priority: undefined,
|
||||
source: undefined,
|
||||
}, {
|
||||
settings: { autoSummarizeTitles: false },
|
||||
@@ -125,6 +126,21 @@ describe("createTaskCreateTool", () => {
|
||||
expect(responseText).toContain("(depends on: PROJ-001)");
|
||||
});
|
||||
|
||||
it("passes explicit priority to store.createTask", async () => {
|
||||
const store = {
|
||||
getSettings: vi.fn().mockResolvedValue({ autoSummarizeTitles: false }),
|
||||
createTask: vi.fn().mockResolvedValue({ id: "PROJ-098", description: "Test", dependencies: [], column: "triage" }),
|
||||
};
|
||||
|
||||
const tool = createTaskCreateTool(store as any);
|
||||
|
||||
await tool.execute("call-1", { description: "Test", priority: "high" } as any, undefined, undefined, {} as any);
|
||||
|
||||
expect(store.createTask).toHaveBeenCalledWith(expect.objectContaining({
|
||||
priority: "high",
|
||||
}), expect.objectContaining({ settings: { autoSummarizeTitles: false } }));
|
||||
});
|
||||
|
||||
it("passes explicit provenance to store.createTask", async () => {
|
||||
const store = {
|
||||
getSettings: vi.fn().mockResolvedValue({ autoSummarizeTitles: false }),
|
||||
|
||||
@@ -2603,6 +2603,7 @@ describe("executeHeartbeat", () => {
|
||||
description: "Follow-up task",
|
||||
dependencies: undefined,
|
||||
column: "triage",
|
||||
priority: undefined,
|
||||
source: {
|
||||
sourceType: "agent_heartbeat",
|
||||
sourceAgentId: "agent-001",
|
||||
@@ -2610,6 +2611,27 @@ describe("executeHeartbeat", () => {
|
||||
},
|
||||
}, expect.objectContaining({ settings: { autoSummarizeTitles: false } }));
|
||||
});
|
||||
|
||||
it("forwards explicit priority when fn_task_create tool is called", async () => {
|
||||
const store = createStoreWithAgentForExec();
|
||||
let capturedCreateTool: any;
|
||||
const mockSession = createMockAgentSession();
|
||||
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
|
||||
capturedCreateTool = opts.customTools[0];
|
||||
return { session: mockSession as any };
|
||||
});
|
||||
|
||||
mockSession.prompt = vi.fn().mockImplementation(async () => {
|
||||
await capturedCreateTool.execute("call-1", { description: "Follow-up task", priority: "high" });
|
||||
});
|
||||
|
||||
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
||||
await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
|
||||
|
||||
expect(mockTaskStore.createTask).toHaveBeenCalledWith(expect.objectContaining({
|
||||
priority: "high",
|
||||
}), expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
|
||||
@@ -134,6 +134,7 @@ describe("createHeartbeatTools", () => {
|
||||
description: "Follow-up task",
|
||||
dependencies: undefined,
|
||||
column: "triage",
|
||||
priority: undefined,
|
||||
source: {
|
||||
sourceType: "agent_heartbeat",
|
||||
sourceAgentId: "agent-001",
|
||||
@@ -147,6 +148,18 @@ describe("createHeartbeatTools", () => {
|
||||
expect(result.details).toEqual({ taskId: "FN-100" });
|
||||
});
|
||||
|
||||
it("fn_task_create forwards explicit priority to TaskStore.createTask", async () => {
|
||||
const store = createMockStore();
|
||||
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
||||
|
||||
const tools = monitor.createHeartbeatTools("agent-001", mockTaskStore, "FN-001");
|
||||
await tools[0]!.execute("call-1", { description: "Follow-up task", priority: "urgent" }, undefined as any, undefined as any, undefined as any);
|
||||
|
||||
expect(mockTaskStore.createTask).toHaveBeenCalledWith(expect.objectContaining({
|
||||
priority: "urgent",
|
||||
}), expect.any(Object));
|
||||
});
|
||||
|
||||
it("fn_task_create details includes taskId matching mock store return", async () => {
|
||||
const store = createMockStore();
|
||||
const matchingStore = createMockTaskStoreForTools({
|
||||
|
||||
@@ -20,6 +20,10 @@ function initRepo(dir: string): void {
|
||||
git(dir, 'git commit -m "chore: initial"');
|
||||
}
|
||||
|
||||
function testTempParent(): string {
|
||||
return process.env.FUSION_TEST_WORKER_ROOT ?? tmpdir();
|
||||
}
|
||||
|
||||
function assertIsolatedWorkspace(dir: string): void {
|
||||
const repoRoot = process.env.FUSION_TEST_REAL_ROOT;
|
||||
if (!repoRoot) return;
|
||||
@@ -51,7 +55,7 @@ describe("commitOrAmendMergeWithFixes ancestor/equivalent-content short-circuit"
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "fusion-test-merger-ancestor-"));
|
||||
dir = mkdtempSync(join(testTempParent(), "fusion-test-merger-ancestor-"));
|
||||
assertIsolatedWorkspace(dir);
|
||||
initRepo(dir);
|
||||
});
|
||||
|
||||
@@ -38,6 +38,10 @@ function createAutostash(dir: string, label: string, content: string): string {
|
||||
return sha;
|
||||
}
|
||||
|
||||
function testTempParent(): string {
|
||||
return process.env.FUSION_TEST_WORKER_ROOT ?? tmpdir();
|
||||
}
|
||||
|
||||
function assertIsolatedWorkspace(dir: string): void {
|
||||
const repoRoot = process.env.FUSION_TEST_REAL_ROOT;
|
||||
if (!repoRoot) return;
|
||||
@@ -48,7 +52,7 @@ describe("autostash orphan surface", () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "fusion-test-merger-autostash-surface-"));
|
||||
dir = mkdtempSync(join(testTempParent(), "fusion-test-merger-autostash-surface-"));
|
||||
assertIsolatedWorkspace(dir);
|
||||
initRepo(dir);
|
||||
});
|
||||
|
||||
@@ -26,11 +26,18 @@ import { computeApprovalDedupeKey } from "./agent-action-gate.js";
|
||||
|
||||
// ── Tool parameter schemas (canonical definitions) ────────────────────────
|
||||
|
||||
const TASK_CREATE_PRIORITY_VALUES = ["low", "normal", "high", "urgent"] as const;
|
||||
|
||||
export const taskCreateParams = Type.Object({
|
||||
description: Type.String({ description: "What needs to be done" }),
|
||||
dependencies: Type.Optional(
|
||||
Type.Array(Type.String(), { description: "Task IDs this new task depends on (e.g. [\"KB-001\"])" }),
|
||||
),
|
||||
priority: Type.Optional(
|
||||
Type.Union(TASK_CREATE_PRIORITY_VALUES.map((priority) => Type.Literal(priority)), {
|
||||
description: "Task priority (low, normal, high, urgent)",
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export const taskLogParams = Type.Object({
|
||||
@@ -628,6 +635,7 @@ export function createTaskCreateTool(
|
||||
description: params.description,
|
||||
dependencies: params.dependencies,
|
||||
column: "triage",
|
||||
priority: params.priority,
|
||||
source: provenance ? {
|
||||
sourceType: provenance.sourceType,
|
||||
sourceAgentId: provenance.sourceAgentId,
|
||||
|
||||
@@ -1574,12 +1574,18 @@ export class TriageProcessor {
|
||||
const taskGetParams = Type.Object({
|
||||
id: Type.String({ description: "Task ID (e.g. KB-001)" }),
|
||||
});
|
||||
const taskCreatePriorityValues = ["low", "normal", "high", "urgent"] as const;
|
||||
const taskCreateParams = Type.Object({
|
||||
title: Type.Optional(Type.String({ description: "Short child task title" })),
|
||||
description: Type.String({ description: "Child task description/mission" }),
|
||||
dependencies: Type.Optional(
|
||||
Type.Array(Type.String({ description: "Task ID dependency (e.g. KB-001)" })),
|
||||
),
|
||||
priority: Type.Optional(
|
||||
Type.Union(taskCreatePriorityValues.map((priority) => Type.Literal(priority)), {
|
||||
description: "Task priority (low, normal, high, urgent)",
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const taskList: ToolDefinition = {
|
||||
@@ -1740,6 +1746,7 @@ export class TriageProcessor {
|
||||
description: params.description,
|
||||
dependencies: validDeps,
|
||||
column: "triage",
|
||||
priority: params.priority,
|
||||
// Inherit parent's model settings if available
|
||||
modelProvider: parentTask?.modelProvider,
|
||||
modelId: parentTask?.modelId,
|
||||
|
||||
Reference in New Issue
Block a user