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:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user