feat(FN-2565): merge fusion/fn-2565

This commit is contained in:
gsxdsm
2026-04-25 18:08:27 -07:00
parent 92589b046f
commit 2026132132
2 changed files with 318 additions and 8 deletions

View File

@@ -3,6 +3,7 @@ import type { TaskStore, Task, TaskDetail, Settings } from "@fusion/core";
import {
TriageProcessor,
TRIAGE_SYSTEM_PROMPT,
FAST_TRIAGE_SYSTEM_PROMPT,
buildSpecificationPrompt,
readAttachmentContents,
computeUserCommentFingerprint,
@@ -562,6 +563,155 @@ describe("TRIAGE_SYSTEM_PROMPT", () => {
});
});
describe("fast-mode triage", () => {
it("exports a lean FAST_TRIAGE_SYSTEM_PROMPT", () => {
expect(typeof FAST_TRIAGE_SYSTEM_PROMPT).toBe("string");
expect(FAST_TRIAGE_SYSTEM_PROMPT.length).toBeGreaterThan(0);
expect(FAST_TRIAGE_SYSTEM_PROMPT).toContain("This task is running in **fast mode**");
expect(FAST_TRIAGE_SYSTEM_PROMPT).toContain("fn_review_spec()");
expect(FAST_TRIAGE_SYSTEM_PROMPT).not.toContain("## Review Level");
expect(FAST_TRIAGE_SYSTEM_PROMPT).not.toContain("## Triage subtask breakdown");
expect(FAST_TRIAGE_SYSTEM_PROMPT).not.toContain("## Proactive Subtask Breakdown");
expect(FAST_TRIAGE_SYSTEM_PROMPT).not.toContain("Frontend UX Criteria");
});
it("selects FAST_TRIAGE_SYSTEM_PROMPT for fast tasks", async () => {
const task = createTriageTask({ id: "FN-FAST-001", executionMode: "fast" });
const store = createMockStore({
getTask: vi.fn().mockResolvedValue({ ...mockTaskDetail, id: task.id, attachments: [], comments: [] }),
});
let capturedSystemPrompt = "";
mockCreateFnAgent.mockImplementationOnce(async (opts: any) => {
capturedSystemPrompt = opts.systemPrompt;
return {
session: {
state: {},
sessionManager: { getLeafId: vi.fn().mockReturnValue(null) },
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
navigateTree: vi.fn(),
},
};
});
const processor = new TriageProcessor(store, "/tmp/root");
await processor.specifyTask(task);
expect(capturedSystemPrompt).toContain("This task is running in **fast mode**");
expect(capturedSystemPrompt).not.toContain("## Review Level");
});
it("keeps standard prompt for standard tasks", async () => {
const task = createTriageTask({ id: "FN-FAST-002", executionMode: "standard" });
const store = createMockStore({
getTask: vi.fn().mockResolvedValue({ ...mockTaskDetail, id: task.id, attachments: [], comments: [] }),
});
let capturedSystemPrompt = "";
mockCreateFnAgent.mockImplementationOnce(async (opts: any) => {
capturedSystemPrompt = opts.systemPrompt;
return {
session: {
state: {},
sessionManager: { getLeafId: vi.fn().mockReturnValue(null) },
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
navigateTree: vi.fn(),
},
};
});
const processor = new TriageProcessor(store, "/tmp/root");
await processor.specifyTask(task);
expect(capturedSystemPrompt).toContain("## Review Level");
});
it("auto-approves fn_review_spec in fast mode without calling reviewer", async () => {
const rootDir = await createTriageFixtureRoot("fusion-triage-fast-review-");
try {
const taskId = "FN-FAST-003";
await mkdir(join(rootDir, ".fusion", "tasks", taskId), { recursive: true });
await writeFile(join(rootDir, ".fusion", "tasks", taskId, "PROMPT.md"), "# Task\n\nSpec");
const store = createMockStore({
getTask: vi.fn().mockResolvedValue({ ...mockTaskDetail, id: taskId, comments: [] }),
});
const processor = new TriageProcessor(store, rootDir);
const verdictRef = { current: null as any };
const approvedCommentFingerprintRef = { current: "" };
const tool = (processor as any).createReviewSpecTool(
taskId,
`.fusion/tasks/${taskId}/PROMPT.md`,
{ current: null },
{ current: null },
verdictRef,
approvedCommentFingerprintRef,
{},
true,
);
const result = await tool.execute({});
expect(mockReviewStep).not.toHaveBeenCalled();
expect(verdictRef.current).toBe("APPROVE");
expect(result.content[0]?.text).toBe("APPROVE");
expect(store.logEntry).toHaveBeenCalledWith(taskId, "Spec review: APPROVE (auto, fast mode)");
} finally {
await cleanupTriageFixtureRoot(rootDir);
}
});
it("passes post-session gate in fast mode after fn_review_spec auto-approval", async () => {
const rootDir = await createTriageFixtureRoot("fusion-triage-fast-gate-");
try {
const task = createTriageTask({ id: "FN-FAST-004", executionMode: "fast" });
const promptPath = join(rootDir, ".fusion", "tasks", task.id, "PROMPT.md");
await mkdir(join(rootDir, ".fusion", "tasks", task.id), { recursive: true });
const store = createMockStore({
getTask: vi.fn().mockResolvedValue({ ...mockTaskDetail, id: task.id, attachments: [], comments: [] }),
parseDependenciesFromPrompt: vi.fn().mockResolvedValue([]),
parseStepsFromPrompt: vi.fn().mockResolvedValue([]),
parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]),
});
let capturedTools: any[] = [];
mockCreateFnAgent.mockImplementationOnce(async (opts: any) => {
capturedTools = opts.customTools;
return {
session: {
state: {},
sessionManager: { getLeafId: vi.fn().mockReturnValue(null) },
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
navigateTree: vi.fn(),
},
};
});
const { promptWithFallback } = await import("../pi.js");
(promptWithFallback as ReturnType<typeof vi.fn>).mockImplementationOnce(async () => {
await writeFile(promptPath, "# Task: FN-FAST-004 - Fast\n\n## Mission\n\nShip it.");
const reviewTool = capturedTools.find((tool) => tool.name === "fn_review_spec");
expect(reviewTool).toBeDefined();
await reviewTool.execute({});
});
const processor = new TriageProcessor(store, rootDir);
await processor.specifyTask(task);
expect(mockReviewStep).not.toHaveBeenCalled();
expect(store.moveTask).toHaveBeenCalledWith("FN-FAST-004", "todo");
expect(store.logEntry).toHaveBeenCalledWith("FN-FAST-004", "Spec review: APPROVE (auto, fast mode)");
} finally {
await cleanupTriageFixtureRoot(rootDir);
}
});
});
describe("readAttachmentContents", () => {
let testDir = "";
const taskId = "FN-TEST";
@@ -860,6 +1010,7 @@ describe("TriageProcessor", () => {
projectValidatorProvider: "anthropic",
projectValidatorModelId: "claude-opus-4-6",
},
false,
);
await tool.execute({});
@@ -2283,6 +2434,7 @@ describe("stale approval detection", () => {
{ current: null },
approvedCommentFingerprintRef,
{},
false,
);
// Execute fn_review_spec — should capture fingerprint at APPROVE time
@@ -2330,6 +2482,7 @@ describe("stale approval detection", () => {
{ current: null },
approvedCommentFingerprintRef,
{},
false,
);
await tool.execute({});

View File

@@ -282,6 +282,149 @@ Use this exact checklist (keep it verbatim — do not expand or reorder):
Only inject this section when the task genuinely touches frontend UI. Omit it for backend-only, config-only, or documentation-only tasks.`;
export const FAST_TRIAGE_SYSTEM_PROMPT = `You are a task specification agent for "fn", an AI-orchestrated task board. This task is running in **fast mode** — produce a lean, executable PROMPT.md without heavyweight review scoring or subtask analysis.
Your job: turn a rough task description into a focused PROMPT.md another agent can execute autonomously.
## What you produce
Write a complete PROMPT.md specification to the given path using the write tool.
## PROMPT.md Format
Follow this structure exactly:
\`\`\`markdown
# Task: {ID} - {Name}
**Created:** {YYYY-MM-DD}
**Size:** {S | M}
## Mission
{One paragraph: what to build and why it matters}
## Dependencies
- **None**
{OR}
- **Task:** {ID} ({what must be complete first})
## Context to Read First
{List the minimal, specific files needed for implementation}
## File Scope
{List exact files/directories expected to change}
- \`path/to/file.ext\`
- \`path/to/directory/*\`
## Steps
### Step 0: Preflight
- [ ] Required files and paths exist
- [ ] Dependencies satisfied
### Step 1: {Implementation step name}
- [ ] {Specific, verifiable outcome}
- [ ] {Specific, verifiable outcome}
- [ ] Run targeted tests for changed files
**Artifacts:**
- \`path/to/file\` (new | modified)
### Step {N-1}: Testing & Verification
> ZERO test failures allowed. Full test suite as quality gate.
> If keeping lint/tests/build/typecheck green requires edits outside the initial File Scope, make those fixes as part of this task.
- [ ] Run lint check (\`pnpm lint\`)
- [ ] Run full test suite
- [ ] Run project typecheck if available
- [ ] Build passes
### Step {N}: Documentation & Delivery
- [ ] Update relevant documentation
- [ ] Save documentation deliverables as task documents via \`fn_task_document_write\` (key="docs", content=...)
- [ ] Create out-of-scope follow-up tasks via \`fn_task_create\` when needed
## Documentation Requirements
**Must Update:**
- \`path/to/doc.md\` — {what to add/change}
**Check If Affected:**
- \`path/to/doc.md\` — {update if relevant}
## Completion Criteria
- [ ] All steps complete
- [ ] Lint passing
- [ ] All tests passing
- [ ] Typecheck passing (if available)
- [ ] Documentation updated
## Git Commit Convention
Commits at step boundaries. All commits include the task ID:
- **Step completion:** \`feat({ID}): complete Step N — description\`
- **Bug fixes:** \`fix({ID}): description\`
- **Tests:** \`test({ID}): description\`
## Do NOT
- Expand task scope
- Skip tests
- Refuse necessary fixes just because they touch files outside the initial File Scope
- Commit without the task ID prefix
- Remove, delete, or gut modules, settings, interfaces, exports, or test files outside the File Scope
- Remove features as "cleanup" — if something seems unused, create a task via \`fn_task_create\`
## Changeset Requirements
If this task REMOVES existing functionality (deleting modules, settings, API endpoints, or exports), a changeset file is REQUIRED:
- Create \`.changeset/{task-id}-removal.md\` explaining what was removed and why
- This is mandatory for any net-negative change (more deletions than additions to existing files)
\`\`\`
## Testing requirements
- Require real automated tests with assertions that run in the project's test runner
- Typecheck/build/manual checks are not tests and cannot replace tests
- Include targeted tests in implementation steps and full quality-gate runs in final verification
## Duplicate check
Before writing a spec, call \`fn_task_list\` to find existing active tasks.
If an existing task already covers the same work, do NOT write a PROMPT.md. Instead write exactly:
\`DUPLICATE: {existing-task-id}\`
## Dependency awareness
When adding a dependency in \`## Dependencies\`, first call \`fn_task_get\` for that task and read its PROMPT.md.
Use that context to align file paths, APIs, assumptions, and completion expectations. If the dependency has no PROMPT.md yet, note that explicitly.
## Guidelines
- Read relevant source files before writing the spec
- Be specific: reference concrete files, modules, and commands from this repo
- Keep steps outcome-focused with 24 checkboxes per step
- Always include Testing & Verification and Documentation & Delivery steps
- Keep fast-mode scope lean and executable; do not add heavyweight review scoring or subtask-analysis sections
## Project commands
When the user prompt includes explicit test/build commands, use those exact commands in the generated spec.
## Spec Review
After writing the PROMPT.md, call \`fn_review_spec()\` to confirm the spec.
Fast-mode specs are auto-approved — the review tool will return APPROVE immediately without spawning an independent reviewer. You do NOT need to wait for or iterate on review feedback.
## Output
Write the PROMPT.md directly using the write tool, then call \`fn_review_spec()\` to confirm.`;
export interface TriageProcessorOptions {
pollIntervalMs?: number;
semaphore?: AgentSemaphore;
@@ -650,6 +793,7 @@ export class TriageProcessor {
const detail = await this.store.getTask(task.id);
const settings = await this.store.getSettings();
const promptPath = `.fusion/tasks/${task.id}/PROMPT.md`;
const isFast = task.executionMode === "fast";
const agentWork = async () => {
// Set status only after the semaphore slot has been acquired, so
@@ -710,6 +854,7 @@ export class TriageProcessor {
specReviewVerdictRef,
approvedCommentFingerprintRef,
settings,
isFast,
),
];
@@ -729,8 +874,10 @@ export class TriageProcessor {
triageLog.warn(`${task.id}: failed to resolve triage agent instructions, continuing with defaults: ${msg}`);
}
}
triageLog.log(`${task.id}: specifying in ${isFast ? "fast" : "standard"} mode`);
const triageSystemPrompt = buildSystemPromptWithInstructions(
resolveAgentPrompt("triage", settings.agentPrompts) || TRIAGE_SYSTEM_PROMPT,
resolveAgentPrompt("triage", settings.agentPrompts)
|| (isFast ? FAST_TRIAGE_SYSTEM_PROMPT : TRIAGE_SYSTEM_PROMPT),
triageInstructions,
);
@@ -1426,6 +1573,7 @@ export class TriageProcessor {
validatorProvider?: string;
validatorModelId?: string;
},
skipSpecReview: boolean,
): ToolDefinition {
const store = this.store;
const rootDir = this.rootDir;
@@ -1475,17 +1623,26 @@ export class TriageProcessor {
};
}
// Re-read task detail to get latest user comments
const currentDetail = await store.getTask(taskId);
const currentUserComments = (currentDetail.comments || []).filter(
(c: any) => c.author === "user",
);
if (skipSpecReview) {
specReviewVerdictRef.current = "APPROVE";
approvedCommentFingerprintRef.current = currentUserComments.length > 0
? computeUserCommentFingerprint(currentUserComments)
: "";
triageLog.log(`${taskId}: spec review auto-approved (fast mode)`);
await store.logEntry(taskId, "Spec review: APPROVE (auto, fast mode)");
return { content: [{ type: "text" as const, text: "APPROVE" }], details: {} };
}
// Re-read settings at review time so long-lived triage sessions pick up
// model changes made after the session started.
const currentSettings = await store.getSettings();
// Re-read task detail to get latest user comments for the reviewer
const currentDetail = await store.getTask(taskId);
const currentUserComments = (currentDetail.comments || []).filter(
(c: any) => c.author === "user",
);
const result = await reviewStep(
rootDir,
taskId,