From 6e57d060e19f4f9e5c9ea2b0e89999a0303faa6f Mon Sep 17 00:00:00 2001 From: ismeth Date: Sun, 1 Mar 2026 18:36:27 +0100 Subject: [PATCH] fix(council-finalize): append runtime guidance to tool response for same-turn delivery Remove contextCollector dependency from council_finalize and append Athena runtime guidance directly to the tool's JSON response string. This fixes the timing bug where guidance registered via contextCollector only arrived on the next message cycle, too late for Athena's same-turn synthesis. - Remove ContextCollector import, RegisterContext type, and collector.register() block - Append buildAthenaRuntimeGuidance() output after JSON result with \n\n separator - Simplify createCouncilFinalize() factory call in tool-registry (no options) - Update unit tests: replace collector mock assertions with direct output string checks - Update integration tests: wrap JSON.parse with extractJson() helper for hybrid output --- src/plugin/tool-registry.ts | 3 +- .../council-flow.integration.test.ts | 22 ++- .../create-council-finalize.test.ts | 184 ++++++++---------- .../create-council-finalize.ts | 21 +- 4 files changed, 95 insertions(+), 135 deletions(-) diff --git a/src/plugin/tool-registry.ts b/src/plugin/tool-registry.ts index b78510d16..2a9cc13f7 100644 --- a/src/plugin/tool-registry.ts +++ b/src/plugin/tool-registry.ts @@ -32,7 +32,6 @@ import { createPrepareCouncilPromptTool, } from "../tools" import { createCouncilFinalize } from "../tools/council-archive" -import { contextCollector } from "../features/context-injector" import { getMainSessionID } from "../features/claude-code-session-state" import { filterDisabledTools } from "../shared/disabled-tools" import { isTaskSystemEnabled, log } from "../shared" @@ -282,7 +281,7 @@ export function createToolRegistry(args: { ...taskToolsRecord, ...hashlineToolsRecord, prepare_council_prompt: createPrepareCouncilPromptTool(ctx.directory), - council_finalize: createCouncilFinalize(ctx.directory, { contextCollector }), + council_finalize: createCouncilFinalize(ctx.directory), } for (const toolDefinition of Object.values(allTools)) { diff --git a/src/tools/council-archive/council-flow.integration.test.ts b/src/tools/council-archive/council-flow.integration.test.ts index eb0f2b5c4..9700165fc 100644 --- a/src/tools/council-archive/council-flow.integration.test.ts +++ b/src/tools/council-archive/council-flow.integration.test.ts @@ -11,6 +11,14 @@ 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 = "" + 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" : "" return [ @@ -107,7 +115,7 @@ describe("council archive integration flow", () => { { task_ids: agents.map((a) => a.id), name: "test", intent: "FREEFORM" }, toolContext, ) - const result: CouncilFinalizeResult = JSON.parse(resultStr) + const result: CouncilFinalizeResult = JSON.parse(extractJson(resultStr)) expect(result.archive_dir).toMatch(/\.sisyphus\/athena\/council-test-[a-f0-9]{4}$/) expect(result.meta_file).toMatch(/meta\.yaml$/) @@ -155,7 +163,7 @@ describe("council archive integration flow", () => { { task_ids: [taskId], name: "partial", intent: "FREEFORM" }, toolContext, ) - const result: CouncilFinalizeResult = JSON.parse(resultStr) + const result: CouncilFinalizeResult = JSON.parse(extractJson(resultStr)) const member = result.members[0] expect(member.has_response).toBe(true) @@ -183,7 +191,7 @@ describe("council archive integration flow", () => { { task_ids: ["bg_notags"], name: "notags", intent: "FREEFORM" }, toolContext, ) - const result: CouncilFinalizeResult = JSON.parse(resultStr) + const result: CouncilFinalizeResult = JSON.parse(extractJson(resultStr)) const member = result.members[0] expect(member.has_response).toBe(false) @@ -211,7 +219,7 @@ describe("council archive integration flow", () => { { task_ids: ["bg_first", "bg_missing", "bg_third"], name: "partial", intent: "FREEFORM" }, toolContext, ) - const result: CouncilFinalizeResult = JSON.parse(resultStr) + const result: CouncilFinalizeResult = JSON.parse(extractJson(resultStr)) expect(result.members).toHaveLength(3) @@ -244,7 +252,7 @@ describe("council archive integration flow", () => { { task_ids: [taskId], name: "large", intent: "FREEFORM" }, toolContext, ) - const result: CouncilFinalizeResult = JSON.parse(resultStr) + const result: CouncilFinalizeResult = JSON.parse(extractJson(resultStr)) const member = result.members[0] expect(member.has_response).toBe(true) @@ -283,7 +291,7 @@ describe("council archive integration flow", () => { }, toolContext, ) - const result: CouncilFinalizeResult = JSON.parse(resultStr) + const result: CouncilFinalizeResult = JSON.parse(extractJson(resultStr)) const metaContent = await readFile(join(tmpDir, result.meta_file), "utf-8") expect(metaContent).toContain("question: |") @@ -319,7 +327,7 @@ describe("council archive integration flow", () => { }, toolContext, ) - const result: CouncilFinalizeResult = JSON.parse(resultStr) + const result: CouncilFinalizeResult = JSON.parse(extractJson(resultStr)) const metaContent = await readFile(join(tmpDir, result.meta_file), "utf-8") expect(metaContent).toContain("question: |") diff --git a/src/tools/council-archive/create-council-finalize.test.ts b/src/tools/council-archive/create-council-finalize.test.ts index da741ba7b..bade4ec9a 100644 --- a/src/tools/council-archive/create-council-finalize.test.ts +++ b/src/tools/council-archive/create-council-finalize.test.ts @@ -26,6 +26,15 @@ function mockTaskOutput(agent: string, responseBody: string, complete = true): s ].join("\n") } +function extractJson(output: string): string { + const marker = "" + 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() +} + const mockCtx = { sessionID: "test-session", messageID: "test-message", @@ -62,7 +71,7 @@ describe("createCouncilFinalize", () => { { task_ids: agents.map((a) => a.id), name: "test", intent: "FREEFORM" }, mockCtx, ) - const result: CouncilFinalizeResult = JSON.parse(resultStr) + const result: CouncilFinalizeResult = JSON.parse(extractJson(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$/) @@ -108,7 +117,7 @@ describe("createCouncilFinalize", () => { { task_ids: ["bg_001", "bg_002", "bg_003"], name: "partial", intent: "FREEFORM" }, mockCtx, ) - const result: CouncilFinalizeResult = JSON.parse(resultStr) + const result: CouncilFinalizeResult = JSON.parse(extractJson(resultStr)) expect(result.members).toHaveLength(3) @@ -138,7 +147,7 @@ describe("createCouncilFinalize", () => { { task_ids: ["bg_large"], name: "large", intent: "FREEFORM" }, mockCtx, ) - const result: CouncilFinalizeResult = JSON.parse(resultStr) + const result: CouncilFinalizeResult = JSON.parse(extractJson(resultStr)) const member = result.members[0] expect(member.has_response).toBe(true) @@ -177,7 +186,7 @@ describe("createCouncilFinalize", () => { { task_ids: ["bg_empty"], name: "empty", intent: "FREEFORM" }, mockCtx, ) - const result: CouncilFinalizeResult = JSON.parse(resultStr) + const result: CouncilFinalizeResult = JSON.parse(extractJson(resultStr)) const member = result.members[0] expect(member.has_response).toBe(false) @@ -202,7 +211,7 @@ describe("createCouncilFinalize", () => { { task_ids: ["bg_alpha", "bg_beta"], name: "collision", intent: "FREEFORM" }, mockCtx, ) - const result: CouncilFinalizeResult = JSON.parse(resultStr) + const result: CouncilFinalizeResult = JSON.parse(extractJson(resultStr)) const firstArchive = result.members[0].archive_file const secondArchive = result.members[1].archive_file @@ -225,37 +234,19 @@ describe("createCouncilFinalize", () => { "utf-8", ) - const calls: Array<{ sessionID: string; content: string; priority?: string; source: string; id: string }> = [] - const collector = { - register: (sessionID: string, options: { id: string; source: string; content: string; priority?: string }) => { - calls.push({ - sessionID, - id: options.id, - source: options.source, - content: options.content, - priority: options.priority, - }) - }, - } - - const toolDef = createCouncilFinalize(tmpDir, { contextCollector: collector }) + const toolDef = createCouncilFinalize(tmpDir) const result = await toolDef.execute( { task_ids: ["bg_intent"], name: "intent", intent: "PLAN" }, mockCtx, ) - expect(() => JSON.parse(result)).not.toThrow() - expect(calls).toHaveLength(1) - expect(calls[0].sessionID).toBe(mockCtx.sessionID) - expect(calls[0].id).toBe("athena-runtime-guidance") - expect(calls[0].source).toBe("custom") - expect(calls[0].priority).toBe("critical") - expect(calls[0].content).toContain("") - expect(calls[0].content).toContain("intent: PLAN") - expect(calls[0].content).toContain("Plan full scope (Prometheus)") - expect(calls[0].content).toContain("Plan selected phase (Prometheus)") - expect(calls[0].content).toContain(".sisyphus/athena/notes/") - expect(calls[0].content).not.toContain("Hand off to Atlas to save the plan as .md") + expect(() => JSON.parse(extractJson(result))).not.toThrow() + expect(result).toContain("") + expect(result).toContain("intent: PLAN") + expect(result).toContain("Plan full scope (Prometheus)") + expect(result).toContain("Plan selected phase (Prometheus)") + expect(result).toContain(".sisyphus/athena/notes/") + expect(result).not.toContain("Hand off to Atlas to save the plan as .md") }) it("#then emits diagnose action options for hephaestus and sisyphus", async () => { @@ -265,29 +256,22 @@ describe("createCouncilFinalize", () => { "utf-8", ) - const calls: Array<{ content: string }> = [] - const collector = { - register: (_sessionID: string, options: { content: string }) => { - calls.push({ content: options.content }) - }, - } - - const toolDef = createCouncilFinalize(tmpDir, { contextCollector: collector }) + const toolDef = createCouncilFinalize(tmpDir) const result = await toolDef.execute( { task_ids: ["bg_diagnose"], name: "diagnose", intent: "DIAGNOSE" }, mockCtx, ) - expect(() => JSON.parse(result)).not.toThrow() - expect(calls).toHaveLength(1) - expect(calls[0].content).toContain("Implement (Hephaestus)") - expect(calls[0].content).toContain("Implement (Sisyphus)") - expect(calls[0].content).toContain("Implement (Sisyphus ultrawork)") - expect(calls[0].content).toContain("switch_agent(agent=\"hephaestus\")") - expect(calls[0].content).toContain("switch_agent(agent=\"sisyphus\")") - expect(calls[0].content).toContain("prefix the handoff context with \"ultrawork \"") - expect(calls[0].content).not.toContain("Fix now (Atlas)") - expect(calls[0].content).not.toContain("Create plan (Prometheus)") + expect(() => JSON.parse(extractJson(result))).not.toThrow() + expect(result).toContain("") + expect(result).toContain("Implement (Hephaestus)") + expect(result).toContain("Implement (Sisyphus)") + expect(result).toContain("Implement (Sisyphus ultrawork)") + expect(result).toContain("switch_agent(agent=\"hephaestus\")") + expect(result).toContain("switch_agent(agent=\"sisyphus\")") + expect(result).toContain("prefix the handoff context with \"ultrawork \"") + expect(result).not.toContain("Fix now (Atlas)") + expect(result).not.toContain("Create plan (Prometheus)") }) it("#then emits audit processing mode and batching guidance", async () => { @@ -297,44 +281,37 @@ describe("createCouncilFinalize", () => { "utf-8", ) - const calls: Array<{ content: string }> = [] - const collector = { - register: (_sessionID: string, options: { content: string }) => { - calls.push({ content: options.content }) - }, - } - - const toolDef = createCouncilFinalize(tmpDir, { contextCollector: collector }) + const toolDef = createCouncilFinalize(tmpDir) const result = await toolDef.execute( { task_ids: ["bg_audit"], name: "audit", intent: "AUDIT" }, mockCtx, ) - expect(() => JSON.parse(result)).not.toThrow() - expect(calls).toHaveLength(1) - expect(calls[0].content).toContain("How would you like to process the findings?") - expect(calls[0].content).toContain("One by one") - expect(calls[0].content).toContain("By severity/urgency") - expect(calls[0].content).toContain("By quorum") - expect(calls[0].content).toContain("Default batch size: 3 findings per batch") - expect(calls[0].content).toContain("Hard cap: 5 findings") - expect(calls[0].content).toContain("Example Question tool call (batch of 3 findings)") - expect(calls[0].content).toContain("Finding #10: choose how to proceed.") - expect(calls[0].content).toContain("#10 Action") - expect(calls[0].content).toContain("Stop review") - expect(calls[0].content).toContain("#10:A, #11:skip") - expect(calls[0].content).toContain("Which findings should we act on by severity?") - expect(calls[0].content).toContain("All Critical (N)") - expect(calls[0].content).toContain("All High (N)") - expect(calls[0].content).toContain("All Medium (N)") - expect(calls[0].content).toContain("All Low (N)") - expect(calls[0].content).toContain("Which findings should we act on? You can also type specific finding numbers") - expect(calls[0].content).toContain("All Unanimous (N)") - expect(calls[0].content).toContain("All Majority (N)") - expect(calls[0].content).toContain("All Minority (N)") - expect(calls[0].content).toContain("All Solo (N)") - expect(calls[0].content).toContain("Fix now (Atlas)") - expect(calls[0].content).toContain("Create plan (Prometheus)") + expect(() => JSON.parse(extractJson(result))).not.toThrow() + expect(result).toContain("") + expect(result).toContain("How would you like to process the findings?") + expect(result).toContain("One by one") + expect(result).toContain("By severity/urgency") + expect(result).toContain("By quorum") + expect(result).toContain("Default batch size: 3 findings per batch") + expect(result).toContain("Hard cap: 5 findings") + expect(result).toContain("Example Question tool call (batch of 3 findings)") + expect(result).toContain("Finding #10: choose how to proceed.") + expect(result).toContain("#10 Action") + expect(result).toContain("Stop review") + expect(result).toContain("#10:A, #11:skip") + expect(result).toContain("Which findings should we act on by severity?") + expect(result).toContain("All Critical (N)") + expect(result).toContain("All High (N)") + expect(result).toContain("All Medium (N)") + expect(result).toContain("All Low (N)") + expect(result).toContain("Which findings should we act on? You can also type specific finding numbers") + expect(result).toContain("All Unanimous (N)") + expect(result).toContain("All Majority (N)") + expect(result).toContain("All Minority (N)") + expect(result).toContain("All Solo (N)") + expect(result).toContain("Fix now (Atlas)") + expect(result).toContain("Create plan (Prometheus)") }) it("#then emits informational write-to-document path without atlas delegation", async () => { @@ -344,26 +321,19 @@ describe("createCouncilFinalize", () => { "utf-8", ) - const calls: Array<{ content: string }> = [] - const collector = { - register: (_sessionID: string, options: { content: string }) => { - calls.push({ content: options.content }) - }, - } - - const toolDef = createCouncilFinalize(tmpDir, { contextCollector: collector }) + const toolDef = createCouncilFinalize(tmpDir) const result = await toolDef.execute( { task_ids: ["bg_eval"], name: "eval", intent: "EVALUATE" }, mockCtx, ) - expect(() => JSON.parse(result)).not.toThrow() - expect(calls).toHaveLength(1) - expect(calls[0].content).toContain("What should we do with this evaluation?") - expect(calls[0].content).toContain("Adopt option -> create plan (Prometheus)") - expect(calls[0].content).toContain("Adopt option -> implement now") - expect(calls[0].content).toContain(".sisyphus/athena/notes/") - expect(calls[0].content).not.toContain("Write to document (Atlas)") + expect(() => JSON.parse(extractJson(result))).not.toThrow() + expect(result).toContain("") + expect(result).toContain("What should we do with this evaluation?") + expect(result).toContain("Adopt option -> create plan (Prometheus)") + expect(result).toContain("Adopt option -> implement now") + expect(result).toContain(".sisyphus/athena/notes/") + expect(result).not.toContain("Write to document (Atlas)") }) it("#then rejects invalid intent values", async () => { @@ -391,7 +361,7 @@ describe("createCouncilFinalize", () => { { task_ids: ["../../etc/passwd", "bg_valid", "foo/bar"], name: "traversal", intent: "FREEFORM" }, mockCtx, ) - const result: CouncilFinalizeResult = JSON.parse(resultStr) + const result: CouncilFinalizeResult = JSON.parse(extractJson(resultStr)) expect(result.members).toHaveLength(3) expect(result.members[0].has_response).toBe(false) @@ -413,7 +383,7 @@ describe("createCouncilFinalize", () => { { task_ids: ["bg_safe"], name: "../../etc", intent: "FREEFORM" }, mockCtx, ) - const result: CouncilFinalizeResult = JSON.parse(resultStr) + const result: CouncilFinalizeResult = JSON.parse(extractJson(resultStr)) expect(result.archive_dir).toMatch(/^\.sisyphus\/athena\/council-/) expect(result.archive_dir).not.toContain("..") @@ -427,7 +397,7 @@ describe("createCouncilFinalize", () => { { task_ids: ["bg_abs"], name: "/absolute/path", intent: "FREEFORM" }, mockCtx, ) - const result: CouncilFinalizeResult = JSON.parse(resultStr) + const result: CouncilFinalizeResult = JSON.parse(extractJson(resultStr)) expect(result.archive_dir).toMatch(/^\.sisyphus\/athena\/council-/) expect(result.archive_dir).not.toContain("/absolute/path") @@ -441,7 +411,7 @@ describe("createCouncilFinalize", () => { { task_ids: ["bg_mid"], name: "foo/../bar", intent: "FREEFORM" }, mockCtx, ) - const result: CouncilFinalizeResult = JSON.parse(resultStr) + const result: CouncilFinalizeResult = JSON.parse(extractJson(resultStr)) expect(result.archive_dir).toMatch(/^\.sisyphus\/athena\/council-/) expect(result.archive_dir).not.toContain("..") @@ -455,7 +425,7 @@ describe("createCouncilFinalize", () => { { task_ids: ["valid-id_123"], name: "valid", intent: "FREEFORM" }, mockCtx, ) - const result: CouncilFinalizeResult = JSON.parse(resultStr) + const result: CouncilFinalizeResult = JSON.parse(extractJson(resultStr)) expect(result.members).toHaveLength(1) expect(result.members[0].has_response).toBe(true) @@ -474,7 +444,7 @@ describe("createCouncilFinalize", () => { { task_ids: ["bg_prompt"], name: "prompt-test", prompt_file: "/etc/passwd", intent: "FREEFORM" }, mockCtx, ) - const result: CouncilFinalizeResult = JSON.parse(resultStr) + const result: CouncilFinalizeResult = JSON.parse(extractJson(resultStr)) expect(result.archive_dir).toBeDefined() const metaContent = await readFile(join(tmpDir, result.meta_file), "utf-8") @@ -493,7 +463,7 @@ describe("createCouncilFinalize", () => { { task_ids: ["bg_rel"], name: "rel-test", prompt_file: "../../etc/passwd", intent: "FREEFORM" }, mockCtx, ) - const result: CouncilFinalizeResult = JSON.parse(resultStr) + const result: CouncilFinalizeResult = JSON.parse(extractJson(resultStr)) expect(result.archive_dir).toBeDefined() const metaContent = await readFile(join(tmpDir, result.meta_file), "utf-8") @@ -514,7 +484,7 @@ describe("createCouncilFinalize", () => { { task_ids: ["bg_ok"], name: "valid-prompt", prompt_file: ".sisyphus/tmp/athena-council-test.md", intent: "FREEFORM" }, mockCtx, ) - const result: CouncilFinalizeResult = JSON.parse(resultStr) + const result: CouncilFinalizeResult = JSON.parse(extractJson(resultStr)) const metaContent = await readFile(join(tmpDir, result.meta_file), "utf-8") expect(metaContent).toContain("prompt_file:") @@ -526,7 +496,7 @@ describe("createCouncilFinalize", () => { { task_ids: ["bg\\evil"], name: "backslash", intent: "FREEFORM" }, mockCtx, ) - const result: CouncilFinalizeResult = JSON.parse(resultStr) + const result: CouncilFinalizeResult = JSON.parse(extractJson(resultStr)) expect(result.members).toHaveLength(1) expect(result.members[0].has_response).toBe(false) @@ -545,7 +515,7 @@ describe("createCouncilFinalize", () => { { task_ids: ["bg_empty_name"], name: "///...", intent: "FREEFORM" }, mockCtx, ) - const result: CouncilFinalizeResult = JSON.parse(resultStr) + const result: CouncilFinalizeResult = JSON.parse(extractJson(resultStr)) expect(result.archive_dir).toMatch(/^\.sisyphus\/athena\/council-unnamed-[a-f0-9]{4}$/) }) diff --git a/src/tools/council-archive/create-council-finalize.ts b/src/tools/council-archive/create-council-finalize.ts index 30af61361..e0fabe48d 100644 --- a/src/tools/council-archive/create-council-finalize.ts +++ b/src/tools/council-archive/create-council-finalize.ts @@ -11,19 +11,14 @@ import { resolveCouncilIntent, COUNCIL_DEFAULTS, } from "../../agents/athena" -import { log } from "../../shared/logger" -import type { ContextCollector } from "../../features/context-injector" import type { CouncilFinalizeArgs, CouncilMemberResult, CouncilFinalizeResult } from "./types" -type RegisterContext = Pick type CouncilFinalizeToolContext = { sessionID?: string } export function createCouncilFinalize( basePath?: string, - options?: { contextCollector?: RegisterContext } ): ToolDefinition { - const collector = options?.contextCollector return tool({ description: @@ -45,19 +40,6 @@ export function createCouncilFinalize( return `Invalid intent: "${args.intent}". Valid intents: ${getValidCouncilIntents().join(", ")}.` } - if (collector && resolvedIntent && toolContext.sessionID) { - collector.register(toolContext.sessionID, { - id: "athena-runtime-guidance", - source: "custom", - priority: "critical", - content: buildAthenaRuntimeGuidance(resolvedIntent), - metadata: { - intent: resolvedIntent, - source: "council_finalize", - }, - }) - } - const base = basePath ?? process.cwd() const hexId = randomBytes(COUNCIL_DEFAULTS.ARCHIVE_ID_BYTES).toString("hex") const safeName = slugify(args.name) || "unnamed" @@ -192,7 +174,8 @@ export function createCouncilFinalize( members, } - return JSON.stringify(result, null, 2) + const guidance = buildAthenaRuntimeGuidance(resolvedIntent) + return JSON.stringify(result, null, 2) + "\n\n" + guidance }, }) }