From 87487d8d25d13627814d47c9acdd0ed7052e64b8 Mon Sep 17 00:00:00 2001 From: ismeth Date: Wed, 18 Feb 2026 20:55:48 +0100 Subject: [PATCH] fix(athena): add partial result tracking to session-waiter Return CouncilSessionWaitResult with timedOut/aborted flags instead of raw array, so callers know when results are partial. Add 5 tests covering normal flow, abort, partial results, and edge cases. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- .../athena-council/session-waiter.test.ts | 108 ++++++++++++++++++ src/tools/athena-council/session-waiter.ts | 21 +++- 2 files changed, 126 insertions(+), 3 deletions(-) create mode 100644 src/tools/athena-council/session-waiter.test.ts diff --git a/src/tools/athena-council/session-waiter.test.ts b/src/tools/athena-council/session-waiter.test.ts new file mode 100644 index 000000000..8c3918b7e --- /dev/null +++ b/src/tools/athena-council/session-waiter.test.ts @@ -0,0 +1,108 @@ +/// + +import { describe, expect, test } from "bun:test" +import type { BackgroundManager } from "../../features/background-agent" +import { waitForCouncilSessions } from "./session-waiter" + +describe("waitForCouncilSessions", () => { + test("resolves all sessions when tasks have sessionIDs immediately", async () => { + //#given + const launched = [ + { member: { model: "openai/gpt-5.3-codex", name: "GPT" }, taskId: "task-1" }, + { member: { model: "anthropic/claude-opus-4-6" }, taskId: "task-2" }, + ] + const manager = { + getTask: (id: string) => ({ sessionID: `ses-${id}` }), + } as unknown as BackgroundManager + + //#when + const result = await waitForCouncilSessions(launched, manager) + + //#then + expect(result.sessions).toHaveLength(2) + expect(result.timedOut).toBe(false) + expect(result.aborted).toBe(false) + expect(result.sessions[0].taskId).toBe("task-1") + expect(result.sessions[0].memberName).toBe("GPT") + expect(result.sessions[1].taskId).toBe("task-2") + expect(result.sessions[1].memberName).toBe("anthropic/claude-opus-4-6") + }) + + test("returns empty sessions for empty launched list", async () => { + //#given + const manager = { getTask: () => undefined } as unknown as BackgroundManager + + //#when + const result = await waitForCouncilSessions([], manager) + + //#then + expect(result.sessions).toHaveLength(0) + expect(result.timedOut).toBe(false) + expect(result.aborted).toBe(false) + }) + + test("sets aborted flag when abort signal fires", async () => { + //#given + const launched = [ + { member: { model: "openai/gpt-5.3-codex" }, taskId: "task-1" }, + ] + const manager = { getTask: () => undefined } as unknown as BackgroundManager + const controller = new AbortController() + // Abort immediately + controller.abort() + + //#when + const result = await waitForCouncilSessions(launched, manager, controller.signal) + + //#then + expect(result.sessions).toHaveLength(0) + expect(result.aborted).toBe(true) + expect(result.timedOut).toBe(false) + }) + + test("resolves partial sessions when some tasks get sessionIDs", async () => { + //#given + const launched = [ + { member: { model: "openai/gpt-5.3-codex", name: "GPT" }, taskId: "task-1" }, + { member: { model: "anthropic/claude-opus-4-6" }, taskId: "task-2" }, + ] + const controller = new AbortController() + let callCount = 0 + const manager = { + getTask: (id: string) => { + callCount++ + // Only task-1 gets a session, task-2 never does + if (id === "task-1") return { sessionID: "ses-task-1" } + return undefined + }, + } as unknown as BackgroundManager + + // Abort after a short delay to avoid waiting full 30s + setTimeout(() => controller.abort(), 200) + + //#when + const result = await waitForCouncilSessions(launched, manager, controller.signal) + + //#then + expect(result.sessions).toHaveLength(1) + expect(result.sessions[0].taskId).toBe("task-1") + expect(result.aborted).toBe(true) + }) + + test("uses member model as memberName when name is not provided", async () => { + //#given + const launched = [ + { member: { model: "google/gemini-3-pro" }, taskId: "task-1" }, + ] + const manager = { + getTask: () => ({ sessionID: "ses-1" }), + } as unknown as BackgroundManager + + //#when + const result = await waitForCouncilSessions(launched, manager) + + //#then + expect(result.sessions[0].memberName).toBe("google/gemini-3-pro") + expect(result.sessions[0].model).toBe("google/gemini-3-pro") + }) +}) diff --git a/src/tools/athena-council/session-waiter.ts b/src/tools/athena-council/session-waiter.ts index 071b27c81..bc7f39478 100644 --- a/src/tools/athena-council/session-waiter.ts +++ b/src/tools/athena-council/session-waiter.ts @@ -11,6 +11,12 @@ interface CouncilSessionInfo { sessionId: string } +export interface CouncilSessionWaitResult { + sessions: CouncilSessionInfo[] + timedOut: boolean + aborted: boolean +} + /** * Waits for background sessions to be created for launched council members. * Returns session info for each member whose session became available within the timeout. @@ -19,16 +25,21 @@ export async function waitForCouncilSessions( launched: CouncilLaunchedMember[], manager: BackgroundManager, abort?: AbortSignal -): Promise { +): Promise { const results: CouncilSessionInfo[] = [] const pending = new Map( launched.map((entry) => [entry.taskId, entry]) ) const deadline = Date.now() + WAIT_TIMEOUT_MS + let timedOut = false + let aborted = false while (pending.size > 0 && Date.now() < deadline) { - if (abort?.aborted) break + if (abort?.aborted) { + aborted = true + break + } for (const [taskId, entry] of pending) { const task = manager.getTask(taskId) @@ -48,5 +59,9 @@ export async function waitForCouncilSessions( } } - return results + if (pending.size > 0 && !aborted) { + timedOut = true + } + + return { sessions: results, timedOut, aborted } }