feat(background-agent): add task output file writer with atomic writes

- writeTaskOutput() writes session transcript to .sisyphus/task-outputs/
- YAML frontmatter with task metadata
- Atomic write via temp file + rename
- Returns null on failure (never throws)
- 8/8 tests passing
This commit is contained in:
ismeth
2026-02-27 15:07:02 +01:00
committed by YeonGyu-Kim
parent b7552f13ac
commit 116b8b4edd
2 changed files with 307 additions and 0 deletions
@@ -0,0 +1,218 @@
import { describe, expect, it, beforeEach, afterEach } from "bun:test"
import { mkdir, rm, readFile } from "node:fs/promises"
import { existsSync } from "node:fs"
import type { BackgroundTask } from "./types"
import type { BackgroundOutputClient } from "../../tools/background-task/clients"
import { writeTaskOutput } from "./task-output-writer"
const TEST_OUTPUT_DIR = ".sisyphus/task-outputs"
function createMockClient(
messages: Array<{ role: string; time: string; parts: Array<{ type: string; text: string }> }>,
): BackgroundOutputClient {
return {
session: {
messages: async () =>
messages.map((m, i) => ({
id: `msg_${i}`,
info: { role: m.role, time: m.time },
parts: m.parts,
})),
},
}
}
function createErrorClient(errorMessage: string): BackgroundOutputClient {
return {
session: {
messages: async () => ({ data: undefined, error: errorMessage }),
},
}
}
function createMockTask(overrides: Partial<BackgroundTask> = {}): BackgroundTask {
return {
id: "bg_test_123",
parentSessionID: "parent-session-456",
parentMessageID: "parent-msg-789",
description: "Test background task",
prompt: "test prompt",
agent: "oracle",
status: "completed",
sessionID: "session-abc",
completedAt: new Date("2026-02-27T10:30:00Z"),
...overrides,
} as BackgroundTask
}
describe("writeTaskOutput", () => {
beforeEach(async () => {
if (existsSync(TEST_OUTPUT_DIR)) {
await rm(TEST_OUTPUT_DIR, { recursive: true })
}
})
afterEach(async () => {
if (existsSync(TEST_OUTPUT_DIR)) {
await rm(TEST_OUTPUT_DIR, { recursive: true })
}
})
describe("#given a completed task with session messages", () => {
it("#then writes file with correct YAML frontmatter and transcript", async () => {
const client = createMockClient([
{
role: "user",
time: "2026-02-27T10:00:00Z",
parts: [{ type: "text", text: "Analyze the codebase" }],
},
{
role: "assistant",
time: "2026-02-27T10:01:00Z",
parts: [{ type: "text", text: "I'll analyze the codebase now." }],
},
])
const task = createMockTask()
const result = await writeTaskOutput(task, client)
expect(result).toBe(`${TEST_OUTPUT_DIR}/bg_test_123.md`)
expect(existsSync(result!)).toBe(true)
const content = await readFile(result!, "utf-8")
expect(content).toContain("---")
expect(content).toContain("task_id: bg_test_123")
expect(content).toContain("agent: oracle")
expect(content).toContain("session_id: session-abc")
expect(content).toContain("parent_session_id: parent-session-456")
expect(content).toContain("status: completed")
expect(content).toContain("completed_at: 2026-02-27T10:30:00.000Z")
expect(content).toContain("[user] 2026-02-27T10:00:00.000Z")
expect(content).toContain("Analyze the codebase")
expect(content).toContain("[assistant] 2026-02-27T10:01:00.000Z")
expect(content).toContain("I'll analyze the codebase now.")
})
})
describe("#given a task with no sessionID", () => {
it("#then returns null without writing any file", async () => {
const client = createMockClient([])
const task = createMockTask({ sessionID: undefined })
const result = await writeTaskOutput(task, client)
expect(result).toBeNull()
expect(existsSync(`${TEST_OUTPUT_DIR}/bg_test_123.md`)).toBe(false)
})
})
describe("#given session messages return an error", () => {
it("#then returns null without writing any file", async () => {
const client = createErrorClient("Session not found")
const task = createMockTask()
const result = await writeTaskOutput(task, client)
expect(result).toBeNull()
expect(existsSync(`${TEST_OUTPUT_DIR}/bg_test_123.md`)).toBe(false)
})
})
describe("#given empty session messages", () => {
it("#then writes file with frontmatter and empty transcript", async () => {
const client = createMockClient([])
const task = createMockTask()
const result = await writeTaskOutput(task, client)
expect(result).toBe(`${TEST_OUTPUT_DIR}/bg_test_123.md`)
expect(existsSync(result!)).toBe(true)
const content = await readFile(result!, "utf-8")
expect(content).toContain("task_id: bg_test_123")
expect(content).toContain("agent: oracle")
const afterFrontmatter = content.split("---")[2]
expect(afterFrontmatter.trim()).toBe("")
})
})
describe("#given the output directory does not exist", () => {
it("#then creates the directory and writes the file", async () => {
expect(existsSync(TEST_OUTPUT_DIR)).toBe(false)
const client = createMockClient([
{
role: "assistant",
time: "2026-02-27T10:00:00Z",
parts: [{ type: "text", text: "Done." }],
},
])
const task = createMockTask()
const result = await writeTaskOutput(task, client)
expect(result).toBe(`${TEST_OUTPUT_DIR}/bg_test_123.md`)
expect(existsSync(TEST_OUTPUT_DIR)).toBe(true)
expect(existsSync(result!)).toBe(true)
})
})
describe("#given messages with reasoning parts", () => {
it("#then includes reasoning text in the transcript", async () => {
const client = createMockClient([
{
role: "assistant",
time: "2026-02-27T10:00:00Z",
parts: [
{ type: "reasoning", text: "Let me think about this..." },
{ type: "text", text: "Here is my answer." },
],
},
])
const task = createMockTask()
const result = await writeTaskOutput(task, client)
const content = await readFile(result!, "utf-8")
expect(content).toContain("Let me think about this...")
expect(content).toContain("Here is my answer.")
})
})
describe("#given messages are not in chronological order", () => {
it("#then sorts messages by time in the transcript", async () => {
const client = createMockClient([
{
role: "assistant",
time: "2026-02-27T10:02:00Z",
parts: [{ type: "text", text: "Second message" }],
},
{
role: "user",
time: "2026-02-27T10:00:00Z",
parts: [{ type: "text", text: "First message" }],
},
])
const task = createMockTask()
const result = await writeTaskOutput(task, client)
const content = await readFile(result!, "utf-8")
const firstIdx = content.indexOf("First message")
const secondIdx = content.indexOf("Second message")
expect(firstIdx).toBeLessThan(secondIdx)
})
})
describe("#given a task with no completedAt", () => {
it("#then writes 'unknown' for completed_at in frontmatter", async () => {
const client = createMockClient([])
const task = createMockTask({ completedAt: undefined })
const result = await writeTaskOutput(task, client)
const content = await readFile(result!, "utf-8")
expect(content).toContain("completed_at: unknown")
})
})
})
@@ -0,0 +1,89 @@
import { mkdir, writeFile, rename } from "node:fs/promises"
import { dirname } from "node:path"
import { log } from "../../shared"
import type { BackgroundTask } from "./types"
import type { BackgroundOutputClient } from "../../tools/background-task/clients"
import { extractMessages, getErrorMessage } from "../../tools/background-task/session-messages"
import { formatMessageTime } from "../../tools/background-task/time-format"
const OUTPUT_DIR = ".sisyphus/task-outputs"
function formatFrontmatter(task: BackgroundTask): string {
const lines = [
"---",
`task_id: ${task.id}`,
`agent: ${task.agent}`,
`session_id: ${task.sessionID}`,
`parent_session_id: ${task.parentSessionID}`,
`status: ${task.status}`,
`completed_at: ${task.completedAt?.toISOString() ?? "unknown"}`,
"---",
]
return lines.join("\n")
}
function formatTranscript(
messages: Array<{ info?: { role?: string; time?: string | { created?: number } }; parts?: Array<{ type?: string; text?: string }> }>,
): string {
const sorted = [...messages].sort((a, b) => {
const timeA = String(a.info?.time ?? "")
const timeB = String(b.info?.time ?? "")
return timeA.localeCompare(timeB)
})
const lines: string[] = []
for (const message of sorted) {
const role = message.info?.role ?? "unknown"
const time = formatMessageTime(message.info?.time)
lines.push(`[${role}] ${time}`)
for (const part of message.parts ?? []) {
if ((part.type === "text" || part.type === "reasoning") && part.text) {
lines.push(part.text.trim())
}
}
lines.push("")
}
return lines.join("\n")
}
export async function writeTaskOutput(
task: BackgroundTask,
client: BackgroundOutputClient,
): Promise<string | null> {
if (!task.sessionID) {
return null
}
try {
const messagesResult = await client.session.messages({
path: { id: task.sessionID },
})
const errorMessage = getErrorMessage(messagesResult)
if (errorMessage) {
log(`[task-output-writer] Error fetching session messages for task ${task.id}: ${errorMessage}`)
return null
}
const messages = extractMessages(messagesResult)
const frontmatter = formatFrontmatter(task)
const transcript = formatTranscript(messages)
const content = `${frontmatter}\n\n${transcript}`
const outputPath = `${OUTPUT_DIR}/${task.id}.md`
const tmpPath = `${outputPath}.tmp`
await mkdir(dirname(outputPath), { recursive: true })
await writeFile(tmpPath, content, "utf-8")
await rename(tmpPath, outputPath)
return outputPath
} catch (error) {
log(`[task-output-writer] Failed to write output for task ${task.id}: ${error}`)
return null
}
}