feat(FN-2565): merge fusion/fn-2565
This commit is contained in:
@@ -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({});
|
||||
|
||||
@@ -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 2–4 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,
|
||||
|
||||
Reference in New Issue
Block a user