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:
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user