e15ce7b279
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
371 lines
15 KiB
TypeScript
371 lines
15 KiB
TypeScript
/// <reference types="bun-types" />
|
|
|
|
import { describe, expect, it, beforeEach, afterEach } from "bun:test"
|
|
import { mkdtemp, mkdir, writeFile, readFile, rm, stat } 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"
|
|
import type { BackgroundTask } from "../../features/background-agent"
|
|
import type { BackgroundOutputManager } from "../background-task/clients"
|
|
import { createBackgroundWait } from "../background-task/create-background-wait"
|
|
import { resetMessageCursor } from "../../shared/session-cursor"
|
|
|
|
function extractJson(output: string): string {
|
|
const marker = "<athena_runtime_guidance>"
|
|
const idx = output.indexOf(marker)
|
|
if (idx === -1) return output
|
|
const beforeMarker = output.substring(0, idx)
|
|
const lastNewlines = beforeMarker.lastIndexOf("\n\n")
|
|
return lastNewlines === -1 ? beforeMarker.trim() : output.substring(0, lastNewlines).trim()
|
|
}
|
|
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")
|
|
}
|
|
|
|
function mockTaskOutputNoTags(agent: string, body: string): string {
|
|
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",
|
|
body,
|
|
].join("\n")
|
|
}
|
|
|
|
const toolContext = {
|
|
sessionID: "test-session",
|
|
messageID: "test-message",
|
|
agent: "test-agent",
|
|
abort: new AbortController().signal,
|
|
}
|
|
|
|
function createMockManager(tasks: Record<string, Partial<BackgroundTask>>): BackgroundOutputManager {
|
|
return {
|
|
getTask: (id: string) => {
|
|
const partial = tasks[id]
|
|
if (!partial) return undefined
|
|
return {
|
|
id,
|
|
parentSessionID: "parent",
|
|
parentMessageID: "parent-msg",
|
|
description: partial.description ?? `Task ${id}`,
|
|
prompt: "test",
|
|
agent: partial.agent ?? "test-agent",
|
|
status: partial.status ?? "running",
|
|
sessionID: partial.sessionID ?? `session-${id}`,
|
|
...partial,
|
|
} as BackgroundTask
|
|
},
|
|
}
|
|
}
|
|
|
|
let tmpDir: string
|
|
|
|
beforeEach(async () => {
|
|
tmpDir = await mkdtemp(join(tmpdir(), "council-flow-"))
|
|
await mkdir(join(tmpDir, ".sisyphus", "task-outputs"), { recursive: true })
|
|
})
|
|
|
|
afterEach(async () => {
|
|
await rm(tmpDir, { recursive: true, force: true })
|
|
})
|
|
|
|
describe("council archive integration flow", () => {
|
|
describe("#given 3 council members with valid output files", () => {
|
|
describe("#when finalize is called and then each archive is read", () => {
|
|
it("#then creates archive with correct structure and archives are readable", async () => {
|
|
const agents = [
|
|
{ id: "bg_opus", agent: "Council: Claude Opus", response: "Opus deep analysis of architecture: This is a detailed analysis from Opus model covering the full scope of the council question." },
|
|
{ id: "bg_gpt", agent: "Council: GPT-5", response: "GPT pragmatic code review: This is a detailed analysis from GPT model covering the full scope of the council question." },
|
|
{ id: "bg_gemini", agent: "Council: Gemini", response: "Gemini creative alternative approach: This is a detailed analysis from Gemini model covering the full scope of the council question." },
|
|
]
|
|
|
|
for (const a of agents) {
|
|
await writeFile(
|
|
join(tmpDir, ".sisyphus", "task-outputs", `${a.id}.md`),
|
|
mockTaskOutput(a.agent, a.response),
|
|
"utf-8",
|
|
)
|
|
}
|
|
|
|
const finalizeTool = createCouncilFinalize(tmpDir)
|
|
const resultStr = await finalizeTool.execute(
|
|
{ task_ids: agents.map((a) => a.id), name: "test", intent: "FREEFORM" },
|
|
toolContext,
|
|
)
|
|
const result: CouncilFinalizeResult = JSON.parse(extractJson(resultStr))
|
|
|
|
expect(result.archive_dir).toMatch(/\.sisyphus\/athena\/council-test-[a-f0-9]{16}$/)
|
|
expect(result.meta_file).toMatch(/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.error).toBeUndefined()
|
|
expect(member.archive_file).toBeDefined()
|
|
}
|
|
|
|
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: "Council: GPT-5"')
|
|
expect(metaContent).toContain('member: "Council: Gemini"')
|
|
expect(metaContent).toContain("has_response: true")
|
|
expect(metaContent).toContain("response_complete: true")
|
|
|
|
for (let i = 0; i < agents.length; i++) {
|
|
const archiveContent = await readFile(join(tmpDir, result.members[i].archive_file!), "utf-8")
|
|
expect(archiveContent).toBe(agents[i].response)
|
|
}
|
|
|
|
})
|
|
})
|
|
})
|
|
|
|
describe("#given a member with incomplete tags (no closing tag)", () => {
|
|
describe("#when finalize is called and archive is read", () => {
|
|
it("#then finalize marks incomplete but archive still contains raw content", async () => {
|
|
const taskId = "bg_partial"
|
|
await writeFile(
|
|
join(tmpDir, ".sisyphus", "task-outputs", `${taskId}.md`),
|
|
mockTaskOutput("Council: Partial Agent", "Analysis still in progress...", false),
|
|
"utf-8",
|
|
)
|
|
|
|
const finalizeTool = createCouncilFinalize(tmpDir)
|
|
const resultStr = await finalizeTool.execute(
|
|
{ task_ids: [taskId], name: "partial", intent: "FREEFORM" },
|
|
toolContext,
|
|
)
|
|
const result: CouncilFinalizeResult = JSON.parse(extractJson(resultStr))
|
|
|
|
const member = result.members[0]
|
|
expect(member.has_response).toBe(true)
|
|
expect(member.response_complete).toBe(false)
|
|
expect(member.archive_file).toBeDefined()
|
|
|
|
const archiveContent = await readFile(join(tmpDir, member.archive_file!), "utf-8")
|
|
expect(archiveContent).toBe("Analysis still in progress...")
|
|
|
|
})
|
|
})
|
|
})
|
|
|
|
describe("#given a member with no COUNCIL_MEMBER_RESPONSE tags at all", () => {
|
|
describe("#when finalize is called", () => {
|
|
it("#then has_response is false and no archive file is created", async () => {
|
|
await writeFile(
|
|
join(tmpDir, ".sisyphus", "task-outputs", "bg_notags.md"),
|
|
mockTaskOutputNoTags("Council: Plain Agent", "Just some plain text with no special tags."),
|
|
"utf-8",
|
|
)
|
|
|
|
const finalizeTool = createCouncilFinalize(tmpDir)
|
|
const resultStr = await finalizeTool.execute(
|
|
{ task_ids: ["bg_notags"], name: "notags", intent: "FREEFORM" },
|
|
toolContext,
|
|
)
|
|
const result: CouncilFinalizeResult = JSON.parse(extractJson(resultStr))
|
|
|
|
const member = result.members[0]
|
|
expect(member.has_response).toBe(false)
|
|
expect(member.archive_file).toBeUndefined()
|
|
})
|
|
})
|
|
})
|
|
|
|
describe("#given 1 of 3 task output files is missing", () => {
|
|
describe("#when finalize is called with all 3 task_ids", () => {
|
|
it("#then 2 members succeed and 1 has error 'Task output file not found'", async () => {
|
|
await writeFile(
|
|
join(tmpDir, ".sisyphus", "task-outputs", "bg_first.md"),
|
|
mockTaskOutput("Council: First", "First analysis: This is a detailed analysis from First model covering the full scope of the council question."),
|
|
"utf-8",
|
|
)
|
|
await writeFile(
|
|
join(tmpDir, ".sisyphus", "task-outputs", "bg_third.md"),
|
|
mockTaskOutput("Council: Third", "Third analysis: This is a detailed analysis from Third model covering the full scope of the council question."),
|
|
"utf-8",
|
|
)
|
|
|
|
const finalizeTool = createCouncilFinalize(tmpDir)
|
|
const resultStr = await finalizeTool.execute(
|
|
{ task_ids: ["bg_first", "bg_missing", "bg_third"], name: "partial", intent: "FREEFORM" },
|
|
toolContext,
|
|
)
|
|
const result: CouncilFinalizeResult = JSON.parse(extractJson(resultStr))
|
|
|
|
expect(result.members).toHaveLength(3)
|
|
|
|
expect(result.members[0].has_response).toBe(true)
|
|
expect(result.members[0].archive_file).toBeDefined()
|
|
|
|
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].archive_file).toBeDefined()
|
|
})
|
|
})
|
|
})
|
|
|
|
describe("#given a very large council response exceeding 8000 chars", () => {
|
|
describe("#when finalize is called and then archive is read", () => {
|
|
it("#then finalize stores full output and archive contains full response", async () => {
|
|
const largeResponse = "A".repeat(9000)
|
|
const taskId = "bg_large"
|
|
await writeFile(
|
|
join(tmpDir, ".sisyphus", "task-outputs", `${taskId}.md`),
|
|
mockTaskOutput("Council: Large Agent", largeResponse),
|
|
"utf-8",
|
|
)
|
|
|
|
const finalizeTool = createCouncilFinalize(tmpDir)
|
|
const resultStr = await finalizeTool.execute(
|
|
{ task_ids: [taskId], name: "large", intent: "FREEFORM" },
|
|
toolContext,
|
|
)
|
|
const result: CouncilFinalizeResult = JSON.parse(extractJson(resultStr))
|
|
|
|
const member = result.members[0]
|
|
expect(member.has_response).toBe(true)
|
|
expect(member.archive_file).toBeDefined()
|
|
|
|
const fullContent = await readFile(join(tmpDir, member.archive_file!), "utf-8")
|
|
expect(fullContent).toHaveLength(9000)
|
|
|
|
})
|
|
})
|
|
})
|
|
|
|
describe("#given question and prompt_file params", () => {
|
|
describe("#when finalize is called with question and prompt_file", () => {
|
|
it("#then meta.yaml includes question and prompt_file, and prompt file is moved to archive", async () => {
|
|
const taskId = "bg_with_meta"
|
|
await writeFile(
|
|
join(tmpDir, ".sisyphus", "task-outputs", `${taskId}.md`),
|
|
mockTaskOutput("Council: Opus", "Analysis result: This is a detailed analysis from Opus model covering the full scope of the council question."),
|
|
"utf-8",
|
|
)
|
|
|
|
const tmpPromptDir = join(tmpDir, ".sisyphus", "tmp")
|
|
await mkdir(tmpPromptDir, { recursive: true })
|
|
const promptFile = join(".sisyphus", "tmp", "athena-council-test.md")
|
|
await writeFile(join(tmpDir, promptFile), "Council prompt content here", "utf-8")
|
|
|
|
const finalizeTool = createCouncilFinalize(tmpDir)
|
|
const resultStr = await finalizeTool.execute(
|
|
{
|
|
task_ids: [taskId],
|
|
name: "meta-test",
|
|
intent: "FREEFORM",
|
|
question: "What is the best architecture for this app?",
|
|
prompt_file: promptFile,
|
|
},
|
|
toolContext,
|
|
)
|
|
const result: CouncilFinalizeResult = JSON.parse(extractJson(resultStr))
|
|
|
|
const metaContent = await readFile(join(tmpDir, result.meta_file), "utf-8")
|
|
expect(metaContent).toContain("question: |")
|
|
expect(metaContent).toContain(" What is the best architecture for this app?")
|
|
expect(metaContent).toContain("prompt_file:")
|
|
expect(metaContent).toContain("council-prompt.md")
|
|
|
|
const promptDest = join(tmpDir, result.archive_dir, "council-prompt.md")
|
|
const promptContent = await readFile(promptDest, "utf-8")
|
|
expect(promptContent).toBe("Council prompt content here")
|
|
|
|
const originalExists = await stat(join(tmpDir, promptFile)).then(() => true).catch(() => false)
|
|
expect(originalExists).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe("#when finalize is called with question only (no prompt_file)", () => {
|
|
it("#then meta.yaml includes question but no prompt_file", async () => {
|
|
const taskId = "bg_question_only"
|
|
await writeFile(
|
|
join(tmpDir, ".sisyphus", "task-outputs", `${taskId}.md`),
|
|
mockTaskOutput("Council: GPT", "GPT analysis: This is a detailed analysis from GPT model covering the full scope of the council question."),
|
|
"utf-8",
|
|
)
|
|
|
|
const finalizeTool = createCouncilFinalize(tmpDir)
|
|
const resultStr = await finalizeTool.execute(
|
|
{
|
|
task_ids: [taskId],
|
|
name: "question-only",
|
|
intent: "FREEFORM",
|
|
question: "How should we handle auth?",
|
|
},
|
|
toolContext,
|
|
)
|
|
const result: CouncilFinalizeResult = JSON.parse(extractJson(resultStr))
|
|
|
|
const metaContent = await readFile(join(tmpDir, result.meta_file), "utf-8")
|
|
expect(metaContent).toContain("question: |")
|
|
expect(metaContent).toContain(" How should we handle auth?")
|
|
expect(metaContent).not.toContain("prompt_file:")
|
|
})
|
|
})
|
|
})
|
|
|
|
describe("#given a completed council task in background_wait", () => {
|
|
describe("#when background_wait returns for that task", () => {
|
|
it("#then completed_tasks entry has no result payload", async () => {
|
|
resetMessageCursor()
|
|
|
|
const manager = createMockManager({
|
|
"council-1": {
|
|
status: "completed",
|
|
agent: "Council: Claude Opus",
|
|
description: "Council analysis",
|
|
startedAt: new Date(Date.now() - 5000),
|
|
completedAt: new Date(),
|
|
},
|
|
})
|
|
const waitTool = createBackgroundWait(manager)
|
|
|
|
const result = await waitTool.execute({ task_ids: ["council-1"] }, toolContext)
|
|
const parsed = JSON.parse(result)
|
|
|
|
expect(parsed.completed_tasks).toBeArray()
|
|
expect(parsed.completed_tasks).toHaveLength(1)
|
|
expect(parsed.completed_tasks[0].task_id).toBe("council-1")
|
|
expect(parsed.completed_tasks[0].status).toBe("completed")
|
|
expect(parsed.completed_tasks[0].description).toBe("Council analysis")
|
|
expect(parsed.completed_tasks[0].result).toBeUndefined()
|
|
expect(parsed.timeout).toBe(false)
|
|
expect(parsed.aborted).toBe(false)
|
|
})
|
|
})
|
|
})
|
|
})
|