- Change MIN_STABILIZATION_MS from 0 to 1_000 to prevent premature exits - Coerce non-positive minStabilizationMs to default instead of treating as disabled - Fix stabilization logic: track firstWorkTimestamp inside the meaningful-work branch - Add tests for default stabilization behavior and zero-value coercion 🤖 Generated with assistance of [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
384 lines
14 KiB
TypeScript
384 lines
14 KiB
TypeScript
import { 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(),
|
|
}
|
|
}
|
|
|
|
describe("pollForCompletion", () => {
|
|
it("requires consecutive stability checks before exiting - not immediate", async () => {
|
|
//#given - 0 todos, 0 children, session idle, meaningful work done
|
|
spyOn(console, "log").mockImplementation(() => {})
|
|
spyOn(console, "error").mockImplementation(() => {})
|
|
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
|
|
spyOn(console, "log").mockImplementation(() => {})
|
|
spyOn(console, "error").mockImplementation(() => {})
|
|
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)
|
|
setTimeout(() => abortController.abort(), 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
|
|
spyOn(console, "log").mockImplementation(() => {})
|
|
spyOn(console, "error").mockImplementation(() => {})
|
|
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
|
|
setTimeout(() => abortController.abort(), 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
|
|
spyOn(console, "log").mockImplementation(() => {})
|
|
spyOn(console, "error").mockImplementation(() => {})
|
|
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
|
|
spyOn(console, "log").mockImplementation(() => {})
|
|
spyOn(console, "error").mockImplementation(() => {})
|
|
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
|
|
spyOn(console, "log").mockImplementation(() => {})
|
|
spyOn(console, "error").mockImplementation(() => {})
|
|
const ctx = createMockContext()
|
|
const eventState = createEventState()
|
|
const abortController = new AbortController()
|
|
|
|
//#when
|
|
setTimeout(() => abortController.abort(), 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
|
|
spyOn(console, "log").mockImplementation(() => {})
|
|
spyOn(console, "error").mockImplementation(() => {})
|
|
const ctx = createMockContext()
|
|
const eventState = createEventState()
|
|
eventState.mainSessionIdle = true
|
|
eventState.hasReceivedMeaningfulWork = false
|
|
const abortController = new AbortController()
|
|
|
|
//#when
|
|
setTimeout(() => abortController.abort(), 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
|
|
spyOn(console, "log").mockImplementation(() => {})
|
|
spyOn(console, "error").mockImplementation(() => {})
|
|
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
|
|
spyOn(console, "log").mockImplementation(() => {})
|
|
spyOn(console, "error").mockImplementation(() => {})
|
|
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
|
|
spyOn(console, "log").mockImplementation(() => {})
|
|
spyOn(console, "error").mockImplementation(() => {})
|
|
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
|
|
spyOn(console, "log").mockImplementation(() => {})
|
|
spyOn(console, "error").mockImplementation(() => {})
|
|
const ctx = createMockContext()
|
|
const eventState = createEventState()
|
|
eventState.mainSessionIdle = true
|
|
eventState.hasReceivedMeaningfulWork = false
|
|
const abortController = new AbortController()
|
|
|
|
//#when - abort before default 1s window elapses
|
|
setTimeout(() => abortController.abort(), 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
|
|
spyOn(console, "log").mockImplementation(() => {})
|
|
spyOn(console, "error").mockImplementation(() => {})
|
|
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
|
|
setTimeout(() => abortController.abort(), 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"
|
|
spyOn(console, "log").mockImplementation(() => {})
|
|
spyOn(console, "error").mockImplementation(() => {})
|
|
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
|
|
spyOn(console, "log").mockImplementation(() => {})
|
|
spyOn(console, "error").mockImplementation(() => {})
|
|
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)
|
|
})
|
|
|
|
})
|