Files
oh-my-opencode/src/cli/run/poll-for-completion.test.ts
T
YeonGyu-Kim 24f4e14f07 Simplify poll completion test setup
Move repeated console suppression and abort scheduling into shared helpers so each test focuses on completion state transitions instead of harness noise.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-11 19:42:46 +09:00

373 lines
12 KiB
TypeScript

import { afterEach, beforeEach, describe, it, expect, mock, spyOn } from "bun:test"
import type { RunContext, Todo, ChildSession, SessionStatus } from "./types"
import { createEventState } from "./events"
import { pollForCompletion } from "./poll-for-completion"
const createMockContext = (overrides: {
todo?: Todo[]
childrenBySession?: Record<string, ChildSession[]>
statuses?: Record<string, SessionStatus>
} = {}): RunContext => {
const {
todo = [],
childrenBySession = { "test-session": [] },
statuses = {},
} = overrides
return {
client: {
session: {
todo: mock(() => Promise.resolve({ data: todo })),
children: mock((opts: { path: { id: string } }) =>
Promise.resolve({ data: childrenBySession[opts.path.id] ?? [] })
),
status: mock(() => Promise.resolve({ data: statuses })),
},
} as unknown as RunContext["client"],
sessionID: "test-session",
directory: "/test",
abortController: new AbortController(),
}
}
let consoleLogSpy: ReturnType<typeof spyOn>
let consoleErrorSpy: ReturnType<typeof spyOn>
function abortAfter(abortController: AbortController, delayMs: number): void {
setTimeout(() => abortController.abort(), delayMs)
}
beforeEach(() => {
consoleLogSpy = spyOn(console, "log").mockImplementation(() => {})
consoleErrorSpy = spyOn(console, "error").mockImplementation(() => {})
})
afterEach(() => {
consoleLogSpy.mockRestore()
consoleErrorSpy.mockRestore()
})
describe("pollForCompletion", () => {
it("requires consecutive stability checks before exiting - not immediate", async () => {
//#given - 0 todos, 0 children, session idle, meaningful work done
const ctx = createMockContext()
const eventState = createEventState()
eventState.mainSessionIdle = true
eventState.hasReceivedMeaningfulWork = true
const abortController = new AbortController()
//#when
const result = await pollForCompletion(ctx, eventState, abortController, {
pollIntervalMs: 10,
requiredConsecutive: 3,
minStabilizationMs: 10,
})
//#then - exits with 0 but only after 3 consecutive checks
expect(result).toBe(0)
const todoCallCount = (ctx.client.session.todo as ReturnType<typeof mock>).mock.calls.length
expect(todoCallCount).toBeGreaterThanOrEqual(3)
})
it("does not check completion during stabilization period after first meaningful work", async () => {
//#given - session idle, meaningful work done, but stabilization period not elapsed
const ctx = createMockContext()
const eventState = createEventState()
eventState.mainSessionIdle = true
eventState.hasReceivedMeaningfulWork = true
const abortController = new AbortController()
//#when - abort after 50ms (within the 60ms stabilization period)
abortAfter(abortController, 50)
const result = await pollForCompletion(ctx, eventState, abortController, {
pollIntervalMs: 10,
requiredConsecutive: 3,
minStabilizationMs: 60,
})
//#then - should be aborted, not completed (stabilization blocked completion check)
expect(result).toBe(130)
const todoCallCount = (ctx.client.session.todo as ReturnType<typeof mock>).mock.calls.length
expect(todoCallCount).toBe(0)
})
it("does not exit when currentTool is set - resets consecutive counter", async () => {
//#given
const ctx = createMockContext()
const eventState = createEventState()
eventState.mainSessionIdle = true
eventState.hasReceivedMeaningfulWork = true
eventState.currentTool = "task"
const abortController = new AbortController()
//#when - abort after enough time to verify it didn't exit
abortAfter(abortController, 100)
const result = await pollForCompletion(ctx, eventState, abortController, {
pollIntervalMs: 10,
requiredConsecutive: 3,
minStabilizationMs: 500,
})
//#then - should be aborted, not completed (tool blocked exit)
expect(result).toBe(130)
const todoCallCount = (ctx.client.session.todo as ReturnType<typeof mock>).mock.calls.length
expect(todoCallCount).toBe(0)
})
it("resets consecutive counter when session becomes busy between checks", async () => {
//#given
const ctx = createMockContext()
const eventState = createEventState()
eventState.mainSessionIdle = true
eventState.hasReceivedMeaningfulWork = true
const abortController = new AbortController()
let todoCallCount = 0
let busyInserted = false
;(ctx.client.session as any).todo = mock(async () => {
todoCallCount++
if (todoCallCount === 1 && !busyInserted) {
busyInserted = true
eventState.mainSessionIdle = false
setTimeout(() => { eventState.mainSessionIdle = true }, 15)
}
return { data: [] }
})
;(ctx.client.session as any).children = mock(() =>
Promise.resolve({ data: [] })
)
;(ctx.client.session as any).status = mock(() =>
Promise.resolve({ data: {} })
)
//#when
const startMs = Date.now()
const result = await pollForCompletion(ctx, eventState, abortController, {
pollIntervalMs: 10,
requiredConsecutive: 3,
minStabilizationMs: 10,
})
const elapsedMs = Date.now() - startMs
//#then - took longer than 3 polls because busy interrupted the streak
expect(result).toBe(0)
expect(elapsedMs).toBeGreaterThan(30)
})
it("returns 1 on session error", async () => {
//#given
const ctx = createMockContext()
const eventState = createEventState()
eventState.mainSessionIdle = true
eventState.mainSessionError = true
eventState.lastError = "Test error"
const abortController = new AbortController()
//#when
const result = await pollForCompletion(ctx, eventState, abortController, {
pollIntervalMs: 10,
requiredConsecutive: 3,
minStabilizationMs: 500,
})
//#then
expect(result).toBe(1)
})
it("returns 130 when aborted", async () => {
//#given
const ctx = createMockContext()
const eventState = createEventState()
const abortController = new AbortController()
//#when
abortAfter(abortController, 50)
const result = await pollForCompletion(ctx, eventState, abortController, {
pollIntervalMs: 10,
requiredConsecutive: 3,
})
//#then
expect(result).toBe(130)
})
it("does not check completion when hasReceivedMeaningfulWork is false", async () => {
//#given
const ctx = createMockContext()
const eventState = createEventState()
eventState.mainSessionIdle = true
eventState.hasReceivedMeaningfulWork = false
const abortController = new AbortController()
//#when
abortAfter(abortController, 100)
const result = await pollForCompletion(ctx, eventState, abortController, {
pollIntervalMs: 10,
requiredConsecutive: 3,
})
//#then
expect(result).toBe(130)
const todoCallCount = (ctx.client.session.todo as ReturnType<typeof mock>).mock.calls.length
expect(todoCallCount).toBe(0)
})
it("falls back to session.status API when idle event is missing", async () => {
//#given - mainSessionIdle not set by events, but status API says idle
const ctx = createMockContext({
statuses: {
"test-session": { type: "idle" },
},
})
const eventState = createEventState()
eventState.mainSessionIdle = false
eventState.hasReceivedMeaningfulWork = true
const abortController = new AbortController()
//#when
const result = await pollForCompletion(ctx, eventState, abortController, {
pollIntervalMs: 10,
requiredConsecutive: 2,
minStabilizationMs: 10,
})
//#then - completion succeeds without idle event
expect(result).toBe(0)
})
it("allows silent completion after stabilization when no meaningful work is received", async () => {
//#given - session is idle and stable but no assistant message/tool event arrived
const ctx = createMockContext()
const eventState = createEventState()
eventState.mainSessionIdle = true
eventState.hasReceivedMeaningfulWork = false
const abortController = new AbortController()
//#when
const result = await pollForCompletion(ctx, eventState, abortController, {
pollIntervalMs: 10,
requiredConsecutive: 1,
minStabilizationMs: 30,
})
//#then - completion succeeds after stabilization window
expect(result).toBe(0)
})
it("uses default stabilization to avoid indefinite wait when no meaningful work arrives", async () => {
//#given - idle with no meaningful work and no explicit minStabilization override
const ctx = createMockContext()
const eventState = createEventState()
eventState.mainSessionIdle = true
eventState.hasReceivedMeaningfulWork = false
const abortController = new AbortController()
//#when
const result = await pollForCompletion(ctx, eventState, abortController, {
pollIntervalMs: 10,
requiredConsecutive: 1,
})
//#then - command exits without manual Ctrl+C
expect(result).toBe(0)
})
it("coerces non-positive stabilization values to default stabilization", async () => {
//#given - explicit zero stabilization should still wait for default window
const ctx = createMockContext()
const eventState = createEventState()
eventState.mainSessionIdle = true
eventState.hasReceivedMeaningfulWork = false
const abortController = new AbortController()
//#when - abort before default 1s window elapses
abortAfter(abortController, 100)
const result = await pollForCompletion(ctx, eventState, abortController, {
pollIntervalMs: 10,
requiredConsecutive: 1,
minStabilizationMs: 0,
})
//#then - should not complete early
expect(result).toBe(130)
})
it("simulates race condition: brief idle with 0 todos does not cause immediate exit", async () => {
//#given - simulate Sisyphus outputting text, session goes idle briefly, then tool fires
const ctx = createMockContext()
const eventState = createEventState()
eventState.mainSessionIdle = true
eventState.hasReceivedMeaningfulWork = true
const abortController = new AbortController()
let pollTick = 0
;(ctx.client.session as any).todo = mock(async () => {
pollTick++
if (pollTick === 2) {
eventState.currentTool = "task"
}
return { data: [] }
})
;(ctx.client.session as any).children = mock(() =>
Promise.resolve({ data: [] })
)
;(ctx.client.session as any).status = mock(() =>
Promise.resolve({ data: {} })
)
//#when - abort after tool stays in-flight
abortAfter(abortController, 200)
const result = await pollForCompletion(ctx, eventState, abortController, {
pollIntervalMs: 10,
requiredConsecutive: 3,
})
//#then - should NOT have exited with 0 (tool blocked it, then aborted)
expect(result).toBe(130)
})
it("returns 1 when session errors while not idle (error not masked by idle gate)", async () => {
//#given - mainSessionIdle=false, mainSessionError=true, lastError="crash"
const ctx = createMockContext()
const eventState = createEventState()
eventState.mainSessionIdle = false
eventState.mainSessionError = true
eventState.lastError = "crash"
eventState.hasReceivedMeaningfulWork = true
const abortController = new AbortController()
//#when - pollForCompletion runs
const result = await pollForCompletion(ctx, eventState, abortController, {
pollIntervalMs: 10,
requiredConsecutive: 3,
})
//#then - returns 1 (not 130/timeout), error message printed
expect(result).toBe(1)
const errorCalls = (console.error as ReturnType<typeof mock>).mock.calls
expect(errorCalls.some((call: unknown[]) => String(call[0] ?? "").includes("Session ended with error"))).toBe(true)
})
it("returns 1 when session errors while tool is active (error not masked by tool gate)", async () => {
//#given - mainSessionIdle=true, currentTool="bash", mainSessionError=true
const ctx = createMockContext()
const eventState = createEventState()
eventState.mainSessionIdle = true
eventState.currentTool = "bash"
eventState.mainSessionError = true
eventState.lastError = "error during tool"
eventState.hasReceivedMeaningfulWork = true
const abortController = new AbortController()
//#when
const result = await pollForCompletion(ctx, eventState, abortController, {
pollIntervalMs: 10,
requiredConsecutive: 3,
})
//#then - returns 1
expect(result).toBe(1)
})
})