test(background-agent): add unit tests for extracted modules
Add 104 new tests across 4 test files: - error-classifier.test.ts (80 tests): isRecord, isAbortedSessionError, getErrorText, extractErrorName, extractErrorMessage, getSessionErrorMessage - fallback-retry-handler.test.ts (19 tests): retry logic, fallback chain, concurrency release, session abort, queue management - process-cleanup.test.ts (7 tests): signal registration, multi-manager shutdown, cleanup on unregister - compaction-aware-message-resolver.test.ts (13 tests): compaction agent detection, message resolution with temp dirs (pre-existing, verified) Total background-agent tests: 161 -> 265 (104 new, 0 regressions)
This commit is contained in:
parent
d53bcfbced
commit
8d66d5641a
@ -0,0 +1,190 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
|
||||
import { mkdtempSync, writeFileSync, rmSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { tmpdir } from "node:os"
|
||||
import { isCompactionAgent, findNearestMessageExcludingCompaction } from "./compaction-aware-message-resolver"
|
||||
|
||||
describe("isCompactionAgent", () => {
|
||||
describe("#given agent name variations", () => {
|
||||
test("returns true for 'compaction'", () => {
|
||||
// when
|
||||
const result = isCompactionAgent("compaction")
|
||||
|
||||
// then
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test("returns true for 'Compaction' (case insensitive)", () => {
|
||||
// when
|
||||
const result = isCompactionAgent("Compaction")
|
||||
|
||||
// then
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test("returns true for ' compaction ' (with whitespace)", () => {
|
||||
// when
|
||||
const result = isCompactionAgent(" compaction ")
|
||||
|
||||
// then
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test("returns false for undefined", () => {
|
||||
// when
|
||||
const result = isCompactionAgent(undefined)
|
||||
|
||||
// then
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false for null", () => {
|
||||
// when
|
||||
const result = isCompactionAgent(null as unknown as string)
|
||||
|
||||
// then
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false for non-compaction agent like 'sisyphus'", () => {
|
||||
// when
|
||||
const result = isCompactionAgent("sisyphus")
|
||||
|
||||
// then
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("findNearestMessageExcludingCompaction", () => {
|
||||
let tempDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "compaction-test-"))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { force: true, recursive: true })
|
||||
})
|
||||
|
||||
describe("#given directory with messages", () => {
|
||||
test("finds message with full agent and model", () => {
|
||||
// given
|
||||
const message = {
|
||||
agent: "sisyphus",
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
|
||||
}
|
||||
writeFileSync(join(tempDir, "001.json"), JSON.stringify(message))
|
||||
|
||||
// when
|
||||
const result = findNearestMessageExcludingCompaction(tempDir)
|
||||
|
||||
// then
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.agent).toBe("sisyphus")
|
||||
expect(result?.model?.providerID).toBe("anthropic")
|
||||
expect(result?.model?.modelID).toBe("claude-opus-4-6")
|
||||
})
|
||||
|
||||
test("skips compaction agent messages", () => {
|
||||
// given
|
||||
const compactionMessage = {
|
||||
agent: "compaction",
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
|
||||
}
|
||||
const validMessage = {
|
||||
agent: "sisyphus",
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
|
||||
}
|
||||
writeFileSync(join(tempDir, "001.json"), JSON.stringify(compactionMessage))
|
||||
writeFileSync(join(tempDir, "002.json"), JSON.stringify(validMessage))
|
||||
|
||||
// when
|
||||
const result = findNearestMessageExcludingCompaction(tempDir)
|
||||
|
||||
// then
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.agent).toBe("sisyphus")
|
||||
})
|
||||
|
||||
test("falls back to partial agent/model match", () => {
|
||||
// given
|
||||
const messageWithAgentOnly = {
|
||||
agent: "hephaestus",
|
||||
}
|
||||
const messageWithModelOnly = {
|
||||
model: { providerID: "openai", modelID: "gpt-5.3" },
|
||||
}
|
||||
writeFileSync(join(tempDir, "001.json"), JSON.stringify(messageWithModelOnly))
|
||||
writeFileSync(join(tempDir, "002.json"), JSON.stringify(messageWithAgentOnly))
|
||||
|
||||
// when
|
||||
const result = findNearestMessageExcludingCompaction(tempDir)
|
||||
|
||||
// then
|
||||
expect(result).not.toBeNull()
|
||||
// Should find the one with agent first (sorted reverse, so 002 is checked first)
|
||||
expect(result?.agent).toBe("hephaestus")
|
||||
})
|
||||
|
||||
test("returns null for empty directory", () => {
|
||||
// given - empty directory (tempDir is already empty)
|
||||
|
||||
// when
|
||||
const result = findNearestMessageExcludingCompaction(tempDir)
|
||||
|
||||
// then
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
test("returns null for non-existent directory", () => {
|
||||
// given
|
||||
const nonExistentDir = join(tmpdir(), "non-existent-dir-12345")
|
||||
|
||||
// when
|
||||
const result = findNearestMessageExcludingCompaction(nonExistentDir)
|
||||
|
||||
// then
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
test("skips invalid JSON files and finds valid message", () => {
|
||||
// given
|
||||
const invalidJson = "{ invalid json"
|
||||
const validMessage = {
|
||||
agent: "oracle",
|
||||
model: { providerID: "google", modelID: "gemini-2-flash" },
|
||||
}
|
||||
writeFileSync(join(tempDir, "001.json"), invalidJson)
|
||||
writeFileSync(join(tempDir, "002.json"), JSON.stringify(validMessage))
|
||||
|
||||
// when
|
||||
const result = findNearestMessageExcludingCompaction(tempDir)
|
||||
|
||||
// then
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.agent).toBe("oracle")
|
||||
})
|
||||
|
||||
test("finds newest valid message (sorted by filename reverse)", () => {
|
||||
// given
|
||||
const olderMessage = {
|
||||
agent: "older",
|
||||
model: { providerID: "a", modelID: "b" },
|
||||
}
|
||||
const newerMessage = {
|
||||
agent: "newer",
|
||||
model: { providerID: "c", modelID: "d" },
|
||||
}
|
||||
writeFileSync(join(tempDir, "001.json"), JSON.stringify(olderMessage))
|
||||
writeFileSync(join(tempDir, "010.json"), JSON.stringify(newerMessage))
|
||||
|
||||
// when
|
||||
const result = findNearestMessageExcludingCompaction(tempDir)
|
||||
|
||||
// then
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.agent).toBe("newer")
|
||||
})
|
||||
})
|
||||
})
|
||||
351
src/features/background-agent/error-classifier.test.ts
Normal file
351
src/features/background-agent/error-classifier.test.ts
Normal file
@ -0,0 +1,351 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import {
|
||||
isRecord,
|
||||
isAbortedSessionError,
|
||||
getErrorText,
|
||||
extractErrorName,
|
||||
extractErrorMessage,
|
||||
getSessionErrorMessage,
|
||||
} from "./error-classifier"
|
||||
|
||||
describe("isRecord", () => {
|
||||
describe("#given null or primitive values", () => {
|
||||
test("returns false for null", () => {
|
||||
expect(isRecord(null)).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false for undefined", () => {
|
||||
expect(isRecord(undefined)).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false for string", () => {
|
||||
expect(isRecord("hello")).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false for number", () => {
|
||||
expect(isRecord(42)).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false for boolean", () => {
|
||||
expect(isRecord(true)).toBe(false)
|
||||
})
|
||||
|
||||
test("returns true for array (arrays are objects)", () => {
|
||||
expect(isRecord([1, 2, 3])).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given plain objects", () => {
|
||||
test("returns true for empty object", () => {
|
||||
expect(isRecord({})).toBe(true)
|
||||
})
|
||||
|
||||
test("returns true for object with properties", () => {
|
||||
expect(isRecord({ key: "value" })).toBe(true)
|
||||
})
|
||||
|
||||
test("returns true for object with nested objects", () => {
|
||||
expect(isRecord({ nested: { deep: true } })).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given Error instances", () => {
|
||||
test("returns true for Error instance", () => {
|
||||
expect(isRecord(new Error("test"))).toBe(true)
|
||||
})
|
||||
|
||||
test("returns true for TypeError instance", () => {
|
||||
expect(isRecord(new TypeError("test"))).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("isAbortedSessionError", () => {
|
||||
describe("#given error with aborted message", () => {
|
||||
test("returns true for string containing aborted", () => {
|
||||
expect(isAbortedSessionError("Session aborted")).toBe(true)
|
||||
})
|
||||
|
||||
test("returns true for string with ABORTED uppercase", () => {
|
||||
expect(isAbortedSessionError("Session ABORTED")).toBe(true)
|
||||
})
|
||||
|
||||
test("returns true for Error with aborted in message", () => {
|
||||
expect(isAbortedSessionError(new Error("Session aborted"))).toBe(true)
|
||||
})
|
||||
|
||||
test("returns true for object with message containing aborted", () => {
|
||||
expect(isAbortedSessionError({ message: "The session was aborted" })).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given error without aborted message", () => {
|
||||
test("returns false for string without aborted", () => {
|
||||
expect(isAbortedSessionError("Session completed")).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false for Error without aborted", () => {
|
||||
expect(isAbortedSessionError(new Error("Something went wrong"))).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false for empty string", () => {
|
||||
expect(isAbortedSessionError("")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given invalid inputs", () => {
|
||||
test("returns false for null", () => {
|
||||
expect(isAbortedSessionError(null)).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false for undefined", () => {
|
||||
expect(isAbortedSessionError(undefined)).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false for object without message", () => {
|
||||
expect(isAbortedSessionError({ code: "ABORTED" })).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("getErrorText", () => {
|
||||
describe("#given string input", () => {
|
||||
test("returns the string as-is", () => {
|
||||
expect(getErrorText("Something went wrong")).toBe("Something went wrong")
|
||||
})
|
||||
|
||||
test("returns empty string for empty string", () => {
|
||||
expect(getErrorText("")).toBe("")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given Error instance", () => {
|
||||
test("returns name and message format", () => {
|
||||
expect(getErrorText(new Error("test message"))).toBe("Error: test message")
|
||||
})
|
||||
|
||||
test("returns TypeError format", () => {
|
||||
expect(getErrorText(new TypeError("type error"))).toBe("TypeError: type error")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given object with message property", () => {
|
||||
test("returns message property as string", () => {
|
||||
expect(getErrorText({ message: "custom error" })).toBe("custom error")
|
||||
})
|
||||
|
||||
test("returns name property when message not available", () => {
|
||||
expect(getErrorText({ name: "CustomError" })).toBe("CustomError")
|
||||
})
|
||||
|
||||
test("prefers message over name", () => {
|
||||
expect(getErrorText({ name: "CustomError", message: "error message" })).toBe("error message")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given invalid inputs", () => {
|
||||
test("returns empty string for null", () => {
|
||||
expect(getErrorText(null)).toBe("")
|
||||
})
|
||||
|
||||
test("returns empty string for undefined", () => {
|
||||
expect(getErrorText(undefined)).toBe("")
|
||||
})
|
||||
|
||||
test("returns empty string for object without message or name", () => {
|
||||
expect(getErrorText({ code: 500 })).toBe("")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("extractErrorName", () => {
|
||||
describe("#given Error instance", () => {
|
||||
test("returns Error for generic Error", () => {
|
||||
expect(extractErrorName(new Error("test"))).toBe("Error")
|
||||
})
|
||||
|
||||
test("returns TypeError name", () => {
|
||||
expect(extractErrorName(new TypeError("test"))).toBe("TypeError")
|
||||
})
|
||||
|
||||
test("returns RangeError name", () => {
|
||||
expect(extractErrorName(new RangeError("test"))).toBe("RangeError")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given plain object with name property", () => {
|
||||
test("returns name property when string", () => {
|
||||
expect(extractErrorName({ name: "CustomError" })).toBe("CustomError")
|
||||
})
|
||||
|
||||
test("returns undefined when name is not string", () => {
|
||||
expect(extractErrorName({ name: 123 })).toBe(undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given invalid inputs", () => {
|
||||
test("returns undefined for null", () => {
|
||||
expect(extractErrorName(null)).toBe(undefined)
|
||||
})
|
||||
|
||||
test("returns undefined for undefined", () => {
|
||||
expect(extractErrorName(undefined)).toBe(undefined)
|
||||
})
|
||||
|
||||
test("returns undefined for string", () => {
|
||||
expect(extractErrorName("Error message")).toBe(undefined)
|
||||
})
|
||||
|
||||
test("returns undefined for object without name property", () => {
|
||||
expect(extractErrorName({ message: "test" })).toBe(undefined)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("extractErrorMessage", () => {
|
||||
describe("#given string input", () => {
|
||||
test("returns the string as-is", () => {
|
||||
expect(extractErrorMessage("error message")).toBe("error message")
|
||||
})
|
||||
|
||||
test("returns undefined for empty string", () => {
|
||||
expect(extractErrorMessage("")).toBe(undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given Error instance", () => {
|
||||
test("returns error message", () => {
|
||||
expect(extractErrorMessage(new Error("test error"))).toBe("test error")
|
||||
})
|
||||
|
||||
test("returns empty string for Error with no message", () => {
|
||||
expect(extractErrorMessage(new Error())).toBe("")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given object with message property", () => {
|
||||
test("returns message property", () => {
|
||||
expect(extractErrorMessage({ message: "custom message" })).toBe("custom message")
|
||||
})
|
||||
|
||||
test("falls through to JSON.stringify for empty message value", () => {
|
||||
expect(extractErrorMessage({ message: "" })).toBe('{"message":""}')
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given nested error structure", () => {
|
||||
test("extracts message from nested error object", () => {
|
||||
expect(extractErrorMessage({ error: { message: "nested error" } })).toBe("nested error")
|
||||
})
|
||||
|
||||
test("extracts message from data.error structure", () => {
|
||||
expect(extractErrorMessage({ data: { error: "data error" } })).toBe("data error")
|
||||
})
|
||||
|
||||
test("extracts message from cause property", () => {
|
||||
expect(extractErrorMessage({ cause: "cause error" })).toBe("cause error")
|
||||
})
|
||||
|
||||
test("extracts message from cause object with message", () => {
|
||||
expect(extractErrorMessage({ cause: { message: "cause message" } })).toBe("cause message")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given complex error with data wrapper", () => {
|
||||
test("extracts from error.data.message", () => {
|
||||
const error = {
|
||||
data: {
|
||||
message: "data message",
|
||||
},
|
||||
}
|
||||
expect(extractErrorMessage(error)).toBe("data message")
|
||||
})
|
||||
|
||||
test("prefers top over nested-level message", () => {
|
||||
const error = {
|
||||
message: "top level",
|
||||
data: { message: "nested" },
|
||||
}
|
||||
expect(extractErrorMessage(error)).toBe("top level")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given invalid inputs", () => {
|
||||
test("returns undefined for null", () => {
|
||||
expect(extractErrorMessage(null)).toBe(undefined)
|
||||
})
|
||||
|
||||
test("returns undefined for undefined", () => {
|
||||
expect(extractErrorMessage(undefined)).toBe(undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given object without extractable message", () => {
|
||||
test("falls back to JSON.stringify for object", () => {
|
||||
const obj = { code: 500, details: "error" }
|
||||
const result = extractErrorMessage(obj)
|
||||
expect(result).toContain('"code":500')
|
||||
})
|
||||
|
||||
test("falls back to String() for non-serializable object", () => {
|
||||
const circular: Record<string, unknown> = { a: 1 }
|
||||
circular.self = circular
|
||||
const result = extractErrorMessage(circular)
|
||||
expect(result).toBe("[object Object]")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("getSessionErrorMessage", () => {
|
||||
describe("#given valid error properties", () => {
|
||||
test("extracts message from error.message", () => {
|
||||
const properties = { error: { message: "session error" } }
|
||||
expect(getSessionErrorMessage(properties)).toBe("session error")
|
||||
})
|
||||
|
||||
test("extracts message from error.data.message", () => {
|
||||
const properties = {
|
||||
error: {
|
||||
data: { message: "data error message" },
|
||||
},
|
||||
}
|
||||
expect(getSessionErrorMessage(properties)).toBe("data error message")
|
||||
})
|
||||
|
||||
test("prefers error.data.message over error.message", () => {
|
||||
const properties = {
|
||||
error: {
|
||||
message: "top level",
|
||||
data: { message: "nested" },
|
||||
},
|
||||
}
|
||||
expect(getSessionErrorMessage(properties)).toBe("nested")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given missing or invalid properties", () => {
|
||||
test("returns undefined when error is missing", () => {
|
||||
expect(getSessionErrorMessage({})).toBe(undefined)
|
||||
})
|
||||
|
||||
test("returns undefined when error is null", () => {
|
||||
expect(getSessionErrorMessage({ error: null })).toBe(undefined)
|
||||
})
|
||||
|
||||
test("returns undefined when error is string", () => {
|
||||
expect(getSessionErrorMessage({ error: "error string" })).toBe(undefined)
|
||||
})
|
||||
|
||||
test("returns undefined when data is not an object", () => {
|
||||
expect(getSessionErrorMessage({ error: { data: "not an object" } })).toBe(undefined)
|
||||
})
|
||||
|
||||
test("returns undefined when message is not string", () => {
|
||||
expect(getSessionErrorMessage({ error: { message: 123 } })).toBe(undefined)
|
||||
})
|
||||
|
||||
test("returns undefined when data.message is not string", () => {
|
||||
expect(getSessionErrorMessage({ error: { data: { message: null } } })).toBe(undefined)
|
||||
})
|
||||
})
|
||||
})
|
||||
270
src/features/background-agent/fallback-retry-handler.test.ts
Normal file
270
src/features/background-agent/fallback-retry-handler.test.ts
Normal file
@ -0,0 +1,270 @@
|
||||
import { describe, test, expect, mock, beforeEach } from "bun:test"
|
||||
|
||||
mock.module("../../shared", () => ({
|
||||
log: mock(() => {}),
|
||||
readConnectedProvidersCache: mock(() => null),
|
||||
readProviderModelsCache: mock(() => null),
|
||||
}))
|
||||
|
||||
mock.module("../../shared/model-error-classifier", () => ({
|
||||
shouldRetryError: mock(() => true),
|
||||
getNextFallback: mock((chain: Array<{ model: string }>, attempt: number) => chain[attempt]),
|
||||
hasMoreFallbacks: mock((chain: Array<{ model: string }>, attempt: number) => attempt < chain.length),
|
||||
selectFallbackProvider: mock((providers: string[]) => providers[0]),
|
||||
}))
|
||||
|
||||
mock.module("../../shared/provider-model-id-transform", () => ({
|
||||
transformModelForProvider: mock((_provider: string, model: string) => model),
|
||||
}))
|
||||
|
||||
import { tryFallbackRetry } from "./fallback-retry-handler"
|
||||
import { shouldRetryError } from "../../shared/model-error-classifier"
|
||||
import type { BackgroundTask } from "./types"
|
||||
import type { ConcurrencyManager } from "./concurrency"
|
||||
|
||||
function createMockTask(overrides: Partial<BackgroundTask> = {}): BackgroundTask {
|
||||
return {
|
||||
id: "test-task-1",
|
||||
description: "test task",
|
||||
prompt: "test prompt",
|
||||
agent: "sisyphus-junior",
|
||||
status: "error",
|
||||
parentSessionID: "parent-session-1",
|
||||
parentMessageID: "parent-message-1",
|
||||
fallbackChain: [
|
||||
{ model: "fallback-model-1", providers: ["provider-a"], variant: undefined },
|
||||
{ model: "fallback-model-2", providers: ["provider-b"], variant: undefined },
|
||||
],
|
||||
attemptCount: 0,
|
||||
concurrencyKey: "provider-a/original-model",
|
||||
model: { providerID: "provider-a", modelID: "original-model" },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function createMockConcurrencyManager(): ConcurrencyManager {
|
||||
return {
|
||||
release: mock(() => {}),
|
||||
acquire: mock(async () => {}),
|
||||
getQueueLength: mock(() => 0),
|
||||
getActiveCount: mock(() => 0),
|
||||
} as unknown as ConcurrencyManager
|
||||
}
|
||||
|
||||
function createMockClient() {
|
||||
return {
|
||||
session: {
|
||||
abort: mock(async () => ({})),
|
||||
},
|
||||
} as any
|
||||
}
|
||||
|
||||
function createDefaultArgs(taskOverrides: Partial<BackgroundTask> = {}) {
|
||||
const processKeyFn = mock(() => {})
|
||||
const queuesByKey = new Map<string, Array<{ task: BackgroundTask; input: any }>>()
|
||||
const idleDeferralTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
const concurrencyManager = createMockConcurrencyManager()
|
||||
const client = createMockClient()
|
||||
const task = createMockTask(taskOverrides)
|
||||
|
||||
return {
|
||||
task,
|
||||
errorInfo: { name: "OverloadedError", message: "model overloaded" },
|
||||
source: "polling",
|
||||
concurrencyManager,
|
||||
client,
|
||||
idleDeferralTimers,
|
||||
queuesByKey,
|
||||
processKey: processKeyFn,
|
||||
}
|
||||
}
|
||||
|
||||
describe("tryFallbackRetry", () => {
|
||||
beforeEach(() => {
|
||||
;(shouldRetryError as any).mockImplementation(() => true)
|
||||
})
|
||||
|
||||
describe("#given retryable error with fallback chain", () => {
|
||||
test("returns true and enqueues retry", () => {
|
||||
const args = createDefaultArgs()
|
||||
|
||||
const result = tryFallbackRetry(args)
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test("resets task status to pending", () => {
|
||||
const args = createDefaultArgs()
|
||||
|
||||
tryFallbackRetry(args)
|
||||
|
||||
expect(args.task.status).toBe("pending")
|
||||
})
|
||||
|
||||
test("increments attemptCount", () => {
|
||||
const args = createDefaultArgs()
|
||||
|
||||
tryFallbackRetry(args)
|
||||
|
||||
expect(args.task.attemptCount).toBe(1)
|
||||
})
|
||||
|
||||
test("updates task model to fallback", () => {
|
||||
const args = createDefaultArgs()
|
||||
|
||||
tryFallbackRetry(args)
|
||||
|
||||
expect(args.task.model?.modelID).toBe("fallback-model-1")
|
||||
expect(args.task.model?.providerID).toBe("provider-a")
|
||||
})
|
||||
|
||||
test("clears sessionID and startedAt", () => {
|
||||
const args = createDefaultArgs({
|
||||
sessionID: "old-session",
|
||||
startedAt: new Date(),
|
||||
})
|
||||
|
||||
tryFallbackRetry(args)
|
||||
|
||||
expect(args.task.sessionID).toBeUndefined()
|
||||
expect(args.task.startedAt).toBeUndefined()
|
||||
})
|
||||
|
||||
test("clears error field", () => {
|
||||
const args = createDefaultArgs({ error: "previous error" })
|
||||
|
||||
tryFallbackRetry(args)
|
||||
|
||||
expect(args.task.error).toBeUndefined()
|
||||
})
|
||||
|
||||
test("sets new queuedAt", () => {
|
||||
const args = createDefaultArgs()
|
||||
|
||||
tryFallbackRetry(args)
|
||||
|
||||
expect(args.task.queuedAt).toBeInstanceOf(Date)
|
||||
})
|
||||
|
||||
test("releases concurrency slot", () => {
|
||||
const args = createDefaultArgs()
|
||||
|
||||
tryFallbackRetry(args)
|
||||
|
||||
expect(args.concurrencyManager.release).toHaveBeenCalledWith("provider-a/original-model")
|
||||
})
|
||||
|
||||
test("clears concurrencyKey after release", () => {
|
||||
const args = createDefaultArgs()
|
||||
|
||||
tryFallbackRetry(args)
|
||||
|
||||
expect(args.task.concurrencyKey).toBeUndefined()
|
||||
})
|
||||
|
||||
test("aborts existing session", () => {
|
||||
const args = createDefaultArgs({ sessionID: "session-to-abort" })
|
||||
|
||||
tryFallbackRetry(args)
|
||||
|
||||
expect(args.client.session.abort).toHaveBeenCalledWith({
|
||||
path: { id: "session-to-abort" },
|
||||
})
|
||||
})
|
||||
|
||||
test("adds retry input to queue and calls processKey", () => {
|
||||
const args = createDefaultArgs()
|
||||
|
||||
tryFallbackRetry(args)
|
||||
|
||||
const key = `${args.task.model!.providerID}/${args.task.model!.modelID}`
|
||||
const queue = args.queuesByKey.get(key)
|
||||
expect(queue).toBeDefined()
|
||||
expect(queue!.length).toBe(1)
|
||||
expect(queue![0].task).toBe(args.task)
|
||||
expect(args.processKey).toHaveBeenCalledWith(key)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given non-retryable error", () => {
|
||||
test("returns false when shouldRetryError returns false", () => {
|
||||
;(shouldRetryError as any).mockImplementation(() => false)
|
||||
const args = createDefaultArgs()
|
||||
|
||||
const result = tryFallbackRetry(args)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given no fallback chain", () => {
|
||||
test("returns false when fallbackChain is undefined", () => {
|
||||
const args = createDefaultArgs({ fallbackChain: undefined })
|
||||
|
||||
const result = tryFallbackRetry(args)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false when fallbackChain is empty", () => {
|
||||
const args = createDefaultArgs({ fallbackChain: [] })
|
||||
|
||||
const result = tryFallbackRetry(args)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given exhausted fallbacks", () => {
|
||||
test("returns false when attemptCount exceeds chain length", () => {
|
||||
const args = createDefaultArgs({ attemptCount: 5 })
|
||||
|
||||
const result = tryFallbackRetry(args)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given task without concurrency key", () => {
|
||||
test("skips concurrency release", () => {
|
||||
const args = createDefaultArgs({ concurrencyKey: undefined })
|
||||
|
||||
tryFallbackRetry(args)
|
||||
|
||||
expect(args.concurrencyManager.release).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given task without session", () => {
|
||||
test("skips session abort", () => {
|
||||
const args = createDefaultArgs({ sessionID: undefined })
|
||||
|
||||
tryFallbackRetry(args)
|
||||
|
||||
expect(args.client.session.abort).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given active idle deferral timer", () => {
|
||||
test("clears the timer and removes from map", () => {
|
||||
const args = createDefaultArgs()
|
||||
const timerId = setTimeout(() => {}, 10000)
|
||||
args.idleDeferralTimers.set("test-task-1", timerId)
|
||||
|
||||
tryFallbackRetry(args)
|
||||
|
||||
expect(args.idleDeferralTimers.has("test-task-1")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given second attempt", () => {
|
||||
test("uses second fallback in chain", () => {
|
||||
const args = createDefaultArgs({ attemptCount: 1 })
|
||||
|
||||
tryFallbackRetry(args)
|
||||
|
||||
expect(args.task.model?.modelID).toBe("fallback-model-2")
|
||||
expect(args.task.attemptCount).toBe(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
156
src/features/background-agent/process-cleanup.test.ts
Normal file
156
src/features/background-agent/process-cleanup.test.ts
Normal file
@ -0,0 +1,156 @@
|
||||
import { describe, test, expect, beforeEach, afterEach, mock } from "bun:test"
|
||||
import {
|
||||
registerManagerForCleanup,
|
||||
unregisterManagerForCleanup,
|
||||
_resetForTesting,
|
||||
} from "./process-cleanup"
|
||||
|
||||
describe("process-cleanup", () => {
|
||||
const registeredManagers: Array<{ shutdown: () => void }> = []
|
||||
const mockShutdown = mock(() => {})
|
||||
|
||||
const processOnCalls: Array<[string, Function]> = []
|
||||
const processOffCalls: Array<[string, Function]> = []
|
||||
const originalProcessOn = process.on.bind(process)
|
||||
const originalProcessOff = process.off.bind(process)
|
||||
|
||||
beforeEach(() => {
|
||||
mockShutdown.mockClear()
|
||||
processOnCalls.length = 0
|
||||
processOffCalls.length = 0
|
||||
registeredManagers.length = 0
|
||||
|
||||
process.on = originalProcessOn as any
|
||||
process.off = originalProcessOff as any
|
||||
_resetForTesting()
|
||||
|
||||
process.on = ((event: string, listener: Function) => {
|
||||
processOnCalls.push([event, listener])
|
||||
return process
|
||||
}) as any
|
||||
|
||||
process.off = ((event: string, listener: Function) => {
|
||||
processOffCalls.push([event, listener])
|
||||
return process
|
||||
}) as any
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.on = originalProcessOn as any
|
||||
process.off = originalProcessOff as any
|
||||
|
||||
for (const manager of [...registeredManagers]) {
|
||||
unregisterManagerForCleanup(manager)
|
||||
}
|
||||
})
|
||||
|
||||
describe("registerManagerForCleanup", () => {
|
||||
test("registers signal handlers on first manager", () => {
|
||||
const manager = { shutdown: mockShutdown }
|
||||
registeredManagers.push(manager)
|
||||
|
||||
registerManagerForCleanup(manager)
|
||||
|
||||
const signals = processOnCalls.map(([signal]) => signal)
|
||||
expect(signals).toContain("SIGINT")
|
||||
expect(signals).toContain("SIGTERM")
|
||||
expect(signals).toContain("beforeExit")
|
||||
expect(signals).toContain("exit")
|
||||
})
|
||||
|
||||
test("signal listener calls shutdown on registered manager", () => {
|
||||
const manager = { shutdown: mockShutdown }
|
||||
registeredManagers.push(manager)
|
||||
|
||||
registerManagerForCleanup(manager)
|
||||
|
||||
const [, listener] = processOnCalls[0]
|
||||
listener()
|
||||
|
||||
expect(mockShutdown).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("multiple managers all get shutdown when signal fires", () => {
|
||||
const shutdown1 = mock(() => {})
|
||||
const shutdown2 = mock(() => {})
|
||||
const shutdown3 = mock(() => {})
|
||||
const manager1 = { shutdown: shutdown1 }
|
||||
const manager2 = { shutdown: shutdown2 }
|
||||
const manager3 = { shutdown: shutdown3 }
|
||||
registeredManagers.push(manager1, manager2, manager3)
|
||||
|
||||
registerManagerForCleanup(manager1)
|
||||
registerManagerForCleanup(manager2)
|
||||
registerManagerForCleanup(manager3)
|
||||
|
||||
const [, listener] = processOnCalls[0]
|
||||
listener()
|
||||
|
||||
expect(shutdown1).toHaveBeenCalledTimes(1)
|
||||
expect(shutdown2).toHaveBeenCalledTimes(1)
|
||||
expect(shutdown3).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test("does not re-register signal handlers for subsequent managers", () => {
|
||||
const manager1 = { shutdown: mockShutdown }
|
||||
const manager2 = { shutdown: mockShutdown }
|
||||
registeredManagers.push(manager1, manager2)
|
||||
|
||||
registerManagerForCleanup(manager1)
|
||||
const callsAfterFirst = processOnCalls.length
|
||||
|
||||
registerManagerForCleanup(manager2)
|
||||
|
||||
expect(processOnCalls.length).toBe(callsAfterFirst)
|
||||
})
|
||||
})
|
||||
|
||||
describe("unregisterManagerForCleanup", () => {
|
||||
test("removes signal handlers when last manager unregisters", () => {
|
||||
const manager = { shutdown: mockShutdown }
|
||||
registeredManagers.push(manager)
|
||||
|
||||
registerManagerForCleanup(manager)
|
||||
unregisterManagerForCleanup(manager)
|
||||
registeredManagers.length = 0
|
||||
|
||||
const offSignals = processOffCalls.map(([signal]) => signal)
|
||||
expect(offSignals).toContain("SIGINT")
|
||||
expect(offSignals).toContain("SIGTERM")
|
||||
expect(offSignals).toContain("beforeExit")
|
||||
expect(offSignals).toContain("exit")
|
||||
})
|
||||
|
||||
test("keeps signal handlers when other managers remain", () => {
|
||||
const manager1 = { shutdown: mockShutdown }
|
||||
const manager2 = { shutdown: mockShutdown }
|
||||
registeredManagers.push(manager1, manager2)
|
||||
|
||||
registerManagerForCleanup(manager1)
|
||||
registerManagerForCleanup(manager2)
|
||||
|
||||
unregisterManagerForCleanup(manager2)
|
||||
|
||||
expect(processOffCalls.length).toBe(0)
|
||||
})
|
||||
|
||||
test("remaining managers still get shutdown after partial unregister", () => {
|
||||
const shutdown1 = mock(() => {})
|
||||
const shutdown2 = mock(() => {})
|
||||
const manager1 = { shutdown: shutdown1 }
|
||||
const manager2 = { shutdown: shutdown2 }
|
||||
registeredManagers.push(manager1, manager2)
|
||||
|
||||
registerManagerForCleanup(manager1)
|
||||
registerManagerForCleanup(manager2)
|
||||
|
||||
const [, listener] = processOnCalls[0]
|
||||
unregisterManagerForCleanup(manager2)
|
||||
|
||||
listener()
|
||||
|
||||
expect(shutdown1).toHaveBeenCalledTimes(1)
|
||||
expect(shutdown2).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user