FN-6117: expose task attachments in workflow step prompts

Make workflow-driven task execution keep attachment context available across step prompts and retries.

- add attachment guidance to full step prompts so agents can read project-root task attachments from worktrees
- include attachment availability in reduced retry prompts after context-limit failures
- cover attachment preservation through workflow runtime execution and step prompt generation with engine tests

Files changed:
 .../src/__tests__/step-session-executor.test.ts    | 272 +++++++++++++++++++++
 .../src/__tests__/workflow-task-runtime.test.ts    |  94 ++++++-
 packages/engine/src/step-session-executor.ts       |  13 +-
 3 files changed, 376 insertions(+), 3 deletions(-)

Fusion-Task-Id: FN-6117

Fusion-Task-Lineage: 520eb1bc-e276-449a-acfe-ccbabefe69e3
This commit is contained in:
gsxdsm
2026-06-09 13:31:30 -07:00
parent 03067935bd
commit b872b37d72
3 changed files with 376 additions and 3 deletions

View File

@@ -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

View File

@@ -32,7 +32,7 @@ function recordingPrimitives(
overrides: Partial<Record<"prepare" | "execute" | "workflowStep", WorkflowNodeResult>> & {
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({

View File

@@ -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.",