🏆 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:
@@ -176,8 +176,8 @@ describe("ConcurrencyManager.acquire/release", () => {
|
||||
await manager.acquire("model-a")
|
||||
await manager.acquire("model-a")
|
||||
|
||||
// #then - both resolved without waiting
|
||||
expect(true).toBe(true)
|
||||
// #then - both resolved without waiting, count should be 2
|
||||
expect(manager.getCount("model-a")).toBe(2)
|
||||
})
|
||||
|
||||
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")
|
||||
|
||||
// #then - all 5 resolved
|
||||
expect(true).toBe(true)
|
||||
// #then - all 5 resolved, count should be 5
|
||||
expect(manager.getCount("model-a")).toBe(5)
|
||||
})
|
||||
|
||||
test("should queue when limit reached", async () => {
|
||||
@@ -276,8 +276,8 @@ describe("ConcurrencyManager.acquire/release", () => {
|
||||
manager.release("model-a")
|
||||
await manager.acquire("model-a")
|
||||
|
||||
// #then
|
||||
expect(true).toBe(true)
|
||||
// #then - count should be 1 after re-acquiring
|
||||
expect(manager.getCount("model-a")).toBe(1)
|
||||
})
|
||||
|
||||
test("should handle release when no acquire", () => {
|
||||
@@ -288,21 +288,21 @@ describe("ConcurrencyManager.acquire/release", () => {
|
||||
// #when - release without acquire
|
||||
manager.release("model-a")
|
||||
|
||||
// #then - should not throw
|
||||
expect(true).toBe(true)
|
||||
// #then - count should be 0 (no negative count)
|
||||
expect(manager.getCount("model-a")).toBe(0)
|
||||
})
|
||||
|
||||
test("should handle release when no prior acquire", () => {
|
||||
// #given - default config
|
||||
|
||||
// #when - release without acquire
|
||||
manager.release("model-a")
|
||||
// #when - release without acquire
|
||||
manager.release("model-a")
|
||||
|
||||
// #then - should not throw
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
// #then - count should be 0 (no negative count)
|
||||
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
|
||||
const config: BackgroundTaskConfig = { defaultConcurrency: 3 }
|
||||
manager = new ConcurrencyManager(config)
|
||||
@@ -317,11 +317,11 @@ describe("ConcurrencyManager.acquire/release", () => {
|
||||
manager.release("model-a")
|
||||
manager.release("model-a")
|
||||
|
||||
// Should be able to acquire again
|
||||
await manager.acquire("model-a")
|
||||
// Should be able to acquire again
|
||||
await manager.acquire("model-a")
|
||||
|
||||
// #then
|
||||
expect(true).toBe(true)
|
||||
// #then - count should be 1 after re-acquiring
|
||||
expect(manager.getCount("model-a")).toBe(1)
|
||||
})
|
||||
|
||||
test("should use model-specific limit for acquire", async () => {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { afterEach, describe, expect, it } from "bun:test"
|
||||
import { findAvailablePort, startCallbackServer, type CallbackServer } from "./callback-server"
|
||||
|
||||
const nativeFetch = Bun.fetch.bind(Bun)
|
||||
|
||||
describe("findAvailablePort", () => {
|
||||
it("returns the start port when it is available", async () => {
|
||||
//#given
|
||||
@@ -34,9 +36,11 @@ describe("findAvailablePort", () => {
|
||||
describe("startCallbackServer", () => {
|
||||
let server: CallbackServer | null = null
|
||||
|
||||
afterEach(() => {
|
||||
afterEach(async () => {
|
||||
server?.close()
|
||||
server = null
|
||||
// Allow time for port to be released before next test
|
||||
await Bun.sleep(10)
|
||||
})
|
||||
|
||||
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`
|
||||
|
||||
//#when
|
||||
const fetchPromise = fetch(callbackUrl)
|
||||
const result = await server.waitForCallback()
|
||||
const response = await fetchPromise
|
||||
// Use Promise.all to ensure fetch and waitForCallback run concurrently
|
||||
// This prevents race condition where waitForCallback blocks before fetch starts
|
||||
const [result, response] = await Promise.all([
|
||||
server.waitForCallback(),
|
||||
nativeFetch(callbackUrl)
|
||||
])
|
||||
|
||||
//#then
|
||||
expect(result).toEqual({ code: "test-code", state: "test-state" })
|
||||
@@ -73,7 +80,7 @@ describe("startCallbackServer", () => {
|
||||
server = await startCallbackServer()
|
||||
|
||||
//#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
|
||||
expect(response.status).toBe(404)
|
||||
@@ -85,7 +92,7 @@ describe("startCallbackServer", () => {
|
||||
const callbackRejection = server.waitForCallback().catch((e: Error) => e)
|
||||
|
||||
//#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
|
||||
expect(response.status).toBe(400)
|
||||
@@ -100,7 +107,7 @@ describe("startCallbackServer", () => {
|
||||
const callbackRejection = server.waitForCallback().catch((e: Error) => e)
|
||||
|
||||
//#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
|
||||
expect(response.status).toBe(400)
|
||||
@@ -120,7 +127,7 @@ describe("startCallbackServer", () => {
|
||||
|
||||
//#then
|
||||
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)
|
||||
} catch (error) {
|
||||
expect(error).toBeDefined()
|
||||
|
||||
@@ -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 type { AutoCompactState } from "./types"
|
||||
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", () => {
|
||||
let autoCompactState: AutoCompactState
|
||||
let mockClient: any
|
||||
let fakeTimeouts: FakeTimeouts
|
||||
const sessionID = "test-session-123"
|
||||
const directory = "/test/dir"
|
||||
const msg = { providerID: "anthropic", modelID: "claude-opus-4-5" }
|
||||
@@ -32,6 +104,12 @@ describe("executeCompact lock management", () => {
|
||||
showToast: mock(() => Promise.resolve()),
|
||||
},
|
||||
}
|
||||
|
||||
fakeTimeouts = createFakeTimeouts()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
fakeTimeouts.restore()
|
||||
})
|
||||
|
||||
test("clears lock on successful summarize completion", async () => {
|
||||
@@ -216,7 +294,7 @@ describe("executeCompact lock management", () => {
|
||||
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
|
||||
|
||||
// Wait for setTimeout callback
|
||||
await new Promise((resolve) => setTimeout(resolve, 600))
|
||||
await fakeTimeouts.advanceBy(600)
|
||||
|
||||
// #then: Lock should be cleared
|
||||
// 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)
|
||||
|
||||
// Wait for setTimeout callback
|
||||
await new Promise((resolve) => setTimeout(resolve, 600))
|
||||
await fakeTimeouts.advanceBy(600)
|
||||
|
||||
// #then: Truncation was attempted
|
||||
expect(truncateSpy).toHaveBeenCalled()
|
||||
|
||||
@@ -4,9 +4,123 @@ import type { BackgroundManager } from "../features/background-agent"
|
||||
import { setMainSession, subagentSessions, _resetForTesting } from "../features/claude-code-session-state"
|
||||
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", () => {
|
||||
let promptCalls: Array<{ sessionID: string; agent?: string; model?: { providerID?: string; modelID?: string }; text: string }>
|
||||
let toastCalls: Array<{ title: string; message: string }>
|
||||
let fakeTimers: FakeTimers
|
||||
|
||||
interface MockMessage {
|
||||
info: {
|
||||
@@ -60,6 +174,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fakeTimers = createFakeTimers()
|
||||
_resetForTesting()
|
||||
promptCalls = []
|
||||
toastCalls = []
|
||||
@@ -67,6 +182,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
fakeTimers.restore()
|
||||
_resetForTesting()
|
||||
})
|
||||
|
||||
@@ -85,12 +201,12 @@ describe("todo-continuation-enforcer", () => {
|
||||
})
|
||||
|
||||
// #then - countdown toast shown
|
||||
await new Promise(r => setTimeout(r, 100))
|
||||
await fakeTimers.advanceBy(100)
|
||||
expect(toastCalls.length).toBeGreaterThanOrEqual(1)
|
||||
expect(toastCalls[0].title).toBe("Todo Continuation")
|
||||
|
||||
// #then - after countdown, continuation injected
|
||||
await new Promise(r => setTimeout(r, 2500))
|
||||
await fakeTimers.advanceBy(2500)
|
||||
expect(promptCalls.length).toBe(1)
|
||||
expect(promptCalls[0].text).toContain("TODO CONTINUATION")
|
||||
})
|
||||
@@ -112,7 +228,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
await fakeTimers.advanceBy(3000)
|
||||
|
||||
// #then - no continuation injected
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
@@ -132,7 +248,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
await fakeTimers.advanceBy(3000)
|
||||
|
||||
// #then - no continuation injected
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
@@ -150,7 +266,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
event: { type: "session.idle", properties: { sessionID: otherSession } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
await fakeTimers.advanceBy(3000)
|
||||
|
||||
// #then - no continuation injected
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
@@ -170,7 +286,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
})
|
||||
|
||||
// #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[0].sessionID).toBe(bgTaskSession)
|
||||
})
|
||||
@@ -190,7 +306,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
})
|
||||
|
||||
// #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({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
@@ -199,7 +315,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
})
|
||||
|
||||
// #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)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
await new Promise(r => setTimeout(r, 2500))
|
||||
await fakeTimers.advanceBy(2500)
|
||||
expect(promptCalls).toHaveLength(1)
|
||||
})
|
||||
|
||||
@@ -242,7 +358,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
})
|
||||
|
||||
// #when - assistant starts responding
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
await fakeTimers.advanceBy(500)
|
||||
await hook.handler({
|
||||
event: {
|
||||
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)
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
@@ -269,12 +385,12 @@ describe("todo-continuation-enforcer", () => {
|
||||
})
|
||||
|
||||
// #when - tool starts executing
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
await fakeTimers.advanceBy(500)
|
||||
await hook.handler({
|
||||
event: { type: "tool.execute.before", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
await fakeTimers.advanceBy(3000)
|
||||
|
||||
// #then - no continuation injected (cancelled)
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
@@ -295,7 +411,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
await fakeTimers.advanceBy(3000)
|
||||
|
||||
// #then - no continuation injected
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
@@ -317,7 +433,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
await fakeTimers.advanceBy(3000)
|
||||
|
||||
// #then - continuation injected
|
||||
expect(promptCalls.length).toBe(1)
|
||||
@@ -336,12 +452,12 @@ describe("todo-continuation-enforcer", () => {
|
||||
})
|
||||
|
||||
// #when - session is deleted during countdown
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
await fakeTimers.advanceBy(500)
|
||||
await hook.handler({
|
||||
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)
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
@@ -362,7 +478,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 100))
|
||||
await fakeTimers.advanceBy(100)
|
||||
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")
|
||||
await new Promise(r => setTimeout(r, 2500))
|
||||
await fakeTimers.advanceBy(2500)
|
||||
expect(toastCalls.length).toBeGreaterThanOrEqual(2)
|
||||
expect(toastCalls[0].message).toContain("2s")
|
||||
})
|
||||
@@ -395,7 +511,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
await new Promise(r => setTimeout(r, 3500))
|
||||
await fakeTimers.advanceBy(3500)
|
||||
|
||||
// #then - first injection happened
|
||||
expect(promptCalls.length).toBe(1)
|
||||
@@ -404,7 +520,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
await hook.handler({
|
||||
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)
|
||||
expect(promptCalls.length).toBe(2)
|
||||
@@ -439,7 +555,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
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)
|
||||
expect(promptCalls.length).toBe(1)
|
||||
@@ -472,7 +588,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
await fakeTimers.advanceBy(3000)
|
||||
|
||||
// #then - no continuation (last message was aborted)
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
@@ -490,12 +606,12 @@ describe("todo-continuation-enforcer", () => {
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - session goes idle
|
||||
// #when - session goes idle
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
await fakeTimers.advanceBy(3000)
|
||||
|
||||
// #then - continuation injected (no abort)
|
||||
expect(promptCalls.length).toBe(1)
|
||||
@@ -518,7 +634,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
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)
|
||||
expect(promptCalls.length).toBe(1)
|
||||
@@ -541,7 +657,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
await fakeTimers.advanceBy(3000)
|
||||
|
||||
// #then - no continuation (abort error detected)
|
||||
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({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
await fakeTimers.advanceBy(3000)
|
||||
|
||||
// #then - no continuation (abort detected via event)
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
@@ -601,7 +717,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
await fakeTimers.advanceBy(3000)
|
||||
|
||||
// #then - no continuation (abort detected via event)
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
@@ -627,13 +743,13 @@ describe("todo-continuation-enforcer", () => {
|
||||
})
|
||||
|
||||
// #when - wait >3s then idle fires
|
||||
await new Promise(r => setTimeout(r, 3100))
|
||||
await fakeTimers.advanceBy(3100, true)
|
||||
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
await fakeTimers.advanceBy(3000)
|
||||
|
||||
// #then - continuation injected (abort flag is stale)
|
||||
expect(promptCalls.length).toBeGreaterThan(0)
|
||||
@@ -659,7 +775,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
})
|
||||
|
||||
// #when - user sends new message (clears abort flag)
|
||||
await new Promise(r => setTimeout(r, 600))
|
||||
await fakeTimers.advanceBy(600)
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
@@ -672,7 +788,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
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)
|
||||
expect(promptCalls.length).toBeGreaterThan(0)
|
||||
@@ -710,7 +826,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
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)
|
||||
expect(promptCalls.length).toBeGreaterThan(0)
|
||||
@@ -748,7 +864,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
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)
|
||||
expect(promptCalls.length).toBeGreaterThan(0)
|
||||
@@ -778,7 +894,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
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)
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
@@ -800,7 +916,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
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)
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
@@ -820,7 +936,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
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)
|
||||
expect(promptCalls.length).toBe(1)
|
||||
@@ -867,7 +983,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
|
||||
// #when - session goes idle
|
||||
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
|
||||
expect(promptCalls.length).toBe(1)
|
||||
@@ -919,7 +1035,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
|
||||
// #when - session goes idle
|
||||
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)
|
||||
expect(promptCalls.length).toBe(1)
|
||||
@@ -964,7 +1080,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
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)
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
@@ -1010,7 +1126,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
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)
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
@@ -1057,7 +1173,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
await fakeTimers.advanceBy(3000)
|
||||
|
||||
// #then - continuation injected (no agents to skip)
|
||||
expect(promptCalls.length).toBe(1)
|
||||
|
||||
@@ -1159,7 +1159,7 @@ describe("sisyphus-task", () => {
|
||||
const mockClient = {
|
||||
app: { agents: async () => ({ data: [] }) },
|
||||
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: {
|
||||
get: async () => ({ data: { directory: "/project" } }),
|
||||
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 () => {
|
||||
// #given - artistry also uses gemini model
|
||||
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
|
||||
|
||||
const mockManager = {
|
||||
@@ -1343,7 +1350,7 @@ describe("sisyphus-task", () => {
|
||||
const mockClient = {
|
||||
app: { agents: async () => ({ data: [] }) },
|
||||
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: {
|
||||
get: async () => ({ data: { directory: "/project" } }),
|
||||
create: async () => ({ data: { id: "ses_artistry_gemini" } }),
|
||||
@@ -1385,6 +1392,7 @@ describe("sisyphus-task", () => {
|
||||
expect(launchCalled).toBe(true)
|
||||
expect(result).toContain("SUPERVISED TASK COMPLETED")
|
||||
expect(result).toContain("Artistry result here")
|
||||
providerModelsSpy.mockRestore()
|
||||
}, { timeout: 20000 })
|
||||
|
||||
test("writing category (gemini-flash) with run_in_background=false should force background but wait for result", async () => {
|
||||
|
||||
Reference in New Issue
Block a user