diff --git a/packages/engine/src/__tests__/step-session-executor.test.ts b/packages/engine/src/__tests__/step-session-executor.test.ts index c69f488f75..daf33448d0 100644 --- a/packages/engine/src/__tests__/step-session-executor.test.ts +++ b/packages/engine/src/__tests__/step-session-executor.test.ts @@ -4,6 +4,7 @@ import { buildConflictMatrix, determineParallelWaves, buildStepPrompt, + buildReducedStepPrompt, StepSessionExecutor, } from "../step-session-executor.js"; import { AgentLogger } from "../agent-logger.js"; @@ -487,6 +488,170 @@ Do important work. expect(result).not.toContain("/repo/project/.worktrees/happy-robin/.fusion/memory/"); }); + it("includes attachment section with absolute project-root paths for image attachments", () => { + const task = makeTaskDetail({ + prompt: fullPrompt, + attachments: [ + { + filename: "abc123-screenshot.png", + originalName: "screenshot.png", + mimeType: "image/png", + size: 2048, + createdAt: new Date().toISOString(), + }, + ], + }); + + const result = buildStepPrompt(task, 1, "/repo/project"); + + expect(result).toContain("## Attachments"); + expect(result).toContain("**screenshot.png** (screenshot)"); + expect(result).toContain("/repo/project/.fusion/tasks/FN-001/attachments/abc123-screenshot.png"); + }); + + it("includes attachment section for text attachments with read-for-context wording", () => { + const task = makeTaskDetail({ + prompt: fullPrompt, + attachments: [ + { + filename: "def456-error.log", + originalName: "error.log", + mimeType: "text/plain", + size: 512, + createdAt: new Date().toISOString(), + }, + ], + }); + + const result = buildStepPrompt(task, 1, "/repo/project"); + + expect(result).toContain("## Attachments"); + expect(result).toContain("**error.log** (text/plain)"); + expect(result).toContain("read for context"); + expect(result).toContain("/repo/project/.fusion/tasks/FN-001/attachments/def456-error.log"); + }); + + it("omits attachment section when attachments is undefined", () => { + const task = makeTaskDetail({ prompt: fullPrompt, attachments: undefined }); + const result = buildStepPrompt(task, 1, "/repo/project"); + + expect(result).not.toContain("## Attachments"); + }); + + it("omits attachment section when attachments is empty", () => { + const task = makeTaskDetail({ prompt: fullPrompt, attachments: [] }); + const result = buildStepPrompt(task, 1, "/repo/project"); + + expect(result).not.toContain("## Attachments"); + }); + + it("includes both image and text attachments together", () => { + const task = makeTaskDetail({ + prompt: fullPrompt, + attachments: [ + { + filename: "abc-shot.png", + originalName: "shot.png", + mimeType: "image/png", + size: 1024, + createdAt: new Date().toISOString(), + }, + { + filename: "def-config.json", + originalName: "config.json", + mimeType: "application/json", + size: 256, + createdAt: new Date().toISOString(), + }, + ], + }); + + const result = buildStepPrompt(task, 1, "/repo/project"); + + expect(result).toContain("**shot.png** (screenshot)"); + expect(result).toContain("/repo/project/.fusion/tasks/FN-001/attachments/abc-shot.png"); + expect(result).toContain("**config.json** (application/json)"); + expect(result).toContain("/repo/project/.fusion/tasks/FN-001/attachments/def-config.json"); + expect(result).toContain("read for context"); + }); + + it("keeps attachment paths at the project root when executing in a worktree", () => { + const task = makeTaskDetail({ + prompt: fullPrompt, + attachments: [ + { + filename: "abc123-screenshot.png", + originalName: "screenshot.png", + mimeType: "image/png", + size: 2048, + createdAt: new Date().toISOString(), + }, + ], + }); + + const result = buildStepPrompt( + task, + 1, + "/repo/project", + undefined, + "/repo/project/.worktrees/happy-robin", + ); + + expect(result).toContain("/repo/project/.fusion/tasks/FN-001/attachments/abc123-screenshot.png"); + expect(result).not.toContain("/repo/project/.worktrees/happy-robin/.fusion/tasks/FN-001/attachments/abc123-screenshot.png"); + }); + + it("includes attachment-read permission note when attachments and rootDir are provided", () => { + const task = makeTaskDetail({ + id: "FN-777", + prompt: fullPrompt, + attachments: [ + { + filename: "abc123-screenshot.png", + originalName: "screenshot.png", + mimeType: "image/png", + size: 2048, + createdAt: new Date().toISOString(), + }, + ], + }); + + const result = buildStepPrompt(task, 1, "/repo/project"); + + expect(result).toContain("## Attachments"); + expect(result).toContain( + "> **Note:** Attachment files are at the project root under `.fusion/tasks/FN-777/attachments/` — you may read them even when working in a worktree.", + ); + expect(result.indexOf("> **Note:** Attachment files")).toBeGreaterThan( + result.indexOf("/repo/project/.fusion/tasks/FN-777/attachments/abc123-screenshot.png"), + ); + }); + + it("omits attachment-read permission note when no attachments exist", () => { + const task = makeTaskDetail({ prompt: fullPrompt, attachments: [] }); + const result = buildStepPrompt(task, 1, "/repo/project"); + + expect(result).not.toContain("Attachment files are at the project root"); + }); + + it("omits attachment-read permission note when rootDir is not provided", () => { + const task = makeTaskDetail({ + prompt: fullPrompt, + attachments: [ + { + filename: "abc123-screenshot.png", + originalName: "screenshot.png", + mimeType: "image/png", + size: 2048, + createdAt: new Date().toISOString(), + }, + ], + }); + const result = buildStepPrompt(task, 1); + + expect(result).not.toContain("Attachment files are at the project root"); + }); + it("handles step 0 (preflight) correctly", () => { const task = makeTaskDetail({ prompt: fullPrompt }); const result = buildStepPrompt(task, 0); @@ -546,6 +711,113 @@ Some freeform text without checkboxes.`; }); }); +// ── buildReducedStepPrompt tests ─────────────────────────────────────── + +describe("buildReducedStepPrompt", () => { + const reducedPrompt = `# Task: FN-001 - Test Task + +## Steps + +### Step 0: Preflight + +- [ ] Check files exist + +### Step 1: Implement + +- [ ] Create new-module.ts +- [ ] Add exports + +### Step 2: Test + +- [ ] Write unit tests +`; + + it("includes one-line attachment reference when attachments exist", () => { + const task = makeTaskDetail({ + id: "FN-123", + prompt: reducedPrompt, + attachments: [ + { + filename: "abc-shot.png", + originalName: "shot.png", + mimeType: "image/png", + size: 1024, + createdAt: new Date().toISOString(), + }, + { + filename: "def-config.json", + originalName: "config.json", + mimeType: "application/json", + size: 256, + createdAt: new Date().toISOString(), + }, + ], + }); + + const result = buildReducedStepPrompt(task, 1); + + expect(result).toContain( + "2 attachment(s) available at .fusion/tasks/FN-123/attachments/ — ask for context if needed.", + ); + }); + + it("places the attachment reference after the step and before the important block", () => { + const task = makeTaskDetail({ + prompt: reducedPrompt, + attachments: [ + { + filename: "abc-shot.png", + originalName: "shot.png", + mimeType: "image/png", + size: 1024, + createdAt: new Date().toISOString(), + }, + ], + }); + + const result = buildReducedStepPrompt(task, 1); + const attachmentReference = + "1 attachment(s) available at .fusion/tasks/FN-001/attachments/ — ask for context if needed."; + + expect(result.indexOf(attachmentReference)).toBeGreaterThan(result.indexOf("Add exports")); + expect(result.indexOf(attachmentReference)).toBeLessThan(result.indexOf("IMPORTANT:")); + }); + + it("does not list individual attachment files in the reduced prompt", () => { + const task = makeTaskDetail({ + prompt: reducedPrompt, + attachments: [ + { + filename: "abc-shot.png", + originalName: "shot.png", + mimeType: "image/png", + size: 1024, + createdAt: new Date().toISOString(), + }, + ], + }); + + const result = buildReducedStepPrompt(task, 1); + + expect(result).not.toContain("abc-shot.png"); + expect(result).not.toContain("shot.png"); + }); + + it("omits attachment reference when attachments is undefined", () => { + const task = makeTaskDetail({ prompt: reducedPrompt, attachments: undefined }); + const result = buildReducedStepPrompt(task, 1); + + expect(result).not.toContain("attachment(s) available"); + }); + + it("omits attachment reference when attachments is empty", () => { + const task = makeTaskDetail({ prompt: reducedPrompt, attachments: [] }); + const result = buildReducedStepPrompt(task, 1); + + expect(result).not.toContain("attachment(s) available"); + }); +}); + // ── StepSessionExecutor test helpers ─────────────────────────────────── // Mock pi.js for StepSessionExecutor tests diff --git a/packages/engine/src/__tests__/workflow-task-runtime.test.ts b/packages/engine/src/__tests__/workflow-task-runtime.test.ts index ea4d7282d4..23c8c58237 100644 --- a/packages/engine/src/__tests__/workflow-task-runtime.test.ts +++ b/packages/engine/src/__tests__/workflow-task-runtime.test.ts @@ -32,7 +32,7 @@ function recordingPrimitives( overrides: Partial> & { prepareData?: PreparedWorktree | null; } = {}, - observed: { prepared?: PreparedWorktree } = {}, + observed: { prepared?: PreparedWorktree; executedTasks?: TaskDetail[] } = {}, ): WorkflowRuntimePrimitives { const prepared: PreparedWorktree = { worktreePath: "/tmp/fusion-worktree" }; return { @@ -58,6 +58,7 @@ function recordingPrimitives( runCodingSession: async (_ctx, _task, preparedWorktree) => { calls.push("execute"); observed.prepared = preparedWorktree; + observed.executedTasks?.push(_task); const override = overrides.execute; return { outcome: override?.outcome ?? "success", @@ -151,6 +152,97 @@ describe("WorkflowTaskRuntime", () => { expect(workflowSelectionReads).toBe(1); }); + it("preserves attachments through selected workflow execution", async () => { + const calls: string[] = []; + const attachments = [ + { + filename: "abc-shot.png", + originalName: "shot.png", + mimeType: "image/png", + size: 1024, + createdAt: new Date().toISOString(), + }, + { + filename: "def-context.txt", + originalName: "context.txt", + mimeType: "text/plain", + size: 256, + createdAt: new Date().toISOString(), + }, + ]; + const attachmentTask = { ...task, attachments } as TaskDetail; + const observed: { executedTasks: TaskDetail[] } = { executedTasks: [] }; + const runtime = new WorkflowTaskRuntime({ + store: { + getTaskWorkflowSelection: () => ({ workflowId: "WF-001", stepIds: [] }), + getWorkflowDefinition: async () => ({ ir: selectedIr() }), + }, + primitives: recordingPrimitives(calls, undefined, observed), + runCustomNode: async (node) => { + calls.push(`custom:${node.id}`); + return { outcome: "success" }; + }, + }); + + const result = await runtime.run(attachmentTask, flagOff); + + expect(result.disposition).toBe("completed"); + expect(calls).toEqual(["custom:prepare", "prepare-worktree", "execute"]); + expect(observed.executedTasks).toHaveLength(1); + expect(observed.executedTasks[0]?.attachments).toEqual(attachments); + }); + + it("preserves attachments through built-in workflow execution", async () => { + const calls: string[] = []; + const attachments = [ + { + filename: "abc-shot.png", + originalName: "shot.png", + mimeType: "image/png", + size: 1024, + createdAt: new Date().toISOString(), + }, + ]; + const attachmentTask = { ...task, attachments } as TaskDetail; + const observed: { executedTasks: TaskDetail[] } = { executedTasks: [] }; + const runtime = new WorkflowTaskRuntime({ + store: { + getTaskWorkflowSelection: () => undefined, + getWorkflowDefinition: async () => undefined, + }, + primitives: recordingPrimitives(calls, undefined, observed), + runCustomNode: async (node) => { + calls.push(`custom:${node.id}`); + return { outcome: "success" }; + }, + }); + + const result = await runtime.run(attachmentTask, flagOff); + + expect(result.disposition).toBe("completed"); + expect(calls).toEqual(["planning", "prepare-worktree", "execute", "workflow-step", "review", "merge"]); + expect(observed.executedTasks).toHaveLength(1); + expect(observed.executedTasks[0]?.attachments).toEqual(attachments); + }); + + it("passes undefined attachments through built-in workflow execution when absent", async () => { + const observed: { executedTasks: TaskDetail[] } = { executedTasks: [] }; + const runtime = new WorkflowTaskRuntime({ + store: { + getTaskWorkflowSelection: () => undefined, + getWorkflowDefinition: async () => undefined, + }, + primitives: recordingPrimitives([], undefined, observed), + runCustomNode: async () => ({ outcome: "success" }), + }); + + const result = await runtime.run(task, flagOff); + + expect(result.disposition).toBe("completed"); + expect(observed.executedTasks).toHaveLength(1); + expect(observed.executedTasks[0]?.attachments).toBeUndefined(); + }); + it("fails execute instead of skipping coding when prepare succeeds without worktree data", async () => { const calls: string[] = []; const runtime = new WorkflowTaskRuntime({ diff --git a/packages/engine/src/step-session-executor.ts b/packages/engine/src/step-session-executor.ts index 6fe611a4ba..23bc96f0ee 100644 --- a/packages/engine/src/step-session-executor.ts +++ b/packages/engine/src/step-session-executor.ts @@ -416,6 +416,10 @@ export function buildStepPrompt( lines.push(`- **${att.originalName}** (${att.mimeType}): \`${absPath}\` — read for context`); } } + lines.push( + "", + `> **Note:** Attachment files are at the project root under \`.fusion/tasks/${id}/attachments/\` — you may read them even when working in a worktree.`, + ); attachmentsSection = "\n" + lines.join("\n") + "\n"; } @@ -553,11 +557,12 @@ function escapeRegex(str: string): string { * @param stepIndex - The 0-based step index. * @returns A reduced prompt string focused on the current step only. */ -function buildReducedStepPrompt(taskDetail: TaskDetail, stepIndex: number): string { - const { prompt, id, title } = taskDetail; +export function buildReducedStepPrompt(taskDetail: TaskDetail, stepIndex: number): string { + const { prompt, id, title, attachments } = taskDetail; // Extract the step-specific section const stepSection = extractStepSection(prompt, stepIndex); + const hasAttachments = Boolean(attachments && attachments.length > 0); // Build a minimal prompt that focuses on the step without excessive context const parts: string[] = [ @@ -568,6 +573,10 @@ function buildReducedStepPrompt(taskDetail: TaskDetail, stepIndex: number): stri "", stepSection, "", + hasAttachments + ? `${attachments?.length ?? 0} attachment(s) available at .fusion/tasks/${id}/attachments/ — ask for context if needed.` + : "", + "", "IMPORTANT: Your previous attempt hit the context window limit.", "Do NOT repeat work that's already been done.", "Check git status and git log to see what's been committed.",