🏆 test: optimize test suite with FakeTimers and race condition fixes (#1284)

* fix: exclude prompt/permission from plan agent config

plan agent should only inherit model settings from prometheus,
not the prompt or permission. This ensures plan agent uses
OpenCode's default behavior while only overriding the model.

* test(todo-continuation-enforcer): use FakeTimers for 15x faster tests

- Add custom FakeTimers implementation (~100 lines)
- Replace all real setTimeout waits with fakeTimers.advanceBy()
- Test time: 104.6s → 7.01s

* test(callback-server): fix race conditions with Promise.all and Bun.fetch

- Use Bun.fetch.bind(Bun) to avoid globalThis.fetch mock interference
- Use Promise.all pattern for concurrent fetch/waitForCallback
- Add Bun.sleep(10) in afterEach for port release

* test(concurrency): replace placeholder assertions with getCount checks

Replace 6 meaningless expect(true).toBe(true) assertions with
actual getCount() verifications for test quality improvement

* refactor(config-handler): simplify planDemoteConfig creation

Remove unnecessary IIFE and destructuring, use direct spread instead

* test(executor): use FakeTimeouts for faster tests

- Add custom FakeTimeouts implementation
- Replace setTimeout waits with fakeTimeouts.advanceBy()
- Test time reduced from ~26s to ~6.8s

* test: fix gemini model mock for artistry unstable mode

* test: fix model list mock payload shape

* test: mock provider models for artistry category

---------

Co-authored-by: justsisyphus <justsisyphus@users.noreply.github.com>
This commit is contained in:
YeonGyu-Kim 2026-01-30 22:10:52 +09:00 committed by GitHub
parent 3ced20d1ab
commit 8c2625cfb0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 285 additions and 76 deletions

View File

@ -176,8 +176,8 @@ describe("ConcurrencyManager.acquire/release", () => {
await manager.acquire("model-a") await manager.acquire("model-a")
await manager.acquire("model-a") await manager.acquire("model-a")
// #then - both resolved without waiting // #then - both resolved without waiting, count should be 2
expect(true).toBe(true) expect(manager.getCount("model-a")).toBe(2)
}) })
test("should allow acquires up to default limit of 5", async () => { test("should allow acquires up to default limit of 5", async () => {
@ -190,8 +190,8 @@ describe("ConcurrencyManager.acquire/release", () => {
await manager.acquire("model-a") await manager.acquire("model-a")
await manager.acquire("model-a") await manager.acquire("model-a")
// #then - all 5 resolved // #then - all 5 resolved, count should be 5
expect(true).toBe(true) expect(manager.getCount("model-a")).toBe(5)
}) })
test("should queue when limit reached", async () => { test("should queue when limit reached", async () => {
@ -276,8 +276,8 @@ describe("ConcurrencyManager.acquire/release", () => {
manager.release("model-a") manager.release("model-a")
await manager.acquire("model-a") await manager.acquire("model-a")
// #then // #then - count should be 1 after re-acquiring
expect(true).toBe(true) expect(manager.getCount("model-a")).toBe(1)
}) })
test("should handle release when no acquire", () => { test("should handle release when no acquire", () => {
@ -288,21 +288,21 @@ describe("ConcurrencyManager.acquire/release", () => {
// #when - release without acquire // #when - release without acquire
manager.release("model-a") manager.release("model-a")
// #then - should not throw // #then - count should be 0 (no negative count)
expect(true).toBe(true) expect(manager.getCount("model-a")).toBe(0)
}) })
test("should handle release when no prior acquire", () => { test("should handle release when no prior acquire", () => {
// #given - default config // #given - default config
// #when - release without acquire // #when - release without acquire
manager.release("model-a") manager.release("model-a")
// #then - should not throw // #then - count should be 0 (no negative count)
expect(true).toBe(true) expect(manager.getCount("model-a")).toBe(0)
}) })
test("should handle multiple acquires and releases correctly", async () => { test("should handle multiple acquires and releases correctly", async () => {
// #given // #given
const config: BackgroundTaskConfig = { defaultConcurrency: 3 } const config: BackgroundTaskConfig = { defaultConcurrency: 3 }
manager = new ConcurrencyManager(config) manager = new ConcurrencyManager(config)
@ -317,11 +317,11 @@ describe("ConcurrencyManager.acquire/release", () => {
manager.release("model-a") manager.release("model-a")
manager.release("model-a") manager.release("model-a")
// Should be able to acquire again // Should be able to acquire again
await manager.acquire("model-a") await manager.acquire("model-a")
// #then // #then - count should be 1 after re-acquiring
expect(true).toBe(true) expect(manager.getCount("model-a")).toBe(1)
}) })
test("should use model-specific limit for acquire", async () => { test("should use model-specific limit for acquire", async () => {

View File

@ -1,6 +1,8 @@
import { afterEach, describe, expect, it } from "bun:test" import { afterEach, describe, expect, it } from "bun:test"
import { findAvailablePort, startCallbackServer, type CallbackServer } from "./callback-server" import { findAvailablePort, startCallbackServer, type CallbackServer } from "./callback-server"
const nativeFetch = Bun.fetch.bind(Bun)
describe("findAvailablePort", () => { describe("findAvailablePort", () => {
it("returns the start port when it is available", async () => { it("returns the start port when it is available", async () => {
//#given //#given
@ -34,9 +36,11 @@ describe("findAvailablePort", () => {
describe("startCallbackServer", () => { describe("startCallbackServer", () => {
let server: CallbackServer | null = null let server: CallbackServer | null = null
afterEach(() => { afterEach(async () => {
server?.close() server?.close()
server = null server = null
// Allow time for port to be released before next test
await Bun.sleep(10)
}) })
it("starts server and returns port", async () => { it("starts server and returns port", async () => {
@ -57,9 +61,12 @@ describe("startCallbackServer", () => {
const callbackUrl = `http://127.0.0.1:${server.port}/oauth/callback?code=test-code&state=test-state` const callbackUrl = `http://127.0.0.1:${server.port}/oauth/callback?code=test-code&state=test-state`
//#when //#when
const fetchPromise = fetch(callbackUrl) // Use Promise.all to ensure fetch and waitForCallback run concurrently
const result = await server.waitForCallback() // This prevents race condition where waitForCallback blocks before fetch starts
const response = await fetchPromise const [result, response] = await Promise.all([
server.waitForCallback(),
nativeFetch(callbackUrl)
])
//#then //#then
expect(result).toEqual({ code: "test-code", state: "test-state" }) expect(result).toEqual({ code: "test-code", state: "test-state" })
@ -73,7 +80,7 @@ describe("startCallbackServer", () => {
server = await startCallbackServer() server = await startCallbackServer()
//#when //#when
const response = await fetch(`http://127.0.0.1:${server.port}/other`) const response = await nativeFetch(`http://127.0.0.1:${server.port}/other`)
//#then //#then
expect(response.status).toBe(404) expect(response.status).toBe(404)
@ -85,7 +92,7 @@ describe("startCallbackServer", () => {
const callbackRejection = server.waitForCallback().catch((e: Error) => e) const callbackRejection = server.waitForCallback().catch((e: Error) => e)
//#when //#when
const response = await fetch(`http://127.0.0.1:${server.port}/oauth/callback?state=s`) const response = await nativeFetch(`http://127.0.0.1:${server.port}/oauth/callback?state=s`)
//#then //#then
expect(response.status).toBe(400) expect(response.status).toBe(400)
@ -100,7 +107,7 @@ describe("startCallbackServer", () => {
const callbackRejection = server.waitForCallback().catch((e: Error) => e) const callbackRejection = server.waitForCallback().catch((e: Error) => e)
//#when //#when
const response = await fetch(`http://127.0.0.1:${server.port}/oauth/callback?code=c`) const response = await nativeFetch(`http://127.0.0.1:${server.port}/oauth/callback?code=c`)
//#then //#then
expect(response.status).toBe(400) expect(response.status).toBe(400)
@ -120,7 +127,7 @@ describe("startCallbackServer", () => {
//#then //#then
try { try {
await fetch(`http://127.0.0.1:${port}/oauth/callback?code=c&state=s`) await nativeFetch(`http://127.0.0.1:${port}/oauth/callback?code=c&state=s`)
expect(true).toBe(false) expect(true).toBe(false)
} catch (error) { } catch (error) {
expect(error).toBeDefined() expect(error).toBeDefined()

View File

@ -1,11 +1,83 @@
import { describe, test, expect, mock, beforeEach, spyOn } from "bun:test" import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"
import { executeCompact } from "./executor" import { executeCompact } from "./executor"
import type { AutoCompactState } from "./types" import type { AutoCompactState } from "./types"
import * as storage from "./storage" import * as storage from "./storage"
type TimerCallback = (...args: any[]) => void
interface FakeTimeouts {
advanceBy: (ms: number) => Promise<void>
restore: () => void
}
function createFakeTimeouts(): FakeTimeouts {
let now = 0
let nextId = 1
const timers = new Map<number, { id: number; time: number; callback: TimerCallback; args: any[] }>()
const cleared = new Set<number>()
const original = {
setTimeout: globalThis.setTimeout,
clearTimeout: globalThis.clearTimeout,
}
const normalizeDelay = (delay?: number) => {
if (typeof delay !== "number" || !Number.isFinite(delay)) return 0
return delay < 0 ? 0 : delay
}
globalThis.setTimeout = ((callback: TimerCallback, delay?: number, ...args: any[]) => {
const id = nextId++
timers.set(id, {
id,
time: now + normalizeDelay(delay),
callback,
args,
})
return id as unknown as ReturnType<typeof setTimeout>
}) as typeof setTimeout
globalThis.clearTimeout = ((id?: number) => {
if (typeof id !== "number") return
cleared.add(id)
timers.delete(id)
}) as typeof clearTimeout
const advanceBy = async (ms: number) => {
const target = now + Math.max(0, ms)
while (true) {
let next: { id: number; time: number; callback: TimerCallback; args: any[] } | undefined
for (const timer of timers.values()) {
if (timer.time <= target && (!next || timer.time < next.time)) {
next = timer
}
}
if (!next) break
now = next.time
timers.delete(next.id)
if (!cleared.has(next.id)) {
next.callback(...next.args)
}
cleared.delete(next.id)
await Promise.resolve()
}
now = target
await Promise.resolve()
}
const restore = () => {
globalThis.setTimeout = original.setTimeout
globalThis.clearTimeout = original.clearTimeout
}
return { advanceBy, restore }
}
describe("executeCompact lock management", () => { describe("executeCompact lock management", () => {
let autoCompactState: AutoCompactState let autoCompactState: AutoCompactState
let mockClient: any let mockClient: any
let fakeTimeouts: FakeTimeouts
const sessionID = "test-session-123" const sessionID = "test-session-123"
const directory = "/test/dir" const directory = "/test/dir"
const msg = { providerID: "anthropic", modelID: "claude-opus-4-5" } const msg = { providerID: "anthropic", modelID: "claude-opus-4-5" }
@ -32,6 +104,12 @@ describe("executeCompact lock management", () => {
showToast: mock(() => Promise.resolve()), showToast: mock(() => Promise.resolve()),
}, },
} }
fakeTimeouts = createFakeTimeouts()
})
afterEach(() => {
fakeTimeouts.restore()
}) })
test("clears lock on successful summarize completion", async () => { test("clears lock on successful summarize completion", async () => {
@ -216,7 +294,7 @@ describe("executeCompact lock management", () => {
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory) await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
// Wait for setTimeout callback // Wait for setTimeout callback
await new Promise((resolve) => setTimeout(resolve, 600)) await fakeTimeouts.advanceBy(600)
// #then: Lock should be cleared // #then: Lock should be cleared
// The continuation happens in setTimeout, but lock is cleared in finally before that // The continuation happens in setTimeout, but lock is cleared in finally before that
@ -288,7 +366,7 @@ describe("executeCompact lock management", () => {
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory) await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
// Wait for setTimeout callback // Wait for setTimeout callback
await new Promise((resolve) => setTimeout(resolve, 600)) await fakeTimeouts.advanceBy(600)
// #then: Truncation was attempted // #then: Truncation was attempted
expect(truncateSpy).toHaveBeenCalled() expect(truncateSpy).toHaveBeenCalled()

View File

@ -4,9 +4,123 @@ import type { BackgroundManager } from "../features/background-agent"
import { setMainSession, subagentSessions, _resetForTesting } from "../features/claude-code-session-state" import { setMainSession, subagentSessions, _resetForTesting } from "../features/claude-code-session-state"
import { createTodoContinuationEnforcer } from "./todo-continuation-enforcer" import { createTodoContinuationEnforcer } from "./todo-continuation-enforcer"
type TimerCallback = (...args: any[]) => void
interface FakeTimers {
advanceBy: (ms: number, advanceClock?: boolean) => Promise<void>
restore: () => void
}
function createFakeTimers(): FakeTimers {
const originalNow = Date.now()
let clockNow = originalNow
let timerNow = 0
let nextId = 1
const timers = new Map<number, { id: number; time: number; interval: number | null; callback: TimerCallback; args: any[] }>()
const cleared = new Set<number>()
const original = {
setTimeout: globalThis.setTimeout,
clearTimeout: globalThis.clearTimeout,
setInterval: globalThis.setInterval,
clearInterval: globalThis.clearInterval,
dateNow: Date.now,
}
const normalizeDelay = (delay?: number) => {
if (typeof delay !== "number" || !Number.isFinite(delay)) return 0
return delay < 0 ? 0 : delay
}
const schedule = (callback: TimerCallback, delay: number | undefined, interval: number | null, args: any[]) => {
const id = nextId++
timers.set(id, {
id,
time: timerNow + normalizeDelay(delay),
interval,
callback,
args,
})
return id
}
const clear = (id: number | undefined) => {
if (typeof id !== "number") return
cleared.add(id)
timers.delete(id)
}
globalThis.setTimeout = ((callback: TimerCallback, delay?: number, ...args: any[]) => {
return schedule(callback, delay, null, args) as unknown as ReturnType<typeof setTimeout>
}) as typeof setTimeout
globalThis.setInterval = ((callback: TimerCallback, delay?: number, ...args: any[]) => {
const interval = normalizeDelay(delay)
return schedule(callback, delay, interval, args) as unknown as ReturnType<typeof setInterval>
}) as typeof setInterval
globalThis.clearTimeout = ((id?: number) => {
clear(id)
}) as typeof clearTimeout
globalThis.clearInterval = ((id?: number) => {
clear(id)
}) as typeof clearInterval
Date.now = () => clockNow
const advanceBy = async (ms: number, advanceClock: boolean = false) => {
const clamped = Math.max(0, ms)
const target = timerNow + clamped
if (advanceClock) {
clockNow += clamped
}
while (true) {
let next: { id: number; time: number; interval: number | null; callback: TimerCallback; args: any[] } | undefined
for (const timer of timers.values()) {
if (timer.time <= target && (!next || timer.time < next.time)) {
next = timer
}
}
if (!next) break
timerNow = next.time
timers.delete(next.id)
next.callback(...next.args)
if (next.interval !== null && !cleared.has(next.id)) {
timers.set(next.id, {
id: next.id,
time: timerNow + next.interval,
interval: next.interval,
callback: next.callback,
args: next.args,
})
} else {
cleared.delete(next.id)
}
await Promise.resolve()
}
timerNow = target
await Promise.resolve()
}
const restore = () => {
globalThis.setTimeout = original.setTimeout
globalThis.clearTimeout = original.clearTimeout
globalThis.setInterval = original.setInterval
globalThis.clearInterval = original.clearInterval
Date.now = original.dateNow
}
return { advanceBy, restore }
}
describe("todo-continuation-enforcer", () => { describe("todo-continuation-enforcer", () => {
let promptCalls: Array<{ sessionID: string; agent?: string; model?: { providerID?: string; modelID?: string }; text: string }> let promptCalls: Array<{ sessionID: string; agent?: string; model?: { providerID?: string; modelID?: string }; text: string }>
let toastCalls: Array<{ title: string; message: string }> let toastCalls: Array<{ title: string; message: string }>
let fakeTimers: FakeTimers
interface MockMessage { interface MockMessage {
info: { info: {
@ -60,6 +174,7 @@ describe("todo-continuation-enforcer", () => {
} }
beforeEach(() => { beforeEach(() => {
fakeTimers = createFakeTimers()
_resetForTesting() _resetForTesting()
promptCalls = [] promptCalls = []
toastCalls = [] toastCalls = []
@ -67,6 +182,7 @@ describe("todo-continuation-enforcer", () => {
}) })
afterEach(() => { afterEach(() => {
fakeTimers.restore()
_resetForTesting() _resetForTesting()
}) })
@ -85,12 +201,12 @@ describe("todo-continuation-enforcer", () => {
}) })
// #then - countdown toast shown // #then - countdown toast shown
await new Promise(r => setTimeout(r, 100)) await fakeTimers.advanceBy(100)
expect(toastCalls.length).toBeGreaterThanOrEqual(1) expect(toastCalls.length).toBeGreaterThanOrEqual(1)
expect(toastCalls[0].title).toBe("Todo Continuation") expect(toastCalls[0].title).toBe("Todo Continuation")
// #then - after countdown, continuation injected // #then - after countdown, continuation injected
await new Promise(r => setTimeout(r, 2500)) await fakeTimers.advanceBy(2500)
expect(promptCalls.length).toBe(1) expect(promptCalls.length).toBe(1)
expect(promptCalls[0].text).toContain("TODO CONTINUATION") expect(promptCalls[0].text).toContain("TODO CONTINUATION")
}) })
@ -112,7 +228,7 @@ describe("todo-continuation-enforcer", () => {
event: { type: "session.idle", properties: { sessionID } }, event: { type: "session.idle", properties: { sessionID } },
}) })
await new Promise(r => setTimeout(r, 3000)) await fakeTimers.advanceBy(3000)
// #then - no continuation injected // #then - no continuation injected
expect(promptCalls).toHaveLength(0) expect(promptCalls).toHaveLength(0)
@ -132,7 +248,7 @@ describe("todo-continuation-enforcer", () => {
event: { type: "session.idle", properties: { sessionID } }, event: { type: "session.idle", properties: { sessionID } },
}) })
await new Promise(r => setTimeout(r, 3000)) await fakeTimers.advanceBy(3000)
// #then - no continuation injected // #then - no continuation injected
expect(promptCalls).toHaveLength(0) expect(promptCalls).toHaveLength(0)
@ -150,7 +266,7 @@ describe("todo-continuation-enforcer", () => {
event: { type: "session.idle", properties: { sessionID: otherSession } }, event: { type: "session.idle", properties: { sessionID: otherSession } },
}) })
await new Promise(r => setTimeout(r, 3000)) await fakeTimers.advanceBy(3000)
// #then - no continuation injected // #then - no continuation injected
expect(promptCalls).toHaveLength(0) expect(promptCalls).toHaveLength(0)
@ -170,7 +286,7 @@ describe("todo-continuation-enforcer", () => {
}) })
// #then - continuation injected for background task session // #then - continuation injected for background task session
await new Promise(r => setTimeout(r, 2500)) await fakeTimers.advanceBy(2500)
expect(promptCalls.length).toBe(1) expect(promptCalls.length).toBe(1)
expect(promptCalls[0].sessionID).toBe(bgTaskSession) expect(promptCalls[0].sessionID).toBe(bgTaskSession)
}) })
@ -190,7 +306,7 @@ describe("todo-continuation-enforcer", () => {
}) })
// #when - wait past grace period (500ms), then user sends message // #when - wait past grace period (500ms), then user sends message
await new Promise(r => setTimeout(r, 600)) await fakeTimers.advanceBy(600, true)
await hook.handler({ await hook.handler({
event: { event: {
type: "message.updated", type: "message.updated",
@ -199,7 +315,7 @@ describe("todo-continuation-enforcer", () => {
}) })
// #then - wait past countdown time and verify no injection (countdown was cancelled) // #then - wait past countdown time and verify no injection (countdown was cancelled)
await new Promise(r => setTimeout(r, 2500)) await fakeTimers.advanceBy(2500)
expect(promptCalls).toHaveLength(0) expect(promptCalls).toHaveLength(0)
}) })
@ -223,9 +339,9 @@ describe("todo-continuation-enforcer", () => {
}, },
}) })
// #then - countdown should continue (message was ignored) // #then - countdown should continue (message was ignored)
// wait past 2s countdown and verify injection happens // wait past 2s countdown and verify injection happens
await new Promise(r => setTimeout(r, 2500)) await fakeTimers.advanceBy(2500)
expect(promptCalls).toHaveLength(1) expect(promptCalls).toHaveLength(1)
}) })
@ -242,7 +358,7 @@ describe("todo-continuation-enforcer", () => {
}) })
// #when - assistant starts responding // #when - assistant starts responding
await new Promise(r => setTimeout(r, 500)) await fakeTimers.advanceBy(500)
await hook.handler({ await hook.handler({
event: { event: {
type: "message.part.updated", type: "message.part.updated",
@ -250,7 +366,7 @@ describe("todo-continuation-enforcer", () => {
}, },
}) })
await new Promise(r => setTimeout(r, 3000)) await fakeTimers.advanceBy(3000)
// #then - no continuation injected (cancelled) // #then - no continuation injected (cancelled)
expect(promptCalls).toHaveLength(0) expect(promptCalls).toHaveLength(0)
@ -269,12 +385,12 @@ describe("todo-continuation-enforcer", () => {
}) })
// #when - tool starts executing // #when - tool starts executing
await new Promise(r => setTimeout(r, 500)) await fakeTimers.advanceBy(500)
await hook.handler({ await hook.handler({
event: { type: "tool.execute.before", properties: { sessionID } }, event: { type: "tool.execute.before", properties: { sessionID } },
}) })
await new Promise(r => setTimeout(r, 3000)) await fakeTimers.advanceBy(3000)
// #then - no continuation injected (cancelled) // #then - no continuation injected (cancelled)
expect(promptCalls).toHaveLength(0) expect(promptCalls).toHaveLength(0)
@ -295,7 +411,7 @@ describe("todo-continuation-enforcer", () => {
event: { type: "session.idle", properties: { sessionID } }, event: { type: "session.idle", properties: { sessionID } },
}) })
await new Promise(r => setTimeout(r, 3000)) await fakeTimers.advanceBy(3000)
// #then - no continuation injected // #then - no continuation injected
expect(promptCalls).toHaveLength(0) expect(promptCalls).toHaveLength(0)
@ -317,7 +433,7 @@ describe("todo-continuation-enforcer", () => {
event: { type: "session.idle", properties: { sessionID } }, event: { type: "session.idle", properties: { sessionID } },
}) })
await new Promise(r => setTimeout(r, 3000)) await fakeTimers.advanceBy(3000)
// #then - continuation injected // #then - continuation injected
expect(promptCalls.length).toBe(1) expect(promptCalls.length).toBe(1)
@ -336,12 +452,12 @@ describe("todo-continuation-enforcer", () => {
}) })
// #when - session is deleted during countdown // #when - session is deleted during countdown
await new Promise(r => setTimeout(r, 500)) await fakeTimers.advanceBy(500)
await hook.handler({ await hook.handler({
event: { type: "session.deleted", properties: { info: { id: sessionID } } }, event: { type: "session.deleted", properties: { info: { id: sessionID } } },
}) })
await new Promise(r => setTimeout(r, 3000)) await fakeTimers.advanceBy(3000)
// #then - no continuation injected (cleaned up) // #then - no continuation injected (cleaned up)
expect(promptCalls).toHaveLength(0) expect(promptCalls).toHaveLength(0)
@ -362,7 +478,7 @@ describe("todo-continuation-enforcer", () => {
event: { type: "session.idle", properties: { sessionID } }, event: { type: "session.idle", properties: { sessionID } },
}) })
await new Promise(r => setTimeout(r, 100)) await fakeTimers.advanceBy(100)
expect(toastCalls.length).toBeGreaterThanOrEqual(1) expect(toastCalls.length).toBeGreaterThanOrEqual(1)
}) })
@ -379,7 +495,7 @@ describe("todo-continuation-enforcer", () => {
}) })
// #then - multiple toast updates during countdown (2s countdown = 2 toasts: "2s" and "1s") // #then - multiple toast updates during countdown (2s countdown = 2 toasts: "2s" and "1s")
await new Promise(r => setTimeout(r, 2500)) await fakeTimers.advanceBy(2500)
expect(toastCalls.length).toBeGreaterThanOrEqual(2) expect(toastCalls.length).toBeGreaterThanOrEqual(2)
expect(toastCalls[0].message).toContain("2s") expect(toastCalls[0].message).toContain("2s")
}) })
@ -395,7 +511,7 @@ describe("todo-continuation-enforcer", () => {
await hook.handler({ await hook.handler({
event: { type: "session.idle", properties: { sessionID } }, event: { type: "session.idle", properties: { sessionID } },
}) })
await new Promise(r => setTimeout(r, 3500)) await fakeTimers.advanceBy(3500)
// #then - first injection happened // #then - first injection happened
expect(promptCalls.length).toBe(1) expect(promptCalls.length).toBe(1)
@ -404,7 +520,7 @@ describe("todo-continuation-enforcer", () => {
await hook.handler({ await hook.handler({
event: { type: "session.idle", properties: { sessionID } }, event: { type: "session.idle", properties: { sessionID } },
}) })
await new Promise(r => setTimeout(r, 3500)) await fakeTimers.advanceBy(3500)
// #then - second injection also happened (no throttle blocking) // #then - second injection also happened (no throttle blocking)
expect(promptCalls.length).toBe(2) expect(promptCalls.length).toBe(2)
@ -439,7 +555,7 @@ describe("todo-continuation-enforcer", () => {
event: { type: "session.idle", properties: { sessionID } }, event: { type: "session.idle", properties: { sessionID } },
}) })
await new Promise(r => setTimeout(r, 2500)) await fakeTimers.advanceBy(2500)
// #then - continuation injected (non-abort errors don't block) // #then - continuation injected (non-abort errors don't block)
expect(promptCalls.length).toBe(1) expect(promptCalls.length).toBe(1)
@ -472,7 +588,7 @@ describe("todo-continuation-enforcer", () => {
event: { type: "session.idle", properties: { sessionID } }, event: { type: "session.idle", properties: { sessionID } },
}) })
await new Promise(r => setTimeout(r, 3000)) await fakeTimers.advanceBy(3000)
// #then - no continuation (last message was aborted) // #then - no continuation (last message was aborted)
expect(promptCalls).toHaveLength(0) expect(promptCalls).toHaveLength(0)
@ -490,12 +606,12 @@ describe("todo-continuation-enforcer", () => {
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {}) const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// #when - session goes idle // #when - session goes idle
await hook.handler({ await hook.handler({
event: { type: "session.idle", properties: { sessionID } }, event: { type: "session.idle", properties: { sessionID } },
}) })
await new Promise(r => setTimeout(r, 3000)) await fakeTimers.advanceBy(3000)
// #then - continuation injected (no abort) // #then - continuation injected (no abort)
expect(promptCalls.length).toBe(1) expect(promptCalls.length).toBe(1)
@ -518,7 +634,7 @@ describe("todo-continuation-enforcer", () => {
event: { type: "session.idle", properties: { sessionID } }, event: { type: "session.idle", properties: { sessionID } },
}) })
await new Promise(r => setTimeout(r, 3000)) await fakeTimers.advanceBy(3000)
// #then - continuation injected (last message is user, not aborted assistant) // #then - continuation injected (last message is user, not aborted assistant)
expect(promptCalls.length).toBe(1) expect(promptCalls.length).toBe(1)
@ -541,7 +657,7 @@ describe("todo-continuation-enforcer", () => {
event: { type: "session.idle", properties: { sessionID } }, event: { type: "session.idle", properties: { sessionID } },
}) })
await new Promise(r => setTimeout(r, 3000)) await fakeTimers.advanceBy(3000)
// #then - no continuation (abort error detected) // #then - no continuation (abort error detected)
expect(promptCalls).toHaveLength(0) expect(promptCalls).toHaveLength(0)
@ -566,12 +682,12 @@ describe("todo-continuation-enforcer", () => {
}, },
}) })
// #when - session goes idle immediately after // #when - session goes idle immediately after
await hook.handler({ await hook.handler({
event: { type: "session.idle", properties: { sessionID } }, event: { type: "session.idle", properties: { sessionID } },
}) })
await new Promise(r => setTimeout(r, 3000)) await fakeTimers.advanceBy(3000)
// #then - no continuation (abort detected via event) // #then - no continuation (abort detected via event)
expect(promptCalls).toHaveLength(0) expect(promptCalls).toHaveLength(0)
@ -601,7 +717,7 @@ describe("todo-continuation-enforcer", () => {
event: { type: "session.idle", properties: { sessionID } }, event: { type: "session.idle", properties: { sessionID } },
}) })
await new Promise(r => setTimeout(r, 3000)) await fakeTimers.advanceBy(3000)
// #then - no continuation (abort detected via event) // #then - no continuation (abort detected via event)
expect(promptCalls).toHaveLength(0) expect(promptCalls).toHaveLength(0)
@ -627,13 +743,13 @@ describe("todo-continuation-enforcer", () => {
}) })
// #when - wait >3s then idle fires // #when - wait >3s then idle fires
await new Promise(r => setTimeout(r, 3100)) await fakeTimers.advanceBy(3100, true)
await hook.handler({ await hook.handler({
event: { type: "session.idle", properties: { sessionID } }, event: { type: "session.idle", properties: { sessionID } },
}) })
await new Promise(r => setTimeout(r, 3000)) await fakeTimers.advanceBy(3000)
// #then - continuation injected (abort flag is stale) // #then - continuation injected (abort flag is stale)
expect(promptCalls.length).toBeGreaterThan(0) expect(promptCalls.length).toBeGreaterThan(0)
@ -659,7 +775,7 @@ describe("todo-continuation-enforcer", () => {
}) })
// #when - user sends new message (clears abort flag) // #when - user sends new message (clears abort flag)
await new Promise(r => setTimeout(r, 600)) await fakeTimers.advanceBy(600)
await hook.handler({ await hook.handler({
event: { event: {
type: "message.updated", type: "message.updated",
@ -672,7 +788,7 @@ describe("todo-continuation-enforcer", () => {
event: { type: "session.idle", properties: { sessionID } }, event: { type: "session.idle", properties: { sessionID } },
}) })
await new Promise(r => setTimeout(r, 3000)) await fakeTimers.advanceBy(3000)
// #then - continuation injected (abort flag was cleared by user activity) // #then - continuation injected (abort flag was cleared by user activity)
expect(promptCalls.length).toBeGreaterThan(0) expect(promptCalls.length).toBeGreaterThan(0)
@ -710,7 +826,7 @@ describe("todo-continuation-enforcer", () => {
event: { type: "session.idle", properties: { sessionID } }, event: { type: "session.idle", properties: { sessionID } },
}) })
await new Promise(r => setTimeout(r, 3000)) await fakeTimers.advanceBy(3000)
// #then - continuation injected (abort flag was cleared by assistant activity) // #then - continuation injected (abort flag was cleared by assistant activity)
expect(promptCalls.length).toBeGreaterThan(0) expect(promptCalls.length).toBeGreaterThan(0)
@ -748,7 +864,7 @@ describe("todo-continuation-enforcer", () => {
event: { type: "session.idle", properties: { sessionID } }, event: { type: "session.idle", properties: { sessionID } },
}) })
await new Promise(r => setTimeout(r, 3000)) await fakeTimers.advanceBy(3000)
// #then - continuation injected (abort flag was cleared by tool execution) // #then - continuation injected (abort flag was cleared by tool execution)
expect(promptCalls.length).toBeGreaterThan(0) expect(promptCalls.length).toBeGreaterThan(0)
@ -778,7 +894,7 @@ describe("todo-continuation-enforcer", () => {
event: { type: "session.idle", properties: { sessionID } }, event: { type: "session.idle", properties: { sessionID } },
}) })
await new Promise(r => setTimeout(r, 3000)) await fakeTimers.advanceBy(3000)
// #then - no continuation (event-based detection wins over API) // #then - no continuation (event-based detection wins over API)
expect(promptCalls).toHaveLength(0) expect(promptCalls).toHaveLength(0)
@ -800,7 +916,7 @@ describe("todo-continuation-enforcer", () => {
event: { type: "session.idle", properties: { sessionID } }, event: { type: "session.idle", properties: { sessionID } },
}) })
await new Promise(r => setTimeout(r, 3000)) await fakeTimers.advanceBy(3000)
// #then - no continuation (API fallback detected the abort) // #then - no continuation (API fallback detected the abort)
expect(promptCalls).toHaveLength(0) expect(promptCalls).toHaveLength(0)
@ -820,7 +936,7 @@ describe("todo-continuation-enforcer", () => {
event: { type: "session.idle", properties: { sessionID } }, event: { type: "session.idle", properties: { sessionID } },
}) })
await new Promise(r => setTimeout(r, 2500)) await fakeTimers.advanceBy(2500)
// #then - prompt call made, model is undefined when no context (expected behavior) // #then - prompt call made, model is undefined when no context (expected behavior)
expect(promptCalls.length).toBe(1) expect(promptCalls.length).toBe(1)
@ -867,7 +983,7 @@ describe("todo-continuation-enforcer", () => {
// #when - session goes idle // #when - session goes idle
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await new Promise(r => setTimeout(r, 2500)) await fakeTimers.advanceBy(2500)
// #then - model should be extracted from assistant message's flat modelID/providerID // #then - model should be extracted from assistant message's flat modelID/providerID
expect(promptCalls.length).toBe(1) expect(promptCalls.length).toBe(1)
@ -919,7 +1035,7 @@ describe("todo-continuation-enforcer", () => {
// #when - session goes idle // #when - session goes idle
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await new Promise(r => setTimeout(r, 2500)) await fakeTimers.advanceBy(2500)
// #then - continuation uses Sisyphus (skipped compaction agent) // #then - continuation uses Sisyphus (skipped compaction agent)
expect(promptCalls.length).toBe(1) expect(promptCalls.length).toBe(1)
@ -964,7 +1080,7 @@ describe("todo-continuation-enforcer", () => {
event: { type: "session.idle", properties: { sessionID } }, event: { type: "session.idle", properties: { sessionID } },
}) })
await new Promise(r => setTimeout(r, 3000)) await fakeTimers.advanceBy(3000)
// #then - no continuation (compaction is in default skipAgents) // #then - no continuation (compaction is in default skipAgents)
expect(promptCalls).toHaveLength(0) expect(promptCalls).toHaveLength(0)
@ -1010,7 +1126,7 @@ describe("todo-continuation-enforcer", () => {
event: { type: "session.idle", properties: { sessionID } }, event: { type: "session.idle", properties: { sessionID } },
}) })
await new Promise(r => setTimeout(r, 3000)) await fakeTimers.advanceBy(3000)
// #then - no continuation (prometheus found after filtering compaction, prometheus is in skipAgents) // #then - no continuation (prometheus found after filtering compaction, prometheus is in skipAgents)
expect(promptCalls).toHaveLength(0) expect(promptCalls).toHaveLength(0)
@ -1057,7 +1173,7 @@ describe("todo-continuation-enforcer", () => {
event: { type: "session.idle", properties: { sessionID } }, event: { type: "session.idle", properties: { sessionID } },
}) })
await new Promise(r => setTimeout(r, 3000)) await fakeTimers.advanceBy(3000)
// #then - continuation injected (no agents to skip) // #then - continuation injected (no agents to skip)
expect(promptCalls.length).toBe(1) expect(promptCalls.length).toBe(1)

View File

@ -1159,7 +1159,7 @@ describe("sisyphus-task", () => {
const mockClient = { const mockClient = {
app: { agents: async () => ({ data: [] }) }, app: { agents: async () => ({ data: [] }) },
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) }, config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
model: { list: async () => [{ id: "google/gemini-3-pro" }] }, model: { list: async () => ({ data: [{ provider: "google", id: "gemini-3-pro" }] }) },
session: { session: {
get: async () => ({ data: { directory: "/project" } }), get: async () => ({ data: { directory: "/project" } }),
create: async () => ({ data: { id: "ses_unstable_gemini" } }), create: async () => ({ data: { id: "ses_unstable_gemini" } }),
@ -1325,6 +1325,13 @@ describe("sisyphus-task", () => {
test("artistry category (gemini) with run_in_background=false should force background but wait for result", async () => { test("artistry category (gemini) with run_in_background=false should force background but wait for result", async () => {
// #given - artistry also uses gemini model // #given - artistry also uses gemini model
const { createDelegateTask } = require("./tools") const { createDelegateTask } = require("./tools")
const providerModelsSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
connected: ["anthropic", "google", "openai"],
updatedAt: new Date().toISOString(),
models: {
google: ["gemini-3-pro", "gemini-3-flash"],
},
})
let launchCalled = false let launchCalled = false
const mockManager = { const mockManager = {
@ -1343,7 +1350,7 @@ describe("sisyphus-task", () => {
const mockClient = { const mockClient = {
app: { agents: async () => ({ data: [] }) }, app: { agents: async () => ({ data: [] }) },
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) }, config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
model: { list: async () => [{ id: "google/gemini-3-pro" }] }, model: { list: async () => ({ data: [{ provider: "google", id: "gemini-3-pro" }] }) },
session: { session: {
get: async () => ({ data: { directory: "/project" } }), get: async () => ({ data: { directory: "/project" } }),
create: async () => ({ data: { id: "ses_artistry_gemini" } }), create: async () => ({ data: { id: "ses_artistry_gemini" } }),
@ -1385,6 +1392,7 @@ describe("sisyphus-task", () => {
expect(launchCalled).toBe(true) expect(launchCalled).toBe(true)
expect(result).toContain("SUPERVISED TASK COMPLETED") expect(result).toContain("SUPERVISED TASK COMPLETED")
expect(result).toContain("Artistry result here") expect(result).toContain("Artistry result here")
providerModelsSpy.mockRestore()
}, { timeout: 20000 }) }, { timeout: 20000 })
test("writing category (gemini-flash) with run_in_background=false should force background but wait for result", async () => { test("writing category (gemini-flash) with run_in_background=false should force background but wait for result", async () => {