diff --git a/src/tools/background-task/constants.ts b/src/tools/background-task/constants.ts index 214d2a8e0..91e746f54 100644 --- a/src/tools/background-task/constants.ts +++ b/src/tools/background-task/constants.ts @@ -6,6 +6,6 @@ export const BACKGROUND_OUTPUT_DESCRIPTION = `Get output from background task. U IMPORTANT: ONLY call this tool AFTER receiving a notification for the task. Do NOT call immediately after launching a background task - wait for the notification first.` -export const BACKGROUND_WAIT_DESCRIPTION = `Wait for the next background task to complete from a set of task IDs. Returns as soon as ANY one finishes, with its result and a progress summary. Call repeatedly with remaining IDs until all are done.` +export const BACKGROUND_WAIT_DESCRIPTION = `Wait for the next background task to complete from a set of task IDs. Returns as soon as ANY one finishes, with metadata-only completed_tasks array (task_id, description, status, duration_s, session_id, output_file_path). Use background_output to fetch full results. Call repeatedly with remaining IDs until all are done.` export const BACKGROUND_CANCEL_DESCRIPTION = `Cancel running background task(s). Use all=true to cancel ALL before final answer.` diff --git a/src/tools/background-task/create-background-wait.test.ts b/src/tools/background-task/create-background-wait.test.ts index ef7b717e9..815ac2aa4 100644 --- a/src/tools/background-task/create-background-wait.test.ts +++ b/src/tools/background-task/create-background-wait.test.ts @@ -2,7 +2,7 @@ import { describe, test, expect, beforeEach } from "bun:test" import type { BackgroundTask } from "../../features/background-agent" -import type { BackgroundOutputClient, BackgroundOutputManager } from "./clients" +import type { BackgroundOutputManager } from "./clients" import { createBackgroundWait } from "./create-background-wait" import { resetMessageCursor } from "../../shared/session-cursor" @@ -26,18 +26,6 @@ function createMockManager(tasks: Record>): Back } } -function createMockClient(responseText = "Test result content"): BackgroundOutputClient { - return { - session: { - messages: async () => [{ - id: "msg_1", - info: { role: "assistant", time: new Date().toISOString() }, - parts: [{ type: "text", text: responseText }], - }], - }, - } -} - const toolContext = { sessionID: "test-session", messageID: "test-message", @@ -52,10 +40,9 @@ describe("createBackgroundWait", () => { describe("#given empty task_ids", () => { describe("#when execute is called with empty array", () => { - test("#then returns JSON with error and empty members", async () => { + test("#then returns JSON with error and empty completed_tasks", async () => { const manager = createMockManager({}) - const client = createMockClient() - const tool = createBackgroundWait(manager, client) + const tool = createBackgroundWait(manager) const result = await tool.execute({ task_ids: [] }, toolContext) @@ -63,17 +50,16 @@ describe("createBackgroundWait", () => { expect(parsed.error).toBe("task_ids array is required and must not be empty.") expect(parsed.members).toEqual([]) expect(parsed.remaining_task_ids).toEqual([]) - expect(parsed.completed_task).toBeNull() + expect(parsed.completed_tasks).toEqual([]) expect(parsed.timeout).toBe(false) expect(parsed.aborted).toBe(false) }) }) }) - describe("#given a completed council task", () => { + describe("#given a completed task", () => { describe("#when execute is called with that task_id", () => { - test("#then returns JSON with council-specific fields", async () => { - const councilResponse = "Council verdict here" + test("#then returns metadata-only completed_tasks array with no result field", async () => { const manager = createMockManager({ "task-1": { status: "completed", @@ -83,19 +69,21 @@ describe("createBackgroundWait", () => { completedAt: new Date(), }, }) - const client = createMockClient(councilResponse) - const tool = createBackgroundWait(manager, client) + const tool = createBackgroundWait(manager) const result = await tool.execute({ task_ids: ["task-1"] }, toolContext) const parsed = JSON.parse(result) - expect(parsed.completed_task).toBeDefined() - expect(parsed.completed_task.task_id).toBe("task-1") - expect(parsed.completed_task.status).toBe("completed") - expect(parsed.completed_task.has_response).toBe(true) - expect(parsed.completed_task.response_complete).toBe(true) - expect(parsed.completed_task.result).toBe("Council verdict here") - expect(parsed.completed_task.duration_s).toBeTypeOf("number") + expect(parsed.completed_tasks).toBeArray() + expect(parsed.completed_tasks).toHaveLength(1) + expect(parsed.completed_tasks[0].task_id).toBe("task-1") + expect(parsed.completed_tasks[0].status).toBe("completed") + expect(parsed.completed_tasks[0].description).toBe("Council analysis") + expect(parsed.completed_tasks[0].duration_s).toBeTypeOf("number") + expect(parsed.completed_tasks[0].session_id).toBeDefined() + expect(parsed.completed_tasks[0].result).toBeUndefined() + expect(parsed.completed_tasks[0].has_response).toBeUndefined() + expect(parsed.completed_tasks[0].response_complete).toBeUndefined() expect(parsed.timeout).toBe(false) expect(parsed.aborted).toBe(false) }) @@ -104,7 +92,7 @@ describe("createBackgroundWait", () => { describe("#given a completed non-council task", () => { describe("#when execute is called with that task_id", () => { - test("#then returns JSON with result as string and no council fields", async () => { + test("#then returns metadata-only entry with no result field", async () => { const manager = createMockManager({ "task-2": { status: "completed", @@ -114,19 +102,19 @@ describe("createBackgroundWait", () => { completedAt: new Date(), }, }) - const client = createMockClient("Exploration complete: found 5 files") - const tool = createBackgroundWait(manager, client) + const tool = createBackgroundWait(manager) const result = await tool.execute({ task_ids: ["task-2"] }, toolContext) const parsed = JSON.parse(result) - expect(parsed.completed_task).toBeDefined() - expect(parsed.completed_task.task_id).toBe("task-2") - expect(parsed.completed_task.status).toBe("completed") - expect(parsed.completed_task.result).toBeTypeOf("string") - expect(parsed.completed_task.result).toContain("Exploration complete") - expect(parsed.completed_task.has_response).toBeUndefined() - expect(parsed.completed_task.response_complete).toBeUndefined() + expect(parsed.completed_tasks).toBeArray() + expect(parsed.completed_tasks).toHaveLength(1) + expect(parsed.completed_tasks[0].task_id).toBe("task-2") + expect(parsed.completed_tasks[0].status).toBe("completed") + expect(parsed.completed_tasks[0].description).toBe("Explore codebase") + expect(parsed.completed_tasks[0].result).toBeUndefined() + expect(parsed.completed_tasks[0].has_response).toBeUndefined() + expect(parsed.completed_tasks[0].response_complete).toBeUndefined() expect(parsed.timeout).toBe(false) }) }) @@ -134,7 +122,7 @@ describe("createBackgroundWait", () => { describe("#given an error task", () => { describe("#when execute is called with that task_id", () => { - test("#then returns JSON with error field on completed_task", async () => { + test("#then returns completed_tasks entry with error field and no result", async () => { const manager = createMockManager({ "task-3": { status: "error", @@ -144,17 +132,17 @@ describe("createBackgroundWait", () => { startedAt: new Date(Date.now() - 2000), }, }) - const client = createMockClient() - const tool = createBackgroundWait(manager, client) + const tool = createBackgroundWait(manager) const result = await tool.execute({ task_ids: ["task-3"] }, toolContext) const parsed = JSON.parse(result) - expect(parsed.completed_task).toBeDefined() - expect(parsed.completed_task.task_id).toBe("task-3") - expect(parsed.completed_task.status).toBe("error") - expect(parsed.completed_task.error).toBe("Model failed") - expect(parsed.completed_task.result).toBeUndefined() + expect(parsed.completed_tasks).toBeArray() + expect(parsed.completed_tasks).toHaveLength(1) + expect(parsed.completed_tasks[0].task_id).toBe("task-3") + expect(parsed.completed_tasks[0].status).toBe("error") + expect(parsed.completed_tasks[0].error).toBe("Model failed") + expect(parsed.completed_tasks[0].result).toBeUndefined() expect(parsed.timeout).toBe(false) }) }) @@ -166,8 +154,7 @@ describe("createBackgroundWait", () => { const manager = createMockManager({ "known-task": { status: "completed", agent: "explore" }, }) - const client = createMockClient() - const tool = createBackgroundWait(manager, client) + const tool = createBackgroundWait(manager) const result = await tool.execute({ task_ids: ["known-task", "unknown-task"] }, toolContext) @@ -181,7 +168,7 @@ describe("createBackgroundWait", () => { describe("#given a running task with session state and progress", () => { describe("#when that task plus a completed task are waited on", () => { - test("#then completed task returns immediately and running task appears in members with session_state", async () => { + test("#then completed task appears in completed_tasks and running task in members with session_state", async () => { const now = new Date() const manager = createMockManager({ "running-task": { @@ -199,13 +186,14 @@ describe("createBackgroundWait", () => { completedAt: now, }, }) - const client = createMockClient("Done result") - const tool = createBackgroundWait(manager, client) + const tool = createBackgroundWait(manager) const result = await tool.execute({ task_ids: ["running-task", "done-task"] }, toolContext) const parsed = JSON.parse(result) - expect(parsed.completed_task.task_id).toBe("done-task") + expect(parsed.completed_tasks).toBeArray() + expect(parsed.completed_tasks).toHaveLength(1) + expect(parsed.completed_tasks[0].task_id).toBe("done-task") const runningMember = parsed.members.find((m: Record) => m.task_id === "running-task") expect(runningMember).toBeDefined() @@ -224,13 +212,14 @@ describe("createBackgroundWait", () => { "t2": { status: "completed", agent: "explore", startedAt: new Date() }, "t3": { status: "running", agent: "oracle" }, }) - const client = createMockClient("Result from t2") - const tool = createBackgroundWait(manager, client) + const tool = createBackgroundWait(manager) const result = await tool.execute({ task_ids: ["t1", "t2", "t3"] }, toolContext) const parsed = JSON.parse(result) - expect(parsed.completed_task.task_id).toBe("t2") + expect(parsed.completed_tasks).toBeArray() + expect(parsed.completed_tasks).toHaveLength(1) + expect(parsed.completed_tasks[0].task_id).toBe("t2") expect(parsed.remaining_task_ids).toContain("t1") expect(parsed.remaining_task_ids).toContain("t3") expect(parsed.remaining_task_ids).not.toContain("t2") @@ -242,16 +231,62 @@ describe("createBackgroundWait", () => { }) }) + describe("#given a task with outputFilePath", () => { + describe("#when execute is called with that task_id", () => { + test("#then completed_tasks entry includes output_file_path", async () => { + const manager = createMockManager({ + "task-out": { + status: "completed", + agent: "explore", + description: "Task with output", + startedAt: new Date(Date.now() - 1000), + completedAt: new Date(), + outputFilePath: "/tmp/output-task-out.md", + }, + }) + const tool = createBackgroundWait(manager) + + const result = await tool.execute({ task_ids: ["task-out"] }, toolContext) + + const parsed = JSON.parse(result) + expect(parsed.completed_tasks[0].output_file_path).toBe("/tmp/output-task-out.md") + }) + }) + }) + + describe("#given multiple terminal tasks", () => { + describe("#when execute is called with all task_ids", () => { + test("#then completed_tasks contains ALL terminal tasks", async () => { + const manager = createMockManager({ + "t1": { status: "completed", agent: "explore", startedAt: new Date(), completedAt: new Date() }, + "t2": { status: "error", agent: "oracle", error: "failed", startedAt: new Date() }, + "t3": { status: "running", agent: "explore" }, + }) + const tool = createBackgroundWait(manager) + + const result = await tool.execute({ task_ids: ["t1", "t2", "t3"] }, toolContext) + + const parsed = JSON.parse(result) + expect(parsed.completed_tasks).toBeArray() + expect(parsed.completed_tasks).toHaveLength(2) + const ids = parsed.completed_tasks.map((t: Record) => t.task_id) + expect(ids).toContain("t1") + expect(ids).toContain("t2") + expect(parsed.remaining_task_ids).toEqual(["t3"]) + }) + }) + }) + describe("#given all results from every code path", () => { describe("#when JSON.parse is applied to each result", () => { - test("#then every result is valid JSON", async () => { + test("#then every result is valid JSON with completed_tasks array", async () => { const now = new Date() const results: string[] = [] - const emptyTool = createBackgroundWait(createMockManager({}), createMockClient()) + const emptyTool = createBackgroundWait(createMockManager({})) results.push(await emptyTool.execute({ task_ids: [] }, toolContext)) - const councilTool = createBackgroundWait( + const completedTool = createBackgroundWait( createMockManager({ "c1": { status: "completed", @@ -260,12 +295,11 @@ describe("createBackgroundWait", () => { completedAt: now, }, }), - createMockClient("verdict"), ) - results.push(await councilTool.execute({ task_ids: ["c1"] }, toolContext)) + results.push(await completedTool.execute({ task_ids: ["c1"] }, toolContext)) resetMessageCursor() - const nonCouncilTool = createBackgroundWait( + const exploreTool = createBackgroundWait( createMockManager({ "nc1": { status: "completed", @@ -274,30 +308,29 @@ describe("createBackgroundWait", () => { completedAt: now, }, }), - createMockClient("result text"), ) - results.push(await nonCouncilTool.execute({ task_ids: ["nc1"] }, toolContext)) + results.push(await exploreTool.execute({ task_ids: ["nc1"] }, toolContext)) const errorTool = createBackgroundWait( createMockManager({ "e1": { status: "error", error: "boom" } }), - createMockClient(), ) results.push(await errorTool.execute({ task_ids: ["e1"] }, toolContext)) const abortController = new AbortController() abortController.abort() const abortedContext = { ...toolContext, abort: abortController.signal } - const notFoundTool = createBackgroundWait(createMockManager({}), createMockClient()) + const notFoundTool = createBackgroundWait(createMockManager({})) results.push(await notFoundTool.execute({ task_ids: ["missing"] }, abortedContext)) const cancelledTool = createBackgroundWait( createMockManager({ "x1": { status: "cancelled", agent: "explore" } }), - createMockClient(), ) results.push(await cancelledTool.execute({ task_ids: ["x1"] }, toolContext)) for (const result of results) { - expect(() => JSON.parse(result)).not.toThrow() + const parsed = JSON.parse(result) + expect(parsed).toBeDefined() + expect(Array.isArray(parsed.completed_tasks)).toBe(true) } }) }) diff --git a/src/tools/background-task/create-background-wait.ts b/src/tools/background-task/create-background-wait.ts index 7f4037e23..9b7891d7e 100644 --- a/src/tools/background-task/create-background-wait.ts +++ b/src/tools/background-task/create-background-wait.ts @@ -1,9 +1,7 @@ import { tool, type ToolDefinition } from "@opencode-ai/plugin" -import type { BackgroundOutputManager, BackgroundOutputClient } from "./clients" +import type { BackgroundOutputManager } from "./clients" import { BACKGROUND_WAIT_DESCRIPTION } from "./constants" -import { formatCouncilTaskResult, isCouncilTask } from "./council-result-format" import { delay } from "./delay" -import { formatTaskResult } from "./task-result-format" const DEFAULT_TIMEOUT_MS = 120_000 const MAX_TIMEOUT_MS = 600_000 @@ -14,7 +12,7 @@ function isTerminal(status: string): boolean { return TERMINAL_STATUSES.has(status) } -export function createBackgroundWait(manager: BackgroundOutputManager, client: BackgroundOutputClient): ToolDefinition { +export function createBackgroundWait(manager: BackgroundOutputManager): ToolDefinition { return tool({ description: BACKGROUND_WAIT_DESCRIPTION, args: { @@ -31,7 +29,7 @@ export function createBackgroundWait(manager: BackgroundOutputManager, client: B progress: { done: 0, total: 0, bar: "" }, members: [], remaining_task_ids: [], - completed_task: null, + completed_tasks: [], timeout: false, aborted: false, }, null, 2) @@ -39,9 +37,9 @@ export function createBackgroundWait(manager: BackgroundOutputManager, client: B const timeoutMs = Math.min(args.timeout ?? DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS) - const alreadyTerminal = findFirstTerminal(manager, taskIds) - if (alreadyTerminal) { - return await buildCompletionResult(alreadyTerminal, manager, client, taskIds) + const alreadyTerminal = findAllTerminal(manager, taskIds) + if (alreadyTerminal.length > 0) { + return buildCompletionResult(alreadyTerminal, manager, taskIds) } const startTime = Date.now() @@ -52,9 +50,9 @@ export function createBackgroundWait(manager: BackgroundOutputManager, client: B await delay(1000) - const found = findFirstTerminal(manager, taskIds) - if (found) { - return await buildCompletionResult(found, manager, client, taskIds) + const found = findAllTerminal(manager, taskIds) + if (found.length > 0) { + return buildCompletionResult(found, manager, taskIds) } } @@ -63,15 +61,16 @@ export function createBackgroundWait(manager: BackgroundOutputManager, client: B }) } -function findFirstTerminal(manager: BackgroundOutputManager, taskIds: string[]): { id: string; status: string } | undefined { +function findAllTerminal(manager: BackgroundOutputManager, taskIds: string[]): { id: string; status: string }[] { + const terminal: { id: string; status: string }[] = [] for (const id of taskIds) { const task = manager.getTask(id) if (!task) continue if (isTerminal(task.status)) { - return { id, status: task.status } + terminal.push({ id, status: task.status }) } } - return undefined + return terminal } function buildMemberEntry(manager: BackgroundOutputManager, id: string): Record { @@ -111,64 +110,45 @@ function buildProgressSummary( progress: { done: doneIds.length, total: taskIds.length, bar: progressBar(doneIds.length, taskIds.length) }, members, remaining_task_ids: remaining, - completed_task: null, + completed_tasks: [], timeout: flags.timeout ?? false, aborted: flags.aborted ?? false, }, null, 2) } -async function buildCompletionResult( - completed: { id: string; status: string }, +function buildCompletionResult( + completedTasks: { id: string; status: string }[], manager: BackgroundOutputManager, - client: BackgroundOutputClient, allIds: string[], -): Promise { - const task = manager.getTask(completed.id) - if (!task) { - return JSON.stringify({ - progress: { done: 0, total: allIds.length, bar: progressBar(0, allIds.length) }, - members: allIds.map((id) => buildMemberEntry(manager, id)), - remaining_task_ids: allIds, - completed_task: { task_id: completed.id, description: completed.id, status: "not_found", error: "Task was deleted" }, - timeout: false, - aborted: false, - }, null, 2) - } - +): string { const doneIds = allIds.filter((id) => isTerminal(manager.getTask(id)?.status ?? "")) const members = allIds.map((id) => buildMemberEntry(manager, id)) const remaining = allIds.filter((id) => !isTerminal(manager.getTask(id)?.status ?? "")) - const completedTask: Record = { - task_id: task.id, - description: task.description || task.id, - status: task.status, - } + const completedTaskEntries = completedTasks.map(({ id }) => { + const task = manager.getTask(id) + if (!task) return { task_id: id, status: "not_found", error: "Task was deleted" } - if (task.startedAt) { - const endTime = task.completedAt ?? new Date() - completedTask.duration_s = Math.floor((endTime.getTime() - task.startedAt.getTime()) / 1000) - } - if (task.sessionID) completedTask.session_id = task.sessionID - - if (task.status === "completed") { - if (isCouncilTask(task)) { - const councilResult = await formatCouncilTaskResult(task, client) - completedTask.has_response = councilResult.has_response - completedTask.response_complete = councilResult.response_complete - completedTask.result = councilResult.result - } else { - completedTask.result = await formatTaskResult(task, client) + const entry: Record = { + task_id: task.id, + description: task.description || task.id, + status: task.status, } - } else { - if (task.error) completedTask.error = task.error - } + if (task.startedAt) { + const endTime = task.completedAt ?? new Date() + entry.duration_s = Math.floor((endTime.getTime() - task.startedAt.getTime()) / 1000) + } + if (task.sessionID) entry.session_id = task.sessionID + if (task.outputFilePath) entry.output_file_path = task.outputFilePath + if (task.error) entry.error = task.error + return entry + }) return JSON.stringify({ progress: { done: doneIds.length, total: allIds.length, bar: progressBar(doneIds.length, allIds.length) }, members, remaining_task_ids: remaining, - completed_task: completedTask, + completed_tasks: completedTaskEntries, timeout: false, aborted: false, }, null, 2) diff --git a/src/tools/council-archive/create-council-finalize.test.ts b/src/tools/council-archive/create-council-finalize.test.ts new file mode 100644 index 000000000..281f7d217 --- /dev/null +++ b/src/tools/council-archive/create-council-finalize.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it, beforeEach } from "bun:test" +import { mkdtemp, mkdir, writeFile, readFile } from "node:fs/promises" +import { join } from "node:path" +import { tmpdir } from "node:os" +import { createCouncilFinalize } from "./create-council-finalize" +import type { CouncilFinalizeResult } from "./types" + +function mockTaskOutput(agent: string, responseBody: string, complete = true): string { + const closing = complete ? "\n" : "" + return [ + "---", + `task_id: bg_test`, + `agent: ${agent}`, + `session_id: ses_test`, + `parent_session_id: ses_parent`, + `status: completed`, + `completed_at: 2026-02-27T14:00:00.000Z`, + "---", + "", + "[assistant] 14:00:00", + ``, + responseBody, + closing, + ].join("\n") +} + +const mockCtx = { + sessionID: "test-session", + messageID: "test-message", + agent: "test-agent", + abort: new AbortController().signal, +} + +describe("createCouncilFinalize", () => { + let tmpDir: string + + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), "council-finalize-")) + await mkdir(join(tmpDir, ".sisyphus", "task-outputs"), { recursive: true }) + }) + + describe("#given 3 members with valid output files", () => { + it("#then creates full archive with all has_response true", async () => { + const agents = [ + { id: "bg_001", agent: "Council: Claude Opus", response: "Opus analysis" }, + { id: "bg_002", agent: "Council: GPT-5", response: "GPT analysis" }, + { id: "bg_003", agent: "Council: Gemini", response: "Gemini analysis" }, + ] + + for (const a of agents) { + await writeFile( + join(tmpDir, ".sisyphus", "task-outputs", `${a.id}.md`), + mockTaskOutput(a.agent, a.response), + "utf-8", + ) + } + + const toolDef = createCouncilFinalize(tmpDir) + const resultStr = await toolDef.execute( + { task_ids: agents.map((a) => a.id), name: "test" }, + mockCtx, + ) + const result: CouncilFinalizeResult = JSON.parse(resultStr) + + expect(result.archive_dir).toMatch(/\.sisyphus\/athena\/council-test-[a-f0-9]{4}$/) + expect(result.meta_file).toMatch(/\.sisyphus\/athena\/council-test-[a-f0-9]{4}\/meta\.yaml$/) + expect(result.members).toHaveLength(3) + + for (let i = 0; i < agents.length; i++) { + const member = result.members[i] + expect(member.task_id).toBe(agents[i].id) + expect(member.has_response).toBe(true) + expect(member.response_complete).toBe(true) + expect(member.result).toBe(agents[i].response) + expect(member.result_truncated).toBeUndefined() + expect(member.error).toBeUndefined() + expect(member.archive_file).toBeDefined() + } + + const opusArchive = await readFile(join(tmpDir, result.members[0].archive_file!), "utf-8") + expect(opusArchive).toBe("Opus analysis") + + const metaContent = await readFile(join(tmpDir, result.meta_file), "utf-8") + expect(metaContent).toContain("archive_name: council-test-") + expect(metaContent).toContain("created_at:") + expect(metaContent).toContain('member: "Council: Claude Opus"') + expect(metaContent).toContain("member_slug: council-claude-opus") + expect(metaContent).toContain("has_response: true") + expect(metaContent).toContain("response_complete: true") + }) + }) + + describe("#given 1 of 3 output files missing", () => { + it("#then returns partial success with error for missing member", async () => { + await writeFile( + join(tmpDir, ".sisyphus", "task-outputs", "bg_001.md"), + mockTaskOutput("Council: Claude Opus", "Opus findings"), + "utf-8", + ) + await writeFile( + join(tmpDir, ".sisyphus", "task-outputs", "bg_003.md"), + mockTaskOutput("Council: Gemini", "Gemini findings"), + "utf-8", + ) + + const toolDef = createCouncilFinalize(tmpDir) + const resultStr = await toolDef.execute( + { task_ids: ["bg_001", "bg_002", "bg_003"], name: "partial" }, + mockCtx, + ) + const result: CouncilFinalizeResult = JSON.parse(resultStr) + + expect(result.members).toHaveLength(3) + + expect(result.members[0].has_response).toBe(true) + expect(result.members[0].result).toBe("Opus findings") + + expect(result.members[1].has_response).toBe(false) + expect(result.members[1].error).toBe("Task output file not found") + expect(result.members[1].member).toBe("unknown") + + expect(result.members[2].has_response).toBe(true) + expect(result.members[2].result).toBe("Gemini findings") + }) + }) + + describe("#given large response exceeding 8000 chars", () => { + it("#then truncates result and sets result_truncated flag", async () => { + const largeResponse = "x".repeat(9000) + await writeFile( + join(tmpDir, ".sisyphus", "task-outputs", "bg_large.md"), + mockTaskOutput("Council: Claude Opus", largeResponse), + "utf-8", + ) + + const toolDef = createCouncilFinalize(tmpDir) + const resultStr = await toolDef.execute( + { task_ids: ["bg_large"], name: "large" }, + mockCtx, + ) + const result: CouncilFinalizeResult = JSON.parse(resultStr) + + const member = result.members[0] + expect(member.has_response).toBe(true) + expect(member.result_truncated).toBe(true) + expect(member.result).toHaveLength(500) + expect(member.result).toBe("x".repeat(500)) + + const fullContent = await readFile(join(tmpDir, member.archive_file!), "utf-8") + expect(fullContent).toHaveLength(9000) + }) + }) + + describe("#given empty response between tags", () => { + it("#then returns has_response true with empty string result", async () => { + const emptyOutput = [ + "---", + "task_id: bg_empty", + "agent: Council: Empty Agent", + "session_id: ses_test", + "parent_session_id: ses_parent", + "status: completed", + "completed_at: 2026-02-27T14:00:00.000Z", + "---", + "", + "[assistant] 14:00:00", + "", + ].join("\n") + + await writeFile( + join(tmpDir, ".sisyphus", "task-outputs", "bg_empty.md"), + emptyOutput, + "utf-8", + ) + + const toolDef = createCouncilFinalize(tmpDir) + const resultStr = await toolDef.execute( + { task_ids: ["bg_empty"], name: "empty" }, + mockCtx, + ) + const result: CouncilFinalizeResult = JSON.parse(resultStr) + + const member = result.members[0] + expect(member.has_response).toBe(true) + expect(member.response_complete).toBe(true) + expect(member.result).toBe("") + expect(member.result_truncated).toBeUndefined() + expect(member.archive_file).toBeDefined() + }) + }) +}) diff --git a/src/tools/council-archive/create-council-finalize.ts b/src/tools/council-archive/create-council-finalize.ts new file mode 100644 index 000000000..8fee91f07 --- /dev/null +++ b/src/tools/council-archive/create-council-finalize.ts @@ -0,0 +1,158 @@ +import { tool, type ToolDefinition } from "@opencode-ai/plugin" +import { readFile, writeFile, mkdir } from "node:fs/promises" +import { join } from "node:path" +import { randomBytes } from "node:crypto" +import { extractCouncilResponse } from "./council-response-extractor" +import type { CouncilFinalizeArgs, CouncilMemberResult, CouncilFinalizeResult } from "./types" + +const RESULT_SIZE_LIMIT = 8000 +const PREVIEW_SIZE = 500 + +interface MetaMember { + task_id: string + member: string + member_slug: string + task_output_path: string + archive_file: string + has_response: boolean + response_complete: boolean +} + +function slugify(text: string): string { + return text + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") +} + +function extractAgentFromFrontmatter(content: string): string | null { + const fmMatch = content.match(/^---\n([\s\S]*?)\n---/) + if (!fmMatch) return null + const agentLine = fmMatch[1].match(/^agent:\s*(.+)$/m) + return agentLine ? agentLine[1].trim() : null +} + +function formatMetaYaml(archiveName: string, createdAt: string, members: MetaMember[]): string { + const lines: string[] = [ + `archive_name: ${archiveName}`, + `created_at: ${createdAt}`, + "members:", + ] + + for (const m of members) { + lines.push(` - task_id: ${m.task_id}`) + lines.push(` member: "${m.member}"`) + lines.push(` member_slug: ${m.member_slug}`) + lines.push(` task_output_path: ${m.task_output_path}`) + lines.push(` archive_file: ${m.archive_file}`) + lines.push(` has_response: ${m.has_response}`) + lines.push(` response_complete: ${m.response_complete}`) + } + + return lines.join("\n") + "\n" +} + +export function createCouncilFinalize(basePath?: string): ToolDefinition { + return tool({ + description: + "Finalize council task outputs: extract COUNCIL_MEMBER_RESPONSE content from raw task output files, write per-member archive files, and create meta.yaml.", + args: { + task_ids: tool.schema + .array(tool.schema.string()) + .describe("Array of background task IDs whose output files should be processed"), + name: tool.schema.string().describe("Council name used in the archive directory name"), + }, + async execute(args: CouncilFinalizeArgs) { + const base = basePath ?? process.cwd() + const hexId = randomBytes(2).toString("hex") + const archiveName = `council-${args.name}-${hexId}` + const relArchiveDir = join(".sisyphus", "athena", archiveName) + const absArchiveDir = join(base, relArchiveDir) + + await mkdir(absArchiveDir, { recursive: true }) + + const members: CouncilMemberResult[] = [] + const metaMembers: MetaMember[] = [] + + for (const taskId of args.task_ids) { + const relTaskOutput = join(".sisyphus", "task-outputs", `${taskId}.md`) + const absTaskOutput = join(base, relTaskOutput) + + let fileContent: string + try { + fileContent = await readFile(absTaskOutput, "utf-8") + } catch { + members.push({ + task_id: taskId, + member: "unknown", + has_response: false, + error: "Task output file not found", + }) + metaMembers.push({ + task_id: taskId, + member: "unknown", + member_slug: "unknown", + task_output_path: relTaskOutput, + archive_file: "", + has_response: false, + response_complete: false, + }) + continue + } + + const agentName = extractAgentFromFrontmatter(fileContent) ?? "unknown" + const memberSlug = slugify(agentName) + const extraction = extractCouncilResponse(fileContent) + + const relArchiveFile = join(relArchiveDir, `${memberSlug}.md`) + const absArchiveFile = join(base, relArchiveFile) + + const memberResult: CouncilMemberResult = { + task_id: taskId, + member: agentName, + has_response: extraction.has_response, + } + + if (extraction.has_response) { + memberResult.response_complete = extraction.response_complete + } + + if (extraction.result !== null) { + await writeFile(absArchiveFile, extraction.result, "utf-8") + memberResult.archive_file = relArchiveFile + + if (extraction.result.length > RESULT_SIZE_LIMIT) { + memberResult.result = extraction.result.slice(0, PREVIEW_SIZE) + memberResult.result_truncated = true + } else { + memberResult.result = extraction.result + } + } + + members.push(memberResult) + metaMembers.push({ + task_id: taskId, + member: agentName, + member_slug: memberSlug, + task_output_path: relTaskOutput, + archive_file: extraction.result !== null ? relArchiveFile : "", + has_response: extraction.has_response, + response_complete: extraction.response_complete, + }) + } + + const relMetaFile = join(relArchiveDir, "meta.yaml") + const absMetaFile = join(base, relMetaFile) + const createdAt = new Date().toISOString() + await writeFile(absMetaFile, formatMetaYaml(archiveName, createdAt, metaMembers), "utf-8") + + const result: CouncilFinalizeResult = { + archive_dir: relArchiveDir, + meta_file: relMetaFile, + members, + } + + return JSON.stringify(result, null, 2) + }, + }) +} diff --git a/src/tools/council-archive/create-council-read.test.ts b/src/tools/council-archive/create-council-read.test.ts new file mode 100644 index 000000000..a18b6c82c --- /dev/null +++ b/src/tools/council-archive/create-council-read.test.ts @@ -0,0 +1,115 @@ +/// + +import { describe, expect, it, beforeEach, afterEach } from "bun:test" +import { mkdtemp, writeFile, mkdir, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { createCouncilRead } from "./create-council-read" + +let tempDir: string +let sisyphusDir: string + +beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "council-read-test-")) + sisyphusDir = join(tempDir, ".sisyphus") + await mkdir(sisyphusDir, { recursive: true }) +}) + +afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }) +}) + +const toolContext = { + sessionID: "test-session", + messageID: "test-message", + agent: "test-agent", + abort: new AbortController().signal, +} + +describe("createCouncilRead", () => { + describe("#given an archive file with complete COUNCIL_MEMBER_RESPONSE tags", () => { + it("#then returns has_response true, response_complete true, and the content", async () => { + const archivePath = join(sisyphusDir, "member-1.txt") + await writeFile(archivePath, "Some preamble\nFull analysis here") + + const tool = createCouncilRead() + const relativePath = `.sisyphus/member-1.txt` + + process.chdir(tempDir) + const result = await tool.execute({ file_path: relativePath }, toolContext) + const parsed = JSON.parse(result) + + expect(parsed.has_response).toBe(true) + expect(parsed.response_complete).toBe(true) + expect(parsed.result).toBe("Full analysis here") + }) + }) + + describe("#given an archive file with incomplete COUNCIL_MEMBER_RESPONSE tags", () => { + it("#then returns has_response true, response_complete false", async () => { + const archivePath = join(sisyphusDir, "member-2.txt") + await writeFile(archivePath, "Partial analysis still writing...") + + const tool = createCouncilRead() + const relativePath = `.sisyphus/member-2.txt` + + process.chdir(tempDir) + const result = await tool.execute({ file_path: relativePath }, toolContext) + const parsed = JSON.parse(result) + + expect(parsed.has_response).toBe(true) + expect(parsed.response_complete).toBe(false) + }) + }) + + describe("#given an archive file with no COUNCIL_MEMBER_RESPONSE tags", () => { + it("#then returns has_response false", async () => { + const archivePath = join(sisyphusDir, "member-3.txt") + await writeFile(archivePath, "Just some plain text without any tags.") + + const tool = createCouncilRead() + const relativePath = `.sisyphus/member-3.txt` + + process.chdir(tempDir) + const result = await tool.execute({ file_path: relativePath }, toolContext) + const parsed = JSON.parse(result) + + expect(parsed.has_response).toBe(false) + }) + }) + + describe("#given a path outside .sisyphus/", () => { + it("#then returns Access denied error", async () => { + const tool = createCouncilRead() + const result = await tool.execute({ file_path: "/etc/passwd" }, toolContext) + const parsed = JSON.parse(result) + + expect(parsed.error).toBe("Access denied: path must be within .sisyphus/") + }) + }) + + describe("#given a missing file within .sisyphus/", () => { + it("#then returns has_response false with File not found error", async () => { + const tool = createCouncilRead() + const relativePath = `.sisyphus/nonexistent-file.txt` + + process.chdir(tempDir) + const result = await tool.execute({ file_path: relativePath }, toolContext) + const parsed = JSON.parse(result) + + expect(parsed.has_response).toBe(false) + expect(parsed.error).toContain("File not found") + expect(parsed.error).toContain(relativePath) + }) + }) + + describe("#given a path traversal attempt", () => { + it("#then returns Access denied error", async () => { + const tool = createCouncilRead() + const result = await tool.execute({ file_path: "../../../etc/passwd" }, toolContext) + const parsed = JSON.parse(result) + + expect(parsed.error).toBe("Access denied: path must be within .sisyphus/") + }) + }) +}) diff --git a/src/tools/council-archive/create-council-read.ts b/src/tools/council-archive/create-council-read.ts new file mode 100644 index 000000000..0235a469d --- /dev/null +++ b/src/tools/council-archive/create-council-read.ts @@ -0,0 +1,26 @@ +import { tool, type ToolDefinition } from "@opencode-ai/plugin" +import { readFile } from "node:fs/promises" +import { extractCouncilResponse } from "./council-response-extractor" + +export function createCouncilRead(): ToolDefinition { + return tool({ + description: + "Read a council archive file and extract the council member response. Use this to access full results for truncated members or for follow-up/cross-check analysis.", + args: { + file_path: tool.schema.string().describe("Path to the archive file (must be within .sisyphus/)"), + }, + async execute(args: { file_path: string }) { + if (!args.file_path.startsWith(".sisyphus/")) { + return JSON.stringify({ error: "Access denied: path must be within .sisyphus/" }, null, 2) + } + + try { + const content = await readFile(args.file_path, "utf-8") + const extraction = extractCouncilResponse(content) + return JSON.stringify(extraction, null, 2) + } catch { + return JSON.stringify({ has_response: false, error: `File not found: ${args.file_path}` }, null, 2) + } + }, + }) +} diff --git a/src/tools/council-archive/types.ts b/src/tools/council-archive/types.ts new file mode 100644 index 000000000..3472863a0 --- /dev/null +++ b/src/tools/council-archive/types.ts @@ -0,0 +1,21 @@ +export interface CouncilFinalizeArgs { + task_ids: string[] + name: string +} + +export interface CouncilMemberResult { + task_id: string + member: string + has_response: boolean + response_complete?: boolean + result?: string + result_truncated?: boolean + archive_file?: string + error?: string +} + +export interface CouncilFinalizeResult { + archive_dir: string + meta_file: string + members: CouncilMemberResult[] +} diff --git a/src/tools/index.ts b/src/tools/index.ts index 94b873bff..e887e3cf4 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -54,7 +54,7 @@ export function createBackgroundTools(manager: BackgroundManager, client: Openco return { background_output: createBackgroundOutput(outputManager, client), background_cancel: createBackgroundCancel(manager, cancelClient), - background_wait: createBackgroundWait(outputManager, client), + background_wait: createBackgroundWait(outputManager), } }