feat(HAI-042): add file attachment support for tasks
- Expand MIME type support for text file uploads - Add --attach flag to CLI task create command - Surface attachments to triage agent with image content support - Reference attachments in executor prompt for task execution context - Add drag-and-drop file upload on dashboard task cards
This commit is contained in:
@@ -8,21 +8,23 @@ hai — AI-orchestrated task board
|
||||
|
||||
Usage:
|
||||
hai dashboard Start the board web UI
|
||||
hai task create [desc] Create a new task (goes to triage)
|
||||
hai task create [desc] [--attach f] Create a new task (goes to triage)
|
||||
hai task list List all tasks
|
||||
hai task show <id> Show task details, steps, log
|
||||
hai task move <id> <col> Move a task to a column
|
||||
hai task update <id> <step> <status> Update step status (pending|in-progress|done|skipped)
|
||||
hai task log <id> <message> Add a log entry
|
||||
hai task merge <id> Merge an in-review task and close it
|
||||
hai task attach <id> <file> Attach a screenshot image to a task
|
||||
hai task attach <id> <file> Attach a file to a task
|
||||
|
||||
Options:
|
||||
--port, -p <port> Dashboard port (default: 4040)
|
||||
--engine Enable AI engine (auto-specify + execute tasks)
|
||||
--attach <file> Attach file(s) on task create (repeatable)
|
||||
--help, -h Show this help
|
||||
|
||||
Columns: triage, todo, in-progress, in-review, done
|
||||
Supported file types: png, jpg, gif, webp, txt, log, json, yaml, yml, toml, csv, xml
|
||||
|
||||
The AI engine uses pi (github.com/badlogic/pi-mono) for agent sessions.
|
||||
Requires configured API keys — run "pi" first to set up authentication.
|
||||
@@ -55,8 +57,19 @@ async function main() {
|
||||
const subcommand = args[1];
|
||||
switch (subcommand) {
|
||||
case "create": {
|
||||
const title = args.slice(2).join(" ");
|
||||
await runTaskCreate(title || undefined);
|
||||
const createArgs = args.slice(2);
|
||||
const attachFiles: string[] = [];
|
||||
const descParts: string[] = [];
|
||||
for (let i = 0; i < createArgs.length; i++) {
|
||||
if (createArgs[i] === "--attach" && i + 1 < createArgs.length) {
|
||||
attachFiles.push(createArgs[i + 1]);
|
||||
i++; // skip the value
|
||||
} else {
|
||||
descParts.push(createArgs[i]);
|
||||
}
|
||||
}
|
||||
const title = descParts.join(" ");
|
||||
await runTaskCreate(title || undefined, attachFiles.length > 0 ? attachFiles : undefined);
|
||||
break;
|
||||
}
|
||||
case "list":
|
||||
|
||||
@@ -22,7 +22,7 @@ vi.mock("@hai/core", () => {
|
||||
vi.mock("@hai/engine", () => ({ aiMergeTask: vi.fn() }));
|
||||
|
||||
import { TaskStore } from "@hai/core";
|
||||
import { runTaskShow } from "./task.js";
|
||||
import { runTaskShow, runTaskCreate } from "./task.js";
|
||||
|
||||
function makeTask(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
@@ -92,3 +92,117 @@ describe("runTaskShow", () => {
|
||||
expect(headerLine![0]).not.toContain("This is the full description");
|
||||
});
|
||||
});
|
||||
|
||||
// Mock fs/promises for runTaskCreate attach tests
|
||||
vi.mock("node:fs/promises", () => ({
|
||||
readFile: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("runTaskCreate with --attach", () => {
|
||||
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||
let errorSpy: ReturnType<typeof vi.spyOn>;
|
||||
let mockAddAttachment: ReturnType<typeof vi.fn>;
|
||||
let mockReadFile: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(async () => {
|
||||
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
mockAddAttachment = vi.fn().mockResolvedValue({
|
||||
filename: "abc123-test.png",
|
||||
originalName: "test.png",
|
||||
mimeType: "image/png",
|
||||
size: 2048,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
|
||||
init: vi.fn(),
|
||||
createTask: vi.fn().mockResolvedValue({
|
||||
id: "HAI-002",
|
||||
description: "test task",
|
||||
column: "triage",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}),
|
||||
addAttachment: mockAddAttachment,
|
||||
}));
|
||||
|
||||
const fsMod = await import("node:fs/promises");
|
||||
mockReadFile = vi.mocked(fsMod.readFile);
|
||||
mockReadFile.mockResolvedValue(Buffer.from("file content"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("creates task and attaches files when attachFiles provided", async () => {
|
||||
await runTaskCreate("test task", ["/tmp/test.png"]);
|
||||
|
||||
expect(mockAddAttachment).toHaveBeenCalledOnce();
|
||||
expect(mockAddAttachment).toHaveBeenCalledWith(
|
||||
"HAI-002",
|
||||
"test.png",
|
||||
expect.any(Buffer),
|
||||
"image/png",
|
||||
);
|
||||
|
||||
const attachLine = logSpy.mock.calls.find(
|
||||
(call) => typeof call[0] === "string" && call[0].includes("Attached"),
|
||||
);
|
||||
expect(attachLine).toBeDefined();
|
||||
});
|
||||
|
||||
it("attaches multiple files", async () => {
|
||||
mockAddAttachment.mockResolvedValueOnce({
|
||||
filename: "abc-screenshot.png",
|
||||
originalName: "screenshot.png",
|
||||
mimeType: "image/png",
|
||||
size: 1024,
|
||||
createdAt: new Date().toISOString(),
|
||||
}).mockResolvedValueOnce({
|
||||
filename: "def-crash.log",
|
||||
originalName: "crash.log",
|
||||
mimeType: "text/plain",
|
||||
size: 512,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
await runTaskCreate("test task", ["/tmp/screenshot.png", "/tmp/crash.log"]);
|
||||
|
||||
expect(mockAddAttachment).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("skips files with unsupported extensions", async () => {
|
||||
await runTaskCreate("test task", ["/tmp/file.exe"]);
|
||||
|
||||
expect(mockAddAttachment).not.toHaveBeenCalled();
|
||||
const errLine = errorSpy.mock.calls.find(
|
||||
(call) => typeof call[0] === "string" && call[0].includes("Unsupported"),
|
||||
);
|
||||
expect(errLine).toBeDefined();
|
||||
});
|
||||
|
||||
it("skips unreadable files", async () => {
|
||||
mockReadFile.mockRejectedValueOnce(new Error("ENOENT"));
|
||||
|
||||
await runTaskCreate("test task", ["/tmp/missing.png"]);
|
||||
|
||||
expect(mockAddAttachment).not.toHaveBeenCalled();
|
||||
const errLine = errorSpy.mock.calls.find(
|
||||
(call) => typeof call[0] === "string" && call[0].includes("Cannot read"),
|
||||
);
|
||||
expect(errLine).toBeDefined();
|
||||
});
|
||||
|
||||
it("creates task without attachments when attachFiles is undefined", async () => {
|
||||
await runTaskCreate("test task");
|
||||
|
||||
expect(mockAddAttachment).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ async function getStore(): Promise<TaskStore> {
|
||||
return store;
|
||||
}
|
||||
|
||||
export async function runTaskCreate(descriptionArg?: string) {
|
||||
export async function runTaskCreate(descriptionArg?: string, attachFiles?: string[]) {
|
||||
let description = descriptionArg;
|
||||
|
||||
if (!description) {
|
||||
@@ -35,6 +35,36 @@ export async function runTaskCreate(descriptionArg?: string) {
|
||||
console.log(` ✓ Created ${task.id}: ${label}`);
|
||||
console.log(` Column: triage`);
|
||||
console.log(` Path: .hai/tasks/${task.id}/`);
|
||||
|
||||
if (attachFiles && attachFiles.length > 0) {
|
||||
const { readFile } = await import("node:fs/promises");
|
||||
const { basename, extname, resolve } = await import("node:path");
|
||||
|
||||
for (const filePath of attachFiles) {
|
||||
const resolvedPath = resolve(filePath);
|
||||
const filename = basename(resolvedPath);
|
||||
const ext = extname(filename).toLowerCase();
|
||||
const mimeType = MIME_TYPES[ext];
|
||||
|
||||
if (!mimeType) {
|
||||
console.error(` ✗ Unsupported file type: ${ext} (${filename})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
let content: Buffer;
|
||||
try {
|
||||
content = await readFile(resolvedPath);
|
||||
} catch {
|
||||
console.error(` ✗ Cannot read file: ${filePath}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const attachment = await store.addAttachment(task.id, filename, content, mimeType);
|
||||
const sizeKB = (attachment.size / 1024).toFixed(1);
|
||||
console.log(` 📎 Attached: ${attachment.originalName} (${sizeKB} KB)`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log();
|
||||
}
|
||||
|
||||
@@ -175,6 +205,14 @@ const MIME_TYPES: Record<string, string> = {
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".txt": "text/plain",
|
||||
".log": "text/plain",
|
||||
".json": "application/json",
|
||||
".yaml": "text/yaml",
|
||||
".yml": "text/yaml",
|
||||
".toml": "text/x-toml",
|
||||
".csv": "text/csv",
|
||||
".xml": "application/xml",
|
||||
};
|
||||
|
||||
export async function runTaskAttach(id: string, filePath: string) {
|
||||
|
||||
@@ -245,10 +245,29 @@ describe("TaskStore", () => {
|
||||
expect(content).toEqual(TINY_PNG);
|
||||
});
|
||||
|
||||
it("rejects non-image mime types", async () => {
|
||||
it("accepts text/plain mime type", async () => {
|
||||
const task = await createTestTask();
|
||||
const attachment = await store.addAttachment(task.id, "error.log", Buffer.from("log content"), "text/plain");
|
||||
expect(attachment.originalName).toBe("error.log");
|
||||
expect(attachment.mimeType).toBe("text/plain");
|
||||
});
|
||||
|
||||
it("accepts application/json mime type", async () => {
|
||||
const task = await createTestTask();
|
||||
const attachment = await store.addAttachment(task.id, "config.json", Buffer.from('{"key":"val"}'), "application/json");
|
||||
expect(attachment.mimeType).toBe("application/json");
|
||||
});
|
||||
|
||||
it("accepts text/yaml mime type", async () => {
|
||||
const task = await createTestTask();
|
||||
const attachment = await store.addAttachment(task.id, "config.yaml", Buffer.from("key: val"), "text/yaml");
|
||||
expect(attachment.mimeType).toBe("text/yaml");
|
||||
});
|
||||
|
||||
it("rejects unsupported mime types", async () => {
|
||||
const task = await createTestTask();
|
||||
await expect(
|
||||
store.addAttachment(task.id, "file.txt", Buffer.from("hello"), "text/plain"),
|
||||
store.addAttachment(task.id, "file.bin", Buffer.from("data"), "application/octet-stream"),
|
||||
).rejects.toThrow("Invalid mime type");
|
||||
});
|
||||
|
||||
|
||||
@@ -718,6 +718,12 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
"image/jpeg",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"text/plain",
|
||||
"application/json",
|
||||
"text/yaml",
|
||||
"text/x-toml",
|
||||
"text/csv",
|
||||
"application/xml",
|
||||
]);
|
||||
|
||||
private static MAX_ATTACHMENT_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { TaskCard } from "./TaskCard";
|
||||
import type { Task } from "@hai/core";
|
||||
|
||||
@@ -9,6 +9,14 @@ vi.mock("lucide-react", () => ({
|
||||
Clock: () => null,
|
||||
}));
|
||||
|
||||
// Mock the api module
|
||||
vi.mock("../api", () => ({
|
||||
fetchTaskDetail: vi.fn(),
|
||||
uploadAttachment: vi.fn(),
|
||||
}));
|
||||
|
||||
import { uploadAttachment } from "../api";
|
||||
|
||||
function makeTask(overrides: Partial<Task> = {}): Task {
|
||||
return {
|
||||
id: "HAI-001",
|
||||
@@ -63,4 +71,89 @@ describe("TaskCard", () => {
|
||||
);
|
||||
expect(container.querySelector(".card-status-badge")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows drop indicator on file dragover and removes on dragleave", () => {
|
||||
const { container } = render(
|
||||
<TaskCard task={makeTask()} onOpenDetail={noop} addToast={noop} />,
|
||||
);
|
||||
const card = container.querySelector(".card")!;
|
||||
|
||||
// Simulate file dragover
|
||||
fireEvent.dragOver(card, {
|
||||
dataTransfer: { types: ["Files"], dropEffect: "none" },
|
||||
});
|
||||
expect(card.classList.contains("file-drop-target")).toBe(true);
|
||||
|
||||
// Simulate dragleave
|
||||
fireEvent.dragLeave(card, {
|
||||
dataTransfer: { types: ["Files"] },
|
||||
});
|
||||
expect(card.classList.contains("file-drop-target")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not show drop indicator for non-file drag", () => {
|
||||
const { container } = render(
|
||||
<TaskCard task={makeTask()} onOpenDetail={noop} addToast={noop} />,
|
||||
);
|
||||
const card = container.querySelector(".card")!;
|
||||
|
||||
// Simulate card dragover (not files)
|
||||
fireEvent.dragOver(card, {
|
||||
dataTransfer: { types: ["text/plain"], dropEffect: "none" },
|
||||
});
|
||||
expect(card.classList.contains("file-drop-target")).toBe(false);
|
||||
});
|
||||
|
||||
it("calls uploadAttachment on file drop", async () => {
|
||||
const mockUpload = vi.mocked(uploadAttachment);
|
||||
mockUpload.mockResolvedValue({
|
||||
filename: "abc-test.png",
|
||||
originalName: "test.png",
|
||||
mimeType: "image/png",
|
||||
size: 1024,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
const addToast = vi.fn();
|
||||
|
||||
const { container } = render(
|
||||
<TaskCard task={makeTask()} onOpenDetail={noop} addToast={addToast} />,
|
||||
);
|
||||
const card = container.querySelector(".card")!;
|
||||
|
||||
const file = new File(["content"], "test.png", { type: "image/png" });
|
||||
fireEvent.drop(card, {
|
||||
dataTransfer: { types: ["Files"], files: [file] },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpload).toHaveBeenCalledWith("HAI-001", file);
|
||||
expect(addToast).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Attached test.png"),
|
||||
"success",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error toast when upload fails", async () => {
|
||||
const mockUpload = vi.mocked(uploadAttachment);
|
||||
mockUpload.mockRejectedValue(new Error("Upload failed"));
|
||||
const addToast = vi.fn();
|
||||
|
||||
const { container } = render(
|
||||
<TaskCard task={makeTask()} onOpenDetail={noop} addToast={addToast} />,
|
||||
);
|
||||
const card = container.querySelector(".card")!;
|
||||
|
||||
const file = new File(["content"], "bad.png", { type: "image/png" });
|
||||
fireEvent.drop(card, {
|
||||
dataTransfer: { types: ["Files"], files: [file] },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addToast).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Failed to attach bad.png"),
|
||||
"error",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { Link, Clock } from "lucide-react";
|
||||
import type { Task, TaskDetail, Column } from "@hai/core";
|
||||
import { fetchTaskDetail } from "../api";
|
||||
import { fetchTaskDetail, uploadAttachment } from "../api";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
|
||||
const COLUMN_COLOR_MAP: Record<Column, string> = {
|
||||
@@ -31,6 +31,7 @@ interface TaskCardProps {
|
||||
|
||||
export function TaskCard({ task, queued, onOpenDetail, addToast }: TaskCardProps) {
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [fileDragOver, setFileDragOver] = useState(false);
|
||||
|
||||
const handleDragStart = useCallback((e: React.DragEvent) => {
|
||||
e.dataTransfer.setData("text/plain", task.id);
|
||||
@@ -42,6 +43,42 @@ export function TaskCard({ task, queued, onOpenDetail, addToast }: TaskCardProps
|
||||
setDragging(false);
|
||||
}, []);
|
||||
|
||||
const isFileDrag = useCallback((e: React.DragEvent) => {
|
||||
return e.dataTransfer.types.includes("Files");
|
||||
}, []);
|
||||
|
||||
const handleFileDragOver = useCallback((e: React.DragEvent) => {
|
||||
if (!isFileDrag(e)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
setFileDragOver(true);
|
||||
}, [isFileDrag]);
|
||||
|
||||
const handleFileDragLeave = useCallback((e: React.DragEvent) => {
|
||||
if (!isFileDrag(e)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setFileDragOver(false);
|
||||
}, [isFileDrag]);
|
||||
|
||||
const handleFileDrop = useCallback(async (e: React.DragEvent) => {
|
||||
if (!isFileDrag(e)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setFileDragOver(false);
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
for (const file of files) {
|
||||
try {
|
||||
await uploadAttachment(task.id, file);
|
||||
addToast(`Attached ${file.name} to ${task.id}`, "success");
|
||||
} catch (err: any) {
|
||||
addToast(`Failed to attach ${file.name}: ${err.message}`, "error");
|
||||
}
|
||||
}
|
||||
}, [task.id, isFileDrag, addToast]);
|
||||
|
||||
const handleClick = useCallback(async () => {
|
||||
try {
|
||||
const detail = await fetchTaskDetail(task.id);
|
||||
@@ -53,7 +90,7 @@ export function TaskCard({ task, queued, onOpenDetail, addToast }: TaskCardProps
|
||||
|
||||
const isFailed = task.status === "failed";
|
||||
const isAgentActive = !queued && !isFailed && (task.column === "in-progress" || ACTIVE_STATUSES.has(task.status as string));
|
||||
const cardClass = `card${dragging ? " dragging" : ""}${queued ? " queued" : ""}${isAgentActive ? " agent-active" : ""}${isFailed ? " failed" : ""}`;
|
||||
const cardClass = `card${dragging ? " dragging" : ""}${queued ? " queued" : ""}${isAgentActive ? " agent-active" : ""}${isFailed ? " failed" : ""}${fileDragOver ? " file-drop-target" : ""}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -62,6 +99,9 @@ export function TaskCard({ task, queued, onOpenDetail, addToast }: TaskCardProps
|
||||
draggable={!queued}
|
||||
onDragStart={queued ? undefined : handleDragStart}
|
||||
onDragEnd={queued ? undefined : handleDragEnd}
|
||||
onDragOver={handleFileDragOver}
|
||||
onDragLeave={handleFileDragLeave}
|
||||
onDrop={handleFileDrop}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<div className="card-header">
|
||||
|
||||
@@ -206,6 +206,10 @@ html, body {
|
||||
}
|
||||
.card:active { cursor: grabbing; }
|
||||
.card.dragging { opacity: 0.4; transform: scale(0.98); }
|
||||
.card.file-drop-target {
|
||||
border: 2px dashed var(--todo);
|
||||
background: rgba(88, 166, 255, 0.08);
|
||||
}
|
||||
|
||||
/* Agent-active glow: animated border glow when an agent is actively working on a task.
|
||||
Uses the in-progress column color to stay consistent with the theme. */
|
||||
|
||||
@@ -27,13 +27,11 @@ vi.mock("node:fs", () => ({
|
||||
existsSync: vi.fn().mockReturnValue(true),
|
||||
}));
|
||||
|
||||
import { TaskExecutor } from "./executor.js";
|
||||
import { aiMergeTask } from "./merger.js";
|
||||
import { WorktreePool } from "./worktree-pool.js";
|
||||
import { TaskExecutor, buildExecutionPrompt } from "./executor.js";
|
||||
import { createHaiAgent } from "./pi.js";
|
||||
import { execSync } from "node:child_process";
|
||||
import { findWorktreeUser } from "./merger.js";
|
||||
import type { Column, Task } from "@hai/core";
|
||||
import type { Column, Task, TaskDetail } from "@hai/core";
|
||||
|
||||
const mockedCreateHaiAgent = vi.mocked(createHaiAgent);
|
||||
|
||||
@@ -622,3 +620,87 @@ describe("Merger worktree pool integration", () => {
|
||||
expect(removeCalls.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
function createMockTaskDetail(overrides: Partial<TaskDetail> = {}): TaskDetail {
|
||||
return {
|
||||
id: "HAI-001",
|
||||
title: "Test Task",
|
||||
description: "A test task",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildExecutionPrompt", () => {
|
||||
it("includes attachment section with absolute paths for image attachments", () => {
|
||||
const task = createMockTaskDetail({
|
||||
attachments: [
|
||||
{ filename: "abc123-screenshot.png", originalName: "screenshot.png", mimeType: "image/png", size: 2048, createdAt: new Date().toISOString() },
|
||||
],
|
||||
});
|
||||
const result = buildExecutionPrompt(task, "/home/user/project");
|
||||
|
||||
expect(result).toContain("## Attachments");
|
||||
expect(result).toContain("**screenshot.png** (screenshot)");
|
||||
expect(result).toContain("/home/user/project/.hai/tasks/HAI-001/attachments/abc123-screenshot.png");
|
||||
});
|
||||
|
||||
it("includes attachment section with absolute paths for text attachments", () => {
|
||||
const task = createMockTaskDetail({
|
||||
attachments: [
|
||||
{ filename: "def456-error.log", originalName: "error.log", mimeType: "text/plain", size: 512, createdAt: new Date().toISOString() },
|
||||
],
|
||||
});
|
||||
const result = buildExecutionPrompt(task, "/home/user/project");
|
||||
|
||||
expect(result).toContain("## Attachments");
|
||||
expect(result).toContain("**error.log** (text/plain)");
|
||||
expect(result).toContain("read for context");
|
||||
expect(result).toContain("/home/user/project/.hai/tasks/HAI-001/attachments/def456-error.log");
|
||||
});
|
||||
|
||||
it("includes both image and text attachments", () => {
|
||||
const task = createMockTaskDetail({
|
||||
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 = buildExecutionPrompt(task, "/home/user/project");
|
||||
|
||||
expect(result).toContain("**shot.png** (screenshot)");
|
||||
expect(result).toContain("**config.json** (application/json)");
|
||||
});
|
||||
|
||||
it("omits attachment section when no attachments", () => {
|
||||
const task = createMockTaskDetail({ attachments: [] });
|
||||
const result = buildExecutionPrompt(task, "/home/user/project");
|
||||
|
||||
expect(result).not.toContain("## Attachments");
|
||||
});
|
||||
|
||||
it("omits attachment section when attachments is undefined", () => {
|
||||
const task = createMockTaskDetail();
|
||||
const result = buildExecutionPrompt(task);
|
||||
|
||||
expect(result).not.toContain("## Attachments");
|
||||
});
|
||||
|
||||
it("omits attachment section when rootDir is not provided", () => {
|
||||
const task = createMockTaskDetail({
|
||||
attachments: [
|
||||
{ filename: "abc.png", originalName: "test.png", mimeType: "image/png", size: 1024, createdAt: new Date().toISOString() },
|
||||
],
|
||||
});
|
||||
const result = buildExecutionPrompt(task);
|
||||
|
||||
expect(result).not.toContain("## Attachments");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -325,7 +325,7 @@ export class TaskExecutor {
|
||||
});
|
||||
|
||||
try {
|
||||
const agentPrompt = buildExecutionPrompt(detail);
|
||||
const agentPrompt = buildExecutionPrompt(detail, this.rootDir);
|
||||
await session.prompt(agentPrompt);
|
||||
|
||||
if (taskDone) {
|
||||
@@ -569,7 +569,7 @@ export class TaskExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
function buildExecutionPrompt(task: TaskDetail): string {
|
||||
export function buildExecutionPrompt(task: TaskDetail, rootDir?: string): string {
|
||||
const reviewMatch = task.prompt.match(/##\s*Review Level[:\s]*(\d)/);
|
||||
const reviewLevel = reviewMatch ? parseInt(reviewMatch[1], 10) : 0;
|
||||
|
||||
@@ -601,6 +601,22 @@ git log --oneline
|
||||
`;
|
||||
}
|
||||
|
||||
// Build attachments section
|
||||
let attachmentsSection = "";
|
||||
if (task.attachments && task.attachments.length > 0 && rootDir) {
|
||||
const IMAGE_MIMES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
|
||||
const lines = ["## Attachments", ""];
|
||||
for (const att of task.attachments) {
|
||||
const absPath = `${rootDir}/.hai/tasks/${task.id}/attachments/${att.filename}`;
|
||||
if (IMAGE_MIMES.has(att.mimeType)) {
|
||||
lines.push(`- **${att.originalName}** (screenshot): \`${absPath}\``);
|
||||
} else {
|
||||
lines.push(`- **${att.originalName}** (${att.mimeType}): \`${absPath}\` — read for context`);
|
||||
}
|
||||
}
|
||||
attachmentsSection = "\n" + lines.join("\n") + "\n";
|
||||
}
|
||||
|
||||
return `Execute this task.
|
||||
|
||||
## Task: ${task.id}
|
||||
@@ -610,7 +626,7 @@ ${task.dependencies.length > 0 ? `Dependencies: ${task.dependencies.join(", ")}`
|
||||
## PROMPT.md
|
||||
|
||||
${task.prompt}
|
||||
${progressSection}
|
||||
${attachmentsSection}${progressSection}
|
||||
## Review level: ${reviewLevel}
|
||||
|
||||
${reviewLevel === 0 ? "No reviews required. Implement directly." : ""}
|
||||
|
||||
@@ -6,7 +6,7 @@ vi.mock("./pi.js", () => ({
|
||||
createHaiAgent: vi.fn(),
|
||||
}));
|
||||
|
||||
import { TriageProcessor, buildSpecificationPrompt } from "./triage.js";
|
||||
import { TriageProcessor, buildSpecificationPrompt, type AttachmentContent } from "./triage.js";
|
||||
import { createHaiAgent } from "./pi.js";
|
||||
import type { TaskDetail } from "@hai/core";
|
||||
|
||||
@@ -282,4 +282,55 @@ describe("buildSpecificationPrompt", () => {
|
||||
|
||||
expect(result).not.toContain("## Project Commands");
|
||||
});
|
||||
|
||||
it("includes text attachment content in fenced code block", () => {
|
||||
const task = createMockTaskDetail();
|
||||
const attachmentContents: AttachmentContent[] = [
|
||||
{ originalName: "error.log", mimeType: "text/plain", text: "ERROR: something broke\nStack trace here" },
|
||||
];
|
||||
const result = buildSpecificationPrompt(task, ".hai/tasks/HAI-001/PROMPT.md", undefined, attachmentContents);
|
||||
|
||||
expect(result).toContain("## Attachments");
|
||||
expect(result).toContain("### error.log (text/plain)");
|
||||
expect(result).toContain("```\nERROR: something broke\nStack trace here\n```");
|
||||
});
|
||||
|
||||
it("includes image attachment reference in prompt", () => {
|
||||
const task = createMockTaskDetail();
|
||||
const attachmentContents: AttachmentContent[] = [
|
||||
{ originalName: "screenshot.png", mimeType: "image/png", text: null },
|
||||
];
|
||||
const result = buildSpecificationPrompt(task, ".hai/tasks/HAI-001/PROMPT.md", undefined, attachmentContents);
|
||||
|
||||
expect(result).toContain("## Attachments");
|
||||
expect(result).toContain("**screenshot.png** (image/png)");
|
||||
expect(result).toContain("included as image below");
|
||||
});
|
||||
|
||||
it("includes both image and text attachments", () => {
|
||||
const task = createMockTaskDetail();
|
||||
const attachmentContents: AttachmentContent[] = [
|
||||
{ originalName: "screenshot.png", mimeType: "image/png", text: null },
|
||||
{ originalName: "config.json", mimeType: "application/json", text: '{"key": "value"}' },
|
||||
];
|
||||
const result = buildSpecificationPrompt(task, ".hai/tasks/HAI-001/PROMPT.md", undefined, attachmentContents);
|
||||
|
||||
expect(result).toContain("**screenshot.png** (image/png)");
|
||||
expect(result).toContain("### config.json (application/json)");
|
||||
expect(result).toContain('{"key": "value"}');
|
||||
});
|
||||
|
||||
it("omits attachments section when no attachments", () => {
|
||||
const task = createMockTaskDetail();
|
||||
const result = buildSpecificationPrompt(task, ".hai/tasks/HAI-001/PROMPT.md", undefined, []);
|
||||
|
||||
expect(result).not.toContain("## Attachments");
|
||||
});
|
||||
|
||||
it("omits attachments section when attachmentContents is undefined", () => {
|
||||
const task = createMockTaskDetail();
|
||||
const result = buildSpecificationPrompt(task, ".hai/tasks/HAI-001/PROMPT.md");
|
||||
|
||||
expect(result).not.toContain("## Attachments");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { TaskStore, Task, TaskDetail, Settings } from "@hai/core";
|
||||
import { Type, type Static } from "@mariozechner/pi-ai";
|
||||
import type { TaskStore, Task, TaskDetail, TaskAttachment, Settings } from "@hai/core";
|
||||
import type { ImageContent } from "@mariozechner/pi-ai";
|
||||
import { Type } from "@mariozechner/pi-ai";
|
||||
import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
|
||||
import { createHaiAgent } from "./pi.js";
|
||||
import type { AgentSemaphore } from "./concurrency.js";
|
||||
@@ -260,8 +261,13 @@ export class TriageProcessor {
|
||||
});
|
||||
|
||||
try {
|
||||
const agentPrompt = buildSpecificationPrompt(detail, promptPath, settings);
|
||||
await session.prompt(agentPrompt);
|
||||
// Read attachment contents for inlining in prompt
|
||||
const { attachmentContents, imageContents } = await readAttachmentContents(
|
||||
this.rootDir, detail.id, detail.attachments,
|
||||
);
|
||||
|
||||
const agentPrompt = buildSpecificationPrompt(detail, promptPath, settings, attachmentContents);
|
||||
await session.prompt(agentPrompt, imageContents.length > 0 ? { images: imageContents } : undefined);
|
||||
|
||||
// Check if the agent flagged a duplicate
|
||||
const { readFile } = await import("node:fs/promises");
|
||||
@@ -372,7 +378,73 @@ export class TriageProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
export function buildSpecificationPrompt(task: TaskDetail, promptPath: string, settings?: Settings): string {
|
||||
/** Content read from an attachment file for inlining in the prompt. */
|
||||
export interface AttachmentContent {
|
||||
originalName: string;
|
||||
mimeType: string;
|
||||
/** Text content for text files, null for images (handled via image content blocks). */
|
||||
text: string | null;
|
||||
}
|
||||
|
||||
const IMAGE_MIME_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
|
||||
const TEXT_INLINE_LIMIT = 50 * 1024; // 50KB
|
||||
|
||||
/**
|
||||
* Read attachment files from disk, returning text contents for inlining
|
||||
* and image contents for pi image content blocks.
|
||||
*/
|
||||
export async function readAttachmentContents(
|
||||
rootDir: string,
|
||||
taskId: string,
|
||||
attachments?: TaskAttachment[],
|
||||
): Promise<{ attachmentContents: AttachmentContent[]; imageContents: ImageContent[] }> {
|
||||
const attachmentContents: AttachmentContent[] = [];
|
||||
const imageContents: ImageContent[] = [];
|
||||
|
||||
if (!attachments || attachments.length === 0) {
|
||||
return { attachmentContents, imageContents };
|
||||
}
|
||||
|
||||
const { readFile } = await import("node:fs/promises");
|
||||
const { join } = await import("node:path");
|
||||
|
||||
for (const att of attachments) {
|
||||
const filePath = join(rootDir, ".hai", "tasks", taskId, "attachments", att.filename);
|
||||
|
||||
try {
|
||||
if (IMAGE_MIME_TYPES.has(att.mimeType)) {
|
||||
const data = await readFile(filePath);
|
||||
imageContents.push({
|
||||
type: "image",
|
||||
data: data.toString("base64"),
|
||||
mimeType: att.mimeType,
|
||||
});
|
||||
attachmentContents.push({
|
||||
originalName: att.originalName,
|
||||
mimeType: att.mimeType,
|
||||
text: null,
|
||||
});
|
||||
} else {
|
||||
const data = await readFile(filePath, "utf-8");
|
||||
const text = data.length > TEXT_INLINE_LIMIT
|
||||
? data.slice(0, TEXT_INLINE_LIMIT) + "\n... (truncated at 50KB)"
|
||||
: data;
|
||||
attachmentContents.push({
|
||||
originalName: att.originalName,
|
||||
mimeType: att.mimeType,
|
||||
text,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Skip unreadable attachments
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return { attachmentContents, imageContents };
|
||||
}
|
||||
|
||||
export function buildSpecificationPrompt(task: TaskDetail, promptPath: string, settings?: Settings, attachmentContents?: AttachmentContent[]): string {
|
||||
let commandsSection = "";
|
||||
if (settings?.testCommand || settings?.buildCommand) {
|
||||
const lines = ["## Project Commands"];
|
||||
@@ -382,6 +454,20 @@ export function buildSpecificationPrompt(task: TaskDetail, promptPath: string, s
|
||||
commandsSection = "\n\n" + lines.join("\n");
|
||||
}
|
||||
|
||||
let attachmentsSection = "";
|
||||
if (attachmentContents && attachmentContents.length > 0) {
|
||||
const parts = ["## Attachments", ""];
|
||||
for (const att of attachmentContents) {
|
||||
if (att.text === null) {
|
||||
// Image — will be passed via image content blocks
|
||||
parts.push(`- **${att.originalName}** (${att.mimeType}) — included as image below`);
|
||||
} else {
|
||||
parts.push(`### ${att.originalName} (${att.mimeType})\n\n\`\`\`\n${att.text}\n\`\`\``);
|
||||
}
|
||||
}
|
||||
attachmentsSection = "\n\n" + parts.join("\n");
|
||||
}
|
||||
|
||||
return `Specify this task and write the result to \`${promptPath}\`.
|
||||
|
||||
## Task
|
||||
@@ -396,5 +482,5 @@ ${task.dependencies.length > 0 ? `- **Dependencies:** ${task.dependencies.join("
|
||||
3. The specification must be detailed enough for an autonomous AI agent to implement without asking questions
|
||||
4. Name actual files, functions, and patterns from the codebase — be specific
|
||||
|
||||
Use the write tool to write the specification file.${commandsSection}`;
|
||||
Use the write tool to write the specification file.${commandsSection}${attachmentsSection}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user