feat(council-archive): add council_finalize and council_read tools, refactor background_wait to metadata-only
- council_finalize: batch extract, archive, and return council results - council_read: secure file reader for archived council responses - background_wait: stripped to metadata-only, returns completed_tasks array - Removed all council-specific logic from background_wait - 28/28 tests passing across all changed files
This commit is contained in:
@@ -6,6 +6,6 @@ export const BACKGROUND_OUTPUT_DESCRIPTION = `Get output from background task. U
|
|||||||
|
|
||||||
IMPORTANT: ONLY call this tool AFTER receiving a <system-reminder> notification for the task. Do NOT call immediately after launching a background task - wait for the notification first.`
|
IMPORTANT: ONLY call this tool AFTER receiving a <system-reminder> 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.`
|
export const BACKGROUND_CANCEL_DESCRIPTION = `Cancel running background task(s). Use all=true to cancel ALL before final answer.`
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { describe, test, expect, beforeEach } from "bun:test"
|
import { describe, test, expect, beforeEach } from "bun:test"
|
||||||
import type { BackgroundTask } from "../../features/background-agent"
|
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 { createBackgroundWait } from "./create-background-wait"
|
||||||
import { resetMessageCursor } from "../../shared/session-cursor"
|
import { resetMessageCursor } from "../../shared/session-cursor"
|
||||||
|
|
||||||
@@ -26,18 +26,6 @@ function createMockManager(tasks: Record<string, Partial<BackgroundTask>>): 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 = {
|
const toolContext = {
|
||||||
sessionID: "test-session",
|
sessionID: "test-session",
|
||||||
messageID: "test-message",
|
messageID: "test-message",
|
||||||
@@ -52,10 +40,9 @@ describe("createBackgroundWait", () => {
|
|||||||
|
|
||||||
describe("#given empty task_ids", () => {
|
describe("#given empty task_ids", () => {
|
||||||
describe("#when execute is called with empty array", () => {
|
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 manager = createMockManager({})
|
||||||
const client = createMockClient()
|
const tool = createBackgroundWait(manager)
|
||||||
const tool = createBackgroundWait(manager, client)
|
|
||||||
|
|
||||||
const result = await tool.execute({ task_ids: [] }, toolContext)
|
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.error).toBe("task_ids array is required and must not be empty.")
|
||||||
expect(parsed.members).toEqual([])
|
expect(parsed.members).toEqual([])
|
||||||
expect(parsed.remaining_task_ids).toEqual([])
|
expect(parsed.remaining_task_ids).toEqual([])
|
||||||
expect(parsed.completed_task).toBeNull()
|
expect(parsed.completed_tasks).toEqual([])
|
||||||
expect(parsed.timeout).toBe(false)
|
expect(parsed.timeout).toBe(false)
|
||||||
expect(parsed.aborted).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", () => {
|
describe("#when execute is called with that task_id", () => {
|
||||||
test("#then returns JSON with council-specific fields", async () => {
|
test("#then returns metadata-only completed_tasks array with no result field", async () => {
|
||||||
const councilResponse = "<COUNCIL_MEMBER_RESPONSE>Council verdict here</COUNCIL_MEMBER_RESPONSE>"
|
|
||||||
const manager = createMockManager({
|
const manager = createMockManager({
|
||||||
"task-1": {
|
"task-1": {
|
||||||
status: "completed",
|
status: "completed",
|
||||||
@@ -83,19 +69,21 @@ describe("createBackgroundWait", () => {
|
|||||||
completedAt: new Date(),
|
completedAt: new Date(),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const client = createMockClient(councilResponse)
|
const tool = createBackgroundWait(manager)
|
||||||
const tool = createBackgroundWait(manager, client)
|
|
||||||
|
|
||||||
const result = await tool.execute({ task_ids: ["task-1"] }, toolContext)
|
const result = await tool.execute({ task_ids: ["task-1"] }, toolContext)
|
||||||
|
|
||||||
const parsed = JSON.parse(result)
|
const parsed = JSON.parse(result)
|
||||||
expect(parsed.completed_task).toBeDefined()
|
expect(parsed.completed_tasks).toBeArray()
|
||||||
expect(parsed.completed_task.task_id).toBe("task-1")
|
expect(parsed.completed_tasks).toHaveLength(1)
|
||||||
expect(parsed.completed_task.status).toBe("completed")
|
expect(parsed.completed_tasks[0].task_id).toBe("task-1")
|
||||||
expect(parsed.completed_task.has_response).toBe(true)
|
expect(parsed.completed_tasks[0].status).toBe("completed")
|
||||||
expect(parsed.completed_task.response_complete).toBe(true)
|
expect(parsed.completed_tasks[0].description).toBe("Council analysis")
|
||||||
expect(parsed.completed_task.result).toBe("Council verdict here")
|
expect(parsed.completed_tasks[0].duration_s).toBeTypeOf("number")
|
||||||
expect(parsed.completed_task.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.timeout).toBe(false)
|
||||||
expect(parsed.aborted).toBe(false)
|
expect(parsed.aborted).toBe(false)
|
||||||
})
|
})
|
||||||
@@ -104,7 +92,7 @@ describe("createBackgroundWait", () => {
|
|||||||
|
|
||||||
describe("#given a completed non-council task", () => {
|
describe("#given a completed non-council task", () => {
|
||||||
describe("#when execute is called with that task_id", () => {
|
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({
|
const manager = createMockManager({
|
||||||
"task-2": {
|
"task-2": {
|
||||||
status: "completed",
|
status: "completed",
|
||||||
@@ -114,19 +102,19 @@ describe("createBackgroundWait", () => {
|
|||||||
completedAt: new Date(),
|
completedAt: new Date(),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const client = createMockClient("Exploration complete: found 5 files")
|
const tool = createBackgroundWait(manager)
|
||||||
const tool = createBackgroundWait(manager, client)
|
|
||||||
|
|
||||||
const result = await tool.execute({ task_ids: ["task-2"] }, toolContext)
|
const result = await tool.execute({ task_ids: ["task-2"] }, toolContext)
|
||||||
|
|
||||||
const parsed = JSON.parse(result)
|
const parsed = JSON.parse(result)
|
||||||
expect(parsed.completed_task).toBeDefined()
|
expect(parsed.completed_tasks).toBeArray()
|
||||||
expect(parsed.completed_task.task_id).toBe("task-2")
|
expect(parsed.completed_tasks).toHaveLength(1)
|
||||||
expect(parsed.completed_task.status).toBe("completed")
|
expect(parsed.completed_tasks[0].task_id).toBe("task-2")
|
||||||
expect(parsed.completed_task.result).toBeTypeOf("string")
|
expect(parsed.completed_tasks[0].status).toBe("completed")
|
||||||
expect(parsed.completed_task.result).toContain("Exploration complete")
|
expect(parsed.completed_tasks[0].description).toBe("Explore codebase")
|
||||||
expect(parsed.completed_task.has_response).toBeUndefined()
|
expect(parsed.completed_tasks[0].result).toBeUndefined()
|
||||||
expect(parsed.completed_task.response_complete).toBeUndefined()
|
expect(parsed.completed_tasks[0].has_response).toBeUndefined()
|
||||||
|
expect(parsed.completed_tasks[0].response_complete).toBeUndefined()
|
||||||
expect(parsed.timeout).toBe(false)
|
expect(parsed.timeout).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -134,7 +122,7 @@ describe("createBackgroundWait", () => {
|
|||||||
|
|
||||||
describe("#given an error task", () => {
|
describe("#given an error task", () => {
|
||||||
describe("#when execute is called with that task_id", () => {
|
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({
|
const manager = createMockManager({
|
||||||
"task-3": {
|
"task-3": {
|
||||||
status: "error",
|
status: "error",
|
||||||
@@ -144,17 +132,17 @@ describe("createBackgroundWait", () => {
|
|||||||
startedAt: new Date(Date.now() - 2000),
|
startedAt: new Date(Date.now() - 2000),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const client = createMockClient()
|
const tool = createBackgroundWait(manager)
|
||||||
const tool = createBackgroundWait(manager, client)
|
|
||||||
|
|
||||||
const result = await tool.execute({ task_ids: ["task-3"] }, toolContext)
|
const result = await tool.execute({ task_ids: ["task-3"] }, toolContext)
|
||||||
|
|
||||||
const parsed = JSON.parse(result)
|
const parsed = JSON.parse(result)
|
||||||
expect(parsed.completed_task).toBeDefined()
|
expect(parsed.completed_tasks).toBeArray()
|
||||||
expect(parsed.completed_task.task_id).toBe("task-3")
|
expect(parsed.completed_tasks).toHaveLength(1)
|
||||||
expect(parsed.completed_task.status).toBe("error")
|
expect(parsed.completed_tasks[0].task_id).toBe("task-3")
|
||||||
expect(parsed.completed_task.error).toBe("Model failed")
|
expect(parsed.completed_tasks[0].status).toBe("error")
|
||||||
expect(parsed.completed_task.result).toBeUndefined()
|
expect(parsed.completed_tasks[0].error).toBe("Model failed")
|
||||||
|
expect(parsed.completed_tasks[0].result).toBeUndefined()
|
||||||
expect(parsed.timeout).toBe(false)
|
expect(parsed.timeout).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -166,8 +154,7 @@ describe("createBackgroundWait", () => {
|
|||||||
const manager = createMockManager({
|
const manager = createMockManager({
|
||||||
"known-task": { status: "completed", agent: "explore" },
|
"known-task": { status: "completed", agent: "explore" },
|
||||||
})
|
})
|
||||||
const client = createMockClient()
|
const tool = createBackgroundWait(manager)
|
||||||
const tool = createBackgroundWait(manager, client)
|
|
||||||
|
|
||||||
const result = await tool.execute({ task_ids: ["known-task", "unknown-task"] }, toolContext)
|
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("#given a running task with session state and progress", () => {
|
||||||
describe("#when that task plus a completed task are waited on", () => {
|
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 now = new Date()
|
||||||
const manager = createMockManager({
|
const manager = createMockManager({
|
||||||
"running-task": {
|
"running-task": {
|
||||||
@@ -199,13 +186,14 @@ describe("createBackgroundWait", () => {
|
|||||||
completedAt: now,
|
completedAt: now,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const client = createMockClient("Done result")
|
const tool = createBackgroundWait(manager)
|
||||||
const tool = createBackgroundWait(manager, client)
|
|
||||||
|
|
||||||
const result = await tool.execute({ task_ids: ["running-task", "done-task"] }, toolContext)
|
const result = await tool.execute({ task_ids: ["running-task", "done-task"] }, toolContext)
|
||||||
|
|
||||||
const parsed = JSON.parse(result)
|
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<string, unknown>) => m.task_id === "running-task")
|
const runningMember = parsed.members.find((m: Record<string, unknown>) => m.task_id === "running-task")
|
||||||
expect(runningMember).toBeDefined()
|
expect(runningMember).toBeDefined()
|
||||||
@@ -224,13 +212,14 @@ describe("createBackgroundWait", () => {
|
|||||||
"t2": { status: "completed", agent: "explore", startedAt: new Date() },
|
"t2": { status: "completed", agent: "explore", startedAt: new Date() },
|
||||||
"t3": { status: "running", agent: "oracle" },
|
"t3": { status: "running", agent: "oracle" },
|
||||||
})
|
})
|
||||||
const client = createMockClient("Result from t2")
|
const tool = createBackgroundWait(manager)
|
||||||
const tool = createBackgroundWait(manager, client)
|
|
||||||
|
|
||||||
const result = await tool.execute({ task_ids: ["t1", "t2", "t3"] }, toolContext)
|
const result = await tool.execute({ task_ids: ["t1", "t2", "t3"] }, toolContext)
|
||||||
|
|
||||||
const parsed = JSON.parse(result)
|
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("t1")
|
||||||
expect(parsed.remaining_task_ids).toContain("t3")
|
expect(parsed.remaining_task_ids).toContain("t3")
|
||||||
expect(parsed.remaining_task_ids).not.toContain("t2")
|
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<string, unknown>) => 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("#given all results from every code path", () => {
|
||||||
describe("#when JSON.parse is applied to each result", () => {
|
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 now = new Date()
|
||||||
const results: string[] = []
|
const results: string[] = []
|
||||||
|
|
||||||
const emptyTool = createBackgroundWait(createMockManager({}), createMockClient())
|
const emptyTool = createBackgroundWait(createMockManager({}))
|
||||||
results.push(await emptyTool.execute({ task_ids: [] }, toolContext))
|
results.push(await emptyTool.execute({ task_ids: [] }, toolContext))
|
||||||
|
|
||||||
const councilTool = createBackgroundWait(
|
const completedTool = createBackgroundWait(
|
||||||
createMockManager({
|
createMockManager({
|
||||||
"c1": {
|
"c1": {
|
||||||
status: "completed",
|
status: "completed",
|
||||||
@@ -260,12 +295,11 @@ describe("createBackgroundWait", () => {
|
|||||||
completedAt: now,
|
completedAt: now,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
createMockClient("<COUNCIL_MEMBER_RESPONSE>verdict</COUNCIL_MEMBER_RESPONSE>"),
|
|
||||||
)
|
)
|
||||||
results.push(await councilTool.execute({ task_ids: ["c1"] }, toolContext))
|
results.push(await completedTool.execute({ task_ids: ["c1"] }, toolContext))
|
||||||
|
|
||||||
resetMessageCursor()
|
resetMessageCursor()
|
||||||
const nonCouncilTool = createBackgroundWait(
|
const exploreTool = createBackgroundWait(
|
||||||
createMockManager({
|
createMockManager({
|
||||||
"nc1": {
|
"nc1": {
|
||||||
status: "completed",
|
status: "completed",
|
||||||
@@ -274,30 +308,29 @@ describe("createBackgroundWait", () => {
|
|||||||
completedAt: now,
|
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(
|
const errorTool = createBackgroundWait(
|
||||||
createMockManager({ "e1": { status: "error", error: "boom" } }),
|
createMockManager({ "e1": { status: "error", error: "boom" } }),
|
||||||
createMockClient(),
|
|
||||||
)
|
)
|
||||||
results.push(await errorTool.execute({ task_ids: ["e1"] }, toolContext))
|
results.push(await errorTool.execute({ task_ids: ["e1"] }, toolContext))
|
||||||
|
|
||||||
const abortController = new AbortController()
|
const abortController = new AbortController()
|
||||||
abortController.abort()
|
abortController.abort()
|
||||||
const abortedContext = { ...toolContext, abort: abortController.signal }
|
const abortedContext = { ...toolContext, abort: abortController.signal }
|
||||||
const notFoundTool = createBackgroundWait(createMockManager({}), createMockClient())
|
const notFoundTool = createBackgroundWait(createMockManager({}))
|
||||||
results.push(await notFoundTool.execute({ task_ids: ["missing"] }, abortedContext))
|
results.push(await notFoundTool.execute({ task_ids: ["missing"] }, abortedContext))
|
||||||
|
|
||||||
const cancelledTool = createBackgroundWait(
|
const cancelledTool = createBackgroundWait(
|
||||||
createMockManager({ "x1": { status: "cancelled", agent: "explore" } }),
|
createMockManager({ "x1": { status: "cancelled", agent: "explore" } }),
|
||||||
createMockClient(),
|
|
||||||
)
|
)
|
||||||
results.push(await cancelledTool.execute({ task_ids: ["x1"] }, toolContext))
|
results.push(await cancelledTool.execute({ task_ids: ["x1"] }, toolContext))
|
||||||
|
|
||||||
for (const result of results) {
|
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)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
|
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 { BACKGROUND_WAIT_DESCRIPTION } from "./constants"
|
||||||
import { formatCouncilTaskResult, isCouncilTask } from "./council-result-format"
|
|
||||||
import { delay } from "./delay"
|
import { delay } from "./delay"
|
||||||
import { formatTaskResult } from "./task-result-format"
|
|
||||||
|
|
||||||
const DEFAULT_TIMEOUT_MS = 120_000
|
const DEFAULT_TIMEOUT_MS = 120_000
|
||||||
const MAX_TIMEOUT_MS = 600_000
|
const MAX_TIMEOUT_MS = 600_000
|
||||||
@@ -14,7 +12,7 @@ function isTerminal(status: string): boolean {
|
|||||||
return TERMINAL_STATUSES.has(status)
|
return TERMINAL_STATUSES.has(status)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createBackgroundWait(manager: BackgroundOutputManager, client: BackgroundOutputClient): ToolDefinition {
|
export function createBackgroundWait(manager: BackgroundOutputManager): ToolDefinition {
|
||||||
return tool({
|
return tool({
|
||||||
description: BACKGROUND_WAIT_DESCRIPTION,
|
description: BACKGROUND_WAIT_DESCRIPTION,
|
||||||
args: {
|
args: {
|
||||||
@@ -31,7 +29,7 @@ export function createBackgroundWait(manager: BackgroundOutputManager, client: B
|
|||||||
progress: { done: 0, total: 0, bar: "" },
|
progress: { done: 0, total: 0, bar: "" },
|
||||||
members: [],
|
members: [],
|
||||||
remaining_task_ids: [],
|
remaining_task_ids: [],
|
||||||
completed_task: null,
|
completed_tasks: [],
|
||||||
timeout: false,
|
timeout: false,
|
||||||
aborted: false,
|
aborted: false,
|
||||||
}, null, 2)
|
}, 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 timeoutMs = Math.min(args.timeout ?? DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS)
|
||||||
|
|
||||||
const alreadyTerminal = findFirstTerminal(manager, taskIds)
|
const alreadyTerminal = findAllTerminal(manager, taskIds)
|
||||||
if (alreadyTerminal) {
|
if (alreadyTerminal.length > 0) {
|
||||||
return await buildCompletionResult(alreadyTerminal, manager, client, taskIds)
|
return buildCompletionResult(alreadyTerminal, manager, taskIds)
|
||||||
}
|
}
|
||||||
|
|
||||||
const startTime = Date.now()
|
const startTime = Date.now()
|
||||||
@@ -52,9 +50,9 @@ export function createBackgroundWait(manager: BackgroundOutputManager, client: B
|
|||||||
|
|
||||||
await delay(1000)
|
await delay(1000)
|
||||||
|
|
||||||
const found = findFirstTerminal(manager, taskIds)
|
const found = findAllTerminal(manager, taskIds)
|
||||||
if (found) {
|
if (found.length > 0) {
|
||||||
return await buildCompletionResult(found, manager, client, taskIds)
|
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) {
|
for (const id of taskIds) {
|
||||||
const task = manager.getTask(id)
|
const task = manager.getTask(id)
|
||||||
if (!task) continue
|
if (!task) continue
|
||||||
if (isTerminal(task.status)) {
|
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<string, unknown> {
|
function buildMemberEntry(manager: BackgroundOutputManager, id: string): Record<string, unknown> {
|
||||||
@@ -111,64 +110,45 @@ function buildProgressSummary(
|
|||||||
progress: { done: doneIds.length, total: taskIds.length, bar: progressBar(doneIds.length, taskIds.length) },
|
progress: { done: doneIds.length, total: taskIds.length, bar: progressBar(doneIds.length, taskIds.length) },
|
||||||
members,
|
members,
|
||||||
remaining_task_ids: remaining,
|
remaining_task_ids: remaining,
|
||||||
completed_task: null,
|
completed_tasks: [],
|
||||||
timeout: flags.timeout ?? false,
|
timeout: flags.timeout ?? false,
|
||||||
aborted: flags.aborted ?? false,
|
aborted: flags.aborted ?? false,
|
||||||
}, null, 2)
|
}, null, 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildCompletionResult(
|
function buildCompletionResult(
|
||||||
completed: { id: string; status: string },
|
completedTasks: { id: string; status: string }[],
|
||||||
manager: BackgroundOutputManager,
|
manager: BackgroundOutputManager,
|
||||||
client: BackgroundOutputClient,
|
|
||||||
allIds: string[],
|
allIds: string[],
|
||||||
): Promise<string> {
|
): string {
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
const doneIds = allIds.filter((id) => isTerminal(manager.getTask(id)?.status ?? ""))
|
const doneIds = allIds.filter((id) => isTerminal(manager.getTask(id)?.status ?? ""))
|
||||||
const members = allIds.map((id) => buildMemberEntry(manager, id))
|
const members = allIds.map((id) => buildMemberEntry(manager, id))
|
||||||
const remaining = allIds.filter((id) => !isTerminal(manager.getTask(id)?.status ?? ""))
|
const remaining = allIds.filter((id) => !isTerminal(manager.getTask(id)?.status ?? ""))
|
||||||
|
|
||||||
const completedTask: Record<string, unknown> = {
|
const completedTaskEntries = completedTasks.map(({ id }) => {
|
||||||
task_id: task.id,
|
const task = manager.getTask(id)
|
||||||
description: task.description || task.id,
|
if (!task) return { task_id: id, status: "not_found", error: "Task was deleted" }
|
||||||
status: task.status,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (task.startedAt) {
|
const entry: Record<string, unknown> = {
|
||||||
const endTime = task.completedAt ?? new Date()
|
task_id: task.id,
|
||||||
completedTask.duration_s = Math.floor((endTime.getTime() - task.startedAt.getTime()) / 1000)
|
description: task.description || task.id,
|
||||||
}
|
status: task.status,
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
} else {
|
if (task.startedAt) {
|
||||||
if (task.error) completedTask.error = task.error
|
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({
|
return JSON.stringify({
|
||||||
progress: { done: doneIds.length, total: allIds.length, bar: progressBar(doneIds.length, allIds.length) },
|
progress: { done: doneIds.length, total: allIds.length, bar: progressBar(doneIds.length, allIds.length) },
|
||||||
members,
|
members,
|
||||||
remaining_task_ids: remaining,
|
remaining_task_ids: remaining,
|
||||||
completed_task: completedTask,
|
completed_tasks: completedTaskEntries,
|
||||||
timeout: false,
|
timeout: false,
|
||||||
aborted: false,
|
aborted: false,
|
||||||
}, null, 2)
|
}, null, 2)
|
||||||
|
|||||||
@@ -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</COUNCIL_MEMBER_RESPONSE>" : ""
|
||||||
|
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",
|
||||||
|
`<COUNCIL_MEMBER_RESPONSE>`,
|
||||||
|
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",
|
||||||
|
"<COUNCIL_MEMBER_RESPONSE></COUNCIL_MEMBER_RESPONSE>",
|
||||||
|
].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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
/// <reference types="bun-types" />
|
||||||
|
|
||||||
|
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\n<COUNCIL_MEMBER_RESPONSE>Full analysis here</COUNCIL_MEMBER_RESPONSE>")
|
||||||
|
|
||||||
|
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, "<COUNCIL_MEMBER_RESPONSE>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/")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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[]
|
||||||
|
}
|
||||||
+1
-1
@@ -54,7 +54,7 @@ export function createBackgroundTools(manager: BackgroundManager, client: Openco
|
|||||||
return {
|
return {
|
||||||
background_output: createBackgroundOutput(outputManager, client),
|
background_output: createBackgroundOutput(outputManager, client),
|
||||||
background_cancel: createBackgroundCancel(manager, cancelClient),
|
background_cancel: createBackgroundCancel(manager, cancelClient),
|
||||||
background_wait: createBackgroundWait(outputManager, client),
|
background_wait: createBackgroundWait(outputManager),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user