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 <clio-agent@sisyphuslabs.ai>
This commit is contained in:
108
src/tools/athena-council/session-waiter.test.ts
Normal file
108
src/tools/athena-council/session-waiter.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
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")
|
||||
})
|
||||
})
|
||||
@@ -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<CouncilSessionInfo[]> {
|
||||
): Promise<CouncilSessionWaitResult> {
|
||||
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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user